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>
81 lines
2.9 KiB
TypeScript
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 } }),
|
|
]);
|
|
}
|