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