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;
}