Phase 2b: backend services, routes, and notification triggers

Attachments: multer-backed uploads with random-hex filenames,
streaming downloads with Content-Disposition, 25MB limit,
mimetype allowlist, audit entries, orphan cleanup on DB failure.

Full-text search: searchTicketIds + searchComments via raw SQL
ranked with ts_rank, composable filters via Prisma.sql/join,
hydrated with findMany and reordered via Map to preserve rank.

Pagination: listTicketsPaged returns {data,total,page,pageSize}
only when page/pageSize present (array response stays default,
so the Goddard n8n flow is unchanged).

Bulk actions: reassign/close/setSeverity/setStatus on POST /bulk,
writes one audit entry per ticket via createMany.

Analytics: summarize(window) runs 5 parallel groupBy + raw-SQL
queries for open-by-severity, status counts, queue load,
age buckets, percentile_cont median resolution hours.

CSV export streams matching tickets via res.write; saved views
CRUD with per-user ownership checks (403 cross-user, 404 missing).

Notifications: in-app Notification rows gated by prefs, email via
nodemailer (SMTP_HOST-gated, no-op when unset), outgoing webhooks
with HMAC-SHA256 signed POST and 3-retry exponential backoff.
Triggers wired into createTicket/updateTicket/addComment; mention
detection via parseMentions skips self-notify.

Infra: docker-compose uploads volume + SMTP env passthrough;
.env.example SMTP section.

