chahinebrini b40b8465b9 feat(lyra,voice): founder-story + voice-tier-mapping + quota system
Two features in one push (both backend, deploy together):

LYRA FOUNDER-STORY (per strategist Option C — mixed/medium-detail):
- COACH_CASUAL_SYSTEM_PROMPT: GRÜNDER-STORY sub-block
  - Sharing-rules: ALWAYS on direct ask, RARELY proactive (only on
    explicit isolation expressions "niemand versteht das"), NEVER in
    SOS-mode, NEVER first-3-msgs, NEVER if user appears minor
  - Detail-level: "aus persönlicher Erfahrung mit Spielsucht in seiner
    Familie" — KEINE Namen, Verwandtschaftsgrade, Verlust-Details
  - Post-share-pivot: "...aber jetzt zu dir: was ist gerade los?"
- COACH_SYSTEM_PROMPT (SOS): SOS-MODE LOCK — hard-Verbot Gründer-Story
  zu erwähnen, auch bei direct-ask. Re-trigger-Risk zu hoch.
- DSGVO: brother bleibt komplett anonymisiert. Hans-Müller-DSB-review für
  verbal-consent-doc empfohlen.

VOICE TIER-MAPPING (per user-decision: voice für ALLE tiers):
- New plan-features.voice config: provider + model + voiceId + dailyQuotaSeconds
- Tier-mapping:
  - Free  → Google TTS Neural2-F (de-DE), 60s/day,  ~$4/1M chars
  - Pro   → Cartesia Sonic-2,            300s/day,  ~$4/1M chars + ~75ms TTFT
  - Legend → ElevenLabs Turbo v2.5,      unlimited, ~$30/1M chars
- New backend/server/db/voiceQuota.ts:
  - getRemainingVoiceQuota(userId, plan)
  - consumeVoiceQuota(userId, seconds)
  - estimateAudioSeconds(text)
- speak.post.ts komplett umgeschrieben als plan-aware dispatcher
- 14 tests passing (partial-consume, exhausted, day-rollover, edge-cases)
- Schema-migration 20260509_voice_quota:
  ADD voice_seconds_used_today, voice_quota_reset_at to profiles
  (auto-deploy via pipeline)

Pending Frontend (separate task):
- Voice-quota-UI in Settings/Profile (remaining seconds + upgrade-prompt
  bei 429 quota_exceeded)

⚠️ Schema-migration auto-deploy via b38bf17 detection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:28:36 +02:00

105 lines
2.9 KiB
TypeScript

/**
* Voice Quota DB-Layer
*
* Tracks daily TTS seconds per user. Quota resets automatically when the
* calendar day (UTC) changes since the last reset.
*
* Schema fields on `profiles`:
* voice_seconds_used_today INTEGER NOT NULL DEFAULT 0
* voice_quota_reset_at TIMESTAMP
*/
import { usePrisma } from "../utils/prisma";
import { getPlanLimits } from "../utils/plan-features";
/** Midnight UTC for the current day (used as reset boundary). */
function todayUtcMidnight(): Date {
const d = new Date();
d.setUTCHours(0, 0, 0, 0);
return d;
}
/**
* Returns remaining quota seconds for the user today.
* Automatically resets counter if last reset was before today (UTC).
* Returns Infinity when dailyQuotaSeconds === 0 (Legend unlimited).
*/
export async function getRemainingVoiceQuota(
userId: string,
plan: string,
): Promise<number> {
const limits = getPlanLimits(plan);
const { dailyQuotaSeconds } = limits.voice;
// Unlimited plan — short-circuit
if (dailyQuotaSeconds === 0) return Infinity;
const db = usePrisma();
const midnight = todayUtcMidnight();
const profile = await db.profile.findUnique({
where: { id: userId },
select: {
voiceSecondsUsedToday: true,
voiceQuotaResetAt: true,
},
});
if (!profile) throw createError({ statusCode: 404, message: "Profil nicht gefunden" });
// Reset if no reset-stamp yet OR stamp is before today's midnight UTC
const needsReset =
!profile.voiceQuotaResetAt ||
profile.voiceQuotaResetAt < midnight;
if (needsReset) {
// Idempotent: even if two concurrent requests hit this, the result is the
// same full quota — no race condition risk here.
await db.profile.update({
where: { id: userId },
data: {
voiceSecondsUsedToday: 0,
voiceQuotaResetAt: midnight,
},
});
return dailyQuotaSeconds;
}
const used = profile.voiceSecondsUsedToday ?? 0;
return Math.max(0, dailyQuotaSeconds - used);
}
/**
* Increment the used-seconds counter. No-op for unlimited plans (Legend).
* Does NOT validate against quota — caller must check `getRemainingVoiceQuota`
* first.
*/
export async function consumeVoiceQuota(
userId: string,
plan: string,
seconds: number,
): Promise<void> {
const limits = getPlanLimits(plan);
if (limits.voice.dailyQuotaSeconds === 0) return; // unlimited — no tracking needed
const db = usePrisma();
const midnight = todayUtcMidnight();
await db.profile.update({
where: { id: userId },
data: {
voiceSecondsUsedToday: { increment: Math.max(0, Math.round(seconds)) },
// Ensure reset-stamp is set (idempotent with getRemainingVoiceQuota reset)
voiceQuotaResetAt: midnight,
},
});
}
/**
* Estimate audio duration in seconds for a given text length.
* Rule of thumb: German TTS ~13 chars/sec at default speed.
* Minimum 1 second.
*/
export function estimateAudioSeconds(text: string): number {
return Math.max(1, Math.ceil(text.length / 13));
}