diff --git a/backend/server/api/auth/me.get.ts b/backend/server/api/auth/me.get.ts index 332d405..a0fc749 100644 --- a/backend/server/api/auth/me.get.ts +++ b/backend/server/api/auth/me.get.ts @@ -22,6 +22,7 @@ export default defineEventHandler(async (event) => { plan, foundingMember: dbProfile?.foundingMember ?? false, streak: dbProfile?.streak ?? 0, + lyraVoiceId: dbProfile?.lyraVoiceId ?? null, created_at: dbProfile?.createdAt?.toISOString() ?? user.created_at, // Für useUserPlan im Frontend — Key-Subset der PlanLimits planLimits: { diff --git a/backend/server/api/coach/speak.post.ts b/backend/server/api/coach/speak.post.ts index 238337f..0ee89c9 100644 --- a/backend/server/api/coach/speak.post.ts +++ b/backend/server/api/coach/speak.post.ts @@ -46,9 +46,11 @@ export default defineEventHandler(async (event) => { const db = usePrisma(); const profile = await db.profile.findUnique({ where: { id: user.id }, - select: { plan: true }, + select: { plan: true, lyraVoiceId: true }, }); const plan = (profile?.plan ?? "free").toLowerCase(); + // lyraVoiceId nur für legend wirksam — plan-check im speakElevenLabs + const userLyraVoiceId = plan === "legend" ? (profile?.lyraVoiceId ?? null) : null; const limits = getPlanLimits(plan); const voiceCfg = limits.voice; @@ -78,7 +80,7 @@ export default defineEventHandler(async (event) => { case "cartesia": return await speakCartesia(event, trimmed, config, voiceCfg, user.id, plan); case "elevenlabs": - return await speakElevenLabs(event, trimmed, mode, config, voiceCfg, user.id, plan); + return await speakElevenLabs(event, trimmed, mode, config, voiceCfg, user.id, plan, userLyraVoiceId); default: { // Unknown provider in config — fallback to Google with warning console.warn("[speak] unknown provider in plan-features:", voiceCfg.provider, "→ falling back to google"); @@ -211,6 +213,7 @@ async function speakElevenLabs( voiceCfg: VoiceConfig, userId: string, plan: string, + userLyraVoiceId: string | null = null, ) { const key = (config.elevenlabsApiKey as string) || process.env.ELEVENLABS_API_KEY || ""; @@ -219,7 +222,9 @@ async function speakElevenLabs( } const ELEVENLABS_FALLBACK_VOICE = "kdmDKE6EkgrWrrykO9Qt"; // Alexandra + // User-Voice hat höchste Priorität (bereits plan-gefiltert vom Caller) const voiceId = + userLyraVoiceId || voiceCfg.voiceId || (config.elevenlabsVoiceId as string) || process.env.ELEVENLABS_VOICE_ID || diff --git a/backend/server/api/profile/me/lyra-voice.patch.ts b/backend/server/api/profile/me/lyra-voice.patch.ts new file mode 100644 index 0000000..4d43c5b --- /dev/null +++ b/backend/server/api/profile/me/lyra-voice.patch.ts @@ -0,0 +1,43 @@ +import { setLyraVoiceId, isAllowedLyraVoiceId } from "../../../db/profile"; + +/** + * PATCH /api/profile/me/lyra-voice + * + * Legend-only. Setzt die ElevenLabs-Voice-ID für Lyra-TTS. + * + * Body: { lyraVoiceId: string | null } + * null → Default-Voice (kdmDKE6EkgrWrrykO9Qt, Alexandra) + * iFSsEDGbm0FiEd2IVH4w → Voice 1 + * Gt7OshJCH7MuzX96wFHi → Voice 2 + * + * Plan-Downgrade-Verhalten: DB-Wert wird NICHT gelöscht. speak.post.ts + * prüft plan zur Laufzeit — non-legend User erhalten Default-Voice. + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + + const db = usePrisma(); + const profile = await db.profile.findUnique({ + where: { id: user.id }, + select: { plan: true }, + }); + + if (!profile) { + throw createError({ statusCode: 404, statusMessage: "profile_not_found" }); + } + + if ((profile.plan ?? "free").toLowerCase() !== "legend") { + throw createError({ statusCode: 403, statusMessage: "legend_only" }); + } + + const body = await readBody(event); + const { lyraVoiceId } = body as { lyraVoiceId?: unknown }; + + if (!isAllowedLyraVoiceId(lyraVoiceId)) { + throw createError({ statusCode: 400, statusMessage: "invalid_voice_id" }); + } + + await setLyraVoiceId(user.id, lyraVoiceId); + + return { success: true, data: { lyraVoiceId } }; +}); diff --git a/backend/server/db/profile.ts b/backend/server/db/profile.ts index 51977b8..fac5944 100644 --- a/backend/server/db/profile.ts +++ b/backend/server/db/profile.ts @@ -204,6 +204,29 @@ export async function tryAwardProTrial( }); } +// ─── Lyra Voice-Picker ──────────────────────────────────────────────────── + +const ALLOWED_LYRA_VOICE_IDS = [ + null, + "iFSsEDGbm0FiEd2IVH4w", // Voice 1 + "Gt7OshJCH7MuzX96wFHi", // Voice 2 +] as const; + +export type LyraVoiceId = (typeof ALLOWED_LYRA_VOICE_IDS)[number]; + +export function isAllowedLyraVoiceId(value: unknown): value is LyraVoiceId { + return (ALLOWED_LYRA_VOICE_IDS as readonly unknown[]).includes(value); +} + +export async function setLyraVoiceId(userId: string, voiceId: LyraVoiceId) { + const db = usePrisma(); + return db.profile.update({ + where: { id: userId }, + data: { lyraVoiceId: voiceId }, + select: { lyraVoiceId: true }, + }); +} + // ─── Banner / Install-Event ──────────────────────────────────────────────── export async function dismissDigaBanner(userId: string) {