# WhatsApp Nudge Strategy — Implementation Plan

**Created:** 2026-06-08
**Owner:** Engineering (working with product / marketing)
**Approach:** Direct-fire via `NotificationService::sendWhatsApp()` (Azmarq), bypassing CleverTap for the first ship. CleverTap journeys remain configurable later — the message path is the same, only the trigger source differs.

---

## Why direct-fire instead of CleverTap journeys

CleverTap journey setup has been blocked / slow. We have:
- Working Azmarq API integration (`NotificationService::sendWhatsApp()`)
- Approved Meta WhatsApp templates (or pending approval, per roadmap)
- Working cron infrastructure (`cron/` dir, `cron-with-alert.sh` wrapper)
- A working multi-channel sender pattern (`InactivityNudgeService`)

Direct-fire means: our cron / event-handler queries the DB → calls `NotificationService::sendWhatsApp()` with the template name + body params → done. No CleverTap, no journey, no webhook indirection.

When CleverTap journeys are ready, flip the trigger source — the `NotificationService` call stays unchanged.

---

## Sources

1. `Nudge Strategy Final.xlsx` — 8 jobseeker + 1 NGO problem statements with prescribed nudge cadences
2. `Whatsapp Triggers and Communication MSG (3).xlsx` — 18 message types (10 recruiter, 8 jobseeker) with message copy
3. [`clevertap-roadmap.md`](clevertap-roadmap.md) — authoritative scope per CT integration (13 journeys)

---

## Existing infrastructure (don't reinvent)

| Component | Location | What it does |
|---|---|---|
| `NotificationService` (recruiter) | `app/Services/NotificationService.php` (514 lines) | Central sender. Methods: `sendWhatsApp($to, $templateName, $components)`, `sendPushNotification`, `createNotification` (in-app), wrapper helpers `notifyShortlisted/Selected/InterviewScheduled/etc`. Local-dry-run guard built in. |
| `InactivityNudgeService` (recruiter) | `app/Services/InactivityNudgeService.php` | Reference pattern for multi-stage cron nudges. 7d/3d/1d stages, log table, idempotency, multi-channel fanout. **Copy this pattern.** |
| `cron/inactivity-nudge.php` | `cron/inactivity-nudge.php` | Cron entry pattern: host auto-detect, dry-run flag, service instantiation, result echo. **Copy this entry-point pattern.** |
| `cron/job-expiring-soon.php` | `cron/job-expiring-soon.php` | **Already exists.** Pre-existing job-expiry cron — needs WhatsApp wiring added. |
| `cron/weekly-jobs-report.php` | `cron/weekly-jobs-report.php` | Reference for weekly-digest pattern. |
| `cron-with-alert.sh` | `cron/cron-with-alert.sh` | Wrapper that emails on cron failure via Brevo. Use for every new cron. |
| `clevertap_templates.php` | `app/config/clevertap_templates.php` | Template name → Azmarq body-params mapping. Adds: bilingual support, button-vars, category. New direct-fire templates can be tracked here too for consistency. |
| `users_notification` table | UAT DB | In-app channel store, 33,966 rows live. Use existing schema. |
| `inactivity_nudge_log` table | UAT DB | Pattern for "did we already send stage X to user Y on date Z". **Copy this schema for each new nudge.** |

---

## Existing wiring — keep working, do not duplicate

These are already firing via direct backend → Azmarq, NOT via CleverTap:

| Template name | Where fired from |
|---|---|
| `on_trigger_new_candidate_applied` | (verify) |
| `on_trigger_shortlisted_by_recruiter` | `NotificationService::notifyShortlisted()` |
| `on_trigger_hired_by_recruiter` | `NotificationService::notifySelected()` |
| `on_trigger_interview_scheduled` | `NotificationService::notifyInterviewScheduled()` |
| `recruiter_interview_confirmation_msg` | (verify) |
| `cron_daily_digest_candidates_applied` | (verify) |
| `new_job_desired_location` | (verify) |

For each in the "(verify)" rows, the first task per-nudge is to confirm it's actually firing on UAT.

---

## Implementation order (P1)

Sequence chosen to ship the highest-impact + lowest-effort first.

| # | Nudge | Effort | Notes |
|---|---|---|---|
| 1 | R7 — Job Expiring 3 Days (recruiter) | ~1 h | Cron already exists; just wire WA via NotificationService + add log table |
| 2 | R5 — Recruiter Interview Reminder 1 Day | ~2 h | New cron; templates pending Meta approval but ship now → fire when approved |
| 3 | R6 — Recruiter Interview Reminder 10 Min | ~30 m | Same cron as R5, finer schedule |
| 4 | J3 — Jobseeker Interview Reminder 1 Day | ~30 m | Mirror of R5; different recipient + template |
| 5 | J4 — Jobseeker Interview Reminder 10 Min | ~30 m | Mirror of R6 |
| 6 | Daily Digest Candidates Applied (Recruiter 7pm) | ~2 h | Verify existing first; build if missing |
| 7 | J5 — Weekly Job Digest (Jobseeker Mon 9am) | ~3 h | Lives in mission-even backend, not recruiter |
| 8 | R8 — Weekly Status Update (Recruiter Mon 10am) | ~2 h | Modeled after #6 |
| 9 | R1 — Recruiter Incomplete Onboarding | ~3 h | Need onboarding-stage tracking |
| 10 | R2, R3, R4 — Profile-completion family | ~1 h to verify | CT journeys ALREADY PUBLISHED per roadmap; verify firing |
| 11 | J1 — Jobseeker Incomplete Onboarding | ~4 h | Lives in mission-even backend; longer because that scaffolding is thinner |
| 12 | J2 — Jobseeker Profile X% Complete | ~3 h | Lives in mission-even backend |

