mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
A comment held one image, and the paste handler took the first item off the clipboard and dropped the rest. Reviewing a cut usually means several screenshots about the same moment, which meant one comment per screenshot or one screenshot and a paragraph describing the others. Editing a comment could not attach anything at all: the edit box had no paste handler, no file picker and no way to remove what was already there. A comment now carries up to five images, in the composer, in a reply and in the editor. One paste stages every image on the clipboard, the file picker takes a multiple selection, and a drop lands on whichever editor is open. Over the cap the extras are refused out loud rather than dropped quietly. A single image still fills the width; several tile into a grid, and either opens full screen on click. The images move into their own table. `comments.imageUrl` stays and follows the first of them, so a reader that has not been updated keeps working, and the migration copies the existing attachments across so the new table is complete from the first read. Every path that resolves a URL back to a comment now asks the new table: R2 cleanup, the orphan sweep, the storage accounting and the reference checks that decide whether an object can be deleted. Left on the old column they would have treated images two through five as unreferenced and swept them. Detaching an image while editing only breaks the link. The file stays in R2 and in the assets pane, which is where it is deleted from and where its bytes are already billed.
890 lines
26 KiB
Plaintext
890 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` 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")
|
|
}
|
|
|
|
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")
|
|
}
|