diff --git a/.env.example b/.env.example index 0b28b2c..56c8feb 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,12 @@ CLIENT_URL=http://tickets.thewrightserver.net # Host port NPM proxies to — change if 3080 is taken PORT=3080 + +# ── Email notifications (optional) ──────────────────────────────────────────── +# Leave SMTP_HOST empty to disable outgoing email entirely. +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASS= +SMTP_FROM=tickets@thewrightserver.net +SMTP_SECURE=false diff --git a/docker-compose.yml b/docker-compose.yml index ccd6208..934dbab 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,15 @@ services: JWT_SECRET: ${JWT_SECRET} CLIENT_URL: ${CLIENT_URL} PORT: 3000 + UPLOADS_DIR: /data/uploads + SMTP_HOST: ${SMTP_HOST:-} + SMTP_PORT: ${SMTP_PORT:-587} + SMTP_USER: ${SMTP_USER:-} + SMTP_PASS: ${SMTP_PASS:-} + SMTP_FROM: ${SMTP_FROM:-noreply@localhost} + SMTP_SECURE: ${SMTP_SECURE:-false} + volumes: + - uploads:/data/uploads depends_on: postgres: condition: service_healthy @@ -52,3 +61,4 @@ networks: volumes: postgres_data: + uploads: diff --git a/server/src/index.ts b/server/src/index.ts index 418de62..bf28548 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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); diff --git a/server/src/routes/analytics.ts b/server/src/routes/analytics.ts new file mode 100644 index 0000000..7362aeb --- /dev/null +++ b/server/src/routes/analytics.ts @@ -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; diff --git a/server/src/routes/attachments.ts b/server/src/routes/attachments.ts new file mode 100644 index 0000000..5933590 --- /dev/null +++ b/server/src/routes/attachments.ts @@ -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; diff --git a/server/src/routes/export.ts b/server/src/routes/export.ts new file mode 100644 index 0000000..81d571f --- /dev/null +++ b/server/src/routes/export.ts @@ -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; diff --git a/server/src/routes/notifications.ts b/server/src/routes/notifications.ts new file mode 100644 index 0000000..067ea37 --- /dev/null +++ b/server/src/routes/notifications.ts @@ -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; diff --git a/server/src/routes/savedViews.ts b/server/src/routes/savedViews.ts new file mode 100644 index 0000000..b09b2b0 --- /dev/null +++ b/server/src/routes/savedViews.ts @@ -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; diff --git a/server/src/routes/search.ts b/server/src/routes/search.ts new file mode 100644 index 0000000..0d2cf43 --- /dev/null +++ b/server/src/routes/search.ts @@ -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; diff --git a/server/src/routes/tickets.ts b/server/src/routes/tickets.ts index c652975..422aada 100644 --- a/server/src/routes/tickets.ts +++ b/server/src/routes/tickets.ts @@ -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); diff --git a/server/src/routes/webhooks.ts b/server/src/routes/webhooks.ts new file mode 100644 index 0000000..54c8a59 --- /dev/null +++ b/server/src/routes/webhooks.ts @@ -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; diff --git a/server/src/services/analyticsService.ts b/server/src/services/analyticsService.ts new file mode 100644 index 0000000..8e9363f --- /dev/null +++ b/server/src/services/analyticsService.ts @@ -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 = { 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 { + 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; +} diff --git a/server/src/services/attachmentService.test.ts b/server/src/services/attachmentService.test.ts new file mode 100644 index 0000000..2096cba --- /dev/null +++ b/server/src/services/attachmentService.test.ts @@ -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 }); + }); +}); diff --git a/server/src/services/attachmentService.ts b/server/src/services/attachmentService.ts new file mode 100644 index 0000000..b524f1f --- /dev/null +++ b/server/src/services/attachmentService.ts @@ -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 { + 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; +} diff --git a/server/src/services/commentService.ts b/server/src/services/commentService.ts index c39041f..d3f57fe 100644 --- a/server/src/services/commentService.ts +++ b/server/src/services/commentService.ts @@ -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; } diff --git a/server/src/services/emailService.ts b/server/src/services/emailService.ts new file mode 100644 index 0000000..c028869 --- /dev/null +++ b/server/src/services/emailService.ts @@ -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 { + 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; +} diff --git a/server/src/services/notificationService.test.ts b/server/src/services/notificationService.test.ts new file mode 100644 index 0000000..bce1eb9 --- /dev/null +++ b/server/src/services/notificationService.test.ts @@ -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'] } }), + }), + ); + }); +}); diff --git a/server/src/services/notificationService.ts b/server/src/services/notificationService.ts index b97d636..9623ea4 100644 --- a/server/src/services/notificationService.ts +++ b/server/src/services/notificationService.ts @@ -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 { - 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; + 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; +}; + +async function deliver(input: NotifyInput): Promise { + 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 { + 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 { + 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(); + 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 { + 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 { + 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 { - 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 { - return; +export async function notifyCommentCreated( + ticketId: string, + commentId: string, +): Promise { + 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 { - 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 { + return prisma.notification.count({ where: { userId, readAt: null } }); +} + +export async function markRead( + userId: string, + ids?: string[], + all?: boolean, +): Promise { + 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 { + 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 { + 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(/(?(); + for (const m of matches) usernames.add(m[1]); + return Array.from(usernames); } diff --git a/server/src/services/savedViewService.test.ts b/server/src/services/savedViewService.test.ts new file mode 100644 index 0000000..6f13b9b --- /dev/null +++ b/server/src/services/savedViewService.test.ts @@ -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, + }); + }); +}); diff --git a/server/src/services/savedViewService.ts b/server/src/services/savedViewService.ts new file mode 100644 index 0000000..3862eeb --- /dev/null +++ b/server/src/services/savedViewService.ts @@ -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 } }); +} diff --git a/server/src/services/searchService.ts b/server/src/services/searchService.ts index 19d8296..34c1d6d 100644 --- a/server/src/services/searchService.ts +++ b/server/src/services/searchService.ts @@ -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 = {}) { - 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} + `); } diff --git a/server/src/services/ticketService.test.ts b/server/src/services/ticketService.test.ts index 1e7de99..4bda31e 100644 --- a/server/src/services/ticketService.test.ts +++ b/server/src/services/ticketService.test.ts @@ -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 }); diff --git a/server/src/services/ticketService.ts b/server/src/services/ticketService.ts index 9808382..3833922 100644 --- a/server/src/services/ticketService.ts +++ b/server/src/services/ticketService.ts @@ -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 = { @@ -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 { 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 => 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 { const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - olderThanDays); diff --git a/server/src/services/webhookService.test.ts b/server/src/services/webhookService.test.ts new file mode 100644 index 0000000..d410b9b --- /dev/null +++ b/server/src/services/webhookService.test.ts @@ -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)); + }); +}); diff --git a/server/src/services/webhookService.ts b/server/src/services/webhookService.ts new file mode 100644 index 0000000..f68bff8 --- /dev/null +++ b/server/src/services/webhookService.ts @@ -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; + 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; + 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 { + await new Promise((r) => setTimeout(r, ms)); +} + +export async function dispatch(event: WebhookEvent, payload: unknown): Promise { + 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'); + }), + ); +}