chahinebrini d31e45e2a8 feat(streak): protection-coverage metric (DiGA core) replacing broken streak
The old streak was non-functional: streaks.current_days was always 0 (never
computed/incremented), and the profile page read me.streak (0) + account
created_at as the "since" date — showing "0 days protected since <signup>"
for everyone. This is the DiGA key metric, so it had to be rebuilt.

New model: optimistic protection-coverage based on actual VPN/MDM protection
state, never resets to 0.
- backend: append-only protection_state_log + migration; POST /api/protection/event
  (ingestion, deduped) + GET /api/protection/coverage (read-time compute, no cron);
  server-side cooldown_disable event on cooldown resolve. Generous >6h-off/day rule.
- frontend: report protection on/off transitions (initial + flips, deduped) from
  useProtectionState; rewrote profile StreakSection → half-donut (protected vs
  unprotected) + progress bar (current streak → personal record) + empty state.
- coverage starts fresh from deploy (no historical backfill — clean data for DiGA).
- spec: docs/specs/protection-coverage-streak.md (shared contract).
- old streaks/streak_events/profiles.streak left intact (coach/scores consumers).

Also adds go-to-market one-pagers under docs/marketing/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 10:54:55 +02:00

38 lines
1.2 KiB
TypeScript

import { requireUser } from "../../utils/auth";
import {
appendProtectionEventDeduped,
type ProtectionSource,
} from "../../db/protectionStateLog";
const VALID_SOURCES: ProtectionSource[] = ["vpn", "mdm", "client"];
/**
* POST /api/protection/event
*
* Body: { active: boolean, source: 'vpn' | 'mdm' | 'client' }
*
* Called from the native app (useProtectionState / lib/protection) when the
* combined protection state transitions on↔off. The client deduplicates
* locally (only fires on real transitions); the server deduplicates again
* against the last DB row for the user.
*
* Returns { success: true, written: true } if a new row was written,
* { success: true, written: false } if deduplicated (state unchanged).
*/
export default defineEventHandler(async (event) => {
const user = await requireUser(event);
const body = await readBody(event);
if (typeof body?.active !== "boolean") {
throw createError({ statusCode: 400, message: "active (boolean) required" });
}
const source: ProtectionSource = VALID_SOURCES.includes(body.source)
? (body.source as ProtectionSource)
: "client";
const row = await appendProtectionEventDeduped(user.id, body.active, source);
return { success: true, written: row !== null };
});