chahinebrini 0ab635c74a feat: art-9 consent flow + outlook-oauth schema + cooldown patterns + mail draft persist
DSGVO Art. 9 — Compliance-Gap im Mail-Connect-Flow geschlossen (Hans-Müller-DSB
hat den Gap für Gmail/iCloud/GMX identifiziert, schon vor Outlook-OAuth-Pflicht):

- Schema: mail_connections.consent_at + consent_version + consent_ip_address;
  neue consent_logs-Tabelle für Audit (grant + revoke append-only)
- Endpoints:
  - POST /api/mail-connections/consent (Bulk-Array für Re-Consent, partial-fail
    wirft sofort = DSGVO-sicher gegen silent-skip fremder IDs)
  - POST /api/mail-connections/:id mit consent-gate (412 wenn consentVersion fehlt)
  - DELETE /api/mail-connections/:id mit Widerruf-Log (OAuth-Token-Revoke als
    TODO für mo Phase 2)
  - GET /api/mail-connections/pending-consent — listet Bestands-Connections
    mit consent_at=NULL für Re-Consent-Modal
- Account-Lösch-Bug fix: deleteAllMailConnections() war in user/delete nicht
  eingebunden — Verbindungen blieben als Waisen
- Frontend:
  - ConnectMailSheet: neuer Consent-Step VOR Provider-Grid (view-Machine
    consent → grid → form), exakter Hans-Müller-Wortlaut für Art. 9 Abs. 2
    lit. a Einwilligung
  - MailConsentReminderSheet: Re-Consent-Modal beim App-Open für Bestands-User
  - Stores mailConsent + mailConnectDraft (letzterer fixt Bug: Email/Provider
    ging verloren wenn User Browser für App-Pw-Generierung öffnete)
  - 12 neue i18n-Keys mail.consent.* in DE + EN
- Versionierter Consent-Text: art9-mail-v1-2026-05-13 (Bump bei Text-Änderung
  triggert Re-Consent für alle)

Outlook-OAuth Schema (Phase 0 — additiv, Endpoints kommen später):

- mail_connections: auth_method (default 'app_password' → keine Bestands-
  Connection bricht), oauth_access_token, oauth_refresh_token,
  oauth_token_expiry, oauth_scope
- Encryption via bestehendes server/utils/crypto.ts (AES-256-GCM, Key aus
  Infisical)
- Plan-Doc backend/docs/mail-outlook-oauth-plan.md (mo)
- DSB-Review backend/docs/mail-outlook-oauth-dsgvo-review.md (Hans-Müller):
  MS als Sub-AV via DPA Sep 2025, EU Data Boundary seit Feb 2025; 5 Pflicht-
  Aufgaben + Anwalts-Klärung zu DPA-Anspruch ohne MS-Lizenz

Profile — Cooldown-Pattern-Analysis als Collapsible:

- CooldownPatternAnalysis: 24h-Uhrzeit-Heatmap, Mo–So-Wochentag-Histogramm,
  Top-5-Reason-Wortcloud mit Stop-Words-Filter, Cancel-Rate-Anzeige
- DiGA-relevant: NLP läuft client-side, reason-Texte verlassen das Device
  nicht (gut für DSB-Akte)
- useProfileData: useCooldownHistoryFull (limit=100) für Pattern-Analyse
- Neutral formuliert, kein Stigma, alle Headings als Frage

Plan-Docs (kein Code):

- backend/docs/mail-custom-keywords-plan.md — Pro/Legend Custom-Keyword-Filter
  (3.25 PT MVP, user-scoped, Body-Match in Phase 2)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 16:35:18 +02:00

200 lines
5.1 KiB
TypeScript

