buildmymcpserver/apps/api/src/routes/support.ts

291 lines
9.1 KiB
TypeScript
Raw Normal View History

feat: Swiss-compliant launch — Impressum/AGB/Contact, support panel, DSG exports, cookie banner Legal (Swiss minimum, no individual named): - Impressum page (UWG Art. 3 lit. s) — provider, contact via support panel, no email required, jurisdiction = Switzerland - AGB page — subscription terms, payment, cancellation, suspension on payment fail, 14-day money-back, AI-processing-per-tier disclosure, Swiss law + Swiss venue, modeled after typical Schweizer SaaS terms - Privacy: Stripe added as subprocessor with full data-flow disclosure Support panel replaces email contact entirely: - @bmm/db: support_status enum + support_tickets + support_messages tables, migration applied to prod DB - @bmm/api: support routes (user create/list/view/reply, admin list/view/reply /set-status), public /v1/contact for logged-out visitors with per-IP rate limit of 3 submissions/day to prevent spam-flood - Web: /settings/support (list + new), /settings/support/[id] (conversation), /admin/support, /admin/support/[id] - Public /contact form with email collection for guest tickets Data rights (DSG Art. 25 / GDPR Art. 15+20): - /v1/account/export returns user-scoped JSON of profile, org, servers, builds, audit, support tickets and messages — excludes hashes, encrypted secrets, other-user data - /settings/account: download button + deletion-via-ticket workflow Production-readiness gaps closed: - org.suspended now blocks /v1/servers POST and /v1/servers/preview (402); webhook flagged this state but enforcement was missing - Cookie banner: minimal, essential-cookies-only disclosure (Swiss DSG + GDPR compliant without dark-pattern consent UI), mounts on both layouts Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 17:12:06 +02:00
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 });
},
);
}