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>
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>
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>
- 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>
- 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>
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>
- 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>
- 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>
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>
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>
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.
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>
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>
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>
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.
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.
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.
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.
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.
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.
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.
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>
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.
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.
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).
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>
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.
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.
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.
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.
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.
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.
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).
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.
- 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.
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>
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>
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.
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.
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.
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.
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>
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>
- 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>
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