46 Commits

Author SHA1 Message Date
chahinebrini
f19d00017a feat: pre-check global blocklist on add + collapse Mails on load
User found that adding bet365.com (which is in the 208k global filter)
silently took a custom-domain slot — they paid a slot for something
the global blocklist already covered. Two pieces:

1. backend/custom-domains/index.post.ts: before any slot-limit check or
   DB insert, look the domain up in blocklist_domain (active rows). If
   present, return 200 { alreadyGlobal: true, domain }. No row gets
   written, no slot consumed. The existing frontend hook + AddSheet
   already handle the alreadyGlobal flag — they surface the
   "bereits global blockiert" alert and don't refresh as if the entry
   landed in the user's list.

2. blocker.tsx default mailOpen state flipped from true to false so the
   Eigene Mails section starts collapsed on page load. Domains stays
   the primary affordance; mail-patterns are an opt-in expansion.
2026-05-16 02:42:42 +02:00
chahinebrini
1215356990 feat(backend): add POST /api/devices/check-lock for native auth flow
Native app uses supabase.auth.signInWithPassword directly, bypassing
/api/auth/login. This authenticated endpoint runs the same device-lock
check post-auth: 409 DEVICE_LOCKED if bound to another user, 200+bind
if Pro/Legend user, no-op bind for Free users. CORS headers extended
to include x-device-name/model/os. 34 tests green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 02:38:59 +02:00
chahinebrini
34491ad220 feat(backend): denormalize domain_submissions.type for admin + lyra + notifications
User asked for the admin review tooling — and the lyra-bot community
post / notification text that goes out with each submission — to know
whether a submission is a website-domain or a mail-sender-domain. Until
now the type lived only on user_custom_domains and the submission
inherited it implicitly via the foreign key. Reading it back for the
admin list or the lyra prompt meant joining the source row every time.

- migration 20260516_domain_submission_type adds a type column to
  rebreak.domain_submissions with a default of 'web' and backfills
  every existing row from its linked user_custom_domains.type. The
  backfill is idempotent (UPDATE … FROM with the type comparison).
- Composite index (type, status) so the admin pending-list can scope
  by category without scanning the whole table.
- submitDomainForReview now copies the source row's type into the new
  submission. The submit endpoint picks it up to vary the auto-generated
  community-vote post copy: a website framing for type='web' and an
  "Mail-Absender"-framing for type='mail_domain'. The user's nickname
  is the only PII referenced.
- adminApproveSubmission returns the type alongside the domain so the
  approve endpoint's Lyra-bot Groq prompt can swap its subject/action
  labels per category. Reject path unchanged — the notification just
  carries the bare domain string, no type framing needed.
- BlocklistDomain stays type-agnostic on purpose. The mail-daemon's
  getBlocklistedDomainsSet is a flat string-set match against sender
  domain or URL host, and works for both categories without splitting.
  Adding a type there would be redundant work in v1.0 — revisit only
  if we ever need a UI to surface what category each global entry
  came from.

38/38 backend tests pass (8 admin/domains, 30 plan-limits including
5 new for the type-copy semantics and community-post text variants).
2026-05-16 02:24:42 +02:00
chahinebrini
c1250836a3 fix(backend): remove display-name pattern support for v1.0
User explicitly chose to drop display-name matching from v1.0 after
the UX trap surfaced — a user typing "EXTRASPIN" without a domain got
a 400 INVALID_DOMAIN back, which is a confusing dead-end. v1.1 will
ship a dedicated display-name UI; until then mail input is domain-only.

- resolveTypeAndValue returns a discriminated union — kind='mail' with
  no dot or @ now resolves to { ok: false, error: 'INVALID_MAIL_DOMAIN' }
  instead of silently turning into a mail_display_name row.
- Full-address mail input (local@domain.tld) still gets its local-part
  stripped server-side so the stored value is always a clean domain.
- Variant-B body { type: 'mail_display_name' } returns 400
  DISPLAY_NAME_NOT_SUPPORTED for direct API consumers.
- The DISPLAY_NAME_PATTERN regex is gone — the path that used it can
  no longer be reached.
- classifyMail's Layer 2.6 (the display-name substring match) is
  intentionally left in place as dead code with a v1.1 marker, so
  re-enabling later is just wiring the input field back up and feeding
  the customDisplayNames array.
- Tests rewritten: the two pre-existing display-name tests now assert
  the 400 INVALID_MAIL_DOMAIN path, plus a new positive case for the
  full-address local-part strip. 217 vitest passes, 4 pre-existing skips.

Staging DB clean — the type column hasn't been deployed yet so no
mail_display_name rows exist to backfill.
2026-05-16 02:17:50 +02:00
chahinebrini
f2b81eef54 feat(backend/plan): separate web/mail slot pools + display-name submit lock
plan-features.customDomains is now { web, mail } per plan instead of a
single number. Free 5+5, Pro 5+5, Legend 10+10 — the user explicitly
chose separate pools so users don't have to trade a website slot for a
mail-pattern slot or vice versa.

- countActiveCustomDomainsSplit(userId) groupBy type → { web, mail }
  (mail aggregates mail_domain + mail_display_name). Old single-count
  function stays as a deprecated alias for any caller still on it.
- POST /api/custom-domains: body-compat accepts both { pattern, kind }
  (current frontend) and { domain, type } (legacy / direct). kind='mail'
  is split into mail_domain vs mail_display_name server-side based on
  whether the pattern looks like a domain. Slot check is per-bucket;
  errors are WEB_LIMIT_REACHED / MAIL_LIMIT_REACHED so the UI can show
  the right limit-reached message per tab.
- GET /api/custom-domains: response shape extended to
  { items, counts: { web, mail }, limits: { web, mail } } so the
  frontend can drive the per-tab counter without client-side estimation.
- POST /api/custom-domains/:id/submit: hard-blocks mail_display_name
  with 400 DISPLAY_NAME_NOT_SUBMITTABLE. Display-name submission to the
  global blocklist is deferred to v1.1 — would require a schema split
  on BlocklistDomain that's risky pre-TestFlight. mail_domain still
  flows through the community-vote pipeline like web entries.
- auth/me.get.ts, plan/change-preview.get.ts, coach/message.post.ts
  updated for the new shape (Lyra prompts untouched, only template
  variables split web vs mail counts).

24 vitest cases in backend/tests/custom-domains/plan-limits.test.ts
cover the new shape, body compat, bucket logic, and the submit guard;
216/216 total backend tests pass.
2026-05-16 02:03:26 +02:00
chahinebrini
7dbcac6700 feat(backend): custom mail patterns — display-name match + type-aware api
Completes the custom-mail-patterns feature (schema + migration shipped
in ba170af alongside the chat-tab-badge commit — apologies for the
mishap, agent staging collided with mine). This is the actual logic
that makes the new type column do work:

- mail-classifier.ts: new layer 2.6 between brand+random-token detect
  and the score-based heuristic. Case-insensitive substring match of
  the From-display-name against the user's customDisplayNames list.
  Hard-block when matched, skip score entirely.
- db/domains.ts: getCustomMailDisplayNames(userId) reads the new
  type=mail_display_name rows. countActiveCustomDomains stays a shared
  total — matches the user's pick of a single 5/5/10 pool spanning
  web + mail patterns rather than separate counts per type.
- scan-internal.post.ts and scan.post.ts both preload the display-name
  list per user before the message loop and thread it into classifyMail.
- POST /api/custom-domains accepts { pattern, kind: 'web' | 'mail' }
  with the server inferring the concrete type — 'mail' splits into
  mail_domain when the input contains a TLD-like shape, otherwise
  mail_display_name. Existing { domain } body shape stays accepted
  for backwards compatibility with older clients.
