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:
JP
2026-08-08 20:35:48 +00:00
parent 181ef13c7e
commit b3a0cc75eb
4 changed files with 258 additions and 35 deletions

View File

@@ -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?