# Africa's Talking WhatsApp Connector Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add Africa's Talking as a direct-message WhatsApp provider that coexists with the current Baileys connector and opens a tested pull request against `main`.

**Architecture:** Preserve `kind = 'whatsapp'` and add a provider discriminator plus API-computed capabilities. Route outbound traffic through focused provider adapters, normalize Africa's Talking callbacks into the existing inbound ticket pipeline, and leave Baileys QR/group behavior unchanged.

**Tech Stack:** TypeScript, Node.js 20, Fastify 4, React 18, PostgreSQL/Supabase, Node test runner, Playwright, Docker Compose.

## Global Constraints

- Existing WhatsApp channels must migrate to `provider = 'baileys'`.
- Africa's Talking credentials come only from `AFRICASTALKING_USERNAME` and `AFRICASTALKING_API_KEY`.
- Africa's Talking supports direct text/media; groups, QR pairing, and session reconnect remain Baileys-only.
- Provider capabilities come from the API and drive disabled UI actions.
- Never log, return, or persist the Africa's Talking API key.
- Public callbacks require a channel token, rate limiting, strict payload limits, and schema validation.
- Edit TypeScript sources only; committed frontend `.js` emit files are not authoritative.
- Keep template administration and a generic plugin framework out of scope.

---

### Task 1: Persist provider identity and expose shared capability types

**Files:**
- Create: `supabase/migrations/026_channel_whatsapp_provider.sql`
- Modify: `docker-compose.yml`
- Modify: `docker-compose.dokploy.yml`
- Modify: `packages/shared/src/types.ts`
- Create: `apps/api/src/services/whatsappCapabilities.ts`
- Create: `apps/api/src/services/whatsappCapabilities.test.ts`

**Interfaces:**
- Produces `WhatsAppProvider`, `WhatsAppCapabilities`, `WHATSAPP_CAPABILITIES`, and `getWhatsAppCapabilities(provider)`.
- Extends `Channel` with `provider` and `capabilities`.

- [ ] **Step 1: Write failing capability tests**

```typescript
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { getWhatsAppCapabilities } from './whatsappCapabilities.js';

describe('getWhatsAppCapabilities', () => {
  it('keeps Baileys QR and group operations', () => {
    assert.deepEqual(getWhatsAppCapabilities('baileys'), {
      direct_text: true, media: true, groups: true,
      qr_pairing: true, session_reconnect: true, templates: false,
    });
  });

  it("limits Africa's Talking to supported direct operations", () => {
    assert.deepEqual(getWhatsAppCapabilities('africas_talking'), {
      direct_text: true, media: true, groups: false,
      qr_pairing: false, session_reconnect: false, templates: true,
    });
  });
});
```

- [ ] **Step 2: Run the focused test and verify the missing-module failure**

Run: `npm run test:unit --workspace=@wa-ticketing/api`  
Expected: FAIL because `whatsappCapabilities.ts` does not exist.

- [ ] **Step 3: Add the migration and shared types**

```sql
DO $$
BEGIN
  CREATE TYPE whatsapp_provider AS ENUM ('baileys', 'africas_talking');
EXCEPTION
  WHEN duplicate_object THEN NULL;
END $$;

ALTER TABLE channels
  ADD COLUMN IF NOT EXISTS provider whatsapp_provider;

UPDATE channels
SET provider = 'baileys'
WHERE kind = 'whatsapp' AND provider IS NULL;

ALTER TABLE channels
  DROP CONSTRAINT IF EXISTS channels_whatsapp_provider_required;
ALTER TABLE channels
  ADD CONSTRAINT channels_whatsapp_provider_required
  CHECK (
    (kind = 'whatsapp' AND provider IS NOT NULL)
    OR (kind <> 'whatsapp' AND provider IS NULL)
  );
```

Add the migration mount after `025_users_staff_tags.sql` in both compose files.

