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

378 lines
12 KiB
TypeScript

import { useState } from 'react';
import { View, Text, TouchableOpacity, LayoutAnimation, Platform, UIManager } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { useColors } from '../../lib/theme';
import type { BackendCooldownEntry } from '../../hooks/useProfileData';
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
UIManager.setLayoutAnimationEnabledExperimental(true);
}
const DE_STOP_WORDS = new Set([
'der', 'die', 'das', 'den', 'dem', 'des', 'ein', 'eine', 'einer', 'einem', 'einen', 'eines',
'und', 'oder', 'aber', 'auch', 'noch', 'nur', 'nicht', 'kein', 'keine', 'keinen',
'ich', 'du', 'er', 'sie', 'es', 'wir', 'ihr', 'sie', 'mich', 'dich', 'sich',
'mir', 'dir', 'uns', 'euch', 'ihm', 'ihr', 'ihnen',
'mein', 'dein', 'sein', 'ihr', 'unser', 'euer',
'meinen', 'deinen', 'seinen', 'ihren', 'unseren',
'meiner', 'deiner', 'seiner', 'ihrer', 'unserer',
'meinem', 'deinem', 'seinem', 'ihrem', 'unserem',
'ist', 'sind', 'war', 'waren', 'bin', 'bist', 'hat', 'haben', 'hatte', 'hatten',
'wird', 'werden', 'wurde', 'wurden', 'kann', 'muss', 'soll', 'will', 'mag',
'bei', 'von', 'mit', 'aus', 'auf', 'in', 'an', 'im', 'am', 'zu', 'zum', 'zur',
'als', 'wie', 'wenn', 'weil', 'dass', 'ob', 'damit', 'wann', 'wo', 'was', 'wer',
'so', 'da', 'hier', 'dort', 'nun', 'mal', 'gerade', 'dann', 'nach', 'vor', 'seit',
'sehr', 'mehr', 'viel', 'wieder', 'immer', 'schon', 'halt', 'einfach', 'eigentlich',
'heute', 'jetzt', 'wieder', 'etwa',
]);
const EN_STOP_WORDS = new Set([
'the', 'a', 'an', 'and', 'or', 'but', 'not', 'no', 'nor',
'i', 'me', 'my', 'myself', 'we', 'our', 'you', 'your', 'he', 'she', 'it', 'they',
'him', 'her', 'his', 'its', 'them', 'their', 'this', 'that', 'these', 'those',
'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had',
'do', 'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might', 'can',
'of', 'in', 'to', 'for', 'on', 'at', 'by', 'with', 'from', 'as', 'into', 'about',
'so', 'just', 'also', 'then', 'now', 'here', 'there', 'up', 'out', 'very',
'really', 'already', 'again', 'too', 'today', 'after', 'before',
]);
const WEEKDAY_KEYS = [
'profile.cooldown.patterns.weekday_mon',
'profile.cooldown.patterns.weekday_tue',
'profile.cooldown.patterns.weekday_wed',
'profile.cooldown.patterns.weekday_thu',
'profile.cooldown.patterns.weekday_fri',
'profile.cooldown.patterns.weekday_sat',
'profile.cooldown.patterns.weekday_sun',
] as const;
const MAX_BAR_HEIGHT = 48;
const MIN_BAR_HEIGHT = 2;
type TopWord = { word: string; count: number };
function buildHourBuckets(entries: BackendCooldownEntry[]): number[] {
const buckets = Array(24).fill(0);
for (const e of entries) {
const h = new Date(e.startedAt).getHours();
buckets[h]++;
}
return buckets;
}
function buildWeekdayBuckets(entries: BackendCooldownEntry[]): number[] {
const buckets = Array(7).fill(0);
for (const e of entries) {
const jsDay = new Date(e.startedAt).getDay();
const moFirst = jsDay === 0 ? 6 : jsDay - 1;
buckets[moFirst]++;
}
return buckets;
}
function extractTopWords(entries: BackendCooldownEntry[], lang: string): TopWord[] {
const stopWords = lang.startsWith('de') ? DE_STOP_WORDS : EN_STOP_WORDS;
const freq: Record<string, number> = {};
for (const e of entries) {
if (!e.reason) continue;
const words = e.reason
.toLowerCase()
.replace(/[^a-zA-ZäöüÄÖÜß\s]/g, ' ')
.split(/\s+/);
for (const w of words) {
if (w.length < 3) continue;
if (stopWords.has(w)) continue;
freq[w] = (freq[w] ?? 0) + 1;
}
}
return Object.entries(freq)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([word, count]) => ({ word, count }));
}
function cancelRate(entries: BackendCooldownEntry[]): number {
if (entries.length === 0) return 0;
const cancelled = entries.filter((e) => e.status === 'cancelled').length;
return Math.round((cancelled / entries.length) * 100);
}
type BarChartProps = {
buckets: number[];
labels: string[];
highlightIndices?: number[];
colors: ReturnType<typeof import('../../lib/theme').useColors>;
};
function BarChart({ buckets, labels, highlightIndices, colors }: BarChartProps) {
const maxCount = Math.max(...buckets, 1);
return (
<View style={{ flexDirection: 'row', alignItems: 'flex-end', gap: 2 }}>
{buckets.map((count, i) => {
const isEmpty = count === 0;
const barHeight = isEmpty
? MIN_BAR_HEIGHT
: Math.max(MIN_BAR_HEIGHT, (count / maxCount) * MAX_BAR_HEIGHT);
const isHighlight = highlightIndices?.includes(i);
return (
<View key={i} style={{ flex: 1, alignItems: 'center' }}>
<View
style={{
width: '100%',
height: barHeight,
borderRadius: 3,
backgroundColor: isEmpty
? colors.border
: isHighlight
? colors.brandOrange
: colors.brandOrange + 'aa',
}}
/>
{labels[i] !== '' ? (
<Text
style={{
marginTop: 4,
fontSize: 8,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
textAlign: 'center',
}}
numberOfLines={1}
>
{labels[i]}
</Text>
) : (
<View style={{ height: 12 }} />
)}
</View>
);
})}
</View>
);
}
type SectionHeadingProps = { label: string; colors: ReturnType<typeof import('../../lib/theme').useColors> };
function SectionHeading({ label, colors }: SectionHeadingProps) {
return (
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_700Bold',
letterSpacing: 0.6,
marginBottom: 10,
}}
>
{label.toUpperCase()}
</Text>
);
}
type Props = {
rawCooldowns: BackendCooldownEntry[] | null;
};
export function CooldownPatternAnalysis({ rawCooldowns }: Props) {
const { t, i18n } = useTranslation();
const colors = useColors();
const lang = i18n.language ?? 'de';
const [expanded, setExpanded] = useState(false);
function toggle() {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
setExpanded((v) => !v);
}
const entries = rawCooldowns ?? [];
const hasData = entries.length > 0;
const hourBuckets = hasData ? buildHourBuckets(entries) : Array(24).fill(0);
const weekdayBuckets = hasData ? buildWeekdayBuckets(entries) : Array(7).fill(0);
const topWords = hasData ? extractTopWords(entries, lang) : [];
const pct = cancelRate(entries);
const hourLabels: string[] = Array(24)
.fill('')
.map((_, h) => {
if (h === 6) return t('profile.cooldown.patterns.hour_morning');
if (h === 12) return t('profile.cooldown.patterns.hour_afternoon');
if (h === 18) return t('profile.cooldown.patterns.hour_evening');
if (h === 0) return t('profile.cooldown.patterns.hour_night');
return '';
});
const weekdayLabels: string[] = WEEKDAY_KEYS.map((k) => t(k));
const peakHour = hasData ? hourBuckets.indexOf(Math.max(...hourBuckets)) : -1;
const peakDay = hasData ? weekdayBuckets.indexOf(Math.max(...weekdayBuckets)) : -1;
return (
<View style={{ marginTop: 8 }}>
<TouchableOpacity onPress={toggle} activeOpacity={0.7}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 10,
paddingHorizontal: 2,
}}
>
<Text
style={{
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_600SemiBold',
}}
>
{t('profile.cooldown.patterns.toggle_label')}
</Text>
<Ionicons
name={expanded ? 'chevron-down' : 'chevron-forward'}
size={14}
color={colors.textMuted}
/>
</View>
</TouchableOpacity>
{expanded ? (
<View
style={{
backgroundColor: colors.card,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 14,
padding: 16,
gap: 20,
}}
>
{!hasData ? (
<Text
style={{
fontSize: 13,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
textAlign: 'center',
paddingVertical: 8,
}}
>
{t('profile.cooldown.patterns.not_enough')}
</Text>
) : (
<>
<View>
<SectionHeading
label={t('profile.cooldown.patterns.hour_heading')}
colors={colors}
/>
<BarChart
buckets={hourBuckets}
labels={hourLabels}
highlightIndices={peakHour >= 0 ? [peakHour] : []}
colors={colors}
/>
</View>
<View
style={{
height: 1,
backgroundColor: colors.border,
}}
/>
<View>
<SectionHeading
label={t('profile.cooldown.patterns.day_heading')}
colors={colors}
/>
<BarChart
buckets={weekdayBuckets}
labels={weekdayLabels}
highlightIndices={peakDay >= 0 ? [peakDay] : []}
colors={colors}
/>
</View>
<View
style={{
height: 1,
backgroundColor: colors.border,
}}
/>
<View>
<SectionHeading
label={t('profile.cooldown.patterns.reason_heading')}
colors={colors}
/>
{topWords.length >= 3 ? (
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginBottom: 12 }}>
{topWords.map(({ word, count }) => (
<View
key={word}
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.border,
borderRadius: 999,
paddingVertical: 5,
paddingHorizontal: 12,
gap: 6,
}}
>
<Text
style={{
fontSize: 12,
color: colors.text,
fontFamily: 'Nunito_600SemiBold',
}}
>
{word}
</Text>
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
({count})
</Text>
</View>
))}
</View>
) : (
<Text
style={{
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
marginBottom: 12,
}}
>
{t('profile.cooldown.patterns.not_enough')}
</Text>
)}
<Text
style={{
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
{t('profile.cooldown.patterns.cancel_rate', { pct })}
</Text>
</View>
</>
)}
</View>
) : null}
</View>
);
}