feat(security): encrypt customer personal data at rest

- 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>
This commit is contained in:
metatroncubeswdev 2026-10-14 18:56:05 -04:00
parent 1e19b773cd
commit 1d2d943d54
14 changed files with 398 additions and 90 deletions

40
SECURITY.md Normal file
View File

@ -0,0 +1,40 @@
# Datewise security and incident response policy
Owner: Metatroncube Software Solutions · Contact: info@metatroncubesolutions.com · Last reviewed: 2026-10-14
## Data we hold
Shop settings and scheduling rules; bookings (order ID and number, method, location, date, time, items);
customers' name, email, phone and delivery address for reminders and driver run sheets; waitlist emails
and phone numbers. Merchant Twilio / WhatsApp credentials when connected.
## Data protection
- **In transit:** HTTPS only (TLS at Nginx or Caddy).
- **At rest:** customer name, email, phone and address (bookings and waitlist) and messaging credentials are
encrypted with AES-256-GCM in the application before they reach the database (`app/lib/pii.server.ts`).
Email and phone lookups use HMAC-SHA256 keyed hashes. The key (`APP_ENCRYPTION_KEY`) lives only in the
server's `.env` and the team password manager.
- **Backups:** nightly `pg_dump`, encrypted with AES-256 (`scripts/backup.sh`), kept 14 days.
- **Minimisation:** postal codes typed into the cart are used for zone lookup and not stored with identity.
- **Retention:** customer redaction requests clear that customer's details from bookings and delete their
waitlist entries; all shop data is deleted 48 hours after uninstall (`shop/redact`).
- Test and production are separate: development uses local databases and Shopify development stores.
## Access control
- Production access (VPS, database, Shopify Partner/Dev Dashboard) is limited to named staff who need it.
- SSH uses key-based login; Shopify and Git accounts use strong unique passwords and two-factor authentication.
- Secrets (`.env`: Shopify secret, database password, `APP_ENCRYPTION_KEY`) are never stored in Git or chat.
Rotate them when someone with access leaves (the encryption key needs a re-encryption step; see below).
- Access is reviewed every quarter.
## Incident response
1. **Detect and contain** (immediately): revoke exposed credentials, rotate secrets, block the access path,
take the affected service offline if needed.
2. **Assess** (within 24 h): what data, which shops, how many records, time window. Preserve logs.
3. **Notify** (within 72 h of becoming aware): affected merchants by email, and Shopify via the Partner
support channel; regulators where the law requires it.
4. **Recover**: fix the root cause, restore from encrypted backups if needed, verify.
5. **Review** (within 2 weeks): written post-mortem, follow-up actions, update this policy.
## Key rotation
Changing `APP_ENCRYPTION_KEY` makes existing ciphertext unreadable. To rotate: deploy with both keys,
re-encrypt rows with a one-off script, then remove the old key. Never change the key on its own.

View File