import { useCallback, useEffect, useState } from 'react';
import { apiFetch } from '../lib/api';
import type { CooldownEntry } from '../components/profile/StreakSection';
import type { ApprovedDomain } from '../components/profile/ApprovedDomainsList';
export type SocialStats = {
postsCount: number;
followersCount: number;
};
export type ApprovedDomainsData = {
count: number;
list: ApprovedDomain[];
};
export type CooldownHistoryData = {
items: CooldownEntry[];
nextCursor: string | null;
};
export type SosInsightsData = {
last30Days: { sessions: number; overcome: number; overcomeRate: number };
helpedBy: { breathing: number; game: number; talk: number; other: number };
topEmotion: string | null;
};
export type BackendCooldownEntry = {
id: string;
startedAt: string;
cooldownEndsAt: string;
durationMinutes: number;
status: 'active' | 'resolved' | 'cancelled';
resolvedAt: string | null;
cancelledAt: string | null;
reason: string | null;
};
function formatDuration(minutes: number): string {
if (minutes < 60) return `${minutes}min`;
const h = Math.round(minutes / 60);
return `${h}h`;
}
function formatStartedAt(isoString: string): string {
const d = new Date(isoString);
const day = String(d.getDate()).padStart(2, '0');
const month = String(d.getMonth() + 1).padStart(2, '0');
return `${day}.${month}.`;
}
function mapCooldownEntry(raw: BackendCooldownEntry): CooldownEntry {
return {
id: raw.id,
startedAt: formatStartedAt(raw.startedAt),
rawStartedAt: raw.startedAt,
durationLabel: formatDuration(raw.durationMinutes),
status: raw.status,
reason: raw.reason,
};
}
function useFetchOnce<T>(
url: string,
): { data: T | null; loading: boolean; error: boolean; reload: () => void } {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [version, setVersion] = useState(0);
useEffect(() => {
if (!url) {
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
setError(false);
apiFetch<T>(url)
.then((res) => {
if (cancelled) return;
setData(res);
})
.catch(() => {
if (!cancelled) setError(true);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [url, version]);
const reload = useCallback(() => setVersion((v) => v + 1), []);
return { data, loading, error, reload };
}
export function useSocialStats(userId: string | undefined) {
const url = userId ? `/api/social/profile/${userId}` : '';
const { data, loading, error, reload } = useFetchOnce<{
postsCount: number;
followersCount: number;
}>(url);
return {
stats: data
? ({ postsCount: data.postsCount, followersCount: data.followersCount } as SocialStats)
: null,
loading,
error,
reload,
};
}
export function useApprovedDomains() {
const { data, loading, error, reload } = useFetchOnce<ApprovedDomainsData>(
'/api/profile/me/approved-domains',
);
return { domains: data, loading, error, reload };
}
export function useCooldownHistory() {
const { data, loading, error, reload } = useFetchOnce<{
items: BackendCooldownEntry[];
nextCursor: string | null;
}>('/api/profile/me/cooldown-history?limit=20');
const mapped: CooldownHistoryData | null = data
? {
items: data.items.map(mapCooldownEntry),
nextCursor: data.nextCursor,
}
: null;
return { cooldownHistory: mapped, loading, error, reload };
}
export function useCooldownHistoryFull() {
const { data, loading, error, reload } = useFetchOnce<{
items: BackendCooldownEntry[];
nextCursor: string | null;
}>('/api/profile/me/cooldown-history?limit=100');
return { rawCooldowns: data?.items ?? null, loading, error, reload };
}
export function useSosInsights() {
const { data, loading, error, reload } = useFetchOnce<SosInsightsData>(
'/api/profile/me/sos-insights',
);
return { sosInsights: data, loading, error, reload };
}
export type Demographics = {
birthYear: number | null;
gender: string | null;
maritalStatus: string | null;
employmentStatus: string | null;
shiftWork: boolean | null;
industry: string | null;
jobTenure: string | null;
bundesland: string | null;
city: string | null;
};
type DemographicsResponse = Demographics & {
consentAt: string | null;
withdrawnAt: string | null;
};
export function useDemographics() {
const { data, loading, error, reload } = useFetchOnce<DemographicsResponse>(
'/api/profile/me/demographics',
);
const demographics: Demographics | null = data
? {
birthYear: data.birthYear,
gender: data.gender,
maritalStatus: data.maritalStatus,
employmentStatus: data.employmentStatus,
shiftWork: data.shiftWork,
industry: data.industry,
jobTenure: data.jobTenure,
bundesland: data.bundesland,
city: data.city,
}
: null;
return {
demographics,
consentAt: data?.consentAt ?? null,
withdrawnAt: data?.withdrawnAt ?? null,
loading,
error,
reload,
};
}