- POST /api/custom-domains/:id/submit treats both mail types as
  community-submittable. The user explicitly chose this; the admin
  review pipeline is the backstop against display-name false positives.
- vitest cases cover: substring match, case insensitivity, no-match
  fallthrough to score, mail_domain still flowing through the existing
  domain-set path, and shared-pool slot counts (3 web + 2 mail_domain
  + 1 mail_display_name = 6 against the 10-slot legend cap).
2026-05-16 01:53:59 +02:00
chahinebrini
1bc38e0732 feat(backend): device-account binding for pro/legend users
Closes the bypass loophole where a Pro/Legend user could log out in a
craving moment, sign in with a fresh Free account on the same iPhone,
and watch the NEFilter blocklist shrink from 208k Casino domains to
the curated 30-domain stub. The user is the patient — the addiction
itself is the attacker.

When a Pro/Legend account signs in via x-device-id, the device is
bound to that user_id (UserDevice.boundToPlan = 'pro'|'legend' …).
A subsequent login attempt from a different account on the same
device returns 409 DEVICE_LOCKED. The original user gets a Resend
email naming the nickname only (no firstName / email leaked per
the anonymity rule) with a link to either confirm the foreign attempt
or release the device.

Release flow:
  - POST /api/devices/:id/request-release schedules releaseAt = now + 24h
  - POST /api/devices/:id/cancel-release reverts it
  - a Nitro plugin cron sweeps both (24h-requested releases AND
    30-day-idle auto-releases) hourly

Free -> Free swaps stay unrestricted so onboarding on a second-hand
iPhone keeps working. Free -> Pro upgrade binds going forward; a
Pro -> Free downgrade keeps the existing lock so the bypass vector
stays closed.

Lock check runs BEFORE Supabase auth in /api/auth/login to avoid
giving a timing oracle for account enumeration. The dummy-UUID filter
in findActiveDeviceLock is the trick: it queries "someone else's
lock" with a userId that can never match.

DSGVO: ON DELETE CASCADE on UserDevice means an Art-17 deletion of
the original user releases all their locks automatically (Hans-Mueller
hand-off noted in the migration SQL).

24 vitest cases cover bind / lock / request-release-24h /
cancel-release / 30-day-idle-release / email rate-limit (1 per 6h) /
DSGVO cascade / multi-device Legend.

Migration to deploy after push:
  infisical run -- npx prisma migrate deploy --schema backend/prisma/schema.prisma

Frontend follow-up (separate task):
  - Sign-In: handle 409 DEVICE_LOCKED with a dedicated error UI
  - Settings/Devices page: "Release device" button + 24h countdown
  - GET /api/devices to include boundToPlan + releaseRequestedAt
2026-05-16 00:29:35 +02:00
chahinebrini
0e4c3787c2 feat(backend): DoH handshake endpoint for protected-device auto-activation
POST /api/devices/protected/handshake — server-to-server endpoint called by
the AdGuard log-watcher whenever a Mac with our DNS-profile makes a DoH query
with its dnsToken embedded in the path (/dns-query/<token>).

- Idempotent: pending → active on first hit, lastDnsQueryAt always updated
- Auth: shared secret via x-handshake-secret (Infisical: HANDSHAKE_SECRET,
  must be set before enabling the watcher)
- Revoked tokens are silently ignored (no info leak to potential attackers)
- Realtime publication added so the native app auto-advances the AddMacSheet
  flow when status flips (no "I've installed it" button needed anymore)
2026-05-15 22:41:17 +02:00
chahinebrini
5b1f89e749 feat(backend): device-info schema + merge heuristic + test-user detection
- Schema: lyraVoiceId stays, new os_version column on user_devices (Migration 20260515)
- registerDevice() merge-heuristic: if existing record matches userId + same name +
  same model + lastSeen < 30 days, update existing instead of inserting new.
  Fixes iOS IDFV-reset creating phantom devices on Recovery-Restore.
- register.post.ts: accepts osVersion in body, maps isCurrent in error-path payload
- New util testUser.ts: isTestUser(email) — explicit allowlist for charioanouar@gmail.com
  plus existing @rebreak.internal suffix

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 21:16:05 +02:00
chahinebrini
76f8595a4f feat(backend): lyra voice picker for legend (mvp)
- profile.ts: Whitelist (null | iFSsEDGbm0FiEd2IVH4w | Gt7OshJCH7MuzX96wFHi) + setLyraVoiceId()
- profile/me/lyra-voice.patch.ts: neuer Endpoint, Legend-Gate (403 legend_only),
  Validation gegen Whitelist (400 invalid_voice_id). DB-Wert bleibt bei Plan-Downgrade.
- coach/speak.post.ts: ElevenLabs-Voice-Prioritätskette nimmt userLyraVoiceId
  zuerst (nur wenn plan === legend), sonst voiceCfg / config / env / FALLBACK.
- auth/me.get.ts: lyraVoiceId in der Profile-Response damit Frontend hydriert.

Schema-Feld lyraVoiceId existiert bereits aus migration
20260507_profile_demographics_and_trial — keine neue Migration nötig.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 22:15:42 +02:00
chahinebrini
f2e3c00943 refactor(mail): remove groq llm layer — deterministic pipeline only
User-Direktive: Mail-Filter bleibt auf dem deterministischen Score+Layer-2.5-Stack.
Groq-LLM Borderline-Call (Layer 4) entfernt. Layer 2.5 Brand+Random fängt den
Apple Hide-My-Email Fall (icloud.com-Adressen mit kryptischen Local-Parts +
Brand-DisplayName) weiterhin sauber via Hard-Block.

Score-Mid-Range 25-79 entscheidet jetzt deterministisch: ≥50 → BLOCK, sonst PASS.

Damit auch DSGVO-P0-Items aus dem Hans-Müller-Review obsolet
(AVV-Annex Groq, Drittland-USA-Consent-Toggle, Datenschutzerklärung-Absatz).

- mail-classifier.ts: callGroqClassifier + redactLocalPartForLLM + groq-Feld raus
- scan.post.ts + scan-internal.post.ts: groqApiKey-Param raus, groq*-Sample-Felder raus
- mail-classifier.test.ts: Groq-Tests + redactLocalPart-Tests entfernt, 46 Tests grün

DB-Spalten in mail_classification_samples (groq_*) bleiben als legacy nullable —
Cleanup-Migration optional in späterem Sprint.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 22:15:32 +02:00
chahinebrini
343f9ab567 fix(mail): DSGVO Art. 17 — manuelles Sample-Cleanup bei Account-Delete
MailClassificationSample hat keine userId-FK-Cascade im Schema (connectionId
ist nullable). Samples ohne connectionId blieben nach deleteAllMailConnections()
als Orphans stehen. Neuer Helper deleteUserMailClassificationSamples() löscht
explizit nach userId — wird in delete.delete.ts parallel zu anderen Lösch-Ops
ausgeführt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 22:05:35 +02:00
chahinebrini
bdd93668ae feat(mail): multi-layer classifier — Brand+Random, Relay-Decoder, Score, Groq + ML-Sampling
Layer 0–4 Klassifikations-Pipeline in mail-classifier.ts:
- Layer 2: Domain-Hard-Block + Relay-Decoder (=domain.tld aus SendGrid/Mailchimp-Bounces)
- Layer 2.5: Brand+Random-Token-Hard-Block (Gambling-Brand-Normalisierung + Random-Token-Detection)
  verhindert LLM-Call für bekannte Gambling-Relayer (Gamblezen, BetandPlay etc.)
- Layer 3: Score 0–100 (TS-Gewichte: Domain-Keywords, Subject-Keywords, Name-Match,
  Geld-Pattern, Urgency, All-Caps, Short-Random-Domain, Brand/Random-Ergänzungen)
