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",
|
"dev": "nitro dev",
|
||||||
"preview": "node .output/server/index.mjs",
|
"preview": "node .output/server/index.mjs",
|
||||||
"start": "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": {
|
"dependencies": {
|
||||||
"@prisma/adapter-pg": "^7.2.0",
|
"@prisma/adapter-pg": "^7.2.0",
|
||||||
@ -26,9 +29,11 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/pg": "^8.11.10",
|
"@types/pg": "^8.11.10",
|
||||||
|
"@vitest/coverage-v8": "^2.1.0",
|
||||||
"h3": "^1.15.4",
|
"h3": "^1.15.4",
|
||||||
"nitropack": "^2.12.4",
|
"nitropack": "^2.12.4",
|
||||||
"prisma": "^7.2.0",
|
"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")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_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[]
|
communityPosts CommunityPost[]
|
||||||
communityReplies CommunityReply[]
|
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();
|
const db = usePrisma();
|
||||||
return db.profile.delete({ where: { id: userId } });
|
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