feat(phase-8): implement GDPR compliance webhook handlers
Some checks failed
CI / Lint, Unit & Integration Tests (push) Has been cancelled

Replace the customers/data_request, customers/redact, and shop/redact
stubs with real logic in app/services/gdpr.server.ts: compile a
customer's Booking history, anonymize customerEmail/customerPhone on
redact, and purge every shopDomain-scoped row on shop uninstall
(Booking before Location, relying on Location's cascade for
SlotTemplate/SlotOverride/BlackoutDate/Zone/Rate). Covered by a new
tests/integration/gdpr.test.ts against live Postgres to verify the
FK deletion order actually works, not just typechecks.

Still commented out in shopify.app.toml pending Protected customer
data access approval in the Partner Dashboard — unrelated to code
readiness.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-08-24 09:20:16 -04:00
parent 5b2207a397
commit e6b8b710c4
6 changed files with 216 additions and 18 deletions

View File

@ -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

View File

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

View File

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

View File

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

View File

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

View File

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