2396 lines
83 KiB
Markdown
2396 lines
83 KiB
Markdown
# WhatsApp QR Re-Authentication Dashboard 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:** When WhatsApp logs out (or on first-time setup), automatically restart the auth flow and surface the QR code in the admin dashboard so any admin can re-authenticate by scanning — no terminal access needed. Admins can also add new WhatsApp accounts directly from the dashboard without touching the database or restarting the worker.
|
||
|
||
**Architecture:** The worker's `session.ts` detects `loggedOut`, clears stale session files, and restarts the Baileys connection to generate a fresh QR; this raw QR string is written to `Account.qrCode` in the database via Prisma. The NestJS API exposes a new `/accounts` module that reads account status and converts the raw QR string to a PNG data URL using the `qrcode` package. The Next.js web app adds an Accounts page with a client component that shows a connected/disconnected badge, polls for QR images, and lets admins add new accounts. The worker polls the DB every 30 seconds for new accounts and starts sessions automatically — no restart needed.
|
||
|
||
**Tech Stack:** Baileys (WhatsApp), Prisma 6 (PostgreSQL), NestJS 11, Next.js 16 App Router, `qrcode` npm package, React Testing Library
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
**Modified:**
|
||
- `apps/api/prisma/schema.prisma` — add `qrCode String?` to Account model
|
||
- `apps/worker/src/whatsapp/session.ts` — add `onQr`/`onStatus` callbacks (with JID); on `loggedOut` clear session files and restart
|
||
- `apps/worker/src/whatsapp/session-pool.ts` — thread `onQr`/`onStatus` (with JID) callbacks through `add()`
|
||
- `apps/worker/src/main.ts` — Prisma-writing handlers + extract `startAccount()` helper + 30s polling loop; initial load includes DISCONNECTED accounts
|
||
- `apps/api/src/modules/accounts/accounts.service.ts` — list + QR + create account
|
||
- `apps/api/src/modules/accounts/accounts.controller.ts` — `GET /accounts`, `GET /accounts/:id/qr`, `POST /accounts`
|
||
- `apps/api/src/app.module.ts` — add `AccountsModule`
|
||
- `apps/web/app/api/accounts/route.ts` — Next.js proxy → `GET` + `POST /accounts`
|
||
- `apps/web/app/layout.tsx` — add Accounts nav link
|
||
|
||
**Created:**
|
||
- `apps/api/src/modules/accounts/accounts.module.ts` — NestJS module
|
||
- `apps/api/src/modules/accounts/accounts.service.spec.ts` — unit tests
|
||
- `apps/api/src/modules/accounts/accounts.controller.spec.ts` — unit tests
|
||
- `apps/web/app/api/accounts/[id]/qr/route.ts` — Next.js proxy → `GET /accounts/:id/qr`
|
||
- `apps/web/app/accounts/AccountCard.tsx` — client component with status badge + QR polling
|
||
- `apps/web/app/accounts/AccountCard.test.tsx` — unit tests
|
||
- `apps/web/app/accounts/AccountsList.tsx` — client component: manages accounts state + add account form
|
||
- `apps/web/app/accounts/AccountsList.test.tsx` — unit tests
|
||
- `apps/web/app/accounts/page.tsx` — server component
|
||
|
||
**Test files updated:**
|
||
- `apps/worker/src/whatsapp/session.test.ts` — update `onStatus('connected')` assertion to include jid
|
||
- `apps/worker/src/whatsapp/session-pool.test.ts` — add QR and status callback threading tests
|
||
|
||
---
|
||
|
||
## Task 1: Prisma Schema Migration — Add `qrCode` to Account
|
||
|
||
**Files:**
|
||
- Modify: `apps/api/prisma/schema.prisma`
|
||
|
||
- [ ] **Step 1: Add `qrCode` field to Account model in schema.prisma**
|
||
|
||
Open `apps/api/prisma/schema.prisma`. The `Account` model currently ends with:
|
||
```prisma
|
||
model Account {
|
||
id String @id @default(cuid())
|
||
platform String
|
||
jid String
|
||
sessionPath String
|
||
displayName String?
|
||
status AccountStatus @default(ACTIVE)
|
||
groups Group[]
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@unique([platform, jid])
|
||
}
|
||
```
|
||
|
||
Change it to:
|
||
```prisma
|
||
model Account {
|
||
id String @id @default(cuid())
|
||
platform String
|
||
jid String
|
||
sessionPath String
|
||
displayName String?
|
||
status AccountStatus @default(ACTIVE)
|
||
qrCode String?
|
||
groups Group[]
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@unique([platform, jid])
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create and apply the migration**
|
||
|
||
```bash
|
||
cd apps/api && pnpm exec prisma migrate dev --name add_account_qr_code
|
||
```
|
||
|
||
Expected output:
|
||
```
|
||
Environment variables loaded from .env
|
||
Prisma schema loaded from prisma/schema.prisma
|
||
Datasource "db": PostgreSQL database "tower_dev" at "localhost:5433"
|
||
|
||
Applying migration `20260529000000_add_account_qr_code`
|
||
|
||
The following migration(s) have been applied:
|
||
|
||
migrations/
|
||
└─ 20260529000000_add_account_qr_code/
|
||
└─ migration.sql
|
||
|
||
Your database is now in sync with your schema.
|
||
```
|
||
|
||
`prisma migrate dev` automatically regenerates the Prisma client after migration. No separate `prisma generate` needed.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add apps/api/prisma/schema.prisma apps/api/prisma/migrations/
|
||
git commit -m "feat: add qrCode field to Account for QR re-auth"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Worker Session — QR/Status Callbacks + Auto-Restart on Logout
|
||
|
||
**Files:**
|
||
- Modify: `apps/worker/src/whatsapp/session.ts`
|
||
- Create: `apps/worker/src/whatsapp/session.test.ts`
|
||
|
||
- [ ] **Step 1: Write the failing tests for session.ts**
|
||
|
||
Create `apps/worker/src/whatsapp/session.test.ts`:
|
||
|
||
```typescript
|
||
import { Boom } from '@hapi/boom';
|
||
import { createWhatsAppSession } from './session';
|
||
import * as fs from 'fs/promises';
|
||
|
||
// Capture connection.update handler so tests can trigger it
|
||
let connectionUpdateHandler: (update: any) => Promise<void>;
|
||
let credsUpdateHandler: () => void;
|
||
|
||
const mockSock = {
|
||
ev: {
|
||
on: jest.fn().mockImplementation((event: string, handler: any) => {
|
||
if (event === 'connection.update') connectionUpdateHandler = handler;
|
||
if (event === 'creds.update') credsUpdateHandler = handler;
|
||
if (event === 'messages.upsert') { /* no-op */ }
|
||
}),
|
||
},
|
||
end: jest.fn(),
|
||
groupFetchAllParticipating: jest.fn().mockResolvedValue({}),
|
||
};
|
||
|
||
jest.mock('@whiskeysockets/baileys', () => ({
|
||
default: jest.fn().mockReturnValue(mockSock),
|
||
useMultiFileAuthState: jest.fn().mockResolvedValue({ state: {}, saveCreds: jest.fn() }),
|
||
fetchLatestBaileysVersion: jest.fn().mockResolvedValue({ version: [2, 0, 0] }),
|
||
DisconnectReason: { loggedOut: 401 },
|
||
}));
|
||
|
||
jest.mock('qrcode-terminal', () => ({ generate: jest.fn() }));
|
||
|
||
jest.mock('fs/promises', () => ({
|
||
rm: jest.fn().mockResolvedValue(undefined),
|
||
mkdir: jest.fn().mockResolvedValue(undefined),
|
||
}));
|
||
|
||
describe('createWhatsAppSession', () => {
|
||
beforeEach(() => {
|
||
jest.clearAllMocks();
|
||
});
|
||
|
||
it('calls onQr with raw QR string when QR event fires', async () => {
|
||
const onQr = jest.fn();
|
||
await createWhatsAppSession('acc_1', '/sessions/1', jest.fn(), jest.fn(), jest.fn(), undefined, onQr);
|
||
await connectionUpdateHandler({ qr: 'test-qr-string' });
|
||
expect(onQr).toHaveBeenCalledWith('test-qr-string');
|
||
});
|
||
|
||
it('calls onStatus with "connected" when connection opens', async () => {
|
||
const onStatus = jest.fn();
|
||
await createWhatsAppSession('acc_1', '/sessions/1', jest.fn(), jest.fn(), jest.fn(), undefined, undefined, onStatus);
|
||
await connectionUpdateHandler({ connection: 'open' });
|
||
expect(onStatus).toHaveBeenCalledWith('connected');
|
||
});
|
||
|
||
it('calls onStatus with "disconnected" on non-logout close', async () => {
|
||
const onStatus = jest.fn();
|
||
await createWhatsAppSession('acc_1', '/sessions/1', jest.fn(), jest.fn(), jest.fn(), undefined, undefined, onStatus);
|
||
const error = new Boom('Connection lost', { statusCode: 408 });
|
||
await connectionUpdateHandler({ connection: 'close', lastDisconnect: { error } });
|
||
expect(onStatus).toHaveBeenCalledWith('disconnected');
|
||
});
|
||
|
||
it('calls onStatus with "logged_out" on loggedOut close', async () => {
|
||
const onStatus = jest.fn();
|
||
await createWhatsAppSession('acc_1', '/sessions/1', jest.fn(), jest.fn(), jest.fn(), undefined, undefined, onStatus);
|
||
const error = new Boom('Logged out', { statusCode: 401 });
|
||
await connectionUpdateHandler({ connection: 'close', lastDisconnect: { error } });
|
||
expect(onStatus).toHaveBeenCalledWith('logged_out');
|
||
});
|
||
|
||
it('deletes session directory on loggedOut', async () => {
|
||
await createWhatsAppSession('acc_1', '/sessions/1', jest.fn(), jest.fn(), jest.fn());
|
||
const error = new Boom('Logged out', { statusCode: 401 });
|
||
await connectionUpdateHandler({ connection: 'close', lastDisconnect: { error } });
|
||
expect(fs.rm).toHaveBeenCalledWith('/sessions/1', { recursive: true, force: true });
|
||
expect(fs.mkdir).toHaveBeenCalledWith('/sessions/1', { recursive: true });
|
||
});
|
||
|
||
it('does not delete session directory on non-logout close', async () => {
|
||
await createWhatsAppSession('acc_1', '/sessions/1', jest.fn(), jest.fn(), jest.fn());
|
||
const error = new Boom('Timeout', { statusCode: 408 });
|
||
await connectionUpdateHandler({ connection: 'close', lastDisconnect: { error } });
|
||
expect(fs.rm).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
```bash
|
||
cd apps/worker && pnpm test -- --testPathPattern=session.test.ts --no-coverage
|
||
```
|
||
|
||
Expected: FAIL — `session.test.ts` has import errors or assertion failures since `session.ts` doesn't yet export the right callbacks.
|
||
|
||
- [ ] **Step 3: Implement the changes in session.ts**
|
||
|
||
Replace the full contents of `apps/worker/src/whatsapp/session.ts`:
|
||
|
||
```typescript
|
||
import makeWASocket, {
|
||
useMultiFileAuthState,
|
||
fetchLatestBaileysVersion,
|
||
DisconnectReason,
|
||
WASocket,
|
||
GroupMetadata,
|
||
} from '@whiskeysockets/baileys';
|
||
import { Boom } from '@hapi/boom';
|
||
import { rm, mkdir } from 'fs/promises';
|
||
import qrcode from 'qrcode-terminal';
|
||
import { NormalizedMessage, NormalizedReaction } from '@tower/types';
|
||
import { normalizeMessage, normalizeReaction } from './normalizer';
|
||
import { createLogger } from '@tower/logger';
|
||
|
||
const logger = createLogger('whatsapp-session');
|
||
|
||
export type OnMessageCallback = (msg: NormalizedMessage) => Promise<void> | void;
|
||
export type OnReactionCallback = (reaction: NormalizedReaction) => Promise<void> | void;
|
||
export type OnGroupsCallback = (groups: Record<string, GroupMetadata>) => Promise<void> | void;
|
||
export type OnQrCallback = (qr: string) => Promise<void> | void;
|
||
export type OnStatusCallback = (status: 'connected' | 'disconnected' | 'logged_out') => Promise<void> | void;
|
||
|
||
export async function createWhatsAppSession(
|
||
accountId: string,
|
||
sessionPath: string,
|
||
onMessage: OnMessageCallback,
|
||
onReaction: OnReactionCallback,
|
||
onGroups: OnGroupsCallback,
|
||
onReconnect?: (newSocket: WASocket) => void,
|
||
onQr?: OnQrCallback,
|
||
onStatus?: OnStatusCallback,
|
||
): Promise<WASocket> {
|
||
const { state, saveCreds } = await useMultiFileAuthState(sessionPath);
|
||
const { version } = await fetchLatestBaileysVersion();
|
||
|
||
const sock = makeWASocket({
|
||
version,
|
||
auth: state,
|
||
printQRInTerminal: false,
|
||
logger: logger as any,
|
||
});
|
||
|
||
sock.ev.on('creds.update', saveCreds);
|
||
|
||
sock.ev.on('connection.update', async ({ connection, qr, lastDisconnect }) => {
|
||
if (qr) {
|
||
qrcode.generate(qr, { small: true });
|
||
await Promise.resolve(onQr?.(qr)).catch((err) =>
|
||
logger.error({ err }, 'Error storing QR'),
|
||
);
|
||
}
|
||
|
||
if (connection === 'close') {
|
||
const reason = (lastDisconnect?.error as Boom)?.output?.statusCode;
|
||
const isLoggedOut = reason === DisconnectReason.loggedOut;
|
||
logger.info({ reason, isLoggedOut }, 'Connection closed');
|
||
|
||
if (isLoggedOut) {
|
||
logger.info({ accountId }, 'Logged out — clearing session and restarting for QR');
|
||
await onStatus?.('logged_out');
|
||
await rm(sessionPath, { recursive: true, force: true });
|
||
await mkdir(sessionPath, { recursive: true });
|
||
setTimeout(async () => {
|
||
const newSocket = await createWhatsAppSession(
|
||
accountId, sessionPath, onMessage, onReaction, onGroups, onReconnect, onQr, onStatus,
|
||
);
|
||
onReconnect?.(newSocket);
|
||
}, 1000);
|
||
} else {
|
||
await onStatus?.('disconnected');
|
||
logger.info('Reconnecting in 5s...');
|
||
setTimeout(async () => {
|
||
const newSocket = await createWhatsAppSession(
|
||
accountId, sessionPath, onMessage, onReaction, onGroups, onReconnect, onQr, onStatus,
|
||
);
|
||
onReconnect?.(newSocket);
|
||
}, 5000);
|
||
}
|
||
} else if (connection === 'open') {
|
||
await onStatus?.('connected');
|
||
try {
|
||
logger.info({ accountId }, 'WhatsApp connected');
|
||
const groups = await sock.groupFetchAllParticipating();
|
||
await Promise.resolve(onGroups(groups)).catch((err) =>
|
||
logger.error({ err }, 'Group sync error'),
|
||
);
|
||
} catch (err) {
|
||
logger.error({ err }, 'Failed to fetch groups on connect');
|
||
}
|
||
}
|
||
});
|
||
|
||
sock.ev.on('messages.upsert', ({ messages, type }) => {
|
||
if (type !== 'notify') return;
|
||
for (const msg of messages) {
|
||
if (msg.message?.reactionMessage) {
|
||
const reaction = normalizeReaction(msg, accountId);
|
||
if (reaction) {
|
||
void Promise.resolve(onReaction(reaction)).catch((err) =>
|
||
logger.error({ err }, 'Error processing reaction'),
|
||
);
|
||
}
|
||
continue;
|
||
}
|
||
const normalized = normalizeMessage(msg, accountId);
|
||
if (!normalized) continue;
|
||
void Promise.resolve(onMessage(normalized)).catch((err) =>
|
||
logger.error({ err }, 'Error processing message'),
|
||
);
|
||
}
|
||
});
|
||
|
||
return sock;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
```bash
|
||
cd apps/worker && pnpm test -- --testPathPattern=session.test.ts --no-coverage
|
||
```
|
||
|
||
Expected: PASS — 6 tests pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add apps/worker/src/whatsapp/session.ts apps/worker/src/whatsapp/session.test.ts
|
||
git commit -m "feat: add onQr/onStatus callbacks to session; auto-restart on loggedOut"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Worker Session Pool + main.ts — Thread Callbacks and Update DB
|
||
|
||
**Files:**
|
||
- Modify: `apps/worker/src/whatsapp/session-pool.ts`
|
||
- Modify: `apps/worker/src/whatsapp/session-pool.test.ts`
|
||
- Modify: `apps/worker/src/main.ts`
|
||
|
||
- [ ] **Step 1: Add QR callback threading test to session-pool.test.ts**
|
||
|
||
Open `apps/worker/src/whatsapp/session-pool.test.ts`. Add this test at the end of the `describe` block (before the closing `}`):
|
||
|
||
```typescript
|
||
it('add() injects accountId into onQr callback', async () => {
|
||
const onQr = jest.fn();
|
||
const { createWhatsAppSession } = require('./session');
|
||
|
||
let capturedOnQr: any;
|
||
(createWhatsAppSession as jest.Mock).mockImplementationOnce(
|
||
(_id: string, _path: string, _onMsg: any, _onReaction: any, _onGroups: any, _onReconnect: any, qrCb: any) => {
|
||
capturedOnQr = qrCb;
|
||
return Promise.resolve({ sendMessage: jest.fn(), logout: jest.fn(), end: jest.fn() });
|
||
},
|
||
);
|
||
|
||
await pool.add('acc_1', './sessions/1', jest.fn(), jest.fn(), jest.fn(), onQr);
|
||
await capturedOnQr('test-qr');
|
||
expect(onQr).toHaveBeenCalledWith('test-qr', 'acc_1');
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify the new test fails**
|
||
|
||
```bash
|
||
cd apps/worker && pnpm test -- --testPathPattern=session-pool.test.ts --no-coverage
|
||
```
|
||
|
||
Expected: FAIL — the new `add() injects accountId into onQr callback` test fails because `pool.add()` doesn't yet accept `onQr`.
|
||
|
||
- [ ] **Step 3: Update session-pool.ts to thread onQr and onStatus**
|
||
|
||
Replace `apps/worker/src/whatsapp/session-pool.ts` with:
|
||
|
||
```typescript
|
||
import type { WASocket } from '@whiskeysockets/baileys';
|
||
import { Boom } from '@hapi/boom';
|
||
import { NormalizedMessage, NormalizedReaction } from '@tower/types';
|
||
import { createWhatsAppSession } from './session';
|
||
import { createLogger } from '@tower/logger';
|
||
|
||
const logger = createLogger('session-pool');
|
||
|
||
export type PoolMessageCallback = (msg: NormalizedMessage, accountId: string) => Promise<void> | void;
|
||
export type PoolReactionCallback = (reaction: NormalizedReaction, accountId: string) => Promise<void> | void;
|
||
export type PoolGroupsCallback = (groups: any, accountId: string) => Promise<void> | void;
|
||
export type PoolQrCallback = (qr: string, accountId: string) => Promise<void> | void;
|
||
export type PoolStatusCallback = (status: string, accountId: string) => Promise<void> | void;
|
||
|
||
export class WhatsAppSessionPool {
|
||
private sessions = new Map<string, WASocket>();
|
||
|
||
async add(
|
||
accountId: string,
|
||
sessionPath: string,
|
||
onMessage: PoolMessageCallback,
|
||
onReaction: PoolReactionCallback,
|
||
onGroups: PoolGroupsCallback,
|
||
onQr?: PoolQrCallback,
|
||
onStatus?: PoolStatusCallback,
|
||
): Promise<void> {
|
||
logger.info({ accountId }, 'Starting session');
|
||
const sock = await createWhatsAppSession(
|
||
accountId,
|
||
sessionPath,
|
||
(msg) => onMessage(msg, accountId),
|
||
(reaction) => onReaction(reaction, accountId),
|
||
(groups) => onGroups(groups, accountId),
|
||
(newSocket) => {
|
||
logger.info({ accountId }, 'Session reconnected — updating pool');
|
||
this.sessions.set(accountId, newSocket);
|
||
},
|
||
onQr ? (qr) => onQr(qr, accountId) : undefined,
|
||
onStatus ? (status) => onStatus(status, accountId) : undefined,
|
||
);
|
||
this.sessions.set(accountId, sock);
|
||
}
|
||
|
||
get(accountId: string): WASocket | undefined {
|
||
return this.sessions.get(accountId);
|
||
}
|
||
|
||
getAll(): Map<string, WASocket> {
|
||
return this.sessions;
|
||
}
|
||
|
||
async sendMessage(accountId: string, groupJid: string, text: string): Promise<void> {
|
||
const sock = this.sessions.get(accountId);
|
||
if (!sock) {
|
||
const available = Array.from(this.sessions.keys()).join(', ') || 'none';
|
||
throw new Error(`No active session for account ${accountId}. Active accounts: [${available}]`);
|
||
}
|
||
await sock.sendMessage(groupJid, { text });
|
||
}
|
||
|
||
async remove(accountId: string): Promise<void> {
|
||
const sock = this.sessions.get(accountId);
|
||
if (sock) {
|
||
await sock.logout().catch(() => {});
|
||
this.sessions.delete(accountId);
|
||
logger.info({ accountId }, 'Session removed');
|
||
}
|
||
}
|
||
|
||
async closeAll(): Promise<void> {
|
||
logger.info({ count: this.sessions.size }, 'Closing all WhatsApp sessions');
|
||
for (const [accountId, sock] of this.sessions) {
|
||
try {
|
||
sock.end(new Boom('Shutdown', { statusCode: 401 }));
|
||
logger.info({ accountId }, 'Session closed');
|
||
} catch (err) {
|
||
logger.error({ accountId, err }, 'Error closing session');
|
||
}
|
||
}
|
||
this.sessions.clear();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run session-pool tests to verify all pass**
|
||
|
||
```bash
|
||
cd apps/worker && pnpm test -- --testPathPattern=session-pool.test.ts --no-coverage
|
||
```
|
||
|
||
Expected: PASS — all tests including the new QR threading test pass.
|
||
|
||
- [ ] **Step 5: Update main.ts to pass QR/status handlers**
|
||
|
||
In `apps/worker/src/main.ts`, find the `pool.add(...)` call (around line 62). It currently has 5 arguments (accountId, sessionPath, onMessage, onReaction, onGroups). Add two more callbacks after the groups callback:
|
||
|
||
```typescript
|
||
await pool.add(
|
||
account.id,
|
||
account.sessionPath,
|
||
async (msg, accountId) => {
|
||
const tags = detectTags(msg.content, msg.senderJid, adminJids);
|
||
if (!isFlagged(tags)) return;
|
||
|
||
const groupMap = groupMaps.get(accountId);
|
||
if (!groupMap) {
|
||
logger.error({ accountId }, 'No group map for account — message dropped');
|
||
return;
|
||
}
|
||
const sourceGroupId = groupMap.get(msg.sourceGroupJid);
|
||
if (!sourceGroupId) {
|
||
logger.warn({ jid: msg.sourceGroupJid, accountId }, 'Unknown group — skipping message');
|
||
return;
|
||
}
|
||
|
||
await ingestQueue.add(
|
||
'ingest',
|
||
{
|
||
platformMsgId: msg.platformMsgId,
|
||
platform: 'whatsapp',
|
||
accountId,
|
||
sourceGroupId,
|
||
senderJid: msg.senderJid,
|
||
senderName: msg.senderName,
|
||
content: msg.content,
|
||
tags,
|
||
},
|
||
{ attempts: 3, backoff: { type: 'exponential', delay: 1000 } },
|
||
);
|
||
|
||
logger.info({ platformMsgId: msg.platformMsgId, tags }, 'Message enqueued');
|
||
},
|
||
async (reaction) => {
|
||
const result = await handleStarReaction(reaction, adminJids, prisma);
|
||
if (!result) return;
|
||
|
||
const { forwardJobs, indexDoc } = result;
|
||
|
||
await indexQueue.add('index', indexDoc, {
|
||
attempts: 3,
|
||
backoff: { type: 'exponential', delay: 1000 },
|
||
});
|
||
|
||
for (const job of forwardJobs) {
|
||
await forwardQueue.add('forward', job, {
|
||
attempts: 3,
|
||
backoff: { type: 'exponential', delay: 2000 },
|
||
});
|
||
}
|
||
|
||
logger.info(
|
||
{ messageId: indexDoc.messageId, forwardCount: forwardJobs.length },
|
||
'Message approved — indexed and forwarded',
|
||
);
|
||
},
|
||
async (groups, accountId) => {
|
||
logger.info({ count: Object.keys(groups).length, accountId }, 'Syncing groups');
|
||
const map = await syncGroups(groups, accountId, prisma);
|
||
groupMaps.set(accountId, map);
|
||
},
|
||
async (qr, accountId) => {
|
||
await prisma.account.update({
|
||
where: { id: accountId },
|
||
data: { qrCode: qr },
|
||
}).catch((err) => logger.error({ accountId, err }, 'Failed to store QR in DB'));
|
||
logger.info({ accountId }, 'QR code updated');
|
||
},
|
||
async (status, accountId) => {
|
||
if (status === 'connected') {
|
||
await prisma.account.update({
|
||
where: { id: accountId },
|
||
data: { qrCode: null, status: 'ACTIVE' },
|
||
}).catch((err) => logger.error({ accountId, err }, 'Failed to update account status'));
|
||
logger.info({ accountId }, 'Account connected — QR cleared');
|
||
} else if (status === 'logged_out') {
|
||
await prisma.account.update({
|
||
where: { id: accountId },
|
||
data: { status: 'DISCONNECTED' },
|
||
}).catch((err) => logger.error({ accountId, err }, 'Failed to update account status'));
|
||
logger.info({ accountId }, 'Account logged out — awaiting QR scan');
|
||
}
|
||
},
|
||
);
|
||
```
|
||
|
||
The full `main.ts` after this change:
|
||
|
||
```typescript
|
||
import { PrismaClient } from '@prisma/client';
|
||
import { createLogger } from '@tower/logger';
|
||
import { validateEnv } from '@tower/config';
|
||
import { createMeiliClient, configureIndex } from '@tower/search';
|
||
import { createIngestQueue } from './queues/ingest.queue';
|
||
import { createIngestWorker } from './queues/ingest.processor';
|
||
import { createForwardQueue } from './queues/forward.queue';
|
||
import { createForwardWorker } from './queues/forward.processor';
|
||
import { createIndexQueue } from './queues/index.queue';
|
||
import { createIndexWorker } from './queues/index.processor';
|
||
import { WhatsAppSessionPool } from './whatsapp/session-pool';
|
||
import { detectTags, isFlagged } from './whatsapp/tag-detector';
|
||
import { syncGroups } from './whatsapp/group-sync';
|
||
import { handleStarReaction } from './core/approval';
|
||
|
||
const logger = createLogger('tower-worker');
|
||
|
||
async function bootstrap() {
|
||
const env = validateEnv();
|
||
const prisma = new PrismaClient();
|
||
await prisma.$connect();
|
||
|
||
const adminJids = env.TOWER_ADMIN_JIDS
|
||
? env.TOWER_ADMIN_JIDS.split(',').map((j) => j.trim()).filter(Boolean)
|
||
: [];
|
||
|
||
const meiliClient = createMeiliClient(env.MEILI_URL, env.MEILI_MASTER_KEY);
|
||
await configureIndex(meiliClient).catch((err) =>
|
||
logger.warn({ err }, 'Failed to configure Meilisearch index — search may be degraded'),
|
||
);
|
||
|
||
const ingestQueue = createIngestQueue(env.REDIS_URL);
|
||
const forwardQueue = createForwardQueue(env.REDIS_URL);
|
||
const indexQueue = createIndexQueue(env.REDIS_URL);
|
||
const pool = new WhatsAppSessionPool();
|
||
|
||
const ingestWorker = createIngestWorker(env.REDIS_URL, prisma);
|
||
const forwardWorker = createForwardWorker(env.REDIS_URL, pool);
|
||
const indexWorker = createIndexWorker(env.REDIS_URL, meiliClient);
|
||
|
||
ingestWorker.on('completed', (job) => logger.info({ jobId: job.id }, 'Ingest job completed'));
|
||
ingestWorker.on('failed', (job, err) => logger.error({ jobId: job?.id, err }, 'Ingest job failed'));
|
||
forwardWorker.on('completed', (job) => logger.info({ jobId: job.id }, 'Forward job completed'));
|
||
forwardWorker.on('failed', (job, err) => logger.error({ jobId: job?.id, err }, 'Forward job failed'));
|
||
indexWorker.on('completed', (job) => logger.info({ jobId: job.id }, 'Index job completed'));
|
||
indexWorker.on('failed', (job, err) => logger.error({ jobId: job?.id, err }, 'Index job failed'));
|
||
|
||
const accounts = await prisma.account.findMany({
|
||
where: { status: 'ACTIVE', platform: 'whatsapp' },
|
||
});
|
||
|
||
if (accounts.length === 0) {
|
||
logger.warn('No active WhatsApp accounts found — seed one in the Account table (see docs)');
|
||
}
|
||
|
||
const groupMaps = new Map<string, Map<string, string>>();
|
||
|
||
for (const account of accounts) {
|
||
groupMaps.set(account.id, new Map());
|
||
|
||
try {
|
||
await pool.add(
|
||
account.id,
|
||
account.sessionPath,
|
||
async (msg, accountId) => {
|
||
const tags = detectTags(msg.content, msg.senderJid, adminJids);
|
||
if (!isFlagged(tags)) return;
|
||
|
||
const groupMap = groupMaps.get(accountId);
|
||
if (!groupMap) {
|
||
logger.error({ accountId }, 'No group map for account — message dropped');
|
||
return;
|
||
}
|
||
const sourceGroupId = groupMap.get(msg.sourceGroupJid);
|
||
if (!sourceGroupId) {
|
||
logger.warn({ jid: msg.sourceGroupJid, accountId }, 'Unknown group — skipping message');
|
||
return;
|
||
}
|
||
|
||
await ingestQueue.add(
|
||
'ingest',
|
||
{
|
||
platformMsgId: msg.platformMsgId,
|
||
platform: 'whatsapp',
|
||
accountId,
|
||
sourceGroupId,
|
||
senderJid: msg.senderJid,
|
||
senderName: msg.senderName,
|
||
content: msg.content,
|
||
tags,
|
||
},
|
||
{ attempts: 3, backoff: { type: 'exponential', delay: 1000 } },
|
||
);
|
||
|
||
logger.info({ platformMsgId: msg.platformMsgId, tags }, 'Message enqueued');
|
||
},
|
||
async (reaction) => {
|
||
const result = await handleStarReaction(reaction, adminJids, prisma);
|
||
if (!result) return;
|
||
|
||
const { forwardJobs, indexDoc } = result;
|
||
|
||
await indexQueue.add('index', indexDoc, {
|
||
attempts: 3,
|
||
backoff: { type: 'exponential', delay: 1000 },
|
||
});
|
||
|
||
for (const job of forwardJobs) {
|
||
await forwardQueue.add('forward', job, {
|
||
attempts: 3,
|
||
backoff: { type: 'exponential', delay: 2000 },
|
||
});
|
||
}
|
||
|
||
logger.info(
|
||
{ messageId: indexDoc.messageId, forwardCount: forwardJobs.length },
|
||
'Message approved — indexed and forwarded',
|
||
);
|
||
},
|
||
async (groups, accountId) => {
|
||
logger.info({ count: Object.keys(groups).length, accountId }, 'Syncing groups');
|
||
const map = await syncGroups(groups, accountId, prisma);
|
||
groupMaps.set(accountId, map);
|
||
},
|
||
async (qr, accountId) => {
|
||
await prisma.account.update({
|
||
where: { id: accountId },
|
||
data: { qrCode: qr },
|
||
}).catch((err) => logger.error({ accountId, err }, 'Failed to store QR in DB'));
|
||
logger.info({ accountId }, 'QR code updated');
|
||
},
|
||
async (status, accountId) => {
|
||
if (status === 'connected') {
|
||
await prisma.account.update({
|
||
where: { id: accountId },
|
||
data: { qrCode: null, status: 'ACTIVE' },
|
||
}).catch((err) => logger.error({ accountId, err }, 'Failed to update account status'));
|
||
logger.info({ accountId }, 'Account connected — QR cleared');
|
||
} else if (status === 'logged_out') {
|
||
await prisma.account.update({
|
||
where: { id: accountId },
|
||
data: { status: 'DISCONNECTED' },
|
||
}).catch((err) => logger.error({ accountId, err }, 'Failed to update account status'));
|
||
logger.info({ accountId }, 'Account logged out — awaiting QR scan');
|
||
}
|
||
},
|
||
);
|
||
} catch (err) {
|
||
logger.error({ accountId: account.id, err }, 'Failed to start session — skipping account');
|
||
}
|
||
}
|
||
|
||
logger.info({ accountCount: accounts.length }, 'Tower worker ready');
|
||
|
||
const shutdown = async () => {
|
||
logger.info('Shutting down...');
|
||
await pool.closeAll();
|
||
await ingestWorker.close();
|
||
await forwardWorker.close();
|
||
await indexWorker.close();
|
||
await ingestQueue.close();
|
||
await forwardQueue.close();
|
||
await indexQueue.close();
|
||
await prisma.$disconnect();
|
||
process.exit(0);
|
||
};
|
||
|
||
process.on('SIGTERM', shutdown);
|
||
process.on('SIGINT', shutdown);
|
||
}
|
||
|
||
bootstrap().catch((err) => {
|
||
console.error('Worker failed to start', err);
|
||
process.exit(1);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 6: Run all worker tests**
|
||
|
||
```bash
|
||
cd apps/worker && pnpm test --no-coverage
|
||
```
|
||
|
||
Expected: PASS — all worker tests pass.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add apps/worker/src/whatsapp/session-pool.ts apps/worker/src/whatsapp/session-pool.test.ts apps/worker/src/main.ts
|
||
git commit -m "feat: thread QR/status callbacks through session pool; persist to DB in main"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: API AccountsModule — List Accounts + QR Endpoint
|
||
|
||
**Files:**
|
||
- Create: `apps/api/src/modules/accounts/accounts.service.ts`
|
||
- Create: `apps/api/src/modules/accounts/accounts.service.spec.ts`
|
||
- Create: `apps/api/src/modules/accounts/accounts.controller.ts`
|
||
- Create: `apps/api/src/modules/accounts/accounts.controller.spec.ts`
|
||
- Create: `apps/api/src/modules/accounts/accounts.module.ts`
|
||
- Modify: `apps/api/src/app.module.ts`
|
||
|
||
- [ ] **Step 1: Install `qrcode` in the API**
|
||
|
||
```bash
|
||
pnpm add qrcode --filter @tower/api
|
||
pnpm add @types/qrcode --filter @tower/api --save-dev
|
||
```
|
||
|
||
Expected: `qrcode` and `@types/qrcode` added to `apps/api/package.json`.
|
||
|
||
- [ ] **Step 2: Write the failing service tests**
|
||
|
||
Create `apps/api/src/modules/accounts/accounts.service.spec.ts`:
|
||
|
||
```typescript
|
||
import { Test, TestingModule } from '@nestjs/testing';
|
||
import { AccountsService } from './accounts.service';
|
||
import { PrismaService } from '../../prisma/prisma.service';
|
||
import * as QRCode from 'qrcode';
|
||
|
||
jest.mock('qrcode', () => ({
|
||
toDataURL: jest.fn().mockResolvedValue('data:image/png;base64,fakedata'),
|
||
}));
|
||
|
||
const mockAccounts = [
|
||
{ id: 'acc_1', platform: 'whatsapp', jid: '111@s.whatsapp.net', displayName: 'Test Account', status: 'ACTIVE' },
|
||
];
|
||
|
||
const mockPrisma = {
|
||
account: {
|
||
findMany: jest.fn().mockResolvedValue(mockAccounts),
|
||
findUnique: jest.fn(),
|
||
},
|
||
};
|
||
|
||
describe('AccountsService', () => {
|
||
let service: AccountsService;
|
||
|
||
beforeEach(async () => {
|
||
jest.clearAllMocks();
|
||
const module: TestingModule = await Test.createTestingModule({
|
||
providers: [
|
||
AccountsService,
|
||
{ provide: PrismaService, useValue: mockPrisma },
|
||
],
|
||
}).compile();
|
||
service = module.get<AccountsService>(AccountsService);
|
||
});
|
||
|
||
describe('list()', () => {
|
||
it('returns accounts from Prisma without qrCode field', async () => {
|
||
const result = await service.list();
|
||
expect(result).toEqual(mockAccounts);
|
||
expect(mockPrisma.account.findMany).toHaveBeenCalledWith(
|
||
expect.objectContaining({ select: expect.not.objectContaining({ qrCode: true }) }),
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('getQr()', () => {
|
||
it('returns null qrDataUrl when account has no qrCode', async () => {
|
||
mockPrisma.account.findUnique.mockResolvedValue({ status: 'ACTIVE', qrCode: null });
|
||
const result = await service.getQr('acc_1');
|
||
expect(result).toEqual({ status: 'ACTIVE', qrDataUrl: null });
|
||
expect(QRCode.toDataURL).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('converts qrCode string to data URL when qrCode is present', async () => {
|
||
mockPrisma.account.findUnique.mockResolvedValue({ status: 'DISCONNECTED', qrCode: 'raw-qr-string' });
|
||
const result = await service.getQr('acc_1');
|
||
expect(QRCode.toDataURL).toHaveBeenCalledWith('raw-qr-string');
|
||
expect(result).toEqual({ status: 'DISCONNECTED', qrDataUrl: 'data:image/png;base64,fakedata' });
|
||
});
|
||
|
||
it('returns not_found status when account does not exist', async () => {
|
||
mockPrisma.account.findUnique.mockResolvedValue(null);
|
||
const result = await service.getQr('nonexistent');
|
||
expect(result).toEqual({ status: 'not_found', qrDataUrl: null });
|
||
});
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Run tests to verify they fail**
|
||
|
||
```bash
|
||
cd apps/api && pnpm test -- --testPathPattern=accounts.service.spec.ts --no-coverage
|
||
```
|
||
|
||
Expected: FAIL — `accounts.service.ts` does not exist yet.
|
||
|
||
- [ ] **Step 4: Implement accounts.service.ts**
|
||
|
||
Create `apps/api/src/modules/accounts/accounts.service.ts`:
|
||
|
||
```typescript
|
||
import { Injectable } from '@nestjs/common';
|
||
import { PrismaService } from '../../prisma/prisma.service';
|
||
import * as QRCode from 'qrcode';
|
||
|
||
export interface AccountSummary {
|
||
id: string;
|
||
platform: string;
|
||
jid: string;
|
||
displayName: string | null;
|
||
status: string;
|
||
}
|
||
|
||
export interface AccountQr {
|
||
status: string;
|
||
qrDataUrl: string | null;
|
||
}
|
||
|
||
@Injectable()
|
||
export class AccountsService {
|
||
constructor(private readonly prisma: PrismaService) {}
|
||
|
||
list(): Promise<AccountSummary[]> {
|
||
return this.prisma.account.findMany({
|
||
orderBy: { createdAt: 'asc' },
|
||
select: { id: true, platform: true, jid: true, displayName: true, status: true },
|
||
});
|
||
}
|
||
|
||
async getQr(id: string): Promise<AccountQr> {
|
||
const account = await this.prisma.account.findUnique({
|
||
where: { id },
|
||
select: { status: true, qrCode: true },
|
||
});
|
||
if (!account) return { status: 'not_found', qrDataUrl: null };
|
||
if (!account.qrCode) return { status: account.status, qrDataUrl: null };
|
||
const qrDataUrl = await QRCode.toDataURL(account.qrCode);
|
||
return { status: account.status, qrDataUrl };
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run service tests to verify they pass**
|
||
|
||
```bash
|
||
cd apps/api && pnpm test -- --testPathPattern=accounts.service.spec.ts --no-coverage
|
||
```
|
||
|
||
Expected: PASS — 4 tests pass.
|
||
|
||
- [ ] **Step 6: Write the failing controller tests**
|
||
|
||
Create `apps/api/src/modules/accounts/accounts.controller.spec.ts`:
|
||
|
||
```typescript
|
||
import { Test, TestingModule } from '@nestjs/testing';
|
||
import { AccountsController } from './accounts.controller';
|
||
import { AccountsService } from './accounts.service';
|
||
|
||
const mockAccounts = [
|
||
{ id: 'acc_1', platform: 'whatsapp', jid: '111@s.whatsapp.net', displayName: 'Test', status: 'ACTIVE' },
|
||
];
|
||
const mockService = {
|
||
list: jest.fn().mockResolvedValue(mockAccounts),
|
||
getQr: jest.fn().mockResolvedValue({ status: 'DISCONNECTED', qrDataUrl: 'data:image/png;base64,fake' }),
|
||
};
|
||
|
||
describe('AccountsController', () => {
|
||
let controller: AccountsController;
|
||
|
||
beforeEach(async () => {
|
||
jest.clearAllMocks();
|
||
const module: TestingModule = await Test.createTestingModule({
|
||
controllers: [AccountsController],
|
||
providers: [{ provide: AccountsService, useValue: mockService }],
|
||
}).compile();
|
||
controller = module.get<AccountsController>(AccountsController);
|
||
});
|
||
|
||
it('list() returns accounts from service', async () => {
|
||
const result = await controller.list();
|
||
expect(result).toEqual(mockAccounts);
|
||
expect(mockService.list).toHaveBeenCalled();
|
||
});
|
||
|
||
it('getQr() calls service with the account id', async () => {
|
||
const result = await controller.getQr('acc_1');
|
||
expect(mockService.getQr).toHaveBeenCalledWith('acc_1');
|
||
expect(result.qrDataUrl).toBe('data:image/png;base64,fake');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 7: Run controller tests to verify they fail**
|
||
|
||
```bash
|
||
cd apps/api && pnpm test -- --testPathPattern=accounts.controller.spec.ts --no-coverage
|
||
```
|
||
|
||
Expected: FAIL — `accounts.controller.ts` does not exist yet.
|
||
|
||
- [ ] **Step 8: Implement accounts.controller.ts and accounts.module.ts**
|
||
|
||
Create `apps/api/src/modules/accounts/accounts.controller.ts`:
|
||
|
||
```typescript
|
||
import { Controller, Get, Param } from '@nestjs/common';
|
||
import { AccountsService } from './accounts.service';
|
||
|
||
@Controller('accounts')
|
||
export class AccountsController {
|
||
constructor(private readonly service: AccountsService) {}
|
||
|
||
@Get()
|
||
list() {
|
||
return this.service.list();
|
||
}
|
||
|
||
@Get(':id/qr')
|
||
getQr(@Param('id') id: string) {
|
||
return this.service.getQr(id);
|
||
}
|
||
}
|
||
```
|
||
|
||
Create `apps/api/src/modules/accounts/accounts.module.ts`:
|
||
|
||
```typescript
|
||
import { Module } from '@nestjs/common';
|
||
import { AccountsController } from './accounts.controller';
|
||
import { AccountsService } from './accounts.service';
|
||
|
||
@Module({
|
||
controllers: [AccountsController],
|
||
providers: [AccountsService],
|
||
})
|
||
export class AccountsModule {}
|
||
```
|
||
|
||
- [ ] **Step 9: Wire AccountsModule into app.module.ts**
|
||
|
||
Open `apps/api/src/app.module.ts`. Add the import and module:
|
||
|
||
```typescript
|
||
import { Module } from '@nestjs/common';
|
||
import { ConfigModule } from '@nestjs/config';
|
||
import { PrismaModule } from './prisma/prisma.module';
|
||
import { HealthModule } from './modules/health/health.module';
|
||
import { SearchModule } from './modules/search/search.module';
|
||
import { GroupsModule } from './modules/groups/groups.module';
|
||
import { RoutesModule } from './modules/routes/routes.module';
|
||
import { AccountsModule } from './modules/accounts/accounts.module';
|
||
|
||
@Module({
|
||
imports: [
|
||
ConfigModule.forRoot({ isGlobal: true }),
|
||
PrismaModule,
|
||
HealthModule,
|
||
SearchModule,
|
||
GroupsModule,
|
||
RoutesModule,
|
||
AccountsModule,
|
||
],
|
||
})
|
||
export class AppModule {}
|
||
```
|
||
|
||
- [ ] **Step 10: Run all API tests**
|
||
|
||
```bash
|
||
cd apps/api && pnpm test --no-coverage
|
||
```
|
||
|
||
Expected: PASS — all tests pass including the new accounts tests.
|
||
|
||
- [ ] **Step 11: Commit**
|
||
|
||
```bash
|
||
git add apps/api/src/modules/accounts/ apps/api/src/app.module.ts apps/api/package.json pnpm-lock.yaml
|
||
git commit -m "feat: add AccountsModule with list and QR endpoints"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Web Route Handlers — Proxy Accounts Endpoints
|
||
|
||
**Files:**
|
||
- Create: `apps/web/app/api/accounts/route.ts`
|
||
- Create: `apps/web/app/api/accounts/[id]/qr/route.ts`
|
||
|
||
- [ ] **Step 1: Create the accounts list proxy**
|
||
|
||
Create `apps/web/app/api/accounts/route.ts`:
|
||
|
||
```typescript
|
||
const API_URL = process.env.API_URL ?? 'http://localhost:3001';
|
||
|
||
export async function GET() {
|
||
const res = await fetch(`${API_URL}/accounts`, { cache: 'no-store' });
|
||
return Response.json(await res.json(), { status: res.status });
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create the QR proxy**
|
||
|
||
Create `apps/web/app/api/accounts/[id]/qr/route.ts`:
|
||
|
||
```typescript
|
||
const API_URL = process.env.API_URL ?? 'http://localhost:3001';
|
||
|
||
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||
const { id } = await params;
|
||
const res = await fetch(`${API_URL}/accounts/${id}/qr`, { cache: 'no-store' });
|
||
return Response.json(await res.json(), { status: res.status });
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add apps/web/app/api/accounts/
|
||
git commit -m "feat: add Next.js proxy routes for accounts list and QR endpoints"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Web Accounts UI — AccountCard + Page + Nav
|
||
|
||
**Files:**
|
||
- Create: `apps/web/app/accounts/AccountCard.tsx`
|
||
- Create: `apps/web/app/accounts/AccountCard.test.tsx`
|
||
- Create: `apps/web/app/accounts/page.tsx`
|
||
- Modify: `apps/web/app/layout.tsx`
|
||
|
||
- [ ] **Step 1: Write the failing AccountCard tests**
|
||
|
||
Create `apps/web/app/accounts/AccountCard.test.tsx`:
|
||
|
||
```typescript
|
||
import { render, screen, waitFor, act } from '@testing-library/react';
|
||
import { AccountCard } from './AccountCard';
|
||
|
||
const activeAccount = {
|
||
id: 'acc_1',
|
||
jid: '111@s.whatsapp.net',
|
||
displayName: 'My Account',
|
||
status: 'ACTIVE',
|
||
platform: 'whatsapp',
|
||
};
|
||
|
||
const disconnectedAccount = {
|
||
id: 'acc_2',
|
||
jid: '222@s.whatsapp.net',
|
||
displayName: null,
|
||
status: 'DISCONNECTED',
|
||
platform: 'whatsapp',
|
||
};
|
||
|
||
let fetchSpy: jest.SpyInstance;
|
||
|
||
beforeEach(() => {
|
||
fetchSpy = jest.spyOn(global, 'fetch');
|
||
});
|
||
|
||
afterEach(() => {
|
||
jest.restoreAllMocks();
|
||
});
|
||
|
||
describe('AccountCard', () => {
|
||
it('shows displayName and Connected badge when ACTIVE', () => {
|
||
render(<AccountCard account={activeAccount} />);
|
||
expect(screen.getByText('My Account')).toBeInTheDocument();
|
||
expect(screen.getByText('Connected')).toBeInTheDocument();
|
||
});
|
||
|
||
it('falls back to jid when displayName is null', () => {
|
||
render(<AccountCard account={disconnectedAccount} />);
|
||
expect(screen.getByText('222@s.whatsapp.net')).toBeInTheDocument();
|
||
});
|
||
|
||
it('shows Awaiting scan badge when DISCONNECTED', () => {
|
||
fetchSpy.mockResolvedValue(
|
||
new Response(JSON.stringify({ status: 'DISCONNECTED', qrDataUrl: null }), {
|
||
status: 200,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
}),
|
||
);
|
||
render(<AccountCard account={disconnectedAccount} />);
|
||
expect(screen.getByText('Awaiting scan')).toBeInTheDocument();
|
||
});
|
||
|
||
it('does not fetch QR when account is ACTIVE', () => {
|
||
render(<AccountCard account={activeAccount} />);
|
||
expect(fetchSpy).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('fetches QR from /api/accounts/:id/qr when DISCONNECTED', async () => {
|
||
fetchSpy.mockResolvedValue(
|
||
new Response(JSON.stringify({ status: 'DISCONNECTED', qrDataUrl: null }), {
|
||
status: 200,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
}),
|
||
);
|
||
render(<AccountCard account={disconnectedAccount} />);
|
||
await waitFor(() =>
|
||
expect(fetchSpy).toHaveBeenCalledWith('/api/accounts/acc_2/qr'),
|
||
);
|
||
});
|
||
|
||
it('shows QR image when qrDataUrl is returned', async () => {
|
||
fetchSpy.mockResolvedValue(
|
||
new Response(
|
||
JSON.stringify({ status: 'DISCONNECTED', qrDataUrl: 'data:image/png;base64,abc123' }),
|
||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||
),
|
||
);
|
||
render(<AccountCard account={disconnectedAccount} />);
|
||
await waitFor(() => {
|
||
expect(screen.getByRole('img', { name: /qr code/i })).toBeInTheDocument();
|
||
});
|
||
expect(screen.getByRole('img', { name: /qr code/i })).toHaveAttribute(
|
||
'src',
|
||
'data:image/png;base64,abc123',
|
||
);
|
||
});
|
||
|
||
it('shows waiting message when DISCONNECTED but no QR yet', async () => {
|
||
fetchSpy.mockResolvedValue(
|
||
new Response(JSON.stringify({ status: 'DISCONNECTED', qrDataUrl: null }), {
|
||
status: 200,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
}),
|
||
);
|
||
render(<AccountCard account={disconnectedAccount} />);
|
||
await waitFor(() => {
|
||
expect(screen.getByText(/waiting for qr/i)).toBeInTheDocument();
|
||
});
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
```bash
|
||
cd apps/web && pnpm test -- --testPathPattern=AccountCard.test.tsx --no-coverage
|
||
```
|
||
|
||
Expected: FAIL — `AccountCard.tsx` does not exist yet.
|
||
|
||
- [ ] **Step 3: Implement AccountCard.tsx**
|
||
|
||
Create `apps/web/app/accounts/AccountCard.tsx`:
|
||
|
||
```tsx
|
||
'use client';
|
||
import { useEffect, useState } from 'react';
|
||
|
||
interface Account {
|
||
id: string;
|
||
jid: string;
|
||
displayName: string | null;
|
||
status: string;
|
||
platform: string;
|
||
}
|
||
|
||
export function AccountCard({ account }: { account: Account }) {
|
||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||
const isDisconnected = account.status === 'DISCONNECTED';
|
||
|
||
useEffect(() => {
|
||
if (!isDisconnected) {
|
||
setQrDataUrl(null);
|
||
return;
|
||
}
|
||
|
||
async function fetchQr() {
|
||
const res = await fetch(`/api/accounts/${account.id}/qr`);
|
||
if (!res.ok) return;
|
||
const data = await res.json();
|
||
setQrDataUrl(data.qrDataUrl ?? null);
|
||
}
|
||
|
||
fetchQr();
|
||
const interval = setInterval(fetchQr, 5000);
|
||
return () => clearInterval(interval);
|
||
}, [account.id, isDisconnected]);
|
||
|
||
return (
|
||
<div className="border rounded-lg p-4 bg-white">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<div>
|
||
<p className="font-medium">{account.displayName ?? account.jid}</p>
|
||
<p className="text-xs text-gray-500">{account.jid}</p>
|
||
</div>
|
||
<span
|
||
className={`text-xs px-2 py-1 rounded-full font-medium ${
|
||
account.status === 'ACTIVE'
|
||
? 'bg-green-100 text-green-700'
|
||
: 'bg-yellow-100 text-yellow-700'
|
||
}`}
|
||
>
|
||
{account.status === 'ACTIVE' ? 'Connected' : 'Awaiting scan'}
|
||
</span>
|
||
</div>
|
||
|
||
{isDisconnected && qrDataUrl && (
|
||
<div className="mt-3">
|
||
<p className="text-sm text-gray-600 mb-2">
|
||
Open WhatsApp → Linked Devices → Link a Device → scan below
|
||
</p>
|
||
<img src={qrDataUrl} alt="WhatsApp QR Code" className="w-48 h-48" />
|
||
</div>
|
||
)}
|
||
|
||
{isDisconnected && !qrDataUrl && (
|
||
<p className="text-sm text-gray-500 mt-2">Waiting for QR code from worker...</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run AccountCard tests to verify they pass**
|
||
|
||
```bash
|
||
cd apps/web && pnpm test -- --testPathPattern=AccountCard.test.tsx --no-coverage
|
||
```
|
||
|
||
Expected: PASS — all 7 tests pass.
|
||
|
||
- [ ] **Step 5: Implement accounts/page.tsx**
|
||
|
||
Create `apps/web/app/accounts/page.tsx`:
|
||
|
||
```tsx
|
||
import { AccountCard } from './AccountCard';
|
||
|
||
interface Account {
|
||
id: string;
|
||
jid: string;
|
||
displayName: string | null;
|
||
status: string;
|
||
platform: string;
|
||
}
|
||
|
||
export default async function AccountsPage() {
|
||
const apiUrl = process.env.API_URL ?? 'http://localhost:3001';
|
||
let accounts: Account[] = [];
|
||
try {
|
||
const res = await fetch(`${apiUrl}/accounts`, { cache: 'no-store' });
|
||
if (res.ok) accounts = await res.json();
|
||
} catch {}
|
||
|
||
return (
|
||
<div className="max-w-2xl">
|
||
<h1 className="text-xl font-semibold mb-6">Accounts</h1>
|
||
{accounts.length === 0 ? (
|
||
<p className="text-gray-500">No accounts found.</p>
|
||
) : (
|
||
<div className="flex flex-col gap-3">
|
||
{accounts.map((a) => (
|
||
<AccountCard key={a.id} account={a} />
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Add Accounts link to layout.tsx**
|
||
|
||
Open `apps/web/app/layout.tsx`. Find the `<nav>` block and add the Accounts link after the Groups & Routes link:
|
||
|
||
```tsx
|
||
import type { Metadata } from 'next';
|
||
import Link from 'next/link';
|
||
import './globals.css';
|
||
|
||
export const metadata: Metadata = {
|
||
title: 'Insignia TOWER',
|
||
description: 'Community Knowledge Infrastructure Platform',
|
||
};
|
||
|
||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||
return (
|
||
<html lang="en">
|
||
<body className="flex min-h-screen bg-gray-50 text-gray-900 antialiased">
|
||
<nav className="w-52 shrink-0 bg-white border-r border-gray-200 p-4 flex flex-col gap-1">
|
||
<span className="font-bold text-base mb-4">TOWER</span>
|
||
<Link href="/search" className="rounded px-3 py-2 text-sm hover:bg-gray-100">
|
||
Search
|
||
</Link>
|
||
<Link href="/groups" className="rounded px-3 py-2 text-sm hover:bg-gray-100">
|
||
Groups & Routes
|
||
</Link>
|
||
<Link href="/accounts" className="rounded px-3 py-2 text-sm hover:bg-gray-100">
|
||
Accounts
|
||
</Link>
|
||
</nav>
|
||
<main className="flex-1 overflow-auto p-6">{children}</main>
|
||
</body>
|
||
</html>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Run all web tests**
|
||
|
||
```bash
|
||
cd apps/web && pnpm test --no-coverage
|
||
```
|
||
|
||
Expected: PASS — all web tests pass including AccountCard tests.
|
||
|
||
- [ ] **Step 8: Run the full test suite**
|
||
|
||
```bash
|
||
cd /path/to/tower && pnpm test --no-coverage
|
||
```
|
||
|
||
Expected: PASS — all test suites across worker, API, and web pass.
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add apps/web/app/accounts/ apps/web/app/layout.tsx
|
||
git commit -m "feat: add Accounts page with QR code display for WhatsApp re-authentication"
|
||
```
|
||
|
||
---
|
||
|
||
## How It Works End-to-End
|
||
|
||
1. **Normal operation:** Account has `status: ACTIVE`, `qrCode: null`. Dashboard shows green "Connected" badge.
|
||
|
||
2. **WhatsApp logout detected:** Worker's `session.ts` receives `DisconnectReason.loggedOut`, calls `onStatus('logged_out')`, deletes session files, recreates the directory, restarts Baileys after 1 second. `main.ts` updates `Account.status = DISCONNECTED` in DB.
|
||
|
||
3. **New QR generated:** Baileys fires `qr` event with a fresh QR string. Worker calls `onQr(qrString)`. `main.ts` writes `qrString` to `Account.qrCode` in DB.
|
||
|
||
4. **Admin opens dashboard:** Navigates to `http://localhost:3000/accounts`. Server component fetches `GET /accounts` from API. AccountCard renders with "Awaiting scan" badge.
|
||
|
||
5. **QR polling:** AccountCard's `useEffect` calls `GET /api/accounts/:id/qr` immediately and every 5 seconds. API's `AccountsService.getQr()` reads `Account.qrCode` from DB, converts to PNG data URL via `qrcode`, returns it. AccountCard shows `<img src="data:image/png;base64,...">`.
|
||
|
||
6. **Admin scans:** Scans QR with WhatsApp (Linked Devices → Link a Device). Baileys fires `connection: 'open'`. Worker calls `onStatus('connected', jid)`. `main.ts` clears `Account.qrCode = null`, sets `Account.status = ACTIVE`, and updates `Account.jid` with the real WhatsApp JID. Next poll shows `qrDataUrl: null` and the card updates to show "Connected".
|
||
|
||
---
|
||
|
||
## Extension: Add Account from Dashboard
|
||
|
||
Tasks 7–9 add the ability to create new WhatsApp accounts from the dashboard UI and have the worker automatically pick them up without a restart.
|
||
|
||
---
|
||
|
||
## Task 7: Worker — JID Update on Connect + Poll for New Accounts
|
||
|
||
**Files:**
|
||
- Modify: `apps/worker/src/whatsapp/session.ts` — pass `sock.user?.id` (JID) to `onStatus('connected')`
|
||
- Modify: `apps/worker/src/whatsapp/session.test.ts` — update assertion for `onStatus('connected')` to include JID
|
||
- Modify: `apps/worker/src/whatsapp/session-pool.ts` — update `PoolStatusCallback` to accept optional `jid`
|
||
- Modify: `apps/worker/src/whatsapp/session-pool.test.ts` — add status JID threading test
|
||
- Modify: `apps/worker/src/main.ts` — update `onStatus` handler to update JID; extract `startAccount()` helper; add 30s polling; change initial load to include DISCONNECTED accounts
|
||
|
||
- [ ] **Step 1: Update `OnStatusCallback` in session.ts to carry optional JID**
|
||
|
||
Open `apps/worker/src/whatsapp/session.ts`. Change the type and the `connection === 'open'` handler:
|
||
|
||
```typescript
|
||
// Change this type:
|
||
export type OnStatusCallback = (status: 'connected' | 'disconnected' | 'logged_out', jid?: string) => Promise<void> | void;
|
||
```
|
||
|
||
In the `connection.update` handler, change the `connection === 'open'` branch from:
|
||
```typescript
|
||
} else if (connection === 'open') {
|
||
await onStatus?.('connected');
|
||
```
|
||
to:
|
||
```typescript
|
||
} else if (connection === 'open') {
|
||
await onStatus?.('connected', sock.user?.id ?? undefined);
|
||
```
|
||
|
||
- [ ] **Step 2: Update session.ts test — `onStatus('connected')` now includes jid**
|
||
|
||
Open `apps/worker/src/whatsapp/session.test.ts`. Find:
|
||
```typescript
|
||
it('calls onStatus with "connected" when connection opens', async () => {
|
||
const onStatus = jest.fn();
|
||
await createWhatsAppSession('acc_1', '/sessions/1', jest.fn(), jest.fn(), jest.fn(), undefined, undefined, onStatus);
|
||
await connectionUpdateHandler({ connection: 'open' });
|
||
expect(onStatus).toHaveBeenCalledWith('connected');
|
||
});
|
||
```
|
||
|
||
Change the assertion to:
|
||
```typescript
|
||
expect(onStatus).toHaveBeenCalledWith('connected', undefined);
|
||
```
|
||
|
||
(`mockSock.user` is not set in the mock so `sock.user?.id` is `undefined`.)
|
||
|
||
- [ ] **Step 3: Run session tests to verify they pass**
|
||
|
||
```bash
|
||
cd apps/worker && pnpm test -- --testPathPattern=session.test.ts --no-coverage
|
||
```
|
||
|
||
Expected: PASS — all 6 session tests pass.
|
||
|
||
- [ ] **Step 4: Update `PoolStatusCallback` in session-pool.ts**
|
||
|
||
Open `apps/worker/src/whatsapp/session-pool.ts`. Change:
|
||
```typescript
|
||
export type PoolStatusCallback = (status: string, accountId: string) => Promise<void> | void;
|
||
```
|
||
to:
|
||
```typescript
|
||
export type PoolStatusCallback = (status: string, accountId: string, jid?: string) => Promise<void> | void;
|
||
```
|
||
|
||
In the `add()` method, change the `onStatus` wrapper from:
|
||
```typescript
|
||
onStatus ? (status) => onStatus(status, accountId) : undefined,
|
||
```
|
||
to:
|
||
```typescript
|
||
onStatus ? (status, jid?) => onStatus(status, accountId, jid) : undefined,
|
||
```
|
||
|
||
- [ ] **Step 5: Add status JID threading test to session-pool.test.ts**
|
||
|
||
Open `apps/worker/src/whatsapp/session-pool.test.ts`. Add this test at the end of the `describe` block:
|
||
|
||
```typescript
|
||
it('add() injects accountId and jid into onStatus callback', async () => {
|
||
const onStatus = jest.fn();
|
||
const { createWhatsAppSession } = require('./session');
|
||
|
||
let capturedOnStatus: any;
|
||
(createWhatsAppSession as jest.Mock).mockImplementationOnce(
|
||
(_id: string, _path: string, _onMsg: any, _onReaction: any, _onGroups: any, _onReconnect: any, _onQr: any, statusCb: any) => {
|
||
capturedOnStatus = statusCb;
|
||
return Promise.resolve({ sendMessage: jest.fn(), logout: jest.fn(), end: jest.fn() });
|
||
},
|
||
);
|
||
|
||
await pool.add('acc_1', './sessions/1', jest.fn(), jest.fn(), jest.fn(), undefined, onStatus);
|
||
await capturedOnStatus('connected', '1234@s.whatsapp.net');
|
||
expect(onStatus).toHaveBeenCalledWith('connected', 'acc_1', '1234@s.whatsapp.net');
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 6: Run session-pool tests to verify all pass**
|
||
|
||
```bash
|
||
cd apps/worker && pnpm test -- --testPathPattern=session-pool.test.ts --no-coverage
|
||
```
|
||
|
||
Expected: PASS — all tests pass including the new status threading test.
|
||
|
||
- [ ] **Step 7: Rewrite main.ts with startAccount() helper + polling + JID update**
|
||
|
||
Replace the full contents of `apps/worker/src/main.ts`:
|
||
|
||
```typescript
|
||
import { PrismaClient } from '@prisma/client';
|
||
import { createLogger } from '@tower/logger';
|
||
import { validateEnv } from '@tower/config';
|
||
import { createMeiliClient, configureIndex } from '@tower/search';
|
||
import { createIngestQueue } from './queues/ingest.queue';
|
||
import { createIngestWorker } from './queues/ingest.processor';
|
||
import { createForwardQueue } from './queues/forward.queue';
|
||
import { createForwardWorker } from './queues/forward.processor';
|
||
import { createIndexQueue } from './queues/index.queue';
|
||
import { createIndexWorker } from './queues/index.processor';
|
||
import { WhatsAppSessionPool } from './whatsapp/session-pool';
|
||
import { detectTags, isFlagged } from './whatsapp/tag-detector';
|
||
import { syncGroups } from './whatsapp/group-sync';
|
||
import { handleStarReaction } from './core/approval';
|
||
|
||
const logger = createLogger('tower-worker');
|
||
|
||
async function bootstrap() {
|
||
const env = validateEnv();
|
||
const prisma = new PrismaClient();
|
||
await prisma.$connect();
|
||
|
||
const adminJids = env.TOWER_ADMIN_JIDS
|
||
? env.TOWER_ADMIN_JIDS.split(',').map((j) => j.trim()).filter(Boolean)
|
||
: [];
|
||
|
||
const meiliClient = createMeiliClient(env.MEILI_URL, env.MEILI_MASTER_KEY);
|
||
await configureIndex(meiliClient).catch((err) =>
|
||
logger.warn({ err }, 'Failed to configure Meilisearch index — search may be degraded'),
|
||
);
|
||
|
||
const ingestQueue = createIngestQueue(env.REDIS_URL);
|
||
const forwardQueue = createForwardQueue(env.REDIS_URL);
|
||
const indexQueue = createIndexQueue(env.REDIS_URL);
|
||
const pool = new WhatsAppSessionPool();
|
||
|
||
const ingestWorker = createIngestWorker(env.REDIS_URL, prisma);
|
||
const forwardWorker = createForwardWorker(env.REDIS_URL, pool);
|
||
const indexWorker = createIndexWorker(env.REDIS_URL, meiliClient);
|
||
|
||
ingestWorker.on('completed', (job) => logger.info({ jobId: job.id }, 'Ingest job completed'));
|
||
ingestWorker.on('failed', (job, err) => logger.error({ jobId: job?.id, err }, 'Ingest job failed'));
|
||
forwardWorker.on('completed', (job) => logger.info({ jobId: job.id }, 'Forward job completed'));
|
||
forwardWorker.on('failed', (job, err) => logger.error({ jobId: job?.id, err }, 'Forward job failed'));
|
||
indexWorker.on('completed', (job) => logger.info({ jobId: job.id }, 'Index job completed'));
|
||
indexWorker.on('failed', (job, err) => logger.error({ jobId: job?.id, err }, 'Index job failed'));
|
||
|
||
const groupMaps = new Map<string, Map<string, string>>();
|
||
|
||
async function startAccount(account: { id: string; sessionPath: string }) {
|
||
groupMaps.set(account.id, new Map());
|
||
try {
|
||
await pool.add(
|
||
account.id,
|
||
account.sessionPath,
|
||
async (msg, accountId) => {
|
||
const tags = detectTags(msg.content, msg.senderJid, adminJids);
|
||
if (!isFlagged(tags)) return;
|
||
|
||
const groupMap = groupMaps.get(accountId);
|
||
if (!groupMap) {
|
||
logger.error({ accountId }, 'No group map for account — message dropped');
|
||
return;
|
||
}
|
||
const sourceGroupId = groupMap.get(msg.sourceGroupJid);
|
||
if (!sourceGroupId) {
|
||
logger.warn({ jid: msg.sourceGroupJid, accountId }, 'Unknown group — skipping message');
|
||
return;
|
||
}
|
||
|
||
await ingestQueue.add(
|
||
'ingest',
|
||
{
|
||
platformMsgId: msg.platformMsgId,
|
||
platform: 'whatsapp',
|
||
accountId,
|
||
sourceGroupId,
|
||
senderJid: msg.senderJid,
|
||
senderName: msg.senderName,
|
||
content: msg.content,
|
||
tags,
|
||
},
|
||
{ attempts: 3, backoff: { type: 'exponential', delay: 1000 } },
|
||
);
|
||
|
||
logger.info({ platformMsgId: msg.platformMsgId, tags }, 'Message enqueued');
|
||
},
|
||
async (reaction) => {
|
||
const result = await handleStarReaction(reaction, adminJids, prisma);
|
||
if (!result) return;
|
||
|
||
const { forwardJobs, indexDoc } = result;
|
||
|
||
await indexQueue.add('index', indexDoc, {
|
||
attempts: 3,
|
||
backoff: { type: 'exponential', delay: 1000 },
|
||
});
|
||
|
||
for (const job of forwardJobs) {
|
||
await forwardQueue.add('forward', job, {
|
||
attempts: 3,
|
||
backoff: { type: 'exponential', delay: 2000 },
|
||
});
|
||
}
|
||
|
||
logger.info(
|
||
{ messageId: indexDoc.messageId, forwardCount: forwardJobs.length },
|
||
'Message approved — indexed and forwarded',
|
||
);
|
||
},
|
||
async (groups, accountId) => {
|
||
logger.info({ count: Object.keys(groups).length, accountId }, 'Syncing groups');
|
||
const map = await syncGroups(groups, accountId, prisma);
|
||
groupMaps.set(accountId, map);
|
||
},
|
||
async (qr, accountId) => {
|
||
await prisma.account.update({
|
||
where: { id: accountId },
|
||
data: { qrCode: qr },
|
||
}).catch((err) => logger.error({ accountId, err }, 'Failed to store QR in DB'));
|
||
logger.info({ accountId }, 'QR code updated');
|
||
},
|
||
async (status, accountId, jid?) => {
|
||
if (status === 'connected') {
|
||
await prisma.account.update({
|
||
where: { id: accountId },
|
||
data: { qrCode: null, status: 'ACTIVE', ...(jid ? { jid } : {}) },
|
||
}).catch((err) => logger.error({ accountId, err }, 'Failed to update account status'));
|
||
logger.info({ accountId, jid }, 'Account connected — QR cleared');
|
||
} else if (status === 'logged_out') {
|
||
await prisma.account.update({
|
||
where: { id: accountId },
|
||
data: { status: 'DISCONNECTED' },
|
||
}).catch((err) => logger.error({ accountId, err }, 'Failed to update account status'));
|
||
logger.info({ accountId }, 'Account logged out — awaiting QR scan');
|
||
}
|
||
},
|
||
);
|
||
} catch (err) {
|
||
logger.error({ accountId: account.id, err }, 'Failed to start session — skipping account');
|
||
}
|
||
}
|
||
|
||
// Load ACTIVE and DISCONNECTED accounts at startup (DISCONNECTED ones need re-auth)
|
||
const accounts = await prisma.account.findMany({
|
||
where: { status: { in: ['ACTIVE', 'DISCONNECTED'] }, platform: 'whatsapp' },
|
||
select: { id: true, sessionPath: true },
|
||
});
|
||
|
||
if (accounts.length === 0) {
|
||
logger.warn('No WhatsApp accounts found — add one via the dashboard');
|
||
}
|
||
|
||
for (const account of accounts) {
|
||
await startAccount(account);
|
||
}
|
||
|
||
logger.info({ accountCount: accounts.length }, 'Tower worker ready');
|
||
|
||
// Poll every 30s for accounts added via the dashboard while worker is running
|
||
setInterval(async () => {
|
||
try {
|
||
const all = await prisma.account.findMany({
|
||
where: { status: { in: ['ACTIVE', 'DISCONNECTED'] }, platform: 'whatsapp' },
|
||
select: { id: true, sessionPath: true },
|
||
});
|
||
for (const account of all) {
|
||
if (!pool.get(account.id)) {
|
||
logger.info({ accountId: account.id }, 'New account detected — starting session');
|
||
await startAccount(account);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
logger.error({ err }, 'Error polling for new accounts');
|
||
}
|
||
}, 30_000);
|
||
|
||
const shutdown = async () => {
|
||
logger.info('Shutting down...');
|
||
await pool.closeAll();
|
||
await ingestWorker.close();
|
||
await forwardWorker.close();
|
||
await indexWorker.close();
|
||
await ingestQueue.close();
|
||
await forwardQueue.close();
|
||
await indexQueue.close();
|
||
await prisma.$disconnect();
|
||
process.exit(0);
|
||
};
|
||
|
||
process.on('SIGTERM', shutdown);
|
||
process.on('SIGINT', shutdown);
|
||
}
|
||
|
||
bootstrap().catch((err) => {
|
||
console.error('Worker failed to start', err);
|
||
process.exit(1);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 8: Run all worker tests**
|
||
|
||
```bash
|
||
cd apps/worker && pnpm test --no-coverage
|
||
```
|
||
|
||
Expected: PASS — all worker tests pass.
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add apps/worker/src/whatsapp/session.ts apps/worker/src/whatsapp/session.test.ts apps/worker/src/whatsapp/session-pool.ts apps/worker/src/whatsapp/session-pool.test.ts apps/worker/src/main.ts
|
||
git commit -m "feat: pass JID on connect; extract startAccount() helper; poll 30s for new accounts"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: API — POST /accounts Endpoint
|
||
|
||
**Files:**
|
||
- Modify: `apps/api/src/modules/accounts/accounts.service.ts` — add `create()` with ConfigService
|
||
- Modify: `apps/api/src/modules/accounts/accounts.service.spec.ts` — add create() tests
|
||
- Modify: `apps/api/src/modules/accounts/accounts.controller.ts` — add `POST /accounts`
|
||
- Modify: `apps/api/src/modules/accounts/accounts.controller.spec.ts` — add controller test
|
||
- Modify: `apps/api/src/modules/accounts/accounts.module.ts` — add ConfigModule import
|
||
|
||
- [ ] **Step 1: Write failing tests for create()**
|
||
|
||
Open `apps/api/src/modules/accounts/accounts.service.spec.ts`. Add `ConfigService` to the mock providers and a new `describe('create()')` block.
|
||
|
||
The full updated file:
|
||
|
||
```typescript
|
||
import { Test, TestingModule } from '@nestjs/testing';
|
||
import { ConfigService } from '@nestjs/config';
|
||
import { AccountsService } from './accounts.service';
|
||
import { PrismaService } from '../../prisma/prisma.service';
|
||
import * as QRCode from 'qrcode';
|
||
|
||
jest.mock('qrcode', () => ({
|
||
toDataURL: jest.fn().mockResolvedValue('data:image/png;base64,fakedata'),
|
||
}));
|
||
|
||
const mockAccounts = [
|
||
{ id: 'acc_1', platform: 'whatsapp', jid: '111@s.whatsapp.net', displayName: 'Test Account', status: 'ACTIVE' },
|
||
];
|
||
|
||
const mockCreatedAccount = {
|
||
id: 'acc_new',
|
||
platform: 'whatsapp',
|
||
jid: 'pending_uuid@placeholder',
|
||
displayName: 'My Number',
|
||
status: 'ACTIVE',
|
||
};
|
||
|
||
const mockPrisma = {
|
||
account: {
|
||
findMany: jest.fn().mockResolvedValue(mockAccounts),
|
||
findUnique: jest.fn(),
|
||
create: jest.fn().mockResolvedValue(mockCreatedAccount),
|
||
},
|
||
};
|
||
|
||
const mockConfig = {
|
||
get: jest.fn().mockImplementation((key: string, def: string) =>
|
||
key === 'WHATSAPP_SESSION_PATH' ? './sessions' : def,
|
||
),
|
||
};
|
||
|
||
describe('AccountsService', () => {
|
||
let service: AccountsService;
|
||
|
||
beforeEach(async () => {
|
||
jest.clearAllMocks();
|
||
const module: TestingModule = await Test.createTestingModule({
|
||
providers: [
|
||
AccountsService,
|
||
{ provide: PrismaService, useValue: mockPrisma },
|
||
{ provide: ConfigService, useValue: mockConfig },
|
||
],
|
||
}).compile();
|
||
service = module.get<AccountsService>(AccountsService);
|
||
});
|
||
|
||
describe('list()', () => {
|
||
it('returns accounts from Prisma without qrCode field', async () => {
|
||
const result = await service.list();
|
||
expect(result).toEqual(mockAccounts);
|
||
expect(mockPrisma.account.findMany).toHaveBeenCalledWith(
|
||
expect.objectContaining({ select: expect.not.objectContaining({ qrCode: true }) }),
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('getQr()', () => {
|
||
it('returns null qrDataUrl when account has no qrCode', async () => {
|
||
mockPrisma.account.findUnique.mockResolvedValue({ status: 'ACTIVE', qrCode: null });
|
||
const result = await service.getQr('acc_1');
|
||
expect(result).toEqual({ status: 'ACTIVE', qrDataUrl: null });
|
||
expect(QRCode.toDataURL).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('converts qrCode string to data URL when qrCode is present', async () => {
|
||
mockPrisma.account.findUnique.mockResolvedValue({ status: 'DISCONNECTED', qrCode: 'raw-qr-string' });
|
||
const result = await service.getQr('acc_1');
|
||
expect(QRCode.toDataURL).toHaveBeenCalledWith('raw-qr-string');
|
||
expect(result).toEqual({ status: 'DISCONNECTED', qrDataUrl: 'data:image/png;base64,fakedata' });
|
||
});
|
||
|
||
it('returns not_found status when account does not exist', async () => {
|
||
mockPrisma.account.findUnique.mockResolvedValue(null);
|
||
const result = await service.getQr('nonexistent');
|
||
expect(result).toEqual({ status: 'not_found', qrDataUrl: null });
|
||
});
|
||
});
|
||
|
||
describe('create()', () => {
|
||
it('creates account with platform whatsapp and status ACTIVE', async () => {
|
||
await service.create('My Number');
|
||
expect(mockPrisma.account.create).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
data: expect.objectContaining({
|
||
platform: 'whatsapp',
|
||
status: 'ACTIVE',
|
||
displayName: 'My Number',
|
||
}),
|
||
}),
|
||
);
|
||
});
|
||
|
||
it('generates a unique sessionPath under WHATSAPP_SESSION_PATH', async () => {
|
||
await service.create();
|
||
const call = mockPrisma.account.create.mock.calls[0][0];
|
||
expect(call.data.sessionPath).toMatch(/^\.\/sessions\/.+/);
|
||
});
|
||
|
||
it('generates a placeholder jid prefixed with pending_', async () => {
|
||
await service.create();
|
||
const call = mockPrisma.account.create.mock.calls[0][0];
|
||
expect(call.data.jid).toMatch(/^pending_/);
|
||
});
|
||
|
||
it('sets displayName to null when not provided', async () => {
|
||
await service.create();
|
||
const call = mockPrisma.account.create.mock.calls[0][0];
|
||
expect(call.data.displayName).toBeNull();
|
||
});
|
||
|
||
it('returns the created account summary', async () => {
|
||
const result = await service.create('My Number');
|
||
expect(result).toEqual(mockCreatedAccount);
|
||
});
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify create() tests fail**
|
||
|
||
```bash
|
||
cd apps/api && pnpm test -- --testPathPattern=accounts.service.spec.ts --no-coverage
|
||
```
|
||
|
||
Expected: FAIL — `create()` method does not exist yet.
|
||
|
||
- [ ] **Step 3: Update accounts.service.ts to add create()**
|
||
|
||
Replace `apps/api/src/modules/accounts/accounts.service.ts`:
|
||
|
||
```typescript
|
||
import { Injectable } from '@nestjs/common';
|
||
import { ConfigService } from '@nestjs/config';
|
||
import { randomUUID } from 'crypto';
|
||
import { PrismaService } from '../../prisma/prisma.service';
|
||
import * as QRCode from 'qrcode';
|
||
|
||
export interface AccountSummary {
|
||
id: string;
|
||
platform: string;
|
||
jid: string;
|
||
displayName: string | null;
|
||
status: string;
|
||
}
|
||
|
||
export interface AccountQr {
|
||
status: string;
|
||
qrDataUrl: string | null;
|
||
}
|
||
|
||
@Injectable()
|
||
export class AccountsService {
|
||
constructor(
|
||
private readonly prisma: PrismaService,
|
||
private readonly config: ConfigService,
|
||
) {}
|
||
|
||
list(): Promise<AccountSummary[]> {
|
||
return this.prisma.account.findMany({
|
||
orderBy: { createdAt: 'asc' },
|
||
select: { id: true, platform: true, jid: true, displayName: true, status: true },
|
||
});
|
||
}
|
||
|
||
async getQr(id: string): Promise<AccountQr> {
|
||
const account = await this.prisma.account.findUnique({
|
||
where: { id },
|
||
select: { status: true, qrCode: true },
|
||
});
|
||
if (!account) return { status: 'not_found', qrDataUrl: null };
|
||
if (!account.qrCode) return { status: account.status, qrDataUrl: null };
|
||
const qrDataUrl = await QRCode.toDataURL(account.qrCode);
|
||
return { status: account.status, qrDataUrl };
|
||
}
|
||
|
||
async create(displayName?: string): Promise<AccountSummary> {
|
||
const sessionBase = this.config.get<string>('WHATSAPP_SESSION_PATH', './sessions');
|
||
const uid = randomUUID();
|
||
return this.prisma.account.create({
|
||
data: {
|
||
platform: 'whatsapp',
|
||
jid: `pending_${uid}@placeholder`,
|
||
sessionPath: `${sessionBase}/${uid}`,
|
||
displayName: displayName ?? null,
|
||
status: 'ACTIVE',
|
||
},
|
||
select: { id: true, platform: true, jid: true, displayName: true, status: true },
|
||
});
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run service tests to verify all pass**
|
||
|
||
```bash
|
||
cd apps/api && pnpm test -- --testPathPattern=accounts.service.spec.ts --no-coverage
|
||
```
|
||
|
||
Expected: PASS — all 9 tests pass.
|
||
|
||
- [ ] **Step 5: Add POST handler to accounts.controller.ts and controller test**
|
||
|
||
Replace `apps/api/src/modules/accounts/accounts.controller.ts`:
|
||
|
||
```typescript
|
||
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
||
import { AccountsService } from './accounts.service';
|
||
|
||
@Controller('accounts')
|
||
export class AccountsController {
|
||
constructor(private readonly service: AccountsService) {}
|
||
|
||
@Get()
|
||
list() {
|
||
return this.service.list();
|
||
}
|
||
|
||
@Get(':id/qr')
|
||
getQr(@Param('id') id: string) {
|
||
return this.service.getQr(id);
|
||
}
|
||
|
||
@Post()
|
||
create(@Body() body: { displayName?: string }) {
|
||
return this.service.create(body.displayName);
|
||
}
|
||
}
|
||
```
|
||
|
||
Open `apps/api/src/modules/accounts/accounts.controller.spec.ts`. Add a test for `create()`:
|
||
|
||
```typescript
|
||
import { Test, TestingModule } from '@nestjs/testing';
|
||
import { AccountsController } from './accounts.controller';
|
||
import { AccountsService } from './accounts.service';
|
||
|
||
const mockAccounts = [
|
||
{ id: 'acc_1', platform: 'whatsapp', jid: '111@s.whatsapp.net', displayName: 'Test', status: 'ACTIVE' },
|
||
];
|
||
const mockCreated = { id: 'acc_new', platform: 'whatsapp', jid: 'pending_uuid@placeholder', displayName: 'New', status: 'ACTIVE' };
|
||
const mockService = {
|
||
list: jest.fn().mockResolvedValue(mockAccounts),
|
||
getQr: jest.fn().mockResolvedValue({ status: 'DISCONNECTED', qrDataUrl: 'data:image/png;base64,fake' }),
|
||
create: jest.fn().mockResolvedValue(mockCreated),
|
||
};
|
||
|
||
describe('AccountsController', () => {
|
||
let controller: AccountsController;
|
||
|
||
beforeEach(async () => {
|
||
jest.clearAllMocks();
|
||
const module: TestingModule = await Test.createTestingModule({
|
||
controllers: [AccountsController],
|
||
providers: [{ provide: AccountsService, useValue: mockService }],
|
||
}).compile();
|
||
controller = module.get<AccountsController>(AccountsController);
|
||
});
|
||
|
||
it('list() returns accounts from service', async () => {
|
||
const result = await controller.list();
|
||
expect(result).toEqual(mockAccounts);
|
||
expect(mockService.list).toHaveBeenCalled();
|
||
});
|
||
|
||
it('getQr() calls service with the account id', async () => {
|
||
const result = await controller.getQr('acc_1');
|
||
expect(mockService.getQr).toHaveBeenCalledWith('acc_1');
|
||
expect(result.qrDataUrl).toBe('data:image/png;base64,fake');
|
||
});
|
||
|
||
it('create() calls service with displayName from body', async () => {
|
||
const result = await controller.create({ displayName: 'New' });
|
||
expect(mockService.create).toHaveBeenCalledWith('New');
|
||
expect(result.id).toBe('acc_new');
|
||
});
|
||
|
||
it('create() calls service with undefined when displayName not provided', async () => {
|
||
await controller.create({});
|
||
expect(mockService.create).toHaveBeenCalledWith(undefined);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 6: Add ConfigModule to accounts.module.ts**
|
||
|
||
Replace `apps/api/src/modules/accounts/accounts.module.ts`:
|
||
|
||
```typescript
|
||
import { Module } from '@nestjs/common';
|
||
import { ConfigModule } from '@nestjs/config';
|
||
import { AccountsController } from './accounts.controller';
|
||
import { AccountsService } from './accounts.service';
|
||
|
||
@Module({
|
||
imports: [ConfigModule],
|
||
controllers: [AccountsController],
|
||
providers: [AccountsService],
|
||
})
|
||
export class AccountsModule {}
|
||
```
|
||
|
||
- [ ] **Step 7: Run all API tests**
|
||
|
||
```bash
|
||
cd apps/api && pnpm test --no-coverage
|
||
```
|
||
|
||
Expected: PASS — all tests pass.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add apps/api/src/modules/accounts/
|
||
git commit -m "feat: add POST /accounts to create new WhatsApp account from dashboard"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Web — AccountsList Client Component + Add Account Form
|
||
|
||
**Files:**
|
||
- Modify: `apps/web/app/api/accounts/route.ts` — add POST handler
|
||
- Create: `apps/web/app/accounts/AccountsList.tsx` — client component: state, add form, renders AccountCards
|
||
- Create: `apps/web/app/accounts/AccountsList.test.tsx` — unit tests
|
||
- Modify: `apps/web/app/accounts/page.tsx` — use AccountsList instead of rendering cards directly
|
||
|
||
> **Note:** `AccountCard.tsx` is already created in Task 6. `AccountsList` uses it.
|
||
|
||
- [ ] **Step 1: Add POST proxy to the accounts route handler**
|
||
|
||
Open `apps/web/app/api/accounts/route.ts` (created in Task 5). Replace its contents with:
|
||
|
||
```typescript
|
||
const API_URL = process.env.API_URL ?? 'http://localhost:3001';
|
||
|
||
export async function GET() {
|
||
const res = await fetch(`${API_URL}/accounts`, { cache: 'no-store' });
|
||
return Response.json(await res.json(), { status: res.status });
|
||
}
|
||
|
||
export async function POST(req: Request) {
|
||
const body = await req.json();
|
||
const res = await fetch(`${API_URL}/accounts`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
});
|
||
return Response.json(await res.json(), { status: res.status });
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Write failing tests for AccountsList**
|
||
|
||
Create `apps/web/app/accounts/AccountsList.test.tsx`:
|
||
|
||
```tsx
|
||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||
import { AccountsList } from './AccountsList';
|
||
|
||
const activeAccount = {
|
||
id: 'acc_1',
|
||
jid: '111@s.whatsapp.net',
|
||
displayName: 'My Account',
|
||
status: 'ACTIVE',
|
||
platform: 'whatsapp',
|
||
};
|
||
|
||
const newAccount = {
|
||
id: 'acc_new',
|
||
jid: 'pending_uuid@placeholder',
|
||
displayName: 'New',
|
||
status: 'ACTIVE',
|
||
platform: 'whatsapp',
|
||
};
|
||
|
||
let fetchSpy: jest.SpyInstance;
|
||
|
||
beforeEach(() => {
|
||
fetchSpy = jest.spyOn(global, 'fetch');
|
||
});
|
||
|
||
afterEach(() => {
|
||
jest.restoreAllMocks();
|
||
});
|
||
|
||
describe('AccountsList', () => {
|
||
it('renders initial accounts', () => {
|
||
render(<AccountsList initialAccounts={[activeAccount]} />);
|
||
expect(screen.getByText('My Account')).toBeInTheDocument();
|
||
});
|
||
|
||
it('shows empty state message when no accounts', () => {
|
||
render(<AccountsList initialAccounts={[]} />);
|
||
expect(screen.getByText(/no accounts yet/i)).toBeInTheDocument();
|
||
});
|
||
|
||
it('renders Add Account button', () => {
|
||
render(<AccountsList initialAccounts={[]} />);
|
||
expect(screen.getByRole('button', { name: /add account/i })).toBeInTheDocument();
|
||
});
|
||
|
||
it('calls POST /api/accounts with displayName when Add Account clicked', async () => {
|
||
fetchSpy.mockResolvedValueOnce(
|
||
new Response(JSON.stringify(newAccount), {
|
||
status: 201,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
}),
|
||
);
|
||
render(<AccountsList initialAccounts={[]} />);
|
||
fireEvent.change(screen.getByPlaceholderText(/account name/i), { target: { value: 'New' } });
|
||
fireEvent.click(screen.getByRole('button', { name: /add account/i }));
|
||
await waitFor(() =>
|
||
expect(fetchSpy).toHaveBeenCalledWith(
|
||
'/api/accounts',
|
||
expect.objectContaining({ method: 'POST' }),
|
||
),
|
||
);
|
||
});
|
||
|
||
it('appends new account card to list after successful creation', async () => {
|
||
fetchSpy.mockResolvedValueOnce(
|
||
new Response(JSON.stringify(newAccount), {
|
||
status: 201,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
}),
|
||
);
|
||
render(<AccountsList initialAccounts={[activeAccount]} />);
|
||
fireEvent.change(screen.getByPlaceholderText(/account name/i), { target: { value: 'New' } });
|
||
fireEvent.click(screen.getByRole('button', { name: /add account/i }));
|
||
await waitFor(() => expect(screen.getByText('New')).toBeInTheDocument());
|
||
expect(screen.getByText('My Account')).toBeInTheDocument();
|
||
});
|
||
|
||
it('clears the input after successful add', async () => {
|
||
fetchSpy.mockResolvedValueOnce(
|
||
new Response(JSON.stringify(newAccount), {
|
||
status: 201,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
}),
|
||
);
|
||
render(<AccountsList initialAccounts={[]} />);
|
||
const input = screen.getByPlaceholderText(/account name/i);
|
||
fireEvent.change(input, { target: { value: 'New' } });
|
||
fireEvent.click(screen.getByRole('button', { name: /add account/i }));
|
||
await waitFor(() => expect(input).toHaveValue(''));
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Run tests to verify they fail**
|
||
|
||
```bash
|
||
cd apps/web && pnpm test -- --testPathPattern=AccountsList.test.tsx --no-coverage
|
||
```
|
||
|
||
Expected: FAIL — `AccountsList.tsx` does not exist yet.
|
||
|
||
- [ ] **Step 4: Implement AccountsList.tsx**
|
||
|
||
Create `apps/web/app/accounts/AccountsList.tsx`:
|
||
|
||
```tsx
|
||
'use client';
|
||
import { useState } from 'react';
|
||
import { AccountCard } from './AccountCard';
|
||
|
||
interface Account {
|
||
id: string;
|
||
jid: string;
|
||
displayName: string | null;
|
||
status: string;
|
||
platform: string;
|
||
}
|
||
|
||
export function AccountsList({ initialAccounts }: { initialAccounts: Account[] }) {
|
||
const [accounts, setAccounts] = useState<Account[]>(initialAccounts);
|
||
const [displayName, setDisplayName] = useState('');
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
async function handleAdd() {
|
||
setBusy(true);
|
||
try {
|
||
const res = await fetch('/api/accounts', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ displayName: displayName.trim() || undefined }),
|
||
});
|
||
if (!res.ok) return;
|
||
const account: Account = await res.json();
|
||
setAccounts((prev) => [...prev, account]);
|
||
setDisplayName('');
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
<div className="border rounded-lg p-4 bg-white flex gap-2 items-center">
|
||
<input
|
||
type="text"
|
||
placeholder="Account name (optional)"
|
||
value={displayName}
|
||
onChange={(e) => setDisplayName(e.target.value)}
|
||
onKeyDown={(e) => e.key === 'Enter' && !busy && handleAdd()}
|
||
className="flex-1 border rounded px-3 py-2 text-sm"
|
||
/>
|
||
<button
|
||
onClick={handleAdd}
|
||
disabled={busy}
|
||
className="bg-blue-600 text-white text-sm px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
|
||
>
|
||
{busy ? 'Adding...' : 'Add Account'}
|
||
</button>
|
||
</div>
|
||
|
||
{accounts.length === 0 ? (
|
||
<p className="text-gray-500 text-sm">No accounts yet. Add one above to get started.</p>
|
||
) : (
|
||
accounts.map((a) => <AccountCard key={a.id} account={a} />)
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run AccountsList tests to verify they pass**
|
||
|
||
```bash
|
||
cd apps/web && pnpm test -- --testPathPattern=AccountsList.test.tsx --no-coverage
|
||
```
|
||
|
||
Expected: PASS — all 6 tests pass.
|
||
|
||
- [ ] **Step 6: Update accounts/page.tsx to use AccountsList**
|
||
|
||
Replace `apps/web/app/accounts/page.tsx`:
|
||
|
||
```tsx
|
||
import { AccountsList } from './AccountsList';
|
||
|
||
interface Account {
|
||
id: string;
|
||
jid: string;
|
||
displayName: string | null;
|
||
status: string;
|
||
platform: string;
|
||
}
|
||
|
||
export default async function AccountsPage() {
|
||
const apiUrl = process.env.API_URL ?? 'http://localhost:3001';
|
||
let accounts: Account[] = [];
|
||
try {
|
||
const res = await fetch(`${apiUrl}/accounts`, { cache: 'no-store' });
|
||
if (res.ok) accounts = await res.json();
|
||
} catch {}
|
||
|
||
return (
|
||
<div className="max-w-2xl">
|
||
<h1 className="text-xl font-semibold mb-6">Accounts</h1>
|
||
<AccountsList initialAccounts={accounts} />
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Run all web tests**
|
||
|
||
```bash
|
||
cd apps/web && pnpm test --no-coverage
|
||
```
|
||
|
||
Expected: PASS — all web tests pass.
|
||
|
||
- [ ] **Step 8: Run the full test suite**
|
||
|
||
```bash
|
||
pnpm test --no-coverage
|
||
```
|
||
|
||
Expected: PASS — all test suites across worker, API, and web pass.
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add apps/web/app/api/accounts/route.ts apps/web/app/accounts/
|
||
git commit -m "feat: add AccountsList with add-account form; POST /api/accounts proxy"
|
||
```
|
||
|
||
---
|
||
|
||
## Full End-to-End Flow (with extension)
|
||
|
||
**Adding a new account:**
|
||
1. Admin opens `http://localhost:3000/accounts`, types a name (optional), clicks "Add Account"
|
||
2. `POST /api/accounts` → `POST /accounts` → DB record created with placeholder JID and a fresh session path, `status: ACTIVE`
|
||
3. Worker's 30s polling detects the new account (not yet in pool), calls `startAccount()`
|
||
4. Baileys starts with empty session dir → fires `qr` event → QR written to DB
|
||
5. Within 30s the account card appears and shows the QR (polls every 5s)
|
||
6. Admin scans → Baileys fires `connection: 'open'` → real JID written to DB, `qrCode` cleared, `status: ACTIVE`
|
||
7. Card shows green "Connected" badge
|