"use server"; import { revalidatePath } from "next/cache"; import { z } from "zod"; import { getServerSession } from "@/lib/auth/guards"; import { prisma } from "@/lib/db"; import { LANGUAGES } from "@/lib/episodes/options"; import { VOICE_CATALOG } from "@/lib/ai/voices"; const VALID_LANGUAGES = new Set(LANGUAGES.map((l) => l.code)); const VALID_VOICES = new Set(VOICE_CATALOG.map((v) => v.id)); const preferencesSchema = z.object({ defaultVoiceId: z.string().nullable().optional(), defaultLanguage: z.string().min(2).max(5).optional(), emailOnEpisodeReady: z.boolean().optional(), productEmails: z.boolean().optional(), }); export type PreferencesInput = z.infer; /** * Persist the current user's editor defaults and notification preferences. * Auth-checked; upserts the single per-user preferences row. Only validated, * known voice/language values are stored. */ export async function savePreferencesAction( input: PreferencesInput ): Promise<{ ok: boolean; error?: string }> { const session = await getServerSession(); if (!session) return { ok: false, error: "You must be signed in." }; const parsed = preferencesSchema.safeParse(input); if (!parsed.success) return { ok: false, error: "Invalid settings." }; const data = parsed.data; if (data.defaultVoiceId && !VALID_VOICES.has(data.defaultVoiceId)) { return { ok: false, error: "Unknown voice." }; } if (data.defaultLanguage && !VALID_LANGUAGES.has(data.defaultLanguage)) { return { ok: false, error: "Unsupported language." }; } const userId = session.user.id; // Normalize "none"/empty voice to null. const defaultVoiceId = data.defaultVoiceId === undefined ? undefined : data.defaultVoiceId || null; await prisma.userPreferences.upsert({ where: { userId }, create: { userId, defaultVoiceId: defaultVoiceId ?? null, defaultLanguage: data.defaultLanguage ?? "en", emailOnEpisodeReady: data.emailOnEpisodeReady ?? true, productEmails: data.productEmails ?? true, }, update: { ...(defaultVoiceId !== undefined ? { defaultVoiceId } : {}), ...(data.defaultLanguage !== undefined ? { defaultLanguage: data.defaultLanguage } : {}), ...(data.emailOnEpisodeReady !== undefined ? { emailOnEpisodeReady: data.emailOnEpisodeReady } : {}), ...(data.productEmails !== undefined ? { productEmails: data.productEmails } : {}), }, }); revalidatePath("/settings"); return { ok: true }; } /** * Permanently delete the current user's account. Auth-checked and gated by a * typed email confirmation that must match the session email. The User delete * cascades to sessions, accounts, episodes, series, usage and preferences. The * client signs out after a successful response. */ export interface ActiveSession { id: string; createdAt: string; expiresAt: string; ipAddress: string | null; userAgent: string | null; current: boolean; } /** * List the signed-in devices for the current user. * * Sessions are read straight from the DB (not the cookie cache) so a revoked * session disappears immediately rather than lingering for the 60s cache window. */ export async function listSessionsAction(): Promise<{ ok: boolean; error?: string; sessions?: ActiveSession[]; }> { const session = await getServerSession(); if (!session) return { ok: false, error: "You must be signed in." }; const rows = await prisma.session.findMany({ where: { userId: session.user.id, expiresAt: { gt: new Date() } }, orderBy: { createdAt: "desc" }, select: { id: true, token: true, createdAt: true, expiresAt: true, ipAddress: true, userAgent: true, }, }); return { ok: true, sessions: rows.map((r) => ({ id: r.id, createdAt: r.createdAt.toISOString(), expiresAt: r.expiresAt.toISOString(), ipAddress: r.ipAddress, userAgent: r.userAgent, // Compare on the session token, never on the id: the token is what the // cookie actually carries, so this is the reliable "this device" marker. current: r.token === session.session.token, })), }; } /** * Revoke one of the current user's sessions (sign out that device). * * Scoped by userId so a session id belonging to someone else can never be * revoked, and the current session is protected — signing yourself out from * here would be indistinguishable from a bug. Use the normal sign-out for that. */ export async function revokeSessionAction( sessionId: string ): Promise<{ ok: boolean; error?: string }> { const session = await getServerSession(); if (!session) return { ok: false, error: "You must be signed in." }; const target = await prisma.session.findFirst({ where: { id: sessionId, userId: session.user.id }, select: { id: true, token: true }, }); if (!target) return { ok: false, error: "Session not found." }; if (target.token === session.session.token) { return { ok: false, error: "That's your current device — use Sign out instead." }; } await prisma.session.delete({ where: { id: target.id } }); revalidatePath("/settings"); return { ok: true }; } /** Sign out every other device, keeping the current one. */ export async function revokeOtherSessionsAction(): Promise<{ ok: boolean; error?: string; count?: number; }> { const session = await getServerSession(); if (!session) return { ok: false, error: "You must be signed in." }; const res = await prisma.session.deleteMany({ where: { userId: session.user.id, token: { not: session.session.token } }, }); revalidatePath("/settings"); return { ok: true, count: res.count }; } /** * Export everything we hold about the current user, as JSON (GDPR access * request, self-serve). * * Deliberately excludes credentials: the `account` table holds password hashes * and OAuth tokens, and nothing there is user-facing data. Media is referenced * by storage key rather than inlined so the payload stays a reasonable size. */ export async function exportMyDataAction(): Promise<{ ok: boolean; error?: string; json?: string; }> { const session = await getServerSession(); if (!session) return { ok: false, error: "You must be signed in." }; const userId = session.user.id; const [user, preferences, episodes, series, subscriptions, usage, apiKeys, memberships] = await Promise.all([ prisma.user.findUnique({ where: { id: userId }, select: { id: true, name: true, email: true, emailVerified: true, image: true, createdAt: true, updatedAt: true, }, }), prisma.userPreferences.findUnique({ where: { userId } }), prisma.episode.findMany({ where: { userId }, orderBy: { createdAt: "desc" }, select: { id: true, title: true, topic: true, tone: true, format: true, language: true, targetLengthMin: true, status: true, shareId: true, createdAt: true, script: { select: { content: true } }, audioAsset: { select: { storageKey: true, durationSec: true, format: true } }, coverArt: { select: { storageKey: true } }, repurposed: { select: { type: true, content: true, createdAt: true } }, }, }), prisma.series.findMany({ where: { userId } }), prisma.subscription.findMany({ where: { referenceId: userId }, select: { plan: true, status: true, billingInterval: true, provider: true, periodStart: true, periodEnd: true, cancelAtPeriodEnd: true, createdAt: true, }, }), prisma.usageRecord.findMany({ where: { ownerId: userId, ownerType: "user" } }), prisma.apiKey.findMany({ where: { userId }, select: { id: true, name: true, createdAt: true, revokedAt: true }, }), prisma.member.findMany({ where: { userId }, select: { role: true, createdAt: true, organization: { select: { name: true } } }, }), ]); const payload = { exportedAt: new Date().toISOString(), note: "Credentials and authentication tokens are intentionally excluded. Audio and cover art are referenced by storage key; download them from each episode page.", user, preferences, episodes, series, subscriptions, usage, apiKeys, memberships, }; return { ok: true, json: JSON.stringify(payload, null, 2) }; } export async function deleteAccountAction( confirmEmail: string ): Promise<{ ok: boolean; error?: string }> { const session = await getServerSession(); if (!session) return { ok: false, error: "You must be signed in." }; if (confirmEmail.trim().toLowerCase() !== session.user.email.toLowerCase()) { return { ok: false, error: "The email you typed doesn't match your account." }; } // Deleting the User row cascades to all owned data via onDelete: Cascade. await prisma.user.delete({ where: { id: session.user.id } }); return { ok: true }; }