Compare commits

...

6 Commits

Author SHA1 Message Date
f3b3b1abc3 Deploy Fly: application_url/app_proxy/redirect -> recesso-custom.fly.dev + escludi .env dall'immagine
- shopify.app.toml: URL Shopify puntano all'app Fly (custom distribution) invece del tunnel dev.
- .dockerignore: esclude .env, *cred*, .shopify (niente segreti dev nell'immagine prod).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
2026-07-07 17:50:11 +02:00
3bb7a30c1d SMTP per-shop configurabile dalle Impostazioni (password cifrata AES-256-GCM)
- Settings: smtpHost/Port/User/Pass(cifrata)/Secure/From. Vuoto -> provider
  default dell'app (env); compilato -> invio dal SMTP del merchant.
- crypto.server: encrypt/decrypt AES-256-GCM (chiave APP_ENCRYPTION_KEY).
- mailer: SmtpConfig + buildTransport(smtp) per-shop o env; mailFrom override.
  Sia ricevuta cliente sia notifica merchant usano lo SMTP del merchant.
- proxy: costruisce SmtpConfig (decifra pass), passa ai due invii.
- admin: nuovo tab 'Email (SMTP)'; password mai ri-mostrata (vuoto = invariata).
- Migrazione smtp_settings. APP_ENCRYPTION_KEY: dev in .env, prod = fly secret.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
2026-07-07 17:29:33 +02:00
ad9f76b6be R4 hardening (A9): retry ricevuta + webhook GDPR + rate-limit prune + fix PII/UX
Da review avversariale indipendente:
- Retry invio email (trySend, 3 tentativi backoff) per ricevuta cliente e notifica merchant.
- Webhook GDPR implementati (erano stub) con idempotenza (skip se gia' processed):
  * customers/redact: pseudonimizza PII (nome/email/dichiarazione) nelle
    WithdrawalRequest del cliente, mantiene il record legale (ordine+timestamp)
    come prova art. 54-bis (base art. 17(3) GDPR).
  * shop/redact: purge completa dei dati shop (Settings/Exclusion/Withdrawal/
    Session/AuditLog + WebhookEvent stale).
  * customers/data_request: registra la richiesta + n. record (merchant li recupera
    dalla dashboard Recessi).
- Rate-limit: pruning periodico del bucket in-memory (fix memory leak).
- Ricevuta fallita: messaggio di successo onesto (successMessage riceve receipt.ok)
  invece del falso 'ti abbiamo inviato la ricevuta'.
- PII: rimossa dai detail audit persistiti degli errori SMTP (ricevuta/notifica).

Non modificati (verificati): off-by-1 finestra = permissivo, non blocca a torto;
'doppio escape' = falso allarme (template e valori escapati una volta ciascuno).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
2026-07-07 17:07:06 +02:00
ce36f1a657 Fix: reso gia' esistente -> stato 'exists' + messaggio corretto al merchant
Se l'ordine ha gia' un reso (order.returns), createShopifyReturn ritorna 'exists'
invece di fallire: la notifica dice 'esiste gia' un reso, gestiscilo dall'ordine'
al posto del fuorviante 'reso non creato, fallo manualmente'. Audit shopify_return_exists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
2026-07-07 16:54:43 +02:00
5fb9bf494c Dashboard Recessi: promemoria rimborso 14gg (Art. 56) + colonna 'Rimborsa entro' (trasmissione + 14gg)
Il promemoria non e' piu' solo nella notifica email: visibile anche dove il
merchant gestisce le richieste (funziona pure con notifiche off).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
2026-07-07 16:47:17 +02:00
04a43182cb R2 (G7): promemoria rimborso 14gg + facolta' di trattenuta (Art. 56) nella notifica al merchant
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
2026-07-07 16:40:05 +02:00
15 changed files with 408 additions and 56 deletions

6
.gitignore vendored
View File

@@ -27,3 +27,9 @@ app/prisma/*.sqlite*
# OS / editor cruft
.DS_Store
# Credenziali locali - MAI committare
Cred Fly
*[Cc]red*
*.secret
token.txt

View File

@@ -1,3 +1,7 @@
.cache
build
node_modules
.env
.env.*
*[Cc]red*
.shopify

View File

@@ -0,0 +1,47 @@
/**
* Cifratura simmetrica per segreti a riposo (es. password SMTP per-shop).
* AES-256-GCM. Chiave da env APP_ENCRYPTION_KEY (qualsiasi lunghezza: derivata
* a 32 byte via SHA-256). In prod = `fly secrets set APP_ENCRYPTION_KEY=...`.
*/
import {
createCipheriv,
createDecipheriv,
createHash,
randomBytes,
} from "node:crypto";
const PREFIX = "enc:v1:";
function key(): Buffer {
const raw = process.env.APP_ENCRYPTION_KEY;
if (!raw || raw.length < 16) {
throw new Error("APP_ENCRYPTION_KEY mancante o troppo corta (>=16 char)");
}
return createHash("sha256").update(raw).digest();
}
/** Cifra -> "enc:v1:<iv>:<tag>:<data>" (base64). */
export function encryptSecret(plain: string): string {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key(), iv);
const enc = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return `${PREFIX}${iv.toString("base64")}:${tag.toString("base64")}:${enc.toString("base64")}`;
}
/** Decifra; se non e' nel formato cifrato, ritorna il valore invariato. */
export function decryptSecret(value: string): string {
if (!value.startsWith(PREFIX)) return value;
const parts = value.split(":");
if (parts.length !== 5) return "";
const iv = Buffer.from(parts[2]!, "base64");
const tag = Buffer.from(parts[3]!, "base64");
const data = Buffer.from(parts[4]!, "base64");
const decipher = createDecipheriv("aes-256-gcm", key(), iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(data), decipher.final()]).toString(
"utf8",
);
}
export const SECRET_PREFIX = PREFIX;

