Insta-style Online-Status mit Following-Filter + User-opt-out: - Profile.lastSeenAt + Profile.presenceVisible (default true) - GET /api/presence/last-seen?userIds=... batch, server-side filter durch Follow-Relation + presenceVisible - GET /api/me/following → User-IDs für client-side Channel-Filter (Supabase Realtime Presence hat keine server-side Filter) - POST /api/me/presence-visibility Toggle - POST /api/me/last-seen Heartbeat (Phase-1-Fallback bis Edge-Function) - /api/auth/me extended um presenceVisible für Settings-Initial-State DB-Layer nutzt raw SQL bis Migration auf staging gelaufen ist (Prisma-Client refresh erst nach CI generate). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
370 lines
12 KiB
TypeScript
370 lines
12 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() },
|
|
});
|
|
}
|
|
|
|
// ─── Following List ───────────────────────────────────────────────────────
|
|
|
|
/** Return IDs of all users that currentUser follows. No pagination — rarely >1k. */
|
|
export async function getFollowingIds(currentUserId: string): Promise<string[]> {
|
|
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<Date> {
|
|
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 }));
|
|
}
|
|
|
|
/** Update presence_visible opt-out toggle for a user. */
|
|
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 };
|
|
}
|