Initial scaffold: Shopify recesso (withdrawal) compliance app

- Remix (TypeScript) + Polaris, official Shopify app template
- Prisma multi-tenant schema (Settings, ExclusionRule, WithdrawalRequest, AuditLog, WebhookEvent) on Postgres
- Mandatory GDPR compliance webhooks (data_request, redact, shop/redact) + HMAC handlers
- API version pinned 2026-04, scopes read_orders/read_products
- Fly deploy config; two-env strategy (custom now, public later)
- Dev setup: shopify.web.toml + Vite allowedHosts for tunnels
- Docs: PLAN.md, ANALISI-REQUISITI-LEGALI.md (Art. 54-bis compliance)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
This commit is contained in:
2026-07-06 18:02:17 +02:00
commit 4586b2e557
62 changed files with 2968 additions and 0 deletions

29
.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
# Monorepo root: docs (PLAN.md, ANALISI-REQUISITI-LEGALI.md, README.md) + the
# Shopify app under app/. Deep rules also live in app/.gitignore.
# Dependencies
node_modules
app/node_modules
# Build output
app/build
app/public/build
# Env / secrets — NEVER commit
.env
.env.*
app/.env
app/.env.*
!app/.env.example
# Shopify CLI local state
app/.shopify
.shopify
# Local SQLite (legacy dev DB from the template)
app/*.sqlite
app/prisma/*.sqlite*
**/dev.sqlite*
# OS / editor cruft
.DS_Store

View File

@@ -0,0 +1,58 @@
# Analisi requisiti legali — funzione di recesso (Art. 54-bis Cod. Consumo)
### Verifica del brief Gemini contro fonti primarie/secondarie
> **Verdetto:** SUFFICIENTE-CON-INTEGRAZIONI. Impianto operativo corretto, ma base giuridica
> errata e lacune sostanziali. Questo documento è la base legale verificata su cui costruire la
> matrice requisiti dell'app.
>
> **Avviso metodologico:** EUR-Lex (testo diretto Dir. 2023/2673 e 2011/83) **non parsabile** dai
> tool. Fonti usate: testo Art. 54-bis (Brocardi/Normattiva) + commentari (Stefanelli, CMS,
> Studio MP, PMI.it). Voci "⚠ da verificare" = da confermare su testo UE primario.
---
## 1. Inquadramento corretto (3 correzioni critiche vs Gemini)
| Tema | Gemini | Realtà verificata |
|---|---|---|
| Norma UE | "Art. 11a Dir. 2011/83" | **FALSO.** L'art. 11a non esiste in questo contesto. La funzione deriva da Dir. (UE) 2023/2673 (nuovi artt. ~16 bis16 septies, divieto interfacce manipolative ~16 sexies). ⚠ numero esatto da confermare su EUR-Lex |
| Origine/ambito UE | e-commerce beni | Dir. 2023/2673 nasce per **servizi finanziari a distanza** (abroga Dir. 2002/65/CE). A livello UE il pulsante sarebbe limitato ai servizi finanziari |
| Recepimento IT | D.Lgs 209/2025 → Art. 54-bis | **VERO e decisivo.** Italia colloca la funzione all'**Art. 54-bis** nella sezione generale sul recesso a distanza → **si applica a TUTTI i contratti B2C online (beni, servizi, contenuto digitale)**. Pharma coperto. In vigore per contratti conclusi **dal 19/6/2026** |
## 2. Verifica punto-per-punto del brief Gemini
| # | Affermazione Gemini | Esito | Correzione |
|---|---|---|---|
| 1 | Pulsante visibile, no PDF/email/PEC; label "Recedi dal contratto qui" | **IMPRECISO** | Label statutaria: **"recedere dal contratto qui"**. MA il pulsante è **AGGIUNTIVO**: non abolisce modulo tipo Allegato I-B né dichiarazione via email. "No PDF/email" errato |
| 2 | Accesso guest via Order ID + email | **IMPRECISO** | **Buona prassi, non obbligo.** Legge impone solo "facilmente accessibile" (imporre login rischia di violarlo) |
| 3 | Dati: nome+cognome, dati ordine, mezzo elettronico, testo dichiarazione | **VERO** | Art. 54-bis: (a) nome, (b) info che identificano il contratto, (c) mezzo elettronico per la conferma |
| 4 | Conferma finale esplicita (doppio check) | **VERO in sostanza** | Serve **"funzione di conferma"** etichettata **"conferma recesso"**: pulsante dedicato a 2 step, **non checkbox**. Vietati pre-flag/dark pattern |
| 5 | Ricevuta supporto durevole, testo + timestamp ricezione | **QUASI VERO** | Correzione: data/ora di **TRASMISSIONE** (fissa il momento dell'esercizio), non "ricezione" |
| 6 | 14 gg da consegna bene / stipula servizio | **VERO con precisazioni** | Beni: da consegna (ultimo bene se lotti); servizi: da conclusione; contenuto digitale/serv. finanziari: decorrenze proprie |
| 7 | Rimborso 14 gg; trattenibile fino a beni/prova spedizione | **VERO ma INCOMPLETO** | Manca: rimborso include **spese consegna standard**; **costi reso** a carico consumatore solo se informato; consumatore risponde **diminuzione di valore** |
| 8 | 14gg → 12 mesi+14gg; AGCM pratica scorretta | **IMPRECISO (2 meccanismi confusi)** | (i) proroga **12 mesi+14gg** = Art. 53, per **omessa informazione** (aggancio al pulsante solo indiretto via Art. 49); (ii) pulsante non conforme = **pratica scorretta Art. 27**, €5.000€10.000.000 / 4% fatturato, AGCM. La formula "12 mesi+14gg" in sé è corretta |
| 9 | Eccezioni Art. 59: su misura, deteriorabili, sigillati igiene | **VERO** | Numerazione IT corretta (Art. 59). Pharma: sigillati igiene/salute, farmaci, deperibili — centrale |
## 3. Requisiti MANCANTI / INSUFFICIENTI in Gemini (la parte critica)
| Requisito | Cosa dice la legge | Impatto app |
|---|---|---|
| **Divieto dark pattern + "non più onerosa della conclusione"** | Vietate interfacce manipolative; recesso non più difficile della conclusione; procedure interne anti-manipolazione | Flusso recesso ≤ passaggi/attriti del checkout; niente pop-up dissuasivi, pre-flag, retention nudge. **Vincolante** |
| **Etichette esatte** | "recedere dal contratto qui" (avvio); "conferma recesso" (step 2) | Stringhe hardcoded conformi; no label ambigue ("Gestisci ordine") |
| **Contenuto avviso supporto durevole** | Contenuto dichiarazione + data/ora di trasmissione, su supporto durevole | Template email/PDF conservabile + valore probatorio |
| **Pulsante AGGIUNTIVO non sostitutivo** | Restano modulo tipo Allegato I-B + qualsiasi dichiarazione esplicita | Non disabilitare/nascondere gli altri canali |
| **Obblighi info Art. 49** | Informare sull'**ubicazione della funzione** nell'interfaccia | Aggiornare pagine legali/checkout; omissione → proroga 12 mesi (Art. 53) |
| **Distinzione beni/servizi/digitale** | Decorrenze ed esclusioni diverse | Logica condizionale per tipo prodotto |
| **Disponibilità continuativa** | Funzione attiva per tutto il periodo, per ogni contratto | Legata a **ogni singolo ordine**, attiva tutta la finestra |
| **Rimborso completo** | Spese consegna standard incluse; costi reso su consumatore se informato; diminuzione valore | Schermata informa su chi paga il reso; calcolo rimborso corretto |
| **Ambito B2C / multi-interfaccia** | Solo B2C; qualsiasi interfaccia online; segue il contratto anche per merchant estero verso consumatore IT | Escludere ordini B2B; coprire sito+app |
| **Lingua** | Coerente con lingua del contratto/offerta | Localizzazione etichette + ricevuta |
| **Onere probatorio** | Professionista deve dimostrare data/ora e contenuto | **Log immutabile/audit trail** server, non solo email |
| **Esclusioni Art. 59 pharma** | Sigillati igiene/salute aperti, farmaci, deperibili | Mapping SKU→ammissibilità; no recesso "cieco" su tutto |
## 4. Punti aperti da confermare su fonte primaria (prima del go-live)
1. ⚠ Numero esatto degli articoli UE (16 bis16 septies / 16 sexies) sul testo EUR-Lex Dir. 2023/2673.
2. ⚠ Inquadramento sanzionatorio definitivo (Art. 27 vs Artt. 62-66 Cod. Cons.) e importi post-Omnibus.
3. Testo integrale Art. 54-bis da Normattiva (non solo Brocardi) per etichette e dati esatti.
4. Verifica cosa Shopify copre nativamente (regole reso/cancellazione UE) vs gap reali.

215
PLAN.md Normal file
View File

@@ -0,0 +1,215 @@
# Pizeta Recesso — App Shopify per la funzione di recesso
### Piano di sviluppo multi-agente
> **Obiettivo:** App Shopify riutilizzabile che rende qualsiasi negozio conforme alla funzione
> elettronica di recesso ("pulsante di recesso") obbligatoria — **Art. 54-bis Codice del Consumo**
> (D.Lgs 209/2025), recepimento Dir. (UE) 2023/2673. In vigore per contratti conclusi **dal 19
> giugno 2026**. I merchant sono esposti *da adesso*.
>
> **Base legale verificata:** vedi `ANALISI-REQUISITI-LEGALI.md` (fonti primarie/secondarie).
> Le voci ⚠ restano da confermare su EUR-Lex prima del go-live.
---
## 0. Decisioni bloccate
| Tema | Decisione | Nota |
|---|---|---|
| Distribuzione | **Custom distribution ora (2-3 store live) → Public molto più avanti** | Custom = no review, ok per pochi store. Account separati → 1 registrazione custom per store. Codice riusato per il futuro public |
| Backend | **Template Shopify Remix (Node) + Polaris** | Strada ufficiale Shopify |
| Hosting | **Fly.io** | Riuso setup esistente |
| Database | **Postgres + Prisma** | Fly Postgres (default) o Supabase (ho le creds) — decido in Fase 0 |
| Email (ricevuta durevole) | **Stub in MVP**, provider (Resend/Postmark) da decidere prima del go-live | Ricevuta con timestamp |
| Store di test | **SOLO `pcrt-reso-test`** (usa e getta) | ⚠ MAI pizeta-pharma-2 o store da trasferire: `shopify app dev`/install app DISABILITA il transfer in modo irreversibile |
| Consegna storefront | **Theme App Extension + App Proxy** | installazione senza toccare il tema, guest-capable |
**La cartella tema (`pizeta-pharma/`) NON è l'app** — è un tema Liquid, solo target di test.
L'app è questo repo separato.
---
## 0-bis. ⚠️ VINCOLO CRITICO — trasferimento store (IRREVERSIBILE)
**Il landmine è STRETTO: solo le DRAFT app / `shopify app dev` disabilitano il transfer.**
Lanciare `shopify app dev` (draft app) contro un development store — o abilitare developer preview —
**disabilita il trasferimento in modo permanente e irreversibile** (CLI issue #3946). Uno store
transfer-disabled non si trasferisce più: andrebbe ricostruito da zero.
**Cosa NON disabilita il transfer** (verificato):
- **Custom distribution via install link** (la nostra app recesso finita): si installa come una
normale app, **non** applica "Transfer Disabled". Installabile anche su store del cliente (via link
store-specifico, scadenza 7 giorni; ownership non richiesta).
- **Legacy/admin custom app** (es. "Import Negozio"): non disabilita il transfer.
**Regole operative:**
1. Sviluppo/test (**`shopify app dev` = draft**) **solo su `pcrt-reso-test`** (usa e getta). ← unica vera protezione.
2. **Mai** `shopify app dev` / draft contro **pizeta-pharma-2** o store destinati al cliente.
3. **App finita (custom link):** installabile su pizeta + altri store, **prima o dopo** il transfer,
senza rompere niente. Per zero-rischi assoluti, installa **dopo** il transfer (banale, via link).
4. ⚠ Nuance da confermare empiricamente: install custom su un client-transfer store ancora "in your
org" (pre-transfer) — fonti in conflitto. Se vuoi installare pre-transfer, testa prima.
---
## 0-ter. Strategia distribuzione & isolamento ambienti
**Principio:** *una build public-grade, distribuita Custom ora.* Non due sviluppi.
Vincolo di piattaforma (verificato): una app **Public — anche unlisted — richiede review Shopify** per
installarsi su store **live**. Solo **Custom** installa su live senza review. Quindi:
- **ORA:** distribuzione **Custom** (unico canale per il live senza review) su 2-3 store clienti.
- **DOMANI:** **nuova registrazione Public** + review, stesso codice. La distribuzione non è convertibile,
ma non si converte: si aggiunge una seconda registrazione.
**Codice public-grade dal giorno 1** (così il public futuro = re-registrazione, non rebuild):
1. Template Shopify CLI/Remix standard — OAuth multi-tenant, session token (no scorciatoia single-store).
2. Dati **multi-tenant per-shop** dal giorno 1, anche con soli 2-3 negozi.
3. **Webhook GDPR obbligatori** subito.
4. **Billing API dietro flag** (`isPublic`): off in custom, on in public. Unico ramo di codice divergente.
5. Performance/sicurezza a livello review **man mano**, non retrofit.
**Isolamento ambienti (anti-rottura) — creato separato dal giorno 1 in A0:**
```
pcrt-reso-test → staging (Fly) → recesso-custom (Fly, CLIENTI LIVE)
sviluppo collaudo stabile — deploy solo testati — DB proprio
recesso-public (Fly, DOMANI) → registrazione Public + DB proprio
stesso codice, deployment SEPARATO → non tocca mai recesso-custom
```
Registrazioni Partner distinte + Fly app distinti + DB distinti → il deploy della public **non può**
impattare gli store live sulla custom (isolamento strutturale, non per disciplina).
**Regole anti-rottura sulla custom-live:** test su `pcrt-reso-test` prima; migrazioni DB solo
**additive/backward-compatible**; theme extension **versionate** (rollback); **API version pinnata**.
---
## 1. Architettura
```
STOREFRONT (acquirente)
├─ Theme App Extension (app embed) → pulsante "recedere dal contratto qui"
└─ App Proxy /apps/recesso → flusso recesso guest-capable sul dominio dello shop
1. lookup: Order ID + email (guest — buona prassi per "facilmente accessibile")
2. form: nome, dichiarazione, email (precompilato se loggato)
3. STEP 2 conferma: "conferma recesso" (funzione dedicata, anti-accidentale)
4. successo + ricevuta durevole inviata
BACKEND (Fly.io)
├─ App Remix (admin embedded, Polaris)
├─ Postgres (Prisma): richieste, regole, impostazioni, audit
├─ Motore compliance: calcolo finestra, esclusioni Art. 59, tipo-prodotto, scadenza
├─ Ricevuta durevole: email con testo dichiarazione + timestamp di TRASMISSIONE
├─ Webhook: fulfillment (avvio finestra) + 3 GDPR obbligatori
└─ Billing (aggiunto in fase App Store)
```
**Perché Theme App Extension + App Proxy:** il pulsante acquirente + form guest devono vivere sul
dominio del negozio e sopravvivere agli aggiornamenti del tema senza che il merchant tocchi il
Liquid. App Proxy consente a un URL storefront (`/apps/recesso`) di raggiungere il backend
restando sul dominio dello shop — necessario per l'accesso guest e per "facile come acquistare".
---
## 2. Matrice requisiti (il contratto su cui ogni agente costruisce)
Corretta e integrata dopo `ANALISI-REQUISITI-LEGALI.md`.
| # | Requisito legale | Funzionalità | Criteri di accettazione |
|---|---|---|---|
| R1 | Funzione visibile, facilmente accessibile, **continuativamente disponibile** per tutta la finestra | Pulsante extension + pagina proxy sempre attiva, legata al singolo ordine | Label **"recedere dal contratto qui"**; funzione attiva tutta la finestra di quell'ordine |
| R2 | Accesso facile (guest = buona prassi, non obbligo) | Lookup Order ID + email | Guest completa il recesso con solo n° ordine + email; nessun login forzato |
| R3 | Raccolta nome, id contratto, mezzo elettronico, dichiarazione | Form recesso | I 4 dati raccolti + persistiti |
| R4 | Conferma a 2 step, funzione dedicata | Funzione **"conferma recesso"** | Invio impossibile senza 2ª azione esplicita; **no dark pattern** |
| R5 | Ricevuta su supporto durevole **senza ritardo**, con dichiarazione + **timestamp di trasmissione** | Email transazionale | Email < 1 min; contiene testo dichiarazione + data/ora di trasmissione |
| R6 | Onere probatorio del professionista | Persistenza DB + **audit log immutabile** | Record immutabile richiesta + timestamp + invio ricevuta |
| R7 | Funzione **non più onerosa della conclusione** (anti dark pattern) | UX ≤ passaggi del checkout | ≤ passaggi/attriti del checkout; no pop-up dissuasivi, pre-flag, retention nudge |
| R8 | Termine 14gg, decorrenza per tipo | Motore compliance | Scadenza corretta per beni (consegna/ultimo lotto) / servizi (conclusione) / digitale |
| R9 | Esclusioni Art. 59 (su misura, deperibili, sigillati igiene) | Regole esclusione (prodotto/collezione/tag) | Item escluso segnalato con motivo; no recesso "cieco" |
| R10 | Non conformità → 14gg → 12 mesi+14gg (Art. 53) | Warning misconfig + calcolo corretto | Admin avvisa su misconfig; scadenza riflette proroga quando applicabile |
| R11 | Pulsante **AGGIUNTIVO**, non sostitutivo | Coesistenza modulo tipo Allegato I-B + email | App non disabilita/nasconde gli altri canali |
| R12 | Obblighi info **Art. 49** (ubicazione funzione) | Testo info recesso + link ubicazione | Info diritto di recesso + posizione funzione mostrate |
| R13 | Rimborso: spese consegna incluse; reso a carico consumatore se informato; diminuzione valore | Logica rimborso + informativa | Schermata informa su chi paga reso; calcolo include spedizione standard |
| R14 | Lingua del contratto/offerta | i18n (IT/EN/DE/FR/ES…) | Flusso acquirente + ricevuta localizzati |
| R15 | Solo **B2C** | Filtro ordini | Ordini B2B esclusi dal flusso obbligatorio |
| R16 | Webhook GDPR obbligatori Shopify | `customers/data_request`,`customers/redact`,`shop/redact` | Tutti e 3 implementati + HMAC verificato |
| R17 | Sanzione pulsante non conforme = pratica scorretta Art. 27 (AGCM, fino €10M/4%) | (rischio, non feature) | Documentato; guida merchant alla conformità |
---
## 3. Modello dati (bozza Prisma)
- **Shop** — dominio, accessToken(cifrato), piano, installedAt
- **Settings** — labelPulsante, brandColors, indirizzoReso, giorniFinestraDefault, overrideMercati, testoInfoRecesso
- **ExclusionRule** — scope(prodotto|collezione|tag|tutto), targetId, motivo(sumisura|deperibile|igiene), attiva
- **WithdrawalRequest** — shopId, orderId, orderName, nomeCliente, email, testoDichiarazione, **trasmessoAt (ts)**, locale, canale(guest|account), tipoProdotto, stato, ricevutaInviataAt, scadenzaCalcolata
- **AuditLog** — shopId, evento, hashPayload, ts (append-only, immutabile)
- **WebhookEvent** — topic, ricevutoAt, processato (idempotenza)
---
## 4. Piano multi-agente (agenti SPECIALIZZATI, esecuzione SEQUENZIALE)
Preferenza utente: agenti **super-specializzati**, eseguiti **uno alla volta** (parallelo solo dove
strettamente utile), **minimo consumo di token**. Ogni agente = un brief focalizzato con
input / deliverable / criteri di uscita espliciti. **Nessun avvio automatico** — parto al tuo ok.
### Fase 0 — Fondamenta *(gate: tutto il resto dipende da qui)*
- **A0 architect** — init app Remix, schema Prisma, config Fly, igiene segreti, collegamento ai 2 dev store, CI. **Uscita:** app installata su entrambi i dev store, `/health` verde su Fly, DB migrato.
### Fase 1 — Specifica compliance *(gate: il contratto di accettazione)*
- **A1 compliance-mapper** — trasforma `ANALISI-REQUISITI-LEGALI.md` + §2 in criteri di accettazione testabili + copy deck (etichette IT esatte, template ricevuta, testo info recesso), risolve i punti ⚠. **Uscita:** file criteri approvato contro cui ogni agente testa.
### Fase 2 — MVP core (minimo legalmente conforme) *(sequenziale)*
- **A2 storefront-extension** — Theme App Extension + App Proxy lookup guest (R1,R2,R7,R11).
- **A3 backend-flow** — endpoint recesso, conferma 2 step, persistenza + timestamp (R3,R4,R6).
- **A4 receipt** — email durevole con dichiarazione + timestamp trasmissione (provider stub) (R5).
- **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).
- **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)*
- **A7 i18n** — localizzazione IT/EN/DE/FR/ES (R14).
- **A8 compliance-webhooks** — 3 webhook GDPR + verifica HMAC + idempotenza (R16).
- **A9 qa-security-auditor** — avversariale: HMAC, abuso lookup guest (enumeration/rate-limit), gestione PII, edge case (fulfillment parziale, beni digitali, calcolo scadenza). **Uscita:** report audit, zero criticità.
### Fase 5 — App Store pubblica *(quando custom → public)*
- **A10 billing** — Shopify Billing API + piani.
- **A11 app-store-submission** — listing, screenshot, privacy, checklist review. **Uscita:** pacchetto pronto per submission.
### Grafo dipendenze
```
A0 → A1 → A2 → A3 → A4 → A5 → A6 → A7 → A8 → A9 → A10 → A11
(sequenziale per default; A7/A8 parallelizzabili solo se serve accelerare)
```
---
## 5. Milestone
- **M1 (Fase 01):** scaffold + contratto compliance. *Nulla di visibile, ma toglie rischio a tutto.*
- **M2 (Fase 2):** **MVP conforme live su pizeta.** ← primo valore reale, azzera esposizione legale.
- **M3 (Fase 34):** production-grade, configurabile, auditato.
- **M4 (Fase 5):** submission App Store.
---
## 6. Rischi / punti di attenzione
- **Sovrapposizione nativo Shopify** — Shopify ha regole reso/cancellazione UE; non ricostruire i resi, avvolgerli. Verificare in A1 cosa è nativo vs gap.
- **Competitor esistenti** (Rescindly, REVER) — differenziare su conformità IT esatta + semplicità + prezzo.
- **Lookup guest = superficie di abuso** — enumeration ordini; serve rate-limit + match email + nessun leak su miss (A9).
- **Prova supporto durevole** — ricevuta riproducibile/loggata, non fire-and-forget (A4/A6).
- **Calcolo scadenza** — consegna vs conclusione, beni digitali, fulfillment parziale; fonte unica di verità nel motore (A6).
- **PII** — dichiarazione + email = dati personali; cifratura at rest + webhook GDPR non negoziabili (A8).
- **Base legale ⚠** — confermare numeri articoli UE e inquadramento Art. 27 su fonte primaria (A1).
---
## 7. Punti aperti (non bloccanti)
1. DB: Fly Postgres vs Supabase Postgres — decido in A0.
2. Provider email — decido prima di M2.
3. Automazione rimborso — fuori scope MVP; confermare che il tracking manuale basta al lancio.

