465 Commits

Author SHA1 Message Date
chahinebrini
68074aa7b7 feat(diga): redeem-code endpoint + 10 test codes seeded
DiGA-Pfad-Foundation: User mit Rezept-Code löst im Onboarding ein, wird auf
plan='legend' hochgestuft (Default), Onboarding-Step springt auf 'done',
diga_code_redeemed_at als Audit-Trail. Trial-Modell wird übersprungen.

- Prisma model DigaCode (code unique, expires_at, used_at, used_by_profile_id,
  grants_plan, notes, label)
- Profile.digaCodeRedeemedAt für Reverse-Audit
- Migration 20260517_add_diga_codes mit Table + FK + Index
- Seed: REBREAK-TEST-001..010 (single-use, reset via SQL für erneutes Testen)
- POST /api/onboarding/redeem-diga-code — atomare Transaction, klare 400-Errors
  (not_found | already_used | expired | invalid_input)

Frontend (Duo-Onboarding) dockt später an — diese Backend-Foundation steht.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 15:52:53 +02:00
chahinebrini
1c9e67c256 feat(onboarding,protection,i18n): spotlight POC, arabic locale, NEFilter recovery
State of work before Duo-style onboarding pivot. Includes work that will be
partly reverted in the next commit (see refactor follow-up).

Onboarding (will be partly reverted):
- Custom Tooltip+Glow spotlight (components/OnboardingHint.tsx)
- Spotlight wiring in app/profile/edit.tsx (nickname-input glow + step-progress
  header, onSubmitEditing auto-save, save-handler routes to /(app)/blocker)
- Spotlight wiring in app/(app)/blocker.tsx (URL-filter LayerSwitchCard wrapped
  + auto-PATCH step='done' when filter activates)
- Routing-gate branches in (app)/_layout.tsx (welcome → /onboarding/welcome,
  nickname → /profile/edit)
- Debug-Reset-Toggle in /debug (welcome|nickname|block|done buttons + redirect)

Will stay (reused in Duo flow):
- Welcome-Screen app/onboarding/welcome.tsx (will become Slide 1)
- Avatar-fix in profile/edit (Dicebear seed stays stable while typing)

i18n + RTL:
- Arabic locale (locales/ar.json, full translation incl. onboarding keys)
- I18nManager.allowRTL(true) + applyRTL helper in stores/language.ts
- Language-Picker option for العربية in settings
- New keys: onboarding.welcome.*, step_progress, nickname_spotlight.*,
  block_spotlight.*, permission_denied.*, language.*, rtl_restart.* (de/en/fr/ar)

NEFilter Permission Recovery (iOS):
- Swift resetUrlFilter() — removeFromPreferences + fresh saveToPreferences to
  bypass iOS's cached denied-state (NEFilterErrorDomain code 5)
- TS module def + lib/protection.ts wrapper
- components/PermissionDeniedSheet.tsx — branded recovery sheet with retry +
  app-settings:// deep-link + fallback hint
- Wired in (app)/blocker.tsx handleActivateUrlFilter (code-5 detection)

Misc:
- Bug fix in onboarding/welcome.tsx: apiFetch body was double-stringified (sent
  as JSON string instead of object → 400 invalid_step)
- Bug fix in profile/edit.tsx: avatar preview Dicebear seed switched from live
  nickname (changed every keystroke) to stable me?.nickname

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 15:44:32 +02:00
chahinebrini
38a8517259 feat(onboarding): interactive welcome + nickname spotlight tour
Stage 1+2 des post-signup Onboarding-Flows:
- Welcome-Screen: dark-slate Full-Screen mit Pulse-Hero, 3 Mission-Bullets,
  DSGVO-Box, CTA "Los geht's"
- Nickname-Spotlight via react-native-copilot ums TextInput in /profile/edit,
  auto-start wenn step='nickname', nach Save → step='block' + back to /(app)
- Backend: Profile.onboardingStep enum (welcome/nickname/block/done),
  Migration mit Backfill (existing → done), PATCH /api/profile/me/onboarding-step,
  /api/auth/me erweitert
- Frontend: CopilotProvider in root, Routing-Gate in (app)/_layout, useMe um
  onboardingStep ergänzt
- i18n (de/en/fr) für onboarding.welcome.* + onboarding.nickname_spotlight.*

Stage 3 (Block-Aktivierung-Spotlight) folgt in nächster Session — der bestehende
ProtectionOnboardingSheet auf Android wird daran angebunden.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 21:00:20 +02:00
chahinebrini
c1dd7e7320 fix(native/protection-android): a11y plugin self-heals XML, arm tamper-lock on return, truthful status check
- with-rebreak-protection-android plugin now copies the source
  accessibility_service_config.xml via withDangerousMod instead of generating
  it from a string. Eliminates the silent regression where prebuild wrote
  flagReportViewIds + missing packageNames, leaving Samsung's content scan
  unable to read OEM dialogs.
