import { and, createDb, desc, eq, supportMessages, supportTickets, users, } from '@bmm/db'; import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { audit } from '../lib/audit.js'; import { checkDailyLimit } from '../lib/rate-limit.js'; import { requireAdmin, requireAuth } from '../plugins/session.js'; const db = createDb(); const NewTicketBody = z.object({ subject: z.string().min(3).max(200), body: z.string().min(10).max(10_000), }); const GuestTicketBody = z.object({ email: z.string().email(), subject: z.string().min(3).max(200), body: z.string().min(10).max(10_000), }); const NewMessageBody = z.object({ body: z.string().min(1).max(10_000), }); const StatusBody = z.object({ status: z.enum(['awaiting_admin', 'awaiting_user', 'closed']), }); export async function supportRoutes(app: FastifyInstance): Promise { // ─── User-side ────────────────────────────────────────────────────────── app.post('/v1/support/tickets', { preHandler: requireAuth }, async (req, reply) => { const user = req.user!; const parsed = NewTicketBody.safeParse(req.body); if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' }); const [ticket] = await db .insert(supportTickets) .values({ userId: user.userId, orgId: user.orgId, subject: parsed.data.subject, status: 'awaiting_admin', }) .returning(); if (!ticket) return reply.code(500).send({ error: 'ticket_create_failed' }); await db.insert(supportMessages).values({ ticketId: ticket.id, authorUserId: user.userId, authorIsAdmin: false, body: parsed.data.body, }); await audit({ orgId: user.orgId, userId: user.userId, action: 'support.ticket_created', resourceType: 'support_ticket', resourceId: ticket.id, metadata: { subject: parsed.data.subject }, ipAddress: req.ip, }); return reply.send({ ticket }); }); app.get('/v1/support/tickets', { preHandler: requireAuth }, async (req, reply) => { const user = req.user!; const rows = await db .select() .from(supportTickets) .where(eq(supportTickets.userId, user.userId)) .orderBy(desc(supportTickets.lastMessageAt)); return reply.send({ tickets: rows }); }); app.get('/v1/support/tickets/:id', { preHandler: requireAuth }, async (req, reply) => { const user = req.user!; const Params = z.object({ id: z.string().uuid() }); const parsed = Params.safeParse(req.params); if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' }); const [ticket] = await db .select() .from(supportTickets) .where( and(eq(supportTickets.id, parsed.data.id), eq(supportTickets.userId, user.userId)), ) .limit(1); if (!ticket) return reply.code(404).send({ error: 'not_found' }); const messages = await db .select() .from(supportMessages) .where(eq(supportMessages.ticketId, ticket.id)) .orderBy(supportMessages.createdAt); return reply.send({ ticket, messages }); }); app.post( '/v1/support/tickets/:id/messages', { preHandler: requireAuth }, async (req, reply) => { const user = req.user!; const Params = z.object({ id: z.string().uuid() }); const parsed = Params.safeParse(req.params); if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' }); const body = NewMessageBody.safeParse(req.body); if (!body.success) return reply.code(400).send({ error: 'invalid_input' }); const [ticket] = await db .select() .from(supportTickets) .where( and(eq(supportTickets.id, parsed.data.id), eq(supportTickets.userId, user.userId)), ) .limit(1); if (!ticket) return reply.code(404).send({ error: 'not_found' }); await db.insert(supportMessages).values({ ticketId: ticket.id, authorUserId: user.userId, authorIsAdmin: false, body: body.data.body, }); await db .update(supportTickets) .set({ status: 'awaiting_admin', lastMessageAt: new Date(), updatedAt: new Date(), }) .where(eq(supportTickets.id, ticket.id)); return reply.send({ ok: true }); }, ); // ─── Public contact form (no auth) ───────────────────────────────────── // Satisfies UWG Art. 3 lit. s ("easy electronic contact") for non-logged-in // visitors. Rate-limited per IP to prevent spam-flood of admin queue. app.post('/v1/contact', async (req, reply) => { const parsed = GuestTicketBody.safeParse(req.body); if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' }); const rl = await checkDailyLimit('contact', req.ip, 3); if (!rl.ok) { return reply.code(429).send({ error: 'rate_limited', detail: 'Too many contact submissions from this IP. Try again tomorrow.', }); } const [ticket] = await db .insert(supportTickets) .values({ guestEmail: parsed.data.email, subject: parsed.data.subject, status: 'awaiting_admin', }) .returning(); if (!ticket) return reply.code(500).send({ error: 'ticket_create_failed' }); await db.insert(supportMessages).values({ ticketId: ticket.id, authorUserId: null, authorIsAdmin: false, body: parsed.data.body, }); return reply.send({ ok: true }); }); // ─── Admin-side ──────────────────────────────────────────────────────── app.get( '/v1/admin/support/tickets', { preHandler: requireAdmin }, async (_req, reply) => { const rows = await db .select({ ticket: supportTickets, userEmail: users.email, userName: users.name, }) .from(supportTickets) .leftJoin(users, eq(users.id, supportTickets.userId)) .orderBy(desc(supportTickets.lastMessageAt)) .limit(200); return reply.send({ tickets: rows }); }, ); app.get( '/v1/admin/support/tickets/:id', { preHandler: requireAdmin }, async (req, reply) => { const Params = z.object({ id: z.string().uuid() }); const parsed = Params.safeParse(req.params); if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' }); const [row] = await db .select({ ticket: supportTickets, userEmail: users.email, userName: users.name }) .from(supportTickets) .leftJoin(users, eq(users.id, supportTickets.userId)) .where(eq(supportTickets.id, parsed.data.id)) .limit(1); if (!row) return reply.code(404).send({ error: 'not_found' }); const messages = await db .select() .from(supportMessages) .where(eq(supportMessages.ticketId, parsed.data.id)) .orderBy(supportMessages.createdAt); return reply.send({ ticket: row.ticket, userEmail: row.userEmail, userName: row.userName, messages }); }, ); app.post( '/v1/admin/support/tickets/:id/messages', { preHandler: requireAdmin }, async (req, reply) => { const user = req.user!; const Params = z.object({ id: z.string().uuid() }); const parsed = Params.safeParse(req.params); if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' }); const body = NewMessageBody.safeParse(req.body); if (!body.success) return reply.code(400).send({ error: 'invalid_input' }); await db.insert(supportMessages).values({ ticketId: parsed.data.id, authorUserId: user.userId, authorIsAdmin: true, body: body.data.body, }); await db .update(supportTickets) .set({ status: 'awaiting_user', lastMessageAt: new Date(), updatedAt: new Date(), }) .where(eq(supportTickets.id, parsed.data.id)); await audit({ orgId: user.orgId, userId: user.userId, action: 'support.admin_reply', resourceType: 'support_ticket', resourceId: parsed.data.id, }); return reply.send({ ok: true }); }, ); app.post( '/v1/admin/support/tickets/:id/status', { preHandler: requireAdmin }, async (req, reply) => { const Params = z.object({ id: z.string().uuid() }); const parsed = Params.safeParse(req.params); if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' }); const body = StatusBody.safeParse(req.body); if (!body.success) return reply.code(400).send({ error: 'invalid_input' }); await db .update(supportTickets) .set({ status: body.data.status, closedAt: body.data.status === 'closed' ? new Date() : null, updatedAt: new Date(), }) .where(eq(supportTickets.id, parsed.data.id)); return reply.send({ ok: true }); }, ); }