```typescript
export type WhatsAppProvider = 'baileys' | 'africas_talking';

export interface WhatsAppCapabilities {
  direct_text: boolean;
  media: boolean;
  groups: boolean;
  qr_pairing: boolean;
  session_reconnect: boolean;
  templates: boolean;
}

// Channel additions
provider?: WhatsAppProvider | null;
capabilities?: WhatsAppCapabilities;
```

- [ ] **Step 4: Implement the immutable capability map**

```typescript
export const WHATSAPP_CAPABILITIES = {
  baileys: {
    direct_text: true, media: true, groups: true,
    qr_pairing: true, session_reconnect: true, templates: false,
  },
  africas_talking: {
    direct_text: true, media: true, groups: false,
    qr_pairing: false, session_reconnect: false, templates: true,
  },
} as const satisfies Record<WhatsAppProvider, WhatsAppCapabilities>;

export function getWhatsAppCapabilities(provider: WhatsAppProvider): WhatsAppCapabilities {
  return { ...WHATSAPP_CAPABILITIES[provider] };
}
```

- [ ] **Step 5: Build shared types and run capability tests**

Run:
```bash
npm run build --workspace=@wa-ticketing/shared
npm run test:unit --workspace=@wa-ticketing/api
```
Expected: shared build succeeds and both capability tests pass.

- [ ] **Step 6: Commit the schema contract**

```bash
git add supabase/migrations/026_channel_whatsapp_provider.sql docker-compose.yml docker-compose.dokploy.yml packages/shared/src/types.ts apps/api/src/services/whatsappCapabilities.ts apps/api/src/services/whatsappCapabilities.test.ts
git commit -m "feat(whatsapp): add provider capability model"
```

### Task 2: Add provider adapters and preserve Baileys behavior

**Files:**
- Create: `apps/api/src/services/whatsappProvider.ts`
- Create: `apps/api/src/services/baileysWhatsAppProvider.ts`
- Create: `apps/api/src/services/africasTalkingWhatsAppProvider.ts`
- Create: `apps/api/src/services/whatsappProvider.test.ts`
- Modify: `apps/api/src/services/channelService.ts`
- Modify: `apps/api/src/services/ticketMediaService.ts`

**Interfaces:**
- Consumes `Channel`, `WhatsAppProvider`, and `WhatsAppCapabilities`.
- Produces `WhatsAppProviderAdapter`, `resolveWhatsAppProvider(channel)`, and provider-neutral send/group/session methods.

- [ ] **Step 1: Write adapter contract tests**

Use injected `fetch` functions so tests assert:

```typescript
assert.equal(request.url, 'https://chat.africastalking.com/whatsapp/message/send');
assert.equal(request.init.headers.apiKey, 'secret');
assert.deepEqual(JSON.parse(String(request.init.body)), {
  username: 'account',
  waNumber: '+27820000000',
  phoneNumber: '+27821111111',
  body: { message: 'Hello' },
});
```

Also assert:
- successful `{ status: 'SENT', messageId: 'at-1' }` returns `at-1`;
- HTTP/authentication failures throw a redacted provider error;
- media maps `image` → `Image`, `voice_note` → `Voice`, `document` → `Document`;
- Baileys requests retain the current worker URLs and payloads;
- Africa's Talking rejects group/session operations before network calls.

- [ ] **Step 2: Run tests and verify failures**

Run: `npm run test:unit --workspace=@wa-ticketing/api`  
Expected: FAIL because adapter modules do not exist.

- [ ] **Step 3: Define the provider-neutral contract**

```typescript
export type WhatsAppMediaPayload = {
  kind: 'image' | 'voice_note' | 'document';
  mediaBase64: string;
  mediaUrl: string;
  mediaMime: string;
  caption?: string;
  fileName?: string;
};

export interface WhatsAppProviderAdapter {
  sendText(channel: Channel, recipient: string, text: string): Promise<string | null>;
  sendMedia(channel: Channel, recipient: string, media: WhatsAppMediaPayload): Promise<string | null>;
  startSession?(channel: Channel, clearAuth: boolean): Promise<void>;
  terminateSession?(channel: Channel): Promise<void>;
  createGroup?(channel: Channel, subject: string, phones: string[]): Promise<{ groupJid: string; subject: string }>;
  getGroupMetadata?(channel: Channel, groupJid: string): Promise<GroupMetadata>;
  updateGroupParticipants?(channel: Channel, groupJid: string, action: 'add' | 'remove', phones: string[]): Promise<void>;
}
```

