291 lines
9.1 KiB
TypeScript
291 lines
9.1 KiB
TypeScript
|
|
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<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/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 });
|
||
|
|
},
|
||
|
|
);
|
||
|
|
}
|