mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
A reservation id was never a secret and could not have been one. An upload token is base64url(payload) followed by its signature, so a client can read every claim out of its own token, and the two R2 init routes hand their reservation ids to the client outright. The asset route takes a reservation id from the request body and deleted it on the strength of that id and the billed user alone, and every hold an account owns is billed to the same user. So a caller could start a Bunny upload, read the id out of the token they were just given, quote it while attaching a one byte image or even a bare YouTube link, and have the quota handed back while the upload carried on. Repeat and a trial worth three gigabytes uploads as much as it likes for as long as Bunny takes to report a figure of its own. Signing the id rather than handing it over bought nothing, because signing is not hiding. A hold now records what it was opened for and is only ever consumed by that flow, so naming one is no longer enough to drop it. Guests hold against the workspace owner's quota rather than their own and had no way to give it back: the release was gated on being signed in. Declaring a size and walking away cost the guest nothing and cost the owner their whole remaining allowance for two hours. The guest grant now carries the reservation and the declared size, bound to the Bunny video as well as to ours, so cancelling gives the quota back and costs them the upload it stood for. What a guest can hold without cancelling lapses in half an hour rather than two hours. The in-transaction fallback check counted the account's Bunny storage as zero on a Bunny upload, because the figure was only prefetched for R2 providers and that branch was unreachable for Bunny until this PR made it reachable. On an account whose storage is all Bunny that was a check that could not fail. It is prefetched for every provider that can reach the fallback now.
874 lines
26 KiB
Plaintext
874 lines
26 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")
|
|
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[]
|
|
|
|
@@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[]
|
|
|
|
@@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")
|
|
}
|
|
|
|
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 String? // URL to uploaded image file
|
|
|
|
// 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 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")
|
|
}
|
|
|
|
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")
|
|
}
|