chahinebrini b23bd6d29f feat(onboarding,protection): Duo-style flow + cooldown auto-disable fix + Family Controls live
## Duo-Style Onboarding (Foundation + alle Slides)

Self-contained Onboarding-Flow mit Lyra-Mascot ersetzt das Spotlight-POC vom
vorherigen Iteration. Slides leben unter `components/onboarding/slides/`.

- Foundation: OnboardingShell (Progress + ScrollView + sticky CTABar),
  LyraBubble (Rive-Avatar + animierte Speech-Bubble), SlideProgress, CTABar
- Slides: Welcome, Privacy (4 Versprechen), Nickname (inline + PATCH /me),
  DigaChoice (Ja/Nein-Branch), DigaCode (redeem-Endpoint + inline-Errors),
  Plan (Pro/Legend cards, monthly/yearly toggle, 2 Monate gratis, Härtefall-
  Mailto), Payment (RevenueCat-Dev-Stub bis Phase-0), Protection (activate +
  PermissionDeniedSheet-Wiring), Done (animierter Checkmark + Streak-Day-1)
- State-Machine in app/onboarding/index.tsx: 9 Slides, DiGA-Branch, Resume-
  on-launch via slideFromStep(me.onboardingStep)
- Routing-gate in (app)/_layout.tsx: step != 'done' → /onboarding
- Backend Profile.onboardingStep enum extended:
  welcome | account | plan | pre_protection | done (+ legacy nickname/block)
- Backend diga redeem: step='pre_protection' (NICHT 'done') — User muss noch
  durch Protection-Slide für NEFilter/VPN-Aktivierung
- Locale-Keys (de/en/fr/ar): onboarding.lyra.<slide>.body, .cta_primary,
  Plan-Tier-Details (3,99/7,99 €/Mo, 39,90/79,90 €/Jahr mit 2 Monaten gratis),
  Härtefall-Link, DiGA-Code-Errors, Protection-Feat-Descriptions

## Cooldown Auto-Disable Race-Fix

Bug: nach Cooldown-Ablauf bleib URL-Filter installiert (NEFilter in iOS-
Settings sichtbar als "Läuft..."). Root-cause: `/api/cooldown/status` GET
auto-resolved beim ersten expired-Hit; zweiter Call in
applyCooldownDisableIfElapsed sah cooldownEndsAt=null → bail → forceDisable
nie aufgerufen.

- useProtectionState.fetchState: lokalen next.cooldown.endsAt state nutzen
  statt redundantem API-Call. Atomarer, race-frei.
- AppState-Listener-Path unverändert (dort ist es der erste API-Call, kein
  Race).
- lib/protection.forceDisable: console.log für Debug-Visibility.

## iOS NEFilter Robust-Disable (Native)

`removeFromPreferences()` alleine ist auf iOS 18+ unzuverlässig — Settings-
UI zeigt "Läuft..." obwohl Provider beendet sein sollte. 2-Step-Pattern:

  1. loadFromPreferences
  2. isEnabled = false + saveToPreferences (stoppt Filter-Daemon)
  3. removeFromPreferences (Config-Eintrag aus Settings)

Quelle: Apple-Developer-Forums + eigene Empirie. Pattern wird auch in
PermissionDeniedSheet's resetUrlFilter genutzt (analog).

## Family Controls jetzt immer aktiv

Apple-Entitlement seit 2026-05 für ReBreak approved (TestFlight-akzeptiert).
`familyControlsEnabled: true` hart in app.config.ts (kein Env-Var-Gating mehr).
"Bald verfügbar"-Placeholder in blocker.tsx entfernt — App-Lock-Toggle ist
jetzt voll funktional auf iOS.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 17:48:05 +02:00

284 lines
9.0 KiB
TypeScript

