From 8ca4de6c0b44e9fd3a665b2c2305ec6cf711500d Mon Sep 17 00:00:00 2001 From: tommaso Date: Tue, 7 Jul 2026 09:52:47 +0200 Subject: [PATCH] MVP core recesso: App Proxy flow + ricevuta durevole + design storefront MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A1: SPEC-MVP-RECESSO.md (criteri + copy IT) - A3: App Proxy /apps/recesso — lookup guest ordine+email (anti-leak), form 2-step, funzione dedicata Conferma recesso, persist WithdrawalRequest + transmittedAt + AuditLog - A4: ricevuta durevole via nodemailer (mailer.server.ts) + receiptSentAt + AuditLog - Design: recesso.server.ts restyle token CSS light/dark, coerente Shopify - Fix: @shopify/shopify-api pinnato 13.1.0 (dedupe) -> tsc clean - PLAN: A5 esteso a motore di stile a 3 livelli Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1 --- PLAN.md | 6 +- SPEC-MVP-RECESSO.md | 326 +++++++++++++++++ app/.env.example | 10 + app/app/lib/mailer.server.ts | 86 +++++ app/app/lib/recesso.copy.ts | 123 +++++++ app/app/lib/recesso.server.ts | 663 ++++++++++++++++++++++++++++++++++ app/app/routes/proxy.tsx | 326 +++++++++++++++++ app/package.json | 4 + app/shopify.app.toml | 8 + 9 files changed, 1551 insertions(+), 1 deletion(-) create mode 100644 SPEC-MVP-RECESSO.md create mode 100644 app/app/lib/mailer.server.ts create mode 100644 app/app/lib/recesso.copy.ts create mode 100644 app/app/lib/recesso.server.ts create mode 100644 app/app/routes/proxy.tsx diff --git a/PLAN.md b/PLAN.md index f2b6573..a21f45a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -167,7 +167,11 @@ input / deliverable / criteri di uscita espliciti. **Nessun avvio automatico** - **Uscita:** guest su dev store immacolato completa il recesso → record + ricevuta. **Questo è già conforme.** ### Fase 3 — Admin merchant *(sequenziale; A5→A6)* -- **A5 admin-UI** — Polaris: dashboard richieste, impostazioni, branding, testo info (R12). +- **A5 admin-UI + motore di stile estensibile** — Polaris: dashboard richieste, impostazioni, testo info (R12). **Theming a 3 livelli** (requisito utente: "super estendibile, personalizzabile da template"): + 1. **Token da Settings** (no-code) — colore brand, font, raggio, logo, larghezza → override delle CSS variables del form (base già pronta: il form del design è tutto a token `:root`). + 2. **Eredita tema** — opzione App Proxy in **Liquid** → il form è reso dentro il tema del negozio (look nativo, "template del merchant"). + 3. **CSS custom** — escape hatch per personalizzazione totale. + Merchant sceglie il livello; default = card a token controllata. Può diventare un agente dedicato (theming) se troppo grande per A5. - **A6 compliance-engine** — esclusioni Art. 59 + calcolo finestra/scadenza per tipo prodotto + warning misconfig + logica rimborso (R8,R9,R10,R13,R15). **Uscita:** merchant configura esclusioni/finestre; item escluso si comporta secondo R9. ### Fase 4 — Robustezza *(A7→A8 sequenziali, poi A9 audit)* diff --git a/SPEC-MVP-RECESSO.md b/SPEC-MVP-RECESSO.md new file mode 100644 index 0000000..d8ecf4e --- /dev/null +++ b/SPEC-MVP-RECESSO.md @@ -0,0 +1,326 @@ +# SPEC-MVP-RECESSO — Contratto operativo di build + +> **Ruolo:** riferimento vincolante per gli agenti di build (A2/A3/A4) e di test (A9). +> Trasforma `ANALISI-REQUISITI-LEGALI.md` + `PLAN.md §2` in criteri testabili + copy IT esatto. +> **Base legale:** Art. 54-bis Cod. Consumo (D.Lgs 209/2025, recepimento Dir. UE 2023/2673), in +> vigore per contratti conclusi dal 19/6/2026. +> +> **Principi non negoziabili (da ANALISI):** +> 1. Timestamp = **TRASMISSIONE** (momento in cui il consumatore esercita), **non** ricezione. +> 2. Il pulsante è **AGGIUNTIVO**, non sostitutivo (restano modulo Allegato I-B + email/PEC). +> 3. La conferma è una **funzione dedicata a 2 step** ("conferma recesso"), **non** una checkbox. +> 4. **Vietati dark pattern**: recesso non più oneroso della conclusione del contratto. +> +> **Legenda scope:** 🟢 MVP (fasi 2) · 🟡 Fase successiva (A6/A7/A8) · ⚠ da confermare. + +--- + +## 1. Criteri di accettazione testabili (Given/When/Then) + +Verificabili da QA (A9). Focus MVP: R1–R7, R11, R12. + +### R1 — 🟢 Funzione visibile, accessibile, continuativamente disponibile +- **Dato** un ordine B2C con finestra di recesso ancora aperta, + **Quando** l'acquirente visita una qualsiasi pagina dove è attivo l'app embed, + **Allora** vede il pulsante con etichetta esatta **"Recedere dal contratto qui"**. +- **Dato** un ordine consegnato oggi (giorno 0), + **Quando** l'acquirente apre la funzione in qualunque momento entro la finestra, + **Allora** la funzione è raggiungibile e operativa (nessuno stato "disabilitato"). +- **Dato** un ordine con finestra **scaduta**, + **Quando** l'acquirente tenta il recesso, + **Allora** il sistema mostra un messaggio di finestra chiusa e NON consente l'invio. +- **Negativo:** l'etichetta non deve mai essere ambigua ("Gestisci ordine", "Resi") → fail. + +### R2 — 🟢 Accesso guest (buona prassi, non login forzato) +- **Dato** un utente NON autenticato, + **Quando** inserisce numero ordine + email corretti, + **Allora** completa l'intero flusso senza creare account né effettuare login. +- **Negativo:** se in qualunque step compare un obbligo di login/registrazione → fail (viola "facilmente accessibile"). +- **Dato** un utente autenticato, **Quando** apre la funzione, **Allora** i campi noti (nome, email, ordine) sono precompilati ma modificabili. + +### R3 — 🟢 Raccolta dei 4 dati di legge +- **Dato** il form di recesso, + **Quando** l'acquirente lo compila, + **Allora** sono raccolti e persistiti: (a) nome e cognome, (b) identificativo ordine/contratto, + (c) email (mezzo elettronico per la conferma), (d) testo della dichiarazione di recesso. +- **Negativo:** invio con uno dei 4 campi obbligatori vuoto → bloccato con messaggio di campo mancante. +- **Dato** l'invio riuscito, **Quando** si ispeziona il DB, **Allora** esiste un record `WithdrawalRequest` con tutti e 4 i valori. + +### R4 — 🟢 Conferma a 2 step, funzione dedicata, anti-dark-pattern +- **Dato** il form compilato, + **Quando** l'acquirente conferma la compilazione, + **Allora** viene mostrata una **schermata di riepilogo** con una funzione dedicata etichettata **"Conferma recesso"**. +- **Dato** la schermata di riepilogo, + **Quando** l'acquirente NON attiva "Conferma recesso", + **Allora** nessuna richiesta viene registrata (l'invio è impossibile senza la 2ª azione esplicita). +- **Negativo (dark pattern):** la conferma NON deve essere una checkbox pre-flaggata, un timer, un + pop-up dissuasivo o un retention nudge → qualsiasi di questi = fail. +- **Dato** i due step, **Quando** si contano le azioni, **Allora** sono esattamente 2 (compila → conferma), senza attriti aggiuntivi. + +### R5 — 🟢 Ricevuta su supporto durevole con timestamp di TRASMISSIONE +- **Dato** una conferma andata a buon fine (attivazione di "Conferma recesso"), + **Quando** il backend registra la richiesta, + **Allora** `transmittedAt` = istante dell'attivazione della conferma (NON istante di ricezione/lettura email). +- **Dato** la richiesta registrata, + **Quando** trascorre meno di 1 minuto, + **Allora** viene inviata al consumatore una ricevuta (email, in MVP via provider stub/log) che contiene: + **(1)** testo integrale della dichiarazione, **(2)** identificativo ordine, **(3)** data e ora di **trasmissione**, **(4)** nome del consumatore. +- **Negativo:** ricevuta priva anche di uno solo di questi 4 elementi → fail. +- **Dato** MVP con provider stub, **Quando** l'email non parte davvero, **Allora** il contenuto della ricevuta è comunque generato e loggato in modo riproducibile (valore probatorio). + +### R6 — 🟢 Onere probatorio / audit log immutabile +- **Dato** ogni evento (creazione richiesta, invio ricevuta), + **Quando** l'evento accade, + **Allora** è scritto un record `AuditLog` **append-only** con timestamp e hash del payload. +- **Negativo:** possibilità di UPDATE/DELETE su `AuditLog` dall'applicazione → fail. +- **Dato** una richiesta, **Quando** la si audita mesi dopo, **Allora** si può dimostrare data/ora e contenuto della dichiarazione dal server (non solo dall'email). + +### R7 — 🟢 Non più onerosa della conclusione del contratto +- **Dato** il flusso di recesso completo, + **Quando** si contano i passaggi utente, + **Allora** sono **≤** ai passaggi del checkout dello stesso store. +- **Negativo:** presenza di pop-up di dissuasione, offerte di trattenimento, campi non necessari, + step aggiuntivi rispetto al checkout → fail. + +### R11 — 🟢 Pulsante aggiuntivo, non sostitutivo +- **Dato** l'app installata, + **Quando** si ispezionano i canali di recesso esistenti (modulo tipo Allegato I-B, email, PEC), + **Allora** restano tutti attivi e raggiungibili; l'app non li disabilita né li nasconde. +- **Dato** il flusso app, **Quando** l'acquirente lo usa, **Allora** vede la **nota di coesistenza** (§2) che ricorda gli altri mezzi. + +### R12 — 🟢 Obblighi informativi Art. 49 (ubicazione della funzione) +- **Dato** il flusso di recesso, + **Quando** l'acquirente lo apre, + **Allora** vede il **testo informativo sul diritto di recesso** (§2) PRIMA dell'invio. +- **Dato** le informazioni precontrattuali, + **Quando** il merchant configura l'app, + **Allora** è disponibile un testo che indica **dove si trova** la funzione nell'interfaccia (per pagine legali/checkout). +- **Nota:** l'omissione di questa informazione attiva la proroga Art. 53 (12 mesi + 14 gg) → vedi §5. + +### R9 — 🟡 Item escluso Art. 59 (criterio anticipato, motore in A6) +- **Dato** un item marcato come escluso (su misura / deperibile / sigillato igiene), + **Quando** l'acquirente apre il recesso per quell'item, + **Allora** l'item è segnalato come non recedibile con il **motivo** (messaggio §2), senza recesso "cieco". +- **MVP:** in fase 2 non è richiesto il motore di esclusione; il copy è pronto per A6. + +--- + +## 2. Copy deck IT — stringhe ESATTE (hardcodabili) + +> Tutte le stringhe sono in IT (lingua del contratto per gli store target). i18n = 🟡 Fase A7. +> Placeholder con `{{doppie graffe}}`. Il testo tra virgolette è letterale. + +### 2.1 Etichetta pulsante di avvio 🟢 +``` +Recedere dal contratto qui +``` +Varianti ammesse (equivalenti statutari, se serve adattare la resa grafica — mantenere il verbo "recedere"): +- `Recedi dal contratto qui` +- `Recedere dal contratto qui` *(forma preferita, coerente col testo di legge)* + +### 2.2 Etichetta funzione di conferma (step 2) 🟢 +``` +Conferma recesso +``` + +### 2.3 Label dei campi del form 🟢 +| Campo | Label esatta | Placeholder / hint | +|---|---|---| +| Nome e cognome | `Nome e cognome` | `Mario Rossi` | +| Identificativo ordine/contratto | `Numero dell'ordine` | `es. #1234` | +| Email (mezzo elettronico per la conferma) | `Email` | `La tua email dell'ordine` | +| Testo della dichiarazione di recesso | `Dichiarazione di recesso` | vedi testo precompilato sotto | + +**Testo precompilato (editabile) della dichiarazione** (buona prassi, deriva dall'Allegato I-B): +``` +Con la presente comunico il recesso dal contratto di vendita relativo all'ordine {{orderName}}. +``` + +### 2.4 Testo informativo sul diritto di recesso (Art. 49) — mostrato prima dell'invio 🟢 +``` +Hai diritto di recedere da questo contratto entro 14 giorni senza dover fornire alcuna motivazione. +Il termine decorre dalla consegna del bene (o dalla conclusione del contratto per i servizi). +Compilando e confermando questo modulo eserciti il recesso: riceverai via email una ricevuta con +la data e l'ora di trasmissione della tua dichiarazione. +``` + +### 2.5 Template email — ricevuta su supporto durevole 🟢 +**Oggetto:** +``` +Ricevuta della tua richiesta di recesso — Ordine {{orderName}} +``` +**Corpo:** +``` +Gentile {{customerName}}, + +confermiamo di aver ricevuto la Sua dichiarazione di recesso relativa all'ordine {{orderName}}, +trasmessa tramite la funzione di recesso presente sul nostro sito. + +Dettagli della richiesta: +- Data e ora di trasmissione: {{transmittedAt}} +- Ordine: {{orderName}} +- Nome del consumatore: {{customerName}} + +Testo integrale della dichiarazione di recesso trasmessa: +"{{statementText}}" + +Questa comunicazione costituisce la ricevuta su supporto durevole della Sua dichiarazione di +recesso, ai sensi dell'art. 54-bis del Codice del Consumo. La data e l'ora sopra indicate +attestano il momento della trasmissione della dichiarazione. + +Le invieremo separatamente le istruzioni per l'eventuale restituzione dei beni e i tempi di rimborso. + +Restano comunque validi anche gli altri mezzi per esercitare il recesso (modulo tipo di cui +all'Allegato I, parte B, o qualsiasi altra dichiarazione esplicita, anche via email): questa +funzione è aggiuntiva e non sostituisce tali strumenti. + +{{shopName}} +``` +> **Vincolo build:** `{{transmittedAt}}` va reso in formato leggibile e conservabile con fuso +> orario esplicito, es. `06/07/2026, 14:32:07 (Europe/Rome, CEST)`. È lo stesso istante di `transmittedAt` del DB. + +### 2.6 Messaggi di successo / conferma finale 🟢 +**Schermata finale (dopo "Conferma recesso"):** +``` +Recesso trasmesso correttamente. +Abbiamo registrato la tua dichiarazione di recesso per l'ordine {{orderName}} in data {{transmittedAt}}. +Ti abbiamo inviato una ricevuta all'indirizzo {{email}}. +``` +**Sotto-testo (coesistenza):** vedi §2.9. + +### 2.7 Messaggi di errore 🟢 +| Caso | Messaggio (UI) | +|---|---| +| Ordine non trovato **o** email non corrispondente (stesso messaggio, no leak) | `Non abbiamo trovato un ordine con questi dati. Verifica il numero dell'ordine e l'email usata per l'acquisto.` | +| Campo obbligatorio mancante | `Compila tutti i campi obbligatori per continuare.` | +| Email in formato non valido | `Inserisci un indirizzo email valido.` | +| Finestra di recesso scaduta | `Il termine di 14 giorni per il recesso su questo ordine è terminato. Puoi comunque contattarci per altre richieste.` | +| Errore generico invio | `Si è verificato un problema. Riprova tra qualche istante; se persiste, contattaci.` | + +> **Regola anti-enumeration (R2/§6):** "ordine inesistente" ed "email non combaciante" DEVONO +> mostrare lo **stesso identico messaggio**. Nessun dettaglio che riveli l'esistenza dell'ordine. + +### 2.8 Messaggio prodotto escluso (Art. 59) 🟡 (copy MVP-ready, logica in A6) +``` +Per questo prodotto il diritto di recesso non è previsto ({{motivoEsclusione}}, ai sensi +dell'art. 59 del Codice del Consumo). Per informazioni o altre richieste, contattaci. +``` +Valori di `{{motivoEsclusione}}`: +- `prodotto realizzato su misura o personalizzato` +- `prodotto deperibile o a rapida scadenza` +- `prodotto sigillato, aperto dopo la consegna, non restituibile per motivi igienici o di salute` + +### 2.9 Nota di coesistenza (pulsante aggiuntivo) 🟢 +``` +Questa funzione è un modo aggiuntivo per esercitare il recesso. Puoi comunque usare il modulo +tipo (Allegato I, parte B) o inviare qualsiasi dichiarazione esplicita, anche via email. +``` + +--- + +## 3. Campi raccolti (UI) → modello dati Prisma `WithdrawalRequest` + +> Modello sorgente: `PLAN.md §3`. I nomi Prisma nel PLAN sono in italiano; qui affiancati agli +> alias EN citati nel brief. **⚠ Scelta da confermare:** standardizzare i nomi campo (IT vs EN). + +| Campo UI | Campo Prisma (PLAN §3) | Alias EN (brief) | Obbligatorio | Note | +|---|---|---|---|---| +| Nome e cognome | `nomeCliente` | `customerName` | **Sì** | Art. 54-bis lett. (a) | +| Numero dell'ordine | `orderName` (+ `orderId` interno) | `orderName` / `orderId` | **Sì** | `orderName` = mostrato (#1234); `orderId` = GID Shopify risolto dal lookup | +| Email | `email` | `email` | **Sì** | Mezzo elettronico per la conferma, lett. (c) | +| Dichiarazione di recesso | `testoDichiarazione` | `statementText` | **Sì** | Testo integrale trasmesso, va nella ricevuta | +| — (generato al submit) | `trasmessoAt` | `transmittedAt` | **Sì (auto)** | Istante di TRASMISSIONE = click "Conferma recesso" | +| — (dal contesto) | `canale` | `channel` | **Sì** | `guest` \| `account` | +| — (dal contesto) | `locale` | `locale` | **Sì** | MVP fisso `it`; multi-lingua = 🟡 A7 | +| — (derivato) | `tipoProdotto` | — | 🟡 A6 | beni/servizi/digitale, per calcolo scadenza | +| — (sistema) | `stato` | — | Sì (default) | `trasmessa` all'invio | +| — (sistema) | `ricevutaInviataAt` | — | No | valorizzato dopo invio ricevuta | +| — (derivato) | `scadenzaCalcolata` | — | 🟡 A6 | dal motore compliance (§5) | + +**Obbligatori MVP (blocco invio se mancanti):** `nomeCliente`, `orderName`, `email`, `testoDichiarazione`. +**Auto MVP:** `trasmessoAt`, `canale`, `locale`, `stato`. + +--- + +## 4. Flusso a 2 step (anti-dark-pattern) 🟢 + +``` +STEP 0 — Avvio + Pulsante "Recedere dal contratto qui" (Theme App Extension) → apre /apps/recesso (App Proxy). + +STEP 1 — Lookup guest + Input: Numero ordine + Email. Nessun login richiesto. + → match OK: prosegue. → miss: messaggio unico anti-leak (§2.7), nessun dettaglio. + +STEP 2 — Form dati + dichiarazione + Campi: Nome e cognome, Email (precompilata), Dichiarazione (precompilata, editabile). + Mostrato SOPRA il form: testo informativo Art. 49 (§2.4). + Azione: "Continua" → schermata di riepilogo. + +STEP 3 — Riepilogo + conferma dedicata + Mostra: ordine, nome, email, testo dichiarazione. + Funzione dedicata: "Conferma recesso" (§2.2). ← unica azione che registra la richiesta. + Al click: transmittedAt = ora, persistenza record, AuditLog, invio ricevuta. + +STEP 4 — Successo + Messaggio §2.6 + nota coesistenza §2.9. +``` + +**Vincoli vincolanti (fail se violati):** +- Numero azioni utente ≤ passaggi del checkout dello store (R7). +- Nessun pop-up dissuasivo, offerta di trattenimento, timer, o retention nudge. +- Nessuna checkbox pre-flaggata; la conferma è **funzione/pulsante dedicato**, non un flag. +- Nessun login o registrazione forzati in nessuno step. +- Gli altri canali di recesso restano visibili/attivi (R11). + +--- + +## 5. Regole di scadenza (compliance engine) 🟡 (spec per A6) + +> Fonte: ANALISI §2 punto 6, §2 punto 8, §3. Tipi: **beni**, **servizi**, **contenuto digitale**. +> Timezone di riferimento: `Europe/Rome`. La finestra è di **14 giorni**. + +**Formule (giorno 0 = data evento di decorrenza):** +- **Beni:** `scadenza = dataConsegna + 14 giorni`. + - Beni in **più lotti/pezzi** (un solo ordine): decorre dalla **consegna dell'ultimo** bene → `dataConsegnaUltimoLotto + 14 giorni`. +- **Servizi:** `scadenza = dataConclusioneContratto + 14 giorni`. +- **Contenuto digitale:** decorrenza propria (a parte); gestione dedicata, **non** assimilare ai beni. +- **Estensione per omessa informazione (Art. 53):** se il merchant NON ha fornito l'informazione + sul diritto/ubicazione della funzione (R12/Art. 49): + `scadenza = decorrenzaBase + 12 mesi + 14 giorni`. + Se l'informazione viene fornita entro i 12 mesi: la finestra dei 14 giorni riparte dalla data in + cui l'informazione è fornita. + +**Note per A6:** +- Fonte di verità unica nel motore compliance; input consegna dai webhook fulfillment. +- Fulfillment parziale → tracciare per-item; usare la consegna più recente pertinente all'ordine. +- Alla scadenza, il flusso mostra il messaggio "finestra chiusa" (§2.7) e blocca l'invio. + +--- + +## 6. Lookup guest & prevenzione abuso 🟢 + +**Regole di match:** +- Chiave di ricerca: **Numero ordine (`orderName`) + Email**, entrambi devono combaciare. +- **Nessun leak:** ordine inesistente ed email non corrispondente producono lo **stesso** messaggio + (§2.7). Nessuna differenza in testo, codice di stato o tempo di risposta osservabile. + +**Anti-abuso (superficie di enumeration ordini) — nota per A9/A3:** +- **Rate-limit** sul lookup: per IP e/o per email (es. N tentativi / finestra temporale) → 🟢 predisporre in A3, verifica avversariale in A9. +- Considerare **backoff/captcha** dopo soglia di tentativi falliti. +- Log dei tentativi falliti in `AuditLog` (senza PII sensibile in chiaro non necessaria). +- Risposte a **tempo costante** per non rivelare l'esistenza dell'ordine via timing. + +--- + +## Confini MVP (esplicito) + +| In MVP 🟢 | Fase successiva 🟡 | +|---|---| +| Pulsante + flusso guest 2 step | Motore esclusioni Art. 59 (A6) | +| Raccolta 4 dati + persistenza | Calcolo scadenza per tipo prodotto (A6) | +| Conferma dedicata anti-dark-pattern | Logica rimborso / diminuzione valore (A6) | +| Ricevuta durevole con timestamp trasmissione (provider **stub**) | Provider email reale (pre-go-live) | +| AuditLog append-only | i18n multi-lingua EN/DE/FR/ES (A7) | +| Copy IT hardcoded | Webhook GDPR (A8), Billing (A10) | +| Rate-limit base + anti-leak | Rate-limit avanzato / captcha (A9) | diff --git a/app/.env.example b/app/.env.example index 187166f..b594904 100644 --- a/app/.env.example +++ b/app/.env.example @@ -13,3 +13,13 @@ SHOPIFY_APP_URL=https://your-tunnel-or-fly-url.example # Postgres connection string (Fly Postgres or Supabase). DATABASE_URL=postgresql://user:password@host:5432/recesso?sslmode=require + +# Email ricevuta recesso (supporto durevole). +# DEV: Mailpit → SMTP_HOST=localhost SMTP_PORT=1025 SMTP_SECURE=false (nessuna auth). +# PROD: provider reale (Resend/Postmark/SMTP) con SMTP_USER/SMTP_PASS. +SMTP_HOST=smtp.your-provider.example +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER=your_smtp_user +SMTP_PASS=your_smtp_password +MAIL_FROM=Recesso diff --git a/app/app/lib/mailer.server.ts b/app/app/lib/mailer.server.ts new file mode 100644 index 0000000..d69a2f6 --- /dev/null +++ b/app/app/lib/mailer.server.ts @@ -0,0 +1,86 @@ +/** + * Invio ricevuta di recesso su supporto durevole (A4 — Art. 54-bis). + * + * L'email È il supporto durevole: deve essere autoconsistente e contenere i 4 + * elementi di legge (dichiarazione integrale, id ordine, data/ora di TRASMISSIONE, + * nome consumatore). Il testo viene dal copy deck (recesso.copy §2.5). + * + * Transport configurato via env (provider-agnostico): + * SMTP_HOST, SMTP_PORT(=587), SMTP_USER?, SMTP_PASS?, SMTP_SECURE("true"/"false"), MAIL_FROM + * DEV: Mailpit su localhost:1025 (nessuna auth) → email visibili su http://localhost:18025 + * PROD: provider reale (Resend/Postmark/SMTP) — vedi .env.example. + */ + +import nodemailer from "nodemailer"; +import { receiptEmailBody, receiptEmailSubject } from "./recesso.copy"; + +function buildTransport() { + const host = process.env.SMTP_HOST; + if (!host) return null; // email non configurata → invio saltato con errore gestito + const port = Number(process.env.SMTP_PORT ?? 587); + const user = process.env.SMTP_USER; + return nodemailer.createTransport({ + host, + port, + secure: process.env.SMTP_SECURE === "true", + auth: user ? { user, pass: process.env.SMTP_PASS ?? "" } : undefined, + connectionTimeout: 10_000, + greetingTimeout: 10_000, + socketTimeout: 10_000, + }); +} + +function textToHtml(text: string): string { + const esc = text + .replace(/&/g, "&") + .replace(//g, ">"); + return `
${esc}
`; +} + +export type ReceiptResult = + | { ok: true; messageId: string } + | { ok: false; error: string }; + +/** + * Invia la ricevuta. Non lancia mai: cattura internamente e ritorna esito. + * `transmittedAt` deve essere già la stringa leggibile (Europe/Rome). + */ +export async function sendWithdrawalReceipt(params: { + to: string; + orderName: string; + customerName: string; + statementText: string; + transmittedAt: string; + shopName: string; +}): Promise { + const transport = buildTransport(); + if (!transport) { + return { ok: false, error: "SMTP non configurato (SMTP_HOST mancante)" }; + } + + const subject = receiptEmailSubject(params.orderName); + const text = receiptEmailBody({ + customerName: params.customerName, + orderName: params.orderName, + transmittedAt: params.transmittedAt, + statementText: params.statementText, + shopName: params.shopName, + }); + + try { + const info = await transport.sendMail({ + from: process.env.MAIL_FROM ?? "no-reply@localhost", + to: params.to, + subject, + text, + html: textToHtml(text), + }); + return { ok: true, messageId: info.messageId }; + } catch (e) { + return { + ok: false, + error: e instanceof Error ? e.message : "invio ricevuta fallito", + }; + } +} diff --git a/app/app/lib/recesso.copy.ts b/app/app/lib/recesso.copy.ts new file mode 100644 index 0000000..1619dba --- /dev/null +++ b/app/app/lib/recesso.copy.ts @@ -0,0 +1,123 @@ +/** + * Copy deck IT — stringhe ESATTE da SPEC-MVP-RECESSO.md §2. + * + * Non modificare i testi: sono vincolanti (contratto di build) e usati sia dal + * flusso storefront (route proxy) sia — per il template email — da A4. + * Placeholder con {{doppie graffe}} vengono sostituiti a runtime. + * + * i18n multi-lingua = fase A7. In MVP tutto IT hardcoded. + */ + +// §2.1 — Etichetta pulsante di avvio (usata dalla Theme App Extension / A2). +export const BUTTON_LABEL = "Recedere dal contratto qui"; + +// §2.2 — Etichetta funzione di conferma (step 3), funzione dedicata anti-dark-pattern. +export const CONFIRM_LABEL = "Conferma recesso"; + +// §2.3 — Label dei campi del form + placeholder/hint. +export const FIELD = { + name: { label: "Nome e cognome", placeholder: "Mario Rossi" }, + orderName: { label: "Numero dell'ordine", placeholder: "es. #1234" }, + email: { label: "Email", placeholder: "La tua email dell'ordine" }, + statement: { label: "Dichiarazione di recesso" }, +} as const; + +// §2.3 — Testo precompilato (editabile) della dichiarazione (deriva dall'Allegato I-B). +export function statementTemplate(orderName: string): string { + return `Con la presente comunico il recesso dal contratto di vendita relativo all'ordine ${orderName}.`; +} + +// §2.4 — Testo informativo sul diritto di recesso (Art. 49), mostrato PRIMA dell'invio. +export const ART49_INFO = + "Hai diritto di recedere da questo contratto entro 14 giorni senza dover fornire alcuna motivazione.\n" + + "Il termine decorre dalla consegna del bene (o dalla conclusione del contratto per i servizi).\n" + + "Compilando e confermando questo modulo eserciti il recesso: riceverai via email una ricevuta con\n" + + "la data e l'ora di trasmissione della tua dichiarazione."; + +// §2.9 — Nota di coesistenza (il pulsante è aggiuntivo, non sostitutivo). +export const COEXISTENCE_NOTE = + "Questa funzione è un modo aggiuntivo per esercitare il recesso. Puoi comunque usare il modulo\n" + + "tipo (Allegato I, parte B) o inviare qualsiasi dichiarazione esplicita, anche via email."; + +// §2.7 — Messaggi di errore. +export const ERROR = { + // Anti-enumeration: ordine inesistente ED email non combaciante DEVONO + // mostrare lo stesso identico messaggio (stesso testo, stesso status 200). + lookupNoMatch: + "Non abbiamo trovato un ordine con questi dati. Verifica il numero dell'ordine e l'email usata per l'acquisto.", + missingField: "Compila tutti i campi obbligatori per continuare.", + invalidEmail: "Inserisci un indirizzo email valido.", + windowClosed: + "Il termine di 14 giorni per il recesso su questo ordine è terminato. Puoi comunque contattarci per altre richieste.", + generic: + "Si è verificato un problema. Riprova tra qualche istante; se persiste, contattaci.", +} as const; + +// §2.6 — Schermata finale (dopo "Conferma recesso"). +export function successMessage( + orderName: string, + transmittedAt: string, + email: string, +): { line1: string; line2: string; line3: string } { + return { + line1: "Recesso trasmesso correttamente.", + line2: `Abbiamo registrato la tua dichiarazione di recesso per l'ordine ${orderName} in data ${transmittedAt}.`, + line3: `Ti abbiamo inviato una ricevuta all'indirizzo ${email}.`, + }; +} + +// §2.5 — Template email ricevuta su supporto durevole. +// Usato da A4 (invio reale). In MVP il flusso lascia receiptSentAt = null. +export function receiptEmailSubject(orderName: string): string { + return `Ricevuta della tua richiesta di recesso — Ordine ${orderName}`; +} + +export function receiptEmailBody(params: { + customerName: string; + orderName: string; + transmittedAt: string; // formato leggibile con fuso orario esplicito (Europe/Rome) + statementText: string; + shopName: string; +}): string { + const { customerName, orderName, transmittedAt, statementText, shopName } = + params; + return `Gentile ${customerName}, + +confermiamo di aver ricevuto la Sua dichiarazione di recesso relativa all'ordine ${orderName}, +trasmessa tramite la funzione di recesso presente sul nostro sito. + +Dettagli della richiesta: +- Data e ora di trasmissione: ${transmittedAt} +- Ordine: ${orderName} +- Nome del consumatore: ${customerName} + +Testo integrale della dichiarazione di recesso trasmessa: +"${statementText}" + +Questa comunicazione costituisce la ricevuta su supporto durevole della Sua dichiarazione di +recesso, ai sensi dell'art. 54-bis del Codice del Consumo. La data e l'ora sopra indicate +attestano il momento della trasmissione della dichiarazione. + +Le invieremo separatamente le istruzioni per l'eventuale restituzione dei beni e i tempi di rimborso. + +Restano comunque validi anche gli altri mezzi per esercitare il recesso (modulo tipo di cui +all'Allegato I, parte B, o qualsiasi altra dichiarazione esplicita, anche via email): questa +funzione è aggiuntiva e non sostituisce tali strumenti. + +${shopName}`; +} + +// §2.8 — Messaggio prodotto escluso (Art. 59). Copy MVP-ready; la LOGICA è in A6. +export const EXCLUSION_REASON = { + CUSTOM: "prodotto realizzato su misura o personalizzato", + PERISHABLE: "prodotto deperibile o a rapida scadenza", + HYGIENE: + "prodotto sigillato, aperto dopo la consegna, non restituibile per motivi igienici o di salute", +} as const; + +export function exclusionMessage(motivoEsclusione: string): string { + return `Per questo prodotto il diritto di recesso non è previsto (${motivoEsclusione}, ai sensi dell'art. 59 del Codice del Consumo). Per informazioni o altre richieste, contattaci.`; +} + +// Titolo pagina / intestazione del flusso (non normato: label neutra, coerente col verbo di legge). +export const PAGE_TITLE = "Recesso dal contratto"; diff --git a/app/app/lib/recesso.server.ts b/app/app/lib/recesso.server.ts new file mode 100644 index 0000000..286020a --- /dev/null +++ b/app/app/lib/recesso.server.ts @@ -0,0 +1,663 @@ +/** + * Helper server-only per il flusso di recesso guest (App Proxy). + * + * Responsabilità: + * - lookup ordine via Admin GraphQL (anti-leak: nessuna differenza tra ordine + * inesistente ed email non combaciante); + * - rate-limit base per shop+IP (hardening -> A9); + * - hashing payload per l'audit trail; + * - formattazione timestamp di TRASMISSIONE (Europe/Rome); + * - rendering HTML standalone (nessun Polaris, CSS inline minimale, accessibile). + * + * NB: `import "server-only"` non è disponibile qui; il suffisso `.server.ts` + * garantisce che Remix non impacchetti questo modulo nel bundle client. + */ + +import { createHash } from "node:crypto"; +import type { AdminApiContext } from "@shopify/shopify-app-remix/server"; +import { FIELD, ART49_INFO, COEXISTENCE_NOTE, CONFIRM_LABEL, PAGE_TITLE } from "./recesso.copy"; + +// --------------------------------------------------------------------------- +// Costanti path storefront (prefix "apps" + subpath "recesso" da shopify.app.toml). +// Le form fanno POST a questo path: Shopify appende la firma e forwarda a /proxy. +// --------------------------------------------------------------------------- +export const PROXY_STOREFRONT_PATH = "/apps/recesso"; + +// Locale MVP fisso (multi-lingua -> A7). +export const MVP_LOCALE = "it"; + +// --------------------------------------------------------------------------- +// Utility +// --------------------------------------------------------------------------- + +/** Escape dei caratteri HTML per prevenire XSS su tutto l'input riflesso. */ +export function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** Attributo HTML sicuro (per value="..."): riusa escapeHtml. */ +export function attr(value: string): string { + return escapeHtml(value); +} + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +export function isValidEmail(value: string): boolean { + return EMAIL_RE.test(value.trim()); +} + +/** Rimuove il prefisso '#' e spazi dal numero ordine digitato dall'utente. */ +export function normalizeOrderName(input: string): string { + return input.trim().replace(/^#+/, "").trim(); +} + +export function sha256(payload: unknown): string { + return createHash("sha256") + .update(typeof payload === "string" ? payload : JSON.stringify(payload)) + .digest("hex"); +} + +/** + * Formatta l'istante di TRASMISSIONE in modo leggibile e conservabile, con fuso + * orario esplicito, es. "06/07/2026, 14:32:07 CEST". Il DB conserva UTC. + */ +export function formatTransmittedAt(date: Date): string { + return new Intl.DateTimeFormat("it-IT", { + timeZone: "Europe/Rome", + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + timeZoneName: "short", + }).format(date); +} + +/** Estrae l'IP client dell'utente storefront (App Proxy forwarda X-Forwarded-For). */ +export function clientIp(request: Request): string { + const xff = request.headers.get("X-Forwarded-For"); + if (xff) return xff.split(",")[0]!.trim(); + return request.headers.get("X-Real-IP")?.trim() || "unknown"; +} + +// --------------------------------------------------------------------------- +// Rate-limit base (in-memory, single-instance) — hardening -> A9. +// Finestra fissa per chiave shop+IP. Predisposto per la verifica avversariale A9. +// TODO(A9): sostituire con store condiviso (Redis/DB) per il deploy multi-istanza, +// aggiungere backoff/captcha oltre soglia e risposte a tempo costante +// (attualmente evitiamo solo leak di testo/status, non di timing). +// --------------------------------------------------------------------------- +const RATE_WINDOW_MS = 15 * 60 * 1000; // 15 minuti +const RATE_MAX_ATTEMPTS = 8; // tentativi di lookup per finestra, per shop+IP +const rateBucket = new Map(); + +/** Ritorna true se la richiesta è consentita, false se ha superato la soglia. */ +export function checkRateLimit(shop: string, ip: string): boolean { + const key = `${shop}:${ip}`; + const now = Date.now(); + const entry = rateBucket.get(key); + if (!entry || entry.resetAt <= now) { + rateBucket.set(key, { count: 1, resetAt: now + RATE_WINDOW_MS }); + return true; + } + if (entry.count >= RATE_MAX_ATTEMPTS) { + return false; + } + entry.count += 1; + return true; +} + +// --------------------------------------------------------------------------- +// Lookup ordine via Admin GraphQL. +// --------------------------------------------------------------------------- + +export interface MatchedOrder { + orderId: string; // GID Shopify (gid://shopify/Order/...) + orderName: string; // es. "#1001" + email: string; // email dell'ordine (per precompilazione) + createdAt: string; +} + +interface OrderLookupGraphQL { + data?: { + orders?: { + edges?: Array<{ + node?: { + id?: string | null; + name?: string | null; + email?: string | null; + createdAt?: string | null; + } | null; + } | null> | null; + } | null; + } | null; +} + +const ORDER_LOOKUP_QUERY = `#graphql + query recessoOrderLookup($query: String!) { + orders(first: 5, query: $query) { + edges { + node { + id + name + email + createdAt + } + } + } + }`; + +/** + * Cerca l'ordine per numero e verifica che l'email combaci. + * + * ANTI-LEAK (SPEC §6 / R2): sia se l'ordine non esiste sia se l'email non + * corrisponde, la funzione ritorna `null` in modo indistinguibile. Il chiamante + * mostra SEMPRE lo stesso messaggio (ERROR.lookupNoMatch) con lo stesso status. + * + * NB "Protected Customer Data": in PRODUZIONE la lettura di `order.email` via + * Admin API richiede l'approvazione Shopify "Protected customer data access". + * Su dev/custom store funziona senza. // TODO: richiedere l'accesso pre-go-live. + * + * Multi-tenant: `admin` proviene dalla sessione App Proxy, quindi la query è + * già scoped allo shop corretto; non ci fidiamo mai dello shop lato client. + */ +export async function lookupOrder( + admin: AdminApiContext, + orderInput: string, + emailInput: string, +): Promise { + const normalized = normalizeOrderName(orderInput); + const email = emailInput.trim().toLowerCase(); + if (!normalized || !email) return null; + + let body: OrderLookupGraphQL; + try { + const res = await admin.graphql(ORDER_LOOKUP_QUERY, { + variables: { query: `name:#${normalized}` }, + }); + body = (await res.json()) as OrderLookupGraphQL; + } catch { + // Errore API: trattiamo come "nessun match" per non rivelare nulla. + return null; + } + + const edges = body.data?.orders?.edges ?? []; + const wantedName = `#${normalized}`.toLowerCase(); + + for (const edge of edges) { + const node = edge?.node; + if (!node?.id || !node.name) continue; + const nameMatch = + node.name.toLowerCase() === wantedName || + node.name.toLowerCase() === normalized.toLowerCase(); + const emailMatch = (node.email ?? "").trim().toLowerCase() === email; + if (nameMatch && emailMatch) { + return { + orderId: node.id, + orderName: node.name, + email: node.email ?? emailInput.trim(), + createdAt: node.createdAt ?? "", + }; + } + } + return null; +} + +// --------------------------------------------------------------------------- +// Rendering HTML — documento standalone, servito sul dominio storefront. +// Niente Polaris, niente root layout admin: solo HTML+CSS inline accessibile. +// --------------------------------------------------------------------------- + +const PAGE_CSS = ` + :root { + color-scheme: light dark; + --bg: #f1f2f4; + --surface: #ffffff; + --text: #1a1a1a; + --text-muted: #5c5f62; + --border: #d7dadf; + --border-input: #8a8f96; + --border-input-hover: #6d7175; + --accent: #005bd3; + --focus-ring: rgba(0, 91, 211, 0.24); + --primary-bg: #1a1a1a; + --primary-bg-hover: #000000; + --primary-text: #ffffff; + --secondary-text: #1a1a1a; + --subtle-bg: #f6f7f8; + --tag-bg: #e4ecf9; + --tag-text: #17457f; + --info-bg: #eef4fb; + --info-border: #cbdcf2; + --info-text: #1f3a5f; + --coexist-bg: #f6f7f8; + --coexist-border: #c7cbd0; + --coexist-text: #4a4f54; + --error-bg: #fdece8; + --error-border: #e3a596; + --error-text: #8b1f0e; + --success: #0f6b3a; + --success-bg: #e4f3ea; + --shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 10px 30px rgba(18, 24, 40, 0.08); + --radius: 14px; + --radius-sm: 9px; + } + * { box-sizing: border-box; } + html { -webkit-text-size-adjust: 100%; } + body { + margin: 0; + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Inter", sans-serif; + line-height: 1.55; + color: var(--text); + background: var(--bg); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + } + .wrap { max-width: 520px; margin: 0 auto; padding: 32px 16px 72px; } + .card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 28px 26px; + box-shadow: var(--shadow); + } + @media (max-width: 480px) { + .wrap { padding: 16px 12px 48px; } + .card { padding: 22px 18px; } + } + h1 { font-size: 1.5rem; line-height: 1.25; letter-spacing: -0.01em; margin: 0 0 6px; font-weight: 650; } + h2 { font-size: 1.05rem; margin: 24px 0 8px; font-weight: 600; } + p { margin: 0 0 12px; } + .muted { color: var(--text-muted); font-size: 0.95rem; } + .muted:last-of-type { margin-bottom: 0; } + + /* Indicatore di step (discreto, non dark-pattern) */ + .stepper { display: flex; align-items: center; gap: 10px; margin: 0 0 22px; } + .stepper__track { display: inline-flex; gap: 5px; } + .stepper__seg { width: 28px; height: 4px; border-radius: 999px; background: var(--border); transition: background 0.15s ease; } + .stepper__seg.is-active, .stepper__seg.is-done { background: var(--accent); } + .stepper__label { font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-muted); } + + /* Campi */ + label { display: block; font-weight: 600; font-size: 0.95rem; margin: 20px 0 7px; color: var(--text); } + input[type="text"], input[type="email"], textarea { + width: 100%; + min-height: 46px; + padding: 11px 13px; + font-size: 1rem; + font-family: inherit; + line-height: 1.5; + color: var(--text); + background: var(--surface); + border: 1px solid var(--border-input); + border-radius: var(--radius-sm); + transition: border-color 0.15s ease, box-shadow 0.15s ease; + } + input::placeholder, textarea::placeholder { color: var(--text-muted); opacity: 0.8; } + textarea { min-height: 128px; resize: vertical; } + input:hover, textarea:hover { border-color: var(--border-input-hover); } + input:focus, textarea:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--focus-ring); + outline: none; + } + .hint { font-weight: 400; color: var(--text-muted); font-size: 0.85rem; margin: 6px 0 0; } + + /* Email ricevuta: de-enfatizzata ma editabile */ + .receipt-field { + margin-top: 20px; + padding: 14px 15px 15px; + background: var(--subtle-bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + } + .receipt-field label { margin-top: 0; font-size: 0.9rem; } + .receipt-field .hint { margin-top: 8px; } + .tag { + display: inline-block; + margin-left: 6px; + padding: 2px 8px; + font-size: 0.66rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + vertical-align: middle; + color: var(--tag-text); + background: var(--tag-bg); + border-radius: 999px; + } + + /* Bottoni */ + .btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 46px; + font-size: 1rem; + font-weight: 600; + font-family: inherit; + padding: 12px 22px; + border-radius: var(--radius-sm); + border: 1px solid transparent; + cursor: pointer; + margin-top: 24px; + transition: background 0.15s ease, border-color 0.15s ease, transform 0.05s ease; + } + .btn:active { transform: translateY(1px); } + .btn-primary { background: var(--primary-bg); color: var(--primary-text); } + .btn-primary:hover { background: var(--primary-bg-hover); } + .btn-secondary { background: var(--surface); color: var(--secondary-text); border-color: var(--border-input); } + .btn-secondary:hover { background: var(--subtle-bg); border-color: var(--border-input-hover); } + .btn:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } + .btn:focus:not(:focus-visible) { outline: none; } + + /* Riga azioni (step riepilogo) */ + .actions { display: flex; flex-direction: column; gap: 12px; margin-top: 26px; } + .actions form { margin: 0; } + .actions .btn { margin-top: 0; width: 100%; } + @media (min-width: 460px) { + .actions { flex-direction: row; } + .actions form { flex: 1; } + } + + /* Info / coesistenza / errore */ + .info { + background: var(--info-bg); + border: 1px solid var(--info-border); + color: var(--info-text); + border-radius: var(--radius-sm); + padding: 14px 16px; + margin: 18px 0 4px; + white-space: pre-line; + font-size: 0.92rem; + } + .coexist { + background: var(--coexist-bg); + border: 1px solid var(--coexist-border); + border-left: 3px solid var(--border-input); + border-radius: var(--radius-sm); + padding: 13px 16px; + margin: 26px 0 0; + white-space: pre-line; + font-size: 0.86rem; + color: var(--coexist-text); + } + .error { + display: flex; + align-items: flex-start; + gap: 10px; + background: var(--error-bg); + border: 1px solid var(--error-border); + border-radius: var(--radius-sm); + padding: 12px 14px; + margin: 0 0 18px; + color: var(--error-text); + font-weight: 600; + font-size: 0.92rem; + } + .error__icon { flex: none; width: 20px; height: 20px; margin-top: 1px; fill: currentColor; } + + /* Riepilogo (step 3) */ + .summary { margin: 18px 0 4px; } + .summary dt { font-weight: 600; font-size: 0.82rem; text-transform: uppercase; letter-spacing: 0.03em; color: var(--text-muted); margin-top: 16px; } + .summary dt:first-child { margin-top: 0; } + .summary dd { margin: 3px 0 0; white-space: pre-line; color: var(--text); } + + /* Successo (step 4) */ + .success { text-align: center; padding: 6px 0 2px; } + .success__icon { + width: 60px; height: 60px; margin: 0 auto 18px; + display: flex; align-items: center; justify-content: center; + border-radius: 999px; + background: var(--success-bg); + } + .success__icon svg { width: 32px; height: 32px; fill: var(--success); } + .success h1 { color: var(--success); } + .success p { color: var(--text-muted); } + + @media (prefers-color-scheme: dark) { + :root { + --bg: #0f1114; + --surface: #1b1d21; + --text: #e7e9ec; + --text-muted: #a1a6ad; + --border: #34373d; + --border-input: #4c5058; + --border-input-hover: #676c75; + --accent: #5aa2ff; + --focus-ring: rgba(90, 162, 255, 0.34); + --primary-bg: #e7e9ec; + --primary-bg-hover: #ffffff; + --primary-text: #16181c; + --secondary-text: #e7e9ec; + --subtle-bg: #212429; + --tag-bg: #23374f; + --tag-text: #bcd6f7; + --info-bg: #15243a; + --info-border: #2d4a6b; + --info-text: #cfe0f5; + --coexist-bg: #212429; + --coexist-border: #3a3e45; + --coexist-text: #a1a6ad; + --error-bg: #3a1512; + --error-border: #7a2a1c; + --error-text: #ffb4a2; + --success: #5fd08a; + --success-bg: #163021; + --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 12px 34px rgba(0, 0, 0, 0.45); + } + } + @media (prefers-reduced-motion: reduce) { + * { transition: none !important; } + } +`; + +/** Wrapper documento HTML standalone. `inner` è già HTML sicuro. */ +export function renderShell(inner: string): string { + return ` + + + + + + +${escapeHtml(PAGE_TITLE)} + + + +
+
+${inner} +
+
+ +`; +} + +function errorBanner(message?: string): string { + if (!message) return ""; + return ``; +} + +function coexistenceBlock(): string { + return `
${escapeHtml(COEXISTENCE_NOTE)}
`; +} + +/** + * Indicatore di step discreto in cima alla card (accessibilità: la traccia è + * decorativa/aria-hidden, l'etichetta testuale resta leggibile). NON è un + * dark-pattern: comunica solo a che punto è l'utente. + */ +function stepIndicator(current: number): string { + const labels: Record = { + 1: "Passo 1 di 2", + 2: "Passo 2 di 2", + 3: "Conferma", + 4: "Fatto", + }; + const segs = [1, 2, 3] + .map((i) => { + const cls = + current >= 4 || current > i + ? "stepper__seg is-done" + : current === i + ? "stepper__seg is-active" + : "stepper__seg"; + return ``; + }) + .join(""); + const label = labels[current] ?? ""; + return `
+ + ${escapeHtml(label)} +
`; +} + +// --- Step 1: lookup guest ------------------------------------------------- +export function renderStep1(opts?: { + error?: string; + orderName?: string; + email?: string; +}): string { + const orderName = opts?.orderName ?? ""; + const email = opts?.email ?? ""; + return renderShell(` +${stepIndicator(1)} +

