# Slug-per-flow chatbot routing — tag short links to custom journeys

**Date:** 2026-06-29
**Branch:** `feat/whatsapp-flow-integration`
**Status:** Design — pending review

## Problem

On a channel with multiple custom journeys, **every short-link slug triggers the
primary journey's flow** instead of the journey the slug belongs to.

Each journey's entry workflow — `"<journey display_name> Default Check Standard
Onboarded User"` — is seeded with keywords taken from the request payload's
`short_links` (`JourneyController.php:2317-2332`, via `$this->slugs =
$request->short_links` at `:2323`). That association lives only in the ephemeral
request; it is never persisted or scoped to the journey. As a result a given
slug can end up as a keyword on more than one journey's entry workflow.

When an inbound slug matches multiple entry workflows, `WorkflowService::findByKeywords`
scores them all equally (exact match = 100), producing a tie. The tie is broken
by DB order — lowest `id` first (`WorkflowService.php:91-123`) — which is the
journey created first, i.e. the **primary**. So every slug resolves to the
primary's flow.

The primary's entry workflow also carries the greedy `*` keyword (`:2319-2321`),
which is the intended catch-all for unmatched input and must be preserved. `*`
only wins via the explicit fallback branch (`WorkflowService.php:112-121`); it
scores ~0 under fuzzy matching, so it does not interfere with a correctly scoped
specific slug.

## Goal

Each custom journey is entered only via **its own** slugs; unmatched input still
falls through to the primary (the `*` catch-all). Mirrors how the seeders give
each flow its own specific entry slug (e.g.
`VitaHealthMalaysiaWatsonsCampaignJourney.php:512` → `["VitaHealth Lucky Draw"]`).

## Approach (kept deliberately small)

Persist the slug→journey association and use it as the source of truth when
seeding entry-workflow keywords. **No routing-layer change** — once each entry
workflow carries only its own slugs, the existing keyword matcher resolves each
slug to the correct journey with no tie.

1. **Tag** `wa_short_links` with a nullable `custom_journey_id`.
2. **Stamp** each journey's short links with its `custom_journey_id` on save.
3. **Scope** the entry-workflow keyword set to the slugs tagged to that journey
   (plus `*` for the primary).

Explicitly **not** doing: workflow-name namespacing, template-slug isolation, or
any change to `ChannelService` / `WorkflowService`. (An earlier draft considered
full per-journey namespacing; this simpler tag-based approach supersedes it.)

## Components

### 1. Migration — `wa_short_links.custom_journey_id`
- Add `unsignedBigInteger('custom_journey_id')->nullable()->index()` (nullable so
  existing/un-attributed links are untouched).
- Foreign key → `custom_journeys.id`, `nullOnDelete` (a deleted journey leaves its
  links orphaned rather than cascading).

### 2. Model — `App\Models\WaShortLinks`
- Add `custom_journey_id` to `$fillable`.
- Add `customJourney()` belongsTo `CustomJourney`.
- (Optional) `CustomJourney::shortLinks()` hasMany for convenience.

### 3. Seeding — `App\Http\Controllers\Journey\JourneyController`
- Make the current journey id available to the seed chain: set
  `$this->customJourneyId = $customJourney->id` after the `CustomJourney` is
  created/fetched and before the seed chain — in **both** `createJourney`
  (before `:1023`) and `createBlast` (which also runs `initialSetupV2`, `:849`).
- **Stamp the tag** in the single short-link creation block — `initialSetupV2`'s
  `collect($this->slugs)->map(...)` at `:3375`, shared by both the journey and
  blast paths: include `'custom_journey_id' => $this->customJourneyId` in the
  `updateOrCreate` values.
- **Scope the entry keywords** (`seedWelcomeWorkflow`, `:2317-2332`): build the
  keyword list from the short links tagged to this journey, instead of iterating
  the raw `$this->slugs`:
  - **Non-primary:** messages of links where `custom_journey_id = $this->customJourneyId`.
  - **Primary** (`$this->is_soft_delete`): `'*'` **plus** messages of links where
    `custom_journey_id = $this->customJourneyId` **OR `custom_journey_id IS NULL`**
    — so any untagged / unassigned slug routes to the primary as the catch-all.

## Data flow

```
Save journey B
  → its flowchart short_links persisted to wa_short_links, stamped custom_journey_id = B
  → B's entry workflow keyword set = messages of links tagged B (only)
Inbound message = B's slug
  → findByKeywords: matches only B's entry workflow (100) → routed to B
Inbound message = generic / unmatched
  → no specific match → greedy '*' branch → primary's entry workflow (catch-all)
```

## Edge cases & notes

- **Existing / untagged links:** links with `custom_journey_id IS NULL` (legacy
  rows, or slugs not authored under any journey) are folded into the **primary**
  journey's entry keywords, so they keep working as before. A link gets a
  non-null tag once the owning journey is saved (`updateOrCreate(['slug' => …])`).
- **Duplicate slug strings across journeys:** `wa_short_links` is keyed by
  `(wa_account_id, slug)` via `updateOrCreate(['slug' => …])`, so the last save
  wins ownership of a shared slug string. Slugs are expected unique per channel;
  enforcing that is out of scope here (note only).
- **Re-seed orchestration:** `savePrimaryJourney()` / `saveOtherJourney()` re-run
  `createJourney` per journey from each journey's own `save_payload`, so
  `$this->customJourneyId` is correct for each invocation and ownership stays
  consistent across re-seeds.
- **Primary unchanged otherwise:** still carries `['*', …its own slugs]`.

## Testing

- Seed a channel with a primary + two campaign journeys, each with its own slug.
- Assert each journey's entry workflow keyword set contains **only** its own
  slug message(s) (primary additionally has `*`).
- Assert `wa_short_links` rows carry the correct `custom_journey_id`.
- Simulate an inbound message equal to a campaign slug → assert its journey's
  entry workflow is selected (not the primary).
- Simulate an unrelated inbound text → assert the primary's entry workflow
  (`*`) is selected.
- Create an untagged `wa_short_links` row (`custom_journey_id` null) → assert its
  message is included in the primary's entry keywords and routes to the primary.

## Out of scope

- Workflow-name / template-slug namespacing.
- `ChannelService.php:387` fallback-by-channel-name lookup.
- Removing the dead `app/Http/Controllers/Journey/v2.php` (stale duplicate
  `JourneyController`; not autoloaded under PSR-4).