import { usePrisma } from "../utils/prisma";
export async function getProfile(userId: string) {
const db = usePrisma();
return db.profile.findUnique({ where: { id: userId } });
}
export async function updateProfile(
userId: string,
data: Partial<{
username: string | null;
nickname: string | null;
avatar: string | null;
}>,
) {
const db = usePrisma();
return db.profile.update({ where: { id: userId }, data });
}
export async function deleteProfile(userId: string) {
const db = usePrisma();
return db.profile.delete({ where: { id: userId } });
}
// ─── Onboarding-Step ────────────────────────────────────────────────────────
// Onboarding-Milestones im Duo-Style-Flow (siehe app/onboarding/index.tsx):
// welcome → Flow noch nicht angefangen (Default für neue Profile)
// account → Nickname gesetzt
// plan → Trial/Sub gewählt (vor Payment)
// pre_protection → Payment confirmed, Protection-Slide noch offen
// done → komplett abgeschlossen
//
// Legacy-Werte 'nickname' und 'block' werden im Backend noch akzeptiert für
// Backwards-Compat (alte Builds in TestFlight), aber im neuen Flow nicht mehr
// geschrieben. Können nach allen-User-Force-Update entfernt werden.
export const ONBOARDING_STEPS = [
"welcome",
"account",
"plan",
"pre_protection",
"done",
// legacy (kept readable to not break old clients):
"nickname",
"block",
] as const;
export type OnboardingStep = (typeof ONBOARDING_STEPS)[number];
export function isOnboardingStep(value: unknown): value is OnboardingStep {
return typeof value === "string" && (ONBOARDING_STEPS as readonly string[]).includes(value);
}
export async function setOnboardingStep(userId: string, step: OnboardingStep) {
const db = usePrisma();
return db.profile.update({
where: { id: userId },
data: { onboardingStep: step },
select: { onboardingStep: true },
});
}
// ─── DiGA-Demographie ──────────────────────────────────────────────────────
export type DemographicsFields = {
birthYear: number | null;
gender: string | null;
maritalStatus: string | null;
// profession is legacy — kept for backwards-compat with old frontend versions
profession: string | null;
employmentStatus: string | null;
shiftWork: boolean | null;
industry: string | null;
jobTenure: string | null;
bundesland: string | null;
city: string | null;
};
export type DemographicsPatch = Partial<DemographicsFields>;
/**
* Update demographic fields. Sets `demographicsConsentAt = NOW()` on first
* non-null write. Returns full updated row.
*/
export async function updateDemographics(
userId: string,
patch: DemographicsPatch,
) {
const db = usePrisma();
const data: Record<string, unknown> = { ...patch };
// First-touch consent stamp: only set if currently null AND at least one
// non-null field is being written. Read-modify-write inside a tx so two
// concurrent updates don't race the consent stamp.
return db.$transaction(async (tx) => {
const current = await tx.profile.findUnique({
where: { id: userId },
select: {
demographicsConsentAt: true,
demographicsWithdrawnAt: true,
},
});
if (!current) {
throw createError({ statusCode: 404, message: "Profil nicht gefunden" });
}
const hasAnyValue = Object.values(patch).some(
(v) => v !== null && v !== undefined,
);
if (hasAnyValue && !current.demographicsConsentAt) {
data.demographicsConsentAt = new Date();
}
// Re-grant after withdrawal: clear withdrawn marker
if (hasAnyValue && current.demographicsWithdrawnAt) {
data.demographicsWithdrawnAt = null;
}
return tx.profile.update({ where: { id: userId }, data });
});
}
/** Withdraw demographics — null all fields, stamp withdrawal, keep consent-audit. */
export async function withdrawDemographics(userId: string) {
const db = usePrisma();
return db.profile.update({
where: { id: userId },
data: {
birthYear: null,
gender: null,
maritalStatus: null,
profession: null, // legacy field — also cleared for completeness
employmentStatus: null,
shiftWork: null,
industry: null,
jobTenure: null,
bundesland: null,
city: null,
demographicsWithdrawnAt: new Date(),
// demographicsConsentAt bleibt — Audit-Trail dass User mal eingewilligt hat
},
});
}
/** Read demographic fields + consent-state for the current user. */
export async function getDemographics(userId: string) {
const db = usePrisma();
const row = await db.profile.findUnique({
where: { id: userId },
select: {
birthYear: true,
gender: true,
maritalStatus: true,
employmentStatus: true,
shiftWork: true,
industry: true,
jobTenure: true,
bundesland: true,
city: true,
demographicsConsentAt: true,
demographicsWithdrawnAt: true,
},
});
if (!row) throw createError({ statusCode: 404, message: "Profil nicht gefunden" });
return {
birthYear: row.birthYear,
gender: row.gender,
maritalStatus: row.maritalStatus,
employmentStatus: row.employmentStatus,
shiftWork: row.shiftWork,
industry: row.industry,
jobTenure: row.jobTenure,
bundesland: row.bundesland,
city: row.city,
consentAt: row.demographicsConsentAt?.toISOString() ?? null,
withdrawnAt: row.demographicsWithdrawnAt?.toISOString() ?? null,
};
}
// ─── Pro-Trial-Reward ──────────────────────────────────────────────────────
export const PRO_TRIAL_DAYS = 7;
/**
* Award a 7-day Pro trial — only if all 6 demographic fields filled,
* plan is currently 'free', and trial has never been used.
*
* Idempotent. Returns the awarded trial record or null if not eligible.
*/
export async function tryAwardProTrial(
userId: string,
source = "demographics_complete",
): Promise<{ trialAwarded: boolean; expiresAt: Date | null }> {
const db = usePrisma();
return db.$transaction(async (tx) => {
const profile = await tx.profile.findUnique({
where: { id: userId },
select: {
plan: true,
proTrialUsedAt: true,
birthYear: true,
gender: true,
maritalStatus: true,
employmentStatus: true,
bundesland: true,
city: true,
},
});
if (!profile) return { trialAwarded: false, expiresAt: null };
// Once-per-user
if (profile.proTrialUsedAt) return { trialAwarded: false, expiresAt: null };
// Already paid plan → no trial needed
const plan = (profile.plan ?? "free").toLowerCase();
if (plan !== "free") return { trialAwarded: false, expiresAt: null };
// Core 6 fields must be non-null/non-empty (employmentStatus replaces profession)
const requiredFilled =
profile.birthYear != null &&
!!profile.gender &&
!!profile.maritalStatus &&
!!profile.employmentStatus &&
!!profile.bundesland &&
!!profile.city;
if (!requiredFilled) return { trialAwarded: false, expiresAt: null };
const startedAt = new Date();
const expiresAt = new Date(
startedAt.getTime() + PRO_TRIAL_DAYS * 24 * 60 * 60 * 1000,
);
await tx.profile.update({
where: { id: userId },
data: {
plan: "pro",
proTrialStartedAt: startedAt,
proTrialExpiresAt: expiresAt,
proTrialSource: source,
proTrialUsedAt: startedAt,
},
});
return { trialAwarded: true, expiresAt };
});
}
// ─── 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) {
const db = usePrisma();
return db.profile.update({
where: { id: userId },
data: { digaBannerDismissedAt: new Date() },
});
}
export async function recordInstallEvent(userId: string) {
const db = usePrisma();
return db.profile.update({
where: { id: userId },
data: { lastInstallAt: new Date() },
});
}