Files
OpenFrame/prisma/schema.prisma
T
Yusuf İpek 7aeda83eb6 feat(billing): cancel in-app with a one-question reason
Add a "Cancel subscription" button beside "Manage Subscription" in Settings.
It opens a dialog with one optional question (five answers, no default, a
note box under the two that want detail), then schedules the Stripe
subscription to end at the close of the current period without a trip to
the portal. The answer is stored in a new subscription_cancellations table
and shown, with an all-time tally, on the admin dashboard; the category is
also mirrored onto Stripe's cancellation feedback, the free text stays local.

The cancel route claims the local cancel flag with a conditional update
before calling Stripe, so two racing requests cannot both write a reason
row, and hands the claim back when Stripe refuses. A subscription Stripe no
longer knows answers 409 with a pointer to the portal instead of a 500. The
route carries an account-keyed rate limit on top of the shared IP one.

Two fixes found on the way: the pinned Stripe API version reports
current_period_end on the subscription item rather than the subscription, so
the sync stored null for every period end; a shared helper now reads the item
first. And the RadioGroup styles targeted a data-checked attribute radix
never writes, so the checked state was invisible in the light theme.
2026-09-08 14:05:16 +03:00

952 lines
29 KiB
Plaintext

// Prisma Schema for OpenFrame - Video Feedback Platform
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
// ============================================
// AUTH MODELS (NextAuth.js compatible)
// ============================================
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
password String? // Hashed password for email/password auth
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
onboardingCompletedAt DateTime?
trialEndsAt DateTime?
billingTrialConsumedAt DateTime?
stripeCustomerId String? @unique
stripeSubscriptionId String? @unique
stripePriceId String?
stripeCurrentPeriodEnd DateTime?
stripeCancelAtPeriodEnd Boolean @default(false)
stripeCancelAt DateTime?
billingAccessEndedAt DateTime?
subscriptionStatus BillingSubscriptionStatus @default(FREE)
// Relations
accounts Account[]
sessions Session[]
ownedWorkspaces Workspace[]
workspaceMemberships WorkspaceMember[]
projects Project[]
comments Comment[]
uploadedVideoAssets VideoAsset[] @relation("VideoAssetUploadedBy")
billedVideoAssets VideoAsset[] @relation("VideoAssetBilledTo")
uploadedVideoSubtitles VideoSubtitle[] @relation("VideoSubtitleUploadedBy")
billedVideoSubtitles VideoSubtitle[] @relation("VideoSubtitleBilledTo")
projectMemberships ProjectMember[]
notificationSetting NotificationSetting?
watchProgress WatchProgress[]
feedbackEntries UserFeedback[]
requestedApprovalRequests ApprovalRequest[] @relation("ApprovalRequestsRequestedBy")
canceledApprovalRequests ApprovalRequest[] @relation("ApprovalRequestsCanceledBy")
approvalDecisions ApprovalDecision[]
sentInvitations Invitation[] @relation("InvitationsSentBy")
acquisition UserAcquisition?
analyticsEvents AnalyticsEvent[]
subscriptionCancellations SubscriptionCancellation[]
@@map("users")
}
enum BillingSubscriptionStatus {
FREE
TRIALING
ACTIVE
PAST_DUE
CANCELED
UNPAID
INCOMPLETE
INCOMPLETE_EXPIRED
}
enum FeedbackEntryType {
FEEDBACK
REVIEW
}
enum FeedbackCategory {
BUG
FEATURE
OTHER
}
enum FeedbackStatus {
NEW
IN_REVIEW
APPROVED
REJECTED
RESOLVED
}
enum DownloadEgressSource {
ORIGINAL
COMPRESSED
}
enum VideoAssetKind {
IMAGE
VIDEO
AUDIO
}
enum VideoAssetProvider {
R2_IMAGE
YOUTUBE
BUNNY
R2_AUDIO
R2_VIDEO
}
model DownloadEgressEvent {
id String @id @default(cuid())
versionId String
videoId String
projectId String
workspaceId String
billedUserId String
downloaderUserId String?
source DownloadEgressSource
quality Int?
estimatedBytes BigInt @default(0)
createdAt DateTime @default(now())
@@index([billedUserId, createdAt])
@@index([workspaceId, createdAt])
@@index([versionId, createdAt])
@@map("download_egress_events")
}
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])
@@map("accounts")
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("sessions")
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
@@map("verification_tokens")
}
// ============================================
// APPLICATION MODELS
// ============================================
// ---- Workspace ----
model Workspace {
id String @id @default(cuid())
name String
slug String @unique
description String? @db.Text
// Ownership
ownerId String
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
members WorkspaceMember[]
projects Project[]
invitations Invitation[]
@@index([ownerId])
@@index([slug])
@@map("workspaces")
}
model WorkspaceMember {
id String @id @default(cuid())
role WorkspaceMemberRole @default(COMMENTATOR)
workspaceId String
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([workspaceId, userId])
@@index([userId])
@@map("workspace_members")
}
enum WorkspaceMemberRole {
ADMIN // Full access: manage members, delete projects, etc.
COMMENTATOR // Can view all projects and comment only
}
// ---- Project ----
model Project {
id String @id @default(cuid())
name String
description String? @db.Text
slug String @unique // URL-friendly identifier
// Visibility
visibility ProjectVisibility @default(PRIVATE)
// Whether non-admin viewers may download project media
allowDownloads Boolean @default(false)
// Ownership
ownerId String
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
// Workspace (required - every project belongs to a workspace)
workspaceId String
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
videos Video[]
members ProjectMember[]
shareLinks ShareLink[]
commentTags CommentTag[]
invitations Invitation[]
@@index([ownerId])
@@index([slug])
@@index([workspaceId])
@@index([workspaceId, updatedAt(sort: Desc)])
@@map("projects")
}
enum ProjectVisibility {
PRIVATE // Only owner can access
INVITE // Owner + specifically invited members
PUBLIC // Anyone with the link can access
}
model ProjectMember {
id String @id @default(cuid())
role ProjectMemberRole @default(COMMENTATOR)
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([projectId, userId])
@@index([userId])
@@map("project_members")
}
enum ProjectMemberRole {
ADMIN // Can manage members, settings, delete videos
COMMENTATOR // Can view and comment only
}
enum InvitationScope {
WORKSPACE
PROJECT
}
enum InvitationRole {
ADMIN
COMMENTATOR
}
enum InvitationStatus {
PENDING
ACCEPTED
CANCELED
EXPIRED
}
model Invitation {
id String @id @default(cuid())
token String @unique
email String
scope InvitationScope
role InvitationRole
status InvitationStatus @default(PENDING)
workspaceId String?
workspace Workspace? @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
projectId String?
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
invitedById String
invitedBy User @relation("InvitationsSentBy", fields: [invitedById], references: [id], onDelete: Cascade)
acceptedAt DateTime?
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([email, status, expiresAt])
@@index([workspaceId, status, createdAt(sort: Desc)])
@@index([projectId, status, createdAt(sort: Desc)])
@@map("invitations")
}
model Video {
id String @id @default(cuid())
title String
description String? @db.Text
// Ordering within project
position Int @default(0)
// Project relation
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
versions VideoVersion[]
assets VideoAsset[]
shareLinks ShareLink[]
@@index([projectId])
@@map("videos")
}
model VideoVersion {
id String @id @default(cuid())
versionNumber Int // 1, 2, 3...
versionLabel String? // Optional label like "Final Cut", "Review Draft"
// Video source (future-proof for multiple providers)
providerId String // 'youtube', 'vimeo', 'direct'
videoId String // Provider-specific video ID
originalUrl String // Original URL submitted by user
// Cached metadata
title String?
thumbnailUrl String?
duration Int? // Duration in seconds
sizeBytes BigInt @default(0) @map("size_bytes") // R2-hosted video file size
// Status
isActive Boolean @default(true) // Currently displayed version
// Parent video
videoParentId String
video Video @relation(fields: [videoParentId], references: [id], onDelete: Cascade)
// Timestamps
createdAt DateTime @default(now())
// Relations
comments Comment[]
watchProgress WatchProgress[]
approvalRequests ApprovalRequest[]
subtitles VideoSubtitle[]
@@unique([videoParentId, versionNumber])
@@index([videoParentId])
@@index([videoParentId, isActive])
@@map("video_versions")
}
model VideoAsset {
id String @id @default(cuid())
videoId String
video Video @relation(fields: [videoId], references: [id], onDelete: Cascade)
kind VideoAssetKind
provider VideoAssetProvider
displayName String
sourceUrl String
providerVideoId String?
thumbnailUrl String?
uploadedByUserId String?
uploadedByUser User? @relation("VideoAssetUploadedBy", fields: [uploadedByUserId], references: [id], onDelete: SetNull)
uploadedByGuestIdentityId String?
uploadedByGuestName String?
billedUserId String
billedUser User @relation("VideoAssetBilledTo", fields: [billedUserId], references: [id], onDelete: Cascade)
sizeBytes BigInt @default(0) @map("size_bytes")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([videoId])
@@index([billedUserId])
@@index([provider])
@@index([providerVideoId])
@@index([videoId, createdAt(sort: Desc)])
@@map("video_assets")
}
/// A subtitle track for one cut. Timings belong to a version rather than to the
/// video: re-editing shifts every cue, so a track attached to the parent would be
/// wrong for every version but the one it was written against.
model VideoSubtitle {
id String @id @default(cuid())
versionId String
version VideoVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
/// BCP-47 tag, lowercased primary subtag, e.g. `tr`, `en-US`.
language String
label String
/// Always an /api/upload/subtitle/<uuid>.vtt path. The file itself lives in
/// S3-compatible storage whatever the video's own provider is, so a Bunny-hosted
/// video and an R2-hosted one take the same path through the player.
sourceUrl String @unique
sizeBytes BigInt @default(0) @map("size_bytes")
billedUserId String
billedUser User @relation("VideoSubtitleBilledTo", fields: [billedUserId], references: [id], onDelete: Cascade)
uploadedByUserId String?
uploadedByUser User? @relation("VideoSubtitleUploadedBy", fields: [uploadedByUserId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([versionId, language])
@@index([versionId])
@@index([billedUserId])
@@map("video_subtitles")
}
model Comment {
id String @id @default(cuid())
// Comment content
content String? @db.Text // Text content (null if voice-only)
// Timestamp in video (in seconds, with decimal for precision)
timestamp Float // e.g., 65.5 = 1:05.5
timestampEnd Float? // Optional end timestamp for range comments
// Voice recording (optional)
voiceUrl String? // URL to voice recording file
voiceDuration Float? // Duration of voice recording in seconds
// Image attachment (optional). `imageUrl` is the first image and stays for
// backwards compatibility; `images` is the full, ordered list.
imageUrl String? // URL to uploaded image file
images CommentImage[]
// Annotation drawing data (JSON string of strokes)
annotationData String? @db.Text
// Threading
parentId String?
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: Cascade)
replies Comment[] @relation("CommentReplies")
// Status
isResolved Boolean @default(false)
resolvedAt DateTime?
// Author (optional for guest comments)
authorId String?
author User? @relation(fields: [authorId], references: [id], onDelete: SetNull)
// Guest author info (when authorId is null)
guestName String?
guestEmail String?
guestIdentityId String?
// Video version relation
versionId String
version VideoVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
// Comment tag (colored category)
tagId String?
tag CommentTag? @relation(fields: [tagId], references: [id], onDelete: SetNull)
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([versionId])
@@index([parentId])
@@index([authorId])
@@index([guestIdentityId])
@@index([timestamp])
@@index([tagId])
@@unique([imageUrl])
@@unique([voiceUrl])
@@index([versionId, isResolved, timestamp])
@@index([versionId, parentId, createdAt])
@@map("comments")
}
model CommentImage {
id String @id @default(cuid())
url String @unique
position Int @default(0)
commentId String
comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@index([commentId, position])
@@map("comment_images")
}
model CommentTag {
id String @id @default(cuid())
name String // e.g., "Feedback", "Technical", "Urgent"
color String // Hex color, e.g., "#3B82F6"
// Project relation (tags are per-project)
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
// Position for ordering in UI
position Int @default(0)
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
comments Comment[]
@@unique([projectId, name])
@@index([projectId])
@@map("comment_tags")
}
model ShareLink {
id String @id @default(cuid())
token String @unique // Random token for URL
// What is being shared
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
videoId String?
video Video? @relation(fields: [videoId], references: [id], onDelete: Cascade)
// Permissions
permission SharePermission @default(VIEW)
// Optional restrictions
expiresAt DateTime? // Link expiration
passwordHash String? // Bcrypt hash of optional password protection
// Settings
allowGuests Boolean @default(true) // Allow comments without account
allowDownloads Boolean @default(false) // Allow downloading video via share link
// Timestamps
createdAt DateTime @default(now())
@@index([projectId])
@@index([videoId])
@@index([projectId, videoId])
@@unique([projectId, videoId, permission])
@@index([token])
@@index([token, expiresAt])
@@map("share_links")
}
model UserFeedback {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
type FeedbackEntryType
category FeedbackCategory?
title String
message String @db.Text
screenshotUrl String?
screenshots UserFeedbackScreenshot[]
rating Int?
status FeedbackStatus @default(NEW)
allowShowcase Boolean @default(false)
showOnLanding Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId, createdAt(sort: Desc)])
@@index([type, createdAt(sort: Desc)])
@@index([status, createdAt(sort: Desc)])
@@index([showOnLanding, createdAt(sort: Desc)])
@@map("user_feedback")
}
enum CancellationReason {
NOT_USING
MISSING_FEATURE
PRICE_OR_BILLING
PROJECT_ENDED
OTHER
}
// One row per in-app cancellation, written the moment the customer asks for
// it, not when Stripe later confirms it. The reason is the whole point of the
// row and it is optional on purpose: the question can be skipped, and a skipped
// answer still counts as a cancellation whose reason is unknown rather than a
// cancellation that never happened. Cancellations made in the Stripe portal
// never produce a row here; the funnel event still records those.
model SubscriptionCancellation {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
stripeSubscriptionId String
reason CancellationReason?
note String? @db.VarChar(500)
// When access was due to end at the time of the request.
periodEnd DateTime?
createdAt DateTime @default(now())
@@index([userId, createdAt(sort: Desc)])
@@index([createdAt(sort: Desc)])
@@map("subscription_cancellations")
}
model UserFeedbackScreenshot {
id String @id @default(cuid())
feedbackId String
feedback UserFeedback @relation(fields: [feedbackId], references: [id], onDelete: Cascade)
url String
createdAt DateTime @default(now())
@@index([feedbackId, createdAt(sort: Desc)])
@@map("user_feedback_screenshots")
}
enum SharePermission {
VIEW // Can only view
COMMENT // Can view and comment
}
enum ApprovalRequestStatus {
PENDING
APPROVED
REJECTED
CANCELED
}
enum ApprovalDecisionStatus {
PENDING
APPROVED
REJECTED
}
model ApprovalRequest {
id String @id @default(cuid())
versionId String
version VideoVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
requestedById String
requestedBy User @relation("ApprovalRequestsRequestedBy", fields: [requestedById], references: [id], onDelete: Cascade)
message String? @db.Text
status ApprovalRequestStatus @default(PENDING)
resolvedAt DateTime?
canceledAt DateTime?
canceledById String?
canceledBy User? @relation("ApprovalRequestsCanceledBy", fields: [canceledById], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
decisions ApprovalDecision[]
@@index([versionId, status, createdAt(sort: Desc)])
@@index([requestedById, createdAt(sort: Desc)])
@@map("approval_requests")
}
model ApprovalDecision {
id String @id @default(cuid())
requestId String
request ApprovalRequest @relation(fields: [requestId], references: [id], onDelete: Cascade)
approverId String
approver User @relation(fields: [approverId], references: [id], onDelete: Cascade)
status ApprovalDecisionStatus @default(PENDING)
note String? @db.Text
respondedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([requestId, approverId])
@@index([approverId, status])
@@map("approval_decisions")
}
// ============================================
// NOTIFICATION SETTINGS
// ============================================
model NotificationSetting {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Telegram webhook
telegramChatId String? // Chat ID to send messages to (bot token is in TELEGRAM_BOT_TOKEN env var)
telegramEnabled Boolean @default(false)
// Email notifications (uses account email by default)
emailEnabled Boolean @default(false)
// Event subscriptions
onNewVideo Boolean @default(true)
onNewVersion Boolean @default(true)
onNewComment Boolean @default(true)
onNewReply Boolean @default(true)
onApprovalEvents Boolean @default(true)
// User timezone for notification timestamps (IANA timezone identifier)
timezone String @default("UTC")
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("notification_settings")
}
// ============================================
// WATCH PROGRESS
// ============================================
model WatchProgress {
id String @id @default(cuid())
// User relation (optional for guest progress, though typically requires auth)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Video version relation
versionId String
version VideoVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
// Progress data
progress Float // Current playback position in seconds
duration Float // Total video duration at time of save
percentage Float // Progress as percentage (0-100)
// Timestamps
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
// One progress record per user per version
@@unique([userId, versionId])
@@index([userId])
@@index([versionId])
@@map("watch_progress")
}
// Tracks in-flight R2 upload slots so concurrent uploads are counted against quota
// before the VideoAsset record is committed. Rows expire after a short TTL.
model UploadReservation {
id String @id @default(cuid())
billedUserId String
sizeBytes BigInt
expiresAt DateTime
createdAt DateTime @default(now())
/// What this hold was opened for. A reservation can only be consumed by the
/// flow that opened it, so naming one is not enough to drop it. See
/// UPLOAD_RESERVATION_PURPOSES in lib/storage-quota.ts.
purpose String
@@index([billedUserId, expiresAt])
@@map("upload_reservations")
}
enum UploadSessionStatus {
INITIATED
FINALIZED
CANCELLED
EXPIRED
}
model VideoUploadSession {
id String @id @default(cuid())
uploadJti String @unique @map("upload_jti")
userId String
projectId String
billedUserId String @map("billed_user_id")
objectKey String @unique @map("object_key")
thumbnailObjectKey String @map("thumbnail_object_key")
declaredSizeBytes BigInt @map("declared_size_bytes")
contentType String @map("content_type")
reservationId String? @map("reservation_id")
multipartUploadId String? @map("multipart_upload_id")
expiresAt DateTime @map("expires_at")
status UploadSessionStatus @default(INITIATED)
consumedAt DateTime? @map("consumed_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([projectId, status])
@@index([userId, status])
@@index([billedUserId])
@@index([expiresAt])
@@map("video_upload_sessions")
}
// ============================================
// ACQUISITION + PRODUCT ANALYTICS
// ============================================
//
// Everything here is first party. Rows are written to this deployment's own
// database and read back by this deployment's own admin panel; no part of the
// codebase ships them anywhere else. The whole subsystem is off unless
// OPENFRAME_ENABLE_ANALYTICS is set, so a self-hosted instance carries the
// tables empty and pays nothing for them.
//
// Referrers are reduced to a host and landing pages to a path, both without
// query strings, so a shared link with a name or token in it cannot leak in
// here. The one free-text column is user_acquisitions.self_reported_note, which
// holds up to 200 characters the account typed into the onboarding question.
enum AcquisitionChannel {
DIRECT
GITHUB
YOUTUBE
GOOGLE
REVIEW_LINK
REFERRAL
OUTBOUND
COMMUNITY
OTHER
}
enum AnalyticsEventName {
LANDING_VIEW
CTA_CLICKED
SIGNUP_STARTED
SIGNUP_COMPLETED
EMAIL_VERIFIED
TRIAL_STARTED
WORKSPACE_CREATED
PROJECT_CREATED
VIDEO_ADDED
SHARE_LINK_CREATED
FIRST_GUEST_COMMENT
APPROVAL_COMPLETED
CHECKOUT_STARTED
SUBSCRIPTION_STARTED
SUBSCRIPTION_CANCELED
SUBSCRIPTION_REACTIVATED
}
// First touch for a visitor who does not have an account yet. Written once per
// anonymous id and never updated: the whole point is what brought them here the
// first time, so a later visit carrying different UTM tags must not overwrite it.
model AcquisitionTouch {
id String @id @default(cuid())
anonymousId String @unique @map("anonymous_id")
channel AcquisitionChannel
utmSource String? @map("utm_source")
utmMedium String? @map("utm_medium")
utmCampaign String? @map("utm_campaign")
referrerHost String? @map("referrer_host")
landingPath String @map("landing_path")
createdAt DateTime @default(now()) @map("created_at")
@@index([channel, createdAt])
@@index([createdAt])
@@map("acquisition_touches")
}
// The first touch copied onto the account at signup, plus the answer to the
// onboarding question. Kept beside User rather than on it so the acquisition
// columns stay out of every session and billing query.
model UserAcquisition {
userId String @id @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
anonymousId String? @map("anonymous_id")
channel AcquisitionChannel @default(DIRECT)
utmSource String? @map("utm_source")
utmMedium String? @map("utm_medium")
utmCampaign String? @map("utm_campaign")
referrerHost String? @map("referrer_host")
landingPath String? @map("landing_path")
selfReported AcquisitionChannel? @map("self_reported")
selfReportedNote String? @map("self_reported_note")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([channel, createdAt])
@@index([anonymousId])
@@map("user_acquisitions")
}
// One row per funnel step. `dedupeKey` is what makes "recorded exactly once"
// a property of the schema instead of a property of every call site: a replayed
// webhook, a double-submitted form or a refreshed page all collide on the unique
// index and the second write is dropped.
//
// The user relation is SetNull rather than Cascade on purpose. A deleted account
// still happened, and dropping its rows would silently rewrite past weeks of the
// funnel.
model AnalyticsEvent {
id String @id @default(cuid())
name AnalyticsEventName
userId String? @map("user_id")
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
anonymousId String? @map("anonymous_id")
channel AcquisitionChannel?
dedupeKey String @unique @map("dedupe_key")
occurredAt DateTime @default(now()) @map("occurred_at")
@@index([name, occurredAt])
@@index([userId, occurredAt])
@@index([channel, name, occurredAt])
@@index([anonymousId])
@@map("analytics_events")
}
// Rate limiting table (created as UNLOGGED via raw SQL migration)
// Defined here so `prisma db push` doesn't drop it
model RateLimit {
id Int @id @default(autoincrement())
key String @db.VarChar(255)
action String @db.VarChar(50)
count Int @default(1)
windowStart DateTime @default(now()) @map("window_start")
@@unique([key, action])
@@index([key, action])
@@index([windowStart])
@@map("rate_limits")
}