using System.Security.Claims; using System.Text.Json; using System.Text.Json.Serialization; using ItWorx.KnowledgeHub.Api.Authorization; using ItWorx.KnowledgeHub.Api.Security; using ItWorx.KnowledgeHub.Application.Identity; using ItWorx.KnowledgeHub.Application.Jobs; using ItWorx.KnowledgeHub.Application.Jobs.UseCases; using ItWorx.KnowledgeHub.Contracts.Errors; using ItWorx.KnowledgeHub.Contracts.Jobs; using ItWorx.KnowledgeHub.Domain.Identity; using ItWorx.KnowledgeHub.Domain.Jobs; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; namespace ItWorx.KnowledgeHub.Api.Endpoints; /// Cookie-admin-only control surface for durable jobs. Mutations require CSRF; /// event streams expose only the latest persisted safe job snapshot. public static class JobsEndpoints { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) } }; public static IEndpointRouteBuilder MapJobsEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup("/api/v1/jobs") .WithTags("Jobs") .RequireAuthorization(AuthorizationPolicies.RequireAdministrator); group.MapPost("/course-tagging", QueueCourseTaggingAsync) .RequireRateLimiting(RateLimitPolicies.JobCreation) .AddEndpointFilter(); group.MapPost("/backups", QueueBackupAsync) .RequireRateLimiting(RateLimitPolicies.JobCreation) .AddEndpointFilter(); group.MapGet("", ListAsync); group.MapGet("/{jobId:guid}", GetAsync); group.MapPost("/{jobId:guid}/cancel", CancelAsync) .AddEndpointFilter(); group.MapGet("/{jobId:guid}/events", EventsAsync); return app; } private static async Task QueueCourseTaggingAsync( [FromBody] QueueCourseTaggingJobRequest request, QueueBackgroundJobUseCase useCase, IWorkspaceRepository workspaces, HttpContext context, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(context.Request.Headers["Idempotency-Key"])) return Results.Problem(statusCode: 400, type: ProblemTypes.Validation, title: "Idempotency-Key is verplicht."); var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken); if (workspaceId is null) return Results.Problem(statusCode: 404, type: ProblemTypes.NotFound); EnqueueJobResult result; try { result = await useCase.QueueCourseTaggingAsync( new QueueCourseTaggingJobCommand( workspaceId.Value, CurrentUserId(context.User), request.RootPageId, request.Tag, context.Request.Headers["Idempotency-Key"].ToString(), CorrelationId(context)), cancellationToken); } catch (ArgumentException) { return Results.Problem(statusCode: 400, type: ProblemTypes.Validation, title: "De cursustagtaak is ongeldig."); } return ToEnqueueResult(result); } private static async Task QueueBackupAsync( QueueBackupJobRequest _, QueueBackgroundJobUseCase useCase, IWorkspaceRepository workspaces, HttpContext context, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(context.Request.Headers["Idempotency-Key"])) return Results.Problem(statusCode: 400, type: ProblemTypes.Validation, title: "Idempotency-Key is verplicht."); var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken); if (workspaceId is null) return Results.Problem(statusCode: 404, type: ProblemTypes.NotFound); EnqueueJobResult result; try { result = await useCase.QueueBackupAsync( new QueueJobCommand(workspaceId.Value, CurrentUserId(context.User), context.Request.Headers["Idempotency-Key"].ToString(), CorrelationId(context)), cancellationToken); } catch (ArgumentException) { return Results.Problem(statusCode: 400, type: ProblemTypes.Validation, title: "Een geldige Idempotency-Key is verplicht."); } return ToEnqueueResult(result); } private static async Task, ProblemHttpResult>> ListAsync( BackgroundJobQueries queries, IWorkspaceRepository workspaces, [FromQuery] int? limit, CancellationToken cancellationToken) { var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken); if (workspaceId is null) return TypedResults.Problem(statusCode: 404, type: ProblemTypes.NotFound); var items = await queries.ListAsync(workspaceId.Value, limit ?? 50, cancellationToken); return TypedResults.Ok(new BackgroundJobListResponse(items.Select(ToResponse).ToList())); } private static async Task, NotFound>> GetAsync( Guid jobId, BackgroundJobQueries queries, IWorkspaceRepository workspaces, CancellationToken cancellationToken) { var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken); if (workspaceId is null) return TypedResults.NotFound(); var job = await queries.GetAsync(workspaceId.Value, jobId, cancellationToken); return job is null ? TypedResults.NotFound() : TypedResults.Ok(ToResponse(job)); } private static async Task CancelAsync( Guid jobId, CancelBackgroundJobUseCase useCase, IWorkspaceRepository workspaces, HttpContext context, CancellationToken cancellationToken) { var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken); if (workspaceId is null) return Results.NotFound(); var (status, job) = await useCase.ExecuteAsync( workspaceId.Value, jobId, CurrentUserId(context.User), CorrelationId(context), cancellationToken); if (status == CancelBackgroundJobStatus.NotFound || job is null) return Results.NotFound(); return Results.Ok(ToResponse(job)); } private static async Task EventsAsync( Guid jobId, BackgroundJobQueries queries, IWorkspaceRepository workspaces, IUserAccountManager users, IWorkspaceMembershipRepository memberships, BackgroundJobsOptions options, TimeProvider timeProvider, HttpContext context, CancellationToken cancellationToken) { var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken); if (workspaceId is null) return Results.NotFound(); var initial = await queries.GetAsync(workspaceId.Value, jobId, cancellationToken); if (initial is null) return Results.NotFound(); context.Response.StatusCode = StatusCodes.Status200OK; context.Response.ContentType = "text/event-stream; charset=utf-8"; context.Response.Headers["Cache-Control"] = "no-cache, no-transform"; context.Response.Headers.Append("X-Accel-Buffering", "no"); var actorId = CurrentUserId(context.User); var lastVersion = -1L; var lastHeartbeat = timeProvider.GetUtcNow(); while (!cancellationToken.IsCancellationRequested) { var actor = await users.FindByIdAsync(actorId, cancellationToken); var role = actor is { Enabled: true } ? await memberships.GetRoleAsync(workspaceId.Value, actorId, cancellationToken) : null; if (role != WorkspaceRole.Administrator) break; var job = await queries.GetAsync(workspaceId.Value, jobId, cancellationToken); if (job is null) break; if (job.IsTerminal) { // Terminal state must close every stream, even if a concurrent // worker update left the terminal snapshot at the last event ID. await WriteEventAsync(context, "terminal", job, cancellationToken); break; } if (job.Version != lastVersion) { var eventName = lastVersion < 0 ? "job" : "progress"; await WriteEventAsync(context, eventName, job, cancellationToken); lastVersion = job.Version; lastHeartbeat = timeProvider.GetUtcNow(); } else if (timeProvider.GetUtcNow() - lastHeartbeat >= TimeSpan.FromSeconds(options.SseHeartbeatSeconds)) { await context.Response.WriteAsync(": heartbeat\n\n", cancellationToken); await context.Response.Body.FlushAsync(cancellationToken); lastHeartbeat = timeProvider.GetUtcNow(); } await Task.Delay(TimeSpan.FromMilliseconds(options.SsePollMilliseconds), timeProvider, cancellationToken); } return Results.Empty; } private static async Task WriteEventAsync(HttpContext context, string eventName, BackgroundJob job, CancellationToken cancellationToken) { var data = JsonSerializer.Serialize(ToResponse(job), JsonOptions); await context.Response.WriteAsync($"id: {job.Version}\nevent: {eventName}\ndata: {data}\n\n", cancellationToken); await context.Response.Body.FlushAsync(cancellationToken); } private static IResult ToEnqueueResult(EnqueueJobResult result) => result.Status switch { EnqueueJobStatus.Created or EnqueueJobStatus.Existing when result.Job is not null => Results.Accepted($"/api/v1/jobs/{result.Job.Id:D}", ToResponse(result.Job)), EnqueueJobStatus.IdempotencyConflict => Results.Problem( statusCode: 409, type: ProblemTypes.IdempotencyKeyConflict, title: "Idempotency-Key is al gebruikt voor een andere aanvraag."), EnqueueJobStatus.RootNotFound => Results.Problem(statusCode: 404, type: ProblemTypes.PageNotFound, title: "Hoofdpagina niet gevonden."), EnqueueJobStatus.TooManyPages => Results.Problem( statusCode: 413, type: ProblemTypes.Validation, title: "De cursuspaginaboom overschrijdt de ingestelde taaklimiet."), EnqueueJobStatus.QueueFull => Results.Problem( statusCode: 429, title: "De taaklimiet is bereikt. Wacht tot lopende taken klaar zijn of het dagbudget is hersteld."), _ => Results.Problem(statusCode: 500, title: "De taak kon niet worden ingepland.") }; private static BackgroundJobResponse ToResponse(BackgroundJob job) { var errors = Array.Empty(); try { errors = (JsonSerializer.Deserialize(job.ErrorsJson) ?? []) .Select(error => new BackgroundJobErrorResponse(error.Code, error.Message, error.ItemId)) .ToArray(); } catch (JsonException) { // A damaged error summary must not turn the read API into a 500 or expose raw storage. } JsonElement? result = null; if (!string.IsNullOrWhiteSpace(job.ResultJson)) { try { result = JsonDocument.Parse(job.ResultJson).RootElement.Clone(); } catch (JsonException) { result = null; } } return new BackgroundJobResponse( job.Id, job.WorkspaceId, job.ActorId, job.Type switch { BackgroundJobType.CourseTagging => "course-tagging", BackgroundJobType.Backup => "backup", _ => "unknown" }, ToStatus(job.Status), job.CreatedAt, job.StartedAt, job.CompletedAt, job.CancelRequestedAt, job.CancelRequestedAt is not null, job.Progress, job.Total, job.Processed, job.Succeeded, job.Skipped, job.Failed, errors, result, job.CorrelationId, job.Version); } private static string ToStatus(BackgroundJobStatus status) => status switch { BackgroundJobStatus.Queued => "queued", BackgroundJobStatus.Running => "running", BackgroundJobStatus.Succeeded => "succeeded", BackgroundJobStatus.PartiallySucceeded => "partially_succeeded", BackgroundJobStatus.Failed => "failed", BackgroundJobStatus.Cancelled => "cancelled", _ => "failed" }; private static Guid CurrentUserId(ClaimsPrincipal principal) => Guid.Parse(principal.FindFirstValue(ClaimTypes.NameIdentifier)!); private static string? CorrelationId(HttpContext context) => context.Items[CorrelationIdMiddleware.ItemKey] as string; }