- [ ] **Step 4: Implement adapters and resolver**

The Africa's Talking adapter sends:

```typescript
await fetch('https://chat.africastalking.com/whatsapp/message/send', {
  method: 'POST',
  headers: {
    Accept: 'application/json',
    'Content-Type': 'application/json',
    apiKey,
  },
  body: JSON.stringify({
    username,
    waNumber: channel.phone_number,
    phoneNumber: recipient,
    body,
  }),
  signal: AbortSignal.timeout(15_000),
});
```

`ticketMediaService.createSignedUrlForPath()` supplies the externally reachable signed URL used in media requests. Keep base64 for Baileys.

- [ ] **Step 5: Refactor `channelService` to resolve adapters**

Select `provider` and attach `capabilities` in `mapChannelRow`. Filter `restoreAllSessions` to `provider = 'baileys'`. Resolve the full channel before outbound/session/group operations and throw a `400` capability error when the selected adapter lacks the requested method.

- [ ] **Step 6: Run adapter and regression tests**

Run:
```bash
npm run build --workspace=@wa-ticketing/shared
npm run test:unit --workspace=@wa-ticketing/api
npm run build --workspace=@wa-ticketing/api
```
Expected: all tests and the API build pass.

- [ ] **Step 7: Commit provider dispatch**

```bash
git add apps/api/src/services/whatsappProvider.ts apps/api/src/services/baileysWhatsAppProvider.ts apps/api/src/services/africasTalkingWhatsAppProvider.ts apps/api/src/services/whatsappProvider.test.ts apps/api/src/services/channelService.ts apps/api/src/services/ticketMediaService.ts
git commit -m "feat(whatsapp): route messages through provider adapters"
```

### Task 3: Share inbound processing and add the Africa's Talking webhook

**Files:**
- Create: `apps/api/src/services/whatsappInboundService.ts`
- Create: `apps/api/src/services/africasTalkingWebhook.ts`
- Create: `apps/api/src/services/africasTalkingWebhook.test.ts`
- Create: `apps/api/src/routes/inboundWhatsapp.ts`
- Modify: `apps/api/src/routes/internal.ts`
- Modify: `apps/api/src/services/channelService.ts`
- Modify: `apps/api/src/index.ts`

**Interfaces:**
- Produces `NormalizedWhatsAppInbound`, `mapAfricasTalkingWebhook(body)`, and `processWhatsAppInbound(input, context)`.
- Existing Baileys `/internal/messages` and the public Africa's Talking route call the same processor.

- [ ] **Step 1: Write mapper and route-contract tests**

Cover both text and media callback forms:

```typescript
const mapped = mapAfricasTalkingWebhook({
  waNumber: '+27820000000',
  phoneNumber: '+27821111111',
  messageId: 'at-in-1',
  type: 'text',
  text: 'Need help',
});
assert.deepEqual(mapped, {
  phone: '+27821111111',
  waId: 'at-in-1',
  text: 'Need help',
  messageType: 'text',
});
```

Also test accepted aliases (`from`, `mobile`, `id`, nested `body.message`), unknown event rejection, channel-number mismatch, duplicate IDs, invalid token, non-Africa's Talking token, and a bounded HTTPS media download.

- [ ] **Step 2: Run the focused unit suite and verify failures**

Run: `npm run test:unit --workspace=@wa-ticketing/api`  
Expected: FAIL because webhook modules do not exist.

- [ ] **Step 3: Extract the current inbound handler**

Move the logic from `POST /internal/messages` into:

