How the app actually works, at the level of files, functions, thresholds, SQL and data flow — read from the live code (iOS + Supabase backend) and the production database. Built as the reference for coding Allin from a fresh environment.
Allin is an iOS wellbeing app: a daily emotional check-in routes the user to a matched guided-audio "rewire" session, plus a 10-step beliefs plan, an audio library and a journal.
Maelwi/allin-ios → ~/Documents/allin-gh/allin-ios, branch main. Bundle com.allinwellltd.allin, App Store ID 6754271583, v2.2(5).wsxpmcbtcknewpiwudsc (us-east-1) — repo Maelwi/allin-backend → ~/Documents/allin-backend. Postgres + Auth (Sign in with Apple) + Storage (bucket audio-sessions) + 2 Deno edge functions.#if canImport(...) — compiles + no-ops until the packages/keys are present.The loop: check in (emotion → situation → intensity+body → intent) → routed to a matched audio → reflect (pre/post mood + note) → journal/streak. The plan is a parallel 10-step path with the same reflect mechanic per step.
Entry point (note: type is named FixingApp, a leftover). init() runs a one-time v1.6 UserDefaults migration (removes 8 stale beliefsPlan_* keys, guarded by "v16_userDefaults_cleaned"), installs a 50MB/500MB URLCache (disk path audio-cache) so Storage mp3s cache, then fires AnalyticsClient.configure() (:45), SubscriptionManager.configure() (:52), EntitlementService.refreshTrialBadge() (:59), and re-arms the daily check-in notification from allin.ritual.* defaults.
Boots the Meta SDK (MetaAdsClient.configure), sets the notification-center delegate, calls registerForRemoteNotifications() every cold launch. APNs token → AnalyticsClient.registerDeviceToken (→ Segment → Customer.io push). Notification taps parse DeepLink.fromNotification, track notification_opened, and set DeepLinkRouter.shared.pending.
Core/AuthenticationManager.swift — Apple Sign In via Supabase, session restore, and identity fan-out.
Builds an ASAuthorizationAppleIDProvider request with requestedScopes = [] — no name/email scope requested. On success (didCompleteWithAuthorization, :149) extracts the Apple identityToken and calls auth.signInWithIdToken(provider:.apple, idToken:) (:162). Then identifies analytics + RevenueCat, tracks sign_in_succeeded, and fires Meta trackCompleteRegistration(method:"apple").
init reads auth.currentSession synchronously (Keychain, no network) and identifies immediately; a background restoreSession() refreshes the token but only clears auth if currentUser == nil — a transient refresh error never wipes a valid cached session (this is a deliberate fix for the "logged-out on cold launch" class of bug).
Deletes the user's emotional_sessions, then calls RPC delete_user (removes the auth user; cascades — see §14). Cleanup then mirrors sign-out. RPC failure is caught so sign-out still happens. Note: erasure is not propagated to Segment/Amplitude/CIO/Meta (only local reset()).
Core/AppView.swift is the hub: it owns every top-level @StateObject (auth, sessions, plan, audio library) and a currentScreen: AppScreen switch across ~15 screens, and it hosts the whole check-in → recommendation → audio → reflection flow.
The routing brain. Sets checkinProcessing, tracks checkin_completed {intent,intensity}, builds a routing catalog from the live audio_library (only slugs resolving to playable files, else bundled AudioCatalog.all), then in a Task: saves the session and calls CheckinRouter.route(checkIn, catalog:). A checkinGeneration counter drops stale results if the user navigated away; safety_handoff → CrisisSupport; otherwise resolves the routed id → library row and shows checkinOverlay = .recommendation (curated description preferred over the AI note).
Persists the session; if the origin was the beliefs plan, computes shift = clamp(post − pre + 5, 0...10), calls beliefsPlanManager.completeStep(...), tracks plan_step_completed, and on a fully-done block tracks plan_block_completed + auto-calls continueJourney().
Other key handlers: applyPendingDeepLinkIfReady() (:403, waits for auth+onboarding+library before applying a deep link), handleStartCheckIn() (:512), handleSessionComplete() (:623, carries reframe+reflectionNotes through the completion upsert — fixes an earlier NULLing bug), checkForceUpgrade() (:718, vs app_config min version, fails open).
AuthenticationManager twice at launch (the line-4 default + the init() reassignment), so two cached-session reads + two background restores fire. The line-4 default is dead. Also the case .recommendation: in the screen switch (:105) is unreachable — the live recommendation renders via checkinOverlay, not currentScreen.Home/EmotionalCheckIn.swift is a step state-machine whose step list changes by lane.
enum Step { emotion, situation, intensity, intent, // universal core
consent, belief, pattern, origin, kinder, // Unblock tail
claritySubTap, confidenceSubTap, embodyNew } // Clarity/Confidence tails
Core is always [emotion, situation, intensity, intent]; then it forks on AudioLane(selectedIntent): soothe → (none); clarity → +claritySubTap; embody → +[confidenceSubTap, embodyNew]; unblock/default → +[consent, belief, pattern, origin, kinder]. Indices are clamped with min(currentStep, steps.count-1) because the list length changes mid-flow.
handleNext() (:849)emotion/belief): if crisisDetected(crisisText()) { onCrisis() }. On situation it runs an async layered check (crisisGateThenAdvance).if step == .intensity && Int(intensity) >= 7 → checkExpressOrAdvance: classifies valence, and if .unpleasant completes immediately (skipping the intent fork) so the router forces soothe-first; if .pleasant it advances to the fork.>= 7 and the slider defaults to 7 (intensity = 7, :36) — so a user who never moves the slider sits exactly on the boundary. Marie's intent is >= 8. One-line change, needs an app build (§15).Core/CheckinRouter.swift — static func route(_ c: CheckIn, catalog:) async -> Recommendation (:165). Gate order:
crisisDetected(allText) → safety_handoff, no network.async let the euphemism classifier and the affect classifier; if crisis → handoff.mustSootheFirst = (c.intensity >= 7 && valence == .unpleasant) → activeLane = mustSootheFirst ? .soothe : intentLane. Authoritative gate, threshold 7.Candidate resolution: filter catalog by activeLane, then lane-narrow (soothe → sleep-need if mentionsSleep else emotion-family match; clarity/embody → sub-tap → need map). 1 candidate → use it; 0 → lane default; blank situation → default with no AI call; else AIRouter.pick POSTs candidates to edge fn /recommend-audio and validates the returned id is in-set (else falls back to default).
POSTs to edge fn /classify-affect (20s). On any failure → offlineFallback: a keyword heuristic over 8 families, defaulting to .anxious; valence is .pleasant only for settled/energised with no distress words, else .unpleasant ("fail safe toward soothing").
Three independent layers; any one triggers the support hand-off. High-recall by design.
crisisDetected() substring-matches a 21-phrase list ("kill myself", "want to die", "self harm", "overdose", …). Scans only free-text (customEmotion, triggerText, customBelief) — chips aren't crisis signal.CrisisClassifier.flags() POSTs to /classify-crisis for euphemisms; network failure → false (layers A + C still cover).safety_handoff when no audio resolves.CrisisResources.forCurrentRegion() (region from Locale.current.region): US/CA 988, GB/IE Samaritans 116 123, AU Lifeline 13 11 14, NZ 1737, else findahelpline.com; plus a constant emergency line (911/999/000/111/112). Tap-to-dial via UIApplication.open. CrisisSupportView shows no audio. Gap: no UK Shout 85258 text line.
Two matchers coexist: the new router (primary) and a legacy keyword matcher (Core/AudioLibraryStore.swift) used as fallback when the routed audio isn't playable.
Legacy soothe threshold is 8 (intensity = 11 − preMood; if intensity >= 8 { return .soothe }). scenarioMatch (:163) is an ordered keyword router (betrayal/breakup/heartbreak/conflict/work-dread/family-trigger…) returning specific slugs; generalMatch (:249) falls back through emotion/belief clusters (clusterMap from backend checkin_config).
Streams from Supabase Storage into the URLCache; a static weak activePlayer guarantees one audio at a time. Progress timer at 0.5s. Completion fires only on natural 100% finish → onComplete(true, 100) → tracks audio_session_completed + RPC record_audio_session_complete. On onAppear, EntitlementService.canStartAudioSession() gates: .allowed → play; .paywall → track audio_session_paywalled + show PaywallView.
AudioLibraryStore uses 8. Since legacy is only fallback, the effective user-facing gate is 7. And completion is all-or-nothing — a 90%-then-exit play consumes no trial/weekly entitlement and logs no completion.iOS BeliefsPlanManager (cache) → BeliefsPlanService (I/O) → edge fn recommend-plan (composition). The whole path is deterministic Postgres FTS — no LLM.
Wraps a background task; POSTs {baseURL}/recommend-plan with the JWT + header x-allin-claude-plan: 1, 90s timeout. Body = camelCase BeliefsIntakeBody (3 areas + ratings + 4 free-text fields each). Maps status codes to typed BeliefsPlanError (400/401/500/502/timeout/offline). Custom decoder strips 6-digit Postgres microseconds before ISO8601 parse.
recommend-plan/index.tsService-role client validates the JWT via getUser(token) (the fn is deployed --no-verify-jwt, so this is the only auth gate). validateIntake requires exactly 3 areas with all 4 text fields. Then buildFTSPlan:
allocateSteps: rating→steps (r≤3→4, ≤6→3, else 2), adjust to total 10; sequenceStepAreas interleaves so areas don't clump.buildAreaQuery concatenates the 4 fields, strips stop-words, OR-joins tokens → RPC search_audio_library(query_text, 20) → keep only candidates whose cluster ∈ AREA_CLUSTERS[area] (structured gate); fallbacks = cluster-mapped, then any non-situational.step_type alternates reset/rewire by parity; repeats allowed; final fallback slug unblocking_empowerment_legacy.is_active=false, inserts beliefs_plans (intake_json) + 10 beliefs_plan_steps, re-selects joined with audio_library, fires server event plan_generated.stuck vs stuckness seed drift. Code (AREA_CLUSTERS) + checkin_config.cluster_map use cluster "stuck", but seed/audio_metadata.json still tags 8 audios "stuckness". The DB was hand-aligned to "stuck", so re-running the seed job would revert those 8 rows and silently break plan matching for Career/Intellectual/Financial/Creative areas (they'd drop to the "any audio" fallback). Fix: correct the seed JSON to "stuck".Singleton client (hardcoded URL + anon JWT). updateEmotionalSession is an UPSERT on id (:196) — library/plan/replay paths never inserted a row first, so a plain update would silently drop the entry. withRetry (3×, 0.4/0.8s backoff) guards writes only. fetchCheckinConfig reads checkin_config key 'v2' (live app reads 'default') so editing new chips never touches production.
Guards every load/save on authManager.currentUser and never clears on a transient nil. updateSession is optimistic (local copy first). currentStreak (:225) walks distinct active days with a 30-day grace window (non-punitive). Only clearSessions() (on sign-out) empties the list.
iOS Segment SDK + backend edge fns → Segment "Allin iOS" source → Amplitude + Customer.io (EU) + Meta.
AnalyticsClient (Segment): configure() with lifecycle-tracking on, flushAt 20 / interval 30s. identify(userId, email) on sign-in; track/screen; registerDeviceToken → CIO push. iOS must not fire plan_generated/_failed — the backend emits those (double-count guard).MetaAdsClient: configure enables auto-app-events + IDFA collection at launch; ATT (requestTrackingAuthorization) is only called from the consent sheet (not at launch). trackPurchase, trackCompleteRegistration. trackStartTrial() is defined but unused._shared/segment.ts (server): fetch-only track (no identify); no-op if SEGMENT_WRITE_KEY unset; swallows all errors. Server events: plan_generated, plan_generation_failed, and from revenuecat-webhook the subscription_* family.Entitlement id "allin Pro". configure(appUserId:), identify → logIn(userId), purchase(tier:) fans out subscribe_succeeded (Segment) + trackPurchase (Meta), distinct from the server webhook's subscription_started to avoid double-count. Annual footnote computes "50% off" dynamically from the store price.
Entitlement RPCs (live SQL, §13/§14): can_start_audio_session() — active sub → unlimited; trial_audio_sessions_used < 10 → trial; else rolling-7-day free; else paywall. record_audio_session_complete() increments the counter / stamps last_free_audio_at. iOS EntitlementService fails open (rpc_error_fail_open) so an infra hiccup never walls a user.
revenuecat-webhook is idempotent (via revenuecat_last_event_id) but is NOT in the CI deploy — it must be deployed manually.9 migrations under supabase/migrations/. Highlights: audio_library (+ GIN tag indexes), beliefs_plans/_steps (RLS own-rows, cascade to auth.users), storage bucket audio-sessions, the FTS search_vector, user_entitlements (+ entitlement RPCs + triggers), an emotional_sessions reframe column patch, the situational cluster data-migration, and checkin_config.
-- delete_user(): cascading erase (auth.users delete cascades to all user tables)
delete from public.emotional_sessions where user_id = auth.uid();
delete from public.users where id = auth.uid();
delete from auth.users where id = auth.uid();
-- search_audio_library(query_text, limit_count=10): ranked FTS
SELECT * FROM audio_library
WHERE search_vector @@ websearch_to_tsquery('english', query_text)
ORDER BY ts_rank(search_vector, websearch_to_tsquery('english', query_text)) DESC, created_at DESC
LIMIT limit_count;
Also live but unused by the app yet: record_consent_granted() and has_granted_consent() exist (they write/read user_entitlements.consent_granted_at) — the durable consent record the GDPR plan calls for is already scaffolded in the DB, just not wired from iOS. handle_new_user() + ensure_user_entitlement() triggers auto-create users + user_entitlements rows on signup.
Push to main, path-filtered jobs (parallel): migrations/** → supabase db push; functions/** → deploy only recommend-plan (--no-verify-jwt); seed/audio_metadata.json → node scripts/seed.mjs (PostgREST upsert on slug). Auth via repo secrets SUPABASE_ACCESS_TOKEN/_DB_PASSWORD/_SERVICE_ROLE_KEY.
| Table | Holds | RLS |
|---|---|---|
| emotional_sessions | emotion, body, trigger, belief, memory, desired/new belief, reframe text[], pre/post mood, reflection_notes, audio_completed/progress | ALL where auth.uid()=user_id |
| beliefs_plans | intake_json jsonb, llm_reasoning (unused), is_active | own read/insert/update |
| beliefs_plan_steps | step_number 1–10, audio_id, step_type (reset/rewire), pre/post mood, shift_rating, feedback_text, completed | own read/insert/update |
| user_entitlements | subscription_status, expires_at, revenuecat_app_user_id, trial_audio_sessions_used, last_free_audio_at, consent_granted_at | own read (writes via SECURITY DEFINER RPCs) |
| audio_library | slug, title, filenames, duration, cluster, 3 tag arrays, search_vector (generated, GIN) | public read |
| checkin_config · app_config | chip lists + cluster_map (jsonb) · min version | public read |
| users / auth.users | id + email (Apple) | own profile |
Every user-owned table has ON DELETE CASCADE to auth.users, so delete_user() fully erases the database (verified). Special-category (mental-health) free-text lives in emotional_sessions, beliefs_plans.intake_json, and beliefs_plan_steps.feedback_text — the targets for the GDPR pseudonymisation/encryption work.
>= 7 (EmotionalCheckIn.swift:871, CheckinRouter.swift:185); legacy AudioLibraryStore uses 8. Slider defaults to 7 → express fires by default. Intended: 8.stuck/stuckness seed drift (§9) — re-seeding silently breaks plan matching for 4 life areas. Fix the seed JSON.recommend-plan/index.ts: SYSTEM_INSTRUCTIONS, formatAudioCatalog, formatUserIntake, parseClaudePlan, ANTHROPIC_* env — all declared, never called. Live composition is 100% FTS.revenuecat-webhook not in CI deploy — redeploy manually. Double AuthenticationManager construction + unreachable .recommendation screen case (§4). All-or-nothing audio completion (§8). trackStartTrial() unused.record_consent_granted RPC exists, just unwired); no age gate; no UK Shout text line.No secret values here — locations only. Full values live in your password manager / the vaults below.
| Secret | Where it lives |
|---|---|
| Supabase anon key | Hardcoded in SupabaseService.swift (public by design) |
| Supabase service-role key | Supabase project secrets + GitHub Actions secret (server only) |
| Supabase Personal Access Token | macOS Keychain — service "Supabase CLI" |
| SEGMENT_WRITE_KEY | iOS Info.plist + Supabase project secret |
| Facebook app id / client token | iOS Info.plist |
| RevenueCat public SDK key | iOS Info.plist (RevenueCatApiKey); secret key in RC dashboard |
| REVENUECAT_WEBHOOK_SECRET · ANTHROPIC_API_KEY | Supabase project secrets (edge fns) |
| ASC API key (.p8) · APNs .p8 | Build Mac (~/.appstoreconnect) · uploaded to Customer.io for push |
| Sign in with Apple key | Apple Developer (key id P6GGA74424) |