feat(phase-8): implement GDPR compliance webhook handlers
Some checks failed
CI / Lint, Unit & Integration Tests (push) Has been cancelled
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:
parent
5b2207a397
commit
e6b8b710c4
22
README.md
22
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
|
||||
|
||||
|
||||
@ -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();
|
||||
};
|
||||
|
||||
@ -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();
|
||||
};
|
||||
|
||||
@ -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();
|
||||
};
|
||||
|
||||
80
app/services/gdpr.server.ts
Normal file
80
app/services/gdpr.server.ts
Normal 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 } }),
|
||||
]);
|
||||
}
|
||||
93
tests/integration/gdpr.test.ts
Normal file
93
tests/integration/gdpr.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user