```typescript
export async function processWhatsAppInbound(
  input: NormalizedWhatsAppInbound,
  context: { io: SocketServer; log: FastifyBaseLogger },
): Promise<
  | { ok: true; duplicate: true }
  | { ok: true; ignored: true; reason: 'no_group_ticket' }
  | { ok: true; ticketId: string; messageId: string }
>;
```

Keep direct/group behavior, media persistence, transcription, alerts, signup, talkback, auditing, and socket events unchanged. The internal route performs input validation and calls this function.

- [ ] **Step 4: Implement the public webhook route**

Register:

```typescript
fastify.post('/inbound/whatsapp/africas-talking/:token', {
  config: { rateLimit: { max: 40, timeWindow: '1 minute' } },
  bodyLimit: 1 * 1024 * 1024,
}, handler);
```

Resolve a channel by token only when `kind = 'whatsapp' AND provider = 'africas_talking'`, verify `waNumber` when supplied, map/download supported media, process the normalized event, and return duplicate events as `200`.

- [ ] **Step 5: Register the route and run tests/build**

Run:
```bash
npm run test:unit --workspace=@wa-ticketing/api
npm run build --workspace=@wa-ticketing/api
```
Expected: mapper/processor tests and API build pass.

- [ ] **Step 6: Commit inbound provider support**

```bash
git add apps/api/src/services/whatsappInboundService.ts apps/api/src/services/africasTalkingWebhook.ts apps/api/src/services/africasTalkingWebhook.test.ts apps/api/src/routes/inboundWhatsapp.ts apps/api/src/routes/internal.ts apps/api/src/services/channelService.ts apps/api/src/index.ts
git commit -m "feat(whatsapp): ingest Africa's Talking webhooks"
```

### Task 4: Add provider-aware channel lifecycle and group guards

**Files:**
- Create: `apps/api/src/services/channelProviderRules.test.ts`
- Modify: `apps/api/src/routes/channels.ts`
- Modify: `apps/api/src/routes/tickets.ts`
- Modify: `apps/api/src/services/ticketGroupService.ts`
- Modify: `apps/api/src/services/automatedOutboundService.ts`
- Modify: `apps/api/src/services/ticketService.ts`

**Interfaces:**
- `POST /api/v1/channels/init` accepts `{ name, phone_number, provider }`.
- Africa's Talking creation returns a connected channel with an inbound token and never starts the Baileys worker.

- [ ] **Step 1: Write failing lifecycle/rule tests**

Test that:
- omitted provider remains `baileys` for backward compatibility;
- Africa's Talking creation requires both deployment secrets;
- Africa's Talking creation generates an inbound token and status `connected`;
- reconnect returns `400` for providers without `session_reconnect`;
- group ticket creation rejects Africa's Talking before inserts;
- direct outbound remains allowed.

- [ ] **Step 2: Implement provider-aware creation**

```typescript
const provider = body.provider ?? 'baileys';
const channel = await channelService.create(name, phone_number, provider);
if (channel.capabilities?.qr_pairing) {
  channelService.startSession(channel.id, fastify.io).catch(logFailure);
}
```

For Africa's Talking, validate env credentials, set `inbound_token = randomUUID()`, `status = 'connected'`, and `last_connected_at = NOW()`.

- [ ] **Step 3: Add capability guards before side effects**

Before group create/member changes/group-target automated sends:

```typescript
channelService.assertCapability(channel, 'groups');
```

Reconnect uses `session_reconnect`; QR/session restore/removal remains Baileys-only.

- [ ] **Step 4: Run unit tests and API build**

Run:
```bash
npm run test:unit --workspace=@wa-ticketing/api
npm run build --workspace=@wa-ticketing/api
```
Expected: all tests pass and no provider branch bypasses capability validation.

- [ ] **Step 5: Commit lifecycle rules**

