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,
|
2026-05-25 17:23:33 +02:00
|
|
|
sql,
|
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
|
|
|
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 ────────────────────────────────────────────────────────
|
2026-05-25 17:23:33 +02:00
|
|
|
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 });
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
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
|
|
|
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 });
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|