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

304 lines
8.4 KiB
TypeScript

import { View, Text } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { useColors } from '../../lib/theme';
import { CooldownPatternAnalysis } from './CooldownPatternAnalysis';
import type { BackendCooldownEntry } from '../../hooks/useProfileData';
export type CooldownEntry = {
id: string;
startedAt: string;
rawStartedAt: string;
durationLabel: string;
status: 'active' | 'resolved' | 'cancelled';
reason: string | null;
};
type Props = {
currentDays: number;
longestDays: number;
startDate: string;
cooldowns: CooldownEntry[];
rawCooldowns: BackendCooldownEntry[] | null;
};
const WEEKS = 8;
const MAX_BAR_HEIGHT = 28;
const MIN_BAR_HEIGHT = 2;
function getMondayOfWeek(date: Date): Date {
const d = new Date(date);
const day = d.getDay();
const diff = (day === 0 ? -6 : 1 - day);
d.setDate(d.getDate() + diff);
d.setHours(0, 0, 0, 0);
return d;
}
function buildWeekBuckets(cooldowns: CooldownEntry[]): number[] {
const now = new Date();
const currentWeekMonday = getMondayOfWeek(now);
const buckets: number[] = Array(WEEKS).fill(0);
for (const c of cooldowns) {
if (!c.rawStartedAt) continue;
const started = new Date(c.rawStartedAt);
const weekMonday = getMondayOfWeek(started);
const diffMs = currentWeekMonday.getTime() - weekMonday.getTime();
const diffWeeks = Math.round(diffMs / (7 * 24 * 60 * 60 * 1000));
if (diffWeeks >= 0 && diffWeeks < WEEKS) {
const bucketIndex = WEEKS - 1 - diffWeeks;
buckets[bucketIndex]++;
}
}
return buckets;
}
function formatLastDate(cooldowns: CooldownEntry[], language: string): string {
if (cooldowns.length === 0) return '';
const sorted = [...cooldowns].sort(
(a, b) => new Date(b.rawStartedAt).getTime() - new Date(a.rawStartedAt).getTime(),
);
const latest = new Date(sorted[0].rawStartedAt);
if (language === 'de') {
const day = String(latest.getDate()).padStart(2, '0');
const month = String(latest.getMonth() + 1).padStart(2, '0');
return `${day}.${month}.`;
}
return latest.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
function formatAvg(totalCount: number, language: string): string {
if (totalCount === 0) return '0';
const avg = WEEKS / totalCount;
if (language === 'de') {
return avg.toFixed(1).replace('.', ',');
}
return avg.toFixed(1);
}
export function StreakSection({ currentDays, longestDays, startDate, cooldowns, rawCooldowns }: Props) {
const colors = useColors();
const { t, i18n } = useTranslation();
const lang = i18n.language ?? 'de';
const buckets = buildWeekBuckets(cooldowns);
const maxCount = Math.max(...buckets, 1);
const totalInWindow = buckets.reduce((s, v) => s + v, 0);
const cooldownsInWindow = totalInWindow;
const lastDate = cooldowns.length > 0 ? formatLastDate(cooldowns, lang) : null;
const avgStr = formatAvg(cooldownsInWindow, lang);
const countLabel =
cooldownsInWindow === 0
? t('profile.cooldown.none')
: cooldownsInWindow === 1
? t('profile.cooldown.count_one', { weeks: WEEKS })
: t('profile.cooldown.count_other', { n: cooldownsInWindow, weeks: WEEKS });
const avgLabel =
cooldownsInWindow > 0 && lastDate
? t('profile.cooldown.avg_last', { avg: avgStr, date: lastDate })
: null;
return (
<View style={{ marginHorizontal: 16, marginTop: 24 }}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginBottom: 10,
}}
>
<Ionicons name="flame-outline" size={14} color={colors.textMuted} />
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_700Bold',
letterSpacing: 0.8,
}}
>
{t('profile.streak_section_label')}
</Text>
</View>
<View
style={{
backgroundColor: colors.card,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 14,
padding: 16,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'baseline', gap: 8 }}>
<Text
style={{
fontSize: 36,
color: colors.text,
fontFamily: 'Nunito_800ExtraBold',
}}
>
{currentDays}
</Text>
<Text
style={{
fontSize: 14,
color: colors.text,
fontFamily: 'Nunito_600SemiBold',
}}
>
{t('profile.streak_days_protected')}
</Text>
</View>
<Text
style={{
marginTop: 2,
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
{t('profile.streak_since', { date: startDate })}
</Text>
<Text
style={{
marginTop: 8,
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
{t('profile.streak_longest', { days: longestDays })}
</Text>
</View>
<View style={{ marginTop: 16 }}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
paddingHorizontal: 2,
}}
>
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_700Bold',
letterSpacing: 0.8,
}}
>
{t('profile.cooldown.heading')}
</Text>
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
{t('profile.cooldown.window_label', { weeks: WEEKS })}
</Text>
</View>
<View
style={{
backgroundColor: colors.card,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 14,
padding: 16,
}}
>
<View
style={{
flexDirection: 'row',
alignItems: 'flex-end',
justifyContent: 'space-between',
height: MAX_BAR_HEIGHT + 20,
marginBottom: 6,
}}
>
{buckets.map((count, i) => {
const isEmpty = count === 0;
const barHeight = isEmpty
? MIN_BAR_HEIGHT
: Math.max(
MIN_BAR_HEIGHT,
Math.min(count, 5) / Math.min(maxCount, 5) * MAX_BAR_HEIGHT,
);
return (
<View key={i} style={{ alignItems: 'center', flex: 1 }}>
<View
style={{
width: 10,
height: barHeight,
borderRadius: 3,
backgroundColor: isEmpty ? colors.border : colors.brandOrange,
}}
/>
</View>
);
})}
</View>
<View
style={{
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 12,
}}
>
{buckets.map((_, i) => (
<View key={i} style={{ flex: 1, alignItems: 'center' }}>
<Text
style={{
fontSize: 9,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
{t('profile.cooldown.week_label', { n: i + 1 })}
</Text>
</View>
))}
</View>
<Text
style={{
fontSize: 12,
color: colors.text,
fontFamily: 'Nunito_600SemiBold',
}}
>
{countLabel}
</Text>
{avgLabel ? (
<Text
style={{
marginTop: 2,
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
{avgLabel}
</Text>
) : null}
</View>
<CooldownPatternAnalysis rawCooldowns={rawCooldowns} />
</View>
</View>
);
}