# WA Flow as Onboarding Substitute — Design

**Date:** 2026-07-07
**Status:** Design (approved for spec)
**Author:** Wilson Santa Cruz

## Problem

User onboarding today is a chain of question message nodes in the journey editor —
`default-q-name`, `default-q-email`, `default-q-address`, each followed by a
`consumer-response` wait node. The runtime (`ChannelService` / `WorkflowService`)
sends them one at a time over WhatsApp, tracks position via the last
`ChatSessionMessages.workflow_node_id`, stores each answer in
`ChannelRecipientVariables`, pushes name/email/address to the external "user"
integration, and finally sets the `onboarded_user` recipient variable.

This is a turn-by-turn conversation: many round trips, easy to abandon mid-way,
and every question is a separate template.

WhatsApp Flows (Meta's native multi-screen forms) can collect the same fields in
a single form submission. The `WaFlow` system already exists in this codebase
end to end (create → publish to Meta → `flow-reply` node in the editor → webhook
capture into `interactive_flows` / `interactive_flow_answers`). We want to let a
WA Flow **replace** the onboarding question chain.

## Goal & Scope

**In scope:** make the existing `flow-reply` node a supported way to do onboarding
in the visual editor, and add the data bridging so an onboarding flow's answers
reach the same downstream the question chain feeds today.

**Out of scope (explicitly not doing):**
- No changes to the seeded default journeys (`seedWelcomeWorkflow`,
  `seedOnboardingWorkflow2`). Existing journeys are untouched.
- No new node type. We reuse `flow-reply`.
- No per-field mapping UI. Mapping is by convention (see below).

## Approach — reuse, don't rebuild

Everything except the downstream bridging already works. The only new machinery is:

1. An `is_onboarding` flag on the flow.
2. One bridge method, called from the single place submissions are persisted.

### Why a flag on the flow (not the node)

The flag lives on `wa_flows`, not on the journey `flow-reply` node, because:
- The bridge runs in `InteractiveFlowService::createFromMessage()`, which already
  resolves the `WaFlow` (via `resolveWaFlowId()`). Reading `$waFlow->is_onboarding`
  is free there; reading a journey-node setting would require parsing the
  `flow_token`'s `template-{id}` and loading the `ChannelTemplate`.
- Onboarding-ness is a property of the flow itself, not of where it's dropped.

## Design

### 1. `is_onboarding` flag

- Migration: add `is_onboarding` boolean, default `false`, to `wa_flows`.
- `app/Models/WaFlow.php`: add `is_onboarding` to `$fillable` and cast to boolean.
- WA Flow editor: add a single "Use as onboarding" checkbox. It persists through
  the existing `POST/PUT /api/v1/wa-flows` path (`WaFlowApiController::store/update`) —
  add `is_onboarding` to the validated/assigned fields.

### 2. The bridge — `InteractiveFlowService::bridgeToRecipient()`

Called at the end of `createFromMessage()`, **after** `InteractiveFlowAnswer::insert()`,
and only when `$waFlow && $waFlow->is_onboarding`. It receives the resolved
`$waFlow`, the `$recipient` (`ChannelRecipients`), the `$session`, and the parsed
answers (`question_key => value`, option-ids already mapped to titles).

Three effects, matching the old question chain:

**a. Recipient variables (convention mapping)**
For each answer, upsert a `ChannelRecipientVariables` row on the recipient:
`variable_name = question_key`, `variable_value = answer_value`. The WA Flow
author is responsible for naming fields `name`, `email`, `address`, etc. — the
`question_key` becomes the variable name verbatim. No config, no UI.

> Note: `ChannelRecipientVariables::EncryptedLists` already includes
> `email, address, name, state`. Write through the model (not a raw insert) so
> the existing encryption + `VariableWasCreated` event fire, exactly as the
> question chain's writes did. (Verified: `name`/`email` land encrypted at rest.)

**a′. Mirror identity fields onto the recipient row**
The `user` integration builds its payload from the recipient's own columns
(`getUserCreationAttrs()` reads `recipient->name / email / recipient_mobile`), **not**
from the variables. So the bridge also mirrors the known identity answers onto the
`ChannelRecipients` row via `RECIPIENT_COLUMN_MAP` (`name→name`, `email→email`,
`mobile→recipient_mobile`). Without this the push would ship a stale record.
`outlet`/`address` have no recipient column and stay as variables only.

**b. User integration push**
For answers whose `question_key` is in the known onboarding set
(`name, email, mobile, outlet, address`), forward to the external "user" system by
**reusing the exact job the old INTEGRATION node used** —
`app/Jobs/Integration/ProcessIntegration`:

```php
ProcessIntegration::dispatch($session, $userIntegration /*, node: null */);
```

- The constructor is `(RecipientChatSessions $chatSession, Integrations $integration, ?WorkflowNodes $node = null)`. The `user` payload doesn't use the node, but
  `initialMonitorData()` dereferences it — so `null` **crashes**. The bridge resolves
  the session's latest workflow node (the cursor `ChannelService::getCurrentLastNode`
  uses) and passes that; if none resolves, it skips the push and logs.
