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>
110 lines
3.0 KiB
TypeScript
110 lines
3.0 KiB
TypeScript
/**
|
|
* 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,
|
|
},
|
|
};
|
|
});
|