Retarget deployment from Plesk to Dokploy (Docker Compose)
This commit is contained in:
@@ -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