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