metatrondelivery/app/services/gdpr.server.ts
metatroncubeswdev e6b8b710c4
Some checks failed
CI / Lint, Unit & Integration Tests (push) Has been cancelled
feat(phase-8): implement GDPR compliance webhook handlers
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>
2026-08-24 09:20:16 -04:00

81 lines
2.9 KiB
TypeScript

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