Files
podcastdistributiona/prisma/schema.prisma
T

552 lines
17 KiB
Plaintext
Raw Normal View History

// Podcast Distribution AI data model.
// Better Auth owns the auth/org/subscription tables (field names follow its conventions);
// we augment User/Session/Subscription and add the podcast + admin domain.
// pg-boss manages its own `pgboss.*` schema separately — not modeled here.
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ─────────────────────────── Better Auth: core ───────────────────────────
model User {
id String @id @default(cuid())
name String
email String @unique
emailVerified Boolean @default(false)
image String?
// admin plugin
role String? @default("user") // "user" | "admin"
banned Boolean? @default(false)
banReason String?
banExpires DateTime?
// app fields
stripeCustomerId String?
paypalPayerId String?
onboardedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
accounts Account[]
memberships Member[]
sentInvitations Invitation[] @relation("SentInvitations")
episodes Episode[]
series Series[]
apiKeys ApiKey[]
usageRecords UsageRecord[]
auditLogs AuditLog[] @relation("ActorLogs")
preferences UserPreferences?
@@index([createdAt])
@@map("user")
}
model Session {
id String @id @default(cuid())
expiresAt DateTime
token String @unique
ipAddress String?
userAgent String?
// organization plugin
activeOrganizationId String?
// admin plugin (impersonation)
impersonatedBy String?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@map("session")
}
model Account {
id String @id @default(cuid())
accountId String
providerId String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accessToken String?
refreshToken String?
idToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
password String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@map("account")
}
model Verification {
id String @id @default(cuid())
identifier String
value String
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([identifier])
@@map("verification")
}
// ─────────────────────── Better Auth: organization / team ───────────────────────
model Organization {
id String @id @default(cuid())
name String
slug String? @unique
logo String?
metadata String? // JSON blob (Better Auth stores as string)
createdAt DateTime @default(now())
members Member[]
invitations Invitation[]
teams Team[]
episodes Episode[]
series Series[]
branding OrgBranding?
@@map("organization")
}
model Member {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
teamId String?
role String @default("member") // "owner" | "admin" | "member"
createdAt DateTime @default(now())
@@unique([organizationId, userId])
@@index([userId])
@@map("member")
}
model Invitation {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
email String
role String?
teamId String?
status String @default("pending")
expiresAt DateTime
inviterId String
inviter User @relation("SentInvitations", fields: [inviterId], references: [id], onDelete: Cascade)
@@index([organizationId])
@@map("invitation")
}
model Team {
id String @id @default(cuid())
name String
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime? @updatedAt
@@index([organizationId])
@@map("team")
}
/// Agency white-label branding, one-to-one with an organization.
model OrgBranding {
organizationId String @id
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
brandName String?
primaryColor String? // hex
logoUrl String?
customDomain String? @unique
removePoweredBy Boolean @default(false)
updatedAt DateTime @updatedAt
@@map("org_branding")
}
// ─────────────────────────── Billing ───────────────────────────
/// One internal subscription model. Better Auth's Stripe plugin populates the Stripe fields;
/// the custom PayPal webhook handler populates the PayPal fields. `provider` discriminates.
model Subscription {
id String @id @default(cuid())
plan String // "free" | "creator" | "pro" | "agency"
referenceId String // user.id (individual) OR organization.id (Agency)
status String @default("active") // active | trialing | past_due | canceled | paused
provider String @default("stripe") // "stripe" | "paypal"
billingInterval String? // "month" | "year"
seats Int?
// Stripe
stripeCustomerId String?
stripeSubscriptionId String?
// PayPal
paypalSubscriptionId String?
paypalPlanId String?
periodStart DateTime?
periodEnd DateTime?
cancelAtPeriodEnd Boolean? @default(false)
trialStart DateTime?
trialEnd DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([referenceId])
2026-06-20 20:59:03 -04:00
// Provider subscription ids are unique so concurrent/replayed webhooks can't
// create duplicate rows (atomic upsert keys on these). Nullable: Postgres
// treats multiple NULLs as distinct, so existing free/null rows are unaffected.
// @@unique already creates a backing index, so no separate @@index is needed.
// MIGRATION REQUIRED: these new @@unique constraints must be generated and
// applied separately by the operator (`prisma migrate dev` / `migrate deploy`).
// The migration will FAIL if duplicate non-null values already exist in the
// table — de-dupe those rows first before applying.
@@unique([stripeSubscriptionId])
@@unique([paypalSubscriptionId])
@@index([status])
@@index([createdAt])
@@map("subscription")
}
/// Optional DB mirror of the code-defined plan catalog (lib/billing/plans.ts) so admins can
/// tune prices/limits without a deploy. `limits` is the PlanLimits JSON.
model Plan {
id String @id @default(cuid())
key String @unique // "free" | "creator" | "pro" | "agency"
name String
priceMonthly Int @default(0) // cents
priceYearly Int @default(0)
stripePriceIdMonthly String?
stripePriceIdYearly String?
paypalPlanIdMonthly String?
paypalPlanIdYearly String?
limits Json
features Json
isPublic Boolean @default(true)
updatedAt DateTime @updatedAt
@@map("plan")
}
/// Monthly usage counters, one row per (subject, period, metric).
model UsageRecord {
id String @id @default(cuid())
ownerId String // user.id or organization.id (the billing subject)
ownerType String // "user" | "organization"
periodKey String // "2026-06"
metric String // "script" | "audio" | "art" | "repurpose"
count Int @default(0)
user User? @relation(fields: [ownerId], references: [id], onDelete: Cascade, map: "usage_owner_user_fk")
updatedAt DateTime @updatedAt
@@unique([ownerId, periodKey, metric])
@@index([ownerId, periodKey])
@@map("usage_record")
}
// ─────────────────────────── Podcast domain ───────────────────────────
enum EpisodeStatus {
DRAFT
QUEUED
SCRIPTING
SYNTHESIZING
STITCHING
ART
SAVING
READY
FAILED
}
enum EpisodeFormat {
SOLO
INTERVIEW
MULTI_HOST
}
model Episode {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
organizationId String?
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
seriesId String?
series Series? @relation(fields: [seriesId], references: [id], onDelete: SetNull)
title String
topic String @db.Text
tone String
format EpisodeFormat @default(SOLO)
language String @default("en") // ISO code
targetLengthMin Int @default(5)
audience String?
status EpisodeStatus @default(DRAFT)
stage String? // human-readable current step
errorMessage String?
// Public share: when shareId is set, the episode is reachable at /p/<shareId>
// without auth. Clearing shareId disables the public page.
shareId String? @unique
sharedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
script Script?
speakers SpeakerConfig[]
audioAsset AudioAsset?
coverArt CoverArt?
repurposed RepurposedContent[]
jobs GenerationJob[]
flags ContentFlag[]
@@index([userId])
@@index([organizationId])
@@index([seriesId])
@@index([status])
@@index([createdAt])
@@map("episode")
}
model Script {
id String @id @default(cuid())
episodeId String @unique
episode Episode @relation(fields: [episodeId], references: [id], onDelete: Cascade)
/// Structured: [{ speakerKey, text, sectionId, title }]
content Json
model String @default("gpt-4o")
version Int @default(1)
updatedAt DateTime @updatedAt
@@map("script")
}
/// Maps a script speaker ("host", "guest", "cohost") to an ElevenLabs voice.
model SpeakerConfig {
id String @id @default(cuid())
episodeId String
episode Episode @relation(fields: [episodeId], references: [id], onDelete: Cascade)
speakerKey String
displayName String
elevenVoiceId String
@@unique([episodeId, speakerKey])
@@map("speaker_config")
}
model AudioAsset {
id String @id @default(cuid())
episodeId String @unique
episode Episode @relation(fields: [episodeId], references: [id], onDelete: Cascade)
storageKey String // relative key, e.g. "mp3/<episodeId>.mp3"
durationSec Int?
sizeBytes Int?
format String @default("mp3")
/// Segment manifest for partial regeneration.
segments Json?
createdAt DateTime @default(now())
@@map("audio_asset")
}
model CoverArt {
id String @id @default(cuid())
episodeId String @unique
episode Episode @relation(fields: [episodeId], references: [id], onDelete: Cascade)
storageKey String // relative key, e.g. "art/<episodeId>.png"
prompt String @db.Text
model String @default("dall-e-3")
createdAt DateTime @default(now())
@@map("cover_art")
}
model RepurposedContent {
id String @id @default(cuid())
episodeId String
episode Episode @relation(fields: [episodeId], references: [id], onDelete: Cascade)
type String // "blog" | "social_thread" | "newsletter"
content Json
createdAt DateTime @default(now())
@@index([episodeId])
@@map("repurposed_content")
}
/// Series / season planner (Pro+).
model Series {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
organizationId String?
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
title String
description String? @db.Text
plannedCount Int @default(0)
/// Season plan: [{ title, topic, summary }]
plan Json?
episodes Episode[]
createdAt DateTime @default(now())
@@index([userId])
@@map("series")
}
// ─────────────────────────── Jobs / async ───────────────────────────
model GenerationJob {
id String @id @default(cuid())
episodeId String
episode Episode @relation(fields: [episodeId], references: [id], onDelete: Cascade)
pgBossJobId String?
type String @default("full") // full | script | audio | art | section | repurpose
status String @default("queued") // queued | running | completed | failed
stage String?
attempts Int @default(0)
/// { openaiTokens, elevenChars, dalleImages, costUsd }
costEstimate Json?
error String?
startedAt DateTime?
finishedAt DateTime?
createdAt DateTime @default(now())
@@index([episodeId])
@@index([status])
@@map("generation_job")
}
// ─────────────────────────── Pro API access ───────────────────────────
/// Custom API keys (we hash + verify ourselves; not the Better Auth apiKey plugin).
model ApiKey {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
hashedKey String @unique
prefix String // display only, e.g. "pky_live_abcd…"
lastUsedAt DateTime?
revokedAt DateTime?
createdAt DateTime @default(now())
@@index([userId])
@@map("api_key")
}
// ─────────────────────────── Admin / ops ───────────────────────────
model AuditLog {
id String @id @default(cuid())
actorId String?
actor User? @relation("ActorLogs", fields: [actorId], references: [id], onDelete: SetNull)
actorType String @default("user") // "user" | "admin" | "system"
action String // "user.ban", "subscription.refund", "flag.toggle", …
target String?
metadata Json?
ip String?
createdAt DateTime @default(now())
@@index([action])
@@index([createdAt])
@@map("audit_log")
}
model FeatureFlag {
key String @id
enabled Boolean @default(false)
rolloutPct Int @default(0)
metadata Json?
updatedAt DateTime @updatedAt
@@map("feature_flag")
}
/// AI usage + cost monitoring.
model AiCostLog {
id String @id @default(cuid())
provider String // "openai" | "elevenlabs"
operation String // "script" | "audio" | "art" | "repurpose"
episodeId String?
userId String?
units Int @default(0) // tokens | chars | images
costUsd Decimal @default(0) @db.Decimal(10, 4)
createdAt DateTime @default(now())
@@index([provider, createdAt])
@@index([userId])
@@map("ai_cost_log")
}
/// Content moderation queue.
model ContentFlag {
id String @id @default(cuid())
episodeId String
episode Episode @relation(fields: [episodeId], references: [id], onDelete: Cascade)
reason String
source String @default("system") // "moderation" | "report" | "system"
severity String @default("medium") // "low" | "medium" | "high"
status String @default("open") // open | reviewed | removed
reviewedBy String?
createdAt DateTime @default(now())
@@index([status])
@@map("content_flag")
}
/// Liveness of a long-running worker process (heartbeat). One row per worker name.
model WorkerHeartbeat {
name String @id
lastBeatAt DateTime
queued Int?
running Int?
meta Json?
@@map("worker_heartbeat")
}
/// Per-user editor defaults and notification preferences (settings page).
model UserPreferences {
userId String @id
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
defaultVoiceId String?
defaultLanguage String @default("en")
emailOnEpisodeReady Boolean @default(true)
productEmails Boolean @default(true)
updatedAt DateTime @updatedAt
@@map("user_preferences")
}
/// Billing webhook delivery log — also dedups replayed events on `eventId`.
model WebhookEvent {
id String @id @default(cuid())
provider String // "stripe" | "paypal"
eventId String @unique
type String
status String @default("processed") // processed | failed | skipped
error String?
createdAt DateTime @default(now())
@@index([provider, createdAt])
@@map("webhook_event")
}