diff --git a/.github/workflows/promotion-guard.yml b/.github/workflows/promotion-guard.yml new file mode 100644 index 00000000..23c3b9d4 --- /dev/null +++ b/.github/workflows/promotion-guard.yml @@ -0,0 +1,82 @@ +name: Promotion Guard + +# Enforces the promotion ladder so environment branches can only receive merges +# from the tier directly below them: +# +# dev -> beta -> main +# +# (dev is the team integration + final-QA environment; beta is a disposable +# infra rehearsal for selected testers; main is production. Staging was dropped — +# per-PR preview envs + dev cover pre-beta testing.) +# +# GitHub rulesets can require a review and status checks, but they cannot +# restrict which *source* branch a PR merges from. This guard fills that gap: +# it fails any PR into a protected branch whose head is not the allowed source. +# Mark the "guard" check as a required status check in each branch's ruleset +# (beta, main) for it to actually block a merge. +# +# PRs into a protected branch must come from a branch in this repo (not a fork). +# Emergency bypass: add the `hotfix` label to skip the ladder — allowed only on +# PRs targeting `main`, for a critical fix that must land out of order. + +on: + pull_request: + branches: [main, beta] + types: [opened, reopened, synchronize, edited, labeled, unlabeled] + +permissions: + contents: read + +jobs: + guard: + name: guard + runs-on: ubuntu-latest + steps: + - name: Check promotion source branch + env: + BASE: ${{ github.base_ref }} + HEAD: ${{ github.head_ref }} + HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + REPO: ${{ github.repository }} + # contains() over the label-name array is an exact element match, so a + # label like "needs hotfix review" does NOT count as the hotfix label. + IS_HOTFIX: ${{ contains(github.event.pull_request.labels.*.name, 'hotfix') }} + shell: bash + run: | + set -euo pipefail + + # Allowed source for each protected target branch. + declare -A ALLOWED=( [main]="beta" [beta]="dev" ) + expected="${ALLOWED[$BASE]:-}" + + echo "PR: $HEAD -> $BASE (from $HEAD_REPO)" + + if [ -z "$expected" ]; then + echo "::notice::No promotion rule defined for base '$BASE'; nothing to enforce." + exit 0 + fi + + # Promotions and hotfixes to protected branches must originate from a + # branch in this repository, never a fork — otherwise a fork branch + # named 'dev' could satisfy 'staging <- dev' and skip the ladder. + if [ "$HEAD_REPO" != "$REPO" ]; then + echo "::error::PRs into '$BASE' must come from a branch in '$REPO', not a fork ('$HEAD_REPO')." + exit 1 + fi + + # Emergency bypass with the 'hotfix' label — permitted only into main. + if [ "$IS_HOTFIX" = "true" ]; then + if [ "$BASE" != "main" ]; then + echo "::error::The 'hotfix' label may only bypass the ladder on PRs into 'main', not '$BASE'." + exit 1 + fi + echo "::warning::'hotfix' label present — bypassing the promotion ladder for 'main'." + exit 0 + fi + + if [ "$HEAD" != "$expected" ]; then + echo "::error::Promotion ladder violation: '$BASE' only accepts merges from '$expected', but this PR is from '$HEAD'. Follow the ladder dev -> beta -> main, or add the 'hotfix' label (main only) to bypass." + exit 1 + fi + + echo "OK: '$BASE' is correctly receiving a promotion from '$expected'." diff --git a/.gitignore b/.gitignore index e036a9da..7944e244 100644 --- a/.gitignore +++ b/.gitignore @@ -106,9 +106,26 @@ TWIZRR_BACKEND_TASKS.md TWIZRR_WEB_TASKS.md TWIZRR_WHATSAPP_TASKS.md TWIZRR_CLEANUP_REPORT.md +TWIZRR_REALTIME_ARCHITECTURE.md +TWIZRR_REMAINING_BALANCE_COLLECTION_DESIGN.md +TWIZRR_SETTLEMENT_ROLLOUT_READINESS.md +TWIZRR_SHOPPER_WALLET_OVERPAYMENT_DESIGN.md +Twizrr_StorePass.md +TWIZRR_MONNIFY_PROVIDER_ASSESSMENT.md +TWIZRR_MERCHANT_EARNINGS_WALLET_DESIGN.md +TWIZRR_MEDIA_ARCHITECTURE.md +TWIZRR_MEDIA_STORAGE.md +TWIZRR_PAYMENT_EXCEPTION_SANDBOX_RUNBOOK.md +TWIZRR_PAYMENT_AMOUNT_EXCEPTION_DESIGN.md +TWIZRR_PLATFORM_FEE_ANALYSIS.md # Audit and report files *_AUDIT_REPORT.md *_CLEANUP_REPORT.md *_HANDOFF.md +# Neon CLI / agent skills tooling (local setup artifacts) +.neon +.agents/ +skills-lock.json + diff --git a/TWIZRR_MEDIA_ARCHITECTURE.md b/TWIZRR_MEDIA_ARCHITECTURE.md new file mode 100644 index 00000000..3364c710 --- /dev/null +++ b/TWIZRR_MEDIA_ARCHITECTURE.md @@ -0,0 +1,292 @@ +# Twizrr — Media Architecture + +How Twizrr stores, processes, protects, and serves media (images now; audio & +video later) as a **scalable, provider-agnostic layer** — not a single vendor +doing everything. + +> **Status:** architecture + roadmap (design intent + phased plan), not a +> command runbook. Media is Twizrr's core differentiator, so the *seams* are +> designed now even where the features come later. + +--- + +## 1. The principle everything hangs on + +> **Own the storage layer. Decouple storage → processing → delivery. Reference +> media by neutral IDs, never by a provider's URL.** + +Cloudinary today does *all three* (storage + transforms + CDN) and your code +stores its URLs + `publicId` directly on records. That's a lock-in: you can't +change any layer without a data migration. The fix is to put a **neutral Asset +record + a media resolver** between your app and whatever provider holds the +bytes. Then swapping a provider is a config change, not a rewrite — which is +exactly how large media platforms stay flexible for years. + +--- + +## 2. Target architecture + +```text + Client (web / app) + │ 1. ask backend for an upload ticket + ▼ + NestJS backend ──2. create MediaAsset(PENDING) + presigned/direct upload URL──┐ + │ │ + │ 3. client uploads BYTES DIRECTLY to storage (never through the backend) │ + ▼ ▼ + ┌────────────────── Storage / processing (by media kind) ──────────────────┐ + │ Images/Audio → Cloudflare R2 (object storage, no egress, S3-compatible) │ + │ Video → Mux (direct upload → encode → adaptive HLS) │ + └───────────────────────────────────────────────────────────────────────────┘ + │ 4. provider webhook → backend marks MediaAsset READY (+ dimensions/duration) + ▼ + Moderation pipeline (scan on upload → auto-decision → human review queue) + │ + ▼ + Delivery + Images → Cloudflare Images / imgix (variants at the edge) + Video → Mux playback (signed HLS) ── all behind Cloudflare CDN ──▶ users +``` + +**The app never holds media bytes and never hardcodes a provider URL.** It talks +to an Asset record and a resolver. + +--- + +## 3. The Asset model (system of record) + +One neutral table in Postgres is the source of truth. Providers are an +implementation detail stored *on* the record. + +```prisma +enum MediaKind { IMAGE VIDEO AUDIO } +enum MediaProvider { R2 MUX CLOUDINARY } // where the bytes live +enum MediaStatus { PENDING_UPLOAD UPLOADED PROCESSING READY FAILED DELETED } +enum MediaVisibility { PUBLIC RESTRICTED } // designed in from day one +enum ModerationState { PENDING APPROVED FLAGGED REJECTED } + +model MediaAsset { + id String @id @default(cuid()) + ownerId String // user/store that uploaded + kind MediaKind + provider MediaProvider + status MediaStatus @default(PENDING_UPLOAD) + visibility MediaVisibility @default(PUBLIC) + moderation ModerationState @default(PENDING) + + storageKey String? // R2 object key (images/audio) + + // Provider refs. Video needs MORE than one id, so don't collapse to a single + // string: Mux returns an upload_id (track the direct upload), an asset_id + // (management + webhook reconciliation), and a playback_id (signed streaming). + providerUploadId String? // Mux upload_id — reconcile the direct upload + providerAssetId String? // Mux asset_id / Cloudinary publicId — management + providerPlaybackId String? // Mux playback_id — video delivery/signing only + + mimeType String? + bytes BigInt? + width Int? + height Int? + durationMs Int? // video/audio + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([ownerId]) + @@index([status]) + @@index([moderation]) +} +``` + +Products/posts/etc. reference `MediaAsset.id` (via a join), **not** a URL. The +old `imageUrl` / `cloudinaryPublicId` columns become a compatibility shim during +migration (§9), then retire. + +### The media resolver +A single service builds delivery URLs so the rest of the app never knows the +provider. **It also enforces access** — signing a URL is *not* authorization: + +```text +resolveUrl(principal, asset, { size? }) → string + 1. authorize(principal, asset) // ownership / ACL check — REQUIRED, always, + // before any URL or token is built + 2. build the URL, signed when asset.visibility = RESTRICTED: + R2 image → Cloudflare Images/imgix variant (signed URL if RESTRICTED) + Mux video → HLS URL from providerPlaybackId (signed JWT if RESTRICTED) + Cloudinary→ legacy URL (during migration only) +``` + +> **Signing ≠ authorization.** `visibility = RESTRICTED` only selects the +> *delivery* mechanism (signed URL / Mux token). It does **not** decide whether +> *this* caller may see the asset. The resolver must take an **authenticated +> principal** and pass the ownership/ACL check **before** issuing anything — +> otherwise anyone who can guess/resolve an asset ID gets access. + +Change providers → change the resolver, not the callers. + +--- + +## 4. Upload flow — direct-to-storage (the scaling backbone) + +**Never buffer media through the backend.** Your current `multer memoryStorage()` +holds the whole file in the NestJS box's RAM — fine for small photos, fatal for +video. Replace it everywhere with **direct-to-storage**: + +1. Client → backend: "I want to upload a {image|video}, type X, size Y." +2. Backend: validate quota/permissions → create `MediaAsset(PENDING_UPLOAD)` → + return a **short-lived upload ticket**: + - **R2 (image/audio):** a presigned `PUT` URL with a **strict policy** — + enforced `content-type`, **max size**, short **expiry** (e.g. 5 min). + - **Mux (video):** a Mux **direct-upload URL** (Mux issues it; client uploads to Mux). +3. Client uploads the **bytes straight to R2/Mux** — bypassing your servers entirely. +4. Provider **webhook** → backend: mark `READY`, fill `width/height/durationMs`, + kick off moderation. + +This is the single most important design decision for scale — it makes uploads +independent of your compute, and it's mandatory before video/audio. + +> **Presign policy is security-critical:** without an enforced content-type + +> size cap + expiry, a presigned URL is an open door for arbitrary/huge uploads. + +--- + +## 5. Delivery & access model (designed for both, day one) + +Commerce media is public today, but a social app *will* have private/restricted +content. Bake the seam in now — it's cheap now, painful to retrofit: + +- **PUBLIC** assets: served straight from the CDN (Cloudflare in front), + cacheable, hotlink-protected at the edge. +- **RESTRICTED** assets: served via **signed URLs / tokens** with expiry: + - Images → Cloudflare Images / imgix **signed URLs**. + - Video → **Mux signed playback** (JWT playback tokens). +- The resolver decides signed-vs-public from `MediaAsset.visibility`, so callers + never think about it. Everything is fronted by Cloudflare for global caching + DDoS. +- **Authorization happens first, in the resolver** (§3) — a signed URL is only + the delivery mechanism for a request that has *already* passed the ownership/ACL + check. Never treat "it's a signed URL" as "the caller is allowed." + +--- + +## 6. Images — now + +- **Store** originals in **Cloudflare R2** (durable, S3-compatible, no egress). +- **Serve/transform** via **Cloudflare Images** (cohesive) or **imgix** + (best-in-class transforms) reading from R2 — resize/format/quality at the edge, + named variants (thumb / card / full). +- Moderation runs on the original at upload (§8). + +--- + +## 7. Video — later (lead: **Mux**) + +**Never DIY transcoding/streaming.** Use **Mux**: +- **Direct upload** → Mux encodes to **adaptive HLS** automatically. +- Store the Mux ids in their own fields — `providerUploadId`, `providerAssetId`, + `providerPlaybackId` — and set the direct-upload **`passthrough`** to the + `MediaAsset.id` so webhooks reconcile back cleanly. +- **Signed playback** (JWT, from `providerPlaybackId`) gives the RESTRICTED path + — but only *after* the resolver's ACL check (§3). +- Webhooks (`video.asset.ready`, errors) drive `status`; thumbnails/animated + previews come built-in; **Mux Data** gives QoE analytics. + +Mux's model *is* this architecture — direct upload, async ready-webhook, signed +delivery — so it drops into the Asset + resolver + moderation seams with no rework. + +**Alternative considered — Cloudflare Stream:** cohesive with the rest of the +Cloudflare stack and simpler pricing; weaker analytics/DX than Mux. Documented as +the fallback if single-vendor cohesion ever outweighs Mux's DX. + +--- + +## 8. Audio — later + +Simplest kind: store in **R2**, transcode to a web codec (AAC/Opus), serve via +CDN with the same public/signed rules. (Mux/Stream can also host audio if you +want unified processing — decide when the feature lands.) + +--- + +## 9. Moderation at scale (first-class, not a footnote) + +Media is the biggest win **and** the biggest responsibility. UGC images — and +*especially* video/audio — carry legal + safety obligations (abusive/illegal +content), and video/audio moderation is much harder than images. + +Pipeline, provider-agnostic: +1. **Scan on upload** (on the `READY` webhook, before the asset is publicly + resolvable): automated classification (nudity/violence/abuse; **known-CSAM + hash matching is mandatory** for UGC). `moderation = PENDING` blocks public delivery. +2. **Auto-decision:** clearly safe → `APPROVED`; clearly disallowed → `REJECTED` + (quarantine, never served); uncertain → `FLAGGED`. +3. **Human review queue** for `FLAGGED` — an admin surface to approve/reject. +4. The **resolver refuses** to serve anything not `APPROVED` when required. + +Keep this decoupled from the storage provider so it survives provider swaps. +Your existing `TWIZRR_CONTENT_MODERATION` logic is the starting point — this +extends it to run *on the pipeline* and to cover video/audio. + +--- + +## 10. Migration off Cloudinary (phased, no big-bang) + +1. **Introduce the seam.** Add `MediaAsset` + the resolver. New image uploads go + to **R2 + direct upload**; the resolver serves both new (R2) and legacy + (Cloudinary) assets. *No user-visible change.* +2. **Backfill.** Copy existing Cloudinary assets → R2, populate `MediaAsset`, + flip their resolver source. Cloudinary stays read-only as a safety net. +3. **Cut delivery** to Cloudflare Images/imgix; verify variants + signed URLs. +4. **Retire Cloudinary** once nothing resolves to it and a soak passes. +5. **Add video (Mux)** and **audio** as *new* asset kinds on the same seams — + plug-in, not rebuild. + +--- + +## 11. Build now vs. design now, build later + +**Build now (the seams — do these before/around launch):** +- `MediaAsset` model + resolver. +- **R2** bucket + **direct-to-storage uploads** (replace `memoryStorage`). +- Public/RESTRICTED visibility flag wired through the resolver. +- Moderation running on the upload pipeline for images. + +**Design now, build later (don't over-build):** +- **Video (Mux)** and **audio** — stand these up when the features are real, not + before. The seams above mean adding them is additive. +- Signed-playback, advanced variants, analytics — layer in with the features. + +> The goal is *not* to build video infra today — it's to make sure that when +> video lands, it's a new asset kind on an existing spine, not a re-architecture. + +--- + +## 12. Provider matrix / decision log + +| Concern | Choice | Alternative | Why | +| --- | --- | --- | --- | +| Object storage | **Cloudflare R2** | S3 / B2 | No egress fees; S3-compatible; already all-in on Cloudflare | +| Image delivery/transform | **Cloudflare Images** or **imgix** | Cloudinary | Decoupled from storage; edge variants | +| **Video** | **Mux** | Cloudflare Stream | Best DX/analytics; direct-upload + signed playback fit the seams | +| Audio | R2 + CDN | Mux/Stream | Simple; unify later if useful | +| System of record | **`MediaAsset` in Postgres/Neon** | provider URLs | Provider-agnostic; swappable | +| Uploads | **Direct-to-storage (presigned/Mux direct)** | backend proxy | Scales; mandatory for video | +| Moderation | **Own pipeline** on the upload flow | provider-only | Survives provider swaps; legal obligation | + +--- + +## 13. Relation to the rest of the system + +- **VPS:** none — media never touches the box, on any host. The box stays stateless. +- **Neon:** holds only the **`MediaAsset` metadata**, never bytes. +- **Cloudflare:** fronts all delivery (CDN/DDoS) and provides R2 + Images. +- **Cost:** a secondary concern per current priorities — chosen for performance + and longevity; R2's no-egress model keeps it sane at scale anyway. + +--- + +## 14. Open decisions to confirm + +- Image delivery: **Cloudflare Images vs imgix** (pick when building §6). +- Exact moderation vendor(s) for automated scanning + CSAM hash matching. +- Whether audio unifies onto Mux or stays R2 + a transcode step. +- Presign policy specifics (max sizes per kind, expiry windows, allowed types). diff --git a/TWIZRR_SELF_HOST_DEPLOYMENT.md b/TWIZRR_SELF_HOST_DEPLOYMENT.md new file mode 100644 index 00000000..f90f2558 --- /dev/null +++ b/TWIZRR_SELF_HOST_DEPLOYMENT.md @@ -0,0 +1,443 @@ +# Twizrr — Self-Host Deployment Runbook (Openship) + +How Twizrr runs on a self-hosted VPS using **Openship** as the control plane, +with **Cloudflare** in front and the database staying on **managed Neon**. + +> This is an operational runbook — follow it top to bottom. Commands that touch +> Openship internals are marked _(confirm against openship.io/docs)_ because +> those docs were being filled out at time of writing. Everything else is +> verified. + +--- + +## 1. Architecture + +```text + Users (Nigeria & beyond) + │ + Cloudflare ── DNS + CDN + DDoS + WAF (proxied, Full-strict TLS) + │ (origin reachable ONLY from Cloudflare IPs) + ┌────────────┴─────────────────────────────┐ + │ One VPS (Contabo VPS 8 / Hetzner CX43) │ + │ │ + │ Openship control plane + OpenResty edge │ :80/:443 (firewalled to CF) + │ ├─ web (Next.js, apps/web) │ loopback only + │ ├─ app (Next.js, apps/web) │ loopback only + │ ├─ admin (Next.js, apps/web) │ loopback only + │ ├─ backend (NestJS, apps/backend) │ loopback only + │ └─ redis (BullMQ + Socket.IO adapter) │ loopback only + └────────────────────┬───────────────────────┘ + │ + Neon (managed Postgres) ← never on the box +``` + +**Why this shape:** Openship's edge does routing + TLS; Cloudflare in front adds +CDN/DDoS/WAF and lets us lock the origin down. Neon stays managed so the +money-critical data + backups are never our responsibility on the box. + +--- + +## 2. Decisions & guardrails (locked) + +| Decision | Choice | +| --- | --- | +| Control plane | **Openship** (self-hosted, pinned release — never `main`) | +| Database | **Neon** (managed) — the box only runs **stateless** app containers | +| Ingress | **Openship edge + Cloudflare proxied**, origin firewalled to Cloudflare IPs | +| Payments at launch | **Paystack only** — `MONNIFY_*_ENABLED=false` | +| Rollout | **Beta first** (disposable rehearsal box), then a **separate dedicated** production box | +| Environments on the box | **Beta only** to start; prod gets its **own** box once beta proves the stack | + +**The three guardrails that make a young platform safe:** +1. **DB on Neon** — if Openship ever breaks, no customer data was on it. +2. **Pin Openship to a tagged release** (`OPENSHIP_VERSION`), not `main`. +3. **Stand up `beta` first** — a **disposable** infra rehearsal (Paystack **test** + keys, throwaway data) — and run real flows (auth, test checkout, feed, product + pages) before production exists on its own box. + +**Environment model (post-staging-drop):** + +| Tier | Who | Where | Data | +| --- | --- | --- | --- | +| **dev + per-PR previews** | Team — integration & final QA (this replaces staging) | Vercel previews / ephemeral Neon | throwaway | +| **beta** | Selected testers — infra rehearsal of the self-host stack | The VPS (this box) | **disposable**, Paystack test | +| **main / prod** | Real users; early access via **allowlist** | A **separate dedicated** box (later) | real, Paystack live | + +**No beta→prod data migration.** Beta and prod are separate Neon databases and +nothing crosses automatically; beta runs sandbox payments, so its financial state +can never become real money. Real early-access users therefore live on **prod +behind an allowlist** from day one — "exiting beta" just opens the gate, and no +data is ever migrated. The beta box stays a disposable rehearsal. + +--- + +## 3. The spec & provider + +Sized from the app's real needs (Next.js build spike ~2–2.5 GB **on top of** +runtime; 3 frontend instances; `socket.io` WebSocket growth): + +| Target | Spec | Provider option | ~Cost/mo | +| --- | --- | --- | --- | +| **Beta rehearsal (current pick)** | **8 vCPU / 24 GB / 300 GB SSD** | **Contabo Cloud VPS 8** (EU) | ~€14 **month-to-month** (no prepay) | +| Recommended for prod (consistent) | 8 vCPU / 16 GB / 160 GB NVMe | **Hetzner CX43** (EU) | ~€16 incl. IPv4 (~$17) | +| Budget floor | 4 vCPU / 8 GB / 80 GB NVMe | Hetzner CX33 | ~€8 | + +- **Two-box end state:** the current box runs **beta only** (rehearsal); when the + stack is proven, **prod gets its own dedicated box** — either promote this box + to prod-only (fresh OS reinstall) and move beta to a cheap box, or buy a fresh + prod box. Never run prod cohabiting with beta long-term (blast radius, noisy + neighbor, wider attack surface). +- **Contabo notes:** cheap because oversubscribed → variable vCPU/disk performance + and slow provisioning/support. Fine for **beta**. Order **month-to-month, 1 + IPv4** — the beta box needs only **one origin IP**; all its surfaces + (`beta`, `app.beta`, `admin.beta`, `api.beta`) sit behind that single IP via the + edge's host-based routing. (The separate **prod** box later gets its **own** + origin IP.) **Do not prepay 24 months** on a box you may rebuild. If prod later + wants consistent performance, Hetzner is the pick — but then Contabo's numbers + won't perfectly predict prod's. +- **Region:** EU (no budget provider has African DCs; Cloudflare edge serves + static close to Nigerian users; API latency is EU-ish, which is fine). +- **Add the provider's automated snapshots / Auto Backup (~€1–5/mo)** — the box is + a single point of failure; snapshots + Neon data + Openship config = fast + rebuild. (Data itself lives on Neon; box backups only protect config/state.) + +--- + +## 4. Phase 0 — Prerequisites + +- [ ] VPS purchased (spec above), **Ubuntu 24.04 LTS**, SSH key added (no password login). +- [ ] Cloudflare zone `twizrr.com` active (nameservers already moved — see the + Cloudflare setup done separately). +- [ ] Neon connection strings ready for **beta** (`ep-winter-hall`) and later + **production** (`ep-morning-mountain`). Pull with + `npx neonctl connection-string --project-id cold-star-22806085`. +- [ ] A pinned Openship version chosen (latest tagged release, e.g. `v0.3.0`). + +--- + +## 5. Phase 1 — Provision the VPS + base hardening + +SSH in as root, then: + +```bash +# --- system --- +apt update && apt -y upgrade +timedatectl set-timezone UTC + +# --- a non-root sudo user --- +adduser --disabled-password --gecos "" deploy +usermod -aG sudo deploy +rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy + +# --- swap (protects against build OOM spikes) --- +fallocate -l 4G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile +echo '/swapfile none swap sw 0 0' >> /etc/fstab + +# --- Docker (Openship Compose mode needs it) --- +curl -fsSL https://get.docker.com | sh +usermod -aG docker deploy + +# --- firewall: SSH now; :80/:443 get locked to Cloudflare in Phase 3 --- +apt -y install ufw +ufw default deny incoming +ufw default allow outgoing +ufw allow OpenSSH +ufw --force enable + +# --- raise file-descriptor limits for WebSocket concurrency (socket.io) --- +cat >> /etc/security/limits.conf <<'EOF' +* soft nofile 65535 +* hard nofile 65535 +EOF +``` + +> **`limits.conf` only affects login shells — not Docker containers.** The +> WebSocket-concurrency safeguard is only real if the limit also reaches the +> **backend container**: set `nofile` on the backend service's runtime (Compose +> `ulimits: nofile: 65535`, or the Docker daemon default-ulimit), then verify +> inside the container with `cat /proc/1/limits | grep "open files"`. +> _(Set this via Openship's service config — confirm against openship.io/docs.)_ + +Harden SSH in `/etc/ssh/sshd_config`: `PermitRootLogin no`, `PasswordAuthentication no`, then **validate before restarting so a typo can't lock you out**: + +```bash +sshd -t && systemctl restart ssh # -t aborts the restart if the config is invalid +``` + +Keep your current SSH session open and confirm you can open a **second** session before closing the first. (Optional: `apt install fail2ban`.) + +--- + +## 6. Phase 2 — Install Openship (pinned) + +As the `deploy` user. Openship on Linux+Docker runs in **Compose mode** — it +brings up Postgres, Redis, API, dashboard, and the OpenResty edge, and hosts +your apps on the same box. + +> **That bundled Postgres is Openship's own control-plane state — not your app +> database.** Twizrr's data always lives on **managed Neon** via the backend's +> `DATABASE_URL`/`DIRECT_URL` (§8.4). Never point the app at the local Postgres. +> Likewise, the bundled Redis here is Openship's; the app's Redis is a separate +> internal service (§8.3). And the OpenResty edge stays reachable only through +> the Cloudflare allowlist (Phase 3). + +```bash +curl -fsSL https://get.openship.io | sh # installs the CLI (latest release) +openship up --public-url https://ops.twizrr.com # start as a boot service; serve dashboard on a subdomain +``` + +- `openship up` picks **Compose mode** automatically on Linux+Docker (force with `--compose`). +- The wizard creates the first **admin login** — store it in your password manager. +- **Pin the version** for reproducible upgrades: set `OPENSHIP_VERSION=v0.3.0` in + the Openship `.env` and upgrade only with `openship update` _(confirm exact + pin mechanism against openship.io/docs)_. +- Do **not** use `get.openship.io/dev` — that's the from-source build, explicitly + "not a production path." +- Add a Cloudflare DNS record `ops.twizrr.com → ` (proxied) so the + dashboard is reachable; it's login-gated. + +--- + +## 7. Phase 3 — Cloudflare in front + lock the origin + +Openship's edge terminates TLS with Let's Encrypt on `:80/:443`. Put Cloudflare +in front and restrict the origin to Cloudflare only. + +1. **DNS (Cloudflare dashboard):** point the app hostnames at the VPS IP, **proxied (orange)**: + - `twizrr.com` → A → `` + - `www`, `app`, `admin`, `api`, `ops` → A (or CNAME to apex) → `` + - (These replace the old Vercel/Railway targets **at go-live**, not before — see Phase 8.) +2. **SSL/TLS → Overview → Full (strict).** Openship's LE cert makes this valid. +3. **Lock `:80/:443` to Cloudflare IPs** so no one can hit the origin directly. + Openship's edge runs `network_mode: host`, so it binds the host's ports and + **UFW rules apply to it** — this allowlist is effective for the edge: + +```bash +# allow HTTP/HTTPS only from Cloudflare's published ranges +for ip in $(curl -s https://www.cloudflare.com/ips-v4); do ufw allow from $ip to any port 80,443 proto tcp; done +for ip in $(curl -s https://www.cloudflare.com/ips-v6); do ufw allow from $ip to any port 80,443 proto tcp; done +``` + +> **⚠️ Docker publishes ports *around* UFW.** A container started with a +> published port (`-p 6379:6379`, `ports:` in Compose) inserts `DOCKER` iptables +> rules that **bypass UFW's INPUT chain** — so a UFW "deny" will *not* protect a +> Docker-published port. Two rules follow from this: +> - **Never publish Redis/Postgres** (`6379`/`5432`). Keep them on Openship's +> **internal Docker network** (loopback only) — the app reaches Redis by +> service name, never a host port. (This is why our edge uses host-networking +> and everything else stays internal.) +> - If you *ever must* publish a container port publicly, filter it in the +> **`DOCKER-USER`** iptables chain (or the `ufw-docker` helper), not plain UFW. + +4. **Verify the lock from an external machine** (not the VPS): + +```bash +curl -I --connect-to twizrr.com:443::443 https://twizrr.com # via a non-CF IP → should hang/refuse +nc -vz -w3 6379 # Redis → must be "refused/filtered" +``` + +> **Let's Encrypt + proxied Cloudflare:** Openship uses HTTP-01, which needs :80 +> reachable during issuance. Cloudflare proxied + the allowlist above still let +> CF→origin reach :80, so issuance works. If a cert ever fails to issue, +> temporarily set that hostname to **DNS-only (grey)**, let Openship issue, then +> re-proxy. _(Confirm Openship's cert flow against openship.io/docs.)_ + +--- + +## 8. Phase 4 — Prepare the apps + +### 8.1 One low-risk code change: Next.js standalone output +In `apps/web/next.config.*` add: + +```js +const nextConfig = { + output: "standalone", // smaller image, lower RAM per instance + // ...existing config +}; +``` + +### 8.2 Frontend layout (start simple) +Keep the **current 3 instances** (web / app / admin) for launch — they're +isolated and the 16 GB box has room. Each is the **same repo** (`apps/web`) with +different `NEXT_PUBLIC_*` flags. Future optimization (documented, not for launch): +**consolidate store+app into one runtime by hostname, keep admin separate.** + +### 8.3 Define the services in Openship +Point Openship at the GitHub repo `coded-devs/twizrr` and create **four apps** +(monorepo-aware — it rebuilds only what a push touches). Use an `openship.json` +per app or the dashboard to set the subdirectory, build/start, and port +_(confirm schema against openship.io/docs)_: + +| Openship app | Path | Build / Start | Port | Domain | +| --- | --- | --- | --- | --- | +| `twizrr-web` | `apps/web` | `pnpm build` / `pnpm start` | 3000 | twizrr.com, www | +| `twizrr-app` | `apps/web` | `pnpm build` / `pnpm start` | 3000 | app.twizrr.com | +| `twizrr-admin` | `apps/web` | `pnpm build` / `pnpm start` | 3000 | admin.twizrr.com | +| `twizrr-backend` | `apps/backend` | `pnpm build` / `pnpm start:prod` | 4000 | api.twizrr.com | + +Add a **Redis** service (Openship-managed) for the backend — it's cache + BullMQ +queues + the Socket.IO adapter, not source-of-truth. Enable **AOF persistence** +so queued jobs survive a restart. **Keep it on the internal Docker network only +— never publish `6379` to the host** (see the Docker/UFW warning in Phase 3); the +backend reaches it by service name via `REDIS_URL`. + +### 8.4 Environment variables (per app) +Set these in each Openship app (secrets stay in Openship, never in git). + +**Environment-specific values differ between beta and production** — do not +reuse production domains/keys on beta or the beta smoke test (Phase 5) +will fail (CORS blocks, live charges). Use this matrix for the vars that change: + +| Var | Beta | Production | +| --- | --- | --- | +| `DATABASE_URL` / `DIRECT_URL` | Neon **beta** (`ep-winter-hall`) | Neon **production** (`ep-morning-mountain`) | +| `CORS_ORIGINS` | `https://beta.twizrr.com,https://app.beta.twizrr.com,https://admin.beta.twizrr.com` | `https://twizrr.com,https://app.twizrr.com,https://admin.twizrr.com` | +| `WEB_URL` / `APP_URL` / `FRONTEND_URL` | `https://beta.twizrr.com` / `https://app.beta.twizrr.com` (each var → its surface's full origin) | `https://twizrr.com` / `https://app.twizrr.com` | +| `AUTH_COOKIE_DOMAIN` | `beta.twizrr.com` | `twizrr.com` | +| `PAYSTACK_*` / `NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY` | **test** keys | **live** keys | +| `NEXT_PUBLIC_API_URL` | `https://api.beta.twizrr.com` | `https://api.twizrr.com` | + +**Backend (`twizrr-backend`)** — everything above, plus (same on both envs): +- `REDIS_URL` → the internal Openship Redis service +- `NODE_ENV=production`, `PORT=4000`, `PAYSTACK_WEBHOOK_SECRET`, **`MONNIFY_*_ENABLED=false`** +- `JWT_*`, `CLOUDINARY_*`, `GOOGLE_OAUTH_REDIRECT_URL`, `SOCKET_IO_REDIS_ADAPTER_ENABLED` + +**Frontend (`twizrr-web` / `-app` / `-admin`)** — API URL + Paystack key per the +matrix, plus the surface flags that differ per app: +- `twizrr-web`: `NEXT_PUBLIC_MARKETPLACE_ENABLED=true` +- `twizrr-app`: app-surface flags +- `twizrr-admin`: `NEXT_PUBLIC_ADMIN_ENABLED=true` — **must NOT enable the marketplace surface** (avoids the admin redirect loop) + +--- + +## 9. Phase 5 — Deploy on BETA first + +1. Point the backend's `DATABASE_URL`/`DIRECT_URL` at the **beta Neon branch** (`ep-winter-hall`). +2. Track the **`beta`** git branch in each Openship app (push-to-deploy). +3. Deploy: + +```bash +cd twizrr # your local checkout +openship init # link directory → project (confirm per-app flow in docs) +openship deploy +``` + +4. Attach **beta domains** in Cloudflare (proxied): `beta.twizrr.com`, + `app.beta.twizrr.com`, `admin.beta.twizrr.com`, `api.beta.twizrr.com` → VPS IP. +5. **Smoke test on beta:** + - [ ] `api.beta.twizrr.com/health` OK (Redis reachable) + - [ ] Create/edit a product; publish + post-to-feed + - [ ] Auth (login/signup), feed renders, product detail renders + - [ ] Paystack **test** checkout end-to-end + - [ ] WebSocket/live updates work through Cloudflare (proxied) + +--- + +## 10. Phase 6 — Production go-live + +Only after beta is signed off. **Production runs on its own dedicated box** (§3 +two-box end state) — provision it and run Phases 1–3 on it, or promote the beta +box to prod-only via a fresh OS reinstall (moving beta to a cheap box first). + +1. **Neon:** confirm the production branch (`ep-morning-mountain`) is migrated to + the current schema and seeded (reference data + `bootstrap:admin`). +2. **Backend env → production Neon**; confirm **Paystack live** keys + + `MONNIFY_*_ENABLED=false`; Paystack dashboard webhook → `api.twizrr.com`. +3. Track the **`main`** branch in the production Openship apps; deploy. +4. **Early access via allowlist, not migration.** Real selected users sign up + **directly on prod** behind an allowlist/invite flag — their data is prod data + from day one. **Nothing migrates from beta** (separate DB, sandbox payments). + "Exiting beta" = opening the gate; there is no cutover of user data. +5. **Cut the domains:** in Cloudflare, repoint `twizrr.com`, `www`, `app`, + `admin`, `api` from the old Vercel/Railway targets to the **prod VPS IP** + (proxied). Give `dev` its own host (e.g. `dev.twizrr.com`, team-gated) so it no + longer squats on the apex. +6. **Verify:** `/health`, all three surfaces, a real login, a live checkout. +7. Keep the old Vercel/Railway deploys **parked** (not deleted) for the soak window. + +--- + +## 11. Security & ops reference + +- **Origin:** `:80/:443` firewalled to Cloudflare IPs; everything else loopback-only. SSH key-only, root login off. +- **Redis:** bound to loopback / internal Docker network only — never public. AOF on. +- **Secrets:** in Openship (and GitHub/Neon) — never committed. +- **Backups:** Neon = automatic PITR (DB). VPS = provider snapshots (config/state). +- **Updates:** `openship update` on a pinned version; reboot for kernel patches during low traffic. +- **Monitoring:** enable Openship logs; add uptime checks on `/health` and the three surfaces. + +--- + +## 12. Promotion pipeline mapping + +The ladder is now `dev → beta → main` (staging dropped). The deployer swaps from +Vercel/Railway to Openship: + +| Git branch | Neon branch | Openship target | +| --- | --- | --- | +| `dev` | dev (`ep-round-silence`) | Vercel previews / local (team) | +| `beta` | beta (`ep-winter-hall`) | **beta apps on this VPS** (disposable rehearsal) | +| `main` | production (`ep-morning-mountain`) | **production apps on a dedicated box** (later) | + +> **`staging` is retired.** Its git branch and Neon branch (`ep-mute-glitter`) go +> dormant — keep them parked (Neon scales to zero) or delete once you're sure. +> Per-PR preview envs + `dev` cover pre-beta testing. + +Each Openship app tracks one branch; a push re-runs the pipeline for that +environment. The GitHub **promotion-guard** workflow enforces the `dev → beta → +main` ladder. + +--- + +## 13. Rollback + +**Bad app deploy (no data corruption)** — the common case: +1. Roll back to the previous release in Openship (dashboard/CLI) — _(confirm the + exact rollback command against openship.io/docs)_. +2. Verify `/health` + a real login. Done — no DB action needed. + +**Data-affecting incident (bad migration / corruption)** — order matters: +1. **Freeze writes.** Stop the backend app in Openship (or flip it to a + maintenance/read-only mode) so nothing writes during recovery. +2. **Neon PITR.** Restore the **production** branch to a timestamp just before + the incident — via the Neon console (Branches → production → Restore) or + `neonctl branches restore production @ --project-id cold-star-22806085`. + Prefer restoring **into a new branch** first so the current state is preserved. +3. **Repoint + reconnect.** If you restored into a new branch, update the + backend's `DATABASE_URL`/`DIRECT_URL` to it and redeploy. +4. **Schema-compatibility check.** Confirm the running app's Prisma schema + matches the restored DB (`prisma migrate status`) **before** taking traffic — + a restore to an older point may predate migrations the code expects. +5. **Unfreeze**, verify, resume traffic. + +**Whole box down:** restore the latest VPS snapshot, or provision a fresh box and +re-run Phases 1–2 + reconnect the repo — **data is safe on Neon**, so this is a +compute rebuild, not a data recovery. + +**Full bail-out (during the launch soak only):** the old Vercel/Railway deploys +stay parked; repoint Cloudflare DNS back to them. Note their DB pointer — if +they still target the old dev DB, only do this before real production data +accumulates on the new stack. + +--- + +## 14. Growth / upgrade path + +- **First limits you'll hit:** WebSocket concurrency (RAM + FDs — already raised) + and build-vs-runtime contention. +- **Vertical:** bump the VPS (CX43 → CX53: 16 vCPU / 32 GB) — a resize + reboot. +- **Then split concerns:** dedicated build, or move Redis to managed (Upstash), + or run frontend and backend on separate boxes. +- **Consolidate frontends** (store+app one runtime, admin separate) to reclaim RAM. +- **HA (multi-node)** is a later problem — not needed at launch. + +--- + +## 15. To confirm against openship.io/docs + +The docs were being filled out at time of writing. Confirm before relying on: +- Exact `openship.json` schema (monorepo subdir, build/start, port, per-app env). +- Per-branch / multi-environment mapping (beta vs prod apps). +- Version pinning mechanism (`OPENSHIP_VERSION`) and upgrade/rollback commands. +- Certificate issuance flow with Cloudflare proxied (HTTP-01 timing). +- Whether to use Openship-managed Redis vs an external managed Redis. diff --git a/TWIZRR_WHATSAPP_AI_CONTRACT.md b/TWIZRR_WHATSAPP_AI_CONTRACT.md index 2481c1a9..5abfcdd4 100644 --- a/TWIZRR_WHATSAPP_AI_CONTRACT.md +++ b/TWIZRR_WHATSAPP_AI_CONTRACT.md @@ -85,24 +85,28 @@ Current implementation notes: ## Capability Matrix -| Capability | Guest After Consent | Requires Active WhatsAppLink | Current Status | Backend Rule | -|---|---:|---:|---|---| -| Assistant identity and safe Twizrr help | Yes | No | Partial | Gemini may classify, backend validates safe answer text. | -| Product text search | Yes | No | Implemented | Hybrid-ranked discovery after consent; must not force account linking. | -| Product-like phrases such as "I want to buy iPhone 15" | Yes | No | Implemented guard | Treat as product discovery unless the shopper asks for account-specific action. | -| Image search | Yes | No | Implemented (embedding-first) | Inbound images are SafeSearch-screened before any discovery; clearly unsafe images get a safe refusal. Vertex multimodal image embedding is the primary signal; labels are context/fallback. | -| Browse categories or search again | Yes | No | Implemented | Category lists paginate within Meta's row limit; browse/search-again actions clear stale selection state, and text follow-up enters an explicit pending-search state. | -| Product detail selection | Yes | No | Implemented | Resolves only against Redis-cached recently shown results (list row, number/ordinal, or title); stale selections get a search-again message. | -| Cart, wishlist, saved products | No | Yes | Placeholder/gated | Deterministic link-required response until handlers exist. | -| Checkout and payment | No | Yes | Placeholder/gated | Must never create payment/order state from Gemini text alone. | -| Order status or tracking | No | Yes | Placeholder/gated | Must require active link and order ownership verification. | -| Delivery confirmation | No | Yes | Placeholder/gated | Must require active link, order ownership, and valid delivery code. | -| Disputes, refunds, receipts | No | Yes | Gap/target | Deterministic response until domain-backed handlers exist. | -| Account linking help | Yes | No | Partial | Explain linking safely; verified link creation must use explicit OTP flow. | -| Support handoff | Yes | No | Deterministic | Must not claim live human handoff unless implemented. | -| Store management | No | No | Must refuse | Send the exact store-management refusal copy. | -| Payouts, KYB, admin actions | No | No | Must refuse | These are web/dashboard/admin flows, not WIZZA flows. | -| Out-of-scope non-shopping requests | No | No | Must redirect | Keep response short and shopping-focused. | +| Capability | Guest After Consent | Requires Active WhatsAppLink | Current Status | Backend Rule | +| ------------------------------------------------------ | ------------------: | ---------------------------: | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Assistant identity and safe Twizrr help | Yes | No | Partial | Gemini may classify, backend validates safe answer text. | +| Product text search | Yes | No | Implemented | Hybrid-ranked discovery after consent; must not force account linking. | +| Product-like phrases such as "I want to buy iPhone 15" | Yes | No | Implemented guard | Treat as product discovery unless the shopper asks for account-specific action. | +| Image search | Yes | No | Implemented (embedding-first) | Inbound images are SafeSearch-screened before any discovery; clearly unsafe images get a safe refusal. Vertex multimodal image embedding is the primary signal; labels are context/fallback. | +| Browse categories or search again | Yes | No | Implemented | Category lists paginate within Meta's row limit; browse/search-again actions clear stale selection state, and text follow-up enters an explicit pending-search state. | +| Product detail selection | Yes | No | Implemented | Resolves only against Redis-cached recently shown results (list row, number/ordinal, or title); stale selections get a search-again message. | +| Cart | No | Yes | Implemented | Returns a bounded, shopper-safe cart summary and confirmation-protected mutations for the linked user. | +| Saved delivery addresses | No | Yes | Implemented read | Returns bounded address labels and location text for the linked user; IDs and phone numbers are not exposed. | +| Wishlist and saved products | No | Yes | Placeholder/gated | Deterministic link-required response until handlers exist. | +| Checkout handoff | No | Yes | Implemented | Validates and summarizes the live cart, requires explicit confirmation, then returns the configured web `/cart` URL without creating an order or payment. | +| Payment | No | Yes | Web only | Payment remains on the protected Twizrr web checkout path. | +| Order list and order status | No | Yes | Implemented read | Requires an active link and scopes public order references to the linked buyer. | +| Delivery status | No | Yes | Implemented read | Requires an active link and reads tracking only after linked-buyer ownership is established. | +| Delivery confirmation | No | Yes | Implemented write | Requires an active link, linked-buyer ownership, explicit eligible-order selection, and a valid delivery code in a short-lived scoped flow. | +| Disputes, refunds, receipts | No | Yes | Gap/target | Deterministic response until domain-backed handlers exist. | +| Account linking help | Yes | No | Partial | Explain linking safely; verified link creation must use explicit OTP flow. | +| Support handoff | Yes | No | Deterministic | Must not claim live human handoff unless implemented. | +| Store management | No | No | Must refuse | Send the exact store-management refusal copy. | +| Payouts, KYB, admin actions | No | No | Must refuse | These are web/dashboard/admin flows, not WIZZA flows. | +| Out-of-scope non-shopping requests | No | No | Must redirect | Keep response short and shopping-focused. | --- @@ -115,7 +119,12 @@ Every declared Gemini function must have a corresponding backend handler or dete Current declared function families: - `search_products` +- `get_cart` +- `start_checkout` +- `get_saved_addresses` +- `list_orders` - `get_order_status` +- `get_delivery_status` - `confirm_delivery` - `show_menu` - `answer_twizrr_question` @@ -127,7 +136,11 @@ Function contract rules: - `answer_twizrr_question` is guest-safe only for bounded Twizrr shopping help. - `show_menu` is guest-safe after consent. - `support_handoff` is guest-safe only as deterministic support guidance. -- `get_order_status`, `track_order`, `confirm_delivery`, checkout, payment, cart, wishlist, receipt, refund, and dispute actions require an active verified `WhatsAppLink`. +- `get_cart`, `get_saved_addresses`, `list_orders`, `get_order_status`, and `get_delivery_status` are read-only tools that require an active verified `WhatsAppLink`. +- `start_checkout` requires an active verified `WhatsAppLink`, validates the live persisted cart, requires explicit confirmation, revalidates the cart, and returns only the configured secure web `/cart` handoff. +- The checkout handoff does not create an order, initialize payment, expose internal identifiers, or transfer authentication/session state through the URL. +- `confirm_delivery` requires an active verified `WhatsAppLink`. It lists only eligible orders owned by the linked buyer, scopes order selection and delivery-code entry in Redis, and delegates confirmation to the canonical order service. +- Payment, wishlist, receipt, refund, and dispute actions require an active verified `WhatsAppLink` and remain deterministic until domain-backed handlers exist. - Gemini must not answer with product listings, order facts, payment facts, delivery facts, refund promises, or account-specific details directly. - Gemini must not invent unsupported Twizrr policies, support availability, delivery timelines, prices, stock, store ratings, or payment status. - Function names, descriptions, and backend switch handlers must be kept in sync in the same PR whenever changed. @@ -346,12 +359,14 @@ Docs-only PRs do not need backend build validation unless hooks require it, but These remain open and must not be documented as complete: -- domain-backed checkout, cart, wishlist, receipt, refund, dispute, order tracking, and delivery confirmation handlers +- domain-backed payment, wishlist, receipt, refund, and dispute handlers - live human support handoff or support-ticket creation from WhatsApp - comprehensive logging/privacy review across all remaining WhatsApp paths Completed since this contract was first written (now reflected above): +- linked-buyer delivery confirmation with explicit order selection, scoped delivery-code handling, bounded invalid attempts, and canonical order-state delegation + - SafeSearch screening of inbound shopper images before image discovery - durable `WhatsAppConsentLog` audit model and Redis/live session split - Redis-backed product result selection for rows, numbers, and titles @@ -364,3 +379,5 @@ Completed since this contract was first written (now reflected above): - batched webhook intake, message-ID deduplication, per-phone rate limiting, and retriable queue failures - bounded outbound notification retries and corrected event/job boundaries - category browsing, search-again, image-to-text follow-up, and stale selection-state handling +- linked cart reads and explicitly confirmed cart mutations +- live-cart checkout validation, one-time confirmation, and secure web `/cart` handoff diff --git a/apps/backend/src/channels/whatsapp/AGENTS.md b/apps/backend/src/channels/whatsapp/AGENTS.md index 5cf36694..9407308b 100644 --- a/apps/backend/src/channels/whatsapp/AGENTS.md +++ b/apps/backend/src/channels/whatsapp/AGENTS.md @@ -1,4 +1,5 @@ # twizrr — WhatsApp Channel AI Assistant Instructions + # apps/backend/src/channels/whatsapp/AGENTS.md > Read root AGENTS.md first, then apps/backend/AGENTS.md, then this file. @@ -216,16 +217,30 @@ TWIZRR_WHATSAPP_AI_CONTRACT.md — every declared function must have a matching backend handler, kept in sync in the same PR): search_products — guest-safe after consent (text product discovery) +get_cart — linked shopper read; bounded cart summary +start_checkout — linked shopper confirmation; validates the live cart, + then hands off to secure web checkout +get_saved_addresses — linked shopper read; safe address labels only +list_orders — linked shopper read; recent public order references get_order_status — requires active verified WhatsAppLink -confirm_delivery — requires active verified WhatsAppLink +get_delivery_status — linked shopper read; ownership checked before tracking +confirm_delivery — linked shopper write; explicit order selection and scoped delivery-code state show_menu — guest-safe after consent answer_twizrr_question — bounded Twizrr shopping help only support_handoff — deterministic support guidance INTENT ROUTING: Text "I want red sneakers under 30k" → search_products (product discovery) +Text "What's in my cart?" → get_cart (link-gated) +Text "Proceed to checkout" → start_checkout (link-gated + confirmation) +Text "Show my saved addresses" → get_saved_addresses (link-gated) +Text "Show my recent orders" → list_orders (link-gated) Text "What's my order status?" → get_order_status (link-gated) -Text "My order arrived, here's the code: 482930" → confirm_delivery (link-gated) +Text "Where is order TWZ-123456?" → get_delivery_status (link-gated) +Text "Confirm delivery for order TWZ-123456" → confirm_delivery (link-gated) +Text "482930" after WIZZA selects that order and requests its delivery code → + handled by the scoped Redis confirmation flow BEFORE Gemini is called +Text "482930" outside that active flow → normal message; never globally intercepted Text "2" / "the first one" / "that iPhone" after results → resolved from Redis recent results BEFORE Gemini is called Text about store management → exact refusal copy: @@ -658,10 +673,14 @@ VERTEX AI FAILURE (image search): do not replace that result with label-keyword products. Labels remain context/fallback input, not a second result authority. -PAYSTACK CTA LINK FAILURE: -└── If checkout link generation fails: - "I could not generate your checkout link right now. - Please visit twizrr.com to complete your purchase." +SECURE CHECKOUT HANDOFF: +└── WIZZA reads and validates the linked shopper's live persisted cart, + summarizes it, and requires explicit confirmation. On confirmation it + re-reads the cart and returns the configured WEB_URL `/cart` route. + WIZZA never creates an order, initializes payment, or transfers auth, + payment, cart-item, or provider identifiers through the URL. +└── If the cart changed, is empty, or WEB_URL is unavailable, return a safe + retry/review message and do not generate a link. META API FAILURE (sending message): └── Meta client throws to WhatsAppProcessor @@ -672,7 +691,7 @@ META API FAILURE (sending message): Never catch and mark a failed outbound delivery as successful ZERO RESULTS: -└── Always respond with: "I could not find [query] right now. +└── Always respond with: "I could not find [query] right now. Try a different search or visit twizrr.com to browse all products." Never: silence, error messages, or raw technical output @@ -715,6 +734,6 @@ IF ANYTHING IS UNCLEAR: stop and ask. Never guess. --- -*Last updated: June 2026* -*Project: twizrr — channels/whatsapp* -*Maintained by: CODEDDEVS TECHNOLOGY LTD* +_Last updated: June 2026_ +_Project: twizrr — channels/whatsapp_ +_Maintained by: CODEDDEVS TECHNOLOGY LTD_ diff --git a/apps/backend/src/channels/whatsapp/agent/shopper-agent-discovery-context.types.ts b/apps/backend/src/channels/whatsapp/agent/shopper-agent-discovery-context.types.ts new file mode 100644 index 00000000..315771ae --- /dev/null +++ b/apps/backend/src/channels/whatsapp/agent/shopper-agent-discovery-context.types.ts @@ -0,0 +1,13 @@ +export interface ShopperDiscoveryContextProduct { + rank: number; + title: string; + storeName: string; + priceDisplay?: string; + publicProductUrl?: string; +} + +export interface ShopperDiscoveryContext { + lastQuery: string | null; + source: "text" | "image"; + products: ShopperDiscoveryContextProduct[]; +} diff --git a/apps/backend/src/channels/whatsapp/agent/shopper-agent-orchestrator.service.spec.ts b/apps/backend/src/channels/whatsapp/agent/shopper-agent-orchestrator.service.spec.ts new file mode 100644 index 00000000..1fb88cde --- /dev/null +++ b/apps/backend/src/channels/whatsapp/agent/shopper-agent-orchestrator.service.spec.ts @@ -0,0 +1,103 @@ +import { ShopperAgentOrchestrator } from "./shopper-agent-orchestrator.service"; +import { ShopperAgentPolicyService } from "./shopper-agent-policy.service"; +import { ShopperAgentToolRegistry } from "./shopper-agent-tool.registry"; + +describe("ShopperAgentOrchestrator", () => { + let orchestrator: ShopperAgentOrchestrator; + + beforeEach(() => { + orchestrator = new ShopperAgentOrchestrator( + new ShopperAgentToolRegistry(), + new ShopperAgentPolicyService(), + ); + }); + + it("builds an allowed typed guest discovery plan", () => { + expect( + orchestrator.planIntent({ + functionName: "search_products", + params: { query: "black sneakers" }, + messageText: "Find black sneakers", + linkedUserId: null, + consentGiven: true, + }), + ).toMatchObject({ + kind: "tool", + functionName: "search_products", + tool: { + name: "search_products", + category: "guest", + input: { query: "black sneakers" }, + }, + policy: { + allowed: true, + confirmationRequired: false, + }, + audit: { + event: "shopper_agent_plan", + actionCategory: "guest", + policyDecision: "allowed", + }, + }); + }); + + it("returns a denied plan for an unlinked account read", () => { + expect( + orchestrator.planIntent({ + functionName: "get_order_status", + params: { orderReference: "TWZ-123456" }, + messageText: "Where is TWZ-123456?", + linkedUserId: null, + consentGiven: true, + }), + ).toMatchObject({ + kind: "tool", + functionName: "get_order_status", + policy: { + allowed: false, + reason: "linked_account_required", + }, + audit: { + policyDecision: "denied", + policyReason: "linked_account_required", + }, + }); + }); + + it("keeps safe natural replies in the conversation lane", () => { + expect( + orchestrator.planIntent({ + functionName: "natural_answer", + params: { answer: "WIZZA helps shoppers find products on Twizrr." }, + messageText: "Who are you?", + linkedUserId: null, + consentGiven: true, + }), + ).toMatchObject({ + kind: "conversation", + functionName: "natural_answer", + params: { + answer: "WIZZA helps shoppers find products on Twizrr.", + }, + audit: { + actionCategory: "conversation", + }, + }); + }); + + it("downgrades unknown model actions to the friendly fallback", () => { + expect( + orchestrator.planIntent({ + functionName: "read_private_store_data", + params: { storeId: "private" }, + messageText: "Show private store data", + linkedUserId: "user-1", + consentGiven: true, + }), + ).toMatchObject({ + kind: "conversation", + functionName: "friendly_fallback", + params: {}, + }); + }); +}); diff --git a/apps/backend/src/channels/whatsapp/agent/shopper-agent-orchestrator.service.ts b/apps/backend/src/channels/whatsapp/agent/shopper-agent-orchestrator.service.ts new file mode 100644 index 00000000..388a80fd --- /dev/null +++ b/apps/backend/src/channels/whatsapp/agent/shopper-agent-orchestrator.service.ts @@ -0,0 +1,94 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { ShopperAgentPolicyService } from "./shopper-agent-policy.service"; +import { ShopperAgentToolRegistry } from "./shopper-agent-tool.registry"; +import type { + ShopperAgentConversationName, + ShopperAgentPlan, + ShopperAgentPlanInput, + ShopperAgentPolicyDecision, +} from "./shopper-agent.types"; + +const CONVERSATION_ACTIONS = new Set([ + "natural_answer", + "friendly_fallback", +]); + +const CONVERSATION_POLICY: ShopperAgentPolicyDecision = { + allowed: true, + reason: "allowed", + confirmationRequired: false, +}; + +@Injectable() +export class ShopperAgentOrchestrator { + private readonly logger = new Logger(ShopperAgentOrchestrator.name); + + constructor( + private readonly toolRegistry: ShopperAgentToolRegistry, + private readonly policyService: ShopperAgentPolicyService, + ) {} + + planIntent(input: ShopperAgentPlanInput): ShopperAgentPlan { + const tool = this.toolRegistry.createRequest( + input.functionName, + input.params, + input.messageText, + ); + + if (tool) { + const policy = this.policyService.evaluate(tool, { + consentGiven: input.consentGiven, + linkedUserId: input.linkedUserId, + }); + const plan: ShopperAgentPlan = { + kind: "tool", + functionName: tool.name, + params: tool.input, + tool, + policy, + audit: { + event: "shopper_agent_plan", + actionName: tool.name, + actionCategory: tool.category, + policyDecision: policy.allowed ? "allowed" : "denied", + policyReason: policy.reason, + }, + }; + this.logPlan(plan); + return plan; + } + + const functionName = CONVERSATION_ACTIONS.has( + input.functionName as ShopperAgentConversationName, + ) + ? (input.functionName as ShopperAgentConversationName) + : "friendly_fallback"; + const plan: ShopperAgentPlan = { + kind: "conversation", + functionName, + params: functionName === "natural_answer" ? (input.params ?? {}) : {}, + policy: CONVERSATION_POLICY, + audit: { + event: "shopper_agent_plan", + actionName: functionName, + actionCategory: "conversation", + policyDecision: "allowed", + policyReason: "allowed", + }, + }; + this.logPlan(plan); + return plan; + } + + private logPlan(plan: ShopperAgentPlan): void { + this.logger.log( + JSON.stringify({ + event: plan.audit.event, + actionName: plan.audit.actionName, + actionCategory: plan.audit.actionCategory, + policyDecision: plan.audit.policyDecision, + policyReason: plan.audit.policyReason, + }), + ); + } +} diff --git a/apps/backend/src/channels/whatsapp/agent/shopper-agent-policy.service.spec.ts b/apps/backend/src/channels/whatsapp/agent/shopper-agent-policy.service.spec.ts new file mode 100644 index 00000000..e0df0f5d --- /dev/null +++ b/apps/backend/src/channels/whatsapp/agent/shopper-agent-policy.service.spec.ts @@ -0,0 +1,157 @@ +import { ShopperAgentPolicyService } from "./shopper-agent-policy.service"; +import type { ShopperAgentToolRequest } from "./shopper-agent.types"; + +describe("ShopperAgentPolicyService", () => { + const service = new ShopperAgentPolicyService(); + + it("allows consented guest discovery without an account link", () => { + const request: ShopperAgentToolRequest = { + name: "search_products", + category: "guest", + input: { query: "phones" }, + }; + + expect( + service.evaluate(request, { + consentGiven: true, + linkedUserId: null, + }), + ).toEqual({ + allowed: true, + reason: "allowed", + confirmationRequired: false, + }); + }); + + it("blocks every tool before WhatsApp consent", () => { + const request: ShopperAgentToolRequest = { + name: "show_menu", + category: "guest", + input: {}, + }; + + expect( + service.evaluate(request, { + consentGiven: false, + linkedUserId: null, + }), + ).toEqual({ + allowed: false, + reason: "consent_required", + confirmationRequired: false, + }); + }); + + it("requires an active link for account-specific reads", () => { + const request: ShopperAgentToolRequest = { + name: "get_order_status", + category: "linked-read", + input: {}, + }; + + expect( + service.evaluate(request, { + consentGiven: true, + linkedUserId: null, + }), + ).toEqual({ + allowed: false, + reason: "linked_account_required", + confirmationRequired: false, + }); + }); + + it("requires an active link for cart, address, and order-list reads", () => { + for (const name of [ + "get_cart", + "get_saved_addresses", + "list_orders", + "get_delivery_status", + ] as const) { + const input = + name === "get_delivery_status" ? { orderReference: "TWZ-123456" } : {}; + const request = { + name, + category: "linked-read", + input, + } as ShopperAgentToolRequest; + + expect( + service.evaluate(request, { + consentGiven: true, + linkedUserId: null, + }), + ).toMatchObject({ + allowed: false, + reason: "linked_account_required", + }); + } + }); + + it("derives access from the trusted tool name instead of caller metadata", () => { + const forgedRequest = { + name: "get_order_status", + category: "guest", + input: {}, + } as unknown as ShopperAgentToolRequest; + + expect( + service.evaluate(forgedRequest, { + consentGiven: true, + linkedUserId: null, + }), + ).toEqual({ + allowed: false, + reason: "linked_account_required", + confirmationRequired: false, + }); + }); + + it("marks sensitive flows as requiring scoped confirmation", () => { + const request: ShopperAgentToolRequest = { + name: "confirm_delivery", + category: "confirmation-required", + input: { orderReference: "TWZ-123456" }, + }; + + expect( + service.evaluate(request, { + consentGiven: true, + linkedUserId: "user-1", + }), + ).toEqual({ + allowed: true, + reason: "allowed", + confirmationRequired: true, + }); + }); + + it("requires both an active link and confirmation for cart mutations", () => { + const request: ShopperAgentToolRequest = { + name: "add_to_cart", + category: "confirmation-required", + input: { productReference: "first", quantity: 1 }, + }; + + expect( + service.evaluate(request, { + consentGiven: true, + linkedUserId: null, + }), + ).toEqual({ + allowed: false, + reason: "linked_account_required", + confirmationRequired: false, + }); + expect( + service.evaluate(request, { + consentGiven: true, + linkedUserId: "user-1", + }), + ).toEqual({ + allowed: true, + reason: "allowed", + confirmationRequired: true, + }); + }); +}); diff --git a/apps/backend/src/channels/whatsapp/agent/shopper-agent-policy.service.ts b/apps/backend/src/channels/whatsapp/agent/shopper-agent-policy.service.ts new file mode 100644 index 00000000..43124807 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/agent/shopper-agent-policy.service.ts @@ -0,0 +1,51 @@ +import { Injectable } from "@nestjs/common"; +import type { + ShopperAgentPolicyDecision, + ShopperAgentToolRequest, +} from "./shopper-agent.types"; +import { SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME } from "./shopper-agent.types"; + +export interface ShopperAgentPolicyContext { + consentGiven: boolean; + linkedUserId: string | null; +} + +@Injectable() +export class ShopperAgentPolicyService { + evaluate( + request: ShopperAgentToolRequest, + context: ShopperAgentPolicyContext, + ): ShopperAgentPolicyDecision { + const category = SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME[request.name]; + + if (!context.consentGiven) { + return { + allowed: false, + reason: "consent_required", + confirmationRequired: false, + }; + } + + if (category === "guest") { + return { + allowed: true, + reason: "allowed", + confirmationRequired: false, + }; + } + + if (!context.linkedUserId) { + return { + allowed: false, + reason: "linked_account_required", + confirmationRequired: false, + }; + } + + return { + allowed: true, + reason: "allowed", + confirmationRequired: category === "confirmation-required", + }; + } +} diff --git a/apps/backend/src/channels/whatsapp/agent/shopper-agent-tool.registry.spec.ts b/apps/backend/src/channels/whatsapp/agent/shopper-agent-tool.registry.spec.ts new file mode 100644 index 00000000..cd3c4569 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/agent/shopper-agent-tool.registry.spec.ts @@ -0,0 +1,361 @@ +import { + SHOPPER_AGENT_GEMINI_DECLARATIONS, + SHOPPER_AGENT_TOOL_DEFINITIONS, + ShopperAgentToolRegistry, +} from "./shopper-agent-tool.registry"; + +describe("ShopperAgentToolRegistry", () => { + let registry: ShopperAgentToolRegistry; + + beforeEach(() => { + registry = new ShopperAgentToolRegistry(); + }); + + it("keeps Gemini declarations aligned with the typed registry", () => { + expect( + SHOPPER_AGENT_GEMINI_DECLARATIONS.map((declaration) => declaration.name), + ).toEqual(SHOPPER_AGENT_TOOL_DEFINITIONS.map((tool) => tool.name)); + }); + + it("normalizes a product search and falls back to the shopper message", () => { + expect( + registry.createRequest("search_products", {}, " black sneakers "), + ).toEqual({ + name: "search_products", + category: "guest", + input: { query: "black sneakers" }, + }); + }); + + it("rejects empty product-search input", () => { + expect(registry.createRequest("search_products", {}, " ")).toBeNull(); + }); + + it("normalizes contextual product refinement input", () => { + expect( + registry.createRequest( + "refine_product_search", + { + refinement: " show cheaper options ", + productReference: " second ", + }, + "ignored", + ), + ).toEqual({ + name: "refine_product_search", + category: "guest", + input: { + refinement: "show cheaper options", + productReference: "second", + }, + }); + }); + + it("limits product comparison references to displayed-result inputs", () => { + expect( + registry.createRequest( + "compare_products", + { + productReferences: [" first ", "", "second", 3, "third", "fourth"], + }, + "compare these", + ), + ).toEqual({ + name: "compare_products", + category: "guest", + input: { productReferences: ["first", "second", "third"] }, + }); + }); + + it("does not create requests for undeclared Gemini tools", () => { + expect( + registry.createRequest( + "query_prisma_directly", + { table: "users" }, + "show users", + ), + ).toBeNull(); + }); + + it("accepts shopper-safe outputs", () => { + const output = { + status: "sent", + productsShownCount: 2, + publicProductUrls: ["https://app.twizrr.com/stores/example/p/TWZ-123456"], + }; + + expect(() => + registry.assertSafeOutput("search_products", output), + ).not.toThrow(); + }); + + it("accepts shopper-safe refinement and comparison outputs", () => { + expect(() => + registry.assertSafeOutput("refine_product_search", { + status: "no_context", + productsShownCount: 0, + publicProductUrls: [], + }), + ).not.toThrow(); + expect(() => + registry.assertSafeOutput("compare_products", { + status: "sent", + productsComparedCount: 2, + publicProductUrls: [ + "https://app.twizrr.com/stores/example/p/TWZ-123456", + "https://app.twizrr.com/stores/example/p/TWZ-654321", + ], + }), + ).not.toThrow(); + }); + + it("accepts shopper-safe linked-read outputs", () => { + expect(() => + registry.assertSafeOutput("get_cart", { + status: "available", + itemCount: 2, + totalQuantity: 3, + subtotalDisplay: "NGN 25,000.00", + }), + ).not.toThrow(); + expect(() => + registry.assertSafeOutput("get_saved_addresses", { + status: "available", + addressCount: 1, + defaultAddressLabel: "Home", + }), + ).not.toThrow(); + expect(() => + registry.assertSafeOutput("list_orders", { + status: "available", + ordersShownCount: 1, + orderReferences: ["TWZ-123456"], + }), + ).not.toThrow(); + expect(() => + registry.assertSafeOutput("get_delivery_status", { + status: "available", + orderReference: "TWZ-123456", + deliveryStatus: "in transit", + }), + ).not.toThrow(); + }); + + it("normalizes confirmation-protected cart and address inputs", () => { + expect( + registry.createRequest( + "add_to_cart", + { productReference: " second ", quantity: 2 }, + "ignored", + ), + ).toEqual({ + name: "add_to_cart", + category: "confirmation-required", + input: { productReference: "second", quantity: 2 }, + }); + expect( + registry.createRequest( + "update_cart_quantity", + { cartItemReference: " black sneakers ", quantity: 3 }, + "ignored", + ), + ).toEqual({ + name: "update_cart_quantity", + category: "confirmation-required", + input: { cartItemReference: "black sneakers", quantity: 3 }, + }); + expect(registry.createRequest("start_checkout", {}, "ignored")).toEqual({ + name: "start_checkout", + category: "confirmation-required", + input: {}, + }); + expect( + registry.createRequest( + "select_delivery_address", + { addressReference: " home " }, + "ignored", + ), + ).toEqual({ + name: "select_delivery_address", + category: "confirmation-required", + input: { addressReference: "home" }, + }); + }); + + it("normalizes post-purchase inputs without accepting internal identifiers", () => { + expect( + registry.createRequest( + "start_dispute", + { + orderReference: " twz-123456 ", + reason: " Damaged item ", + description: " The item arrived damaged. ", + requestedOutcome: " refund ", + orderId: "private-order-id", + }, + "ignored", + ), + ).toEqual({ + name: "start_dispute", + category: "confirmation-required", + input: { + orderReference: "twz-123456", + reason: "Damaged item", + description: "The item arrived damaged.", + requestedOutcome: "refund", + }, + }); + expect( + registry.createRequest( + "get_refund_status", + { + orderReference: " TWZ-654321 ", + disputeId: "private-dispute-id", + }, + "ignored", + ), + ).toEqual({ + name: "get_refund_status", + category: "linked-read", + input: { orderReference: "TWZ-654321" }, + }); + }); + + it("rejects invalid cart quantities before a tool plan is created", () => { + expect( + registry.createRequest( + "add_to_cart", + { productReference: "first", quantity: 0 }, + "ignored", + ), + ).toBeNull(); + expect( + registry.createRequest( + "update_cart_quantity", + { cartItemReference: "first", quantity: 0 }, + "ignored", + ), + ).toBeNull(); + expect( + registry.createRequest( + "update_cart_quantity", + { cartItemReference: "first", quantity: 100 }, + "ignored", + ), + ).toBeNull(); + }); + + it("accepts only the safe confirmation status contract for cart writes", () => { + expect(() => + registry.assertSafeOutput("add_to_cart", { + status: "confirmation_required", + nextStep: "confirm_or_cancel", + }), + ).not.toThrow(); + expect(() => + registry.assertSafeOutput("start_checkout", { + status: "confirmation_required", + itemCount: 2, + totalQuantity: 3, + subtotalDisplay: "NGN 25,000.00", + nextStep: "confirm_or_cancel", + }), + ).not.toThrow(); + expect(() => + registry.assertSafeOutput("start_checkout", { + status: "confirmation_required", + paymentReference: "private-payment-reference", + }), + ).toThrow("Unsafe start_checkout tool output field: paymentReference"); + expect(() => + registry.assertSafeOutput("remove_from_cart", { + status: "confirmation_required", + cartItemId: "private-cart-item", + }), + ).toThrow("Unsafe remove_from_cart tool output field: cartItemId"); + }); + + it("accepts shopper-safe dispute and refund outputs and rejects internals", () => { + expect(() => + registry.assertSafeOutput("start_dispute", { + status: "confirmation_required", + orderReference: "TWZ-123456", + nextStep: "confirm_or_cancel", + }), + ).not.toThrow(); + expect(() => + registry.assertSafeOutput("get_dispute_status", { + status: "available", + orderReference: "TWZ-123456", + disputeStatus: "under review", + }), + ).not.toThrow(); + expect(() => + registry.assertSafeOutput("get_refund_status", { + status: "available", + orderReference: "TWZ-123456", + refundStatus: "processing", + refundAmountDisplay: "NGN 25,000.00", + }), + ).not.toThrow(); + expect(() => + registry.assertSafeOutput("get_refund_status", { + status: "available", + orderReference: "TWZ-123456", + refundStatus: "processing", + providerReference: "private-provider-reference", + }), + ).toThrow("Unsafe get_refund_status tool output field: providerReference"); + }); + + it("rejects malformed linked-read outputs", () => { + expect(() => + registry.assertSafeOutput("get_cart", { + status: "available", + itemCount: -1, + totalQuantity: 0, + }), + ).toThrow("Unsafe get_cart tool output itemCount"); + expect(() => + registry.assertSafeOutput("list_orders", { + status: "available", + ordersShownCount: 1, + orderReferences: ["TWZ-123456", 42], + }), + ).toThrow("Unsafe list_orders tool output orderReferences"); + }); + + it("rejects outputs with missing required fields", () => { + expect(() => registry.assertSafeOutput("search_products", {})).toThrow( + "Unsafe search_products tool output status", + ); + }); + + it("rejects outputs with invalid runtime value types", () => { + expect(() => + registry.assertSafeOutput("search_products", { + status: "sent", + productsShownCount: "two", + }), + ).toThrow("Unsafe search_products tool output productsShownCount"); + }); + + it("rejects statuses outside the declared tool contract", () => { + expect(() => + registry.assertSafeOutput("get_order_status", { + status: "sent", + }), + ).toThrow("Unsafe get_order_status tool output status"); + }); + + it("rejects private source fields even when nested", () => { + expect(() => + registry.assertSafeOutput("search_products", { + status: "sent", + productsShownCount: 1, + publicProductUrls: [], + result: { physicalStoreId: "private-store-id" }, + }), + ).toThrow("Unsafe shopper-agent output field: physicalStoreId"); + }); +}); diff --git a/apps/backend/src/channels/whatsapp/agent/shopper-agent-tool.registry.ts b/apps/backend/src/channels/whatsapp/agent/shopper-agent-tool.registry.ts new file mode 100644 index 00000000..55688876 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/agent/shopper-agent-tool.registry.ts @@ -0,0 +1,880 @@ +import { Injectable } from "@nestjs/common"; +import type { GeminiFunctionDeclaration } from "../../../integrations/ai/gemini.client"; +import type { + ShopperAgentToolInputMap, + ShopperAgentToolCategoryMap, + ShopperAgentToolName, + ShopperAgentToolOutputMap, + ShopperAgentToolRequest, +} from "./shopper-agent.types"; +import { SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME } from "./shopper-agent.types"; +import { isWhatsAppCartQuantity } from "../whatsapp.constants"; + +interface ShopperAgentToolDefinition { + name: Name; + category: ShopperAgentToolCategoryMap[Name]; + executorKey: + | "product_search" + | "product_refinement" + | "product_comparison" + | "cart_read" + | "cart_add" + | "cart_update" + | "cart_remove" + | "checkout_handoff" + | "address_read" + | "address_select" + | "order_list" + | "order_status" + | "delivery_status" + | "delivery_confirmation" + | "dispute_open" + | "dispute_status" + | "refund_status" + | "menu" + | "support" + | "store_redirect"; + description: string; + parameters: NonNullable; + safeOutputFields: readonly (keyof ShopperAgentToolOutputMap[Name])[]; +} + +type AnyShopperAgentToolDefinition = { + [Name in ShopperAgentToolName]: ShopperAgentToolDefinition; +}[ShopperAgentToolName]; + +const EMPTY_PARAMETERS = { + type: "object" as const, + properties: {}, +}; + +export const SHOPPER_AGENT_TOOL_DEFINITIONS = [ + { + name: "search_products", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.search_products, + executorKey: "product_search", + description: + "Search for products the shopper wants to buy. Use for product names, categories, descriptions, budget, location, or availability requests.", + parameters: { + type: "object" as const, + properties: { + query: { + type: "string", + description: "The shopper's product search text.", + }, + }, + required: ["query"], + }, + safeOutputFields: ["status", "productsShownCount", "publicProductUrls"], + }, + { + name: "refine_product_search", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.refine_product_search, + executorKey: "product_refinement", + description: + "Refine the active product results using a follow-up preference such as cheaper, similar, a colour, a budget, or a location. Use only when recent product context exists.", + parameters: { + type: "object" as const, + properties: { + refinement: { + type: "string", + description: "The shopper's follow-up refinement.", + }, + productReference: { + type: "string", + description: + "Optional result reference such as first, second, number 2, or a displayed product name.", + }, + }, + required: ["refinement"], + }, + safeOutputFields: ["status", "productsShownCount", "publicProductUrls"], + }, + { + name: "compare_products", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.compare_products, + executorKey: "product_comparison", + description: + "Compare two or three products from the active recent result set. References must point to displayed results, never arbitrary internal identifiers.", + parameters: { + type: "object" as const, + properties: { + productReferences: { + type: "array", + items: { type: "string" }, + description: + "Optional displayed result references, such as first and second or product names. Defaults to the first two active results.", + }, + }, + }, + safeOutputFields: ["status", "productsComparedCount", "publicProductUrls"], + }, + { + name: "get_cart", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.get_cart, + executorKey: "cart_read", + description: + "Read the linked shopper's current cart. Use for questions about cart contents, quantities, or cart subtotal.", + parameters: EMPTY_PARAMETERS, + safeOutputFields: [ + "status", + "itemCount", + "totalQuantity", + "subtotalDisplay", + ], + }, + { + name: "add_to_cart", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.add_to_cart, + executorKey: "cart_add", + description: + "Prepare to add a native product from the active recent result set to the linked shopper's cart. The backend must resolve the displayed product and ask for explicit confirmation before changing the cart.", + parameters: { + type: "object" as const, + properties: { + productReference: { + type: "string", + description: + "A displayed result reference such as first, second, number 2, or the product name.", + }, + quantity: { + type: "number", + description: "Whole-number quantity to add. Defaults to 1.", + }, + }, + }, + safeOutputFields: ["status", "nextStep"], + }, + { + name: "update_cart_quantity", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.update_cart_quantity, + executorKey: "cart_update", + description: + "Prepare to change the quantity of an item already in the linked shopper's cart. Resolve only against the shopper's current cart and require explicit confirmation.", + parameters: { + type: "object" as const, + properties: { + cartItemReference: { + type: "string", + description: + "A current cart item reference such as first, second, product name, or displayed product code.", + }, + quantity: { + type: "number", + description: "The new whole-number quantity.", + }, + }, + required: ["quantity"], + }, + safeOutputFields: ["status", "nextStep"], + }, + { + name: "remove_from_cart", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.remove_from_cart, + executorKey: "cart_remove", + description: + "Prepare to remove an item from the linked shopper's current cart. Resolve only against current cart contents and require explicit confirmation.", + parameters: { + type: "object" as const, + properties: { + cartItemReference: { + type: "string", + description: + "A current cart item reference such as first, second, product name, or displayed product code.", + }, + }, + }, + safeOutputFields: ["status", "nextStep"], + }, + { + name: "start_checkout", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.start_checkout, + executorKey: "checkout_handoff", + description: + "Validate and summarize the linked shopper's current cart, then require explicit confirmation before providing the secure Twizrr web checkout handoff. Never initialize payment or create an order in WhatsApp.", + parameters: EMPTY_PARAMETERS, + safeOutputFields: [ + "status", + "itemCount", + "totalQuantity", + "subtotalDisplay", + "handoffUrl", + "nextStep", + ], + }, + { + name: "get_saved_addresses", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.get_saved_addresses, + executorKey: "address_read", + description: + "Read the linked shopper's saved delivery addresses. Return only shopper-safe address labels and formatted locations.", + parameters: EMPTY_PARAMETERS, + safeOutputFields: ["status", "addressCount", "defaultAddressLabel"], + }, + { + name: "select_delivery_address", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.select_delivery_address, + executorKey: "address_select", + description: + "Prepare to select one of the linked shopper's saved delivery addresses for the current shopping session. Resolve only against saved addresses and require explicit confirmation.", + parameters: { + type: "object" as const, + properties: { + addressReference: { + type: "string", + description: + "A saved address label or ordinal such as home, work, first, or second.", + }, + }, + }, + safeOutputFields: ["status", "nextStep"], + }, + { + name: "list_orders", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.list_orders, + executorKey: "order_list", + description: + "List the linked shopper's recent orders using public order codes and shopper-safe statuses.", + parameters: EMPTY_PARAMETERS, + safeOutputFields: ["status", "ordersShownCount", "orderReferences"], + }, + { + name: "get_order_status", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.get_order_status, + executorKey: "order_status", + description: + "Read the linked shopper's order status. Use for tracking, shipment, delivery, or order status questions.", + parameters: { + type: "object" as const, + properties: { + orderReference: { + type: "string", + description: "Optional shopper-facing order code.", + }, + }, + }, + safeOutputFields: [ + "status", + "orderReference", + "orderStatus", + "deliveryStatus", + ], + }, + { + name: "get_delivery_status", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.get_delivery_status, + executorKey: "delivery_status", + description: + "Read delivery progress for one of the linked shopper's orders. Use a shopper-facing order code when supplied.", + parameters: { + type: "object" as const, + properties: { + orderReference: { + type: "string", + description: "Optional shopper-facing order code.", + }, + }, + }, + safeOutputFields: ["status", "orderReference", "deliveryStatus"], + }, + { + name: "confirm_delivery", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.confirm_delivery, + executorKey: "delivery_confirmation", + description: + "Start the linked shopper's explicit delivery-confirmation flow. Never confirm delivery without the scoped confirmation step.", + parameters: { + type: "object" as const, + properties: { + orderReference: { + type: "string", + description: "Optional shopper-facing order code.", + }, + }, + }, + safeOutputFields: [ + "status", + "ordersShownCount", + "orderReference", + "nextStep", + ], + }, + { + name: "start_dispute", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.start_dispute, + executorKey: "dispute_open", + description: + "Prepare to open a dispute for an eligible order owned by the linked shopper. Include the shopper's reason and description when provided. The backend must verify eligibility and require explicit confirmation before opening the dispute.", + parameters: { + type: "object" as const, + properties: { + orderReference: { + type: "string", + description: "Optional shopper-facing order code.", + }, + reason: { + type: "string", + description: + "A short factual reason for the dispute, based only on what the shopper reported.", + }, + description: { + type: "string", + description: + "The shopper's factual description of the order problem.", + }, + requestedOutcome: { + type: "string", + description: + "Optional outcome requested by the shopper, such as a refund or replacement.", + }, + }, + }, + safeOutputFields: ["status", "orderReference", "nextStep"], + }, + { + name: "get_dispute_status", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.get_dispute_status, + executorKey: "dispute_status", + description: + "Read the current dispute status for an order owned by the linked shopper. Use only shopper-facing order codes and safe status text.", + parameters: { + type: "object" as const, + properties: { + orderReference: { + type: "string", + description: "Optional shopper-facing order code.", + }, + }, + }, + safeOutputFields: ["status", "orderReference", "disputeStatus"], + }, + { + name: "get_refund_status", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.get_refund_status, + executorKey: "refund_status", + description: + "Read the real refund settlement status for a disputed order owned by the linked shopper. Never promise a refund or expose provider references.", + parameters: { + type: "object" as const, + properties: { + orderReference: { + type: "string", + description: "Optional shopper-facing order code.", + }, + }, + }, + safeOutputFields: [ + "status", + "orderReference", + "refundStatus", + "refundAmountDisplay", + ], + }, + { + name: "show_menu", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.show_menu, + executorKey: "menu", + description: + "Show the shopper assistant menu when the shopper asks for help or available actions.", + parameters: EMPTY_PARAMETERS, + safeOutputFields: ["status"], + }, + { + name: "support_handoff", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.support_handoff, + executorKey: "support", + description: + "Give deterministic Twizrr support guidance. Do not claim live handoff or immediate agent availability.", + parameters: EMPTY_PARAMETERS, + safeOutputFields: ["status"], + }, + { + name: "store_management_redirect", + category: SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME.store_management_redirect, + executorKey: "store_redirect", + description: + "Redirect store-management requests to the Twizrr web store workspace because WIZZA is shopper-only.", + parameters: EMPTY_PARAMETERS, + safeOutputFields: ["status"], + }, +] as const satisfies readonly AnyShopperAgentToolDefinition[]; + +export const SHOPPER_AGENT_GEMINI_DECLARATIONS: GeminiFunctionDeclaration[] = + SHOPPER_AGENT_TOOL_DEFINITIONS.map((definition) => ({ + name: definition.name, + description: definition.description, + parameters: definition.parameters, + })); + +const FORBIDDEN_SAFE_OUTPUT_FIELDS = new Set([ + "physicalStoreId", + "physicalProductId", + "dropshipperCostKobo", + "digitalMarginKobo", + "linkedOrderId", + "sourceCode", + "supplierId", + "supplierStoreId", + "embedding", + "vector", + "rankingScore", +]); + +@Injectable() +export class ShopperAgentToolRegistry { + createRequest( + name: string, + params: Record | undefined, + fallbackMessage: string, + ): ShopperAgentToolRequest | null { + const definition = SHOPPER_AGENT_TOOL_DEFINITIONS.find( + (candidate) => candidate.name === name, + ); + + if (!definition) { + return null; + } + + const input = this.parseInput( + definition.name, + params ?? {}, + fallbackMessage, + ); + + if (!input) { + return null; + } + + return { + name: definition.name, + category: definition.category, + input, + } as ShopperAgentToolRequest; + } + + assertSafeOutput( + name: Name, + output: unknown, + ): asserts output is ShopperAgentToolOutputMap[Name] { + if (!this.isRecord(output)) { + throw new Error(`Unsafe ${name} tool output`); + } + + this.assertNoForbiddenFields(output); + const definition = SHOPPER_AGENT_TOOL_DEFINITIONS.find( + (candidate) => candidate.name === name, + ); + const allowedFields = new Set( + definition?.safeOutputFields.map(String) ?? [], + ); + + for (const key of Object.keys(output)) { + if (!allowedFields.has(key)) { + throw new Error(`Unsafe ${name} tool output field: ${key}`); + } + } + + this.assertOutputShape(name, output); + } + + private parseInput( + name: Name, + params: Record, + fallbackMessage: string, + ): ShopperAgentToolInputMap[Name] | null { + switch (name) { + case "search_products": { + const query = + this.optionalString(params.query) || fallbackMessage.trim(); + return (query ? { query } : null) as + | ShopperAgentToolInputMap[Name] + | null; + } + case "refine_product_search": { + const refinement = + this.optionalString(params.refinement) || fallbackMessage.trim(); + const productReference = this.optionalString(params.productReference); + return ( + refinement + ? { + refinement, + ...(productReference ? { productReference } : {}), + } + : null + ) as ShopperAgentToolInputMap[Name] | null; + } + case "compare_products": { + const productReferences = this.optionalStringArray( + params.productReferences, + ); + return ( + productReferences?.length ? { productReferences } : {} + ) as ShopperAgentToolInputMap[Name]; + } + case "get_cart": + case "start_checkout": + case "get_saved_addresses": + case "list_orders": + return {} as ShopperAgentToolInputMap[Name]; + case "add_to_cart": { + const productReference = this.optionalString(params.productReference); + const quantity = + params.quantity === undefined + ? 1 + : this.optionalPositiveInteger(params.quantity); + if (!quantity) { + return null; + } + return { + ...(productReference ? { productReference } : {}), + quantity, + } as ShopperAgentToolInputMap[Name]; + } + case "update_cart_quantity": { + const quantity = this.optionalPositiveInteger(params.quantity); + if (!quantity) { + return null; + } + const cartItemReference = this.optionalString(params.cartItemReference); + return { + ...(cartItemReference ? { cartItemReference } : {}), + quantity, + } as ShopperAgentToolInputMap[Name]; + } + case "remove_from_cart": { + const cartItemReference = this.optionalString(params.cartItemReference); + return ( + cartItemReference ? { cartItemReference } : {} + ) as ShopperAgentToolInputMap[Name]; + } + case "select_delivery_address": { + const addressReference = this.optionalString(params.addressReference); + return ( + addressReference ? { addressReference } : {} + ) as ShopperAgentToolInputMap[Name]; + } + case "get_order_status": + case "get_delivery_status": + case "confirm_delivery": + case "get_dispute_status": + case "get_refund_status": { + const orderReference = this.optionalString(params.orderReference); + return ( + orderReference ? { orderReference } : {} + ) as ShopperAgentToolInputMap[Name]; + } + case "start_dispute": { + const orderReference = this.optionalString(params.orderReference); + const reason = this.optionalString(params.reason); + const description = this.optionalString(params.description); + const requestedOutcome = this.optionalString(params.requestedOutcome); + return { + ...(orderReference ? { orderReference } : {}), + ...(reason ? { reason } : {}), + ...(description ? { description } : {}), + ...(requestedOutcome ? { requestedOutcome } : {}), + } as ShopperAgentToolInputMap[Name]; + } + case "show_menu": + case "support_handoff": + case "store_management_redirect": + return {} as ShopperAgentToolInputMap[Name]; + } + } + + private optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; + } + + private optionalStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + + const strings = value + .map((item) => this.optionalString(item)) + .filter((item): item is string => Boolean(item)) + .slice(0, 3); + return strings.length ? strings : undefined; + } + + private optionalPositiveInteger(value: unknown): number | undefined { + return isWhatsAppCartQuantity(value) ? value : undefined; + } + + private assertOutputShape( + name: ShopperAgentToolName, + output: Record, + ): void { + switch (name) { + case "search_products": + case "refine_product_search": + this.assertStatus( + name, + output.status, + name === "refine_product_search" + ? ["sent", "no_results", "no_context"] + : ["sent", "no_results"], + ); + if ( + typeof output.productsShownCount !== "number" || + !Number.isInteger(output.productsShownCount) || + output.productsShownCount < 0 + ) { + throw new Error(`Unsafe ${name} tool output productsShownCount`); + } + if ( + output.publicProductUrls !== undefined && + (!Array.isArray(output.publicProductUrls) || + !output.publicProductUrls.every( + (url) => typeof url === "string" && Boolean(url.trim()), + )) + ) { + throw new Error(`Unsafe ${name} tool output publicProductUrls`); + } + return; + case "compare_products": + this.assertStatus(name, output.status, [ + "sent", + "no_context", + "not_enough_products", + ]); + if ( + typeof output.productsComparedCount !== "number" || + !Number.isInteger(output.productsComparedCount) || + output.productsComparedCount < 0 + ) { + throw new Error(`Unsafe ${name} tool output productsComparedCount`); + } + if ( + output.publicProductUrls !== undefined && + (!Array.isArray(output.publicProductUrls) || + !output.publicProductUrls.every( + (url) => typeof url === "string" && Boolean(url.trim()), + )) + ) { + throw new Error(`Unsafe ${name} tool output publicProductUrls`); + } + return; + case "get_cart": + this.assertStatus(name, output.status, ["available", "empty"]); + this.assertNonNegativeIntegers(name, output, [ + "itemCount", + "totalQuantity", + ]); + this.assertOptionalStrings(name, output, ["subtotalDisplay"]); + return; + case "add_to_cart": + this.assertStatus(name, output.status, [ + "confirmation_required", + "no_context", + "unsupported", + "temporarily_unavailable", + ]); + this.assertOptionalStrings(name, output, ["nextStep"]); + return; + case "update_cart_quantity": + case "remove_from_cart": + case "select_delivery_address": + this.assertStatus(name, output.status, [ + "confirmation_required", + "empty", + "not_found", + "temporarily_unavailable", + ]); + this.assertOptionalStrings(name, output, ["nextStep"]); + return; + case "start_checkout": + this.assertStatus(name, output.status, [ + "confirmation_required", + "empty", + "unavailable", + "temporarily_unavailable", + ]); + this.assertOptionalNonNegativeIntegers(name, output, [ + "itemCount", + "totalQuantity", + ]); + this.assertOptionalStrings(name, output, [ + "subtotalDisplay", + "handoffUrl", + "nextStep", + ]); + return; + case "get_saved_addresses": + this.assertStatus(name, output.status, ["available", "empty"]); + this.assertNonNegativeIntegers(name, output, ["addressCount"]); + this.assertOptionalStrings(name, output, ["defaultAddressLabel"]); + return; + case "list_orders": + this.assertStatus(name, output.status, ["available", "empty"]); + this.assertNonNegativeIntegers(name, output, ["ordersShownCount"]); + this.assertOptionalStringArray(name, output, "orderReferences"); + return; + case "get_order_status": + this.assertStatus(name, output.status, ["available", "not_found"]); + this.assertOptionalStrings(name, output, [ + "orderReference", + "orderStatus", + "deliveryStatus", + ]); + return; + case "get_delivery_status": + this.assertStatus(name, output.status, ["available", "not_found"]); + this.assertOptionalStrings(name, output, [ + "orderReference", + "deliveryStatus", + ]); + return; + case "confirm_delivery": + this.assertStatus(name, output.status, [ + "selection_required", + "code_required", + "no_eligible_order", + "not_found", + "temporarily_unavailable", + ]); + this.assertOptionalNonNegativeIntegers(name, output, [ + "ordersShownCount", + ]); + this.assertOptionalStrings(name, output, [ + "orderReference", + "nextStep", + ]); + return; + case "start_dispute": + this.assertStatus(name, output.status, [ + "confirmation_required", + "details_required", + "not_found", + "not_eligible", + "existing_dispute", + "support_required", + "temporarily_unavailable", + ]); + this.assertOptionalStrings(name, output, [ + "orderReference", + "nextStep", + ]); + return; + case "get_dispute_status": + this.assertStatus(name, output.status, [ + "available", + "none", + "not_found", + ]); + this.assertOptionalStrings(name, output, [ + "orderReference", + "disputeStatus", + ]); + return; + case "get_refund_status": + this.assertStatus(name, output.status, [ + "available", + "none", + "not_found", + ]); + this.assertOptionalStrings(name, output, [ + "orderReference", + "refundStatus", + "refundAmountDisplay", + ]); + return; + case "show_menu": + case "support_handoff": + case "store_management_redirect": + this.assertStatus(name, output.status, ["sent"]); + } + } + + private assertStatus( + name: ShopperAgentToolName, + status: unknown, + allowedStatuses: readonly string[], + ): void { + if (typeof status !== "string" || !allowedStatuses.includes(status)) { + throw new Error(`Unsafe ${name} tool output status`); + } + } + + private assertOptionalStrings( + name: ShopperAgentToolName, + output: Record, + fields: readonly string[], + ): void { + for (const field of fields) { + const value = output[field]; + if (value !== undefined && (typeof value !== "string" || !value.trim())) { + throw new Error(`Unsafe ${name} tool output ${field}`); + } + } + } + + private assertNonNegativeIntegers( + name: ShopperAgentToolName, + output: Record, + fields: readonly string[], + ): void { + for (const field of fields) { + const value = output[field]; + if (typeof value !== "number" || !Number.isInteger(value) || value < 0) { + throw new Error(`Unsafe ${name} tool output ${field}`); + } + } + } + + private assertOptionalNonNegativeIntegers( + name: ShopperAgentToolName, + output: Record, + fields: readonly string[], + ): void { + for (const field of fields) { + const value = output[field]; + if ( + value !== undefined && + (typeof value !== "number" || !Number.isInteger(value) || value < 0) + ) { + throw new Error(`Unsafe ${name} tool output ${field}`); + } + } + } + + private assertOptionalStringArray( + name: ShopperAgentToolName, + output: Record, + field: string, + ): void { + const value = output[field]; + if ( + value !== undefined && + (!Array.isArray(value) || + !value.every( + (item) => typeof item === "string" && Boolean(item.trim()), + )) + ) { + throw new Error(`Unsafe ${name} tool output ${field}`); + } + } + + private assertNoForbiddenFields(value: unknown): void { + if (Array.isArray(value)) { + value.forEach((item) => this.assertNoForbiddenFields(item)); + return; + } + + if (!this.isRecord(value)) { + return; + } + + for (const [key, nestedValue] of Object.entries(value)) { + if (FORBIDDEN_SAFE_OUTPUT_FIELDS.has(key)) { + throw new Error(`Unsafe shopper-agent output field: ${key}`); + } + this.assertNoForbiddenFields(nestedValue); + } + } + + private isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); + } +} diff --git a/apps/backend/src/channels/whatsapp/agent/shopper-agent.types.ts b/apps/backend/src/channels/whatsapp/agent/shopper-agent.types.ts new file mode 100644 index 00000000..abeb26fa --- /dev/null +++ b/apps/backend/src/channels/whatsapp/agent/shopper-agent.types.ts @@ -0,0 +1,270 @@ +export type ShopperAgentActionCategory = + | "guest" + | "linked-read" + | "linked-write" + | "confirmation-required"; + +export type ShopperAgentToolName = + | "search_products" + | "refine_product_search" + | "compare_products" + | "get_cart" + | "add_to_cart" + | "update_cart_quantity" + | "remove_from_cart" + | "start_checkout" + | "get_saved_addresses" + | "select_delivery_address" + | "list_orders" + | "get_order_status" + | "get_delivery_status" + | "confirm_delivery" + | "start_dispute" + | "get_dispute_status" + | "get_refund_status" + | "show_menu" + | "support_handoff" + | "store_management_redirect"; + +export const SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME = { + search_products: "guest", + refine_product_search: "guest", + compare_products: "guest", + get_cart: "linked-read", + add_to_cart: "confirmation-required", + update_cart_quantity: "confirmation-required", + remove_from_cart: "confirmation-required", + start_checkout: "confirmation-required", + get_saved_addresses: "linked-read", + select_delivery_address: "confirmation-required", + list_orders: "linked-read", + get_order_status: "linked-read", + get_delivery_status: "linked-read", + confirm_delivery: "confirmation-required", + start_dispute: "confirmation-required", + get_dispute_status: "linked-read", + get_refund_status: "linked-read", + show_menu: "guest", + support_handoff: "guest", + store_management_redirect: "guest", +} as const satisfies Record; + +export type ShopperAgentToolCategoryMap = + typeof SHOPPER_AGENT_TOOL_CATEGORY_BY_NAME; + +export type ShopperAgentConversationName = + | "natural_answer" + | "friendly_fallback"; + +export interface ShopperAgentToolInputMap { + search_products: { query: string }; + refine_product_search: { + refinement: string; + productReference?: string; + }; + compare_products: { productReferences?: string[] }; + get_cart: Record; + add_to_cart: { productReference?: string; quantity: number }; + update_cart_quantity: { cartItemReference?: string; quantity: number }; + remove_from_cart: { cartItemReference?: string }; + start_checkout: Record; + get_saved_addresses: Record; + select_delivery_address: { addressReference?: string }; + list_orders: Record; + get_order_status: { orderReference?: string }; + get_delivery_status: { orderReference?: string }; + confirm_delivery: { orderReference?: string }; + start_dispute: { + orderReference?: string; + reason?: string; + description?: string; + requestedOutcome?: string; + }; + get_dispute_status: { orderReference?: string }; + get_refund_status: { orderReference?: string }; + show_menu: Record; + support_handoff: Record; + store_management_redirect: Record; +} + +export interface ShopperAgentToolOutputMap { + search_products: { + status: "sent" | "no_results"; + productsShownCount: number; + publicProductUrls?: string[]; + }; + refine_product_search: { + status: "sent" | "no_results" | "no_context"; + productsShownCount: number; + publicProductUrls?: string[]; + }; + compare_products: { + status: "sent" | "no_context" | "not_enough_products"; + productsComparedCount: number; + publicProductUrls?: string[]; + }; + get_cart: { + status: "available" | "empty"; + itemCount: number; + totalQuantity: number; + subtotalDisplay?: string; + }; + add_to_cart: { + status: + | "confirmation_required" + | "no_context" + | "unsupported" + | "temporarily_unavailable"; + nextStep?: string; + }; + update_cart_quantity: { + status: + | "confirmation_required" + | "empty" + | "not_found" + | "temporarily_unavailable"; + nextStep?: string; + }; + remove_from_cart: { + status: + | "confirmation_required" + | "empty" + | "not_found" + | "temporarily_unavailable"; + nextStep?: string; + }; + start_checkout: { + status: + | "confirmation_required" + | "empty" + | "unavailable" + | "temporarily_unavailable"; + itemCount?: number; + totalQuantity?: number; + subtotalDisplay?: string; + handoffUrl?: string; + nextStep?: string; + }; + get_saved_addresses: { + status: "available" | "empty"; + addressCount: number; + defaultAddressLabel?: string; + }; + select_delivery_address: { + status: + | "confirmation_required" + | "empty" + | "not_found" + | "temporarily_unavailable"; + nextStep?: string; + }; + list_orders: { + status: "available" | "empty"; + ordersShownCount: number; + orderReferences?: string[]; + }; + get_order_status: { + status: "available" | "not_found"; + orderReference?: string; + orderStatus?: string; + deliveryStatus?: string; + }; + get_delivery_status: { + status: "available" | "not_found"; + orderReference?: string; + deliveryStatus?: string; + }; + confirm_delivery: { + status: + | "selection_required" + | "code_required" + | "no_eligible_order" + | "not_found" + | "temporarily_unavailable"; + ordersShownCount?: number; + orderReference?: string; + nextStep?: string; + }; + start_dispute: { + status: + | "confirmation_required" + | "details_required" + | "not_found" + | "not_eligible" + | "existing_dispute" + | "support_required" + | "temporarily_unavailable"; + orderReference?: string; + nextStep?: string; + }; + get_dispute_status: { + status: "available" | "none" | "not_found"; + orderReference?: string; + disputeStatus?: string; + }; + get_refund_status: { + status: "available" | "none" | "not_found"; + orderReference?: string; + refundStatus?: string; + refundAmountDisplay?: string; + }; + show_menu: { status: "sent" }; + support_handoff: { status: "sent" }; + store_management_redirect: { status: "sent" }; +} + +export type ShopperAgentToolRequest = { + [Name in ShopperAgentToolName]: { + name: Name; + category: ShopperAgentToolCategoryMap[Name]; + input: ShopperAgentToolInputMap[Name]; + }; +}[ShopperAgentToolName]; + +export type ShopperAgentPolicyReason = + | "allowed" + | "consent_required" + | "linked_account_required"; + +export interface ShopperAgentPolicyDecision { + allowed: boolean; + reason: ShopperAgentPolicyReason; + confirmationRequired: boolean; +} + +export interface ShopperAgentAuditEvent { + event: "shopper_agent_plan"; + actionName: ShopperAgentToolName | ShopperAgentConversationName; + actionCategory: ShopperAgentActionCategory | "conversation"; + policyDecision: "allowed" | "denied"; + policyReason: ShopperAgentPolicyReason; +} + +export interface ShopperAgentToolPlan { + kind: "tool"; + functionName: ShopperAgentToolName; + params: Record; + tool: ShopperAgentToolRequest; + policy: ShopperAgentPolicyDecision; + audit: ShopperAgentAuditEvent; +} + +export interface ShopperAgentConversationPlan { + kind: "conversation"; + functionName: ShopperAgentConversationName; + params: Record; + policy: ShopperAgentPolicyDecision; + audit: ShopperAgentAuditEvent; +} + +export type ShopperAgentPlan = + | ShopperAgentToolPlan + | ShopperAgentConversationPlan; + +export interface ShopperAgentPlanInput { + functionName: string; + params?: Record; + messageText: string; + linkedUserId: string | null; + consentGiven: boolean; +} diff --git a/apps/backend/src/channels/whatsapp/image-search.service.spec.ts b/apps/backend/src/channels/whatsapp/image-search.service.spec.ts index c900e444..3f889198 100644 --- a/apps/backend/src/channels/whatsapp/image-search.service.spec.ts +++ b/apps/backend/src/channels/whatsapp/image-search.service.spec.ts @@ -158,6 +158,7 @@ describe("ImageSearchService", () => { deletedAt: null, productCode: { not: null }, moderationStatus: { not: ModerationStatus.BLOCKED }, + productStockCaches: { some: { stock: { gt: 0 } } }, storeProfile: { tier: { not: StoreTier.TIER_0 }, isOpen: true }, OR: [ { @@ -205,12 +206,19 @@ describe("ImageSearchService", () => { expect(outboundText).not.toContain("Sneaker Store Legal Ltd"); expect( productDiscoveryService.storeRecentSearchResults, - ).toHaveBeenCalledWith("+2348012345678", [ - expect.objectContaining({ - id: "internal-product-id", - productCode: "TWZ-SNK-001", - }), - ]); + ).toHaveBeenCalledWith( + "+2348012345678", + [ + expect.objectContaining({ + id: "internal-product-id", + productCode: "TWZ-SNK-001", + }), + ], + { + lastQuery: "sneakers", + source: "image", + }, + ); expect( productDiscoveryService.sendCanonicalProductLinks, ).toHaveBeenCalledWith("+2348012345678", [ @@ -343,9 +351,15 @@ describe("ImageSearchService", () => { }, }), ); + expect( + prisma.product.findMany.mock.calls[0][0].where.productStockCaches, + ).toEqual({ some: { stock: { gt: 0 } } }); expect(prisma.product.findMany.mock.calls[1][0].where.storeProfile).toEqual( { tier: { not: StoreTier.TIER_0 }, isOpen: true }, ); + expect( + prisma.product.findMany.mock.calls[1][0].where.productStockCaches, + ).toEqual({ some: { stock: { gt: 0 } } }); expect(analytics).toMatchObject({ productsShown: ["product-1"], zeroResults: false, diff --git a/apps/backend/src/channels/whatsapp/image-search.service.ts b/apps/backend/src/channels/whatsapp/image-search.service.ts index 42da6575..529dc1f0 100644 --- a/apps/backend/src/channels/whatsapp/image-search.service.ts +++ b/apps/backend/src/channels/whatsapp/image-search.service.ts @@ -246,6 +246,7 @@ export class ImageSearchService { deletedAt: null, productCode: { not: null }, moderationStatus: { not: ModerationStatus.BLOCKED }, + productStockCaches: { some: { stock: { gt: 0 } } }, storeProfile: { tier: { not: StoreTier.TIER_0 }, isOpen: true }, }, include: { @@ -264,6 +265,7 @@ export class ImageSearchService { await this.productDiscoveryService.storeRecentSearchResults( phone, fallbackProducts, + { lastQuery: searchQuery, source: "image" }, ); const rows = fallbackProducts.map((product, index) => ({ id: `product_result_${index + 1}`, @@ -344,6 +346,7 @@ export class ImageSearchService { await this.productDiscoveryService.storeRecentSearchResults( phone, displayProducts, + { lastQuery: searchQuery, source: "image" }, ); const rows = displayProducts.map((product, index) => ({ id: `product_result_${index + 1}`, @@ -531,6 +534,7 @@ export class ImageSearchService { deletedAt: null, productCode: { not: null }, moderationStatus: { not: ModerationStatus.BLOCKED }, + productStockCaches: { some: { stock: { gt: 0 } } }, storeProfile: { tier: { not: StoreTier.TIER_0 }, isOpen: true, @@ -699,6 +703,7 @@ export class ImageSearchService { deletedAt: null, productCode: { not: null }, moderationStatus: { not: ModerationStatus.BLOCKED }, + productStockCaches: { some: { stock: { gt: 0 } } }, storeProfile: { tier: { not: StoreTier.TIER_0 }, isOpen: true }, OR: nativeCategoryOr, }, @@ -758,6 +763,7 @@ export class ImageSearchService { deletedAt: null, productCode: { not: null }, moderationStatus: { not: ModerationStatus.BLOCKED }, + productStockCaches: { some: { stock: { gt: 0 } } }, storeProfile: { tier: { not: StoreTier.TIER_0 }, isOpen: true, diff --git a/apps/backend/src/channels/whatsapp/whatsapp-delivery-confirmation.service.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp-delivery-confirmation.service.spec.ts new file mode 100644 index 00000000..802e6b82 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-delivery-confirmation.service.spec.ts @@ -0,0 +1,422 @@ +import { BadRequestException } from "@nestjs/common"; +import { OrderStatus } from "@prisma/client"; +import { WhatsAppDeliveryConfirmationService } from "./whatsapp-delivery-confirmation.service"; +import { WhatsAppRetryableActionError } from "./whatsapp-retryable-action.error"; + +describe("WhatsAppDeliveryConfirmationService", () => { + const orderService = { + getByCodeForBuyer: jest.fn(), + listDeliveryConfirmationCandidates: jest.fn(), + confirmDelivery: jest.fn(), + }; + const redisService = { + get: jest.fn(), + getDel: jest.fn(), + set: jest.fn(), + del: jest.fn(), + incrementWithExpiry: jest.fn(), + }; + + let service: WhatsAppDeliveryConfirmationService; + + beforeEach(() => { + jest.clearAllMocks(); + redisService.get.mockResolvedValue(null); + redisService.set.mockResolvedValue(true); + redisService.del.mockResolvedValue(1); + redisService.incrementWithExpiry.mockResolvedValue(1); + service = new WhatsAppDeliveryConfirmationService( + orderService as never, + redisService as never, + ); + }); + + it("scopes an explicitly referenced eligible order before requesting its delivery code", async () => { + orderService.getByCodeForBuyer.mockResolvedValue({ + id: "order-1", + orderCode: "TWZ-123456", + status: OrderStatus.DISPATCHED, + }); + + const result = await service.start("+2348012345678", "buyer-1", { + orderReference: "twz-123456", + }); + + expect(orderService.getByCodeForBuyer).toHaveBeenCalledWith( + "TWZ-123456", + "buyer-1", + ); + expect(redisService.set).toHaveBeenCalledWith( + "wa:delivery-confirmation:+2348012345678", + expect.stringContaining('"step":"enter_code"'), + 600, + ); + expect(result.output).toEqual({ + status: "code_required", + orderReference: "TWZ-123456", + nextStep: "enter_delivery_code", + }); + }); + + it("requires an explicit selection when eligible orders exist", async () => { + orderService.listDeliveryConfirmationCandidates.mockResolvedValue([ + { + id: "order-1", + orderCode: "TWZ-111111", + status: OrderStatus.DISPATCHED, + }, + { + id: "order-2", + orderCode: "TWZ-222222", + status: OrderStatus.IN_TRANSIT, + }, + ]); + + const result = await service.start("+2348012345678", "buyer-1", {}); + + expect(result.output).toEqual({ + status: "selection_required", + ordersShownCount: 2, + nextStep: "select_order", + }); + expect(result.message).toContain("1. TWZ-111111"); + expect(result.message).toContain("2. TWZ-222222"); + expect(redisService.set).toHaveBeenCalledWith( + "wa:delivery-confirmation:+2348012345678", + expect.stringContaining('"step":"select_order"'), + 600, + ); + }); + + it("limits the selectable order list to five entries", async () => { + orderService.listDeliveryConfirmationCandidates.mockResolvedValue( + Array.from({ length: 6 }, (_, index) => ({ + id: `order-${index + 1}`, + orderCode: `TWZ-${String(index + 1).padStart(6, "0")}`, + status: OrderStatus.DISPATCHED, + })), + ); + + const result = await service.start("+2348012345678", "buyer-1", {}); + + expect(result.output).toEqual( + expect.objectContaining({ ordersShownCount: 5 }), + ); + expect(result.message).toContain("5. TWZ-000005"); + expect(result.message).not.toContain("TWZ-000006"); + expect(redisService.set).toHaveBeenCalledWith( + "wa:delivery-confirmation:+2348012345678", + expect.not.stringContaining("TWZ-000006"), + 600, + ); + }); + + it("does not handle numeric input without active delivery state", async () => { + redisService.get.mockResolvedValue(null); + + await expect( + service.handleScopedReply("+2348012345678", "buyer-1", "123456"), + ).resolves.toEqual({ handled: false }); + expect(orderService.confirmDelivery).not.toHaveBeenCalled(); + }); + + it("moves a scoped numeric order selection to the delivery-code step", async () => { + redisService.get + .mockResolvedValueOnce( + JSON.stringify({ + step: "select_order", + userId: "buyer-1", + orders: [ + { id: "order-1", orderReference: "TWZ-111111" }, + { id: "order-2", orderReference: "TWZ-222222" }, + ], + }), + ) + .mockResolvedValueOnce(null); + + const result = await service.handleScopedReply( + "+2348012345678", + "buyer-1", + "2", + ); + + expect(result).toEqual({ + handled: true, + message: + 'Order TWZ-222222 is selected. Reply with its 6-digit delivery code, or type "cancel" to stop.', + }); + expect(redisService.set).toHaveBeenCalledWith( + "wa:delivery-confirmation:+2348012345678", + expect.stringContaining('"orderId":"order-2"'), + 600, + ); + }); + + it("lets non-text messages continue through the normal WhatsApp pipeline", async () => { + redisService.get.mockResolvedValue( + JSON.stringify({ + step: "enter_code", + userId: "buyer-1", + orderId: "order-1", + orderReference: "TWZ-123456", + attempts: 0, + }), + ); + + await expect( + service.handleScopedReply("+2348012345678", "buyer-1"), + ).resolves.toEqual({ handled: false }); + expect(redisService.getDel).not.toHaveBeenCalled(); + expect(orderService.confirmDelivery).not.toHaveBeenCalled(); + }); + + it("clears the active flow when the shopper cancels", async () => { + redisService.get.mockResolvedValue( + JSON.stringify({ + step: "select_order", + userId: "buyer-1", + orders: [{ id: "order-1", orderReference: "TWZ-111111" }], + }), + ); + + const result = await service.handleScopedReply( + "+2348012345678", + "buyer-1", + "cancel", + ); + + expect(result).toEqual({ + handled: true, + message: "Delivery confirmation was cancelled.", + }); + expect(redisService.del).toHaveBeenCalledWith( + "wa:delivery-confirmation:+2348012345678", + ); + }); + + it("consumes the scoped state and delegates valid codes to the canonical order service", async () => { + const state = JSON.stringify({ + step: "enter_code", + userId: "buyer-1", + orderId: "order-1", + orderReference: "TWZ-123456", + attempts: 0, + }); + redisService.get.mockResolvedValue(state); + redisService.getDel.mockResolvedValue(state); + orderService.confirmDelivery.mockResolvedValue({ + message: "Delivery confirmed", + }); + + const result = await service.handleScopedReply( + "+2348012345678", + "buyer-1", + "654321", + ); + + expect(redisService.getDel).toHaveBeenCalledWith( + "wa:delivery-confirmation:+2348012345678", + ); + expect(orderService.confirmDelivery).toHaveBeenCalledWith( + "buyer-1", + "order-1", + "654321", + ); + expect(result.message).toBe( + "Delivery confirmed for order TWZ-123456. Thank you.", + ); + expect(redisService.del).toHaveBeenCalledWith( + "wa:delivery-confirmation-attempts:buyer-1:order-1", + ); + }); + + it("does not retry an ambiguous confirmation failure after the domain call starts", async () => { + const state = JSON.stringify({ + step: "enter_code", + userId: "buyer-1", + orderId: "order-1", + orderReference: "TWZ-123456", + attempts: 0, + }); + redisService.get.mockResolvedValue(state); + redisService.getDel.mockResolvedValue(state); + orderService.confirmDelivery.mockRejectedValue( + new Error("Database timeout"), + ); + + const result = await service.handleScopedReply( + "+2348012345678", + "buyer-1", + "654321", + ); + + expect(result).toEqual({ + handled: true, + message: + "I could not confirm the final status of order TWZ-123456. Check its current order status before trying again.", + }); + expect(redisService.set).not.toHaveBeenCalled(); + expect(redisService.del).toHaveBeenCalledWith( + "wa:delivery-confirmation-attempts:buyer-1:order-1", + ); + }); + + it("does not read delivery state for unrelated messages", async () => { + const result = await service.handleScopedReply( + "+2348012345678", + "buyer-1", + "show me black shoes", + ); + + expect(result).toEqual({ handled: false }); + expect(redisService.get).not.toHaveBeenCalled(); + }); + + it("preserves retry semantics when relevant delivery state cannot be read", async () => { + redisService.get.mockRejectedValueOnce(new Error("Redis unavailable")); + + await expect( + service.handleScopedReply("+2348012345678", "buyer-1", "654321"), + ).rejects.toBeInstanceOf(WhatsAppRetryableActionError); + }); + + it("restores scoped state for a bounded invalid-code retry", async () => { + const state = JSON.stringify({ + step: "enter_code", + userId: "buyer-1", + orderId: "order-1", + orderReference: "TWZ-123456", + attempts: 0, + }); + redisService.get.mockResolvedValue(state); + redisService.getDel.mockResolvedValue(state); + orderService.confirmDelivery.mockRejectedValue( + new BadRequestException({ + message: "Invalid delivery code", + code: "OTP_INVALID", + }), + ); + + const result = await service.handleScopedReply( + "+2348012345678", + "buyer-1", + "000000", + ); + + expect(result.message).toContain("2 attempts left"); + expect(redisService.incrementWithExpiry).toHaveBeenCalledWith( + "wa:delivery-confirmation-attempts:buyer-1:order-1", + 172800, + ); + expect(redisService.set).toHaveBeenCalledWith( + "wa:delivery-confirmation:+2348012345678", + expect.stringContaining('"attempts":1'), + 600, + ); + }); + + it("keeps the attempt limit across restarted WhatsApp flows", async () => { + orderService.getByCodeForBuyer.mockResolvedValue({ + id: "order-1", + orderCode: "TWZ-123456", + status: OrderStatus.DISPATCHED, + }); + redisService.get.mockResolvedValue("3"); + + const result = await service.start("+2348012345678", "buyer-1", { + orderReference: "TWZ-123456", + }); + + expect(result.output).toEqual({ + status: "temporarily_unavailable", + orderReference: "TWZ-123456", + }); + expect(redisService.set).not.toHaveBeenCalled(); + }); + + it("does not restore the flow after the final invalid-code attempt", async () => { + const state = JSON.stringify({ + step: "enter_code", + userId: "buyer-1", + orderId: "order-1", + orderReference: "TWZ-123456", + attempts: 2, + }); + redisService.get.mockResolvedValue(state); + redisService.getDel.mockResolvedValue(state); + redisService.incrementWithExpiry.mockResolvedValue(3); + orderService.confirmDelivery.mockRejectedValue( + new BadRequestException({ + message: "Invalid delivery code", + code: "OTP_INVALID", + }), + ); + + const result = await service.handleScopedReply( + "+2348012345678", + "buyer-1", + "000000", + ); + + expect(result.message).toContain("Start again"); + expect(redisService.set).not.toHaveBeenCalled(); + }); + + it("fails safely when the atomically consumed state has expired", async () => { + const state = JSON.stringify({ + step: "enter_code", + userId: "buyer-1", + orderId: "order-1", + orderReference: "TWZ-123456", + attempts: 0, + }); + redisService.get.mockResolvedValue(state); + redisService.getDel.mockResolvedValue(null); + + const result = await service.handleScopedReply( + "+2348012345678", + "buyer-1", + "654321", + ); + + expect(result.message).toContain("has expired"); + expect(orderService.confirmDelivery).not.toHaveBeenCalled(); + expect(redisService.set).not.toHaveBeenCalled(); + }); + + it("clears state that belongs to a different linked user", async () => { + redisService.get.mockResolvedValue( + JSON.stringify({ + step: "enter_code", + userId: "buyer-1", + orderId: "order-1", + orderReference: "TWZ-123456", + attempts: 0, + }), + ); + + const result = await service.handleScopedReply( + "+2348012345678", + "buyer-2", + "654321", + ); + + expect(result.message).toContain("has expired"); + expect(redisService.del).toHaveBeenCalledWith( + "wa:delivery-confirmation:+2348012345678", + ); + expect(orderService.confirmDelivery).not.toHaveBeenCalled(); + }); + + it("does not expose or accept an order owned by another buyer", async () => { + orderService.getByCodeForBuyer.mockResolvedValue(null); + + const result = await service.start("+2348012345678", "buyer-1", { + orderReference: "TWZ-999999", + }); + + expect(result.output.status).toBe("not_found"); + expect(result.message).not.toContain("order-"); + expect(redisService.set).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/channels/whatsapp/whatsapp-delivery-confirmation.service.ts b/apps/backend/src/channels/whatsapp/whatsapp-delivery-confirmation.service.ts new file mode 100644 index 00000000..e26ea788 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-delivery-confirmation.service.ts @@ -0,0 +1,546 @@ +import { + HttpException, + Inject, + Injectable, + Logger, + forwardRef, +} from "@nestjs/common"; +import { OrderStatus } from "@prisma/client"; +import { OrderService } from "../../domains/orders/order/order.service"; +import { RedisService } from "../../redis/redis.service"; +import type { + ShopperAgentToolInputMap, + ShopperAgentToolOutputMap, +} from "./agent/shopper-agent.types"; +import { + DELIVERY_CONFIRMATION_ATTEMPT_TTL, + DELIVERY_CONFIRMATION_MAX_CODE_ATTEMPTS, + DELIVERY_CONFIRMATION_TTL, + WA_DELIVERY_CONFIRMATION_ATTEMPTS_PREFIX, + WA_DELIVERY_CONFIRMATION_PREFIX, +} from "./whatsapp.constants"; +import { WhatsAppRetryableActionError } from "./whatsapp-retryable-action.error"; +import { maskWhatsAppPhone, normalizeWhatsAppPhone } from "./whatsapp.utils"; + +const ORDER_REFERENCE_PATTERN = /^TWZ-\d{6}$/; +const MAX_DELIVERY_CONFIRMATION_CHOICES = 5; + +interface DeliveryOrderReference { + id: string; + orderReference: string; +} + +type PendingDeliveryConfirmation = + | { + step: "select_order"; + userId: string; + orders: DeliveryOrderReference[]; + } + | { + step: "enter_code"; + userId: string; + orderId: string; + orderReference: string; + attempts: number; + }; + +interface DeliveryConfirmationPreparation { + message: string; + output: ShopperAgentToolOutputMap["confirm_delivery"]; +} + +export interface DeliveryConfirmationReplyResult { + handled: boolean; + message?: string; +} + +@Injectable() +export class WhatsAppDeliveryConfirmationService { + private readonly logger = new Logger( + WhatsAppDeliveryConfirmationService.name, + ); + + constructor( + @Inject(forwardRef(() => OrderService)) + private readonly orderService: OrderService, + private readonly redisService: RedisService, + ) {} + + async start( + phone: string, + userId: string, + input: ShopperAgentToolInputMap["confirm_delivery"], + ): Promise { + const requestedReference = this.normalizeOrderReference( + input.orderReference, + ); + + try { + if (requestedReference) { + const order = await this.orderService.getByCodeForBuyer( + requestedReference, + userId, + ); + if (!order) { + return { + message: + "I could not find that order on your linked Twizrr account.", + output: { status: "not_found" }, + }; + } + if (!this.isEligibleStatus(order.status)) { + return { + message: + "That order is not ready for delivery confirmation. Check its current delivery status first.", + output: { + status: "no_eligible_order", + orderReference: order.orderCode, + }, + }; + } + + const attempts = await this.getAttemptCount(userId, order.id); + if (attempts >= DELIVERY_CONFIRMATION_MAX_CODE_ATTEMPTS) { + return this.attemptLimitReached(order.orderCode); + } + + const stored = await this.storeState(phone, { + step: "enter_code", + userId, + orderId: order.id, + orderReference: order.orderCode, + attempts, + }); + if (!stored) { + return this.temporaryFailure(); + } + + return { + message: `Order ${order.orderCode} is selected. Reply with its 6-digit delivery code, or type "cancel" to stop.`, + output: { + status: "code_required", + orderReference: order.orderCode, + nextStep: "enter_delivery_code", + }, + }; + } + + const orders = + await this.orderService.listDeliveryConfirmationCandidates(userId); + if (orders.length === 0) { + return { + message: + "You do not have an order that is ready for delivery confirmation.", + output: { status: "no_eligible_order" }, + }; + } + + const references = orders + .slice(0, MAX_DELIVERY_CONFIRMATION_CHOICES) + .map((order) => ({ + id: order.id, + orderReference: order.orderCode, + })); + const stored = await this.storeState(phone, { + step: "select_order", + userId, + orders: references, + }); + if (!stored) { + return this.temporaryFailure(); + } + + const choices = references + .map((order, index) => `${index + 1}. ${order.orderReference}`) + .join("\n"); + return { + message: `Select the delivered order you want to confirm:\n${choices}\nReply with its number or order code. Type "cancel" to stop.`, + output: { + status: "selection_required", + ordersShownCount: references.length, + nextStep: "select_order", + }, + }; + } catch (error) { + this.logFailure("start", phone, error); + return this.temporaryFailure(); + } + } + + async handleScopedReply( + phone: string, + userId: string | null, + messageText?: string, + ): Promise { + const text = messageText?.trim() ?? ""; + if (!this.isPotentialScopedReply(text)) { + return { handled: false }; + } + + const key = this.stateKey(phone); + let raw: string | null; + try { + raw = await this.redisService.get(key); + } catch (error) { + this.logFailure("state read", phone, error); + throw new WhatsAppRetryableActionError( + "Delivery confirmation state could not be read", + error, + ); + } + + const state = raw ? this.parseState(raw) : null; + if (!state) { + return { handled: false }; + } + if (!userId || state.userId !== userId) { + await this.clearState(key, phone); + return { + handled: true, + message: + "That delivery confirmation has expired. Start the action again.", + }; + } + + if (text.toLowerCase() === "cancel") { + await this.clearState(key, phone); + return { + handled: true, + message: "Delivery confirmation was cancelled.", + }; + } + + if (state.step === "select_order") { + return this.selectOrder(phone, state, text); + } + + return this.submitCode(phone, key, state, text); + } + + private async selectOrder( + phone: string, + state: Extract, + text: string, + ): Promise { + const index = /^\d+$/.test(text) ? Number(text) - 1 : -1; + const normalizedReference = this.normalizeOrderReference(text); + const selected = + (index >= 0 && index < state.orders.length + ? state.orders[index] + : undefined) ?? + state.orders.find( + (order) => order.orderReference === normalizedReference, + ); + + if (!selected) { + return { + handled: true, + message: + 'Choose one of the listed order numbers or reply with its Twizrr order code. Type "cancel" to stop.', + }; + } + + let attempts: number; + try { + attempts = await this.getAttemptCount(state.userId, selected.id); + } catch (error) { + this.logFailure("attempt read", phone, error); + throw new WhatsAppRetryableActionError( + "Delivery confirmation attempts could not be read", + error, + ); + } + if (attempts >= DELIVERY_CONFIRMATION_MAX_CODE_ATTEMPTS) { + return { + handled: true, + message: this.attemptLimitReached(selected.orderReference).message, + }; + } + + const stored = await this.storeState(phone, { + step: "enter_code", + userId: state.userId, + orderId: selected.id, + orderReference: selected.orderReference, + attempts, + }); + if (!stored) { + return { + handled: true, + message: + "I could not save that order selection. Start delivery confirmation again.", + }; + } + + return { + handled: true, + message: `Order ${selected.orderReference} is selected. Reply with its 6-digit delivery code, or type "cancel" to stop.`, + }; + } + + private async submitCode( + phone: string, + key: string, + state: Extract, + text: string, + ): Promise { + if (!/^\d{6}$/.test(text)) { + return { + handled: true, + message: + 'Reply with the 6-digit delivery code for the selected order, or type "cancel" to stop.', + }; + } + + let consumed: string | null; + try { + consumed = await this.redisService.getDel(key); + } catch (error) { + this.logFailure("state consume", phone, error); + throw new WhatsAppRetryableActionError( + "Delivery confirmation state could not be consumed", + error, + ); + } + const consumedState = consumed ? this.parseState(consumed) : null; + if ( + !consumedState || + consumedState.step !== "enter_code" || + consumedState.userId !== state.userId || + consumedState.orderId !== state.orderId + ) { + return { + handled: true, + message: + "That delivery confirmation has expired. Start the action again.", + }; + } + + try { + await this.orderService.confirmDelivery( + consumedState.userId, + consumedState.orderId, + text, + ); + await this.clearAttempts( + consumedState.userId, + consumedState.orderId, + phone, + ); + return { + handled: true, + message: `Delivery confirmed for order ${consumedState.orderReference}. Thank you.`, + }; + } catch (error) { + return this.handleConfirmationError(phone, consumedState, error); + } + } + + private async handleConfirmationError( + phone: string, + state: Extract, + error: unknown, + ): Promise { + const code = this.domainErrorCode(error); + if (code === "OTP_INVALID") { + let attempts: number; + try { + attempts = await this.redisService.incrementWithExpiry( + this.attemptKey(state.userId, state.orderId), + DELIVERY_CONFIRMATION_ATTEMPT_TTL, + ); + } catch (attemptError) { + this.logFailure("attempt increment", phone, attemptError); + await this.storeState(phone, state); + throw new WhatsAppRetryableActionError( + "Delivery confirmation attempts could not be updated", + attemptError, + ); + } + if (attempts >= DELIVERY_CONFIRMATION_MAX_CODE_ATTEMPTS) { + return { + handled: true, + message: + "That delivery code could not be confirmed. Start again when you have the correct code.", + }; + } + await this.storeState(phone, { ...state, attempts }); + return { + handled: true, + message: `That delivery code is incorrect. You have ${DELIVERY_CONFIRMATION_MAX_CODE_ATTEMPTS - attempts} ${DELIVERY_CONFIRMATION_MAX_CODE_ATTEMPTS - attempts === 1 ? "attempt" : "attempts"} left.`, + }; + } + if (code === "OTP_EXPIRED") { + await this.clearAttempts(state.userId, state.orderId, phone); + return { + handled: true, + message: + "That delivery code has expired. Check the order in Twizrr or contact support.", + }; + } + if (error instanceof HttpException && error.getStatus() < 500) { + await this.clearAttempts(state.userId, state.orderId, phone); + return { + handled: true, + message: + "That order can no longer be confirmed from this flow. Check its current order status.", + }; + } + + this.logFailure("confirmation", phone, error); + await this.clearAttempts(state.userId, state.orderId, phone); + return { + handled: true, + message: `I could not confirm the final status of order ${state.orderReference}. Check its current order status before trying again.`, + }; + } + + private isPotentialScopedReply(text: string): boolean { + const normalized = text.trim().toLowerCase(); + return ( + normalized === "cancel" || + /^\d+$/.test(normalized) || + ORDER_REFERENCE_PATTERN.test(normalized.toUpperCase()) + ); + } + + private isEligibleStatus(status: OrderStatus): boolean { + return ( + status === OrderStatus.DISPATCHED || status === OrderStatus.IN_TRANSIT + ); + } + + private normalizeOrderReference(value?: string): string | null { + const match = value?.trim().toUpperCase().match(ORDER_REFERENCE_PATTERN); + return match?.[0] ?? null; + } + + private async storeState( + phone: string, + state: PendingDeliveryConfirmation, + ): Promise { + try { + return await this.redisService.set( + this.stateKey(phone), + JSON.stringify(state), + DELIVERY_CONFIRMATION_TTL, + ); + } catch (error) { + this.logFailure("state write", phone, error); + return false; + } + } + + private parseState(raw: string): PendingDeliveryConfirmation | null { + try { + const value = JSON.parse(raw) as PendingDeliveryConfirmation; + if ( + value?.step === "select_order" && + typeof value.userId === "string" && + Array.isArray(value.orders) && + value.orders.length > 0 && + value.orders.every( + (order) => + typeof order?.id === "string" && + typeof order.orderReference === "string" && + ORDER_REFERENCE_PATTERN.test(order.orderReference), + ) + ) { + return value; + } + if ( + value?.step === "enter_code" && + typeof value.userId === "string" && + typeof value.orderId === "string" && + typeof value.orderReference === "string" && + ORDER_REFERENCE_PATTERN.test(value.orderReference) && + Number.isInteger(value.attempts) && + value.attempts >= 0 && + value.attempts < DELIVERY_CONFIRMATION_MAX_CODE_ATTEMPTS + ) { + return value; + } + return null; + } catch { + return null; + } + } + + private domainErrorCode(error: unknown): string | null { + if (!(error instanceof HttpException)) { + return null; + } + const response = error.getResponse(); + return typeof response === "object" && + response !== null && + "code" in response && + typeof response.code === "string" + ? response.code + : null; + } + + private stateKey(phone: string): string { + return `${WA_DELIVERY_CONFIRMATION_PREFIX}${normalizeWhatsAppPhone(phone)}`; + } + + private attemptKey(userId: string, orderId: string): string { + return `${WA_DELIVERY_CONFIRMATION_ATTEMPTS_PREFIX}${userId}:${orderId}`; + } + + private async getAttemptCount( + userId: string, + orderId: string, + ): Promise { + const raw = await this.redisService.get(this.attemptKey(userId, orderId)); + const attempts = Number(raw); + return Number.isInteger(attempts) && attempts >= 0 ? attempts : 0; + } + + private async clearAttempts( + userId: string, + orderId: string, + phone: string, + ): Promise { + try { + await this.redisService.del(this.attemptKey(userId, orderId)); + } catch (error) { + this.logFailure("attempt clear", phone, error); + } + } + + private async clearState(key: string, phone: string): Promise { + try { + await this.redisService.del(key); + } catch (error) { + this.logFailure("state clear", phone, error); + } + } + + private temporaryFailure(): DeliveryConfirmationPreparation { + return { + message: + "I could not start delivery confirmation right now. Please try again.", + output: { status: "temporarily_unavailable" }, + }; + } + + private attemptLimitReached( + orderReference: string, + ): DeliveryConfirmationPreparation { + return { + message: `Delivery confirmation is temporarily unavailable for order ${orderReference} after too many incorrect code attempts. Check the order in Twizrr or contact support.`, + output: { + status: "temporarily_unavailable", + orderReference, + }, + }; + } + + private logFailure(action: string, phone: string, error: unknown): void { + this.logger.warn( + `WhatsApp delivery confirmation ${action} failed for ${maskWhatsAppPhone(phone)}: ${ + error instanceof Error ? error.message : "unknown error" + }`, + ); + } +} diff --git a/apps/backend/src/channels/whatsapp/whatsapp-intent.service.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp-intent.service.spec.ts index 3debe0d4..d11185ee 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp-intent.service.spec.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp-intent.service.spec.ts @@ -148,23 +148,132 @@ describe("WhatsAppIntentService", () => { expect(geminiClient.parseFunctionCall).not.toHaveBeenCalled(); }); - it("answers general shopping advice naturally without requiring a function action", async () => { - geminiClient.generateContent.mockResolvedValue( - "For a wedding, choose something polished and comfortable. WIZZA can help you find dresses, shirts, shoes, or accessories on Twizrr.", - ); - + it("routes general shopping recommendations through real product discovery", async () => { const intent = await service.parseIntent( "What should I wear to a wedding?", ); expect(intent).toEqual({ - functionName: "natural_answer", + functionName: "search_products", params: { - answer: - "For a wedding, choose something polished and comfortable. WIZZA can help you find dresses, shirts, shoes, or accessories on Twizrr.", + query: "What should I wear to a wedding?", }, }); expect(geminiClient.parseFunctionCall).not.toHaveBeenCalled(); + expect(geminiClient.generateContent).not.toHaveBeenCalled(); + }); + + it("routes contextual comparisons and refinements against active results", async () => { + geminiClient.isConfigured.mockReturnValue(false); + const context = { + lastQuery: "black sneakers", + source: "text" as const, + products: [ + { + rank: 1, + title: "Street Runner", + storeName: "Style Hub", + priceDisplay: "NGN 25,000", + publicProductUrl: "https://twizrr.com/stores/stylehub/p/TWZ-111111", + }, + { + rank: 2, + title: "City Runner", + storeName: "Shoe Room", + priceDisplay: "NGN 30,000", + publicProductUrl: "https://twizrr.com/stores/shoeroom/p/TWZ-222222", + }, + ], + }; + + await expect( + service.parseIntent("compare the first and second", context), + ).resolves.toEqual({ + functionName: "compare_products", + params: { productReferences: ["first", "second"] }, + }); + await expect( + service.parseIntent("show me cheaper options", context), + ).resolves.toEqual({ + functionName: "refine_product_search", + params: { refinement: "show me cheaper options" }, + }); + }); + + it("does not treat standalone budget search as a context refinement", async () => { + geminiClient.isConfigured.mockReturnValue(false); + + await expect(service.parseIntent("find bags under 50k")).resolves.toEqual({ + functionName: "search_products", + params: { query: "find bags under 50k" }, + }); + }); + + it("lets Gemini route a new budget search instead of stale discovery context", async () => { + geminiClient.parseFunctionCall.mockResolvedValue({ + name: "search_products", + args: { query: "bags under 50k" }, + }); + const staleContext = { + lastQuery: "black sneakers", + source: "text" as const, + products: [ + { + rank: 1, + title: "Street Runner", + storeName: "Style Hub", + priceDisplay: "NGN 25,000", + publicProductUrl: "https://twizrr.com/stores/stylehub/p/TWZ-111111", + }, + ], + }; + + await expect( + service.parseIntent("find bags under 50k", staleContext), + ).resolves.toEqual({ + functionName: "search_products", + params: { query: "bags under 50k" }, + }); + expect(geminiClient.parseFunctionCall).toHaveBeenCalled(); + }); + + it("gives Gemini only shopper-safe recent product context", async () => { + geminiClient.parseFunctionCall.mockResolvedValue({ + name: "compare_products", + args: { productReferences: ["first", "second"] }, + }); + const context = { + lastQuery: "phones under 200k", + source: "image" as const, + products: [ + { + rank: 1, + title: "Phone A", + storeName: "Mobile Hub", + priceDisplay: "NGN 190,000", + publicProductUrl: "https://twizrr.com/stores/mobilehub/p/TWZ-111111", + }, + { + rank: 2, + title: "Phone B", + storeName: "Device Shop", + priceDisplay: "NGN 180,000", + publicProductUrl: "https://twizrr.com/stores/deviceshop/p/TWZ-222222", + }, + ], + }; + + await service.parseIntent("Which one has better value?", context); + + const prompt = geminiClient.parseFunctionCall.mock.calls[0][0].systemPrompt; + expect(prompt).toContain("Phone A"); + expect(prompt).toContain("NGN 190,000"); + expect(prompt).toContain( + "https://twizrr.com/stores/mobilehub/p/TWZ-111111", + ); + expect(prompt).not.toMatch( + /physicalStoreId|physicalProductId|sourceCode|dropshipperCostKobo|digitalMarginKobo|embedding|vector|rankingScore/, + ); }); it("answers mild off-topic messages with natural shopping steer-back", async () => { @@ -212,11 +321,12 @@ describe("WhatsAppIntentService", () => { GEMINI_FUNCTION_DECLARATIONS.map((declaration) => declaration.name), ).toEqual(WHATSAPP_FUNCTION_REGISTRY.map((entry) => entry.name)); for (const entry of WHATSAPP_FUNCTION_REGISTRY) { - expect(entry.access).toMatch( - /^(guest_safe|linked_account_required|deterministic)$/, + expect(entry.category).toMatch( + /^(guest|linked-read|linked-write|confirmation-required)$/, ); - expect(entry.handler).toEqual(expect.any(String)); - expect(entry.handler.trim()).not.toHaveLength(0); + expect(entry.executorKey).toEqual(expect.any(String)); + expect(entry.executorKey.trim()).not.toHaveLength(0); + expect(entry.safeOutputFields.length).toBeGreaterThan(0); } }); @@ -260,6 +370,67 @@ describe("WhatsAppIntentService", () => { }); }); + it.each([ + [ + "dispute status for twz-123456", + "get_dispute_status", + { orderReference: "TWZ-123456" }, + ], + [ + "where is my refund for twz-654321", + "get_refund_status", + { orderReference: "TWZ-654321" }, + ], + ])( + "routes post-purchase status requests through linked reads: %s", + async (text, functionName, params) => { + geminiClient.isConfigured.mockReturnValue(false); + service = new WhatsAppIntentService( + configService as unknown as ConfigService, + geminiClient as unknown as GeminiClient, + ); + + await expect(service.parseIntent(text)).resolves.toEqual({ + functionName, + params, + }); + }, + ); + + it("asks the dispute tool to collect details when the shopper gives only an action", async () => { + geminiClient.isConfigured.mockReturnValue(false); + service = new WhatsAppIntentService( + configService as unknown as ConfigService, + geminiClient as unknown as GeminiClient, + ); + + await expect( + service.parseIntent("open a dispute for twz-123456"), + ).resolves.toEqual({ + functionName: "start_dispute", + params: { orderReference: "TWZ-123456" }, + }); + }); + + it("preserves the shopper's factual problem report for dispute review", async () => { + geminiClient.isConfigured.mockReturnValue(false); + service = new WhatsAppIntentService( + configService as unknown as ConfigService, + geminiClient as unknown as GeminiClient, + ); + const message = + "Order twz-123456 arrived damaged and the screen is cracked"; + + await expect(service.parseIntent(message)).resolves.toEqual({ + functionName: "start_dispute", + params: { + orderReference: "TWZ-123456", + reason: message, + description: message, + }, + }); + }); + it("does not route bare menu numbers globally without explicit menu context", async () => { geminiClient.isConfigured.mockReturnValue(false); service = new WhatsAppIntentService( @@ -333,4 +504,111 @@ describe("WhatsAppIntentService", () => { }); }, ); + + it.each([ + ["show my cart", "get_cart"], + ["show my saved delivery addresses", "get_saved_addresses"], + ["list my recent orders", "list_orders"], + ])( + "routes linked shopper read fallback text: %s", + async (text, functionName) => { + geminiClient.isConfigured.mockReturnValue(false); + service = new WhatsAppIntentService( + configService as unknown as ConfigService, + geminiClient as unknown as GeminiClient, + ); + + await expect(service.parseIntent(text)).resolves.toEqual({ + functionName, + params: {}, + }); + }, + ); + + it.each([ + ["checkout", "start_checkout", {}], + ["proceed to checkout", "start_checkout", {}], + ["pay for my cart", "start_checkout", {}], + ["complete my purchase", "start_checkout", {}], + [ + "add 2 of the first product to my cart", + "add_to_cart", + { productReference: "first", quantity: 2 }, + ], + [ + "change black sneakers quantity to 3", + "update_cart_quantity", + { cartItemReference: "black sneakers", quantity: 3 }, + ], + [ + "remove black sneakers from my cart", + "remove_from_cart", + { cartItemReference: "black sneakers" }, + ], + [ + "use my home delivery address", + "select_delivery_address", + { addressReference: "home" }, + ], + [ + "deliver to my home address", + "select_delivery_address", + { addressReference: "home" }, + ], + ])( + "routes shopper write fallback text through confirmation tools: %s", + async (text, functionName, params) => { + geminiClient.isConfigured.mockReturnValue(false); + service = new WhatsAppIntentService( + configService as unknown as ConfigService, + geminiClient as unknown as GeminiClient, + ); + + await expect(service.parseIntent(text)).resolves.toEqual({ + functionName, + params, + }); + }, + ); + + it("does not treat selecting a discovery result as checkout", async () => { + geminiClient.isConfigured.mockReturnValue(false); + service = new WhatsAppIntentService( + configService as unknown as ConfigService, + geminiClient as unknown as GeminiClient, + ); + + const result = await service.parseIntent("buy the second one"); + + expect(result.functionName).not.toBe("start_checkout"); + }); + + it("does not interpret general delivery wording as saved-address selection", async () => { + geminiClient.isConfigured.mockReturnValue(false); + service = new WhatsAppIntentService( + configService as unknown as ConfigService, + geminiClient as unknown as GeminiClient, + ); + + const result = await service.parseIntent( + "deliver to my place in Ikeja by 5pm", + ); + + expect(result.functionName).not.toBe("select_delivery_address"); + }); + + it("preserves a public order code when routing delivery status", async () => { + geminiClient.isConfigured.mockReturnValue(false); + service = new WhatsAppIntentService( + configService as unknown as ConfigService, + geminiClient as unknown as GeminiClient, + ); + + await expect( + service.parseIntent("delivery status for twz-123456"), + ).resolves.toEqual({ + functionName: "get_delivery_status", + params: { orderReference: "TWZ-123456" }, + }); + }); }); diff --git a/apps/backend/src/channels/whatsapp/whatsapp-intent.service.ts b/apps/backend/src/channels/whatsapp/whatsapp-intent.service.ts index 6476626e..78b13f0e 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp-intent.service.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp-intent.service.ts @@ -2,9 +2,11 @@ import { Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { GEMINI_FUNCTION_DECLARATIONS, + isWhatsAppCartQuantity, MINIMAL_WHATSAPP_SYSTEM_PROMPT, } from "./whatsapp.constants"; import { GeminiClient } from "../../integrations/ai/gemini.client"; +import type { ShopperDiscoveryContext } from "./agent/shopper-agent-discovery-context.types"; export interface ParsedIntent { functionName: string; @@ -48,7 +50,10 @@ export class WhatsAppIntentService { } } - async parseIntent(messageText: string): Promise { + async parseIntent( + messageText: string, + discoveryContext: ShopperDiscoveryContext | null = null, + ): Promise { const text = messageText.trim(); const lower = text.toLowerCase(); @@ -65,6 +70,10 @@ export class WhatsAppIntentService { return { functionName: "support_handoff", params: {} }; } + if (this.isGeneralShoppingAdviceQuestion(lower)) { + return { functionName: "search_products", params: { query: text } }; + } + if (this.isSafeGeneralQuestion(lower)) { return this.getNaturalAnswer(text); } @@ -72,22 +81,25 @@ export class WhatsAppIntentService { if (this.geminiClient.isConfigured()) { try { this.logger.log("Parsing WhatsApp intent with Gemini"); - const intent = await this.parseWithGemini(text); + const intent = await this.parseWithGemini(text, discoveryContext); this.logger.log(`Gemini returned intent: ${intent.functionName}`); return intent; } catch (error) { this.logger.error( `Gemini intent parsing failed: ${error instanceof Error ? error.message : error}`, ); - return this.basicKeywordMatch(text); + return this.basicKeywordMatch(text, discoveryContext); } } this.logger.log("No AI available, trying keyword intent match"); - return this.basicKeywordMatch(text); + return this.basicKeywordMatch(text, discoveryContext); } - private basicKeywordMatch(text: string): ParsedIntent { + private basicKeywordMatch( + text: string, + discoveryContext: ShopperDiscoveryContext | null = null, + ): ParsedIntent { const lower = text.toLowerCase(); if (this.isStoreManagementRequest(lower)) { @@ -98,18 +110,128 @@ export class WhatsAppIntentService { return { functionName: "support_handoff", params: {} }; } + const orderReference = this.extractOrderReference(text); + if (this.isDeliveryConfirmationRequest(lower)) { - return { functionName: "confirm_delivery", params: {} }; + return { + functionName: "confirm_delivery", + params: orderReference ? { orderReference } : {}, + }; + } + + if (this.isDisputeStatusRequest(lower)) { + return { + functionName: "get_dispute_status", + params: orderReference ? { orderReference } : {}, + }; + } + + if (this.isRefundStatusRequest(lower)) { + return { + functionName: "get_refund_status", + params: orderReference ? { orderReference } : {}, + }; + } + + if (this.isDisputeStartRequest(lower)) { + const details = text.trim(); + return { + functionName: "start_dispute", + params: { + ...(orderReference ? { orderReference } : {}), + ...(this.hasProblemDetails(lower) + ? { + reason: details.slice(0, 120), + description: details.slice(0, 2000), + } + : {}), + }, + }; + } + + if (this.isCheckoutHandoffRequest(lower)) { + return { functionName: "start_checkout", params: {} }; + } + + const addToCart = this.matchAddToCart(text); + if (addToCart) { + return { functionName: "add_to_cart", params: addToCart }; + } + + const quantityUpdate = this.matchCartQuantityUpdate(text); + if (quantityUpdate) { + return { + functionName: "update_cart_quantity", + params: quantityUpdate, + }; + } + + const cartRemoval = this.matchCartRemoval(text); + if (cartRemoval) { + return { functionName: "remove_from_cart", params: cartRemoval }; + } + + const addressSelection = this.matchAddressSelection(text); + if (addressSelection) { + return { + functionName: "select_delivery_address", + params: addressSelection, + }; + } + + if ( + lower.includes("my cart") || + lower.includes("cart contents") || + lower.includes("what is in the cart") || + lower.includes("what's in the cart") + ) { + return { functionName: "get_cart", params: {} }; + } + + if ( + lower.includes("saved address") || + lower.includes("delivery addresses") + ) { + return { functionName: "get_saved_addresses", params: {} }; + } + + if ( + lower.includes("order history") || + lower.includes("recent orders") || + lower.includes("my orders") + ) { + return { functionName: "list_orders", params: {} }; + } + + if ( + lower.includes("delivery status") || + lower.includes("delivery progress") || + lower.includes("shipment") + ) { + return { + functionName: "get_delivery_status", + params: orderReference ? { orderReference } : {}, + }; + } + + const contextualIntent = this.getContextualDiscoveryIntent( + text, + discoveryContext, + ); + if (contextualIntent) { + return contextualIntent; } if ( lower.includes("track") || - lower.includes("shipment") || lower.includes("where is my order") || lower.includes("order status") || (lower.includes("delivery") && lower.includes("order")) ) { - return { functionName: "get_order_status", params: {} }; + return { + functionName: "get_order_status", + params: orderReference ? { orderReference } : {}, + }; } if ( @@ -125,6 +247,10 @@ export class WhatsAppIntentService { return { functionName: "search_products", params: { query: text } }; } + if (this.isGeneralShoppingAdviceQuestion(lower)) { + return { functionName: "search_products", params: { query: text } }; + } + if (this.isSafeGeneralQuestion(lower)) { return { functionName: "natural_answer", @@ -144,11 +270,111 @@ export class WhatsAppIntentService { return { functionName: "friendly_fallback", params: {} }; } - private async parseWithGemini(messageText: string): Promise { + private extractOrderReference(text: string): string | null { + return text.match(/\bTWZ-\d{6}\b/i)?.[0]?.toUpperCase() ?? null; + } + + private matchAddToCart( + text: string, + ): { productReference?: string; quantity: number } | null { + const match = + /\b(?:add|put)\s+(?:(\d{1,2})\s*(?:x|of)\s+)?(.+?)\s+(?:to|in)\s+(?:my\s+)?cart\b/i.exec( + text, + ); + if (!match) { + return null; + } + + const quantity = match[1] ? Number(match[1]) : 1; + if (!isWhatsAppCartQuantity(quantity)) { + return null; + } + + const productReference = this.cleanActionReference(match[2]); + return { + ...(productReference ? { productReference } : {}), + quantity, + }; + } + + private matchCartQuantityUpdate( + text: string, + ): { cartItemReference?: string; quantity: number } | null { + const match = + /\b(?:set|change|update)\s+(.+?)\s+(?:quantity|qty)\s+(?:to\s+)?(\d{1,2})\b/i.exec( + text, + ) ?? + /\b(?:set|change|update)\s+(?:the\s+)?(?:quantity|qty)\s+(?:of\s+)?(.+?)\s+to\s+(\d{1,2})\b/i.exec( + text, + ); + if (!match) { + return null; + } + + const quantity = Number(match[2]); + if (!isWhatsAppCartQuantity(quantity)) { + return null; + } + + const cartItemReference = this.cleanActionReference(match[1]); + return { + ...(cartItemReference ? { cartItemReference } : {}), + quantity, + }; + } + + private matchCartRemoval( + text: string, + ): { cartItemReference?: string } | null { + const match = /\b(?:remove|delete)\s+(.+?)\s+from\s+(?:my\s+)?cart\b/i.exec( + text, + ); + if (!match) { + return null; + } + + const cartItemReference = this.cleanActionReference(match[1]); + return cartItemReference ? { cartItemReference } : {}; + } + + private matchAddressSelection( + text: string, + ): { addressReference?: string } | null { + const match = + /\b(?:use|select|choose)\s+(.+?)(?:\s+delivery)?\s+address\b/i.exec( + text, + ) ?? /\bdeliver\s+to\s+(.+?)\s+(?:delivery\s+)?address\s*$/i.exec(text); + if (!match) { + return null; + } + + const addressReference = this.cleanActionReference(match[1]); + return addressReference ? { addressReference } : {}; + } + + private cleanActionReference(value?: string): string | undefined { + if (!value) { + return undefined; + } + + const normalized = value + .replace(/\b(?:the|my|item|product)\b/gi, " ") + .replace(/\s+/g, " ") + .trim(); + return /^(?:it|this|that)$/i.test(normalized) + ? undefined + : normalized || undefined; + } + + private async parseWithGemini( + messageText: string, + discoveryContext: ShopperDiscoveryContext | null, + ): Promise { const systemPrompt = this.getSystemPrompt(); + const contextPrompt = this.buildDiscoveryContextPrompt(discoveryContext); const functionCallingSystemPrompt = systemPrompt - ? `${systemPrompt}\n\n${FUNCTION_CALL_PROMPT}` - : FUNCTION_CALL_PROMPT; + ? `${systemPrompt}\n\n${FUNCTION_CALL_PROMPT}${contextPrompt}` + : `${FUNCTION_CALL_PROMPT}${contextPrompt}`; const functionCall = await this.geminiClient.parseFunctionCall({ message: messageText, @@ -227,11 +453,101 @@ export class WhatsAppIntentService { return ( safeTopicPhrases.some((phrase) => lower.includes(phrase)) || - this.isGeneralShoppingAdviceQuestion(lower) || this.isMildOffTopicSteerBack(lower) ); } + private getContextualDiscoveryIntent( + messageText: string, + discoveryContext: ShopperDiscoveryContext | null, + ): ParsedIntent | null { + if (!discoveryContext?.products.length) { + return null; + } + + const lower = messageText.toLowerCase(); + if ( + /\b(?:compare|difference between|which (?:one )?is better)\b/.test(lower) + ) { + return { + functionName: "compare_products", + params: { + productReferences: this.extractProductReferences(messageText), + }, + }; + } + + const refinementPhrases = [ + "similar", + "cheaper", + "less expensive", + "more affordable", + "in black", + "in white", + "in red", + "in blue", + "near me", + "nearby", + "under ", + "below ", + "budget ", + "show another", + "show me another", + ]; + const isRefinement = refinementPhrases.some((phrase) => + lower.includes(phrase), + ); + if (!isRefinement) { + return null; + } + + const references = this.extractProductReferences(messageText); + return { + functionName: "refine_product_search", + params: { + refinement: messageText, + ...(references[0] ? { productReference: references[0] } : {}), + }, + }; + } + + private extractProductReferences(messageText: string): string[] { + const references = messageText.match( + /\b(?:first|second|third|fourth|fifth|sixth|seventh|eighth|ninth|tenth|number\s+(?:10|[1-9])|option\s+(?:10|[1-9]))\b/gi, + ); + return [ + ...new Set((references ?? []).map((value) => value.toLowerCase())), + ].slice(0, 3); + } + + private buildDiscoveryContextPrompt( + context: ShopperDiscoveryContext | null, + ): string { + if (!context?.products.length) { + return "\n\nThere is no active product result context. Do not invent or infer displayed products."; + } + + const products = context.products + .slice(0, 5) + .map((product) => { + const fields = [ + `${product.rank}. ${product.title}`, + `store: ${product.storeName}`, + product.priceDisplay ? `price: ${product.priceDisplay}` : null, + product.publicProductUrl + ? `public URL: ${product.publicProductUrl}` + : null, + ].filter((field): field is string => Boolean(field)); + return fields.join(" | "); + }) + .join("\n"); + const lastQuery = context.lastQuery + ? `Last search: ${context.lastQuery}\n` + : ""; + + return `\n\nActive shopper-safe product context (${context.source}):\n${lastQuery}${products}\nUse refine_product_search for follow-up preferences and compare_products for comparisons. References must point only to these displayed results. Never invent prices, stock, product identifiers, or private store data.`; + } + private isGeneralShoppingAdviceQuestion(lower: string): boolean { return [ "what should i wear", @@ -279,6 +595,16 @@ export class WhatsAppIntentService { ].some((phrase) => lower.includes(phrase)); } + private isCheckoutHandoffRequest(lower: string): boolean { + return [ + /\bcheck\s*out\b/, + /\bcheckout\b/, + /\bproceed\s+to\s+(?:secure\s+)?checkout\b/, + /\b(?:pay|checkout)\s+(?:for\s+)?my\s+cart\b/, + /\bcomplete\s+(?:my\s+)?(?:purchase|checkout)\b/, + ].some((pattern) => pattern.test(lower)); + } + private isSupportRequest(lower: string): boolean { return [ "support", @@ -306,6 +632,51 @@ export class WhatsAppIntentService { ); } + private isDisputeStatusRequest(lower: string): boolean { + return [ + /\bdispute\s+status\b/, + /\b(?:my|the)\s+dispute\s+status\b/, + /\bstatus\s+of\s+(?:my|the)\s+dispute\b/, + /\btrack\s+(?:my|the)\s+dispute\b/, + /\bwhat(?:'s| is)\s+happening\s+with\s+(?:my|the)\s+dispute\b/, + ].some((pattern) => pattern.test(lower)); + } + + private isRefundStatusRequest(lower: string): boolean { + return [ + /\b(?:my|the)\s+refund\s+status\b/, + /\bstatus\s+of\s+(?:my|the)\s+refund\b/, + /\btrack\s+(?:my|the)\s+refund\b/, + /\bwhere\s+is\s+(?:my|the)\s+refund\b/, + /\bhas\s+(?:my|the)\s+refund\s+been\s+(?:sent|processed|completed)\b/, + ].some((pattern) => pattern.test(lower)); + } + + private isDisputeStartRequest(lower: string): boolean { + return ( + /\b(?:open|start|raise|file)\s+(?:a\s+)?dispute\b/.test(lower) || + /\b(?:i\s+)?want\s+(?:a\s+)?refund\b/.test(lower) || + (this.hasProblemDetails(lower) && + /\b(?:order|purchase|item|product|delivery)\b/.test(lower)) + ); + } + + private hasProblemDetails(lower: string): boolean { + return [ + "wrong item", + "incorrect item", + "missing item", + "damaged", + "broken", + "defective", + "not as described", + "never arrived", + "did not arrive", + "didn't arrive", + "incomplete order", + ].some((phrase) => lower.includes(phrase)); + } + private hasProblemReportKeyword(lower: string): boolean { return [ "wrong", diff --git a/apps/backend/src/channels/whatsapp/whatsapp-post-purchase.service.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp-post-purchase.service.spec.ts new file mode 100644 index 00000000..a3974c20 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-post-purchase.service.spec.ts @@ -0,0 +1,278 @@ +import { ConflictException } from "@nestjs/common"; +import { WhatsAppPostPurchaseService } from "./whatsapp-post-purchase.service"; + +describe("WhatsAppPostPurchaseService", () => { + const orderService = { + getByCodeForBuyer: jest.fn(), + getLatestByBuyer: jest.fn(), + }; + const disputeService = { + assertBuyerCanOpenDispute: jest.fn(), + openBuyerDispute: jest.fn(), + getBuyerOrderDisputes: jest.fn(), + getBuyerSettlementVisibility: jest.fn(), + }; + const redisService = { + get: jest.fn(), + getDel: jest.fn(), + set: jest.fn(), + del: jest.fn(), + }; + + let service: WhatsAppPostPurchaseService; + + beforeEach(() => { + jest.clearAllMocks(); + orderService.getByCodeForBuyer.mockResolvedValue({ + id: "order-1", + orderCode: "TWZ-123456", + status: "DELIVERED", + }); + orderService.getLatestByBuyer.mockResolvedValue({ + id: "order-1", + orderCode: "TWZ-123456", + status: "DELIVERED", + }); + disputeService.assertBuyerCanOpenDispute.mockResolvedValue(undefined); + disputeService.openBuyerDispute.mockResolvedValue({ id: "dispute-1" }); + disputeService.getBuyerOrderDisputes.mockResolvedValue([]); + disputeService.getBuyerSettlementVisibility.mockResolvedValue(null); + redisService.get.mockResolvedValue(null); + redisService.getDel.mockResolvedValue(null); + redisService.set.mockResolvedValue(true); + redisService.del.mockResolvedValue(1); + + service = new WhatsAppPostPurchaseService( + orderService as never, + disputeService as never, + redisService as never, + ); + }); + + it("collects factual details before preparing a dispute", async () => { + const result = await service.prepareDispute("+2348012345678", "buyer-1", { + orderReference: "TWZ-123456", + }); + + expect(result.output).toEqual({ + status: "details_required", + nextStep: "describe_order_problem", + }); + expect(orderService.getByCodeForBuyer).not.toHaveBeenCalled(); + expect(redisService.set).not.toHaveBeenCalled(); + expect(disputeService.openBuyerDispute).not.toHaveBeenCalled(); + }); + + it("stages an eligible owned dispute without opening it before confirmation", async () => { + const result = await service.prepareDispute("+2348012345678", "buyer-1", { + orderReference: "twz-123456", + reason: "Damaged item", + description: "The screen arrived cracked.", + requestedOutcome: "Refund", + }); + + expect(orderService.getByCodeForBuyer).toHaveBeenCalledWith( + "TWZ-123456", + "buyer-1", + ); + expect(disputeService.assertBuyerCanOpenDispute).toHaveBeenCalledWith( + "order-1", + "buyer-1", + ); + expect(redisService.set).toHaveBeenCalledWith( + "wa:pending-dispute:+2348012345678", + JSON.stringify({ + userId: "buyer-1", + orderId: "order-1", + orderReference: "TWZ-123456", + reason: "Damaged item", + description: "The screen arrived cracked.", + requestedOutcome: "Refund", + }), + 300, + ); + expect(disputeService.openBuyerDispute).not.toHaveBeenCalled(); + expect(result.output).toEqual({ + status: "confirmation_required", + orderReference: "TWZ-123456", + nextStep: "confirm_or_cancel", + }); + expect(result.confirmationButtons).toBe(true); + }); + + it("directs an existing dispute to the status flow", async () => { + disputeService.assertBuyerCanOpenDispute.mockRejectedValue( + new ConflictException({ + code: "DISPUTE_ALREADY_OPEN", + message: "An active dispute already exists", + }), + ); + + const result = await service.prepareDispute("+2348012345678", "buyer-1", { + reason: "Damaged item", + description: "The screen arrived cracked.", + }); + + expect(result.output).toEqual({ + status: "existing_dispute", + orderReference: "TWZ-123456", + nextStep: "get_dispute_status", + }); + expect(redisService.set).not.toHaveBeenCalled(); + }); + + it("consumes a pending dispute once before calling the canonical domain service", async () => { + const pending = JSON.stringify({ + userId: "buyer-1", + orderId: "order-1", + orderReference: "TWZ-123456", + reason: "Damaged item", + description: "The screen arrived cracked.", + requestedOutcome: "Refund", + }); + redisService.get.mockResolvedValue(pending); + redisService.getDel.mockResolvedValueOnce(pending).mockResolvedValue(null); + + const first = await service.handlePendingReply( + "+2348012345678", + "buyer-1", + "confirm", + ); + const second = await service.handlePendingReply( + "+2348012345678", + "buyer-1", + "confirm", + ); + + expect(redisService.getDel).toHaveBeenCalledTimes(2); + expect(disputeService.openBuyerDispute).toHaveBeenCalledTimes(1); + expect(disputeService.openBuyerDispute).toHaveBeenCalledWith( + "order-1", + "buyer-1", + { + reason: "Damaged item", + description: "The screen arrived cracked.", + buyerRequestedOutcome: "Refund", + }, + ); + expect(first.message).toContain("has been opened"); + expect(second.message).toContain("has expired"); + }); + + it("does not retry an ambiguous dispute failure after the domain call starts", async () => { + const pending = JSON.stringify({ + userId: "buyer-1", + orderId: "order-1", + orderReference: "TWZ-123456", + reason: "Damaged item", + description: "The screen arrived cracked.", + }); + redisService.get.mockResolvedValue(pending); + redisService.getDel.mockResolvedValue(pending); + disputeService.openBuyerDispute.mockRejectedValue( + new Error("Database timeout"), + ); + + const result = await service.handlePendingReply( + "+2348012345678", + "buyer-1", + "confirm", + ); + + expect(result.message).toContain( + "Check its dispute status before starting another request", + ); + expect(redisService.set).not.toHaveBeenCalled(); + }); + + it("clears a pending dispute when the shopper moves to another request", async () => { + redisService.get.mockResolvedValue( + JSON.stringify({ + userId: "buyer-1", + orderId: "order-1", + orderReference: "TWZ-123456", + reason: "Damaged item", + description: "The screen arrived cracked.", + }), + ); + + const result = await service.handlePendingReply( + "+2348012345678", + "buyer-1", + "show my orders", + ); + + expect(result).toEqual({ handled: false }); + expect(redisService.del).toHaveBeenCalledWith( + "wa:pending-dispute:+2348012345678", + ); + }); + + it("returns a shopper-safe current dispute status", async () => { + disputeService.getBuyerOrderDisputes.mockResolvedValue([ + { + id: "dispute-private-id", + status: "UNDER_REVIEW", + storeId: "private-store-id", + }, + ]); + + const result = await service.getDisputeStatus("buyer-1", { + orderReference: "TWZ-123456", + }); + + expect(result.output).toEqual({ + status: "available", + orderReference: "TWZ-123456", + disputeStatus: "under review", + }); + expect(JSON.stringify(result.output)).not.toContain("private"); + }); + + it("returns real refund settlement state without provider or counterparty data", async () => { + disputeService.getBuyerOrderDisputes.mockResolvedValue([ + { id: "dispute-1", status: "RESOLVED" }, + ]); + disputeService.getBuyerSettlementVisibility.mockResolvedValue({ + outcome: "REFUND_BUYER", + settlementStatus: "PROCESSING", + amountKobo: "2500000", + status: "SUBMITTED", + providerReference: "private-provider-reference", + merchantPayoutAmountKobo: "5000000", + }); + + const result = await service.getRefundStatus("buyer-1", { + orderReference: "TWZ-123456", + }); + + expect(disputeService.getBuyerSettlementVisibility).toHaveBeenCalledWith( + "dispute-1", + "buyer-1", + ); + expect(result.output).toEqual({ + status: "available", + orderReference: "TWZ-123456", + refundStatus: "submitted", + refundAmountDisplay: "NGN 25,000.00", + }); + expect(JSON.stringify(result)).not.toContain("private-provider-reference"); + expect(JSON.stringify(result)).not.toContain("merchantPayoutAmountKobo"); + }); + + it("does not claim a refund when no buyer settlement exists", async () => { + disputeService.getBuyerOrderDisputes.mockResolvedValue([ + { id: "dispute-1", status: "RESOLVED" }, + ]); + + const result = await service.getRefundStatus("buyer-1", { + orderReference: "TWZ-123456", + }); + + expect(result.output).toEqual({ + status: "none", + orderReference: "TWZ-123456", + }); + expect(result.message).toContain("no approved refund recorded"); + }); +}); diff --git a/apps/backend/src/channels/whatsapp/whatsapp-post-purchase.service.ts b/apps/backend/src/channels/whatsapp/whatsapp-post-purchase.service.ts new file mode 100644 index 00000000..ba8e6b65 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-post-purchase.service.ts @@ -0,0 +1,515 @@ +import { + HttpStatus, + Inject, + Injectable, + Logger, + forwardRef, +} from "@nestjs/common"; +import { DisputeService } from "../../domains/orders/dispute/dispute.service"; +import { OrderService } from "../../domains/orders/order/order.service"; +import { RedisService } from "../../redis/redis.service"; +import type { + ShopperAgentToolInputMap, + ShopperAgentToolOutputMap, +} from "./agent/shopper-agent.types"; +import { + SHOPPER_ACTION_CONFIRMATION_TTL, + WA_PENDING_DISPUTE_PREFIX, + WA_SHOPPER_ACTION_CANCEL_ID, + WA_SHOPPER_ACTION_CONFIRM_ID, +} from "./whatsapp.constants"; +import { WhatsAppRetryableActionError } from "./whatsapp-retryable-action.error"; +import { maskWhatsAppPhone, normalizeWhatsAppPhone } from "./whatsapp.utils"; + +interface PostPurchaseResult< + Name extends "start_dispute" | "get_dispute_status" | "get_refund_status", +> { + message: string; + output: ShopperAgentToolOutputMap[Name]; + confirmationButtons?: boolean; +} + +interface PendingDispute { + userId: string; + orderId: string; + orderReference: string; + reason: string; + description: string; + requestedOutcome?: string; +} + +interface PendingDisputeReplyResult { + handled: boolean; + message?: string; +} + +interface BuyerOrder { + id: string; + orderCode: string; + status: string; +} + +@Injectable() +export class WhatsAppPostPurchaseService { + private readonly logger = new Logger(WhatsAppPostPurchaseService.name); + + constructor( + @Inject(forwardRef(() => OrderService)) + private readonly orderService: OrderService, + private readonly disputeService: DisputeService, + private readonly redisService: RedisService, + ) {} + + async prepareDispute( + phone: string, + userId: string, + input: ShopperAgentToolInputMap["start_dispute"], + ): Promise> { + const reason = this.normalizeText(input.reason, 120); + const description = this.normalizeText(input.description, 2000); + if (!reason || !description) { + return { + message: + "Tell me what went wrong with the order before I prepare a dispute. Include what happened and what you want Twizrr to review.", + output: { + status: "details_required", + nextStep: "describe_order_problem", + }, + }; + } + + const order = await this.resolveOrder(userId, input.orderReference); + if (!order) { + return { + message: + "I could not find that order in your linked Twizrr account. Check the order code and try again.", + output: { status: "not_found" }, + }; + } + + try { + await this.disputeService.assertBuyerCanOpenDispute(order.id, userId); + } catch (error) { + return this.mapDisputePreparationError(order.orderCode, error); + } + + const requestedOutcome = this.normalizeText(input.requestedOutcome, 500); + const stored = await this.storePendingDispute(phone, { + userId, + orderId: order.id, + orderReference: order.orderCode, + reason, + description, + ...(requestedOutcome ? { requestedOutcome } : {}), + }); + if (!stored) { + return this.temporaryFailure(); + } + + return { + message: [ + `Open a dispute for order ${order.orderCode}?`, + `Reason: ${reason}`, + "Twizrr will place the order under review. Confirm only if these details are correct.", + ].join("\n"), + output: { + status: "confirmation_required", + orderReference: order.orderCode, + nextStep: "confirm_or_cancel", + }, + confirmationButtons: true, + }; + } + + async getDisputeStatus( + userId: string, + input: ShopperAgentToolInputMap["get_dispute_status"], + ): Promise> { + const order = await this.resolveOrder(userId, input.orderReference); + if (!order) { + return { + message: + "I could not find that order in your linked Twizrr account. Check the order code and try again.", + output: { status: "not_found" }, + }; + } + + const disputes = await this.disputeService.getBuyerOrderDisputes( + order.id, + userId, + ); + const latest = this.firstRecord(disputes); + if (!latest) { + return { + message: `There is no dispute recorded for order ${order.orderCode}.`, + output: { + status: "none", + orderReference: order.orderCode, + }, + }; + } + + const disputeStatus = this.formatStatus(latest.status); + return { + message: `The dispute for order ${order.orderCode} is ${disputeStatus}.`, + output: { + status: "available", + orderReference: order.orderCode, + disputeStatus, + }, + }; + } + + async getRefundStatus( + userId: string, + input: ShopperAgentToolInputMap["get_refund_status"], + ): Promise> { + const order = await this.resolveOrder(userId, input.orderReference); + if (!order) { + return { + message: + "I could not find that order in your linked Twizrr account. Check the order code and try again.", + output: { status: "not_found" }, + }; + } + + const disputes = await this.disputeService.getBuyerOrderDisputes( + order.id, + userId, + ); + const latest = this.firstRecord(disputes); + const disputeId = this.readString(latest?.id); + if (!disputeId) { + return this.noRefund(order.orderCode); + } + + const settlement = await this.disputeService.getBuyerSettlementVisibility( + disputeId, + userId, + ); + if (!settlement || BigInt(settlement.amountKobo) <= 0n) { + return this.noRefund(order.orderCode); + } + + const refundStatus = this.formatStatus( + settlement.status || settlement.settlementStatus, + ); + const refundAmountDisplay = this.formatKobo(settlement.amountKobo); + return { + message: `The refund for order ${order.orderCode} is ${refundStatus}. Amount: ${refundAmountDisplay}.`, + output: { + status: "available", + orderReference: order.orderCode, + refundStatus, + refundAmountDisplay, + }, + }; + } + + async handlePendingReply( + phone: string, + userId: string | null, + messageText?: string, + interactiveReply?: { id: string }, + ): Promise { + const key = this.pendingKey(phone); + const reply = interactiveReply?.id.trim().toLowerCase(); + const text = messageText?.trim().toLowerCase(); + const confirms = + reply === WA_SHOPPER_ACTION_CONFIRM_ID || + text === "confirm" || + text === "yes"; + const cancels = + reply === WA_SHOPPER_ACTION_CANCEL_ID || + text === "cancel" || + text === "no"; + let raw: string | null; + try { + raw = await this.redisService.get(key); + } catch (error) { + this.logFailure("pending dispute read", phone, error); + if (confirms || cancels) { + throw new WhatsAppRetryableActionError( + "Pending dispute could not be read", + error, + ); + } + return { handled: false }; + } + if (!raw) { + return { handled: false }; + } + + if (!confirms && !cancels) { + try { + await this.redisService.del(key); + } catch (error) { + this.logFailure("stale pending dispute clear", phone, error); + } + return { handled: false }; + } + if (cancels) { + try { + await this.redisService.del(key); + } catch (error) { + this.logFailure("pending dispute clear", phone, error); + throw new WhatsAppRetryableActionError( + "Pending dispute could not be cancelled", + error, + ); + } + return { handled: true, message: "The dispute request was cancelled." }; + } + + let consumed: string | null; + try { + consumed = await this.redisService.getDel(key); + } catch (error) { + this.logFailure("pending dispute consume", phone, error); + throw new WhatsAppRetryableActionError( + "Pending dispute could not be consumed", + error, + ); + } + + const pending = consumed ? this.parsePendingDispute(consumed) : null; + if (!pending || !userId || pending.userId !== userId) { + return { + handled: true, + message: "That dispute confirmation has expired. Start it again.", + }; + } + + try { + await this.disputeService.openBuyerDispute( + pending.orderId, + pending.userId, + { + reason: pending.reason, + description: pending.description, + ...(pending.requestedOutcome + ? { buyerRequestedOutcome: pending.requestedOutcome } + : {}), + }, + ); + return { + handled: true, + message: `Your dispute for order ${pending.orderReference} has been opened. Twizrr will review it and notify you when its status changes.`, + }; + } catch (error) { + const safeMessage = this.safeOpenError( + pending.orderReference, + phone, + error, + ); + if (!safeMessage) { + return { + handled: true, + message: `I could not confirm whether the dispute for order ${pending.orderReference} was opened. Check its dispute status before starting another request.`, + }; + } + return { + handled: true, + message: safeMessage, + }; + } + } + + private async resolveOrder( + userId: string, + orderReference?: string, + ): Promise { + if (orderReference) { + return this.orderService.getByCodeForBuyer( + orderReference.trim().toUpperCase(), + userId, + ); + } + + return this.orderService.getLatestByBuyer(userId); + } + + private mapDisputePreparationError( + orderReference: string, + error: unknown, + ): PostPurchaseResult<"start_dispute"> { + const code = this.errorCode(error); + if (code === "DISPUTE_ALREADY_OPEN") { + return { + message: `Order ${orderReference} already has an active dispute. Ask for its dispute status instead.`, + output: { + status: "existing_dispute", + orderReference, + nextStep: "get_dispute_status", + }, + }; + } + if (code === "ORDER_NOT_DISPUTABLE" || code === "DISPUTE_WINDOW_CLOSED") { + return { + message: + "That order is not eligible for a new dispute. Contact Twizrr support if you still need help with it.", + output: { + status: "support_required", + orderReference, + nextStep: "support_handoff", + }, + }; + } + + this.logFailure("dispute eligibility check", orderReference, error); + return this.temporaryFailure(); + } + + private safeOpenError( + orderReference: string, + phone: string, + error: unknown, + ): string | null { + const code = this.errorCode(error); + if (code === "DISPUTE_ALREADY_OPEN") { + return `Order ${orderReference} already has an active dispute. Ask for its dispute status instead.`; + } + if (code === "ORDER_NOT_DISPUTABLE" || code === "DISPUTE_WINDOW_CLOSED") { + return "That order is no longer eligible for a new dispute. Contact Twizrr support if you still need help."; + } + this.logFailure("dispute open", phone, error); + return null; + } + + private noRefund( + orderReference: string, + ): PostPurchaseResult<"get_refund_status"> { + return { + message: `There is no approved refund recorded for order ${orderReference}.`, + output: { + status: "none", + orderReference, + }, + }; + } + + private async storePendingDispute( + phone: string, + pending: PendingDispute, + ): Promise { + try { + await this.redisService.set( + this.pendingKey(phone), + JSON.stringify(pending), + SHOPPER_ACTION_CONFIRMATION_TTL, + ); + return true; + } catch (error) { + this.logFailure("pending dispute write", phone, error); + return false; + } + } + + private parsePendingDispute(value: string): PendingDispute | null { + try { + const parsed: unknown = JSON.parse(value); + if (!this.isRecord(parsed)) { + return null; + } + const userId = this.readString(parsed.userId); + const orderId = this.readString(parsed.orderId); + const orderReference = this.readString(parsed.orderReference); + const reason = this.readString(parsed.reason); + const description = this.readString(parsed.description); + const requestedOutcome = this.readString(parsed.requestedOutcome); + if (!userId || !orderId || !orderReference || !reason || !description) { + return null; + } + return { + userId, + orderId, + orderReference, + reason, + description, + ...(requestedOutcome ? { requestedOutcome } : {}), + }; + } catch { + return null; + } + } + + private firstRecord(value: unknown): Record | null { + if (!Array.isArray(value) || !this.isRecord(value[0])) { + return null; + } + return value[0]; + } + + private temporaryFailure(): PostPurchaseResult<"start_dispute"> { + return { + message: + "I could not prepare that dispute request right now. Please try again.", + output: { status: "temporarily_unavailable" }, + }; + } + + private errorCode(error: unknown): string | null { + if (!this.isRecord(error)) { + return null; + } + if (typeof error.getResponse === "function") { + const response = (error.getResponse as () => unknown)(); + if (this.isRecord(response)) { + return this.readString(response.code); + } + } + const status = + typeof error.getStatus === "function" + ? (error.getStatus as () => unknown)() + : null; + if (status === HttpStatus.NOT_FOUND) { + return "ORDER_NOT_FOUND"; + } + return null; + } + + private formatStatus(value: unknown): string { + return ( + this.readString(value)?.toLowerCase().replace(/_/g, " ") ?? "pending" + ); + } + + private formatKobo(value: string): string { + const kobo = BigInt(value); + const naira = kobo / 100n; + const remainder = (kobo % 100n).toString().padStart(2, "0"); + return `NGN ${naira.toLocaleString("en-NG")}.${remainder}`; + } + + private normalizeText(value: string | undefined, maxLength: number) { + const normalized = value?.trim().replace(/\s+/g, " "); + return normalized ? normalized.slice(0, maxLength) : null; + } + + private pendingKey(phone: string): string { + return `${WA_PENDING_DISPUTE_PREFIX}${normalizeWhatsAppPhone(phone)}`; + } + + private logFailure( + operation: string, + reference: string, + error: unknown, + ): void { + const safeReference = reference.startsWith("+") + ? maskWhatsAppPhone(reference) + : reference; + this.logger.warn( + `${operation} failed for ${safeReference}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + + private readString(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; + } + + private isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); + } +} diff --git a/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.spec.ts index a30d940e..060adc66 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.spec.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.spec.ts @@ -132,6 +132,7 @@ describe("WhatsAppProductDiscoveryService", () => { deletedAt: null, productCode: { not: null }, moderationStatus: { not: ModerationStatus.BLOCKED }, + productStockCaches: { some: { stock: { gt: 0 } } }, storeProfile: { tier: { not: StoreTier.TIER_0 }, isOpen: true }, OR: [ { @@ -214,10 +215,19 @@ describe("WhatsAppProductDiscoveryService", () => { storeHandle: "stylehub", productUrl: "https://twizrr.com/stores/stylehub/p/TWZ-RED-001", priceDisplay: "NGN 2,500", + priceKobo: "250000", }, ]), 900, ); + expect(redisService.set).toHaveBeenCalledWith( + "wa:discovery-context:+2348012345678", + JSON.stringify({ + lastQuery: "red shoes", + source: "text", + }), + 900, + ); }); it("never uses businessName in shopper-facing replies and falls back to public fields only", async () => { @@ -413,6 +423,46 @@ describe("WhatsAppProductDiscoveryService", () => { expect(detailMessage).not.toContain("Style Hub Legal Ltd"); }); + it("clears recent selection state when a selected product fails live revalidation", async () => { + redisService.get.mockResolvedValue( + JSON.stringify([ + { + rank: 1, + listingType: "NATIVE", + productId: "product-1", + productCode: "TWZ-ONE-001", + title: "Everyday Runner", + storeName: "City Kicks", + storeHandle: "citykicks", + productUrl: "https://twizrr.com/stores/citykicks/p/TWZ-ONE-001", + priceDisplay: "NGN 30,000", + }, + ]), + ); + prisma.product.findFirst.mockResolvedValue(null); + + await expect( + service.sendProductSelectionFromRecentResults("2348012345678", { + interactiveReply: { + type: "list_reply", + id: "product_result_1", + title: "Everyday Runner", + }, + }), + ).resolves.toMatchObject({ + handled: true, + intentSuccessful: false, + errorType: "STALE_PRODUCT_SELECTION", + }); + + expect(redisService.del).toHaveBeenCalledWith( + "wa:recent-products:+2348012345678", + ); + expect(redisService.del).toHaveBeenCalledWith( + "wa:discovery-context:+2348012345678", + ); + }); + it("resolves sourced product selections with canonical URLs only", async () => { redisService.get.mockResolvedValue( JSON.stringify([ @@ -807,6 +857,7 @@ describe("WhatsAppProductDiscoveryService", () => { deletedAt: null, productCode: { not: null }, moderationStatus: { not: ModerationStatus.BLOCKED }, + productStockCaches: { some: { stock: { gt: 0 } } }, storeProfile: { tier: { not: StoreTier.TIER_0 }, isOpen: true, @@ -1301,4 +1352,572 @@ describe("WhatsAppProductDiscoveryService", () => { expect.stringContaining("+2348012345678"), ); }); + + it("returns only shopper-safe fields in conversational discovery context", async () => { + redisService.get + .mockResolvedValueOnce( + JSON.stringify([ + { + rank: 1, + listingType: "SOURCED", + productId: "private-sourced-id", + productCode: "TWZ-839201", + title: "Curated Sneakers", + storeName: "Gadget Plug", + storeHandle: "gadgetplug", + productUrl: "https://twizrr.com/stores/gadgetplug/p/TWZ-839201", + priceDisplay: "NGN 18,000", + priceKobo: "1800000", + }, + ]), + ) + .mockResolvedValueOnce( + JSON.stringify({ lastQuery: "sneakers", source: "image" }), + ); + + const context = await service.getConversationContext("2348012345678"); + + expect(context).toEqual({ + lastQuery: "sneakers", + source: "image", + products: [ + { + rank: 1, + title: "Curated Sneakers", + storeName: "Gadget Plug", + priceDisplay: "NGN 18,000", + publicProductUrl: "https://twizrr.com/stores/gadgetplug/p/TWZ-839201", + }, + ], + }); + expectShopperSafeOutput(context); + expect(JSON.stringify(context)).not.toContain("private-sourced-id"); + }); + + it("uses text as the legacy source when recent results predate context storage", async () => { + redisService.get + .mockResolvedValueOnce( + JSON.stringify([ + { + rank: 1, + listingType: "NATIVE", + productId: "product-1", + productCode: "TWZ-RED-001", + title: "Red Shoes", + storeName: "Style Hub", + }, + ]), + ) + .mockResolvedValueOnce(null); + + await expect( + service.getConversationContext("2348012345678"), + ).resolves.toMatchObject({ + lastQuery: null, + source: "text", + }); + }); + + it("asks for an initial search when a refinement has no recent context", async () => { + redisService.get.mockResolvedValueOnce(null); + + await expect( + service.sendRefinedProductSearch( + "2348012345678", + "show me cheaper options", + undefined, + { + locationText: null, + source: "none", + countryCode: null, + state: null, + city: null, + area: null, + }, + ), + ).resolves.toMatchObject({ + productsShownCount: 0, + dropOffStep: "missing_discovery_context", + }); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining("Search for a product first"), + ); + expect(prisma.product.findMany).not.toHaveBeenCalled(); + }); + + it("limits cheaper refinements below the referenced product price", async () => { + redisService.get + .mockResolvedValueOnce( + JSON.stringify([ + { + rank: 1, + listingType: "NATIVE", + productId: "product-1", + productCode: "TWZ-RED-001", + title: "Red Shoes", + storeName: "Style Hub", + priceDisplay: "NGN 2,500", + priceKobo: "250000", + }, + ]), + ) + .mockResolvedValueOnce( + JSON.stringify({ lastQuery: "red shoes", source: "text" }), + ); + prisma.product.findMany.mockResolvedValue([]); + + await service.sendRefinedProductSearch( + "2348012345678", + "show me cheaper options", + "first", + { + locationText: null, + source: "none", + countryCode: null, + state: null, + city: null, + area: null, + }, + ); + + expect(prisma.product.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + AND: [ + { + OR: [ + { retailPriceKobo: { lte: 249999n } }, + { pricePerUnitKobo: { lte: 249999n } }, + ], + }, + ], + }), + }), + ); + }); + + it("keeps repeated refinements anchored to the original discovery query", async () => { + const recentResults = JSON.stringify([ + { + rank: 1, + listingType: "NATIVE", + productId: "product-1", + productCode: "TWZ-RED-001", + title: "Red Shoes", + storeName: "Style Hub", + priceDisplay: "NGN 2,500", + priceKobo: "250000", + }, + ]); + const storedContext = JSON.stringify({ + lastQuery: "red shoes", + source: "text", + }); + redisService.get + .mockResolvedValueOnce(recentResults) + .mockResolvedValueOnce(storedContext) + .mockResolvedValueOnce(recentResults) + .mockResolvedValueOnce(storedContext); + const searchSpy = jest + .spyOn(service, "sendGenericProductSearch") + .mockResolvedValue({ + searchQuery: null, + productsRankedCount: 0, + productsShown: [], + productsShownCount: 0, + zeroResults: true, + intentSuccessful: true, + vectorSearchUsed: false, + embeddingModel: null, + }); + const locationContext = { + locationText: null, + source: "none" as const, + countryCode: null, + state: null, + city: null, + area: null, + }; + + await service.sendRefinedProductSearch( + "2348012345678", + "show me cheaper options", + undefined, + locationContext, + ); + await service.sendRefinedProductSearch( + "2348012345678", + "only black ones", + undefined, + locationContext, + ); + + expect(searchSpy.mock.calls[0][1]).toBe( + "red shoes show me cheaper options", + ); + expect(searchSpy.mock.calls[1][1]).toBe("red shoes only black ones"); + expect(searchSpy.mock.calls[0][4]).toEqual({ + lastQuery: "red shoes", + source: "text", + }); + expect(searchSpy.mock.calls[1][4]).toEqual({ + lastQuery: "red shoes", + source: "text", + }); + }); + + it("does not claim one product is cheaper when live prices are equal", async () => { + redisService.get.mockResolvedValueOnce( + JSON.stringify([ + { + rank: 1, + listingType: "NATIVE", + productId: "product-1", + productCode: "TWZ-ONE-001", + title: "Everyday Runner", + storeName: "City Kicks", + storeHandle: "citykicks", + productUrl: "https://twizrr.com/stores/citykicks/p/TWZ-ONE-001", + priceDisplay: "NGN 30,000", + priceKobo: "3000000", + }, + { + rank: 2, + listingType: "NATIVE", + productId: "product-2", + productCode: "TWZ-TWO-002", + title: "Premium Runner", + storeName: "Urban Sole", + storeHandle: "urbansole", + productUrl: "https://twizrr.com/stores/urbansole/p/TWZ-TWO-002", + priceDisplay: "NGN 30,000", + priceKobo: "3000000", + }, + ]), + ); + prisma.product.findFirst + .mockResolvedValueOnce({ + id: "product-1", + productCode: "TWZ-ONE-001", + name: "Everyday Runner", + description: "Comfortable daily sneakers", + retailPriceKobo: 3000000n, + pricePerUnitKobo: null, + storeProfile: { + storeHandle: "citykicks", + storeName: "City Kicks", + }, + }) + .mockResolvedValueOnce({ + id: "product-2", + productCode: "TWZ-TWO-002", + name: "Premium Runner", + description: "Premium daily sneakers", + retailPriceKobo: 3000000n, + pricePerUnitKobo: null, + storeProfile: { + storeHandle: "urbansole", + storeName: "Urban Sole", + }, + }); + + await service.sendRecentProductComparison("2348012345678", [ + "first", + "second", + ]); + + const outbound = interactiveService.sendTextMessage.mock.calls[0][1]; + expect(outbound).not.toContain("lower-priced option"); + }); + + it("revalidates live eligible products before sending a shopper-safe comparison", async () => { + redisService.get.mockResolvedValueOnce( + JSON.stringify([ + { + rank: 1, + listingType: "NATIVE", + productId: "product-1", + productCode: "TWZ-ONE-001", + title: "Everyday Runner", + storeName: "City Kicks", + storeHandle: "citykicks", + productUrl: "https://twizrr.com/stores/citykicks/p/TWZ-ONE-001", + priceDisplay: "NGN 30,000", + priceKobo: 3000000, + }, + { + rank: 2, + listingType: "NATIVE", + productId: "product-2", + productCode: "TWZ-TWO-002", + title: "Premium Runner", + storeName: "Urban Sole", + storeHandle: "urbansole", + productUrl: "https://twizrr.com/stores/urbansole/p/TWZ-TWO-002", + priceDisplay: "NGN 45,000", + priceKobo: 4500000, + }, + ]), + ); + prisma.product.findFirst + .mockResolvedValueOnce({ + id: "product-1", + productCode: "TWZ-ONE-001", + name: "Everyday Runner", + shortDescription: "Comfortable daily sneakers", + description: "Comfortable daily sneakers with a soft sole", + retailPriceKobo: 3000000n, + pricePerUnitKobo: null, + storeProfile: { + storeHandle: "citykicks", + storeName: "City Kicks", + }, + }) + .mockResolvedValueOnce({ + id: "product-2", + productCode: "TWZ-TWO-002", + name: "Premium Runner", + shortDescription: "Premium leather sneakers", + description: "Premium leather sneakers for everyday wear", + retailPriceKobo: 4500000n, + pricePerUnitKobo: null, + storeProfile: { + storeHandle: "urbansole", + storeName: "Urban Sole", + }, + }); + + const result = await service.sendRecentProductComparison("2348012345678", [ + "first", + "second", + ]); + + expect(prisma.product.findFirst).toHaveBeenCalledTimes(2); + expect(result).toEqual({ + handled: true, + productsComparedCount: 2, + publicProductUrls: [ + "https://twizrr.com/stores/citykicks/p/TWZ-ONE-001", + "https://twizrr.com/stores/urbansole/p/TWZ-TWO-002", + ], + errorType: null, + }); + const outbound = interactiveService.sendTextMessage.mock.calls[0][1]; + expect(outbound).toContain("Everyday Runner"); + expect(outbound).toContain("Premium Runner"); + expect(outbound).toContain( + "https://twizrr.com/stores/citykicks/p/TWZ-ONE-001", + ); + expect(outbound).toContain("Everyday Runner is the lower-priced option."); + expectShopperSafeOutput(outbound); + }); + + it("clears recent selection state when a comparison no longer has two eligible products", async () => { + redisService.get.mockResolvedValueOnce( + JSON.stringify([ + { + rank: 1, + listingType: "NATIVE", + productId: "product-1", + productCode: "TWZ-ONE-001", + title: "Everyday Runner", + storeName: "City Kicks", + storeHandle: "citykicks", + productUrl: "https://twizrr.com/stores/citykicks/p/TWZ-ONE-001", + priceDisplay: "NGN 30,000", + }, + { + rank: 2, + listingType: "NATIVE", + productId: "product-2", + productCode: "TWZ-TWO-002", + title: "Premium Runner", + storeName: "Urban Sole", + storeHandle: "urbansole", + productUrl: "https://twizrr.com/stores/urbansole/p/TWZ-TWO-002", + priceDisplay: "NGN 45,000", + }, + ]), + ); + prisma.product.findFirst + .mockResolvedValueOnce({ + id: "product-1", + productCode: "TWZ-ONE-001", + name: "Everyday Runner", + shortDescription: "Comfortable daily sneakers", + description: "Comfortable daily sneakers with a soft sole", + retailPriceKobo: 3000000n, + pricePerUnitKobo: null, + storeProfile: { + storeHandle: "citykicks", + storeName: "City Kicks", + }, + }) + .mockResolvedValueOnce(null); + + await expect( + service.sendRecentProductComparison("2348012345678", ["first", "second"]), + ).resolves.toMatchObject({ + handled: true, + productsComparedCount: 0, + errorType: "STALE_PRODUCT_SELECTION", + }); + + expect(redisService.del).toHaveBeenCalledWith( + "wa:recent-products:+2348012345678", + ); + expect(redisService.del).toHaveBeenCalledWith( + "wa:discovery-context:+2348012345678", + ); + }); + + it("revalidates a recent native result before returning its internal cart id", async () => { + redisService.get.mockResolvedValueOnce( + JSON.stringify([ + { + rank: 1, + listingType: "NATIVE", + productId: "product-1", + productCode: "TWZ-ONE-001", + title: "Everyday Runner", + storeName: "City Kicks", + storeHandle: "citykicks", + productUrl: "https://twizrr.com/stores/citykicks/p/TWZ-ONE-001", + priceDisplay: "NGN 30,000", + priceKobo: "3000000", + }, + ]), + ); + prisma.product.findFirst.mockResolvedValue({ + id: "product-1", + productCode: "TWZ-ONE-001", + name: "Everyday Runner", + title: null, + variants: [], + }); + + await expect( + service.resolveRecentProductForCart("2348012345678", "first"), + ).resolves.toEqual({ + status: "available", + productId: "product-1", + title: "Everyday Runner", + productCode: "TWZ-ONE-001", + }); + expect(prisma.product.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + id: "product-1", + productCode: "TWZ-ONE-001", + status: ProductStatus.ACTIVE, + isActive: true, + productStockCaches: { some: { stock: { gt: 0 } } }, + storeProfile: { + tier: { not: StoreTier.TIER_0 }, + isOpen: true, + }, + }), + select: expect.objectContaining({ + variants: expect.any(Object), + }), + }), + ); + }); + + it("clears recent selection state when a native cart product is no longer eligible", async () => { + redisService.get.mockResolvedValueOnce( + JSON.stringify([ + { + rank: 1, + listingType: "NATIVE", + productId: "product-1", + productCode: "TWZ-ONE-001", + title: "Everyday Runner", + storeName: "City Kicks", + storeHandle: "citykicks", + productUrl: "https://twizrr.com/stores/citykicks/p/TWZ-ONE-001", + priceDisplay: "NGN 30,000", + priceKobo: "3000000", + }, + ]), + ); + prisma.product.findFirst.mockResolvedValue(null); + + await expect( + service.resolveRecentProductForCart("2348012345678", "first"), + ).resolves.toEqual({ status: "not_found" }); + + expect(redisService.del).toHaveBeenCalledWith( + "wa:recent-products:+2348012345678", + ); + expect(redisService.del).toHaveBeenCalledWith( + "wa:discovery-context:+2348012345678", + ); + }); + + it("keeps sourced listings out of the native WhatsApp cart path", async () => { + redisService.get.mockResolvedValueOnce( + JSON.stringify([ + { + rank: 1, + listingType: "SOURCED", + productId: "private-sourced-id", + productCode: "TWZ-SOURCE-001", + title: "Sourced Runner", + storeName: "Digital Kicks", + storeHandle: "digitalkicks", + productUrl: "https://twizrr.com/stores/digitalkicks/p/TWZ-SOURCE-001", + priceDisplay: "NGN 35,000", + priceKobo: "3500000", + }, + ]), + ); + + const result = await service.resolveRecentProductForCart( + "2348012345678", + "first", + ); + + expect(result).toEqual({ + status: "sourced_unsupported", + publicProductUrl: + "https://twizrr.com/stores/digitalkicks/p/TWZ-SOURCE-001", + }); + expect(prisma.product.findFirst).not.toHaveBeenCalled(); + expectShopperSafeOutput(result); + }); + + it("requires variant choice on the canonical web product page", async () => { + redisService.get.mockResolvedValueOnce( + JSON.stringify([ + { + rank: 1, + listingType: "NATIVE", + productId: "product-1", + productCode: "TWZ-ONE-001", + title: "Everyday Runner", + storeName: "City Kicks", + storeHandle: "citykicks", + productUrl: "https://twizrr.com/stores/citykicks/p/TWZ-ONE-001", + priceDisplay: "NGN 30,000", + priceKobo: "3000000", + }, + ]), + ); + prisma.product.findFirst.mockResolvedValue({ + id: "product-1", + productCode: "TWZ-ONE-001", + name: "Everyday Runner", + title: null, + variants: [{ id: "variant-1" }], + }); + + await expect( + service.resolveRecentProductForCart("2348012345678", "first"), + ).resolves.toEqual({ + status: "variant_unsupported", + publicProductUrl: "https://twizrr.com/stores/citykicks/p/TWZ-ONE-001", + }); + }); }); diff --git a/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.ts b/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.ts index 528c7ab6..2e04d637 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp-product-discovery.service.ts @@ -16,6 +16,7 @@ import { PrismaService } from "../../prisma/prisma.service"; import { RedisService } from "../../redis/redis.service"; import { SESSION_TTL, + WA_DISCOVERY_CONTEXT_PREFIX, WA_PENDING_TEXT_SEARCH_PREFIX, WA_RECENT_PRODUCTS_PREFIX, } from "./whatsapp.constants"; @@ -36,6 +37,7 @@ import { TaxonomyZeroResultSignal, } from "../../domains/commerce/search/search-zero-result"; import { WizzaDiscoveryLocationContext } from "./wizza-location-resolver.service"; +import type { ShopperDiscoveryContext } from "./agent/shopper-agent-discovery-context.types"; export interface RecentWhatsAppProductResult { rank: number; @@ -47,6 +49,7 @@ export interface RecentWhatsAppProductResult { storeHandle?: string; productUrl?: string; priceDisplay?: string; + priceKobo?: bigint; } interface ProductSelectionInput { @@ -94,6 +97,23 @@ export interface WhatsAppProductSelectionAnalytics { dropOffStep?: string | null; } +export type WhatsAppCartProductResolution = + | { + status: "available"; + productId: string; + title: string; + productCode: string | null; + } + | { + status: + | "no_context" + | "not_found" + | "ambiguous" + | "sourced_unsupported" + | "variant_unsupported"; + publicProductUrl?: string; + }; + export interface WhatsAppProductLinkRecord { id: string; listingType?: ProductListingType; @@ -129,6 +149,22 @@ interface ProductSearchOutcome { embeddingModel: string | null; } +interface ProductSearchFilters { + maxPriceKobo?: bigint; +} + +interface StoredDiscoveryContext { + lastQuery: string | null; + source: "text" | "image"; +} + +export interface WhatsAppProductComparisonAnalytics { + handled: boolean; + productsComparedCount: number; + publicProductUrls: string[]; + errorType?: string | null; +} + type ProductDetailRecord = ProductListRecord & { shortDescription?: string | null; description?: string | null; @@ -217,6 +253,8 @@ export class WhatsAppProductDiscoveryService { city: null, area: null, }, + filters: ProductSearchFilters = {}, + discoveryContext?: StoredDiscoveryContext, ): Promise { const searchText = query.trim(); @@ -243,12 +281,16 @@ export class WhatsAppProductDiscoveryService { } let { products, candidateCount, vectorSearchUsed, embeddingModel } = - await this.searchProducts(searchText, locationContext.locationText); + await this.searchProducts( + searchText, + locationContext.locationText, + filters, + ); let locationWidened = false; if (products.length === 0 && locationContext.locationText) { ({ products, candidateCount, vectorSearchUsed, embeddingModel } = - await this.searchProducts(searchText)); + await this.searchProducts(searchText, null, filters)); locationWidened = products.length > 0; } @@ -287,7 +329,14 @@ export class WhatsAppProductDiscoveryService { } const displayProducts = products.slice(0, MAX_RESULTS); - await this.storeRecentSearchResults(phone, displayProducts); + await this.storeRecentSearchResults( + phone, + displayProducts, + discoveryContext ?? { + lastQuery: searchText, + source: "text", + }, + ); const rows = displayProducts.map((product, index) => ({ id: `product_result_${index + 1}`, @@ -325,6 +374,7 @@ export class WhatsAppProductDiscoveryService { async storeRecentSearchResults( phone: string, products: ProductListRecord[], + context?: StoredDiscoveryContext, ): Promise { const normalizedPhone = normalizeWhatsAppPhone(phone); const compactResults = products.slice(0, 10).map((product, index) => { @@ -344,16 +394,28 @@ export class WhatsAppProductDiscoveryService { ...(storeHandle ? { storeHandle } : {}), ...(productUrl ? { productUrl } : {}), priceDisplay: this.formatProductPrice(product), + priceKobo: this.getProductPriceKobo(product).toString(), }; }); try { await this.clearPendingTextSearch(normalizedPhone); - await this.redisService.set( - `${WA_RECENT_PRODUCTS_PREFIX}${normalizedPhone}`, - JSON.stringify(compactResults), - SESSION_TTL, - ); + await Promise.all([ + this.redisService.set( + `${WA_RECENT_PRODUCTS_PREFIX}${normalizedPhone}`, + JSON.stringify(compactResults), + SESSION_TTL, + ), + ...(context + ? [ + this.redisService.set( + `${WA_DISCOVERY_CONTEXT_PREFIX}${normalizedPhone}`, + JSON.stringify(context), + SESSION_TTL, + ), + ] + : []), + ]); } catch (error) { this.logger.warn( `Recent product result cache write failed for ${maskWhatsAppPhone( @@ -387,6 +449,9 @@ export class WhatsAppProductDiscoveryService { try { await Promise.all([ this.redisService.del(`${WA_RECENT_PRODUCTS_PREFIX}${normalizedPhone}`), + this.redisService.del( + `${WA_DISCOVERY_CONTEXT_PREFIX}${normalizedPhone}`, + ), this.redisService.del( `${WA_PENDING_TEXT_SEARCH_PREFIX}${normalizedPhone}`, ), @@ -436,9 +501,12 @@ export class WhatsAppProductDiscoveryService { async clearRecentSearchResults(phone: string): Promise { const normalizedPhone = normalizeWhatsAppPhone(phone); try { - await this.redisService.del( - `${WA_RECENT_PRODUCTS_PREFIX}${normalizedPhone}`, - ); + await Promise.all([ + this.redisService.del(`${WA_RECENT_PRODUCTS_PREFIX}${normalizedPhone}`), + this.redisService.del( + `${WA_DISCOVERY_CONTEXT_PREFIX}${normalizedPhone}`, + ), + ]); } catch (error) { this.logger.warn( `Recent product result cache clear failed for ${maskWhatsAppPhone( @@ -448,6 +516,279 @@ export class WhatsAppProductDiscoveryService { } } + async getConversationContext( + phone: string, + ): Promise { + const normalizedPhone = normalizeWhatsAppPhone(phone); + const products = await this.readRecentSearchResults(normalizedPhone); + if (!products.length) { + return null; + } + + const storedContext = (await this.readStoredDiscoveryContext( + normalizedPhone, + )) ?? { + lastQuery: null, + source: "text" as const, + }; + + return { + ...storedContext, + products: products.map((product) => ({ + rank: product.rank, + title: product.title, + storeName: product.storeName, + ...(product.priceDisplay ? { priceDisplay: product.priceDisplay } : {}), + ...(product.productUrl ? { publicProductUrl: product.productUrl } : {}), + })), + }; + } + + async sendRefinedProductSearch( + phone: string, + refinement: string, + productReference: string | undefined, + locationContext: WizzaDiscoveryLocationContext, + ): Promise { + const recent = await this.readRecentSearchResults(phone); + if (!recent.length) { + await this.interactiveService.sendTextMessage( + phone, + "Search for a product first, then I can show similar, cheaper, colour, budget, or nearby options.", + ); + return { + ...this.emptySearchAnalytics(), + dropOffStep: "missing_discovery_context", + }; + } + + const stored = await this.readStoredDiscoveryContext(phone); + const referenced = productReference + ? this.findRecentReference(recent, productReference) + : null; + const anchor = referenced ?? recent[0]; + const baseQuery = stored?.lastQuery || anchor.title; + const maxPriceKobo = this.resolveMaxPriceKobo(refinement, anchor); + const refinedQuery = `${baseQuery} ${refinement}`.trim(); + + return this.sendGenericProductSearch( + phone, + refinedQuery, + locationContext, + maxPriceKobo ? { maxPriceKobo } : {}, + { + lastQuery: baseQuery, + source: stored?.source ?? "text", + }, + ); + } + + async sendRecentProductComparison( + phone: string, + references: string[] = [], + ): Promise { + const recent = await this.readRecentSearchResults(phone); + if (recent.length < 2) { + await this.interactiveService.sendTextMessage( + phone, + "Search for products first, then ask me to compare two of the results.", + ); + return { + handled: true, + productsComparedCount: 0, + publicProductUrls: [], + errorType: "MISSING_DISCOVERY_CONTEXT", + }; + } + + const selected = ( + references.length + ? references + .map((reference) => this.findRecentReference(recent, reference)) + .filter( + (result): result is RecentWhatsAppProductResult => + result !== null, + ) + : recent.slice(0, 2) + ).filter( + (result, index, results) => + results.findIndex( + (candidate) => + candidate.listingType === result.listingType && + candidate.productId === result.productId, + ) === index, + ); + + if (selected.length < 2) { + await this.interactiveService.sendTextMessage( + phone, + "I could not match two active results to compare. Refer to them by number, such as compare the first and second.", + ); + return { + handled: true, + productsComparedCount: 0, + publicProductUrls: [], + errorType: "AMBIGUOUS_PRODUCT_COMPARISON", + }; + } + + const revalidated = ( + await Promise.all( + selected.slice(0, 3).map(async (result) => { + const reference = this.toProductSelectionReference(result); + const product = + reference.listingType === "SOURCED" + ? await this.findSourcedProductDetail(reference) + : await this.findNativeProductDetail(reference); + return product ? { result, product } : null; + }), + ) + ).filter( + ( + entry, + ): entry is { + result: RecentWhatsAppProductResult; + product: ProductDetailRecord; + } => entry !== null, + ); + + if (revalidated.length < 2) { + await this.clearRecentSearchResults(phone); + await this.sendStaleSelectionMessage(phone); + return { + handled: true, + productsComparedCount: 0, + publicProductUrls: [], + errorType: "STALE_PRODUCT_SELECTION", + }; + } + + const comparisonLines = revalidated.map(({ result, product }, index) => { + const description = ( + product.shortDescription || + product.description || + "No description is available." + ) + .replace(/\s+/g, " ") + .trim() + .substring(0, 100); + const url = result.productUrl ? `\nOpen: ${result.productUrl}` : ""; + return `${index + 1}. ${this.getProductTitle(product)}\n${this.formatProductPrice( + product, + )} from ${this.getStoreName(product.storeProfile)}\n${description}${url}`; + }); + const priced = revalidated + .map(({ product }) => ({ + title: this.getProductTitle(product), + priceKobo: this.getProductPriceKobo(product), + })) + .filter((item) => item.priceKobo > 0n) + .sort((left, right) => + left.priceKobo < right.priceKobo + ? -1 + : left.priceKobo > right.priceKobo + ? 1 + : 0, + ); + const priceSummary = + priced.length > 1 && priced[0].priceKobo < priced[1].priceKobo + ? `\n\n${priced[0].title} is the lower-priced option.` + : ""; + + await this.interactiveService.sendTextMessage( + phone, + `Here is a quick comparison:\n\n${comparisonLines.join( + "\n\n", + )}${priceSummary}`, + ); + + return { + handled: true, + productsComparedCount: revalidated.length, + publicProductUrls: revalidated + .map(({ result }) => result.productUrl) + .filter((url): url is string => Boolean(url)), + errorType: null, + }; + } + + async resolveRecentProductForCart( + phone: string, + productReference?: string, + ): Promise { + const recent = await this.readRecentSearchResults(phone); + if (!recent.length) { + return { status: "no_context" }; + } + + const selected = productReference + ? this.findRecentReference(recent, productReference) + : recent.length === 1 + ? recent[0] + : null; + + if (!selected) { + return { status: "ambiguous" }; + } + + if (selected.listingType === "SOURCED") { + return { + status: "sourced_unsupported", + ...(selected.productUrl + ? { publicProductUrl: selected.productUrl } + : {}), + }; + } + + const product = await this.prisma.product.findFirst({ + where: { + id: selected.productId, + ...(selected.productCode ? { productCode: selected.productCode } : {}), + status: ProductStatus.ACTIVE, + isActive: true, + deletedAt: null, + moderationStatus: { not: ModerationStatus.BLOCKED }, + productStockCaches: { some: { stock: { gt: 0 } } }, + storeProfile: { + tier: { not: StoreTier.TIER_0 }, + isOpen: true, + }, + }, + select: { + id: true, + name: true, + title: true, + productCode: true, + variants: { + where: { isActive: true }, + select: { id: true }, + take: 1, + }, + }, + }); + + if (!product) { + await this.clearRecentSearchResults(phone); + return { status: "not_found" }; + } + + if (product.variants.length > 0) { + return { + status: "variant_unsupported", + ...(selected.productUrl + ? { publicProductUrl: selected.productUrl } + : {}), + }; + } + + return { + status: "available", + productId: product.id, + title: product.title || product.name, + productCode: product.productCode, + }; + } + async sendProductSelectionFromRecentResults( phone: string, input: ProductSelectionInput, @@ -569,6 +910,7 @@ export class WhatsAppProductDiscoveryService { private async searchProducts( query: string, location: string | null = null, + filters: ProductSearchFilters = {}, ): Promise { const queryVector = await this.generateQueryVector(query); const vectorCandidateProductIds = queryVector @@ -593,6 +935,8 @@ export class WhatsAppProductDiscoveryService { deletedAt: null, productCode: { not: null }, moderationStatus: { not: ModerationStatus.BLOCKED }, + productStockCaches: { some: { stock: { gt: 0 } } }, + ...this.buildNativePriceFilter(filters.maxPriceKobo), storeProfile: { tier: { not: StoreTier.TIER_0 }, isOpen: true, @@ -623,6 +967,8 @@ export class WhatsAppProductDiscoveryService { deletedAt: null, productCode: { not: null }, moderationStatus: { not: ModerationStatus.BLOCKED }, + productStockCaches: { some: { stock: { gt: 0 } } }, + ...this.buildNativePriceFilter(filters.maxPriceKobo), storeProfile: { tier: { not: StoreTier.TIER_0 }, isOpen: true, @@ -648,6 +994,7 @@ export class WhatsAppProductDiscoveryService { this.prisma.sourcedProduct.findMany({ where: { isActive: true, + ...this.buildSourcedPriceFilter(filters.maxPriceKobo), digitalStore: { tier: { not: StoreTier.TIER_0 }, isOpen: true, @@ -700,6 +1047,7 @@ export class WhatsAppProductDiscoveryService { ? this.prisma.sourcedProduct.findMany({ where: { isActive: true, + ...this.buildSourcedPriceFilter(filters.maxPriceKobo), digitalStore: { tier: { not: StoreTier.TIER_0 }, isOpen: true, @@ -768,7 +1116,12 @@ export class WhatsAppProductDiscoveryService { this.toSourcedProductListRecord(product), ), // Defensive guard — the queries above already exclude Tier 0 stores. - ].filter((candidate) => candidate.storeProfile?.tier !== StoreTier.TIER_0); + ].filter( + (candidate) => + candidate.storeProfile?.tier !== StoreTier.TIER_0 && + (filters.maxPriceKobo === undefined || + this.getProductPriceKobo(candidate) <= filters.maxPriceKobo), + ); if (candidates.length === 0) { return { @@ -823,6 +1176,35 @@ export class WhatsAppProductDiscoveryService { }; } + private buildNativePriceFilter( + maxPriceKobo?: bigint, + ): Prisma.ProductWhereInput { + return maxPriceKobo === undefined + ? {} + : { + AND: [ + { + OR: [ + { retailPriceKobo: { lte: maxPriceKobo } }, + { pricePerUnitKobo: { lte: maxPriceKobo } }, + ], + }, + ], + }; + } + + private buildSourcedPriceFilter( + maxPriceKobo?: bigint, + ): Prisma.SourcedProductWhereInput { + return maxPriceKobo === undefined + ? {} + : { + sellingPriceKobo: { + lte: maxPriceKobo, + }, + }; + } + private buildProductKeywordSearchConditions( query: string, ): Prisma.ProductWhereInput[] { @@ -1000,6 +1382,7 @@ export class WhatsAppProductDiscoveryService { // taxonomy discovery helper. Deterministic, no external calls. private resolveTaxonomyRankingHints(query: string): TaxonomyRankingHints { const hints = this.taxonomyDiscovery.resolveDiscoveryHints(query); + return { parentCategoryLabels: hints.parentCategoryLabels, subcategoryLabels: hints.subcategoryLabels, @@ -1017,6 +1400,7 @@ export class WhatsAppProductDiscoveryService { : await this.findNativeProductDetail(reference); if (!product) { + await this.clearRecentSearchResults(phone); await this.sendStaleSelectionMessage(phone); return false; } @@ -1041,6 +1425,7 @@ export class WhatsAppProductDiscoveryService { isActive: true, deletedAt: null, moderationStatus: { not: ModerationStatus.BLOCKED }, + productStockCaches: { some: { stock: { gt: 0 } } }, storeProfile: { tier: { not: StoreTier.TIER_0 }, isOpen: true, @@ -1149,6 +1534,7 @@ export class WhatsAppProductDiscoveryService { } const record = value as Record; + const priceKobo = this.parseCachedKobo(record.priceKobo); if ( typeof record.rank !== "number" || !Number.isInteger(record.rank) || @@ -1180,9 +1566,158 @@ export class WhatsAppProductDiscoveryService { typeof record.priceDisplay === "string" ? record.priceDisplay : undefined, + ...(priceKobo !== undefined ? { priceKobo } : {}), + }; + } + + private emptySearchAnalytics(): WhatsAppProductSearchAnalytics { + return { + searchQuery: null, + productsRankedCount: 0, + productsShown: [], + productsShownCount: 0, + zeroResults: true, + intentSuccessful: true, + vectorSearchUsed: false, + embeddingModel: null, }; } + private getProductPriceKobo(product: { + retailPriceKobo?: bigint | number | null; + pricePerUnitKobo?: bigint | number | null; + }): bigint { + const value = product.retailPriceKobo ?? product.pricePerUnitKobo ?? 0n; + if (typeof value === "bigint") { + return value >= 0n ? value : 0n; + } + + return Number.isSafeInteger(value) && value >= 0 ? BigInt(value) : 0n; + } + + private parseCachedKobo(value: unknown): bigint | undefined { + if (typeof value === "string" && /^\d+$/.test(value)) { + return BigInt(value); + } + + if ( + typeof value === "number" && + Number.isSafeInteger(value) && + value >= 0 + ) { + return BigInt(value); + } + + return undefined; + } + + private isStoredDiscoveryContext( + value: unknown, + ): value is StoredDiscoveryContext { + if (!value || typeof value !== "object") { + return false; + } + + const record = value as Record; + return ( + (record.lastQuery === null || typeof record.lastQuery === "string") && + (record.source === "text" || record.source === "image") + ); + } + + private async readStoredDiscoveryContext( + phone: string, + ): Promise { + const normalizedPhone = normalizeWhatsAppPhone(phone); + + try { + const cached = await this.redisService.get( + `${WA_DISCOVERY_CONTEXT_PREFIX}${normalizedPhone}`, + ); + if (!cached) { + return null; + } + + const parsed: unknown = JSON.parse(cached); + return this.isStoredDiscoveryContext(parsed) ? parsed : null; + } catch (error) { + this.logger.warn( + `Discovery context cache read failed for ${maskWhatsAppPhone( + normalizedPhone, + )}: ${error instanceof Error ? error.message : error}`, + ); + return null; + } + } + + private findRecentReference( + results: RecentWhatsAppProductResult[], + reference: string, + ): RecentWhatsAppProductResult | null { + const normalized = this.normalizeSearchText(reference); + if (!normalized) { + return null; + } + + const numeric = this.parseIndexReference(normalized); + if (numeric) { + return results.find((result) => result.rank === numeric) ?? null; + } + + const ordinal = ORDINAL_INDEX[normalized]; + if (ordinal) { + return results.find((result) => result.rank === ordinal) ?? null; + } + + const tokens = normalized.split(" ").filter(Boolean); + const matches = results.filter((result) => { + const title = this.normalizeSearchText(result.title); + return ( + title.includes(normalized) || + tokens.every((token) => title.includes(token)) + ); + }); + + return matches.length === 1 ? matches[0] : null; + } + + private resolveMaxPriceKobo( + refinement: string, + anchor: RecentWhatsAppProductResult, + ): bigint | undefined { + const normalized = refinement.toLowerCase().replace(/,/g, ""); + const explicitBudget = + /(?:under|below|less than|budget(?: of)?|up to|max(?:imum)?(?: of)?)\s*(?:ngn|n|\u20a6)?\s*(\d+(?:\.\d+)?)\s*([km])?/i.exec( + normalized, + ); + + if (explicitBudget) { + const multiplierKobo = + explicitBudget[2]?.toLowerCase() === "m" + ? 100_000_000n + : explicitBudget[2]?.toLowerCase() === "k" + ? 100_000n + : 100n; + const [wholePart, fractionalPart = ""] = explicitBudget[1].split("."); + const denominator = 10n ** BigInt(fractionalPart.length); + const digits = BigInt(`${wholePart}${fractionalPart}`); + const kobo = (digits * multiplierKobo + denominator / 2n) / denominator; + return kobo > 0n ? kobo : undefined; + } + + if ( + /\b(?:cheaper|less expensive|lower priced|lower price|more affordable)\b/i.test( + normalized, + ) && + anchor.priceKobo !== undefined && + anchor.priceKobo > 1n + ) { + return anchor.priceKobo - 1n; + } + + return undefined; + } + private parseProductRowSelection(interactiveReply?: { type: string; id: string; diff --git a/apps/backend/src/channels/whatsapp/whatsapp-retryable-action.error.ts b/apps/backend/src/channels/whatsapp/whatsapp-retryable-action.error.ts new file mode 100644 index 00000000..9ba56928 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-retryable-action.error.ts @@ -0,0 +1,9 @@ +export class WhatsAppRetryableActionError extends Error { + readonly cause: unknown; + + constructor(message: string, cause?: unknown) { + super(message); + this.name = "WhatsAppRetryableActionError"; + this.cause = cause; + } +} diff --git a/apps/backend/src/channels/whatsapp/whatsapp-shared.module.ts b/apps/backend/src/channels/whatsapp/whatsapp-shared.module.ts index 005a1aec..9f2ad375 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp-shared.module.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp-shared.module.ts @@ -15,6 +15,9 @@ import { WhatsAppProductDiscoveryService } from "./whatsapp-product-discovery.se import { WhatsAppSessionService } from "./whatsapp-session.service"; import { WhatsAppAnalyticsService } from "./whatsapp-analytics.service"; import { WizzaLocationResolver } from "./wizza-location-resolver.service"; +import { ShopperAgentOrchestrator } from "./agent/shopper-agent-orchestrator.service"; +import { ShopperAgentPolicyService } from "./agent/shopper-agent-policy.service"; +import { ShopperAgentToolRegistry } from "./agent/shopper-agent-tool.registry"; @Module({ imports: [ @@ -35,6 +38,9 @@ import { WizzaLocationResolver } from "./wizza-location-resolver.service"; WhatsAppSessionService, WhatsAppAnalyticsService, WizzaLocationResolver, + ShopperAgentToolRegistry, + ShopperAgentPolicyService, + ShopperAgentOrchestrator, ], exports: [ WhatsAppInteractiveService, @@ -45,6 +51,9 @@ import { WizzaLocationResolver } from "./wizza-location-resolver.service"; WhatsAppSessionService, WhatsAppAnalyticsService, WizzaLocationResolver, + ShopperAgentToolRegistry, + ShopperAgentPolicyService, + ShopperAgentOrchestrator, AiModule, CloudinaryModule, MetaWhatsAppModule, diff --git a/apps/backend/src/channels/whatsapp/whatsapp-shopper-action.service.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp-shopper-action.service.spec.ts new file mode 100644 index 00000000..075074cc --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-shopper-action.service.spec.ts @@ -0,0 +1,489 @@ +import { NotFoundException } from "@nestjs/common"; +import { WhatsAppShopperActionService } from "./whatsapp-shopper-action.service"; +import { WhatsAppRetryableActionError } from "./whatsapp-retryable-action.error"; +import { + WA_SELECTED_DELIVERY_ADDRESS_PREFIX, + WA_SHOPPER_ACTION_CANCEL_ID, + WA_SHOPPER_ACTION_CONFIRM_ID, +} from "./whatsapp.constants"; + +describe("WhatsAppShopperActionService", () => { + const cartService = { + getCart: jest.fn(), + addItemToCart: jest.fn(), + updateItemQuantity: jest.fn(), + removeItemFromCart: jest.fn(), + }; + const userService = { + getAddresses: jest.fn(), + }; + const redisService = { + get: jest.fn(), + getDel: jest.fn(), + set: jest.fn(), + del: jest.fn(), + }; + const productDiscoveryService = { + resolveRecentProductForCart: jest.fn(), + }; + const configService = { + get: jest.fn((key: string) => + key === "app.webUrl" ? "https://app.twizrr.com" : undefined, + ), + }; + + let service: WhatsAppShopperActionService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new WhatsAppShopperActionService( + cartService as never, + userService as never, + redisService as never, + productDiscoveryService as never, + configService as never, + ); + }); + + it("prepares a recent native product without mutating the cart", async () => { + productDiscoveryService.resolveRecentProductForCart.mockResolvedValue({ + status: "available", + productId: "product-1", + title: "Black Sneakers", + productCode: "TWZ-123456", + }); + + const result = await service.prepareAddToCart("+2348012345678", "user-1", { + productReference: "first", + quantity: 2, + }); + + expect( + productDiscoveryService.resolveRecentProductForCart, + ).toHaveBeenCalledWith("+2348012345678", "first"); + expect(redisService.set).toHaveBeenCalledWith( + expect.stringContaining("2348012345678"), + expect.stringContaining('"action":"add_to_cart"'), + 300, + ); + expect(cartService.addItemToCart).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + confirmationButtons: true, + output: { + status: "confirmation_required", + nextStep: "confirm_or_cancel", + }, + }); + }); + + it("does not request confirmation when pending action storage fails", async () => { + productDiscoveryService.resolveRecentProductForCart.mockResolvedValue({ + status: "available", + productId: "product-1", + title: "Black Sneakers", + productCode: "TWZ-123456", + }); + redisService.set.mockRejectedValueOnce(new Error("Redis unavailable")); + + const result = await service.prepareAddToCart("+2348012345678", "user-1", { + productReference: "first", + quantity: 1, + }); + + expect(result).toEqual({ + message: "I could not stage that change, please try again.", + output: { status: "temporarily_unavailable" }, + }); + expect(result).not.toHaveProperty("confirmationButtons"); + expect(cartService.addItemToCart).not.toHaveBeenCalled(); + }); + + it("consumes confirmation once before adding to the cart", async () => { + const pending = JSON.stringify({ + action: "add_to_cart", + userId: "user-1", + productId: "product-1", + productTitle: "Black Sneakers", + quantity: 2, + }); + redisService.get.mockResolvedValue(pending); + redisService.getDel + .mockResolvedValueOnce(pending) + .mockResolvedValueOnce(null); + cartService.addItemToCart.mockResolvedValue({ + items: [{ quantity: 2 }], + }); + + const first = await service.handlePendingActionReply( + "+2348012345678", + "user-1", + undefined, + { id: WA_SHOPPER_ACTION_CONFIRM_ID }, + ); + const second = await service.handlePendingActionReply( + "+2348012345678", + "user-1", + "confirm", + ); + + expect(cartService.addItemToCart).toHaveBeenCalledTimes(1); + expect(cartService.addItemToCart).toHaveBeenCalledWith("user-1", { + productId: "product-1", + quantity: 2, + }); + expect(first.message).toContain("Added Black Sneakers"); + expect(second.message).toContain("expired"); + }); + + it("cancels without applying the pending mutation", async () => { + redisService.get.mockResolvedValue( + JSON.stringify({ + action: "remove_from_cart", + userId: "user-1", + cartItemId: "cart-item-1", + productTitle: "Black Sneakers", + }), + ); + + const result = await service.handlePendingActionReply( + "+2348012345678", + "user-1", + undefined, + { id: WA_SHOPPER_ACTION_CANCEL_ID }, + ); + + expect(redisService.del).toHaveBeenCalled(); + expect(cartService.removeItemFromCart).not.toHaveBeenCalled(); + expect(result).toEqual({ + handled: true, + message: "That action was cancelled.", + }); + }); + + it("preserves retry semantics when pending action storage cannot be read", async () => { + redisService.get.mockRejectedValueOnce(new Error("Redis unavailable")); + + await expect( + service.handlePendingActionReply("+2348012345678", "user-1", "confirm"), + ).rejects.toBeInstanceOf(WhatsAppRetryableActionError); + expect(cartService.addItemToCart).not.toHaveBeenCalled(); + }); + + it("does not retry an ambiguous cart failure after the domain call starts", async () => { + const pending = JSON.stringify({ + action: "add_to_cart", + userId: "user-1", + productId: "product-1", + productTitle: "Black Sneakers", + quantity: 1, + }); + redisService.get.mockResolvedValue(pending); + redisService.getDel.mockResolvedValue(pending); + cartService.addItemToCart.mockRejectedValue(new Error("Database timeout")); + + const result = await service.handlePendingActionReply( + "+2348012345678", + "user-1", + "confirm", + ); + + expect(result).toEqual({ + handled: true, + message: + "I could not confirm whether that action completed. Check your current cart or checkout state before trying again.", + }); + expect(redisService.set).not.toHaveBeenCalled(); + }); + + it("rejects a pending mutation created for a different linked user", async () => { + const pending = JSON.stringify({ + action: "remove_from_cart", + userId: "user-1", + cartItemId: "cart-item-1", + productTitle: "Black Sneakers", + }); + redisService.get.mockResolvedValue(pending); + redisService.getDel.mockResolvedValue(pending); + + const result = await service.handlePendingActionReply( + "+2348012345678", + "user-2", + "confirm", + ); + + expect(result).toEqual({ + handled: true, + message: "That confirmation has expired. Start the cart action again.", + }); + expect(cartService.removeItemFromCart).not.toHaveBeenCalled(); + }); + + it("does not expose domain exception details in shopper-facing errors", async () => { + const pending = JSON.stringify({ + action: "remove_from_cart", + userId: "user-1", + cartItemId: "private-cart-item-id", + productTitle: "Black Sneakers", + }); + redisService.get.mockResolvedValue(pending); + redisService.getDel.mockResolvedValue(pending); + cartService.removeItemFromCart.mockRejectedValue( + new NotFoundException("Cart item private-cart-item-id was not found"), + ); + + const result = await service.handlePendingActionReply( + "+2348012345678", + "user-1", + "confirm", + ); + + expect(result).toEqual({ + handled: true, + message: + "That cart item or product is no longer available. Your cart was not changed.", + }); + expect(result.message).not.toContain("private-cart-item-id"); + }); + + it.each(["sourced_unsupported", "variant_unsupported"] as const)( + "does not prepare unsupported %s products", + async (status) => { + productDiscoveryService.resolveRecentProductForCart.mockResolvedValue({ + status, + publicProductUrl: "https://twizrr.com/stores/store-one/p/TWZ-123456", + }); + + const result = await service.prepareAddToCart( + "+2348012345678", + "user-1", + { quantity: 1 }, + ); + + expect(redisService.set).not.toHaveBeenCalled(); + expect(cartService.addItemToCart).not.toHaveBeenCalled(); + expect(result.output.status).toBe("unsupported"); + expect(result.message).toContain( + "https://twizrr.com/stores/store-one/p/TWZ-123456", + ); + }, + ); + + it("resolves updates only from the linked shopper's current cart", async () => { + cartService.getCart.mockResolvedValue({ + items: [ + { + id: "cart-item-1", + quantity: 1, + product: { + name: "Black Sneakers", + title: "Black Sneakers", + productCode: "TWZ-123456", + }, + }, + ], + }); + + const result = await service.prepareUpdateCartQuantity( + "+2348012345678", + "user-1", + { cartItemReference: "Black Sneakers", quantity: 3 }, + ); + + expect(cartService.getCart).toHaveBeenCalledWith("user-1"); + expect(redisService.set).toHaveBeenCalledWith( + expect.stringContaining("2348012345678"), + expect.stringContaining('"cartItemId":"cart-item-1"'), + 300, + ); + expect(cartService.updateItemQuantity).not.toHaveBeenCalled(); + expect(result.output.status).toBe("confirmation_required"); + }); + + it("selects only a saved address after explicit confirmation", async () => { + userService.getAddresses.mockResolvedValue({ + deliveryAddresses: [ + { + id: "address-1", + label: "Home", + street: "1 Market Road", + city: "Lagos", + state: "Lagos", + isDefault: true, + }, + ], + }); + + const prepared = await service.prepareSelectDeliveryAddress( + "+2348012345678", + "user-1", + { addressReference: "home" }, + ); + const pending = redisService.set.mock.calls[0][1] as string; + redisService.get.mockResolvedValue(pending); + redisService.getDel.mockResolvedValue(pending); + + const confirmed = await service.handlePendingActionReply( + "+2348012345678", + "user-1", + "yes", + ); + + expect(prepared.output.status).toBe("confirmation_required"); + expect(redisService.set).toHaveBeenLastCalledWith( + expect.stringContaining(WA_SELECTED_DELIVERY_ADDRESS_PREFIX), + JSON.stringify({ + userId: "user-1", + addressId: "address-1", + addressLabel: "Home", + }), + 900, + ); + expect(confirmed.message).toContain( + "Home is selected for this shopping session", + ); + }); + + it("clears stale confirmation when the shopper sends another request", async () => { + redisService.get.mockResolvedValue( + JSON.stringify({ + action: "remove_from_cart", + userId: "user-1", + cartItemId: "cart-item-1", + productTitle: "Black Sneakers", + }), + ); + + const result = await service.handlePendingActionReply( + "+2348012345678", + "user-1", + "show me cheaper shoes", + ); + + expect(redisService.del).toHaveBeenCalled(); + expect(result).toEqual({ handled: false }); + expect(cartService.removeItemFromCart).not.toHaveBeenCalled(); + }); + + it("summarizes an available cart before staging checkout", async () => { + cartService.getCart.mockResolvedValue({ + items: [ + { id: "cart-1", quantity: 2, isAvailable: true }, + { id: "cart-2", quantity: 1, isAvailable: true }, + ], + subtotalKobo: "1250000", + }); + + const result = await service.prepareCheckoutHandoff( + "+2348012345678", + "user-1", + ); + + expect(result).toMatchObject({ + confirmationButtons: true, + output: { + status: "confirmation_required", + itemCount: 2, + totalQuantity: 3, + subtotalDisplay: "NGN 12,500.00", + nextStep: "confirm_or_cancel", + }, + }); + expect(redisService.set).toHaveBeenCalledWith( + expect.stringContaining("2348012345678"), + JSON.stringify({ action: "start_checkout", userId: "user-1" }), + 300, + ); + expect(cartService).not.toHaveProperty("checkout"); + }); + + it("does not stage checkout for an empty or unavailable cart", async () => { + cartService.getCart + .mockResolvedValueOnce({ items: [], subtotalKobo: "0" }) + .mockResolvedValueOnce({ + items: [{ id: "cart-1", quantity: 1, isAvailable: false }], + subtotalKobo: "500000", + }); + + const empty = await service.prepareCheckoutHandoff( + "+2348012345678", + "user-1", + ); + const unavailable = await service.prepareCheckoutHandoff( + "+2348012345678", + "user-1", + ); + + expect(empty.output.status).toBe("empty"); + expect(unavailable.output.status).toBe("unavailable"); + expect(redisService.set).not.toHaveBeenCalled(); + }); + + it("revalidates the cart and returns the public web cart only after confirmation", async () => { + const pending = JSON.stringify({ + action: "start_checkout", + userId: "user-1", + }); + redisService.get.mockResolvedValue(pending); + redisService.getDel.mockResolvedValue(pending); + cartService.getCart.mockResolvedValue({ + items: [{ id: "cart-1", quantity: 1, isAvailable: true }], + subtotalKobo: "500000", + }); + + const result = await service.handlePendingActionReply( + "+2348012345678", + "user-1", + "confirm", + ); + + expect(cartService.getCart).toHaveBeenCalledWith("user-1"); + expect(result.message).toContain("https://app.twizrr.com/cart"); + expect(result.message).toContain("Payment is completed on Twizrr web"); + expect(result.message).not.toContain("cart-1"); + }); + + it("does not return a checkout link when the cart changes before confirmation", async () => { + const pending = JSON.stringify({ + action: "start_checkout", + userId: "user-1", + }); + redisService.get.mockResolvedValue(pending); + redisService.getDel.mockResolvedValue(pending); + cartService.getCart.mockResolvedValue({ + items: [{ id: "cart-1", quantity: 1, isAvailable: false }], + subtotalKobo: "500000", + }); + + const result = await service.handlePendingActionReply( + "+2348012345678", + "user-1", + "yes", + ); + + expect(result.message).toContain("cart changed"); + expect(result.message).not.toContain("https://"); + }); + + it("fails safely when the web handoff URL is not configured", async () => { + const pending = JSON.stringify({ + action: "start_checkout", + userId: "user-1", + }); + redisService.get.mockResolvedValue(pending); + redisService.getDel.mockResolvedValue(pending); + cartService.getCart.mockResolvedValue({ + items: [{ id: "cart-1", quantity: 1, isAvailable: true }], + subtotalKobo: "500000", + }); + configService.get.mockReturnValue(undefined); + + const result = await service.handlePendingActionReply( + "+2348012345678", + "user-1", + "confirm", + ); + + expect(result.message).toContain("temporarily unavailable"); + expect(result.message).not.toContain("http"); + }); +}); diff --git a/apps/backend/src/channels/whatsapp/whatsapp-shopper-action.service.ts b/apps/backend/src/channels/whatsapp/whatsapp-shopper-action.service.ts new file mode 100644 index 00000000..5e486938 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-shopper-action.service.ts @@ -0,0 +1,846 @@ +import { + HttpStatus, + Inject, + Injectable, + Logger, + forwardRef, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { CartService } from "../../domains/orders/cart/cart.service"; +import { UserService } from "../../domains/users/user/user.service"; +import { RedisService } from "../../redis/redis.service"; +import type { + ShopperAgentToolInputMap, + ShopperAgentToolOutputMap, +} from "./agent/shopper-agent.types"; +import { + SHOPPER_ACTION_CONFIRMATION_TTL, + SESSION_TTL, + WA_PENDING_SHOPPER_ACTION_PREFIX, + WA_SELECTED_DELIVERY_ADDRESS_PREFIX, + WA_SHOPPER_ACTION_CANCEL_ID, + WA_SHOPPER_ACTION_CONFIRM_ID, + isWhatsAppCartQuantity, +} from "./whatsapp.constants"; +import { WhatsAppProductDiscoveryService } from "./whatsapp-product-discovery.service"; +import { WhatsAppRetryableActionError } from "./whatsapp-retryable-action.error"; +import { maskWhatsAppPhone, normalizeWhatsAppPhone } from "./whatsapp.utils"; + +type ActionToolName = + | "add_to_cart" + | "update_cart_quantity" + | "remove_from_cart" + | "start_checkout" + | "select_delivery_address"; + +type PendingShopperAction = + | { + action: "add_to_cart"; + userId: string; + productId: string; + productTitle: string; + quantity: number; + } + | { + action: "update_cart_quantity"; + userId: string; + cartItemId: string; + productTitle: string; + quantity: number; + } + | { + action: "remove_from_cart"; + userId: string; + cartItemId: string; + productTitle: string; + } + | { + action: "select_delivery_address"; + userId: string; + addressId: string; + addressLabel: string; + } + | { + action: "start_checkout"; + userId: string; + }; + +interface SavedAddress { + id: string; + label: string; + street: string; + city: string; + state: string; + isDefault: boolean; +} + +interface ActionPreparation { + message: string; + output: ShopperAgentToolOutputMap[Name]; + confirmationButtons?: true; +} + +export interface PendingActionReplyResult { + handled: boolean; + message?: string; +} + +@Injectable() +export class WhatsAppShopperActionService { + private readonly logger = new Logger(WhatsAppShopperActionService.name); + + constructor( + @Inject(forwardRef(() => CartService)) + private readonly cartService: CartService, + private readonly userService: UserService, + private readonly redisService: RedisService, + private readonly productDiscoveryService: WhatsAppProductDiscoveryService, + private readonly configService: ConfigService, + ) {} + + async prepareAddToCart( + phone: string, + userId: string, + input: ShopperAgentToolInputMap["add_to_cart"], + ): Promise> { + const product = + await this.productDiscoveryService.resolveRecentProductForCart( + phone, + input.productReference, + ); + + if (product.status === "no_context") { + return { + message: + "Search for a product first, then tell me which result you want to add.", + output: { status: "no_context" }, + }; + } + + if (product.status === "ambiguous") { + return { + message: + "Tell me which displayed result to add, such as add the first one or add number 2.", + output: { status: "no_context" }, + }; + } + + if (product.status === "sourced_unsupported") { + return { + message: product.publicProductUrl + ? `This item is not available for WhatsApp cart yet. Open it to continue: ${product.publicProductUrl}` + : "This item is not available for WhatsApp cart yet. Open it from the latest results to continue.", + output: { status: "unsupported" }, + }; + } + + if (product.status === "variant_unsupported") { + return { + message: product.publicProductUrl + ? `Choose the product option on Twizrr before adding it: ${product.publicProductUrl}` + : "This product needs an option such as size or colour. Open it on Twizrr to choose the option first.", + output: { status: "unsupported" }, + }; + } + + if (product.status === "not_found") { + return { + message: + "That product is no longer available. Search again for current options.", + output: { status: "no_context" }, + }; + } + + if (product.status !== "available") { + return { + message: + "I could not safely match that product. Search again and choose from the current results.", + output: { status: "no_context" }, + }; + } + + const staged = await this.storePendingAction(phone, { + action: "add_to_cart", + userId, + productId: product.productId, + productTitle: product.title, + quantity: input.quantity, + }); + if (!staged) { + return this.stagingFailure(); + } + + return { + message: `Add ${input.quantity} x ${product.title} to your cart?`, + output: { + status: "confirmation_required", + nextStep: "confirm_or_cancel", + }, + confirmationButtons: true, + }; + } + + async prepareUpdateCartQuantity( + phone: string, + userId: string, + input: ShopperAgentToolInputMap["update_cart_quantity"], + ): Promise> { + const target = await this.resolveCartItem(userId, input.cartItemReference); + if (target.status !== "available") { + return { + message: + target.status === "empty" + ? "Your cart is empty." + : "I could not match that to one current cart item. Refer to the item by its product name or position.", + output: { status: target.status }, + }; + } + + const staged = await this.storePendingAction(phone, { + action: "update_cart_quantity", + userId, + cartItemId: target.item.id, + productTitle: target.title, + quantity: input.quantity, + }); + if (!staged) { + return this.stagingFailure(); + } + + return { + message: `Change ${target.title} to quantity ${input.quantity}?`, + output: { + status: "confirmation_required", + nextStep: "confirm_or_cancel", + }, + confirmationButtons: true, + }; + } + + async prepareRemoveFromCart( + phone: string, + userId: string, + input: ShopperAgentToolInputMap["remove_from_cart"], + ): Promise> { + const target = await this.resolveCartItem(userId, input.cartItemReference); + if (target.status !== "available") { + return { + message: + target.status === "empty" + ? "Your cart is empty." + : "I could not match that to one current cart item. Refer to the item by its product name or position.", + output: { status: target.status }, + }; + } + + const staged = await this.storePendingAction(phone, { + action: "remove_from_cart", + userId, + cartItemId: target.item.id, + productTitle: target.title, + }); + if (!staged) { + return this.stagingFailure(); + } + + return { + message: `Remove ${target.title} from your cart?`, + output: { + status: "confirmation_required", + nextStep: "confirm_or_cancel", + }, + confirmationButtons: true, + }; + } + + async prepareSelectDeliveryAddress( + phone: string, + userId: string, + input: ShopperAgentToolInputMap["select_delivery_address"], + ): Promise> { + const addresses = await this.getSavedAddresses(userId); + if (!addresses.length) { + return { + message: + "You do not have a saved delivery address yet. Add one from your Twizrr settings.", + output: { status: "empty" }, + }; + } + + const address = this.resolveAddress(addresses, input.addressReference); + if (!address) { + return { + message: + "I could not match that saved address. Refer to it by label, such as home or work.", + output: { status: "not_found" }, + }; + } + + const staged = await this.storePendingAction(phone, { + action: "select_delivery_address", + userId, + addressId: address.id, + addressLabel: address.label, + }); + if (!staged) { + return this.stagingFailure(); + } + + return { + message: `Use ${address.label}: ${address.street}, ${address.city}, ${address.state} for this shopping session?`, + output: { + status: "confirmation_required", + nextStep: "confirm_or_cancel", + }, + confirmationButtons: true, + }; + } + + async prepareCheckoutHandoff( + phone: string, + userId: string, + ): Promise> { + const cart = await this.cartService.getCart(userId); + const summary = this.checkoutCartSummary(cart); + + if (summary.status === "empty") { + return { + message: "Your cart is empty. Add a product before starting checkout.", + output: { status: "empty" }, + }; + } + + if (summary.status === "unavailable") { + return { + message: + "One or more cart items are no longer available at the current quantity or price. Review your cart on Twizrr before checkout.", + output: { status: "unavailable" }, + }; + } + + const staged = await this.storePendingAction(phone, { + action: "start_checkout", + userId, + }); + if (!staged) { + return this.stagingFailure(); + } + + return { + message: `Your cart has ${summary.totalQuantity} ${summary.totalQuantity === 1 ? "unit" : "units"} across ${summary.itemCount} ${summary.itemCount === 1 ? "item" : "items"}, subtotal ${summary.subtotalDisplay}. Continue to secure checkout on Twizrr?`, + output: { + status: "confirmation_required", + itemCount: summary.itemCount, + totalQuantity: summary.totalQuantity, + subtotalDisplay: summary.subtotalDisplay, + nextStep: "confirm_or_cancel", + }, + confirmationButtons: true, + }; + } + + async handlePendingActionReply( + phone: string, + userId: string | null, + messageText?: string, + interactiveReply?: { id: string }, + ): Promise { + const key = this.pendingActionKey(phone); + const reply = interactiveReply?.id.trim().toLowerCase(); + const text = messageText?.trim().toLowerCase(); + const confirms = + reply === WA_SHOPPER_ACTION_CONFIRM_ID || + text === "confirm" || + text === "yes"; + const cancels = + reply === WA_SHOPPER_ACTION_CANCEL_ID || + text === "cancel" || + text === "no"; + + let raw: string | null; + try { + raw = await this.redisService.get(key); + } catch (error) { + this.logRedisFailure("pending shopper action read", phone, error); + if (confirms || cancels) { + throw new WhatsAppRetryableActionError( + "Pending shopper action could not be read", + error, + ); + } + return { handled: false }; + } + if (!raw) { + return { handled: false }; + } + + if (!confirms && !cancels) { + try { + await this.redisService.del(key); + } catch (error) { + this.logRedisFailure("stale shopper action clear", phone, error); + } + return { handled: false }; + } + + if (cancels) { + try { + await this.redisService.del(key); + } catch (error) { + this.logRedisFailure("shopper action cancellation", phone, error); + throw new WhatsAppRetryableActionError( + "Pending shopper action could not be cancelled", + error, + ); + } + return { handled: true, message: "That action was cancelled." }; + } + + let consumed: string | null; + try { + consumed = await this.redisService.getDel(key); + } catch (error) { + this.logRedisFailure("pending shopper action consume", phone, error); + throw new WhatsAppRetryableActionError( + "Pending shopper action could not be consumed", + error, + ); + } + const action = consumed ? this.parsePendingAction(consumed) : null; + if (!action || !userId || action.userId !== userId) { + return { + handled: true, + message: "That confirmation has expired. Start the cart action again.", + }; + } + + try { + return { + handled: true, + message: await this.executePendingAction(phone, action), + }; + } catch (error) { + const safeMessage = this.safeDomainError(phone, error); + if (!safeMessage) { + return { + handled: true, + message: + "I could not confirm whether that action completed. Check your current cart or checkout state before trying again.", + }; + } + return { + handled: true, + message: safeMessage, + }; + } + } + + private async executePendingAction( + phone: string, + action: PendingShopperAction, + ): Promise { + switch (action.action) { + case "add_to_cart": { + const cart = await this.cartService.addItemToCart(action.userId, { + productId: action.productId, + quantity: action.quantity, + }); + return `Added ${action.productTitle} to your cart. ${this.cartSummary(cart)}`; + } + case "update_cart_quantity": { + const cart = await this.cartService.updateItemQuantity( + action.userId, + action.cartItemId, + { quantity: action.quantity }, + ); + return `Updated ${action.productTitle} to quantity ${action.quantity}. ${this.cartSummary(cart)}`; + } + case "remove_from_cart": { + const cart = await this.cartService.removeItemFromCart( + action.userId, + action.cartItemId, + ); + return `Removed ${action.productTitle} from your cart. ${this.cartSummary(cart)}`; + } + case "select_delivery_address": { + const addresses = await this.getSavedAddresses(action.userId); + const address = addresses.find( + (candidate) => candidate.id === action.addressId, + ); + if (!address) { + return "That saved address is no longer available. Choose another saved address."; + } + try { + await this.redisService.set( + this.selectedAddressKey(phone), + JSON.stringify({ + userId: action.userId, + addressId: address.id, + addressLabel: address.label, + }), + SESSION_TTL, + ); + } catch (error) { + this.logRedisFailure("selected delivery address write", phone, error); + throw error; + } + return `${address.label} is selected for this shopping session.`; + } + case "start_checkout": { + const cart = await this.cartService.getCart(action.userId); + const summary = this.checkoutCartSummary(cart); + if (summary.status === "empty") { + return "Your cart is now empty. Add a product before starting checkout."; + } + if (summary.status === "unavailable") { + return "Your cart changed and one or more items are no longer available at the current quantity or price. Review your cart before checkout."; + } + + const handoffUrl = this.checkoutHandoffUrl(); + if (!handoffUrl) { + return "Secure web checkout is temporarily unavailable. Your cart has not changed. Please try again later."; + } + + return `Continue to secure checkout on Twizrr: ${handoffUrl}\n\nYour cart has ${summary.totalQuantity} ${summary.totalQuantity === 1 ? "unit" : "units"}, subtotal ${summary.subtotalDisplay}. Payment is completed on Twizrr web.`; + } + } + } + + private async resolveCartItem( + userId: string, + reference?: string, + ): Promise< + | { + status: "available"; + item: Awaited>["items"][number]; + title: string; + } + | { status: "empty" | "not_found" } + > { + const cart = await this.cartService.getCart(userId); + if (!cart.items.length) { + return { status: "empty" }; + } + + const matched = this.matchByReference(cart.items, reference, (item) => [ + item.product.title || item.product.name, + item.product.productCode || "", + ]); + if (!matched) { + return { status: "not_found" }; + } + + return { + status: "available", + item: matched, + title: matched.product.title || matched.product.name, + }; + } + + private async getSavedAddresses(userId: string): Promise { + const response = await this.userService.getAddresses(userId); + if (!Array.isArray(response.deliveryAddresses)) { + return []; + } + + return response.deliveryAddresses.flatMap((value) => { + if (!this.isRecord(value)) { + return []; + } + const id = this.readString(value.id); + const label = this.readString(value.label); + const street = this.readString(value.street); + const city = this.readString(value.city); + const state = this.readString(value.state); + if (!id || !label || !street || !city || !state) { + return []; + } + return [ + { + id, + label, + street, + city, + state, + isDefault: value.isDefault === true, + }, + ]; + }); + } + + private resolveAddress( + addresses: SavedAddress[], + reference?: string, + ): SavedAddress | null { + if (!reference) { + return ( + addresses.find((address) => address.isDefault) ?? + (addresses.length === 1 ? addresses[0] : null) + ); + } + + return this.matchByReference(addresses, reference, (address) => [ + address.label, + ]); + } + + private matchByReference( + items: T[], + reference: string | undefined, + searchableValues: (item: T) => string[], + ): T | null { + if (!reference) { + return items.length === 1 ? items[0] : null; + } + + const normalized = this.normalizeReference(reference); + const ordinal = this.referenceIndex(normalized); + if (ordinal !== null) { + return items[ordinal - 1] ?? null; + } + + const matches = items.filter((item) => + searchableValues(item).some((value) => { + const candidate = this.normalizeReference(value); + return ( + candidate === normalized || + candidate.includes(normalized) || + normalized.includes(candidate) + ); + }), + ); + return matches.length === 1 ? matches[0] : null; + } + + private referenceIndex(value: string): number | null { + const ordinals: Record = { + first: 1, + second: 2, + third: 3, + fourth: 4, + fifth: 5, + }; + if (ordinals[value]) { + return ordinals[value]; + } + const numeric = /^(?:number\s*)?([1-5])$/.exec(value); + return numeric ? Number(numeric[1]) : null; + } + + private normalizeReference(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, " ") + .replace(/\b(?:the|item|product|one)\b/g, " ") + .replace(/\s+/g, " ") + .trim(); + } + + private async storePendingAction( + phone: string, + action: PendingShopperAction, + ): Promise { + try { + await this.redisService.set( + this.pendingActionKey(phone), + JSON.stringify(action), + SHOPPER_ACTION_CONFIRMATION_TTL, + ); + return true; + } catch (error) { + this.logRedisFailure("pending shopper action write", phone, error); + return false; + } + } + + private parsePendingAction(value: string): PendingShopperAction | null { + try { + const parsed: unknown = JSON.parse(value); + if (!this.isRecord(parsed) || !this.readString(parsed.userId)) { + return null; + } + + const action = parsed.action; + if ( + action === "add_to_cart" && + this.readString(parsed.productId) && + this.readString(parsed.productTitle) && + isWhatsAppCartQuantity(parsed.quantity) + ) { + return parsed as unknown as PendingShopperAction; + } + if ( + action === "update_cart_quantity" && + this.readString(parsed.cartItemId) && + this.readString(parsed.productTitle) && + isWhatsAppCartQuantity(parsed.quantity) + ) { + return parsed as unknown as PendingShopperAction; + } + if ( + action === "remove_from_cart" && + this.readString(parsed.cartItemId) && + this.readString(parsed.productTitle) + ) { + return parsed as unknown as PendingShopperAction; + } + if ( + action === "select_delivery_address" && + this.readString(parsed.addressId) && + this.readString(parsed.addressLabel) + ) { + return parsed as unknown as PendingShopperAction; + } + if (action === "start_checkout") { + return parsed as unknown as PendingShopperAction; + } + return null; + } catch { + return null; + } + } + + private cartSummary( + cart: Awaited>, + ): string { + const count = cart.items.reduce((total, item) => total + item.quantity, 0); + return count === 0 + ? "Your cart is now empty." + : `Your cart now has ${count} ${count === 1 ? "unit" : "units"}.`; + } + + private checkoutCartSummary( + cart: Awaited>, + ): + | { status: "empty" } + | { status: "unavailable" } + | { + status: "available"; + itemCount: number; + totalQuantity: number; + subtotalDisplay: string; + } { + if (!cart.items.length) { + return { status: "empty" }; + } + + let subtotalKobo: bigint; + try { + subtotalKobo = BigInt(cart.subtotalKobo); + } catch { + return { status: "unavailable" }; + } + + if ( + subtotalKobo <= 0n || + cart.items.some( + (item) => + item.isAvailable === false || !isWhatsAppCartQuantity(item.quantity), + ) + ) { + return { status: "unavailable" }; + } + + return { + status: "available", + itemCount: cart.items.length, + totalQuantity: cart.items.reduce( + (total, item) => total + item.quantity, + 0, + ), + subtotalDisplay: this.formatKobo(subtotalKobo), + }; + } + + private checkoutHandoffUrl(): string | null { + const configuredUrl = + this.configService.get("app.webUrl") || + this.configService.get("app.frontendUrl") || + this.configService.get("WEB_URL"); + if (!configuredUrl) { + return null; + } + + try { + const url = new URL("/cart", configuredUrl); + return url.protocol === "https:" || url.protocol === "http:" + ? url.toString() + : null; + } catch { + return null; + } + } + + private formatKobo(value: bigint): string { + const naira = value / 100n; + const kobo = (value % 100n).toString().padStart(2, "0"); + return `NGN ${naira.toLocaleString("en-NG")}.${kobo}`; + } + + private stagingFailure< + Name extends ActionToolName, + >(): ActionPreparation { + return { + message: "I could not stage that change, please try again.", + output: { + status: "temporarily_unavailable", + } as ShopperAgentToolOutputMap[Name], + }; + } + + private safeDomainError(phone: string, error: unknown): string | null { + this.logger.warn( + `Shopper action failed for ${maskWhatsAppPhone(phone)}: ${this.errorMessage(error)}`, + ); + + const status = this.errorStatus(error); + if (status === HttpStatus.NOT_FOUND) { + return "That cart item or product is no longer available. Your cart was not changed."; + } + if (status === HttpStatus.BAD_REQUEST) { + return "I could not apply that cart change. Check the quantity and availability, then try again. Your cart was not changed."; + } + if (status === HttpStatus.CONFLICT) { + return "Your cart changed before I could apply that request. Review it and try again."; + } + return null; + } + + private errorStatus(error: unknown): number | null { + if (!this.isRecord(error) || typeof error.getStatus !== "function") { + return null; + } + const status = (error.getStatus as () => unknown)(); + return typeof status === "number" ? status : null; + } + + private errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } + + private logRedisFailure( + operation: string, + phone: string, + error: unknown, + ): void { + this.logger.warn( + `${operation} failed for ${maskWhatsAppPhone(phone)}: ${this.errorMessage(error)}`, + ); + } + + private pendingActionKey(phone: string): string { + return `${WA_PENDING_SHOPPER_ACTION_PREFIX}${normalizeWhatsAppPhone(phone)}`; + } + + private selectedAddressKey(phone: string): string { + return `${WA_SELECTED_DELIVERY_ADDRESS_PREFIX}${normalizeWhatsAppPhone(phone)}`; + } + + private readString(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; + } + + private isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); + } +} diff --git a/apps/backend/src/channels/whatsapp/whatsapp-shopper-read.service.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp-shopper-read.service.spec.ts new file mode 100644 index 00000000..bb4aedd2 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-shopper-read.service.spec.ts @@ -0,0 +1,192 @@ +import { WhatsAppShopperReadService } from "./whatsapp-shopper-read.service"; + +describe("WhatsAppShopperReadService", () => { + const cartService = { + getCart: jest.fn(), + }; + const orderService = { + listByBuyer: jest.fn(), + getByCodeForBuyer: jest.fn(), + getTracking: jest.fn(), + }; + const userService = { + getAddresses: jest.fn(), + }; + + let service: WhatsAppShopperReadService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new WhatsAppShopperReadService( + cartService as never, + orderService as never, + userService as never, + ); + }); + + it("summarizes the linked shopper cart without returning product IDs", async () => { + cartService.getCart.mockResolvedValue({ + items: [ + { + quantity: 2, + itemTotalKobo: "500000", + product: { name: "Canvas shoes", title: null }, + }, + ], + subtotalKobo: "500000", + }); + + const result = await service.getCart("user-1"); + + expect(cartService.getCart).toHaveBeenCalledWith("user-1"); + expect(result.message).toContain("Canvas shoes x 2 - NGN 5,000.00"); + expect(result.output).toEqual({ + status: "available", + itemCount: 1, + totalQuantity: 2, + subtotalDisplay: "NGN 5,000.00", + }); + expect(JSON.stringify(result)).not.toContain("productId"); + }); + + it("returns a deterministic empty-cart response", async () => { + cartService.getCart.mockResolvedValue({ + items: [], + subtotalKobo: "0", + }); + + await expect(service.getCart("user-1")).resolves.toEqual({ + message: "Your cart is empty.", + output: { status: "empty", itemCount: 0, totalQuantity: 0 }, + }); + }); + + it("shows safe saved-address fields and omits phone metadata", async () => { + userService.getAddresses.mockResolvedValue({ + deliveryAddresses: [ + { + id: "address-1", + label: "Home", + street: "12 Market Road", + city: "Lagos", + state: "Lagos", + isDefault: true, + deliveryPhone: "+2348012345678", + }, + ], + }); + + const result = await service.getSavedAddresses("user-1"); + + expect(userService.getAddresses).toHaveBeenCalledWith("user-1"); + expect(result.message).toContain( + "Home (default): 12 Market Road, Lagos, Lagos", + ); + expect(result.output).toEqual({ + status: "available", + addressCount: 1, + defaultAddressLabel: "Home", + }); + expect(JSON.stringify(result)).not.toContain("+2348012345678"); + expect(JSON.stringify(result)).not.toContain("address-1"); + }); + + it("lists recent orders using only public order references", async () => { + orderService.listByBuyer.mockResolvedValue({ + success: true, + data: [ + { + id: "internal-order-id", + orderCode: "TWZ-123456", + status: "IN_TRANSIT", + product: { name: "Travel bag" }, + physicalStoreId: "private-store", + }, + ], + meta: { page: 1, limit: 5, total: 1, totalPages: 1 }, + }); + + const result = await service.listOrders("user-1"); + + expect(orderService.listByBuyer).toHaveBeenCalledWith("user-1", 1, 5); + expect(result.message).toContain("TWZ-123456: Travel bag - in transit"); + expect(result.output).toEqual({ + status: "available", + ordersShownCount: 1, + orderReferences: ["TWZ-123456"], + }); + expect(JSON.stringify(result)).not.toContain("internal-order-id"); + expect(JSON.stringify(result)).not.toContain("private-store"); + }); + + it("scopes status lookup to the linked shopper and public order code", async () => { + orderService.getByCodeForBuyer.mockResolvedValue({ + id: "internal-order-id", + orderCode: "TWZ-123456", + status: "PAID", + }); + orderService.getTracking.mockResolvedValue([ + { status: "READY_FOR_PICKUP" }, + { status: "IN_TRANSIT" }, + ]); + + const result = await service.getOrderStatus("user-1", { + orderReference: "twz-123456", + }); + + expect(orderService.getByCodeForBuyer).toHaveBeenCalledWith( + "TWZ-123456", + "user-1", + ); + expect(orderService.getTracking).toHaveBeenCalledWith( + "internal-order-id", + "user-1", + ); + expect(result.output).toEqual({ + status: "available", + orderReference: "TWZ-123456", + orderStatus: "paid", + deliveryStatus: "in transit", + }); + }); + + it("uses the latest owned order for delivery status when no code is supplied", async () => { + orderService.listByBuyer.mockResolvedValue({ + success: true, + data: [ + { + id: "order-2", + orderCode: "TWZ-654321", + status: "DISPATCHED", + product: { name: "Phone case" }, + }, + ], + meta: { page: 1, limit: 1, total: 1, totalPages: 1 }, + }); + orderService.getTracking.mockResolvedValue([]); + + const result = await service.getDeliveryStatus("user-1", {}); + + expect(orderService.listByBuyer).toHaveBeenCalledWith("user-1", 1, 1); + expect(result.output).toEqual({ + status: "available", + orderReference: "TWZ-654321", + deliveryStatus: "dispatched", + }); + }); + + it("does not disclose whether an order belongs to someone else", async () => { + orderService.getByCodeForBuyer.mockResolvedValue(null); + + const result = await service.getOrderStatus("user-1", { + orderReference: "TWZ-999999", + }); + + expect(result).toEqual({ + message: + "I could not find that order in your linked Twizrr account. Check the order code and try again.", + output: { status: "not_found" }, + }); + expect(orderService.getTracking).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/channels/whatsapp/whatsapp-shopper-read.service.ts b/apps/backend/src/channels/whatsapp/whatsapp-shopper-read.service.ts new file mode 100644 index 00000000..d3d4c3c6 --- /dev/null +++ b/apps/backend/src/channels/whatsapp/whatsapp-shopper-read.service.ts @@ -0,0 +1,306 @@ +import { Inject, Injectable, forwardRef } from "@nestjs/common"; +import { CartService } from "../../domains/orders/cart/cart.service"; +import { OrderService } from "../../domains/orders/order/order.service"; +import { UserService } from "../../domains/users/user/user.service"; +import type { + ShopperAgentToolInputMap, + ShopperAgentToolOutputMap, +} from "./agent/shopper-agent.types"; + +type ReadToolName = + | "get_cart" + | "get_saved_addresses" + | "list_orders" + | "get_order_status" + | "get_delivery_status"; + +export interface ShopperReadResult { + message: string; + output: ShopperAgentToolOutputMap[Name]; +} + +type BuyerOrdersResponse = Awaited>; + +interface SafeAddress { + label: string; + street: string; + city: string; + state: string; + isDefault: boolean; +} + +@Injectable() +export class WhatsAppShopperReadService { + constructor( + @Inject(forwardRef(() => CartService)) + private readonly cartService: CartService, + @Inject(forwardRef(() => OrderService)) + private readonly orderService: OrderService, + private readonly userService: UserService, + ) {} + + async getCart(userId: string): Promise> { + const cart = await this.cartService.getCart(userId); + const itemCount = cart.items.length; + const totalQuantity = cart.items.reduce( + (total, item) => total + item.quantity, + 0, + ); + + if (itemCount === 0) { + return { + message: "Your cart is empty.", + output: { status: "empty", itemCount: 0, totalQuantity: 0 }, + }; + } + + const itemLines = cart.items.slice(0, 5).map((item) => { + const name = item.product.title || item.product.name; + return `${name} x ${item.quantity} - ${this.formatKobo(item.itemTotalKobo)}`; + }); + const remaining = itemCount - itemLines.length; + const subtotalDisplay = this.formatKobo(cart.subtotalKobo); + + return { + message: [ + `Your cart has ${itemCount} ${itemCount === 1 ? "item" : "items"} (${totalQuantity} total units).`, + ...itemLines, + ...(remaining > 0 ? [`And ${remaining} more.`] : []), + `Subtotal: ${subtotalDisplay}`, + ].join("\n"), + output: { + status: "available", + itemCount, + totalQuantity, + subtotalDisplay, + }, + }; + } + + async getSavedAddresses( + userId: string, + ): Promise> { + const response = await this.userService.getAddresses(userId); + const addresses = this.readAddresses(response.deliveryAddresses); + + if (addresses.length === 0) { + return { + message: + "You do not have a saved delivery address yet. Add one from your Twizrr settings.", + output: { status: "empty", addressCount: 0 }, + }; + } + + const visible = addresses.slice(0, 3); + const lines = visible.map((address) => { + const defaultLabel = address.isDefault ? " (default)" : ""; + return `${address.label}${defaultLabel}: ${address.street}, ${address.city}, ${address.state}`; + }); + const defaultAddress = addresses.find((address) => address.isDefault); + + return { + message: [ + "Your saved delivery addresses:", + ...lines, + ...(addresses.length > visible.length + ? [`And ${addresses.length - visible.length} more in your settings.`] + : []), + ].join("\n"), + output: { + status: "available", + addressCount: addresses.length, + ...(defaultAddress + ? { defaultAddressLabel: defaultAddress.label } + : {}), + }, + }; + } + + async listOrders(userId: string): Promise> { + const response = await this.orderService.listByBuyer(userId, 1, 5); + const orders = this.readOrders(response); + + if (orders.length === 0) { + return { + message: "You do not have any Twizrr orders yet.", + output: { status: "empty", ordersShownCount: 0 }, + }; + } + + return { + message: [ + "Your recent orders:", + ...orders.map( + (order) => + `${order.orderCode}: ${order.productName} - ${this.formatStatus(order.status)}`, + ), + ].join("\n"), + output: { + status: "available", + ordersShownCount: orders.length, + orderReferences: orders.map((order) => order.orderCode), + }, + }; + } + + async getOrderStatus( + userId: string, + input: ShopperAgentToolInputMap["get_order_status"], + ): Promise> { + const order = await this.resolveOrder(userId, input.orderReference); + if (!order) { + return { + message: + "I could not find that order in your linked Twizrr account. Check the order code and try again.", + output: { status: "not_found" }, + }; + } + + const orderStatus = this.formatStatus(order.status); + const deliveryStatus = await this.resolveDeliveryStatus( + userId, + order.id, + order.status, + ); + + return { + message: `Order ${order.orderCode} is ${orderStatus}. Delivery status: ${deliveryStatus}.`, + output: { + status: "available", + orderReference: order.orderCode, + orderStatus, + deliveryStatus, + }, + }; + } + + async getDeliveryStatus( + userId: string, + input: ShopperAgentToolInputMap["get_delivery_status"], + ): Promise> { + const order = await this.resolveOrder(userId, input.orderReference); + if (!order) { + return { + message: + "I could not find that order in your linked Twizrr account. Check the order code and try again.", + output: { status: "not_found" }, + }; + } + + const deliveryStatus = await this.resolveDeliveryStatus( + userId, + order.id, + order.status, + ); + return { + message: `Delivery for order ${order.orderCode} is ${deliveryStatus}.`, + output: { + status: "available", + orderReference: order.orderCode, + deliveryStatus, + }, + }; + } + + private async resolveOrder( + userId: string, + orderReference?: string, + ): Promise<{ + id: string; + orderCode: string; + status: string; + } | null> { + if (orderReference) { + return this.orderService.getByCodeForBuyer( + this.normalizeOrderReference(orderReference), + userId, + ); + } + + const response = await this.orderService.listByBuyer(userId, 1, 1); + return this.readOrders(response)[0] ?? null; + } + + private async resolveDeliveryStatus( + userId: string, + orderId: string, + orderStatus: string, + ): Promise { + const tracking = await this.orderService.getTracking(orderId, userId); + const latest = tracking.at(-1); + return this.formatStatus(latest?.status ?? orderStatus); + } + + private readOrders(response: BuyerOrdersResponse): Array<{ + id: string; + orderCode: string; + status: string; + productName: string; + }> { + return response.data.flatMap((rawOrder) => { + const order = rawOrder as unknown as Record; + const id = this.readString(order.id); + const orderCode = this.readString(order.orderCode); + const status = this.readString(order.status); + if (!id || !orderCode || !status) { + return []; + } + + const product = this.isRecord(order.product) ? order.product : null; + const productName = this.readString(product?.name) || "Twizrr order"; + return [{ id, orderCode, status, productName }]; + }); + } + + private readAddresses(value: unknown): SafeAddress[] { + if (!Array.isArray(value)) { + return []; + } + + return value.flatMap((candidate) => { + if (!this.isRecord(candidate)) { + return []; + } + const label = this.readString(candidate.label); + const street = this.readString(candidate.street); + const city = this.readString(candidate.city); + const state = this.readString(candidate.state); + if (!label || !street || !city || !state) { + return []; + } + return [ + { + label, + street, + city, + state, + isDefault: candidate.isDefault === true, + }, + ]; + }); + } + + private normalizeOrderReference(value: string): string { + return value.trim().toUpperCase(); + } + + private formatStatus(value: unknown): string { + const status = this.readString(value) ?? "unknown"; + return status.toLowerCase().replace(/_/g, " "); + } + + private formatKobo(value: string): string { + const kobo = BigInt(value); + const naira = kobo / 100n; + const remainder = (kobo % 100n).toString().padStart(2, "0"); + return `NGN ${naira.toLocaleString("en-NG")}.${remainder}`; + } + + private readString(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; + } + + private isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); + } +} diff --git a/apps/backend/src/channels/whatsapp/whatsapp.constants.ts b/apps/backend/src/channels/whatsapp/whatsapp.constants.ts index 81463301..2285981d 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp.constants.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp.constants.ts @@ -31,7 +31,7 @@ export const LINK_CODE_EXPIRED = "Your verification code has expired. Please start account linking again."; export const GENERIC_ERROR = `We encountered a problem while processing your request. Please try again or type "menu" to see available options.`; export const SAFE_NATURAL_ANSWER_FALLBACK = - "I can help with shopping on Twizrr, product discovery, Twizrr Buyer Protection, account linking, and supported order or delivery questions. I cannot complete checkout, cart, payment, refund, or dispute actions in WhatsApp yet."; + "I can help with shopping on Twizrr, product discovery, linked cart actions, secure web checkout handoff, Twizrr Buyer Protection, account linking, and supported order, delivery, dispute, or refund-status questions. Payment stays on Twizrr web. Disputes are opened only for eligible orders after you confirm the request."; export const SUPPORT_HANDOFF_RESPONSE = "I can help with shopping questions here. For account or order support, please visit twizrr support in the app or on the web."; export const STORE_MANAGEMENT_REDIRECT = @@ -45,107 +45,46 @@ export enum SessionState { WAITING_FOR_CODE = "waiting_for_code", } -export type WhatsAppFunctionAccess = - | "guest_safe" - | "linked_account_required" - | "deterministic"; - -export const WHATSAPP_FUNCTION_REGISTRY = [ - { - name: "search_products", - access: "guest_safe" as const, - handler: "WhatsAppProductDiscoveryService.sendGenericProductSearch", - description: - "Search for products the shopper wants to buy. Triggered by product names, categories, descriptions, price/location shopping requests, or questions about whether a product category is available.", - parameters: { - type: "object" as const, - properties: { - query: { - type: "string", - description: "The shopper's product search text.", - }, - }, - required: ["query"], - }, - }, - { - name: "get_order_status", - access: "linked_account_required" as const, - handler: "WhatsAppService.requireLinkedAccount", - description: - "Help a shopper track an existing order. Triggered by tracking, delivery, shipment, or order status questions.", - parameters: { - type: "object" as const, - properties: { - orderReference: { - type: "string", - description: "Optional order code or reference provided by shopper.", - }, - }, - }, - }, - { - name: "confirm_delivery", - access: "linked_account_required" as const, - handler: "WhatsAppService.requireLinkedAccount", - description: - "Help a shopper confirm delivery after receiving their order. Triggered by delivery confirmation or delivery code confirmation requests.", - parameters: { - type: "object" as const, - properties: { - orderReference: { - type: "string", - description: "Optional order code or reference provided by shopper.", - }, - }, - }, - }, - { - name: "show_menu", - access: "guest_safe" as const, - handler: "WhatsAppService.sendShopperMenu", - description: - "Show the shopper assistant menu when intent is unclear or the shopper asks for help/menu.", - parameters: { type: "object" as const, properties: {} }, - }, - { - name: "support_handoff", - access: "deterministic" as const, - handler: "WhatsAppService.SUPPORT_HANDOFF_RESPONSE", - description: - "Route requests for support guidance to a deterministic Twizrr support message. Do not claim live handoff or immediate agent availability.", - parameters: { type: "object" as const, properties: {} }, - }, - { - name: "store_management_redirect", - access: "deterministic" as const, - handler: "WhatsAppService.STORE_MANAGEMENT_REDIRECT", - description: - "Route store owner or store-management requests to the deterministic dashboard redirect.", - parameters: { type: "object" as const, properties: {} }, - }, -]; - -export const GEMINI_FUNCTION_DECLARATIONS = WHATSAPP_FUNCTION_REGISTRY.map( - (entry) => ({ - name: entry.name, - description: entry.description, - parameters: entry.parameters, - }), -); +export { + SHOPPER_AGENT_GEMINI_DECLARATIONS as GEMINI_FUNCTION_DECLARATIONS, + SHOPPER_AGENT_TOOL_DEFINITIONS as WHATSAPP_FUNCTION_REGISTRY, +} from "./agent/shopper-agent-tool.registry"; export const META_API_VERSION = "v21.0"; export const WHATSAPP_OTP_TEMPLATE = "auth_otp"; export const WA_SESSION_PREFIX = "wa:session:"; export const WA_CONSENT_PREFIX = "wa:consent:"; export const WA_RECENT_PRODUCTS_PREFIX = "wa:recent-products:"; +export const WA_DISCOVERY_CONTEXT_PREFIX = "wa:discovery-context:"; export const WA_PENDING_TEXT_SEARCH_PREFIX = "wa:pending-text-search:"; +export const WA_PENDING_SHOPPER_ACTION_PREFIX = "wa:pending-shopper-action:"; +export const WA_PENDING_DISPUTE_PREFIX = "wa:pending-dispute:"; +export const WA_DELIVERY_CONFIRMATION_PREFIX = "wa:delivery-confirmation:"; +export const WA_DELIVERY_CONFIRMATION_ATTEMPTS_PREFIX = + "wa:delivery-confirmation-attempts:"; +export const WA_SELECTED_DELIVERY_ADDRESS_PREFIX = + "wa:selected-delivery-address:"; +export const WA_SHOPPER_ACTION_CONFIRM_ID = "shopper_action_confirm"; +export const WA_SHOPPER_ACTION_CANCEL_ID = "shopper_action_cancel"; +export const SHOPPER_ACTION_CONFIRMATION_TTL = 5 * 60; +export const DELIVERY_CONFIRMATION_TTL = 10 * 60; +export const DELIVERY_CONFIRMATION_ATTEMPT_TTL = 48 * 60 * 60; +export const DELIVERY_CONFIRMATION_MAX_CODE_ATTEMPTS = 3; +export const WA_CART_QUANTITY_MIN = 1; +export const WA_CART_QUANTITY_MAX = 99; +export const isWhatsAppCartQuantity = (value: unknown): value is number => + typeof value === "number" && + Number.isInteger(value) && + value >= WA_CART_QUANTITY_MIN && + value <= WA_CART_QUANTITY_MAX; export const LINK_FLOW_TTL = 15 * 60; export const LINK_CODE_TTL = 10 * 60; export const SESSION_TTL = LINK_FLOW_TTL; export const CONSENT_CACHE_TTL = 30 * 24 * 60 * 60; export const WA_MSG_DEDUP_PREFIX = "wa:msg:"; export const MSG_DEDUP_TTL = 5 * 60; +export const WA_INBOUND_ACTION_OUTCOME_PREFIX = "wa:inbound-action-outcome:"; +export const WA_INBOUND_ACTION_OUTCOME_TTL = 24 * 60 * 60; export const WA_INBOUND_RATE_LIMIT_PREFIX = "wa:inbound-rate:"; export const WA_INBOUND_RATE_LIMIT_MAX = 30; export const WA_INBOUND_RATE_LIMIT_WINDOW = 60 * 60; diff --git a/apps/backend/src/channels/whatsapp/whatsapp.module.ts b/apps/backend/src/channels/whatsapp/whatsapp.module.ts index 41555220..66f50379 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp.module.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp.module.ts @@ -4,12 +4,19 @@ import { PrismaModule } from "../../prisma/prisma.module"; import { RedisModule } from "../../redis/redis.module"; import { QueueModule } from "../../queue/queue.module"; import { OrderModule } from "../../domains/orders/order/order.module"; +import { CartModule } from "../../domains/orders/cart/cart.module"; +import { DisputeModule } from "../../domains/orders/dispute/dispute.module"; +import { UserModule } from "../../domains/users/user/user.module"; import { EmailModule } from "../../domains/social/email/email.module"; import { WhatsAppController } from "./whatsapp.controller"; import { WhatsAppService } from "./whatsapp.service"; import { WhatsAppProcessor } from "./whatsapp.processor"; import { WhatsAppAuthFlowsModule } from "./whatsapp-auth-flows.module"; import { WhatsAppSharedModule } from "./whatsapp-shared.module"; +import { WhatsAppShopperReadService } from "./whatsapp-shopper-read.service"; +import { WhatsAppShopperActionService } from "./whatsapp-shopper-action.service"; +import { WhatsAppDeliveryConfirmationService } from "./whatsapp-delivery-confirmation.service"; +import { WhatsAppPostPurchaseService } from "./whatsapp-post-purchase.service"; /** * WhatsApp Bot Module @@ -35,12 +42,22 @@ import { WhatsAppSharedModule } from "./whatsapp-shared.module"; RedisModule, forwardRef(() => QueueModule), forwardRef(() => OrderModule), + forwardRef(() => CartModule), + DisputeModule, + forwardRef(() => UserModule), EmailModule, WhatsAppSharedModule, WhatsAppAuthFlowsModule, ], controllers: [WhatsAppController], - providers: [WhatsAppService, WhatsAppProcessor], + providers: [ + WhatsAppService, + WhatsAppProcessor, + WhatsAppShopperReadService, + WhatsAppShopperActionService, + WhatsAppDeliveryConfirmationService, + WhatsAppPostPurchaseService, + ], exports: [WhatsAppService], }) export class WhatsAppModule {} diff --git a/apps/backend/src/channels/whatsapp/whatsapp.service.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp.service.spec.ts index 94be9094..9ef31579 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp.service.spec.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp.service.spec.ts @@ -10,6 +10,8 @@ import { MAIN_MENU, SAFE_NATURAL_ANSWER_FALLBACK, STORE_MANAGEMENT_REDIRECT, + WA_SHOPPER_ACTION_CANCEL_ID, + WA_SHOPPER_ACTION_CONFIRM_ID, } from "./whatsapp.constants"; describe("WhatsAppService", () => { @@ -24,11 +26,45 @@ describe("WhatsAppService", () => { const intentService = { parseIntent: jest.fn(), }; + const shopperAgentOrchestrator = { + planIntent: jest.fn(), + }; + const shopperAgentToolRegistry = { + assertSafeOutput: jest.fn(), + }; + const shopperReadService = { + getCart: jest.fn(), + getSavedAddresses: jest.fn(), + listOrders: jest.fn(), + getOrderStatus: jest.fn(), + getDeliveryStatus: jest.fn(), + }; + const shopperActionService = { + handlePendingActionReply: jest.fn(), + prepareAddToCart: jest.fn(), + prepareUpdateCartQuantity: jest.fn(), + prepareRemoveFromCart: jest.fn(), + prepareCheckoutHandoff: jest.fn(), + prepareSelectDeliveryAddress: jest.fn(), + }; + const deliveryConfirmationService = { + handleScopedReply: jest.fn(), + start: jest.fn(), + }; + const postPurchaseService = { + handlePendingReply: jest.fn(), + prepareDispute: jest.fn(), + getDisputeStatus: jest.fn(), + getRefundStatus: jest.fn(), + }; const imageSearchService = { handleImageSearch: jest.fn(), }; const productDiscoveryService = { sendGenericProductSearch: jest.fn(), + sendRefinedProductSearch: jest.fn(), + sendRecentProductComparison: jest.fn(), + getConversationContext: jest.fn(), sendProductSelectionFromRecentResults: jest.fn(), consumePendingTextSearch: jest.fn(), clearDiscoverySelectionState: jest.fn(), @@ -37,6 +73,7 @@ describe("WhatsAppService", () => { }; const redisService = { get: jest.fn(), + set: jest.fn(), del: jest.fn(), }; const interactiveService = { @@ -69,6 +106,7 @@ describe("WhatsAppService", () => { jest.clearAllMocks(); configService.get.mockReturnValue(undefined); redisService.get.mockResolvedValue(null); + redisService.set.mockResolvedValue(true); authService.hasActiveLinkingFlow.mockResolvedValue(false); authService.startLinkingFlow.mockResolvedValue( "To continue, please link your Twizrr account. Reply with the email address on your Twizrr account, or type 'cancel' to stop.", @@ -89,10 +127,117 @@ describe("WhatsAppService", () => { productDiscoveryService.sendProductSelectionFromRecentResults.mockResolvedValue( { handled: false }, ); + productDiscoveryService.getConversationContext.mockResolvedValue(null); + productDiscoveryService.sendRefinedProductSearch.mockResolvedValue({ + intentSuccessful: true, + productsShownCount: 2, + productsShown: ["product-1", "product-2"], + productsRankedCount: 2, + vectorSearchUsed: true, + zeroResults: false, + }); + productDiscoveryService.sendRecentProductComparison.mockResolvedValue({ + handled: true, + productsComparedCount: 2, + publicProductUrls: [ + "https://twizrr.com/stores/one/p/TWZ-111111", + "https://twizrr.com/stores/two/p/TWZ-222222", + ], + errorType: null, + }); productDiscoveryService.consumePendingTextSearch.mockResolvedValue(false); + shopperAgentOrchestrator.planIntent.mockImplementation( + ({ + functionName, + params, + linkedUserId, + }: { + functionName: string; + params?: Record; + linkedUserId: string | null; + }) => { + const categories: Record = { + search_products: "guest", + refine_product_search: "guest", + compare_products: "guest", + get_cart: "linked-read", + add_to_cart: "confirmation-required", + update_cart_quantity: "confirmation-required", + remove_from_cart: "confirmation-required", + start_checkout: "confirmation-required", + get_saved_addresses: "linked-read", + select_delivery_address: "confirmation-required", + list_orders: "linked-read", + get_order_status: "linked-read", + get_delivery_status: "linked-read", + confirm_delivery: "confirmation-required", + start_dispute: "confirmation-required", + get_dispute_status: "linked-read", + get_refund_status: "linked-read", + show_menu: "guest", + support_handoff: "guest", + store_management_redirect: "guest", + }; + const category = categories[functionName]; + if (!category) { + const conversationName = + functionName === "natural_answer" + ? "natural_answer" + : "friendly_fallback"; + return { + kind: "conversation", + functionName: conversationName, + params: conversationName === "natural_answer" ? (params ?? {}) : {}, + policy: { + allowed: true, + reason: "allowed", + confirmationRequired: false, + }, + audit: { + event: "shopper_agent_plan", + actionName: conversationName, + actionCategory: "conversation", + policyDecision: "allowed", + policyReason: "allowed", + }, + }; + } + + const requiresLink = category !== "guest"; + const allowed = !requiresLink || Boolean(linkedUserId); + return { + kind: "tool", + functionName, + params: params ?? {}, + tool: { name: functionName, category, input: params ?? {} }, + policy: { + allowed, + reason: allowed ? "allowed" : "linked_account_required", + confirmationRequired: + allowed && category === "confirmation-required", + }, + audit: { + event: "shopper_agent_plan", + actionName: functionName, + actionCategory: category, + policyDecision: allowed ? "allowed" : "denied", + policyReason: allowed ? "allowed" : "linked_account_required", + }, + }; + }, + ); whatsappSessionService.normalizePhone.mockImplementation((phone: string) => phone.startsWith("+") ? phone : `+${phone}`, ); + shopperActionService.handlePendingActionReply.mockResolvedValue({ + handled: false, + }); + deliveryConfirmationService.handleScopedReply.mockResolvedValue({ + handled: false, + }); + postPurchaseService.handlePendingReply.mockResolvedValue({ + handled: false, + }); service = new WhatsAppService( configService as any, prisma as any, @@ -106,6 +251,12 @@ describe("WhatsAppService", () => { whatsappSessionService as any, whatsappAnalyticsService as any, wizzaLocationResolver as any, + shopperAgentOrchestrator as any, + shopperAgentToolRegistry as never, + shopperReadService as never, + shopperActionService as never, + deliveryConfirmationService as never, + postPurchaseService as never, ); }); @@ -319,6 +470,127 @@ describe("WhatsAppService", () => { ).toHaveBeenCalledWith("2348012345678", "sneakers", locationContext); }); + it("passes recent shopper-safe discovery context into intent parsing", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + userId: null, + }); + const discoveryContext = { + lastQuery: "black sneakers", + source: "text" as const, + products: [ + { + rank: 1, + title: "Black Runner", + storeName: "City Kicks", + priceDisplay: "NGN 45,000", + publicProductUrl: "https://twizrr.com/stores/citykicks/p/TWZ-111111", + }, + ], + }; + productDiscoveryService.getConversationContext.mockResolvedValue( + discoveryContext, + ); + intentService.parseIntent.mockResolvedValue({ + functionName: "natural_answer", + params: { answer: "The first result is the Black Runner." }, + }); + + await service.processMessage( + "2348012345678", + "which one was first?", + "message-1", + ); + + expect(productDiscoveryService.getConversationContext).toHaveBeenCalledWith( + "2348012345678", + ); + expect(intentService.parseIntent).toHaveBeenCalledWith( + "which one was first?", + discoveryContext, + ); + }); + + it("routes contextual refinements through the discovery service", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + userId: null, + }); + productDiscoveryService.getConversationContext.mockResolvedValue({ + lastQuery: "black sneakers", + source: "text", + products: [ + { + rank: 1, + title: "Black Runner", + storeName: "City Kicks", + priceDisplay: "NGN 45,000", + }, + ], + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "refine_product_search", + params: { + refinement: "show me cheaper options in Lekki", + productReference: "first", + }, + }); + const locationContext = { + locationText: "Lekki", + source: "explicit_query" as const, + countryCode: null, + state: "Lagos", + city: "Lagos", + area: "Lekki", + }; + wizzaLocationResolver.resolve.mockResolvedValue(locationContext); + + await service.processMessage( + "2348012345678", + "show me cheaper options in Lekki", + "message-1", + ); + + expect( + productDiscoveryService.sendRefinedProductSearch, + ).toHaveBeenCalledWith( + "2348012345678", + "show me cheaper options in Lekki", + "first", + locationContext, + ); + expect( + productDiscoveryService.sendGenericProductSearch, + ).not.toHaveBeenCalled(); + }); + + it("routes product comparisons through recent discovery context", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + userId: null, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "compare_products", + params: { productReferences: ["first", "second"] }, + }); + + await service.processMessage( + "2348012345678", + "compare the first and second", + "message-1", + ); + + expect( + productDiscoveryService.sendRecentProductComparison, + ).toHaveBeenCalledWith("2348012345678", ["first", "second"]); + expect( + productDiscoveryService.sendGenericProductSearch, + ).not.toHaveBeenCalled(); + }); + it("routes an image caption through image discovery without triggering account linking", async () => { authService.resolvePhone.mockResolvedValue(null); whatsappSessionService.recordInbound.mockResolvedValue({ @@ -580,7 +852,7 @@ describe("WhatsAppService", () => { interactiveReply: undefined, messageText: "that iPhone", }); - expect(intentService.parseIntent).toHaveBeenCalledWith("that iPhone"); + expect(intentService.parseIntent).toHaveBeenCalledWith("that iPhone", null); expect( productDiscoveryService.sendGenericProductSearch, ).toHaveBeenCalledWith("2348012345678", "that iPhone"); @@ -797,11 +1069,66 @@ describe("WhatsAppService", () => { expect(authService.startLinkingFlow).not.toHaveBeenCalled(); expect(intentService.parseIntent).toHaveBeenCalledWith( "what is payment protection?", + null, ); expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( "2348012345678", expect.stringContaining("Buyer Protection"), ); + expect(whatsappAnalyticsService.recordMessage).toHaveBeenCalledWith( + expect.objectContaining({ + intentClassified: "natural_answer", + }), + ); + const recordedAnalytics = + whatsappAnalyticsService.recordMessage.mock.calls.at(-1)?.[0]; + expect(recordedAnalytics).not.toHaveProperty("parsedFilters.shopperAgent"); + }); + + it("does not treat general delivery coverage questions as address selection", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "natural_answer", + params: { + answer: + "Twizrr supports delivery coverage based on the item and route.", + }, + }); + + await service.processMessage( + "2348012345678", + "do you deliver to Lagos?", + "message-1", + ); + + expect(authService.startLinkingFlow).not.toHaveBeenCalled(); + expect(intentService.parseIntent).toHaveBeenCalledWith( + "do you deliver to Lagos?", + null, + ); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining("delivery coverage"), + ); + }); + + it("still requires linking for explicit saved-address selection", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + + await service.processMessage( + "2348012345678", + "deliver to my home address", + "message-1", + ); + + expect(authService.startLinkingFlow).toHaveBeenCalledWith("+2348012345678"); + expect(intentService.parseIntent).not.toHaveBeenCalled(); }); it("does not treat wishlist concept questions as protected actions", async () => { @@ -826,6 +1153,7 @@ describe("WhatsAppService", () => { expect(authService.startLinkingFlow).not.toHaveBeenCalled(); expect(intentService.parseIntent).toHaveBeenCalledWith( "what is a wishlist?", + null, ); expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( "2348012345678", @@ -959,7 +1287,10 @@ describe("WhatsAppService", () => { SAFE_NATURAL_ANSWER_FALLBACK, ); expect(SAFE_NATURAL_ANSWER_FALLBACK).toContain( - "supported order or delivery questions", + "supported order, delivery, dispute, or refund-status questions", + ); + expect(SAFE_NATURAL_ANSWER_FALLBACK).toContain( + "secure web checkout handoff", ); expect(SAFE_NATURAL_ANSWER_FALLBACK).not.toContain( "use your twizrr dashboard for now", @@ -1309,25 +1640,447 @@ describe("WhatsAppService", () => { ); }); - it("lets linked users reach account action handlers", async () => { + it("lets linked users read their order status through the safe adapter", async () => { authService.resolvePhone.mockResolvedValue("user-1"); whatsappSessionService.recordInbound.mockResolvedValue({ isConsentGiven: true, }); intentService.parseIntent.mockResolvedValue({ functionName: "get_order_status", + params: { orderReference: "TWZ-123456" }, + }); + shopperReadService.getOrderStatus.mockResolvedValue({ + message: "Order TWZ-123456 is paid. Delivery status: paid.", + output: { + status: "available", + orderReference: "TWZ-123456", + orderStatus: "paid", + deliveryStatus: "paid", + }, + }); + + await service.processMessage( + "2348012345678", + "where is order TWZ-123456", + "message-1", + ); + + expect(shopperReadService.getOrderStatus).toHaveBeenCalledWith("user-1", { + orderReference: "TWZ-123456", + }); + expect(shopperAgentToolRegistry.assertSafeOutput).toHaveBeenCalledWith( + "get_order_status", + expect.objectContaining({ status: "available" }), + ); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + "Order TWZ-123456 is paid. Delivery status: paid.", + ); + }); + + it("lets linked users read a shopper-safe refund status", async () => { + authService.resolvePhone.mockResolvedValue("user-1"); + whatsappSessionService.recordInbound.mockResolvedValue({ + userId: "user-1", + isConsentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "get_refund_status", + params: { orderReference: "TWZ-123456" }, + }); + postPurchaseService.getRefundStatus.mockResolvedValue({ + message: + "The refund for order TWZ-123456 is processing. Amount: NGN 25,000.00.", + output: { + status: "available", + orderReference: "TWZ-123456", + refundStatus: "processing", + amountDisplay: "NGN 25,000.00", + }, + }); + + await service.processMessage( + "2348012345678", + "where is my refund for TWZ-123456", + "message-1", + ); + + expect(postPurchaseService.getRefundStatus).toHaveBeenCalledWith("user-1", { + orderReference: "TWZ-123456", + }); + expect(shopperAgentToolRegistry.assertSafeOutput).toHaveBeenCalledWith( + "get_refund_status", + expect.objectContaining({ status: "available" }), + ); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining("processing"), + ); + }); + + it("prepares an eligible dispute and asks for explicit confirmation", async () => { + authService.resolvePhone.mockResolvedValue("user-1"); + whatsappSessionService.recordInbound.mockResolvedValue({ + userId: "user-1", + isConsentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "start_dispute", + params: { + orderReference: "TWZ-123456", + reason: "Damaged item", + description: "The screen arrived cracked.", + }, + }); + postPurchaseService.prepareDispute.mockResolvedValue({ + message: "Open a dispute for order TWZ-123456?", + output: { + status: "confirmation_required", + orderReference: "TWZ-123456", + nextStep: "confirm_or_cancel", + }, + confirmationButtons: true, + }); + + await service.processMessage( + "2348012345678", + "open a dispute for TWZ-123456 because the screen arrived cracked", + "message-1", + ); + + expect(postPurchaseService.prepareDispute).toHaveBeenCalledWith( + "+2348012345678", + "user-1", + expect.objectContaining({ orderReference: "TWZ-123456" }), + ); + expect(interactiveService.sendReplyButtons).toHaveBeenCalledWith( + "2348012345678", + "Open a dispute for order TWZ-123456?", + [ + { id: WA_SHOPPER_ACTION_CONFIRM_ID, title: "Confirm" }, + { id: WA_SHOPPER_ACTION_CANCEL_ID, title: "Cancel" }, + ], + ); + }); + + it("handles a pending dispute confirmation before generic shopper actions", async () => { + authService.resolvePhone.mockResolvedValue("user-1"); + whatsappSessionService.recordInbound.mockResolvedValue({ + userId: "user-1", + isConsentGiven: true, + }); + postPurchaseService.handlePendingReply.mockResolvedValue({ + handled: true, + message: + "Your dispute for order TWZ-123456 has been opened. Twizrr will review it.", + }); + + await service.processMessage("2348012345678", "confirm", "message-2"); + + expect(postPurchaseService.handlePendingReply).toHaveBeenCalledWith( + "+2348012345678", + "user-1", + "confirm", + undefined, + ); + expect( + shopperActionService.handlePendingActionReply, + ).not.toHaveBeenCalled(); + expect(intentService.parseIntent).not.toHaveBeenCalled(); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining("has been opened"), + ); + }); + + it("starts the scoped delivery confirmation flow for a linked shopper", async () => { + authService.resolvePhone.mockResolvedValue("user-1"); + whatsappSessionService.recordInbound.mockResolvedValue({ + userId: "user-1", + isConsentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "confirm_delivery", + params: { orderReference: "TWZ-123456" }, + }); + deliveryConfirmationService.start.mockResolvedValue({ + message: + 'Order TWZ-123456 is selected. Reply with its 6-digit delivery code, or type "cancel" to stop.', + output: { + status: "code_required", + orderReference: "TWZ-123456", + nextStep: "enter_delivery_code", + }, + }); + + await service.processMessage( + "2348012345678", + "confirm delivery for TWZ-123456", + "message-1", + ); + + expect(deliveryConfirmationService.start).toHaveBeenCalledWith( + "+2348012345678", + "user-1", + { orderReference: "TWZ-123456" }, + ); + expect(metaWhatsAppClient.markMessageAsReadWithTyping).toHaveBeenCalledWith( + "message-1", + ); + expect(shopperAgentToolRegistry.assertSafeOutput).toHaveBeenCalledWith( + "confirm_delivery", + expect.objectContaining({ status: "code_required" }), + ); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining("6-digit delivery code"), + ); + }); + + it("handles a scoped delivery-code reply before parsing another intent", async () => { + authService.resolvePhone.mockResolvedValue("user-1"); + whatsappSessionService.recordInbound.mockResolvedValue({ + userId: "user-1", + isConsentGiven: true, + }); + deliveryConfirmationService.handleScopedReply.mockResolvedValue({ + handled: true, + message: "Delivery confirmed for order TWZ-123456. Thank you.", + }); + + await service.processMessage("2348012345678", "654321", "message-2"); + + expect(deliveryConfirmationService.handleScopedReply).toHaveBeenCalledWith( + "+2348012345678", + "user-1", + "654321", + ); + expect(intentService.parseIntent).not.toHaveBeenCalled(); + expect( + shopperActionService.handlePendingActionReply, + ).not.toHaveBeenCalled(); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + "Delivery confirmed for order TWZ-123456. Thank you.", + ); + }); + + it("replays a committed action outcome without executing the action twice", async () => { + authService.resolvePhone.mockResolvedValue("user-1"); + whatsappSessionService.recordInbound.mockResolvedValue({ + userId: "user-1", + isConsentGiven: true, + }); + deliveryConfirmationService.handleScopedReply.mockResolvedValue({ + handled: true, + message: "Delivery confirmed for order TWZ-123456. Thank you.", + }); + interactiveService.sendTextMessage.mockRejectedValueOnce( + new Error("Meta unavailable"), + ); + + await expect( + service.processMessage("2348012345678", "654321", "message-2"), + ).rejects.toThrow("Meta unavailable"); + + const cachedOutcome = redisService.set.mock.calls.find( + ([key]) => key === "wa:inbound-action-outcome:+2348012345678:message-2", + )?.[1] as string; + expect(JSON.parse(cachedOutcome)).toEqual({ + message: "Delivery confirmed for order TWZ-123456. Thank you.", + intentClassified: "delivery_confirmation_reply", + flowAtEnd: "delivery_confirmation", + }); + + redisService.get.mockReset(); + redisService.get.mockResolvedValueOnce(cachedOutcome); + interactiveService.sendTextMessage.mockResolvedValue(undefined); + + await service.processMessage("2348012345678", "654321", "message-2"); + + expect(deliveryConfirmationService.handleScopedReply).toHaveBeenCalledTimes( + 1, + ); + expect(interactiveService.sendTextMessage).toHaveBeenLastCalledWith( + "2348012345678", + "Delivery confirmed for order TWZ-123456. Thank you.", + ); + }); + + it("does not retry a committed action when neither caching nor reply delivery succeeds", async () => { + authService.resolvePhone.mockResolvedValue("user-1"); + whatsappSessionService.recordInbound.mockResolvedValue({ + userId: "user-1", + isConsentGiven: true, + }); + deliveryConfirmationService.handleScopedReply.mockResolvedValue({ + handled: true, + message: "Delivery confirmed for order TWZ-123456. Thank you.", + }); + redisService.set.mockRejectedValueOnce(new Error("Redis unavailable")); + interactiveService.sendTextMessage.mockRejectedValueOnce( + new Error("Meta unavailable"), + ); + + await expect( + service.processMessage("2348012345678", "654321", "message-2"), + ).resolves.toBeUndefined(); + + expect(deliveryConfirmationService.handleScopedReply).toHaveBeenCalledTimes( + 1, + ); + }); + + it("routes linked cart reads without using the old account placeholder", async () => { + authService.resolvePhone.mockResolvedValue("user-1"); + whatsappSessionService.recordInbound.mockResolvedValue({ + isConsentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "get_cart", params: {}, }); + shopperReadService.getCart.mockResolvedValue({ + message: "Your cart is empty.", + output: { status: "empty", itemCount: 0, totalQuantity: 0 }, + }); + + await service.processMessage("2348012345678", "show my cart", "message-1"); + + expect(shopperReadService.getCart).toHaveBeenCalledWith("user-1"); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + "Your cart is empty.", + ); + expect(interactiveService.sendTextMessage).not.toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining("coming next"), + ); + }); + + it("prepares linked cart writes and asks for explicit confirmation", async () => { + authService.resolvePhone.mockResolvedValue("user-1"); + whatsappSessionService.recordInbound.mockResolvedValue({ + userId: "user-1", + isConsentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "add_to_cart", + params: { productReference: "first", quantity: 2 }, + }); + shopperActionService.prepareAddToCart.mockResolvedValue({ + message: "Add 2 x Black Sneakers to your cart?", + output: { + status: "confirmation_required", + nextStep: "confirm_or_cancel", + }, + confirmationButtons: true, + }); await service.processMessage( "2348012345678", - "where is my order", + "add 2 of the first product to my cart", "message-1", ); + expect(shopperActionService.prepareAddToCart).toHaveBeenCalledWith( + "+2348012345678", + "user-1", + { productReference: "first", quantity: 2 }, + ); + expect(shopperAgentToolRegistry.assertSafeOutput).toHaveBeenCalledWith( + "add_to_cart", + { + status: "confirmation_required", + nextStep: "confirm_or_cancel", + }, + ); + expect(interactiveService.sendReplyButtons).toHaveBeenCalledWith( + "2348012345678", + "Add 2 x Black Sneakers to your cart?", + [ + { id: WA_SHOPPER_ACTION_CONFIRM_ID, title: "Confirm" }, + { id: WA_SHOPPER_ACTION_CANCEL_ID, title: "Cancel" }, + ], + ); + }); + + it("prepares a linked checkout handoff and asks for explicit confirmation", async () => { + authService.resolvePhone.mockResolvedValue("user-1"); + whatsappSessionService.recordInbound.mockResolvedValue({ + userId: "user-1", + isConsentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "start_checkout", + params: {}, + }); + shopperActionService.prepareCheckoutHandoff.mockResolvedValue({ + message: + "Your cart has 3 units across 2 items, subtotal NGN 25,000.00. Continue to secure checkout on Twizrr?", + output: { + status: "confirmation_required", + itemCount: 2, + totalQuantity: 3, + subtotalDisplay: "NGN 25,000.00", + nextStep: "confirm_or_cancel", + }, + confirmationButtons: true, + }); + + await service.processMessage( + "2348012345678", + "proceed to checkout", + "message-checkout", + ); + + expect(shopperActionService.prepareCheckoutHandoff).toHaveBeenCalledWith( + "+2348012345678", + "user-1", + ); + expect(shopperAgentToolRegistry.assertSafeOutput).toHaveBeenCalledWith( + "start_checkout", + { + status: "confirmation_required", + itemCount: 2, + totalQuantity: 3, + subtotalDisplay: "NGN 25,000.00", + nextStep: "confirm_or_cancel", + }, + ); + expect(interactiveService.sendReplyButtons).toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining("Continue to secure checkout"), + [ + { id: WA_SHOPPER_ACTION_CONFIRM_ID, title: "Confirm" }, + { id: WA_SHOPPER_ACTION_CANCEL_ID, title: "Cancel" }, + ], + ); + }); + + it("handles a pending confirmation before parsing another intent", async () => { + authService.resolvePhone.mockResolvedValue("user-1"); + whatsappSessionService.recordInbound.mockResolvedValue({ + userId: "user-1", + isConsentGiven: true, + }); + shopperActionService.handlePendingActionReply.mockResolvedValue({ + handled: true, + message: "Added Black Sneakers to your cart. Your cart now has 1 unit.", + }); + + await service.processMessage("2348012345678", "confirm", "message-confirm"); + + expect(shopperActionService.handlePendingActionReply).toHaveBeenCalledWith( + "+2348012345678", + "user-1", + "confirm", + undefined, + ); + expect(intentService.parseIntent).not.toHaveBeenCalled(); expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( "2348012345678", - expect.stringContaining("Your account is linked"), + "Added Black Sneakers to your cart. Your cart now has 1 unit.", ); }); diff --git a/apps/backend/src/channels/whatsapp/whatsapp.service.ts b/apps/backend/src/channels/whatsapp/whatsapp.service.ts index 3625c15a..1a5386ec 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp.service.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp.service.ts @@ -13,6 +13,10 @@ import { SAFE_NATURAL_ANSWER_FALLBACK, STORE_MANAGEMENT_REDIRECT, SUPPORT_HANDOFF_RESPONSE, + WA_INBOUND_ACTION_OUTCOME_PREFIX, + WA_INBOUND_ACTION_OUTCOME_TTL, + WA_SHOPPER_ACTION_CANCEL_ID, + WA_SHOPPER_ACTION_CONFIRM_ID, } from "./whatsapp.constants"; import { WhatsAppProductDiscoveryService, @@ -38,6 +42,29 @@ import { } from "../../domains/commerce/search/search-zero-result"; import { WizzaLocationResolver } from "./wizza-location-resolver.service"; import type { WizzaDiscoveryLocationContext } from "./wizza-location-resolver.service"; +import { ShopperAgentOrchestrator } from "./agent/shopper-agent-orchestrator.service"; +import type { + ShopperAgentPlan, + ShopperAgentToolInputMap, +} from "./agent/shopper-agent.types"; +import { ShopperAgentToolRegistry } from "./agent/shopper-agent-tool.registry"; +import { WhatsAppShopperReadService } from "./whatsapp-shopper-read.service"; +import { + type PendingActionReplyResult, + WhatsAppShopperActionService, +} from "./whatsapp-shopper-action.service"; +import { WhatsAppDeliveryConfirmationService } from "./whatsapp-delivery-confirmation.service"; +import { WhatsAppPostPurchaseService } from "./whatsapp-post-purchase.service"; +import { WhatsAppRetryableActionError } from "./whatsapp-retryable-action.error"; + +type ShopperWriteToolName = + | "add_to_cart" + | "update_cart_quantity" + | "remove_from_cart" + | "start_checkout" + | "select_delivery_address"; + +type ShopperWriteToolInput = ShopperAgentToolInputMap[ShopperWriteToolName]; type WhatsAppAnalyticsContext = Omit< WhatsAppAnalyticsMessageInput, @@ -61,6 +88,12 @@ interface DiscoveryFollowUpOutcome { searchAnalytics?: WhatsAppProductSearchAnalytics; } +interface InboundActionOutcome { + message: string; + intentClassified: string; + flowAtEnd: string; +} + @Injectable() export class WhatsAppService { private readonly logger = new Logger(WhatsAppService.name); @@ -78,6 +111,12 @@ export class WhatsAppService { private whatsappSessionService: WhatsAppSessionService, private whatsappAnalyticsService: WhatsAppAnalyticsService, private wizzaLocationResolver: WizzaLocationResolver, + private shopperAgentOrchestrator: ShopperAgentOrchestrator, + private shopperAgentToolRegistry: ShopperAgentToolRegistry, + private shopperReadService: WhatsAppShopperReadService, + private shopperActionService: WhatsAppShopperActionService, + private deliveryConfirmationService: WhatsAppDeliveryConfirmationService, + private postPurchaseService: WhatsAppPostPurchaseService, ) {} async processMessage( @@ -89,6 +128,8 @@ export class WhatsAppService { imageCaption?: string, ): Promise { let analytics: WhatsAppAnalyticsContext | null = null; + let committedActionOutcome: InboundActionOutcome | null = null; + let committedActionOutcomeReplayable = false; let typingIndicatorShown = false; const showTypingIndicatorOnce = async () => { if (typingIndicatorShown) { @@ -99,6 +140,16 @@ export class WhatsAppService { await this.showTypingIndicator(messageId); }; + const replayed = await this.replayInboundActionOutcome( + phone, + messageText, + messageId, + interactiveReply, + ); + if (replayed) { + return; + } + const pauseKey = `ai_paused_${phone}`; const isPaused = await this.redisService.get(pauseKey); @@ -232,6 +283,97 @@ export class WhatsAppService { } } + const deliveryConfirmationReply = + await this.deliveryConfirmationService.handleScopedReply( + normalizedPhone, + session.userId ?? userId ?? null, + messageText, + ); + if (deliveryConfirmationReply.handled) { + await showTypingIndicatorOnce(); + committedActionOutcome = { + message: + deliveryConfirmationReply.message ?? + "That delivery confirmation could not be completed.", + intentClassified: "delivery_confirmation_reply", + flowAtEnd: "delivery_confirmation", + }; + committedActionOutcomeReplayable = + await this.persistInboundActionOutcomeBestEffort( + phone, + messageId, + committedActionOutcome, + ); + await this.interactiveService.sendTextMessage( + phone, + committedActionOutcome.message, + ); + analytics.intentClassified = "delivery_confirmation_reply"; + analytics.intentSuccessful = true; + analytics.flowAtEnd = "delivery_confirmation"; + return; + } + + const pendingDisputeReply = + await this.postPurchaseService.handlePendingReply( + normalizedPhone, + session.userId ?? userId ?? null, + messageText, + interactiveReply, + ); + if (pendingDisputeReply.handled) { + await showTypingIndicatorOnce(); + committedActionOutcome = { + message: + pendingDisputeReply.message ?? + "That dispute request is no longer available.", + intentClassified: "dispute_confirmation", + flowAtEnd: "dispute_confirmation", + }; + committedActionOutcomeReplayable = + await this.persistInboundActionOutcomeBestEffort( + phone, + messageId, + committedActionOutcome, + ); + await this.interactiveService.sendTextMessage( + phone, + committedActionOutcome.message, + ); + analytics.intentClassified = "dispute_confirmation"; + analytics.intentSuccessful = true; + analytics.flowAtEnd = "dispute_confirmation"; + return; + } + + const pendingActionReply = + await this.shopperActionService.handlePendingActionReply( + normalizedPhone, + session.userId ?? userId ?? null, + messageText, + interactiveReply, + ); + if (pendingActionReply.handled) { + await showTypingIndicatorOnce(); + committedActionOutcome = { + message: + pendingActionReply.message ?? "That action is no longer available.", + intentClassified: "shopper_action_confirmation", + flowAtEnd: "shopper_action_confirmation", + }; + committedActionOutcomeReplayable = + await this.persistInboundActionOutcomeBestEffort( + phone, + messageId, + committedActionOutcome, + ); + await this.sendPendingActionResult(phone, pendingActionReply); + analytics.intentClassified = "shopper_action_confirmation"; + analytics.intentSuccessful = true; + analytics.flowAtEnd = "shopper_action_confirmation"; + return; + } + if (imageId) { await showTypingIndicatorOnce(); const locationContext = await this.wizzaLocationResolver.resolve( @@ -334,14 +476,33 @@ export class WhatsAppService { } const intentStartMs = Date.now(); - const intent = await this.intentService.parseIntent(messageText || ""); + const discoveryContext = + await this.productDiscoveryService.getConversationContext(phone); + const parsedIntent = await this.intentService.parseIntent( + messageText || "", + discoveryContext, + ); + const intent = this.shopperAgentOrchestrator.planIntent({ + functionName: parsedIntent.functionName, + params: parsedIntent.params, + messageText: messageText || "", + linkedUserId: session.userId ?? userId ?? null, + consentGiven: session.isConsentGiven, + }); analytics.geminiResponseMs = Date.now() - intentStartMs; analytics.geminiModel = this.getGeminiModelName(); analytics.intentClassified = intent.functionName; + if (intent.kind === "tool" && !intent.policy.allowed) { + await this.handleDeniedAgentTool(phone, userId, intent, analytics); + return; + } + if ( this.isAccountActionRequest(messageText || "", intent.functionName) && - intent.functionName !== "get_order_status" && + !this.isShopperReadTool(intent.functionName) && + !this.isShopperWriteTool(intent.functionName) && + !this.isPostPurchaseTool(intent.functionName) && intent.functionName !== "confirm_delivery" ) { if (!(await this.requireLinkedAccount(phone, userId))) { @@ -369,9 +530,62 @@ export class WhatsAppService { ); return; + case "refine_product_search": { + await showTypingIndicatorOnce(); + const refinement = this.getStringParam( + intent.params, + "refinement", + messageText || "", + ); + const productReference = this.getOptionalStringParam( + intent.params, + "productReference", + ); + const locationContext = await this.wizzaLocationResolver.resolve( + refinement, + session.userId ?? userId ?? null, + ); + this.applyLocationContext(analytics, locationContext); + const searchAnalytics = + await this.productDiscoveryService.sendRefinedProductSearch( + phone, + refinement, + productReference, + locationContext, + ); + this.applyProductSearchAnalytics(analytics, searchAnalytics); + analytics.flowAtEnd = "product_refinement"; + return; + } + + case "compare_products": { + await showTypingIndicatorOnce(); + const comparison = + await this.productDiscoveryService.sendRecentProductComparison( + phone, + this.getStringArrayParam(intent.params, "productReferences"), + ); + analytics.intentSuccessful = + comparison.handled && comparison.productsComparedCount >= 2; + analytics.productsShownCount = comparison.productsComparedCount; + analytics.errorType = comparison.errorType ?? null; + analytics.dropOffStep = + comparison.productsComparedCount >= 2 + ? null + : "product_comparison_context"; + analytics.flowAtEnd = "product_comparison"; + return; + } + + case "get_cart": + case "get_saved_addresses": + case "list_orders": case "get_order_status": - case "confirm_delivery": - if (!(await this.requireLinkedAccount(phone, userId))) { + case "get_delivery_status": + case "get_dispute_status": + case "get_refund_status": { + const linkedUserId = session.userId ?? userId ?? null; + if (!(await this.requireLinkedAccount(phone, linkedUserId))) { analytics.intentClassified = "account_link_required"; analytics.intentSuccessful = true; analytics.flowAtEnd = "account_linking"; @@ -379,13 +593,132 @@ export class WhatsAppService { return; } - await this.sendLinkedAccountActionPlaceholder( - phone, + await showTypingIndicatorOnce(); + const result = await this.executeShopperReadTool( + intent.functionName, + linkedUserId as string, + intent.params, + ); + this.shopperAgentToolRegistry.assertSafeOutput( + intent.functionName, + result.output, + ); + await this.interactiveService.sendTextMessage(phone, result.message); + analytics.intentSuccessful = true; + analytics.flowAtEnd = intent.functionName; + return; + } + + case "start_dispute": { + const linkedUserId = session.userId ?? userId ?? null; + if (!(await this.requireLinkedAccount(phone, linkedUserId))) { + analytics.intentClassified = "account_link_required"; + analytics.intentSuccessful = true; + analytics.flowAtEnd = "account_linking"; + analytics.dropOffStep = "account_link_required"; + return; + } + + await showTypingIndicatorOnce(); + const result = await this.postPurchaseService.prepareDispute( + normalizedPhone, + linkedUserId as string, + intent.tool.input as ShopperAgentToolInputMap["start_dispute"], + ); + this.shopperAgentToolRegistry.assertSafeOutput( + intent.functionName, + result.output, + ); + if (result.confirmationButtons) { + await this.interactiveService.sendReplyButtons( + phone, + result.message, + [ + { id: WA_SHOPPER_ACTION_CONFIRM_ID, title: "Confirm" }, + { id: WA_SHOPPER_ACTION_CANCEL_ID, title: "Cancel" }, + ], + ); + } else { + await this.interactiveService.sendTextMessage( + phone, + result.message, + ); + } + analytics.intentSuccessful = true; + analytics.flowAtEnd = intent.functionName; + return; + } + + case "add_to_cart": + case "update_cart_quantity": + case "remove_from_cart": + case "start_checkout": + case "select_delivery_address": { + const linkedUserId = session.userId ?? userId ?? null; + if (!(await this.requireLinkedAccount(phone, linkedUserId))) { + analytics.intentClassified = "account_link_required"; + analytics.intentSuccessful = true; + analytics.flowAtEnd = "account_linking"; + analytics.dropOffStep = "account_link_required"; + return; + } + + await showTypingIndicatorOnce(); + const result = await this.executeShopperWriteTool( + intent.functionName, + normalizedPhone, + linkedUserId as string, + intent.tool.input as ShopperWriteToolInput, + ); + this.shopperAgentToolRegistry.assertSafeOutput( + intent.functionName, + result.output, + ); + if (result.confirmationButtons) { + await this.interactiveService.sendReplyButtons( + phone, + result.message, + [ + { id: WA_SHOPPER_ACTION_CONFIRM_ID, title: "Confirm" }, + { id: WA_SHOPPER_ACTION_CANCEL_ID, title: "Cancel" }, + ], + ); + } else { + await this.interactiveService.sendTextMessage( + phone, + result.message, + ); + } + analytics.intentSuccessful = true; + analytics.flowAtEnd = intent.functionName; + return; + } + + case "confirm_delivery": { + const linkedUserId = session.userId ?? userId ?? null; + if (!(await this.requireLinkedAccount(phone, linkedUserId))) { + analytics.intentClassified = "account_link_required"; + analytics.intentSuccessful = true; + analytics.flowAtEnd = "account_linking"; + analytics.dropOffStep = "account_link_required"; + return; + } + + await showTypingIndicatorOnce(); + const result = await this.deliveryConfirmationService.start( + normalizedPhone, + linkedUserId as string, + intent.tool.input as ShopperAgentToolInputMap["confirm_delivery"], + ); + this.shopperAgentToolRegistry.assertSafeOutput( intent.functionName, + result.output, ); + await this.interactiveService.sendTextMessage(phone, result.message); analytics.intentSuccessful = true; analytics.flowAtEnd = intent.functionName; return; + } case "show_menu": await this.sendShopperMenu(phone); @@ -441,6 +774,21 @@ export class WhatsAppService { error instanceof Error ? error.message : error }`, ); + if ( + !committedActionOutcome && + error instanceof WhatsAppRetryableActionError + ) { + throw error; + } + if (committedActionOutcome) { + if (committedActionOutcomeReplayable) { + throw error; + } + this.logger.error( + `WhatsApp committed action reply could not be delivered or cached for ${maskWhatsAppPhone(phone)}; suppressing automatic retry to avoid repeating the action`, + ); + return; + } await this.interactiveService.sendTextMessage( phone, "Something went wrong while processing your message. Please try again.", @@ -450,6 +798,41 @@ export class WhatsAppService { } } + private getStringParam( + params: Record, + key: string, + fallback: string, + ): string { + const value = params[key]; + return typeof value === "string" && value.trim() + ? value.trim() + : fallback.trim(); + } + + private getOptionalStringParam( + params: Record, + key: string, + ): string | undefined { + const value = params[key]; + return typeof value === "string" && value.trim() ? value.trim() : undefined; + } + + private getStringArrayParam( + params: Record, + key: string, + ): string[] { + const value = params[key]; + if (!Array.isArray(value)) { + return []; + } + + return value + .filter((item): item is string => typeof item === "string") + .map((item) => item.trim()) + .filter(Boolean) + .slice(0, 3); + } + private getMessageType( messageText?: string, interactiveReply?: { type: string; id: string; title: string }, @@ -724,12 +1107,35 @@ export class WhatsAppService { } } + private async handleDeniedAgentTool( + phone: string, + userId: string | null, + intent: Extract, + analytics: WhatsAppAnalyticsContext, + ): Promise { + if (intent.policy.reason === "linked_account_required") { + await this.requireLinkedAccount(phone, userId); + analytics.intentClassified = "account_link_required"; + analytics.intentSuccessful = true; + analytics.flowAtEnd = "account_linking"; + analytics.dropOffStep = "account_link_required"; + return; + } + + await this.interactiveService.sendTextMessage(phone, FRIENDLY_FALLBACK); + analytics.intentSuccessful = false; + analytics.flowAtEnd = "fallback"; + analytics.dropOffStep = intent.policy.reason; + } + private isAccountActionRequest( messageText: string, functionName?: string, ): boolean { if ( - functionName === "get_order_status" || + this.isShopperReadTool(functionName) || + this.isShopperWriteTool(functionName) || + this.isPostPurchaseTool(functionName) || functionName === "confirm_delivery" ) { return true; @@ -738,6 +1144,239 @@ export class WhatsAppService { return this.isProtectedAccountActionText(messageText); } + private isShopperWriteTool( + functionName?: string, + ): functionName is ShopperWriteToolName { + return [ + "add_to_cart", + "update_cart_quantity", + "remove_from_cart", + "start_checkout", + "select_delivery_address", + ].includes(functionName ?? ""); + } + + private isShopperReadTool( + functionName?: string, + ): functionName is + | "get_cart" + | "get_saved_addresses" + | "list_orders" + | "get_order_status" + | "get_delivery_status" + | "get_dispute_status" + | "get_refund_status" { + return [ + "get_cart", + "get_saved_addresses", + "list_orders", + "get_order_status", + "get_delivery_status", + "get_dispute_status", + "get_refund_status", + ].includes(functionName ?? ""); + } + + private isPostPurchaseTool( + functionName?: string, + ): functionName is + | "start_dispute" + | "get_dispute_status" + | "get_refund_status" { + return [ + "start_dispute", + "get_dispute_status", + "get_refund_status", + ].includes(functionName ?? ""); + } + + private executeShopperReadTool( + functionName: + | "get_cart" + | "get_saved_addresses" + | "list_orders" + | "get_order_status" + | "get_delivery_status" + | "get_dispute_status" + | "get_refund_status", + userId: string, + params: Record, + ) { + switch (functionName) { + case "get_cart": + return this.shopperReadService.getCart(userId); + case "get_saved_addresses": + return this.shopperReadService.getSavedAddresses(userId); + case "list_orders": + return this.shopperReadService.listOrders(userId); + case "get_order_status": + return this.shopperReadService.getOrderStatus(userId, { + orderReference: this.getOptionalStringParam(params, "orderReference"), + }); + case "get_delivery_status": + return this.shopperReadService.getDeliveryStatus(userId, { + orderReference: this.getOptionalStringParam(params, "orderReference"), + }); + case "get_dispute_status": + return this.postPurchaseService.getDisputeStatus(userId, { + orderReference: this.getOptionalStringParam(params, "orderReference"), + }); + case "get_refund_status": + return this.postPurchaseService.getRefundStatus(userId, { + orderReference: this.getOptionalStringParam(params, "orderReference"), + }); + } + } + + private executeShopperWriteTool( + functionName: ShopperWriteToolName, + phone: string, + userId: string, + input: ShopperWriteToolInput, + ) { + switch (functionName) { + case "add_to_cart": + return this.shopperActionService.prepareAddToCart( + phone, + userId, + input as { + productReference?: string; + quantity: number; + }, + ); + case "update_cart_quantity": + return this.shopperActionService.prepareUpdateCartQuantity( + phone, + userId, + input as { + cartItemReference?: string; + quantity: number; + }, + ); + case "remove_from_cart": + return this.shopperActionService.prepareRemoveFromCart( + phone, + userId, + input as { cartItemReference?: string }, + ); + case "start_checkout": + return this.shopperActionService.prepareCheckoutHandoff(phone, userId); + case "select_delivery_address": + return this.shopperActionService.prepareSelectDeliveryAddress( + phone, + userId, + input as { addressReference?: string }, + ); + } + } + + private async sendPendingActionResult( + phone: string, + result: PendingActionReplyResult, + ): Promise { + await this.interactiveService.sendTextMessage( + phone, + result.message ?? "That action is no longer available.", + ); + } + + private async replayInboundActionOutcome( + phone: string, + messageText?: string, + messageId?: string, + interactiveReply?: { id: string }, + ): Promise { + if ( + !messageId?.trim() || + !this.isPotentialConfirmedActionReply(messageText, interactiveReply) + ) { + return false; + } + + const raw = await this.redisService.get( + this.inboundActionOutcomeKey(phone, messageId), + ); + const outcome = raw ? this.parseInboundActionOutcome(raw) : null; + if (!outcome) { + return false; + } + + await this.interactiveService.sendTextMessage(phone, outcome.message); + return true; + } + + private async persistInboundActionOutcomeBestEffort( + phone: string, + messageId: string | undefined, + outcome: InboundActionOutcome, + ): Promise { + if (!messageId?.trim()) { + return false; + } + + try { + await this.redisService.set( + this.inboundActionOutcomeKey(phone, messageId), + JSON.stringify(outcome), + WA_INBOUND_ACTION_OUTCOME_TTL, + ); + return true; + } catch (error) { + this.logger.warn( + `WhatsApp action outcome caching failed for ${maskWhatsAppPhone(phone)}: ${ + error instanceof Error ? error.message : "unknown error" + }`, + ); + return false; + } + } + + private parseInboundActionOutcome(raw: string): InboundActionOutcome | null { + try { + const value: unknown = JSON.parse(raw); + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + const outcome = value as Record; + if ( + typeof outcome.message !== "string" || + !outcome.message.trim() || + typeof outcome.intentClassified !== "string" || + typeof outcome.flowAtEnd !== "string" + ) { + return null; + } + return { + message: outcome.message, + intentClassified: outcome.intentClassified, + flowAtEnd: outcome.flowAtEnd, + }; + } catch { + return null; + } + } + + private isPotentialConfirmedActionReply( + messageText?: string, + interactiveReply?: { id: string }, + ): boolean { + const text = messageText?.trim().toLowerCase(); + const reply = interactiveReply?.id.trim().toLowerCase(); + return ( + text === "confirm" || + text === "yes" || + text === "cancel" || + text === "no" || + Boolean(text && /^\d{6}$/.test(text)) || + reply === WA_SHOPPER_ACTION_CONFIRM_ID || + reply === WA_SHOPPER_ACTION_CANCEL_ID + ); + } + + private inboundActionOutcomeKey(phone: string, messageId: string): string { + return `${WA_INBOUND_ACTION_OUTCOME_PREFIX}${this.whatsappSessionService.normalizePhone(phone)}:${messageId.trim()}`; + } + private getSearchQuery( params: Record | undefined, fallback?: string, @@ -929,6 +1568,10 @@ export class WhatsAppService { /\bcheckout\b/, /\badd\b.{0,60}\bto\s+(?:my\s+)?cart\b/, /\bput\b.{0,60}\bin\s+(?:my\s+)?cart\b/, + /\b(?:remove|delete)\b.{0,60}\bfrom\s+(?:my\s+)?cart\b/, + /\b(?:set|change|update)\b.{0,60}\b(?:quantity|qty)\b/, + /\b(?:use|select|choose)\b.{0,60}\b(?:delivery\s+)?address\b/, + /\bdeliver\s+to\b.{1,60}\b(?:delivery\s+)?address\b/, /\bsave\s+(?:this|that|it|the\s+product)\b/, /\b(?:save|add)\b.{0,60}\bto\s+(?:my\s+)?wishlist\b/, /\bpay\s+(?:for|now|here)\b/, @@ -937,6 +1580,10 @@ export class WhatsAppService { /\bbuy\s+(?:this|that|it)\s+for\s+me\b/, /\btrack\s+(?:my|the|this|an)?\s*order\b/, /\border\s+(?:status|history)\b/, + /\b(?:show|view|check|what(?:'s| is))\s+(?:in\s+)?(?:my\s+)?cart\b/, + /\b(?:show|view|list)\s+(?:my\s+)?saved\s+(?:delivery\s+)?addresses\b/, + /\b(?:show|view|list)\s+(?:my\s+)?(?:recent\s+)?orders\b/, + /\bdelivery\s+(?:status|progress)\b/, /\bwhere\s+is\s+my\s+order\b/, /\bconfirm\s+delivery\b/, /\bdelivery\s+(?:confirmation|code)\b/, diff --git a/apps/backend/src/domains/money/payment/payment.service.spec.ts b/apps/backend/src/domains/money/payment/payment.service.spec.ts index f62456f6..c077ab8d 100644 --- a/apps/backend/src/domains/money/payment/payment.service.spec.ts +++ b/apps/backend/src/domains/money/payment/payment.service.spec.ts @@ -496,13 +496,14 @@ describe("PaymentService order notifications", () => { }); it("passes merchandise amount separately for store-facing paid-order notifications", async () => { - const { service, orderService, outbox } = makeSuccessfulPaymentService(); + const { service, notifications, orderService, outbox } = + makeSuccessfulPaymentService(); await ( service as unknown as PaymentServiceWithPrivateSuccess ).processSuccessfulPayment("payment-1", "tx-reference"); - expect(outbox.append).toHaveBeenCalledTimes(2); + expect(outbox.append).toHaveBeenCalledTimes(3); expect(outbox.append).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ @@ -519,6 +520,22 @@ describe("PaymentService order notifications", () => { }), }), ); + expect(outbox.append).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + topic: "whatsapp.direct-order-notification.requested", + idempotencyKey: "payment:payment-1:whatsapp:direct-order:store-1", + payload: { + storeId: "store-1", + orderData: { + orderId: "order-1", + productName: "Canvas Tote", + quantity: 2, + amountKobo: "200000", + }, + }, + }), + ); expect(orderService.transitionBySystemInTransaction).toHaveBeenCalledWith( expect.anything(), "order-1", @@ -529,6 +546,9 @@ describe("PaymentService order notifications", () => { reference: "tx-reference", }), ); + expect( + notifications.triggerOrderPurchaseConfirmedWhatsApp, + ).not.toHaveBeenCalled(); }); it("does not subtract a merchant-borne platform fee from cart notification merchandise", () => { diff --git a/apps/backend/src/domains/money/payment/payment.service.ts b/apps/backend/src/domains/money/payment/payment.service.ts index 686b0a0b..cd660728 100644 --- a/apps/backend/src/domains/money/payment/payment.service.ts +++ b/apps/backend/src/domains/money/payment/payment.service.ts @@ -1485,6 +1485,23 @@ export class PaymentService { order.storeId + ":purchase-received", }); + await this.outbox.append(tx, { + topic: OUTBOX_TOPIC.WHATSAPP_DIRECT_ORDER_NOTIFICATION_REQUESTED, + version: 1, + aggregateType: "PAYMENT", + aggregateId: payment.id, + idempotencyKey: + "payment:" + payment.id + ":whatsapp:direct-order:" + order.storeId, + payload: { + storeId: order.storeId, + orderData: { + orderId: payment.orderId, + productName, + quantity: order.quantity, + amountKobo: storeAmountKobo.toString(), + }, + }, + }); return; } @@ -2101,30 +2118,6 @@ export class PaymentService { } } - const orderData = await this.prisma.order.findUnique({ - where: { id: payment.orderId }, - include: { product: true, user: true }, - }); - - if ( - orderData && - orderData.productId !== null && - orderData.quantity !== null - ) { - await this.notifications.triggerOrderPurchaseConfirmedWhatsApp( - payment.order.storeId as string, - { - orderId: payment.orderId, - productName: orderData.product?.name || "Product", - quantity: orderData.quantity, - amountKobo: this.calculateStoreNotificationAmount( - payment.order, - payment.order.platformFeeBearer, - ).toString(), - }, - ); - } - this.logger.log( `Payment ${payment.id} SUCCESS for order ${payment.orderId}`, ); @@ -2494,30 +2487,6 @@ export class PaymentService { }); } } - - const orderData = await this.prisma.order.findUnique({ - where: { id: payment.orderId }, - include: { product: true }, - }); - if ( - orderData && - orderData.productId !== null && - orderData.quantity !== null && - payment.order.storeId - ) { - await this.notifications.triggerOrderPurchaseConfirmedWhatsApp( - payment.order.storeId as string, - { - orderId: payment.orderId, - productName: orderData.product?.name || "Product", - quantity: orderData.quantity, - amountKobo: this.calculateStoreNotificationAmount( - payment.order, - payment.order.platformFeeBearer, - ).toString(), - }, - ); - } } // ────────────────────────────────────────────── diff --git a/apps/backend/src/domains/orders/dispute/dispute.service.ts b/apps/backend/src/domains/orders/dispute/dispute.service.ts index e20bc7d3..6d50f2e7 100644 --- a/apps/backend/src/domains/orders/dispute/dispute.service.ts +++ b/apps/backend/src/domains/orders/dispute/dispute.service.ts @@ -183,6 +183,14 @@ export class DisputeService { } async openDispute(orderId: string, user: JwtPayload, dto: CreateDisputeDto) { + return this.openBuyerDispute(orderId, user.sub, dto); + } + + async openBuyerDispute( + orderId: string, + buyerId: string, + dto: CreateDisputeDto, + ) { const order = await this.prisma.order.findUnique({ where: { id: orderId }, include: { storeProfile: true, payments: true }, @@ -192,7 +200,7 @@ export class DisputeService { throw new NotFoundException("Order not found"); } - this.assertBuyerOwnsOrder(order, user.sub); + this.assertBuyerOwnsOrder(order, buyerId); this.assertOrderCanBeDisputed(order); const existingActive = await this.prisma.dispute.findFirst({ @@ -212,17 +220,48 @@ export class DisputeService { const dispute = await this.runOpenDisputeTransaction( order, orderId, - user, + buyerId, dto, ); return this.mapDispute(dispute); } + async assertBuyerCanOpenDispute( + orderId: string, + buyerId: string, + ): Promise { + const order = await this.prisma.order.findUnique({ + where: { id: orderId }, + include: { storeProfile: true, payments: true }, + }); + + if (!order) { + throw new NotFoundException("Order not found"); + } + + this.assertBuyerOwnsOrder(order, buyerId); + this.assertOrderCanBeDisputed(order); + + const existingActive = await this.prisma.dispute.findFirst({ + where: { + orderId, + status: { in: [...ACTIVE_DISPUTE_STATUSES] }, + }, + select: { id: true }, + }); + if (existingActive) { + throw new ConflictException({ + message: "An active dispute already exists for this order", + code: "DISPUTE_ALREADY_OPEN", + }); + } + } + private async runOpenDisputeTransaction( order: OrderForDispute, orderId: string, - user: JwtPayload, + buyerId: string, dto: CreateDisputeDto, ): Promise { try { @@ -273,7 +312,7 @@ export class DisputeService { await this.createEvidenceRows( tx, created.id, - user.sub, + buyerId, DisputeActorType.BUYER, dto.evidence, ); @@ -283,7 +322,7 @@ export class DisputeService { orderId, fromStatus: order.status, toStatus: OrderStatus.DISPUTE, - triggeredBy: user.sub, + triggeredBy: buyerId, metadata: { action: "dispute_opened", disputeId: created.id, @@ -297,7 +336,7 @@ export class DisputeService { created, "DISPUTE_OPENED", "USER", - user.sub, + buyerId, { reason: dto.reason, buyerRequestedOutcome: dto.buyerRequestedOutcome, @@ -387,6 +426,26 @@ export class DisputeService { this.assertCanAccessOrder(order.buyerId, order.storeId, user); + return this.findOrderDisputes(orderId); + } + + async getBuyerOrderDisputes(orderId: string, buyerId: string) { + const order = await this.prisma.order.findUnique({ + where: { id: orderId }, + }); + + if (!order) { + throw new NotFoundException("Order not found"); + } + + if (order.buyerId !== buyerId) { + throw new ForbiddenException("You cannot access this order"); + } + + return this.findOrderDisputes(orderId); + } + + private async findOrderDisputes(orderId: string) { const disputes = await this.prisma.dispute.findMany({ where: { orderId }, include: this.disputeInclude(), @@ -422,6 +481,18 @@ export class DisputeService { async getSettlementVisibility(id: string, user: JwtPayload) { const dispute = await this.findDisputeByIdOrThrow(id); this.assertCanAccessDispute(dispute, user); + return this.findSettlementVisibility(id, dispute.buyerId === user.sub); + } + + async getBuyerSettlementVisibility(id: string, buyerId: string) { + const dispute = await this.findDisputeByIdOrThrow(id); + if (dispute.buyerId !== buyerId) { + throw new ForbiddenException("You cannot access this dispute"); + } + return this.findSettlementVisibility(id, true); + } + + private async findSettlementVisibility(id: string, isBuyer: boolean) { const settlement = await this.prisma.disputeSettlement.findUnique({ where: { disputeId: id }, select: { @@ -440,7 +511,6 @@ export class DisputeService { }, }); if (!settlement) return null; - const isBuyer = dispute.buyerId === user.sub; const leg = settlement.legs.find((item) => isBuyer ? item.type === "BUYER_REFUND" : item.type === "MERCHANT_PAYOUT", ); diff --git a/apps/backend/src/domains/orders/order/order.module.ts b/apps/backend/src/domains/orders/order/order.module.ts index f87cc014..1c464f52 100644 --- a/apps/backend/src/domains/orders/order/order.module.ts +++ b/apps/backend/src/domains/orders/order/order.module.ts @@ -14,6 +14,7 @@ import { LedgerModule } from "../../money/ledger/ledger.module"; import { QueueModule } from "../../../queue/queue.module"; import { DomainEventModule } from "../../platform/domain-event/domain-event.module"; import { OutboxModule } from "../../platform/outbox/outbox.module"; +import { AutoConfirmProcessor } from "../../../queue/processors/auto-confirm.processor"; @Module({ imports: [ @@ -30,7 +31,7 @@ import { OutboxModule } from "../../platform/outbox/outbox.module"; PayoutModule, ], controllers: [OrderController, StoreOrderController], - providers: [OrderService, InvoiceService], + providers: [OrderService, InvoiceService, AutoConfirmProcessor], exports: [OrderService], }) export class OrderModule {} diff --git a/apps/backend/src/domains/orders/order/order.service.spec.ts b/apps/backend/src/domains/orders/order/order.service.spec.ts index dfc876ed..cb64b2fa 100644 --- a/apps/backend/src/domains/orders/order/order.service.spec.ts +++ b/apps/backend/src/domains/orders/order/order.service.spec.ts @@ -19,6 +19,7 @@ type TransitionForTest = ( toStatus: OrderStatus, triggeredBy: string, metadata?: Record, + updateData?: Record, ) => Promise<{ id: string; status: OrderStatus }>; describe("OrderService domain events", () => { @@ -1088,6 +1089,11 @@ describe("OrderService delivery confirmation", () => { const prisma = { order: { findUnique: jest.fn().mockResolvedValue(dispatchedOrder), + update: jest.fn().mockResolvedValue(deliveredOrder), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + orderTracking: { + create: jest.fn().mockResolvedValue({ id: "tracking-1" }), }, $transaction: jest.fn((callback: (transaction: typeof tx) => unknown) => callback(tx), @@ -1095,6 +1101,7 @@ describe("OrderService delivery confirmation", () => { }; const notifications = { triggerDeliveryConfirmed: jest.fn(), + triggerOrderAutoConfirmed: jest.fn(), }; const verification = { checkAndUpgradeTier: jest.fn(), @@ -1168,7 +1175,7 @@ describe("OrderService delivery confirmation", () => { data: expect.objectContaining({ fromStatus: OrderStatus.DELIVERED, toStatus: OrderStatus.COMPLETED, - metadata: { action: "auto_completed" }, + metadata: { action: "delivery_confirmed_completed" }, }), }); expect(domainEvents.append).toHaveBeenCalledWith( @@ -1193,6 +1200,125 @@ describe("OrderService delivery confirmation", () => { expect(verification.checkAndUpgradeTier).toHaveBeenCalledWith("store-1"); }); + it("resumes a buyer confirmation from DELIVERED after an escrow ledger failure", async () => { + const dispatchedOrder = await makeDispatchedOrder(); + const deliveredOrder = { + ...dispatchedOrder, + status: OrderStatus.DELIVERED, + }; + const { service, prisma, tx, ledger, payout } = await makeService({ + order: dispatchedOrder, + }); + prisma.order.findUnique + .mockResolvedValueOnce(dispatchedOrder) + .mockResolvedValueOnce(deliveredOrder) + .mockResolvedValue(dispatchedOrder); + ledger.recordEscrowRelease + .mockRejectedValueOnce(new Error("ledger unavailable")) + .mockResolvedValueOnce(undefined); + + await expect( + service.confirmDelivery("buyer-1", "order-1", "123456"), + ).rejects.toThrow("ledger unavailable"); + + await expect( + service.confirmDelivery("buyer-1", "order-1", "123456"), + ).resolves.toEqual({ message: "Delivery confirmed" }); + + expect(tx.order.updateMany).toHaveBeenCalledTimes(2); + expect(tx.order.updateMany).toHaveBeenNthCalledWith(1, { + where: { id: "order-1", status: OrderStatus.DISPATCHED }, + data: { status: OrderStatus.DELIVERED }, + }); + expect(tx.order.updateMany).toHaveBeenNthCalledWith(2, { + where: { id: "order-1", status: OrderStatus.DELIVERED }, + data: { status: OrderStatus.COMPLETED }, + }); + expect(ledger.recordEscrowRelease).toHaveBeenCalledTimes(2); + expect(payout.queuePayout).toHaveBeenCalledTimes(1); + }); + + it("records escrow release and safely resumes an automatic confirmation", async () => { + const dispatchedOrder = await makeDispatchedOrder(); + const deliveredOrder = { + ...dispatchedOrder, + status: OrderStatus.DELIVERED, + disputeWindowEndsAt: new Date(), + }; + const { service, prisma, tx, notifications, verification, ledger, payout } = + await makeService({ order: dispatchedOrder }); + prisma.order.findUnique + .mockResolvedValueOnce(dispatchedOrder) + .mockResolvedValueOnce(deliveredOrder) + .mockResolvedValue(dispatchedOrder); + ledger.recordEscrowRelease + .mockRejectedValueOnce(new Error("ledger unavailable")) + .mockResolvedValueOnce(undefined); + + await expect(service.autoConfirmDelivery("order-1")).rejects.toThrow( + "ledger unavailable", + ); + await expect(service.autoConfirmDelivery("order-1")).resolves.toEqual({ + success: true, + }); + + expect(tx.order.updateMany).toHaveBeenCalledTimes(2); + expect(tx.order.updateMany).toHaveBeenNthCalledWith(1, { + where: { id: "order-1", status: OrderStatus.DISPATCHED }, + data: { + disputeWindowEndsAt: expect.any(Date), + status: OrderStatus.DELIVERED, + }, + }); + expect(tx.order.updateMany).toHaveBeenNthCalledWith(2, { + where: { id: "order-1", status: OrderStatus.DELIVERED }, + data: { status: OrderStatus.COMPLETED }, + }); + expect(prisma.order.update).not.toHaveBeenCalled(); + expect(prisma.orderTracking.create).toHaveBeenCalledTimes(1); + expect(prisma.orderTracking.create).toHaveBeenCalledWith({ + data: { + orderId: "order-1", + status: OrderStatus.DELIVERED, + note: "Delivery auto-confirmed after 48 hours of no response.", + }, + }); + expect(notifications.triggerOrderAutoConfirmed).toHaveBeenCalledTimes(1); + expect(ledger.recordEscrowRelease).toHaveBeenCalledTimes(2); + expect(payout.queuePayout).toHaveBeenCalledWith("order-1"); + expect(verification.checkAndUpgradeTier).toHaveBeenCalledWith("store-1"); + }); + + it("backfills a missing dispute timestamp when resuming an already delivered order", async () => { + const deliveredOrder = { + ...(await makeDispatchedOrder()), + status: OrderStatus.DELIVERED, + disputeWindowEndsAt: null, + }; + const { service, prisma, tx } = await makeService({ + order: deliveredOrder, + }); + + await expect(service.autoConfirmDelivery("order-1")).resolves.toEqual({ + success: true, + }); + + expect(prisma.order.updateMany).toHaveBeenCalledWith({ + where: { + id: "order-1", + status: OrderStatus.DELIVERED, + disputeWindowEndsAt: null, + }, + data: { disputeWindowEndsAt: expect.any(Date) }, + }); + expect(tx.order.updateMany).toHaveBeenCalledTimes(1); + expect(tx.order.updateMany).toHaveBeenCalledWith({ + where: { id: "order-1", status: OrderStatus.DELIVERED }, + data: { status: OrderStatus.COMPLETED }, + }); + expect(prisma.orderTracking.create).not.toHaveBeenCalled(); + }); + it("rejects an invalid delivery code without changing order status", async () => { const { service, tx, ledger, payout } = await makeService(); @@ -2180,3 +2306,140 @@ describe("CartService checkout delivery compatibility", () => { expect("deliveryMethod" in forwardedDto).toBe(false); }); }); + +describe("OrderService WhatsApp shopper ownership lookup", () => { + const makeService = (prisma: Record) => + new OrderService( + prisma as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + ); + + it("scopes a public order reference to the linked buyer", async () => { + const prisma = { + order: { + findFirst: jest.fn().mockResolvedValue({ + id: "order-1", + orderCode: "TWZ-123456", + status: "PAID", + }), + }, + }; + const service = makeService(prisma); + + await service.getByCodeForBuyer("TWZ-123456", "buyer-1"); + + expect(prisma.order.findFirst).toHaveBeenCalledWith({ + where: { + orderCode: "TWZ-123456", + buyerId: "buyer-1", + orderType: { + in: [OrderType.DIRECT, OrderType.DROPSHIP_CUSTOMER], + }, + }, + select: { + id: true, + orderCode: true, + status: true, + }, + }); + }); + + it("selects the latest shopper-facing order without exposing fulfillment orders", async () => { + const prisma = { + order: { + findFirst: jest.fn().mockResolvedValue({ + id: "order-1", + orderCode: "TWZ-123456", + status: "PAID", + }), + }, + }; + const service = makeService(prisma); + + await service.getLatestByBuyer("buyer-1"); + + expect(prisma.order.findFirst).toHaveBeenCalledWith({ + where: { + buyerId: "buyer-1", + orderType: { + in: [OrderType.DIRECT, OrderType.DROPSHIP_CUSTOMER], + }, + status: { + in: [ + OrderStatus.PAID, + OrderStatus.DISPATCHED, + OrderStatus.DELIVERED, + OrderStatus.COMPLETED, + OrderStatus.DISPUTE, + OrderStatus.REFUND_PENDING, + OrderStatus.REFUNDED, + ], + }, + }, + orderBy: { + createdAt: "desc", + }, + select: { + id: true, + orderCode: true, + status: true, + }, + }); + }); + + it("lists only buyer-owned dispatched orders with a delivery code", async () => { + const prisma = { + order: { + findMany: jest.fn().mockResolvedValue([ + { + id: "order-1", + orderCode: "TWZ-123456", + status: OrderStatus.DISPATCHED, + }, + ]), + }, + }; + const service = makeService(prisma); + + const result = await service.listDeliveryConfirmationCandidates( + "buyer-1", + 3, + ); + + expect(prisma.order.findMany).toHaveBeenCalledWith({ + where: { + buyerId: "buyer-1", + orderType: { + in: [OrderType.DIRECT, OrderType.DROPSHIP_CUSTOMER], + }, + status: { + in: [OrderStatus.DISPATCHED, OrderStatus.IN_TRANSIT], + }, + deliveryOtp: { not: null }, + }, + orderBy: { dispatchedAt: "desc" }, + take: 3, + select: { + id: true, + orderCode: true, + status: true, + }, + }); + expect(result).toEqual([ + { + id: "order-1", + orderCode: "TWZ-123456", + status: OrderStatus.DISPATCHED, + }, + ]); + }); +}); diff --git a/apps/backend/src/domains/orders/order/order.service.ts b/apps/backend/src/domains/orders/order/order.service.ts index 0a2a42eb..0e1fe767 100644 --- a/apps/backend/src/domains/orders/order/order.service.ts +++ b/apps/backend/src/domains/orders/order/order.service.ts @@ -16,6 +16,7 @@ import { PlatformFeeBearer, Prisma, ProductStatus, + OrderType, StoreType, } from "@prisma/client"; import { PrismaService } from "../../../prisma/prisma.service"; @@ -1407,6 +1408,7 @@ export class OrderService { toStatus: OrderStatus, triggeredBy: string, metadata?: Record, + updateData?: Prisma.OrderUpdateManyMutationInput, ) { if (!validateTransition(fromStatus, toStatus)) { throw new BadRequestException( @@ -1417,7 +1419,7 @@ export class OrderService { const updatedOrder = await this.prisma.$transaction(async (tx) => { const { count } = await tx.order.updateMany({ where: { id: orderId, status: fromStatus }, - data: { status: toStatus }, + data: { ...(updateData ?? {}), status: toStatus }, }); if (count !== 1) { @@ -1858,6 +1860,79 @@ export class OrderService { return paginated as PaginatedResponse; } + async getByCodeForBuyer(orderCode: string, buyerId: string) { + return this.prisma.order.findFirst({ + where: { + orderCode, + buyerId, + orderType: { + in: [OrderType.DIRECT, OrderType.DROPSHIP_CUSTOMER], + }, + }, + select: { + id: true, + orderCode: true, + status: true, + }, + }); + } + + async getLatestByBuyer(buyerId: string) { + return this.prisma.order.findFirst({ + where: { + buyerId, + orderType: { + in: [OrderType.DIRECT, OrderType.DROPSHIP_CUSTOMER], + }, + status: { + in: [ + OrderStatus.PAID, + OrderStatus.DISPATCHED, + OrderStatus.DELIVERED, + OrderStatus.COMPLETED, + OrderStatus.DISPUTE, + OrderStatus.REFUND_PENDING, + OrderStatus.REFUNDED, + ], + }, + }, + orderBy: { + createdAt: "desc", + }, + select: { + id: true, + orderCode: true, + status: true, + }, + }); + } + + async listDeliveryConfirmationCandidates(buyerId: string, limit = 5) { + return this.prisma.order.findMany({ + where: { + buyerId, + orderType: { + in: [OrderType.DIRECT, OrderType.DROPSHIP_CUSTOMER], + }, + status: { + in: [OrderStatus.DISPATCHED, OrderStatus.IN_TRANSIT], + }, + deliveryOtp: { + not: null, + }, + }, + orderBy: { + dispatchedAt: "desc", + }, + take: limit, + select: { + id: true, + orderCode: true, + status: true, + }, + }); + } + async listByMerchant( storeId: string, page: number, @@ -2443,6 +2518,42 @@ export class OrderService { // CONFIRM DELIVERY (buyer only, verifies OTP) // ────────────────────────────────────────────── + private async releaseEscrowAndComplete( + order: { + id: string; + buyerId: string; + storeId: string | null; + totalAmountKobo: bigint; + }, + completionAction: string, + ): Promise { + try { + await this.ledgerService.recordEscrowRelease( + order.id, + order.totalAmountKobo, + { + ...(order.storeId ? { storeId: order.storeId } : {}), + }, + ); + } catch (ledgerError) { + this.logger.error( + `Escrow release ledger write failed for order ${order.id}: ${ + ledgerError instanceof Error ? ledgerError.message : "unknown error" + } - payout NOT queued.`, + ledgerError instanceof Error ? ledgerError.stack : undefined, + ); + throw ledgerError; + } + + await this.transition( + order.id, + OrderStatus.DELIVERED, + OrderStatus.COMPLETED, + order.buyerId, + { action: completionAction }, + ); + } + async confirmDelivery(buyerId: string, orderId: string, otp: string) { const order = await this.prisma.order.findUnique({ where: { id: orderId }, @@ -2451,83 +2562,58 @@ export class OrderService { if (order.buyerId !== buyerId) throw new ForbiddenException("Access denied"); - // Explicit status check before OTP validation + if (order.status === OrderStatus.COMPLETED) { + return { message: "Delivery confirmed" }; + } + + const isRecoveringDelivery = order.status === OrderStatus.DELIVERED; + if ( order.status !== OrderStatus.DISPATCHED && - order.status !== OrderStatus.IN_TRANSIT + order.status !== OrderStatus.IN_TRANSIT && + !isRecoveringDelivery ) { throw new BadRequestException( - "Order must be in DISPATCHED or IN_TRANSIT status to confirm delivery", + "Order must be in DISPATCHED, IN_TRANSIT, or DELIVERED status to confirm delivery", ); } - // OTP expiry: 48 hours from dispatch. Auto-confirm queue normally - // moves the order out of DISPATCHED first, but a shopper trying to - // submit late while the queue is delayed deserves an explicit signal. - if ( - order.dispatchedAt && - Date.now() > order.dispatchedAt.getTime() + OTP_VALIDITY_MS - ) { - throw new BadRequestException({ - message: "Delivery code expired", - code: "OTP_EXPIRED", - }); - } - - // Compare against the stored bcrypt hash. Empty string fallback keeps - // bcrypt.compare from throwing when the hash column is somehow null. - const otpMatches = await bcrypt.compare(otp, order.deliveryOtp ?? ""); - if (!otpMatches) { - throw new BadRequestException({ - message: "Invalid delivery code", - code: "OTP_INVALID", - }); - } + if (!isRecoveringDelivery) { + // OTP expiry: 48 hours from dispatch. Auto-confirm queue normally + // moves the order out of DISPATCHED first, but a shopper trying to + // submit late while the queue is delayed deserves an explicit signal. + if ( + order.dispatchedAt && + Date.now() > order.dispatchedAt.getTime() + OTP_VALIDITY_MS + ) { + throw new BadRequestException({ + message: "Delivery code expired", + code: "OTP_EXPIRED", + }); + } - // Transition: CURRENT_STATUS -> DELIVERED (atomic via Step 2 fix) - await this.transition( - orderId, - order.status as OrderStatus, - OrderStatus.DELIVERED, - buyerId, - { action: "delivery_confirmed" }, - ); + // Compare against the stored bcrypt hash. Empty string fallback keeps + // bcrypt.compare from throwing when the hash column is somehow null. + const otpMatches = await bcrypt.compare(otp, order.deliveryOtp ?? ""); + if (!otpMatches) { + throw new BadRequestException({ + message: "Invalid delivery code", + code: "OTP_INVALID", + }); + } - // Ledger release MUST be recorded BEFORE the COMPLETED transition. - // If we completed the order first and the ledger write later failed, - // the order would be stuck in COMPLETED with no LedgerEntry, blocking - // retries (the status guard rejects re-execution) and breaking the - // "every kobo tracked" invariant. By writing the ledger while still - // in DELIVERED, a failure leaves the order in a state the shopper - // and the system can both retry safely. recordEscrowRelease is - // idempotent on (orderId, amountKobo) so safe retries are cheap. - try { - await this.ledgerService.recordEscrowRelease( + await this.transition( orderId, - BigInt(order.totalAmountKobo), - { storeId: order.storeId }, - ); - } catch (ledgerError) { - this.logger.error( - `Escrow release ledger write failed for order ${orderId}: ${ - ledgerError instanceof Error ? ledgerError.message : "unknown error" - } — payout NOT queued.`, - ledgerError instanceof Error ? ledgerError.stack : undefined, + order.status as OrderStatus, + OrderStatus.DELIVERED, + buyerId, + { action: "delivery_confirmed" }, ); - throw ledgerError; } - // Now safe to finalize: ledger has the matching ESCROW_RELEASE row, - // so completing the order locks in a state where finance and status - // agree. This transition is atomic (Step 2 fix), so a concurrent - // caller cannot race past us here. - await this.transition( - orderId, - OrderStatus.DELIVERED, - OrderStatus.COMPLETED, - buyerId, - { action: "auto_completed" }, - ); + // A ledger failure leaves the order in DELIVERED. A subsequent buyer + // request can resume here without replaying OTP or delivery side effects. + await this.releaseEscrowAndComplete(order, "delivery_confirmed_completed"); // Queue payout ONLY after the ledger release has been recorded AND // the order has been finalized. PayoutService.queuePayout owns the @@ -2580,7 +2666,7 @@ export class OrderService { } /** - * System-triggered delivery confirmation after 72 hours of inactivity. + * System-triggered delivery confirmation after 48 hours of inactivity. */ async autoConfirmDelivery(orderId: string) { const order = await this.prisma.order.findUnique({ @@ -2595,79 +2681,86 @@ export class OrderService { throw new NotFoundException(`Order ${orderId} not found`); } + // Explicitly guard against DISPUTE - funds must NOT be auto-released. + if (order.status === OrderStatus.DISPUTE) { + this.logger.warn( + `Order ${orderId} is in DISPUTE state. Auto-confirmation blocked - awaiting admin resolution.`, + ); + return; + } + + if (order.status === OrderStatus.COMPLETED) { + return { success: true }; + } + + const isRecoveringDelivery = order.status === OrderStatus.DELIVERED; + if ( order.status !== OrderStatus.DISPATCHED && - order.status !== OrderStatus.IN_TRANSIT + order.status !== OrderStatus.IN_TRANSIT && + !isRecoveringDelivery ) { - // Explicitly guard against DISPUTE - funds must NOT be auto-released - if (order.status === OrderStatus.DISPUTE) { - this.logger.warn( - `Order ${orderId} is in DISPUTE state. Auto-confirmation blocked — awaiting admin resolution.`, - ); - return; - } - this.logger.warn( `Order ${orderId} is in ${order.status} state, cannot auto-confirm.`, ); return; } - this.logger.log(`Auto-confirming delivery for order ${orderId}`); - - // 1. Transition to DELIVERED (uses standard audit trail) - await this.transition( - orderId, - order.status as OrderStatus, - OrderStatus.DELIVERED, - order.buyerId, // System acting on behalf of buyer's silence - { action: "system_auto_confirm" }, - ); - - // 2. Clear dispute window immediately for auto-confirmation - await this.prisma.order.update({ - where: { id: orderId }, - data: { disputeWindowEndsAt: new Date() }, - }); + if (!isRecoveringDelivery) { + this.logger.log(`Auto-confirming delivery for order ${orderId}`); - // 3. Create tracking entry AFTER transition - await this.prisma.orderTracking.create({ - data: { + await this.transition( orderId, - status: OrderStatus.DELIVERED, - note: "Delivery auto-confirmed after 72 hours of no response.", - }, - }); + order.status as OrderStatus, + OrderStatus.DELIVERED, + order.buyerId, + { action: "system_auto_confirm" }, + { disputeWindowEndsAt: new Date() }, + ); - // 4. Notify participants with strict isolation - try { - if (order.storeId) { - await this.notifications.triggerOrderAutoConfirmed( - order.buyerId, - order.storeId, - { - orderId: order.id, - reference: order.id.slice(0, 8).toUpperCase(), - amountKobo: order.totalAmountKobo, - }, + await this.prisma.orderTracking.create({ + data: { + orderId, + status: OrderStatus.DELIVERED, + note: "Delivery auto-confirmed after 48 hours of no response.", + }, + }); + + try { + if (order.storeId) { + await this.notifications.triggerOrderAutoConfirmed( + order.buyerId, + order.storeId, + { + orderId: order.id, + reference: order.id.slice(0, 8).toUpperCase(), + amountKobo: order.totalAmountKobo, + }, + ); + } + } catch (error) { + this.logger.error( + `Failed to notify for auto-confirmed order ${orderId}: ${error instanceof Error ? error.message : error}`, ); } - } catch (error) { - this.logger.error( - `Failed to notify for auto-confirmed order ${orderId}: ${error instanceof Error ? error.message : error}`, - ); + } else if (!order.disputeWindowEndsAt) { + // Older or partially completed deliveries may predate the atomic + // transition write. Backfill once before resuming escrow release. + await this.prisma.order.updateMany({ + where: { + id: orderId, + status: OrderStatus.DELIVERED, + disputeWindowEndsAt: null, + }, + data: { disputeWindowEndsAt: new Date() }, + }); } - // 5. Final transition to COMPLETED - await this.transition( - orderId, - OrderStatus.DELIVERED, - OrderStatus.COMPLETED, - order.buyerId, - { action: "auto_completion_payout" }, - ); + // BullMQ retries resume from DELIVERED after an escrow ledger failure. + // The ledger idempotency key prevents duplicate release entries. + await this.releaseEscrowAndComplete(order, "auto_completion_payout"); - // 6. Post-completion processing (payouts and tier checks) + // Post-completion processing (payouts and tier checks) try { if (order.storeId) { // Upgrade check diff --git a/apps/backend/src/domains/platform/outbox/handlers/order-auto-confirm-schedule.outbox-handler.spec.ts b/apps/backend/src/domains/platform/outbox/handlers/order-auto-confirm-schedule.outbox-handler.spec.ts index 9ff1651c..8747e51c 100644 --- a/apps/backend/src/domains/platform/outbox/handlers/order-auto-confirm-schedule.outbox-handler.spec.ts +++ b/apps/backend/src/domains/platform/outbox/handlers/order-auto-confirm-schedule.outbox-handler.spec.ts @@ -30,7 +30,12 @@ describe("OrderAutoConfirmScheduleOutboxHandler", () => { expect(queue.add).toHaveBeenCalledWith( "auto-confirm", expect.objectContaining({ orderId: "order-1" }), - expect.objectContaining({ jobId: "auto-confirm-order-1" }), + expect.objectContaining({ + jobId: "auto-confirm-order-1", + attempts: 3, + backoff: { type: "exponential", delay: 5000 }, + removeOnFail: false, + }), ); }); }); diff --git a/apps/backend/src/domains/platform/outbox/handlers/order-auto-confirm-schedule.outbox-handler.ts b/apps/backend/src/domains/platform/outbox/handlers/order-auto-confirm-schedule.outbox-handler.ts index 74445925..5e888e60 100644 --- a/apps/backend/src/domains/platform/outbox/handlers/order-auto-confirm-schedule.outbox-handler.ts +++ b/apps/backend/src/domains/platform/outbox/handlers/order-auto-confirm-schedule.outbox-handler.ts @@ -48,7 +48,9 @@ export class OrderAutoConfirmScheduleOutboxHandler implements OutboxHandler { + it("enqueues the allowlisted WhatsApp job with a deterministic id", async () => { + const queue = { add: jest.fn().mockResolvedValue({ id: "job-1" }) }; + const handler = new WhatsAppDirectOrderNotificationOutboxHandler( + queue as never, + ); + + await handler.handle({ + id: "outbox-1", + topic: "whatsapp.direct-order-notification.requested", + version: 1, + aggregateType: "PAYMENT", + aggregateId: "payment-1", + payload: { + storeId: "store-1", + orderData: { + orderId: "order-1", + productName: "Canvas Tote", + quantity: 2, + amountKobo: "200000", + }, + }, + attempts: 1, + lockedAt: new Date(), + lockedBy: "worker-1", + createdAt: new Date(), + }); + + expect(queue.add).toHaveBeenCalledWith( + "send-direct-order-notification", + { + storeId: "store-1", + orderData: { + orderId: "order-1", + productName: "Canvas Tote", + quantity: 2, + amountKobo: "200000", + }, + }, + { + jobId: "outbox:outbox-1:whatsapp-direct-order", + attempts: 3, + backoff: { type: "exponential", delay: 5000 }, + removeOnComplete: { age: 86400 }, + removeOnFail: false, + }, + ); + }); + + it("rejects malformed persisted payloads without retrying forever", async () => { + const handler = new WhatsAppDirectOrderNotificationOutboxHandler({ + add: jest.fn(), + } as never); + + await expect( + handler.handle({ + id: "outbox-1", + topic: "whatsapp.direct-order-notification.requested", + version: 1, + aggregateType: "PAYMENT", + aggregateId: "payment-1", + payload: { + storeId: "store-1", + orderData: { quantity: 0 }, + } as never, + attempts: 1, + lockedAt: new Date(), + lockedBy: "worker-1", + createdAt: new Date(), + }), + ).rejects.toThrow("Invalid WhatsApp direct-order notification data"); + }); + + it("propagates queue failures so the outbox relay can retry", async () => { + const enqueueError = new Error("Redis unavailable"); + const handler = new WhatsAppDirectOrderNotificationOutboxHandler({ + add: jest.fn().mockRejectedValue(enqueueError), + } as never); + + await expect( + handler.handle({ + id: "outbox-1", + topic: "whatsapp.direct-order-notification.requested", + version: 1, + aggregateType: "PAYMENT", + aggregateId: "payment-1", + payload: { + storeId: "store-1", + orderData: { + orderId: "order-1", + productName: "Canvas Tote", + quantity: 2, + amountKobo: "200000", + }, + }, + attempts: 1, + lockedAt: new Date(), + lockedBy: "worker-1", + createdAt: new Date(), + }), + ).rejects.toBe(enqueueError); + }); +}); diff --git a/apps/backend/src/domains/platform/outbox/handlers/whatsapp-direct-order-notification.outbox-handler.ts b/apps/backend/src/domains/platform/outbox/handlers/whatsapp-direct-order-notification.outbox-handler.ts new file mode 100644 index 00000000..8cab1e92 --- /dev/null +++ b/apps/backend/src/domains/platform/outbox/handlers/whatsapp-direct-order-notification.outbox-handler.ts @@ -0,0 +1,86 @@ +import { InjectQueue } from "@nestjs/bullmq"; +import { Injectable } from "@nestjs/common"; +import { Queue } from "bullmq"; + +import { QUEUE } from "../../../../queue/queue.constants"; +import { NonRetryableOutboxError } from "../outbox.errors"; +import { + ClaimedOutboxEvent, + OUTBOX_TOPIC, + OutboxHandler, + WhatsAppDirectOrderNotificationRequestedV1, +} from "../outbox.types"; + +@Injectable() +export class WhatsAppDirectOrderNotificationOutboxHandler implements OutboxHandler { + readonly topic = OUTBOX_TOPIC.WHATSAPP_DIRECT_ORDER_NOTIFICATION_REQUESTED; + readonly version = 1; + + constructor( + @InjectQueue(QUEUE.WHATSAPP) + private readonly whatsAppQueue: Queue, + ) {} + + async handle( + event: ClaimedOutboxEvent, + ): Promise { + const payload = this.validate(event.payload); + + await this.whatsAppQueue.add("send-direct-order-notification", payload, { + jobId: `outbox:${event.id}:whatsapp-direct-order`, + attempts: 3, + backoff: { type: "exponential", delay: 5000 }, + removeOnComplete: { age: 24 * 60 * 60 }, + removeOnFail: false, + }); + } + + private validate(value: unknown): WhatsAppDirectOrderNotificationRequestedV1 { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new NonRetryableOutboxError( + "WhatsApp direct-order outbox payload must be an object", + ); + } + + const record = value as Record; + const orderData = record.orderData; + if ( + typeof record.storeId !== "string" || + record.storeId.trim().length === 0 || + typeof orderData !== "object" || + orderData === null || + Array.isArray(orderData) + ) { + throw new NonRetryableOutboxError( + "Invalid WhatsApp direct-order outbox payload", + ); + } + + const order = orderData as Record; + if ( + typeof order.orderId !== "string" || + order.orderId.trim().length === 0 || + typeof order.productName !== "string" || + order.productName.trim().length === 0 || + typeof order.quantity !== "number" || + !Number.isSafeInteger(order.quantity) || + order.quantity <= 0 || + typeof order.amountKobo !== "string" || + !/^\d+$/.test(order.amountKobo) + ) { + throw new NonRetryableOutboxError( + "Invalid WhatsApp direct-order notification data", + ); + } + + return { + storeId: record.storeId, + orderData: { + orderId: order.orderId, + productName: order.productName, + quantity: order.quantity, + amountKobo: order.amountKobo, + }, + }; + } +} diff --git a/apps/backend/src/domains/platform/outbox/outbox.module.ts b/apps/backend/src/domains/platform/outbox/outbox.module.ts index 6d1f202d..9312a278 100644 --- a/apps/backend/src/domains/platform/outbox/outbox.module.ts +++ b/apps/backend/src/domains/platform/outbox/outbox.module.ts @@ -8,6 +8,7 @@ import { CommerceOutboxService } from "./commerce-outbox.service"; import { OrderAutoConfirmScheduleOutboxHandler } from "./handlers/order-auto-confirm-schedule.outbox-handler"; import { NotificationDispatchOutboxHandler } from "./handlers/notification-dispatch.outbox-handler"; import { RealtimeUserEventOutboxHandler } from "./handlers/realtime-user-event.outbox-handler"; +import { WhatsAppDirectOrderNotificationOutboxHandler } from "./handlers/whatsapp-direct-order-notification.outbox-handler"; import { OUTBOX_HANDLER_TOKEN, OutboxHandlerRegistry, @@ -24,6 +25,7 @@ import { OutboxService } from "./outbox.service"; NotificationDispatchOutboxHandler, RealtimeUserEventOutboxHandler, OrderAutoConfirmScheduleOutboxHandler, + WhatsAppDirectOrderNotificationOutboxHandler, OutboxHandlerRegistry, { provide: OUTBOX_HANDLER_TOKEN, @@ -31,11 +33,18 @@ import { OutboxService } from "./outbox.service"; notificationHandler: NotificationDispatchOutboxHandler, realtimeHandler: RealtimeUserEventOutboxHandler, autoConfirmHandler: OrderAutoConfirmScheduleOutboxHandler, - ) => [notificationHandler, realtimeHandler, autoConfirmHandler], + whatsAppDirectOrderHandler: WhatsAppDirectOrderNotificationOutboxHandler, + ) => [ + notificationHandler, + realtimeHandler, + autoConfirmHandler, + whatsAppDirectOrderHandler, + ], inject: [ NotificationDispatchOutboxHandler, RealtimeUserEventOutboxHandler, OrderAutoConfirmScheduleOutboxHandler, + WhatsAppDirectOrderNotificationOutboxHandler, ], }, ], diff --git a/apps/backend/src/domains/platform/outbox/outbox.types.ts b/apps/backend/src/domains/platform/outbox/outbox.types.ts index 10354232..fb856b0c 100644 --- a/apps/backend/src/domains/platform/outbox/outbox.types.ts +++ b/apps/backend/src/domains/platform/outbox/outbox.types.ts @@ -7,6 +7,8 @@ import { export const OUTBOX_TOPIC = { NOTIFICATION_DISPATCH_REQUESTED: "notification.dispatch.requested", + WHATSAPP_DIRECT_ORDER_NOTIFICATION_REQUESTED: + "whatsapp.direct-order-notification.requested", REALTIME_USER_EVENT_REQUESTED: "realtime.user-event.requested", ORDER_AUTO_CONFIRM_SCHEDULE_REQUESTED: "order.auto-confirm.schedule.requested", @@ -53,6 +55,16 @@ export interface NotificationDispatchRequestedV1 { audience?: NotificationAudienceValue; } +export interface WhatsAppDirectOrderNotificationRequestedV1 { + storeId: string; + orderData: { + orderId: string; + productName: string; + quantity: number; + amountKobo: string; + }; +} + export interface RealtimeUserEventRequestedV1 { recipientUserId: string; eventType: SupportedRealtimeUserEvent; diff --git a/apps/backend/src/domains/social/notification/notification-trigger.service.spec.ts b/apps/backend/src/domains/social/notification/notification-trigger.service.spec.ts index 9d42817a..2a1d8cc5 100644 --- a/apps/backend/src/domains/social/notification/notification-trigger.service.spec.ts +++ b/apps/backend/src/domains/social/notification/notification-trigger.service.spec.ts @@ -66,25 +66,7 @@ describe("NotificationTriggerService order purchase notifications", () => { }), expect.any(Object), ); - expect(whatsappQueue.add).toHaveBeenCalledWith( - "send-direct-order-notification", - { - storeId: "store-1", - orderData: { - orderId: "order-1", - productName: "Canvas Tote", - quantity: 2, - amountKobo: "200000", - }, - }, - expect.objectContaining({ - attempts: 3, - backoff: { type: "exponential", delay: 5000 }, - jobId: "whatsapp-order-purchase-order-1", - removeOnComplete: { age: 86400 }, - removeOnFail: false, - }), - ); + expect(whatsappQueue.add).not.toHaveBeenCalled(); }); it("queues dispatched WhatsApp updates with the buyer identity, not a raw phone", async () => { diff --git a/apps/backend/src/domains/social/notification/notification-trigger.service.ts b/apps/backend/src/domains/social/notification/notification-trigger.service.ts index f0fb87bc..87fb5651 100644 --- a/apps/backend/src/domains/social/notification/notification-trigger.service.ts +++ b/apps/backend/src/domains/social/notification/notification-trigger.service.ts @@ -367,44 +367,6 @@ export class NotificationTriggerService { }, [NotificationChannel.IN_APP, NotificationChannel.EMAIL], ); - - await this.enqueueWhatsAppNotification( - "send-direct-order-notification", - { - storeId, - orderData: { - orderId: metadata.orderId, - productName: metadata.productName, - quantity: metadata.quantity, - amountKobo: storeAmountKobo.toString(), - }, - }, - `whatsapp-order-purchase-${metadata.orderId}`, - ); - } - - async triggerOrderPurchaseConfirmedWhatsApp( - storeId: string, - metadata: { - orderId: string; - productName: string; - quantity: number; - amountKobo: string; - }, - ): Promise { - await this.enqueueWhatsAppNotification( - "send-direct-order-notification", - { - storeId, - orderData: { - orderId: metadata.orderId, - productName: metadata.productName, - quantity: metadata.quantity, - amountKobo: metadata.amountKobo, - }, - }, - `whatsapp-order-purchase-${metadata.orderId}`, - ); } async triggerMerchantVerified(userId: string) { await this.addJob( diff --git a/apps/backend/src/queue/processors/auto-confirm.processor.spec.ts b/apps/backend/src/queue/processors/auto-confirm.processor.spec.ts new file mode 100644 index 00000000..cf15c907 --- /dev/null +++ b/apps/backend/src/queue/processors/auto-confirm.processor.spec.ts @@ -0,0 +1,52 @@ +import { Job } from "bullmq"; + +import { OrderService } from "../../domains/orders/order/order.service"; +import { AutoConfirmProcessor } from "./auto-confirm.processor"; + +describe("AutoConfirmProcessor", () => { + it("delegates a scheduled order to the canonical order service", async () => { + const orderService = { + autoConfirmDelivery: jest.fn().mockResolvedValue({ success: true }), + }; + const processor = new AutoConfirmProcessor(orderService as never); + + await processor.process({ + id: "auto-confirm-order-1", + name: "auto-confirm", + data: { + orderId: "order-1", + expectedStatus: "DISPATCHED", + }, + } as Job< + { orderId: string; expectedStatus: "DISPATCHED" }, + void, + "auto-confirm" + >); + + expect(orderService.autoConfirmDelivery).toHaveBeenCalledTimes(1); + expect(orderService.autoConfirmDelivery).toHaveBeenCalledWith("order-1"); + }); + + it("rejects malformed jobs instead of silently acknowledging them", async () => { + const orderService = { + autoConfirmDelivery: jest.fn(), + }; + const processor = new AutoConfirmProcessor( + orderService as unknown as OrderService, + ); + + await expect( + processor.process({ + id: "auto-confirm-invalid", + name: "auto-confirm", + data: {}, + } as unknown as Job< + { orderId: string; expectedStatus: "DISPATCHED" }, + void, + "auto-confirm" + >), + ).rejects.toThrow("Auto-confirm job is missing orderId"); + + expect(orderService.autoConfirmDelivery).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/queue/processors/auto-confirm.processor.ts b/apps/backend/src/queue/processors/auto-confirm.processor.ts index 53b0dbfc..446ed29e 100644 --- a/apps/backend/src/queue/processors/auto-confirm.processor.ts +++ b/apps/backend/src/queue/processors/auto-confirm.processor.ts @@ -1,11 +1,35 @@ import { Processor, WorkerHost } from "@nestjs/bullmq"; +import { Logger } from "@nestjs/common"; import { Job } from "bullmq"; +import { OrderService } from "../../domains/orders/order/order.service"; import { QUEUE } from "../queue.constants"; +type AutoConfirmJobData = { + orderId: string; + expectedStatus: "DISPATCHED"; +}; + @Processor(QUEUE.AUTO_CONFIRM) export class AutoConfirmProcessor extends WorkerHost { - async process(job: Job): Promise { - void job; + private readonly logger = new Logger(AutoConfirmProcessor.name); + + constructor(private readonly orderService: OrderService) { + super(); + } + + async process( + job: Job, + ): Promise { + const orderId = job.data?.orderId; + + if (!orderId) { + throw new Error("Auto-confirm job is missing orderId"); + } + + this.logger.log( + `Processing auto-confirm job ${job.id ?? "unknown"} for order ${orderId}`, + ); + await this.orderService.autoConfirmDelivery(orderId); } } diff --git a/apps/backend/src/queue/queue.module.ts b/apps/backend/src/queue/queue.module.ts index 7379f1fb..39d50b55 100644 --- a/apps/backend/src/queue/queue.module.ts +++ b/apps/backend/src/queue/queue.module.ts @@ -3,7 +3,6 @@ import { Logger, Module } from "@nestjs/common"; import { ConfigModule, ConfigService } from "@nestjs/config"; import { AutoConfirmWarningProcessor } from "./processors/auto-confirm-warning.processor"; -import { AutoConfirmProcessor } from "./processors/auto-confirm.processor"; import { AuditFlushProcessor } from "./processors/audit-flush.processor"; import { DeliveryEstimateProcessor } from "./processors/delivery-estimate.processor"; import { FloorCheckProcessor } from "./processors/floor-check.processor"; @@ -126,7 +125,6 @@ const queueRegistrations = Object.values(QUEUE).map((name) => ({ name })); BullModule.registerQueue(...queueRegistrations), ], providers: [ - AutoConfirmProcessor, AutoConfirmWarningProcessor, FloorCheckProcessor, ModerationProcessor,