using System.Security.Claims; using Campaign_Tracker.Server.Audit; using Campaign_Tracker.Server.Authorization; using Campaign_Tracker.Server.ElectionCycles; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Campaign_Tracker.Server.Controllers; [ApiController] [Authorize(Policy = ApplicationPolicy.ClientServicesAccess)] [Route("api/election-cycles/jobs")] public sealed class ElectionCycleJobsController : ControllerBase { private readonly IElectionCycleJobRepository _jobs; private readonly IAuditService _audit; private readonly TimeProvider _timeProvider; public ElectionCycleJobsController( IElectionCycleJobRepository jobs, IAuditService audit, TimeProvider timeProvider) { _jobs = jobs; _audit = audit; _timeProvider = timeProvider; } [HttpPost] public async Task> Create( [FromBody] CreateElectionCycleJobRequest request, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(request.JCode)) return UnprocessableEntity(new ElectionCycleJobProblem("Municipality identifier (JCode) is required.")); if (string.IsNullOrWhiteSpace(request.CycleId) && string.IsNullOrWhiteSpace(request.CycleName)) return UnprocessableEntity(new ElectionCycleJobProblem("Cycle selection is required — provide an existing cycle or a new cycle name.")); var cycleId = string.IsNullOrWhiteSpace(request.CycleId) ? NormalizeNewCycleId(request.CycleName!) : request.CycleId.Trim(); var cycleName = string.IsNullOrWhiteSpace(request.CycleName) ? cycleId // fallback — shouldn't happen given validation above : request.CycleName.Trim(); var actor = GetActor(); var result = await _jobs.CreateAsync( request.JCode, cycleId, cycleName, actor, cancellationToken); if (!result.Saved || result.Job is null) return UnprocessableEntity(new ElectionCycleJobProblem(result.Error ?? "Job creation failed.")); _audit.Record(new AuditEvent( EventType: "ELECTION_CYCLE_JOB_CREATED", ActorIdentity: actor, Resource: $"election-cycles/jobs/{result.Job.JobId}", Outcome: $"created job for {request.JCode} in cycle {cycleName}", TraceIdentifier: HttpContext.TraceIdentifier, RecordedAt: _timeProvider.GetUtcNow())); return CreatedAtAction( nameof(GetById), new { jobId = result.Job.JobId }, ElectionCycleJobResponse.From(result.Job)); } [HttpGet("{jobId}")] public async Task> GetById( string jobId, CancellationToken cancellationToken) { var allAssignments = await _jobs.GetAllAsync(cancellationToken); var match = allAssignments.FirstOrDefault(a => string.Equals(a.JobId, jobId, StringComparison.OrdinalIgnoreCase)); if (match is null) return NotFound(); // For dynamically created jobs we don't have full entity here from assignments; // the kanban read model serves as the source of truth for listing. // This endpoint returns the assignment data mapped to a response shape. return Ok(new ElectionCycleJobResponse( JobId: match.JobId, JCode: match.JCode, CycleId: match.CycleId, CycleName: match.CycleName, Status: match.Status, CreatedBy: "system", CreatedAt: DateTimeOffset.UtcNow.ToString("O"))); } private static string NormalizeNewCycleId(string cycleName) => cycleName.ToLowerInvariant() .Replace(" ", "-") .Replace("'", "") .Replace(",", ""); private string GetActor() => User.Identity?.Name ?? User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown"; } public sealed record CreateElectionCycleJobRequest( string JCode, string? CycleId, string? CycleName); public sealed record ElectionCycleJobResponse( string JobId, string JCode, string CycleId, string CycleName, string Status, string CreatedBy, string CreatedAt) { public static ElectionCycleJobResponse From(ElectionCycleJob job) => new( JobId: job.JobId, JCode: job.JCode, CycleId: job.CycleId, CycleName: job.CycleName, Status: job.Status, CreatedBy: job.CreatedBy, CreatedAt: job.CreatedAt.ToString("O")); } public sealed record ElectionCycleJobProblem(string Error);