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>
105 lines
2.9 KiB
TypeScript
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));
|
|
}
|