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>
328 lines
9.5 KiB
Plaintext
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)
|
|
}
|