Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ const config: Config = {
'!src/**/*.test.ts',
'!src/generated/**',
],
transform: {
'^.+\\.tsx?$': ['ts-jest', {
tsconfig: {
strict: true,
esModuleInterop: true,
skipLibCheck: true,
types: ['jest', 'node'],
},
}],
},
};

export default config;
14 changes: 14 additions & 0 deletions prisma/migrations/003_incident_timeline.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- Add incident timeline table and indexes

CREATE TABLE IF NOT EXISTS incident_timeline (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
incident_id UUID NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id),
type VARCHAR(50) NOT NULL,
content TEXT NOT NULL,
metadata JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_incident_timeline_incident_created
ON incident_timeline (incident_id, created_at);
46 changes: 46 additions & 0 deletions src/modules/incidents/escalation.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { prisma } from '../../config/database';
import { logger } from '../../config/logger';
import { checkSLABreach } from './sla.service';

// Check high-severity incidents for SLA breaches and trigger escalations
export async function checkAndEscalate() {
const threshold = new Date();
threshold.setHours(threshold.getHours() - 1);

const incidents = await prisma.incident.findMany({
where: {
severity: { in: ['SEV1', 'SEV2'] },
createdAt: { lt: threshold },
},
include: {
project: { select: { id: true, name: true } },
assignedTo: { select: { id: true, name: true, email: true } },
},
});

const escalations = [];

for (const incident of incidents) {
const breached = checkSLABreach(incident.severity, incident.createdAt);
if (breached) {
logger.warn(
{
incidentId: incident.id,
severity: incident.severity,
assignee: incident.assignedTo?.email,
},
'Escalation triggered for SLA breach',
);

escalations.push({
incidentId: incident.id,
severity: incident.severity,
project: incident.project.name,
assignee: incident.assignedTo?.email || 'unassigned',
breachedAt: new Date().toISOString(),
});
}
}

return escalations;
}
127 changes: 127 additions & 0 deletions src/modules/incidents/incidents.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { Response, NextFunction } from 'express';
import * as incidentsService from './incidents.service';
import * as timelineService from './timeline.service';
import * as escalationService from './escalation.service';
import { AuthenticatedRequest } from '../../shared/types';
import { parsePagination } from '../../shared/pagination';

export async function createIncident(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const incident = await incidentsService.createIncident(req.user!.id, req.body);
res.status(201).json(incident);
} catch (err) {
next(err);
}
}

export async function getIncident(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const incident = await incidentsService.getIncident(req.params.id as string);
res.json(incident);
} catch (err) {
next(err);
}
}

export async function listIncidents(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const params = parsePagination(req.query as Record<string, unknown>);
const { projectId, severity, status } = req.query;
const result = await incidentsService.listIncidents(
params,
projectId as string | undefined,
severity as string | undefined,
status as string | undefined,
);
res.json(result);
} catch (err) {
next(err);
}
}

export async function updateIncident(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const incident = await incidentsService.updateIncident(req.params.id as string, req.body);
res.json(incident);
} catch (err) {
next(err);
}
}

export async function updateStatus(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const incident = await incidentsService.updateIncidentStatus(
req.params.id as string,
req.user!.id,
req.body.status,
);
res.json(incident);
} catch (err) {
next(err);
}
}

export async function assignIncident(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const incident = await incidentsService.assignIncident(req.params.id as string, req.body);
res.json(incident);
} catch (err) {
next(err);
}
}

export async function addTimelineEntry(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const entry = await incidentsService.addTimelineEntry(
req.params.id as string,
req.user!.id,
req.body,
);
res.status(201).json(entry);
} catch (err) {
next(err);
}
}

export async function getTimeline(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const timeline = await timelineService.getTimeline(req.params.id as string);
res.json(timeline);
} catch (err) {
next(err);
}
}

export async function searchIncidents(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const { query, severity, status, projectId } = req.query;
const results = await incidentsService.searchIncidents(
query as string | undefined,
severity as string | undefined,
status as string | undefined,
projectId as string | undefined,
);
res.json(results);
} catch (err) {
next(err);
}
}