90
README.md Normal file
View File

@@ -0,0 +1,90 @@
# Pizeta Recesso — App Shopify per la funzione di recesso
App Shopify riutilizzabile che rende un negozio conforme alla **funzione elettronica
di recesso** ("pulsante di recesso") obbligatoria — **Art. 54-bis Codice del Consumo**
(D.Lgs 209/2025, recepimento Dir. UE 2023/2673), in vigore per i contratti conclusi
**dal 19 giugno 2026**.
Contesto e requisiti legali: vedi [`PLAN.md`](./PLAN.md) e
[`ANALISI-REQUISITI-LEGALI.md`](./ANALISI-REQUISITI-LEGALI.md).
## Struttura cartelle
```
pizeta-recesso-app/ ← repo git (docs + codice insieme)
├─ PLAN.md piano multi-agente + decisioni bloccate
├─ ANALISI-REQUISITI-LEGALI.md base legale verificata
├─ README.md questo file
└─ app/ app Shopify (template ufficiale Remix, TypeScript)
├─ app/routes/ route Remix (admin embedded + webhook)
├─ prisma/schema.prisma modello dati Postgres (multi-tenant per-shop)
├─ extensions/ theme app extension (storefront) — in arrivo
├─ shopify.app.toml config app (scopes, api_version pinnata, webhook)
├─ fly.toml deployment Fly (app "recesso-custom")
├─ Dockerfile build produzione
└─ .env.example template variabili d'ambiente
```
## Prerequisiti
- **Node ≥ 20.19** (vedi `app/package.json``engines`)
- **Shopify CLI** (`npm i -g @shopify/cli`)
- **Fly CLI** (`flyctl`) per il deployment
- Un **Postgres** raggiungibile (Fly Postgres o Supabase) per `DATABASE_URL`
## Setup sviluppo
```bash
cd app
npm install
# 1. Collega la registrazione app "Legal Return PCRT" (scrive client_id nel toml)
shopify app config link
# 2. Configura le env (mai committare .env)
cp .env.example .env # poi compila i valori
# 3. Provisiona il DB e applica lo schema Prisma
# (imposta prima DATABASE_URL nel .env)
npx prisma migrate dev --name init
# 4. Avvia il dev server — SOLO sullo store usa-e-getta pcrt-reso-test
shopify app dev --store pcrt-reso-test
```
### ⚠️ Landmine — mai `shopify app dev` su store da trasferire
`shopify app dev` (draft app) contro un development store **disabilita in modo
irreversibile il trasferimento** di quello store (PLAN §0-bis). Usare `shopify app dev`
/ draft **SOLO su `pcrt-reso-test`**. Mai su `pizeta-pharma-2` o su store destinati al
cliente. L'app **finita**, installata via link custom, non ha questo problema.
## Strategia due ambienti (custom vs public)
Una sola base di codice **public-grade**, distribuita **Custom** ora e **Public** in
futuro (PLAN §0-ter). Gli ambienti sono **isolati per costruzione**:
| Ambiente | Distribuzione | Fly app | DB |
|-----------------|----------------------|------------------|----------|
| Sviluppo/test | draft (`app dev`) | — | locale |
| **Custom LIVE** | Custom install link | `recesso-custom` | dedicato |
| **Public** (poi)| Public + review | `recesso-public` | dedicato |
Registrazioni Partner distinte + Fly app distinte + DB distinti → un deploy della
public **non può** impattare gli store live sulla custom. Regole anti-rottura:
migrazioni DB solo **additive/backward-compatible**, theme extension **versionate**,
**API version pinnata** (bump solo dopo test su `pcrt-reso-test`).
## Deployment (Fly)
I segreti si impostano con `fly secrets` (mai nel repo):
```bash
cd app
fly secrets set \
DATABASE_URL="postgresql://..." \
SHOPIFY_API_KEY="..." \
SHOPIFY_API_SECRET="..." \
SHOPIFY_APP_URL="https://recesso-custom.fly.dev"
fly deploy
```

