Piattaforma ITI TECHNOLOGY: monorepo, Medusa, API, sito, area riservata e deploy
Deploy stack su Proxmox / deploy (push) Failing after 3s
Deploy stack su Proxmox / deploy (push) Failing after 3s
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
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
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
|
||||
Reference in New Issue
Block a user