Add roles and invite models
Additive schema only; no behaviour changes yet. Verified with prisma migrate diff against production: two enums, three new tables, new User columns and foreign keys, and no destructive statements. Role lives on the User row rather than an OWNER_EMAIL env check, because email is nullable and user-editable and so a poor thing to authorize against. The jwt callback will need a per-request lookup for status anyway, so reading role in the same query is free. Invites are bearer tokens in a URL, shared out of band as a link or QR code, because the app has no email capability. claimInvite consumes a use with a single UPDATE guarded on usedCount < maxUses: Prisma cannot compare two columns in a where clause, and one statement means one row lock, so two people redeeming the last use cannot both succeed. SearchCache finally gets its user relation. Every other user-owned model cascades; without it, deleting a user left orphaned rows holding their raw search queries. Verified zero orphans before adding the constraint. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,16 +9,37 @@ datasource db {
|
||||
|
||||
// ─── Auth.js Models ──────────────────────────────────────────────
|
||||
|
||||
enum UserRole {
|
||||
OWNER
|
||||
MEMBER
|
||||
}
|
||||
|
||||
enum UserStatus {
|
||||
ACTIVE
|
||||
SUSPENDED
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
name String?
|
||||
email String? @unique
|
||||
emailVerified DateTime?
|
||||
image String?
|
||||
password String? // hashed password for credentials auth
|
||||
password String? // hashed password for credentials auth
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Authorization is on this column, not on an OWNER_EMAIL env check: email is
|
||||
// nullable and user-editable, so it is a poor thing to authorize against.
|
||||
role UserRole @default(MEMBER)
|
||||
status UserStatus @default(ACTIVE)
|
||||
invitedById String?
|
||||
|
||||
// Per-member AI controls. aiDailyBudgetUsd null means "use the deployment default"
|
||||
// when minting that member's gateway key.
|
||||
aiEnabled Boolean @default(true)
|
||||
aiDailyBudgetUsd Float?
|
||||
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
drinks Drink[]
|
||||
@@ -31,6 +52,62 @@ model User {
|
||||
barItems BarItem[]
|
||||
recipes Recipe[]
|
||||
flavorProfile FlavorProfile?
|
||||
searchCache SearchCache[]
|
||||
|
||||
invitesCreated Invite[] @relation("InviteCreator")
|
||||
redemption InviteRedemption?
|
||||
passwordResets PasswordReset[]
|
||||
}
|
||||
|
||||
// ─── Invites ─────────────────────────────────────────────────────
|
||||
// Signup is invite-only. There is no email capability in this app, so an invite is
|
||||
// a bearer token in a URL that the owner shares out of band (link or QR code).
|
||||
|
||||
model Invite {
|
||||
id String @id @default(cuid())
|
||||
token String @unique // randomBytes(16).toString("hex")
|
||||
label String? // free text so the owner remembers who it was for
|
||||
createdById String
|
||||
maxUses Int @default(1)
|
||||
usedCount Int @default(0)
|
||||
expiresAt DateTime?
|
||||
revokedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
createdBy User @relation("InviteCreator", fields: [createdById], references: [id], onDelete: Cascade)
|
||||
redemptions InviteRedemption[]
|
||||
|
||||
@@index([createdById])
|
||||
}
|
||||
|
||||
model InviteRedemption {
|
||||
id String @id @default(cuid())
|
||||
inviteId String
|
||||
userId String @unique // a user is created by at most one invite
|
||||
email String?
|
||||
provider String // "credentials" | "oauth"
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
invite Invite @relation(fields: [inviteId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([inviteId])
|
||||
}
|
||||
|
||||
// Owner-generated, single-use password reset. Delivered out of band like an invite,
|
||||
// because there is no email infrastructure to send one.
|
||||
model PasswordReset {
|
||||
id String @id @default(cuid())
|
||||
token String @unique
|
||||
userId String
|
||||
expiresAt DateTime
|
||||
usedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model Account {
|
||||
@@ -72,15 +149,15 @@ model VerificationToken {
|
||||
// ─── App Models ──────────────────────────────────────────────────
|
||||
|
||||
model UserApiKey {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
provider String // "switchboard" (legacy rows may be "claude" | "openai")
|
||||
encryptedKey String @db.Text
|
||||
iv String // initialization vector for decryption
|
||||
label String? // optional user-friendly label
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
provider String // "switchboard" (legacy rows may be "claude" | "openai")
|
||||
encryptedKey String @db.Text
|
||||
iv String // initialization vector for decryption
|
||||
label String? // optional user-friendly label
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -94,7 +171,7 @@ model UserPreference {
|
||||
avoidedStyles String[] // e.g., ["Sour", "Light Lager"]
|
||||
minAbv Float?
|
||||
maxAbv Float?
|
||||
defaultProvider String? // deprecated and unused; kept so old backups still restore
|
||||
defaultProvider String? // deprecated and unused; kept so old backups still restore
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -114,8 +191,8 @@ model Drink {
|
||||
userId String
|
||||
name String
|
||||
type DrinkType
|
||||
subType String? // e.g., "IPA", "Stout", "Cabernet Sauvignon"
|
||||
brewery String? // brewery or winery
|
||||
subType String? // e.g., "IPA", "Stout", "Cabernet Sauvignon"
|
||||
brewery String? // brewery or winery
|
||||
region String?
|
||||
abv Float?
|
||||
description String? @db.Text
|
||||
@@ -137,10 +214,10 @@ model Rating {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
drinkId String
|
||||
score Int // 1-5
|
||||
score Int // 1-5
|
||||
notes String? @db.Text
|
||||
wouldReorder Boolean @default(false)
|
||||
location String? // where they tried it
|
||||
location String? // where they tried it
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -159,16 +236,16 @@ enum ScanStatus {
|
||||
}
|
||||
|
||||
model MenuScan {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
imageUrl String
|
||||
status ScanStatus @default(UPLOADING)
|
||||
aiProvider String? // "switchboard:<model_id>" — the model the gateway routed to
|
||||
aiRawResponse Json? // raw AI response for debugging
|
||||
errorMessage String? @db.Text
|
||||
processedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
imageUrl String
|
||||
status ScanStatus @default(UPLOADING)
|
||||
aiProvider String? // "switchboard:<model_id>" — the model the gateway routed to
|
||||
aiRawResponse Json? // raw AI response for debugging
|
||||
errorMessage String? @db.Text
|
||||
processedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
items MenuItem[]
|
||||
@@ -187,7 +264,7 @@ model MenuItem {
|
||||
price String?
|
||||
description String? @db.Text
|
||||
matchedDrinkId String?
|
||||
userRating Int? // cached rating from matched drink
|
||||
userRating Int? // cached rating from matched drink
|
||||
aiRecommended Boolean @default(false)
|
||||
aiReason String? @db.Text // why AI recommends it
|
||||
createdAt DateTime @default(now())
|
||||
@@ -209,8 +286,8 @@ model WishlistItem {
|
||||
brewery String?
|
||||
abv Float?
|
||||
description String? @db.Text
|
||||
notes String? @db.Text // user's personal note ("saw at Joe's bar")
|
||||
source String? // "scan", "ai_search", "manual"
|
||||
notes String? @db.Text // user's personal note ("saw at Joe's bar")
|
||||
source String? // "scan", "ai_search", "manual"
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -224,12 +301,16 @@ model WishlistItem {
|
||||
model SearchCache {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
queryHash String // normalized (lowercase, trimmed)
|
||||
query String // original text
|
||||
results Json // { drinks: [...] }
|
||||
provider String // "switchboard"
|
||||
queryHash String // normalized (lowercase, trimmed)
|
||||
query String // original text
|
||||
results Json // { drinks: [...] }
|
||||
provider String // "switchboard"
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
// Every other user-owned model cascades; without this, deleting a user left
|
||||
// orphaned rows holding their raw search queries and AI results.
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, queryHash, provider])
|
||||
@@index([userId])
|
||||
}
|
||||
@@ -239,7 +320,7 @@ model SearchCache {
|
||||
model SharedList {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
slug String @unique // public URL slug
|
||||
slug String @unique // public URL slug
|
||||
title String
|
||||
description String? @db.Text
|
||||
listType String @default("collection") // "collection", "wishlist", "custom"
|
||||
@@ -297,8 +378,8 @@ model Recipe {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
title String
|
||||
ingredients Json // [{ name: string, amount: string, available: boolean }]
|
||||
steps Json // string[]
|
||||
ingredients Json // [{ name: string, amount: string, available: boolean }]
|
||||
steps Json // string[]
|
||||
garnish String?
|
||||
glassware String?
|
||||
sourceDrinkId String?
|
||||
|
||||
Reference in New Issue
Block a user