43 server tests passing (attachment/webhook/notification/savedView
services covered; bulkAction covered in ticketService).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 15:56:33 -04:00
parent 0806aec4a4
commit edf4c5eb3c
25 changed files with 1582 additions and 36 deletions
+14
View File
@@ -9,6 +9,13 @@ import authRoutes from './routes/auth';
import ticketRoutes from './routes/tickets';
import ctiRoutes from './routes/cti';
import userRoutes from './routes/users';
import attachmentRoutes from './routes/attachments';
import searchRoutes from './routes/search';
import analyticsRoutes from './routes/analytics';
import exportRoutes from './routes/export';
import savedViewRoutes from './routes/savedViews';
import notificationRoutes from './routes/notifications';
import webhookRoutes from './routes/webhooks';
import { authenticate } from './middleware/auth';
import { errorHandler } from './middleware/errorHandler';
import { startAutoCloseJob } from './jobs/autoClose';
@@ -43,6 +50,13 @@ app.use('/api/auth', authRoutes);
app.use('/api/tickets', authenticate, ticketRoutes);
app.use('/api/cti', authenticate, ctiRoutes);
app.use('/api/users', authenticate, userRoutes);
app.use('/api', attachmentRoutes); // self-mounts authenticate inside
app.use('/api/search', authenticate, searchRoutes);
app.use('/api/analytics', authenticate, analyticsRoutes);
app.use('/api/export', authenticate, exportRoutes);
app.use('/api/saved-views', authenticate, savedViewRoutes);
app.use('/api/notifications', authenticate, notificationRoutes);
app.use('/api/webhooks', authenticate, webhookRoutes);
app.use(errorHandler);
+14
View File
@@ -0,0 +1,14 @@
import { Router } from 'express';
import * as analyticsService from '../services/analyticsService';
const router = Router();
router.get('/summary', async (req, res) => {
const raw = Number(req.query.window);
const window: analyticsService.AnalyticsWindow =
raw === 14 || raw === 30 || raw === 90 ? raw : 30;
const summary = await analyticsService.summarize(window);
res.json(summary);
});
export default router;
+190
View File
@@ -0,0 +1,190 @@
import { Router, Request } from 'express';
import multer from 'multer';
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { AuthRequest, authenticate } from '../middleware/auth';
import { HttpError } from '../lib/httpError';
import * as attachmentService from '../services/attachmentService';
import * as ticketService from '../services/ticketService';
import prisma from '../lib/prisma';
import { ATTACHMENT_MAX_BYTES } from '../../../shared/schemas/attachment';
const storage = multer.diskStorage({
destination: (_req, _file, cb) => {
attachmentService
.ensureUploadsDir()
.then(() => cb(null, attachmentService.UPLOADS_DIR))
.catch((err: Error) => cb(err, ''));
},
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname).slice(0, 20);
const safeBase = crypto.randomBytes(16).toString('hex');
cb(null, `${safeBase}${ext}`);
},
});
const upload = multer({
storage,
limits: { fileSize: ATTACHMENT_MAX_BYTES },
});
async function cleanup(file?: Express.Multer.File) {
if (!file) return;
await import('node:fs/promises').then((fsp) => fsp.unlink(file.path).catch(() => undefined));
}
const router = Router();
router.use(authenticate);
router.post(
'/tickets/:ticketId/attachments',
upload.single('file'),
async (req: AuthRequest, res) => {
const file = req.file;
if (!file) throw new HttpError(400, 'No file provided');
try {
attachmentService.validateUpload(file);
const ticket = await ticketService.findByIdOrDisplay(req.params.ticketId);
if (!ticket) throw new HttpError(404, 'Ticket not found');
const attachment = await prisma.$transaction(async (tx) => {
const created = await tx.attachment.create({
data: {
filename: file.originalname,
mimetype: file.mimetype,
size: file.size,
storagePath: path.relative(attachmentService.UPLOADS_DIR, file.path),
uploadedById: req.user!.id,
ticketId: ticket.id,
},
include: {
uploadedBy: { select: { id: true, username: true, displayName: true } },
},
});
await tx.auditLog.create({
data: {
ticketId: ticket.id,
userId: req.user!.id,
action: 'ATTACHMENT_ADDED',
detail: file.originalname,
},
});
return created;
});
res.status(201).json(attachment);
} catch (err) {
await cleanup(file);
throw err;
}
},
);
router.post(
'/comments/:commentId/attachments',
upload.single('file'),
async (req: AuthRequest, res) => {
const file = req.file;
if (!file) throw new HttpError(400, 'No file provided');
try {
attachmentService.validateUpload(file);
const comment = await prisma.comment.findUnique({
where: { id: req.params.commentId },
select: { id: true, ticketId: true },
});
if (!comment) throw new HttpError(404, 'Comment not found');
const attachment = await prisma.$transaction(async (tx) => {
const created = await tx.attachment.create({
data: {
filename: file.originalname,
mimetype: file.mimetype,
size: file.size,
storagePath: path.relative(attachmentService.UPLOADS_DIR, file.path),
uploadedById: req.user!.id,
commentId: comment.id,
},
include: {
uploadedBy: { select: { id: true, username: true, displayName: true } },
},
});
await tx.auditLog.create({
data: {
ticketId: comment.ticketId,
userId: req.user!.id,
action: 'ATTACHMENT_ADDED',
detail: file.originalname,
},
});
return created;
});
res.status(201).json(attachment);
} catch (err) {
await cleanup(file);
throw err;
}
},
);
router.get('/tickets/:ticketId/attachments', async (req, res) => {
const ticket = await ticketService.findByIdOrDisplay(req.params.ticketId);
if (!ticket) throw new HttpError(404, 'Ticket not found');
const attachments = await attachmentService.listForTicket(ticket.id);
res.json(attachments);
});
router.get('/attachments/:id', async (req: Request, res) => {
const attachment = await attachmentService.getAttachment(req.params.id);
const abs = attachmentService.absolutePathFor(attachment.storagePath);
res.setHeader('Content-Type', attachment.mimetype);
res.setHeader('Content-Length', String(attachment.size));
res.setHeader(
'Content-Disposition',
`inline; filename="${encodeURIComponent(attachment.filename)}"`,
);
const stream = fs.createReadStream(abs);
stream.on('error', (err) => {
if (!res.headersSent) res.status(404).json({ error: 'File not found on disk' });
else res.destroy(err);
});
stream.pipe(res);
});
router.delete('/attachments/:id', async (req: AuthRequest, res) => {
const attachment = await attachmentService.deleteAttachment(req.params.id, {
id: req.user!.id,
role: req.user!.role,
});
const ticketId =
attachment.ticketId ??
(attachment.commentId
? (
await prisma.comment.findUnique({
where: { id: attachment.commentId },
select: { ticketId: true },
})
)?.ticketId
: null);
if (ticketId) {
await prisma.auditLog.create({
data: {
ticketId,
userId: req.user!.id,
action: 'ATTACHMENT_REMOVED',
detail: attachment.filename,
},
});
}
res.status(204).send();
});
export default router;
+66
View File
@@ -0,0 +1,66 @@
import { Router } from 'express';
import * as ticketService from '../services/ticketService';
const router = Router();
function csvEscape(v: unknown): string {
if (v === null || v === undefined) return '';
const s = String(v);
if (/[",\r\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
return s;
}
router.get('/tickets.csv', async (req, res) => {
const { status, severity, assigneeId, categoryId, typeId, itemId, search } = req.query;
const tickets = await ticketService.listTickets({
status: status as string | undefined,
severity: severity ? Number(severity) : undefined,
assigneeId: assigneeId as string | undefined,
categoryId: categoryId as string | undefined,
typeId: typeId as string | undefined,
itemId: itemId as string | undefined,
search: search as string | undefined,
});
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader(
'Content-Disposition',
`attachment; filename="tickets-${new Date().toISOString().slice(0, 10)}.csv"`,
);
const header = [
'displayId',
'title',
'status',
'severity',
'category',
'type',
'item',
'assignee',
'createdBy',
'createdAt',
'resolvedAt',
];
res.write(header.join(',') + '\n');
for (const t of tickets) {
const row = [
t.displayId,
t.title,
t.status,
t.severity,
t.category?.name,
t.type?.name,
t.item?.name,
t.assignee?.displayName ?? '',
t.createdBy?.displayName ?? '',
t.createdAt.toISOString(),
t.resolvedAt ? t.resolvedAt.toISOString() : '',
];
res.write(row.map(csvEscape).join(',') + '\n');
}
res.end();
});
export default router;
+43
View File
@@ -0,0 +1,43 @@
import { Router } from 'express';
import { AuthRequest } from '../middleware/auth';
import {
markReadSchema,
notificationPrefsSchema,
} from '../../../shared/schemas/notification';
import * as notificationService from '../services/notificationService';
const router = Router();
router.get('/', async (req: AuthRequest, res) => {
const unreadOnly = req.query.unread === 'true';
const limit = req.query.limit ? Math.min(200, Number(req.query.limit)) : undefined;
const notifications = await notificationService.listForUser(req.user!.id, {
unreadOnly,
limit,
});
res.json(notifications);
});
router.get('/unread-count', async (req: AuthRequest, res) => {
const count = await notificationService.unreadCount(req.user!.id);
res.json({ count });
});
router.post('/read', async (req: AuthRequest, res) => {
const { ids, all } = markReadSchema.parse(req.body);
const updated = await notificationService.markRead(req.user!.id, ids, all);
res.json({ updated });
});
router.get('/prefs', async (req: AuthRequest, res) => {
const prefs = await notificationService.getPrefs(req.user!.id);
res.json(prefs);
});
router.put('/prefs', async (req: AuthRequest, res) => {
const prefs = notificationPrefsSchema.parse(req.body);
const saved = await notificationService.updatePrefs(req.user!.id, prefs);
res.json(saved);
});
export default router;
+33
View File
@@ -0,0 +1,33 @@
import { Router } from 'express';
import { AuthRequest } from '../middleware/auth';
import {
createSavedViewSchema,
updateSavedViewSchema,
} from '../../../shared/schemas/savedView';
import * as savedViewService from '../services/savedViewService';
const router = Router();
router.get('/', async (req: AuthRequest, res) => {
const views = await savedViewService.listForUser(req.user!.id);
res.json(views);
});
router.post('/', async (req: AuthRequest, res) => {
const data = createSavedViewSchema.parse(req.body);
const view = await savedViewService.createView(req.user!.id, data);
res.status(201).json(view);
});
router.patch('/:id', async (req: AuthRequest, res) => {
const data = updateSavedViewSchema.parse(req.body);
const view = await savedViewService.updateView(req.params.id, req.user!.id, data);
res.json(view);
});
router.delete('/:id', async (req: AuthRequest, res) => {
await savedViewService.deleteView(req.params.id, req.user!.id);
res.status(204).send();
});
export default router;
+30
View File
@@ -0,0 +1,30 @@
import { Router } from 'express';
import * as searchService from '../services/searchService';
import * as ticketService from '../services/ticketService';
const router = Router();
router.get('/', async (req, res) => {
const q = (req.query.q as string | undefined)?.trim();
if (!q) {
res.json({ tickets: [], comments: [] });
return;
}
const [ticketResult, comments] = await Promise.all([
searchService.searchTicketIds(q, {}, 25, 0),
searchService.searchComments(q, 25),
]);
const tickets =
ticketResult.ids.length > 0
? await ticketService.listTickets({ search: q })
: [];
res.json({
tickets: tickets.slice(0, 25),
comments,
});
});
export default router;
+42 -4
View File
@@ -1,7 +1,11 @@
import { Router } from 'express';
import { requireAdmin, requireAgent, AuthRequest } from '../middleware/auth';
import commentRouter from './comments';
import { createTicketSchema, updateTicketSchema } from '../../../shared/schemas/ticket';
import {
createTicketSchema,
updateTicketSchema,
bulkActionSchema,
} from '../../../shared/schemas/ticket';
import * as ticketService from '../services/ticketService';
const router = Router();
@@ -9,16 +13,41 @@ const router = Router();
router.use('/:ticketId/comments', commentRouter);
router.get('/', async (req: AuthRequest, res) => {
const { status, severity, assigneeId, categoryId, typeId, itemId, search } = req.query;
const tickets = await ticketService.listTickets({
const {
status,
severity,
assigneeId,
createdById,
categoryId,
typeId,
itemId,
search,
page,
pageSize,
} = req.query;
const filters = {
status: status as string | undefined,
severity: severity ? Number(severity) : undefined,
assigneeId: assigneeId as string | undefined,
createdById: createdById as string | undefined,
categoryId: categoryId as string | undefined,
typeId: typeId as string | undefined,
itemId: itemId as string | undefined,
search: search as string | undefined,
});
};
const paginated = page !== undefined || pageSize !== undefined;
if (paginated) {
const result = await ticketService.listTicketsPaged(filters, {
page: page ? Number(page) : undefined,
pageSize: pageSize ? Number(pageSize) : undefined,
});
res.json(result);
return;
}
const tickets = await ticketService.listTickets(filters);
res.json(tickets);
});
@@ -32,6 +61,15 @@ router.get('/:id/audit', async (req, res) => {
res.json(logs);
});
router.post('/bulk', requireAgent, async (req: AuthRequest, res) => {
const data = bulkActionSchema.parse(req.body);
const result = await ticketService.bulkAction(data, {
id: req.user!.id,
role: req.user!.role,
});
res.json(result);
});
router.post('/', requireAgent, async (req: AuthRequest, res) => {
const data = createTicketSchema.parse(req.body);
const ticket = await ticketService.createTicket(data, req.user!.id);
+40
View File
@@ -0,0 +1,40 @@
import { Router } from 'express';
import { requireAdmin } from '../middleware/auth';
import {
createWebhookSchema,
updateWebhookSchema,
} from '../../../shared/schemas/notification';
import * as webhookService from '../services/webhookService';
const router = Router();
router.use(requireAdmin);
router.get('/', async (_req, res) => {
const hooks = await webhookService.listWebhooks();
res.json(hooks);
});
router.post('/', async (req, res) => {
const data = createWebhookSchema.parse(req.body);
const hook = await webhookService.createWebhook(data);
res.status(201).json(hook); // secret is returned once on creation
});
router.patch('/:id', async (req, res) => {
const data = updateWebhookSchema.parse(req.body);
const hook = await webhookService.updateWebhook(req.params.id, data);
res.json(hook);
});
router.post('/:id/rotate-secret', async (req, res) => {
const hook = await webhookService.rotateSecret(req.params.id);
res.json(hook); // secret returned in full so admin can save new value
});
router.delete('/:id', async (req, res) => {
await webhookService.deleteWebhook(req.params.id);
res.status(204).send();
});
export default router;
+81
View File
@@ -0,0 +1,81 @@
import { Prisma } from '@prisma/client';
import prisma from '../lib/prisma';
export type AnalyticsWindow = 14 | 30 | 90;
export async function summarize(window: AnalyticsWindow = 30) {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - window);
const [
openBySeverity,
statusCounts,
queueByAssignee,
ageBuckets,
medianResolution,
] = await Promise.all([
prisma.ticket.groupBy({
by: ['severity'],
where: { status: { in: ['OPEN', 'IN_PROGRESS'] } },
_count: { _all: true },
orderBy: { severity: 'asc' },
}),
prisma.ticket.groupBy({
by: ['status'],
_count: { _all: true },
}),
prisma.ticket.groupBy({
by: ['assigneeId'],
where: { status: { in: ['OPEN', 'IN_PROGRESS'] } },
_count: { _all: true },
}),
ageBucketsQuery(),
medianResolutionQuery(cutoff),
]);
return {
windowDays: window,
openBySeverity: openBySeverity.map((r) => ({
severity: r.severity,
count: r._count._all,
})),
statusCounts: statusCounts.map((r) => ({ status: r.status, count: r._count._all })),
queueByAssignee: queueByAssignee.map((r) => ({
assigneeId: r.assigneeId,
count: r._count._all,
})),
ageBuckets,
medianResolutionHours: medianResolution,
};
}
async function ageBucketsQuery() {
const rows = await prisma.$queryRaw<{ bucket: string; count: bigint }[]>(Prisma.sql`
SELECT bucket, COUNT(*)::bigint AS count FROM (
SELECT CASE
WHEN AGE(now(), "createdAt") <= interval '1 day' THEN 'd1'
WHEN AGE(now(), "createdAt") <= interval '7 days' THEN 'd7'
WHEN AGE(now(), "createdAt") <= interval '14 days' THEN 'd14'
ELSE 'older'
END AS bucket
FROM "Ticket"
WHERE "status" IN ('OPEN', 'IN_PROGRESS')
) t
GROUP BY bucket
`);
const counts: Record<string, number> = { d1: 0, d7: 0, d14: 0, older: 0 };
for (const row of rows) counts[row.bucket] = Number(row.count);
return counts;
}
async function medianResolutionQuery(cutoff: Date): Promise<number | null> {
const rows = await prisma.$queryRaw<{ median_hours: number | null }[]>(Prisma.sql`
SELECT percentile_cont(0.5) WITHIN GROUP (
ORDER BY EXTRACT(EPOCH FROM ("resolvedAt" - "createdAt")) / 3600
)::float8 AS median_hours
FROM "Ticket"
WHERE "resolvedAt" IS NOT NULL
AND "resolvedAt" >= ${cutoff}
`);
return rows[0]?.median_hours ?? null;
}
@@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest';
import { prismaMock } from '../test/setup';
import * as attachmentService from './attachmentService';
import { HttpError } from '../lib/httpError';
const now = new Date();
const fixture = {
id: 'a1',
filename: 'report.pdf',
mimetype: 'application/pdf',
size: 1024,
storagePath: 'deadbeef.pdf',
ticketId: 't1',
commentId: null,
uploadedById: 'u1',
createdAt: now,
};
describe('attachmentService.validateUpload', () => {
it('accepts files within limits and on the allowlist', () => {
expect(() =>
attachmentService.validateUpload({ mimetype: 'image/png', size: 1024 }),
).not.toThrow();
});
it('rejects files exceeding size limit', () => {
expect(() =>
attachmentService.validateUpload({ mimetype: 'image/png', size: 100 * 1024 * 1024 }),
).toThrow(HttpError);
});
it('rejects disallowed mimetypes', () => {
expect(() =>
attachmentService.validateUpload({ mimetype: 'application/x-msdownload', size: 1024 }),
).toThrow(HttpError);
});
});
describe('attachmentService.deleteAttachment', () => {
it('allows the uploader to delete their own file', async () => {
prismaMock.attachment.findUnique.mockResolvedValue(fixture);
prismaMock.attachment.delete.mockResolvedValue(fixture);
await expect(
attachmentService.deleteAttachment('a1', { id: 'u1', role: 'AGENT' }),
).resolves.toMatchObject({ id: 'a1' });
});
it('allows admins to delete any attachment', async () => {
prismaMock.attachment.findUnique.mockResolvedValue(fixture);
prismaMock.attachment.delete.mockResolvedValue(fixture);
await expect(
attachmentService.deleteAttachment('a1', { id: 'someone-else', role: 'ADMIN' }),
).resolves.toMatchObject({ id: 'a1' });
});
it('rejects non-uploaders with a 403', async () => {
prismaMock.attachment.findUnique.mockResolvedValue(fixture);
await expect(
attachmentService.deleteAttachment('a1', { id: 'someone-else', role: 'AGENT' }),
).rejects.toMatchObject({ status: 403 });
});
it('throws 404 when attachment is missing', async () => {
prismaMock.attachment.findUnique.mockResolvedValue(null);
await expect(
attachmentService.deleteAttachment('missing', { id: 'u1', role: 'AGENT' }),
).rejects.toMatchObject({ status: 404 });
});
});
+94
View File
@@ -0,0 +1,94 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import prisma from '../lib/prisma';
import { HttpError } from '../lib/httpError';
import { ATTACHMENT_MAX_BYTES, ATTACHMENT_MIME_ALLOWLIST } from '../../../shared/schemas/attachment';
export const UPLOADS_DIR = process.env.UPLOADS_DIR
? path.resolve(process.env.UPLOADS_DIR)
: path.resolve(__dirname, '../../uploads');
export async function ensureUploadsDir(): Promise<void> {
await fs.mkdir(UPLOADS_DIR, { recursive: true });
}
const attachmentInclude = {
uploadedBy: { select: { id: true, username: true, displayName: true } },
} as const;
export function validateUpload(file: { mimetype: string; size: number }): void {
if (file.size > ATTACHMENT_MAX_BYTES) {
throw new HttpError(413, `File exceeds ${ATTACHMENT_MAX_BYTES / 1024 / 1024}MB limit`);
}
if (!ATTACHMENT_MIME_ALLOWLIST.includes(file.mimetype)) {
throw new HttpError(415, `Unsupported file type: ${file.mimetype}`);
}
}
type CreateAttachmentInput = {
filename: string;
mimetype: string;
size: number;
storagePath: string;
uploadedById: string;
ticketId?: string;
commentId?: string;
};
export async function createAttachment(input: CreateAttachmentInput) {
if (!input.ticketId && !input.commentId) {
throw new HttpError(400, 'Attachment must belong to a ticket or comment');
}
return prisma.attachment.create({
data: input,
include: attachmentInclude,
});
}
export async function getAttachment(id: string) {
const a = await prisma.attachment.findUnique({
where: { id },
include: attachmentInclude,
});
if (!a) throw new HttpError(404, 'Attachment not found');
return a;
}
export async function listForTicket(ticketId: string) {
return prisma.attachment.findMany({
where: {
OR: [
{ ticketId },
{ comment: { ticketId } },
],
},
include: attachmentInclude,
orderBy: { createdAt: 'asc' },
});
}
export async function deleteAttachment(id: string, actor: { id: string; role: string }) {
const a = await prisma.attachment.findUnique({ where: { id } });
if (!a) throw new HttpError(404, 'Attachment not found');
if (a.uploadedById !== actor.id && actor.role !== 'ADMIN') {
throw new HttpError(403, 'Only the uploader or an admin can delete attachments');
}
await prisma.attachment.delete({ where: { id } });
const abs = path.resolve(UPLOADS_DIR, a.storagePath);
if (!abs.startsWith(UPLOADS_DIR)) {
throw new HttpError(500, 'Attachment path escapes uploads dir');
}
await fs.unlink(abs).catch(() => undefined);
return a;
}
export function absolutePathFor(storagePath: string): string {
const abs = path.resolve(UPLOADS_DIR, storagePath);
if (!abs.startsWith(UPLOADS_DIR)) {
throw new HttpError(500, 'Attachment path escapes uploads dir');
}
return abs;
}
+19
View File
@@ -1,5 +1,7 @@
import prisma from '../lib/prisma';
import { HttpError } from '../lib/httpError';
import * as notificationService from './notificationService';
import { logger } from '../lib/logger';
export async function addComment(ticketIdOrDisplay: string, body: string, actorId: string) {
const ticket = await prisma.ticket.findFirst({
@@ -17,6 +19,23 @@ export async function addComment(ticketIdOrDisplay: string, body: string, actorI
}),
]);
notificationService
.notifyCommentCreated(ticket.id, comment.id)
.catch((err: Error) => logger.error({ err }, 'notifyCommentCreated failed'));
const usernames = notificationService.parseMentions(body);
if (usernames.length > 0) {
prisma.user
.findMany({ where: { username: { in: usernames } }, select: { id: true } })
.then((users) => {
const ids = users.map((u) => u.id).filter((id) => id !== actorId);
if (ids.length > 0) {
return notificationService.notifyMention(ticket.id, comment.id, ids);
}
})
.catch((err: Error) => logger.error({ err }, 'mention notification failed'));
}
return comment;
}
+48
View File
@@ -0,0 +1,48 @@
import nodemailer, { Transporter } from 'nodemailer';
import { logger } from '../lib/logger';
let transporter: Transporter | null = null;
export function getTransporter(): Transporter | null {
if (transporter) return transporter;
if (!process.env.SMTP_HOST) return null;
transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT) || 587,
secure: process.env.SMTP_SECURE === 'true',
auth: process.env.SMTP_USER
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
: undefined,
});
return transporter;
}
export async function sendMail(
to: string,
subject: string,
text: string,
html?: string,
): Promise<void> {
const t = getTransporter();
if (!t) {
logger.debug({ to, subject }, 'SMTP not configured; skipping email');
return;
}
try {
await t.sendMail({
from: process.env.SMTP_FROM ?? 'noreply@localhost',
to,
subject,
text,
html,
});
} catch (err) {
logger.error({ err, to, subject }, 'Failed to send email');
}
}
// Exposed for tests
export function __reset(): void {
transporter = null;
}
@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest';
import { prismaMock } from '../test/setup';
import { parseMentions, markRead } from './notificationService';
describe('notificationService.parseMentions', () => {
it('extracts @usernames from a comment body', () => {
expect(parseMentions('cc @alice please look. also @bob.')).toEqual(['alice', 'bob']);
});
it('deduplicates the same mention', () => {
expect(parseMentions('@alice and @alice again')).toEqual(['alice']);
});
it('ignores email addresses (@ following word char)', () => {
expect(parseMentions('ping bob@example.com thanks')).toEqual([]);
});
it('returns empty when no mentions', () => {
expect(parseMentions('no mentions here')).toEqual([]);
});
});
describe('notificationService.markRead', () => {
it('requires ids when not marking all', async () => {
await expect(markRead('u1')).rejects.toMatchObject({ status: 400 });
});
it('marks all unread when all=true', async () => {
prismaMock.notification.updateMany.mockResolvedValue({ count: 5 });
const n = await markRead('u1', undefined, true);
expect(n).toBe(5);
expect(prismaMock.notification.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ userId: 'u1', readAt: null }),
}),
);
});
it('marks specific ids read', async () => {
prismaMock.notification.updateMany.mockResolvedValue({ count: 2 });
const n = await markRead('u1', ['n1', 'n2']);
expect(n).toBe(2);
expect(prismaMock.notification.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ id: { in: ['n1', 'n2'] } }),
}),
);
});
});
+227 -12
View File
@@ -1,22 +1,237 @@
// Stub — filled in Phase 2 (email, webhooks, in-app).
// Routes/services call these hooks; implementations land later.
import { Prisma } from '@prisma/client';
import prisma from '../lib/prisma';
import { logger } from '../lib/logger';
import { HttpError } from '../lib/httpError';
import { sendMail } from './emailService';
import * as webhookService from './webhookService';
import type { NotificationPrefs } from '../../../shared/schemas/notification';
export async function notifyAssigned(_ticketId: string, _assigneeId: string): Promise<void> {
return;
const DEFAULT_PREFS: NotificationPrefs = {
email: { assignment: true, mention: true, resolved: false },
inApp: { assignment: true, mention: true, resolved: true },
};
function mergePrefs(raw: unknown): NotificationPrefs {
if (!raw || typeof raw !== 'object') return DEFAULT_PREFS;
const r = raw as Partial<NotificationPrefs>;
return {
email: { ...DEFAULT_PREFS.email, ...(r.email ?? {}) },
inApp: { ...DEFAULT_PREFS.inApp, ...(r.inApp ?? {}) },
};
}
type NotifyKind = 'assignment' | 'mention' | 'status.resolved' | 'comment';
type NotifyInput = {
userId: string;
kind: NotifyKind;
ticketId?: string;
commentId?: string;
subject: string;
body: string;
data?: Record<string, unknown>;
};
async function deliver(input: NotifyInput): Promise<void> {
const user = await prisma.user.findUnique({
where: { id: input.userId },
select: { email: true, notificationPrefs: true },
});
if (!user) return;
const prefs = mergePrefs(user.notificationPrefs);
const inAppCategory: keyof NotificationPrefs['inApp'] =
input.kind === 'assignment'
? 'assignment'
: input.kind === 'mention'
? 'mention'
: 'resolved';
if (prefs.inApp[inAppCategory]) {
await prisma.notification
.create({
data: {
userId: input.userId,
kind: input.kind,
ticketId: input.ticketId ?? null,
commentId: input.commentId ?? null,
data: (input.data ?? {}) as Prisma.InputJsonValue,
},
})
.catch((err: Error) =>
logger.error({ err, userId: input.userId }, 'Failed to write notification'),
);
}
const emailCategory = inAppCategory;
if (prefs.email[emailCategory] && user.email) {
await sendMail(user.email, input.subject, input.body);
}
}
export async function notifyAssigned(ticketId: string, assigneeId: string): Promise<void> {
const ticket = await prisma.ticket.findUnique({
where: { id: ticketId },
select: { displayId: true, title: true },
});
if (!ticket) return;
await deliver({
userId: assigneeId,
kind: 'assignment',
ticketId,
subject: `Assigned: ${ticket.displayId}${ticket.title}`,
body: `You have been assigned ticket ${ticket.displayId}: ${ticket.title}`,
});
await webhookService
.dispatch('ticket.assigned', { ticketId, assigneeId })
.catch((err: Error) => logger.error({ err }, 'Webhook dispatch failed'));
}
export async function notifyResolved(ticketId: string): Promise<void> {
const ticket = await prisma.ticket.findUnique({
where: { id: ticketId },
select: { displayId: true, title: true, assigneeId: true, createdById: true },
});
if (!ticket) return;
const recipients = new Set<string>();
if (ticket.assigneeId) recipients.add(ticket.assigneeId);
if (ticket.createdById) recipients.add(ticket.createdById);
await Promise.all(
Array.from(recipients).map((userId) =>
deliver({
userId,
kind: 'status.resolved',
ticketId,
subject: `Resolved: ${ticket.displayId}${ticket.title}`,
body: `Ticket ${ticket.displayId} is now RESOLVED.`,
}),
),
);
await webhookService
.dispatch('ticket.resolved', { ticketId })
.catch((err: Error) => logger.error({ err }, 'Webhook dispatch failed'));
}
export async function notifyMention(
ticketId: string,
commentId: string,
mentionedUserIds: string[],
): Promise<void> {
if (mentionedUserIds.length === 0) return;
const ticket = await prisma.ticket.findUnique({
where: { id: ticketId },
select: { displayId: true, title: true },
});
if (!ticket) return;
await Promise.all(
mentionedUserIds.map((userId) =>
deliver({
userId,
kind: 'mention',
ticketId,
commentId,
subject: `Mentioned in ${ticket.displayId}${ticket.title}`,
body: `You were mentioned in ticket ${ticket.displayId}: ${ticket.title}`,
}),
),
);
}
export async function notifyTicketCreated(ticketId: string): Promise<void> {
await webhookService
.dispatch('ticket.created', { ticketId })
.catch((err: Error) => logger.error({ err }, 'Webhook dispatch failed'));
}
export async function notifyStatusChanged(
_ticketId: string,
_from: string,
_to: string,
ticketId: string,
from: string,
to: string,
): Promise<void> {
return;
await webhookService
.dispatch('ticket.status_changed', { ticketId, from, to })
.catch((err: Error) => logger.error({ err }, 'Webhook dispatch failed'));
if (to === 'RESOLVED') await notifyResolved(ticketId);
}
export async function notifyCommentAdded(_ticketId: string, _commentId: string): Promise<void> {
return;
export async function notifyCommentCreated(
ticketId: string,
commentId: string,
): Promise<void> {
await webhookService
.dispatch('comment.created', { ticketId, commentId })
.catch((err: Error) => logger.error({ err }, 'Webhook dispatch failed'));
}
export async function notifyMention(_ticketId: string, _mentionedUserId: string): Promise<void> {
return;
// In-app notification queries for logged-in user
export async function listForUser(userId: string, opts: { unreadOnly?: boolean; limit?: number } = {}) {
return prisma.notification.findMany({
where: {
userId,
...(opts.unreadOnly ? { readAt: null } : {}),
},
orderBy: { createdAt: 'desc' },
take: opts.limit ?? 50,
include: {
ticket: { select: { id: true, displayId: true, title: true } },
},
});
}
export async function unreadCount(userId: string): Promise<number> {
return prisma.notification.count({ where: { userId, readAt: null } });
}
export async function markRead(
userId: string,
ids?: string[],
all?: boolean,
): Promise<number> {
const where: Prisma.NotificationWhereInput = { userId, readAt: null };
if (!all) {
if (!ids || ids.length === 0) {
throw new HttpError(400, 'Provide ids or set all=true');
}
where.id = { in: ids };
}
const result = await prisma.notification.updateMany({
where,
data: { readAt: new Date() },
});
return result.count;
}
export async function getPrefs(userId: string): Promise<NotificationPrefs> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { notificationPrefs: true },
});
return mergePrefs(user?.notificationPrefs ?? null);
}
export async function updatePrefs(
userId: string,
prefs: NotificationPrefs,
): Promise<NotificationPrefs> {
await prisma.user.update({
where: { id: userId },
data: { notificationPrefs: prefs as Prisma.InputJsonValue },
});
return prefs;
}
export function parseMentions(body: string): string[] {
const matches = body.matchAll(/(?<![a-zA-Z0-9_-])@([a-zA-Z0-9_-]+)/g);
const usernames = new Set<string>();
for (const m of matches) usernames.add(m[1]);
return Array.from(usernames);
}
@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest';
import { prismaMock } from '../test/setup';
import * as savedViewService from './savedViewService';
const fixture = {
id: 'v1',
userId: 'u1',
name: 'High sev open',
filters: { status: 'OPEN', severity: 1 },
createdAt: new Date(),
updatedAt: new Date(),
};
describe('savedViewService.updateView', () => {
it('rejects 403 when updating another user view', async () => {
prismaMock.savedView.findUnique.mockResolvedValue(fixture);
await expect(
savedViewService.updateView('v1', 'u2', { name: 'hostile' }),
).rejects.toMatchObject({ status: 403 });
});
it('404s missing view', async () => {
prismaMock.savedView.findUnique.mockResolvedValue(null);
await expect(
savedViewService.updateView('missing', 'u1', { name: 'x' }),
).rejects.toMatchObject({ status: 404 });
});
});
describe('savedViewService.deleteView', () => {
it('rejects 403 when deleting another user view', async () => {
prismaMock.savedView.findUnique.mockResolvedValue(fixture);
await expect(savedViewService.deleteView('v1', 'u2')).rejects.toMatchObject({
status: 403,
});
});
});
+51
View File
@@ -0,0 +1,51 @@
import { Prisma } from '@prisma/client';
import prisma from '../lib/prisma';
import { HttpError } from '../lib/httpError';
import type {
CreateSavedViewInput,
UpdateSavedViewInput,
} from '../../../shared/schemas/savedView';
export function listForUser(userId: string) {
return prisma.savedView.findMany({
where: { userId },
orderBy: { createdAt: 'asc' },
});
}
export function createView(userId: string, input: CreateSavedViewInput) {
return prisma.savedView.create({
data: {
userId,
name: input.name,
filters: input.filters as Prisma.InputJsonValue,
},
});
}
export async function updateView(
id: string,
userId: string,
input: UpdateSavedViewInput,
) {
const existing = await prisma.savedView.findUnique({ where: { id } });
if (!existing) throw new HttpError(404, 'Saved view not found');
if (existing.userId !== userId) throw new HttpError(403, 'Not your saved view');
return prisma.savedView.update({
where: { id },
data: {
...(input.name !== undefined ? { name: input.name } : {}),
...(input.filters !== undefined
? { filters: input.filters as Prisma.InputJsonValue }
: {}),
},
});
}
export async function deleteView(id: string, userId: string) {
const existing = await prisma.savedView.findUnique({ where: { id } });
if (!existing) throw new HttpError(404, 'Saved view not found');
if (existing.userId !== userId) throw new HttpError(403, 'Not your saved view');
await prisma.savedView.delete({ where: { id } });
}
+57 -6
View File
@@ -1,8 +1,59 @@
// Stub — Phase 2 replaces this with Postgres FTS (tsvector + plainto_tsquery).
// For now this is a thin wrapper; listTickets already supports a `search` filter.
import { Prisma } from '@prisma/client';
import prisma from '../lib/prisma';
import type { TicketFilters } from './ticketService';
import { listTickets, type TicketFilters } from './ticketService';
export function searchTickets(query: string, filters: Omit<TicketFilters, 'search'> = {}) {
return listTickets({ ...filters, search: query });
function whereConditions(query: string, filters: TicketFilters): Prisma.Sql[] {
const conds: Prisma.Sql[] = [
Prisma.sql`"searchVector" @@ plainto_tsquery('english', ${query})`,
];
if (filters.status) conds.push(Prisma.sql`"status" = ${filters.status}::"TicketStatus"`);
if (filters.severity !== undefined) conds.push(Prisma.sql`"severity" = ${filters.severity}`);
if (filters.assigneeId) conds.push(Prisma.sql`"assigneeId" = ${filters.assigneeId}`);
if (filters.createdById) conds.push(Prisma.sql`"createdById" = ${filters.createdById}`);
if (filters.itemId) conds.push(Prisma.sql`"itemId" = ${filters.itemId}`);
else if (filters.typeId) conds.push(Prisma.sql`"typeId" = ${filters.typeId}`);
else if (filters.categoryId) conds.push(Prisma.sql`"categoryId" = ${filters.categoryId}`);
return conds;
}
export async function searchTicketIds(
query: string,
filters: TicketFilters,
limit: number,
offset: number,
): Promise<{ ids: string[]; total: number }> {
const conds = whereConditions(query, filters);
const where = Prisma.sql`WHERE ${Prisma.join(conds, ' AND ')}`;
const [rows, countRows] = await Promise.all([
prisma.$queryRaw<{ id: string }[]>(Prisma.sql`
SELECT "id"
FROM "Ticket"
${where}
ORDER BY ts_rank("searchVector", plainto_tsquery('english', ${query})) DESC,
"createdAt" DESC
LIMIT ${limit} OFFSET ${offset}
`),
prisma.$queryRaw<{ count: bigint }[]>(Prisma.sql`
SELECT COUNT(*)::bigint AS count FROM "Ticket" ${where}
`),
]);
return {
ids: rows.map((r) => r.id),
total: Number(countRows[0]?.count ?? 0),
};
}
export async function searchComments(query: string, limit = 50) {
return prisma.$queryRaw<
{ id: string; body: string; ticketId: string; createdAt: Date; rank: number }[]
>(Prisma.sql`
SELECT "id", "body", "ticketId", "createdAt",
ts_rank("searchVector", plainto_tsquery('english', ${query})) AS rank
FROM "Comment"
WHERE "searchVector" @@ plainto_tsquery('english', ${query})
ORDER BY rank DESC, "createdAt" DESC
LIMIT ${limit}
`);
}
+41 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { prismaMock } from '../test/setup';
import { createTicket, updateTicket, closeStale } from './ticketService';
import { createTicket, updateTicket, closeStale, bulkAction } from './ticketService';
const existing = {
id: 'tid',
@@ -119,6 +119,46 @@ describe('ticketService.updateTicket', () => {
});
});
describe('ticketService.bulkAction', () => {
it('rejects non-admin attempting close', async () => {
await expect(
bulkAction({ action: 'close', ids: ['t1', 't2'] }, { id: 'u1', role: 'AGENT' }),
).rejects.toMatchObject({ status: 403 });
});
it('rejects USER role entirely', async () => {
await expect(
bulkAction(
{ action: 'setSeverity', ids: ['t1'], value: 2 },
{ id: 'u1', role: 'USER' },
),
).rejects.toMatchObject({ status: 403 });
});
it('updates many + writes audit entries for each ticket', async () => {
prismaMock.ticket.findMany.mockResolvedValue([{ id: 't1' }, { id: 't2' }] as never);
prismaMock.ticket.updateMany.mockResolvedValue({ count: 2 });
const result = await bulkAction(
{ action: 'setSeverity', ids: ['t1', 't2'], value: 1 },
{ id: 'u1', role: 'AGENT' },
);
expect(result.updated).toBe(2);
expect(prismaMock.ticket.updateMany).toHaveBeenCalledWith(
expect.objectContaining({ data: { severity: 1 } }),
);
expect(prismaMock.auditLog.createMany).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.arrayContaining([
expect.objectContaining({ ticketId: 't1', action: 'SEVERITY_CHANGED' }),
expect.objectContaining({ ticketId: 't2', action: 'SEVERITY_CHANGED' }),
]),
}),
);
});
});
describe('ticketService.closeStale', () => {
it('closes RESOLVED tickets older than cutoff and returns count', async () => {
prismaMock.ticket.updateMany.mockResolvedValue({ count: 3 });
+163 -13
View File
@@ -1,9 +1,13 @@
import { Prisma } from '@prisma/client';
import prisma from '../lib/prisma';
import { HttpError } from '../lib/httpError';
import { searchTicketIds } from './searchService';
import * as notificationService from './notificationService';
import { logger } from '../lib/logger';
import type {
CreateTicketInput,
UpdateTicketInput,
BulkActionInput,
} from '../../../shared/schemas/ticket';
export const ticketInclude = {
@@ -13,7 +17,16 @@ export const ticketInclude = {
assignee: { select: { id: true, username: true, displayName: true } },
createdBy: { select: { id: true, username: true, displayName: true } },
comments: {
include: { author: { select: { id: true, username: true, displayName: true } } },
include: {
author: { select: { id: true, username: true, displayName: true } },
attachments: {
include: { uploadedBy: { select: { id: true, username: true, displayName: true } } },
},
},
orderBy: { createdAt: 'asc' as const },
},
attachments: {
include: { uploadedBy: { select: { id: true, username: true, displayName: true } } },
orderBy: { createdAt: 'asc' as const },
},
} as const;
@@ -24,7 +37,7 @@ const ticketListInclude = {
item: true,
assignee: { select: { id: true, username: true, displayName: true } },
createdBy: { select: { id: true, username: true, displayName: true } },
_count: { select: { comments: true } },
_count: { select: { comments: true, attachments: true } },
} as const;
const STATUS_LABELS: Record<string, string> = {
@@ -42,8 +55,17 @@ export type TicketFilters = {
typeId?: string;
itemId?: string;
search?: string;
createdById?: string;
};
export type PaginationInput = {
page?: number;
pageSize?: number;
};
export const DEFAULT_PAGE_SIZE = 25;
export const MAX_PAGE_SIZE = 100;
async function generateDisplayId(): Promise<string> {
while (true) {
const num = Math.floor(Math.random() * 900_000_000) + 100_000_000;
@@ -59,29 +81,66 @@ export function findByIdOrDisplay(idOrDisplay: string) {
});
}
export async function listTickets(filters: TicketFilters) {
export function buildTicketWhere(filters: TicketFilters): Prisma.TicketWhereInput {
const where: Prisma.TicketWhereInput = {};
if (filters.status) where.status = filters.status as Prisma.TicketWhereInput['status'];
if (filters.severity) where.severity = filters.severity;
if (filters.assigneeId) where.assigneeId = filters.assigneeId;
if (filters.createdById) where.createdById = filters.createdById;
if (filters.itemId) where.itemId = filters.itemId;
else if (filters.typeId) where.typeId = filters.typeId;
else if (filters.categoryId) where.categoryId = filters.categoryId;
if (filters.search) {
where.OR = [
{ title: { contains: filters.search, mode: 'insensitive' } },
{ overview: { contains: filters.search, mode: 'insensitive' } },
{ displayId: { contains: filters.search, mode: 'insensitive' } },
];
}
return where;
}
async function hydrateTicketsInOrder(ids: string[]) {
if (ids.length === 0) return [];
const tickets = await prisma.ticket.findMany({
where: { id: { in: ids } },
include: ticketListInclude,
});
const byId = new Map(tickets.map((t) => [t.id, t]));
return ids.map((id) => byId.get(id)).filter((t): t is NonNullable<typeof t> => t !== undefined);
}
export async function listTickets(filters: TicketFilters) {
if (filters.search) {
const { ids } = await searchTicketIds(filters.search, filters, 500, 0);
return hydrateTicketsInOrder(ids);
}
return prisma.ticket.findMany({
where,
where: buildTicketWhere(filters),
include: ticketListInclude,
orderBy: [{ severity: 'asc' }, { createdAt: 'desc' }],
});
}
export async function listTicketsPaged(filters: TicketFilters, pagination: PaginationInput) {
const page = Math.max(1, Math.floor(pagination.page ?? 1));
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Math.floor(pagination.pageSize ?? DEFAULT_PAGE_SIZE)));
const skip = (page - 1) * pageSize;
if (filters.search) {
const { ids, total } = await searchTicketIds(filters.search, filters, pageSize, skip);
const data = await hydrateTicketsInOrder(ids);
return { data, total, page, pageSize };
}
const where = buildTicketWhere(filters);
const [total, data] = await prisma.$transaction([
prisma.ticket.count({ where }),
prisma.ticket.findMany({
where,
include: ticketListInclude,
orderBy: [{ severity: 'asc' }, { createdAt: 'desc' }],
skip,
take: pageSize,
}),
]);
return { data, total, page, pageSize };
}
export async function getTicket(idOrDisplay: string) {
const ticket = await prisma.ticket.findFirst({
where: { OR: [{ id: idOrDisplay }, { displayId: idOrDisplay }] },
@@ -104,7 +163,7 @@ export async function getTicketAudit(idOrDisplay: string) {
export async function createTicket(data: CreateTicketInput, actorId: string) {
const displayId = await generateDisplayId();
return prisma.$transaction(async (tx) => {
const result = await prisma.$transaction(async (tx) => {
const created = await tx.ticket.create({
data: { displayId, ...data, createdById: actorId },
});
@@ -113,6 +172,18 @@ export async function createTicket(data: CreateTicketInput, actorId: string) {
});
return tx.ticket.findUnique({ where: { id: created.id }, include: ticketInclude });
});
if (result) {
notificationService
.notifyTicketCreated(result.id)
.catch((err: Error) => logger.error({ err }, 'notifyTicketCreated failed'));
if (result.assigneeId) {
notificationService
.notifyAssigned(result.id, result.assigneeId)
.catch((err: Error) => logger.error({ err }, 'notifyAssigned failed'));
}
}
return result;
}
export async function updateTicket(
@@ -202,7 +273,7 @@ export async function updateTicket(
update.resolvedAt = null;
}
return prisma.$transaction(async (tx) => {
const result = await prisma.$transaction(async (tx) => {
const updated = await tx.ticket.update({
where: { id: existing.id },
data: update,
@@ -219,6 +290,25 @@ export async function updateTicket(
}
return tx.ticket.findUnique({ where: { id: updated.id }, include: ticketInclude });
});
if (result) {
if (data.status && data.status !== existing.status) {
notificationService
.notifyStatusChanged(result.id, existing.status, data.status)
.catch((err: Error) => logger.error({ err }, 'notifyStatusChanged failed'));
}
if (
'assigneeId' in data &&
data.assigneeId &&
data.assigneeId !== existing.assigneeId
) {
notificationService
.notifyAssigned(result.id, data.assigneeId)
.catch((err: Error) => logger.error({ err }, 'notifyAssigned failed'));
}
}
return result;
}
export async function deleteTicket(idOrDisplay: string) {
@@ -227,6 +317,66 @@ export async function deleteTicket(idOrDisplay: string) {
await prisma.ticket.delete({ where: { id: ticket.id } });
}
export async function bulkAction(
input: BulkActionInput,
actor: { id: string; role: string },
): Promise<{ updated: number }> {
if (input.action === 'close' && actor.role !== 'ADMIN') {
throw new HttpError(403, 'Only admins can close tickets');
}
if (actor.role !== 'ADMIN' && actor.role !== 'AGENT') {
throw new HttpError(403, 'Bulk actions require agent or admin');
}
const where: Prisma.TicketWhereInput = { id: { in: input.ids } };
let data: Prisma.TicketUncheckedUpdateManyInput;
let auditAction: string;
let auditDetail: string | undefined;
switch (input.action) {
case 'reassign':
data = { assigneeId: input.value };
auditAction = 'ASSIGNEE_CHANGED';
auditDetail = input.value ? `${input.value}` : '→ Unassigned';
break;
case 'close':
data = { status: 'CLOSED' };
auditAction = 'STATUS_CHANGED';
auditDetail = '→ Closed';
break;
case 'setSeverity':
data = { severity: input.value };
auditAction = 'SEVERITY_CHANGED';
auditDetail = `→ SEV ${input.value}`;
break;
case 'setStatus':
data = { status: input.value };
auditAction = 'STATUS_CHANGED';
auditDetail = `${STATUS_LABELS[input.value] ?? input.value}`;
break;
}
const result = await prisma.$transaction(async (tx) => {
const matching = await tx.ticket.findMany({ where, select: { id: true } });
const ids = matching.map((t) => t.id);
if (ids.length === 0) return { count: 0 };
const updateRes = await tx.ticket.updateMany({ where: { id: { in: ids } }, data });
await tx.auditLog.createMany({
data: ids.map((ticketId) => ({
ticketId,
userId: actor.id,
action: auditAction,
detail: auditDetail ?? null,
})),
});
return updateRes;
});
return { updated: result.count };
}
export async function closeStale(olderThanDays = 14): Promise<number> {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - olderThanDays);
@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest';
import crypto from 'node:crypto';
import { sign } from './webhookService';
describe('webhookService.sign', () => {
it('produces deterministic HMAC-SHA256 over `timestamp.body`', () => {
const secret = 'whsec_test';
const body = JSON.stringify({ event: 'ticket.created', payload: { ticketId: 't1' } });
const timestamp = 1_712_345_678;
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${body}`)
.digest('hex');
expect(sign(secret, body, timestamp)).toBe(expected);
});
it('changes when the timestamp changes', () => {
const secret = 'whsec_test';
const body = 'x';
expect(sign(secret, body, 1)).not.toBe(sign(secret, body, 2));
});
it('changes when the body changes', () => {
const secret = 'whsec_test';
expect(sign(secret, 'a', 1)).not.toBe(sign(secret, 'b', 1));
});
});
+121
View File
@@ -0,0 +1,121 @@
import crypto from 'node:crypto';
import prisma from '../lib/prisma';
import { logger } from '../lib/logger';
import { HttpError } from '../lib/httpError';
import type {
CreateWebhookInput,
UpdateWebhookInput,
WebhookEvent,
} from '../../../shared/schemas/notification';
function generateSecret(): string {
return 'whsec_' + crypto.randomBytes(24).toString('hex');
}
export function sign(secret: string, body: string, timestamp: number): string {
const h = crypto.createHmac('sha256', secret);
h.update(`${timestamp}.${body}`);
return h.digest('hex');
}
function redact(w: { secret: string }) {
const { secret, ...rest } = w as unknown as { secret: string } & Record<string, unknown>;
void secret;
return rest;
}
export async function listWebhooks() {
const rows = await prisma.webhook.findMany({ orderBy: { createdAt: 'asc' } });
return rows.map(redact);
}
export async function createWebhook(input: CreateWebhookInput) {
const created = await prisma.webhook.create({
data: { ...input, secret: generateSecret() },
});
// Secret returned once on creation so admin can save it
return created;
}
export async function updateWebhook(id: string, input: UpdateWebhookInput) {
const existing = await prisma.webhook.findUnique({ where: { id } });
if (!existing) throw new HttpError(404, 'Webhook not found');
const updated = await prisma.webhook.update({ where: { id }, data: input });
return redact(updated);
}
export async function deleteWebhook(id: string) {
const existing = await prisma.webhook.findUnique({ where: { id } });
if (!existing) throw new HttpError(404, 'Webhook not found');
await prisma.webhook.delete({ where: { id } });
}
export async function rotateSecret(id: string) {
const existing = await prisma.webhook.findUnique({ where: { id } });
if (!existing) throw new HttpError(404, 'Webhook not found');
const updated = await prisma.webhook.update({
where: { id },
data: { secret: generateSecret() },
});
return updated;
}
type FetchLike = (input: string, init: {
method: string;
headers: Record<string, string>;
body: string;
}) => Promise<{ ok: boolean; status: number; statusText: string }>;
async function deliverOnce(
url: string,
secret: string,
body: string,
timestamp: number,
fetchImpl: FetchLike = fetch as unknown as FetchLike,
): Promise<{ ok: boolean; status: number }> {
const signature = sign(secret, body, timestamp);
const res = await fetchImpl(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Ticketing-Timestamp': String(timestamp),
'X-Ticketing-Signature': `sha256=${signature}`,
},
body,
});
return { ok: res.ok, status: res.status };
}
async function wait(ms: number): Promise<void> {
await new Promise((r) => setTimeout(r, ms));
}
export async function dispatch(event: WebhookEvent, payload: unknown): Promise<void> {
const hooks =
(await prisma.webhook.findMany({
where: { active: true, events: { has: event } },
})) ?? [];
if (hooks.length === 0) return;
const body = JSON.stringify({ event, timestamp: Date.now(), payload });
const timestamp = Math.floor(Date.now() / 1000);
await Promise.all(
hooks.map(async (hook) => {
let attempt = 0;
const maxAttempts = 3;
while (attempt < maxAttempts) {
attempt += 1;
try {
const { ok, status } = await deliverOnce(hook.url, hook.secret, body, timestamp);
if (ok) return;
logger.warn({ hook: hook.id, attempt, status }, 'Webhook non-2xx');
} catch (err) {
logger.warn({ hook: hook.id, attempt, err }, 'Webhook delivery failed');
}
if (attempt < maxAttempts) await wait(500 * 2 ** (attempt - 1));
}
logger.error({ hook: hook.id, event }, 'Webhook gave up after retries');
}),
);
}