diff --git a/app/app/lib/mailer.server.ts b/app/app/lib/mailer.server.ts index 7623c90..0260f5d 100644 --- a/app/app/lib/mailer.server.ts +++ b/app/app/lib/mailer.server.ts @@ -34,6 +34,28 @@ function buildTransport() { }); } +type Transport = NonNullable>; + +/** Invio con retry (backoff lineare). Riduce le ricevute perse per glitch SMTP. */ +async function trySend( + transport: Transport, + message: Parameters[0], + attempts = 3, +) { + let lastErr: unknown; + for (let i = 0; i < attempts; i++) { + try { + return await transport.sendMail(message); + } catch (e) { + lastErr = e; + if (i < attempts - 1) { + await new Promise((r) => setTimeout(r, 400 * (i + 1))); + } + } + } + throw lastErr; +} + /** Versione testo grezza dell'HTML (fallback per client senza HTML). */ function htmlToText(html: string): string { return html @@ -83,7 +105,7 @@ export async function sendWithdrawalReceipt(params: { const text = htmlToText(html); try { - const info = await transport.sendMail({ + const info = await trySend(transport, { from: process.env.MAIL_FROM ?? "no-reply@localhost", to: params.to, subject, @@ -171,7 +193,7 @@ ${orderBtn} .join("\n"); try { - const info = await transport.sendMail({ + const info = await trySend(transport, { from: process.env.MAIL_FROM ?? "no-reply@localhost", to: params.to, subject, diff --git a/app/app/lib/recesso.copy.ts b/app/app/lib/recesso.copy.ts index dcdfb97..e768da2 100644 --- a/app/app/lib/recesso.copy.ts +++ b/app/app/lib/recesso.copy.ts @@ -58,11 +58,14 @@ export function successMessage( orderName: string, transmittedAt: string, email: string, + receiptSent = true, ): { line1: string; line2: string; line3: string } { return { line1: "Recesso trasmesso", line2: `Registrato per l'ordine ${orderName} il ${transmittedAt}.`, - line3: `Ti abbiamo inviato una ricevuta a ${email}.`, + line3: receiptSent + ? `Ti abbiamo inviato una ricevuta a ${email}.` + : `La ricevuta verrà inviata a ${email}. Se non la ricevi a breve, contattaci.`, }; } diff --git a/app/app/lib/recesso.server.ts b/app/app/lib/recesso.server.ts index 0725b29..d52a7a9 100644 --- a/app/app/lib/recesso.server.ts +++ b/app/app/lib/recesso.server.ts @@ -98,9 +98,20 @@ 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. */ +let lastRatePruneAt = 0; +/** Rimuove le voci scadute dal bucket (evita crescita illimitata della Map). */ +function pruneRateBucket(now: number): void { + if (now - lastRatePruneAt < 60_000) return; + lastRatePruneAt = now; + for (const [k, v] of rateBucket) { + if (v.resetAt <= now) rateBucket.delete(k); + } +} + export function checkRateLimit(shop: string, ip: string): boolean { const key = `${shop}:${ip}`; const now = Date.now(); + pruneRateBucket(now); const entry = rateBucket.get(key); if (!entry || entry.resetAt <= now) { rateBucket.set(key, { count: 1, resetAt: now + RATE_WINDOW_MS }); diff --git a/app/app/routes/proxy.tsx b/app/app/routes/proxy.tsx index 7cc8f19..b1ad130 100644 --- a/app/app/routes/proxy.tsx +++ b/app/app/routes/proxy.tsx @@ -412,11 +412,12 @@ export const action = async ({ request }: ActionFunctionArgs) => { data: { shop, event: "receipt_failed", - detail: receipt.error.slice(0, 200), + // no PII in audit: l'errore SMTP puo' contenere l'email. + detail: "invio ricevuta fallito", }, }); - // TODO(A9): coda/retry per la ricevuta fallita ("senza ritardo") + - // messaggio di successo che rifletta l'esito reale dell'invio. + // Retry in-request nel mailer (trySend) + messaggio di successo onesto + // (successMessage riceve receipt.ok). Coda persistente = eventuale futuro. } } catch (e) { console.error("[recesso] aggiornamento stato ricevuta fallito:", e); @@ -533,11 +534,15 @@ export const action = async ({ request }: ActionFunctionArgs) => { transmittedAt: transmittedLabel, returnStatus, }); + if (!notif.ok) { + console.error("[recesso] notifica merchant fallita:", notif.error); + } await db.auditLog.create({ data: { shop, event: notif.ok ? "merchant_notified" : "merchant_notify_failed", - detail: notif.ok ? match.orderName : notif.error.slice(0, 200), + // no PII in audit: l'errore SMTP puo' contenere l'email. + detail: notif.ok ? match.orderName : "notifica merchant fallita", }, }); } catch (e) { @@ -545,7 +550,12 @@ export const action = async ({ request }: ActionFunctionArgs) => { } } - const msg = successMessage(match.orderName, transmittedLabel, email); + const msg = successMessage( + match.orderName, + transmittedLabel, + email, + receipt.ok, + ); return htmlResponse(renderStep4(msg)); } diff --git a/app/app/routes/webhooks.customers.data_request.tsx b/app/app/routes/webhooks.customers.data_request.tsx index cdd0d13..d4a9b8a 100644 --- a/app/app/routes/webhooks.customers.data_request.tsx +++ b/app/app/routes/webhooks.customers.data_request.tsx @@ -17,23 +17,39 @@ export const action = async ({ request }: ActionFunctionArgs) => { .update(JSON.stringify(payload ?? {})) .digest("hex"); - // Idempotency: record the webhook once (webhookId is unique when present). + // Idempotency: se gia' lavorato, esci. const dedupeKey = webhookId ?? `${topic}:${payloadHash}`; + const existing = await db.webhookEvent.findUnique({ + where: { webhookId: dedupeKey }, + }); + if (existing?.processed) return new Response(); await db.webhookEvent.upsert({ where: { webhookId: dedupeKey }, create: { shop, topic, webhookId: dedupeKey, processed: false }, update: {}, }); + // I dati del cliente (richieste di recesso) sono consultabili dal merchant + // (titolare) nella dashboard Recessi, che li relaziona al data subject. + // Registriamo la richiesta e quanti record esistono. + const email = ((payload as { customer?: { email?: unknown } } | null)?.customer + ?.email ?? null) as string | null; + const count = + typeof email === "string" && email + ? await db.withdrawalRequest.count({ where: { shop, email } }) + : 0; + await db.auditLog.create({ - data: { shop, event: `gdpr.${topic}`, payloadHash, detail: "customers/data_request received" }, + data: { + shop, + event: `gdpr.${topic}`, + payloadHash, + detail: `customers/data_request: ${count} record disponibili nella dashboard Recessi`, + }, + }); + await db.webhookEvent.update({ + where: { webhookId: dedupeKey }, + data: { processed: true }, }); - - // TODO(A8): gather every stored personal data point for this customer - // (WithdrawalRequest rows matched by email / customer id: name, email, - // statement text, transmission timestamp, order refs) and hand it to the - // merchant (data controller), who relays it to the data subject. Then mark - // the WebhookEvent processed = true. - return new Response(); }; diff --git a/app/app/routes/webhooks.customers.redact.tsx b/app/app/routes/webhooks.customers.redact.tsx index bec8455..e8b66e1 100644 --- a/app/app/routes/webhooks.customers.redact.tsx +++ b/app/app/routes/webhooks.customers.redact.tsx @@ -18,22 +18,47 @@ export const action = async ({ request }: ActionFunctionArgs) => { .update(JSON.stringify(payload ?? {})) .digest("hex"); - // Idempotency: record the webhook once (webhookId is unique when present). + // Idempotency: se gia' lavorato, esci. const dedupeKey = webhookId ?? `${topic}:${payloadHash}`; + const existing = await db.webhookEvent.findUnique({ + where: { webhookId: dedupeKey }, + }); + if (existing?.processed) return new Response(); await db.webhookEvent.upsert({ where: { webhookId: dedupeKey }, create: { shop, topic, webhookId: dedupeKey, processed: false }, update: {}, }); + // Pseudonimizza la PII del cliente nelle richieste di recesso, mantenendo il + // record legale (ordine, timestamp) come prova ex art. 54-bis. Base di + // conservazione: obbligo legale / difesa in giudizio (art. 17(3) GDPR). + const email = ((payload as { customer?: { email?: unknown } } | null)?.customer + ?.email ?? null) as string | null; + let redacted = 0; + if (typeof email === "string" && email) { + const res = await db.withdrawalRequest.updateMany({ + where: { shop, email }, + data: { + customerName: "[redatto]", + email: "[redatto]", + statementText: "[redatto]", + }, + }); + redacted = res.count; + } + await db.auditLog.create({ - data: { shop, event: `gdpr.${topic}`, payloadHash, detail: "customers/redact received" }, + data: { + shop, + event: `gdpr.${topic}`, + payloadHash, + detail: `customers/redact: ${redacted} record pseudonimizzati`, + }, + }); + await db.webhookEvent.update({ + where: { webhookId: dedupeKey }, + data: { processed: true }, }); - - // TODO(A8): redact/anonymize this customer's PII in WithdrawalRequest - // (customerName, email, statementText) for the given shop + customer/orders, - // WITHOUT destroying the legal audit trail (keep AuditLog + hashed refs). - // Then mark the WebhookEvent processed = true. - return new Response(); }; diff --git a/app/app/routes/webhooks.shop.redact.tsx b/app/app/routes/webhooks.shop.redact.tsx index 2573281..e966b60 100644 --- a/app/app/routes/webhooks.shop.redact.tsx +++ b/app/app/routes/webhooks.shop.redact.tsx @@ -19,22 +19,33 @@ export const action = async ({ request }: ActionFunctionArgs) => { .update(JSON.stringify(payload ?? {})) .digest("hex"); - // Idempotency: record the webhook once (webhookId is unique when present). + // Idempotency: se gia' lavorato, esci. const dedupeKey = webhookId ?? `${topic}:${payloadHash}`; + const existing = await db.webhookEvent.findUnique({ + where: { webhookId: dedupeKey }, + }); + if (existing?.processed) return new Response(); await db.webhookEvent.upsert({ where: { webhookId: dedupeKey }, create: { shop, topic, webhookId: dedupeKey, processed: false }, update: {}, }); - await db.auditLog.create({ - data: { shop, event: `gdpr.${topic}`, payloadHash, detail: "shop/redact received" }, + // Purge completa dei dati dello shop (app disinstallata + ~48h). Il merchant, + // come titolare, deve aver esportato prima cio' che gli serve. Cancelliamo + // anche l'AuditLog: cessata la relazione, non c'e' piu' base per conservarlo. + await db.withdrawalRequest.deleteMany({ where: { shop } }); + await db.exclusionRule.deleteMany({ where: { shop } }); + await db.settings.deleteMany({ where: { shop } }); + await db.session.deleteMany({ where: { shop } }); + await db.auditLog.deleteMany({ where: { shop } }); + await db.webhookEvent.deleteMany({ + where: { shop, webhookId: { not: dedupeKey } }, + }); + // Manteniamo SOLO la WebhookEvent corrente (marcata processata) per idempotenza. + await db.webhookEvent.update({ + where: { webhookId: dedupeKey }, + data: { processed: true }, }); - - // TODO(A8): delete all data for this shop (Settings, ExclusionRule, - // WithdrawalRequest, Session, and stale WebhookEvent rows). Decide the legal - // retention policy for AuditLog before wiring the real deletion. Then mark - // the WebhookEvent processed = true. - return new Response(); };