- `processUserPayload()` builds its payload from the recipient's data
  (`getUserCreationAttrs()`), which is why step (a) must run first — the variables
  we just wrote are what the push carries.
- `$userIntegration` is the channel's existing `Integrations` record of type
  `user` — the same integration the seeded INTEGRATION node referenced. Resolve it
  by the channel/account; if the channel has no `user` integration configured,
  skip this step and log (do not fail the submission).

**c. Onboarded flag**
Set the `onboarded_user` recipient variable, matching `seedOnboardingWorkflow2`'s
`SET_VARIABLES` node (value `1` / increment). This is what the "Default Check
Onboarded User" condition (`onboarded_user >= 1`) gates on.

### Ordering & failure handling

Order inside `bridgeToRecipient()`: (a) variables → (c) onboarded flag →
(b) dispatch integration. The bridge is wrapped so that a failure in the
integration dispatch does **not** roll back the persisted `InteractiveFlow` /
answers or the recipient variables — the submission is already captured; the push
is best-effort and queued.

### Non-onboarding flows

If `is_onboarding` is false (all existing survey flows), `bridgeToRecipient()` is
not called. Zero behavior change for current WA Flow surveys.

## Data flow (after change)

```
User completes onboarding WA Flow
  -> Meta webhook nfm_reply
  -> WaWebhookController -> InteractiveFlowService::createFromMessage()
       -> InteractiveFlow + InteractiveFlowAnswer rows      (unchanged)
       -> if $waFlow->is_onboarding:
            bridgeToRecipient():
              a. ChannelRecipientVariables upsert per answer (question_key => value)
              b. set onboarded_user
              c. ProcessIntegration::dispatch(session, userIntegration)  -> external user system
```

## Trade-offs accepted

- **All-or-nothing capture.** A WA Flow returns one submission; there is no partial
  capture (name saved, then user abandons before email). For onboarding this is
  acceptable — arguably better, since we no longer create half-onboarded records.
  Documented, not engineered around.
- **Convention over configuration.** If an author names a field wrong, it lands in
  a mis-named recipient variable. Acceptable for the lazy path; a per-field mapping
  UI can be added later if a real flow needs field renaming.

## Testing

A feature test driving a synthetic `nfm_reply` message through
`InteractiveFlowService::createFromMessage()`:

1. **Onboarding flow bridges.** Given a `WaFlow` with `is_onboarding = true` and a
   payload with `name`, `email`, `address`:
   - asserts a `ChannelRecipientVariables` row exists for each answer with the
     correct `variable_name`/`variable_value`;
   - asserts `onboarded_user` is set on the recipient;
   - asserts `ProcessIntegration` is dispatched for the `user` integration
     (`Queue::fake()` / `Bus::fake()` and assert dispatched).
2. **Non-onboarding flow does not bridge.** Same payload, `is_onboarding = false`:
   - asserts no `ChannelRecipientVariables` written by the bridge;
   - asserts `onboarded_user` is not set;
   - asserts `ProcessIntegration` is **not** dispatched.
3. **Idempotency.** Replaying the same `wa_message_id` returns the existing
   `InteractiveFlow` and does not double-write variables or double-dispatch the
   integration.
4. **Missing user integration.** Onboarding flow on a channel with no `user`
   integration: variables + `onboarded_user` still written; integration dispatch
   skipped and logged; no exception thrown.

## Affected files

- `database/migrations/XXXX_add_is_onboarding_to_wa_flows.php` (new)
- `app/Models/WaFlow.php` — fillable + cast
- `app/Http/Controllers/Api/WaFlowApiController.php` — accept `is_onboarding` in store/update
- `app/Skale/Services/InteractiveFlowService.php` — `bridgeToRecipient()` + call site
- WA Flow editor view/component — "Use as onboarding" checkbox
- `tests/Feature/...InteractiveFlowOnboardingBridgeTest.php` (new)
