Files
iti-technology/apps/api/src/routes/public/contact.ts
T
Lorenzo SanesiandClaude Opus 5.5 b83ce039fb
Deploy stack su Proxmox / deploy (push) Failing after 3s
Piattaforma ITI TECHNOLOGY: monorepo, Medusa, API, sito, area riservata e deploy
Monorepo npm con apps/public (Angular SSR), apps/private (Angular SPA),
apps/api (Fastify), packages/shared e packages/styles; Medusa 2.21 in medusa/;
infrastruttura Docker (Postgres PostGIS+pgvector, Redis, Caddy, backup) e
workflow Gitea Actions per il deploy via SSH a ogni push su master.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jQyT6dR9czzGwqb7FkG6u
2026-10-20 09:30:48 +02:00

99 lines
4.0 KiB
TypeScript

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