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>
108 lines
3.2 KiB
TypeScript
108 lines
3.2 KiB
TypeScript
import { requireUser } from "../../utils/auth";
|
|
import { getActiveCooldown, resolveCooldown } from "../../db/cooldown";
|
|
import { signCooldownToken } from "../../utils/cooldownToken";
|
|
import { usePrisma } from "../../utils/prisma";
|
|
import { appendProtectionEventDeduped } from "../../db/protectionStateLog";
|
|
|
|
/** GET /api/cooldown/status — Current cooldown state for the authenticated user. */
|
|
export default defineEventHandler(async (event) => {
|
|
const user = await requireUser(event);
|
|
|
|
// ─── MDM-Guard: wenn User via MDM verwaltet wird, kann er Schutz nie selbst
|
|
// deaktivieren — Cooldown-Flow ist komplett irrelevant. Früh zurückkehren.
|
|
const db = usePrisma();
|
|
const profile = await db.profile.findUnique({
|
|
where: { id: user.id },
|
|
select: { mdmManaged: true },
|
|
});
|
|
|
|
if (profile?.mdmManaged) {
|
|
return {
|
|
success: true,
|
|
data: {
|
|
active: false,
|
|
remainingSeconds: 0,
|
|
cooldownEndsAt: null,
|
|
canDisableProtection: false,
|
|
reason: "mdm_managed",
|
|
message: "MDM-Mode: Schutz nur via Apple Configurator oder Trustee deaktivierbar",
|
|
token: null,
|
|
},
|
|
};
|
|
}
|
|
|
|
const cooldown = await getActiveCooldown(user.id);
|
|
const now = new Date();
|
|
|
|
if (!cooldown) {
|
|
// No cooldown ever started (or all were cancelled/resolved).
|
|
return {
|
|
success: true,
|
|
data: {
|
|
active: false,
|
|
remainingSeconds: 0,
|
|
cooldownEndsAt: null,
|
|
canDisableProtection: true,
|
|
token: null, // no cooldown row to bind to; app may proceed freely
|
|
},
|
|
};
|
|
}
|
|
|
|
const expired = now >= cooldown.cooldownEndsAt;
|
|
|
|
if (expired) {
|
|
// Auto-resolve so we don't re-check next time.
|
|
await resolveCooldown(cooldown.id);
|
|
|
|
// Anti-Auto-Reactivation: User hat den 24h-Cooldown durchgehalten + jetzt
|
|
// wird der Schutz abgeschaltet. Markiere Profile damit /api/protection/state
|
|
// protectionShouldBeActive=false zurückgibt → Frontend macht keine Auto-
|
|
// Reactivation (Sucht-Recovery-Pattern: einfach an, schwer aus, sehr schwer
|
|
// wieder zurück an Auto-Magic). User muss explizit reaktivieren.
|
|
const db = usePrisma();
|
|
const disabledAt = new Date();
|
|
await db.profile.update({
|
|
where: { id: user.id },
|
|
data: { protectionDisabledAt: disabledAt },
|
|
}).catch(() => {});
|
|
|
|
// Protection-state log: record the server-triggered disable so coverage
|
|
// metrics reflect this gap. Fire-and-forget — never block the response.
|
|
appendProtectionEventDeduped(user.id, false, "cooldown_disable", disabledAt).catch(() => {});
|
|
|
|
const token = await signCooldownToken(
|
|
user.id,
|
|
cooldown.tokenJti,
|
|
cooldown.cooldownEndsAt,
|
|
);
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
active: false,
|
|
remainingSeconds: 0,
|
|
cooldownEndsAt: cooldown.cooldownEndsAt.toISOString(),
|
|
canDisableProtection: true,
|
|
token,
|
|
},
|
|
};
|
|
}
|
|
|
|
// Still counting down.
|
|
const remainingSeconds = Math.max(
|
|
0,
|
|
Math.floor((cooldown.cooldownEndsAt.getTime() - now.getTime()) / 1000),
|
|
);
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
active: true,
|
|
remainingSeconds,
|
|
cooldownEndsAt: cooldown.cooldownEndsAt.toISOString(),
|
|
canDisableProtection: false,
|
|
token: null,
|
|
},
|
|
};
|
|
});
|