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:
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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'] } }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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}
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user