buildmymcpserver/apps/api/src/routes/support.ts
Marco Sadjadi 20910f5466
All checks were successful
Deploy to Production / deploy (push) Successful in 52s
fix(admin): Support entry in sidebar + awaiting-admin badge
The /admin/support page existed but was invisible from the panel — sidebar
NAV array didn't list it. Adds Support as the 2nd nav item (right after
Overview, since unanswered tickets are the most-time-sensitive thing an
admin checks). Sidebar polls /v1/admin/support/counts every 30s and renders
an amber count badge next to the entry when tickets are awaiting_admin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 17:23:33 +02:00

304 lines
9.4 KiB
TypeScript

import {
and,
createDb,
desc,
eq,
sql,
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<void> {
// ─── 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/counts',
{ preHandler: requireAdmin },
async (_req, reply) => {
const [row] = await db
.select({ count: sql<number>`count(*)::int` })
.from(supportTickets)
.where(eq(supportTickets.status, 'awaiting_admin'));
return reply.send({ awaitingAdmin: row?.count ?? 0 });
},
);
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 });
},
);
}