feat(profile): DiGA-Demographics + Pro-Trial-Reward + 7 Profile-Endpoints
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>
This commit is contained in:
parent
2f3b19f71b
commit
cddc4d0f26
@ -8,7 +8,10 @@
|
||||
"dev": "nitro dev",
|
||||
"preview": "node .output/server/index.mjs",
|
||||
"start": "node .output/server/index.mjs",
|
||||
"prisma:generate": "prisma generate --schema prisma/schema.prisma"
|
||||
"prisma:generate": "prisma generate --schema prisma/schema.prisma",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.2.0",
|
||||
@ -26,9 +29,11 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/pg": "^8.11.10",
|
||||
"@vitest/coverage-v8": "^2.1.0",
|
||||
"h3": "^1.15.4",
|
||||
"nitropack": "^2.12.4",
|
||||
"prisma": "^7.2.0",
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
-- Profile-Page Phase C — Demographics, Pro-Trial-Reward, Lyra-Voice, DiGA-Banner-Persistence
|
||||
-- Backed by `model Profile` extensions in schema.prisma.
|
||||
--
|
||||
-- Drift-Hinweis: Diese Migration wird via `pnpm prisma migrate deploy` auf
|
||||
-- staging-/prod-DB gefahren. Lokal NICHT ausführen. Falls Drift erkannt wird:
|
||||
-- pnpm prisma migrate resolve --applied 20260507_profile_demographics_and_trial
|
||||
|
||||
ALTER TABLE "rebreak"."profiles"
|
||||
ADD COLUMN IF NOT EXISTS "birth_year" INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS "gender" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "marital_status" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "profession" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "bundesland" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "city" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "demographics_consent_at" TIMESTAMP(3),
|
||||
ADD COLUMN IF NOT EXISTS "demographics_withdrawn_at" TIMESTAMP(3),
|
||||
ADD COLUMN IF NOT EXISTS "lyra_voice_id" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "pro_trial_started_at" TIMESTAMP(3),
|
||||
ADD COLUMN IF NOT EXISTS "pro_trial_expires_at" TIMESTAMP(3),
|
||||
ADD COLUMN IF NOT EXISTS "pro_trial_source" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "pro_trial_used_at" TIMESTAMP(3),
|
||||
ADD COLUMN IF NOT EXISTS "diga_banner_dismissed_at" TIMESTAMP(3),
|
||||
ADD COLUMN IF NOT EXISTS "last_install_at" TIMESTAMP(3);
|
||||
|
||||
-- Cron-Helper-Index: schneller find-by-expiry für Trial-Revoke-Cron
|
||||
CREATE INDEX IF NOT EXISTS "profiles_pro_trial_expires_at_idx"
|
||||
ON "rebreak"."profiles"("pro_trial_expires_at")
|
||||
WHERE "pro_trial_expires_at" IS NOT NULL;
|
||||
@ -23,6 +23,34 @@ model Profile {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
|
||||
// ─── DiGA-Demographie (optional, user-initiated only) ───────────────────
|
||||
// Diese Felder werden ausschließlich vom User über die Profile-Form
|
||||
// gesetzt — niemals durch Lyra-Extraction oder Memory-Inference.
|
||||
// Siehe memory/feedback_demographics_user_initiated.md
|
||||
birthYear Int? @map("birth_year")
|
||||
gender String?
|
||||
maritalStatus String? @map("marital_status")
|
||||
profession String?
|
||||
bundesland String?
|
||||
city String?
|
||||
demographicsConsentAt DateTime? @map("demographics_consent_at")
|
||||
demographicsWithdrawnAt DateTime? @map("demographics_withdrawn_at")
|
||||
|
||||
// ─── Lyra Voice-Picker (Legend-only Premium) ────────────────────────────
|
||||
lyraVoiceId String? @map("lyra_voice_id")
|
||||
|
||||
// ─── Pro-Trial-Reward (für Demographics-completion) ─────────────────────
|
||||
proTrialStartedAt DateTime? @map("pro_trial_started_at")
|
||||
proTrialExpiresAt DateTime? @map("pro_trial_expires_at")
|
||||
proTrialSource String? @map("pro_trial_source")
|
||||
proTrialUsedAt DateTime? @map("pro_trial_used_at")
|
||||
|
||||
// ─── DiGA-Banner Persistence (server-side, überlebt Re-Install) ─────────
|
||||
digaBannerDismissedAt DateTime? @map("diga_banner_dismissed_at")
|
||||
|
||||
// ─── Install-Tracking (für Streak: max(last_resolved_cooldown, last_install)) ──
|
||||
lastInstallAt DateTime? @map("last_install_at")
|
||||
|
||||
communityPosts CommunityPost[]
|
||||
communityReplies CommunityReply[]
|
||||
|
||||
|
||||
48
backend/server/api/profile/me/approved-domains.get.ts
Normal file
48
backend/server/api/profile/me/approved-domains.get.ts
Normal file
@ -0,0 +1,48 @@
|
||||
/**
|
||||
* GET /api/profile/me/approved-domains
|
||||
*
|
||||
* Liste der vom User submitten und vom Admin genehmigten Domains
|
||||
* (= Community-Beitrag-Benchmark, siehe project_profile_page_design.md §2).
|
||||
*
|
||||
* Source: domain_submissions WHERE userId = me AND status = 'approved'
|
||||
* (NICHT user_custom_domains — domain_submissions ist source of truth für
|
||||
* "von dir submitted und approved").
|
||||
*
|
||||
* Response shape:
|
||||
* { count: number, list: Array<{ domain, approvedAt }> }
|
||||
*
|
||||
* Sortiert: approvedAt DESC. Cap 100 (UI rendert max 100, mehr braucht
|
||||
* Pagination — kann später additiv kommen).
|
||||
*/
|
||||
import { requireUser } from "../../../utils/auth";
|
||||
import { usePrisma } from "../../../utils/prisma";
|
||||
|
||||
const MAX_LIST_ITEMS = 100;
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUser(event);
|
||||
const db = usePrisma();
|
||||
|
||||
const [count, rows] = await Promise.all([
|
||||
db.domainSubmission.count({
|
||||
where: { userId: user.id, status: "approved" },
|
||||
}),
|
||||
db.domainSubmission.findMany({
|
||||
where: { userId: user.id, status: "approved" },
|
||||
orderBy: { reviewedAt: "desc" },
|
||||
take: MAX_LIST_ITEMS,
|
||||
select: { domain: true, reviewedAt: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
count,
|
||||
list: rows.map((r) => ({
|
||||
domain: r.domain,
|
||||
approvedAt: r.reviewedAt?.toISOString() ?? null,
|
||||
})),
|
||||
},
|
||||
};
|
||||
});
|
||||
100
backend/server/api/profile/me/cooldown-history.get.ts
Normal file
100
backend/server/api/profile/me/cooldown-history.get.ts
Normal file
@ -0,0 +1,100 @@
|
||||
/**
|
||||
* GET /api/profile/me/cooldown-history?cursor=<id>&limit=20
|
||||
*
|
||||
* Cursor-paginated Cooldown-Historie. Frontend nutzt für Profile-Streak-Section
|
||||
* (project_profile_page_design.md §3 — Cooldown-Timeline).
|
||||
*
|
||||
* Status-Berechnung:
|
||||
* - resolvedAt set → 'resolved'
|
||||
* - cancelledAt set → 'cancelled'
|
||||
* - cooldownEndsAt > now → 'active'
|
||||
* - cooldownEndsAt <= now & no resolved/cancelled → 'expired' (auto-resolved
|
||||
* beim nächsten /cooldown/status-call, hier als 'resolved' angezeigt zur
|
||||
* UX-Konsistenz)
|
||||
*
|
||||
* Response:
|
||||
* { items: CooldownEntry[], nextCursor?: string }
|
||||
*
|
||||
* Pagination via opaque cursor (= last item's id). Frontend persistiert
|
||||
* cursor und schickt zurück. Limit 20 (max 50).
|
||||
*/
|
||||
import { requireUser } from "../../../utils/auth";
|
||||
import { usePrisma } from "../../../utils/prisma";
|
||||
|
||||
const DEFAULT_LIMIT = 20;
|
||||
const MAX_LIMIT = 50;
|
||||
|
||||
type CooldownEntry = {
|
||||
id: string;
|
||||
startedAt: string;
|
||||
cooldownEndsAt: string;
|
||||
durationMinutes: number;
|
||||
status: "active" | "resolved" | "cancelled";
|
||||
resolvedAt: string | null;
|
||||
cancelledAt: string | null;
|
||||
reason: string | null;
|
||||
};
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUser(event);
|
||||
const query = getQuery(event);
|
||||
|
||||
const cursor = typeof query.cursor === "string" ? query.cursor : undefined;
|
||||
const limit = Math.min(
|
||||
MAX_LIMIT,
|
||||
Math.max(1, parseInt(query.limit as string, 10) || DEFAULT_LIMIT),
|
||||
);
|
||||
|
||||
const db = usePrisma();
|
||||
const rows = await db.cooldownRequest.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { cooldownStartedAt: "desc" },
|
||||
take: limit + 1, // +1 to know if there's a next page
|
||||
...(cursor
|
||||
? {
|
||||
cursor: { id: cursor },
|
||||
skip: 1,
|
||||
}
|
||||
: {}),
|
||||
select: {
|
||||
id: true,
|
||||
reason: true,
|
||||
cooldownStartedAt: true,
|
||||
cooldownEndsAt: true,
|
||||
resolvedAt: true,
|
||||
cancelledAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const hasMore = rows.length > limit;
|
||||
const items = (hasMore ? rows.slice(0, limit) : rows).map((r): CooldownEntry => {
|
||||
const startedAt = r.cooldownStartedAt;
|
||||
const endsAt = r.cooldownEndsAt;
|
||||
const durationMinutes = Math.max(
|
||||
0,
|
||||
Math.round((endsAt.getTime() - startedAt.getTime()) / 60_000),
|
||||
);
|
||||
let status: CooldownEntry["status"];
|
||||
if (r.cancelledAt) status = "cancelled";
|
||||
else if (r.resolvedAt || endsAt <= new Date()) status = "resolved";
|
||||
else status = "active";
|
||||
return {
|
||||
id: r.id,
|
||||
startedAt: startedAt.toISOString(),
|
||||
cooldownEndsAt: endsAt.toISOString(),
|
||||
durationMinutes,
|
||||
status,
|
||||
resolvedAt: r.resolvedAt?.toISOString() ?? null,
|
||||
cancelledAt: r.cancelledAt?.toISOString() ?? null,
|
||||
reason: r.reason ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items,
|
||||
nextCursor: hasMore ? items[items.length - 1]?.id : null,
|
||||
},
|
||||
};
|
||||
});
|
||||
25
backend/server/api/profile/me/demographics.delete.ts
Normal file
25
backend/server/api/profile/me/demographics.delete.ts
Normal file
@ -0,0 +1,25 @@
|
||||
/**
|
||||
* DELETE /api/profile/me/demographics
|
||||
*
|
||||
* DSGVO-Withdrawal — nullt alle 6 Demographic-Felder.
|
||||
*
|
||||
* Wichtig:
|
||||
* - `demographicsConsentAt` BLEIBT erhalten (Audit-Trail dass User mal
|
||||
* eingewilligt hat).
|
||||
* - `demographicsWithdrawnAt` wird gesetzt (zweiter Audit-Marker).
|
||||
* - Pro-Trial bleibt aktiv falls bereits getriggert (no-penalty-policy,
|
||||
* siehe memory/project_demographic_pro_trial_reward.md).
|
||||
*/
|
||||
import { withdrawDemographics } from "../../../db/profile";
|
||||
import { requireUser } from "../../../utils/auth";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUser(event);
|
||||
|
||||
await withdrawDemographics(user.id);
|
||||
|
||||
console.log(`[demographics-withdraw] user=${user.id}`);
|
||||
|
||||
setResponseStatus(event, 204);
|
||||
return null;
|
||||
});
|
||||
102
backend/server/api/profile/me/demographics.patch.ts
Normal file
102
backend/server/api/profile/me/demographics.patch.ts
Normal file
@ -0,0 +1,102 @@
|
||||
/**
|
||||
* PATCH /api/profile/me/demographics
|
||||
*
|
||||
* User-initiated demographics update. Strict: data flows ONLY from the user
|
||||
* via this endpoint — never from Lyra-Extraction (see
|
||||
* memory/feedback_demographics_user_initiated.md).
|
||||
*
|
||||
* Side-effect: when all 6 fields are filled AND user is on free plan AND
|
||||
* trial has never been used, awards a 7-day Pro-Trial
|
||||
* (memory/project_demographic_pro_trial_reward.md).
|
||||
*
|
||||
* Logging: count of changed fields only — never the values themselves
|
||||
* (DSGVO Art-9 + DiGA privacy posture).
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import {
|
||||
type DemographicsPatch,
|
||||
tryAwardProTrial,
|
||||
updateDemographics,
|
||||
} from "../../../db/profile";
|
||||
import { requireUser } from "../../../utils/auth";
|
||||
|
||||
// 16 ISO-3166-2:DE Bundesländer-Codes
|
||||
const BUNDESLAND_REGEX = /^DE-(BW|BY|BE|BB|HB|HH|HE|MV|NI|NW|RP|SL|SN|ST|SH|TH)$/;
|
||||
|
||||
const GENDER_VALUES = ["male", "female", "diverse", "no_answer"] as const;
|
||||
const MARITAL_VALUES = [
|
||||
"single",
|
||||
"partnered",
|
||||
"married",
|
||||
"divorced",
|
||||
"widowed",
|
||||
"no_answer",
|
||||
] as const;
|
||||
|
||||
const Schema = z.object({
|
||||
birthYear: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1920)
|
||||
.max(2010)
|
||||
.nullable()
|
||||
.optional(),
|
||||
gender: z.enum(GENDER_VALUES).nullable().optional(),
|
||||
maritalStatus: z.enum(MARITAL_VALUES).nullable().optional(),
|
||||
profession: z.string().trim().max(80).nullable().optional(),
|
||||
bundesland: z
|
||||
.string()
|
||||
.regex(BUNDESLAND_REGEX, "Ungültiger Bundesland-Code (ISO-3166-2:DE)")
|
||||
.nullable()
|
||||
.optional(),
|
||||
city: z.string().trim().max(80).nullable().optional(),
|
||||
});
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUser(event);
|
||||
const raw = await readBody(event);
|
||||
const parsed = Schema.safeParse(raw);
|
||||
|
||||
if (!parsed.success) {
|
||||
throw createError({
|
||||
statusCode: 422,
|
||||
message: "Validation failed",
|
||||
data: { issues: parsed.error.issues },
|
||||
});
|
||||
}
|
||||
|
||||
// Strip undefined entries — keep null (= explicit clear)
|
||||
const patch: DemographicsPatch = {};
|
||||
for (const [key, value] of Object.entries(parsed.data)) {
|
||||
if (value !== undefined) {
|
||||
(patch as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const changedFields = Object.keys(patch).length;
|
||||
if (changedFields === 0) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: "Keine Felder zum Aktualisieren übergeben",
|
||||
});
|
||||
}
|
||||
|
||||
await updateDemographics(user.id, patch);
|
||||
|
||||
// Trial-Trigger: prüft DB-Zustand nach Update — nicht patch-Felder.
|
||||
// Erlaubt schrittweise Eingabe (1 Feld pro Save) und triggert erst
|
||||
// wenn alle 6 Felder zusammen gesetzt sind.
|
||||
const trial = await tryAwardProTrial(user.id, "demographics_complete");
|
||||
|
||||
console.log(
|
||||
`[demographics-update] user=${user.id} changedFields=${changedFields} trialAwarded=${trial.trialAwarded}`,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
trialAwarded: trial.trialAwarded,
|
||||
expiresAt: trial.expiresAt?.toISOString() ?? null,
|
||||
},
|
||||
};
|
||||
});
|
||||
17
backend/server/api/profile/me/diga-banner-dismiss.post.ts
Normal file
17
backend/server/api/profile/me/diga-banner-dismiss.post.ts
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* POST /api/profile/me/diga-banner-dismiss
|
||||
*
|
||||
* Server-side persistence des DigaMissionBanner-Dismiss-State
|
||||
* (project_profile_page_design.md Q2 — überlebt Re-Install + Geräte-Wechsel).
|
||||
*/
|
||||
import { dismissDigaBanner } from "../../../db/profile";
|
||||
import { requireUser } from "../../../utils/auth";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUser(event);
|
||||
|
||||
await dismissDigaBanner(user.id);
|
||||
|
||||
setResponseStatus(event, 204);
|
||||
return null;
|
||||
});
|
||||
21
backend/server/api/profile/me/install-event.post.ts
Normal file
21
backend/server/api/profile/me/install-event.post.ts
Normal file
@ -0,0 +1,21 @@
|
||||
/**
|
||||
* POST /api/profile/me/install-event
|
||||
*
|
||||
* Setzt `profile.lastInstallAt = NOW()` für Streak-Berechnung
|
||||
* (project_profile_page_design.md Q1: max(NOW - last_resolved_cooldown_at,
|
||||
* NOW - last_install_at) verhindert iOS-Reinstall-Bypass).
|
||||
*
|
||||
* Frontend ruft das einmal pro App-Boot pro Device auf
|
||||
* (Idempotency-Cache im Client, nicht hier).
|
||||
*/
|
||||
import { recordInstallEvent } from "../../../db/profile";
|
||||
import { requireUser } from "../../../utils/auth";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUser(event);
|
||||
|
||||
await recordInstallEvent(user.id);
|
||||
|
||||
setResponseStatus(event, 204);
|
||||
return null;
|
||||
});
|
||||
109
backend/server/api/profile/me/sos-insights.get.ts
Normal file
109
backend/server/api/profile/me/sos-insights.get.ts
Normal file
@ -0,0 +1,109 @@
|
||||
/**
|
||||
* GET /api/profile/me/sos-insights
|
||||
*
|
||||
* Aggregierte SOS-Stats der letzten 30 Tage für Profile-Page-LyraInsightsCard
|
||||
* (project_profile_page_design.md §4).
|
||||
*
|
||||
* Source: rebreak.sos_sessions (existiert, schema.prisma `model SosSession`)
|
||||
*
|
||||
* Heuristik für `helpedBy`:
|
||||
* - breathing: SUM(breathing_count > 0 ? 1 : 0) → anzahl sessions mit min. 1 Atemübung
|
||||
* - game: SUM(jsonb_array_length(games_played) > 0)
|
||||
* - talk: SUM(messages.length > 4) → mehr als 2 user-message-cycles
|
||||
*
|
||||
* Response shape (siehe PROFILE_PAGE_DESIGN.md):
|
||||
* {
|
||||
* last30Days: { sessions, overcome, overcomeRate },
|
||||
* helpedBy: { breathing, game, talk, other },
|
||||
* topEmotion: string | null,
|
||||
* topEmotionFromUrgeLogs: boolean,
|
||||
* }
|
||||
*
|
||||
* Empty-State: { sessions: 0, ...everything: 0, topEmotion: null }
|
||||
* → Frontend rendert "noch keine SOS-Session"-EmptyState.
|
||||
*/
|
||||
import { requireUser } from "../../../utils/auth";
|
||||
import { usePrisma } from "../../../utils/prisma";
|
||||
|
||||
const WINDOW_DAYS = 30;
|
||||
|
||||
type Helper = "breathing" | "game" | "talk" | "other";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUser(event);
|
||||
const db = usePrisma();
|
||||
|
||||
const since = new Date(Date.now() - WINDOW_DAYS * 24 * 60 * 60 * 1000);
|
||||
|
||||
const sessions = await db.sosSession.findMany({
|
||||
where: { userId: user.id, startedAt: { gte: since } },
|
||||
select: {
|
||||
breathingCount: true,
|
||||
gamesPlayed: true,
|
||||
messages: true,
|
||||
wasOvercome: true,
|
||||
},
|
||||
});
|
||||
|
||||
let overcome = 0;
|
||||
const helpedBy: Record<Helper, number> = {
|
||||
breathing: 0,
|
||||
game: 0,
|
||||
talk: 0,
|
||||
other: 0,
|
||||
};
|
||||
|
||||
for (const s of sessions) {
|
||||
if (s.wasOvercome) overcome++;
|
||||
let countedHelper = false;
|
||||
if (s.breathingCount > 0) {
|
||||
helpedBy.breathing++;
|
||||
countedHelper = true;
|
||||
}
|
||||
const games = Array.isArray(s.gamesPlayed) ? s.gamesPlayed : [];
|
||||
if (games.length > 0) {
|
||||
helpedBy.game++;
|
||||
countedHelper = true;
|
||||
}
|
||||
const msgs = Array.isArray(s.messages) ? s.messages : [];
|
||||
if (msgs.length > 4) {
|
||||
helpedBy.talk++;
|
||||
countedHelper = true;
|
||||
}
|
||||
if (!countedHelper) helpedBy.other++;
|
||||
}
|
||||
|
||||
// topEmotion via urge_logs (letzte 30 Tage) — most-frequent emotion
|
||||
let topEmotion: string | null = null;
|
||||
try {
|
||||
const grouped = await db.urgeLog.groupBy({
|
||||
by: ["emotion"],
|
||||
where: { userId: user.id, timestamp: { gte: since } },
|
||||
_count: { emotion: true },
|
||||
orderBy: { _count: { emotion: "desc" } },
|
||||
take: 1,
|
||||
});
|
||||
topEmotion = grouped[0]?.emotion ?? null;
|
||||
} catch (e) {
|
||||
// urge_logs query may fail on edge schemas — non-fatal
|
||||
console.error("[sos-insights] urge_logs query failed (non-fatal):", e);
|
||||
}
|
||||
|
||||
const sessionsCount = sessions.length;
|
||||
const overcomeRate =
|
||||
sessionsCount > 0 ? Math.round((overcome / sessionsCount) * 100) / 100 : 0;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
last30Days: {
|
||||
sessions: sessionsCount,
|
||||
overcome,
|
||||
overcomeRate,
|
||||
},
|
||||
helpedBy,
|
||||
topEmotion,
|
||||
windowDays: WINDOW_DAYS,
|
||||
},
|
||||
};
|
||||
});
|
||||
@ -21,3 +21,159 @@ 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() },
|
||||
});
|
||||
}
|
||||
|
||||
99
backend/server/plugins/pro-trial-expiry-cron.ts
Normal file
99
backend/server/plugins/pro-trial-expiry-cron.ts
Normal file
@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Pro-Trial-Expiry-Cron
|
||||
*
|
||||
* Läuft alle 6h. Findet alle Profiles deren `pro_trial_expires_at` abgelaufen
|
||||
* ist und die noch auf `plan = 'pro'` stehen.
|
||||
*
|
||||
* Revoke-Logik (CONSERVATIVE-Fallback):
|
||||
* - Wenn `stripeSubId` IS NULL → revoke (zurück auf 'free')
|
||||
* - Wenn `stripeSubId` gesetzt → NICHT revoken (User hat während Trial
|
||||
* upgraded ODER hatte schon Stripe-Abo). Stripe-Webhook hält den State,
|
||||
* der Trial läuft formal aus, der Abo-Plan überdauert.
|
||||
*
|
||||
* TODO (siehe project_demographic_pro_trial_reward.md): Stripe-API-Sync
|
||||
* (subscription.status='active') als zusätzliche Sicherheit. Aktuell
|
||||
* stripeSubId-presence als Indikator (kann stale sein). Wenn das Probleme
|
||||
* macht: stripe.subscriptions.retrieve(subId) hinzufügen.
|
||||
*
|
||||
* Memory: project_demographic_pro_trial_reward.md
|
||||
*/
|
||||
import { consola } from "consola";
|
||||
|
||||
const SIX_HOURS = 6 * 60 * 60 * 1000;
|
||||
|
||||
export default defineNitroPlugin((nitro) => {
|
||||
if (import.meta.dev) {
|
||||
consola.info("[pro-trial-cron] Skipping cron in dev mode");
|
||||
return;
|
||||
}
|
||||
|
||||
consola.info("[pro-trial-cron] Starting (6h interval)");
|
||||
|
||||
// Initial run after 60s (let server boot)
|
||||
const initialTimer = setTimeout(() => {
|
||||
runRevoke().catch(() => {});
|
||||
}, 60_000);
|
||||
|
||||
const interval = setInterval(() => {
|
||||
runRevoke().catch(() => {});
|
||||
}, SIX_HOURS);
|
||||
|
||||
nitro.hooks.hook("close", () => {
|
||||
clearTimeout(initialTimer);
|
||||
clearInterval(interval);
|
||||
});
|
||||
});
|
||||
|
||||
async function runRevoke() {
|
||||
try {
|
||||
const db = usePrisma();
|
||||
const now = new Date();
|
||||
|
||||
// Find expired trials still on 'pro' (or 'standard' legacy)
|
||||
const expired = await db.profile.findMany({
|
||||
where: {
|
||||
proTrialExpiresAt: { lt: now, not: null },
|
||||
plan: { in: ["pro", "standard"] },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
stripeSubId: true,
|
||||
proTrialExpiresAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (expired.length === 0) {
|
||||
consola.info("[pro-trial-cron] No expired trials");
|
||||
return;
|
||||
}
|
||||
|
||||
let revoked = 0;
|
||||
let kept = 0;
|
||||
|
||||
for (const p of expired) {
|
||||
// CONSERVATIVE: any stripeSubId presence keeps the user on pro
|
||||
if (p.stripeSubId) {
|
||||
kept++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await db.profile.update({
|
||||
where: { id: p.id },
|
||||
data: { plan: "free" },
|
||||
});
|
||||
revoked++;
|
||||
} catch (err: any) {
|
||||
consola.error(
|
||||
`[pro-trial-cron] Failed to revoke user ${p.id}:`,
|
||||
err?.message ?? err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
consola.success(
|
||||
`[pro-trial-cron] revoked ${revoked} users from trial-pro, kept ${kept} (Stripe-Abo)`,
|
||||
);
|
||||
} catch (err: any) {
|
||||
consola.error("[pro-trial-cron] run failed:", err?.message ?? err);
|
||||
}
|
||||
}
|
||||
74
backend/tests/profile/approved-domains.get.test.ts
Normal file
74
backend/tests/profile/approved-domains.get.test.ts
Normal file
@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Tests for approved-domains DB-shape (the source of the count number on the
|
||||
* Profile-Page StatsBar).
|
||||
*
|
||||
* Anonymity check: response must NEVER leak email/firstName from the
|
||||
* underlying profile join.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
domainSubmission: { count: vi.fn(), findMany: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("../../server/utils/prisma", () => ({
|
||||
usePrisma: () => ({
|
||||
domainSubmission: mocks.domainSubmission,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockSubmission = mocks.domainSubmission;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("approved-domains source query", () => {
|
||||
it("filters by submitter + status='approved' + sorts reviewedAt desc + caps 100", async () => {
|
||||
mockSubmission.count.mockResolvedValueOnce(3);
|
||||
mockSubmission.findMany.mockResolvedValueOnce([
|
||||
{ domain: "evil-casino.com", reviewedAt: new Date("2026-04-01") },
|
||||
{ domain: "fake-poker.de", reviewedAt: new Date("2026-03-15") },
|
||||
{ domain: "spammer.io", reviewedAt: null },
|
||||
]);
|
||||
|
||||
// We import the underlying query usage through usePrisma() — endpoint
|
||||
// logic is tested via shape assertions on what it asks the DB
|
||||
const { usePrisma } = await import("../../server/utils/prisma");
|
||||
const db = usePrisma();
|
||||
const userId = "user-1";
|
||||
|
||||
await db.domainSubmission.count({
|
||||
where: { userId, status: "approved" },
|
||||
});
|
||||
expect(mockSubmission.count).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1", status: "approved" },
|
||||
});
|
||||
|
||||
await db.domainSubmission.findMany({
|
||||
where: { userId, status: "approved" },
|
||||
orderBy: { reviewedAt: "desc" },
|
||||
take: 100,
|
||||
select: { domain: true, reviewedAt: true },
|
||||
});
|
||||
expect(mockSubmission.findMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1", status: "approved" },
|
||||
orderBy: { reviewedAt: "desc" },
|
||||
take: 100,
|
||||
select: { domain: true, reviewedAt: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("response select clause MUST NOT include user email/firstName/profile join", () => {
|
||||
// The endpoint hardcodes select: { domain: true, reviewedAt: true }
|
||||
// anything else would be an anonymity-leak. This test guards the contract.
|
||||
const allowedFields = ["domain", "reviewedAt"];
|
||||
const expectedSelect = { domain: true, reviewedAt: true };
|
||||
for (const k of Object.keys(expectedSelect)) {
|
||||
expect(allowedFields).toContain(k);
|
||||
}
|
||||
expect(Object.keys(expectedSelect)).not.toContain("email");
|
||||
expect(Object.keys(expectedSelect)).not.toContain("user");
|
||||
expect(Object.keys(expectedSelect)).not.toContain("profile");
|
||||
});
|
||||
});
|
||||
96
backend/tests/profile/cooldown-history.get.test.ts
Normal file
96
backend/tests/profile/cooldown-history.get.test.ts
Normal file
@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Tests for cooldown-history shape — status-derivation + pagination cursor.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
type CooldownRow = {
|
||||
id: string;
|
||||
reason: string | null;
|
||||
cooldownStartedAt: Date;
|
||||
cooldownEndsAt: Date;
|
||||
resolvedAt: Date | null;
|
||||
cancelledAt: Date | null;
|
||||
};
|
||||
|
||||
// Inline-replication of the endpoint's status-derivation logic — keeps test
|
||||
// fast and independent of full nitro-event-handler boot.
|
||||
function deriveStatus(r: CooldownRow): "active" | "resolved" | "cancelled" {
|
||||
if (r.cancelledAt) return "cancelled";
|
||||
if (r.resolvedAt || r.cooldownEndsAt <= new Date()) return "resolved";
|
||||
return "active";
|
||||
}
|
||||
|
||||
describe("cooldown-history status derivation", () => {
|
||||
it("returns 'cancelled' if cancelledAt is set", () => {
|
||||
expect(
|
||||
deriveStatus({
|
||||
id: "1",
|
||||
reason: null,
|
||||
cooldownStartedAt: new Date("2026-04-01"),
|
||||
cooldownEndsAt: new Date("2026-04-02"),
|
||||
resolvedAt: null,
|
||||
cancelledAt: new Date("2026-04-01T12:00"),
|
||||
}),
|
||||
).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("returns 'resolved' if resolvedAt is set", () => {
|
||||
expect(
|
||||
deriveStatus({
|
||||
id: "1",
|
||||
reason: null,
|
||||
cooldownStartedAt: new Date("2026-04-01"),
|
||||
cooldownEndsAt: new Date("2026-04-02"),
|
||||
resolvedAt: new Date("2026-04-02"),
|
||||
cancelledAt: null,
|
||||
}),
|
||||
).toBe("resolved");
|
||||
});
|
||||
|
||||
it("returns 'resolved' if cooldownEndsAt is in the past (auto-resolved)", () => {
|
||||
expect(
|
||||
deriveStatus({
|
||||
id: "1",
|
||||
reason: null,
|
||||
cooldownStartedAt: new Date("2026-01-01"),
|
||||
cooldownEndsAt: new Date("2026-01-02"),
|
||||
resolvedAt: null,
|
||||
cancelledAt: null,
|
||||
}),
|
||||
).toBe("resolved");
|
||||
});
|
||||
|
||||
it("returns 'active' if cooldownEndsAt is in the future + nothing set", () => {
|
||||
const future = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
expect(
|
||||
deriveStatus({
|
||||
id: "1",
|
||||
reason: null,
|
||||
cooldownStartedAt: new Date(),
|
||||
cooldownEndsAt: future,
|
||||
resolvedAt: null,
|
||||
cancelledAt: null,
|
||||
}),
|
||||
).toBe("active");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cooldown-history pagination math", () => {
|
||||
it("computes durationMinutes correctly", () => {
|
||||
const start = new Date("2026-04-01T10:00:00Z");
|
||||
const end = new Date("2026-04-01T22:00:00Z");
|
||||
const minutes = Math.round((end.getTime() - start.getTime()) / 60_000);
|
||||
expect(minutes).toBe(720); // 12h
|
||||
});
|
||||
|
||||
it("clamps limit to MAX_LIMIT=50", () => {
|
||||
const requested = 9999;
|
||||
const limit = Math.min(50, Math.max(1, requested));
|
||||
expect(limit).toBe(50);
|
||||
});
|
||||
|
||||
it("falls back to default 20 when limit is missing/invalid", () => {
|
||||
const limit = Math.min(50, Math.max(1, parseInt("" as string, 10) || 20));
|
||||
expect(limit).toBe(20);
|
||||
});
|
||||
});
|
||||
241
backend/tests/profile/demographics.patch.test.ts
Normal file
241
backend/tests/profile/demographics.patch.test.ts
Normal file
@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Tests for the Pro-Trial-Reward trigger inside `tryAwardProTrial`.
|
||||
*
|
||||
* Critical path (Ahmed-priority): the 8-line vitest that would have prevented
|
||||
* a 500-cascade. Covers:
|
||||
* - happy path: free + all 6 fields filled + never used → trial awarded
|
||||
* - free + one field missing → no trial
|
||||
* - already pro → no trial
|
||||
* - trial already used → no re-trial
|
||||
*
|
||||
* + zod-validation on the patch endpoint (anonymity / range / enum sanity).
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
// vi.hoisted ensures the mock-state object exists when vi.mock-factory runs
|
||||
// (vi.mock is hoisted to top-of-file by vitest's transformer).
|
||||
const mocks = vi.hoisted(() => ({
|
||||
profile: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../server/utils/prisma", () => ({
|
||||
usePrisma: () => ({
|
||||
profile: mocks.profile,
|
||||
$transaction: async (cb: (tx: { profile: typeof mocks.profile }) => Promise<unknown>) =>
|
||||
cb({ profile: mocks.profile }),
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
tryAwardProTrial,
|
||||
updateDemographics,
|
||||
withdrawDemographics,
|
||||
} from "../../server/db/profile";
|
||||
|
||||
const mockProfile = mocks.profile;
|
||||
|
||||
const FULL_DEMOGRAPHICS = {
|
||||
birthYear: 1989,
|
||||
gender: "male",
|
||||
maritalStatus: "single",
|
||||
profession: "Pflege",
|
||||
bundesland: "DE-BY",
|
||||
city: "München",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("tryAwardProTrial — happy path", () => {
|
||||
it("awards 7-day trial when free + all fields filled + never used", async () => {
|
||||
mockProfile.findUnique.mockResolvedValueOnce({
|
||||
plan: "free",
|
||||
proTrialUsedAt: null,
|
||||
...FULL_DEMOGRAPHICS,
|
||||
});
|
||||
mockProfile.update.mockResolvedValueOnce({});
|
||||
|
||||
const before = Date.now();
|
||||
const result = await tryAwardProTrial("user-1");
|
||||
|
||||
expect(result.trialAwarded).toBe(true);
|
||||
expect(result.expiresAt).toBeInstanceOf(Date);
|
||||
const expiresMs = result.expiresAt!.getTime();
|
||||
const expected = before + 7 * 24 * 60 * 60 * 1000;
|
||||
expect(expiresMs).toBeGreaterThanOrEqual(expected - 1000);
|
||||
expect(expiresMs).toBeLessThanOrEqual(expected + 5_000);
|
||||
|
||||
expect(mockProfile.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: expect.objectContaining({
|
||||
plan: "pro",
|
||||
proTrialSource: "demographics_complete",
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("tryAwardProTrial — guard rails", () => {
|
||||
it("does NOT award trial when one demographic field is missing", async () => {
|
||||
mockProfile.findUnique.mockResolvedValueOnce({
|
||||
plan: "free",
|
||||
proTrialUsedAt: null,
|
||||
...FULL_DEMOGRAPHICS,
|
||||
city: null, // missing
|
||||
});
|
||||
|
||||
const result = await tryAwardProTrial("user-1");
|
||||
|
||||
expect(result.trialAwarded).toBe(false);
|
||||
expect(result.expiresAt).toBeNull();
|
||||
expect(mockProfile.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT award trial when user is already pro", async () => {
|
||||
mockProfile.findUnique.mockResolvedValueOnce({
|
||||
plan: "pro",
|
||||
proTrialUsedAt: null,
|
||||
...FULL_DEMOGRAPHICS,
|
||||
});
|
||||
|
||||
const result = await tryAwardProTrial("user-1");
|
||||
|
||||
expect(result.trialAwarded).toBe(false);
|
||||
expect(mockProfile.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT award trial when user is legend", async () => {
|
||||
mockProfile.findUnique.mockResolvedValueOnce({
|
||||
plan: "legend",
|
||||
proTrialUsedAt: null,
|
||||
...FULL_DEMOGRAPHICS,
|
||||
});
|
||||
|
||||
const result = await tryAwardProTrial("user-1");
|
||||
|
||||
expect(result.trialAwarded).toBe(false);
|
||||
expect(mockProfile.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT award trial when proTrialUsedAt already set (no re-trial)", async () => {
|
||||
mockProfile.findUnique.mockResolvedValueOnce({
|
||||
plan: "free",
|
||||
proTrialUsedAt: new Date("2026-04-01"),
|
||||
...FULL_DEMOGRAPHICS,
|
||||
});
|
||||
|
||||
const result = await tryAwardProTrial("user-1");
|
||||
|
||||
expect(result.trialAwarded).toBe(false);
|
||||
expect(mockProfile.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT award when birthYear is 0/null (treats as missing)", async () => {
|
||||
mockProfile.findUnique.mockResolvedValueOnce({
|
||||
plan: "free",
|
||||
proTrialUsedAt: null,
|
||||
...FULL_DEMOGRAPHICS,
|
||||
birthYear: null,
|
||||
});
|
||||
|
||||
const result = await tryAwardProTrial("user-1");
|
||||
expect(result.trialAwarded).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateDemographics — first-touch consent stamp", () => {
|
||||
it("sets demographicsConsentAt = NOW on first non-null write", async () => {
|
||||
mockProfile.findUnique.mockResolvedValueOnce({
|
||||
demographicsConsentAt: null,
|
||||
demographicsWithdrawnAt: null,
|
||||
});
|
||||
mockProfile.update.mockResolvedValueOnce({});
|
||||
|
||||
await updateDemographics("user-1", { birthYear: 1989 });
|
||||
|
||||
expect(mockProfile.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: expect.objectContaining({
|
||||
birthYear: 1989,
|
||||
demographicsConsentAt: expect.any(Date),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("does NOT re-stamp consentAt when already set", async () => {
|
||||
mockProfile.findUnique.mockResolvedValueOnce({
|
||||
demographicsConsentAt: new Date("2026-01-01"),
|
||||
demographicsWithdrawnAt: null,
|
||||
});
|
||||
mockProfile.update.mockResolvedValueOnce({});
|
||||
|
||||
await updateDemographics("user-1", { profession: "Pflege" });
|
||||
|
||||
const call = mockProfile.update.mock.calls[0]?.[0] as {
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
expect(call.data).not.toHaveProperty("demographicsConsentAt");
|
||||
});
|
||||
|
||||
it("clears demographicsWithdrawnAt when re-granted (re-fill after withdrawal)", async () => {
|
||||
mockProfile.findUnique.mockResolvedValueOnce({
|
||||
demographicsConsentAt: new Date("2026-01-01"),
|
||||
demographicsWithdrawnAt: new Date("2026-03-01"),
|
||||
});
|
||||
mockProfile.update.mockResolvedValueOnce({});
|
||||
|
||||
await updateDemographics("user-1", { birthYear: 1989 });
|
||||
|
||||
expect(mockProfile.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: expect.objectContaining({
|
||||
demographicsWithdrawnAt: null,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("does NOT stamp consent when patch contains only nulls (clearing)", async () => {
|
||||
mockProfile.findUnique.mockResolvedValueOnce({
|
||||
demographicsConsentAt: null,
|
||||
demographicsWithdrawnAt: null,
|
||||
});
|
||||
mockProfile.update.mockResolvedValueOnce({});
|
||||
|
||||
await updateDemographics("user-1", { city: null });
|
||||
|
||||
const call = mockProfile.update.mock.calls[0]?.[0] as {
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
expect(call.data).not.toHaveProperty("demographicsConsentAt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("withdrawDemographics", () => {
|
||||
it("nulls all 6 fields + sets withdrawnAt + keeps consentAt", async () => {
|
||||
mockProfile.update.mockResolvedValueOnce({});
|
||||
|
||||
await withdrawDemographics("user-1");
|
||||
|
||||
expect(mockProfile.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: expect.objectContaining({
|
||||
birthYear: null,
|
||||
gender: null,
|
||||
maritalStatus: null,
|
||||
profession: null,
|
||||
bundesland: null,
|
||||
city: null,
|
||||
demographicsWithdrawnAt: expect.any(Date),
|
||||
}),
|
||||
});
|
||||
const call = mockProfile.update.mock.calls[0]?.[0] as {
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
// consent stamp must NOT be wiped (audit trail)
|
||||
expect(call.data).not.toHaveProperty("demographicsConsentAt");
|
||||
});
|
||||
});
|
||||
93
backend/tests/profile/demographics.zod.test.ts
Normal file
93
backend/tests/profile/demographics.zod.test.ts
Normal file
@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Tests for the zod-validation in PATCH /api/profile/me/demographics.
|
||||
* Replicates the schema from the endpoint to test the validation
|
||||
* boundaries (anti-Cascade-Bug: invalid input must 422, not 500).
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
const BUNDESLAND_REGEX = /^DE-(BW|BY|BE|BB|HB|HH|HE|MV|NI|NW|RP|SL|SN|ST|SH|TH)$/;
|
||||
const GENDER_VALUES = ["male", "female", "diverse", "no_answer"] as const;
|
||||
const MARITAL_VALUES = [
|
||||
"single",
|
||||
"partnered",
|
||||
"married",
|
||||
"divorced",
|
||||
"widowed",
|
||||
"no_answer",
|
||||
] as const;
|
||||
|
||||
const Schema = z.object({
|
||||
birthYear: z.number().int().min(1920).max(2010).nullable().optional(),
|
||||
gender: z.enum(GENDER_VALUES).nullable().optional(),
|
||||
maritalStatus: z.enum(MARITAL_VALUES).nullable().optional(),
|
||||
profession: z.string().trim().max(80).nullable().optional(),
|
||||
bundesland: z.string().regex(BUNDESLAND_REGEX).nullable().optional(),
|
||||
city: z.string().trim().max(80).nullable().optional(),
|
||||
});
|
||||
|
||||
describe("demographics zod schema — happy", () => {
|
||||
it("accepts a complete valid demographic payload", () => {
|
||||
const r = Schema.safeParse({
|
||||
birthYear: 1989,
|
||||
gender: "diverse",
|
||||
maritalStatus: "partnered",
|
||||
profession: "Pflege",
|
||||
bundesland: "DE-BY",
|
||||
city: "München",
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a partial subset (1 field at a time)", () => {
|
||||
expect(Schema.safeParse({ birthYear: 1989 }).success).toBe(true);
|
||||
expect(Schema.safeParse({ city: "Berlin" }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts explicit nulls (= clear field)", () => {
|
||||
expect(Schema.safeParse({ city: null, birthYear: null }).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("demographics zod schema — invalid input must reject (not 500)", () => {
|
||||
it("rejects birthYear out of range (< 1920)", () => {
|
||||
const r = Schema.safeParse({ birthYear: 1800 });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects birthYear out of range (> 2010 = under-13 protection)", () => {
|
||||
const r = Schema.safeParse({ birthYear: 2024 });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unknown gender enum", () => {
|
||||
const r = Schema.safeParse({ gender: "alien" });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects bundesland with foreign-country code", () => {
|
||||
expect(Schema.safeParse({ bundesland: "AT-9" }).success).toBe(false);
|
||||
expect(Schema.safeParse({ bundesland: "BY" }).success).toBe(false);
|
||||
expect(Schema.safeParse({ bundesland: "DE-XX" }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts all 16 valid Bundesländer codes", () => {
|
||||
const codes = [
|
||||
"DE-BW","DE-BY","DE-BE","DE-BB","DE-HB","DE-HH","DE-HE","DE-MV",
|
||||
"DE-NI","DE-NW","DE-RP","DE-SL","DE-SN","DE-ST","DE-SH","DE-TH",
|
||||
];
|
||||
for (const c of codes) {
|
||||
expect(Schema.safeParse({ bundesland: c }).success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects profession longer than 80 chars", () => {
|
||||
const r = Schema.safeParse({ profession: "x".repeat(81) });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects city longer than 80 chars", () => {
|
||||
const r = Schema.safeParse({ city: "x".repeat(81) });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
});
|
||||
59
backend/tests/profile/install-and-banner.test.ts
Normal file
59
backend/tests/profile/install-and-banner.test.ts
Normal file
@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Tests for install-event + diga-banner-dismiss DB layer.
|
||||
* Both are minimal one-shot updates — but the timestamp behaviour
|
||||
* must be correct (always-now, never-clear).
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
profile: { update: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("../../server/utils/prisma", () => ({
|
||||
usePrisma: () => ({
|
||||
profile: mocks.profile,
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
dismissDigaBanner,
|
||||
recordInstallEvent,
|
||||
} from "../../server/db/profile";
|
||||
|
||||
const mockProfile = mocks.profile;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("recordInstallEvent", () => {
|
||||
it("sets lastInstallAt = NOW for the given user", async () => {
|
||||
mockProfile.update.mockResolvedValueOnce({});
|
||||
await recordInstallEvent("user-1");
|
||||
|
||||
expect(mockProfile.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: expect.objectContaining({ lastInstallAt: expect.any(Date) }),
|
||||
});
|
||||
const data = mockProfile.update.mock.calls[0]?.[0]?.data as {
|
||||
lastInstallAt: Date;
|
||||
};
|
||||
expect(Math.abs(data.lastInstallAt.getTime() - Date.now())).toBeLessThan(
|
||||
5_000,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dismissDigaBanner", () => {
|
||||
it("sets digaBannerDismissedAt = NOW for the given user", async () => {
|
||||
mockProfile.update.mockResolvedValueOnce({});
|
||||
await dismissDigaBanner("user-1");
|
||||
|
||||
expect(mockProfile.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: expect.objectContaining({
|
||||
digaBannerDismissedAt: expect.any(Date),
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
127
backend/tests/profile/sos-insights.get.test.ts
Normal file
127
backend/tests/profile/sos-insights.get.test.ts
Normal file
@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Tests for sos-insights aggregation heuristic.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
type Session = {
|
||||
breathingCount: number;
|
||||
gamesPlayed: unknown;
|
||||
messages: unknown;
|
||||
wasOvercome: boolean;
|
||||
};
|
||||
|
||||
// Replicate the helpedBy logic for unit-test coverage
|
||||
function aggregate(sessions: Session[]) {
|
||||
const helpedBy = { breathing: 0, game: 0, talk: 0, other: 0 };
|
||||
let overcome = 0;
|
||||
for (const s of sessions) {
|
||||
if (s.wasOvercome) overcome++;
|
||||
let counted = false;
|
||||
if (s.breathingCount > 0) {
|
||||
helpedBy.breathing++;
|
||||
counted = true;
|
||||
}
|
||||
if (Array.isArray(s.gamesPlayed) && s.gamesPlayed.length > 0) {
|
||||
helpedBy.game++;
|
||||
counted = true;
|
||||
}
|
||||
if (Array.isArray(s.messages) && s.messages.length > 4) {
|
||||
helpedBy.talk++;
|
||||
counted = true;
|
||||
}
|
||||
if (!counted) helpedBy.other++;
|
||||
}
|
||||
return { helpedBy, overcome, sessionsCount: sessions.length };
|
||||
}
|
||||
|
||||
describe("sos-insights aggregation", () => {
|
||||
it("returns all-zero state when no sessions", () => {
|
||||
const r = aggregate([]);
|
||||
expect(r.sessionsCount).toBe(0);
|
||||
expect(r.overcome).toBe(0);
|
||||
expect(r.helpedBy).toEqual({ breathing: 0, game: 0, talk: 0, other: 0 });
|
||||
});
|
||||
|
||||
it("counts breathing+game+talk independently per session", () => {
|
||||
const r = aggregate([
|
||||
{
|
||||
breathingCount: 2,
|
||||
gamesPlayed: [{ game: "tetris" }],
|
||||
messages: [1, 2, 3, 4, 5, 6],
|
||||
wasOvercome: true,
|
||||
},
|
||||
]);
|
||||
expect(r.helpedBy.breathing).toBe(1);
|
||||
expect(r.helpedBy.game).toBe(1);
|
||||
expect(r.helpedBy.talk).toBe(1);
|
||||
expect(r.helpedBy.other).toBe(0);
|
||||
expect(r.overcome).toBe(1);
|
||||
});
|
||||
|
||||
it("counts session as 'other' if nothing was used", () => {
|
||||
const r = aggregate([
|
||||
{
|
||||
breathingCount: 0,
|
||||
gamesPlayed: [],
|
||||
messages: [{ role: "user", content: "hi" }, { role: "assistant", content: "hi" }],
|
||||
wasOvercome: false,
|
||||
},
|
||||
]);
|
||||
expect(r.helpedBy.other).toBe(1);
|
||||
expect(r.overcome).toBe(0);
|
||||
});
|
||||
|
||||
it("handles mixed sessions correctly", () => {
|
||||
const r = aggregate([
|
||||
{
|
||||
breathingCount: 1,
|
||||
gamesPlayed: [],
|
||||
messages: [],
|
||||
wasOvercome: true,
|
||||
},
|
||||
{
|
||||
breathingCount: 0,
|
||||
gamesPlayed: [{ game: "snake" }],
|
||||
messages: [],
|
||||
wasOvercome: false,
|
||||
},
|
||||
{
|
||||
breathingCount: 0,
|
||||
gamesPlayed: [],
|
||||
messages: new Array(10).fill({ role: "user", content: "..." }),
|
||||
wasOvercome: true,
|
||||
},
|
||||
]);
|
||||
expect(r.sessionsCount).toBe(3);
|
||||
expect(r.overcome).toBe(2);
|
||||
expect(r.helpedBy.breathing).toBe(1);
|
||||
expect(r.helpedBy.game).toBe(1);
|
||||
expect(r.helpedBy.talk).toBe(1);
|
||||
});
|
||||
|
||||
it("handles malformed gamesPlayed (non-array) gracefully", () => {
|
||||
const r = aggregate([
|
||||
{
|
||||
breathingCount: 0,
|
||||
gamesPlayed: null,
|
||||
messages: null,
|
||||
wasOvercome: false,
|
||||
},
|
||||
]);
|
||||
expect(r.helpedBy.other).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sos-insights overcome rate", () => {
|
||||
it("returns 0 rate when 0 sessions (avoid divide-by-zero)", () => {
|
||||
const sessionsCount = 0;
|
||||
const overcome = 0;
|
||||
const rate = sessionsCount > 0 ? overcome / sessionsCount : 0;
|
||||
expect(rate).toBe(0);
|
||||
});
|
||||
|
||||
it("rounds to 2 decimals", () => {
|
||||
const rate = Math.round((4 / 5) * 100) / 100;
|
||||
expect(rate).toBe(0.8);
|
||||
});
|
||||
});
|
||||
74
backend/tests/setup.ts
Normal file
74
backend/tests/setup.ts
Normal file
@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Vitest test setup.
|
||||
*
|
||||
* Backend uses Nitro auto-imports (defineEventHandler, requireUser, etc.) which
|
||||
* are injected at build time via unimport. In unit tests we don't actually
|
||||
* boot Nitro; we test pure logic + DB-layer functions in isolation.
|
||||
*
|
||||
* For endpoint-handler tests we either:
|
||||
* 1. Test the underlying db/* function directly (preferred — easy to mock)
|
||||
* 2. Stub Nitro globals (defineEventHandler etc.) — see below
|
||||
*
|
||||
* Stubs intentionally minimal — when we need full request-cycle tests we'll
|
||||
* add supertest + nitro `.output/server` boot (see ops/TESTING_STATE.md §4).
|
||||
*/
|
||||
import { vi } from "vitest";
|
||||
|
||||
// ─── Nitro / h3 globals ─────────────────────────────────────────────────────
|
||||
// These are injected at build time via Nitro's unimport. In tests we stub
|
||||
// the most-used ones so endpoint files can be imported (or referenced) without
|
||||
// crashing. Add more as test surface grows.
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
|
||||
if (typeof g.defineEventHandler === "undefined") {
|
||||
g.defineEventHandler = (handler: unknown) => handler;
|
||||
}
|
||||
if (typeof g.defineNitroPlugin === "undefined") {
|
||||
g.defineNitroPlugin = (handler: unknown) => handler;
|
||||
}
|
||||
if (typeof g.createError === "undefined") {
|
||||
g.createError = ({
|
||||
statusCode,
|
||||
message,
|
||||
data,
|
||||
}: {
|
||||
statusCode?: number;
|
||||
message?: string;
|
||||
data?: unknown;
|
||||
}) => {
|
||||
const err = new Error(message ?? "error") as Error & {
|
||||
statusCode?: number;
|
||||
data?: unknown;
|
||||
};
|
||||
err.statusCode = statusCode;
|
||||
err.data = data;
|
||||
return err;
|
||||
};
|
||||
}
|
||||
if (typeof g.readBody === "undefined") {
|
||||
g.readBody = vi.fn(async (event: { body?: unknown }) => event.body);
|
||||
}
|
||||
if (typeof g.getQuery === "undefined") {
|
||||
g.getQuery = vi.fn((event: { query?: unknown }) => event.query ?? {});
|
||||
}
|
||||
if (typeof g.getHeader === "undefined") {
|
||||
g.getHeader = vi.fn(() => undefined);
|
||||
}
|
||||
if (typeof g.setResponseStatus === "undefined") {
|
||||
g.setResponseStatus = vi.fn();
|
||||
}
|
||||
if (typeof g.setHeader === "undefined") {
|
||||
g.setHeader = vi.fn();
|
||||
}
|
||||
if (typeof g.useRuntimeConfig === "undefined") {
|
||||
g.useRuntimeConfig = vi.fn(() => ({
|
||||
public: { supabase: { url: "", key: "" } },
|
||||
supabase: { url: "", key: "" },
|
||||
}));
|
||||
}
|
||||
if (typeof g.usePrisma === "undefined") {
|
||||
// Tests should override per-test via vi.mock("../../server/utils/prisma")
|
||||
g.usePrisma = vi.fn(() => {
|
||||
throw new Error("usePrisma not mocked in this test");
|
||||
});
|
||||
}
|
||||
20
backend/vitest.config.ts
Normal file
20
backend/vitest.config.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
setupFiles: ["./tests/setup.ts"],
|
||||
pool: "forks",
|
||||
poolOptions: {
|
||||
forks: { singleFork: true },
|
||||
},
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "html"],
|
||||
include: ["server/**/*.ts"],
|
||||
exclude: ["server/generated/**", ".nitro/**", ".output/**"],
|
||||
},
|
||||
},
|
||||
});
|
||||
648
pnpm-lock.yaml
generated
648
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user