@ -1,5 +1,6 @@
import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import { piiExtension } from "./lib/pii.server";
declare global {
// eslint-disable-next-line no-var
@ -8,11 +9,13 @@ declare global {
/**
* Rust-free Prisma client over node-postgres (no native engine binary needed,
* which also makes shared/VPS hosting simpler).
* which also makes shared/VPS hosting simpler). Every client encrypts customer
* personal data at rest (lib/pii.server.ts). The extension keeps the field types,
* so the client is typed as a plain PrismaClient.
*/
export function createPrisma(url = process.env.DATABASE_URL) {
export function createPrisma(url = process.env.DATABASE_URL): PrismaClient {
if (!url) throw new Error("DATABASE_URL is not set");
return new PrismaClient({ adapter: new PrismaPg({ connectionString: url }) });
return new PrismaClient({ adapter: new PrismaPg({ connectionString: url }) }).$extends(piiExtension) as unknown as PrismaClient;
}
function instance(): PrismaClient {

View File

@ -0,0 +1,91 @@
/**
* Customer personal data is encrypted at rest.
* Crypto unit tests always run; the database tests need TEST_DATABASE_URL (migrations applied).
*/
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import type { PrismaClient } from "@prisma/client";
import { createPrisma } from "../../db.server";
import { blindIndex, seal, unseal } from "../../services/crypto.server";
describe("crypto helpers", () => {
it("seals and unseals, and reads legacy plain text unchanged", () => {
const s = seal("Priya Raman")!;
expect(s.startsWith("enc1:")).toBe(true);
expect(s).not.toContain("Priya");
expect(unseal(s)).toBe("Priya Raman");
expect(unseal("plain old value")).toBe("plain old value");
expect(seal(null)).toBeNull();
expect(seal(s)).toBe(s); // never double-encrypts
});
it("blind indexes normalise and differ by kind", () => {
expect(blindIndex(" Priya@Example.com ", "email")).toBe(blindIndex("priya@example.com", "email"));
expect(blindIndex("+1 (519) 555-0101", "phone")).toBe(blindIndex("+15195550101", "phone"));
expect(blindIndex("x", "email")).not.toBe(blindIndex("x", "phone"));
expect(blindIndex("", "email")).toBeNull();
});
});
const url = process.env.TEST_DATABASE_URL;
const d = url ? describe : describe.skip;
d("PII encryption at rest (Postgres)", () => {
let db: PrismaClient;
let shopId: string;
const base = { method: "delivery", status: "confirmed", timezone: "America/Toronto", configVersion: 1, snapshot: {} };
const pii = {
customerName: "Priya Raman",
customerEmail: "Priya@Example.com",
customerPhone: "+1 519 555 0101",
address: { line1: "12 Guelph St", city: "Kitchener", postal: "N2H 5Z6" },
};
beforeAll(() => {
db = createPrisma(url);
});
afterAll(async () => db.$disconnect());
beforeEach(async () => {
await db.shop.deleteMany({ where: { domain: "pii.myshopify.com" } });
shopId = (await db.shop.create({ data: { domain: "pii.myshopify.com", settings: {} } })).id;
});
it("stores only ciphertext and reads back plain values", async () => {
const b = await db.booking.create({ data: { shopId, orderId: "o1", ...base, ...pii } });
expect(b.customerName).toBe("Priya Raman");
expect(b.address).toEqual(pii.address);
const [raw] = await db.$queryRaw<{ customerName: string; customerEmail: string; customerPhone: string; address: unknown }[]>`
SELECT "customerName", "customerEmail", "customerPhone", "address" FROM "Booking" WHERE id = ${b.id}`;
const dump = JSON.stringify(raw);
for (const secret of ["Priya", "Example.com", "555", "Guelph", "N2H"]) expect(dump).not.toContain(secret);
expect(raw.customerEmail.startsWith("enc1:")).toBe(true);
const again = await db.booking.findUniqueOrThrow({ where: { id: b.id } });
expect([again.customerName, again.customerEmail, again.customerPhone]).toEqual([pii.customerName, pii.customerEmail, pii.customerPhone]);
});
it("decrypts nested reads (reminder job → booking)", async () => {
const b = await db.booking.create({ data: { shopId, orderId: "o2", ...base, ...pii } });
await db.reminderJob.create({ data: { shopId, bookingId: b.id, policyId: "p", channel: "email", sendAt: new Date(), body: "hi" } });
const j = await db.reminderJob.findFirstOrThrow({ where: { bookingId: b.id }, include: { booking: true } });
expect(j.booking.customerEmail).toBe(pii.customerEmail);
});
it("finds by email/phone hash and redacts everything", async () => {
await db.booking.create({ data: { shopId, orderId: "o3", ...base, ...pii } });
const hash = blindIndex("priya@example.com", "email")!;
expect(await db.booking.count({ where: { shopId, customerEmailHash: hash } })).toBe(1);
expect(await db.booking.count({ where: { shopId, customerPhoneHash: blindIndex("+15195550101", "phone")! } })).toBe(1);
await db.booking.updateMany({
where: { shopId, customerEmailHash: hash },
data: { customerName: null, customerEmail: null, customerPhone: null, customerEmailHash: null, customerPhoneHash: null },
});
const r = await db.booking.findFirstOrThrow({ where: { shopId, orderId: "o3" } });
expect([r.customerName, r.customerEmail, r.customerPhone, r.customerEmailHash]).toEqual([null, null, null, null]);
});
it("encrypts waitlist contacts and matches them by hash", async () => {
await db.waitlistEntry.create({ data: { shopId, locationId: "kit", method: "delivery", date: "2026-11-07", slot: "*", email: "a@b.co", name: "Anu" } });
const [raw] = await db.$queryRaw<{ email: string; name: string }[]>`SELECT email, name FROM "WaitlistEntry" WHERE "shopId" = ${shopId}`;
expect(raw.email).not.toContain("a@b.co");
const w = await db.waitlistEntry.findFirstOrThrow({ where: { shopId, emailHash: blindIndex("A@B.co", "email")! } });
expect([w.email, w.name]).toEqual(["a@b.co", "Anu"]);
});
});

View File

@ -1,4 +1,5 @@
import type { Prisma } from "@prisma/client";
import { blindIndex } from "../services/crypto.server";
/** Booking list filters shared by the list page and the CSV export. */
export function bookingWhere(shopId: string, sp: URLSearchParams, today: string): Prisma.BookingWhereInput {
@ -15,6 +16,12 @@ export function bookingWhere(shopId: string, sp: URLSearchParams, today: string)
const to = sp.get("to");
if (from || to) where.date = { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) };
const q = sp.get("q")?.trim();
if (q) where.AND = [{ OR: [{ orderName: { contains: q, mode: "insensitive" } }, { customerName: { contains: q, mode: "insensitive" } }, { customerEmail: { contains: q, mode: "insensitive" } }] }];
// Customer details are encrypted at rest: match order numbers by text, emails and phones exactly (by keyed hash).
if (q) {
const or: Prisma.BookingWhereInput[] = [{ orderName: { contains: q, mode: "insensitive" } }];
if (q.includes("@")) or.push({ customerEmailHash: blindIndex(q, "email") });
else if (/^[\d\s()+.-]{7,}$/.test(q)) or.push({ customerPhoneHash: blindIndex(q, "phone") });
where.AND = [{ OR: or }];
}
return where;
}

101
app/lib/pii.server.ts Normal file
View File

@ -0,0 +1,101 @@
/**
* Encryption at rest for customer personal data, applied to every Prisma client (db.server.ts).
*
* Booking: customerName, customerEmail, customerPhone, address (+ customerEmailHash, customerPhoneHash)
* WaitlistEntry: name, email, phone (+ emailHash, phoneHash)
*
* Writes are sealed with AES-256-GCM in a query extension; reads are opened in a result extension,
* which also covers nested reads (e.g. reminderJob → booking). Code that queries by email or phone
* must use the *Hash columns with blindIndex(), because ciphertext can't be matched.
*/
import { Prisma } from "@prisma/client";
import { blindIndex, seal, unseal } from "../services/crypto.server";
type Data = Record<string, unknown>;
function sealAddress(v: unknown) {
if (v == null || v === Prisma.DbNull || v === Prisma.JsonNull) return v;
if (typeof v === "object" && v && "enc1" in v) return v;
return { enc1: seal(JSON.stringify(v)) };
}
function openAddress(v: unknown) {
if (v && typeof v === "object" && "enc1" in v && typeof (v as { enc1: unknown }).enc1 === "string") {
return JSON.parse(unseal((v as { enc1: string }).enc1) ?? "null");
}
return v ?? null;
}
const str = (v: unknown) => (typeof v === "string" ? v : null);
function sealBooking(d: Data | undefined) {
if (!d) return d;
const out: Data = { ...d };
if ("customerName" in d) out.customerName = seal(str(d.customerName));
if ("customerEmail" in d) {
out.customerEmail = seal(str(d.customerEmail));
out.customerEmailHash = blindIndex(str(d.customerEmail), "email");
}
if ("customerPhone" in d) {
out.customerPhone = seal(str(d.customerPhone));
out.customerPhoneHash = blindIndex(str(d.customerPhone), "phone");
}
if ("address" in d) out.address = sealAddress(d.address);
return out;
}
function sealWaitlist(d: Data | undefined) {
if (!d) return d;
const out: Data = { ...d };
if ("name" in d) out.name = seal(str(d.name));
if ("email" in d) {
out.email = seal(str(d.email));
out.emailHash = blindIndex(str(d.email), "email");
}
if ("phone" in d) {
out.phone = seal(str(d.phone));
out.phoneHash = blindIndex(str(d.phone), "phone");
}
return out;
}
/** Applies a sealer to the data of create / update / upsert / createMany / updateMany. */
function sealArgs(args: Data, sealer: (d: Data | undefined) => Data | undefined) {
const a: Data = { ...args };
if (Array.isArray(a.data)) a.data = (a.data as Data[]).map((x) => sealer(x));
else if (a.data) a.data = sealer(a.data as Data);
if (a.create) a.create = sealer(a.create as Data);
if (a.update) a.update = sealer(a.update as Data);
return a;
}
const WRITES = new Set(["create", "createMany", "createManyAndReturn", "update", "updateMany", "updateManyAndReturn", "upsert"]);
export const piiExtension = Prisma.defineExtension({
name: "datewise-pii",
query: {
booking: {
$allOperations({ operation, args, query }) {
return query(WRITES.has(operation) ? (sealArgs(args as Data, sealBooking) as typeof args) : args);
},
},
waitlistEntry: {
$allOperations({ operation, args, query }) {
return query(WRITES.has(operation) ? (sealArgs(args as Data, sealWaitlist) as typeof args) : args);
},
},
},
result: {
booking: {
customerName: { needs: { customerName: true }, compute: (b) => unseal(b.customerName) },
customerEmail: { needs: { customerEmail: true }, compute: (b) => unseal(b.customerEmail) },
customerPhone: { needs: { customerPhone: true }, compute: (b) => unseal(b.customerPhone) },
address: { needs: { address: true }, compute: (b) => openAddress(b.address) as Prisma.JsonValue },
},
waitlistEntry: {
name: { needs: { name: true }, compute: (w) => unseal(w.name) },
email: { needs: { email: true }, compute: (w) => unseal(w.email) },
phone: { needs: { phone: true }, compute: (w) => unseal(w.phone) },
},
},
});

View File

@ -64,7 +64,7 @@ export default function Bookings() {
</s-select>
<s-date-field label="From" name="from" value={sp.get("from") ?? ""} />
<s-date-field label="To" name="to" value={sp.get("to") ?? ""} />
<s-search-field label="Search" name="q" placeholder="Order, name or email" value={sp.get("q") ?? ""} />
<s-search-field label="Search" name="q" placeholder="Order number, or a full email or phone" value={sp.get("q") ?? ""} />
<s-button type="submit">Apply</s-button>
</s-grid>
</Form>

View File

@ -45,8 +45,10 @@ export default function Privacy() {
<h2>Security</h2>
<p>
Data is encrypted in transit (HTTPS). Messaging credentials you enter are encrypted at rest. Access to our systems is limited
to authorised staff. Our data processing terms are in the <a href="/terms">terms of service</a>.
Data is encrypted in transit (HTTPS). Customers' names, emails, phone numbers and addresses, and the messaging credentials
you enter, are encrypted at rest (AES-256-GCM); lookups use keyed hashes, never the plain values. Database backups are
encrypted. Access to our systems is limited to authorised staff. Our data processing terms are in
the <a href="/terms">terms of service</a>.
</p>
<h2>Retention and deletion</h2>

View File

@ -35,7 +35,7 @@ export default function Terms() {
<ul>
<li>process customer data (name, email, phone, delivery address, order and booking details) only to provide the app's features and on your instructions given through the app;</li>
<li>do not sell or share personal data, and do not use it for advertising, profiling, automated decisions or training AI models;</li>
<li>encrypt data in transit (HTTPS) and encrypt messaging credentials at rest;</li>
<li>encrypt data in transit (HTTPS), encrypt customer contact details and messaging credentials at rest (AES-256-GCM, keyed hashing), and encrypt backups;</li>
<li>limit access to our systems to authorised staff bound by confidentiality;</li>
<li>use these sub-processors: our hosting provider, Resend (email) and Google Maps (delivery distances); Twilio and Meta only when you connect your own accounts. We will tell you before adding others;</li>
<li>notify you without undue delay, and within 72 hours, after becoming aware of a breach affecting your data;</li>

View File

@ -2,6 +2,7 @@ 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.
@ -13,24 +14,27 @@ 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 = await prisma.booking.count({ where: { shopId: s.id, customerEmail: p.customer?.email ?? "__none__" } });
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 } }, ...(p.customer?.email ? [{ customerEmail: p.customer.email }] : [])],
OR: [{ orderId: { in: orderIds } }, ...(emailHash ? [{ customerEmailHash: emailHash }] : [])],
};
await prisma.booking.updateMany({
where,
data: { customerName: null, customerEmail: null, customerPhone: null, address: Prisma.DbNull },
data: { customerName: null, customerEmail: null, customerPhone: null, address: Prisma.DbNull, customerEmailHash: null, customerPhoneHash: null },
});
if (p.customer?.email || p.customer?.phone)
await prisma.waitlistEntry.deleteMany({ where: { shopId: s.id, OR: [{ email: p.customer?.email ?? "__" }, { phone: p.customer?.phone ?? "__" }] } });
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 } });