- Layer 4: Groq Llama 3.3 70B Borderline-Klassifikation (Score 25–75)
  mit Local-Part-Redaction (DSGVO: nur behalten wenn local-part selbst Keyword enthält)
- Layer 5: MailClassificationSample-Insert nach jeder Klassifikation (ML-Phase 3)

Migrations:
- 20260514_add_mail_blocked_trigger_source: ADD COLUMN trigger_source auf mail_blocked
- 20260514_add_mail_classification_sample: CREATE TABLE mail_classification_samples

50 neue Tests (mail-classifier.test.ts): alle Layer, beide Screenshot-Beispiele (Gamblezen +
BetandPlay) bestätigt als Layer-2.5-Hard-Block ohne LLM-Call, Whitelist, Score, Redaction.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 22:05:35 +02:00
chahinebrini
8075c8e79c feat(mail): outlook-OAuth scan + daemon initial-sweep + page polish v4
USP-Confirmed: Outlook-OAuth Casino-Bonus-Mail wurde end-to-end gefiltert
(User-verifiziert). Mit dieser Welle ist der Daemon plus alle Scan-Pfade
OAuth-aware.

Backend — Mail-Stack (mo):

- backend/server/utils/mail-auth.ts NEU: zentraler resolveImapAuth-Helper
  kapselt OAuth-vs-AppPassword-Entscheidung. 5-min-Token-Expiry-Puffer,
  race-condition-sicheres Refresh via refreshAndSaveTokens.
- scan.post.ts + scan-internal.post.ts nutzen jetzt resolveImapAuth statt
  decrypt(passwordEncrypted). Vorher: Outlook-Connections wurden still
  übersprungen weil passwordEncrypted='' → decrypt failed. Cron + manueller
  Scan-Button funktionieren jetzt für OAuth-Connections.
- imap-idle: Initial-Sweep via triggerScan(conn) direkt nach Connect-Success.
  Neue Outlook-Connections kriegen sofort einen Full-Folder-Scan statt bis
  zu 30 Min Cron-Lag zu warten. scan-internal scannt ohnehin schon alle
  Folders via imap.list() (Junk, Spam, Archive, Custom) — Multi-Folder-
  Anforderung ist damit erfüllt.

Frontend — Mail-Page Polish v4 (rebreak-native-ui):

- MailDistributionChart: Donut zurück auf 200px (240 wuchs auch in der
  Breite und quetschte die Legend), "Live"-Pill-Header komplett raus
  (paddingTop von 16 auf 13 reduziert für tighteres Layout)
- mail.tsx Page-Hierarchie: "Mehr Infos"-Collapsible wandert von unter
  der Postfach-Liste direkt unter den Hero-Donut. Sub-Beschreibung
  "Blockiert — letzte 30 Tage" entfernt — Title reicht.
- Account-Card Expanded: adaptive Bar-Chart über Connection-Age
  (too-new <24h zeigt Empty-State, 1-14d Day-Buckets via Backend
  ?connectionId=, 15-90d client-Week-Aggregation, >90d Month)
- Account-Card Expanded: Scan-Button "Jetzt scannen" mit Refresh-Icon
  (Memory: kein Pen-Icon, refresh ok). Spinner während Scan, Feedback
  mit Blocked-Count nach Success.

Eskalations-Hinweis (nicht in dieser Welle):
- POST /api/mail/scan akzeptiert noch keinen connectionId-Filter →
  Scan-Button-Tap scannt aktuell alle Connections statt nur die
  angeklickte. Kleiner Folge-Patch, nicht blocking.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:55:18 +02:00
chahinebrini
432d9d27a3 feat(mail-page): hero-donut + FAB + collapsible bar-chart + legend truncation
UX-Welle nach User-Feedback aus dem ersten Live-Test der Mail-Page:

Page-Hierarchie neu (top → bottom):

1. HALF-DONUT als HERO-Karte — bisherige "BLOCKIERT XX über N Postfächer Live"-
   Banner-Card weg, Inhalt ist jetzt Title-Zeile innerhalb der Donut-Karte
   (rendert nur ab ≥2 Connections; Fallback-Stats-Row für 0-1 Connections)
2. Postfach-Liste (Account-Cards aus letztem Refactor — schlanker Header)
3. NEU: "Mehr Infos"-Collapsible — Bar-Chart "Blockiert letzte 30 Tage"
   liegt jetzt versteckt drin (default collapsed)
4. Activity-Log "Kürzlich blockiert" (unverändert)
5. NEU: FAB unten rechts — 56pt brandOrange Kreis mit "+"-Icon,
   öffnet ConnectMailSheet. Section-Header-Plus-Button entfällt.

Half-Donut Legend-Truncation:
- ≤3 Connections → alle anzeigen
- =4 Connections → alle anzeigen
- ≥5 Connections → Top-3 by blocked-count + "Sonstige"-Bucket
  · Donut: 4 Segmente (Top-3 + OTHER_COLOR grau)
  · Legend: 4 Zeilen (Top-3 fett, "weitere"-Zeile in regular grau)

Backend: GET /api/mail/stats/blocked-by-day?connectionId=<uuid> als
optionaler Filter (für per-Connection-Bar-Chart in expanded Account-Card,
in dieser Welle noch nicht im UI verdrahtet — Erweiterung kommt wenn
gewünscht).

FAB-Details (iOS-diskreter Shadow statt Material-Glow):
- position absolute, right 24, bottom = tabBarHeight + insets.bottom + 16
- 56pt, borderRadius 28, brandOrange BG, weißes Plus-Icon
- ScrollView paddingBottom angehoben damit kein Content unter dem FAB clipped

Edge-Cases:
- 0 Accounts → FAB sichtbar, Donut/Stats/Charts/Log versteckt + EmptyState
- 1 Account → Donut hidden (nur mit ≥2 Connections sinnvoll), Fallback-Stats-Row
- limitReached + FAB-Tap → bestehender Plan-Alert (FAB ist visuell nicht disabled)

Memory: Pull-to-refresh + bestehendes 30s-Status-Polling reichen für "wartet
auf erste verbindung"→"aktiv"-Übergang nach OAuth-Connect (Daemon-Heartbeat
braucht initial 2-9min, mo-Befund). UX-Polish-Option für später: in der
Initial-Phase einen freundlicheren "Verbinde gerade…"-Status anzeigen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 22:39:45 +02:00
chahinebrini
7fb76465f0 fix(mail): explicit imports for MS-OAuth handlers (createOauthPendingState et al.)
Runtime-Error im /init und /callback weil Nitro auto-import nur server/utils/
scannt, nicht server/db/. Die Helper-Funktionen (createOauthPendingState,
consumeOauthPendingState, upsertOauthMicrosoftConnection, countMailConnections)
sowie ms-oauth-utility-Functions (exchangeCodeForTokens, extractEmailFromId
Token, generate*-Helpers, MS_*-Konstanten) wurden im Code implizit referenziert
aber nicht explizit importiert.

pnpm build:backend hat das nicht gefangen (Nitro Bundle-Step ist toleranter als
strict tsc). Erster Symptom auf staging:

  ReferenceError: createOauthPendingState is not defined
    at .../routes/api/mail/oauth/microsoft/init.post.mjs:36:3

Fix: explizite import-Statements in beiden Endpoints.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 21:25:43 +02:00
chahinebrini
fc69a14f25 feat(mail): outlook oauth — full end-to-end (backend + daemon + frontend)
Microsoft hat App-Passwords für consumer-Outlook im September 2024 abgeschaltet.
Diese Welle bringt OAuth2/XOAUTH2-Support als zweiten AuthMethod-Pfad — Gmail/
iCloud/GMX/Yahoo bleiben unangetastet auf App-Password.