- ProtectionOnboardingSheet refresh() now calls activateFamilyControls()
  once a11y is detected as enabled, so armTamperLock() actually runs.
  Previously the sheet auto-completed on getDeviceState() alone, leaving
  tamper_armed=false and the service permanently passive.
- RebreakProtectionModule.isAccessibilityServiceEnabled() now trusts the
  AccessibilityManager list as authoritative when AM is available (even when
  empty). Settings.Secure fallback only kicks in if AM is null/exception.
  Fixes the banner falsely showing "Schutz aktiv" when the system has
  unbound the service but ENABLED_ACCESSIBILITY_SERVICES still holds the id.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 11:24:45 +02:00
chahinebrini
83b0d7a062 feat(native/protection): android a11y banner status + 2-step onboarding sheet
- Blocker banner: show real accessibility status on Android (active/inactive)
  instead of the iOS Family-Controls "bald verfügbar" fallback
- AppState listener refreshes state when user returns from system settings
- New ProtectionOnboardingSheet: enforced order VPN → a11y because once a11y
  is on it locks VPN settings access. Step 2 disabled until step 1 done.
  Skip is allowed; storage flag set only after both steps complete.
- i18n: blocker.layers_a11y_subtitle_active/inactive + protection_onboarding.*

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 10:34:48 +02:00
chahinebrini
6e34631246 feat(db): temporary default plan=legend while tier toggle is missing
UPDATE all existing profiles to legend + ALTER COLUMN default to legend
so internal testers can exercise premium paths until the settings toggle
is rebuilt. Revert via follow-up migration once the toggle is back.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 10:04:34 +02:00
chahinebrini
4124463097 chore(release): bump to v0.2.1 / versionCode 9 — theme-crash, double-splash, dm-reopen fixes
- Android Theme parent → Theme.MaterialComponents.DayNight.NoActionBar.Bridge
  (fix BadgeDrawable crash in react-native-bottom-tabs after AccessibilityService toggle)
- Plugin with-material-theme-android keeps theme idempotent across prebuilds
- Plugin with-release-signing-android wires release signingConfig from key.properties
- Splash: align native splash image with JS BrandSplash (icon.png) to eliminate
  double-splash flicker on app start
- DM: reset partner/messages/replyTo state on userId change, disable cache for
  history query, switch spinner condition to isLoading||isFetching so reopens
  always load fresh and never show empty-state with stale partner

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 09:32:28 +02:00
chahinebrini
6ac6a26b9c feat(native/dm): WhatsApp-style chat — bg pattern, bubble redesign, avatar + realtime fixes
- Header: partner avatar left-aligned (was centered)
- ChatBubble: replace bright blue with subtle mint/brand tint, asymmetric
  tail-corner radius, footer pinned bottom-right, reply-quote with green
  side-bar
- New DmChatBackground: SVG hex-offset doodle pattern (stars, hearts,
  clouds, dots) at 7% opacity — light-cream / dark-warm-green base
- Avatar in chat list: use resolveAvatar() consistently to handle
  hero-id, https, and null cases
- Realtime subscription: stabilize deps via partnerRef to stop
  re-subscribing on partner state change
- Pressable → TouchableOpacity throughout

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 08:50:12 +02:00
chahinebrini
dba33b5733 feat(db): enable realtime publication for direct_messages + chat_messages
Same pattern as notifications/protected_devices/user_custom_domains:
without ALTER PUBLICATION supabase_realtime ADD TABLE, Postgres does
not broadcast inserts and clients receive no live messages.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 08:50:04 +02:00
chahinebrini
2cc0b20fc8 fix(backend/dm): include attachmentUrl + replyTo in history response
Map-response dropped attachment fields and reply reference even though
getDmHistory loads them — caused images and reply quotes to disappear
on conversation re-open.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 08:49:59 +02:00
chahinebrini
6bbf9e4cfd fix(native/mail): kürzlich-blockiert uses createdAt, not the original receive date
User saw entries like "vor 61d · Outlook" under the "Kürzlich
blockiert · In den letzten 24h" header. createdAt (when the daemon
wrote the mail_blocked row) is always inside the 24h retention window
because deleteOldMailBlocked sweeps everything older than that on
every fetch — but the row preserves the original receivedAt header
from the email, which for old Casino mails the daemon only just got
around to scanning can be weeks or months ago.

