feat(api): add incident management endpoints

This commit is contained in:
2024-12-26 12:00:00 -05:00
parent 929327eca3
commit d4c5f257af

View File

@@ -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<ActionResult<IncidentListResponse>> 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<IncidentStatus>(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<IncidentDto>();
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<ActionResult<IncidentDto>> 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<IIncidentTriggeredJob>(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<ActionResult<IncidentDto>> 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<ActionResult<IReadOnlyList<IncidentEventDto>>> 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<IncidentEventDto>();
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<ActionResult<IncidentDto>> 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, IncidentStatus[]>
{
{ 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<ActionResult<IncidentEventDto>> 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
));
}
}