Files
drinktracker/prisma/schema.prisma
JP 4fd339dabc Add MCP server so Claude and ChatGPT can read and write drink data
Exposes the collection over the Model Context Protocol at /api/mcp, with 22
tools covering drinks, ratings, bar inventory, recipes, wishlist and taste
preferences, plus search/fetch aliases for ChatGPT's deep-research mode.

Authentication is a bearer token, the app's first header-borne credential -
every other route derives identity from the NextAuth cookie, which a machine
client cannot present. /api/mcp sits under the middleware's /api exclusion so
it can answer a JSON 401 with an RFC 9728 WWW-Authenticate challenge instead
of an HTML redirect to /login.

Tokens are stored as a SHA-256 hash rather than plaintext like Invite.token
and PasswordReset.token. Those are single-use and short-lived; this one is
long-lived and grants read/write over a whole collection, and the nightly
pg_dump keeps 14 days of history. Not encrypt(), which is reversible AES and
right only for outbound keys we must replay; not bcrypt, which cannot be
indexed and would turn verification into a table scan per request.

verifyMcpToken joins User.status on every call, mirroring the jwt callback, so
suspending a member kills their MCP access immediately rather than leaving the
token as a documented way to outlive suspension. It fails closed on a database
error, deliberately unlike the jwt callback, which keeps the session because a
throw there would sign out every user at once.

No tool reaches the Switchboard gateway. Claude and ChatGPT are language
models already, so they can reason over a bar inventory without the app paying
to do it a second time, and a remote client looping a vision call is not a
failure mode worth having. Account deletion, restore, gateway keys, admin
routes and shared-list creation are excluded too.

The OAuth models ship now but are unused; the token endpoint will write the
same McpAccessToken rows, so adding it later touches no verification code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1Ee4Mc1X1SX8HgYa52zu7
2026-08-09 04:29:54 +00:00

581 lines
18 KiB
Plaintext

generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ─── 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
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[]
ratings Rating[]
menuScans MenuScan[]
apiKeys UserApiKey[]
preferences UserPreference?
wishlistItems WishlistItem[]
sharedLists SharedList[]
barItems BarItem[]
recipes Recipe[]
flavorProfile FlavorProfile?
searchCache SearchCache[]
aiCalls AiCall[]
invitesCreated Invite[] @relation("InviteCreator")
redemption InviteRedemption?
passwordResets PasswordReset[]
mcpTokens McpAccessToken[]
mcpAuditLogs McpAuditLog[]
oauthAuthCodes OAuthAuthCode[]
oauthGrants OAuthGrant[]
}
// ─── 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])
}
// ─── MCP access ──────────────────────────────────────────────────
// The app's first header-borne credential. Every other route derives identity
// from the NextAuth JWT cookie, which a machine client (claude.ai, ChatGPT,
// Claude Code) cannot present.
/// Bearer token for the MCP endpoint. Two producers, one table: a token minted
/// by hand in Settings (source "pat") and one issued by the OAuth token endpoint
/// (source "oauth") are the same row shape, so verification has a single path.
///
/// Stored as a SHA-256 hash, unlike Invite.token and PasswordReset.token which
/// are plaintext. Those are single-use and short-lived; this one is long-lived
/// and grants full read/write over a user's whole collection, and the nightly
/// pg_dump keeps 14 days of history.
model McpAccessToken {
id String @id @default(cuid())
userId String
/// sha256 hex of the raw secret - never the secret itself
tokenHash String @unique
/// First 8 chars of the secret, so the UI can tell two tokens apart
prefix String
/// User-supplied label, or the OAuth client name
name String?
scopes String[]
/// "pat" | "oauth"
source String @default("pat")
/// Set only for OAuth-issued tokens; revoking the grant revokes these with it
grantId String?
lastUsedAt DateTime?
expiresAt DateTime?
revokedAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
grant OAuthGrant? @relation(fields: [grantId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([grantId])
@@index([expiresAt])
}
/// One row per MCP tool call. Modelled on AiCall: the arguments are deliberately
/// not recorded - they carry free-text tasting notes and could carry anything.
model McpAuditLog {
id String @id @default(cuid())
userId String
tokenId String?
tool String
ok Boolean
errorCode String?
/// Row the call created, updated or deleted
recordId String?
durationMs Int?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, createdAt])
@@index([createdAt])
}
/// A client registered through RFC 7591 dynamic registration. Not user-owned -
/// registrations are global, and a user's access is revoked by cascading their
/// OAuthGrant instead.
model OAuthClient {
id String @id @default(cuid())
clientId String @unique
clientName String
redirectUris String[]
grantTypes String[] @default(["authorization_code", "refresh_token"])
responseTypes String[] @default(["code"])
/// Public clients only - this server stores no client secrets
tokenEndpointAuthMethod String @default("none")
scope String?
clientUri String?
logoUri String?
softwareId String?
createdAt DateTime @default(now())
lastUsedAt DateTime?
authCodes OAuthAuthCode[]
grants OAuthGrant[]
@@index([createdAt])
}
/// Authorization code, hashed and single-use. Consumed with a guarded updateMany
/// so a replayed code cannot mint a second token.
model OAuthAuthCode {
id String @id @default(cuid())
codeHash String @unique
clientId String
userId String
/// Exactly what was sent to /oauth/authorize - re-checked at the token endpoint
redirectUri String
/// RFC 8707 audience binding
resource String?
scopes String[]
codeChallenge String
codeChallengeMethod String @default("S256")
expiresAt DateTime
consumedAt DateTime?
createdAt DateTime @default(now())
client OAuthClient @relation(fields: [clientId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([expiresAt])
}
/// A user's standing consent for one client. Holds the refresh token, since
/// rotation replaces it while the grant itself persists.
model OAuthGrant {
id String @id @default(cuid())
clientId String
userId String
scopes String[]
refreshHash String? @unique
/// One generation back. A hit here means a rotated token was replayed, which
/// is a theft signal - revoke the whole grant rather than issuing again.
refreshPrevHash String?
refreshExpiresAt DateTime?
revokedAt DateTime?
lastUsedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
client OAuthClient @relation(fields: [clientId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accessTokens McpAccessToken[]
@@unique([clientId, userId])
@@index([userId])
}
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
// Gateway-side id of a key this app minted, so it can be revoked when the
// account is deleted. Null for a key the owner pasted in by hand.
gatewayKeyId String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, provider])
}
/// One row per AI request, written from the gateway's response metadata. Turns
/// "who is spending my money" into a query rather than a journald grep, now that
/// members share the owner's budget.
model AiCall {
id String @id @default(cuid())
userId String
feature String // e.g. "menu.extract"
modelId String? // model the gateway actually routed to
provider String?
costUsd Float?
latencyMs Int?
failover Boolean @default(false)
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, createdAt])
@@index([createdAt])
}
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())
// Denormalised from MenuScan. Ownership was only ever transitive via scanId, which
// held because nothing queries MenuItem directly - but left any future direct query
// an IDOR with nothing to stop it. Backfilled from the parent scan, then made
// required so it cannot silently go missing.
userId String
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])
@@index([userId])
}
// ─── 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())
// 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])
}
// ─── 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)
}