import type { FastifyPluginAsyncZod } from "fastify-type-provider-zod" import { z } from "zod" import { contactRequests } from "../../db/schema/index.js" import { findUserById, normalizeEmail } from "../../services/auth.js" import { recordConsents } from "../../services/consents.js" import { createFormToken, FORM_MIN_SECONDS, formGuardFields, passesFormGuard } from "../../services/form-guard.js" import { notifyStaff } from "../../services/notifications.js" /** Campi comuni dei form pubblici con dati personali (contatti, showroom). */ export const personalDataFields = { name: z.string().trim().min(1, "Nome obbligatorio").max(120), email: z.string().trim().toLowerCase().pipe(z.email("Email non valida")), phone: z.string().trim().min(5).max(30).optional(), acceptPrivacy: z.literal(true, { error: "Il consenso al trattamento dei dati è obbligatorio" }), acceptMarketing: z.boolean().optional(), ...formGuardFields, } export const messageResponse = z.object({ message: z.string() }) const CONTACT_RECEIVED = "Messaggio inviato: ti risponderemo al più presto" /** Form contatti e token anti-spam dei form pubblici (vedi services/form-guard.ts). */ const contactRoutes: FastifyPluginAsyncZod = async (app) => { const { config, db } = app // GET /forms/token — da chiedere all'apertura del form; il form va inviato dopo FORM_MIN_SECONDS app.get( "/forms/token", { config: { rateLimit: { max: 60, timeWindow: "1 minute" } }, schema: { response: { 200: z.object({ token: z.string(), minSeconds: z.number().int(), expiresAt: z.string() }) } }, }, async (_request, reply) => { reply.header("cache-control", "no-store") const { token, expiresAt } = createFormToken(config.COOKIE_SECRET) return { token, minSeconds: FORM_MIN_SECONDS, expiresAt: expiresAt.toISOString() } }, ) // POST /contact — salva la richiesta e avvisa lo staff. Spam (honeypot, invio troppo rapido) → stessa risposta, nulla salvato app.post( "/contact", { onRequest: [app.optionalAuthenticate], config: { rateLimit: { max: 5, timeWindow: "15 minutes" } }, schema: { body: z.object({ ...personalDataFields, subject: z.string().trim().min(1).max(200).optional(), message: z.string().trim().min(10, "Messaggio troppo breve").max(5000), }), response: { 200: messageResponse }, }, }, async (request) => { const body = request.body if (!passesFormGuard(request, config.COOKIE_SECRET, body, "contact")) return { message: CONTACT_RECEIVED } const user = request.user ? await findUserById(db, request.user.sub) : undefined const userId = user && !user.disabledAt ? user.id : null const email = normalizeEmail(body.email) const created = await db.transaction(async (tx) => { const [row] = await tx .insert(contactRequests) .values({ userId, name: body.name, email, phone: body.phone ?? null, subject: body.subject ?? null, message: body.message }) .returning({ id: contactRequests.id }) await recordConsents(tx, { userId, email, version: config.PRIVACY_POLICY_VERSION, source: "contact", ip: request.ip, marketing: body.acceptMarketing === true, }) return row! }) await notifyStaff(app, { title: `Nuovo messaggio dal sito - ${body.name}`, intro: "È arrivato un nuovo messaggio dal form contatti.", rows: [ { label: "Nome", value: body.name }, { label: "Email", value: email }, { label: "Telefono", value: body.phone ?? "-" }, { label: "Oggetto", value: body.subject ?? "-" }, { label: "Messaggio", value: body.message }, ], url: `${config.PRIVATE_APP_URL}/admin/contatti/${created.id}`, }) request.log.info({ contactRequestId: created.id, userId }, "nuova richiesta di contatto") return { message: CONTACT_RECEIVED } }, ) } export default contactRoutes