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
581 lines
18 KiB
Plaintext
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)
|
|
}
|