An independent model reviewed the MCP and OAuth surfaces. All five findings were verified against the code before changing anything; none were false positives. audit.md is kept as the record of what was reviewed. F1 (High) - refresh reuse detection forgot the token family. Only the current hash and one predecessor lived on the grant, and each rotation overwrote the predecessor. A thief who rotated a stolen token twice made the victim's original unrecognisable: replaying it returned "unknown token" instead of revoking the family, and the thief kept working. Refresh tokens are now rows in OAuthRefreshToken, one per generation, retained for the life of the family and consumed by a guarded update on usedAt - which also means two concurrent uses of the same token can no longer both succeed. This corrects a claim I made when the OAuth server shipped: reuse detection covered one generation, not the family. F2 (Medium) - issueAccessToken wrote scopes back onto the grant, so redeeming a stale authorization code redefined standing consent. Token issuance is not consent; the consent endpoint is now the only writer. Codes are additionally bound to a grant id and epoch, with a coversScopes check behind that. F3 (Medium) - revocation was reversible. Reconnecting a disconnected app cleared revokedAt and left credentials that had raced the revoke usable again. Every approval now starts a clean epoch: the counter advances and prior access tokens, refresh tokens and unconsumed codes are destroyed. Token writes are conditional on the epoch they validated, so a revoke that wins a race aborts them. Refresh also now requires offline_access to still be granted. F4 (Medium) - loopback redirect matching compared only scheme, host and path, silently accepting a differing query, fragment or userinfo. RFC 9700 2.1 wants exact matching apart from the RFC 8252 port exception; that is what it does now. F5 (Low) - get_collection_stats returned bar and recipe counts under drinks:read. Gated on the caller actually holding bar:read. Verified with regression tests for each: the two-rotation attack now revokes the family, concurrent refresh yields exactly one winner, a pre-narrowing code is refused, disconnect-reconnect leaves old credentials dead, and Claude Code's ephemeral-port callback still works while query/userinfo/fragment variants are rejected. Existing protections re-checked - code replay, PKCE mismatch, deny, confidential-client rejection, and the MCP tools themselves. Note for deploy: OAuthAuthCode gains a required grantId, so existing rows must be cleared first. They are 60-second ephemeral codes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W1Ee4Mc1X1SX8HgYa52zu7
620 lines
20 KiB
Plaintext
620 lines
20 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
|
|
/// The grant, and the epoch of that grant, this code was authorized against.
|
|
/// Without both, a code is bound only to (client, user): one minted under a
|
|
/// broad consent stays redeemable after the user narrows or revokes and
|
|
/// re-grants, carrying the old scopes with it.
|
|
grantId String
|
|
grantEpoch Int @default(0)
|
|
/// 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)
|
|
grant OAuthGrant @relation(fields: [grantId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([userId])
|
|
@@index([grantId])
|
|
@@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
|
|
/// Written ONLY by the consent endpoint. Token issuance must never rewrite
|
|
/// standing consent - doing so let a stale authorization code redefine what
|
|
/// the user had most recently agreed to.
|
|
scopes String[]
|
|
|
|
/// Bumped every time consent is re-granted after a revocation. Codes and
|
|
/// refresh tokens carry the epoch they were issued under, so anything minted
|
|
/// before a revoke is dead even if the grant row is later reactivated.
|
|
epoch Int @default(0)
|
|
|
|
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[]
|
|
authCodes OAuthAuthCode[]
|
|
refreshTokens OAuthRefreshToken[]
|
|
|
|
@@unique([clientId, userId])
|
|
@@index([userId])
|
|
}
|
|
|
|
/// One row per issued refresh token, retained after use.
|
|
///
|
|
/// The previous design kept only the current hash and one predecessor on the
|
|
/// grant, so each rotation erased the generation before last. A thief who
|
|
/// rotated a stolen token twice made the victim's original token unrecognisable:
|
|
/// replaying it returned "unknown token" instead of revoking the family, and the
|
|
/// thief kept working. Keeping every generation is what makes reuse detectable
|
|
/// for the life of the family (RFC 9700 4.14.2).
|
|
///
|
|
/// Consumed with a guarded update on usedAt, so two concurrent uses of the same
|
|
/// token cannot both succeed.
|
|
model OAuthRefreshToken {
|
|
id String @id @default(cuid())
|
|
grantId String
|
|
tokenHash String @unique
|
|
generation Int @default(0)
|
|
/// Set the moment it is redeemed. A second presentation is reuse.
|
|
usedAt DateTime?
|
|
expiresAt DateTime
|
|
createdAt DateTime @default(now())
|
|
|
|
grant OAuthGrant @relation(fields: [grantId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([grantId])
|
|
@@index([expiresAt])
|
|
}
|
|
|
|
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)
|
|
}
|