View File

@@ -18,22 +18,67 @@ import {
type OperationalConfig,
} from "./emailTemplate";
function buildTransport() {
const host = process.env.SMTP_HOST;
/** Config SMTP per-shop (password gia' DECIFRATA). Se host assente -> usa env app. */
export interface SmtpConfig {
host?: string | null;
port?: number | null;
user?: string | null;
pass?: string | null;
secure?: boolean;
from?: string | null;
}
function buildTransport(smtp?: SmtpConfig | null) {
const useShop = !!(smtp && smtp.host && smtp.host.trim());
const host = useShop ? smtp!.host!.trim() : process.env.SMTP_HOST;
if (!host) return null;
const port = Number(process.env.SMTP_PORT ?? 587);
const user = process.env.SMTP_USER;
const port = useShop
? Number(smtp!.port ?? 587)
: Number(process.env.SMTP_PORT ?? 587);
const secure = useShop ? !!smtp!.secure : process.env.SMTP_SECURE === "true";
const user = useShop ? smtp!.user : process.env.SMTP_USER;
const pass = useShop ? smtp!.pass : process.env.SMTP_PASS;
return nodemailer.createTransport({
host,
port,
secure: process.env.SMTP_SECURE === "true",
auth: user ? { user, pass: process.env.SMTP_PASS ?? "" } : undefined,
secure,
auth: user ? { user, pass: pass ?? "" } : undefined,
connectionTimeout: 10_000,
greetingTimeout: 10_000,
socketTimeout: 10_000,
});
}
function mailFrom(smtp?: SmtpConfig | null): string {
return (
(smtp?.from && smtp.from.trim()) ||
process.env.MAIL_FROM ||
"no-reply@localhost"
);
}
type Transport = NonNullable<ReturnType<typeof buildTransport>>;
/** Invio con retry (backoff lineare). Riduce le ricevute perse per glitch SMTP. */
async function trySend(
transport: Transport,
message: Parameters<Transport["sendMail"]>[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
@@ -67,8 +112,9 @@ export async function sendWithdrawalReceipt(params: {
intro?: string | null;
note?: string | null;
operational?: OperationalConfig | null;
smtp?: SmtpConfig | null;
}): Promise<ReceiptResult> {
const transport = buildTransport();
const transport = buildTransport(params.smtp);
if (!transport) {
return { ok: false, error: "SMTP non configurato (SMTP_HOST mancante)" };
}
@@ -83,8 +129,8 @@ export async function sendWithdrawalReceipt(params: {
const text = htmlToText(html);
try {
const info = await transport.sendMail({
from: process.env.MAIL_FROM ?? "no-reply@localhost",
const info = await trySend(transport, {
from: mailFrom(params.smtp),
to: params.to,
subject,
text,
@@ -118,9 +164,10 @@ export async function sendMerchantNotification(params: {
customerEmail: string;
orderUrl: string;
transmittedAt: string;
returnStatus: "created" | "no_returnable" | "error";
returnStatus: "created" | "no_returnable" | "exists" | "error";
smtp?: SmtpConfig | null;
}): Promise<ReceiptResult> {
const transport = buildTransport();
const transport = buildTransport(params.smtp);
if (!transport) {
return { ok: false, error: "SMTP non configurato (SMTP_HOST mancante)" };
}
@@ -128,9 +175,11 @@ export async function sendMerchantNotification(params: {
const actionLine =
params.returnStatus === "created"
? "E' stato creato un reso nell'ordine: gestiscilo dalla pagina dell'ordine."
: params.returnStatus === "no_returnable"
? "L'ordine non risulta evaso: valuta annullamento o rimborso."
: "Reso non creato automaticamente: verifica manualmente l'ordine.";
: params.returnStatus === "exists"
? "Esiste gia' un reso per questo ordine: gestiscilo dalla pagina dell'ordine."
: params.returnStatus === "no_returnable"
? "L'ordine non risulta evaso: valuta annullamento o rimborso."
: "Reso non creato automaticamente: verifica manualmente l'ordine.";
const orderBtn = /^https?:\/\//i.test(params.orderUrl)
? `<p style="margin:16px 0 0;"><a href="${escM(params.orderUrl)}" style="display:inline-block;padding:10px 18px;background:#1a1a1a;color:#fff;text-decoration:none;border-radius:8px;font-size:14px;font-weight:600;">Apri l'ordine</a></p>`
@@ -151,6 +200,7 @@ export async function sendMerchantNotification(params: {
<div><span style="color:#777;">Trasmesso:</span> ${escM(params.transmittedAt)}</div>
</td></tr></table>
<p style="margin:16px 0 0;font-size:14px;line-height:1.6;color:#3a3a3a;">${actionLine}</p>
<p style="margin:10px 0 0;font-size:12.5px;line-height:1.6;color:#8a8a8a;">Promemoria: disponi il rimborso entro 14 giorni dalla richiesta (art. 56 Cod. Consumo). Puoi trattenerlo fino alla riconsegna della merce o alla prova di spedizione da parte del cliente.</p>
${orderBtn}
</td></tr></table>
</td></tr></table>
@@ -161,14 +211,15 @@ ${orderBtn}
`Cliente: ${params.customerName} (${params.customerEmail})`,
`Trasmesso: ${params.transmittedAt}`,
actionLine,
"Promemoria: rimborso entro 14 giorni dalla richiesta (art. 56); puoi trattenere fino alla riconsegna della merce o alla prova di spedizione.",
/^https?:\/\//i.test(params.orderUrl) ? params.orderUrl : "",
]
.filter(Boolean)
.join("\n");
try {
const info = await transport.sendMail({
from: process.env.MAIL_FROM ?? "no-reply@localhost",
const info = await trySend(transport, {
from: mailFrom(params.smtp),
to: params.to,
subject,
text,

View File

@@ -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.`,
};
}

View File

@@ -98,9 +98,20 @@ const RATE_MAX_ATTEMPTS = 8; // tentativi di lookup per finestra, per shop+IP
const rateBucket = new Map<string, { count: number; resetAt: number }>();
/** 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 });
@@ -356,6 +367,9 @@ export async function getShopInfo(admin: AdminApiContext): Promise<ShopInfo> {
const RETURNABLE_QUERY = `#graphql
query recessoOrderFulfillments($orderId: ID!) {
order(id: $orderId) {
returns(first: 1) {
edges { node { id } }
}
fulfillments(first: 10) {
fulfillmentLineItems(first: 50) {
edges {
@@ -380,6 +394,7 @@ const RETURN_CREATE_MUTATION = `#graphql
interface ReturnableGraphQL {
data?: {
order?: {
returns?: { edges?: Array<unknown> | null } | null;
fulfillments?: Array<{
fulfillmentLineItems?: {
edges?: Array<{
@@ -402,6 +417,7 @@ interface ReturnCreateGraphQL {
export type ReturnCreation =
| { status: "created"; returnId: string }
| { status: "no_returnable" }
| { status: "exists" } // esiste gia' un reso per l'ordine
| { status: "error"; error: string };
/**
@@ -418,6 +434,10 @@ export async function createShopifyReturn(
variables: { orderId: orderGid },
});
const qBody = (await qRes.json()) as ReturnableGraphQL;
// Se esiste gia' un reso per l'ordine, non crearne un altro (evita errore fuorviante).
if ((qBody.data?.order?.returns?.edges ?? []).length > 0) {
return { status: "exists" };
}
const returnLineItems: Array<{
fulfillmentLineItemId: string;
quantity: number;

View File

@@ -25,6 +25,7 @@ import { TitleBar } from "@shopify/app-bridge-react";
import { authenticate } from "../shopify.server";
import db from "../db.server";
import { encryptSecret } from "../lib/crypto.server";
import {
DEFAULT_INTRO,
DEFAULT_NOTE,
@@ -58,6 +59,12 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
returnAddress: s?.returnAddress ?? "",
opTextUnfulfilled: s?.opTextUnfulfilled ?? DEFAULT_OP_UNFULFILLED,
opTextShipped: s?.opTextShipped ?? DEFAULT_OP_SHIPPED,
smtpHost: s?.smtpHost ?? "",
smtpPort: s?.smtpPort != null ? String(s.smtpPort) : "",
smtpUser: s?.smtpUser ?? "",
smtpSecure: s?.smtpSecure ?? false,
smtpFrom: s?.smtpFrom ?? "",
smtpPassSet: !!s?.smtpPass,
};
};
@@ -90,12 +97,24 @@ export const action = async ({ request }: ActionFunctionArgs) => {
opTextUnfulfilled:
opUnf && opUnf !== DEFAULT_OP_UNFULFILLED ? opUnf : null,
opTextShipped: opShip && opShip !== DEFAULT_OP_SHIPPED ? opShip : null,
smtpHost: String(f.get("smtpHost") ?? "").trim() || null,
smtpPort:
Number(f.get("smtpPort")) > 0 ? Math.trunc(Number(f.get("smtpPort"))) : null,
smtpUser: String(f.get("smtpUser") ?? "").trim() || null,
smtpSecure: f.get("smtpSecure") === "true",
smtpFrom: String(f.get("smtpFrom") ?? "").trim() || null,
};
// Password SMTP: cifrata solo se fornita; vuota = invariata.
const newPass = String(f.get("smtpPass") ?? "").trim();
const finalData = newPass
? { ...data, smtpPass: encryptSecret(newPass) }
: data;
await db.settings.upsert({
where: { shop: session.shop },
create: { shop: session.shop, ...data },
update: data,
create: { shop: session.shop, ...finalData },
update: finalData,
});
return { ok: true };
};
@@ -143,6 +162,12 @@ export default function SettingsPage() {
const [returnAddress, setReturnAddress] = useState(d.returnAddress);
const [opTextUnfulfilled, setOpTextUnfulfilled] = useState(d.opTextUnfulfilled);
const [opTextShipped, setOpTextShipped] = useState(d.opTextShipped);
const [smtpHost, setSmtpHost] = useState(d.smtpHost);
const [smtpPort, setSmtpPort] = useState(d.smtpPort);
const [smtpUser, setSmtpUser] = useState(d.smtpUser);
const [smtpPass, setSmtpPass] = useState("");
const [smtpSecure, setSmtpSecure] = useState(d.smtpSecure);
const [smtpFrom, setSmtpFrom] = useState(d.smtpFrom);
const [showSaved, setShowSaved] = useState(false);
const saving = nav.state === "submitting";
@@ -206,6 +231,12 @@ export default function SettingsPage() {
fd.set("returnAddress", returnAddress);
fd.set("opTextUnfulfilled", opTextUnfulfilled);
fd.set("opTextShipped", opTextShipped);
fd.set("smtpHost", smtpHost);
fd.set("smtpPort", smtpPort);
fd.set("smtpUser", smtpUser);
fd.set("smtpPass", smtpPass);
fd.set("smtpSecure", String(smtpSecure));
fd.set("smtpFrom", smtpFrom);
submit(fd, { method: "post" });
};
@@ -229,6 +260,7 @@ export default function SettingsPage() {
{ id: "notifiche", content: "Notifiche" },
{ id: "regole", content: "Regole recesso" },
{ id: "reso", content: "Reso e stato ordine" },
{ id: "smtp", content: "Email (SMTP)" },
];
return (
@@ -470,6 +502,69 @@ export default function SettingsPage() {
</Card>
</BlockStack>
) : null}
{tab === 4 ? (
<Card>
<BlockStack gap="400">
<BlockStack gap="100">
<Text as="h2" variant="headingMd">
Email (SMTP)
</Text>
<Text as="p" tone="subdued">
Vuoto = provider di default dell'app. Compila per inviare dal
tuo SMTP (email dal tuo dominio). La password è cifrata a
riposo.
</Text>
</BlockStack>
<TextField
label="Host SMTP"
value={smtpHost}
onChange={setSmtpHost}
autoComplete="off"
placeholder="smtp-relay.brevo.com"
/>
<TextField
label="Porta"
type="number"
value={smtpPort}
onChange={setSmtpPort}
autoComplete="off"
placeholder="587"
/>
<TextField
label="Utente"
value={smtpUser}
onChange={setSmtpUser}
autoComplete="off"
/>
<TextField
label="Password"
type="password"
value={smtpPass}
onChange={setSmtpPass}
autoComplete="off"
helpText={
d.smtpPassSet
? "Impostata. Lascia vuoto per non cambiarla."
: "Non impostata."
}
/>
<Checkbox
label="Connessione sicura diretta (SSL/TLS, porta 465)"
checked={smtpSecure}
onChange={setSmtpSecure}
/>
<TextField
label="Mittente (From)"
value={smtpFrom}
onChange={setSmtpFrom}
autoComplete="off"
placeholder="Il tuo negozio <no-reply@tuodominio.it>"
/>
{saveBtn}
</BlockStack>
</Card>
) : null}
</BlockStack>
</Layout.Section>
</Layout>

View File

@@ -7,6 +7,7 @@ import {
DataTable,
Text,
Badge,
Banner,
BlockStack,
} from "@shopify/polaris";
import { TitleBar } from "@shopify/app-bridge-react";
@@ -15,6 +16,18 @@ import { authenticate } from "../shopify.server";
import db from "../db.server";
import { formatTransmittedAt } from "../lib/recesso.server";
// Scadenza rimborso (Art. 56): trasmissione + 14 giorni, data Europe/Rome.
function refundBy(transmittedAt: Date): string {
const d = new Date(transmittedAt);
d.setDate(d.getDate() + 14);
return new Intl.DateTimeFormat("it-IT", {
timeZone: "Europe/Rome",
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(d);
}
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.admin(request);
const items = await db.withdrawalRequest.findMany({
@@ -29,6 +42,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
customerName: w.customerName,
email: w.email,
transmittedAt: formatTransmittedAt(w.transmittedAt),
refundBy: refundBy(w.transmittedAt),
receiptSent: !!w.receiptSentAt,
hasReturn: !!w.shopifyReturnId,
})),
@@ -43,6 +57,7 @@ export default function WithdrawalsPage() {
r.customerName,
r.email,
r.transmittedAt,
r.refundBy,
r.receiptSent ? "Inviata" : "-",
r.hasReturn ? "Sì" : "-",
]);
@@ -68,6 +83,11 @@ export default function WithdrawalsPage() {
<Text as="h2" variant="headingMd">
Registro recessi ({rows.length})
</Text>
<Banner tone="warning">
Ricorda: disponi il rimborso entro 14 giorni dalla richiesta
(art. 56). Puoi trattenere fino alla riconsegna della merce o
alla prova di spedizione.
</Banner>
<DataTable
columnContentTypes={[
"text",
@@ -76,12 +96,14 @@ export default function WithdrawalsPage() {
"text",
"text",
"text",
"text",
]}
headings={[
"Ordine",
"Cliente",
"Email",
"Trasmesso",
"Rimborsa entro",
"Ricevuta",
"Reso",
]}

View File

@@ -20,7 +20,9 @@ import db from "../db.server";
import {
sendMerchantNotification,
sendWithdrawalReceipt,
type SmtpConfig,
} from "../lib/mailer.server";
import { decryptSecret } from "../lib/crypto.server";
import {
ERROR,
EXCLUSION_REASON,
@@ -381,6 +383,22 @@ export const action = async ({ request }: ActionFunctionArgs) => {
atCustomerExpense: settings?.returnAtCustomerExpense ?? true,
}
: null;
// SMTP per-shop (se configurato): password decifrata; altrimenti default app.
let smtp: SmtpConfig | null = null;
if (settings?.smtpHost) {
try {
smtp = {
host: settings.smtpHost,
port: settings.smtpPort,
user: settings.smtpUser,
pass: settings.smtpPass ? decryptSecret(settings.smtpPass) : null,
secure: settings.smtpSecure,
from: settings.smtpFrom,
};
} catch {
console.error("[recesso] SMTP shop non decifrabile: uso default app");
}
}
const receipt = await sendWithdrawalReceipt({
to: email,
vars: {
@@ -396,6 +414,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
intro: settings?.emailIntro,
note: settings?.emailNote,
operational,
smtp,
});
try {
if (receipt.ok) {
@@ -412,11 +431,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);
@@ -426,7 +446,8 @@ export const action = async ({ request }: ActionFunctionArgs) => {
// (best-effort; il recesso legale e' gia' registrato). Ordini annullati/
// rimborsati -> skip (G5: evita doppio reso/rimborso). Non evaso -> il
// merchant gestisce annullo/rimborso.
let returnStatus: "created" | "no_returnable" | "error" = "error";
let returnStatus: "created" | "no_returnable" | "exists" | "error" =
"error";
const orderClosed =
!!match.cancelledAt ||
match.financialStatus === "REFUNDED" ||
@@ -464,6 +485,14 @@ export const action = async ({ request }: ActionFunctionArgs) => {
detail: "ordine non evaso o nulla da rendere",
},
});
} else if (ret.status === "exists") {
await db.auditLog.create({
data: {
shop,
event: "shopify_return_exists",
detail: match.orderName,
},
});
} else {
console.error("[recesso] returnCreate:", ret.error);
await db.auditLog.create({
@@ -523,12 +552,17 @@ export const action = async ({ request }: ActionFunctionArgs) => {
orderUrl: match.orderUrl,
transmittedAt: transmittedLabel,
returnStatus,
smtp,
});
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) {
@@ -536,7 +570,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));
}

View File

@@ -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();
};

View File

@@ -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();
};

View File

@@ -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();
};

View File

@@ -0,0 +1,7 @@
-- AlterTable
ALTER TABLE "Settings" ADD COLUMN "smtpFrom" TEXT,
ADD COLUMN "smtpHost" TEXT,
ADD COLUMN "smtpPass" TEXT,
ADD COLUMN "smtpPort" INTEGER,
ADD COLUMN "smtpSecure" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "smtpUser" TEXT;

View File

@@ -63,6 +63,12 @@ model Settings {
returnInstructions String? // deprecato: sostituito da opTextShipped
opTextUnfulfilled String?
opTextShipped String?
smtpHost String?
smtpPort Int?
smtpUser String?
smtpPass String? // cifrato AES-256-GCM (mai in chiaro)
smtpSecure Boolean @default(false)
smtpFrom String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@@ -3,14 +3,14 @@
client_id = "8d67c00a078a615037497ad056f7f99d"
name = "Legal Return PCRT "
# DEV: quick tunnel Cloudflare (no interstitial). Auto-update Dev Dashboard rotto → settato via deploy. Cambia se il tunnel riparte.
application_url = "https://miscellaneous-connections-harvest-chronicle.trycloudflare.com"
application_url = "https://recesso-custom.fly.dev"
embedded = true
# App Proxy: lo storefront /apps/recesso viene proxato a <application_url>/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"
url = "https://recesso-custom.fly.dev/proxy"
subpath = "recesso"
prefix = "apps"
@@ -49,7 +49,7 @@ use_legacy_install_flow = false
[auth]
redirect_urls = [
"https://miscellaneous-connections-harvest-chronicle.trycloudflare.com/auth/callback",
"https://miscellaneous-connections-harvest-chronicle.trycloudflare.com/auth/shopify/callback",
"https://miscellaneous-connections-harvest-chronicle.trycloudflare.com/api/auth/callback"
"https://recesso-custom.fly.dev/auth/callback",
"https://recesso-custom.fly.dev/auth/shopify/callback",
"https://recesso-custom.fly.dev/api/auth/callback"
]