3
app/.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
.cache
build
node_modules

15
app/.editorconfig Normal file
View File

@@ -0,0 +1,15 @@
# editorconfig.org
root = true
[*]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
# Markdown syntax specifies that trailing whitespaces can be meaningful,
# so lets not trim those. e.g. 2 trailing spaces = linebreak (<br />)
# See https://daringfireball.net/projects/markdown/syntax#p
[*.md]
trim_trailing_whitespace = false

15
app/.env.example Normal file
View File

@@ -0,0 +1,15 @@
# Copy this file to `.env` and fill in real values. NEVER commit `.env`.
# In production (Fly) these are provided via `fly secrets set`, not this file.
# Shopify app credentials — from the Partner Dashboard app "Legal Return PCRT".
SHOPIFY_API_KEY=your_api_key_here
SHOPIFY_API_SECRET=your_api_secret_here
# Access scopes — keep in sync with shopify.app.toml [access_scopes].scopes
SCOPES=read_orders,read_products
# Public URL of the app (dev tunnel, or the Fly URL in prod). No trailing slash.
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

6
app/.eslintignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
build
public/build
shopify-app-remix
*/*.yml
.shopify

13
app/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,13 @@
/** @type {import('@types/eslint').Linter.BaseConfig} */
module.exports = {
root: true,
extends: [
"@remix-run/eslint-config",
"@remix-run/eslint-config/node",
"@remix-run/eslint-config/jest-testing-library",
"prettier",
],
globals: {
shopify: "readonly"
},
};

1
app/.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @shop/dev_experience

73
app/.github/CODE_OF_CONDUCT.md vendored Normal file
View File

@@ -0,0 +1,73 @@
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, gender identity and expression, level of experience,
nationality, personal appearance, race, religion, or sexual identity and
orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
- Using welcoming and inclusive language
- Being respectful of differing viewpoints and experiences
- Gracefully accepting constructive criticism
- Focusing on what is best for the community
- Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
- The use of sexualized language or imagery and unwelcome sexual attention or
advances
- Trolling, insulting/derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or electronic
address, without explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at opensource@shopify.com. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct/
[homepage]: https://www.contributor-covenant.org

41
app/.github/CONTRIBUTING.md vendored Normal file
View File

@@ -0,0 +1,41 @@
# How to contribute
The Shopify Remix app template is an open source project. We want to make it as easy and transparent as possible to contribute. If we are missing anything or can make the process easier in any way, please let us know by [opening an issue](https://github.com/Shopify/shopify-app-template-remix/issues/new).
## Code of conduct
We expect all participants to read our [code of conduct](https://github.com/Shopify/shopify-app-template-remix/.github/CODE_OF_CONDUCT.md) to understand which actions are and arent tolerated.
## Open development
All work on the Shopify Remix app template happens directly on GitHub. Both team members and external contributors send pull requests which go through the same review process.
## Bugs
### Where to find known issues
We track all of our issues in GitHub and [bugs](https://github.com/Shopify/shopify-app-template-remix/labels/Bug) are labeled accordingly. If you are planning to work on an issue, avoid ones which already have an assignee, where someone has commented within the last two weeks they are working on it, or the issue is labeled with [fix in progress](https://github.com/Shopify/shopify-app-template-remix/labels/fix%20in%20progress). We will do our best to communicate when an issue is being worked on internally.
### Reporting new issues
To reduce duplicates, look through open issues before filing one. When [opening an issue](https://github.com/Shopify/shopify-app-template-remix/issues/new?template=ISSUE.md), complete as much of the template as possible.
## Your first pull request
Working on your first pull request? You can learn how from this free video series:
[How to Contribute to an Open Source Project on GitHub](https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github)
To help you get familiar with our contribution process, we have a list of [good first issues](https://github.com/Shopify/shopify-app-template-remix/labels/good%20first%20issue) that contain bugs with limited scope. This is a great place to get started.
If you decide to fix an issue, please check the comment thread in case somebody is already working on a fix. If nobody is working on it, leave a comment stating that you intend to work on it.
If somebody claims an issue but doesnt follow up for more than two weeks, its fine to take it over but still leave a comment stating that you intend to work on it.
### Sending a pull request
Well review your pull request and either merge it, request changes to it, or close it with an explanation. Well do our best to provide updates and feedback throughout the process.
### Contributor License Agreement (CLA)
Each contributor is required to [sign a CLA](https://cla.shopify.com/). This process is automated as part of your first pull request and is only required once. If any contributor has not signed or does not have an associated GitHub account, the CLA check will fail and the pull request is unable to be merged.

46
app/.github/ISSUE_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,46 @@
---
name: '🐛 Bug Report'
about: Something isn't working
labels: 'Type: Bug 🐛'
---
# Issue summary
Before opening this issue, I have:
- [ ] Upgraded to the latest version of the `@shopify` packages
- Affected `@shopify/shopify-*` package and version:
- Node version:
- Operating system:
- [ ] Set `{ logger: { level: LogSeverity.Debug } }` in my configuration
- [ ] Found a reliable way to reproduce the problem that indicates it's a problem with the package
- [ ] Looked for similar issues in this repository
- [ ] Checked that this isn't an issue with a Shopify API
- If it is, please create a post in the [Shopify community forums](https://community.shopify.com/c/partners-and-developers/ct-p/appdev) or report it to [Shopify Partner Support](https://help.shopify.com/en/support/partners/org-select)
<!--
Write a short description of the issue here.
We can only fix issues for which there is a clear reproduction scenario.
The more context you can provide, the easier it becomes for us to investigate and fix the issue.
-->
## Expected behavior
What do you think should happen?
## Actual behavior
What actually happens?
## Steps to reproduce the problem
1.
1.
1.
## Debug logs
```
// Paste any relevant logs here
```

34
app/.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,34 @@
<!--
How to write a good PR title:
- Prefix it with [Feature] (if applicable)
- Start with a verb, for example: Add, Delete, Improve, Fix…
- Give as much context as necessary and as little as possible
- Prefix it with [WIP] while its a work in progress
-->
### WHY are these changes introduced?
Fixes #0000 <!-- link to issue if one exists -->
<!--
Context about the problem thats being addressed.
-->
### WHAT is this pull request doing?
<!--
Summary of the changes committed.
Before / after screenshots appreciated for UI changes.
-->
### Test this PR
```bash
shopify app init --template=https://github.com/Shopify/shopify-app-template-remix#<your-branch-name>
```
### Checklist
- [ ] I have made changes to the `README.md` file and other related documentation, if applicable
- [ ] I have added an entry to `CHANGELOG.md`
- [ ] I'm aware I need to create a new release when this PR is merged

58
app/.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,58 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
# Enable version updates for npm
- package-ecosystem: 'npm'
# Look for `package.json` and `lock` files in the `root` directory
directory: '/'
# Check the npm registry for updates every day (weekdays)
schedule:
interval: 'weekly'
# Dependabot defaults to 5 open pull requests at a time
open-pull-requests-limit: 100
# Cooldown is the number of days after a release to wait until opening a PR
# This gives us more confidence changes can be merged because changes have been community tested.
# See: https://github.blog/changelog/2025-07-01-dependabot-supports-configuration-of-a-minimum-package-age/
cooldown:
default-days: 14
semver-major-days: 30
semver-minor-days: 14
semver-patch-days: 14
groups:
# Group together PRs of dependant packages
prisma:
patterns:
- 'prisma'
- '@prisma/client'
react:
patterns:
- 'react'
- 'react-dom'
- '@types/react'
- '@types/react-dom'
vite:
patterns:
- 'vite'
- 'vite-tsconfig-paths'
remix:
patterns:
- '@remix-run/dev'
- '@remix-run/fs-routes'
- '@remix-run/node'
- '@remix-run/react'
- '@remix-run/eslint-config'
- '@remix-run/route-config'
# Group all patch updates not accounted for in prior groups in a single PR.
# This reduces the number of PRs to review and rebase.
patch-updates:
patterns:
- "*"
update-types:
- "patch"

18
app/.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,18 @@
on: [push, pull_request]
name: CI
jobs:
CI:
name: CI_Node_${{ matrix.version }}
runs-on: ubuntu-latest
strategy:
matrix:
version: [20.19.0, 22, 24]
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: ${{ matrix.version }}
- name: Install
run: yarn install

22
app/.github/workflows/cla.yml vendored Normal file
View File

@@ -0,0 +1,22 @@
name: Contributor License Agreement (CLA)
on:
pull_request_target:
types: [opened, synchronize]
issue_comment:
types: [created]
jobs:
cla:
runs-on: ubuntu-latest
if: |
(github.event.issue.pull_request
&& !github.event.issue.pull_request.merged_at
&& contains(github.event.comment.body, 'signed')
)
|| (github.event.pull_request && !github.event.pull_request.merged)
steps:
- uses: Shopify/shopify-cla-action@v1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
cla-token: ${{ secrets.CLA_TOKEN }}

View File

@@ -0,0 +1,20 @@
name: Close Waiting for Response Issues
on:
schedule:
- cron: "30 1 * * *"
workflow_dispatch:
jobs:
check-need-info:
runs-on: ubuntu-latest
steps:
- name: close-issues
uses: actions-cool/issues-helper@45d75b6cf72bf4f254be6230cb887ad002702491 # v3.6.3
with:
actions: "close-issues"
token: ${{ secrets.GITHUB_TOKEN }}
labels: "Waiting for Response"
inactive-day: 14
body: |
We are closing this issue because we did not hear back regarding additional details we needed to resolve this issue. If the issue persists and you are able to provide the missing clarification we need, you can respond here or create a new issue.
We appreciate your understanding as we try to manage our number of open issues.

99
app/.github/workflows/convert-to-js.yml vendored Normal file
View File

@@ -0,0 +1,99 @@
name: Create Javascript conversion PR
on:
push:
branches:
- main
workflow_dispatch:
jobs:
convert-ts-files:
runs-on: ubuntu-latest
steps:
- name: Checkout Repo
uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0
- name: Create lock file
run: touch yarn.lock
- name: Setup Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: 22.12.x
cache: 'yarn'
- name: Install dependencies
run: yarn add -W --dev @shopify/eslint-plugin --ignore-engines
- name: Create temporary tsconfig file
run: |
echo '{
"include": ["./app/**/*", "*.ts", "*.tsx", ".graphqlrc.ts"],
"compilerOptions": {
"strict": true,
"removeComments": false,
"skipLibCheck": true,
"isolatedModules": true,
"noEmitOnError": true,
"jsx": "preserve",
"module": "ES2022",
"moduleResolution": "bundler",
"target": "ES2022",
"paths": {
"~/*": ["./app/*"]
}
}
}' > tsconfig.js.json
- name: Transpile to Javascript
run: yarn tsc -p tsconfig.js.json
- name: Remove Typescript files
run: |
find app \( -name "*.ts" -o -name "*.tsx" \) -delete
find . \( -name ".graphqlrc.ts" -o -name "tsconfig.js.json" -o -name "vite.config.ts" \) -delete
- name: Run prettier
run: yarn prettier -w "app/**/*.{js,jsx}" ".graphqlrc.js" "vite.config.js"
- name: Run ESLint
run: |
yarn lint "app/**/*.{js,jsx}" ".graphqlrc.js" "vite.config.js" --fix --no-cache --ignore-pattern "\!.graphqlrc.js" --plugin @shopify/eslint-plugin --rule '{
"import/order": "error",
"import/newline-after-import": "error",
"padding-line-between-statements": ["error",
{ "blankLine": "always", "prev": ["const", "let", "var"], "next": "*"},
{ "blankLine": "any", "prev": ["const", "let", "var"], "next": ["const", "let", "var"]}
{ "blankLine": "always", "prev": "*", "next": "return" },
{ "blankLine": "always", "prev": "*", "next": "export" },
{ "blankLine": "never", "prev": "export", "next": "export" },
{ "blankLine": "always", "prev": "*", "next": "block-like" },
{ "blankLine": "always", "prev": "block-like", "next": "*" }
]}'
- name: Prepare files for git
run: |
git config user.name GitHub
git config user.email noreply@github.com
git fetch
git restore --staged package.json
git restore package.json
- name: Stage changes to files
run: |
git add .
git checkout -b temp_javascript_updates
git commit -m "Convert template to Javascript"
git checkout javascript
git pull
git checkout -
git rebase -m -X theirs javascript
git push -f origin temp_javascript_updates:javascript_updates
- name: Create Javascript PR
run: |
gh pr view --json mergedAt -q ".mergedAt" javascript_updates | grep -E "^$" || \
gh pr create -B javascript -H javascript_updates --title 'Convert template to Javascript' --body 'This is an automated PR that converts the latest changes from Typescript to Javascript'
env:
GH_TOKEN: ${{ github.token }}

View File

@@ -0,0 +1,15 @@
name: Remove Waiting Labels
on:
issue_comment:
types: [created]
workflow_dispatch:
jobs:
remove-labels-on-activity:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0
- uses: actions-ecosystem/action-remove-labels@2ce5d41b4b6aa8503e285553f75ed56e0a40bae0 # v1.2.0
if: contains(github.event.issue.labels.*.name, 'Waiting for Response')
with:
labels: |
Waiting for Response

26
app/.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
node_modules
.DS_Store
/.cache
/build
/app/build
/public/build/
/public/_dev
/app/public/build
/prisma/dev.sqlite
/prisma/dev.sqlite-journal
database.sqlite
.env
.env.*
!.env.example
package-lock.json
yarn.lock
pnpm-lock.yaml
/extensions/*/dist
# Ignore shopify files created during app dev
.shopify/*
.shopify.lock

45
app/.graphqlrc.ts Normal file
View File

@@ -0,0 +1,45 @@
import fs from "fs";
import { ApiVersion } from "@shopify/shopify-api";
import { shopifyApiProject, ApiType } from "@shopify/api-codegen-preset";
import type { IGraphQLConfig } from "graphql-config";
function getConfig() {
const config: IGraphQLConfig = {
projects: {
default: shopifyApiProject({
apiType: ApiType.Admin,
apiVersion: ApiVersion.July25,
documents: [
"./app/**/*.{js,ts,jsx,tsx}",
"./app/.server/**/*.{js,ts,jsx,tsx}",
],
outputDir: "./app/types",
}),
},
};
let extensions: string[] = [];
try {
extensions = fs.readdirSync("./extensions");
} catch {
// ignore if no extensions
}
for (const entry of extensions) {
const extensionPath = `./extensions/${entry}`;
const schema = `${extensionPath}/schema.graphql`;
if (!fs.existsSync(schema)) {
continue;
}
config.projects[entry] = {
schema,
documents: [`${extensionPath}/**/*.graphql`],
};
}
return config;
}
const config = getConfig();
export default config;

