- Prisma extension seals booking name/email/phone/address and waitlist contacts with AES-256-GCM on write and opens them on read (incl. nested reads) - keyed-hash columns for exact email/phone lookups (privacy webhooks, booking search, waitlist de-duplication); migration 0002 - tests prove only ciphertext is stored; privacy/terms/SECURITY.md updated to match Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
45 lines
2.1 KiB
TypeScript
45 lines
2.1 KiB
TypeScript
import { Prisma } from "@prisma/client";
|
|
import type { ActionFunctionArgs } from "react-router";
|
|
import prisma from "../db.server";
|
|
import { authenticate } from "../shopify.server";
|
|
import { blindIndex } from "../services/crypto.server";
|
|
|
|
/**
|
|
* Mandatory privacy webhooks.
|
|
* customers/data_request → we hold booking contact details; logged for the merchant to fulfil.
|
|
* customers/redact → anonymise that customer's bookings and waitlist entries.
|
|
* shop/redact → delete all shop data (48h after uninstall).
|
|
*/
|
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
|
const { shop, topic, payload } = await authenticate.webhook(request);
|
|
const p = payload as { customer?: { email?: string; phone?: string }; orders_to_redact?: number[] };
|
|
const s = await prisma.shop.findUnique({ where: { domain: shop } });
|
|
// Contact details are encrypted at rest; match them by keyed hash.
|
|
const emailHash = blindIndex(p.customer?.email, "email");
|
|
const phoneHash = blindIndex(p.customer?.phone, "phone");
|
|
if (!s) return new Response();
|
|
|
|
if (topic === "CUSTOMERS_DATA_REQUEST") {
|
|
const n = emailHash ? await prisma.booking.count({ where: { shopId: s.id, customerEmailHash: emailHash } }) : 0;
|
|
console.info(`data_request for ${shop}: ${n} bookings hold this customer's contact details`);
|
|
}
|
|
if (topic === "CUSTOMERS_REDACT") {
|
|
const orderIds = (p.orders_to_redact ?? []).map((id) => `gid://shopify/Order/${id}`);
|
|
const where = {
|
|
shopId: s.id,
|
|
OR: [{ orderId: { in: orderIds } }, ...(emailHash ? [{ customerEmailHash: emailHash }] : [])],
|
|
};
|
|
await prisma.booking.updateMany({
|
|
where,
|
|
data: { customerName: null, customerEmail: null, customerPhone: null, address: Prisma.DbNull, customerEmailHash: null, customerPhoneHash: null },
|
|
});
|
|
if (emailHash || phoneHash)
|
|
await prisma.waitlistEntry.deleteMany({ where: { shopId: s.id, OR: [...(emailHash ? [{ emailHash }] : []), ...(phoneHash ? [{ phoneHash }] : [])] } });
|
|
}
|
|
if (topic === "SHOP_REDACT") {
|
|
await prisma.shop.delete({ where: { id: s.id } });
|
|
await prisma.session.deleteMany({ where: { shop } });
|
|
}
|
|
return new Response();
|
|
};
|