diff --git a/README.md b/README.md index 4257a96..07cad16 100644 --- a/README.md +++ b/README.md @@ -45,11 +45,23 @@ The 3 mandatory GDPR compliance webhooks (`customers/data_request`, — Shopify refuses to push them until the org requests and is granted **Protected customer data access** in the Partner Dashboard (Apps → this app → API access → Protected customer data), which is a manual -questionnaire/approval step. The handlers already exist and are fully -wired (`app/routes/webhooks.customers.*.tsx`, `webhooks.shop.redact.tsx`) — -once that access is granted, uncomment the three `[[webhooks.subscriptions]]` -blocks near the bottom of the webhooks section. **Required before any -public launch or Built-for-Shopify submission** — don't ship without it. +questionnaire/approval step. The handlers are fully implemented against the +real schema (`app/services/gdpr.server.ts`, used by +`app/routes/webhooks.customers.*.tsx` and `webhooks.shop.redact.tsx`, +covered by `tests/integration/gdpr.test.ts`): `customers/data_request` +compiles and logs the customer's Booking history for the merchant to hand +off (Shopify's webhook has no response payload — a self-serve export is a +Phase 9+ notification-system enhancement); `customers/redact` anonymizes +`customerEmail`/`customerPhone` on matching Bookings while keeping the +booking rows for the shop's own revenue/utilization history; +`shop/redact` deletes every shopDomain-scoped row (Booking first, then +Location — whose cascade removes SlotTemplate/SlotOverride/BlackoutDate/ +Zone/Rate — then Shop and Session; `GeocodeCache` is deliberately excluded, +it's a shared address-keyed cache with no shopDomain). Once Protected +customer data access is granted, uncomment the three +`[[webhooks.subscriptions]]` blocks near the bottom of the webhooks +section. **Required before any public launch or Built-for-Shopify +submission** — don't ship without it. ## Status diff --git a/app/routes/webhooks.customers.data_request.tsx b/app/routes/webhooks.customers.data_request.tsx index 6f656c3..f03552d 100644 --- a/app/routes/webhooks.customers.data_request.tsx +++ b/app/routes/webhooks.customers.data_request.tsx @@ -1,13 +1,22 @@ import type { ActionFunctionArgs } from "@remix-run/node"; import { authenticate } from "../shopify.server"; +import { compileCustomerData, type CustomerWebhookPayload } from "../services/gdpr.server"; // GDPR: a customer (or Shopify on their behalf) requesting their data. -// TODO (Phase 8): compile and return the shop's stored Booking/Waitlist/ -// Reschedule/Notification records for this customer. +// Shopify requires the app to make the data available to the merchant +// within 30 days; there's no response payload on the webhook itself, so we +// log the compiled records for the merchant to hand off (a self-serve +// export/notification is a Phase 9+ enhancement). export const action = async ({ request }: ActionFunctionArgs) => { const { shop, topic, payload } = await authenticate.webhook(request); + const { customer } = payload as CustomerWebhookPayload; - console.log(`Received ${topic} webhook for ${shop}`, payload); + const bookings = await compileCustomerData(shop, customer); + + console.log( + `[gdpr] ${topic} for ${shop}: customer ${customer?.id ?? "unknown"} — ${bookings.length} booking(s) found`, + bookings, + ); return new Response(); }; diff --git a/app/routes/webhooks.customers.redact.tsx b/app/routes/webhooks.customers.redact.tsx index 7824fa6..5193d36 100644 --- a/app/routes/webhooks.customers.redact.tsx +++ b/app/routes/webhooks.customers.redact.tsx @@ -1,14 +1,20 @@ import type { ActionFunctionArgs } from "@remix-run/node"; import { authenticate } from "../shopify.server"; +import { redactCustomerData, type CustomerWebhookPayload } from "../services/gdpr.server"; -// GDPR: erase a specific customer's data (fires 10 days after a data -// erasure request, or 6 months after their last order on the shop). -// TODO (Phase 8): purge/anonymize Booking.customerEmail/customerPhone, -// Waitlist entries, and Notification payloads for this customer. +// GDPR: erase a specific customer's data (fires 10 days after a customer's +// data erasure request, or 6 months after their last order on the shop). +// Bookings are kept for the shop's own revenue/utilization history — only +// the customer-identifying fields (email/phone) are anonymized. export const action = async ({ request }: ActionFunctionArgs) => { const { shop, topic, payload } = await authenticate.webhook(request); + const { customer } = payload as CustomerWebhookPayload; - console.log(`Received ${topic} webhook for ${shop}`, payload); + const { count } = await redactCustomerData(shop, customer); + + console.log( + `[gdpr] ${topic} for ${shop}: customer ${customer?.id ?? "unknown"} — redacted ${count} booking(s)`, + ); return new Response(); }; diff --git a/app/routes/webhooks.shop.redact.tsx b/app/routes/webhooks.shop.redact.tsx index 85931f8..26e8c31 100644 --- a/app/routes/webhooks.shop.redact.tsx +++ b/app/routes/webhooks.shop.redact.tsx @@ -1,16 +1,14 @@ import type { ActionFunctionArgs } from "@remix-run/node"; -import db from "../db.server"; import { authenticate } from "../shopify.server"; +import { purgeShopData } from "../services/gdpr.server"; // GDPR: shop uninstalled the app 48 hours ago — erase all shop data. -// TODO (Phase 8): once the full schema exists, delete every row scoped to -// this shopDomain across Location/SlotTemplate/Booking/etc. export const action = async ({ request }: ActionFunctionArgs) => { const { shop, topic, payload } = await authenticate.webhook(request); - console.log(`Received ${topic} webhook for ${shop}`, payload); + console.log(`[gdpr] ${topic} for ${shop} — purging all shop data`, payload); - await db.session.deleteMany({ where: { shop } }); + await purgeShopData(shop); return new Response(); }; diff --git a/app/services/gdpr.server.ts b/app/services/gdpr.server.ts new file mode 100644 index 0000000..d075868 --- /dev/null +++ b/app/services/gdpr.server.ts @@ -0,0 +1,80 @@ +import db from "../db.server"; + +// Shopify's mandatory GDPR webhook payload shapes (customers/data_request, +// customers/redact). Only the fields we actually use are declared. +export interface CustomerWebhookPayload { + customer?: { + id?: number; + email?: string | null; + phone?: string | null; + }; +} + +function customerContactMatch(customer: CustomerWebhookPayload["customer"]) { + const email = customer?.email ?? undefined; + const phone = customer?.phone ?? undefined; + const or: Array<{ customerEmail?: string; customerPhone?: string }> = []; + if (email) or.push({ customerEmail: email }); + if (phone) or.push({ customerPhone: phone }); + return or; +} + +// customers/data_request: compile every Booking record this shop holds for +// the requesting customer. Shopify doesn't accept a data payload back over +// the webhook itself — the merchant is expected to supply it to the +// customer directly (email/support ticket), so we log a structured record +// here for the merchant to retrieve. A self-serve export (e.g. emailed to +// the shop owner) is a Phase 9+ notification-system enhancement. +export async function compileCustomerData( + shopDomain: string, + customer: CustomerWebhookPayload["customer"], +) { + const or = customerContactMatch(customer); + if (or.length === 0) return []; + return db.booking.findMany({ + where: { shopDomain, OR: or }, + select: { + id: true, + orderId: true, + orderName: true, + method: true, + slotStart: true, + slotEnd: true, + status: true, + customerEmail: true, + customerPhone: true, + createdAt: true, + }, + }); +} + +// customers/redact: anonymize this customer's PII on any Booking rows for +// this shop. Bookings themselves are kept (needed for revenue/utilization +// history on the dashboard) — only the customer-identifying fields go. +export async function redactCustomerData( + shopDomain: string, + customer: CustomerWebhookPayload["customer"], +) { + const or = customerContactMatch(customer); + if (or.length === 0) return { count: 0 }; + return db.booking.updateMany({ + where: { shopDomain, OR: or }, + data: { customerEmail: null, customerPhone: null }, + }); +} + +// shop/redact: erase every shopDomain-scoped row, 48h after uninstall. +// Booking has no cascade from Location (kept deliberately restrictive so an +// active shop can't accidentally cascade-delete booking history by deleting +// a Location), so it must be deleted before Location; deleting Location +// cascades SlotTemplate/SlotOverride/BlackoutDate/Zone/Rate per schema.prisma. +// GeocodeCache is intentionally excluded — it's a shared, address-keyed +// cache with no shopDomain, not shop-owned data. +export async function purgeShopData(shopDomain: string) { + await db.$transaction([ + db.booking.deleteMany({ where: { shopDomain } }), + db.location.deleteMany({ where: { shopDomain } }), + db.shop.deleteMany({ where: { shopDomain } }), + db.session.deleteMany({ where: { shop: shopDomain } }), + ]); +} diff --git a/tests/integration/gdpr.test.ts b/tests/integration/gdpr.test.ts new file mode 100644 index 0000000..f93d40f --- /dev/null +++ b/tests/integration/gdpr.test.ts @@ -0,0 +1,93 @@ +import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import db from "../../app/db.server"; +import { compileCustomerData, purgeShopData, redactCustomerData } from "../../app/services/gdpr.server"; + +const shopDomain = "gdpr-integration-test.myshopify.com"; + +async function cleanup() { + await db.booking.deleteMany({ where: { shopDomain } }); + await db.location.deleteMany({ where: { shopDomain } }); + await db.shop.deleteMany({ where: { shopDomain } }); + await db.session.deleteMany({ where: { shop: shopDomain } }); +} + +async function seed() { + await db.shop.create({ data: { shopDomain } }); + const location = await db.location.create({ + data: { shopDomain, name: "Test Location", address: "", timezone: "America/Toronto" }, + }); + const zone = await db.zone.create({ + data: { shopDomain, locationId: location.id, name: "Local", type: "postal", postalCodes: ["M5V"] }, + }); + await db.rate.create({ + data: { shopDomain, method: "LOCAL_DELIVERY", zoneId: zone.id, name: "Standard", priceCents: 500, keyedBy: "zone" }, + }); + await db.slotTemplate.create({ + data: { shopDomain, locationId: location.id, method: "PICKUP", weekday: 1, startMin: 540, endMin: 600, capacity: 5 }, + }); + const booking = await db.booking.create({ + data: { + shopDomain, + orderId: "gid://shopify/Order/gdpr-1", + locationId: location.id, + method: "PICKUP", + slotStart: new Date("2026-08-25T13:00:00.000Z"), + slotEnd: new Date("2026-08-25T13:30:00.000Z"), + customerEmail: "shopper@example.com", + customerPhone: "+15551234567", + }, + }); + return { location, zone, booking }; +} + +describe("GDPR handlers", () => { + beforeEach(cleanup); + afterAll(async () => { + await cleanup(); + await db.$disconnect(); + }); + + it("compileCustomerData finds Bookings matching the customer's email", async () => { + await seed(); + const results = await compileCustomerData(shopDomain, { email: "shopper@example.com" }); + expect(results).toHaveLength(1); + expect(results[0].orderId).toBe("gid://shopify/Order/gdpr-1"); + }); + + it("compileCustomerData finds Bookings matching the customer's phone", async () => { + await seed(); + const results = await compileCustomerData(shopDomain, { phone: "+15551234567" }); + expect(results).toHaveLength(1); + }); + + it("compileCustomerData returns nothing for an unrelated customer", async () => { + await seed(); + const results = await compileCustomerData(shopDomain, { email: "someone-else@example.com" }); + expect(results).toHaveLength(0); + }); + + it("redactCustomerData anonymizes matching Bookings but keeps the row", async () => { + const { booking } = await seed(); + const { count } = await redactCustomerData(shopDomain, { email: "shopper@example.com" }); + expect(count).toBe(1); + + const after = await db.booking.findUnique({ where: { id: booking.id } }); + expect(after).not.toBeNull(); + expect(after?.customerEmail).toBeNull(); + expect(after?.customerPhone).toBeNull(); + expect(after?.orderId).toBe("gid://shopify/Order/gdpr-1"); // booking history preserved + }); + + it("purgeShopData deletes every shopDomain-scoped row, respecting FK order", async () => { + const { location, zone, booking } = await seed(); + + await purgeShopData(shopDomain); + + expect(await db.booking.findUnique({ where: { id: booking.id } })).toBeNull(); + expect(await db.location.findUnique({ where: { id: location.id } })).toBeNull(); + expect(await db.zone.findUnique({ where: { id: zone.id } })).toBeNull(); // cascaded from Location + expect(await db.rate.findMany({ where: { shopDomain } })).toHaveLength(0); // cascaded from Zone + expect(await db.slotTemplate.findMany({ where: { shopDomain } })).toHaveLength(0); // cascaded from Location + expect(await db.shop.findUnique({ where: { shopDomain } })).toBeNull(); + }); +});