Initial commit: PodcastYes — AI podcast platform

This commit is contained in:
Leon Serfaty
2026-06-07 03:58:32 -04:00
commit 155507f21a
151 changed files with 19826 additions and 0 deletions
@@ -0,0 +1,545 @@
-- CreateEnum
CREATE TYPE "EpisodeStatus" AS ENUM ('DRAFT', 'QUEUED', 'SCRIPTING', 'SYNTHESIZING', 'STITCHING', 'ART', 'SAVING', 'READY', 'FAILED');
-- CreateEnum
CREATE TYPE "EpisodeFormat" AS ENUM ('SOLO', 'INTERVIEW', 'MULTI_HOST');
-- CreateTable
CREATE TABLE "user" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
"image" TEXT,
"role" TEXT DEFAULT 'user',
"banned" BOOLEAN DEFAULT false,
"banReason" TEXT,
"banExpires" TIMESTAMP(3),
"stripeCustomerId" TEXT,
"paypalPayerId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "user_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "session" (
"id" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"token" TEXT NOT NULL,
"ipAddress" TEXT,
"userAgent" TEXT,
"activeOrganizationId" TEXT,
"impersonatedBy" TEXT,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "account" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"accessToken" TEXT,
"refreshToken" TEXT,
"idToken" TEXT,
"accessTokenExpiresAt" TIMESTAMP(3),
"refreshTokenExpiresAt" TIMESTAMP(3),
"scope" TEXT,
"password" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "verification" (
"id" TEXT NOT NULL,
"identifier" TEXT NOT NULL,
"value" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "verification_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "organization" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT,
"logo" TEXT,
"metadata" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "organization_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "member" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"teamId" TEXT,
"role" TEXT NOT NULL DEFAULT 'member',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "member_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "invitation" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"role" TEXT,
"teamId" TEXT,
"status" TEXT NOT NULL DEFAULT 'pending',
"expiresAt" TIMESTAMP(3) NOT NULL,
"inviterId" TEXT NOT NULL,
CONSTRAINT "invitation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "team" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3),
CONSTRAINT "team_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "org_branding" (
"organizationId" TEXT NOT NULL,
"brandName" TEXT,
"primaryColor" TEXT,
"logoUrl" TEXT,
"customDomain" TEXT,
"removePoweredBy" BOOLEAN NOT NULL DEFAULT false,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "org_branding_pkey" PRIMARY KEY ("organizationId")
);
-- CreateTable
CREATE TABLE "subscription" (
"id" TEXT NOT NULL,
"plan" TEXT NOT NULL,
"referenceId" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'active',
"provider" TEXT NOT NULL DEFAULT 'stripe',
"billingInterval" TEXT,
"seats" INTEGER,
"stripeCustomerId" TEXT,
"stripeSubscriptionId" TEXT,
"paypalSubscriptionId" TEXT,
"paypalPlanId" TEXT,
"periodStart" TIMESTAMP(3),
"periodEnd" TIMESTAMP(3),
"cancelAtPeriodEnd" BOOLEAN DEFAULT false,
"trialStart" TIMESTAMP(3),
"trialEnd" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "subscription_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "plan" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"name" TEXT NOT NULL,
"priceMonthly" INTEGER NOT NULL DEFAULT 0,
"priceYearly" INTEGER NOT NULL DEFAULT 0,
"stripePriceIdMonthly" TEXT,
"stripePriceIdYearly" TEXT,
"paypalPlanIdMonthly" TEXT,
"paypalPlanIdYearly" TEXT,
"limits" JSONB NOT NULL,
"features" JSONB NOT NULL,
"isPublic" BOOLEAN NOT NULL DEFAULT true,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "plan_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "usage_record" (
"id" TEXT NOT NULL,
"ownerId" TEXT NOT NULL,
"ownerType" TEXT NOT NULL,
"periodKey" TEXT NOT NULL,
"metric" TEXT NOT NULL,
"count" INTEGER NOT NULL DEFAULT 0,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "usage_record_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "episode" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"organizationId" TEXT,
"seriesId" TEXT,
"title" TEXT NOT NULL,
"topic" TEXT NOT NULL,
"tone" TEXT NOT NULL,
"format" "EpisodeFormat" NOT NULL DEFAULT 'SOLO',
"language" TEXT NOT NULL DEFAULT 'en',
"targetLengthMin" INTEGER NOT NULL DEFAULT 5,
"audience" TEXT,
"status" "EpisodeStatus" NOT NULL DEFAULT 'DRAFT',
"stage" TEXT,
"errorMessage" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "episode_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "script" (
"id" TEXT NOT NULL,
"episodeId" TEXT NOT NULL,
"content" JSONB NOT NULL,
"model" TEXT NOT NULL DEFAULT 'gpt-4o',
"version" INTEGER NOT NULL DEFAULT 1,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "script_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "speaker_config" (
"id" TEXT NOT NULL,
"episodeId" TEXT NOT NULL,
"speakerKey" TEXT NOT NULL,
"displayName" TEXT NOT NULL,
"elevenVoiceId" TEXT NOT NULL,
CONSTRAINT "speaker_config_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "audio_asset" (
"id" TEXT NOT NULL,
"episodeId" TEXT NOT NULL,
"storageKey" TEXT NOT NULL,
"durationSec" INTEGER,
"sizeBytes" INTEGER,
"format" TEXT NOT NULL DEFAULT 'mp3',
"segments" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "audio_asset_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "cover_art" (
"id" TEXT NOT NULL,
"episodeId" TEXT NOT NULL,
"storageKey" TEXT NOT NULL,
"prompt" TEXT NOT NULL,
"model" TEXT NOT NULL DEFAULT 'dall-e-3',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "cover_art_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "repurposed_content" (
"id" TEXT NOT NULL,
"episodeId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"content" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "repurposed_content_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "series" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"organizationId" TEXT,
"title" TEXT NOT NULL,
"description" TEXT,
"plannedCount" INTEGER NOT NULL DEFAULT 0,
"plan" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "series_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "generation_job" (
"id" TEXT NOT NULL,
"episodeId" TEXT NOT NULL,
"pgBossJobId" TEXT,
"type" TEXT NOT NULL DEFAULT 'full',
"status" TEXT NOT NULL DEFAULT 'queued',
"stage" TEXT,
"attempts" INTEGER NOT NULL DEFAULT 0,
"costEstimate" JSONB,
"error" TEXT,
"startedAt" TIMESTAMP(3),
"finishedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "generation_job_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "api_key" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"hashedKey" TEXT NOT NULL,
"prefix" TEXT NOT NULL,
"lastUsedAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "api_key_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "audit_log" (
"id" TEXT NOT NULL,
"actorId" TEXT,
"actorType" TEXT NOT NULL DEFAULT 'user',
"action" TEXT NOT NULL,
"target" TEXT,
"metadata" JSONB,
"ip" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "audit_log_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "feature_flag" (
"key" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT false,
"rolloutPct" INTEGER NOT NULL DEFAULT 0,
"metadata" JSONB,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "feature_flag_pkey" PRIMARY KEY ("key")
);
-- CreateTable
CREATE TABLE "ai_cost_log" (
"id" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"operation" TEXT NOT NULL,
"episodeId" TEXT,
"userId" TEXT,
"units" INTEGER NOT NULL DEFAULT 0,
"costUsd" DECIMAL(10,4) NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ai_cost_log_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "content_flag" (
"id" TEXT NOT NULL,
"episodeId" TEXT NOT NULL,
"reason" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'open',
"reviewedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "content_flag_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "user_email_key" ON "user"("email");
-- CreateIndex
CREATE UNIQUE INDEX "session_token_key" ON "session"("token");
-- CreateIndex
CREATE INDEX "session_userId_idx" ON "session"("userId");
-- CreateIndex
CREATE INDEX "account_userId_idx" ON "account"("userId");
-- CreateIndex
CREATE INDEX "verification_identifier_idx" ON "verification"("identifier");
-- CreateIndex
CREATE UNIQUE INDEX "organization_slug_key" ON "organization"("slug");
-- CreateIndex
CREATE INDEX "member_userId_idx" ON "member"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "member_organizationId_userId_key" ON "member"("organizationId", "userId");
-- CreateIndex
CREATE INDEX "invitation_organizationId_idx" ON "invitation"("organizationId");
-- CreateIndex
CREATE INDEX "team_organizationId_idx" ON "team"("organizationId");
-- CreateIndex
CREATE UNIQUE INDEX "org_branding_customDomain_key" ON "org_branding"("customDomain");
-- CreateIndex
CREATE INDEX "subscription_referenceId_idx" ON "subscription"("referenceId");
-- CreateIndex
CREATE INDEX "subscription_stripeSubscriptionId_idx" ON "subscription"("stripeSubscriptionId");
-- CreateIndex
CREATE INDEX "subscription_paypalSubscriptionId_idx" ON "subscription"("paypalSubscriptionId");
-- CreateIndex
CREATE UNIQUE INDEX "plan_key_key" ON "plan"("key");
-- CreateIndex
CREATE INDEX "usage_record_ownerId_periodKey_idx" ON "usage_record"("ownerId", "periodKey");
-- CreateIndex
CREATE UNIQUE INDEX "usage_record_ownerId_periodKey_metric_key" ON "usage_record"("ownerId", "periodKey", "metric");
-- CreateIndex
CREATE INDEX "episode_userId_idx" ON "episode"("userId");
-- CreateIndex
CREATE INDEX "episode_organizationId_idx" ON "episode"("organizationId");
-- CreateIndex
CREATE INDEX "episode_seriesId_idx" ON "episode"("seriesId");
-- CreateIndex
CREATE INDEX "episode_status_idx" ON "episode"("status");
-- CreateIndex
CREATE UNIQUE INDEX "script_episodeId_key" ON "script"("episodeId");
-- CreateIndex
CREATE UNIQUE INDEX "speaker_config_episodeId_speakerKey_key" ON "speaker_config"("episodeId", "speakerKey");
-- CreateIndex
CREATE UNIQUE INDEX "audio_asset_episodeId_key" ON "audio_asset"("episodeId");
-- CreateIndex
CREATE UNIQUE INDEX "cover_art_episodeId_key" ON "cover_art"("episodeId");
-- CreateIndex
CREATE INDEX "repurposed_content_episodeId_idx" ON "repurposed_content"("episodeId");
-- CreateIndex
CREATE INDEX "series_userId_idx" ON "series"("userId");
-- CreateIndex
CREATE INDEX "generation_job_episodeId_idx" ON "generation_job"("episodeId");
-- CreateIndex
CREATE INDEX "generation_job_status_idx" ON "generation_job"("status");
-- CreateIndex
CREATE UNIQUE INDEX "api_key_hashedKey_key" ON "api_key"("hashedKey");
-- CreateIndex
CREATE INDEX "api_key_userId_idx" ON "api_key"("userId");
-- CreateIndex
CREATE INDEX "audit_log_action_idx" ON "audit_log"("action");
-- CreateIndex
CREATE INDEX "audit_log_createdAt_idx" ON "audit_log"("createdAt");
-- CreateIndex
CREATE INDEX "ai_cost_log_provider_createdAt_idx" ON "ai_cost_log"("provider", "createdAt");
-- CreateIndex
CREATE INDEX "ai_cost_log_userId_idx" ON "ai_cost_log"("userId");
-- CreateIndex
CREATE INDEX "content_flag_status_idx" ON "content_flag"("status");
-- AddForeignKey
ALTER TABLE "session" ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "account" ADD CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "member" ADD CONSTRAINT "member_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "member" ADD CONSTRAINT "member_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "invitation" ADD CONSTRAINT "invitation_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "invitation" ADD CONSTRAINT "invitation_inviterId_fkey" FOREIGN KEY ("inviterId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "team" ADD CONSTRAINT "team_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "org_branding" ADD CONSTRAINT "org_branding_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "usage_record" ADD CONSTRAINT "usage_owner_user_fk" FOREIGN KEY ("ownerId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "episode" ADD CONSTRAINT "episode_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "episode" ADD CONSTRAINT "episode_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "episode" ADD CONSTRAINT "episode_seriesId_fkey" FOREIGN KEY ("seriesId") REFERENCES "series"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "script" ADD CONSTRAINT "script_episodeId_fkey" FOREIGN KEY ("episodeId") REFERENCES "episode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "speaker_config" ADD CONSTRAINT "speaker_config_episodeId_fkey" FOREIGN KEY ("episodeId") REFERENCES "episode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "audio_asset" ADD CONSTRAINT "audio_asset_episodeId_fkey" FOREIGN KEY ("episodeId") REFERENCES "episode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "cover_art" ADD CONSTRAINT "cover_art_episodeId_fkey" FOREIGN KEY ("episodeId") REFERENCES "episode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "repurposed_content" ADD CONSTRAINT "repurposed_content_episodeId_fkey" FOREIGN KEY ("episodeId") REFERENCES "episode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "series" ADD CONSTRAINT "series_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "series" ADD CONSTRAINT "series_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "generation_job" ADD CONSTRAINT "generation_job_episodeId_fkey" FOREIGN KEY ("episodeId") REFERENCES "episode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "api_key" ADD CONSTRAINT "api_key_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "content_flag" ADD CONSTRAINT "content_flag_episodeId_fkey" FOREIGN KEY ("episodeId") REFERENCES "episode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+492
View File
@@ -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")
}
+38
View File
@@ -0,0 +1,38 @@
// Seed the Plan catalog (admin-tunable mirror of lib/billing/plans.ts).
// Run: npm run db:seed
import "dotenv/config";
import type { Prisma } from "@prisma/client";
import { prisma } from "@/lib/db";
import { PLANS, PLAN_ORDER } from "@/lib/billing/plans";
async function main() {
for (const key of PLAN_ORDER) {
const plan = PLANS[key];
await prisma.plan.upsert({
where: { key },
create: {
key,
name: plan.name,
priceMonthly: plan.priceMonthly,
priceYearly: plan.priceYearly,
limits: plan.limits as unknown as Prisma.InputJsonValue,
features: plan.features as unknown as Prisma.InputJsonValue,
},
update: {
name: plan.name,
priceMonthly: plan.priceMonthly,
priceYearly: plan.priceYearly,
limits: plan.limits as unknown as Prisma.InputJsonValue,
features: plan.features as unknown as Prisma.InputJsonValue,
},
});
console.log(`✓ Seeded plan: ${plan.name}`);
}
}
main()
.catch((err) => {
console.error("Seed failed:", err.message ?? err);
process.exit(1);
})
.finally(() => prisma.$disconnect());