Schema: - 8 neue Profile-Felder fuer DiGA-Demographics (birthYear/gender/maritalStatus/ profession/bundesland/city + 2 consent-stamps demographicsConsentAt/ demographicsWithdrawnAt) - 4 Pro-Trial-Felder (proTrialStartedAt/ExpiresAt/Source/UsedAt) — Free-User bekommen 1 Woche Pro als Reward fuer DiGA-Daten-Pflege (siehe project_demographic_pro_trial_reward.md) - lyra_voice_id (Legend-only Voice-Picker) - diga_banner_dismissed_at (server-side persistence ueber Re-Install) - last_install_at (Streak-Logic survives Re-Install) - Migration 20260507_profile_demographics_and_trial: alle Felder optional, keine Backfill-Logik notwendig Endpoints (alle auth-protected, scope=me): - GET /api/profile/me/sos-insights - GET /api/profile/me/cooldown-history - GET /api/profile/me/approved-domains - POST /api/profile/me/install-event (track app re-installs) - POST /api/profile/me/diga-banner-dismiss - PATCH /api/profile/me/demographics (consent-stamp + re-grant-after-withdrawal in tx) - DELETE /api/profile/me/demographics (DSGVO right-to-be-forgotten) Plugin: - pro-trial-expiry-cron: 6h-Interval, conservative-fallback (revoke nur wenn kein stripeSubId), 60s initial-delay damit Server-boot nicht blockiert wird Tests: - vitest config + erste Test-Files (test-infrastructure setup) Memory: - feedback_demographics_user_initiated.md (Lyra darf NIE extrahieren) - project_demographic_pro_trial_reward.md (Pro-Trial-Reward-Mechanik) - project_profile_page_design.md (UI-Showpiece, eigene/fremde-Ansicht streng getrennt) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
180 lines
5.4 KiB
TypeScript
180 lines
5.4 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 } });
|
|
}
|
|
|
|
// ─── DiGA-Demographie ──────────────────────────────────────────────────────
|
|
|
|
export type DemographicsFields = {
|
|
birthYear: number | null;
|
|
gender: string | null;
|
|
maritalStatus: string | null;
|
|
profession: 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,
|
|
bundesland: null,
|
|
city: null,
|
|
demographicsWithdrawnAt: new Date(),
|
|
// demographicsConsentAt bleibt — Audit-Trail dass User mal eingewilligt hat
|
|
},
|
|
});
|
|
}
|
|
|
|
// ─── 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,
|
|
profession: 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 };
|
|
|
|
// All 6 fields must be non-null/non-empty
|
|
const requiredFilled =
|
|
profile.birthYear != null &&
|
|
!!profile.gender &&
|
|
!!profile.maritalStatus &&
|
|
!!profile.profession &&
|
|
!!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 };
|
|
});
|
|
}
|
|
|
|
// ─── 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() },
|
|
});
|
|
}
|