mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
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.
952 lines
29 KiB
Plaintext
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")
|
|
}
|