P2 (deferred): NGO candidate, hyperlocal-women, irrelevant-jobs-in-app, BOT-call coordination.

---

## Per-nudge implementation methodology (every item follows this)

1. **Survey:** read related existing files end-to-end. Note every reusable function, table column, helper.
2. **Design (write before code):** specify
   - SQL query (which table, which WHERE)
   - Schedule (cron expression, or event trigger location)
   - Template name + body params order
   - Idempotency mechanism (log table, schema, unique key)
   - Dry-run mode + how a dev verifies output without sending real messages
3. **Implement:** modular service class in `app/Services/`, cron entry in `cron/`, optional config in `app/config/`. No duplication of NotificationService logic.
4. **Local dry-run test:** invoke cron with `--dry-run` flag, verify SQL hits, verify Azmarq payload printed, no real messages sent.
5. **Testing checklist:** write a `docs/wa-nudge-<id>-testing.md` for product/QA with exact steps + expected outcomes.
6. **Feature branch + MR:** push to a `feat/wa-nudge-<id>-<date>` branch, open MR via GitLab UI, wait for review + merge via UI (no local merges).
7. **Post-deploy verification:** test on UAT after CodeBuild deploy, confirm logs.
8. **Mark complete in this plan.**

---

## Per-nudge testing checklist template

For each nudge we ship, the testing checklist will follow this skeleton:

```
1. [Pre-conditions] User entity exists in DB matching the trigger condition
2. [Dry-run] CLI: php cron/<name>.php --dry-run
   Expected: [logs printed; no rows in nudge_log table; no Azmarq call made]
3. [Live run on UAT] CLI: php cron/<name>.php (as cron user)
   Expected: [rows inserted in nudge_log; Azmarq HTTP 200; users_notification row created; user receives WA in 30-60s]
4. [Idempotency] Re-run cron immediately
   Expected: [no new rows in nudge_log; no Azmarq call; "already sent" log line]
5. [Template payload sanity] Capture Azmarq request body via dev log
   Expected: [components.body.params order exactly matches template registration]
6. [In-app channel] Open recruiter dashboard
   Expected: [red dot present; clicking opens notification with same body]
7. [Edge case: user opted out] If users.whatsapp_consent = 0
   Expected: [no Azmarq send; in-app + email may still fire per channel preference]
8. [Failure mode: Azmarq returns 4xx] Test with bad template name in dry-run
   Expected: [logged + skipped; other channels still fire]
```

---

## Status tracker (updated as each item ships)

| # | Nudge | Status | Branch | MR | Testing report |
|---|---|---|---|---|---|
| 1 | R7 Job Expiring 3 Days | ✅ merged + SCP'd to UAT (2026-06-08) | feat/wa-nudge-r7-job-expiry-3day-2026-06-08 | merged | docs/wa-nudge-r7-testing.md |
| 2 | R5 Recruiter Interview 1 Day | 🔨 built — dry-run on UAT passes | feat/wa-nudge-r5-r6-j3-j4-interview-reminders-2026-06-08 | pending | docs/wa-nudge-interview-reminder-family-testing.md |
| 3 | R6 Recruiter Interview 10 Min | 🔨 built — dry-run on UAT passes | feat/wa-nudge-r5-r6-j3-j4-interview-reminders-2026-06-08 | pending | docs/wa-nudge-interview-reminder-family-testing.md |
| 4 | J3 Jobseeker Interview 1 Day | 🔨 built — dry-run on UAT passes | feat/wa-nudge-r5-r6-j3-j4-interview-reminders-2026-06-08 | pending | docs/wa-nudge-interview-reminder-family-testing.md |
| 5 | J4 Jobseeker Interview 10 Min | 🔨 built — dry-run on UAT passes | feat/wa-nudge-r5-r6-j3-j4-interview-reminders-2026-06-08 | pending | docs/wa-nudge-interview-reminder-family-testing.md |
| 6 | Daily Digest 7pm | ⏳ pending (verify existing first) | — | — | — |
| 7 | J5 Weekly Job Digest | 🔨 built — dry-run on UAT passes (412 candidates) | feat/wa-nudge-batch2-r8-j5-r4-j2-2026-06-08 | pending | docs/wa-nudge-batch2-testing.md |
| 8 | R8 Recruiter Status Weekly | 🔨 built — dry-run on UAT passes | feat/wa-nudge-batch2-r8-j5-r4-j2-2026-06-08 | pending | docs/wa-nudge-batch2-testing.md |
| 9 | R1 Recruiter Onboarding | ⏳ pending | — | — | — |
| 10 | R4 No Job Posted | 🔨 built — dry-run on UAT passes (136 candidates) | feat/wa-nudge-batch2-r8-j5-r4-j2-2026-06-08 | pending | docs/wa-nudge-batch2-testing.md |
| 10b | R2/R3 verify | ⏳ pending | — | — | — |
| 11 | J1 Jobseeker Onboarding | ⏳ pending | — | — | — |
| 12 | J2 Jobseeker Profile % | ⏳ pending | — | — | — |
