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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 15:56:33 -04:00
parent 0806aec4a4
commit edf4c5eb3c
25 changed files with 1582 additions and 36 deletions
+190
View File
@@ -0,0 +1,190 @@
import { Router, Request } from 'express';
import multer from 'multer';
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { AuthRequest, authenticate } from '../middleware/auth';
import { HttpError } from '../lib/httpError';
import * as attachmentService from '../services/attachmentService';
import * as ticketService from '../services/ticketService';
import prisma from '../lib/prisma';
import { ATTACHMENT_MAX_BYTES } from '../../../shared/schemas/attachment';
const storage = multer.diskStorage({
destination: (_req, _file, cb) => {
attachmentService
.ensureUploadsDir()
.then(() => cb(null, attachmentService.UPLOADS_DIR))
.catch((err: Error) => cb(err, ''));
},
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname).slice(0, 20);
const safeBase = crypto.randomBytes(16).toString('hex');
cb(null, `${safeBase}${ext}`);
},
});
const upload = multer({
storage,
limits: { fileSize: ATTACHMENT_MAX_BYTES },
});
async function cleanup(file?: Express.Multer.File) {
if (!file) return;
await import('node:fs/promises').then((fsp) => fsp.unlink(file.path).catch(() => undefined));
}
const router = Router();
router.use(authenticate);
router.post(
'/tickets/:ticketId/attachments',
upload.single('file'),
async (req: AuthRequest, res) => {
const file = req.file;
if (!file) throw new HttpError(400, 'No file provided');
try {
attachmentService.validateUpload(file);
const ticket = await ticketService.findByIdOrDisplay(req.params.ticketId);
if (!ticket) throw new HttpError(404, 'Ticket not found');
const attachment = await prisma.$transaction(async (tx) => {
const created = await tx.attachment.create({
data: {
filename: file.originalname,
mimetype: file.mimetype,
size: file.size,
storagePath: path.relative(attachmentService.UPLOADS_DIR, file.path),
uploadedById: req.user!.id,
ticketId: ticket.id,
},
include: {
uploadedBy: { select: { id: true, username: true, displayName: true } },
},
});
await tx.auditLog.create({
data: {
ticketId: ticket.id,
userId: req.user!.id,
action: 'ATTACHMENT_ADDED',
detail: file.originalname,
},
});
return created;
});
res.status(201).json(attachment);
} catch (err) {
await cleanup(file);
throw err;
}
},
);
router.post(
'/comments/:commentId/attachments',
upload.single('file'),
async (req: AuthRequest, res) => {
const file = req.file;
if (!file) throw new HttpError(400, 'No file provided');
try {
attachmentService.validateUpload(file);
const comment = await prisma.comment.findUnique({
where: { id: req.params.commentId },
select: { id: true, ticketId: true },
});
if (!comment) throw new HttpError(404, 'Comment not found');
const attachment = await prisma.$transaction(async (tx) => {
const created = await tx.attachment.create({
data: {
filename: file.originalname,
mimetype: file.mimetype,
size: file.size,
storagePath: path.relative(attachmentService.UPLOADS_DIR, file.path),
uploadedById: req.user!.id,
commentId: comment.id,
},
include: {
uploadedBy: { select: { id: true, username: true, displayName: true } },
},
});
await tx.auditLog.create({
data: {
ticketId: comment.ticketId,
userId: req.user!.id,
action: 'ATTACHMENT_ADDED',
detail: file.originalname,
},
});
return created;
});
res.status(201).json(attachment);
} catch (err) {
await cleanup(file);
throw err;
}
},
);
router.get('/tickets/:ticketId/attachments', async (req, res) => {
const ticket = await ticketService.findByIdOrDisplay(req.params.ticketId);
if (!ticket) throw new HttpError(404, 'Ticket not found');
const attachments = await attachmentService.listForTicket(ticket.id);
res.json(attachments);
});
router.get('/attachments/:id', async (req: Request, res) => {
const attachment = await attachmentService.getAttachment(req.params.id);
const abs = attachmentService.absolutePathFor(attachment.storagePath);
res.setHeader('Content-Type', attachment.mimetype);
res.setHeader('Content-Length', String(attachment.size));
res.setHeader(
'Content-Disposition',
`inline; filename="${encodeURIComponent(attachment.filename)}"`,
);
const stream = fs.createReadStream(abs);
stream.on('error', (err) => {
if (!res.headersSent) res.status(404).json({ error: 'File not found on disk' });
else res.destroy(err);
});
stream.pipe(res);
});
router.delete('/attachments/:id', async (req: AuthRequest, res) => {
const attachment = await attachmentService.deleteAttachment(req.params.id, {
id: req.user!.id,
role: req.user!.role,
});
const ticketId =
attachment.ticketId ??
(attachment.commentId
? (
await prisma.comment.findUnique({
where: { id: attachment.commentId },
select: { ticketId: true },
})
)?.ticketId
: null);
if (ticketId) {
await prisma.auditLog.create({
data: {
ticketId,
userId: req.user!.id,
action: 'ATTACHMENT_REMOVED',
detail: attachment.filename,
},
});
}
res.status(204).send();
});
export default router;