From d21502ed458344bbb891ba166fffe9dd871659c8 Mon Sep 17 00:00:00 2001 From: David Ortinau Date: Tue, 19 May 2026 21:35:38 -0500 Subject: [PATCH 1/6] feat(api): conversation HTTP endpoints for Flutter client (spec 004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements specs/004-conversation-http-endpoints/spec.md — exposes the Conversation activity to the Flutter client over HTTP. Endpoints (all under /api/v1/conversation, RequireAuthorization): - GET /scenarios → PascalCase scenarios list (matches MAUI EF shape) - POST /start → opening assistant message for a scenario - POST /continue → next assistant turn + grading (comprehension score + grammar corrections + vocabulary analysis) Server-side service is stateless: the client passes full history every turn. Why a new server-side IServerConversationService rather than reusing the MAUI IConversationAgentService: - AppLib is UseMaui=true / net11; the API is net10.0 and cannot reference it. - The MAUI impl is stateful (holds _conversationAgent on the instance); wrong model for HTTP. - The MAUI impl loads prompts via MAUI's OpenAppPackageFileAsync; the API's IFileSystemService stub throws on that method. ServerConversationService inlines the 4 scriban prompt templates as C# string interpolation and runs partner + grader IChatClient calls in parallel on /continue. Defensive score normalization handles both 0–1 and 0–100 grader scales (Math.Round, clamp to [0,100]). DI hardening in Program.cs (fixes pre-existing test breakage): The unconditional Singleton registrations of AiService, TranscriptFormattingService, VideoImportPipelineService, and IVoiceDiscoveryService were causing ValidateOnBuild to fail any test host that didn't configure AI:OpenAI:ApiKey or ElevenLabsKey, because those services transitively require IChatClient / ElevenLabsClient (which are themselves registered conditionally). Moved each into the conditional block alongside its underlying dependency. The new IServerConversationService follows the same pattern; endpoints return 503 when it isn't registered (parity with /api/v1/ai/chat). Wire-contract enforcement: - /scenarios DTO uses explicit [JsonPropertyName] PascalCase keys because minimal API defaults to camelCase. - /start and /continue use default camelCase. - Tests assert both directions (keys present and not-leaking). Tests: 17 new (9 endpoint integration + 8 score-normalization theory cases). All pass. Full API.Tests suite: 182/183 — the one remaining failure (API_PlansEndpointWorksWithValidToken returning 401) is a pre-existing latent test issue that was previously masked by the DI validation failure and is unrelated to this work. Out of scope per spec: sessionized memory, per-bubble TTS, plan timer, vocabulary scoring write-back. vocabularyAnalysis is emitted as an empty array for v1. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../IServerConversationService.cs | 34 +++ .../Conversation/ServerConversationService.cs | 261 +++++++++++++++++ .../ConversationEndpoints.cs | 190 ++++++++++++ src/SentenceStudio.Api/Program.cs | 30 +- .../ConversationContinueRequest.cs | 14 + .../ConversationContinueResponse.cs | 46 +++ .../ConversationHistoryItemDto.cs | 19 ++ .../Conversation/ConversationScenarioDto.cs | 44 +++ .../Conversation/ConversationStartRequest.cs | 18 ++ .../Conversation/ConversationStartResponse.cs | 12 + .../ConversationEndpointsTests.cs | 273 ++++++++++++++++++ .../Infrastructure/ConversationApiFactory.cs | 64 ++++ .../StubServerConversationService.cs | 57 ++++ 13 files changed, 1057 insertions(+), 5 deletions(-) create mode 100644 src/SentenceStudio.Api/Conversation/IServerConversationService.cs create mode 100644 src/SentenceStudio.Api/Conversation/ServerConversationService.cs create mode 100644 src/SentenceStudio.Api/ConversationEndpoints.cs create mode 100644 src/SentenceStudio.Contracts/Conversation/ConversationContinueRequest.cs create mode 100644 src/SentenceStudio.Contracts/Conversation/ConversationContinueResponse.cs create mode 100644 src/SentenceStudio.Contracts/Conversation/ConversationHistoryItemDto.cs create mode 100644 src/SentenceStudio.Contracts/Conversation/ConversationScenarioDto.cs create mode 100644 src/SentenceStudio.Contracts/Conversation/ConversationStartRequest.cs create mode 100644 src/SentenceStudio.Contracts/Conversation/ConversationStartResponse.cs create mode 100644 tests/SentenceStudio.Api.Tests/ConversationEndpointsTests.cs create mode 100644 tests/SentenceStudio.Api.Tests/Infrastructure/ConversationApiFactory.cs create mode 100644 tests/SentenceStudio.Api.Tests/Infrastructure/StubServerConversationService.cs diff --git a/src/SentenceStudio.Api/Conversation/IServerConversationService.cs b/src/SentenceStudio.Api/Conversation/IServerConversationService.cs new file mode 100644 index 00000000..39470c94 --- /dev/null +++ b/src/SentenceStudio.Api/Conversation/IServerConversationService.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using SentenceStudio.Contracts.Conversation; +using SentenceStudio.Shared.Models; + +namespace SentenceStudio.Api.Conversation; + +/// +/// Stateless server-side analogue of IConversationAgentService. +/// Each request is fully self-contained: the client passes scenario + full +/// history every turn. See spec 004 §Implementation Note 8. +/// +public interface IServerConversationService +{ + /// + /// Generates the opening assistant message for a (possibly null) scenario. + /// + Task StartAsync( + ConversationScenario? scenario, + string targetLanguageLabel, + CancellationToken cancellationToken); + + /// + /// Continues the conversation with the user's latest turn. Returns the + /// assistant reply plus grading results (already in wire shape). + /// + Task ContinueAsync( + string userMessage, + IReadOnlyList conversationHistory, + ConversationScenario? scenario, + string targetLanguageLabel, + CancellationToken cancellationToken); +} diff --git a/src/SentenceStudio.Api/Conversation/ServerConversationService.cs b/src/SentenceStudio.Api/Conversation/ServerConversationService.cs new file mode 100644 index 00000000..a104e290 --- /dev/null +++ b/src/SentenceStudio.Api/Conversation/ServerConversationService.cs @@ -0,0 +1,261 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using SentenceStudio.Contracts.Conversation; +using SentenceStudio.Shared.Models; + +namespace SentenceStudio.Api.Conversation; + +/// +/// HTTP-shaped, stateless implementation of . +/// Builds the conversation-partner system prompt and grading instructions +/// inline (port of the four scriban templates the MAUI agent service loads +/// from Resources/Raw) so the API does not need a MAUI file-system +/// package source. +/// +public sealed class ServerConversationService : IServerConversationService +{ + private readonly IChatClient _chatClient; + private readonly ILogger _logger; + + public ServerConversationService( + IChatClient chatClient, + ILogger logger) + { + _chatClient = chatClient; + _logger = logger; + } + + public async Task StartAsync( + ConversationScenario? scenario, + string targetLanguageLabel, + CancellationToken cancellationToken) + { + var systemPrompt = BuildConversationPartnerPrompt(scenario, targetLanguageLabel); + var openingPrompt = BuildOpeningPrompt(scenario, targetLanguageLabel); + + _logger.LogInformation( + "ConversationStart: scenario={ScenarioName} language={Language}", + scenario?.Name ?? "(default)", targetLanguageLabel); + + var messages = new List + { + new(ChatRole.User, openingPrompt), + }; + + var response = await _chatClient.GetResponseAsync( + messages, + new ChatOptions { Instructions = systemPrompt }, + cancellationToken); + + return (response.Text ?? string.Empty).Trim(); + } + + public async Task ContinueAsync( + string userMessage, + IReadOnlyList conversationHistory, + ConversationScenario? scenario, + string targetLanguageLabel, + CancellationToken cancellationToken) + { + var systemPrompt = BuildConversationPartnerPrompt(scenario, targetLanguageLabel); + var gradingInstructions = BuildGradingInstructions(targetLanguageLabel); + + // Build partner context: replay prior history (oldest -> newest), then the user's new turn. + var partnerMessages = new List(conversationHistory.Count + 1); + foreach (var item in conversationHistory) + { + var role = string.Equals(item.Role, "assistant", StringComparison.OrdinalIgnoreCase) + ? ChatRole.Assistant + : ChatRole.User; + partnerMessages.Add(new ChatMessage(role, item.Text ?? string.Empty)); + } + + partnerMessages.Add(new ChatMessage(ChatRole.User, userMessage)); + + var gradingPrompt = BuildGradingPrompt(userMessage, conversationHistory); + + // Run partner + grader in parallel — they don't share state. + var partnerTask = _chatClient.GetResponseAsync( + partnerMessages, + new ChatOptions { Instructions = systemPrompt }, + cancellationToken); + + var gradingTask = _chatClient.GetResponseAsync( + gradingPrompt, + new ChatOptions { Instructions = gradingInstructions }, + cancellationToken: cancellationToken); + + await Task.WhenAll(partnerTask, gradingTask); + + var partnerText = ((await partnerTask).Text ?? string.Empty).Trim(); + var grade = (await gradingTask)?.Result ?? new GradeResult { ComprehensionScore = 0.5 }; + + return new ConversationContinueResponse + { + AssistantMessage = partnerText, + ComprehensionScore = NormalizeComprehensionScore(grade.ComprehensionScore), + ComprehensionNotes = grade.ComprehensionNotes, + GrammarCorrections = (grade.GrammarCorrections ?? new()) + .Select(g => new ConversationGrammarCorrectionDto + { + Original = g.Original ?? string.Empty, + Corrected = g.Corrected ?? string.Empty, + Explanation = g.Explanation ?? string.Empty, + }) + .ToList(), + // v1: vocabulary analysis is not produced by the grader prompt yet. + // Spec defers vocabulary scoring write-back; emit an empty array + // so the Flutter parser sees a stable shape. + VocabularyAnalysis = new List(), + // v1: isComplete derivation is out of scope (spec §Behavior). The + // Flutter UI handles `false` gracefully. + IsComplete = false, + }; + } + + /// + /// Normalizes a grader score into [0, 100]. The MAUI service treats the + /// score as 0.0-1.0, but at least one scenario template prompts the model + /// for 0-100; we accept either by detecting the magnitude. + /// + public static int NormalizeComprehensionScore(double raw) + { + if (double.IsNaN(raw) || double.IsInfinity(raw)) return 0; + var asPercent = raw <= 1.0 ? raw * 100.0 : raw; + var rounded = (int)Math.Round(asPercent, MidpointRounding.AwayFromZero); + if (rounded < 0) return 0; + if (rounded > 100) return 100; + return rounded; + } + + private static string BuildConversationPartnerPrompt( + ConversationScenario? scenario, + string targetLanguage) + { + if (scenario is null) + { + return $@"You are playing the role of a friendly {targetLanguage} native speaker. + +## Your Role +You are meeting me for conversation practice. Make up your backstory as needed to answer my questions naturally. + +## Conversation Style +This is an open-ended conversation. Keep exploring topics with follow-up questions. +Never end abruptly - always leave room for continuation. + +## Rules: +- Speak naturally in {targetLanguage} as a native speaker would +- Keep responses conversational and friendly +- If the user's message is hard to understand or could be phrased more clearly, ask for clarification or confirm your understanding +- If the user asks a question, respond to it before asking a new question +- In your response, only include your newest reply in {targetLanguage} - do not repeat your character name"; + } + + var sb = new StringBuilder(); + sb.AppendLine($"You are playing the role of {scenario.PersonaName}, {scenario.PersonaDescription}."); + sb.AppendLine(); + sb.AppendLine("## Situation"); + sb.AppendLine(scenario.SituationDescription); + sb.AppendLine(); + sb.AppendLine("## Conversation Style"); + if (scenario.ConversationType == ConversationType.Finite) + { + sb.AppendLine("This is a transactional conversation. Complete the interaction naturally when the task is done."); + sb.AppendLine("When the conversation reaches its natural conclusion (e.g., payment complete, directions given, order placed), wrap up gracefully."); + } + else + { + sb.AppendLine("This is an open-ended conversation. Keep exploring topics with follow-up questions."); + sb.AppendLine("Never end abruptly - always leave room for continuation."); + } + sb.AppendLine(); + sb.AppendLine("## Rules:"); + sb.AppendLine($"- Speak naturally in {targetLanguage} as a native speaker would"); + sb.AppendLine($"- Stay in character as {scenario.PersonaName}"); + sb.AppendLine("- Keep responses conversational and friendly"); + sb.AppendLine("- If the user's message is hard to understand or could be phrased more clearly, ask for clarification or confirm your understanding"); + sb.AppendLine("- If the user asks a question, respond to it before asking a new question"); + sb.AppendLine($"- In your response, only include your newest reply in {targetLanguage} - do not repeat your character name"); + + if (!string.IsNullOrWhiteSpace(scenario.QuestionBank)) + { + sb.AppendLine(); + sb.AppendLine("## Suggested topics/phrases (work these into the conversation naturally):"); + sb.AppendLine(scenario.QuestionBank); + } + + return sb.ToString(); + } + + private static string BuildOpeningPrompt( + ConversationScenario? scenario, + string targetLanguage) + { + if (scenario is null) + { + return $"You will play the role of a friendly {targetLanguage} native speaker who I am meeting for conversation practice. " + + "Make up your backstory as needed to answer my questions naturally. " + + $"Let's have a conversation in {targetLanguage}. Start by greeting me and asking for my name in a natural, friendly way."; + } + + var styleLine = scenario.ConversationType == ConversationType.Finite + ? $"This is a transactional conversation, so ask something relevant to completing the transaction in {targetLanguage}." + : $"This is an open-ended conversation, so ask something to get to know them or explore the topic in {targetLanguage}."; + + return + "Start the conversation with an appropriate greeting for this scenario.\n\n" + + $"As {scenario.PersonaName}, {scenario.PersonaDescription}, begin by greeting the customer/person naturally in {targetLanguage} and asking an appropriate opening question for this situation:\n\n" + + $"Situation: {scenario.SituationDescription}\n\n" + + styleLine + "\n\n" + + $"Only respond with your greeting and question in {targetLanguage} - nothing else."; + } + + private static string BuildGradingInstructions(string targetLanguage) + { + return $@"You are a {targetLanguage} language grading assistant. Your job is to: + +1. Evaluate the user's {targetLanguage} message for comprehension (0.0 to 1.0 scale): + - 1.0: Perfect, native-like communication + - 0.8-0.9: Minor errors but message is clear + - 0.6-0.7: Understandable with some effort + - 0.4-0.5: Partially understandable + - 0.2-0.3: Difficult to understand + - 0.0-0.1: Not understandable + +2. Provide brief comprehension notes explaining what worked well or what was unclear. + +3. Identify grammar corrections: + - Find grammar mistakes in the user's {targetLanguage} + - Provide the corrected version + - Explain the grammar rule simply + +Focus on being helpful and encouraging while providing accurate corrections. +Only identify significant errors that affect meaning or are common learner mistakes. +Do not nitpick minor stylistic preferences."; + } + + private static string BuildGradingPrompt( + string userMessage, + IReadOnlyList history) + { + var sb = new StringBuilder(); + sb.AppendLine("Conversation context:"); + foreach (var chunk in history.TakeLast(6)) + { + var speaker = string.Equals(chunk.Role, "user", StringComparison.OrdinalIgnoreCase) + ? "User" + : "Partner"; + sb.AppendLine($"{speaker}: {chunk.Text}"); + } + + sb.AppendLine(); + sb.AppendLine($"User's latest message to grade: {userMessage}"); + return sb.ToString(); + } +} diff --git a/src/SentenceStudio.Api/ConversationEndpoints.cs b/src/SentenceStudio.Api/ConversationEndpoints.cs new file mode 100644 index 00000000..99fcdeba --- /dev/null +++ b/src/SentenceStudio.Api/ConversationEndpoints.cs @@ -0,0 +1,190 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Mvc; +using SentenceStudio.Api.Conversation; +using SentenceStudio.Contracts; +using SentenceStudio.Contracts.Conversation; +using SentenceStudio.Data; +using SentenceStudio.Shared.Models; + +namespace SentenceStudio.Api; + +/// +/// HTTP endpoints for the Conversation activity. Thin wrapper over +/// and . +/// Mirrors the wire contract documented in spec 004. +/// +public static class ConversationEndpoints +{ + /// + /// Subset of BCP-47 tags we recognize; falls through to the original + /// value when no mapping matches. Mirrors SpeechEndpoints.Bcp47ToLabel + /// to keep agent prompts addressable by language name. + /// + private static readonly Dictionary Bcp47ToLabel = new(StringComparer.OrdinalIgnoreCase) + { + { "en", "English" }, + { "fr", "French" }, + { "de", "German" }, + { "ko", "Korean" }, + { "es", "Spanish" }, + }; + + public static WebApplication MapConversationEndpoints(this WebApplication app) + { + var group = app.MapGroup("/api/v1/conversation").RequireAuthorization(); + + group.MapGet("/scenarios", GetScenarios); + group.MapPost("/start", StartConversation); + group.MapPost("/continue", ContinueConversation); + + return app; + } + + private static async Task GetScenarios( + ClaimsPrincipal user, + [FromServices] ScenarioRepository scenarioRepository, + [FromServices] ILoggerFactory loggerFactory, + CancellationToken cancellationToken) + { + var logger = loggerFactory.CreateLogger("ConversationEndpoints"); + + var userProfileId = user.FindFirstValue(AuthClaimTypes.UserProfileId); + if (string.IsNullOrEmpty(userProfileId)) return Results.Unauthorized(); + + var scenarios = await scenarioRepository.GetAllAsync(); + var dtos = scenarios.Select(MapScenario).ToList(); + + logger.LogInformation("GetScenarios: returning {Count} scenarios for user {UserProfileId}", + dtos.Count, userProfileId); + + return Results.Ok(dtos); + } + + private static async Task StartConversation( + ConversationStartRequest request, + ClaimsPrincipal user, + [FromServices] IServerConversationService? conversationService, + [FromServices] ScenarioRepository scenarioRepository, + [FromServices] ILoggerFactory loggerFactory, + CancellationToken cancellationToken) + { + var logger = loggerFactory.CreateLogger("ConversationEndpoints"); + + var userProfileId = user.FindFirstValue(AuthClaimTypes.UserProfileId); + if (string.IsNullOrEmpty(userProfileId)) return Results.Unauthorized(); + + if (conversationService is null) + { + logger.LogWarning("ConversationStart: IServerConversationService not registered (no AI:OpenAI:ApiKey)"); + return Results.StatusCode(StatusCodes.Status503ServiceUnavailable); + } + + ConversationScenario? scenario = null; + if (request.ScenarioId is int id) + { + scenario = await scenarioRepository.GetByIdAsync(id); + if (scenario is null) + { + logger.LogWarning("ConversationStart: scenario {ScenarioId} not found", id); + return Results.NotFound(new { error = $"Scenario {id} not found." }); + } + } + + var languageLabel = ResolveLanguageLabel(request.TargetLanguage) ?? "Korean"; + + var opening = await conversationService.StartAsync(scenario, languageLabel, cancellationToken); + + return Results.Ok(new ConversationStartResponse + { + FirstAssistantMessage = opening, + PersonaName = scenario?.PersonaName, + ConversationType = (scenario?.ConversationType ?? ConversationType.OpenEnded).ToString(), + }); + } + + private static async Task ContinueConversation( + ConversationContinueRequest request, + ClaimsPrincipal user, + [FromServices] IServerConversationService? conversationService, + [FromServices] ScenarioRepository scenarioRepository, + [FromServices] ILoggerFactory loggerFactory, + CancellationToken cancellationToken) + { + var logger = loggerFactory.CreateLogger("ConversationEndpoints"); + + var userProfileId = user.FindFirstValue(AuthClaimTypes.UserProfileId); + if (string.IsNullOrEmpty(userProfileId)) return Results.Unauthorized(); + + if (conversationService is null) + { + logger.LogWarning("ConversationContinue: IServerConversationService not registered (no AI:OpenAI:ApiKey)"); + return Results.StatusCode(StatusCodes.Status503ServiceUnavailable); + } + + if (request.History is null || request.History.Count == 0) + { + return Results.ValidationProblem(new Dictionary + { + ["history"] = new[] { "history must contain at least one turn (the user's new message)." }, + }); + } + + var lastTurn = request.History[^1]; + if (!string.Equals(lastTurn.Role, "user", StringComparison.OrdinalIgnoreCase) || + string.IsNullOrWhiteSpace(lastTurn.Text)) + { + return Results.ValidationProblem(new Dictionary + { + ["history"] = new[] { "the last history entry must be a non-empty user turn." }, + }); + } + + ConversationScenario? scenario = null; + if (request.ScenarioId is int id) + { + scenario = await scenarioRepository.GetByIdAsync(id); + if (scenario is null) + { + logger.LogWarning("ConversationContinue: scenario {ScenarioId} not found", id); + return Results.NotFound(new { error = $"Scenario {id} not found." }); + } + } + + var languageLabel = ResolveLanguageLabel(request.TargetLanguage) ?? "Korean"; + + var userMessage = lastTurn.Text; + var priorHistory = request.History.Take(request.History.Count - 1).ToList(); + + var result = await conversationService.ContinueAsync( + userMessage, + priorHistory, + scenario, + languageLabel, + cancellationToken); + + return Results.Ok(result); + } + + private static ConversationScenarioDto MapScenario(ConversationScenario s) => new() + { + Id = s.Id, + Name = s.Name, + NameKorean = s.NameKorean, + PersonaName = s.PersonaName, + PersonaDescription = s.PersonaDescription, + SituationDescription = s.SituationDescription, + ConversationType = s.ConversationType.ToString(), + QuestionBank = s.QuestionBank, + IsPredefined = s.IsPredefined, + }; + + private static string? ResolveLanguageLabel(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + var trimmed = value.Trim(); + if (Bcp47ToLabel.Values.Contains(trimmed, StringComparer.OrdinalIgnoreCase)) + return trimmed; + var primary = trimmed.Split('-')[0]; + return Bcp47ToLabel.TryGetValue(primary, out var label) ? label : trimmed; + } +} diff --git a/src/SentenceStudio.Api/Program.cs b/src/SentenceStudio.Api/Program.cs index ffd91f10..e1401660 100644 --- a/src/SentenceStudio.Api/Program.cs +++ b/src/SentenceStudio.Api/Program.cs @@ -167,11 +167,10 @@ // YouTube channel monitoring services builder.Services.AddSingleton(); -builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +// VideoImportPipelineService, TranscriptFormattingService, AiService all +// transitively depend on IChatClient — registered conditionally below. // Release notes service builder.Services.AddSingleton(); @@ -287,8 +286,16 @@ SentenceStudio.Services.Plans.PlanService>(); -// Voice discovery (ElevenLabs) — registered here for the same reason as above. -builder.Services.AddSingleton(); +// Voice discovery (ElevenLabs) — depends on ElevenLabsClient which is +// registered conditionally below. + +// Conversation activity — server-side stateless analogue of the MAUI agent +// service. ScenarioRepository is reused from SentenceStudio.Shared (no MAUI +// deps); the server service is a thin IChatClient wrapper. See +// specs/004-conversation-http-endpoints/spec.md. The IServerConversationService +// registration is conditional on IChatClient being available; endpoints return +// 503 when it isn't (parity with /api/v1/ai/chat). +builder.Services.AddSingleton(); // Vocabulary progress services builder.Services.AddSingleton(); @@ -314,6 +321,14 @@ return new OpenAIClient(new System.ClientModel.ApiKeyCredential(openAiApiKey), clientOptions) .GetChatClient(chatModel).AsIChatClient(); }); + + // Services that depend on IChatClient (directly or transitively via AiService). + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + builder.Services.AddScoped(); } // GitHub API client for feedback issue creation @@ -332,6 +347,8 @@ // timeout (100s) trips on /synthesize-timestamped with long input. Bump to 5 min. var elevenLabsHttp = new HttpClient { Timeout = TimeSpan.FromMinutes(5) }; builder.Services.AddSingleton(new ElevenLabsClient(elevenLabsKey, httpClient: elevenLabsHttp)); + builder.Services.AddSingleton(); } var app = builder.Build(); @@ -508,6 +525,9 @@ await context.Response.WriteAsync(""" // Speech / voice discovery app.MapSpeechEndpoints(); +// Conversation activity (Flutter client) +app.MapConversationEndpoints(); + app.MapGet("/api/v1/auth/bootstrap", (ClaimsPrincipal user, ITenantContext tenantContext) => Results.Ok(new BootstrapResponse { diff --git a/src/SentenceStudio.Contracts/Conversation/ConversationContinueRequest.cs b/src/SentenceStudio.Contracts/Conversation/ConversationContinueRequest.cs new file mode 100644 index 00000000..c0268005 --- /dev/null +++ b/src/SentenceStudio.Contracts/Conversation/ConversationContinueRequest.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace SentenceStudio.Contracts.Conversation; + +/// +/// Body for POST /api/v1/conversation/continue. +/// History is oldest -> newest; the last entry must be the user's new turn. +/// +public sealed class ConversationContinueRequest +{ + public int? ScenarioId { get; set; } + public string? TargetLanguage { get; set; } + public List History { get; set; } = new(); +} diff --git a/src/SentenceStudio.Contracts/Conversation/ConversationContinueResponse.cs b/src/SentenceStudio.Contracts/Conversation/ConversationContinueResponse.cs new file mode 100644 index 00000000..577cb36f --- /dev/null +++ b/src/SentenceStudio.Contracts/Conversation/ConversationContinueResponse.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; + +namespace SentenceStudio.Contracts.Conversation; + +/// +/// Body for POST /api/v1/conversation/continue. +/// Keys serialize as camelCase via the minimal-API web defaults. +/// +public sealed class ConversationContinueResponse +{ + public string AssistantMessage { get; set; } = string.Empty; + + /// + /// Integer 0-100. The underlying agent grades on a 0.0-1.0 scale; the + /// endpoint multiplies, clamps, and rounds before serializing. + /// + public int ComprehensionScore { get; set; } + + public string? ComprehensionNotes { get; set; } + + public List GrammarCorrections { get; set; } = new(); + + public List VocabularyAnalysis { get; set; } = new(); + + /// + /// True when a finite scenario reaches its natural end. v1 always emits + /// false; the Flutter UI handles false gracefully. See spec 004 §Behavior. + /// + public bool IsComplete { get; set; } +} + +public sealed class ConversationGrammarCorrectionDto +{ + public string Original { get; set; } = string.Empty; + public string Corrected { get; set; } = string.Empty; + public string Explanation { get; set; } = string.Empty; +} + +public sealed class ConversationVocabularyAnalysisDto +{ + public string UsedForm { get; set; } = string.Empty; + public string DictionaryForm { get; set; } = string.Empty; + public string Meaning { get; set; } = string.Empty; + public bool UsageCorrect { get; set; } + public string? UsageExplanation { get; set; } +} diff --git a/src/SentenceStudio.Contracts/Conversation/ConversationHistoryItemDto.cs b/src/SentenceStudio.Contracts/Conversation/ConversationHistoryItemDto.cs new file mode 100644 index 00000000..f7e66b4b --- /dev/null +++ b/src/SentenceStudio.Contracts/Conversation/ConversationHistoryItemDto.cs @@ -0,0 +1,19 @@ +namespace SentenceStudio.Contracts.Conversation; + +/// +/// One turn in the conversation history. The client sends oldest -> newest. +/// +public sealed class ConversationHistoryItemDto +{ + /// + /// Lowercase wire value: "user" or "assistant". + /// + public string Role { get; set; } = "user"; + + /// + /// Optional display name of the speaker (persona name for assistant turns). + /// + public string? Author { get; set; } + + public string Text { get; set; } = string.Empty; +} diff --git a/src/SentenceStudio.Contracts/Conversation/ConversationScenarioDto.cs b/src/SentenceStudio.Contracts/Conversation/ConversationScenarioDto.cs new file mode 100644 index 00000000..a9580f77 --- /dev/null +++ b/src/SentenceStudio.Contracts/Conversation/ConversationScenarioDto.cs @@ -0,0 +1,44 @@ +using System.Text.Json.Serialization; + +namespace SentenceStudio.Contracts.Conversation; + +/// +/// Wire shape for a conversation scenario. +/// Uses explicit PascalCase JSON property names to match the +/// existing `ConversationScenario` EF entity shape the MAUI app +/// already consumes (the Flutter client's conversation_dtos.dart +/// is the source of truth — see spec 004). +/// +public sealed class ConversationScenarioDto +{ + [JsonPropertyName("Id")] + public int Id { get; set; } + + [JsonPropertyName("Name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("NameKorean")] + public string? NameKorean { get; set; } + + [JsonPropertyName("PersonaName")] + public string PersonaName { get; set; } = string.Empty; + + [JsonPropertyName("PersonaDescription")] + public string PersonaDescription { get; set; } = string.Empty; + + [JsonPropertyName("SituationDescription")] + public string SituationDescription { get; set; } = string.Empty; + + /// + /// Wire values are the strings "OpenEnded" or "Finite" (PascalCase). + /// Unknown values fall back to OpenEnded on the Flutter side. + /// + [JsonPropertyName("ConversationType")] + public string ConversationType { get; set; } = "OpenEnded"; + + [JsonPropertyName("QuestionBank")] + public string? QuestionBank { get; set; } + + [JsonPropertyName("IsPredefined")] + public bool IsPredefined { get; set; } +} diff --git a/src/SentenceStudio.Contracts/Conversation/ConversationStartRequest.cs b/src/SentenceStudio.Contracts/Conversation/ConversationStartRequest.cs new file mode 100644 index 00000000..66417154 --- /dev/null +++ b/src/SentenceStudio.Contracts/Conversation/ConversationStartRequest.cs @@ -0,0 +1,18 @@ +namespace SentenceStudio.Contracts.Conversation; + +/// +/// Body for POST /api/v1/conversation/start. +/// All JSON keys serialize as camelCase via the minimal-API web defaults. +/// +public sealed class ConversationStartRequest +{ + /// + /// Optional scenario id; when omitted, a free-chat opening is used. + /// + public int? ScenarioId { get; set; } + + /// + /// BCP-47 tag (e.g. "ko", "ko-KR") or the human label (e.g. "Korean"). + /// + public string? TargetLanguage { get; set; } +} diff --git a/src/SentenceStudio.Contracts/Conversation/ConversationStartResponse.cs b/src/SentenceStudio.Contracts/Conversation/ConversationStartResponse.cs new file mode 100644 index 00000000..e0c9e14b --- /dev/null +++ b/src/SentenceStudio.Contracts/Conversation/ConversationStartResponse.cs @@ -0,0 +1,12 @@ +namespace SentenceStudio.Contracts.Conversation; + +/// +/// Body for POST /api/v1/conversation/start. +/// Keys serialize as camelCase via the minimal-API web defaults. +/// +public sealed class ConversationStartResponse +{ + public string FirstAssistantMessage { get; set; } = string.Empty; + public string? PersonaName { get; set; } + public string ConversationType { get; set; } = "OpenEnded"; +} diff --git a/tests/SentenceStudio.Api.Tests/ConversationEndpointsTests.cs b/tests/SentenceStudio.Api.Tests/ConversationEndpointsTests.cs new file mode 100644 index 00000000..494bb1f4 --- /dev/null +++ b/tests/SentenceStudio.Api.Tests/ConversationEndpointsTests.cs @@ -0,0 +1,273 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using SentenceStudio.Api.Conversation; +using SentenceStudio.Api.Tests.Infrastructure; +using SentenceStudio.Contracts.Conversation; +using SentenceStudio.Data; +using SentenceStudio.Shared.Models; + +namespace SentenceStudio.Api.Tests; + +/// +/// Integration tests for the Conversation HTTP endpoints (spec 004). +/// The Flutter client treats conversation_dtos.dart as the source of +/// truth for the wire shape; these tests pin the contract. +/// +public sealed class ConversationEndpointsTests : IClassFixture +{ + private readonly ConversationApiFactory _factory; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public ConversationEndpointsTests(ConversationApiFactory factory) + { + _factory = factory; + } + + private HttpClient ClientWithJwt(string userProfileId) + { + var token = TestJwtGenerator.GenerateToken(userProfileId: userProfileId); + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + return client; + } + + private async Task SeedScenarioAsync( + string name = "First Meeting", + ConversationType type = ConversationType.OpenEnded, + bool isPredefined = true) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var scenario = new ConversationScenario + { + Name = name, + NameKorean = "첫 만남", + PersonaName = "Mr. Kim", + PersonaDescription = "a friendly stranger", + SituationDescription = "Meeting for the first time at a coffee shop.", + ConversationType = type, + QuestionBank = null, + IsPredefined = isPredefined, + }; + db.ConversationScenarios.Add(scenario); + await db.SaveChangesAsync(); + return scenario; + } + + [Fact] + public async Task GetScenarios_NoJwt_Returns401() + { + var client = _factory.CreateClient(); + var response = await client.GetAsync("/api/v1/conversation/scenarios"); + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task GetScenarios_Authed_ReturnsSeededScenariosWithPascalCaseKeys() + { + await SeedScenarioAsync(name: $"PascalCase Smoke {Guid.NewGuid():N}"); + var client = ClientWithJwt($"conv-scenarios-{Guid.NewGuid():N}"); + + var response = await client.GetAsync("/api/v1/conversation/scenarios"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var raw = await response.Content.ReadAsStringAsync(); + // Wire contract: scenario keys are PascalCase. A camelCase key like + // "personaName" indicates JSON serialization defaults leaked through. + raw.Should().Contain("\"PersonaName\""); + raw.Should().Contain("\"ConversationType\""); + raw.Should().Contain("\"IsPredefined\""); + raw.Should().NotContain("\"personaName\""); + raw.Should().NotContain("\"conversationType\""); + + var scenarios = await response.Content.ReadFromJsonAsync>(JsonOptions); + scenarios.Should().NotBeNullOrEmpty(); + scenarios!.Should().Contain(s => s.PersonaName == "Mr. Kim"); + scenarios.Should().OnlyContain(s => s.ConversationType == "OpenEnded" || s.ConversationType == "Finite"); + } + + [Fact] + public async Task PostStart_NoJwt_Returns401() + { + var client = _factory.CreateClient(); + var response = await client.PostAsJsonAsync( + "/api/v1/conversation/start", + new ConversationStartRequest { TargetLanguage = "ko" }); + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task PostStart_KnownScenario_ReturnsOpeningMessage() + { + var scenario = await SeedScenarioAsync(name: $"Start OK {Guid.NewGuid():N}"); + _factory.ConversationService.OpeningMessage = "안녕하세요! 반갑습니다."; + + var client = ClientWithJwt($"conv-start-{Guid.NewGuid():N}"); + var response = await client.PostAsJsonAsync("/api/v1/conversation/start", + new ConversationStartRequest { ScenarioId = scenario.Id, TargetLanguage = "ko" }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(JsonOptions); + payload.Should().NotBeNull(); + payload!.FirstAssistantMessage.Should().Be("안녕하세요! 반갑습니다."); + payload.PersonaName.Should().Be("Mr. Kim"); + payload.ConversationType.Should().Be("OpenEnded"); + + // Language normalization happens at the endpoint boundary. + _factory.ConversationService.LastTargetLanguageLabel.Should().Be("Korean"); + _factory.ConversationService.LastScenario.Should().NotBeNull(); + _factory.ConversationService.LastScenario!.Id.Should().Be(scenario.Id); + } + + [Fact] + public async Task PostStart_UnknownScenario_Returns404() + { + var client = ClientWithJwt($"conv-start-404-{Guid.NewGuid():N}"); + var response = await client.PostAsJsonAsync("/api/v1/conversation/start", + new ConversationStartRequest { ScenarioId = 999999, TargetLanguage = "ko" }); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task PostContinue_OneTurnHistory_ReturnsExpectedWireShape() + { + var scenario = await SeedScenarioAsync(name: $"Continue OK {Guid.NewGuid():N}"); + _factory.ConversationService.ContinueResponse = new ConversationContinueResponse + { + AssistantMessage = "네, 좋아요.", + ComprehensionScore = 92, + ComprehensionNotes = "Great natural phrasing.", + GrammarCorrections = new() + { + new ConversationGrammarCorrectionDto + { + Original = "나는 학생이에요", + Corrected = "저는 학생이에요", + Explanation = "Use 저 in polite speech.", + }, + }, + VocabularyAnalysis = new(), + IsComplete = false, + }; + + var client = ClientWithJwt($"conv-continue-{Guid.NewGuid():N}"); + var response = await client.PostAsJsonAsync("/api/v1/conversation/continue", + new ConversationContinueRequest + { + ScenarioId = scenario.Id, + TargetLanguage = "ko", + History = new() + { + new ConversationHistoryItemDto + { + Role = "assistant", Author = "Mr. Kim", Text = "안녕하세요.", + }, + new ConversationHistoryItemDto + { + Role = "user", Text = "안녕하세요, 저는 학생이에요.", + }, + }, + }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var raw = await response.Content.ReadAsStringAsync(); + // Wire contract: response keys are camelCase. Assert key presence to + // catch JSON serialization regressions early. + raw.Should().Contain("\"assistantMessage\""); + raw.Should().Contain("\"comprehensionScore\""); + raw.Should().Contain("\"comprehensionNotes\""); + raw.Should().Contain("\"grammarCorrections\""); + raw.Should().Contain("\"vocabularyAnalysis\""); + raw.Should().Contain("\"isComplete\""); + raw.Should().NotContain("\"AssistantMessage\""); + raw.Should().NotContain("\"ComprehensionScore\""); + + var payload = await response.Content.ReadFromJsonAsync(JsonOptions); + payload.Should().NotBeNull(); + payload!.AssistantMessage.Should().Be("네, 좋아요."); + payload.ComprehensionScore.Should().BeInRange(0, 100); + payload.ComprehensionScore.Should().Be(92); + payload.GrammarCorrections.Should().ContainSingle() + .Which.Corrected.Should().Be("저는 학생이에요"); + payload.IsComplete.Should().BeFalse(); + + // Endpoint extracts the last entry as the user's new message; prior + // turns become conversation history for the service call. + _factory.ConversationService.LastUserMessage.Should().Be("안녕하세요, 저는 학생이에요."); + _factory.ConversationService.LastHistory.Should().ContainSingle() + .Which.Role.Should().Be("assistant"); + } + + [Fact] + public async Task PostContinue_EmptyHistory_Returns400() + { + var client = ClientWithJwt($"conv-continue-empty-{Guid.NewGuid():N}"); + var response = await client.PostAsJsonAsync("/api/v1/conversation/continue", + new ConversationContinueRequest + { + ScenarioId = null, + TargetLanguage = "ko", + History = new(), + }); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task PostContinue_LastTurnNotUser_Returns400() + { + var client = ClientWithJwt($"conv-continue-badlast-{Guid.NewGuid():N}"); + var response = await client.PostAsJsonAsync("/api/v1/conversation/continue", + new ConversationContinueRequest + { + ScenarioId = null, + TargetLanguage = "ko", + History = new() + { + new ConversationHistoryItemDto + { + Role = "assistant", Text = "안녕하세요.", + }, + }, + }); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task PostContinue_UnknownScenario_Returns404() + { + var client = ClientWithJwt($"conv-continue-404-{Guid.NewGuid():N}"); + var response = await client.PostAsJsonAsync("/api/v1/conversation/continue", + new ConversationContinueRequest + { + ScenarioId = 999999, + TargetLanguage = "ko", + History = new() + { + new ConversationHistoryItemDto { Role = "user", Text = "안녕." }, + }, + }); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Theory] + [InlineData(0.0, 0)] + [InlineData(0.5, 50)] + [InlineData(0.85, 85)] + [InlineData(1.0, 100)] + [InlineData(85.0, 85)] // grader returned a 0-100 magnitude — normalize, don't multiply. + [InlineData(100.0, 100)] + [InlineData(-0.5, 0)] // clamp below + [InlineData(150.0, 100)] // clamp above + public void NormalizeComprehensionScore_HandlesBothScales(double raw, int expected) + { + ServerConversationService.NormalizeComprehensionScore(raw).Should().Be(expected); + } +} diff --git a/tests/SentenceStudio.Api.Tests/Infrastructure/ConversationApiFactory.cs b/tests/SentenceStudio.Api.Tests/Infrastructure/ConversationApiFactory.cs new file mode 100644 index 00000000..96bd858e --- /dev/null +++ b/tests/SentenceStudio.Api.Tests/Infrastructure/ConversationApiFactory.cs @@ -0,0 +1,64 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using SentenceStudio.Api.Conversation; + +namespace SentenceStudio.Api.Tests.Infrastructure; + +/// +/// Test host for Conversation endpoint tests. Replaces the real +/// with +/// so tests never call OpenAI +/// and can deterministically assert wire shape and routing behavior. +/// +public class ConversationApiFactory : WebApplicationFactory +{ + private readonly string _dbPath = Path.Combine(Path.GetTempPath(), + $"sentencestudio_conversation_{Guid.NewGuid():N}.db"); + + public StubServerConversationService ConversationService { get; } = new(); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Development"); + builder.UseSetting("ConnectionStrings:sentencestudio", + TestApiHostConfigurator.DummyPostgresConnectionString); + builder.UseSetting("Database:SkipMigrateOnStartup", "true"); + builder.UseSetting("Auth:EnableDevAuthFallback", "false"); + builder.UseSetting("Jwt:SigningKey", TestJwtGenerator.TestSigningKeyValue); + builder.UseSetting("Jwt:Issuer", TestJwtGenerator.TestIssuer); + builder.UseSetting("Jwt:Audience", TestJwtGenerator.TestAudience); + + builder.ConfigureServices(services => + { + TestApiHostConfigurator.ConfigureSqliteDatabaseAndSync(services, _dbPath); + + services.Configure(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }); + + services.RemoveAll(); + services.AddSingleton(ConversationService); + }); + } + + protected override IHost CreateHost(IHostBuilder builder) + { + var host = base.CreateHost(builder); + TestApiHostConfigurator.InitializeSqliteDatabaseAndSync(host.Services); + return host; + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + if (File.Exists(_dbPath)) + File.Delete(_dbPath); + } +} diff --git a/tests/SentenceStudio.Api.Tests/Infrastructure/StubServerConversationService.cs b/tests/SentenceStudio.Api.Tests/Infrastructure/StubServerConversationService.cs new file mode 100644 index 00000000..ce3dbbd8 --- /dev/null +++ b/tests/SentenceStudio.Api.Tests/Infrastructure/StubServerConversationService.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using SentenceStudio.Api.Conversation; +using SentenceStudio.Contracts.Conversation; +using SentenceStudio.Shared.Models; + +namespace SentenceStudio.Api.Tests.Infrastructure; + +/// +/// Stub implementation of used by the +/// conversation endpoint tests so they never reach an LLM. Tests can inspect +/// the last call's arguments and set canned responses. +/// +public sealed class StubServerConversationService : IServerConversationService +{ + public ConversationScenario? LastScenario { get; private set; } + public string? LastUserMessage { get; private set; } + public List LastHistory { get; private set; } = new(); + public string? LastTargetLanguageLabel { get; private set; } + + public string OpeningMessage { get; set; } = "안녕하세요. 만나서 반갑습니다."; + + public ConversationContinueResponse ContinueResponse { get; set; } = new() + { + AssistantMessage = "네, 그렇군요.", + ComprehensionScore = 85, + ComprehensionNotes = "Clear and natural.", + GrammarCorrections = new(), + VocabularyAnalysis = new(), + IsComplete = false, + }; + + public Task StartAsync( + ConversationScenario? scenario, + string targetLanguageLabel, + CancellationToken cancellationToken) + { + LastScenario = scenario; + LastTargetLanguageLabel = targetLanguageLabel; + return Task.FromResult(OpeningMessage); + } + + public Task ContinueAsync( + string userMessage, + IReadOnlyList conversationHistory, + ConversationScenario? scenario, + string targetLanguageLabel, + CancellationToken cancellationToken) + { + LastUserMessage = userMessage; + LastHistory = new List(conversationHistory); + LastScenario = scenario; + LastTargetLanguageLabel = targetLanguageLabel; + return Task.FromResult(ContinueResponse); + } +} From 708de7b6a5bf6ebbf565f157b5031199aa62a72d Mon Sep 17 00:00:00 2001 From: David Ortinau Date: Tue, 19 May 2026 22:11:00 -0500 Subject: [PATCH 2/6] feat(api,maui): seed ConversationScenario server-side; share seed data with MAUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 5 predefined Conversation scenarios were previously seeded only by the MAUI client into each device's local SQLite. The Flutter client has no local DB and reaches the API directly, so GET /api/v1/conversation/scenarios returned an empty list against Postgres. Move the seed data to a single shared source (Shared/Data/ConversationScenarioSeedData.cs) consumed by both: - Api: new ConversationScenarioSeeder runs from Program.cs startup alongside NumberContentSeeder. Idempotent upsert keyed on (Name, IsPredefined=true). Updates existing predefined rows when seed copy changes; leaves user-created rows alone. - AppLib: ScenarioService.SeedPredefinedScenariosAsync now consumes the shared data. Same call site (SentenceStudioAppBuilder), same idempotent HasPredefinedScenariosAsync gate — preserves offline-first-launch UX. Both heads seed independently (mirrors the NumberContentSeeder precedent); ConversationScenario is intentionally NOT added to CoreSync — model has no UserProfileId yet and existing locally-seeded rows would collide on id. Tests: 5 new ConversationScenarioSeederTests cover empty / idempotent / stale-update / partial-state / user-row-untouched scenarios. Verified: - dotnet build src/SentenceStudio.Api → 0 errors - dotnet test tests/SentenceStudio.Api.Tests → 187/188 (1 pre-existing unrelated failure: API_PlansEndpointWorksWithValidToken) - aspire run --detach --isolated → AppHost up, API resource Ready, no startup errors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ConversationScenarioSeeder.cs | 101 +++++++++++ src/SentenceStudio.Api/Program.cs | 6 + .../Services/ScenarioService.cs | 63 +------ .../Data/ConversationScenarioSeedData.cs | 79 +++++++++ .../ConversationScenarioSeederTests.cs | 159 ++++++++++++++++++ 5 files changed, 349 insertions(+), 59 deletions(-) create mode 100644 src/SentenceStudio.Api/Conversation/ConversationScenarioSeeder.cs create mode 100644 src/SentenceStudio.Shared/Data/ConversationScenarioSeedData.cs create mode 100644 tests/SentenceStudio.Api.Tests/ConversationScenarioSeederTests.cs diff --git a/src/SentenceStudio.Api/Conversation/ConversationScenarioSeeder.cs b/src/SentenceStudio.Api/Conversation/ConversationScenarioSeeder.cs new file mode 100644 index 00000000..0202129e --- /dev/null +++ b/src/SentenceStudio.Api/Conversation/ConversationScenarioSeeder.cs @@ -0,0 +1,101 @@ +using Microsoft.EntityFrameworkCore; +using SentenceStudio.Data; +using SentenceStudio.Shared.Models; + +namespace SentenceStudio.Api.Conversation; + +/// +/// Seeds predefined rows into the API database on startup. +/// Idempotent — re-running upserts existing rows (matched by Name + IsPredefined=true) and +/// inserts any that are missing. Mirrors the MAUI-side seed in +/// ScenarioService.SeedPredefinedScenariosAsync; both consume the same shared seed list +/// (). +/// +public class ConversationScenarioSeeder +{ + private readonly ApplicationDbContext _db; + private readonly ILogger _logger; + + public ConversationScenarioSeeder(ApplicationDbContext db, ILogger logger) + { + _db = db; + _logger = logger; + } + + public async Task SeedAsync(CancellationToken ct = default) + { + // Guard against concurrent replicas seeding simultaneously (ACA scale-out, parallel restarts). + // pg_advisory_xact_lock blocks until released at transaction commit; serializes seeders across + // every connection in the cluster. The arbitrary int key (4242_4242) is unique to this seeder. + // No-op on SQLite (tests) — that path is single-threaded by construction. + var isPostgres = _db.Database.IsNpgsql(); + if (isPostgres) + { + await using var tx = await _db.Database.BeginTransactionAsync(ct); + await _db.Database.ExecuteSqlRawAsync("SELECT pg_advisory_xact_lock(42424242);", ct); + await SeedCoreAsync(ct); + await tx.CommitAsync(ct); + return; + } + + await SeedCoreAsync(ct); + } + + private async Task SeedCoreAsync(CancellationToken ct) + { + var seedData = ConversationScenarioSeedData.GetPredefinedScenarios(); + + // Load existing predefined rows once; match on Name (case-sensitive — seed names are canonical English). + var existingPredefined = await _db.ConversationScenarios + .Where(s => s.IsPredefined) + .ToListAsync(ct); + + int inserted = 0; + int updated = 0; + + foreach (var seed in seedData) + { + var existing = existingPredefined.FirstOrDefault(s => s.Name == seed.Name); + if (existing is null) + { + seed.CreatedAt = DateTime.UtcNow; + seed.UpdatedAt = DateTime.UtcNow; + _db.ConversationScenarios.Add(seed); + inserted++; + continue; + } + + bool changed = + existing.NameKorean != seed.NameKorean || + existing.PersonaName != seed.PersonaName || + existing.PersonaDescription != seed.PersonaDescription || + existing.SituationDescription != seed.SituationDescription || + existing.ConversationType != seed.ConversationType || + existing.QuestionBank != seed.QuestionBank; + + if (changed) + { + existing.NameKorean = seed.NameKorean; + existing.PersonaName = seed.PersonaName; + existing.PersonaDescription = seed.PersonaDescription; + existing.SituationDescription = seed.SituationDescription; + existing.ConversationType = seed.ConversationType; + existing.QuestionBank = seed.QuestionBank; + existing.UpdatedAt = DateTime.UtcNow; + updated++; + } + } + + if (inserted == 0 && updated == 0) + { + _logger.LogDebug("ConversationScenario seed: no changes ({Count} predefined rows already current)", seedData.Count); + return; + } + + await _db.SaveChangesAsync(ct); + _logger.LogInformation( + "ConversationScenario seed: inserted {Inserted}, updated {Updated} predefined rows", + inserted, + updated); + } +} diff --git a/src/SentenceStudio.Api/Program.cs b/src/SentenceStudio.Api/Program.cs index e1401660..decdaaab 100644 --- a/src/SentenceStudio.Api/Program.cs +++ b/src/SentenceStudio.Api/Program.cs @@ -221,6 +221,7 @@ // NumberDrill content seeder — populates NumberContext / NumberSubMode / NumberCounter // from lib/content/numbers/{language}.json (idempotent upsert by natural key). builder.Services.AddScoped(); +builder.Services.AddScoped(); // Multi-worktree footgun: if the API binds to a fresh Postgres volume (different worktree // or freshly-provisioned Aspire environment), AspNetUsers will be empty and login will return @@ -372,6 +373,11 @@ // Seed Korean number content (NumberDrill activity — idempotent upsert by natural key) var numberSeeder = scope.ServiceProvider.GetRequiredService(); await numberSeeder.SeedAsync("ko"); + + // Seed predefined Conversation scenarios (idempotent upsert keyed on Name + IsPredefined=true). + // Shared seed data with MAUI lives in SentenceStudio.Data.ConversationScenarioSeedData. + var scenarioSeeder = scope.ServiceProvider.GetRequiredService(); + await scenarioSeeder.SeedAsync(); } // Apply CoreSync provisioning (creates change-tracking tables if missing) diff --git a/src/SentenceStudio.AppLib/Services/ScenarioService.cs b/src/SentenceStudio.AppLib/Services/ScenarioService.cs index 125bc865..d6c5cb57 100644 --- a/src/SentenceStudio.AppLib/Services/ScenarioService.cs +++ b/src/SentenceStudio.AppLib/Services/ScenarioService.cs @@ -303,71 +303,16 @@ public async Task SeedPredefinedScenariosAsync() _logger.LogInformation("Seeding predefined conversation scenarios"); - var predefinedScenarios = new[] - { - new ConversationScenario - { - Name = "First Meeting", - NameKorean = "첫 만남", - PersonaName = "김철수", - PersonaDescription = "a 25-year-old drama writer from Seoul", - SituationDescription = "Getting acquainted with a new person", - ConversationType = Shared.Models.ConversationType.OpenEnded, - QuestionBank = "몇 살이에요? 성함이 어떻게 되세요? 생일이 언제예요? 나이가 어떻게 되세요? 무슨 일해요? 어디에 살아요? 어릴 때 뭐가 되고 싶었어요? 취미가 뭐예요? 뭐 좋아해요? 취미가 어떻게 되세요? 왜 한국어 배워요? 오늘 뭐 먹었어요? 지난 주말에 뭐 했어요? 내일 뭐 할 거예요? 어느 나라 여행하고 싶어요? 한 주 동안 뭐 했어요? 한국에 가 봤어요? 한국에 가면 뭐 해 보고 싶어요?", - IsPredefined = true - }, - new ConversationScenario - { - Name = "Ordering Coffee", - NameKorean = "커피 주문", - PersonaName = "박지영", - PersonaDescription = "a friendly barista at a local cafe", - SituationDescription = "Ordering coffee and snacks at a Korean cafe", - ConversationType = Shared.Models.ConversationType.Finite, - QuestionBank = "", - IsPredefined = true - }, - new ConversationScenario - { - Name = "Ordering Dinner", - NameKorean = "저녁 식사 주문", - PersonaName = "이민호", - PersonaDescription = "a waiter at a Korean BBQ restaurant", - SituationDescription = "Ordering food at a Korean BBQ restaurant", - ConversationType = Shared.Models.ConversationType.Finite, - QuestionBank = "몇 분이세요? 뭐 드시겠어요? 고기는 어떤 거로 하시겠어요? 반찬 더 필요하세요? 음료는요? 디저트는요? 계산은 어떻게 하시겠어요?", - IsPredefined = true - }, - new ConversationScenario - { - Name = "Asking for Directions", - NameKorean = "길 찾기", - PersonaName = "최수진", - PersonaDescription = "a helpful stranger on the street", - SituationDescription = "Asking for directions to a destination", - ConversationType = Shared.Models.ConversationType.Finite, - QuestionBank = "어디 가세요? 이 근처 아세요? 지하철역이 어디예요? 버스 정류장이 어디예요? 얼마나 걸려요? 걸어서 갈 수 있어요?", - IsPredefined = true - }, - new ConversationScenario - { - Name = "Weekend Plans", - NameKorean = "주말 계획", - PersonaName = "김하나", - PersonaDescription = "a curious friend asking about your plans", - SituationDescription = "Discussing weekend activities and plans with a friend", - ConversationType = Shared.Models.ConversationType.OpenEnded, - QuestionBank = "주말에 뭐 해요? 어디 가요? 누구랑 가요? 뭐 먹을 거예요? 영화 볼 거예요? 쇼핑할 거예요? 집에서 쉴 거예요?", - IsPredefined = true - } - }; + // Source of truth: SentenceStudio.Data.ConversationScenarioSeedData.GetPredefinedScenarios() + // Shared with the API server-side seeder so the two never drift. + var predefinedScenarios = ConversationScenarioSeedData.GetPredefinedScenarios(); foreach (var scenario in predefinedScenarios) { await _repository.SaveAsync(scenario); } - _logger.LogInformation("Seeded {Count} predefined scenarios", predefinedScenarios.Length); + _logger.LogInformation("Seeded {Count} predefined scenarios", predefinedScenarios.Count); } #endregion diff --git a/src/SentenceStudio.Shared/Data/ConversationScenarioSeedData.cs b/src/SentenceStudio.Shared/Data/ConversationScenarioSeedData.cs new file mode 100644 index 00000000..ed58eebf --- /dev/null +++ b/src/SentenceStudio.Shared/Data/ConversationScenarioSeedData.cs @@ -0,0 +1,79 @@ +using SentenceStudio.Shared.Models; + +namespace SentenceStudio.Data; + +/// +/// Single source of truth for the predefined set. +/// Consumed by both the API (server-side seeding to Postgres on startup) and the +/// MAUI app (local SQLite seeding via ScenarioService.SeedPredefinedScenariosAsync). +/// Keep this list in sync — both seeders are idempotent and re-run safely. +/// +public static class ConversationScenarioSeedData +{ + /// + /// Returns fresh instances of every predefined scenario. + /// Returns new objects on each call so callers may mutate (e.g. set CreatedAt/UpdatedAt) + /// without cross-talk between seeders. + /// + public static IReadOnlyList GetPredefinedScenarios() + { + return new[] + { + new ConversationScenario + { + Name = "First Meeting", + NameKorean = "첫 만남", + PersonaName = "김철수", + PersonaDescription = "a 25-year-old drama writer from Seoul", + SituationDescription = "Getting acquainted with a new person", + ConversationType = ConversationType.OpenEnded, + QuestionBank = "몇 살이에요? 성함이 어떻게 되세요? 생일이 언제예요? 나이가 어떻게 되세요? 무슨 일해요? 어디에 살아요? 어릴 때 뭐가 되고 싶었어요? 취미가 뭐예요? 뭐 좋아해요? 취미가 어떻게 되세요? 왜 한국어 배워요? 오늘 뭐 먹었어요? 지난 주말에 뭐 했어요? 내일 뭐 할 거예요? 어느 나라 여행하고 싶어요? 한 주 동안 뭐 했어요? 한국에 가 봤어요? 한국에 가면 뭐 해 보고 싶어요?", + IsPredefined = true + }, + new ConversationScenario + { + Name = "Ordering Coffee", + NameKorean = "커피 주문", + PersonaName = "박지영", + PersonaDescription = "a friendly barista at a local cafe", + SituationDescription = "Ordering coffee and snacks at a Korean cafe", + ConversationType = ConversationType.Finite, + QuestionBank = "", + IsPredefined = true + }, + new ConversationScenario + { + Name = "Ordering Dinner", + NameKorean = "저녁 식사 주문", + PersonaName = "이민호", + PersonaDescription = "a waiter at a Korean BBQ restaurant", + SituationDescription = "Ordering food at a Korean BBQ restaurant", + ConversationType = ConversationType.Finite, + QuestionBank = "몇 분이세요? 뭐 드시겠어요? 고기는 어떤 거로 하시겠어요? 반찬 더 필요하세요? 음료는요? 디저트는요? 계산은 어떻게 하시겠어요?", + IsPredefined = true + }, + new ConversationScenario + { + Name = "Asking for Directions", + NameKorean = "길 찾기", + PersonaName = "최수진", + PersonaDescription = "a helpful stranger on the street", + SituationDescription = "Asking for directions to a destination", + ConversationType = ConversationType.Finite, + QuestionBank = "어디 가세요? 이 근처 아세요? 지하철역이 어디예요? 버스 정류장이 어디예요? 얼마나 걸려요? 걸어서 갈 수 있어요?", + IsPredefined = true + }, + new ConversationScenario + { + Name = "Weekend Plans", + NameKorean = "주말 계획", + PersonaName = "김하나", + PersonaDescription = "a curious friend asking about your plans", + SituationDescription = "Discussing weekend activities and plans with a friend", + ConversationType = ConversationType.OpenEnded, + QuestionBank = "주말에 뭐 해요? 어디 가요? 누구랑 가요? 뭐 먹을 거예요? 영화 볼 거예요? 쇼핑할 거예요? 집에서 쉴 거예요?", + IsPredefined = true + } + }; + } +} diff --git a/tests/SentenceStudio.Api.Tests/ConversationScenarioSeederTests.cs b/tests/SentenceStudio.Api.Tests/ConversationScenarioSeederTests.cs new file mode 100644 index 00000000..122b1849 --- /dev/null +++ b/tests/SentenceStudio.Api.Tests/ConversationScenarioSeederTests.cs @@ -0,0 +1,159 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using SentenceStudio.Api.Conversation; +using SentenceStudio.Data; +using SentenceStudio.Shared.Models; + +namespace SentenceStudio.Api.Tests; + +public class ConversationScenarioSeederTests : IDisposable +{ + private readonly ApplicationDbContext _db; + + public ConversationScenarioSeederTests() + { + var options = new DbContextOptionsBuilder() + .UseSqlite("DataSource=:memory:") + .Options; + _db = new ApplicationDbContext(options); + _db.Database.OpenConnection(); + _db.Database.EnsureCreated(); + } + + public void Dispose() + { + _db.Database.CloseConnection(); + _db.Dispose(); + } + + private ConversationScenarioSeeder CreateSeeder() => + new(_db, NullLogger.Instance); + + [Fact] + public async Task SeedAsync_EmptyDatabase_InsertsAllPredefinedScenarios() + { + var expected = ConversationScenarioSeedData.GetPredefinedScenarios(); + + await CreateSeeder().SeedAsync(); + + var stored = await _db.ConversationScenarios.AsNoTracking().ToListAsync(); + Assert.Equal(expected.Count, stored.Count); + Assert.All(stored, s => Assert.True(s.IsPredefined)); + foreach (var seed in expected) + { + Assert.Contains(stored, s => s.Name == seed.Name && s.PersonaName == seed.PersonaName); + } + } + + [Fact] + public async Task SeedAsync_RunTwice_IsIdempotent_NoDuplicates() + { + await CreateSeeder().SeedAsync(); + var firstCount = await _db.ConversationScenarios.CountAsync(); + + // Detach so the second seeder reads fresh from DB + foreach (var entry in _db.ChangeTracker.Entries().ToList()) + entry.State = EntityState.Detached; + + await CreateSeeder().SeedAsync(); + + var secondCount = await _db.ConversationScenarios.CountAsync(); + Assert.Equal(firstCount, secondCount); + Assert.Equal(ConversationScenarioSeedData.GetPredefinedScenarios().Count, secondCount); + } + + [Fact] + public async Task SeedAsync_ExistingPredefinedWithStaleData_UpdatesInPlace() + { + var first = ConversationScenarioSeedData.GetPredefinedScenarios().First(); + + var stale = new ConversationScenario + { + Name = first.Name, + NameKorean = first.NameKorean, + PersonaName = "STALE", + PersonaDescription = "stale description", + SituationDescription = "stale situation", + ConversationType = first.ConversationType, + QuestionBank = "stale", + IsPredefined = true, + CreatedAt = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc), + UpdatedAt = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc) + }; + _db.ConversationScenarios.Add(stale); + await _db.SaveChangesAsync(); + var staleId = stale.Id; + + foreach (var entry in _db.ChangeTracker.Entries().ToList()) + entry.State = EntityState.Detached; + + await CreateSeeder().SeedAsync(); + + var refreshed = await _db.ConversationScenarios.AsNoTracking().FirstAsync(s => s.Id == staleId); + Assert.Equal(first.PersonaName, refreshed.PersonaName); + Assert.Equal(first.PersonaDescription, refreshed.PersonaDescription); + Assert.Equal(first.SituationDescription, refreshed.SituationDescription); + Assert.Equal(first.QuestionBank, refreshed.QuestionBank); + Assert.True(refreshed.UpdatedAt > new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + + var total = await _db.ConversationScenarios.CountAsync(); + Assert.Equal(ConversationScenarioSeedData.GetPredefinedScenarios().Count, total); + } + + [Fact] + public async Task SeedAsync_PartialState_FillsInMissingScenarios() + { + var seedList = ConversationScenarioSeedData.GetPredefinedScenarios(); + var existing = seedList.First(); + _db.ConversationScenarios.Add(new ConversationScenario + { + Name = existing.Name, + NameKorean = existing.NameKorean, + PersonaName = existing.PersonaName, + PersonaDescription = existing.PersonaDescription, + SituationDescription = existing.SituationDescription, + ConversationType = existing.ConversationType, + QuestionBank = existing.QuestionBank, + IsPredefined = true + }); + await _db.SaveChangesAsync(); + + foreach (var entry in _db.ChangeTracker.Entries().ToList()) + entry.State = EntityState.Detached; + + await CreateSeeder().SeedAsync(); + + var total = await _db.ConversationScenarios.CountAsync(); + Assert.Equal(seedList.Count, total); + } + + [Fact] + public async Task SeedAsync_DoesNotTouchUserCreatedScenarios() + { + _db.ConversationScenarios.Add(new ConversationScenario + { + Name = "My Custom Scenario", + PersonaName = "Custom", + PersonaDescription = "custom", + SituationDescription = "custom", + ConversationType = ConversationType.OpenEnded, + IsPredefined = false + }); + await _db.SaveChangesAsync(); + + foreach (var entry in _db.ChangeTracker.Entries().ToList()) + entry.State = EntityState.Detached; + + await CreateSeeder().SeedAsync(); + + var userCreated = await _db.ConversationScenarios + .AsNoTracking() + .Where(s => !s.IsPredefined) + .ToListAsync(); + Assert.Single(userCreated); + Assert.Equal("My Custom Scenario", userCreated[0].Name); + + var predefined = await _db.ConversationScenarios.CountAsync(s => s.IsPredefined); + Assert.Equal(ConversationScenarioSeedData.GetPredefinedScenarios().Count, predefined); + } +} From d4844e642cb0faff9d09f7b2b28a23993b77229c Mon Sep 17 00:00:00 2001 From: David Ortinau Date: Wed, 20 May 2026 09:32:30 -0500 Subject: [PATCH 3/6] fix(api): wrap ConversationScenarioSeeder tx in execution strategy Npgsql's retrying execution strategy disallows user-initiated transactions unless they run inside IExecutionStrategy.ExecuteAsync. Production deploy crashed the API container on startup with: InvalidOperationException: The configured execution strategy 'NpgsqlRetryingExecutionStrategy' does not support user-initiated transactions. Wrap the BeginTransaction + pg_advisory_xact_lock + SeedCoreAsync sequence in a strategy delegate so it's retriable as a unit. SeedCoreAsync now also clears the change tracker at entry so a retry runs against a clean state. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ConversationScenarioSeeder.cs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/SentenceStudio.Api/Conversation/ConversationScenarioSeeder.cs b/src/SentenceStudio.Api/Conversation/ConversationScenarioSeeder.cs index 0202129e..9d025a54 100644 --- a/src/SentenceStudio.Api/Conversation/ConversationScenarioSeeder.cs +++ b/src/SentenceStudio.Api/Conversation/ConversationScenarioSeeder.cs @@ -31,10 +31,17 @@ public async Task SeedAsync(CancellationToken ct = default) var isPostgres = _db.Database.IsNpgsql(); if (isPostgres) { - await using var tx = await _db.Database.BeginTransactionAsync(ct); - await _db.Database.ExecuteSqlRawAsync("SELECT pg_advisory_xact_lock(42424242);", ct); - await SeedCoreAsync(ct); - await tx.CommitAsync(ct); + // Npgsql retry strategy requires user-initiated transactions to run inside + // an IExecutionStrategy.ExecuteAsync block. The strategy may retry the whole + // delegate on transient failure — SeedCoreAsync is idempotent so that's safe. + var strategy = _db.Database.CreateExecutionStrategy(); + await strategy.ExecuteAsync(async cancel => + { + await using var tx = await _db.Database.BeginTransactionAsync(cancel); + await _db.Database.ExecuteSqlRawAsync("SELECT pg_advisory_xact_lock(42424242);", cancel); + await SeedCoreAsync(cancel); + await tx.CommitAsync(cancel); + }, ct); return; } @@ -43,6 +50,10 @@ public async Task SeedAsync(CancellationToken ct = default) private async Task SeedCoreAsync(CancellationToken ct) { + // Reset the change tracker so this method is safe to invoke multiple times + // (the Npgsql retry strategy may re-execute the delegate on transient failure). + _db.ChangeTracker.Clear(); + var seedData = ConversationScenarioSeedData.GetPredefinedScenarios(); // Load existing predefined rows once; match on Name (case-sensitive — seed names are canonical English). From 65cc8af6c469eb102f20a054e4abdc4891c5dce4 Mon Sep 17 00:00:00 2001 From: David Ortinau Date: Wed, 20 May 2026 20:24:38 -0500 Subject: [PATCH 4/6] fix(api): use writable temp dir for ApiFileSystemService.AppDataDirectory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production /api/v1/plans/today (and other plans endpoints) returned 500 with: UnauthorizedAccessException: Access to the path '/app/sentencestudio' is denied ---> IOException: Permission denied at ApiFileSystemService..ctor(String appDataDirectory) at TenantContextMiddleware.InvokeAsync(...) ACA's /app filesystem is read-only for the runtime user, so Environment.SpecialFolder.LocalApplicationData (→ /app/sentencestudio) threw on Directory.CreateDirectory. The singleton constructs lazily on first transitive resolution; plans pulls it in via PlanService → LearningResourceRepository, so the first plans request blew the ctor. Conversation doesn't depend on it transitively, which is why that endpoint worked. The API doesn't actually persist anything in AppDataDirectory — the dependency is structural (the abstraction exists for MAUI). Default to Path.GetTempPath() (/tmp in ACA, always writable), with an AppDataDirectory config override for ops flexibility. Also harden ApiFileSystemService.ctor to swallow UnauthorizedAccessException and IOException on the CreateDirectory call so DI resolution can never crash the request pipeline if a future deployment lands on a read-only mount. Credit: diagnosis from SentenceStudioFlutter session (Azure Log Analytics trace + repro). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Platform/ApiFileSystemService.cs | 16 +++++++++++++++- src/SentenceStudio.Api/Program.cs | 10 ++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/SentenceStudio.Api/Platform/ApiFileSystemService.cs b/src/SentenceStudio.Api/Platform/ApiFileSystemService.cs index 11e148ee..728eb4b3 100644 --- a/src/SentenceStudio.Api/Platform/ApiFileSystemService.cs +++ b/src/SentenceStudio.Api/Platform/ApiFileSystemService.cs @@ -7,7 +7,21 @@ public sealed class ApiFileSystemService : IFileSystemService public ApiFileSystemService(string appDataDirectory) { AppDataDirectory = appDataDirectory; - Directory.CreateDirectory(AppDataDirectory); + try + { + Directory.CreateDirectory(AppDataDirectory); + } + catch (UnauthorizedAccessException) + { + // Read-only filesystem (e.g. ACA /app). The API doesn't actually write to this + // path — the dependency is structural via shared services. Swallow so the DI + // graph can resolve; downstream writes will surface their own errors if they + // ever happen, but in practice the API never writes here. + } + catch (IOException) + { + // Same rationale — permission/EROFS wrapped as IOException on some platforms. + } } public string AppDataDirectory { get; } diff --git a/src/SentenceStudio.Api/Program.cs b/src/SentenceStudio.Api/Program.cs index decdaaab..8b32fc99 100644 --- a/src/SentenceStudio.Api/Program.cs +++ b/src/SentenceStudio.Api/Program.cs @@ -144,8 +144,14 @@ builder.Services.AddScoped(); -// Platform abstractions for API server -var appDataDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "sentencestudio", "api"); +// Platform abstractions for API server. +// AppDataDirectory: ACA's /app filesystem is read-only for the runtime user, so resolving +// Environment.SpecialFolder.LocalApplicationData (→ /app/sentencestudio) throws on +// Directory.CreateDirectory. The API doesn't persist anything here in practice (the +// dependency is structural — pulled in transitively by MAUI-oriented services). Default +// to a writable temp path, allow override via the AppDataDirectory config key. +var appDataDirectory = builder.Configuration["AppDataDirectory"] + ?? Path.Combine(Path.GetTempPath(), "sentencestudio", "api"); builder.Services.AddSingleton(); builder.Services.AddSingleton(_ => new ApiFileSystemService(appDataDirectory)); From d00db8b75ae8f309a8f3f0467472e423535abdbe Mon Sep 17 00:00:00 2001 From: David Ortinau Date: Thu, 21 May 2026 08:03:40 -0500 Subject: [PATCH 5/6] feat(api): activity log HTTP endpoint for Flutter client Implements GET /api/v1/activity-log per Flutter handoff spec (activity-log-api-spec.md). Extracts the per-user activity rollup logic out of ProgressService into a new IActivityLogService so the API and MAUI share one code path; ProgressService.GetActivityLogAsync now delegates to it. Endpoint contract: - Auth: Bearer JWT required (401 otherwise). - Query: fromUtc, toUtc (required, ISO-8601), filter=All|Input|Output (optional). - Validation: 400 on missing dates, fromUtc>toUtc, range >90 days, bad filter. - Body: [] when no activity; otherwise Monday-anchored 7-day weeks newest-first with camelCase keys (weekStart, weekEnd, hasActivity, inputMinutes, outputMinutes, completedAtUtc, etc.) and PascalCase enum values (Input/Output, Reading/Writing/...). - Ad-hoc plans (PlanItemId prefixed "adhoc-") surface as isAdhoc=true with displayName="Ad-hoc"; generated plans within a day are numbered "Plan 1", "Plan 2", ... Registers IActivityLogService scoped on the API side (consumes the request-scoped IUserScopeProvider via HttpUserScopeProvider) and singleton on the MAUI side (DeviceUserScopeProvider is a singleton). Tests: 10 new endpoint tests cover auth, validation, empty result, rollup wire shape, ad-hoc labeling, filter behavior, and per-user isolation. Full API suite 197/198 (the 1 pre-existing PlansEndpoint failure is unrelated and was red on main before this work). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Activity/ActivityLogEndpoints.cs | 181 +++++++++++++ src/SentenceStudio.Api/Program.cs | 10 + .../Services/CoreServiceExtensions.cs | 1 + .../Activity/ActivityLogDto.cs | 96 +++++++ .../Services/Progress/ActivityLogService.cs | 254 ++++++++++++++++++ .../Services/Progress/ProgressService.cs | 237 +--------------- .../ActivityLogEndpointsTests.cs | 251 +++++++++++++++++ .../Infrastructure/ActivityLogApiFactory.cs | 56 ++++ 8 files changed, 854 insertions(+), 232 deletions(-) create mode 100644 src/SentenceStudio.Api/Activity/ActivityLogEndpoints.cs create mode 100644 src/SentenceStudio.Contracts/Activity/ActivityLogDto.cs create mode 100644 src/SentenceStudio.Shared/Services/Progress/ActivityLogService.cs create mode 100644 tests/SentenceStudio.Api.Tests/ActivityLogEndpointsTests.cs create mode 100644 tests/SentenceStudio.Api.Tests/Infrastructure/ActivityLogApiFactory.cs diff --git a/src/SentenceStudio.Api/Activity/ActivityLogEndpoints.cs b/src/SentenceStudio.Api/Activity/ActivityLogEndpoints.cs new file mode 100644 index 00000000..157017db --- /dev/null +++ b/src/SentenceStudio.Api/Activity/ActivityLogEndpoints.cs @@ -0,0 +1,181 @@ +using System.Globalization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using SentenceStudio.Contracts.Activity; +using SentenceStudio.Services.Progress; + +namespace SentenceStudio.Api.Activity; + +/// +/// GET /api/v1/activity-log — read-only Activity Log endpoint serving +/// the Flutter client. Wraps and maps the +/// rich internal week/day/plan/entry records to the wire DTOs documented in +/// specs/activity-log-api-spec.md. No new business logic — clustering, +/// week rollup, and category mapping all happen in the shared service. +/// +public static class ActivityLogEndpoints +{ + /// Maximum days the server will service in a single call. The Flutter + /// client requests at most ~56 days (8 weeks) up front, 4-week pages thereafter, + /// so 90 days is a comfortable ceiling. + public const int MaxRangeDays = 90; + + public static IEndpointRouteBuilder MapActivityLog(this IEndpointRouteBuilder app) + { + app.MapGet("/api/v1/activity-log", GetActivityLogAsync) + .WithName("GetActivityLog") + .RequireAuthorization(); + return app; + } + + private static async Task GetActivityLogAsync( + DateTime? fromUtc, + DateTime? toUtc, + string? filter, + IActivityLogService activityLogService, + ILoggerFactory loggerFactory, + CancellationToken ct) + { + var logger = loggerFactory.CreateLogger("ActivityLog"); + + if (fromUtc is null || toUtc is null) + { + return Results.Problem( + title: "Missing date range", + detail: "Both 'fromUtc' and 'toUtc' query parameters are required (ISO 8601 UTC).", + statusCode: StatusCodes.Status400BadRequest); + } + + if (fromUtc > toUtc) + { + return Results.Problem( + title: "Invalid date range", + detail: "'fromUtc' must be on or before 'toUtc'.", + statusCode: StatusCodes.Status400BadRequest); + } + + if ((toUtc.Value.Date - fromUtc.Value.Date).TotalDays > MaxRangeDays) + { + return Results.Problem( + title: "Date range too large", + detail: $"Range cannot exceed {MaxRangeDays} days. Paginate by passing narrower fromUtc/toUtc windows.", + statusCode: StatusCodes.Status400BadRequest); + } + + ActivityCategory? categoryFilter = null; + if (!string.IsNullOrEmpty(filter)) + { + if (!Enum.TryParse(filter, ignoreCase: true, out var parsed)) + { + return Results.Problem( + title: "Invalid filter", + detail: "'filter' must be 'Input', 'Output', or omitted.", + statusCode: StatusCodes.Status400BadRequest); + } + categoryFilter = parsed; + } + + try + { + var weeks = await activityLogService.GetActivityLogAsync( + fromUtc.Value, toUtc.Value, categoryFilter, ct); + var wire = weeks.Select(MapWeek).ToList(); + return Results.Ok(wire); + } + catch (UnauthorizedAccessException) + { + return Results.Unauthorized(); + } + catch (Exception ex) + { + logger.LogError(ex, "GET /api/v1/activity-log failed"); + return Results.Problem( + detail: "Failed to load activity log.", + statusCode: StatusCodes.Status500InternalServerError); + } + } + + internal static ActivityLogWeekDto MapWeek(ActivityLogWeek week) + { + // Weeks: WeekEnd in the service model is the Monday-anchored start + 6 + // days (i.e. Sunday at 00:00). The spec asks for Sunday 23:59:59 UTC so + // the half-open interval reads naturally on the client. + var weekEnd = week.WeekEnd.Date.AddDays(1).AddSeconds(-1); + + return new ActivityLogWeekDto + { + WeekStart = DateTime.SpecifyKind(week.WeekStart.Date, DateTimeKind.Utc), + WeekEnd = DateTime.SpecifyKind(weekEnd, DateTimeKind.Utc), + Days = week.Days.Select(MapDay).ToList(), + }; + } + + internal static ActivityLogDayDto MapDay(ActivityLogDay day) + { + // Number generated plans within the day for display labels. Ad-hoc + // plans are labeled separately. + var generatedIdx = 0; + var plans = new List(day.Plans.Count); + foreach (var plan in day.Plans) + { + var isAdhoc = plan.Items.Count > 0 && IsAdhocId(plan.Items[0].PlanItemId); + var displayName = isAdhoc + ? "Ad-hoc" + : $"Plan {++generatedIdx}"; + + plans.Add(new ActivityLogPlanDto + { + IsAdhoc = isAdhoc, + PlanItemId = plan.Items.FirstOrDefault()?.PlanItemId ?? string.Empty, + DisplayName = displayName, + TotalMinutes = plan.TotalMinutesSpent, + Completed = plan.IsFullyCompleted, + Entries = plan.Items + .OrderBy(e => e.CompletedAt ?? DateTime.MaxValue) + .Select(MapEntry) + .ToList(), + }); + } + + // Per-day input/output minute totals (the service computes these at the + // week level but not the day level, so derive from entries). + var inputMinutes = day.Plans.Sum(p => p.Items + .Where(i => i.Category == ActivityCategory.Input) + .Sum(i => i.MinutesSpent)); + var outputMinutes = day.Plans.Sum(p => p.Items + .Where(i => i.Category == ActivityCategory.Output) + .Sum(i => i.MinutesSpent)); + + return new ActivityLogDayDto + { + Date = day.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + HasActivity = day.Plans.Count > 0, + InputMinutes = inputMinutes, + OutputMinutes = outputMinutes, + TotalMinutes = day.TotalMinutes, + AllPlansCompleted = day.AllPlansCompleted, + Plans = plans, + }; + } + + internal static ActivityLogEntryDto MapEntry(ActivityLogEntry entry) + { + var completedAt = entry.CompletedAt is { } ts + ? DateTime.SpecifyKind(ts, DateTimeKind.Utc) + : (DateTime?)null; + return new ActivityLogEntryDto + { + ActivityType = entry.ActivityType.ToString(), + Category = entry.Category.ToString(), + MinutesSpent = entry.MinutesSpent, + CompletedAtUtc = completedAt, + }; + } + + private static bool IsAdhocId(string planItemId) + => !string.IsNullOrEmpty(planItemId) + && planItemId.StartsWith("adhoc-", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/SentenceStudio.Api/Program.cs b/src/SentenceStudio.Api/Program.cs index 8b32fc99..f0b10ed9 100644 --- a/src/SentenceStudio.Api/Program.cs +++ b/src/SentenceStudio.Api/Program.cs @@ -23,6 +23,7 @@ using SentenceStudio; using SentenceStudio.Abstractions; using SentenceStudio.Api; +using SentenceStudio.Api.Activity; using SentenceStudio.Api.Auth; using SentenceStudio.Api.Diagnostics; using SentenceStudio.Api.Platform; @@ -38,6 +39,7 @@ using SentenceStudio.Infrastructure; using SentenceStudio.Services; using SentenceStudio.Services.LanguageSegmentation; +using SentenceStudio.Services.Progress; using SentenceStudio.Shared.Models; var builder = WebApplication.CreateBuilder(args); @@ -304,6 +306,13 @@ // 503 when it isn't (parity with /api/v1/ai/chat). builder.Services.AddSingleton(); +// Activity Log — Flutter client read endpoint (activity-log-api-spec.md). +// Extracted from ProgressService so the API doesn't need the full progress +// graph (LLM plan generation, vocabulary services, sync). MAUI continues to +// resolve IProgressService which delegates Activity Log work here. +// Scoped because it consumes the request-scoped IUserScopeProvider. +builder.Services.AddScoped(); + // Vocabulary progress services builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -520,6 +529,7 @@ await context.Response.WriteAsync(""" // plan.md. Replaces the legacy /api/v1/plans/generate stub below (kept // for backward compat during the MAUI Blazor v2 flip). app.MapPlans(); +app.MapActivityLog(); // YouTube channel monitoring endpoints app.MapChannelEndpoints(); diff --git a/src/SentenceStudio.AppLib/Services/CoreServiceExtensions.cs b/src/SentenceStudio.AppLib/Services/CoreServiceExtensions.cs index abb15fb4..fd64ad30 100644 --- a/src/SentenceStudio.AppLib/Services/CoreServiceExtensions.cs +++ b/src/SentenceStudio.AppLib/Services/CoreServiceExtensions.cs @@ -105,6 +105,7 @@ public static IServiceCollection AddSentenceStudioCoreServices(this IServiceColl // Progress & timing services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); // Plan generation — use local DeterministicPlanBuilder for rich narratives diff --git a/src/SentenceStudio.Contracts/Activity/ActivityLogDto.cs b/src/SentenceStudio.Contracts/Activity/ActivityLogDto.cs new file mode 100644 index 00000000..01b1f81f --- /dev/null +++ b/src/SentenceStudio.Contracts/Activity/ActivityLogDto.cs @@ -0,0 +1,96 @@ +using System.Text.Json.Serialization; + +namespace SentenceStudio.Contracts.Activity; + +/// +/// Wire contract for GET /api/v1/activity-log. Mirrors the shape the +/// Flutter client builds its UI around (see activity-log-api-spec.md). The +/// server maps from SentenceStudio.Services.Progress.ActivityLogWeek +/// to this DTO so the wire contract stays explicit and stable even as the +/// internal model evolves. +/// +public sealed record ActivityLogWeekDto +{ + [JsonPropertyName("weekStart")] + public DateTime WeekStart { get; init; } + + [JsonPropertyName("weekEnd")] + public DateTime WeekEnd { get; init; } + + [JsonPropertyName("days")] + public required IReadOnlyList Days { get; init; } +} + +public sealed record ActivityLogDayDto +{ + /// Calendar date as yyyy-MM-dd. No time component — Flutter + /// treats this as the user's local day. + [JsonPropertyName("date")] + public required string Date { get; init; } + + [JsonPropertyName("hasActivity")] + public bool HasActivity { get; init; } + + [JsonPropertyName("inputMinutes")] + public int InputMinutes { get; init; } + + [JsonPropertyName("outputMinutes")] + public int OutputMinutes { get; init; } + + [JsonPropertyName("totalMinutes")] + public int TotalMinutes { get; init; } + + [JsonPropertyName("allPlansCompleted")] + public bool AllPlansCompleted { get; init; } + + [JsonPropertyName("plans")] + public required IReadOnlyList Plans { get; init; } +} + +public sealed record ActivityLogPlanDto +{ + [JsonPropertyName("isAdhoc")] + public bool IsAdhoc { get; init; } + + /// Stable identifier for the plan grouping. For ad-hoc plans this + /// is the first entry's PlanItemId (already prefixed adhoc-). For + /// generated plans it is the first entry's PlanItemId in the cluster. + [JsonPropertyName("planItemId")] + public string PlanItemId { get; init; } = string.Empty; + + /// Human-readable label for the plan grouping. Generated plans are + /// labeled Plan N ordered chronologically within the day. Ad-hoc + /// plans are labeled Ad-hoc. + [JsonPropertyName("displayName")] + public required string DisplayName { get; init; } + + [JsonPropertyName("totalMinutes")] + public int TotalMinutes { get; init; } + + [JsonPropertyName("completed")] + public bool Completed { get; init; } + + [JsonPropertyName("entries")] + public required IReadOnlyList Entries { get; init; } +} + +public sealed record ActivityLogEntryDto +{ + /// PascalCase enum name — e.g. Reading, Writing, + /// VocabularyReview. Matches the PlanActivityType enum. + [JsonPropertyName("activityType")] + public required string ActivityType { get; init; } + + /// Either "Input" or "Output" — PascalCase, matching + /// ActivityCategory. + [JsonPropertyName("category")] + public required string Category { get; init; } + + [JsonPropertyName("minutesSpent")] + public int MinutesSpent { get; init; } + + /// ISO 8601 UTC timestamp with Z suffix. Null when the + /// activity is in-progress (i.e. IsCompleted=false). + [JsonPropertyName("completedAtUtc")] + public DateTime? CompletedAtUtc { get; init; } +} diff --git a/src/SentenceStudio.Shared/Services/Progress/ActivityLogService.cs b/src/SentenceStudio.Shared/Services/Progress/ActivityLogService.cs new file mode 100644 index 00000000..09c5c7e4 --- /dev/null +++ b/src/SentenceStudio.Shared/Services/Progress/ActivityLogService.cs @@ -0,0 +1,254 @@ +using SentenceStudio.Data; +using SentenceStudio.Services.Plans; +using SentenceStudio.Shared.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace SentenceStudio.Services.Progress; + +/// +/// Computes the per-week Activity Log rollup from DailyPlanCompletions. +/// Extracted from so the API can serve it +/// without depending on the full progress graph (LLM plan generator, +/// vocabulary services, sync, etc.). MAUI's +/// delegates here to keep a single source of truth for the rollup logic. +/// +public interface IActivityLogService +{ + Task> GetActivityLogAsync( + DateTime fromUtc, + DateTime toUtc, + ActivityCategory? filter = null, + CancellationToken ct = default); +} + +public sealed class ActivityLogService : IActivityLogService +{ + private readonly LearningResourceRepository _resourceRepo; + private readonly SkillProfileRepository _skillRepo; + private readonly IUserScopeProvider _userScope; + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public ActivityLogService( + LearningResourceRepository resourceRepo, + SkillProfileRepository skillRepo, + IUserScopeProvider userScope, + IServiceProvider serviceProvider, + ILogger logger) + { + _resourceRepo = resourceRepo; + _skillRepo = skillRepo; + _userScope = userScope; + _serviceProvider = serviceProvider; + _logger = logger; + } + + public async Task> GetActivityLogAsync( + DateTime fromUtc, + DateTime toUtc, + ActivityCategory? filter = null, + CancellationToken ct = default) + { + _logger.LogDebug("📅 GetActivityLogAsync — from={From:yyyy-MM-dd}, to={To:yyyy-MM-dd}, filter={Filter}", fromUtc, toUtc, filter); + + if (!_userScope.TryGetUserProfileId(out var userProfileId) || string.IsNullOrEmpty(userProfileId)) + { + _logger.LogWarning("❌ No active user profile — cannot get activity log"); + return new List(); + } + + using var scope = _serviceProvider.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var completions = await db.DailyPlanCompletions + .Where(c => c.UserProfileId == userProfileId && c.Date >= fromUtc.Date && c.Date <= toUtc.Date) + .OrderBy(c => c.Date) + .ThenBy(c => c.CreatedAt) + .ToListAsync(ct); + + if (!completions.Any()) + { + _logger.LogDebug("ℹ️ No completions found in date range"); + return new List(); + } + + var resources = await _resourceRepo.GetAllResourcesLightweightAsync(); + var resourceLookup = resources + .GroupBy(r => r.Id) + .ToDictionary(g => g.Key, g => g.First().Title ?? string.Empty); + + var skills = await _skillRepo.ListAsync(); + var skillLookup = skills + .GroupBy(s => s.Id) + .ToDictionary(g => g.Key, g => g.First().Title ?? string.Empty); + + var dayGroups = completions + .GroupBy(c => c.Date.Date) + .OrderBy(g => g.Key) + .ToList(); + + var activityLogDays = new List(); + + foreach (var dayGroup in dayGroups) + { + var date = dayGroup.Key; + var dayCompletions = dayGroup.OrderBy(c => c.CreatedAt).ToList(); + + // Sub-group by plan generation (cluster items within 60 seconds of each other) + var plans = new List(); + if (dayCompletions.Any()) + { + var currentPlan = new List { dayCompletions[0] }; + for (int i = 1; i < dayCompletions.Count; i++) + { + if ((dayCompletions[i].CreatedAt - dayCompletions[i - 1].CreatedAt).TotalSeconds <= 60) + currentPlan.Add(dayCompletions[i]); + else + { + plans.Add(BuildActivityLogPlan(currentPlan, resourceLookup, skillLookup, filter)); + currentPlan = new List { dayCompletions[i] }; + } + } + plans.Add(BuildActivityLogPlan(currentPlan, resourceLookup, skillLookup, filter)); + } + + // Drop plans whose entries were entirely filtered out + plans = plans.Where(p => p.Items.Any()).ToList(); + if (!plans.Any()) + continue; + + var totalMinutes = plans.Sum(p => p.TotalMinutesSpent); + var hasInput = plans.Any(p => p.Items.Any(i => i.Category == ActivityCategory.Input)); + var hasOutput = plans.Any(p => p.Items.Any(i => i.Category == ActivityCategory.Output)); + var allPlansCompleted = plans.All(p => p.IsFullyCompleted); + + activityLogDays.Add(new ActivityLogDay( + Date: date, + Plans: plans, + TotalMinutes: totalMinutes, + HasInput: hasInput, + HasOutput: hasOutput, + AllPlansCompleted: allPlansCompleted)); + } + + var weeks = BuildWeeks(fromUtc.Date, toUtc.Date, activityLogDays); + + _logger.LogDebug("✅ Built {WeekCount} weeks with {DayCount} active days", weeks.Count, activityLogDays.Count); + + return weeks; + } + + private ActivityLogPlan BuildActivityLogPlan( + List completions, + Dictionary resourceLookup, + Dictionary skillLookup, + ActivityCategory? filter) + { + var entries = new List(); + + foreach (var completion in completions) + { + if (!Enum.TryParse(completion.ActivityType, out var actType)) + { + _logger.LogWarning("⚠️ Failed to parse ActivityType '{ActivityType}' for completion {Id}", completion.ActivityType, completion.Id); + continue; + } + + var category = ActivityCategoryMapper.Categorize(actType); + if (filter.HasValue && category != filter.Value) + continue; + + var resourceTitle = !string.IsNullOrEmpty(completion.ResourceId) && resourceLookup.TryGetValue(completion.ResourceId, out var title) + ? title : null; + var skillName = !string.IsNullOrEmpty(completion.SkillId) && skillLookup.TryGetValue(completion.SkillId, out var skill) + ? skill : null; + + entries.Add(new ActivityLogEntry( + PlanItemId: completion.PlanItemId, + ActivityType: actType, + Category: category, + MinutesSpent: completion.MinutesSpent, + EstimatedMinutes: completion.EstimatedMinutes, + IsCompleted: completion.IsCompleted, + CompletedAt: completion.CompletedAt, + ResourceTitle: resourceTitle, + SkillName: skillName, + TitleKey: completion.TitleKey, + DescriptionKey: completion.DescriptionKey)); + } + + var generatedAt = completions.FirstOrDefault()?.CreatedAt ?? DateTime.UtcNow; +#pragma warning disable CS0618 // Rationale/NarrativeJson on completion are deprecated but still readable. + var rationale = completions.FirstOrDefault()?.Rationale; + var narrativeJson = completions.FirstOrDefault()?.NarrativeJson; +#pragma warning restore CS0618 + + return new ActivityLogPlan( + GeneratedAt: generatedAt, + Items: entries, + CompletedCount: entries.Count(e => e.IsCompleted), + TotalCount: entries.Count, + IsFullyCompleted: entries.Any() && entries.All(e => e.IsCompleted), + TotalMinutesSpent: entries.Sum(e => e.MinutesSpent), + TotalEstimatedMinutes: entries.Sum(e => e.EstimatedMinutes), + Rationale: rationale, + NarrativeJson: narrativeJson); + } + + private static List BuildWeeks(DateTime fromDate, DateTime toDate, List activityLogDays) + { + var dayLookup = activityLogDays.ToDictionary(d => d.Date.Date, d => d); + var weeks = new List(); + var current = fromDate.Date; + + // Anchor to Monday + while (current.DayOfWeek != DayOfWeek.Monday && current <= toDate) + current = current.AddDays(-1); + + while (current <= toDate) + { + var weekStart = current; + var weekEnd = weekStart.AddDays(6); + + var days = new ActivityLogDay[7]; + for (int i = 0; i < 7; i++) + { + var date = weekStart.AddDays(i); + days[i] = dayLookup.TryGetValue(date, out var day) + ? day + : new ActivityLogDay( + Date: date, + Plans: new List(), + TotalMinutes: 0, + HasInput: false, + HasOutput: false, + AllPlansCompleted: false); + } + + var totalMinutes = days.Sum(d => d.TotalMinutes); + var inputMinutes = days.Sum(d => d.Plans.Sum(p => p.Items.Where(i => i.Category == ActivityCategory.Input).Sum(i => i.MinutesSpent))); + var outputMinutes = days.Sum(d => d.Plans.Sum(p => p.Items.Where(i => i.Category == ActivityCategory.Output).Sum(i => i.MinutesSpent))); + var activityCount = days.Sum(d => d.Plans.Sum(p => p.Items.Count)); + var plansCompleted = days.Sum(d => d.Plans.Count(p => p.IsFullyCompleted)); + var plansTotal = days.Sum(d => d.Plans.Count); + + weeks.Add(new ActivityLogWeek( + WeekStart: weekStart, + WeekEnd: weekEnd, + Days: days, + TotalMinutes: totalMinutes, + InputMinutes: inputMinutes, + OutputMinutes: outputMinutes, + ActivityCount: activityCount, + PlansCompleted: plansCompleted, + PlansTotal: plansTotal)); + + current = weekEnd.AddDays(1); + } + + weeks.Reverse(); // newest first + return weeks; + } +} diff --git a/src/SentenceStudio.Shared/Services/Progress/ProgressService.cs b/src/SentenceStudio.Shared/Services/Progress/ProgressService.cs index 74e6ea72..cadf32b6 100644 --- a/src/SentenceStudio.Shared/Services/Progress/ProgressService.cs +++ b/src/SentenceStudio.Shared/Services/Progress/ProgressService.cs @@ -18,6 +18,7 @@ public class ProgressService : IProgressService private readonly ILogger _logger; private readonly UserProfileRepository _userProfileRepo; private readonly ISyncService? _syncService; + private readonly IActivityLogService _activityLogService; public ProgressService( LearningResourceRepository resourceRepo, @@ -30,6 +31,7 @@ public ProgressService( ILlmPlanGenerationService llmPlanService, ILogger logger, UserProfileRepository userProfileRepo, + IActivityLogService activityLogService, ISyncService? syncService = null) { _resourceRepo = resourceRepo; @@ -42,6 +44,7 @@ public ProgressService( _llmPlanService = llmPlanService; _logger = logger; _userProfileRepo = userProfileRepo; + _activityLogService = activityLogService; _syncService = syncService; } @@ -1284,236 +1287,6 @@ private string GeneratePlanItemId(DateTime date, PlanActivityType activityType, return guid.ToString(); } - public async Task> GetActivityLogAsync(DateTime fromUtc, DateTime toUtc, ActivityCategory? filter = null, CancellationToken ct = default) - { - _logger.LogDebug("📅 GetActivityLogAsync - from={From:yyyy-MM-dd}, to={To:yyyy-MM-dd}, filter={Filter}", fromUtc, toUtc, filter); - - // Get user profile - var userProfile = await _userProfileRepo.GetAsync(); - if (userProfile == null) - { - _logger.LogWarning("❌ No user profile found - cannot get activity log"); - return new List(); - } - - // Query all completions within the date range - using var scope = _serviceProvider.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - - var completions = await db.DailyPlanCompletions - .Where(c => c.UserProfileId == userProfile.Id && c.Date >= fromUtc.Date && c.Date <= toUtc.Date) - .OrderBy(c => c.Date) - .ThenBy(c => c.CreatedAt) - .ToListAsync(ct); - - if (!completions.Any()) - { - _logger.LogDebug("ℹ️ No completions found in date range"); - return new List(); - } - - // Lookup resource and skill titles for enrichment (handle duplicates gracefully) - var resources = await _resourceRepo.GetAllResourcesLightweightAsync(); - var resourceLookup = resources - .GroupBy(r => r.Id) - .ToDictionary(g => g.Key, g => g.First().Title ?? string.Empty); - - var skills = await _skillRepo.ListAsync(); - var skillLookup = skills - .GroupBy(s => s.Id) - .ToDictionary(g => g.Key, g => g.First().Title ?? string.Empty); - - // Group by date - var dayGroups = completions - .GroupBy(c => c.Date.Date) - .OrderBy(g => g.Key) - .ToList(); - - var activityLogDays = new List(); - - foreach (var dayGroup in dayGroups) - { - var date = dayGroup.Key; - var dayCompletions = dayGroup.OrderBy(c => c.CreatedAt).ToList(); - - // Sub-group by plan generation (cluster items within 60 seconds) - var plans = new List(); - if (dayCompletions.Any()) - { - var currentPlan = new List { dayCompletions[0] }; - - for (int i = 1; i < dayCompletions.Count; i++) - { - if ((dayCompletions[i].CreatedAt - dayCompletions[i - 1].CreatedAt).TotalSeconds <= 60) - { - currentPlan.Add(dayCompletions[i]); - } - else - { - plans.Add(BuildActivityLogPlan(currentPlan, resourceLookup, skillLookup, filter)); - currentPlan = new List { dayCompletions[i] }; - } - } - plans.Add(BuildActivityLogPlan(currentPlan, resourceLookup, skillLookup, filter)); - } - - // Remove plans with no entries (all filtered out) - plans = plans.Where(p => p.Items.Any()).ToList(); - - if (!plans.Any()) - continue; // Skip days with no matching activities after filtering - - var totalMinutes = plans.Sum(p => p.TotalMinutesSpent); - var hasInput = plans.Any(p => p.Items.Any(i => i.Category == ActivityCategory.Input)); - var hasOutput = plans.Any(p => p.Items.Any(i => i.Category == ActivityCategory.Output)); - var allPlansCompleted = plans.All(p => p.IsFullyCompleted); - - activityLogDays.Add(new ActivityLogDay( - Date: date, - Plans: plans, - TotalMinutes: totalMinutes, - HasInput: hasInput, - HasOutput: hasOutput, - AllPlansCompleted: allPlansCompleted - )); - } - - // Build weeks (Monday-anchored) - var weeks = BuildWeeks(fromUtc.Date, toUtc.Date, activityLogDays); - - _logger.LogDebug("✅ Built {WeekCount} weeks with {DayCount} active days", weeks.Count, activityLogDays.Count); - - return weeks; - } - - private ActivityLogPlan BuildActivityLogPlan( - List completions, - Dictionary resourceLookup, - Dictionary skillLookup, - ActivityCategory? filter) - { - var entries = new List(); - - foreach (var completion in completions) - { - // Parse ActivityType - if (!Enum.TryParse(completion.ActivityType, out var actType)) - { - _logger.LogWarning("⚠️ Failed to parse ActivityType '{ActivityType}' for completion {Id}", completion.ActivityType, completion.Id); - continue; - } - - var category = ActivityCategoryMapper.Categorize(actType); - - // Apply filter - if (filter.HasValue && category != filter.Value) - continue; - - var resourceTitle = !string.IsNullOrEmpty(completion.ResourceId) && resourceLookup.TryGetValue(completion.ResourceId, out var title) - ? title - : null; - - var skillName = !string.IsNullOrEmpty(completion.SkillId) && skillLookup.TryGetValue(completion.SkillId, out var skill) - ? skill - : null; - - entries.Add(new ActivityLogEntry( - PlanItemId: completion.PlanItemId, - ActivityType: actType, - Category: category, - MinutesSpent: completion.MinutesSpent, - EstimatedMinutes: completion.EstimatedMinutes, - IsCompleted: completion.IsCompleted, - CompletedAt: completion.CompletedAt, - ResourceTitle: resourceTitle, - SkillName: skillName, - TitleKey: completion.TitleKey, - DescriptionKey: completion.DescriptionKey - )); - } - - var generatedAt = completions.FirstOrDefault()?.CreatedAt ?? DateTime.UtcNow; - var rationale = completions.FirstOrDefault()?.Rationale; - var narrativeJson = completions.FirstOrDefault()?.NarrativeJson; - - return new ActivityLogPlan( - GeneratedAt: generatedAt, - Items: entries, - CompletedCount: entries.Count(e => e.IsCompleted), - TotalCount: entries.Count, - IsFullyCompleted: entries.Any() && entries.All(e => e.IsCompleted), - TotalMinutesSpent: entries.Sum(e => e.MinutesSpent), - TotalEstimatedMinutes: entries.Sum(e => e.EstimatedMinutes), - Rationale: rationale, - NarrativeJson: narrativeJson - ); - } - - private List BuildWeeks(DateTime fromDate, DateTime toDate, List activityLogDays) - { - // Create a dictionary for fast lookup - var dayLookup = activityLogDays.ToDictionary(d => d.Date.Date, d => d); - - // Find Monday-anchored weeks - var weeks = new List(); - var current = fromDate.Date; - - // Align to Monday - while (current.DayOfWeek != DayOfWeek.Monday && current <= toDate) - current = current.AddDays(-1); - - while (current <= toDate) - { - var weekStart = current; - var weekEnd = weekStart.AddDays(6); - - var days = new ActivityLogDay[7]; - for (int i = 0; i < 7; i++) - { - var date = weekStart.AddDays(i); - if (dayLookup.TryGetValue(date, out var day)) - { - days[i] = day; - } - else - { - // Empty day - days[i] = new ActivityLogDay( - Date: date, - Plans: new List(), - TotalMinutes: 0, - HasInput: false, - HasOutput: false, - AllPlansCompleted: false - ); - } - } - - var totalMinutes = days.Sum(d => d.TotalMinutes); - var inputMinutes = days.Sum(d => d.Plans.Sum(p => p.Items.Where(i => i.Category == ActivityCategory.Input).Sum(i => i.MinutesSpent))); - var outputMinutes = days.Sum(d => d.Plans.Sum(p => p.Items.Where(i => i.Category == ActivityCategory.Output).Sum(i => i.MinutesSpent))); - var activityCount = days.Sum(d => d.Plans.Sum(p => p.Items.Count)); - var plansCompleted = days.Sum(d => d.Plans.Count(p => p.IsFullyCompleted)); - var plansTotal = days.Sum(d => d.Plans.Count); - - weeks.Add(new ActivityLogWeek( - WeekStart: weekStart, - WeekEnd: weekEnd, - Days: days, - TotalMinutes: totalMinutes, - InputMinutes: inputMinutes, - OutputMinutes: outputMinutes, - ActivityCount: activityCount, - PlansCompleted: plansCompleted, - PlansTotal: plansTotal - )); - - current = weekEnd.AddDays(1); - } - - // Return most recent week first - weeks.Reverse(); - - return weeks; - } + public Task> GetActivityLogAsync(DateTime fromUtc, DateTime toUtc, ActivityCategory? filter = null, CancellationToken ct = default) + => _activityLogService.GetActivityLogAsync(fromUtc, toUtc, filter, ct); } diff --git a/tests/SentenceStudio.Api.Tests/ActivityLogEndpointsTests.cs b/tests/SentenceStudio.Api.Tests/ActivityLogEndpointsTests.cs new file mode 100644 index 00000000..5face5c8 --- /dev/null +++ b/tests/SentenceStudio.Api.Tests/ActivityLogEndpointsTests.cs @@ -0,0 +1,251 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using SentenceStudio.Api.Tests.Infrastructure; +using SentenceStudio.Contracts.Activity; +using SentenceStudio.Data; +using SentenceStudio.Shared.Models; + +namespace SentenceStudio.Api.Tests; + +/// +/// Integration tests for GET /api/v1/activity-log. Pins the wire shape +/// the Flutter client builds against (see activity-log-api-spec.md): camelCase +/// keys, yyyy-MM-dd day dates, 7-day weeks newest-first, PascalCase +/// enum values for category and activityType. +/// +public sealed class ActivityLogEndpointsTests : IClassFixture +{ + private readonly ActivityLogApiFactory _factory; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public ActivityLogEndpointsTests(ActivityLogApiFactory factory) + { + _factory = factory; + } + + private HttpClient ClientWithJwt(string userProfileId) + { + var token = TestJwtGenerator.GenerateToken(userProfileId: userProfileId); + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + return client; + } + + private async Task SeedCompletionAsync( + string userProfileId, + DateTime date, + string planItemId, + string activityType, + int minutesSpent, + DateTime createdAt) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.DailyPlanCompletions.Add(new DailyPlanCompletion + { + Id = Guid.NewGuid().ToString(), + UserProfileId = userProfileId, + Date = date.Date, + PlanItemId = planItemId, + ActivityType = activityType, + IsCompleted = true, + CompletedAt = createdAt, + MinutesSpent = minutesSpent, + EstimatedMinutes = minutesSpent, + TitleKey = "PlanItem.Title", + DescriptionKey = "PlanItem.Description", + CreatedAt = createdAt, + UpdatedAt = createdAt, + }); + await db.SaveChangesAsync(); + } + + [Fact] + public async Task GetActivityLog_NoJwt_Returns401() + { + var client = _factory.CreateClient(); + var response = await client.GetAsync("/api/v1/activity-log?fromUtc=2026-01-01T00:00:00Z&toUtc=2026-01-07T23:59:59Z"); + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task GetActivityLog_MissingDates_Returns400() + { + var client = ClientWithJwt($"actlog-bad-{Guid.NewGuid():N}"); + var response = await client.GetAsync("/api/v1/activity-log"); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task GetActivityLog_InvertedRange_Returns400() + { + var client = ClientWithJwt($"actlog-bad-{Guid.NewGuid():N}"); + var response = await client.GetAsync( + "/api/v1/activity-log?fromUtc=2026-01-08T00:00:00Z&toUtc=2026-01-01T00:00:00Z"); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task GetActivityLog_RangeTooLarge_Returns400() + { + var client = ClientWithJwt($"actlog-bad-{Guid.NewGuid():N}"); + var response = await client.GetAsync( + "/api/v1/activity-log?fromUtc=2025-01-01T00:00:00Z&toUtc=2026-01-01T00:00:00Z"); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task GetActivityLog_InvalidFilter_Returns400() + { + var client = ClientWithJwt($"actlog-bad-{Guid.NewGuid():N}"); + var response = await client.GetAsync( + "/api/v1/activity-log?fromUtc=2026-01-01T00:00:00Z&toUtc=2026-01-07T23:59:59Z&filter=Bogus"); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task GetActivityLog_NoActivity_ReturnsEmptyArray() + { + var client = ClientWithJwt($"actlog-empty-{Guid.NewGuid():N}"); + var response = await client.GetAsync( + "/api/v1/activity-log?fromUtc=2026-01-01T00:00:00Z&toUtc=2026-01-07T23:59:59Z"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var weeks = await response.Content.ReadFromJsonAsync>(JsonOptions); + weeks.Should().NotBeNull(); + weeks!.Should().BeEmpty(); + } + + [Fact] + public async Task GetActivityLog_WithCompletions_RollsUpWeekWithCamelCaseShape() + { + var userProfileId = $"actlog-rollup-{Guid.NewGuid():N}"; + + // Two completions on a Tuesday — clustered (within 60s) into one Plan. + // One Input (Reading) + one Output (Writing) so we exercise the day's + // input/output minute totals. + var tuesday = new DateTime(2026, 3, 31, 0, 0, 0, DateTimeKind.Utc); // 2026-03-31 is a Tuesday + var firstCompletion = tuesday.AddHours(13).AddMinutes(42); + var secondCompletion = firstCompletion.AddSeconds(30); + + await SeedCompletionAsync(userProfileId, tuesday, "item-1", "Reading", 12, firstCompletion); + await SeedCompletionAsync(userProfileId, tuesday, "item-2", "Writing", 8, secondCompletion); + + var client = ClientWithJwt(userProfileId); + var response = await client.GetAsync( + "/api/v1/activity-log?fromUtc=2026-03-30T00:00:00Z&toUtc=2026-04-05T23:59:59Z"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var raw = await response.Content.ReadAsStringAsync(); + // Wire contract: camelCase keys, PascalCase enum values, yyyy-MM-dd dates. + raw.Should().Contain("\"weekStart\""); + raw.Should().Contain("\"hasActivity\""); + raw.Should().Contain("\"inputMinutes\""); + raw.Should().Contain("\"outputMinutes\""); + raw.Should().Contain("\"completedAtUtc\""); + raw.Should().Contain("\"category\":\"Input\""); + raw.Should().Contain("\"category\":\"Output\""); + raw.Should().Contain("\"activityType\":\"Reading\""); + raw.Should().Contain("\"activityType\":\"Writing\""); + raw.Should().Contain("\"date\":\"2026-03-31\""); + + var weeks = await response.Content.ReadFromJsonAsync>(JsonOptions); + weeks.Should().NotBeNull().And.HaveCount(1); + + var week = weeks![0]; + week.Days.Should().HaveCount(7, "weeks always have 7 day entries Mon–Sun"); + week.WeekStart.DayOfWeek.Should().Be(DayOfWeek.Monday); + + var tuesdayDay = week.Days.Single(d => d.Date == "2026-03-31"); + tuesdayDay.HasActivity.Should().BeTrue(); + tuesdayDay.InputMinutes.Should().Be(12); + tuesdayDay.OutputMinutes.Should().Be(8); + tuesdayDay.TotalMinutes.Should().Be(20); + tuesdayDay.AllPlansCompleted.Should().BeTrue(); + tuesdayDay.Plans.Should().HaveCount(1, "the two completions are within 60s of each other"); + + var plan = tuesdayDay.Plans[0]; + plan.IsAdhoc.Should().BeFalse(); + plan.DisplayName.Should().Be("Plan 1"); + plan.TotalMinutes.Should().Be(20); + plan.Completed.Should().BeTrue(); + plan.Entries.Should().HaveCount(2); + plan.Entries[0].ActivityType.Should().Be("Reading"); + plan.Entries[0].Category.Should().Be("Input"); + plan.Entries[0].CompletedAtUtc.Should().NotBeNull(); + plan.Entries[1].ActivityType.Should().Be("Writing"); + plan.Entries[1].Category.Should().Be("Output"); + + // Empty days still present with hasActivity=false and zero totals. + var monday = week.Days.Single(d => d.Date == "2026-03-30"); + monday.HasActivity.Should().BeFalse(); + monday.Plans.Should().BeEmpty(); + monday.TotalMinutes.Should().Be(0); + } + + [Fact] + public async Task GetActivityLog_AdhocCompletion_LabeledAsAdhoc() + { + var userProfileId = $"actlog-adhoc-{Guid.NewGuid():N}"; + var wednesday = new DateTime(2026, 4, 1, 14, 0, 0, DateTimeKind.Utc); // Wednesday + await SeedCompletionAsync(userProfileId, wednesday, "adhoc-001", "Reading", 5, wednesday); + + var client = ClientWithJwt(userProfileId); + var response = await client.GetAsync( + "/api/v1/activity-log?fromUtc=2026-03-30T00:00:00Z&toUtc=2026-04-05T23:59:59Z"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var weeks = await response.Content.ReadFromJsonAsync>(JsonOptions); + var day = weeks!.SelectMany(w => w.Days).Single(d => d.Date == "2026-04-01"); + day.Plans.Should().HaveCount(1); + day.Plans[0].IsAdhoc.Should().BeTrue(); + day.Plans[0].DisplayName.Should().Be("Ad-hoc"); + day.Plans[0].PlanItemId.Should().Be("adhoc-001"); + } + + [Fact] + public async Task GetActivityLog_InputFilter_DropsOutputEntries() + { + var userProfileId = $"actlog-filter-{Guid.NewGuid():N}"; + var thursday = new DateTime(2026, 4, 2, 10, 0, 0, DateTimeKind.Utc); + await SeedCompletionAsync(userProfileId, thursday, "i-1", "Reading", 10, thursday); + await SeedCompletionAsync(userProfileId, thursday, "o-1", "Writing", 7, thursday.AddSeconds(5)); + + var client = ClientWithJwt(userProfileId); + var response = await client.GetAsync( + "/api/v1/activity-log?fromUtc=2026-03-30T00:00:00Z&toUtc=2026-04-05T23:59:59Z&filter=Input"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var weeks = await response.Content.ReadFromJsonAsync>(JsonOptions); + var day = weeks!.SelectMany(w => w.Days).Single(d => d.Date == "2026-04-02"); + day.Plans.Should().HaveCount(1); + day.Plans[0].Entries.Should().OnlyContain(e => e.Category == "Input"); + day.Plans[0].Entries.Should().HaveCount(1); + } + + [Fact] + public async Task GetActivityLog_PerUserIsolation() + { + var meId = $"actlog-me-{Guid.NewGuid():N}"; + var otherId = $"actlog-other-{Guid.NewGuid():N}"; + var friday = new DateTime(2026, 4, 3, 9, 0, 0, DateTimeKind.Utc); + await SeedCompletionAsync(meId, friday, "me-1", "Reading", 9, friday); + await SeedCompletionAsync(otherId, friday, "you-1", "Writing", 15, friday); + + var client = ClientWithJwt(meId); + var response = await client.GetAsync( + "/api/v1/activity-log?fromUtc=2026-03-30T00:00:00Z&toUtc=2026-04-05T23:59:59Z"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var weeks = await response.Content.ReadFromJsonAsync>(JsonOptions); + weeks!.SelectMany(w => w.Days) + .Where(d => d.HasActivity) + .SelectMany(d => d.Plans) + .SelectMany(p => p.Entries) + .Should().OnlyContain(e => e.ActivityType == "Reading", + "the other user's Writing entry must not appear"); + } +} diff --git a/tests/SentenceStudio.Api.Tests/Infrastructure/ActivityLogApiFactory.cs b/tests/SentenceStudio.Api.Tests/Infrastructure/ActivityLogApiFactory.cs new file mode 100644 index 00000000..eff99f58 --- /dev/null +++ b/tests/SentenceStudio.Api.Tests/Infrastructure/ActivityLogApiFactory.cs @@ -0,0 +1,56 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace SentenceStudio.Api.Tests.Infrastructure; + +/// +/// Test host for Activity Log endpoint tests. Uses the real +/// so the +/// test exercises the actual rollup logic against seeded SQLite data. +/// +public class ActivityLogApiFactory : WebApplicationFactory +{ + private readonly string _dbPath = Path.Combine(Path.GetTempPath(), + $"sentencestudio_activitylog_{Guid.NewGuid():N}.db"); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Development"); + builder.UseSetting("ConnectionStrings:sentencestudio", + TestApiHostConfigurator.DummyPostgresConnectionString); + builder.UseSetting("Database:SkipMigrateOnStartup", "true"); + builder.UseSetting("Auth:EnableDevAuthFallback", "false"); + builder.UseSetting("Jwt:SigningKey", TestJwtGenerator.TestSigningKeyValue); + builder.UseSetting("Jwt:Issuer", TestJwtGenerator.TestIssuer); + builder.UseSetting("Jwt:Audience", TestJwtGenerator.TestAudience); + + builder.ConfigureServices(services => + { + TestApiHostConfigurator.ConfigureSqliteDatabaseAndSync(services, _dbPath); + + services.Configure(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }); + }); + } + + protected override IHost CreateHost(IHostBuilder builder) + { + var host = base.CreateHost(builder); + TestApiHostConfigurator.InitializeSqliteDatabaseAndSync(host.Services); + return host; + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + if (File.Exists(_dbPath)) + File.Delete(_dbPath); + } +} From 5c5cfc87ac296832f4a1203c395fa49b699360ce Mon Sep 17 00:00:00 2001 From: David Ortinau Date: Fri, 22 May 2026 08:11:56 -0500 Subject: [PATCH 6/6] feat(api): widen activity-log DTO + add POST /plans/adhoc/start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 2 of the Flutter-unblock contract. No MAUI breakage. Activity Log DTO widened to match the spec: - Week: totalMinutes, inputMinutes, outputMinutes, activityCount, plansCompleted, plansTotal. - Entry: planItemId, estimatedMinutes, isCompleted, resourceTitle (nullable), skillName (nullable), title, description. title and description currently pass TitleKey/DescriptionKey through unchanged (e.g. "PlanItemReadingTitle") until server-side localization is wired up — the spec explicitly permits this as temporary. New endpoint: POST /api/v1/plans/adhoc/start - Auth: Bearer JWT, user resolved via IUserScopeProvider. - Idempotent via deterministic planItemId = "adhoc-{clientSessionId}". No schema change; the (UserProfileId, Date, PlanItemId) lookup is the uniqueness contract. - 201 Created on first call; 200 OK on idempotent replay (same id). - 400 problem+json for missing/invalid clientSessionId, unknown activityType, non-positive estimatedMinutes. - estimatedMinutes defaults to 10 when omitted; clamps at 24h max. - Returned planItemId is accepted by the existing POST /api/v1/plans/{date}/items/{id}/progress endpoint (covered by a test). Tests: 10 new ad-hoc + 1 widened activity-log test. Full API suite 207/208 (1 pre-existing unrelated JwtBearer failure on main). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Activity/ActivityLogEndpoints.cs | 20 ++ .../Plans/AdhocPlanEndpoints.cs | 204 +++++++++++++++ src/SentenceStudio.Api/Program.cs | 1 + .../Activity/ActivityLogDto.cs | 45 ++++ .../Plans/AdhocStartDto.cs | 51 ++++ .../ActivityLogEndpointsTests.cs | 35 ++- .../AdhocPlanEndpointsTests.cs | 247 ++++++++++++++++++ 7 files changed, 598 insertions(+), 5 deletions(-) create mode 100644 src/SentenceStudio.Api/Plans/AdhocPlanEndpoints.cs create mode 100644 src/SentenceStudio.Contracts/Plans/AdhocStartDto.cs create mode 100644 tests/SentenceStudio.Api.Tests/AdhocPlanEndpointsTests.cs diff --git a/src/SentenceStudio.Api/Activity/ActivityLogEndpoints.cs b/src/SentenceStudio.Api/Activity/ActivityLogEndpoints.cs index 157017db..2580b7fe 100644 --- a/src/SentenceStudio.Api/Activity/ActivityLogEndpoints.cs +++ b/src/SentenceStudio.Api/Activity/ActivityLogEndpoints.cs @@ -109,6 +109,12 @@ internal static ActivityLogWeekDto MapWeek(ActivityLogWeek week) { WeekStart = DateTime.SpecifyKind(week.WeekStart.Date, DateTimeKind.Utc), WeekEnd = DateTime.SpecifyKind(weekEnd, DateTimeKind.Utc), + TotalMinutes = week.TotalMinutes, + InputMinutes = week.InputMinutes, + OutputMinutes = week.OutputMinutes, + ActivityCount = week.ActivityCount, + PlansCompleted = week.PlansCompleted, + PlansTotal = week.PlansTotal, Days = week.Days.Select(MapDay).ToList(), }; } @@ -166,12 +172,26 @@ internal static ActivityLogEntryDto MapEntry(ActivityLogEntry entry) var completedAt = entry.CompletedAt is { } ts ? DateTime.SpecifyKind(ts, DateTimeKind.Utc) : (DateTime?)null; + // TitleKey/DescriptionKey are resource keys for now (e.g. + // "PlanItemReadingTitle"). Spec activity-log-api-spec.md explicitly + // permits this passthrough until server-side localization lands. + var title = !string.IsNullOrEmpty(entry.TitleKey) + ? entry.TitleKey + : entry.ActivityType.ToString(); + var description = entry.DescriptionKey ?? string.Empty; return new ActivityLogEntryDto { + PlanItemId = entry.PlanItemId ?? string.Empty, ActivityType = entry.ActivityType.ToString(), Category = entry.Category.ToString(), MinutesSpent = entry.MinutesSpent, + EstimatedMinutes = entry.EstimatedMinutes, + IsCompleted = entry.IsCompleted, CompletedAtUtc = completedAt, + ResourceTitle = entry.ResourceTitle, + SkillName = entry.SkillName, + Title = title, + Description = description, }; } diff --git a/src/SentenceStudio.Api/Plans/AdhocPlanEndpoints.cs b/src/SentenceStudio.Api/Plans/AdhocPlanEndpoints.cs new file mode 100644 index 00000000..8c9220bd --- /dev/null +++ b/src/SentenceStudio.Api/Plans/AdhocPlanEndpoints.cs @@ -0,0 +1,204 @@ +using System.Globalization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using SentenceStudio.Contracts.Plans; +using SentenceStudio.Data; +using SentenceStudio.Services.Plans; +using SentenceStudio.Services.Progress; +using SentenceStudio.Shared.Models; + +namespace SentenceStudio.Api.Plans; + +/// +/// POST /api/v1/plans/adhoc/start — starts (or idempotently resumes) an +/// ad-hoc activity-tracking session. The synthetic planItemId is +/// deterministic from clientSessionId (adhoc-{clientSessionId}), +/// which gives durable idempotency without a schema change: the existing +/// (UserProfileId, Date, PlanItemId) uniqueness contract on +/// DailyPlanCompletion covers replay protection. +/// +/// MAUI's ProgressService.StartAdHocSessionAsync continues to generate +/// adhoc-{Guid.NewGuid()} for local-only flows and is unaffected. +/// +public static class AdhocPlanEndpoints +{ + /// Default when the client omits estimatedMinutes. Matches + /// the MAUI default. + public const int DefaultEstimatedMinutes = 10; + + /// Upper clamp to mirror plan endpoint validation. Aligns with + /// PlanService.MaxMinutesSpent so an ad-hoc row can't blow past the + /// progress endpoint's clamp anyway. + public const int MaxEstimatedMinutes = 24 * 60; + + public static IEndpointRouteBuilder MapAdhocPlan(this IEndpointRouteBuilder app) + { + app.MapPost("/api/v1/plans/adhoc/start", StartAdhocAsync) + .WithName("StartAdhocSession") + .RequireAuthorization(); + return app; + } + + private static async Task StartAdhocAsync( + AdhocStartRequest? request, + IUserScopeProvider userScope, + ApplicationDbContext db, + ILoggerFactory loggerFactory, + CancellationToken ct) + { + var logger = loggerFactory.CreateLogger("AdhocPlan"); + + if (request is null) + { + return Results.Problem( + title: "Missing request body", + detail: "Request body is required.", + statusCode: StatusCodes.Status400BadRequest); + } + + if (string.IsNullOrWhiteSpace(request.ClientSessionId) || + !Guid.TryParse(request.ClientSessionId, out var clientSessionGuid)) + { + return Results.Problem( + title: "Invalid clientSessionId", + detail: "'clientSessionId' must be a non-empty UUID generated by the client.", + statusCode: StatusCodes.Status400BadRequest); + } + + if (string.IsNullOrWhiteSpace(request.ActivityType) || + !Enum.TryParse(request.ActivityType, ignoreCase: false, out var activityType) || + !Enum.IsDefined(typeof(PlanActivityType), activityType)) + { + return Results.Problem( + title: "Invalid activityType", + detail: "'activityType' must be a valid PlanActivityType (e.g. Reading, Writing, Translation, ...).", + statusCode: StatusCodes.Status400BadRequest); + } + + var estimatedMinutes = request.EstimatedMinutes ?? DefaultEstimatedMinutes; + if (estimatedMinutes <= 0) + { + return Results.Problem( + title: "Invalid estimatedMinutes", + detail: "'estimatedMinutes' must be a positive integer.", + statusCode: StatusCodes.Status400BadRequest); + } + if (estimatedMinutes > MaxEstimatedMinutes) + { + // Clamp on the upper bound rather than rejecting — matches the + // progress endpoint's clamp-rather-than-reject behaviour. + estimatedMinutes = MaxEstimatedMinutes; + } + + if (!userScope.TryGetUserProfileId(out var userProfileId) || string.IsNullOrEmpty(userProfileId)) + { + return Results.Unauthorized(); + } + + // Deterministic id makes the operation idempotent end-to-end: the + // primary uniqueness check is the lookup below, and even a race + // between two replays would only collide on the same row. + var planItemId = $"adhoc-{clientSessionGuid:D}"; + var today = DateTime.UtcNow.Date; + + try + { + var existing = await db.DailyPlanCompletions + .FirstOrDefaultAsync(c => c.UserProfileId == userProfileId + && c.Date == today + && c.PlanItemId == planItemId, ct); + + if (existing is not null) + { + logger.LogInformation("🔁 Ad-hoc replay for client session {ClientSessionId} → {PlanItemId}", + clientSessionGuid, planItemId); + return Results.Ok(BuildResponse(existing, activityType, estimatedMinutes)); + } + + var completion = new DailyPlanCompletion + { + Id = Guid.NewGuid().ToString(), + UserProfileId = userProfileId, + Date = today, + PlanItemId = planItemId, + ActivityType = activityType.ToString(), + ResourceId = request.ResourceId, + SkillId = request.SkillId, + IsCompleted = false, + CompletedAt = null, + MinutesSpent = 0, + EstimatedMinutes = estimatedMinutes, + Priority = 999, // sort after planned items, mirrors ProgressService.StartAdHocSessionAsync + TitleKey = $"Activity_{activityType}", + DescriptionKey = string.Empty, + Rationale = string.Empty, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + + db.DailyPlanCompletions.Add(completion); + try + { + await db.SaveChangesAsync(ct); + } + catch (DbUpdateException ex) when (IsUniqueViolation(ex)) + { + // Lost a race with a concurrent replay — fetch the winner and + // return 200 as if we were the duplicate caller. + db.Entry(completion).State = EntityState.Detached; + var winner = await db.DailyPlanCompletions + .FirstAsync(c => c.UserProfileId == userProfileId + && c.Date == today + && c.PlanItemId == planItemId, ct); + logger.LogInformation("🔁 Ad-hoc replay (race) for client session {ClientSessionId} → {PlanItemId}", + clientSessionGuid, planItemId); + return Results.Ok(BuildResponse(winner, activityType, estimatedMinutes)); + } + + logger.LogInformation("🎯 Started ad-hoc session {PlanItemId} for {ActivityType} (user={UserProfileId})", + planItemId, activityType, userProfileId); + + return Results.Created($"/api/v1/plans/{today:yyyy-MM-dd}/items/{planItemId}/progress", + BuildResponse(completion, activityType, estimatedMinutes)); + } + catch (Exception ex) + { + logger.LogError(ex, "POST /api/v1/plans/adhoc/start failed"); + return Results.Problem( + detail: "Failed to start ad-hoc session.", + statusCode: StatusCodes.Status500InternalServerError); + } + } + + private static AdhocStartResponse BuildResponse( + DailyPlanCompletion row, + PlanActivityType activityType, + int requestedEstimatedMinutes) + { + // On replay, prefer the row's stored EstimatedMinutes (the first call + // is the source of truth for the session). On insert these are equal. + return new AdhocStartResponse + { + PlanItemId = row.PlanItemId, + ActivityType = activityType.ToString(), + Date = row.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + EstimatedMinutes = row.EstimatedMinutes > 0 ? row.EstimatedMinutes : requestedEstimatedMinutes, + }; + } + + private static bool IsUniqueViolation(DbUpdateException ex) + { + // SQLite: "UNIQUE constraint failed". Postgres: SqlState 23505. + // We treat any inner-exception message containing "unique" as a + // best-effort hint — sufficient because we only get here if the + // primary-key Id collides (very unlikely) OR a unique index is added + // on (UserProfileId, Date, PlanItemId) later. + var message = ex.InnerException?.Message ?? ex.Message; + return message.Contains("UNIQUE", StringComparison.OrdinalIgnoreCase) + || message.Contains("23505", StringComparison.Ordinal); + } +} diff --git a/src/SentenceStudio.Api/Program.cs b/src/SentenceStudio.Api/Program.cs index f0b10ed9..9d6674f9 100644 --- a/src/SentenceStudio.Api/Program.cs +++ b/src/SentenceStudio.Api/Program.cs @@ -529,6 +529,7 @@ await context.Response.WriteAsync(""" // plan.md. Replaces the legacy /api/v1/plans/generate stub below (kept // for backward compat during the MAUI Blazor v2 flip). app.MapPlans(); +app.MapAdhocPlan(); app.MapActivityLog(); // YouTube channel monitoring endpoints diff --git a/src/SentenceStudio.Contracts/Activity/ActivityLogDto.cs b/src/SentenceStudio.Contracts/Activity/ActivityLogDto.cs index 01b1f81f..6ab27ed9 100644 --- a/src/SentenceStudio.Contracts/Activity/ActivityLogDto.cs +++ b/src/SentenceStudio.Contracts/Activity/ActivityLogDto.cs @@ -17,6 +17,24 @@ public sealed record ActivityLogWeekDto [JsonPropertyName("weekEnd")] public DateTime WeekEnd { get; init; } + [JsonPropertyName("totalMinutes")] + public int TotalMinutes { get; init; } + + [JsonPropertyName("inputMinutes")] + public int InputMinutes { get; init; } + + [JsonPropertyName("outputMinutes")] + public int OutputMinutes { get; init; } + + [JsonPropertyName("activityCount")] + public int ActivityCount { get; init; } + + [JsonPropertyName("plansCompleted")] + public int PlansCompleted { get; init; } + + [JsonPropertyName("plansTotal")] + public int PlansTotal { get; init; } + [JsonPropertyName("days")] public required IReadOnlyList Days { get; init; } } @@ -76,6 +94,9 @@ public sealed record ActivityLogPlanDto public sealed record ActivityLogEntryDto { + [JsonPropertyName("planItemId")] + public required string PlanItemId { get; init; } + /// PascalCase enum name — e.g. Reading, Writing, /// VocabularyReview. Matches the PlanActivityType enum. [JsonPropertyName("activityType")] @@ -89,8 +110,32 @@ public sealed record ActivityLogEntryDto [JsonPropertyName("minutesSpent")] public int MinutesSpent { get; init; } + [JsonPropertyName("estimatedMinutes")] + public int EstimatedMinutes { get; init; } + + [JsonPropertyName("isCompleted")] + public bool IsCompleted { get; init; } + /// ISO 8601 UTC timestamp with Z suffix. Null when the /// activity is in-progress (i.e. IsCompleted=false). [JsonPropertyName("completedAtUtc")] public DateTime? CompletedAtUtc { get; init; } + + [JsonPropertyName("resourceTitle")] + public string? ResourceTitle { get; init; } + + [JsonPropertyName("skillName")] + public string? SkillName { get; init; } + + /// Display title. TEMPORARY: currently returns the TitleKey + /// resource identifier (e.g. "PlanItemReadingTitle") until the API + /// gains server-side localization. The MAUI client renders these via + /// LocalizationManager; Flutter currently does the same via its own + /// localization assets. See activity-log-api-spec.md. + [JsonPropertyName("title")] + public required string Title { get; init; } + + /// Display description. TEMPORARY — see . + [JsonPropertyName("description")] + public required string Description { get; init; } } diff --git a/src/SentenceStudio.Contracts/Plans/AdhocStartDto.cs b/src/SentenceStudio.Contracts/Plans/AdhocStartDto.cs new file mode 100644 index 00000000..5026c449 --- /dev/null +++ b/src/SentenceStudio.Contracts/Plans/AdhocStartDto.cs @@ -0,0 +1,51 @@ +using System.Text.Json.Serialization; + +namespace SentenceStudio.Contracts.Plans; + +/// +/// Wire request for POST /api/v1/plans/adhoc/start. The ClientSessionId +/// is the idempotency key — repeat calls with the same id resolve to the same +/// PlanItemId and never create duplicate DailyPlanCompletion rows. +/// +public sealed record AdhocStartRequest +{ + /// Client-generated UUID. Used as the idempotency key per user. + [JsonPropertyName("clientSessionId")] + public string? ClientSessionId { get; init; } + + /// PascalCase enum string — see . + [JsonPropertyName("activityType")] + public string? ActivityType { get; init; } + + [JsonPropertyName("resourceId")] + public string? ResourceId { get; init; } + + [JsonPropertyName("skillId")] + public string? SkillId { get; init; } + + /// Optional. Defaults to 10 when omitted. + [JsonPropertyName("estimatedMinutes")] + public int? EstimatedMinutes { get; init; } +} + +/// +/// Wire response for POST /api/v1/plans/adhoc/start. +/// +public sealed record AdhocStartResponse +{ + /// Synthetic id prefixed with adhoc-. Accepted by the existing + /// POST /api/v1/plans/{date}/items/{id}/progress and + /// POST /api/v1/plans/{date}/items/{id}/complete endpoints. + [JsonPropertyName("planItemId")] + public required string PlanItemId { get; init; } + + [JsonPropertyName("activityType")] + public required string ActivityType { get; init; } + + /// Calendar date the completion row is attached to, yyyy-MM-dd UTC. + [JsonPropertyName("date")] + public required string Date { get; init; } + + [JsonPropertyName("estimatedMinutes")] + public int EstimatedMinutes { get; init; } +} diff --git a/tests/SentenceStudio.Api.Tests/ActivityLogEndpointsTests.cs b/tests/SentenceStudio.Api.Tests/ActivityLogEndpointsTests.cs index 5face5c8..c79c239e 100644 --- a/tests/SentenceStudio.Api.Tests/ActivityLogEndpointsTests.cs +++ b/tests/SentenceStudio.Api.Tests/ActivityLogEndpointsTests.cs @@ -142,10 +142,21 @@ public async Task GetActivityLog_WithCompletions_RollsUpWeekWithCamelCaseShape() var raw = await response.Content.ReadAsStringAsync(); // Wire contract: camelCase keys, PascalCase enum values, yyyy-MM-dd dates. raw.Should().Contain("\"weekStart\""); + raw.Should().Contain("\"weekEnd\""); + raw.Should().Contain("\"activityCount\""); + raw.Should().Contain("\"plansCompleted\""); + raw.Should().Contain("\"plansTotal\""); raw.Should().Contain("\"hasActivity\""); raw.Should().Contain("\"inputMinutes\""); raw.Should().Contain("\"outputMinutes\""); + raw.Should().Contain("\"estimatedMinutes\""); + raw.Should().Contain("\"isCompleted\""); raw.Should().Contain("\"completedAtUtc\""); + raw.Should().Contain("\"resourceTitle\""); + raw.Should().Contain("\"skillName\""); + raw.Should().Contain("\"title\""); + raw.Should().Contain("\"description\""); + raw.Should().Contain("\"planItemId\""); raw.Should().Contain("\"category\":\"Input\""); raw.Should().Contain("\"category\":\"Output\""); raw.Should().Contain("\"activityType\":\"Reading\""); @@ -158,6 +169,12 @@ public async Task GetActivityLog_WithCompletions_RollsUpWeekWithCamelCaseShape() var week = weeks![0]; week.Days.Should().HaveCount(7, "weeks always have 7 day entries Mon–Sun"); week.WeekStart.DayOfWeek.Should().Be(DayOfWeek.Monday); + week.TotalMinutes.Should().Be(20); + week.InputMinutes.Should().Be(12); + week.OutputMinutes.Should().Be(8); + week.ActivityCount.Should().Be(2); + week.PlansCompleted.Should().Be(1); + week.PlansTotal.Should().Be(1); var tuesdayDay = week.Days.Single(d => d.Date == "2026-03-31"); tuesdayDay.HasActivity.Should().BeTrue(); @@ -173,11 +190,19 @@ public async Task GetActivityLog_WithCompletions_RollsUpWeekWithCamelCaseShape() plan.TotalMinutes.Should().Be(20); plan.Completed.Should().BeTrue(); plan.Entries.Should().HaveCount(2); - plan.Entries[0].ActivityType.Should().Be("Reading"); - plan.Entries[0].Category.Should().Be("Input"); - plan.Entries[0].CompletedAtUtc.Should().NotBeNull(); - plan.Entries[1].ActivityType.Should().Be("Writing"); - plan.Entries[1].Category.Should().Be("Output"); + + var reading = plan.Entries.Single(e => e.ActivityType == "Reading"); + reading.Category.Should().Be("Input"); + reading.PlanItemId.Should().Be("item-1"); + reading.MinutesSpent.Should().Be(12); + reading.EstimatedMinutes.Should().Be(12); + reading.IsCompleted.Should().BeTrue(); + reading.CompletedAtUtc.Should().NotBeNull(); + reading.Title.Should().Be("PlanItem.Title", "passthrough TitleKey while server-side localization pending"); + reading.Description.Should().Be("PlanItem.Description"); + + var writing = plan.Entries.Single(e => e.ActivityType == "Writing"); + writing.Category.Should().Be("Output"); // Empty days still present with hasActivity=false and zero totals. var monday = week.Days.Single(d => d.Date == "2026-03-30"); diff --git a/tests/SentenceStudio.Api.Tests/AdhocPlanEndpointsTests.cs b/tests/SentenceStudio.Api.Tests/AdhocPlanEndpointsTests.cs new file mode 100644 index 00000000..f8c1e8c7 --- /dev/null +++ b/tests/SentenceStudio.Api.Tests/AdhocPlanEndpointsTests.cs @@ -0,0 +1,247 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SentenceStudio.Api.Tests.Infrastructure; +using SentenceStudio.Contracts.Plans; +using SentenceStudio.Data; + +namespace SentenceStudio.Api.Tests; + +/// +/// Integration tests for POST /api/v1/plans/adhoc/start. Reuses the +/// Activity Log factory (same SQLite + JWT setup); the endpoint depends only +/// on IUserScopeProvider and ApplicationDbContext. +/// +public sealed class AdhocPlanEndpointsTests : IClassFixture +{ + private readonly ActivityLogApiFactory _factory; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public AdhocPlanEndpointsTests(ActivityLogApiFactory factory) + { + _factory = factory; + } + + private HttpClient ClientWithJwt(string userProfileId) + { + var token = TestJwtGenerator.GenerateToken(userProfileId: userProfileId); + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + return client; + } + + [Fact] + public async Task Start_NoJwt_Returns401() + { + var client = _factory.CreateClient(); + var response = await client.PostAsJsonAsync("/api/v1/plans/adhoc/start", new AdhocStartRequest + { + ClientSessionId = Guid.NewGuid().ToString(), + ActivityType = "Translation", + }); + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Start_MissingClientSessionId_Returns400() + { + var client = ClientWithJwt($"adhoc-{Guid.NewGuid():N}"); + var response = await client.PostAsJsonAsync("/api/v1/plans/adhoc/start", new AdhocStartRequest + { + ClientSessionId = null, + ActivityType = "Translation", + }); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Start_InvalidClientSessionId_Returns400() + { + var client = ClientWithJwt($"adhoc-{Guid.NewGuid():N}"); + var response = await client.PostAsJsonAsync("/api/v1/plans/adhoc/start", new AdhocStartRequest + { + ClientSessionId = "not-a-uuid", + ActivityType = "Translation", + }); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Start_InvalidActivityType_Returns400() + { + var client = ClientWithJwt($"adhoc-{Guid.NewGuid():N}"); + var response = await client.PostAsJsonAsync("/api/v1/plans/adhoc/start", new AdhocStartRequest + { + ClientSessionId = Guid.NewGuid().ToString(), + ActivityType = "NotARealActivity", + }); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Start_NonPositiveEstimatedMinutes_Returns400() + { + var client = ClientWithJwt($"adhoc-{Guid.NewGuid():N}"); + var response = await client.PostAsJsonAsync("/api/v1/plans/adhoc/start", new AdhocStartRequest + { + ClientSessionId = Guid.NewGuid().ToString(), + ActivityType = "Translation", + EstimatedMinutes = 0, + }); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Start_ValidRequest_Returns201AndCreatesCompletion() + { + var userProfileId = $"adhoc-create-{Guid.NewGuid():N}"; + var clientSessionId = Guid.NewGuid().ToString(); + var client = ClientWithJwt(userProfileId); + + var response = await client.PostAsJsonAsync("/api/v1/plans/adhoc/start", new AdhocStartRequest + { + ClientSessionId = clientSessionId, + ActivityType = "Translation", + EstimatedMinutes = 15, + }); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + var body = await response.Content.ReadFromJsonAsync(JsonOptions); + body.Should().NotBeNull(); + body!.PlanItemId.Should().StartWith("adhoc-"); + body.PlanItemId.Should().Contain(clientSessionId); + body.ActivityType.Should().Be("Translation"); + body.EstimatedMinutes.Should().Be(15); + body.Date.Should().MatchRegex(@"^\d{4}-\d{2}-\d{2}$"); + + // Row actually exists in the DB. + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var row = await db.DailyPlanCompletions + .SingleAsync(c => c.UserProfileId == userProfileId && c.PlanItemId == body.PlanItemId); + row.ActivityType.Should().Be("Translation"); + row.EstimatedMinutes.Should().Be(15); + row.IsCompleted.Should().BeFalse(); + row.MinutesSpent.Should().Be(0); + } + + [Fact] + public async Task Start_OmittedEstimatedMinutes_DefaultsTo10() + { + var userProfileId = $"adhoc-default-{Guid.NewGuid():N}"; + var client = ClientWithJwt(userProfileId); + var response = await client.PostAsJsonAsync("/api/v1/plans/adhoc/start", new AdhocStartRequest + { + ClientSessionId = Guid.NewGuid().ToString(), + ActivityType = "Reading", + }); + response.StatusCode.Should().Be(HttpStatusCode.Created); + var body = await response.Content.ReadFromJsonAsync(JsonOptions); + body!.EstimatedMinutes.Should().Be(10); + } + + [Fact] + public async Task Start_ReplayWithSameClientSessionId_Returns200AndSamePlanItemId() + { + var userProfileId = $"adhoc-replay-{Guid.NewGuid():N}"; + var clientSessionId = Guid.NewGuid().ToString(); + var client = ClientWithJwt(userProfileId); + + var first = await client.PostAsJsonAsync("/api/v1/plans/adhoc/start", new AdhocStartRequest + { + ClientSessionId = clientSessionId, + ActivityType = "Writing", + EstimatedMinutes = 20, + }); + first.StatusCode.Should().Be(HttpStatusCode.Created); + var firstBody = await first.Content.ReadFromJsonAsync(JsonOptions); + + // Replay with same id (and even different EstimatedMinutes) — must + // not create a duplicate row, must return the same planItemId, and + // must return 200 OK (not 201). + var second = await client.PostAsJsonAsync("/api/v1/plans/adhoc/start", new AdhocStartRequest + { + ClientSessionId = clientSessionId, + ActivityType = "Writing", + EstimatedMinutes = 99, + }); + second.StatusCode.Should().Be(HttpStatusCode.OK); + var secondBody = await second.Content.ReadFromJsonAsync(JsonOptions); + secondBody!.PlanItemId.Should().Be(firstBody!.PlanItemId); + // The first call's EstimatedMinutes wins (stored on the row). + secondBody.EstimatedMinutes.Should().Be(20); + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var rows = await db.DailyPlanCompletions + .Where(c => c.UserProfileId == userProfileId && c.PlanItemId == firstBody.PlanItemId) + .ToListAsync(); + rows.Should().HaveCount(1, "idempotent replay must not insert a duplicate row"); + } + + [Fact] + public async Task Start_PlanItemIdUsableByProgressEndpoint() + { + var userProfileId = $"adhoc-progress-{Guid.NewGuid():N}"; + var client = ClientWithJwt(userProfileId); + var response = await client.PostAsJsonAsync("/api/v1/plans/adhoc/start", new AdhocStartRequest + { + ClientSessionId = Guid.NewGuid().ToString(), + ActivityType = "Translation", + EstimatedMinutes = 10, + }); + response.StatusCode.Should().Be(HttpStatusCode.Created); + var body = await response.Content.ReadFromJsonAsync(JsonOptions); + + // POST progress against the existing /api/v1/plans/{date}/items/{id}/progress + // endpoint must find the synthetic ad-hoc row. PlanService.UpdateProgressAsync + // returns NoContent on success, NotFound when no row matches. + var progress = await client.PostAsJsonAsync( + $"/api/v1/plans/{body!.Date}/items/{body.PlanItemId}/progress", + new { minutesSpent = 5 }); + + progress.StatusCode.Should().BeOneOf(HttpStatusCode.NoContent, HttpStatusCode.OK); + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var row = await db.DailyPlanCompletions + .SingleAsync(c => c.UserProfileId == userProfileId && c.PlanItemId == body.PlanItemId); + row.MinutesSpent.Should().Be(5); + } + + [Fact] + public async Task Start_PerUserIsolation_DifferentUsersSameClientSessionIdGetDistinctRows() + { + var sharedSessionId = Guid.NewGuid().ToString(); + var userA = $"adhoc-userA-{Guid.NewGuid():N}"; + var userB = $"adhoc-userB-{Guid.NewGuid():N}"; + + var clientA = ClientWithJwt(userA); + var clientB = ClientWithJwt(userB); + + var aResp = await clientA.PostAsJsonAsync("/api/v1/plans/adhoc/start", new AdhocStartRequest + { + ClientSessionId = sharedSessionId, + ActivityType = "Reading", + }); + var bResp = await clientB.PostAsJsonAsync("/api/v1/plans/adhoc/start", new AdhocStartRequest + { + ClientSessionId = sharedSessionId, + ActivityType = "Reading", + }); + + aResp.StatusCode.Should().Be(HttpStatusCode.Created); + bResp.StatusCode.Should().Be(HttpStatusCode.Created, + "user B has never used this clientSessionId; idempotency is scoped per user"); + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var aRow = await db.DailyPlanCompletions.SingleAsync(c => c.UserProfileId == userA); + var bRow = await db.DailyPlanCompletions.SingleAsync(c => c.UserProfileId == userB); + aRow.PlanItemId.Should().Be(bRow.PlanItemId, + "deterministic id from same clientSessionId — collision is fine because UserProfileId differs"); + } +}