Backend (rebreak-backend):

- POST /api/mail/oauth/microsoft/init: PKCE-Flow-Start, generiert
  code_verifier + Authorization-URL, persistiert pending state mit TTL
- POST /api/mail/oauth/microsoft/callback: Token-Exchange (PKCE, kein
  client_secret weil Public Client), id_token-Decode für Email, MailConnection
  upsert mit auth_method='oauth2_microsoft' + encrypted Tokens
- Token-Refresh-Util backend/server/utils/ms-oauth.ts + DB-Function
  refreshAndSaveTokens(connectionId, clientId) mit optimistic-concurrency-
  Race-Condition-Schutz (UPDATE WHERE oauth_token_expiry = <gelesener-wert>,
  bei affected_rows=0 → frischen Wert lesen statt nochmal refreshen sonst
  invalid_grant via Token-Rotation)
- Neue Tabelle oauth_pending_states (TTL via createdAt + Cleanup-Job-TODO)
- [id].delete.ts: echter OAuth-Disconnect — DB-Token-Löschung + Audit-Log
  (MS hat keinen Drittanbieter-Revoke-Endpoint, daher User-Information-Pflicht
  per Frontend-Modal, siehe DSB-Memo Section 5.1)
- Consent-Gate auch in scan.post.ts + scan-internal.post.ts (Cron-Trigger
  war ohne Consent-Check = DSGVO-Lücke, jetzt geschlossen mit
  skippedNoConsent-Field in Response)

IDLE-Daemon (backend/imap-idle/index.mjs, mo):

- XOAUTH2-Auth-Branch via getCredentialsForConnection() — wenn
  auth_method='oauth2_microsoft', Token-Expiry-Check (<5min remaining →
  proaktiver Refresh), sonst decrypted accessToken zu ImapFlow
- AUTHENTICATIONFAILED-Recovery: bis 3× reaktiv refresh + reconnect, danach
  last_connect_error='auth_revoked' (kein Endlos-Loop)
- IDLE_RENEW_INTERVAL_MS = 10min — passt für MS 29min-Timeout (gleich wie
  Gmail/iCloud)
- Consent-Pause: Connections mit consent_at=null laufen IDLE weiter (für
  exists-Event-Wiederaufnahme), aber triggerScan() ist deaktiviert bis
  consent erteilt
- start-idle-staging.sh: MS_OAUTH_CLIENT_ID explizit weiterleiten in den
  inneren bash -c-Block (war Infisical-Var, ging aber durch strict-mode
  verloren)

Frontend (rebreak-native-ui):

- Outlook-Tile re-aktiviert (war disabled mit "Kommt bald" seit Sept-2024-
  Awareness), authMethod-Discriminator löst statt Email+Pw-Form den
  OAuth-Flow aus
- ConnectMailSheet: neuer view-State 'oauth_warning' (Outing-Effekt-Hinweis
  per Hans-Müller-Memo Section 6.1) + 'oauth_pending' (Browser-Step-Spinner)
- Deep-Link-Handler app/auth/mail-oauth-callback.tsx — auto-registriert
  durch expo-router-File-Routing, kein Native-Rebuild (scheme 'rebreak'
  schon im app.config.ts)
- mailConnectDraft-Store: pendingOAuthConnectionId für Title-Edit-Sheet
  direkt nach Connect
- MailAccountCard: Password-Row hidden für OAuth-Connections, Post-Disconnect-
  Modal mit MS-Account-Anleitung (DSB-konform — kompensiert fehlenden
  Drittanbieter-Revoke-Endpoint mit User-Information)

Hans-Müller-DSB-Memo (mail-outlook-oauth-dsgvo-review.md):

- Section 4.1 Datenschutzerklärung-Textbaustein: "Wir widerrufen den Token
  aktiv bei Microsoft"-Satz raus (war faktisch falsch — MS hat keinen
  Drittanbieter-Revoke). Neuer Wortlaut: DB-Löschung + User-Anleitung
  account.microsoft.com → Sicherheit → App-Berechtigungen
- Section 4.1: User.Read-Scope offen dokumentiert mit Datenminimierungs-
  Klausel (Scope breiter, wir nutzen NUR Display-Name + Email-Claim)
- Section 5.1: ehrliche Doku dass MS keinen RFC-7009-Revoke hat
- Section 9 Anwalts-Themen: neue Frage 5 zur Art. 17-Erfüllung trotz
  fehlendem MS-Revoke

Architektur-Eigenschaften:

- Generisches AuthMethod-Framework — Gmail/iCloud/Yahoo können später als
  reine Config-Erweiterung OAuth bekommen, kein Refactor nötig
- Token-Encryption via bestehendes crypto.ts (AES-256-GCM, Key aus
  Infisical)
- Consent-Gate konsistent: ConnectMailSheet-Consent-Step VOR Provider-
  Auswahl (Frontend), backend-Endpoint 412 wenn consent fehlt, Daemon +
  Scan-Endpoints pausieren bei consent_at=null

Open follow-ups:

- oauth_pending_states-Cleanup-Cron für abgelaufene Entries (TODO im
  Backend-Code dokumentiert)
- Anwalts-Klärung Hans-Müller Section 9 (DPA-Anspruch ohne MS-Lizenz +
  Art. 17 mit User-Information statt Revoke-Endpoint)
- TIA (Transfer Impact Assessment) für MS-Sub-AV — Hans-Müller-Draft-Aufgabe
- Outlook-Tile-Wieder-Aktivierung ist live, aber Phase-1-Production-Test
  steht aus (User Test auf iPhone nach Pipeline-Deploy)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 21:04:14 +02:00
chahinebrini
275637f0b0 feat(mail): separate mail_blocked_stats table — preserves charts beyond 24h cleanup
deleteOldMailBlocked löscht weiter rohe Einträge nach 24h (Datenminimierung
für Mail-Inhalte, DSGVO Art. 5 Abs. 1 lit. c). Aber für Charts und
Pattern-Analysen werden vor dem Cleanup permanent aggregierte Daten in
einer separaten Tabelle geführt.

Architektur:

- Neue Tabelle mail_blocked_stats — UNIQUE (user_id, date, connection_id),
  enthält ausschließlich counts + UTC-Datum + IMAP-Host. Kein Subject,
  kein Sender, kein Mail-Inhalt. Datenminimierung jetzt auch im Audit-
  Pfad sichtbar.
- Live-Aggregation: scan.post.ts + scan-internal.post.ts upserten direkt
  nach jedem mail_blocked-INSERT in mail_blocked_stats (count += 1).
- 30-Tage-Backfill als SQL im Migration-File: bestehende mail_blocked-
  Rows der letzten 30 Tage werden einmalig aggregiert, damit Charts
  nicht 30 Tage lang leer aussehen.
- Stats-Endpoints (blocked-by-day, blocked-by-connection) lesen jetzt
  aus mail_blocked_stats. Response-Shape unverändert → Frontend bleibt
  unberührt.

ON DELETE CASCADE auf mail_connection_id (Hans-Müller-konservativ):
User-initiierter Disconnect = Art. 17-Signal → assoziierte Stats werden
mitgelöscht. SetNull wäre DSGVO-grenzwertig (orphan stats ohne klare
Lösch-Kontext-Zuordnung).

pnpm build:backend clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 19:13:24 +02:00
chahinebrini
b7909d77e4 feat(mail): custom title + settings collapsible + stats charts + provider filter
Mail-Page-Refactor — Privacy-friendly + DiGA-tauglich:

