92 lines
3.3 KiB
TypeScript
92 lines
3.3 KiB
TypeScript
|
|
"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<string>(LANGUAGES.map((l) => l.code));
|
||
|
|
const VALID_VOICES = new Set<string>(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<typeof preferencesSchema>;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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 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 };
|
||
|
|
}
|