diff --git a/src/IncidentOps.Api/Controllers/IncidentsController.cs b/src/IncidentOps.Api/Controllers/IncidentsController.cs new file mode 100644 index 0000000..9147115 --- /dev/null +++ b/src/IncidentOps.Api/Controllers/IncidentsController.cs @@ -0,0 +1,290 @@ +using Hangfire; +using IncidentOps.Api.Auth; +using IncidentOps.Contracts.Incidents; +using IncidentOps.Domain.Entities; +using IncidentOps.Domain.Enums; +using IncidentOps.Infrastructure.Data.Repositories; +using IncidentOps.Infrastructure.Jobs; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace IncidentOps.Api.Controllers; + +[ApiController] +[Authorize] +public class IncidentsController : ControllerBase +{ + private readonly IIncidentRepository _incidentRepository; + private readonly IIncidentEventRepository _incidentEventRepository; + private readonly IServiceRepository _serviceRepository; + private readonly IUserRepository _userRepository; + private readonly IBackgroundJobClient _backgroundJobClient; + + public IncidentsController( + IIncidentRepository incidentRepository, + IIncidentEventRepository incidentEventRepository, + IServiceRepository serviceRepository, + IUserRepository userRepository, + IBackgroundJobClient backgroundJobClient) + { + _incidentRepository = incidentRepository; + _incidentEventRepository = incidentEventRepository; + _serviceRepository = serviceRepository; + _userRepository = userRepository; + _backgroundJobClient = backgroundJobClient; + } + + [HttpGet("v1/incidents")] + public async Task> GetIncidents( + [FromQuery] string? status = null, + [FromQuery] string? cursor = null, + [FromQuery] int limit = 20) + { + var ctx = User.GetRequestContext(); + + IncidentStatus? statusFilter = null; + if (!string.IsNullOrEmpty(status) && Enum.TryParse(status, ignoreCase: true, out var parsed)) + { + statusFilter = parsed; + } + + var incidents = await _incidentRepository.GetByOrgIdAsync(ctx.OrgId, statusFilter, limit + 1, cursor); + var hasMore = incidents.Count > limit; + var items = incidents.Take(limit).ToList(); + + var dtos = new List(); + foreach (var incident in items) + { + var service = await _serviceRepository.GetByIdAsync(incident.ServiceId, ctx.OrgId); + var assignedUser = incident.AssignedToUserId.HasValue + ? await _userRepository.GetByIdAsync(incident.AssignedToUserId.Value) + : null; + + dtos.Add(new IncidentDto( + incident.Id, + incident.ServiceId, + service?.Name ?? "Unknown", + incident.Title, + incident.Description, + incident.Status.ToString().ToLowerInvariant(), + incident.Version, + incident.AssignedToUserId, + assignedUser?.DisplayName, + incident.CreatedAt, + incident.AcknowledgedAt, + incident.MitigatedAt, + incident.ResolvedAt + )); + } + + var nextCursor = hasMore ? items.Last().CreatedAt.ToString("O") : null; + return new IncidentListResponse(dtos, nextCursor); + } + + [HttpPost("v1/services/{serviceId}/incidents")] + [Authorize(Policy = "Member")] + public async Task> CreateIncident(Guid serviceId, [FromBody] CreateIncidentRequest request) + { + var ctx = User.GetRequestContext(); + + var service = await _serviceRepository.GetByIdAsync(serviceId, ctx.OrgId); + if (service == null) + return NotFound(new { message = "Service not found" }); + + var incident = new Incident + { + Id = Guid.NewGuid(), + OrgId = ctx.OrgId, + ServiceId = serviceId, + Title = request.Title, + Description = request.Description, + Status = IncidentStatus.Triggered, + Version = 1, + CreatedAt = DateTime.UtcNow + }; + await _incidentRepository.CreateAsync(incident); + + var incidentEvent = new IncidentEvent + { + Id = Guid.NewGuid(), + IncidentId = incident.Id, + EventType = IncidentEventType.Created, + ActorUserId = ctx.UserId, + CreatedAt = DateTime.UtcNow + }; + await _incidentEventRepository.CreateAsync(incidentEvent); + + // Enqueue notification job + _backgroundJobClient.Enqueue(j => j.ExecuteAsync(incident.Id)); + + return CreatedAtAction(nameof(GetIncident), new { incidentId = incident.Id }, new IncidentDto( + incident.Id, + incident.ServiceId, + service.Name, + incident.Title, + incident.Description, + incident.Status.ToString().ToLowerInvariant(), + incident.Version, + null, + null, + incident.CreatedAt, + null, + null, + null + )); + } + + [HttpGet("v1/incidents/{incidentId}")] + public async Task> GetIncident(Guid incidentId) + { + var ctx = User.GetRequestContext(); + + var incident = await _incidentRepository.GetByIdAsync(incidentId, ctx.OrgId); + if (incident == null) + return NotFound(); + + var service = await _serviceRepository.GetByIdAsync(incident.ServiceId, ctx.OrgId); + var assignedUser = incident.AssignedToUserId.HasValue + ? await _userRepository.GetByIdAsync(incident.AssignedToUserId.Value) + : null; + + return new IncidentDto( + incident.Id, + incident.ServiceId, + service?.Name ?? "Unknown", + incident.Title, + incident.Description, + incident.Status.ToString().ToLowerInvariant(), + incident.Version, + incident.AssignedToUserId, + assignedUser?.DisplayName, + incident.CreatedAt, + incident.AcknowledgedAt, + incident.MitigatedAt, + incident.ResolvedAt + ); + } + + [HttpGet("v1/incidents/{incidentId}/events")] + public async Task>> GetIncidentEvents(Guid incidentId) + { + var ctx = User.GetRequestContext(); + + var incident = await _incidentRepository.GetByIdAsync(incidentId, ctx.OrgId); + if (incident == null) + return NotFound(); + + var events = await _incidentEventRepository.GetByIncidentIdAsync(incidentId); + + var dtos = new List(); + foreach (var evt in events) + { + var actor = evt.ActorUserId.HasValue + ? await _userRepository.GetByIdAsync(evt.ActorUserId.Value) + : null; + + dtos.Add(new IncidentEventDto( + evt.Id, + evt.EventType.ToString().ToLowerInvariant(), + evt.ActorUserId, + actor?.DisplayName, + evt.Payload, + evt.CreatedAt + )); + } + + return dtos; + } + + [HttpPost("v1/incidents/{incidentId}/transition")] + [Authorize(Policy = "Member")] + public async Task> TransitionIncident(Guid incidentId, [FromBody] TransitionRequest request) + { + var ctx = User.GetRequestContext(); + + var incident = await _incidentRepository.GetByIdAsync(incidentId, ctx.OrgId); + if (incident == null) + return NotFound(); + + var newStatus = request.Action.ToLowerInvariant() switch + { + "ack" or "acknowledge" => IncidentStatus.Acknowledged, + "mitigate" => IncidentStatus.Mitigated, + "resolve" => IncidentStatus.Resolved, + _ => (IncidentStatus?)null + }; + + if (newStatus == null) + return BadRequest(new { message = "Invalid action" }); + + // Validate transition + var validTransitions = new Dictionary + { + { IncidentStatus.Triggered, new[] { IncidentStatus.Acknowledged } }, + { IncidentStatus.Acknowledged, new[] { IncidentStatus.Mitigated } }, + { IncidentStatus.Mitigated, new[] { IncidentStatus.Resolved } } + }; + + if (!validTransitions.TryGetValue(incident.Status, out var allowedStatuses) || !allowedStatuses.Contains(newStatus.Value)) + { + return BadRequest(new { message = $"Cannot transition from {incident.Status} to {newStatus}" }); + } + + var timestamp = DateTime.UtcNow; + var success = await _incidentRepository.TransitionAsync(incidentId, ctx.OrgId, request.ExpectedVersion, newStatus.Value, timestamp); + if (!success) + return Conflict(new { message = "Concurrent modification detected. Please refresh and try again." }); + + var eventType = newStatus.Value switch + { + IncidentStatus.Acknowledged => IncidentEventType.Acknowledged, + IncidentStatus.Mitigated => IncidentEventType.Mitigated, + IncidentStatus.Resolved => IncidentEventType.Resolved, + _ => throw new InvalidOperationException() + }; + + await _incidentEventRepository.CreateAsync(new IncidentEvent + { + Id = Guid.NewGuid(), + IncidentId = incidentId, + EventType = eventType, + ActorUserId = ctx.UserId, + CreatedAt = timestamp + }); + + return await GetIncident(incidentId); + } + + [HttpPost("v1/incidents/{incidentId}/comment")] + [Authorize(Policy = "Member")] + public async Task> AddComment(Guid incidentId, [FromBody] CommentRequest request) + { + var ctx = User.GetRequestContext(); + + var incident = await _incidentRepository.GetByIdAsync(incidentId, ctx.OrgId); + if (incident == null) + return NotFound(); + + var incidentEvent = new IncidentEvent + { + Id = Guid.NewGuid(), + IncidentId = incidentId, + EventType = IncidentEventType.Comment, + ActorUserId = ctx.UserId, + Payload = request.Content, + CreatedAt = DateTime.UtcNow + }; + await _incidentEventRepository.CreateAsync(incidentEvent); + + var user = await _userRepository.GetByIdAsync(ctx.UserId); + + return CreatedAtAction(nameof(GetIncidentEvents), new { incidentId }, new IncidentEventDto( + incidentEvent.Id, + incidentEvent.EventType.ToString().ToLowerInvariant(), + ctx.UserId, + user?.DisplayName, + incidentEvent.Payload, + incidentEvent.CreatedAt + )); + } +}