Switched the time-label in MailActivityLog to format createdAt
instead. The MailBlockedItem type now carries createdAt explicitly
(the backend has been returning it all along, the FE type just hadn't
acknowledged it). receivedAt stays in the shape for any future
"received vs blocked" comparison view but isn't used in the recent-
activity list anymore.
2026-05-16 05:26:52 +02:00
chahinebrini
4573d16e1a refactor(mail-classifier): display-name aus Score-Pfad entfernen (v1.0)
SENDER_NAME_GAMBLING_KEYWORD (+30) und SENDER_NAME_BRAND_MATCH (+20) aus
SCORE_WEIGHTS entfernt. Layer-2.5-Brand-Match prüft nur noch Domain-Root
und Relay-Domain, nicht mehr displayNameNorm. Sender-Name-Keywords-Block
in computeScore() entfernt. keywordHitsName bleibt im Interface für v1.1.

Tests: Brand+Random-Tests die Display-Name als einzige Brand-Source hatten
auf neues v1.0-Verhalten (PASS) umgeschrieben. Zwei neue Tests: Display-Name-
only Casino-Signal → Score=0 → PASS verifiziert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 05:18:00 +02:00
chahinebrini
00ec716694 fix(mail): skip Gmail system folders in scan + raise subject-keyword score to 50
Fix 1 (scan-internal): filter out \All, \Drafts, \Sent, \Trash, \Flagged via
specialUse — stops [Gmail]/All Mail from consuming the SCAN_LIMIT=200 and
blocking new INBOX mails from reaching fetch range. \Junk/\Spam stay in scope.
Folders without specialUse (iCloud, GMX) pass through untouched — no false
exclusions without confirmed metadata.

Fix 2 (mail-classifier): raise SUBJECT_GAMBLING_KEYWORD from 35 to 50 so a
single unambiguous casino/jackpot/freispiel subject hit alone reaches the
SCORE_BLOCK_MIDRANGE threshold and triggers a block. Previously 35 pts fell
short when sender domain was generic and display name empty.

Tests: 9 new cases added (2 Fix-2 classifier + 4 Fix-1 folder-filter unit +
1 computeScore score=50 exact assertion). All 265 tests green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 05:12:14 +02:00
chahinebrini
bf6affb3eb fix(mail): Gmail-Delete als Trash-Move + Scan-Trigger nach Custom-Domain-Add
Fix 1 (scan-internal): Gmail ignoriert IMAP EXPUNGE — stattdessen messageMove()
in Trash-Folder (via specialUse='\\Trash', Fallback '[Gmail]/Trash'). Verhindert
dass Gambling-Mails bei Gmail-Usern in 'All Mail' verbleiben statt zu verschwinden.
Alle anderen Provider (iCloud, Outlook, IONOS) bleiben beim bestehenden
messageDelete() + EXPUNGE-Fallback.

Fix 2 (custom-domains): Nach erfolgreichem mail_domain-Add fire-and-forget
$fetch auf /api/mail/scan-internal — damit neue Mail-Patterns sofort (< 5s)
wirken statt erst beim nächsten 30min-Cron. Scan-Fehler blockieren den POST nicht.

Tests: 16 neue Tests (gmail-delete-strategy + scan-trigger). 259 passed, 0 failed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 05:03:09 +02:00
chahinebrini
d97e3aa496 chore(release): bump to v0.2.0 / versionCode 8 — device-binding, custom mail-filter, chat v1.0, Mac DNS auto-detect
CHANGELOG entry covers:
- Mac DNS auto-detect (DoH handshake + realtime)
- Device-account-binding (Pro/Legend anti-bypass)
- Custom mail-patterns alongside web-domains (10 + 10 for Legend)
- Unified filter section with single + button + auto-detect sheet
- Chat v1.0 DM-only with unread badge
- Avatar cropper switched to iOS-native UIImagePickerController
- Help/Support pages (FAQ, Contact, About, Crisis hotlines)
- Settings: notification + streak-reminder section
- Game-over modal layout + regenerate suggestion
- Devices page redesign with central <Button> + progress bar
- Pre-check global blocklist before consuming a slot
- Local dev FAMILY_CONTROLS flag defaults on

Marketing download page version bumped to 0.2.0 build 8; sha256 +
apk size will be filled after the EAS build lands and the APK is
uploaded to the download host.
2026-05-16 03:22:53 +02:00
chahinebrini
0a35b58cd9 fix(native): human error messages + kind override checkbox in AddDomainSheet
Two related fixes after the user saw a raw 400 JSON dump in the sheet
("API 400: { error: true, message: 'Eintrag bereits vorhanden' … }").

1. apiFetch now extracts the prettiest available message from the
   response body (data.message → message → statusMessage → raw text →
   bare status code) and throws an Error whose .message is that string
   only. Stashes the structured pieces on the Error too (.code, .data,
   .status) so callers that switch on error codes still have them, but
   the default `e?.message` path delivers a clean human sentence.
2. AddDomainSheet maps the known error codes to localized strings —
   WEB_LIMIT_REACHED / MAIL_LIMIT_REACHED / INVALID_MAIL_DOMAIN /
   DISPLAY_NAME_NOT_SUPPORTED / INVALID_DOMAIN / "Eintrag bereits
   vorhanden" (duplicate) — and falls back to a generic copy if the
   code is unknown. The raw API JSON never appears in the UI again.

