Files
tower/docs/superpowers/plans/2026-05-29-whatsapp-qr-dashboard.md
2026-06-09 02:02:40 +05:30

2396 lines
83 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 &amp; 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 79 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