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>
280 lines
9.6 KiB
TypeScript
280 lines
9.6 KiB
TypeScript
import { useRef, useState } from 'react';
|
|
import { View, ScrollView, Text, Alert, findNodeHandle, UIManager } from 'react-native';
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
import { useRouter } from 'expo-router';
|
|
import { AppHeader } from '../../components/AppHeader';
|
|
import { ProfileHeader, type AuthProvider } from '../../components/profile/ProfileHeader';
|
|
import { StatsBar } from '../../components/profile/StatsBar';
|
|
import { ApprovedDomainsList } from '../../components/profile/ApprovedDomainsList';
|
|
import { StreakSection, type CooldownEntry } from '../../components/profile/StreakSection';
|
|
import { UrgeStatsCard, type HelpedByEntry } from '../../components/profile/UrgeStatsCard';
|
|
import { DemographicsAccordion, type Demographics } from '../../components/profile/DemographicsAccordion';
|
|
import { DigaMissionBanner } from '../../components/profile/DigaMissionBanner';
|
|
import { useColors } from '../../lib/theme';
|
|
import type { Plan } from '../../hooks/useUserPlan';
|
|
import { useMe } from '../../hooks/useMe';
|
|
import { useAuthStore } from '../../stores/auth';
|
|
import {
|
|
useSocialStats,
|
|
useApprovedDomains,
|
|
useCooldownHistory,
|
|
useCooldownHistoryFull,
|
|
useSosInsights,
|
|
useDemographics,
|
|
} from '../../hooks/useProfileData';
|
|
import { apiFetch } from '../../lib/api';
|
|
|
|
const EMPTY_COOLDOWNS: CooldownEntry[] = [];
|
|
|
|
function isDemographicsComplete(d: Demographics): boolean {
|
|
const base =
|
|
d.birthYear !== null &&
|
|
!!d.gender &&
|
|
!!d.maritalStatus &&
|
|
!!d.employmentStatus &&
|
|
!!d.bundesland &&
|
|
!!d.city;
|
|
if (!base) return false;
|
|
const status = d.employmentStatus!;
|
|
const needsShift = ['employed', 'self_employed'].includes(status);
|
|
const needsIndustry = ['employed', 'self_employed', 'in_training'].includes(status);
|
|
const needsTenure = ['employed', 'self_employed'].includes(status);
|
|
if (needsShift && d.shiftWork === null) return false;
|
|
if (needsIndustry && !d.industry) return false;
|
|
if (needsTenure && !d.jobTenure) return false;
|
|
return true;
|
|
}
|
|
|
|
const EMPTY_DEMOGRAPHICS: Demographics = {
|
|
birthYear: null,
|
|
gender: null,
|
|
maritalStatus: null,
|
|
employmentStatus: null,
|
|
shiftWork: null,
|
|
industry: null,
|
|
jobTenure: null,
|
|
bundesland: null,
|
|
city: null,
|
|
};
|
|
|
|
function formatMemberSince(isoString: string | undefined): string {
|
|
if (!isoString) return '';
|
|
const d = new Date(isoString);
|
|
return d.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
|
|
}
|
|
|
|
function formatStreakStartDate(isoString: string | undefined): string {
|
|
if (!isoString) return '';
|
|
const d = new Date(isoString);
|
|
return d.toLocaleDateString('de-DE', { day: 'numeric', month: 'long', year: 'numeric' });
|
|
}
|
|
|
|
function mapHelpedBy(helpedBy: {
|
|
breathing: number;
|
|
game: number;
|
|
talk: number;
|
|
other: number;
|
|
}): HelpedByEntry[] {
|
|
const entries: HelpedByEntry[] = [
|
|
{ key: 'breathing', label: 'Atemübung', count: helpedBy.breathing },
|
|
{ key: 'game', label: 'Spiel', count: helpedBy.game },
|
|
{ key: 'talk', label: 'Reden mit Lyra', count: helpedBy.talk },
|
|
{ key: 'other', label: 'Sonstiges', count: helpedBy.other },
|
|
];
|
|
return entries.filter((e) => e.count > 0);
|
|
}
|
|
|
|
export default function ProfileScreen() {
|
|
const router = useRouter();
|
|
const insets = useSafeAreaInsets();
|
|
const colors = useColors();
|
|
const [bannerDismissed, setBannerDismissed] = useState(false);
|
|
const [demographicsExpanded, setDemographicsExpanded] = useState(false);
|
|
const { me } = useMe();
|
|
const { user } = useAuthStore();
|
|
|
|
const { stats: socialStats } = useSocialStats(me?.id);
|
|
const { domains: approvedDomainsData } = useApprovedDomains();
|
|
const { cooldownHistory } = useCooldownHistory();
|
|
const { rawCooldowns } = useCooldownHistoryFull();
|
|
const { sosInsights } = useSosInsights();
|
|
const {
|
|
demographics: serverDemographics,
|
|
withdrawnAt,
|
|
reload: reloadDemographics,
|
|
} = useDemographics();
|
|
|
|
const demographics: Demographics = serverDemographics ?? EMPTY_DEMOGRAPHICS;
|
|
|
|
const scrollViewRef = useRef<ScrollView | null>(null);
|
|
const demographicsAnchorRef = useRef<View | null>(null);
|
|
|
|
const provider: AuthProvider =
|
|
((user?.app_metadata as { provider?: string } | undefined)?.provider as AuthProvider) ?? 'email';
|
|
|
|
const profile = {
|
|
nickname: me?.nickname ?? user?.email?.split('@')[0] ?? 'User',
|
|
email: user?.email ?? '',
|
|
avatar: me?.avatar ?? null,
|
|
plan: ((me as { plan?: string } | null | undefined)?.plan ?? 'free') as Plan,
|
|
memberSince: formatMemberSince(me?.created_at),
|
|
provider,
|
|
};
|
|
|
|
const currentStreak = me?.streak ?? 0;
|
|
// TODO(backend): longestDays + streakStartDate fehlen in /api/auth/me.
|
|
// Backend-Agent: Profile-Tabelle braucht longestStreak:Int + streakStartedAt:DateTime.
|
|
// Tracking: streakStartedAt wird bei jedem Streak-Reset auf NOW() gesetzt.
|
|
const longestDays = currentStreak;
|
|
const streakStartDate = formatStreakStartDate(me?.created_at);
|
|
|
|
const showDigaBanner = currentStreak >= 30 && !bannerDismissed;
|
|
const demoComplete = !withdrawnAt && isDemographicsComplete(demographics);
|
|
|
|
function scrollToDemographics() {
|
|
const node = demographicsAnchorRef.current;
|
|
const scroll = scrollViewRef.current;
|
|
if (!node || !scroll) return;
|
|
const handle = findNodeHandle(node);
|
|
const scrollHandle = findNodeHandle(scroll);
|
|
if (!handle || !scrollHandle) return;
|
|
UIManager.measureLayout(
|
|
handle,
|
|
scrollHandle,
|
|
() => {},
|
|
(_x, y) => {
|
|
scroll.scrollTo({ y: Math.max(0, y - 16), animated: true });
|
|
},
|
|
);
|
|
}
|
|
|
|
function openDemographics() {
|
|
setDemographicsExpanded(true);
|
|
scrollToDemographics();
|
|
}
|
|
|
|
return (
|
|
<View style={{ flex: 1, backgroundColor: colors.groupedBg }}>
|
|
<AppHeader showBack title="Profil" />
|
|
<ScrollView
|
|
ref={scrollViewRef}
|
|
style={{ flex: 1 }}
|
|
contentContainerStyle={{ paddingBottom: insets.bottom + 80 }}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
<ProfileHeader
|
|
nickname={profile.nickname}
|
|
email={profile.email}
|
|
avatar={profile.avatar}
|
|
plan={profile.plan}
|
|
memberSince={profile.memberSince}
|
|
provider={profile.provider}
|
|
demographicsComplete={demoComplete}
|
|
showDemographicsHint={!demoComplete}
|
|
onDemographicsHintPress={openDemographics}
|
|
onEditAvatar={() => router.push('/profile/edit')}
|
|
onEditNickname={() => router.push('/profile/edit')}
|
|
/>
|
|
|
|
<View
|
|
style={{
|
|
height: 1,
|
|
backgroundColor: colors.border,
|
|
marginHorizontal: 16,
|
|
}}
|
|
/>
|
|
|
|
<View style={{ marginTop: 16 }}>
|
|
<StatsBar
|
|
postsCount={socialStats?.postsCount ?? 0}
|
|
followersCount={socialStats?.followersCount ?? 0}
|
|
approvedDomainsCount={approvedDomainsData?.count ?? 0}
|
|
onPostsPress={() => {}}
|
|
onFollowersPress={() => {}}
|
|
onApprovedDomainsPress={() => {}}
|
|
/>
|
|
</View>
|
|
|
|
{showDigaBanner ? (
|
|
<DigaMissionBanner
|
|
onDismiss={() => {
|
|
setBannerDismissed(true);
|
|
apiFetch('/api/profile/me/diga-banner-dismiss', { method: 'POST' }).catch(() => {});
|
|
}}
|
|
onContribute={() => {
|
|
setBannerDismissed(true);
|
|
scrollToDemographics();
|
|
}}
|
|
/>
|
|
) : null}
|
|
|
|
<StreakSection
|
|
currentDays={currentStreak}
|
|
longestDays={longestDays}
|
|
startDate={streakStartDate}
|
|
cooldowns={cooldownHistory?.items ?? EMPTY_COOLDOWNS}
|
|
rawCooldowns={rawCooldowns}
|
|
/>
|
|
|
|
<UrgeStatsCard
|
|
sessions={sosInsights?.last30Days.sessions ?? 0}
|
|
overcome={sosInsights?.last30Days.overcome ?? 0}
|
|
helpedBy={
|
|
sosInsights
|
|
? mapHelpedBy(sosInsights.helpedBy)
|
|
: []
|
|
}
|
|
topEmotion={sosInsights?.topEmotion ?? null}
|
|
/>
|
|
|
|
<View ref={demographicsAnchorRef} collapsable={false}>
|
|
<DemographicsAccordion
|
|
demographics={demographics}
|
|
plan={profile.plan}
|
|
expanded={demographicsExpanded}
|
|
onChange={async (next) => {
|
|
try {
|
|
const result = await apiFetch<{ trialAwarded: boolean; expiresAt: string | null }>(
|
|
'/api/profile/me/demographics',
|
|
{ method: 'PATCH', body: next },
|
|
);
|
|
reloadDemographics();
|
|
if (result.trialAwarded) {
|
|
Alert.alert(
|
|
'Pro-Woche freigeschaltet',
|
|
'Danke fur deine DiGA-Daten. Du hast 7 Tage Pro kostenlos erhalten.',
|
|
);
|
|
}
|
|
} catch {
|
|
// write failed — optimistic update not applied, server state preserved
|
|
}
|
|
}}
|
|
onRevokeConsent={() => {
|
|
Alert.alert(
|
|
'Daten zuruckziehen',
|
|
'Alle demografischen Angaben werden geloscht. Fortfahren?',
|
|
[
|
|
{ text: 'Abbrechen', style: 'cancel' },
|
|
{
|
|
text: 'Loschen',
|
|
style: 'destructive',
|
|
onPress: () => {
|
|
apiFetch('/api/profile/me/demographics', { method: 'DELETE' })
|
|
.then(() => reloadDemographics())
|
|
.catch(() => {});
|
|
},
|
|
},
|
|
],
|
|
);
|
|
}}
|
|
/>
|
|
</View>
|
|
|
|
<ApprovedDomainsList domains={approvedDomainsData?.list ?? []} />
|
|
|
|
<View style={{ height: 24 }} />
|
|
</ScrollView>
|
|
</View>
|
|
);
|
|
}
|