edf4c5eb3c
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>
191 lines
5.6 KiB
TypeScript
191 lines
5.6 KiB
TypeScript
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;
|