```bash
git add apps/api/src/services/channelProviderRules.test.ts apps/api/src/routes/channels.ts apps/api/src/routes/tickets.ts apps/api/src/services/ticketGroupService.ts apps/api/src/services/automatedOutboundService.ts apps/api/src/services/ticketService.ts
git commit -m "feat(channels): enforce WhatsApp provider capabilities"
```

### Task 5: Build provider-aware channel UI

**Files:**
- Create: `apps/frontend/src/components/channels/ChannelCapabilities.tsx`
- Create: `apps/frontend/src/components/channels/ChannelProviderBadge.tsx`
- Modify: `apps/frontend/src/pages/Channels.tsx`
- Modify: `apps/frontend/src/pages/ChannelDetail.tsx`
- Modify: `apps/frontend/src/components/StaffNewTicketModal.tsx`
- Modify: `apps/frontend/src/pages/TicketDetail.tsx`
- Modify: `apps/frontend/src/lib/ticketMessageDelivery.ts`
- Modify: `e2e/smoke.spec.ts`

**Interfaces:**
- Consumes `Channel.provider` and `Channel.capabilities`.
- Adds no frontend-owned provider capability table.

- [ ] **Step 1: Add an E2E assertion that describes the new UX**

Add a channels smoke scenario that opens “Add WhatsApp”, sees provider choices “Baileys / QR” and “Africa's Talking”, selects Africa's Talking, and verifies the create button does not mention QR.

- [ ] **Step 2: Implement reusable provider/capability badges**

```tsx
export function ChannelProviderBadge({ provider }: { provider: WhatsAppProvider | null | undefined }) {
  return <Badge variant="outline">{provider === 'africas_talking' ? "Africa's Talking" : 'Baileys'}</Badge>;
}
```

`ChannelCapabilities` renders API-supplied supported capabilities and explanatory muted badges for groups, QR pairing, and reconnect.

- [ ] **Step 3: Convert the add modal into a provider flow**

Add provider state defaulting to `baileys`, send it to `/channels/init`, open `QRModal` only when the returned channel has `capabilities.qr_pairing`, and invalidate/navigate for Africa's Talking.

- [ ] **Step 4: Add list/detail indicators and webhook instructions**

Show provider/capability badges on channel cards and detail header. For Africa's Talking, construct:

```typescript
`${getApiBaseUrl()}/api/v1/inbound/whatsapp/africas-talking/${channel.inbound_token}`
```

Render a copy button and dashboard-configuration guidance. Hide reconnect controls when `session_reconnect` is false.

- [ ] **Step 5: Gate group UX and provider-specific error copy**

Disable group mode when the selected channel has `capabilities.groups === false`, explain “Groups require a Baileys channel”, and ensure disconnected Africa's Talking guidance links to channel configuration rather than QR reconnect.

- [ ] **Step 6: Build frontend and run the smoke test**

Run:
```bash
npm run build --workspace=@wa-ticketing/shared
npm run build --workspace=@wa-ticketing/frontend
npm run test:e2e -- --grep "channels"
```
Expected: frontend compiles and the provider-selection smoke test passes against the running stack.

- [ ] **Step 7: Commit the provider UX**

```bash
git add apps/frontend/src/components/channels/ChannelCapabilities.tsx apps/frontend/src/components/channels/ChannelProviderBadge.tsx apps/frontend/src/pages/Channels.tsx apps/frontend/src/pages/ChannelDetail.tsx apps/frontend/src/components/StaffNewTicketModal.tsx apps/frontend/src/pages/TicketDetail.tsx apps/frontend/src/lib/ticketMessageDelivery.ts e2e/smoke.spec.ts
git commit -m "feat(channels): add provider-aware WhatsApp setup"
```

### Task 6: Wire deployment configuration and documentation

**Files:**
- Modify: `.env.example`
- Modify: `.env.prod.example`
- Modify: `docker-compose.yml`
- Modify: `docker-compose.dokploy.yml`
- Modify: `README.md`
- Modify: `CHANGELOG.md`
- Modify: `docs/NewKnowledgeBase.md`
- Modify: `docs/scope_wa_ticketing_system.html`
- Modify: `docs/testsprite_run.md`
- Modify: `testsprite_tests/testsprite_frontend_test_plan.json`

