feat(api): add incident management endpoints
This commit is contained in:
290
src/IncidentOps.Api/Controllers/IncidentsController.cs
Normal file
290
src/IncidentOps.Api/Controllers/IncidentsController.cs
Normal 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
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user