// 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]) @@index([stripeSubscriptionId]) @@index([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/ // 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/.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/.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") }