**Interfaces:**
- API containers receive the two Africa's Talking env variables.
- Operator docs describe provider limits and webhook setup without including credentials.

- [ ] **Step 1: Add env passthrough**

```yaml
AFRICASTALKING_USERNAME: ${AFRICASTALKING_USERNAME:-}
AFRICASTALKING_API_KEY: ${AFRICASTALKING_API_KEY:-}
```

Add empty documented values to both env examples.

- [ ] **Step 2: Update operator and feature documentation**

Document:
- provider selection and capability badges;
- one deployment account;
- dashboard webhook URL setup;
- direct text/media support;
- Baileys-only groups/QR/reconnect;
- migration `026`;
- rollback by retaining Baileys and removing Africa's Talking channels before reverting the schema.

- [ ] **Step 3: Extend TestSprite coverage**

Add test-plan cases for provider selection, webhook URL display, capability badges, and disabled group/reconnect actions. Append the actual run result and date to `docs/testsprite_run.md`.

- [ ] **Step 4: Validate documentation and compose syntax**

Run:
```bash
docker compose config --quiet
docker compose -f docker-compose.dokploy.yml config --quiet
git diff --check
```
Expected: both compose files parse and the diff has no whitespace errors.

- [ ] **Step 5: Commit deployment/docs**

```bash
git add .env.example .env.prod.example docker-compose.yml docker-compose.dokploy.yml README.md CHANGELOG.md docs/NewKnowledgeBase.md docs/scope_wa_ticketing_system.html docs/testsprite_run.md testsprite_tests/testsprite_frontend_test_plan.json
git commit -m "docs(whatsapp): document Africa's Talking connector"
```

### Task 7: Apply migration, verify end-to-end, review, and open the PR

**Files:**
- Verify all files changed by Tasks 1–6.

- [ ] **Step 1: Install dependencies and run the full build/unit suite**

Run:
```bash
npm ci
npm run build
npm run test:unit --workspace=@wa-ticketing/api
```
Expected: all workspaces build and all unit tests pass.

- [ ] **Step 2: Start a fresh stack and apply migrations**

Check existing related services first, then run:
```bash
docker compose up -d --build
npm run migrate:sql
docker compose ps
```
Expected: migration `026_channel_whatsapp_provider.sql` is applied and app services are healthy.

- [ ] **Step 3: Run browser verification**

Run:
```bash
npm run test:e2e
```
Verify:
- Baileys creation still opens QR and a connected Baileys channel can create groups.
- Africa's Talking creation shows a webhook URL and no QR/reconnect/group actions.
- A test webhook creates/deduplicates a ticket message.
- Stubbed provider responses cover direct text/media success and failure without real credentials.

- [ ] **Step 4: Run TestSprite or record the environment blocker**

Run `node scripts/run-testsprite-execute.mjs` when its authenticated config is available. If unavailable, record the exact blocker plus Playwright/manual smoke results in `docs/testsprite_run.md`.

- [ ] **Step 5: Review the complete diff**

Run:
```bash
git status --short
git diff --check
git diff --stat origin/main...HEAD
git log --oneline origin/main..HEAD
```

Review security, secret handling, provider error redaction, API rate limits, Baileys regressions, file sizes, and test coverage. Fix and retest any issue before pushing.

- [ ] **Step 6: Push and create the Bitbucket pull request**

```bash
git push -u origin HEAD
```

Create a PR targeting `main` using the repository's available Bitbucket tooling. The PR body must summarize provider coexistence, capability limits, migration/deployment requirements, tests run, TestSprite status, and any inability to exercise the live Africa's Talking account without production credentials.

- [ ] **Step 7: Deliver the PR**

Post the PR URL and concise verification results on OVER-345, then move the issue to `in_review`.
