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

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
}