Plus the kind-override checkbox: the auto-detect (input contains "@" →
mail, contains "." → web) is fine for the typical case but a user can
type a clean domain and still want it filtered against mail senders
(e.g. they know "casino.de" is also their casino's sender domain).
The new pill below the preview toggles between mail and web, defaults
to whatever auto-detect said, and resets when the input is cleared. The
local-part strip still runs for mail-mode so the stored value stays a
domain.

i18n: error_invalid_mail / error_invalid_input / error_duplicate /
kind_override_label across DE/EN/FR.
2026-05-16 03:15:33 +02:00
chahinebrini
80d89303f5 fix(native/blocker): pass kind to addDomain so mail patterns route correctly
User added info@info.mail-slotoro.com and it landed in Eigene Domains
as type=web instead of in Eigene Mails as type=mail_domain. Bug trace:

1. AddDomainSheet detects kind='mail' from the @ in the user's input
2. mailDomain() strips the local-part → "info.mail-slotoro.com"
3. handleAdd calls onAdd(pattern) — only the stripped string, no kind
4. useCustomDomains.addDomain then sends { pattern } with no kind
5. Backend Variante C auto-detect keys on @ in the pattern — but the
   pattern no longer contains @ (frontend already stripped it), so the
   detector falls into the kind='web' branch

Fix: pass the kind explicitly from the sheet through the prop chain.
AddDomainSheet.onAdd is now (pattern, kind?) — the sheet's handleAdd
forwards the kind it detected. blocker.tsx's onAdd handler threads
it into addDomain so the body includes { pattern, kind }. Backend
then takes the explicit path and stores type='mail_domain' for the
already-stripped value. Auto-detect on bare pattern (no kind) still
works for any caller that genuinely doesn't know — that path just
isn't used by the sheet anymore.
2026-05-16 03:06:34 +02:00
chahinebrini
26de3dade9 fix(native/blocker): thinner filter overview banner + count badge in header
Match the existing DomainSection visual pattern. One row at the top:
title "Eigene Filter", inline Web/Mail legend dots, the X/Y count pill
and a small + button — all on the same line. The bar drops below at
5px height (same as DomainSection). The 48×48 floating add button is
gone in favour of a 28×28 inline button next to the count pill so the
overview reads as a single horizontal strip rather than a tall card.
2026-05-16 02:58:51 +02:00
chahinebrini
8a6ab6fe64 feat(native/blocker): unified slot bar + single + button + auto-detect sheet
Single shared affordance for adding either a website-domain or a mail-
sender-domain. The per-section add buttons (one inside "Eigene Domains"
and one inside "Eigene Mails") are gone — replaced by a CustomFilter-
Overview card above both sections with:

- title "Eigene Filter" and a "X von 20" counter (free/pro: 10, legend:
  20 — sum of the two per-type buckets)
- a 2-colour progress pill: brandOrange for the web slice, success-green
  for the mail slice on top of the surface-elevated rest
- a 48×48 rounded-full TouchableOpacity on the right (brandOrange,
  ionicons add 24px, white) that opens the AddDomainSheet directly

AddDomainSheet was rewritten one more time: the Seite / E-Mail type
picker is gone. The user types one thing — domain or full address —
and a live preview shows which one we detected (Domain-Filter for a
bare host, Mail-Filter for input that contains "@", stripping to the
domain after the last @). The shape is also what we send: the body is
{ pattern } with no kind field. The backend (commit a2680f6) does the
authoritative auto-detect and sends back the resolved type with the
created row, so the frontend never has to guess in two places.

useCustomDomains.addDomain now treats kind as optional. When omitted,
the request body just carries pattern — when present it's still sent
through verbatim so any caller that wants to force a category still can.

DomainSection no longer renders a per-section add button when its onAdd
prop is undefined — domains and mails sections in blocker.tsx both
omit onAdd now. The mails section stays default-collapsed.

i18n: new keys custom_filter_overview_title / count + preview_web /
preview_mail / preview_invalid; tabs_web / tabs_mail removed since the
TypePicker is gone. type_web / type_mail kept in the locales as
inactive entries in case the type-picker comes back in a future
direct-add flow.
2026-05-16 02:54:38 +02:00
chahinebrini
a2680f6e19 feat(backend): auto-detect kind from pattern when body omits kind/type
POST /api/custom-domains now accepts a third body variant — { pattern }
without an explicit kind or type — which the resolver infers from the
pattern shape so the frontend can ship a single dynamic input field
instead of asking the user to choose between Seite / E-Mail in advance.

- pattern contains '@'             → treat as kind='mail', strip the
                                     local-part, store as mail_domain
                                     after the same TLD / DOMAIN_RE
                                     validation as the explicit-kind path
- pattern contains '.' (no '@')   → treat as kind='web'
- neither                          → 400 INVALID_PATTERN with a clear
                                     message ("Bitte eine Domain oder
                                     Mail-Adresse eingeben")

Variant A ({ pattern, kind }) and Variant B ({ domain, type }) stay
fully supported, plus a `kind: 'auto'` keyword if a client prefers an
explicit opt-in to the auto-detect path. The display-name path is still
locked off in v1.0 — pure tokens without dots route into the same
INVALID_PATTERN response, which keeps the v1.0 guarantee intact.

Plan-limits.test.ts grew the matching test cases — auto-detect for a
domain, auto-detect for a full address (local-part stripped to mail_-
domain), auto-detect rejection for a bare token. All existing tests
keep their pass status.
2026-05-16 02:49:48 +02:00
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
c2323c1aba fix(native): read { items, counts, limits } from custom-domains GET
The /api/custom-domains endpoint changed shape with the slot-pool split
in commit f2b81ee — it now returns { items, counts, limits } where it
used to return a bare CustomDomain[]. The hook was still matching
Array.isArray(res) or res.domains and silently fell back to an empty
list, so a successful POST went unreflected on the blocker page (user
reported "kein fehler aber domain taucht nicht in der liste" after
adding communications@only4subscribers.com).

Now reads items / counts / limits when present, prefers the API-driven
counts and limits over the client-side derivation (still kept as a
fallback for the stale-bundle window between deploys). Legacy bare-
array + { domains } shapes still resolve too in case a cached client
hits this code path before the new backend lands.
2026-05-16 02:32:21 +02:00
chahinebrini
63b6d2ff11 fix(native): provisional slot limits match the backend plan (5/5, 10/10)
The user looked at the legend dashboard and saw 4/8 + 0/2 for the web
and mail buckets instead of the agreed 10/10. The hook's client-side
provisional limits were holdovers from an earlier sketch — 8/2 for
legend, 4/1 for free/pro — that never matched plan-features.ts on the
backend (5/5 and 10/10 respectively). Brought them in line so the
header counters read correctly until the API-driven values land via
the new GET /api/custom-domains response shape.
2026-05-16 02:24:55 +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
e370842072 fix(native): domains-section flat, mails-section collapsible only
DomainSection bekommt collapsible-Prop (default false).
Domains-Section: kein Chevron, kein useState, Content immer sichtbar.
Mails-Section: collapsible={true} + open/onToggle wie bisher.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 02:20:38 +02:00
chahinebrini
f4da81f551 feat(native/blocker): two collapsible sections + new AddDomainSheet layout
The Seiten/Mails top-tabs added in 5c6fa3d are gone. Per the user's
revised vision, web-domains and mail-patterns live side by side as two
collapsible <DomainSection>s with their own header, slot pill, progress
bar, and add-button — closer to the original Eigene-Domains affordance
plus a sibling Eigene-Mails section. Both default open; chevron-up/down
per the existing icon convention.

AddDomainSheet was rewritten from scratch to fix the layout-bug
visible in the screenshot — SheetFieldStack's two-ScrollView intro/
fields split was wrong for a single-input use case and was rendering
the chip at the bottom of the scroll area with a huge gap under the
TypePicker. The new sheet is a plain ScrollView with TypePicker, label,
TextInput, help-card, preview-card, warning-card, confirm-row, and the
Cancel + Hinzufügen buttons stacked top-to-bottom with `gap: 12`. No
Pressable anywhere — TouchableOpacity only, per the hard rule.

DomainGrid is now a pure tile renderer: the header / slot pill / add
affordance live on the section component above it. Its `kind` prop
(renamed from `activeTab`) drives the type filter — for v1.0, mail
means strictly `mail_domain` (display-name is gone).

i18n: new keys section_domains / section_mails / add_sheet_cta. mail-
related copy (label, placeholder, help, empty) had every "Display-Name"
mention stripped so the user can't read about an option that doesn't
ship.

Progressbar inline in DomainSection with the same Animated.timing
pattern DeviceProgressBar uses, with a 3-step color threshold
(green / brandOrange / error) keyed on the bucket fill ratio.
2026-05-16 02:19:27 +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
1e07e8303f fix(native): mail-pattern domain extraction + drop Pressable from FormSheet
Two bugs reported on the new mail-pattern flow:

1. The sheet sent the full local@domain.tld pattern to the backend so a
   user blocking communications@only4-subscribers.com would only catch
   that exact local-part — newsletter@, info@, promo@ from the same
   sender would slip through. Casino affiliates rotate the local-part
   on every blast while keeping the domain stable, so we now strip the
   local-part on submit. The preview-card under the input shows what
   actually gets stored (only4-subscribers.com), so the user sees the
   pattern that will hit. Bare tokens without "@" stay as-is and reach
   the backend as display-name candidates.

2. FormSheet's backdrop was a <Pressable> — straight violation of the
   "TouchableOpacity, never Pressable" rule. Swapped for
   <TouchableOpacity activeOpacity={1}> so the tap-to-dismiss still
   works with no visible feedback on the dim layer.
2026-05-16 02:03:53 +02:00
chahinebrini
5c6fa3d45b feat(native/blocker): underlined Seiten/Mails tabs + per-type counter
Top-tabs above the custom-domains grid: Seiten (web) and Mails (mail_*).
2px underline highlight in colors.brandOrange for the active tab, the
muted label otherwise — matches the community/feed tab style we already
use. Pill segmented control would have needed extra inset math for two
tabs without adding clarity.

- DomainGrid filters items by the active tab. Tab-specific empty-state
  copy and icon (mail-outline for the Mails tab) so the empty Mails tab
  doesn't read like a broken Web view.
- mail_display_name tiles hide the submit-to-global button entirely —
  matches the v1.0 backend lock; the user can't accidentally tap into a
  400 from the API.
- useCustomDomains exposes countsByType + limits. Provisional client-
  side estimation until the new API response shape (extended in the
  parallel backend commit f2b81ee) is wired through — same TS shape,
  so dropping the estimation is a one-line swap when ready.
- AddDomainSheet picks up initialType so tapping "+" while the Mails tab
  is active opens the sheet pre-selected to E-Mail. Plan-limit error
  handling maps WEB_LIMIT_REACHED / MAIL_LIMIT_REACHED to the right
  per-bucket message.

i18n: tabs_web / tabs_mail / count_label / error_web_limit_reached /
error_mail_limit_reached / empty_web / empty_mail across DE/EN/FR with
%{var} placeholders.
2026-05-16 02:03:41 +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
4eab5df7e2 feat(native/blocker): type picker + mail patterns in AddDomainSheet
AddDomainSheet now opens with a Seite / E-Mail segmented control.
Web keeps the existing flow (label, placeholder, favicon preview,
domain normalization). Mail switches to a free-form pattern input
(address / domain / display-name — user types what they see in
their inbox) with a mail-icon preview after the field is filled.
addDomain(pattern, kind) now sends { pattern, kind: 'web' | 'mail' }
and the server decides the concrete type. Type field flows through
the CustomDomain type so DomainGrid tiles render the mail-outline
icon for mail entries instead of the favicon fallback.

i18n: blocker.type_web / type_mail / add_web_* / add_mail_* across
de/en/fr with %{var} placeholders per repo convention.
2026-05-16 01:54:32 +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
ba170afd20 feat(native): chat tab badge for unread DMs
Adds a tabBarBadge on the bottom Chat tab driven by the same
dm-conversations query the chat screen already uses — React Query
dedupes the call. Badge shows the unread total (capped to "99+")
and disappears when 0. Query is gated on session so unauthenticated
launches don't fire it.
2026-05-16 01:53:03 +02:00
chahinebrini
d11d548c10 feat(native/chat): partner avatars + pill-shape search field
- DmItem now goes through resolveAvatar(partnerAvatar, partnerName) so
  the Dicebear-initials fallback kicks in for null avatars, hero ids
  resolve to their image url, and direct URLs pass through. Adds the
  PostCard-style avatarLoadFailed state for graceful broken-image
  fallback.
- Search row pill-shaped (borderRadius 999) with 16px horizontal padding
  and the outline search icon for better visual rhythm.
2026-05-16 01:51:59 +02:00
chahinebrini
40ccefab5b fix(native): replace #007AFF with colors.brandOrange in dm + room screens
Consistent with chat.tsx refactor — ActivityIndicator, joinBtn, and
avatarEdit badge all now use the theme token.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 01:28:08 +02:00
chahinebrini
a8ccfab274 feat(native): chat v1.0 — DM-only layout, search field, theme colors
Removes 2-tab Groups/DMs layout; Chat screen is now DM-only for v1.0.
Groups tab state, rooms query, RoomCard/CreateRoomSheet imports removed.
Replaces static title+create-button header with sticky search field
(client-side filter on partnerName + lastMessage). No create-DM button
added — /dm-new route does not exist yet (follow-up task).

All #007AFF in chat.tsx replaced with colors.brandOrange.
Adds chat.search_placeholder to de/en/fr locales.
Tab-bar styles kept in makeStyles (dead code, v1.1 Groups comeback path).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 01:28:04 +02:00
chahinebrini
500f673e53 fix(native/community): sync foreign likes_count into PostCard.localCount
Pure additive change — wasLikingRef + a small useEffect right after the
existing useState declarations. handleLike, the heart animation, localLike,
the memo comparator, and the render path are not touched.

Mechanism:
  - useCommunityRealtime already patches the React-Query cache on UPDATE
    events for rebreak.community_posts (the table IS in supabase_realtime
    — verified via pg_publication_tables on staging today).
  - The cache patch propagates to PostCard as a new post.likesCount prop.
  - The useState seed (post.likesCount on mount) was never re-read after
    the first render — the source of the bug.
  - The new useEffect mirrors post.likesCount into localCount with one
    guard: when isLiking transitions from true → false, skip the first
    run. The cache patch from our own action arrives ~100–300ms after
    the API response settles, so on the immediate run the prop is still
    stale; skipping prevents an overwrite of the value handleLike just
    set. The next prop change (cache patch arrival) re-fires the effect
    and syncs correctly.
  - Pure foreign likes (no own action in flight) sync immediately.

Earlier attempts (4c4792c, d28d1f1) tried to refactor wider — both broke
own-likes / comments / animations. This commit deliberately changes only
the new code paths.
2026-05-16 01:08:14 +02:00
chahinebrini
7c6b463acb Revert "fix(native/community): derive heart state from props + store-optimistic delta"
This reverts commit d28d1f145d9bdaa45fb788aaef69c645719f56bb.
2026-05-16 00:48:14 +02:00
chahinebrini
6f760f3aea Revert "fix(backend/realtime): add community_posts to supabase_realtime publication"
This reverts commit 0679aa6218e56710a7290770bcb94d0913d9721d.
2026-05-16 00:48:13 +02:00
chahinebrini
0679aa6218 fix(backend/realtime): add community_posts to supabase_realtime publication
The community feed's likes/dislikes/comments/reposts counters never live-
updated for foreign actions because the table simply wasn't in the
publication. useCommunityRealtime subscribed to UPDATE on community_posts,
the channel opened cleanly, but no events ever arrived for that table —
Supabase only broadcasts what's published.

The notifications channel (rebreak.notifications, added in 20260511) was
in the publication from day one, so users got the "X liked your post"
banner correctly. That made the gap look like a frontend rendering bug
all along; it was actually a missing one-line publication grant.

After this migration deploys, the React-Query cache patcher in
useCommunityRealtime will receive UPDATE events, patch the post in place,
and PostCard will re-render with the correct displayedCount derived from
post.likesCount + the (now-cleared) optimistic delta.
2026-05-16 00:47:44 +02:00
chahinebrini
964dc2b6e0 fix(native/games): game-over modal — maxHeight 85%, KeyboardAvoidingView, Button comp, regenerate
Four issues from the screenshot review plus one new affordance:

1. Modal overflowing on small devices — capped at maxHeight: '85%'. Header
   (handle bar + Lyra avatar + title + subtitle) stays fixed above a
   ScrollView body; action buttons stay fixed below with a border separator.
   Stat cards, star rating, and TextInput now live inside the scrollable body.

2. Keyboard pushed the TextInput out of sight — replaced the bespoke
   Keyboard.addListener + Animated.multiply lift hack (Easing, keyboardLiftY,
   the whole apparatus) with a plain KeyboardAvoidingView wrapper
   (behavior="padding" iOS / "height" Android). ScrollView already had
   keyboardShouldPersistTaps="handled" so taps on Posten/Abbrechen still
   work while the keyboard is up.

3. All four action buttons (Nochmal, Beenden, Abbrechen, Posten) plus the
   inner Save-Rating CTA now route through components/Button.tsx — picks
   up the slimmer paddingVertical:12 default from the central component.
   Posten gets the paper-plane icon. Nochmal + Posten = primary, Beenden +
   Abbrechen = secondary.

4. New "Neuer Vorschlag" regenerate button (ghost variant, sm size,
   refresh-outline icon) sits between the TextInput and the Abbrechen/
   Posten row. Reuses POST /api/games/share-text — no new endpoint. Tracks
   the last Lyra-generated text in a ref so we can detect user edits; if
   the user has modified the suggestion, taps go through an Alert.alert
   confirm before overwrite. Spinner during the regen call, Posten /
   Abbrechen stay active. i18n keys gameOver.regen_* across DE/EN/FR.
2026-05-16 00:44:44 +02:00
chahinebrini
d28d1f145d fix(native/community): derive heart state from props + store-optimistic delta
Replaces the previous mirrored localCount / localLike useState with derived
values computed from `post.likesCount` / `post.userLike` plus the existing
optimisticLikes entry from the community store. The local-state mirror was
the root cause of two separate bugs:

1. Foreign likes never reflected — useState seeded once from props on mount,
   so the React-Query cache patch in useCommunityRealtime updated the prop
   but the displayed count stayed frozen at the mount value.
2. The earlier sync-via-useEffect attempt (4c4792c, reverted in ab9472b)
   broke own-likes because clearing optimistic state could happen before
   the cache patch landed, so useEffect re-read a stale `post.likesCount`
   and snapped the count back down — visible as a 2 → 1 → 2 flicker on tap,
   and as the heart staying red after a toggle-off.

The fix is to NOT mirror at all. The store's `optimisticLikes` map already
stores `{ delta, userLike }` per post (it was set but never read before).
Render path now:
  displayedLike  = optimistic?.userLike  ?? (post.userLike === 'like' ? 'like' : null)
  displayedCount = (post.likesCount ?? 0) + (optimistic?.delta ?? 0)

In handleLike, after the API responds, the React-Query cache is patched
synchronously with the server-truth response before clearOptimisticLike
runs — so the moment the delta drops to 0, the prop already reflects the
new count. No race window, no useEffect, no own/foreign distinction needed.

`isLiking` is still kept as a re-tap guard against double-tap-mid-flight.
2026-05-16 00:40:46 +02:00
chahinebrini
a735f9a2ab feat(native): bound-device states + release flow in Devices page
MobileDeviceRow now handles three binding states driven by
boundToPlan / releaseRequestedAt from the UserDevice type:

- Bound, no release pending: blue "Gebunden" badge next to device name;
  trash icon replaced by lock-open icon → Alert → requestRelease()
- Release active (countdown running): footer shows "Freigabe in Xh Ymin"
  in amber; close-circle icon → Alert → cancelRelease()
- Current device (isCurrent): existing behaviour unchanged, no action
  button regardless of binding state

releaseAt is computed client-side as releaseRequestedAt + 24h — avoids
a backend round-trip for the countdown display.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 00:37:32 +02:00
chahinebrini
0d073b398f feat(native): DEVICE_LOCKED sign-in handling + DeviceLockedPanel UI
After Supabase auth succeeds the store calls POST /api/devices/check-lock
(x-device-id auto-attached via apiFetch). A 409 DEVICE_LOCKED response
triggers a Supabase sign-out and returns { deviceLocked } instead of
proceeding. The signin screen swaps to DeviceLockedPanel which shows:
- lock icon + headline + explanatory body
- amber countdown badge if a release is already in progress
- grey hint pointing to the email notification
- primary CTA to go back and sign in with the original account

Backend TODO: POST /api/devices/check-lock endpoint — same device-lock
query as login.post.ts but callable with a valid Supabase session token
(for email-login flow that bypasses /api/auth/login).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 00:37:22 +02:00
chahinebrini
edf047eacf feat(native): device-lock i18n keys + devices store type extensions
- Add auth.device_locked_* keys (DE/EN/FR): headline, body, countdown,
  email_hint, use_original CTA, back link
- Add devices.bound_badge + devices.release_* keys (DE/EN/FR) for the
  bound-device / release-flow in the Devices page
- Extend UserDevice interface with boundToPlan and releaseRequestedAt
- Add requestRelease + cancelRelease store actions calling the new
  POST /api/devices/:id/request-release|cancel-release endpoints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 00:37:12 +02:00
chahinebrini
ab9472b976 Revert "fix(native/community): sync realtime-patched likes_count back into PostCard"
This reverts commit 4c4792c153aa6949fc656ed570c0c147ba33ec87.
2026-05-16 00:35:21 +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
4c4792c153 fix(native/community): sync realtime-patched likes_count back into PostCard
`useCommunityRealtime` was already patching the React-Query cache
on community_posts UPDATE events — likesCount, dislikesCount, userLike
all reached the component as props on re-render. But PostCard was
seeding `localLike` / `localCount` once via useState initial values
and never re-reading the props after mount, so a like from another
account showed up as a notification but the heart counter stayed
stale until pull-to-refresh.

Added a useEffect that mirrors `post.likesCount` / `post.userLike`
back into local state, guarded by `isLiking` so an in-flight
optimistic update isn't clobbered by a concurrent realtime patch
of the same row.

Handles unlike (decrement) on the same path, plus off-screen posts
which get the patched cache value on remount and feed-list cards
that refresh in place without scroll.
2026-05-16 00:25:38 +02:00
chahinebrini
a57a873215 refactor(native/profile): use native iOS crop UI for avatar, drop custom sheet
ImagePicker.launchImageLibraryAsync now opens with `allowsEditing: true`
and `aspect: [1, 1]`, which triggers Apple's built-in square crop UI
(pan + zoom on the user's selection). The output URI is the actually
cropped image — fixing the long-standing bug where AvatarCropSheet
displayed a visual transform but `manipulateAsync` only resized the
original, so any pan/zoom the user did was discarded on confirm.

Removes the entire AvatarCropSheet component (~285 lines) and its sole
consumer wiring in profile/edit.tsx. The avatar continues to render as
a circle everywhere via borderRadius — the underlying square output is
just storage-agnostic.

Native-look-first per memory rule, zero new dependencies, no new
native module to link.
2026-05-16 00:25:18 +02:00