493 lines
15 KiB
Plaintext
493 lines
15 KiB
Plaintext
// 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")
|
|
}
|