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[] } // ─── 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]) } 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()) 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()) // 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) }