- Custom title pro mail-connection (z.B. "Privat-Gmail" statt voller E-Mail).
  Memory-Pattern: Anonymität via Nickname jetzt auch für Mail-Adressen
  sichtbar, Datenminimierung. Title nullable, Fallback auf Email-Domain.
- Schema-Migration mail_connection_title (additiv, NULL default für Bestand)
- Endpoint PATCH /api/mail-connections/:id mit title-Validation (max 60,
  trim, leerer String → NULL)
- "Passwort ändern"-Collapsible → vollwertige "Einstellungen"-Sektion:
  Title editieren · Email read-only · Passwort neu setzen · Verbindung
  trennen (mit Confirm-Dialog)
- EditMailTitleSheet als FormSheet-Pattern für Title-Edit
- mailConnectDraft-Store kriegt Title-Feld für Pre-Fill bei Re-Open

Zwei neue Stats-Charts auf der Mail-Page:

- MailBlockedByDayChart — 30-Tage-Bar-Chart, Plain-View-Bars (Pattern wie
  Sparkline-Profile), Empty-State bei 0 Cooldowns
  · Backend: GET /api/mail/stats/blocked-by-day?days=30
- MailDistributionChart — Half-Donut via react-native-svg, Top-5 Connections
  + "Sonstige", rendert nicht bei ≤1 Connection
  · Backend: GET /api/mail/stats/blocked-by-connection

Activity-Log mit Provider-Filter:

- Filter-Chips Mo Gmail/Outlook/iCloud/etc. über bestehendem Activity-Log
- GET /api/mail/results?provider=X (war vorher hardcoded all)
- Endpoint-Naming-Fix in useMailResults (war /api/mail/blocked, jetzt
  korrekt /api/mail/results — UI-Agent hatte falschen Path geraten)

Backend-Side-Effects:

- imap-providers util resolveProviderMeta(host) — gibt {provider, label,
  isCustomDomain} zurück, von 3 Endpoints konsumiert
- /api/mail/status erweitert: title, provider, providerLabel,
  isCustomDomain im Account-Shape
- /api/mail/results erweitert: connection-Sub-Objekt pro Entry +
  provider-Filter-Query

Open follow-ups (TODOs):

- deleteOldMailBlocked-Cron löscht <24h → Bar-Chart-Daten weg. Retention
  auf 90 Tage hochsetzen oder Cron stoppen.
- POST /api/mail/connect könnte die neue connection.id im Response
  mitliefern → Title-PATCH direkt ohne Extra-GET (UI-Agent-Empfehlung).
- /api/mail/status zeigt nur active Connections — paused mit Title wären
  unsichtbar. Entscheiden.

18 neue i18n-Keys (mail.title_*, mail.settings_*, mail.row_*,
mail.disconnect_confirm_*, mail.stats.*, mail.filter.all) in DE + EN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 19:06:01 +02:00
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
chahinebrini
335945fe2c feat(tier): plan limits Rev.2 + downgrade reconciliation + change-preview (Phase 2 backend)
- plan-features.ts: globalBlocklist 'curated'|'full' (curated = 30-domain stub,
  TODO real ~1-2k HaGeZi subset); maxAppDevices vs maxProtectedDevices split
  (legend maxProtectedDevices: 2); mail 1/3/Infinity
- limit-enforcement structured errors on mail/connect, custom-domains/add, devices/enroll
  ({ error:'plan_limit', resource, current, limit }); approved-own-submissions already
  excluded from custom-domain count (slot frees on approval)
- server/utils/downgrade-reconciliation.ts: founding-member exemption; re-upgrade
  reactivates paused mail + degraded devices; downgrade pauses newest-N mail accounts
  (isActive=false, pausedAt, pausedReason; pre-pause sets nextScanAt=now for a final
  sweep — real direct IMAP scan is TODO/stub); degrades excess device profiles
  (status='degraded', degradedAt); free → globalBlocklistGraceUntil = now+14d;
  custom domains grandfathered
- set-plan.post.ts + stripe/webhook.post.ts: run reconciliation on plan change;
  set-plan accepts { foundingMember } for testing
- GET /api/plan/change-preview?to=<plan>: gains/keeps/changes per resource (8 axes),
  founding-member → direction 'same'
- me.get.ts: + foundingMember, globalBlocklistGraceUntil, planLimits block
- blocklist + mail-scan honour globalBlocklistGraceUntil (grace → treat as 'full')
- db: countMailConnections/getMailConnections exclude paused; getAllMailConnections;
  getDeviceBlocklistMode (active|grace|passthrough|revoked)
- migration 20260511_tier_system_phase2 (profiles.founding_member +
  global_blocklist_grace_until; mail_connections.paused_at/paused_reason;
  protected_devices.degraded_at). prisma generate + build:backend clean.

TODOs (separate tickets): founding-member auto-counter on signup; real direct IMAP
final-scan (not just nextScanAt nudge); real curated blocklist data + wiring the
stub into the blocklist response for free users.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:23:02 +02:00
chahinebrini
eb871073f2 feat(backend): __DEV__ /api/dev/set-plan — user sets own plan (non-prod only)
POST /api/dev/set-plan { plan: 'free'|'pro'|'legend' } — requireUser, sets the
caller's own profile.plan via Prisma. Refuses on production URL (same guard as
the cooldown testMode: appUrl includes rebreak.org && !includes staging). Lets
the __DEV__ tier-toggle work without admin rights. Does NOT weaken updateProfile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:44:26 +02:00
chahinebrini
518510c088 feat(mail): IONOS-Detection + MX-Lookup-Fallback + humanisierte Error-Messages
- imap-providers: IONOS/1&1/1blu, msn.com, magenta.de, yahoo.co.uk, ymail.com, tutanota hinzugefügt
- detectImapProviderAsync: MX-Lookup-Fallback für Custom-Domains (IONOS kundenserver.de/ionos.de Pattern)
- connect.post.ts: nutzt jetzt detectImapProviderAsync statt sync-Variante
- ConnectMailSheet: rohe Server-Errors werden via humanizeMailError() + t() übersetzt
- useMailConnect: IONOS/t-online/freenet Domains in Client-Side-Detection ergänzt
- Locale de/en: provider_other, app_password_guide_other, host_unreachable, unknown Text präzisiert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 05:15:29 +02:00
chahinebrini
6962e09403 feat(devices): Windows 11 DoH protection — reg-file endpoint + tests
- Add server/utils/regfile.ts: generateWindowsDohRegFile() producing
  UTF-16 LE + BOM .reg content for DohWellKnownServers registry path.
  label and dohTemplate values are properly escape'd (\, ", \n, \r, \t).
- Add GET /api/devices/:id/profile.reg — public, windows-platform-gated,
  returns octet-stream with Content-Disposition attachment.
- Update enroll.post.ts: downloadUrl is now platform-aware
  (windows → .reg, all others → .mobileconfig).
