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,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;
|
||||
Reference in New Issue
Block a user