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; /** * 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 = { ...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() }, }); } // ─── Following List ─────────────────────────────────────────────────────── /** Return IDs of all users that currentUser follows. No pagination — rarely >1k. */ export async function getFollowingIds(currentUserId: string): Promise { const db = usePrisma(); const rows = await db.userFollow.findMany({ where: { followerId: currentUserId }, select: { followingId: true }, }); return rows.map((r) => r.followingId); } // ─── Presence / Online-Status ───────────────────────────────────────────── // // NOTE: lastSeenAt is added in migration 20260518_add_last_seen_at. // Prisma client is regenerated on staging after `prisma migrate deploy`. // Until then the generated client type doesn't include lastSeenAt — raw SQL // is used here to avoid breaking the tsc build on pre-migration client types. /** * Touch lastSeenAt for the authenticated user (Heartbeat, Phase 1). * Phase 2: replaced by Supabase Edge-Function on presence-leave events. */ export async function touchLastSeen(userId: string): Promise { const db = usePrisma(); const now = new Date(); await db.$executeRaw` UPDATE "rebreak"."profiles" SET "last_seen_at" = ${now} WHERE "id" = ${userId}::uuid `; return now; } /** * Batch-fetch lastSeenAt for up to 50 user IDs. * * Privacy filters applied: * 1. Target's presenceVisible must be true (opt-out respected). * 2. Requester must follow the target (UserFollow row must exist). * * Targets failing either condition are silently omitted — caller maps them * to null in the response, which the frontend interprets as "no indicator". */ export async function getLastSeenBatch( currentUserId: string, userIds: string[], ): Promise<{ id: string; lastSeenAt: Date | null }[]> { const db = usePrisma(); // Step 1: which of the requested IDs does currentUser actually follow? const follows = await db.userFollow.findMany({ where: { followerId: currentUserId, followingId: { in: userIds } }, select: { followingId: true }, }); const followingSet = new Set(follows.map((f) => f.followingId)); // Step 2: only query profiles that are followed AND have visibility enabled const visibleIds = userIds.filter((id) => followingSet.has(id)); if (visibleIds.length === 0) return []; const rows = await db.$queryRaw<{ id: string; last_seen_at: Date | null }[]>` SELECT "id", "last_seen_at" FROM "rebreak"."profiles" WHERE "id" = ANY(${visibleIds}::uuid[]) AND "presence_visible" = true `; return rows.map((r) => ({ id: r.id, lastSeenAt: r.last_seen_at })); } // ─── MDM-Managed Flag ──────────────────────────────────────────────────────── /** * Set or clear the mdmManaged flag on a profile. * On the first true-write, mdmDetectedAt is stamped (never overwritten). * On false-write, mdmDetectedAt is left intact as audit trail. */ export async function setMdmManaged( userId: string, mdmManaged: boolean, ): Promise<{ mdmManaged: boolean; mdmDetectedAt: Date | null }> { const db = usePrisma(); return db.$transaction(async (tx) => { const current = await tx.profile.findUnique({ where: { id: userId }, select: { mdmManaged: true, mdmDetectedAt: true }, }); if (!current) { throw createError({ statusCode: 404, data: { error: "PROFILE_NOT_FOUND" } }); } const data: Record = { mdmManaged }; // Stamp mdmDetectedAt only on first true-write, never overwrite. if (mdmManaged && !current.mdmDetectedAt) { data.mdmDetectedAt = new Date(); } const updated = await tx.profile.update({ where: { id: userId }, data, select: { mdmManaged: true, mdmDetectedAt: true }, }); return { mdmManaged: updated.mdmManaged, mdmDetectedAt: updated.mdmDetectedAt }; }); } /** Update presence_visible opt-out toggle for a user. */ export async function setCallsEnabled( userId: string, enabled: boolean, ): Promise<{ callsEnabled: boolean }> { const db = usePrisma(); await db.profile.update({ where: { id: userId }, data: { callsEnabled: enabled }, select: { callsEnabled: true }, }); return { callsEnabled: enabled }; } export async function setPresenceVisible( userId: string, visible: boolean, ): Promise<{ presenceVisible: boolean }> { const db = usePrisma(); // Raw SQL — presenceVisible added in 20260518_add_presence_fields, // Prisma client regenerated on staging after migrate deploy. await db.$executeRaw` UPDATE "rebreak"."profiles" SET "presence_visible" = ${visible} WHERE "id" = ${userId}::uuid `; return { presenceVisible: visible }; }