- Add tests/devices/regfile.test.ts: 13 tests covering BOM, CRLF,
  token embed, subkey naming, AutoUpgradeFlag, label escaping (", \, \n),
  and labelToSlug edge cases. All 111 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 04:48:51 +02:00
chahinebrini
677b67902b feat(devices): protected device enrollment + mobileconfig generator
Backend:
- ProtectedDevice prisma model + migration add_protected_devices
- DB helpers: list/count/get/create/confirm/revoke
- mobileconfig.ts utility — XML-escape, unique UUIDs per request
- 5 endpoints under /api/devices/* (avoid /api/devices conflict with existing
  Capacitor UserDevice route by using /api/devices/protected for list)

Phase 1: backend ready. DoH-server token-routing comes in phase 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 04:06:49 +02:00
chahinebrini
86445d8607 feat(url-filter): add blocklist.txt endpoint for DNS-filter sync
AdGuard Home auf rebreak-mdm pullt diese Liste alle 1h für DoH-DNS-NXDOMAIN.
Single source of truth mit dem URL-Filter (NEFilter) — gleicher
getActiveBlocklistDomains() backend-call.

Public (no auth) — Casino-Domains sind keine PII, andere DNS-Blocklisten
(HaGeZi, OISD) sind genauso public.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:18:11 +02:00
chahinebrini
c1a66e3d07 feat(mail): connect-error tracking + IDLE-heartbeat for accurate UI status
Adds 3 fields to mail_connections so UI can distinguish between
"connection alive but no new mail" vs "connection dead" vs "auth-failed":

- last_connect_error      — text of last IMAP error (auth-fail, connect-fail)
- last_connect_error_at   — timestamp of error
- last_idle_heartbeat_at  — updated every 2min by NOOP-success in daemon

Daemon (backend/imap-idle/index.mjs):
- updateConnectionError() / clearConnectionError() / updateIdleHeartbeat()
  SQL helpers
- logError now uses err.responseText (shows "AUTHENTICATIONFAILED" instead
  of generic "Command failed")
- clearError on connect() success
- updateError on connect() catch
- updateHeartbeat in NOOP-success-path (every 2min)

API (status.get.ts): returns the 3 new fields per account.

Migration: ALTER TABLE rebreak.mail_connections ADD COLUMN ... (idempotent).

UI-side (in flight, separate task): MailAccountCard renders auth-error
banner when lastConnectError != null + heartbeat-based "live" indicator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:48:11 +02:00
chahinebrini
a81ba2e54a feat(community): Post.gameName + GameShareBanner-rendering chain
Adds optional `gameName` column to community_posts so game-share posts
can render with the game-banner above the post-content (Snake/Tetris/
Memory/TTT visual indicator).

- prisma/schema.prisma: CommunityPost.gameName String? @map("game_name")
- migration: ALTER TABLE rebreak.community_posts ADD COLUMN game_name
- db/community.ts: createPost() accepts gameName param
- api/community/post.post.ts: extracts gameName from body
- api/community/posts.get.ts: returns gameName, prefers DB over content-parse

Frontend (already in flight on upgrade/sdk-54): PostCard.tsx renders
GameShareBanner when post.category === 'game_share' && post.gameName.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:28:07 +02:00
chahinebrini
e5c9fadd1d fix(speak): explicit imports for voice-quota helpers
Nitro auto-import doesn't reliably pick up named exports from
db/voiceQuota.ts at runtime — speak endpoint threw 500 with
"ReferenceError: getRemainingVoiceQuota is not defined".

Explicit imports for getRemainingVoiceQuota, consumeVoiceQuota,
estimateAudioSeconds + getPlanLimits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:11:42 +02:00
chahinebrini
30ed4191b6 fix(coach): markdown-strip safety-net for LLM responses
LLMs (especially Haiku) keep emitting markdown despite explicit "no markdown"
prompt rule. Mobile app has no markdown renderer — users see raw asterisks.

- New stripMarkdown() util handles **bold**, bullet-lists, headings,
  code-fences, links, blockquotes
- /api/coach/message: applies stripMarkdown(text) post-LLM as safety-net

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:58:22 +02:00
chahinebrini
b40b8465b9 feat(lyra,voice): founder-story + voice-tier-mapping + quota system
Two features in one push (both backend, deploy together):

LYRA FOUNDER-STORY (per strategist Option C — mixed/medium-detail):
- COACH_CASUAL_SYSTEM_PROMPT: GRÜNDER-STORY sub-block
  - Sharing-rules: ALWAYS on direct ask, RARELY proactive (only on
    explicit isolation expressions "niemand versteht das"), NEVER in
    SOS-mode, NEVER first-3-msgs, NEVER if user appears minor
  - Detail-level: "aus persönlicher Erfahrung mit Spielsucht in seiner
    Familie" — KEINE Namen, Verwandtschaftsgrade, Verlust-Details
  - Post-share-pivot: "...aber jetzt zu dir: was ist gerade los?"
- COACH_SYSTEM_PROMPT (SOS): SOS-MODE LOCK — hard-Verbot Gründer-Story
  zu erwähnen, auch bei direct-ask. Re-trigger-Risk zu hoch.
- DSGVO: brother bleibt komplett anonymisiert. Hans-Müller-DSB-review für
  verbal-consent-doc empfohlen.

VOICE TIER-MAPPING (per user-decision: voice für ALLE tiers):
- New plan-features.voice config: provider + model + voiceId + dailyQuotaSeconds
- Tier-mapping:
  - Free  → Google TTS Neural2-F (de-DE), 60s/day,  ~$4/1M chars
  - Pro   → Cartesia Sonic-2,            300s/day,  ~$4/1M chars + ~75ms TTFT
  - Legend → ElevenLabs Turbo v2.5,      unlimited, ~$30/1M chars
- New backend/server/db/voiceQuota.ts:
  - getRemainingVoiceQuota(userId, plan)
  - consumeVoiceQuota(userId, seconds)
  - estimateAudioSeconds(text)
- speak.post.ts komplett umgeschrieben als plan-aware dispatcher
- 14 tests passing (partial-consume, exhausted, day-rollover, edge-cases)
- Schema-migration 20260509_voice_quota:
  ADD voice_seconds_used_today, voice_quota_reset_at to profiles
  (auto-deploy via pipeline)

Pending Frontend (separate task):
- Voice-quota-UI in Settings/Profile (remaining seconds + upgrade-prompt
  bei 429 quota_exceeded)

⚠️ Schema-migration auto-deploy via b38bf17 detection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:28:36 +02:00
chahinebrini
376f3454d6 feat(games,lyra): GameOverScreen migration + Lyra markdown-strip
GAMES (Nuxt → RN migration):
- New components/games/GameOverScreen.tsx — slide-in + fade overlay
  Props: score, bestScore, gameName, onRetry, onExit, isNewBest
- New lib/gameScores.ts — AsyncStorage helpers
  rebreak_best_snake (higher=better), _tetris (higher=better),
  _memory (lower=better, inverted isNewBest)
- UrgeGames.tsx wired: snake-collision/tetris-topout/memory-finish trigger
  GameOverScreen with retry/exit + best-score persist
- TicTacToe NICHT — round-aggregation game hat eigenen Fertig-Flow
- 7 i18n keys (gameOver.* DE+EN, 5 motivational texts statisch aus pool)

LYRA (markdown-bug fix):
- User-Report: Lyra antwortet mit ** in mobile-app, verwirrt user
- Beide system-prompts (COACH_SYSTEM_PROMPT für SOS, COACH_CASUAL_SYSTEM_PROMPT
  für Coach) bekommen "ANTWORTFORMAT - KRITISCH"-section:
  NIE Markdown (kein **bold**, _italic_, #-Headings, -Bullets) — Klartext only
- Reason: Mobile-App-bubbles rendern markdown nicht → User sieht raw `**text**`

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:17:38 +02:00
chahinebrini
33108a6774 feat(lyra): Coach-Mode persona refactor + mode-badge UI distinction
Per strategist-spec: Lyra-Coach-Mode klarer von SOS-Mode trennen.
- SOS-Mode (urge): crisis-intervention, focused, kurz
- Coach-Mode (lyra): casual, profile-building, philosophy, features

Backend (backend/server/api/coach/message.post.ts):
- COACH_CASUAL_SYSTEM_PROMPT komplett neu strukturiert (~620 tokens)
- Stärkerer Fokus: 3 explicit Aufträge (echtes Gespräch / Profile-Building /
  Rebreak sprechen)
- Profile-building-mandate: "wenn du wenig weißt, sag's ehrlich; frag nach
  Hobbies/Zielen/Menschen — eingewoben, NICHT als Checkliste"
- Cleanere Mission-Section: Bewegung, Anonymität, kein-pathologisieren,
  community-getrieben, DiGA-Listung-Ziel
- Hard-rules klarer: NIE demographics extrahieren (User-Form ist tabu),
  kein Sucht-Vokabular, kein medical-advice
- Existing PLAN_DETAILS-template-var bleibt
- Memory-system unverändert (lyra-memories table, extractAndStoreMemories
  fire-and-forget — kein schema-change nötig)

Frontend Mode-Badges:
- app/lyra.tsx (Coach-Mode): Header-pill "Coach" in brandOrange-tint neben
  Lyra-name
- app/urge.tsx (SOS-Mode): Header-pill "SOS" in error/red-tint neben
  Lyra-name (alt: "Lyra · SOS [v2]" inline-text → cleaner badge-style)

i18n:
- coach.modeBadge.coach + coach.modeBadge.sos in DE + EN

Switch-Logic: route-based (lyra.tsx vs urge.tsx → separate persona via
backend endpoint). Kein User-Toggle — User soll nicht entscheiden müssen
"bin ich grade in Krise?".

Implementation Risk: LOW — schema-neutral, prompt-only + 2 small UI badges.
Erste Beta-Testing-Phase: ~1-2 Wochen iterieren bei Feedback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:07:48 +02:00
chahinebrini
056726a166 feat(admin): Phase 2 Backend — Users + Moderation endpoints + 2 schema migrations
Two parallel agent-batches consolidated:

USERS-MGMT (rebreak-backend agent):
- Schema: Profile gets banned, bannedAt, bannedReason, deletedAt + indexes
- Migration: 20260509_profile_admin_management (additive, idempotent)
- DB-layer backend/server/db/adminUsers.ts:
  listAdminUsers (cursor-pagination, search, plan-filter)
  updateAdminUser (plan-validation, ban-stamping)
  softDeleteAdminUser (DSGVO PII-scrub: nickname=null, email=deleted-{shortid}@deleted.local)
- 3 endpoints under /api/admin/users:
  GET (list with ?cursor&limit&q&plan&includeDeleted)
  PATCH /:id (plan/banned/bannedReason/lyraVoiceId)
  DELETE /:id (soft-delete idempotent)
- 12 tests passing

MODERATION (rebreak-backend agent):
- Schema: CommunityPost+CommunityReply get isModerated, isDeleted, deletedAt,
  reportedAt + index (is_moderated, reported_at)
- New ModerationAction model → audit-log table
- Migration: 20260509_moderation_queue (additive, idempotent)
- DB-layer backend/server/db/moderation.ts:
  listModerationQueue (merge posts+comments, sort by reportedAt, cursor)
  dismissModerationItem
  deleteModerationItem (content scrub + audit snapshot)
  banUserFromModerationItem (reuses banned/bannedAt/bannedReason fields)
- 4 endpoints under /api/admin/moderation:
  GET /queue, POST /:id/dismiss, POST /:id/delete, POST /:id/ban-user
- 11 tests passing

Backend total: 78 tests passing | 4 skipped (pre-existing requireAdmin tests)

Auth: x-admin-secret header (consistent with existing /admin/* endpoints).

DSGVO:
- Soft-delete scrubt PII statt hard-delete
- Email NICHT in admin user-list (lebt nur in auth.users)
- Audit-log für moderation-actions (90-day cleanup-cron pending hans-mueller-DSB-review)

⚠️ MIGRATIONS — auto-deploy via pipeline (commit b38bf17 detection):
- 20260509_profile_admin_management
- 20260509_moderation_queue

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:48:35 +02:00
chahinebrini
587b0c273b feat(admin): Phase 3 — requireAdmin middleware + verify-admin endpoint
Backend-side admin-auth. Admin-App (apps/admin/) braucht das damit
useAdminAuth.verifyAdminRole() nach Login server-side prüfen kann ob User
in admin_users-tabelle steht.

New schema:
- model AdminUser → table rebreak.admin_users (user_id UUID PK FK Profile.id,
  created_at, added_by). Migration 20260508_admin_users/migration.sql.
- ⚠️  SCHEMA-MIGRATION — NICHT autopushen. User entscheidet wann pipeline
  triggert.

New backend code:
- backend/server/db/admin.ts: isAdminUser(userId) → boolean
- backend/server/utils/auth.ts: requireAdmin(event) wraps requireUser +
  isAdminUser-check. Throws 403 wenn nicht admin.
- backend/server/api/admin/verify-admin.get.ts: GET endpoint. Returns
  { isAdmin: true, userId, email } bei success, 403 sonst, 401 if not auth'd.

Tests (5 cases in tests/admin/verify-admin.test.ts):
- isAdminUser DB-layer: row exists/null
- requireAdmin: admin → user, non-admin → 403, no token → 401
- Endpoint: admin → success, non-admin → 403

Pending User-Actions nach Push+Deploy:
1. Migration deploy auf staging:
   ssh rebreak-server && cd /srv/rebreak && pnpm exec prisma migrate deploy
2. Seed-Admin eintragen:
   INSERT INTO "rebreak"."admin_users" ("user_id", "created_at")
   VALUES ('128df360-2008-4d6f-8aa1-bdb41ec1362f', NOW())
   ON CONFLICT DO NOTHING;
3. Admin-App composables/useAdminAuth.ts kann dann verifyAdminRole()
   gegen GET /api/admin/verify-admin aufrufen

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:30:03 +02:00
chahinebrini
5264dba257 fix(social): compute postsCount + followingCount live (were hardcoded 0)
Endpoint /api/social/profile/[userId] returned (profile as any).postsCount ?? 0
und (profile as any).followingCount ?? 0 — Profile-schema hat aber weder
postsCount noch followingCount columns. Daher zeigte UI immer 0 obwohl User
Posts hatte.

Fix: 2 zusätzliche COUNT-queries in Promise.all:
- usePrisma().communityPost.count({ userId, isModerated: false }) → postsCount
- usePrisma().userFollow.count({ followerId: userId }) → followingCount

followersCount bleibt unverändert (wird via trigger denormalisiert in profile-row).

Tests: backend/tests/social/profile-counts.test.ts — 4 Cases
  (posts>0, posts=0, following count, followers passthrough). 4/4 grün.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:40:42 +02:00
chahinebrini
0e94ddb68a feat(api): GET /api/profile/me/demographics endpoint
Read-counterpart zum existierenden PATCH/DELETE. Frontend braucht den endpoint
um nach Page-Reload die schon-gespeicherten Werte zu fetchen — sonst sieht User
leere Felder und denkt save funktioniert nicht.

- backend/server/db/profile.ts: getDemographics(userId) — SELECT der 9 fields +
  demographics_consent_at + demographics_withdrawn_at
- backend/server/api/profile/me/demographics.get.ts: requireUser + getDemographics
  + ISO-string conversion. 404 wenn Profile-row fehlt.
- backend/tests/profile/demographics.get.test.ts: 5 vitest cases
  (null fields, 404, populated, withdrawn, 401)

Response shape kompatibel mit PATCH-input (gleiche field names, camelCase) plus
metadata consentAt/withdrawnAt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:40:42 +02:00
chahinebrini
d7efd627f5 feat(profile): Demographics employment-split + Pro-Trial-Reward + tests
- New Prisma migration 20260508_demographics_employment_split:
  ADD COLUMNS employment_status / shift_work / industry / job_tenure
  (legacy `profession` kept untouched)
- PATCH /api/profile/me/demographics:
  Zod-enums updated to match Frontend values (employed/self_employed/in_training/
  unemployed/retired/homemaking/other; jobTenure: less_1y/1_3y/3_5y/5_10y/more_10y)
- profile.ts db-layer: tryAwardProTrial covers new + legacy fields,
  withdrawDemographics nulls all (incl. legacy profession)
- Vitest: 8-line trial happy-path + guard rails (free+pro+legend+used) +
  zod-validation tests covering new enum boundaries

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:31:28 +02:00
chahinebrini
192f67cd07 feat(coach): tier-based LLM-Routing + COACH_CASUAL_SYSTEM_PROMPT
Coach-Page ist NICHT SOS — User ist nicht in Krise, will small-talk,
Reflexion, Feature-Wuensche, Philosophie. „Lockere Lyra".

Aenderungen:
- Neuer Prompt COACH_CASUAL_SYSTEM_PROMPT (exportiert): warm/neugierig/
  manchmal humorvoll, bis 4-5 Saetze, darf eigene Empfehlungen + Mini-
  Meinungen formulieren, lädt Feedback aktiv ein. Kein Crisis-Framing.
  Sprachregeln (keine Pathologisierung, kein „Sucht") gelten unveraendert.
- Tier-LLM-Routing analog zu sos-stream:
  Free/Pro = Groq llama-3.3-70b-versatile (Fallback llama-3.1-8b)
  Legend  = OpenRouter anthropic/claude-haiku-4.5 (Fallback claude-3.5-haiku)
- max_tokens 280→500 (Coach darf laenger antworten)
- Demographics-Injection (analog sos-stream): birthYear/gender/etc als
  USER-DEMOGRAPHIE-Block in Prompt (read-only, kein Extract)
- sosMode-Branch deprecated — Frontend kann den Param noch senden, wird
  ignoriert. Folge-TODO: UI-Agent entfernt sosMode aus Coach-Call.

NICHT geändert:
- TTS-Endpoints bleiben plan-agnostisch (Frontend routet by tier)
- sos-stream.get.ts/sos-stream.post.ts unberuehrt (importieren weiter
  COACH_SYSTEM_PROMPT, kein Breaking Change)

Memory:
- project_llm_per_plan.md (tier-LLM-Default-Logic)
- feedback_anonymity_nickname.md
- feedback_demographics_user_initiated.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 21:22:51 +02:00
chahinebrini
cddc4d0f26 feat(profile): DiGA-Demographics + Pro-Trial-Reward + 7 Profile-Endpoints
Schema:
- 8 neue Profile-Felder fuer DiGA-Demographics (birthYear/gender/maritalStatus/
  profession/bundesland/city + 2 consent-stamps demographicsConsentAt/
  demographicsWithdrawnAt)
- 4 Pro-Trial-Felder (proTrialStartedAt/ExpiresAt/Source/UsedAt) — Free-User
  bekommen 1 Woche Pro als Reward fuer DiGA-Daten-Pflege (siehe
  project_demographic_pro_trial_reward.md)
- lyra_voice_id (Legend-only Voice-Picker)
- diga_banner_dismissed_at (server-side persistence ueber Re-Install)
- last_install_at (Streak-Logic survives Re-Install)
- Migration 20260507_profile_demographics_and_trial: alle Felder optional,
  keine Backfill-Logik notwendig

Endpoints (alle auth-protected, scope=me):
- GET /api/profile/me/sos-insights
- GET /api/profile/me/cooldown-history
- GET /api/profile/me/approved-domains
- POST /api/profile/me/install-event (track app re-installs)
- POST /api/profile/me/diga-banner-dismiss
- PATCH /api/profile/me/demographics (consent-stamp + re-grant-after-withdrawal in tx)
- DELETE /api/profile/me/demographics (DSGVO right-to-be-forgotten)

Plugin:
- pro-trial-expiry-cron: 6h-Interval, conservative-fallback (revoke nur wenn
  kein stripeSubId), 60s initial-delay damit Server-boot nicht blockiert wird

Tests:
- vitest config + erste Test-Files (test-infrastructure setup)

Memory:
- feedback_demographics_user_initiated.md (Lyra darf NIE extrahieren)
- project_demographic_pro_trial_reward.md (Pro-Trial-Reward-Mechanik)
- project_profile_page_design.md (UI-Showpiece, eigene/fremde-Ansicht streng getrennt)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 21:14:06 +02:00
chahinebrini
82c6c61a5e fix(sos-stream): profile is not defined ReferenceError
let profile vor try-block hoisten, damit es im plan-routing-Fallback
(line 210, profile?.plan) sichtbar ist. Vorher: const profile innerhalb
try-block → block-scoped → ReferenceError außerhalb.

Demographics-Block-Injection added — gracefully no-op wenn neue Felder
(birthYear/gender/etc.) noch nicht im DB-Schema sind (optional chaining).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 21:03:27 +02:00
chahinebrini
355166c194 feat(sos): tier-based LLM-Default — Free/Pro=Groq, Legend=Haiku
Backend (sos-stream.get.ts): wenn sessionData.llmProvider === 'auto'
oder undefined, resolved zu plan-based default via profile.plan:
- legend → openrouter-haiku (Anthropic warm + sort:latency)
- pro/free → groq-llama (sachlich + schnell, ~157ms TTFB)

Frontend (llmProvider.ts): DEFAULT_PROVIDER = 'auto', neue Pill 'Auto'
in LlmProviderToggle. Explicit-Toggles (Sonnet/Haiku/Groq) sind
debug-overrides die plan-logic bypassen.
2026-05-07 04:15:31 +02:00
chahinebrini
4e5f8e6c93 fix(sos-stream): nickname-Injektion in systemPrompt
Lyra halluzinierte Namen wie 'Max' im SOS-Stream weil systemPrompt
keinen Nicknamen-Anker hatte. message.post.ts hatte das Pattern schon,
aber beim Cutover ins backend/-Layout war's nur dort, nicht in
sos-stream.{get,post}.ts.

Fix: getProfile(user.id) + Inject 'NUTZER-NAME: ...' wie in message.post.ts.
Non-blocking (catch), Memory-Pattern preserved.
2026-05-07 04:10:27 +02:00
chahinebrini
431ae6a75d fix(sos-stream): undefined 'key' var nach cutover-refactor
Im Cutover wurde 'const key = config.openrouterApiKey' rausgenommen,
aber line 311 referenzierte 'key' weiter für extractAndStoreMemories.
ReferenceError fiel in catch → 'stream failed' SSE event → App
fiel auf non-streaming fallback (4-5s wait) statt streaming.

Fix: explizit memoryExtractKey aus config.openrouterApiKey holen.
2026-05-07 03:41:11 +02:00
chahinebrini
f2e822be95 feat(sos): llmProvider toggle + sort:latency + bench scaffolding
- backend/coach: routing zu Sonnet (default) / Haiku / Groq Llama je nach
  sessionData.llmProvider. sort:latency für Anthropic-Modelle (-30..58% TTFB).
- frontend: LlmProviderToggle (Sonnet/Haiku/Groq pills), llmProvider.ts
  Storage-Helper. sosStream.ts schickt llmProvider im /sos-session-Body.
- bench: SosTtsBenchmark sammelt Marker (req->session, lyra-ttfb, lyra-done,
  tts-fired/headers/body/file, audio-loaded, first-audio); Output als console.table.
- ops: backend/scripts/llm-bench.sh + Python-Variante für realistic SOS-Prompt.
- speak-cartesia + speak-elevenlabs Endpoints (waren ungetracked, jetzt mit drin).
2026-05-06 13:58:07 +02:00
RaynisDev
b58588cf3c initial commit: rebreak-monorepo (RN app + standalone Nitro backend) 2026-05-06 07:13:43 +02:00