View File

@ -1,22 +1,53 @@
/** AES-256-GCM for merchant messaging credentials. APP_ENCRYPTION_KEY = 32 random bytes, base64. */
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
/**
* AES-256-GCM encryption at rest (messaging credentials and customer contact details) and keyed
* hashes ("blind indexes") for exact-match lookups on encrypted values.
* APP_ENCRYPTION_KEY = 32 random bytes, base64 (`openssl rand -base64 32`).
*/
import { createCipheriv, createDecipheriv, createHmac, randomBytes } from "node:crypto";
function key(): Buffer {
const k = Buffer.from(process.env.APP_ENCRYPTION_KEY ?? "", "base64");
if (k.length !== 32) throw new Error("APP_ENCRYPTION_KEY must be 32 bytes (base64)");
return k;
let cached: { raw: string; enc: Buffer; idx: Buffer } | null = null;
function keys() {
const raw = process.env.APP_ENCRYPTION_KEY ?? "";
if (cached?.raw === raw) return cached;
const enc = Buffer.from(raw, "base64");
if (enc.length !== 32) throw new Error("APP_ENCRYPTION_KEY must be 32 bytes (base64)");
// A separate key for hashing, derived so one secret covers both uses.
const idx = createHmac("sha256", enc).update("datewise:blind-index:v1").digest();
cached = { raw, enc, idx };
return cached;
}
export function encrypt(plain: string): string {
const iv = randomBytes(12);
const c = createCipheriv("aes-256-gcm", key(), iv);
const c = createCipheriv("aes-256-gcm", keys().enc, iv);
const enc = Buffer.concat([c.update(plain, "utf8"), c.final()]);
return [iv, c.getAuthTag(), enc].map((b) => b.toString("base64url")).join(".");
}
export function decrypt(blob: string): string {
const [iv, tag, enc] = blob.split(".").map((p) => Buffer.from(p, "base64url"));
const d = createDecipheriv("aes-256-gcm", key(), iv);
const d = createDecipheriv("aes-256-gcm", keys().enc, iv);
d.setAuthTag(tag);
return Buffer.concat([d.update(enc), d.final()]).toString("utf8");
}
/** Marks sealed column values so rows written before encryption (plain text) still read correctly. */
const PREFIX = "enc1:";
export function seal(plain: string | null | undefined): string | null | undefined {
if (plain == null || plain === "" || plain.startsWith(PREFIX)) return plain;
return PREFIX + encrypt(plain);
}
export function unseal(value: string | null | undefined): string | null {
if (value == null) return null;
return value.startsWith(PREFIX) ? decrypt(value.slice(PREFIX.length)) : value;
}
/** Keyed hash of a normalised email or phone, for exact-match queries. Never reversible. */
export function blindIndex(value: string | null | undefined, kind: "email" | "phone"): string | null {
if (!value) return null;
const norm = kind === "email" ? value.trim().toLowerCase() : value.replace(/[^\d+]/g, "");
if (!norm) return null;
return createHmac("sha256", keys().idx).update(`${kind}:${norm}`).digest("hex");
}

View File

@ -19,6 +19,7 @@ import { resolveAddress } from "./geocode.server";
import { features } from "./plans";
import { ReservationService } from "./reservations.server";
import { SELECTION_ATTR } from "./settings";
import { blindIndex } from "./crypto.server";
export const reservations = new ReservationService(prisma);
@ -260,7 +261,10 @@ export async function joinWaitlist(domain: string, raw: unknown) {
const cfg = await loadShopConfig(domain);
if (!features(cfg.shop.plan).waitlist) return { ok: false, message: "Waitlist isn't available" };
const dup = await prisma.waitlistEntry.findFirst({
where: { shopId: cfg.shop.id, locationId: w.locationId, method: w.method, date: w.date, OR: [{ email: w.email ?? "__" }, { phone: w.phone ?? "__" }] },
where: {
shopId: cfg.shop.id, locationId: w.locationId, method: w.method, date: w.date,
OR: [{ emailHash: blindIndex(w.email, "email") ?? "__" }, { phoneHash: blindIndex(w.phone, "phone") ?? "__" }],
},
});
if (!dup) await prisma.waitlistEntry.create({ data: { shopId: cfg.shop.id, ...w } });
return { ok: true };

View File

@ -0,0 +1,8 @@
-- Customer contact details are now encrypted at rest by the app (app/lib/pii.server.ts).
-- Keyed hashes allow exact-match lookups (privacy webhooks, booking search) without decrypting.
ALTER TABLE "Booking" ADD COLUMN "customerEmailHash" TEXT;
ALTER TABLE "Booking" ADD COLUMN "customerPhoneHash" TEXT;
CREATE INDEX "Booking_shopId_customerEmailHash_idx" ON "Booking"("shopId", "customerEmailHash");
ALTER TABLE "WaitlistEntry" ADD COLUMN "emailHash" TEXT;
ALTER TABLE "WaitlistEntry" ADD COLUMN "phoneHash" TEXT;

View File

@ -12,47 +12,47 @@ datasource db {
// Required by @shopify/shopify-app-session-storage-prisma
model Session {
id String @id
shop String
state String
isOnline Boolean @default(false)
scope String?
expires DateTime?
accessToken String
userId BigInt?
firstName String?
lastName String?
email String?
accountOwner Boolean @default(false)
locale String?
collaborator Boolean? @default(false)
emailVerified Boolean? @default(false)
refreshToken String?
refreshTokenExpires DateTime?
id String @id
shop String
state String
isOnline Boolean @default(false)
scope String?
expires DateTime?
accessToken String
userId BigInt?
firstName String?
lastName String?
email String?
accountOwner Boolean @default(false)
locale String?
collaborator Boolean? @default(false)
emailVerified Boolean? @default(false)
refreshToken String?
refreshTokenExpires DateTime?
}
model Shop {
id String @id @default(cuid())
domain String @unique
currency String @default("CAD")
timezone String @default("America/Toronto")
id String @id @default(cuid())
domain String @unique
currency String @default("CAD")
timezone String @default("America/Toronto")
/// ShopSettings JSON (holds, reschedule policy, reminders, mixed-cart policy, widget text)
settings Json
settings Json
/// Incremented on every config change; stored on bookings as the rule version used.
configVersion Int @default(1)
plan String @default("free")
feeProductId String?
installedAt DateTime @default(now())
uninstalledAt DateTime?
locations Location[]
rules ProductRule[]
peakDays PeakDay[]
bookings Booking[]
holds Hold[]
counters CapacityCounter[]
waitlist WaitlistEntry[]
reminderJobs ReminderJob[]
events BookingEvent[]
configVersion Int @default(1)
plan String @default("free")
feeProductId String?
installedAt DateTime @default(now())
uninstalledAt DateTime?
locations Location[]
rules ProductRule[]
peakDays PeakDay[]
bookings Booking[]
holds Hold[]
counters CapacityCounter[]
waitlist WaitlistEntry[]
reminderJobs ReminderJob[]
events BookingEvent[]
}
model Location {
@ -147,40 +147,44 @@ model Hold {
}
model Booking {
id String @id @default(cuid())
shopId String
shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade)
orderId String?
orderName String?
status String // confirmed | needs_review | cancelled | fulfilled
reviewReason String?
method String
locationId String?
date String? // local YYYY-MM-DD
slotStart String? // local HH:MM
slotEnd String?
timezone String
startUtc DateTime?
endUtc DateTime?
customerName String?
customerEmail String?
customerPhone String?
address Json?
items Json?
fee Int @default(0)
peakDayId String?
rescheduleCount Int @default(0)
configVersion Int
id String @id @default(cuid())
shopId String
shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade)
orderId String?
orderName String?
status String // confirmed | needs_review | cancelled | fulfilled
reviewReason String?
method String
locationId String?
date String? // local YYYY-MM-DD
slotStart String? // local HH:MM
slotEnd String?
timezone String
startUtc DateTime?
endUtc DateTime?
/// Encrypted at rest (app/lib/pii.server.ts). Query email/phone through the *Hash columns.
customerName String?
customerEmail String?
customerPhone String?
address Json?
customerEmailHash String?
customerPhoneHash String?
items Json?
fee Int @default(0)
peakDayId String?
rescheduleCount Int @default(0)
configVersion Int
/// Snapshot of what the customer agreed to (method, location, slot, fee, rules matched)
snapshot Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
events BookingEvent[]
reminderJobs ReminderJob[]
snapshot Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
events BookingEvent[]
reminderJobs ReminderJob[]
@@unique([shopId, orderId])
@@index([shopId, date])
@@index([shopId, status])
@@index([shopId, customerEmailHash])
}
model BookingEvent {
@ -189,8 +193,8 @@ model BookingEvent {
shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade)
bookingId String
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
actor String // system | merchant:<staff id> | customer
action String // confirmed | flagged | rescheduled | cancelled | resolved
actor String // system | merchant:<staff id> | customer
action String // confirmed | flagged | rescheduled | cancelled | resolved
before Json?
after Json?
at DateTime @default(now())
@ -205,10 +209,13 @@ model WaitlistEntry {
locationId String
method String
date String
slot String // "*" = any slot that day
slot String // "*" = any slot that day
/// Encrypted at rest (app/lib/pii.server.ts). Query through emailHash / phoneHash.
email String?
phone String?
name String?
emailHash String?
phoneHash String?
createdAt DateTime @default(now())
offeredAt DateTime?
/// Hold token reserved for this person when a place is offered.

10
vitest.config.ts Normal file
View File

@ -0,0 +1,10 @@
import tsconfigPaths from "vite-tsconfig-paths";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
// Test-only key (the bytes spell "datewise-test-key-not-a-secret!!"). Production reads APP_ENCRYPTION_KEY from .env.
env: { APP_ENCRYPTION_KEY: "ZGF0ZXdpc2UtdGVzdC1rZXktbm90LWEtc2VjcmV0ISE=" },
},
});