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:" — 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) }