export async function getMetrics(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const projectId = req.query.projectId as string | undefined;
const metrics = await incidentsService.getIncidentMetrics(projectId);
res.json(metrics);
} catch (err) {
next(err);
}
}

export async function checkEscalations(_req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const escalations = await escalationService.checkAndEscalate();
res.json({ escalations, count: escalations.length });
} catch (err) {
next(err);
}
}
36 changes: 24 additions & 12 deletions src/modules/incidents/incidents.routes.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,32 @@
import { Router } from 'express';
import * as incidentsController from './incidents.controller';
import { authenticate } from '../../middleware/auth';
import { validateRequest } from '../../middleware/validateRequest';
import {
createIncidentSchema,
updateStatusSchema,
assignIncidentSchema,
addTimelineEntrySchema,
} from './incidents.schema';

const router = Router();

// TODO: Implement incident management endpoints
// - POST / Create an incident
// - GET / List incidents (with search, filtering)
// - GET /:id Get incident details with timeline
// - PUT /:id Update incident
// - PUT /:id/status Change incident status
// - PUT /:id/assign Assign incident
// - POST /:id/timeline Add timeline entry
// - GET /metrics Get incident metrics (MTTR, count by severity)
// Incident CRUD
router.get('/search', authenticate, incidentsController.searchIncidents);
router.get('/metrics', authenticate, incidentsController.getMetrics);
router.get('/escalations', authenticate, incidentsController.checkEscalations);
router.get('/', authenticate, incidentsController.listIncidents);
router.get('/:id', authenticate, incidentsController.getIncident);
router.post('/', authenticate, validateRequest(createIncidentSchema), incidentsController.createIncident);

router.get('/', authenticate, (_req, res) => {
res.status(501).json({ error: { code: 'NOT_IMPLEMENTED', message: 'Incident management coming soon' } });
});
router.put('/:id', authenticate, incidentsController.updateIncident);

// Status and assignment
router.put('/:id/status', authenticate, validateRequest(updateStatusSchema), incidentsController.updateStatus);
router.put('/:id/assign', authenticate, validateRequest(assignIncidentSchema), incidentsController.assignIncident);

// Timeline
router.get('/:id/timeline', authenticate, incidentsController.getTimeline);
router.post('/:id/timeline', authenticate, validateRequest(addTimelineEntrySchema), incidentsController.addTimelineEntry);

export default router;
41 changes: 41 additions & 0 deletions src/modules/incidents/incidents.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { z } from 'zod';

export const createIncidentSchema = z.object({
projectId: z.string().uuid(),
title: z.string().min(5).max(200),
description: z.string().min(10).max(5000),
severity: z.enum(['SEV1', 'SEV2', 'SEV3', 'SEV4']),
});

export const updateIncidentSchema = z.object({
title: z.string().min(5).max(200).optional(),
description: z.string().min(10).max(5000).optional(),
severity: z.enum(['SEV1', 'SEV2', 'SEV3', 'SEV4']).optional(),
});

export const updateStatusSchema = z.object({
status: z.enum(['OPEN', 'INVESTIGATING', 'MITIGATING', 'RESOLVED', 'CLOSED']),
});

export const assignIncidentSchema = z.object({
assigneeId: z.string().uuid(),
reportedById: z.string().uuid().optional(),
});

export const addTimelineEntrySchema = z.object({
type: z.enum(['comment', 'status_change', 'assignment', 'severity_change']),
content: z.string().min(1).max(5000),
metadata: z.record(z.string(), z.unknown()).optional(),
});

export const searchIncidentsSchema = z.object({
query: z.string().optional(),
severity: z.string().optional(),
status: z.string().optional(),
projectId: z.string().uuid().optional(),
});

export type CreateIncidentInput = z.infer<typeof createIncidentSchema>;
export type UpdateIncidentInput = z.infer<typeof updateIncidentSchema>;
export type AssignIncidentInput = z.infer<typeof assignIncidentSchema>;
export type AddTimelineEntryInput = z.infer<typeof addTimelineEntrySchema>;
Loading