${escapeHtml(PAGE_TITLE)}

+

Inserisci il numero dell'ordine e l'email usata per l'acquisto per iniziare. Non è necessario alcun account.

+${errorBanner(opts?.error)} +
+ + + + + + +
+${coexistenceBlock()} +`); +} + +// --- Step 2: form dati + dichiarazione ------------------------------------ +export function renderStep2(data: { + orderId: string; + orderName: string; + email: string; + customerName?: string; + statementText: string; + error?: string; +}): string { + const customerName = data.customerName ?? ""; + return renderShell(` +${stepIndicator(2)} +

${escapeHtml(PAGE_TITLE)}

+

Ordine ${escapeHtml(data.orderName)}

+
${escapeHtml(ART49_INFO)}
+${errorBanner(data.error)} +
+ + + + + + + + + +

Puoi modificare il testo della dichiarazione se lo desideri.

+ +
+ + +

Qui riceverai la ricevuta del recesso. Già preso dal tuo ordine — puoi modificarlo.

+
+ + +
+${coexistenceBlock()} +`); +} + +// --- Step 3: riepilogo + conferma dedicata -------------------------------- +export function renderStep3(data: { + orderId: string; + orderName: string; + email: string; + customerName: string; + statementText: string; + error?: string; +}): string { + return renderShell(` +${stepIndicator(3)} +

