datewise/prisma/schema.prisma
metatroncubeswdev c102594ca5 feat(security): log staff access to customers' personal data
DataAccessLog records bookings list/view/export and run-sheet views (actor, action,
target, time); pruned after 12 months by the worker; migration 0003.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:18:44 -04:00

267 lines
8.4 KiB
Plaintext

// Datewise data model. Postgres is required: reservations rely on row locks.
generator client {
provider = "prisma-client-js"
previewFeatures = ["queryCompiler", "driverAdapters"]
engineType = "client"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// 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?
}
model Shop {
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
/// 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[]
accessLogs DataAccessLog[]
}
model Location {
id String @id @default(cuid())
shopId String
shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade)
shopifyLocationId String?
name String
address String
timezone String
lat Float?
lng Float?
enabled Boolean @default(true)
priority Int @default(1)
pickupInstructions String?
/// Partial<Record<Method, MethodConfig>>
methods Json
/// DateException[]
exceptions Json @default("[]")
/// DeliveryZone[]
zones Json @default("[]")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([shopId])
}
model ProductRule {
id String @id @default(cuid())
shopId String
shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade)
name String
enabled Boolean @default(true)
priority Int @default(10)
target Json
action Json
message String?
updatedAt DateTime @updatedAt
@@index([shopId])
}
model PeakDay {
id String @id @default(cuid())
shopId String
shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade)
name String
dates String[]
locationIds String[]
methods String[]
dailyCapacity Int?
slotCapacity Int?
extraPrepMinutes Int @default(0)
cutoffDaysBefore Int?
cutoffTime String?
surcharge Int @default(0)
waitlist Boolean @default(false)
message String?
@@index([shopId])
}
/// One row per (location, method, date, slot). slot = "*" is the day total.
/// Counts confirmed bookings + live holds. Mutated only inside row-locked transactions.
model CapacityCounter {
shopId String
shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade)
locationId String
method String
date String
slot String
count Int @default(0)
@@id([shopId, locationId, method, date, slot])
}
model Hold {
token String @id
shopId String
shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade)
locationId String
method String
date String
slot String
expiresAt DateTime
/// Fee quoted to the customer for this selection (minor units); re-checked against the paid order.
fee Int @default(0)
createdAt DateTime @default(now())
@@index([shopId, locationId, method, date])
@@index([expiresAt])
}
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?
/// 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[]
@@unique([shopId, orderId])
@@index([shopId, date])
@@index([shopId, status])
@@index([shopId, customerEmailHash])
}
model BookingEvent {
id String @id @default(cuid())
shopId String
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
before Json?
after Json?
at DateTime @default(now())
@@index([bookingId])
}
model WaitlistEntry {
id String @id @default(cuid())
shopId String
shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade)
locationId String
method String
date String
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.
offerToken String?
@@index([shopId, locationId, method, date])
}
model ReminderJob {
id String @id @default(cuid())
shopId String
shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade)
bookingId String
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
policyId String
channel String
sendAt DateTime
body String
status String @default("pending") // pending | sent | failed | cancelled
attempts Int @default(0)
lastError String?
sentAt DateTime?
@@index([status, sendAt])
}
/// Who looked at customers' personal data, and when (staff views/exports, customer self-service).
/// Kept 12 months (worker), deleted with the shop on shop/redact.
model DataAccessLog {
id String @id @default(cuid())
shopId String
shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade)
actor String // merchant:<staff email> | customer:<customer id> | system
action String // bookings.list | booking.view | bookings.export | runsheet.view | customer.booking.view
target String?
at DateTime @default(now())
@@index([shopId, at])
}
/// Webhook idempotency: Shopify may deliver the same event more than once.
model ProcessedWebhook {
id String @id // X-Shopify-Event-Id (falls back to webhook id)
topic String
shop String
receivedAt DateTime @default(now())
}