Retarget deployment from Plesk to Dokploy (Docker Compose)

This commit is contained in:
Leon Serfaty
2026-06-07 18:30:53 -04:00
parent f033f00379
commit 8138827657
16 changed files with 1001 additions and 170 deletions
+60
View File
@@ -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());
+333
View File
@@ -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());