Files
drinktracker/prisma/schema.prisma
JP a0e1619072 Route all AI features through the Switchboard gateway
Replace the direct Anthropic and OpenAI integrations with a single
provider that talks to Switchboard, an OpenAI-compatible gateway that
routes each request to the best available model. The app no longer pins
a model id anywhere: it sends switchboard/auto and lets the gateway
choose, then logs which model answered and what it cost.

Routing levers are set per feature in src/lib/ai/routing.ts. Three of
those choices came from measuring against the live gateway:

- category and prefer_free are set explicitly on every request. An API
  key carries its own routing defaults, and anything left unset inherits
  them - drink prompts were being sent to a free coding model.
- Token budgets are generous because the router may pick a reasoning
  model, and reasoning tokens come out of the same max_tokens budget as
  the answer. At 512 tokens a request returned null content; at 4096 the
  same request returned correct JSON.
- No tier lever on text features. tier "cheap" pinned a slow reasoning
  model (42-180s, two timeouts and one truncated response in five
  trials) and tier "frontier" escalated as far as Opus at $0.02 a call,
  while unconstrained routing answered in about a second. Vision keeps
  "frontier", where the accuracy is worth a few tenths of a cent.

Gateway failures are mapped to actionable messages rather than passed
through: a 401 relayed as 401 would read as an expired session and
bounce the user to login, and a 429 would collide with the app's own
rate limiter.

Also collapses the key lookup that was duplicated across ten call sites
into getUserProvider(), which fixes a latent bug where a bare findFirst
with no ordering let different features pick different providers.

Existing claude/openai key rows are ignored at runtime and offered for
removal in Settings, so no migration is needed before deploying.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 16:41:00 +00:00

328 lines
9.5 KiB
Plaintext

generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ─── Auth.js Models ──────────────────────────────────────────────
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
password String? // hashed password for credentials auth
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
sessions Session[]
drinks Drink[]
ratings Rating[]
menuScans MenuScan[]
apiKeys UserApiKey[]
preferences UserPreference?
wishlistItems WishlistItem[]
sharedLists SharedList[]
barItems BarItem[]
recipes Recipe[]
flavorProfile FlavorProfile?
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
// ─── 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
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, provider])
}
model UserPreference {
id String @id @default(cuid())
userId String @unique
preferredStyles String[] // e.g., ["IPA", "Stout", "Pinot Noir"]
avoidedStyles String[] // e.g., ["Sour", "Light Lager"]
minAbv Float?
maxAbv Float?
defaultProvider String? // deprecated and unused; kept so old backups still restore
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
enum DrinkType {
BEER
WINE
COCKTAIL
SPIRIT
OTHER
}
model Drink {
id String @id @default(cuid())
userId String
name String
type DrinkType
subType String? // e.g., "IPA", "Stout", "Cabernet Sauvignon"
brewery String? // brewery or winery
region String?
abv Float?
description String? @db.Text
imageUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
ratings Rating[]
menuItems MenuItem[]
recipes Recipe[]
@@index([userId])
@@index([userId, type])
@@index([userId, name])
}
model Rating {
id String @id @default(cuid())
userId String
drinkId String
score Int // 1-5
notes String? @db.Text
wouldReorder Boolean @default(false)
location String? // where they tried it
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
drink Drink @relation(fields: [drinkId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([drinkId])
}
enum ScanStatus {
UPLOADING
PROCESSING
COMPLETED
FAILED
}
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
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
items MenuItem[]
@@index([userId])
}
model MenuItem {
id String @id @default(cuid())
scanId String
name String
type DrinkType
subType String?
brewery String?
abv Float?
price String?
description String? @db.Text
matchedDrinkId String?
userRating Int? // cached rating from matched drink
aiRecommended Boolean @default(false)
aiReason String? @db.Text // why AI recommends it
createdAt DateTime @default(now())
scan MenuScan @relation(fields: [scanId], references: [id], onDelete: Cascade)
matchedDrink Drink? @relation(fields: [matchedDrinkId], references: [id], onDelete: SetNull)
@@index([scanId])
}
// ─── Wishlist / Try Later ────────────────────────────────────────
model WishlistItem {
id String @id @default(cuid())
userId String
name String
type DrinkType
subType String?
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"
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
// ─── Search Cache ───────────────────────────────────────────────
model SearchCache {
id String @id @default(cuid())
userId String
queryHash String // normalized (lowercase, trimmed)
query String // original text
results Json // { drinks: [...] }
provider String // "switchboard"
createdAt DateTime @default(now())
@@unique([userId, queryHash, provider])
@@index([userId])
}
// ─── Shared Lists ────────────────────────────────────────────────
model SharedList {
id String @id @default(cuid())
userId String
slug String @unique // public URL slug
title String
description String? @db.Text
listType String @default("collection") // "collection", "wishlist", "custom"
isPublic Boolean @default(true)
drinkIds String[] // ids of drinks to include (empty = all)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([slug])
}
// ─── Bar Inventory ──────────────────────────────────────────────
enum BarItemCategory {
SPIRITS
LIQUEURS
MIXERS
BITTERS
GARNISHES
TOOLS
}
enum BarItemQuantity {
FULL
HALF
LOW
EMPTY
}
model BarItem {
id String @id @default(cuid())
userId String
name String
category BarItemCategory
quantity BarItemQuantity @default(FULL)
notes String? @db.Text
barcode String?
imageUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([userId, category])
@@index([userId, barcode])
}
// ─── Recipes ────────────────────────────────────────────────────
model Recipe {
id String @id @default(cuid())
userId String
title String
ingredients Json // [{ name: string, amount: string, available: boolean }]
steps Json // string[]
garnish String?
glassware String?
sourceDrinkId String?
notes String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
sourceDrink Drink? @relation(fields: [sourceDrinkId], references: [id], onDelete: SetNull)
@@index([userId])
}
// ─── Flavor Profile ─────────────────────────────────────────────
model FlavorProfile {
id String @id @default(cuid())
userId String @unique
profileText String @db.Text
profileData Json?
generatedAt DateTime @default(now())
ratingCount Int @default(0)
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}