Initial commit: PodcastYes — AI podcast platform
This commit is contained in:
@@ -0,0 +1,492 @@
|
||||
// PodcastYes 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?
|
||||
|
||||
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")
|
||||
|
||||
@@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])
|
||||
@@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?
|
||||
|
||||
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])
|
||||
@@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
|
||||
status String @default("open") // open | reviewed | removed
|
||||
reviewedBy String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([status])
|
||||
@@map("content_flag")
|
||||
}
|
||||
Reference in New Issue
Block a user