PAGE_CSS, renderShell, stepLayout, renderStep1..4, escapeHtml/attr e PROXY_STOREFRONT_PATH spostati in un modulo PURO (nessun node/server). recesso.server.ts li ri-esporta: nessun chiamante cambia (proxy invariato). Motivo: l'anteprima nell'admin deve rendere lo stesso identico markup e CSS dello storefront, e non puo' farlo via <iframe src=route-admin> perche' l'admin embedded si autentica con session token via App Bridge, che una navigazione iframe non trasporta. Serve anche per il livello 2 (eredita il tema). Rimosso il parametro 'theme' spurio che il replace globale aveva aggiunto a stepLayout (non lo usa: il tema lo applica renderShell).
644 lines
20 KiB
TypeScript
644 lines
20 KiB
TypeScript
/**
|
|
* 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).
|
|
*
|
|
* Il RENDERING vive in ./recesso.view.ts (modulo puro, condiviso con l'anteprima
|
|
* admin). Qui lo ri-esportiamo, cosi' i chiamanti non cambiano.
|
|
*
|
|
* 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 type { ExclusionRule } from "@prisma/client";
|
|
|
|
export {
|
|
renderStep1,
|
|
renderStep2,
|
|
renderStep3,
|
|
renderStep4,
|
|
} from "./recesso.view";
|
|
|
|
// Locale MVP fisso (multi-lingua -> A7).
|
|
export const MVP_LOCALE = "it";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Utility
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
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<string, { count: number; resetAt: number }>();
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
/** 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();
|
|
pruneRateBucket(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;
|
|
orderUrl: string; // URL pagina di stato dell'ordine (link per il cliente)
|
|
fulfilledAt: string | null; // data ultima evasione (spedizione), null se non evaso
|
|
deliveredAt: string | null; // data di consegna (possesso fisico, Art. 52), null se non consegnato
|
|
cancelledAt: string | null; // data annullamento ordine, null altrimenti
|
|
financialStatus: string | null; // displayFinancialStatus (REFUNDED/VOIDED/PAID/...)
|
|
lineItems: Array<{ productId: string | null; tags: string[] }>;
|
|
}
|
|
|
|
// --- A6: finestra di recesso (deadline engine) ---------------------------
|
|
// Riferimento = data di CONSEGNA (possesso fisico, Art. 52), NON la spedizione.
|
|
// Se non consegnato -> la finestra non e' iniziata -> nessuna scadenza (non si
|
|
// blocca mai): il recesso resta ammesso (nasce dalla conclusione del contratto).
|
|
// NB: l'estensione a 12 mesi + 14gg per mancata informativa (Art. 49) NON e'
|
|
// gestita qui: l'app FORNISCE l'informativa, quindi vale il termine ordinario.
|
|
export function computeDeadline(
|
|
match: MatchedOrder,
|
|
windowDays: number,
|
|
): Date | null {
|
|
if (!match.deliveredAt) return null;
|
|
const d = new Date(match.deliveredAt);
|
|
if (Number.isNaN(d.getTime())) return null;
|
|
d.setUTCDate(d.getUTCDate() + windowDays);
|
|
return d;
|
|
}
|
|
|
|
export function isWindowExpired(
|
|
match: MatchedOrder,
|
|
windowDays: number,
|
|
now: Date,
|
|
): boolean {
|
|
const deadline = computeDeadline(match, windowDays);
|
|
return deadline ? now.getTime() > deadline.getTime() : false;
|
|
}
|
|
|
|
// --- A6: esclusioni Art. 59 ----------------------------------------------
|
|
export interface ExclusionCheck {
|
|
fullyExcluded: boolean; // tutte le righe escluse -> recesso non ammesso
|
|
anyExcluded: boolean; // almeno una riga esclusa -> recesso parziale
|
|
reasons: string[]; // ragioni distinte (ExclusionReason)
|
|
}
|
|
|
|
function ruleMatchesLine(
|
|
rule: ExclusionRule,
|
|
li: { productId: string | null; tags: string[] },
|
|
): boolean {
|
|
switch (rule.scope) {
|
|
case "ALL":
|
|
return true;
|
|
case "PRODUCT":
|
|
return !!rule.targetId && li.productId === rule.targetId;
|
|
case "TAG":
|
|
return !!rule.targetId && li.tags.includes(rule.targetId);
|
|
default:
|
|
return false; // COLLECTION non supportato in MVP
|
|
}
|
|
}
|
|
|
|
export function checkExclusions(
|
|
match: MatchedOrder,
|
|
rules: ExclusionRule[],
|
|
): ExclusionCheck {
|
|
const active = rules.filter((r) => r.active);
|
|
if (!active.length || !match.lineItems.length) {
|
|
return { fullyExcluded: false, anyExcluded: false, reasons: [] };
|
|
}
|
|
const reasons = new Set<string>();
|
|
let excluded = 0;
|
|
for (const li of match.lineItems) {
|
|
const rule = active.find((r) => ruleMatchesLine(r, li));
|
|
if (rule) {
|
|
excluded++;
|
|
reasons.add(rule.reason);
|
|
}
|
|
}
|
|
return {
|
|
fullyExcluded: excluded === match.lineItems.length,
|
|
anyExcluded: excluded > 0,
|
|
reasons: [...reasons],
|
|
};
|
|
}
|
|
|
|
// --- A6-bis: stato operativo ordine --------------------------------------
|
|
export type OrderState = "unfulfilled" | "shipped" | "delivered" | "closed";
|
|
|
|
/** Stato per il flusso recesso (email differenziata + auto-annullo). */
|
|
export function orderState(match: MatchedOrder): OrderState {
|
|
if (
|
|
match.cancelledAt ||
|
|
match.financialStatus === "REFUNDED" ||
|
|
match.financialStatus === "VOIDED"
|
|
) {
|
|
return "closed";
|
|
}
|
|
if (match.deliveredAt) return "delivered";
|
|
if (match.fulfilledAt) return "shipped";
|
|
return "unfulfilled";
|
|
}
|
|
|
|
interface OrderLookupGraphQL {
|
|
data?: {
|
|
orders?: {
|
|
edges?: Array<{
|
|
node?: {
|
|
id?: string | null;
|
|
name?: string | null;
|
|
email?: string | null;
|
|
createdAt?: string | null;
|
|
statusPageUrl?: string | null;
|
|
cancelledAt?: string | null;
|
|
displayFulfillmentStatus?: string | null;
|
|
displayFinancialStatus?: string | null;
|
|
fulfillments?: Array<{
|
|
createdAt?: string | null;
|
|
events?: {
|
|
edges?: Array<{
|
|
node?: {
|
|
status?: string | null;
|
|
happenedAt?: string | null;
|
|
} | null;
|
|
} | null> | null;
|
|
} | null;
|
|
} | null> | null;
|
|
lineItems?: {
|
|
edges?: Array<{
|
|
node?: {
|
|
quantity?: number | null;
|
|
product?: { id?: string | null; tags?: string[] | null } | null;
|
|
} | null;
|
|
} | null> | null;
|
|
} | 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
|
|
statusPageUrl
|
|
cancelledAt
|
|
displayFulfillmentStatus
|
|
displayFinancialStatus
|
|
fulfillments(first: 10) {
|
|
createdAt
|
|
events(first: 25) {
|
|
edges {
|
|
node {
|
|
status
|
|
happenedAt
|
|
}
|
|
}
|
|
}
|
|
}
|
|
lineItems(first: 50) {
|
|
edges {
|
|
node {
|
|
quantity
|
|
product {
|
|
id
|
|
tags
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`;
|
|
|
|
/**
|
|
* 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 interface ShopInfo {
|
|
name: string;
|
|
contactEmail: string;
|
|
url: string;
|
|
}
|
|
interface ShopInfoGraphQL {
|
|
data?: {
|
|
shop?: {
|
|
name?: string | null;
|
|
contactEmail?: string | null;
|
|
primaryDomain?: { url?: string | null } | null;
|
|
} | null;
|
|
} | null;
|
|
}
|
|
const SHOP_INFO_QUERY = `#graphql
|
|
query recessoShopInfo {
|
|
shop {
|
|
name
|
|
primaryDomain {
|
|
url
|
|
}
|
|
}
|
|
}`;
|
|
|
|
/** Dati del negozio per l'email (nome, URL, email contatto). Fallback vuoto su errore. */
|
|
export async function getShopInfo(admin: AdminApiContext): Promise<ShopInfo> {
|
|
try {
|
|
const res = await admin.graphql(SHOP_INFO_QUERY);
|
|
const body = (await res.json()) as ShopInfoGraphQL;
|
|
const s = body.data?.shop;
|
|
return {
|
|
name: s?.name?.trim() ?? "",
|
|
contactEmail: "",
|
|
url: s?.primaryDomain?.url?.trim() ?? "",
|
|
};
|
|
} catch {
|
|
return { name: "", contactEmail: "", url: "" };
|
|
}
|
|
}
|
|
|
|
// --- Integrazione Resi Shopify -------------------------------------------
|
|
const RETURNABLE_QUERY = `#graphql
|
|
query recessoOrderFulfillments($orderId: ID!) {
|
|
order(id: $orderId) {
|
|
returns(first: 1) {
|
|
edges { node { id } }
|
|
}
|
|
fulfillments(first: 10) {
|
|
fulfillmentLineItems(first: 50) {
|
|
edges {
|
|
node {
|
|
id
|
|
quantity
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`;
|
|
|
|
const RETURN_CREATE_MUTATION = `#graphql
|
|
mutation recessoReturnCreate($returnInput: ReturnInput!) {
|
|
returnCreate(returnInput: $returnInput) {
|
|
return { id status }
|
|
userErrors { field message }
|
|
}
|
|
}`;
|
|
|
|
interface ReturnableGraphQL {
|
|
data?: {
|
|
order?: {
|
|
returns?: { edges?: Array<unknown> | null } | null;
|
|
fulfillments?: Array<{
|
|
fulfillmentLineItems?: {
|
|
edges?: Array<{
|
|
node?: { id?: string | null; quantity?: number | null } | null;
|
|
} | null> | null;
|
|
} | null;
|
|
} | null> | null;
|
|
} | null;
|
|
} | null;
|
|
}
|
|
interface ReturnCreateGraphQL {
|
|
data?: {
|
|
returnCreate?: {
|
|
return?: { id?: string | null; status?: string | null } | null;
|
|
userErrors?: Array<{ field?: string[] | null; message?: string | null }> | null;
|
|
} | null;
|
|
} | null;
|
|
}
|
|
|
|
export type ReturnCreation =
|
|
| { status: "created"; returnId: string }
|
|
| { status: "no_returnable" }
|
|
| { status: "exists" } // esiste gia' un reso per l'ordine
|
|
| { status: "error"; error: string };
|
|
|
|
/**
|
|
* Crea un Reso Shopify nativo per l'ordine (solo articoli evasi). Best-effort:
|
|
* il recesso legale e' gia' registrato a prescindere. Ordini non evasi -> nessun
|
|
* reso (il merchant gestisce annullo/rimborso).
|
|
*/
|
|
export async function createShopifyReturn(
|
|
admin: AdminApiContext,
|
|
orderGid: string,
|
|
): Promise<ReturnCreation> {
|
|
try {
|
|
const qRes = await admin.graphql(RETURNABLE_QUERY, {
|
|
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;
|
|
returnReason: string;
|
|
returnReasonNote: string;
|
|
}> = [];
|
|
for (const f of qBody.data?.order?.fulfillments ?? []) {
|
|
for (const liEdge of f?.fulfillmentLineItems?.edges ?? []) {
|
|
const flId = liEdge?.node?.id;
|
|
const qty = liEdge?.node?.quantity ?? 0;
|
|
if (flId && qty > 0) {
|
|
returnLineItems.push({
|
|
fulfillmentLineItemId: flId,
|
|
quantity: qty,
|
|
returnReason: "OTHER",
|
|
returnReasonNote:
|
|
"Recesso ai sensi dell'art. 54-bis del Codice del Consumo",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
if (!returnLineItems.length) return { status: "no_returnable" };
|
|
|
|
const mRes = await admin.graphql(RETURN_CREATE_MUTATION, {
|
|
variables: { returnInput: { orderId: orderGid, returnLineItems } },
|
|
});
|
|
const mBody = (await mRes.json()) as ReturnCreateGraphQL;
|
|
const errs = mBody.data?.returnCreate?.userErrors ?? [];
|
|
if (errs.length) {
|
|
return {
|
|
status: "error",
|
|
error: errs
|
|
.map((e) => e?.message ?? "")
|
|
.filter(Boolean)
|
|
.join("; "),
|
|
};
|
|
}
|
|
const returnId = mBody.data?.returnCreate?.return?.id;
|
|
if (!returnId) return { status: "error", error: "returnCreate: nessun id" };
|
|
return { status: "created", returnId };
|
|
} catch (e) {
|
|
return {
|
|
status: "error",
|
|
error: e instanceof Error ? e.message : "returnCreate fallito",
|
|
};
|
|
}
|
|
}
|
|
|
|
const ORDER_TAG_MUTATION = `#graphql
|
|
mutation recessoTagAdd($id: ID!, $tags: [String!]!) {
|
|
tagsAdd(id: $id, tags: $tags) {
|
|
userErrors { field message }
|
|
}
|
|
}`;
|
|
|
|
interface TagsAddGraphQL {
|
|
data?: {
|
|
tagsAdd?: {
|
|
userErrors?: Array<{ message?: string | null }> | null;
|
|
} | null;
|
|
} | null;
|
|
}
|
|
|
|
/** Aggiunge il tag "Recesso" all'ordine (visibilita' merchant, filtrabile). Best-effort. */
|
|
export async function tagOrderRecesso(
|
|
admin: AdminApiContext,
|
|
orderGid: string,
|
|
): Promise<{ ok: boolean; error?: string }> {
|
|
try {
|
|
const res = await admin.graphql(ORDER_TAG_MUTATION, {
|
|
variables: { id: orderGid, tags: ["Recesso"] },
|
|
});
|
|
const body = (await res.json()) as TagsAddGraphQL;
|
|
const errs = body.data?.tagsAdd?.userErrors ?? [];
|
|
if (errs.length) {
|
|
return {
|
|
ok: false,
|
|
error: errs
|
|
.map((e) => e?.message ?? "")
|
|
.filter(Boolean)
|
|
.join("; "),
|
|
};
|
|
}
|
|
return { ok: true };
|
|
} catch (e) {
|
|
return {
|
|
ok: false,
|
|
error: e instanceof Error ? e.message : "tagsAdd fallito",
|
|
};
|
|
}
|
|
}
|
|
|
|
const ORDER_CANCEL_MUTATION = `#graphql
|
|
mutation recessoOrderCancel($id: ID!) {
|
|
orderCancel(orderId: $id, reason: CUSTOMER, refund: true, restock: true, notifyCustomer: false, staffNote: "Recesso art. 54-bis") {
|
|
job { id }
|
|
orderCancelUserErrors { field message }
|
|
}
|
|
}`;
|
|
|
|
interface OrderCancelGraphQL {
|
|
data?: {
|
|
orderCancel?: {
|
|
orderCancelUserErrors?: Array<{ message?: string | null }> | null;
|
|
} | null;
|
|
} | null;
|
|
}
|
|
|
|
/** Annulla l'ordine (recesso su ordine non evaso): refund + restock. Best-effort. */
|
|
export async function cancelOrder(
|
|
admin: AdminApiContext,
|
|
orderGid: string,
|
|
): Promise<{ ok: boolean; error?: string }> {
|
|
try {
|
|
const res = await admin.graphql(ORDER_CANCEL_MUTATION, {
|
|
variables: { id: orderGid },
|
|
});
|
|
const body = (await res.json()) as OrderCancelGraphQL;
|
|
const errs = body.data?.orderCancel?.orderCancelUserErrors ?? [];
|
|
if (errs.length) {
|
|
return {
|
|
ok: false,
|
|
error: errs
|
|
.map((e) => e?.message ?? "")
|
|
.filter(Boolean)
|
|
.join("; "),
|
|
};
|
|
}
|
|
return { ok: true };
|
|
} catch (e) {
|
|
return {
|
|
ok: false,
|
|
error: e instanceof Error ? e.message : "orderCancel fallito",
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function lookupOrder(
|
|
admin: AdminApiContext,
|
|
orderInput: string,
|
|
emailInput: string,
|
|
): Promise<MatchedOrder | null> {
|
|
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) {
|
|
const fulfillmentDates = (node.fulfillments ?? [])
|
|
.map((f) => f?.createdAt)
|
|
.filter((d): d is string => !!d)
|
|
.sort();
|
|
const deliveryDates = (node.fulfillments ?? [])
|
|
.flatMap((f) => f?.events?.edges ?? [])
|
|
.map((e) => e?.node)
|
|
.filter((n): n is NonNullable<typeof n> => !!n)
|
|
.filter((n) => n.status === "DELIVERED" && !!n.happenedAt)
|
|
.map((n) => n.happenedAt as string)
|
|
.sort();
|
|
const lineItems = (node.lineItems?.edges ?? [])
|
|
.map((e) => e?.node)
|
|
.filter((n): n is NonNullable<typeof n> => !!n)
|
|
.map((n) => ({
|
|
productId: n.product?.id ?? null,
|
|
tags: n.product?.tags ?? [],
|
|
}));
|
|
return {
|
|
orderId: node.id,
|
|
orderName: node.name,
|
|
email: node.email ?? emailInput.trim(),
|
|
createdAt: node.createdAt ?? "",
|
|
orderUrl: node.statusPageUrl ?? "",
|
|
fulfilledAt: fulfillmentDates.length
|
|
? fulfillmentDates[fulfillmentDates.length - 1]
|
|
: null,
|
|
deliveredAt: deliveryDates.length
|
|
? deliveryDates[deliveryDates.length - 1]
|
|
: null,
|
|
cancelledAt: node.cancelledAt ?? null,
|
|
financialStatus: node.displayFinancialStatus ?? null,
|
|
lineItems,
|
|
};
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
|
|
/** 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" },
|
|
});
|
|
}
|