using System.Security.Claims; using Campaign_Tracker.Server.Audit; using Campaign_Tracker.Server.Authorization; using Campaign_Tracker.Server.Municipalities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Campaign_Tracker.Server.Controllers; [ApiController] [Authorize(Policy = ApplicationPolicy.ClientServicesAccess)] [Route("api/municipalities/{profileId}/contacts")] public sealed class MunicipalityContactsController : ControllerBase { private readonly IMunicipalityProfileRepository _profiles; private readonly IMunicipalityContactRepository _contacts; private readonly IAuditService _audit; private readonly TimeProvider _timeProvider; public MunicipalityContactsController( IMunicipalityProfileRepository profiles, IMunicipalityContactRepository contacts, IAuditService audit, TimeProvider timeProvider) { _profiles = profiles; _contacts = contacts; _audit = audit; _timeProvider = timeProvider; } [HttpGet] public async Task>> GetAll( string profileId, CancellationToken cancellationToken) { if (!await ProfileExists(profileId, cancellationToken)) return NotFound(); var contacts = await _contacts.GetByProfileIdAsync(profileId, cancellationToken); return Ok(contacts.Select(MunicipalityContactResponse.From).ToArray()); } [HttpGet("{contactId}")] public async Task> GetById( string profileId, string contactId, CancellationToken cancellationToken) { var contact = await _contacts.GetByIdAsync(contactId, cancellationToken); return IsContactInProfile(contact, profileId) ? Ok(MunicipalityContactResponse.From(contact!)) : NotFound(); } [HttpPost] public async Task> Add( string profileId, [FromBody] AddMunicipalityContactRequest request, CancellationToken cancellationToken) { if (!await ProfileExists(profileId, cancellationToken)) return NotFound(); var actor = GetActor(); var result = await _contacts.AddAsync( profileId, request.ContactType, request.Name, request.RoleTitle, request.Phone, request.Email, actor, cancellationToken); if (!result.Saved || result.Contact is null) return UnprocessableEntity(new MunicipalityContactProblem(result.Error ?? "Save failed.")); _audit.Record(new AuditEvent( EventType: "MUNICIPALITY_CONTACT_ADDED", ActorIdentity: actor, Resource: $"municipalities/{profileId}/contacts/{result.Contact.ContactId}", Outcome: $"added {result.Contact.ContactType} contact", TraceIdentifier: HttpContext.TraceIdentifier, RecordedAt: _timeProvider.GetUtcNow())); return CreatedAtAction(nameof(GetById), new { profileId, contactId = result.Contact.ContactId }, MunicipalityContactResponse.From(result.Contact)); } [HttpPut("{contactId}")] public async Task> Update( string profileId, string contactId, [FromBody] UpdateMunicipalityContactRequest request, CancellationToken cancellationToken) { var existing = await _contacts.GetByIdAsync(contactId, cancellationToken); if (!IsContactInProfile(existing, profileId)) return NotFound(new MunicipalityContactProblem("Contact not found.")); var actor = GetActor(); var result = await _contacts.UpdateAsync( contactId, request.ContactType, request.Name, request.RoleTitle, request.Phone, request.Email, actor, cancellationToken); if (!result.Saved || result.Contact is null) { if (result.IsNotFound) return NotFound(new MunicipalityContactProblem(result.Error ?? "Contact not found.")); return UnprocessableEntity(new MunicipalityContactProblem(result.Error ?? "Update failed.")); } _audit.Record(new AuditEvent( EventType: "MUNICIPALITY_CONTACT_UPDATED", ActorIdentity: actor, Resource: $"municipalities/{profileId}/contacts/{contactId}", Outcome: $"updated {result.Contact.ContactType} contact", TraceIdentifier: HttpContext.TraceIdentifier, RecordedAt: _timeProvider.GetUtcNow())); return Ok(MunicipalityContactResponse.From(result.Contact)); } [HttpDelete("{contactId}")] public async Task Delete( string profileId, string contactId, CancellationToken cancellationToken) { var existing = await _contacts.GetByIdAsync(contactId, cancellationToken); if (!IsContactInProfile(existing, profileId)) return NotFound(); var actor = GetActor(); var result = await _contacts.SoftDeleteAsync(contactId, actor, cancellationToken); if (!result.Saved) return result.IsNotFound ? NotFound() : UnprocessableEntity(); _audit.Record(new AuditEvent( EventType: "MUNICIPALITY_CONTACT_DELETED", ActorIdentity: actor, Resource: $"municipalities/{profileId}/contacts/{contactId}", Outcome: "soft-deleted", TraceIdentifier: HttpContext.TraceIdentifier, RecordedAt: _timeProvider.GetUtcNow())); return NoContent(); } private string GetActor() => User.Identity?.Name ?? User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown"; private async Task ProfileExists( string profileId, CancellationToken cancellationToken) => await _profiles.GetByIdAsync(profileId, cancellationToken) is not null; private static bool IsContactInProfile( MunicipalityContact? contact, string profileId) => contact is not null && string.Equals(contact.ProfileId, profileId, StringComparison.OrdinalIgnoreCase); } public sealed record AddMunicipalityContactRequest( string ContactType, string Name, string? RoleTitle, string? Phone, string? Email); public sealed record UpdateMunicipalityContactRequest( string ContactType, string Name, string? RoleTitle, string? Phone, string? Email); public sealed record MunicipalityContactResponse( string ContactId, string ProfileId, string ContactType, string Name, string? RoleTitle, string? Phone, string? Email, string CreatedAt, string CreatedBy, string UpdatedAt, string UpdatedBy) { public static MunicipalityContactResponse From(MunicipalityContact c) => new(c.ContactId, c.ProfileId, c.ContactType, c.Name, c.RoleTitle, c.Phone, c.Email, c.CreatedAt.ToString("O"), c.CreatedBy, c.UpdatedAt.ToString("O"), c.UpdatedBy); } public sealed record MunicipalityContactProblem(string Error);