From 34e3c2c8fbbb5d7b7b42b16112fcf38cbba482fa Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Tue, 18 Aug 2026 16:55:11 +0100 Subject: [PATCH 1/2] feat(support): surface live verification state to Crisp agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Support agents have no visibility into a user's live verification state, so they guess where a user is stuck. Adds a support-facing snapshot to the Crisp agent sidebar (session:data), derived entirely from the two backend read-models already on /get-user (`capabilities`, `identityVerification`) — no backend change and no new provider-state interpretation on the client. New agent-only fields: identity_status, email_on_file, verification_gates, verification_rails, failure_reason, pending_actions. Threaded through all three Crisp sinks: web widget (setCrispUserData), the proxy iframe (which receives the whole CrispUserData over the postMessage handshake), and native Capacitor (SupportDrawer). Sidebar only — the user's own composer (message:text) is never touched, so internal reason codes and rail ids stay out of the user's view. Closes #2360. --- src/components/Global/SupportDrawer/index.tsx | 12 ++ src/hooks/useCrispUserData.ts | 21 +++- .../__tests__/support-verification.test.ts | 107 ++++++++++++++++++ src/utils/crisp.ts | 12 ++ src/utils/support-verification.ts | 77 +++++++++++++ 5 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 src/utils/__tests__/support-verification.test.ts create mode 100644 src/utils/support-verification.ts diff --git a/src/components/Global/SupportDrawer/index.tsx b/src/components/Global/SupportDrawer/index.tsx index e07ce2cfbd..fccb27a740 100644 --- a/src/components/Global/SupportDrawer/index.tsx +++ b/src/components/Global/SupportDrawer/index.tsx @@ -176,6 +176,18 @@ const SupportDrawer = () => { if (userData.userId) { CapacitorCrisp.setString({ key: 'user_id', value: userData.userId }) } + // live verification state so agents stop guessing (#2360). Always + // write (empty string when absent) so a prior user's values can't + // linger on the device-local Crisp session — matching web/proxy. + CapacitorCrisp.setString({ key: 'identity_status', value: userData.identityStatus || '' }) + CapacitorCrisp.setString({ + key: 'email_on_file', + value: userData.emailOnFile === undefined ? '' : userData.emailOnFile ? 'yes' : 'no', + }) + CapacitorCrisp.setString({ key: 'verification_gates', value: userData.verificationGates || '' }) + CapacitorCrisp.setString({ key: 'verification_rails', value: userData.verificationRails || '' }) + CapacitorCrisp.setString({ key: 'failure_reason', value: userData.failureReason || '' }) + CapacitorCrisp.setString({ key: 'pending_actions', value: userData.pendingActions || '' }) if (prefilledMessage) { CapacitorCrisp.sendMessage({ value: prefilledMessage }) } diff --git a/src/hooks/useCrispUserData.ts b/src/hooks/useCrispUserData.ts index dc41d2d72a..ff5457e781 100644 --- a/src/hooks/useCrispUserData.ts +++ b/src/hooks/useCrispUserData.ts @@ -1,3 +1,4 @@ +import { buildSupportVerificationSummary } from '@/utils/support-verification' import { useAuth } from '@/context/authContext' import { AccountType } from '@/interfaces/interfaces' import { useMemo } from 'react' @@ -14,6 +15,13 @@ export interface CrispUserData { bridgeCustomerLink: string | undefined mantecaUserId: string | undefined posthogPersonLink: string | undefined + // Live verification state so agents stop guessing where a user is stuck (#2360). + identityStatus: string | undefined + emailOnFile: boolean | undefined + verificationGates: string | undefined + verificationRails: string | undefined + failureReason: string | undefined + pendingActions: string | undefined } /** @@ -44,10 +52,15 @@ export function useCrispUserData(): CrispUserData { const posthogPersonLink = userId ? `${POSTHOG_PERSON_BASE_URL}/${userId}` : undefined + const email = user?.user?.email || undefined + const verification = user + ? buildSupportVerificationSummary(user.capabilities, user.identityVerification, email) + : undefined + return { username, userId, - email: user?.user?.email || undefined, + email, fullName: user?.user?.fullName, avatar: user?.user?.profile_picture || undefined, walletAddress, @@ -55,6 +68,12 @@ export function useCrispUserData(): CrispUserData { bridgeCustomerLink, mantecaUserId, posthogPersonLink, + identityStatus: verification?.identityStatus, + emailOnFile: verification?.emailOnFile, + verificationGates: verification?.gates, + verificationRails: verification?.verificationRails, + failureReason: verification?.failureReason, + pendingActions: verification?.pendingActions, } }, [username, userId, user]) } diff --git a/src/utils/__tests__/support-verification.test.ts b/src/utils/__tests__/support-verification.test.ts new file mode 100644 index 0000000000..c00ca4a34d --- /dev/null +++ b/src/utils/__tests__/support-verification.test.ts @@ -0,0 +1,107 @@ +import { buildSupportVerificationSummary } from '../support-verification' +import type { IdentityVerification, RailCapability, UserCapabilities } from '@/types/capabilities' + +function caps(rails: RailCapability[], nextActions: UserCapabilities['nextActions'] = []): UserCapabilities { + return { rails, nextActions, restrictions: [] } +} + +const enabledRail: RailCapability = { + id: 'bridge.ach_us', + provider: 'bridge', + method: 'ACH_US', + channel: 'bank', + country: 'US', + currency: 'USD', + status: 'enabled', + resolved: { status: 'enabled' }, +} + +const emailBlockedRail: RailCapability = { + id: 'manteca.pix_br', + provider: 'manteca', + method: 'PIX_BR', + channel: 'bank', + country: 'BR', + currency: 'BRL', + status: 'blocked', + reason: { + code: 'no_email_captured', + userMessage: 'We need your email', + details: 'No email captured during submission', + }, + resolved: { + status: 'fixable', + blocking: { + code: 'no_email_captured', + userMessage: 'We need your email', + selfHealable: true, + selfHealKind: 'provide-email', + details: 'No email captured during submission', + }, + }, +} + +describe('buildSupportVerificationSummary', () => { + test('reports identity status, email-on-file and per-op gates', () => { + const identity: IdentityVerification = { status: 'verified' } + const summary = buildSupportVerificationSummary(caps([enabledRail]), identity, 'a@b.com') + + expect(summary.identityStatus).toBe('verified') + expect(summary.emailOnFile).toBe(true) + expect(summary.gates).toContain('pay:ready') + }) + + test('surfaces the stuck rail + failure reason', () => { + const identity: IdentityVerification = { status: 'verified' } + const summary = buildSupportVerificationSummary(caps([emailBlockedRail]), identity, undefined) + + expect(summary.emailOnFile).toBe(false) + expect(summary.failureReason).toBe('manteca.pix_br · no_email_captured — No email captured during submission') + expect(summary.gates).toContain('deposit:provide-email') + expect(summary.verificationRails).toBe('manteca.pix_br:fixable(no_email_captured)') + }) + + test('names the rail behind a pending/waiting gate that has no failure reason', () => { + const waitingRail: RailCapability = { + id: 'manteca.bank_transfer_ar', + provider: 'manteca', + method: 'BANK_TRANSFER_AR', + channel: 'bank', + country: 'AR', + currency: 'ARS', + status: 'pending', + resolved: { status: 'pending' }, + } + const summary = buildSupportVerificationSummary(caps([waitingRail]), { status: 'verified' }, 'a@b.com') + + // no failure to report, but the agent must still see WHICH rail is stuck + expect(summary.failureReason).toBeUndefined() + expect(summary.verificationRails).toBe('manteca.bank_transfer_ar:pending') + }) + + test('lists pending next-actions as kind(purpose)', () => { + const summary = buildSupportVerificationSummary( + caps([emailBlockedRail], [{ key: 'k1', kind: 'provide-email', purpose: 'unlock-manteca-pix' }]), + { status: 'verified' }, + undefined + ) + expect(summary.pendingActions).toBe('provide-email(unlock-manteca-pix)') + }) + + test('degrades cleanly with no read-models', () => { + const summary = buildSupportVerificationSummary(undefined, undefined, undefined) + expect(summary.identityStatus).toBe('unknown') + expect(summary.emailOnFile).toBe(false) + expect(summary.failureReason).toBeUndefined() + expect(summary.pendingActions).toBeUndefined() + }) + + test('reports action_required identity status', () => { + const summary = buildSupportVerificationSummary( + caps([enabledRail]), + { status: 'action_required', actionMessage: 'Re-upload your document' }, + 'a@b.com' + ) + expect(summary.identityStatus).toBe('action_required') + }) +}) diff --git a/src/utils/crisp.ts b/src/utils/crisp.ts index aec45cc58a..d9f2e046b9 100644 --- a/src/utils/crisp.ts +++ b/src/utils/crisp.ts @@ -31,6 +31,12 @@ export function setCrispUserData( bridgeCustomerLink, mantecaUserId, posthogPersonLink, + identityStatus, + emailOnFile, + verificationGates, + verificationRails, + failureReason, + pendingActions, } = userData if (email) { @@ -59,6 +65,12 @@ export function setCrispUserData( ['bridge_user_id', bridgeCustomerLink || ''], ['manteca_user_id', mantecaUserId || ''], ['posthog_person', posthogPersonLink || ''], + ['identity_status', identityStatus || ''], + ['email_on_file', emailOnFile === undefined ? '' : emailOnFile ? 'yes' : 'no'], + ['verification_gates', verificationGates || ''], + ['verification_rails', verificationRails || ''], + ['failure_reason', failureReason || ''], + ['pending_actions', pendingActions || ''], ], ], ]) diff --git a/src/utils/support-verification.ts b/src/utils/support-verification.ts new file mode 100644 index 0000000000..54fcc05ef5 --- /dev/null +++ b/src/utils/support-verification.ts @@ -0,0 +1,77 @@ +/** + * Support-facing verification snapshot — the state a Crisp agent needs to stop + * guessing where a user is stuck (issue #2360). Reads the two backend read-models + * already on the /get-user response (`capabilities`, `identityVerification`) and + * the on-file email; derives nothing the FE doesn't already render for the user. + * + * Everything here is pushed into Crisp `session:data` — the agent sidebar, which + * only the support agent sees. The user's own message is never touched. + */ + +import { deriveGate, railVerdict } from '@/utils/capability-gate' +import type { IdentityVerification, RailOperation, UserCapabilities } from '@/types/capabilities' + +const SUMMARY_OPERATIONS: RailOperation[] = ['pay', 'deposit', 'withdraw'] + +export interface SupportVerificationSummary { + /** identityVerification.status, or 'unknown' when the read-model is absent. */ + identityStatus: string + /** whether an email is on file — provider submission can't run without one. */ + emailOnFile: boolean + /** per-operation gate kinds, e.g. "pay:ready deposit:provide-email withdraw:blocked-rejection". */ + gates: string + /** every non-enabled rail as "id:status(reasonCode)" — names which rail is stuck, even for pending/waiting gates. */ + verificationRails?: string + /** the stuck rail's id + normalized reason code + technical details, if any. */ + failureReason?: string + /** pending next-actions as "kind(purpose)", comma-joined. */ + pendingActions?: string +} + +export function buildSupportVerificationSummary( + capabilities: UserCapabilities | undefined, + identityVerification: IdentityVerification | undefined, + email: string | undefined +): SupportVerificationSummary { + const rails = capabilities?.rails ?? [] + const nextActions = capabilities?.nextActions ?? [] + const identityStatus = identityVerification?.status ?? 'unknown' + const identityVerified = identityStatus === 'verified' + const emailOnFile = Boolean(email) + + const gateState = { rails, nextActions, identityVerified, isLoading: false } + const gates = SUMMARY_OPERATIONS.map((op) => `${op}:${deriveGate(gateState, op).kind}`).join(' ') + + // Per-rail verdicts. `gates` gives the op-level kind but not the rail; this + // names every rail that isn't clear (blocked/fixable/pending/requires-info) + // so an agent can see WHICH rail is stuck even for pending/waiting gates, + // where there's no failureReason to fall back on. + const actionByKey = new Map(nextActions.map((action) => [action.key, action])) + const railStates = rails + .map((rail) => ({ rail, verdict: railVerdict(rail, actionByKey) })) + .filter(({ verdict }) => verdict.status !== 'enabled') + + const verificationRails = railStates.length + ? railStates + .map( + ({ rail, verdict }) => + `${rail.id}:${verdict.status}${verdict.blocking?.code ? `(${verdict.blocking.code})` : ''}` + ) + .join(' ') + : undefined + + // The one blocker worth its technical detail — provider `details` (e.g. + // "No email captured…") is what the support agent actually needs. + const blocked = railStates.find(({ verdict }) => verdict.status === 'blocked' || verdict.status === 'fixable') + let failureReason: string | undefined + if (blocked?.verdict.blocking) { + const { code, details } = blocked.verdict.blocking + failureReason = `${blocked.rail.id} · ${code}${details ? ` — ${details}` : ''}` + } + + const pendingActions = nextActions.length + ? nextActions.map((action) => `${action.kind}(${action.purpose})`).join(', ') + : undefined + + return { identityStatus, emailOnFile, gates, verificationRails, failureReason, pendingActions } +} From 69c103cb2a1d5553c896e5dcbeea5a9678b5a772 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Tue, 18 Aug 2026 17:27:44 +0100 Subject: [PATCH 2/2] fix(support): don't report an absent capability read-model as needs-identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `capabilities` is optional on /get-user during the capability migration. Deriving gates over the empty fallback state made every operation read `needs-identity`, which a support agent cannot tell apart from a genuinely unverified user — the exact misreading this snapshot exists to prevent. Report an empty `gates` when the read-model is absent; a read-model that is present but empty still derives normally, since needs-identity is the truth there. --- src/utils/__tests__/support-verification.test.ts | 9 +++++++++ src/utils/support-verification.ts | 7 ++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/utils/__tests__/support-verification.test.ts b/src/utils/__tests__/support-verification.test.ts index c00ca4a34d..f0161c562f 100644 --- a/src/utils/__tests__/support-verification.test.ts +++ b/src/utils/__tests__/support-verification.test.ts @@ -92,10 +92,19 @@ describe('buildSupportVerificationSummary', () => { const summary = buildSupportVerificationSummary(undefined, undefined, undefined) expect(summary.identityStatus).toBe('unknown') expect(summary.emailOnFile).toBe(false) + // no capability read-model — must NOT read as "needs-identity on everything", + // which an agent can't tell apart from a genuinely unverified user + expect(summary.gates).toBe('') + expect(summary.verificationRails).toBeUndefined() expect(summary.failureReason).toBeUndefined() expect(summary.pendingActions).toBeUndefined() }) + test('still reports gates for a user whose read-model is present but empty', () => { + const summary = buildSupportVerificationSummary(caps([]), undefined, undefined) + expect(summary.gates).toBe('pay:needs-identity deposit:needs-identity withdraw:needs-identity') + }) + test('reports action_required identity status', () => { const summary = buildSupportVerificationSummary( caps([enabledRail]), diff --git a/src/utils/support-verification.ts b/src/utils/support-verification.ts index 54fcc05ef5..39aa500924 100644 --- a/src/utils/support-verification.ts +++ b/src/utils/support-verification.ts @@ -39,8 +39,13 @@ export function buildSupportVerificationSummary( const identityVerified = identityStatus === 'verified' const emailOnFile = Boolean(email) + // An absent capability read-model is NOT a verified-identity signal: deriving + // over the empty state would report `needs-identity` on every op, which an + // agent cannot tell apart from a genuinely unverified user. Report nothing. const gateState = { rails, nextActions, identityVerified, isLoading: false } - const gates = SUMMARY_OPERATIONS.map((op) => `${op}:${deriveGate(gateState, op).kind}`).join(' ') + const gates = capabilities + ? SUMMARY_OPERATIONS.map((op) => `${op}:${deriveGate(gateState, op).kind}`).join(' ') + : '' // Per-rail verdicts. `gates` gives the op-level kind but not the rail; this // names every rail that isn't clear (blocked/fixable/pending/requires-info)