2
app/.npmrc Normal file
View File

@@ -0,0 +1,2 @@
engine-strict=true
@shopify:registry=https://registry.npmjs.org

7
app/.prettierignore Normal file
View File

@@ -0,0 +1,7 @@
package.json
.shadowenv.d
.vscode
node_modules
prisma
public
.shopify

6
app/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"recommendations": [
"graphql.vscode-graphql",
"shopify.polaris-for-vscode",
]
}

8
app/.vscode/mcp.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"servers": {
"shopify-dev-mcp": {
"command": "npx",
"args": ["-y", "@shopify/dev-mcp@latest"]
}
}
}

94
app/CHANGELOG.md Normal file
View File

@@ -0,0 +1,94 @@
# @shopify/shopify-app-template-remix
## 2025.12.11
- [#1201](https://github.com/Shopify/shopify-app-template-remix/pull/1201) Update `@shopify/shopify-app-remix` to v4.1.0 and `@shopify/shopify-app-session-storage-prisma` to v8.0.0, add refresh token fields (`refreshToken` and `refreshTokenExpires`) to Session model in Prisma schema, and adopt the `expiringOfflineAccessTokens` flag for enhanced security through token rotation. See [expiring vs non-expiring offline tokens](https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/offline-access-tokens#expiring-vs-non-expiring-offline-tokens) for more information.
## 2025.10.01
**Remix is now React Router.** As of [React Router v7](https://remix.run/blog/merging-remix-and-react-router), Remix and React Router have merged.
For new projects, use the **[Shopify App Template - React Router](https://github.com/Shopify/shopify-app-template-react-router)** instead.
To migrate your existing Remix app, follow the **[migration guide](https://github.com/Shopify/shopify-app-template-react-router/wiki/Upgrading-from-Remix)**.
## 2025.08.16
- [#52](https://github.com/Shopify/shopify-app-template-remix/pull/1153) Use `ApiVersion.July25` rather than `LATEST_API_VERSION` in `.graphqlrc`.
## 2025.07.07
- [#1103](https://github.com/Shopify/shopify-app-template-remix/pull/1086) Remove deprecated .npmrc config values
## 2025.06.12
- [#1075](https://github.com/Shopify/shopify-app-template-remix/pull/1075) Add Shopify MCP to [VSCode configs](https://code.visualstudio.com/docs/copilot/chat/mcp-servers#_enable-mcp-support-in-vs-code)
## 2025.06.12
-[#1082](https://github.com/Shopify/shopify-app-template-remix/pull/1082) Remove local Shopify CLI from the template. Developers should use the Shopify CLI [installed globally](https://shopify.dev/docs/api/shopify-cli#installation).
## 2025.03.18
-[#998](https://github.com/Shopify/shopify-app-template-remix/pull/998) Update to Vite 6
## 2025.03.01
- [#982](https://github.com/Shopify/shopify-app-template-remix/pull/982) Add Shopify Dev Assistant extension to the VSCode extension recommendations
## 2025.01.31
- [#952](https://github.com/Shopify/shopify-app-template-remix/pull/952) Update to Shopify App API v2025-01
## 2025.01.23
- [#923](https://github.com/Shopify/shopify-app-template-remix/pull/923) Update `@shopify/shopify-app-session-storage-prisma` to v6.0.0
## 2025.01.8
- [#923](https://github.com/Shopify/shopify-app-template-remix/pull/923) Enable GraphQL autocomplete for Javascript
## 2024.12.19
- [#904](https://github.com/Shopify/shopify-app-template-remix/pull/904) bump `@shopify/app-bridge-react` to latest
-
## 2024.12.18
- [875](https://github.com/Shopify/shopify-app-template-remix/pull/875) Add Scopes Update Webhook
## 2024.12.05
- [#910](https://github.com/Shopify/shopify-app-template-remix/pull/910) Install `openssl` in Docker image to fix Prisma (see [#25817](https://github.com/prisma/prisma/issues/25817#issuecomment-2538544254))
- [#907](https://github.com/Shopify/shopify-app-template-remix/pull/907) Move `@remix-run/fs-routes` to `dependencies` to fix Docker image build
- [#899](https://github.com/Shopify/shopify-app-template-remix/pull/899) Disable v3_singleFetch flag
- [#898](https://github.com/Shopify/shopify-app-template-remix/pull/898) Enable the `removeRest` future flag so new apps aren't tempted to use the REST Admin API.
## 2024.12.04
- [#891](https://github.com/Shopify/shopify-app-template-remix/pull/891) Enable remix future flags.
## 2024.11.26
- [888](https://github.com/Shopify/shopify-app-template-remix/pull/888) Update restResources version to 2024-10
## 2024.11.06
- [881](https://github.com/Shopify/shopify-app-template-remix/pull/881) Update to the productCreate mutation to use the new ProductCreateInput type
## 2024.10.29
- [876](https://github.com/Shopify/shopify-app-template-remix/pull/876) Update shopify-app-remix to v3.4.0 and shopify-app-session-storage-prisma to v5.1.5
## 2024.10.02
- [863](https://github.com/Shopify/shopify-app-template-remix/pull/863) Update to Shopify App API v2024-10 and shopify-app-remix v3.3.2
## 2024.09.18
- [850](https://github.com/Shopify/shopify-app-template-remix/pull/850) Removed "~" import alias
## 2024.09.17
- [842](https://github.com/Shopify/shopify-app-template-remix/pull/842) Move webhook processing to individual routes
## 2024.08.19
Replaced deprecated `productVariantUpdate` with `productVariantsBulkUpdate`
## v2024.08.06
Allow `SHOP_REDACT` webhook to process without admin context
## v2024.07.16
Started tracking changes and releases using calver

21
app/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM node:18-alpine
RUN apk add --no-cache openssl
EXPOSE 3000
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev && npm cache clean --force
# Remove CLI packages since we don't need them in production by default.
# Remove this line if you want to run CLI commands in your container.
RUN npm remove @shopify/cli
COPY . .
RUN npm run build
CMD ["npm", "run", "docker-start"]

352
app/README.md Normal file
View File

@@ -0,0 +1,352 @@
# Shopify App Template - Remix
> [!NOTE]
> **Remix is now React Router.** As of [React Router v7](https://remix.run/blog/merging-remix-and-react-router), Remix and React Router have merged.
>
> For new projects, use the **[Shopify App Template - React Router](https://github.com/Shopify/shopify-app-template-react-router)** instead.
>
> To migrate your existing Remix app, follow the **[migration guide](https://github.com/Shopify/shopify-app-template-react-router/wiki/Upgrading-from-Remix)**.
This is a template for building a [Shopify app](https://shopify.dev/docs/apps/getting-started) using the [Remix](https://remix.run) framework.
Rather than cloning this repo, you can use your preferred package manager and the Shopify CLI with [these steps](https://shopify.dev/docs/apps/getting-started/create).
Visit the [`shopify.dev` documentation](https://shopify.dev/docs/api/shopify-app-remix) for more details on the Remix app package.
## Quick start
### Prerequisites
Before you begin, you'll need the following:
1. **Node.js**: [Download and install](https://nodejs.org/en/download/) it if you haven't already.
2. **Shopify Partner Account**: [Create an account](https://partners.shopify.com/signup) if you don't have one.
3. **Test Store**: Set up either a [development store](https://help.shopify.com/en/partners/dashboard/development-stores#create-a-development-store) or a [Shopify Plus sandbox store](https://help.shopify.com/en/partners/dashboard/managing-stores/plus-sandbox-store) for testing your app.
4. **Shopify CLI**: [Download and install](https://shopify.dev/docs/apps/tools/cli/getting-started) it if you haven't already.
```shell
npm install -g @shopify/cli@latest
```
### Setup
```shell
shopify app init --template=https://github.com/Shopify/shopify-app-template-remix
```
### Local Development
```shell
shopify app dev
```
Local development is powered by [the Shopify CLI](https://shopify.dev/docs/apps/tools/cli). It logs into your partners account, connects to an app, provides environment variables, updates remote config, creates a tunnel and provides commands to generate extensions.
### Authenticating and querying data
To authenticate and query data you can use the `shopify` const that is exported from `/app/shopify.server.js`:
```js
export async function loader({ request }) {
const { admin } = await shopify.authenticate.admin(request);
const response = await admin.graphql(`
{
products(first: 25) {
nodes {
title
description
}
}
}`);
const {
data: {
products: { nodes },
},
} = await response.json();
return nodes;
}
```
This template comes preconfigured with examples of:
1. Setting up your Shopify app in [/app/shopify.server.ts](https://github.com/Shopify/shopify-app-template-remix/blob/main/app/shopify.server.ts)
2. Querying data using Graphql. Please see: [/app/routes/app.\_index.tsx](https://github.com/Shopify/shopify-app-template-remix/blob/main/app/routes/app._index.tsx).
3. Responding to webhooks in individual files such as [/app/routes/webhooks.app.uninstalled.tsx](https://github.com/Shopify/shopify-app-template-remix/blob/main/app/routes/webhooks.app.uninstalled.tsx) and [/app/routes/webhooks.app.scopes_update.tsx](https://github.com/Shopify/shopify-app-template-remix/blob/main/app/routes/webhooks.app.scopes_update.tsx)
Please read the [documentation for @shopify/shopify-app-remix](https://www.npmjs.com/package/@shopify/shopify-app-remix#authenticating-admin-requests) to understand what other API's are available.
## Deployment
### Application Storage
This template uses [Prisma](https://www.prisma.io/) to store session data, by default using an [SQLite](https://www.sqlite.org/index.html) database.
The database is defined as a Prisma schema in `prisma/schema.prisma`.
This use of SQLite works in production if your app runs as a single instance.
The database that works best for you depends on the data your app needs and how it is queried.
You can run your database of choice on a server yourself or host it with a SaaS company.
Here's a short list of databases providers that provide a free tier to get started:
| Database | Type | Hosters |
| ---------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| MySQL | SQL | [Digital Ocean](https://www.digitalocean.com/products/managed-databases-mysql), [Planet Scale](https://planetscale.com/), [Amazon Aurora](https://aws.amazon.com/rds/aurora/), [Google Cloud SQL](https://cloud.google.com/sql/docs/mysql) |
| PostgreSQL | SQL | [Digital Ocean](https://www.digitalocean.com/products/managed-databases-postgresql), [Amazon Aurora](https://aws.amazon.com/rds/aurora/), [Google Cloud SQL](https://cloud.google.com/sql/docs/postgres) |
| Redis | Key-value | [Digital Ocean](https://www.digitalocean.com/products/managed-databases-redis), [Amazon MemoryDB](https://aws.amazon.com/memorydb/) |
| MongoDB | NoSQL / Document | [Digital Ocean](https://www.digitalocean.com/products/managed-databases-mongodb), [MongoDB Atlas](https://www.mongodb.com/atlas/database) |
To use one of these, you can use a different [datasource provider](https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference#datasource) in your `schema.prisma` file, or a different [SessionStorage adapter package](https://github.com/Shopify/shopify-api-js/blob/main/packages/shopify-api/docs/guides/session-storage.md).
### Build
Remix handles building the app for you, by running the command below with the package manager of your choice:
Using yarn:
```shell
yarn build
```
Using npm:
```shell
npm run build
```
Using pnpm:
```shell
pnpm run build
```
## Hosting
When you're ready to set up your app in production, you can follow [our deployment documentation](https://shopify.dev/docs/apps/deployment/web) to host your app on a cloud provider like [Heroku](https://www.heroku.com/) or [Fly.io](https://fly.io/).
When you reach the step for [setting up environment variables](https://shopify.dev/docs/apps/deployment/web#set-env-vars), you also need to set the variable `NODE_ENV=production`.
### Hosting on Vercel
Using the Vercel Preset is recommended when hosting your Shopify Remix app on Vercel. You'll also want to ensure imports that would normally come from `@remix-run/node` are imported from `@vercel/remix` instead. Learn more about hosting Remix apps on Vercel [here](https://vercel.com/docs/frameworks/remix).
```diff
// vite.config.ts
import { vitePlugin as remix } from "@remix-run/dev";
import { defineConfig, type UserConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
+ import { vercelPreset } from '@vercel/remix/vite';
installGlobals();
export default defineConfig({
plugins: [
remix({
ignoredRouteFiles: ["**/.*"],
+ presets: [vercelPreset()],
}),
tsconfigPaths(),
],
});
```
## Troubleshooting
### Database tables don't exist
If you get this error:
```
The table `main.Session` does not exist in the current database.
```
You need to create the database for Prisma. Run the `setup` script in `package.json` using your preferred package manager.
### Navigating/redirecting breaks an embedded app
Embedded Shopify apps must maintain the user session, which can be tricky inside an iFrame. To avoid issues:
1. Use `Link` from `@remix-run/react` or `@shopify/polaris`. Do not use `<a>`.
2. Use the `redirect` helper returned from `authenticate.admin`. Do not use `redirect` from `@remix-run/node`
3. Use `useSubmit` or `<Form/>` from `@remix-run/react`. Do not use a lowercase `<form/>`.
This only applies if your app is embedded, which it will be by default.
### Non Embedded
Shopify apps are best when they are embedded in the Shopify Admin, which is how this template is configured. If you have a reason to not embed your app please make the following changes:
1. Ensure `embedded = false` is set in [shopify.app.toml`](./shopify.app.toml). [Docs here](https://shopify.dev/docs/apps/build/cli-for-apps/app-configuration#global).
2. Pass `isEmbeddedApp: false` to `shopifyApp()` in `./app/shopify.server.js|ts`.
3. Change the `isEmbeddedApp` prop to `isEmbeddedApp={false}` for the `AppProvider` in `/app/routes/app.jsx|tsx`.
4. Remove the `@shopify/app-bridge-react` dependency from [package.json](./package.json) and `vite.config.ts|js`.
5. Remove anything imported from `@shopify/app-bridge-react`. For example: `NavMenu`, `TitleBar` and `useAppBridge`.
### OAuth goes into a loop when I change my app's scopes
If you change your app's scopes and authentication goes into a loop and fails with a message from Shopify that it tried too many times, you might have forgotten to update your scopes with Shopify.
To do that, you can run the `deploy` CLI command.
Using yarn:
```shell
yarn deploy
```
Using npm:
```shell
npm run deploy
```
Using pnpm:
```shell
pnpm run deploy
```
### My shop-specific webhook subscriptions aren't updated
If you are registering webhooks in the `afterAuth` hook, using `shopify.registerWebhooks`, you may find that your subscriptions aren't being updated.
Instead of using the `afterAuth` hook, the recommended approach is to declare app-specific webhooks in the `shopify.app.toml` file. This approach is easier since Shopify will automatically update changes to webhook subscriptions every time you run `deploy` (e.g: `npm run deploy`). Please read these guides to understand more:
1. [app-specific vs shop-specific webhooks](https://shopify.dev/docs/apps/build/webhooks/subscribe#app-specific-subscriptions)
2. [Create a subscription tutorial](https://shopify.dev/docs/apps/build/webhooks/subscribe/get-started?framework=remix&deliveryMethod=https)
If you do need shop-specific webhooks, please keep in mind that the package calls `afterAuth` in 2 scenarios:
- After installing the app
- When an access token expires
During normal development, the app won't need to re-authenticate most of the time, so shop-specific subscriptions aren't updated. To force your app to update the subscriptions, you can uninstall and reinstall it in your development store. That will force the OAuth process and call the `afterAuth` hook.
### Admin created webhook failing HMAC validation
Webhooks subscriptions created in the [Shopify admin](https://help.shopify.com/en/manual/orders/notifications/webhooks) will fail HMAC validation. This is because the webhook payload is not signed with your app's secret key. There are 2 solutions:
1. Use [app-specific webhooks](https://shopify.dev/docs/apps/build/webhooks/subscribe#app-specific-subscriptions) defined in your toml file instead (recommended)
2. Create [webhook subscriptions](https://shopify.dev/docs/api/shopify-app-remix/v1/guide-webhooks) using the `shopifyApp` object.
Test your webhooks with the [Shopify CLI](https://shopify.dev/docs/apps/tools/cli/commands#webhook-trigger) or by triggering events manually in the Shopify admin(e.g. Updating the product title to trigger a `PRODUCTS_UPDATE`).
### Incorrect GraphQL Hints
By default the [graphql.vscode-graphql](https://marketplace.visualstudio.com/items?itemName=GraphQL.vscode-graphql) extension for VS Code will assume that GraphQL queries or mutations are for the [Shopify Admin API](https://shopify.dev/docs/api/admin). This is a sensible default, but it may not be true if:
1. You use another Shopify API such as the storefront API.
2. You use a third party GraphQL API.
in this situation, please update the [.graphqlrc.ts](https://github.com/Shopify/shopify-app-template-remix/blob/main/.graphqlrc.ts) config.
### First parameter has member 'readable' that is not a ReadableStream.
See [hosting on Vercel](#hosting-on-vercel).
### Admin object undefined on webhook events triggered by the CLI
When you trigger a webhook event using the Shopify CLI, the `admin` object will be `undefined`. This is because the CLI triggers an event with a valid, but non-existent, shop. The `admin` object is only available when the webhook is triggered by a shop that has installed the app.
Webhooks triggered by the CLI are intended for initial experimentation testing of your webhook configuration. For more information on how to test your webhooks, see the [Shopify CLI documentation](https://shopify.dev/docs/apps/tools/cli/commands#webhook-trigger).
### Using Defer & await for streaming responses
To test [streaming using defer/await](https://remix.run/docs/en/main/guides/streaming) during local development you'll need to use the Shopify CLI slightly differently:
1. First setup ngrok: https://ngrok.com/product/secure-tunnels
2. Create an ngrok tunnel on port 8080: `ngrok http 8080`.
3. Copy the forwarding address. This should be something like: `https://f355-2607-fea8-bb5c-8700-7972-d2b5-3f2b-94ab.ngrok-free.app`
4. In a separate terminal run `yarn shopify app dev --tunnel-url=TUNNEL_URL:8080` replacing `TUNNEL_URL` for the address you copied in step 3.
By default the CLI uses a cloudflare tunnel. Unfortunately it cloudflare tunnels wait for the Response stream to finish, then sends one chunk.
This will not affect production, since tunnels are only for local development.
### Using MongoDB and Prisma
By default this template uses SQLlite as the database. It is recommended to move to a persisted database for production. If you choose to use MongoDB, you will need to make some modifications to the schema and prisma configuration. For more information please see the [Prisma MongoDB documentation](https://www.prisma.io/docs/orm/overview/databases/mongodb).
Alternatively you can use a MongDB database directly with the [MongoDB session storage adapter](https://github.com/Shopify/shopify-app-js/tree/main/packages/apps/session-storage/shopify-app-session-storage-mongodb).
#### Mapping the id field
In MongoDB, an ID must be a single field that defines an @id attribute and a @map("\_id") attribute.
The prisma adapter expects the ID field to be the ID of the session, and not the \_id field of the document.
To make this work you can add a new field to the schema that maps the \_id field to the id field. For more information see the [Prisma documentation](https://www.prisma.io/docs/orm/prisma-schema/data-model/models#defining-an-id-field)
```prisma
model Session {
session_id String @id @default(auto()) @map("_id") @db.ObjectId
id String @unique
...
}
```
#### Error: The "mongodb" provider is not supported with this command
MongoDB does not support the [prisma migrate](https://www.prisma.io/docs/orm/prisma-migrate/understanding-prisma-migrate/overview) command. Instead, you can use the [prisma db push](https://www.prisma.io/docs/orm/reference/prisma-cli-reference#db-push) command and update the `shopify.web.toml` file with the following commands. If you are using MongoDB please see the [Prisma documentation](https://www.prisma.io/docs/orm/overview/databases/mongodb) for more information.
```toml
[commands]
predev = "npx prisma generate && npx prisma db push"
dev = "npm exec remix vite:dev"
```
#### Prisma needs to perform transactions, which requires your mongodb server to be run as a replica set
See the [Prisma documentation](https://www.prisma.io/docs/getting-started/setup-prisma/start-from-scratch/mongodb/connect-your-database-node-mongodb) for connecting to a MongoDB database.
### I want to use Polaris v13.0.0 or higher
Currently, this template is set up to work on node v18.20 or higher. However, `@shopify/polaris` is limited to v12 because v13 can only run on node v20+.
You don't have to make any changes to the code in order to be able to upgrade Polaris to v13, but you'll need to do the following:
- Upgrade your node version to v20.10 or higher.
- Update your `Dockerfile` to pull `FROM node:20-alpine` instead of `node:18-alpine`
### "nbf" claim timestamp check failed
This error will occur of the `nbf` claim timestamp check failed. This is because the JWT token is expired.
If you are consistently getting this error, it could be that the clock on your machine is not in sync with the server.
To fix this ensure you have enabled `Set time and date automatically` in the `Date and Time` settings on your computer.
## Benefits
Shopify apps are built on a variety of Shopify tools to create a great merchant experience.
<!-- TODO: Uncomment this after we've updated the docs -->
<!-- The [create an app](https://shopify.dev/docs/apps/getting-started/create) tutorial in our developer documentation will guide you through creating a Shopify app using this template. -->
The Remix app template comes with the following out-of-the-box functionality:
- [OAuth](https://github.com/Shopify/shopify-app-js/tree/main/packages/shopify-app-remix#authenticating-admin-requests): Installing the app and granting permissions
- [GraphQL Admin API](https://github.com/Shopify/shopify-app-js/tree/main/packages/shopify-app-remix#using-the-shopify-admin-graphql-api): Querying or mutating Shopify admin data
- [Webhooks](https://github.com/Shopify/shopify-app-js/tree/main/packages/shopify-app-remix#authenticating-webhook-requests): Callbacks sent by Shopify when certain events occur
- [AppBridge](https://shopify.dev/docs/api/app-bridge): This template uses the next generation of the Shopify App Bridge library which works in unison with previous versions.
- [Polaris](https://polaris.shopify.com/): Design system that enables apps to create Shopify-like experiences
## Tech Stack
This template uses [Remix](https://remix.run). The following Shopify tools are also included to ease app development:
- [Shopify App Remix](https://shopify.dev/docs/api/shopify-app-remix) provides authentication and methods for interacting with Shopify APIs.
- [Shopify App Bridge](https://shopify.dev/docs/apps/tools/app-bridge) allows your app to seamlessly integrate your app within Shopify's Admin.
- [Polaris React](https://polaris.shopify.com/) is a powerful design system and component library that helps developers build high quality, consistent experiences for Shopify merchants.
- [Webhooks](https://github.com/Shopify/shopify-app-js/tree/main/packages/shopify-app-remix#authenticating-webhook-requests): Callbacks sent by Shopify when certain events occur
- [Polaris](https://polaris.shopify.com/): Design system that enables apps to create Shopify-like experiences
## Resources
- [Remix Docs](https://remix.run/docs/en/v1)
- [Shopify App Remix](https://shopify.dev/docs/api/shopify-app-remix)
- [Introduction to Shopify apps](https://shopify.dev/docs/apps/getting-started)
- [App authentication](https://shopify.dev/docs/apps/auth)
- [Shopify CLI](https://shopify.dev/docs/apps/tools/cli)
- [App extensions](https://shopify.dev/docs/apps/app-extensions/list)
- [Shopify Functions](https://shopify.dev/docs/api/functions)
- [Getting started with internationalizing your app](https://shopify.dev/docs/apps/best-practices/internationalization/getting-started)

15
app/app/db.server.ts Normal file
View File

@@ -0,0 +1,15 @@
import { PrismaClient } from "@prisma/client";
declare global {
var prismaGlobal: PrismaClient;
}
if (process.env.NODE_ENV !== "production") {
if (!global.prismaGlobal) {
global.prismaGlobal = new PrismaClient();
}
}
const prisma = global.prismaGlobal ?? new PrismaClient();
export default prisma;

59
app/app/entry.server.tsx Normal file
View File

@@ -0,0 +1,59 @@
import { PassThrough } from "stream";
import { renderToPipeableStream } from "react-dom/server";
import { RemixServer } from "@remix-run/react";
import {
createReadableStreamFromReadable,
type EntryContext,
} from "@remix-run/node";
import { isbot } from "isbot";
import { addDocumentResponseHeaders } from "./shopify.server";
export const streamTimeout = 5000;
export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
addDocumentResponseHeaders(request, responseHeaders);
const userAgent = request.headers.get("user-agent");
const callbackName = isbot(userAgent ?? '')
? "onAllReady"
: "onShellReady";
return new Promise((resolve, reject) => {
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
/>,
{
[callbackName]: () => {
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set("Content-Type", "text/html");
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);
pipe(body);
},
onShellError(error) {
reject(error);
},
onError(error) {
responseStatusCode = 500;
console.error(error);
},
}
);
// Automatically timeout the React renderer after 6 seconds, which ensures
// React has enough time to flush down the rejected boundary contents
setTimeout(abort, streamTimeout + 1000);
});
}

1
app/app/globals.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module "*.css";

30
app/app/root.tsx Normal file
View File

@@ -0,0 +1,30 @@
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react";
export default function App() {
return (
<html>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="preconnect" href="https://cdn.shopify.com/" />
<link
rel="stylesheet"
href="https://cdn.shopify.com/static/fonts/inter/v4/styles.css"
/>
<Meta />
<Links />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}

3
app/app/routes.ts Normal file
View File

@@ -0,0 +1,3 @@
import { flatRoutes } from "@remix-run/fs-routes";
export default flatRoutes();

View File

@@ -0,0 +1,58 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
import { login } from "../../shopify.server";
import styles from "./styles.module.css";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
if (url.searchParams.get("shop")) {
throw redirect(`/app?${url.searchParams.toString()}`);
}
return { showForm: Boolean(login) };
};
export default function App() {
const { showForm } = useLoaderData<typeof loader>();
return (
<div className={styles.index}>
<div className={styles.content}>
<h1 className={styles.heading}>A short heading about [your app]</h1>
<p className={styles.text}>
A tagline about [your app] that describes your value proposition.
</p>
{showForm && (
<Form className={styles.form} method="post" action="/auth/login">
<label className={styles.label}>
<span>Shop domain</span>
<input className={styles.input} type="text" name="shop" />
<span>e.g: my-shop-domain.myshopify.com</span>
</label>
<button className={styles.button} type="submit">
Log in
</button>
</Form>
)}
<ul className={styles.list}>
<li>
<strong>Product feature</strong>. Some detail about your feature and
its benefit to your customer.
</li>
<li>
<strong>Product feature</strong>. Some detail about your feature and
its benefit to your customer.
</li>
<li>
<strong>Product feature</strong>. Some detail about your feature and
its benefit to your customer.
</li>
</ul>
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
.index {
align-items: center;
display: flex;
justify-content: center;
height: 100%;
width: 100%;
text-align: center;
padding: 1rem;
}
.heading,
.text {
padding: 0;
margin: 0;
}
.text {
font-size: 1.2rem;
padding-bottom: 2rem;
}
.content {
display: grid;
gap: 2rem;
}
.form {
display: flex;
align-items: center;
justify-content: flex-start;
margin: 0 auto;
gap: 1rem;
}
.label {
display: grid;
gap: 0.2rem;
max-width: 20rem;
text-align: left;
font-size: 1rem;
}
.input {
padding: 0.4rem;
}
.button {
padding: 0.4rem;
}
.list {
list-style: none;
padding: 0;
padding-top: 3rem;
margin: 0;
display: flex;
gap: 2rem;
}
.list > li {
max-width: 20rem;
text-align: left;
}
@media only screen and (max-width: 50rem) {
.list {
display: block;
}
.list > li {
padding-bottom: 1rem;
}
}

View File

@@ -0,0 +1,334 @@
import { useEffect } from "react";
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { useFetcher } from "@remix-run/react";
import {
Page,
Layout,
Text,
Card,
Button,
BlockStack,
Box,
List,
Link,
InlineStack,
} from "@shopify/polaris";
import { TitleBar, useAppBridge } from "@shopify/app-bridge-react";
import { authenticate } from "../shopify.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
await authenticate.admin(request);
return null;
};
export const action = async ({ request }: ActionFunctionArgs) => {
const { admin } = await authenticate.admin(request);
const color = ["Red", "Orange", "Yellow", "Green"][
Math.floor(Math.random() * 4)
];
const response = await admin.graphql(
`#graphql
mutation populateProduct($product: ProductCreateInput!) {
productCreate(product: $product) {
product {
id
title
handle
status
variants(first: 10) {
edges {
node {
id
price
barcode
createdAt
}
}
}
}
}
}`,
{
variables: {
product: {
title: `${color} Snowboard`,
},
},
},
);
const responseJson = await response.json();
const product = responseJson.data!.productCreate!.product!;
const variantId = product.variants.edges[0]!.node!.id!;
const variantResponse = await admin.graphql(
`#graphql
mutation shopifyRemixTemplateUpdateVariant($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
productVariantsBulkUpdate(productId: $productId, variants: $variants) {
productVariants {
id
price
barcode
createdAt
}
}
}`,
{
variables: {
productId: product.id,
variants: [{ id: variantId, price: "100.00" }],
},
},
);
const variantResponseJson = await variantResponse.json();
return {
product: responseJson!.data!.productCreate!.product,
variant:
variantResponseJson!.data!.productVariantsBulkUpdate!.productVariants,
};
};
export default function Index() {
const fetcher = useFetcher<typeof action>();
const shopify = useAppBridge();
const isLoading =
["loading", "submitting"].includes(fetcher.state) &&
fetcher.formMethod === "POST";
const productId = fetcher.data?.product?.id.replace(
"gid://shopify/Product/",
"",
);
useEffect(() => {
if (productId) {
shopify.toast.show("Product created");
}
}, [productId, shopify]);
const generateProduct = () => fetcher.submit({}, { method: "POST" });
return (
<Page>
<TitleBar title="Remix app template">
<button variant="primary" onClick={generateProduct}>
Generate a product
</button>
</TitleBar>
<BlockStack gap="500">
<Layout>
<Layout.Section>
<Card>
<BlockStack gap="500">
<BlockStack gap="200">
<Text as="h2" variant="headingMd">
Congrats on creating a new Shopify app 🎉
</Text>
<Text variant="bodyMd" as="p">
This embedded app template uses{" "}
<Link
url="https://shopify.dev/docs/apps/tools/app-bridge"
target="_blank"
removeUnderline
>
App Bridge
</Link>{" "}
interface examples like an{" "}
<Link url="/app/additional" removeUnderline>
additional page in the app nav
</Link>
, as well as an{" "}
<Link
url="https://shopify.dev/docs/api/admin-graphql"
target="_blank"
removeUnderline
>
Admin GraphQL
</Link>{" "}
mutation demo, to provide a starting point for app
development.
</Text>
</BlockStack>
<BlockStack gap="200">
<Text as="h3" variant="headingMd">
Get started with products
</Text>
<Text as="p" variant="bodyMd">
Generate a product with GraphQL and get the JSON output for
that product. Learn more about the{" "}
<Link
url="https://shopify.dev/docs/api/admin-graphql/latest/mutations/productCreate"
target="_blank"
removeUnderline
>
productCreate
</Link>{" "}
mutation in our API references.
</Text>
</BlockStack>
<InlineStack gap="300">
<Button loading={isLoading} onClick={generateProduct}>
Generate a product
</Button>
{fetcher.data?.product && (
<Button
url={`shopify:admin/products/${productId}`}
target="_blank"
variant="plain"
>
View product
</Button>
)}
</InlineStack>
{fetcher.data?.product && (
<>
<Text as="h3" variant="headingMd">
{" "}
productCreate mutation
</Text>
<Box
padding="400"
background="bg-surface-active"
borderWidth="025"
borderRadius="200"
borderColor="border"
overflowX="scroll"
>
<pre style={{ margin: 0 }}>
<code>
{JSON.stringify(fetcher.data.product, null, 2)}
</code>
</pre>
</Box>
<Text as="h3" variant="headingMd">
{" "}
productVariantsBulkUpdate mutation
</Text>
<Box
padding="400"
background="bg-surface-active"
borderWidth="025"
borderRadius="200"
borderColor="border"
overflowX="scroll"
>
<pre style={{ margin: 0 }}>
<code>
{JSON.stringify(fetcher.data.variant, null, 2)}
</code>
</pre>
</Box>
</>
)}
</BlockStack>
</Card>
</Layout.Section>
<Layout.Section variant="oneThird">
<BlockStack gap="500">
<Card>
<BlockStack gap="200">
<Text as="h2" variant="headingMd">
App template specs
</Text>
<BlockStack gap="200">
<InlineStack align="space-between">
<Text as="span" variant="bodyMd">
Framework
</Text>
<Link
url="https://remix.run"
target="_blank"
removeUnderline
>
Remix
</Link>
</InlineStack>
<InlineStack align="space-between">
<Text as="span" variant="bodyMd">
Database
</Text>
<Link
url="https://www.prisma.io/"
target="_blank"
removeUnderline
>
Prisma
</Link>
</InlineStack>
<InlineStack align="space-between">
<Text as="span" variant="bodyMd">
Interface
</Text>
<span>
<Link
url="https://polaris.shopify.com"
target="_blank"
removeUnderline
>
Polaris
</Link>
{", "}
<Link
url="https://shopify.dev/docs/apps/tools/app-bridge"
target="_blank"
removeUnderline
>
App Bridge
</Link>
</span>
</InlineStack>
<InlineStack align="space-between">
<Text as="span" variant="bodyMd">
API
</Text>
<Link
url="https://shopify.dev/docs/api/admin-graphql"
target="_blank"
removeUnderline
>
GraphQL API
</Link>
</InlineStack>
</BlockStack>
</BlockStack>
</Card>
<Card>
<BlockStack gap="200">
<Text as="h2" variant="headingMd">
Next steps
</Text>
<List>
<List.Item>
Build an{" "}
<Link
url="https://shopify.dev/docs/apps/getting-started/build-app-example"
target="_blank"
removeUnderline
>
{" "}
example app
</Link>{" "}
to get started
</List.Item>
<List.Item>
Explore Shopifys API with{" "}
<Link
url="https://shopify.dev/docs/apps/tools/graphiql-admin-api"
target="_blank"
removeUnderline
>
GraphiQL
</Link>
</List.Item>
</List>
</BlockStack>
</Card>
</BlockStack>
</Layout.Section>
</Layout>
</BlockStack>
</Page>
);
}

View File

@@ -0,0 +1,83 @@
import {
Box,
Card,
Layout,
Link,
List,
Page,
Text,
BlockStack,
} from "@shopify/polaris";
import { TitleBar } from "@shopify/app-bridge-react";
export default function AdditionalPage() {
return (
<Page>
<TitleBar title="Additional page" />
<Layout>
<Layout.Section>
<Card>
<BlockStack gap="300">
<Text as="p" variant="bodyMd">
The app template comes with an additional page which
demonstrates how to create multiple pages within app navigation
using{" "}
<Link
url="https://shopify.dev/docs/apps/tools/app-bridge"
target="_blank"
removeUnderline
>
App Bridge
</Link>
.
</Text>
<Text as="p" variant="bodyMd">
To create your own page and have it show up in the app
navigation, add a page inside <Code>app/routes</Code>, and a
link to it in the <Code>&lt;NavMenu&gt;</Code> component found
in <Code>app/routes/app.jsx</Code>.
</Text>
</BlockStack>
</Card>
</Layout.Section>
<Layout.Section variant="oneThird">
<Card>
<BlockStack gap="200">
<Text as="h2" variant="headingMd">
Resources
</Text>
<List>
<List.Item>
<Link
url="https://shopify.dev/docs/apps/design-guidelines/navigation#app-nav"
target="_blank"
removeUnderline
>
App nav best practices
</Link>
</List.Item>
</List>
</BlockStack>
</Card>
</Layout.Section>
</Layout>
</Page>
);
}
function Code({ children }: { children: React.ReactNode }) {
return (
<Box
as="span"
padding="025"
paddingInlineStart="100"
paddingInlineEnd="100"
background="bg-surface-active"
borderWidth="025"
borderColor="border"
borderRadius="100"
>
<code>{children}</code>
</Box>
);
}

41
app/app/routes/app.tsx Normal file
View File

@@ -0,0 +1,41 @@
import type { HeadersFunction, LoaderFunctionArgs } from "@remix-run/node";
import { Link, Outlet, useLoaderData, useRouteError } from "@remix-run/react";
import { boundary } from "@shopify/shopify-app-remix/server";
import { AppProvider } from "@shopify/shopify-app-remix/react";
import { NavMenu } from "@shopify/app-bridge-react";
import polarisStyles from "@shopify/polaris/build/esm/styles.css?url";
import { authenticate } from "../shopify.server";
export const links = () => [{ rel: "stylesheet", href: polarisStyles }];
export const loader = async ({ request }: LoaderFunctionArgs) => {
await authenticate.admin(request);
return { apiKey: process.env.SHOPIFY_API_KEY || "" };
};
export default function App() {
const { apiKey } = useLoaderData<typeof loader>();
return (
<AppProvider isEmbeddedApp apiKey={apiKey}>
<NavMenu>
<Link to="/app" rel="home">
Home
</Link>
<Link to="/app/additional">Additional page</Link>
</NavMenu>
<Outlet />
</AppProvider>
);
}
// Shopify needs Remix to catch some thrown responses, so that their headers are included in the response.
export function ErrorBoundary() {
return boundary.error(useRouteError());
}
export const headers: HeadersFunction = (headersArgs) => {
return boundary.headers(headersArgs);
};

View File

@@ -0,0 +1,8 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { authenticate } from "../shopify.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
await authenticate.admin(request);
return null;
};

View File

@@ -0,0 +1,16 @@
import type { LoginError } from "@shopify/shopify-app-remix/server";
import { LoginErrorType } from "@shopify/shopify-app-remix/server";
interface LoginErrorMessage {
shop?: string;
}
export function loginErrorMessage(loginErrors: LoginError): LoginErrorMessage {
if (loginErrors?.shop === LoginErrorType.MissingShop) {
return { shop: "Please enter your shop domain to log in" };
} else if (loginErrors?.shop === LoginErrorType.InvalidShop) {
return { shop: "Please enter a valid shop domain to log in" };
}
return {};
}

View File

@@ -0,0 +1,68 @@
import { useState } from "react";
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { Form, useActionData, useLoaderData } from "@remix-run/react";
import {
AppProvider as PolarisAppProvider,
Button,
Card,
FormLayout,
Page,
Text,
TextField,
} from "@shopify/polaris";
import polarisTranslations from "@shopify/polaris/locales/en.json";
import polarisStyles from "@shopify/polaris/build/esm/styles.css?url";
import { login } from "../../shopify.server";
import { loginErrorMessage } from "./error.server";
export const links = () => [{ rel: "stylesheet", href: polarisStyles }];
export const loader = async ({ request }: LoaderFunctionArgs) => {
const errors = loginErrorMessage(await login(request));
return { errors, polarisTranslations };
};
export const action = async ({ request }: ActionFunctionArgs) => {
const errors = loginErrorMessage(await login(request));
return {
errors,
};
};
export default function Auth() {
const loaderData = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const [shop, setShop] = useState("");
const { errors } = actionData || loaderData;
return (
<PolarisAppProvider i18n={loaderData.polarisTranslations}>
<Page>
<Card>
<Form method="post">
<FormLayout>
<Text variant="headingMd" as="h2">
Log in
</Text>
<TextField
type="text"
name="shop"
label="Shop domain"
helpText="example.myshopify.com"
value={shop}
onChange={setShop}
autoComplete="on"
error={errors.shop}
/>
<Button submit>Log in</Button>
</FormLayout>
</Form>
</Card>
</Page>
</PolarisAppProvider>
);
}

View File

@@ -0,0 +1,21 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { authenticate } from "../shopify.server";
import db from "../db.server";
export const action = async ({ request }: ActionFunctionArgs) => {
const { payload, session, topic, shop } = await authenticate.webhook(request);
console.log(`Received ${topic} webhook for ${shop}`);
const current = payload.current as string[];
if (session) {
await db.session.update({
where: {
id: session.id
},
data: {
scope: current.toString(),
},
});
}
return new Response();
};

View File

@@ -0,0 +1,17 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { authenticate } from "../shopify.server";
import db from "../db.server";
export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, session, topic } = await authenticate.webhook(request);
console.log(`Received ${topic} webhook for ${shop}`);
// Webhook requests can trigger multiple times and after an app has already been uninstalled.
// If this webhook already ran, the session may have been deleted previously.
if (session) {
await db.session.deleteMany({ where: { shop } });
}
return new Response();
};

View File

@@ -0,0 +1,39 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { createHash } from "node:crypto";
import { authenticate } from "../shopify.server";
import db from "../db.server";
/**
* GDPR mandatory compliance webhook: customers/data_request.
* Shopify sends this when a customer requests their stored data.
* HMAC is verified by authenticate.webhook(request); an invalid request throws.
*/
export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, topic, payload, webhookId } = await authenticate.webhook(request);
console.log(`Received ${topic} webhook for ${shop}`);
const payloadHash = createHash("sha256")
.update(JSON.stringify(payload ?? {}))
.digest("hex");
// Idempotency: record the webhook once (webhookId is unique when present).
const dedupeKey = webhookId ?? `${topic}:${payloadHash}`;
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: "customers/data_request received" },
});
// 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

@@ -0,0 +1,39 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { createHash } from "node:crypto";
import { authenticate } from "../shopify.server";
import db from "../db.server";
/**
* GDPR mandatory compliance webhook: customers/redact.
* Shopify sends this (typically 10 days after an order is cancelled/deleted, or
* on merchant request) to require deletion of a customer's personal data.
* HMAC is verified by authenticate.webhook(request); an invalid request throws.
*/
export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, topic, payload, webhookId } = await authenticate.webhook(request);
console.log(`Received ${topic} webhook for ${shop}`);
const payloadHash = createHash("sha256")
.update(JSON.stringify(payload ?? {}))
.digest("hex");
// Idempotency: record the webhook once (webhookId is unique when present).
const dedupeKey = webhookId ?? `${topic}:${payloadHash}`;
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: "customers/redact received" },
});
// 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

@@ -0,0 +1,40 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { createHash } from "node:crypto";
import { authenticate } from "../shopify.server";
import db from "../db.server";
/**
* GDPR mandatory compliance webhook: shop/redact.
* Shopify sends this ~48h after a shop uninstalls the app, to require deletion
* of that shop's data. Note: `session` may already be gone here — rely only on
* shop/topic from the verified webhook.
* HMAC is verified by authenticate.webhook(request); an invalid request throws.
*/
export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, topic, payload, webhookId } = await authenticate.webhook(request);
console.log(`Received ${topic} webhook for ${shop}`);
const payloadHash = createHash("sha256")
.update(JSON.stringify(payload ?? {}))
.digest("hex");
// Idempotency: record the webhook once (webhookId is unique when present).
const dedupeKey = webhookId ?? `${topic}:${payloadHash}`;
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" },
});
// 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();
};

35
app/app/shopify.server.ts Normal file
View File

@@ -0,0 +1,35 @@
import "@shopify/shopify-app-remix/adapters/node";
import {
ApiVersion,
AppDistribution,
shopifyApp,
} from "@shopify/shopify-app-remix/server";
import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma";
import prisma from "./db.server";
const shopify = shopifyApp({
apiKey: process.env.SHOPIFY_API_KEY,
apiSecretKey: process.env.SHOPIFY_API_SECRET || "",
apiVersion: ApiVersion.April26,
scopes: process.env.SCOPES?.split(","),
appUrl: process.env.SHOPIFY_APP_URL || "",
authPathPrefix: "/auth",
sessionStorage: new PrismaSessionStorage(prisma),
distribution: AppDistribution.AppStore,
future: {
unstable_newEmbeddedAuthStrategy: true,
expiringOfflineAccessTokens: true,
},
...(process.env.SHOP_CUSTOM_DOMAIN
? { customShopDomains: [process.env.SHOP_CUSTOM_DOMAIN] }
: {}),
});
export default shopify;
export const apiVersion = ApiVersion.April26;
export const addDocumentResponseHeaders = shopify.addDocumentResponseHeaders;
export const authenticate = shopify.authenticate;
export const unauthenticated = shopify.unauthenticated;
export const login = shopify.login;
export const registerWebhooks = shopify.registerWebhooks;
export const sessionStorage = shopify.sessionStorage;

2
app/env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="@remix-run/node" />

0
app/extensions/.gitkeep Normal file
View File

24
app/fly.toml Normal file
View File

@@ -0,0 +1,24 @@
# Fly.io deployment — CUSTOM distribution environment (clienti LIVE).
# App name: recesso-custom. The future PUBLIC app is a SEPARATE Fly app
# (recesso-public) with its own DB — deploys here never touch it (PLAN §0-ter).
#
# Secrets are NOT stored here. Set them with `fly secrets set` (never commit):
# DATABASE_URL, SHOPIFY_API_KEY, SHOPIFY_API_SECRET, SHOPIFY_APP_URL
# (SCOPES can also be set as a secret/env to match shopify.app.toml.)
app = "recesso-custom"
primary_region = "fra" # Frankfurt (EU data residency)
[build]
dockerfile = "Dockerfile"
[http_service]
internal_port = 3000
force_https = true
auto_stop_machines = true
auto_start_machines = true
min_machines_running = 0
[[vm]]
size = "shared-cpu-1x"
memory = "512mb"

78
app/package.json Normal file
View File

@@ -0,0 +1,78 @@
{
"name": "app",
"private": true,
"scripts": {
"build": "remix vite:build",
"dev": "shopify app dev",
"config:link": "shopify app config link",
"generate": "shopify app generate",
"deploy": "shopify app deploy",
"config:use": "shopify app config use",
"env": "shopify app env",
"start": "remix-serve ./build/server/index.js",
"docker-start": "npm run setup && npm run start",
"setup": "prisma generate && prisma migrate deploy",
"lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .",
"shopify": "shopify",
"prisma": "prisma",
"graphql-codegen": "graphql-codegen",
"vite": "vite"
},
"type": "module",
"engines": {
"node": ">=20.19 <22 || >=22.12"
},
"dependencies": {
"@prisma/client": "^6.2.1",
"@remix-run/dev": "^2.16.1",
"@remix-run/fs-routes": "^2.16.1",
"@remix-run/node": "^2.16.1",
"@remix-run/react": "^2.16.1",
"@remix-run/serve": "^2.16.1",
"@shopify/app-bridge-react": "^4.1.6",
"@shopify/polaris": "^12.0.0",
"@shopify/shopify-app-remix": "^4.1.0",
"@shopify/shopify-app-session-storage-prisma": "^8.0.0",
"isbot": "^5.1.0",
"prisma": "^6.2.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"vite-tsconfig-paths": "^5.0.1"
},
"devDependencies": {
"@remix-run/eslint-config": "^2.16.1",
"@remix-run/route-config": "^2.16.1",
"@shopify/api-codegen-preset": "^1.1.1",
"@types/eslint": "^9.6.1",
"@types/node": "^22.2.0",
"@types/react": "^18.2.31",
"@types/react-dom": "^18.2.14",
"eslint": "^8.42.0",
"eslint-config-prettier": "^10.0.1",
"prettier": "^3.2.4",
"typescript": "^5.2.2",
"vite": "^6.2.2"
},
"workspaces": {
"packages": [
"extensions/*"
]
},
"trustedDependencies": [
"@shopify/plugin-cloudflare"
],
"resolutions": {
"@graphql-tools/url-loader": "8.0.16",
"@graphql-codegen/client-preset": "4.7.0",
"@graphql-codegen/typescript-operations": "4.5.0",
"minimatch": "9.0.5",
"vite": "^6.2.2"
},
"overrides": {
"@graphql-tools/url-loader": "8.0.16",
"@graphql-codegen/client-preset": "4.7.0",
"@graphql-codegen/typescript-operations": "4.5.0",
"minimatch": "9.0.5",
"vite": "^6.2.2"
}
}

9
app/pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,9 @@
packages:
- 'extensions/*'
allowBuilds:
'@parcel/watcher': true
'@prisma/client': true
'@prisma/engines': true
esbuild: true
prisma: true
unrs-resolver: true

View File

@@ -0,0 +1,133 @@
-- CreateEnum
CREATE TYPE "ExclusionScope" AS ENUM ('PRODUCT', 'COLLECTION', 'TAG', 'ALL');
-- CreateEnum
CREATE TYPE "ExclusionReason" AS ENUM ('CUSTOM', 'PERISHABLE', 'HYGIENE', 'OTHER');
-- CreateEnum
CREATE TYPE "WithdrawalChannel" AS ENUM ('GUEST', 'ACCOUNT');
-- CreateEnum
CREATE TYPE "WithdrawalStatus" AS ENUM ('RECEIVED', 'ACKNOWLEDGED', 'GOODS_PENDING', 'CLOSED', 'REJECTED');
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"shop" TEXT NOT NULL,
"state" TEXT NOT NULL,
"isOnline" BOOLEAN NOT NULL DEFAULT false,
"scope" TEXT,
"expires" TIMESTAMP(3),
"accessToken" TEXT NOT NULL,
"userId" BIGINT,
"firstName" TEXT,
"lastName" TEXT,
"email" TEXT,
"accountOwner" BOOLEAN NOT NULL DEFAULT false,
"locale" TEXT,
"collaborator" BOOLEAN DEFAULT false,
"emailVerified" BOOLEAN DEFAULT false,
"refreshToken" TEXT,
"refreshTokenExpires" TIMESTAMP(3),
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Settings" (
"id" TEXT NOT NULL,
"shop" TEXT NOT NULL,
"buttonLabel" TEXT NOT NULL DEFAULT 'Recedere dal contratto qui',
"confirmLabel" TEXT NOT NULL DEFAULT 'Conferma recesso',
"brandPrimaryColor" TEXT,
"returnAddress" TEXT,
"defaultWindowDays" INTEGER NOT NULL DEFAULT 14,
"withdrawalInfoText" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Settings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ExclusionRule" (
"id" TEXT NOT NULL,
"shop" TEXT NOT NULL,
"scope" "ExclusionScope" NOT NULL,
"targetId" TEXT,
"reason" "ExclusionReason" NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ExclusionRule_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WithdrawalRequest" (
"id" TEXT NOT NULL,
"shop" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"orderName" TEXT,
"customerName" TEXT NOT NULL,
"email" TEXT NOT NULL,
"statementText" TEXT NOT NULL,
"transmittedAt" TIMESTAMP(3) NOT NULL,
"locale" TEXT,
"channel" "WithdrawalChannel" NOT NULL,
"productType" TEXT,
"status" "WithdrawalStatus" NOT NULL DEFAULT 'RECEIVED',
"receiptSentAt" TIMESTAMP(3),
"computedDeadline" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WithdrawalRequest_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL,
"shop" TEXT NOT NULL,
"event" TEXT NOT NULL,
"payloadHash" TEXT,
"detail" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WebhookEvent" (
"id" TEXT NOT NULL,
"shop" TEXT NOT NULL,
"topic" TEXT NOT NULL,
"webhookId" TEXT,
"receivedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"processed" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "WebhookEvent_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Settings_shop_key" ON "Settings"("shop");
-- CreateIndex
CREATE INDEX "Settings_shop_idx" ON "Settings"("shop");
-- CreateIndex
CREATE INDEX "ExclusionRule_shop_idx" ON "ExclusionRule"("shop");
-- CreateIndex
CREATE INDEX "WithdrawalRequest_shop_idx" ON "WithdrawalRequest"("shop");
-- CreateIndex
CREATE INDEX "WithdrawalRequest_shop_orderId_idx" ON "WithdrawalRequest"("shop", "orderId");
-- CreateIndex
CREATE INDEX "AuditLog_shop_idx" ON "AuditLog"("shop");
-- CreateIndex
CREATE UNIQUE INDEX "WebhookEvent_webhookId_key" ON "WebhookEvent"("webhookId");
-- CreateIndex
CREATE INDEX "WebhookEvent_shop_idx" ON "WebhookEvent"("shop");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

147
app/prisma/schema.prisma Normal file
View File

@@ -0,0 +1,147 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ---------------------------------------------------------------------------
// Session — used by @shopify/shopify-app-session-storage-prisma.
// DO NOT change the shape of this model (the session storage depends on it).
// ---------------------------------------------------------------------------
model Session {
id String @id
shop String
state String
isOnline Boolean @default(false)
scope String?
expires DateTime?
accessToken String
userId BigInt?
firstName String?
lastName String?
email String?
accountOwner Boolean @default(false)
locale String?
collaborator Boolean? @default(false)
emailVerified Boolean? @default(false)
refreshToken String?
refreshTokenExpires DateTime?
}
// ---------------------------------------------------------------------------
// Multi-tenant app models. Every tenant-scoped row carries `shop` + an index
// on it (public-grade isolation from day 1, even with only 2-3 custom stores).
// ---------------------------------------------------------------------------
// Per-shop merchant configuration for the withdrawal button/flow.
model Settings {
id String @id @default(cuid())
shop String @unique
buttonLabel String @default("Recedere dal contratto qui")
confirmLabel String @default("Conferma recesso")
brandPrimaryColor String?
returnAddress String?
defaultWindowDays Int @default(14)
withdrawalInfoText String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([shop])
}
// Art. 59 exclusions (made-to-order, perishable, sealed-for-hygiene, etc.).
model ExclusionRule {
id String @id @default(cuid())
shop String
scope ExclusionScope
targetId String?
reason ExclusionReason
active Boolean @default(true)
createdAt DateTime @default(now())
@@index([shop])
}
// A withdrawal (recesso) statement submitted by a consumer.
model WithdrawalRequest {
id String @id @default(cuid())
shop String
orderId String
orderName String?
customerName String
email String
statementText String
transmittedAt DateTime // legal timestamp of transmission (Art. 54-bis)
locale String?
channel WithdrawalChannel
productType String?
status WithdrawalStatus @default(RECEIVED)
receiptSentAt DateTime?
computedDeadline DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([shop])
@@index([shop, orderId])
}
// Append-only audit trail (burden of proof — Art. 54-bis / R6). No updatedAt.
model AuditLog {
id String @id @default(cuid())
shop String
event String
payloadHash String?
detail String?
createdAt DateTime @default(now())
@@index([shop])
}
// Webhook idempotency ledger.
model WebhookEvent {
id String @id @default(cuid())
shop String
topic String
webhookId String? @unique
receivedAt DateTime @default(now())
processed Boolean @default(false)
@@index([shop])
}
// ---------------------------------------------------------------------------
// Enums
// ---------------------------------------------------------------------------
enum ExclusionScope {
PRODUCT
COLLECTION
TAG
ALL
}
enum ExclusionReason {
CUSTOM
PERISHABLE
HYGIENE
OTHER
}
enum WithdrawalChannel {
GUEST
ACCOUNT
}
enum WithdrawalStatus {
RECEIVED
ACKNOWLEDGED
GOODS_PENDING
CLOSED
REJECTED
}

BIN
app/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

47
app/shopify.app.toml Normal file
View File

@@ -0,0 +1,47 @@
# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration
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"
embedded = true
[build]
automatically_update_urls_on_dev = false
dev_store_url = "pcrt-reso-test.myshopify.com"
[webhooks]
api_version = "2026-04"
[[webhooks.subscriptions]]
uri = "/webhooks/customers/data_request"
compliance_topics = [ "customers/data_request" ]
[[webhooks.subscriptions]]
uri = "/webhooks/customers/redact"
compliance_topics = [ "customers/redact" ]
[[webhooks.subscriptions]]
uri = "/webhooks/shop/redact"
compliance_topics = [ "shop/redact" ]
[[webhooks.subscriptions]]
uri = "/webhooks/app/scopes_update"
topics = [ "app/scopes_update" ]
[[webhooks.subscriptions]]
uri = "/webhooks/app/uninstalled"
topics = [ "app/uninstalled" ]
[access_scopes]
# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes
scopes = "read_orders,read_products"
optional_scopes = [ ]
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"
]

7
app/shopify.web.toml Normal file
View File

@@ -0,0 +1,7 @@
name = "remix"
roles = ["frontend", "backend"]
webhooks_path = "/webhooks/app/uninstalled"
[commands]
predev = "npm exec prisma generate"
dev = "npm exec prisma migrate deploy && npm exec remix vite:dev"

View File

@@ -0,0 +1,11 @@
name = "remix"
roles = ["frontend", "backend"]
webhooks_path = "/webhooks/app/uninstalled"
{%- assign exec = dependency_manager | append: ' exec' -%}
{%- if dependency_manager == 'yarn' -%}
{%- assign exec = 'yarn' -%}
{%- endif %}
[commands]
predev = "{{ exec }} prisma generate"
dev = "{{ exec }} prisma migrate deploy && {{ exec }} remix vite:dev"

21
app/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"include": ["env.d.ts", "**/*.ts", "**/*.tsx"],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"strict": true,
"skipLibCheck": true,
"isolatedModules": true,
"allowSyntheticDefaultImports": true,
"removeComments": false,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"allowJs": true,
"resolveJsonModule": true,
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "Bundler",
"target": "ES2022",
"baseUrl": ".",
"types": ["node"]
}
}

74
app/vite.config.ts Normal file
View File

@@ -0,0 +1,74 @@
import { vitePlugin as remix } from "@remix-run/dev";
import { installGlobals } from "@remix-run/node";
import { defineConfig, type UserConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
installGlobals({ nativeFetch: true });
// Related: https://github.com/remix-run/remix/issues/2835#issuecomment-1144102176
// Replace the HOST env var with SHOPIFY_APP_URL so that it doesn't break the remix server. The CLI will eventually
// stop passing in HOST, so we can remove this workaround after the next major release.
if (
process.env.HOST &&
(!process.env.SHOPIFY_APP_URL ||
process.env.SHOPIFY_APP_URL === process.env.HOST)
) {
process.env.SHOPIFY_APP_URL = process.env.HOST;
delete process.env.HOST;
}
const host = new URL(process.env.SHOPIFY_APP_URL || "http://localhost")
.hostname;
let hmrConfig;
if (host === "localhost") {
hmrConfig = {
protocol: "ws",
host: "localhost",
port: 64999,
clientPort: 64999,
};
} else {
hmrConfig = {
protocol: "wss",
host: host,
port: parseInt(process.env.FRONTEND_PORT!) || 8002,
clientPort: 443,
};
}
export default defineConfig({
server: {
// host da SHOPIFY_APP_URL + wildcard tunnel dev (cloudflare quick tunnel / ngrok)
allowedHosts: [host, ".trycloudflare.com", ".ngrok-free.app"],
cors: {
preflightContinue: true,
},
port: Number(process.env.PORT || 3000),
hmr: hmrConfig,
fs: {
// See https://vitejs.dev/config/server-options.html#server-fs-allow for more information
allow: ["app", "node_modules"],
},
},
plugins: [
remix({
ignoredRouteFiles: ["**/.*"],
future: {
v3_fetcherPersist: true,
v3_relativeSplatPath: true,
v3_throwAbortReason: true,
v3_lazyRouteDiscovery: true,
v3_singleFetch: false,
v3_routeConfig: true,
},
}),
tsconfigPaths(),
],
build: {
assetsInlineLimit: 0,
},
optimizeDeps: {
include: ["@shopify/app-bridge-react", "@shopify/polaris"],
},
}) satisfies UserConfig;