Retarget deployment from Plesk to Dokploy (Docker Compose)
This commit is contained in:
@@ -0,0 +1,17 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
storage
|
||||||
|
.claude
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
npm-debug.log*
|
||||||
|
*.tsbuildinfo
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
README.md
|
||||||
|
deploy
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
# Single image used by both the web and worker services (see docker-compose.yml).
|
||||||
|
# Includes ffmpeg (audio stitching) + the full node_modules so the worker can run
|
||||||
|
# via tsx and `prisma migrate deploy` can run on web startup.
|
||||||
|
|
||||||
|
FROM node:20-bookworm-slim AS base
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ffmpeg openssl ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
# ---- dependencies ----
|
||||||
|
FROM base AS deps
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# ---- build ----
|
||||||
|
FROM base AS build
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
# NEXT_PUBLIC_* are inlined into the client bundle at build time, so they must be
|
||||||
|
# provided as build args (Dokploy passes them from the env — see docker-compose.yml).
|
||||||
|
ARG NEXT_PUBLIC_APP_URL
|
||||||
|
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||||
|
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL
|
||||||
|
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||||
|
# A throwaway BETTER_AUTH_SECRET, scoped to THIS command only (not a persisted ENV
|
||||||
|
# layer), satisfies the prod-secret guard in lib/auth/auth.ts during `next build`.
|
||||||
|
# Dokploy injects the real secret at run time; it's never baked into the bundle.
|
||||||
|
RUN BETTER_AUTH_SECRET=build-time-placeholder npm run build
|
||||||
|
|
||||||
|
# ---- runtime ----
|
||||||
|
FROM base AS runner
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
ENV STORAGE_DIR=/app/storage
|
||||||
|
# Copy the whole built app (node_modules incl. tsx + prisma CLI, .next, source).
|
||||||
|
COPY --from=build /app ./
|
||||||
|
RUN mkdir -p /app/storage/mp3 /app/storage/art /app/storage/exports
|
||||||
|
EXPOSE 3000
|
||||||
|
# Default = web; the worker service overrides this command in docker-compose.yml.
|
||||||
|
CMD ["npm", "run", "start"]
|
||||||
@@ -13,12 +13,12 @@ you fine-tune, download, and repurpose the result.
|
|||||||
| UI | Tailwind CSS + shadcn/ui + Recharts |
|
| UI | Tailwind CSS + shadcn/ui + Recharts |
|
||||||
| Database | PostgreSQL + Prisma |
|
| Database | PostgreSQL + Prisma |
|
||||||
| Auth | Better Auth (email/password + Google, admin + organization plugins) |
|
| Auth | Better Auth (email/password + Google, admin + organization plugins) |
|
||||||
| Queue | **pg-boss** (Postgres-backed, no Redis) + a PM2 worker process |
|
| Queue | **pg-boss** (Postgres-backed, no Redis) + a separate worker process |
|
||||||
| Storage | Local disk (swappable `StorageProvider` → S3/R2) |
|
| Storage | Local disk on a persistent volume (swappable `StorageProvider` → S3/R2) |
|
||||||
| AI | OpenAI (GPT-4 + DALL·E), ElevenLabs (TTS + dialogue) |
|
| AI | OpenAI (GPT-4 + DALL·E), ElevenLabs (TTS + dialogue) |
|
||||||
| Billing | **Stripe and PayPal** → one unified `Subscription` model |
|
| Billing | **Stripe and PayPal** → one unified `Subscription` model |
|
||||||
| Email | Resend |
|
| Email | Resend |
|
||||||
| Deploy | Plesk / Linux VPS (see [`deploy/README.md`](deploy/README.md)) |
|
| Deploy | **Dokploy** (Docker Compose + Traefik) — see [`deploy/README.md`](deploy/README.md) |
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -84,5 +84,6 @@ per segment, then concatenated and loudness-normalized with ffmpeg.
|
|||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
See [`deploy/README.md`](deploy/README.md) for the full Plesk / Linux runbook
|
Containerized for **Dokploy** (Docker Compose: `web` + `worker`, Traefik for
|
||||||
(PM2, nginx, SSL, webhooks, backups).
|
domain/TLS/SSE, a persistent `storage` volume). See [`deploy/README.md`](deploy/README.md)
|
||||||
|
for the full runbook (env, domain, migrations-on-boot, webhooks, backups).
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { LegalDoc, type LegalSection } from "@/components/marketing/legal-doc";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Acceptable Use Policy" };
|
||||||
|
|
||||||
|
const UPDATED = "June 7, 2026";
|
||||||
|
|
||||||
|
const SECTIONS: LegalSection[] = [
|
||||||
|
{
|
||||||
|
heading: "Purpose",
|
||||||
|
paragraphs: [
|
||||||
|
"This Acceptable Use Policy (AUP) sets out what you may and may not do with PodcastYes. It supplements our Terms of Service. We may update it as the service and abuse patterns evolve.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Prohibited content",
|
||||||
|
paragraphs: ["You may not use PodcastYes to create, store, or distribute content that:"],
|
||||||
|
bullets: [
|
||||||
|
"Is illegal, infringes intellectual-property or privacy rights, or violates any applicable law.",
|
||||||
|
"Is hateful, harassing, threatening, or incites violence against people or groups.",
|
||||||
|
"Is sexually explicit, exploits minors, or sexualizes real individuals.",
|
||||||
|
"Impersonates a real person or clones a real voice or likeness without that person's consent.",
|
||||||
|
"Spreads deliberate disinformation, fraud, or scams, or facilitates malware or phishing.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Prohibited conduct",
|
||||||
|
paragraphs: ["You also agree not to:"],
|
||||||
|
bullets: [
|
||||||
|
"Bypass or attempt to bypass usage limits, rate limits, authentication, or other security controls.",
|
||||||
|
"Scrape, reverse-engineer, or attempt to extract the underlying models or source code.",
|
||||||
|
"Resell, sublicense, or white-label the service except as expressly permitted by your plan.",
|
||||||
|
"Use automated access (including the API) in a way that degrades the service for others.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "AI-generated media",
|
||||||
|
paragraphs: [
|
||||||
|
"Because PodcastYes produces synthetic voice and imagery, you are responsible for using it ethically: do not create deceptive deepfakes of real people, and disclose that audio is AI-generated where your audience or the law expects it. You are responsible for clearing any rights needed for the topics, names, and references in your episodes.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Enforcement",
|
||||||
|
paragraphs: [
|
||||||
|
"We use automated moderation to screen topics and generated scripts, and we may flag, hold, remove, or refuse to generate content that violates this policy. We may suspend or terminate accounts that abuse the service or put it, our providers, or other users at risk — immediately in serious cases.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Reporting abuse",
|
||||||
|
paragraphs: [
|
||||||
|
"If you believe content created with PodcastYes violates this policy, report it to abuse@podcastyes.app with enough detail for us to investigate.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AcceptableUsePage() {
|
||||||
|
return (
|
||||||
|
<LegalDoc
|
||||||
|
title="Acceptable Use Policy"
|
||||||
|
updated={UPDATED}
|
||||||
|
intro="We want PodcastYes to be a safe, trustworthy place to create. This policy describes the content and conduct that are not allowed on the platform."
|
||||||
|
sections={SECTIONS}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { LegalDoc, type LegalSection } from "@/components/marketing/legal-doc";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Cookie Policy" };
|
||||||
|
|
||||||
|
const UPDATED = "June 7, 2026";
|
||||||
|
|
||||||
|
const SECTIONS: LegalSection[] = [
|
||||||
|
{
|
||||||
|
heading: "What cookies are",
|
||||||
|
paragraphs: [
|
||||||
|
"Cookies are small text files stored on your device by your browser. They let a website remember information between requests — for example, that you are signed in. Similar technologies such as local storage are covered by this policy too.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Cookies we use",
|
||||||
|
paragraphs: ["PodcastYes uses only the cookies needed to run the service securely:"],
|
||||||
|
bullets: [
|
||||||
|
"Authentication — a session cookie that keeps you signed in after you log in. Without it you would have to re-authenticate on every page.",
|
||||||
|
"Security — short-lived cookies that protect your session and help prevent cross-site request forgery.",
|
||||||
|
"Preferences — remembering lightweight UI choices so the app behaves the way you left it.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "What we don't use",
|
||||||
|
paragraphs: [
|
||||||
|
"We do not use third-party advertising cookies, cross-site tracking, or sell cookie data. We do not build advertising profiles about you.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Managing cookies",
|
||||||
|
paragraphs: [
|
||||||
|
"You can clear or block cookies in your browser settings. Because our cookies are strictly necessary, blocking them will prevent you from signing in and using the application.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Changes",
|
||||||
|
paragraphs: [
|
||||||
|
"If we introduce new categories of cookies we will update this policy and the date above before relying on them.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Contact",
|
||||||
|
paragraphs: ["Questions about cookies? Email privacy@podcastyes.app."],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function CookiePolicyPage() {
|
||||||
|
return (
|
||||||
|
<LegalDoc
|
||||||
|
title="Cookie Policy"
|
||||||
|
updated={UPDATED}
|
||||||
|
intro="This Cookie Policy explains how PodcastYes uses cookies and similar technologies, and the choices available to you. It should be read together with our Privacy Policy."
|
||||||
|
sections={SECTIONS}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ChevronDown, ArrowRight } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "FAQ",
|
||||||
|
description:
|
||||||
|
"Answers to common questions about creating AI podcasts with PodcastYes — generation, voices, languages, plans, billing, repurposing, the API, and teams.",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface QA {
|
||||||
|
q: string;
|
||||||
|
a: string;
|
||||||
|
}
|
||||||
|
interface Category {
|
||||||
|
title: string;
|
||||||
|
items: QA[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const FAQ: Category[] = [
|
||||||
|
{
|
||||||
|
title: "Getting started",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
q: "What is PodcastYes and what does it do?",
|
||||||
|
a: "PodcastYes is an all-in-one AI studio that turns a single topic into a finished podcast episode. It writes the script with GPT-4, records realistic multi-voice audio with ElevenLabs, and designs matching cover art with DALL·E — then lets you fine-tune, download, and repurpose the result. You go from idea to a publishable MP3 in minutes, with no microphone and no editing software.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "Do I need recording equipment or editing experience?",
|
||||||
|
a: "Neither. There's nothing to record and nothing to install — every part of the episode is generated for you. If you can describe a topic, you can produce a polished episode, and the built-in script editor and one-click regeneration let you refine it without any audio-engineering knowledge.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "How do I create my first episode?",
|
||||||
|
a: "Click “New episode” and follow the three-step wizard: describe your topic and pick a tone, length, language, and format; cast a voice for each speaker; then review and generate. The AI handles scripting, narration, and artwork, and the episode page shows live progress until it's ready.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "How long does it take to generate an episode?",
|
||||||
|
a: "Most episodes finish in a minute or two. Longer scripts are split into segments, synthesized, and stitched back together, so length affects timing — but you can leave the page and we'll email you the moment it's ready.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Creating & editing episodes",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
q: "What episode formats can I make?",
|
||||||
|
a: "Three: solo (a single host), interview (host and guest), and multi-host (a panel). Interview and multi-host formats produce natural back-and-forth dialogue, with a distinct AI voice for each speaker.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "Can I edit the AI-generated script?",
|
||||||
|
a: "Yes. Every generated script is fully editable in the built-in editor, and you can regenerate any single section if you want a different take without rewriting the whole thing. When you're happy with the words, regenerate the audio so the narration matches your edits.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "Can I regenerate just the audio or the cover art?",
|
||||||
|
a: "Yes. You can re-run the audio or the cover art independently of the script — handy for trying a new voice or a fresh design. Each regeneration draws from your monthly allowance for that step.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "Who owns the podcasts I create?",
|
||||||
|
a: "You own the scripts, audio, and artwork you generate and are free to publish them, subject to our Terms of Service and our AI providers' terms. Because AI output can occasionally be inaccurate or resemble existing work, review it for accuracy and rights before publishing.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "How accurate and safe is the generated content?",
|
||||||
|
a: "Treat the AI as a fast, high-quality first draft: it's strong but can make mistakes, so verify facts before you publish. To keep the platform safe, we automatically screen episode topics and generated scripts and flag anything that violates our Acceptable Use Policy for review.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Voices & languages",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
q: "How many voices are there, and can I use multiple speakers?",
|
||||||
|
a: "There are 14+ realistic voices spanning genders and accents, and you can assign a different voice to each speaker for true multi-person dialogue. You can swap voices and regenerate the audio at any time.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "What languages are supported?",
|
||||||
|
a: "PodcastYes supports 13+ languages, available on the Creator plan and above. Choose your language in the wizard and the script and narration are produced natively in that language — no re-recording required.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "Can I clone my own voice?",
|
||||||
|
a: "No. Custom voice cloning isn't offered, and our Acceptable Use Policy prohibits cloning or impersonating a real person's voice without their consent. You choose from our library of licensed AI voices instead.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Plans, billing & limits",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
q: "What's included in the Free plan?",
|
||||||
|
a: "The Free plan lets you try the full workflow at no cost and with no card: 3 scripts and 1 audio generation per month, episodes up to 5 minutes, two narrator voices, and cover-art generation. It's the easiest way to produce a complete sample episode before deciding to upgrade.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "How do the paid plans differ?",
|
||||||
|
a: "Creator ($9/mo) unlocks all voices, 13+ languages, content repurposing, and much higher limits (50 scripts, 20 audio, 20-minute episodes). Pro ($29/mo) adds unlimited scripts, the series generator, AI co-host mode, API access, priority generation, and 45-minute episodes. Agency ($79/mo) adds a 5-seat team workspace, white-label mode, custom branding, and up to 90-minute episodes.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "How do usage limits work, and when do they reset?",
|
||||||
|
a: "Each plan includes monthly allowances for scripts, audio, artwork, and repurposing. Allowances reset on the first of each calendar month, and you can always see exactly what's left on the Usage page.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "What payment methods do you accept, and can I cancel anytime?",
|
||||||
|
a: "We accept cards through Stripe and payments through PayPal, and you can cancel anytime from the billing page — your plan stays active through the end of the period you've already paid for. Payments are generally non-refundable; see our Refund & Cancellation Policy for the details.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "What happens if I hit my monthly limit?",
|
||||||
|
a: "When you reach a limit, that feature pauses until your allowance resets at the start of the next month — or you can upgrade to a higher plan to keep creating right away. Anything you've already generated is unaffected.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Repurposing, series & teams",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
q: "Can I turn an episode into other content?",
|
||||||
|
a: "Yes. With one click you can repurpose any finished episode into a blog post, a social thread, or a newsletter, so a single recording fuels your whole content calendar. Repurposing is available on the Creator plan and above.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "Can I plan a whole season at once?",
|
||||||
|
a: "Yes — the series generator (Pro and above) plans an entire season from a single prompt, proposing a season title plus a set of episodes with topics and summaries. You can then generate any episode in the plan with one click.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "Do you offer an API and team or white-label workspaces?",
|
||||||
|
a: "The Pro plan includes API access, so you can create episodes programmatically with your own keys. The Agency plan adds a 5-seat team workspace plus white-label mode and custom branding, so you can run PodcastYes as your own studio for clients.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function FaqPage() {
|
||||||
|
return (
|
||||||
|
<div className="bg-hero-wash">
|
||||||
|
<div className="container max-w-3xl py-20 md:py-28">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-[13px] font-semibold uppercase tracking-[0.04em] text-brand">Support</p>
|
||||||
|
<h1 className="mt-3 font-display text-5xl font-extrabold tracking-tight md:text-6xl">
|
||||||
|
Frequently asked questions
|
||||||
|
</h1>
|
||||||
|
<p className="mx-auto mt-4 max-w-2xl text-lg text-muted-foreground">
|
||||||
|
Everything you need to know about producing podcasts with PodcastYes. Can't find your
|
||||||
|
answer? Email{" "}
|
||||||
|
<a href="mailto:support@podcastyes.app" className="font-semibold text-brand hover:underline">
|
||||||
|
support@podcastyes.app
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-16 space-y-12">
|
||||||
|
{FAQ.map((category) => (
|
||||||
|
<section key={category.title}>
|
||||||
|
<h2 className="font-display text-sm font-semibold uppercase tracking-[0.06em] text-muted-foreground/70">
|
||||||
|
{category.title}
|
||||||
|
</h2>
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{category.items.map((item) => (
|
||||||
|
<details
|
||||||
|
key={item.q}
|
||||||
|
className="group rounded-2xl border border-border bg-card px-5 transition-shadow open:shadow-sm"
|
||||||
|
>
|
||||||
|
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 py-5 [&::-webkit-details-marker]:hidden">
|
||||||
|
<span className="font-display text-base font-bold tracking-tight">{item.q}</span>
|
||||||
|
<ChevronDown className="h-5 w-5 shrink-0 text-muted-foreground transition-transform duration-200 group-open:rotate-180" />
|
||||||
|
</summary>
|
||||||
|
<p className="pb-5 leading-relaxed text-muted-foreground">{item.a}</p>
|
||||||
|
</details>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<div className="mt-20 rounded-3xl border border-border bg-card p-10 text-center shadow-sm">
|
||||||
|
<h2 className="font-display text-2xl font-extrabold tracking-tight md:text-3xl">
|
||||||
|
Ready to make your first episode?
|
||||||
|
</h2>
|
||||||
|
<p className="mx-auto mt-3 max-w-md text-muted-foreground">
|
||||||
|
Spin up a fully produced episode on the free plan in a couple of minutes — no card required.
|
||||||
|
</p>
|
||||||
|
<div className="mt-7 flex flex-col justify-center gap-3 sm:flex-row">
|
||||||
|
<Button asChild size="lg">
|
||||||
|
<Link href="/sign-up">
|
||||||
|
Start free <ArrowRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild size="lg" variant="outline">
|
||||||
|
<Link href="/pricing">See pricing</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { LegalDoc, type LegalSection } from "@/components/marketing/legal-doc";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Refund & Cancellation Policy" };
|
||||||
|
|
||||||
|
const UPDATED = "June 7, 2026";
|
||||||
|
|
||||||
|
const SECTIONS: LegalSection[] = [
|
||||||
|
{
|
||||||
|
heading: "Subscriptions and renewals",
|
||||||
|
paragraphs: [
|
||||||
|
"Paid plans are billed in advance and renew automatically each billing period (monthly or yearly) until you cancel. By subscribing you authorize PodcastYes and our payment processors (Stripe and PayPal) to charge your payment method for each renewal at the then-current price.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Cancelling",
|
||||||
|
paragraphs: [
|
||||||
|
"You can cancel at any time from the billing page in your account or through the payment provider's portal. Cancellation stops future renewals; your plan stays active until the end of the period you have already paid for, after which the account moves to the Free plan.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Refunds",
|
||||||
|
paragraphs: [
|
||||||
|
"Except where required by law, payments are non-refundable and we do not pro-rate partial periods. We do not provide refunds for unused generation allowance or for time remaining after a cancellation. If you believe you were charged in error, contact us within 14 days and we will review it in good faith.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Free plan",
|
||||||
|
paragraphs: [
|
||||||
|
"The Free plan is, and remains, free. It has its own monthly allowances and requires no payment method.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Failed payments",
|
||||||
|
paragraphs: [
|
||||||
|
"If a renewal payment fails, your subscription may be marked past due and we may retry the charge. If payment cannot be collected, paid features are paused and the account is downgraded to Free until billing is restored.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Price changes and taxes",
|
||||||
|
paragraphs: [
|
||||||
|
"We may change plan prices; changes take effect at your next renewal and we will give reasonable notice. Prices are exclusive of any taxes, which may be added based on your location.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Chargebacks",
|
||||||
|
paragraphs: [
|
||||||
|
"Please contact us before initiating a chargeback so we can resolve the issue directly. Accounts with unresolved chargebacks may be suspended.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Contact",
|
||||||
|
paragraphs: ["Billing questions? Email billing@podcastyes.app."],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function RefundsPage() {
|
||||||
|
return (
|
||||||
|
<LegalDoc
|
||||||
|
title="Refund & Cancellation Policy"
|
||||||
|
updated={UPDATED}
|
||||||
|
intro="This policy explains how billing, renewals, cancellations, and refunds work for PodcastYes subscriptions. It forms part of our Terms of Service."
|
||||||
|
sections={SECTIONS}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { LegalDoc, type LegalSection } from "@/components/marketing/legal-doc";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Subprocessors" };
|
||||||
|
|
||||||
|
const UPDATED = "June 7, 2026";
|
||||||
|
|
||||||
|
const SECTIONS: LegalSection[] = [
|
||||||
|
{
|
||||||
|
heading: "About this list",
|
||||||
|
paragraphs: [
|
||||||
|
"To provide PodcastYes we use a small number of third-party companies (\"subprocessors\") that process data on our behalf. We share only the data each provider needs to perform its function, and we require appropriate confidentiality and security commitments from them.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "AI generation",
|
||||||
|
paragraphs: ["These providers generate the content you request:"],
|
||||||
|
bullets: [
|
||||||
|
"OpenAI — script generation, content moderation, and cover-art generation. Receives episode prompts and generated text.",
|
||||||
|
"ElevenLabs — text-to-speech and multi-speaker dialogue. Receives the script text to be voiced.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Payments",
|
||||||
|
paragraphs: ["These providers process subscriptions and payments:"],
|
||||||
|
bullets: [
|
||||||
|
"Stripe — card payments and subscription management. Receives billing and contact details; card data is handled by Stripe directly.",
|
||||||
|
"PayPal — PayPal payments and subscription management. Receives billing and contact details.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Email",
|
||||||
|
paragraphs: ["This provider delivers transactional email:"],
|
||||||
|
bullets: [
|
||||||
|
"Resend — sends verification, password-reset, and episode-ready emails. Receives your name and email address.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Infrastructure",
|
||||||
|
paragraphs: [
|
||||||
|
"PodcastYes runs on managed server infrastructure that stores your account data and generated assets to operate the service. Hosting providers process service data only to provide hosting and do not use it for their own purposes.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Changes to this list",
|
||||||
|
paragraphs: [
|
||||||
|
"We may add or replace subprocessors as the service evolves. When we do, we will update this page and the date above. If you have a data-processing agreement with us that requires advance notice of subprocessor changes, we will honor it.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Contact",
|
||||||
|
paragraphs: ["Questions about our subprocessors or data processing? Email privacy@podcastyes.app."],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SubprocessorsPage() {
|
||||||
|
return (
|
||||||
|
<LegalDoc
|
||||||
|
title="Subprocessors"
|
||||||
|
updated={UPDATED}
|
||||||
|
intro="This page lists the third-party providers PodcastYes relies on to deliver the service and the data each one processes. It supports our Privacy Policy and is provided for transparency."
|
||||||
|
sections={SECTIONS}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ export function SiteFooter() {
|
|||||||
["How it works", "/#how-it-works"],
|
["How it works", "/#how-it-works"],
|
||||||
["Features", "/#features"],
|
["Features", "/#features"],
|
||||||
["Pricing", "/pricing"],
|
["Pricing", "/pricing"],
|
||||||
|
["FAQ", "/faq"],
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<FooterCol
|
<FooterCol
|
||||||
@@ -38,8 +39,12 @@ export function SiteFooter() {
|
|||||||
<FooterCol
|
<FooterCol
|
||||||
title="Legal"
|
title="Legal"
|
||||||
links={[
|
links={[
|
||||||
["Terms", "/terms"],
|
["Terms of Service", "/terms"],
|
||||||
["Privacy", "/privacy"],
|
["Privacy Policy", "/privacy"],
|
||||||
|
["Cookie Policy", "/cookies"],
|
||||||
|
["Acceptable Use", "/acceptable-use"],
|
||||||
|
["Refund Policy", "/refunds"],
|
||||||
|
["Subprocessors", "/subprocessors"],
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export function SiteHeader() {
|
|||||||
<Link href="/#how-it-works" className="transition-colors hover:text-foreground">How it works</Link>
|
<Link href="/#how-it-works" className="transition-colors hover:text-foreground">How it works</Link>
|
||||||
<Link href="/#features" className="transition-colors hover:text-foreground">Features</Link>
|
<Link href="/#features" className="transition-colors hover:text-foreground">Features</Link>
|
||||||
<Link href="/pricing" className="transition-colors hover:text-foreground">Pricing</Link>
|
<Link href="/pricing" className="transition-colors hover:text-foreground">Pricing</Link>
|
||||||
|
<Link href="/faq" className="transition-colors hover:text-foreground">FAQ</Link>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
+50
-92
@@ -1,107 +1,65 @@
|
|||||||
# Deploying PodcastYes on Plesk (Linux)
|
# Deploying PodcastYes on Dokploy
|
||||||
|
|
||||||
Single-VPS deployment: one Next.js web process + one worker process under PM2, an
|
Containerized deploy: one image, two services (`web` + `worker`) via Docker
|
||||||
**external** Postgres, **local-disk** storage, and **no Redis**.
|
Compose, a persistent volume for generated assets, an **external** Postgres, and
|
||||||
|
**no Redis**. Dokploy's bundled Traefik handles the domain, TLS, and SSE streaming.
|
||||||
|
|
||||||
## 0. Prerequisites on the VPS
|
Artifacts: [`Dockerfile`](../Dockerfile), [`docker-compose.yml`](../docker-compose.yml), [`.dockerignore`](../.dockerignore).
|
||||||
|
|
||||||
```bash
|
## 1. Create the app in Dokploy
|
||||||
# Node 20+ (Plesk → Tools & Settings → Node.js, or nvm)
|
- New Project → **Create Service → Compose**.
|
||||||
node -v # >= 20
|
- **Provider:** Git → `https://git.serfaty.co/admin/podcastyes`, branch `main`.
|
||||||
|
- **Compose path:** `docker-compose.yml`.
|
||||||
|
|
||||||
# ffmpeg (the worker shells out to it for audio stitching)
|
## 2. Environment (Dokploy → Environment tab)
|
||||||
sudo apt update && sudo apt install -y ffmpeg
|
These feed both the build args (`NEXT_PUBLIC_*`) and runtime env. Minimum:
|
||||||
ffmpeg -version
|
|
||||||
|
|
||||||
# PM2 (run outside Plesk's Passenger/Node extension)
|
|
||||||
sudo npm install -g pm2
|
|
||||||
```
|
```
|
||||||
|
DATABASE_URL=postgresql://user:pass@host:5432/db?schema=public
|
||||||
## 1. Database
|
BETTER_AUTH_SECRET=<openssl rand -base64 32>
|
||||||
|
BETTER_AUTH_URL=https://your-domain
|
||||||
Create the database on your external Postgres host and put its URL in `.env` as
|
NEXT_PUBLIC_APP_URL=https://your-domain
|
||||||
`DATABASE_URL`. pg-boss auto-creates its own `pgboss` schema on first run.
|
OPENAI_API_KEY=...
|
||||||
|
ELEVENLABS_API_KEY=...
|
||||||
## 2. Code + env
|
RESEND_API_KEY=... # optional (emails)
|
||||||
|
GOOGLE_CLIENT_ID=... # optional (OAuth)
|
||||||
```bash
|
GOOGLE_CLIENT_SECRET=...
|
||||||
cd /var/www/vhosts/<your-domain> # your vhost docroot
|
# Billing (optional until you sell)
|
||||||
git clone <repo> app && cd app # or Plesk Git deployment
|
STRIPE_SECRET_KEY=...
|
||||||
cp .env.example .env # then fill in real values
|
STRIPE_WEBHOOK_SECRET=...
|
||||||
npm ci
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=...
|
||||||
|
STRIPE_PRICE_CREATOR_MONTHLY=... # + PRO/AGENCY, _YEARLY
|
||||||
|
PAYPAL_CLIENT_ID=...
|
||||||
|
PAYPAL_CLIENT_SECRET=...
|
||||||
|
PAYPAL_WEBHOOK_ID=...
|
||||||
|
PAYPAL_API_BASE=https://api-m.paypal.com
|
||||||
|
PAYPAL_PLAN_CREATOR=... # + PRO/AGENCY
|
||||||
```
|
```
|
||||||
|
`STORAGE_DIR` and `PORT` are set in the compose file — don't override.
|
||||||
|
|
||||||
Fill in `.env`: `DATABASE_URL`, `BETTER_AUTH_SECRET` (`openssl rand -base64 32`),
|
## 3. Domain + SSL
|
||||||
`BETTER_AUTH_URL` / `NEXT_PUBLIC_APP_URL` (your https domain), `OPENAI_API_KEY`,
|
Dokploy → the **web** service → **Domains** → add your domain, container port **3000**, enable HTTPS (Let's Encrypt). Traefik streams responses, so the episode-generation SSE works with no extra config.
|
||||||
`ELEVENLABS_API_KEY`, `STORAGE_DIR` (absolute, e.g.
|
|
||||||
`/var/www/vhosts/<your-domain>/storage`), Stripe + PayPal keys, `RESEND_API_KEY`.
|
|
||||||
|
|
||||||
## 3. Migrate, seed, build
|
## 4. Deploy
|
||||||
|
Hit **Deploy**. The build installs deps + ffmpeg, runs `next build`, and starts:
|
||||||
|
- `web` runs `prisma migrate deploy` (applies all migrations) then `next start`.
|
||||||
|
- `worker` runs the pg-boss consumer (ffmpeg available in-image).
|
||||||
|
Both share the `storage` volume at `/app/storage` (mp3/art/exports). Add a volume backup schedule in Dokploy.
|
||||||
|
|
||||||
|
## 5. First admin
|
||||||
|
Dokploy → web service → **Terminal** (or `docker exec`):
|
||||||
```bash
|
```bash
|
||||||
npx prisma migrate deploy # create all tables
|
npx tsx scripts/make-admin.ts you@email.com
|
||||||
npm run db:seed # populate the Plan catalog
|
|
||||||
npm run build # next build (standalone) + postbuild asset copy
|
|
||||||
mkdir -p "$STORAGE_DIR"/{mp3,art,exports}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 4. Run under PM2
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pm2 start ecosystem.config.js # starts podcastyes-web + podcastyes-worker
|
|
||||||
pm2 save # remember the process list
|
|
||||||
pm2 startup # print the systemd command to run once (resurrect on reboot)
|
|
||||||
pm2 logs # tail logs
|
|
||||||
```
|
|
||||||
|
|
||||||
The web process listens on `127.0.0.1:3000`; the worker consumes the pg-boss queue.
|
|
||||||
|
|
||||||
## 5. nginx (reverse proxy + public art)
|
|
||||||
|
|
||||||
Plesk → Domains → your domain → **Apache & nginx Settings** → *Additional nginx
|
|
||||||
directives*. Paste `deploy/nginx-podcastyes.conf` (replace `PODCASTYES_DOMAIN` and
|
|
||||||
the storage path). `proxy_buffering off` is required for the live progress stream (SSE).
|
|
||||||
|
|
||||||
## 6. SSL
|
|
||||||
|
|
||||||
Plesk → SSL/TLS Certificates → install **Let's Encrypt** for the domain. Make sure
|
|
||||||
`BETTER_AUTH_URL` / `NEXT_PUBLIC_APP_URL` use `https://`.
|
|
||||||
|
|
||||||
## 7. Webhooks
|
|
||||||
|
|
||||||
- **Stripe**: Dashboard → Developers → Webhooks → add endpoint
|
|
||||||
`https://<domain>/api/webhooks/stripe`, events `checkout.session.completed`,
|
|
||||||
`customer.subscription.created/updated/deleted`. Put the signing secret in
|
|
||||||
`STRIPE_WEBHOOK_SECRET`. Create the 3 paid Prices and set the `STRIPE_PRICE_*` env vars.
|
|
||||||
- **PayPal**: create a subscription Product + Plans (Creator/Pro/Agency), set
|
|
||||||
`PAYPAL_PLAN_*`. Add a webhook to `https://<domain>/api/webhooks/paypal` for the
|
|
||||||
`BILLING.SUBSCRIPTION.*` events and set `PAYPAL_WEBHOOK_ID`.
|
|
||||||
|
|
||||||
## 8. First admin
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# After signing up in the app once:
|
|
||||||
npm run make-admin you@email.com
|
|
||||||
```
|
|
||||||
|
|
||||||
Then visit `/admin`.
|
Then visit `/admin`.
|
||||||
|
|
||||||
## 9. Redeploys (zero-downtime)
|
## 6. Webhooks
|
||||||
|
- Stripe → `https://your-domain/api/webhooks/stripe` (events `checkout.session.completed`, `customer.subscription.created/updated/deleted`); set `STRIPE_WEBHOOK_SECRET` + the `STRIPE_PRICE_*`.
|
||||||
|
- PayPal → `https://your-domain/api/webhooks/paypal` (`BILLING.SUBSCRIPTION.*`); set `PAYPAL_WEBHOOK_ID` + `PAYPAL_PLAN_*`.
|
||||||
|
|
||||||
```bash
|
## Redeploys
|
||||||
git pull
|
Push to `main` → Dokploy auto-deploys (enable the Git webhook), or click **Redeploy**. Migrations run automatically on each `web` boot.
|
||||||
npm ci
|
|
||||||
npx prisma migrate deploy
|
|
||||||
npm run build
|
|
||||||
pm2 reload ecosystem.config.js
|
|
||||||
```
|
|
||||||
|
|
||||||
## Backups
|
## Notes
|
||||||
|
- **Assets** are served by the app (authed `/api/assets/...`, public `/api/public/episodes/[shareId]/...`) — no separate static route needed.
|
||||||
`STORAGE_DIR` (generated MP3s + art) is the only state not in Postgres — add it to a
|
- **Scaling:** the `worker` can be scaled or moved to its own Compose/host pointing at the same `DATABASE_URL`; swap local disk for S3/R2 by adding `lib/storage/s3.ts` and switching the registry in `lib/storage/index.ts`.
|
||||||
nightly offsite backup. The DB is your external Postgres provider's responsibility.
|
- **Build args:** `NEXT_PUBLIC_*` are baked at image-build time; changing them requires a rebuild (Dokploy does this on deploy).
|
||||||
|
|
||||||
## Scaling later (optional)
|
|
||||||
|
|
||||||
- Move the worker to a second VPS pointing at the same `DATABASE_URL` — change nothing in code.
|
|
||||||
- Swap local disk for S3/R2 by adding `lib/storage/s3.ts` and switching the registry in `lib/storage/index.ts`.
|
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
# Plesk → Domains → <your-domain> → Apache & nginx Settings →
|
|
||||||
# "Additional nginx directives". Paste the blocks below (adjust the storage path).
|
|
||||||
#
|
|
||||||
# Plesk already terminates SSL (Let's Encrypt) and proxies to Apache; these
|
|
||||||
# directives make nginx proxy directly to the Next.js standalone server instead,
|
|
||||||
# and serve public cover art straight from disk.
|
|
||||||
|
|
||||||
# Public cover art only (MP3s stay private, served via the authed /api/assets route).
|
|
||||||
location /media/art/ {
|
|
||||||
alias /var/www/vhosts/PODCASTYES_DOMAIN/storage/art/;
|
|
||||||
expires 7d;
|
|
||||||
add_header Cache-Control "public";
|
|
||||||
access_log off;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Proxy everything else to the Next.js web process (PM2 podcastyes-web on :3000).
|
|
||||||
location / {
|
|
||||||
proxy_pass http://127.0.0.1:3000;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
|
|
||||||
# Critical for Server-Sent Events (episode generation progress stream):
|
|
||||||
proxy_buffering off;
|
|
||||||
proxy_cache off;
|
|
||||||
proxy_read_timeout 3600s;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Dokploy "Compose" application. Two services share one image + a storage volume.
|
||||||
|
# Set the env vars in Dokploy's Environment tab (they populate the .env Dokploy
|
||||||
|
# generates, which feeds both the build args and runtime env below).
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL}
|
||||||
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY}
|
||||||
|
image: podcastyes:latest
|
||||||
|
# Apply pending migrations on boot, then serve.
|
||||||
|
command: sh -c "npx prisma migrate deploy && npm run start"
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
STORAGE_DIR: /app/storage
|
||||||
|
PORT: "3000"
|
||||||
|
volumes:
|
||||||
|
- storage:/app/storage
|
||||||
|
expose:
|
||||||
|
- "3000"
|
||||||
|
# Dokploy attaches the Traefik router (domain + SSL) to this service on port 3000.
|
||||||
|
|
||||||
|
worker:
|
||||||
|
image: podcastyes:latest
|
||||||
|
command: npx tsx worker/index.ts
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
STORAGE_DIR: /app/storage
|
||||||
|
volumes:
|
||||||
|
- storage:/app/storage
|
||||||
|
depends_on:
|
||||||
|
- web
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
storage:
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
// PM2 process definitions for the Plesk Linux VPS.
|
|
||||||
// pm2 start ecosystem.config.js # start both
|
|
||||||
// pm2 reload ecosystem.config.js # zero-downtime redeploy
|
|
||||||
// pm2 save && pm2 startup # resurrect both on reboot
|
|
||||||
//
|
|
||||||
// Run PM2 independently of Plesk's Node/Passenger extension. nginx (managed by Plesk)
|
|
||||||
// reverse-proxies the domain to the `web` process on PORT.
|
|
||||||
module.exports = {
|
|
||||||
apps: [
|
|
||||||
{
|
|
||||||
name: "podcastyes-web",
|
|
||||||
// Next.js standalone server emitted by `next build` (output: "standalone").
|
|
||||||
script: ".next/standalone/server.js",
|
|
||||||
cwd: __dirname,
|
|
||||||
instances: 1,
|
|
||||||
exec_mode: "fork",
|
|
||||||
// Load runtime secrets from .env (Node 20+). Keeps secrets out of this file.
|
|
||||||
node_args: "--env-file=.env",
|
|
||||||
env: {
|
|
||||||
NODE_ENV: "production",
|
|
||||||
PORT: 3000,
|
|
||||||
HOSTNAME: "127.0.0.1",
|
|
||||||
},
|
|
||||||
max_memory_restart: "512M",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "podcastyes-worker",
|
|
||||||
// Long-running pg-boss consumer; shells out to ffmpeg for audio stitching.
|
|
||||||
script: "node_modules/.bin/tsx",
|
|
||||||
args: "worker/index.ts",
|
|
||||||
cwd: __dirname,
|
|
||||||
instances: 1,
|
|
||||||
exec_mode: "fork",
|
|
||||||
env: {
|
|
||||||
NODE_ENV: "production",
|
|
||||||
},
|
|
||||||
max_memory_restart: "768M",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Create (or promote) an admin user with a hashed email/password credential.
|
||||||
|
*
|
||||||
|
* Usage: npx tsx scripts/create-admin.ts <email> <password> [name]
|
||||||
|
*
|
||||||
|
* Passwords are hashed with Better Auth's own hasher and stored in the
|
||||||
|
* `account` table — never in plaintext. If the user already exists they are
|
||||||
|
* promoted to admin and their password is reset.
|
||||||
|
*/
|
||||||
|
import "dotenv/config";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { auth } from "@/lib/auth/auth";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const [email, password, ...nameParts] = process.argv.slice(2);
|
||||||
|
if (!email || !password) {
|
||||||
|
console.error("Usage: npx tsx scripts/create-admin.ts <email> <password> [name]");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const name = nameParts.join(" ") || email.split("@")[0];
|
||||||
|
|
||||||
|
const ctx = await auth.$context;
|
||||||
|
const passwordHash = await ctx.password.hash(password);
|
||||||
|
|
||||||
|
const existing = await prisma.user.findUnique({ where: { email } });
|
||||||
|
|
||||||
|
const user = existing
|
||||||
|
? await prisma.user.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { role: "admin", emailVerified: true, name: existing.name || name },
|
||||||
|
})
|
||||||
|
: await prisma.user.create({
|
||||||
|
data: { name, email, role: "admin", emailVerified: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upsert the credential account (no compound unique on account, so do it manually).
|
||||||
|
const cred = await prisma.account.findFirst({
|
||||||
|
where: { userId: user.id, providerId: "credential" },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (cred) {
|
||||||
|
await prisma.account.update({ where: { id: cred.id }, data: { password: passwordHash } });
|
||||||
|
} else {
|
||||||
|
await prisma.account.create({
|
||||||
|
data: { accountId: user.id, providerId: "credential", userId: user.id, password: passwordHash },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n✅ Admin ${existing ? "updated" : "created"}: ${email}`);
|
||||||
|
console.log(` id: ${user.id}`);
|
||||||
|
console.log(` role: ${user.role}`);
|
||||||
|
console.log(` login: ${email} / (the password you provided)\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("create-admin failed:", err?.message ?? err);
|
||||||
|
process.exitCode = 1;
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
/**
|
||||||
|
* Seed 10 demo users with realistic activity (subscriptions, episodes, usage,
|
||||||
|
* AI cost, moderation flags, an agency workspace). Idempotent: re-running first
|
||||||
|
* removes prior demo data (everything under the @demo.podcastyes.app domain and
|
||||||
|
* `demo-` orgs), then recreates it.
|
||||||
|
*
|
||||||
|
* Run: npx tsx scripts/seed-demo.ts
|
||||||
|
* Login: any seeded email, password "Password123!".
|
||||||
|
*/
|
||||||
|
import "dotenv/config";
|
||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { auth } from "@/lib/auth/auth";
|
||||||
|
import { periodKey } from "@/lib/utils";
|
||||||
|
import type { PlanKey } from "@/lib/billing/plans";
|
||||||
|
|
||||||
|
const DOMAIN = "demo.podcastyes.app";
|
||||||
|
const PASSWORD = "Password123!";
|
||||||
|
|
||||||
|
// ── tiny helpers (plain Node script — Math.random/Date are fine here) ──
|
||||||
|
const rnd = (n: number) => Math.floor(Math.random() * n);
|
||||||
|
const pick = <T>(a: readonly T[]): T => a[rnd(a.length)];
|
||||||
|
const between = (a: number, b: number) => a + rnd(b - a + 1);
|
||||||
|
const daysAgo = (d: number) => new Date(Date.now() - d * 86_400_000 - rnd(24) * 3_600_000);
|
||||||
|
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||||
|
|
||||||
|
type Provider = "stripe" | "paypal";
|
||||||
|
interface Persona {
|
||||||
|
name: string;
|
||||||
|
handle: string;
|
||||||
|
plan: PlanKey;
|
||||||
|
hasSub: boolean;
|
||||||
|
provider: Provider;
|
||||||
|
status: "active" | "trialing" | "canceled" | "past_due";
|
||||||
|
interval: "month" | "year";
|
||||||
|
signupDaysAgo: number;
|
||||||
|
activity: "low" | "med" | "high";
|
||||||
|
org?: boolean; // agency workspace owner
|
||||||
|
}
|
||||||
|
|
||||||
|
const PERSONAS: Persona[] = [
|
||||||
|
{ name: "Maya Chen", handle: "maya", plan: "free", hasSub: false, provider: "stripe", status: "active", interval: "month", signupDaysAgo: 3, activity: "low" },
|
||||||
|
{ name: "Liam O'Brien", handle: "liam", plan: "free", hasSub: false, provider: "stripe", status: "active", interval: "month", signupDaysAgo: 9, activity: "low" },
|
||||||
|
{ name: "Sofia Rossi", handle: "sofia", plan: "creator", hasSub: true, provider: "stripe", status: "active", interval: "month", signupDaysAgo: 21, activity: "med" },
|
||||||
|
{ name: "Noah Patel", handle: "noah", plan: "creator", hasSub: true, provider: "paypal", status: "active", interval: "year", signupDaysAgo: 34, activity: "med" },
|
||||||
|
{ name: "Emma Schmidt", handle: "emma", plan: "creator", hasSub: true, provider: "stripe", status: "canceled", interval: "month", signupDaysAgo: 52, activity: "med" },
|
||||||
|
{ name: "Kenji Tanaka", handle: "kenji", plan: "pro", hasSub: true, provider: "stripe", status: "active", interval: "month", signupDaysAgo: 28, activity: "high" },
|
||||||
|
{ name: "Aisha Bello", handle: "aisha", plan: "pro", hasSub: true, provider: "stripe", status: "active", interval: "year", signupDaysAgo: 41, activity: "high" },
|
||||||
|
{ name: "Diego Morales", handle: "diego", plan: "pro", hasSub: true, provider: "paypal", status: "trialing", interval: "month", signupDaysAgo: 6, activity: "med" },
|
||||||
|
{ name: "Hannah Weiss", handle: "hannah", plan: "pro", hasSub: true, provider: "stripe", status: "past_due", interval: "month", signupDaysAgo: 47, activity: "med" },
|
||||||
|
{ name: "Oliver Grant", handle: "oliver", plan: "agency", hasSub: true, provider: "stripe", status: "active", interval: "month", signupDaysAgo: 60, activity: "high", org: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TOPICS = [
|
||||||
|
"The hidden history of coffee and global trade",
|
||||||
|
"Why sleep is the ultimate productivity hack",
|
||||||
|
"How AI is reshaping independent music",
|
||||||
|
"The psychology of habit formation",
|
||||||
|
"Space tourism: hype vs. reality",
|
||||||
|
"The rise and fall of ancient trade routes",
|
||||||
|
"Personal finance for freelancers",
|
||||||
|
"The science of flavor and why we crave umami",
|
||||||
|
"Remote work and the future of cities",
|
||||||
|
"Mythology's grip on modern storytelling",
|
||||||
|
"Climate tech you've never heard of",
|
||||||
|
"The art of the perfect interview",
|
||||||
|
];
|
||||||
|
const TONES = ["Conversational", "Educational", "Energetic", "Calm", "Witty"];
|
||||||
|
const FORMATS = ["SOLO", "INTERVIEW", "MULTI_HOST"] as const;
|
||||||
|
const LANGS = ["en", "en", "en", "es", "fr", "de"];
|
||||||
|
const LENGTHS = [5, 10, 10, 15, 20, 30];
|
||||||
|
const VOICE = "21m00Tcm4TlvDq8ikWAM";
|
||||||
|
|
||||||
|
const ACTIVITY_RANGE: Record<Persona["activity"], [number, number]> = {
|
||||||
|
low: [1, 3],
|
||||||
|
med: [4, 9],
|
||||||
|
high: [8, 16],
|
||||||
|
};
|
||||||
|
// Status weights → mostly produced, some failed/in-flight/draft.
|
||||||
|
const STATUS_BAG = [
|
||||||
|
"READY", "READY", "READY", "READY", "READY", "READY",
|
||||||
|
"FAILED", "QUEUED", "SCRIPTING", "DRAFT",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function buildScript(topic: string): Prisma.InputJsonValue {
|
||||||
|
return {
|
||||||
|
title: topic,
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: "intro",
|
||||||
|
title: "Introduction",
|
||||||
|
turns: [{ speakerKey: "host", text: `Welcome back. Today we're digging into ${topic.toLowerCase()}.` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "main",
|
||||||
|
title: "The big idea",
|
||||||
|
turns: [
|
||||||
|
{ speakerKey: "host", text: "Let's start with what most people get wrong about it." },
|
||||||
|
{ speakerKey: "host", text: "Then we'll look at the surprising part nobody talks about." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where: { email: { endsWith: `@${DOMAIN}` } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const userIds = users.map((u) => u.id);
|
||||||
|
const orgs = await prisma.organization.findMany({
|
||||||
|
where: { slug: { startsWith: "demo-" } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const orgIds = orgs.map((o) => o.id);
|
||||||
|
const refIds = [...userIds, ...orgIds];
|
||||||
|
|
||||||
|
if (refIds.length) {
|
||||||
|
await prisma.subscription.deleteMany({ where: { referenceId: { in: refIds } } });
|
||||||
|
await prisma.auditLog.deleteMany({ where: { OR: [{ actorId: { in: userIds } }, { target: { in: refIds } }] } });
|
||||||
|
}
|
||||||
|
if (userIds.length) await prisma.aiCostLog.deleteMany({ where: { userId: { in: userIds } } });
|
||||||
|
if (orgIds.length) await prisma.organization.deleteMany({ where: { id: { in: orgIds } } });
|
||||||
|
if (userIds.length) await prisma.user.deleteMany({ where: { id: { in: userIds } } });
|
||||||
|
return { removedUsers: userIds.length, removedOrgs: orgIds.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`\n🌱 Seeding demo users under @${DOMAIN}\n`);
|
||||||
|
|
||||||
|
const removed = await cleanup();
|
||||||
|
if (removed.removedUsers) {
|
||||||
|
console.log(` cleaned up ${removed.removedUsers} prior demo users, ${removed.removedOrgs} orgs`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash the shared demo password with Better Auth's own hasher so the accounts
|
||||||
|
// can actually log in (no need to replicate scrypt params).
|
||||||
|
const ctx = await auth.$context;
|
||||||
|
const passwordHash = await ctx.password.hash(PASSWORD);
|
||||||
|
|
||||||
|
const counts = { users: 0, subs: 0, episodes: 0, jobs: 0, costLogs: 0, usageRows: 0, flags: 0, orgs: 0 };
|
||||||
|
const month = periodKey(new Date());
|
||||||
|
|
||||||
|
for (const p of PERSONAS) {
|
||||||
|
const email = `${p.handle}@${DOMAIN}`;
|
||||||
|
const createdAt = daysAgo(p.signupDaysAgo);
|
||||||
|
|
||||||
|
// 1) user + credential account + preferences
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: { name: p.name, email, emailVerified: true, role: "user", createdAt, updatedAt: createdAt },
|
||||||
|
});
|
||||||
|
await prisma.account.create({
|
||||||
|
data: { accountId: user.id, providerId: "credential", userId: user.id, password: passwordHash, createdAt, updatedAt: createdAt },
|
||||||
|
});
|
||||||
|
await prisma.userPreferences.create({
|
||||||
|
data: { userId: user.id, defaultVoiceId: VOICE, defaultLanguage: "en" },
|
||||||
|
}).catch(() => {});
|
||||||
|
counts.users++;
|
||||||
|
|
||||||
|
// 2) agency workspace (org-owned subscription + branding)
|
||||||
|
let orgId: string | null = null;
|
||||||
|
if (p.org) {
|
||||||
|
const org = await prisma.organization.create({
|
||||||
|
data: { name: `${p.name.split(" ")[0]}'s Studio`, slug: `demo-${p.handle}-studio`, createdAt },
|
||||||
|
});
|
||||||
|
orgId = org.id;
|
||||||
|
counts.orgs++;
|
||||||
|
await prisma.member.create({ data: { organizationId: org.id, userId: user.id, role: "owner", createdAt } });
|
||||||
|
await prisma.orgBranding.create({
|
||||||
|
data: {
|
||||||
|
organizationId: org.id,
|
||||||
|
brandName: "Grant Audio Studio",
|
||||||
|
primaryColor: "#0F766E",
|
||||||
|
logoUrl: null,
|
||||||
|
removePoweredBy: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) subscription (on the org for agency, else on the user)
|
||||||
|
if (p.hasSub) {
|
||||||
|
const referenceId = orgId ?? user.id;
|
||||||
|
const subStart = daysAgo(Math.min(p.signupDaysAgo, 30));
|
||||||
|
const active = p.status === "active" || p.status === "trialing" || p.status === "past_due";
|
||||||
|
await prisma.subscription.create({
|
||||||
|
data: {
|
||||||
|
plan: p.plan,
|
||||||
|
referenceId,
|
||||||
|
status: p.status,
|
||||||
|
provider: p.provider,
|
||||||
|
billingInterval: p.interval,
|
||||||
|
seats: p.plan === "agency" ? 5 : 1,
|
||||||
|
stripeCustomerId: p.provider === "stripe" ? `cus_demo_${p.handle}` : null,
|
||||||
|
stripeSubscriptionId: p.provider === "stripe" ? `sub_demo_${p.handle}` : null,
|
||||||
|
paypalSubscriptionId: p.provider === "paypal" ? `I-DEMO${p.handle.toUpperCase()}` : null,
|
||||||
|
periodStart: subStart,
|
||||||
|
periodEnd: active ? daysAgo(-between(2, 26)) : daysAgo(between(1, 10)),
|
||||||
|
cancelAtPeriodEnd: p.status === "canceled",
|
||||||
|
trialStart: p.status === "trialing" ? subStart : null,
|
||||||
|
trialEnd: p.status === "trialing" ? daysAgo(-between(2, 9)) : null,
|
||||||
|
createdAt: subStart,
|
||||||
|
updatedAt: subStart,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
counts.subs++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) episodes + script/speakers/jobs/cost
|
||||||
|
const [lo, hi] = ACTIVITY_RANGE[p.activity];
|
||||||
|
const episodeCount = between(lo, hi);
|
||||||
|
let readyThisMonth = 0;
|
||||||
|
let repurposeThisMonth = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < episodeCount; i++) {
|
||||||
|
const status = pick(STATUS_BAG);
|
||||||
|
const epCreated = daysAgo(rnd(p.signupDaysAgo + 1));
|
||||||
|
const topic = pick(TOPICS);
|
||||||
|
const format = pick(FORMATS);
|
||||||
|
const ep = await prisma.episode.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
organizationId: orgId ?? undefined,
|
||||||
|
title: topic,
|
||||||
|
topic,
|
||||||
|
tone: pick(TONES),
|
||||||
|
format,
|
||||||
|
language: pick(LANGS),
|
||||||
|
targetLengthMin: pick(LENGTHS),
|
||||||
|
status: status as Prisma.EpisodeCreateInput["status"],
|
||||||
|
stage: status === "READY" ? "Done" : status === "FAILED" ? null : "Queued for generation",
|
||||||
|
errorMessage: status === "FAILED" ? "ElevenLabs timed out after 3 attempts" : null,
|
||||||
|
createdAt: epCreated,
|
||||||
|
updatedAt: epCreated,
|
||||||
|
speakers: {
|
||||||
|
create: [{ speakerKey: "host", displayName: "Host", elevenVoiceId: VOICE }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
counts.episodes++;
|
||||||
|
|
||||||
|
if (status === "READY") {
|
||||||
|
await prisma.script.create({
|
||||||
|
data: { episodeId: ep.id, content: buildScript(topic), model: "gpt-4o", updatedAt: epCreated },
|
||||||
|
});
|
||||||
|
await prisma.generationJob.create({
|
||||||
|
data: { episodeId: ep.id, type: "full", status: "completed", startedAt: epCreated, finishedAt: epCreated, createdAt: epCreated },
|
||||||
|
});
|
||||||
|
// cost: script + audio + art
|
||||||
|
const rows: Prisma.AiCostLogCreateManyInput[] = [
|
||||||
|
{ provider: "openai", operation: "script", episodeId: ep.id, userId: user.id, units: between(1800, 6000), costUsd: round2(0.03 + Math.random() * 0.12), createdAt: epCreated },
|
||||||
|
{ provider: "elevenlabs", operation: "audio", episodeId: ep.id, userId: user.id, units: between(3000, 14000), costUsd: round2(0.06 + Math.random() * 0.4), createdAt: epCreated },
|
||||||
|
{ provider: "openai", operation: "art", episodeId: ep.id, userId: user.id, units: 1, costUsd: 0.04, createdAt: epCreated },
|
||||||
|
];
|
||||||
|
await prisma.aiCostLog.createMany({ data: rows });
|
||||||
|
counts.costLogs += rows.length;
|
||||||
|
if (epCreated >= new Date(`${month}-01T00:00:00Z`)) readyThisMonth++;
|
||||||
|
if (Math.random() < 0.3) repurposeThisMonth++;
|
||||||
|
} else if (status === "FAILED") {
|
||||||
|
await prisma.generationJob.create({
|
||||||
|
data: { episodeId: ep.id, type: "full", status: "failed", error: "synthesis timeout", attempts: 3, startedAt: epCreated, finishedAt: epCreated, createdAt: epCreated },
|
||||||
|
});
|
||||||
|
await prisma.aiCostLog.create({
|
||||||
|
data: { provider: "openai", operation: "script", episodeId: ep.id, userId: user.id, units: between(1500, 4000), costUsd: round2(0.03 + Math.random() * 0.08), createdAt: epCreated },
|
||||||
|
});
|
||||||
|
counts.costLogs++;
|
||||||
|
} else {
|
||||||
|
await prisma.generationJob.create({
|
||||||
|
data: { episodeId: ep.id, type: "full", status: status === "DRAFT" ? "queued" : "running", createdAt: epCreated },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
counts.jobs++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) current-period usage counters. UsageRecord.ownerId carries a FK to User
|
||||||
|
// (usage_owner_user_fk), so meter against the user id even for agency members.
|
||||||
|
const ownerId = user.id;
|
||||||
|
const ownerType = "user";
|
||||||
|
const usage: { metric: string; count: number }[] = [
|
||||||
|
{ metric: "script", count: readyThisMonth },
|
||||||
|
{ metric: "audio", count: readyThisMonth },
|
||||||
|
{ metric: "art", count: readyThisMonth },
|
||||||
|
{ metric: "repurpose", count: repurposeThisMonth },
|
||||||
|
];
|
||||||
|
for (const u of usage) {
|
||||||
|
if (u.count <= 0) continue;
|
||||||
|
await prisma.usageRecord.upsert({
|
||||||
|
where: { ownerId_periodKey_metric: { ownerId, periodKey: month, metric: u.metric } },
|
||||||
|
create: { ownerId, ownerType, periodKey: month, metric: u.metric, count: u.count },
|
||||||
|
update: { count: u.count },
|
||||||
|
});
|
||||||
|
counts.usageRows++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6) a few open moderation flags on real READY episodes
|
||||||
|
const readyEpisodes = await prisma.episode.findMany({
|
||||||
|
where: { status: "READY", user: { email: { endsWith: `@${DOMAIN}` } } },
|
||||||
|
select: { id: true },
|
||||||
|
take: 40,
|
||||||
|
});
|
||||||
|
const flagTargets = readyEpisodes.sort(() => Math.random() - 0.5).slice(0, 3);
|
||||||
|
for (const e of flagTargets) {
|
||||||
|
await prisma.contentFlag
|
||||||
|
.create({
|
||||||
|
data: {
|
||||||
|
episodeId: e.id,
|
||||||
|
reason: pick([
|
||||||
|
"Automated moderation flagged: harassment",
|
||||||
|
"Automated moderation flagged: hate",
|
||||||
|
"Automated moderation flagged: violence",
|
||||||
|
]),
|
||||||
|
source: "moderation",
|
||||||
|
severity: pick(["high", "medium"]),
|
||||||
|
status: "open",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(() => counts.flags++)
|
||||||
|
.catch((err) => console.warn(" (content flag skipped:", err.message, ")"));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n✅ Demo data created:");
|
||||||
|
console.table(counts);
|
||||||
|
console.log(`\n Sign in with any of: ${PERSONAS.map((p) => `${p.handle}@${DOMAIN}`).join(", ")}`);
|
||||||
|
console.log(` Password for all: ${PASSWORD}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("Seed failed:", err?.message ?? err);
|
||||||
|
process.exitCode = 1;
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
Reference in New Issue
Block a user