Phase 4 §2.1: anonymous → existing-user merge breadcrumb
Adds `customers.account_belongs_to UUID NULL` and refactors customer sign-in (phone/Google/Apple) so an anon row that re-verifies into an existing customer no longer 409s. Instead the anon row stays intact with a breadcrumb pointing at the real customer; tokens are issued for the existing user. Actual data reconciliation onto the existing row (chat_sessions, customer_transactions, payment_sessions, pairing_failures) is deferred. Backend - migrate.js: ADD COLUMN account_belongs_to UUID REFERENCES customers(id) ON DELETE SET NULL. - customer.service.js: stampAccountBelongsTo helper; account_belongs_to exposed in CUSTOMER_SELECT. - auth.service.js: new shared resolveCustomerForIdentity (4-case logic); normalizeIdentityConflict + IDENTITY_ALREADY_LINKED 409 deleted; completeCustomerPhoneSignIn / signInWithGoogle / signInWithApple all route through the shared helper. - client.auth.routes.js: new resolveAnonymousCustomerId picks the anon prefix ONLY from a verified Bearer JWT — closes the UUID-leak attack where a tamper-able body field could mis-route someone else's transactions. /otp/verify, /google, /apple all use it; the body field `anonymous_customer_id` is no longer accepted on any of them. - test/services/auth.service.test.js: 9 Vitest cases covering phone + Google + Apple, all 4 logic cases + multi-merge accumulation. Customer app - auth_notifier.dart::verifyOtp: drop `skipAuth: true` and the dead body field so ApiClient auto-attaches the anon's Bearer from AuthBridge. Survives the AuthOtpSentData state transition (the earlier `_currentAnonymousCustomerId()` state-drop bug is bypassed by sourcing the id from the bridge instead of state). - Google + Apple client paths remain unchanged (gated on provider creds; mirror this fix when wiring lands). Docs - flow_customer.mermaid.md: new §2.1 sub-section with the merge diagram, schema note, replaces-current-behaviour paragraph, and Bearer-only security callout. - phase3.4-testing.md: §1.5 line 76 simplified (no more per-path split); new §1.5.1 with the 5-step operator scenario + DB invariants + curl recipe + Vitest pointer; new §1.5.2 covering Google/Apple parity (deferred client work flagged). Verification (against live dev backend, before this commit): - Vitest: 9/9 in auth.service.test.js; 49/51 overall (2 unrelated pre-existing failures in session-timer.service.test.js). - Operator Node smoke: 14/14 in the §1.5.1 scenario; 11/11 in the Bearer-precedence cases. - Real-device UI walkthrough on SM-A530F still pending — see resume memory `project_phase4_2_1_resume_test`. Sister WIP bundled in migrate.js + customer.service.js: `usp_seen` column + `markCustomerUspSeen` helper (Phase 4 USP one-time gate, was already uncommitted in the working tree). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -131,6 +131,91 @@ flowchart TD
|
||||
> exists (see Phase 1 auto-link via phone); the app-side reconciliation +
|
||||
> `has_transacted` plumbing is the new work.
|
||||
|
||||
### 2.1 Anonymous → existing-user merge (post-transaction OTP) 🔴
|
||||
|
||||
> **Concern:** a real user can accidentally transact as anonymous (skipped
|
||||
> login, picked "tanpa verifikasi", or reopened the app after logout) and
|
||||
> only later verify their phone. If that phone already belongs to another
|
||||
> customer row in our DB, the anonymous customer's transactions live under
|
||||
> a separate, unreachable identity — a data gap where the same human shows
|
||||
> up as two distinct customers. The flow must capture this transition so
|
||||
> the orphan can be reconciled with the real account later.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Open["Open app · fresh install or post-logout"] --> Transact["Anonymous transact(s) under Anon-row · S2→S6→…→S10→close"]
|
||||
Transact --> More{"more sessions?"}
|
||||
More -->|"yes"| Transact
|
||||
More -->|"no · verify phone"| OTP["S3a/S3b OTP"]
|
||||
OTP -->|"OTP ok"| PhoneLookup{"phone exists in customers?"}
|
||||
|
||||
PhoneLookup -->|"5.1 · new phone"| AnonUpgrade["Upgrade Anon-row in place<br/>(set phone, preserve display_name<br/>+ has_transacted + history) 🔴"]
|
||||
AnonUpgrade --> Continue["Resume verified flow as same id"]
|
||||
|
||||
PhoneLookup -->|"5.2 · existing user"| StampBelongsTo["5.2.1 · stamp Anon.account_belongs_to = Existing.id<br/>(keep Anon row + its chat_sessions/transactions intact) 🔴"]
|
||||
StampBelongsTo --> ReloginAs["5.2.2 · issue tokens for Existing.id<br/>app re-logs in as Existing 🔴"]
|
||||
ReloginAs --> Continue
|
||||
|
||||
classDef partial fill:#fff7e6,stroke:#d4a017
|
||||
class StampBelongsTo,ReloginAs,AnonUpgrade partial
|
||||
```
|
||||
|
||||
> **Schema (new):** add a nullable self-FK on the customers table:
|
||||
> `account_belongs_to UUID NULL REFERENCES customers(id)`. This points from
|
||||
> an orphaned anon row → the real account it should be merged into. The
|
||||
> column is *just the breadcrumb* — actually moving `chat_sessions`,
|
||||
> `customer_transactions`, `payment_sessions`, `pairing_failures` onto the
|
||||
> real account is a separate reconciliation step deferred to a later
|
||||
> phase. Keeping the anon row intact (rather than deleting it) preserves
|
||||
> the historical record and lets reconciliation be replayed/audited.
|
||||
|
||||
> **Flow:**
|
||||
> 1. User opens the app (fresh install or after logout).
|
||||
> 2. User taps "aku mau curhat" and proceeds through S2 Nama → VerifChoice.
|
||||
> 3. User picks "tanpa verifikasi" (anonymous path).
|
||||
> 4. One or more anonymous transactions complete and close.
|
||||
> 5. User later opts to verify phone (S3a/S3b OTP). Backend looks up phone:
|
||||
> - **5.1 · phone not found** → upgrade the active anon row in place:
|
||||
> set `phone`, keep `display_name`, `has_transacted`, `usp_seen`. The
|
||||
> same customer id continues; all prior anonymous transactions are
|
||||
> now attached to the verified identity automatically.
|
||||
> - **5.2 · phone exists (Existing-row)** →
|
||||
> - **5.2.1** stamp `account_belongs_to = Existing.id` on the active
|
||||
> anon row. The anon row is **not** deleted; its
|
||||
> `chat_sessions`/`customer_transactions` FKs stay valid.
|
||||
> - **5.2.2** issue tokens for `Existing.id` and re-login the app as
|
||||
> that user — the app should treat this like a normal returning
|
||||
> verified session (overwrites local call_sign per mermaid §2
|
||||
> line 62; honors `has_transacted` to skip S6 paywall).
|
||||
|
||||
> **Replaces current behaviour:**
|
||||
> The legacy `normalizeIdentityConflict` helper threw `IDENTITY_ALREADY_LINKED`
|
||||
> (409) for case 5.2 across all three identity paths (phone/Google/Apple).
|
||||
> It has been deleted; the new shared `resolveCustomerForIdentity`
|
||||
> ([auth.service.js](../backend/src/services/auth.service.js)) implements the
|
||||
> 4-case merge logic uniformly. The customer app currently only exercises
|
||||
> the phone-OTP path, but Google and Apple behave identically once their
|
||||
> credentials land.
|
||||
|
||||
> **Auth source for the anon id (security):** the backend identifies the
|
||||
> anon row to stamp **only** from a verified Bearer JWT presented as
|
||||
> `Authorization: Bearer <token>` on `/otp/verify`. The earlier body field
|
||||
> `anonymous_customer_id` is no longer read — accepting it would let anyone
|
||||
> who learns a victim's anon UUID mis-route their transactions. Bearer
|
||||
> tokens are HS256-signed with `AUTH_JWT_SECRET` and unforgeable. See
|
||||
> `resolveAnonymousCustomerId` in
|
||||
> [client.auth.routes.js](../backend/src/routes/public/client.auth.routes.js).
|
||||
> Client implication: on the verified-OTP path the app must carry the
|
||||
> anonymous session's access token through `requestOtp` → `verifyOtp` (today
|
||||
> the client uses `skipAuth: true` and drops the bridge token — needs to be
|
||||
> fixed alongside the `AuthOtpSentData` carry-through bug, which is the
|
||||
> "real account verification implementation" tracked separately).
|
||||
|
||||
> **Note on multi-merge:** if a user does this dance repeatedly across
|
||||
> installs/logouts, multiple anon rows can accumulate, each pointing at the
|
||||
> same `account_belongs_to`. That's fine — reconciliation later walks the
|
||||
> set; treat `account_belongs_to` as many-to-one.
|
||||
|
||||
---
|
||||
|
||||
## 3. Pre-pairing → Searching → Match (shared)
|
||||
|
||||
Reference in New Issue
Block a user