${escapeHtml(PAGE_TITLE)}

+

Controlla i dati. Il recesso sarà trasmesso solo quando premi «${escapeHtml(CONFIRM_LABEL)}».

+${errorBanner(data.error)} +
+
${escapeHtml(FIELD.orderName.label)}
+
${escapeHtml(data.orderName)}
+
${escapeHtml(FIELD.name.label)}
+
${escapeHtml(data.customerName)}
+
${escapeHtml(FIELD.email.label)}
+
${escapeHtml(data.email)}
+
${escapeHtml(FIELD.statement.label)}
+
${escapeHtml(data.statementText)}
+
+ +
+
+ + + + + + + +
+ +
+ + + + + + + +
+
+${coexistenceBlock()} +`); +} + +// --- Step 4: successo ----------------------------------------------------- +export function renderStep4(data: { + line1: string; + line2: string; + line3: string; +}): string { + return renderShell(` +${stepIndicator(4)} +
+ +

${escapeHtml(data.line1)}

+

${escapeHtml(data.line2)}

+

${escapeHtml(data.line3)}

+
+${coexistenceBlock()} +`); +} + +/** Helper: Response HTML standalone (status 200 di default per non leakare via status). */ +export function htmlResponse(html: string, status = 200): Response { + return new Response(html, { + status, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); +} diff --git a/app/app/routes/proxy.tsx b/app/app/routes/proxy.tsx new file mode 100644 index 0000000..9f6b3bf --- /dev/null +++ b/app/app/routes/proxy.tsx @@ -0,0 +1,326 @@ +/** + * App Proxy — flusso di recesso guest (SPEC-MVP-RECESSO §4). + * + * Storefront `/apps/recesso` -> (App Proxy) -> questa route `/proxy`. + * Config in shopify.app.toml: [app_proxy] url=/proxy, subpath=recesso, prefix=apps. + * + * La route espone SOLO loader + action e ritorna sempre una `Response` HTML + * standalone (nessun default component, nessun Polaris, nessun root layout admin): + * è il pattern delle route App Proxy (come l'helper `liquid()`). + * + * Macchina a stati a 2 step (guest, zero JS client). Lo stato viaggia tra gli + * step in campi hidden delle form; nessuno stato server è persistito prima della + * conferma finale. Ogni richiesta è autenticata da authenticate.public.appProxy, + * che verifica la firma HMAC di Shopify e fornisce session/admin scoped allo shop. + */ + +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; +import { authenticate } from "../shopify.server"; +import db from "../db.server"; +import { sendWithdrawalReceipt } from "../lib/mailer.server"; +import { ERROR, statementTemplate, successMessage } from "../lib/recesso.copy"; +import { + MVP_LOCALE, + checkRateLimit, + clientIp, + formatTransmittedAt, + htmlResponse, + isValidEmail, + lookupOrder, + renderStep1, + renderStep2, + renderStep3, + renderStep4, + sha256, +} from "../lib/recesso.server"; + +// GET /apps/recesso -> Step 1 (form di lookup). +export const loader = async ({ request }: LoaderFunctionArgs) => { + await authenticate.public.appProxy(request); + return htmlResponse(renderStep1()); +}; + +export const action = async ({ request }: ActionFunctionArgs) => { + const { session, admin } = await authenticate.public.appProxy(request); + + // Narrowing: senza sessione offline non abbiamo Admin API per lo shop. + // Multi-tenant: lo shop lo prendiamo SOLO da session.shop, mai dal client. + if (!session || !admin) { + return htmlResponse(renderStep1({ error: ERROR.generic })); + } + const shop = session.shop; + + const form = await request.formData(); + const intent = String(form.get("intent") ?? ""); + + switch (intent) { + // ------------------------------------------------------------------- + // STEP 1 -> lookup ordine (anti-leak) -> STEP 2 + // ------------------------------------------------------------------- + case "lookup": { + const orderNameInput = String(form.get("orderName") ?? "").trim(); + const emailInput = String(form.get("email") ?? "").trim(); + + // Validazione base (stesso status 200 di ogni risposta in-flow). + if (!orderNameInput || !emailInput) { + return htmlResponse( + renderStep1({ + error: ERROR.missingField, + orderName: orderNameInput, + email: emailInput, + }), + ); + } + if (!isValidEmail(emailInput)) { + return htmlResponse( + renderStep1({ + error: ERROR.invalidEmail, + orderName: orderNameInput, + email: emailInput, + }), + ); + } + + // Rate-limit base per shop+IP (hardening -> A9). + if (!checkRateLimit(shop, clientIp(request))) { + await db.auditLog.create({ + data: { + shop, + event: "withdrawal_lookup_failed", + payloadHash: sha256({ orderNameInput, emailInput }), + detail: "rate_limited", + }, + }); + return htmlResponse( + renderStep1({ + error: ERROR.generic, + orderName: orderNameInput, + email: emailInput, + }), + ); + } + + const match = await lookupOrder(admin, orderNameInput, emailInput); + + // ANTI-LEAK (SPEC §6 / R2): ordine inesistente ED email non combaciante + // producono lo STESSO messaggio, stesso testo e stesso status 200. + if (!match) { + await db.auditLog.create({ + data: { + shop, + event: "withdrawal_lookup_failed", + payloadHash: sha256({ orderNameInput, emailInput }), // no PII in chiaro + detail: "no_match", + }, + }); + return htmlResponse( + renderStep1({ + error: ERROR.lookupNoMatch, + orderName: orderNameInput, + email: emailInput, + }), + ); + } + + // TODO(A6): qui andrà il check finestra 14 gg (deadline engine). Se la + // finestra è scaduta -> mostrare ERROR.windowClosed e bloccare. + // TODO(A6): qui andrà il check esclusioni Art. 59 per gli item dell'ordine. + + return htmlResponse( + renderStep2({ + orderId: match.orderId, + orderName: match.orderName, + email: match.email, + statementText: statementTemplate(match.orderName), + }), + ); + } + + // ------------------------------------------------------------------- + // STEP 2 -> validazione 4 dati -> STEP 3 (riepilogo) + // ------------------------------------------------------------------- + case "details": { + const orderId = String(form.get("orderId") ?? "").trim(); + const orderName = String(form.get("orderName") ?? "").trim(); + const customerName = String(form.get("customerName") ?? "").trim(); + const email = String(form.get("email") ?? "").trim(); + const statementText = String(form.get("statementText") ?? "").trim(); + + // Sicurezza: se mancano i riferimenti d'ordine (tamper/link diretto), + // riparti dallo Step 1 senza rivelare nulla. + if (!orderId || !orderName) { + return htmlResponse(renderStep1({ error: ERROR.generic })); + } + + if (!customerName || !email || !statementText) { + return htmlResponse( + renderStep2({ + orderId, + orderName, + email, + customerName, + statementText: statementText || statementTemplate(orderName), + error: ERROR.missingField, + }), + ); + } + if (!isValidEmail(email)) { + return htmlResponse( + renderStep2({ + orderId, + orderName, + email, + customerName, + statementText, + error: ERROR.invalidEmail, + }), + ); + } + + return htmlResponse( + renderStep3({ orderId, orderName, email, customerName, statementText }), + ); + } + + // ------------------------------------------------------------------- + // STEP 3 «Torna indietro» -> ripopola STEP 2 (editing consentito, non dark pattern) + // ------------------------------------------------------------------- + case "edit": { + const orderId = String(form.get("orderId") ?? "").trim(); + const orderName = String(form.get("orderName") ?? "").trim(); + const customerName = String(form.get("customerName") ?? "").trim(); + const email = String(form.get("email") ?? "").trim(); + const statementText = String(form.get("statementText") ?? "").trim(); + if (!orderId || !orderName) { + return htmlResponse(renderStep1({ error: ERROR.generic })); + } + return htmlResponse( + renderStep2({ + orderId, + orderName, + email, + customerName, + statementText: statementText || statementTemplate(orderName), + }), + ); + } + + // ------------------------------------------------------------------- + // STEP 3 «Conferma recesso» -> TRASMISSIONE: persistenza + audit -> STEP 4 + // Unica azione che registra la richiesta (funzione dedicata, no checkbox). + // ------------------------------------------------------------------- + case "confirm": { + const orderId = String(form.get("orderId") ?? "").trim(); + const orderName = String(form.get("orderName") ?? "").trim(); + const customerName = String(form.get("customerName") ?? "").trim(); + const email = String(form.get("email") ?? "").trim(); + const statementText = String(form.get("statementText") ?? "").trim(); + + if ( + !orderId || + !orderName || + !customerName || + !email || + !statementText || + !isValidEmail(email) + ) { + return htmlResponse(renderStep1({ error: ERROR.generic })); + } + + // Re-verifica server-side (integrità hidden fields / anti-tamper): + // l'ordine deve ancora esistere e l'email combaciare per questo shop. + const match = await lookupOrder(admin, orderName, email); + if (!match || match.orderId !== orderId) { + return htmlResponse(renderStep1({ error: ERROR.lookupNoMatch })); + } + + // TODO(A6): re-check finestra 14 gg + esclusioni Art. 59 prima di registrare. + + // transmittedAt = ISTANTE DI TRASMISSIONE (click "Conferma recesso"), + // NON di ricezione. Salvato in UTC (Prisma DateTime). + const transmittedAt = new Date(); + + let created; + try { + created = await db.withdrawalRequest.create({ + data: { + shop, // sempre da session.shop + orderId: match.orderId, // GID risolto dal lookup + orderName: match.orderName, + customerName, + email, + statementText, + transmittedAt, + channel: "GUEST", + locale: MVP_LOCALE, + status: "RECEIVED", + receiptSentAt: null, // valorizzato da A4 dopo invio ricevuta + }, + }); + + // Audit trail append-only (SPEC R6): evento + hash del payload. + await db.auditLog.create({ + data: { + shop, + event: "withdrawal_received", + payloadHash: sha256({ + orderId: match.orderId, + orderName: match.orderName, + customerName, + email, + statementText, + transmittedAt: transmittedAt.toISOString(), + }), + detail: match.orderName, + }, + }); + } catch { + return htmlResponse(renderStep1({ error: ERROR.generic })); + } + + const transmittedLabel = formatTransmittedAt(transmittedAt); + + // A4 — ricevuta su supporto durevole, senza ritardo. Il recesso è GIÀ + // persistito e valido: un invio email fallito NON deve invalidarlo. + const receipt = await sendWithdrawalReceipt({ + to: email, + orderName: match.orderName, + customerName, + statementText, + transmittedAt: transmittedLabel, + shopName: shop, + }); + try { + if (receipt.ok) { + await db.withdrawalRequest.update({ + where: { id: created.id }, + data: { receiptSentAt: new Date() }, + }); + await db.auditLog.create({ + data: { shop, event: "receipt_sent", detail: match.orderName }, + }); + } else { + console.error("[recesso] invio ricevuta fallito:", receipt.error); + await db.auditLog.create({ + data: { + shop, + event: "receipt_failed", + detail: receipt.error.slice(0, 200), + }, + }); + // TODO(A9): coda/retry per la ricevuta fallita ("senza ritardo") + + // messaggio di successo che rifletta l'esito reale dell'invio. + } + } catch (e) { + console.error("[recesso] aggiornamento stato ricevuta fallito:", e); + } + + const msg = successMessage(match.orderName, transmittedLabel, email); + return htmlResponse(renderStep4(msg)); + } + + default: + // Intent sconosciuto: torna allo Step 1 senza rivelare dettagli. + return htmlResponse(renderStep1()); + } +}; diff --git a/app/package.json b/app/package.json index f0beb90..f8c09cf 100644 --- a/app/package.json +++ b/app/package.json @@ -34,6 +34,7 @@ "@shopify/shopify-app-remix": "^4.1.0", "@shopify/shopify-app-session-storage-prisma": "^8.0.0", "isbot": "^5.1.0", + "nodemailer": "^9.0.3", "prisma": "^6.2.1", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -45,6 +46,7 @@ "@shopify/api-codegen-preset": "^1.1.1", "@types/eslint": "^9.6.1", "@types/node": "^22.2.0", + "@types/nodemailer": "^8.0.1", "@types/react": "^18.2.31", "@types/react-dom": "^18.2.14", "eslint": "^8.42.0", @@ -62,6 +64,7 @@ "@shopify/plugin-cloudflare" ], "resolutions": { + "@shopify/shopify-api": "13.1.0", "@graphql-tools/url-loader": "8.0.16", "@graphql-codegen/client-preset": "4.7.0", "@graphql-codegen/typescript-operations": "4.5.0", @@ -69,6 +72,7 @@ "vite": "^6.2.2" }, "overrides": { + "@shopify/shopify-api": "13.1.0", "@graphql-tools/url-loader": "8.0.16", "@graphql-codegen/client-preset": "4.7.0", "@graphql-codegen/typescript-operations": "4.5.0", diff --git a/app/shopify.app.toml b/app/shopify.app.toml index 8ec0c03..44a07a4 100644 --- a/app/shopify.app.toml +++ b/app/shopify.app.toml @@ -6,6 +6,14 @@ name = "Legal Return PCRT " application_url = "https://miscellaneous-connections-harvest-chronicle.trycloudflare.com" embedded = true +# App Proxy: lo storefront /apps/recesso viene proxato a /proxy. +# NB: se il tunnel Cloudflare (application_url) cambia, aggiornare anche `url` qui. +# Richiede `shopify app deploy` perché la configurazione abbia effetto. +[app_proxy] +url = "https://miscellaneous-connections-harvest-chronicle.trycloudflare.com/proxy" +subpath = "recesso" +prefix = "apps" + [build] automatically_update_urls_on_dev = false dev_store_url = "pcrt-reso-test.myshopify.com"