# 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; 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; export type OnReactionCallback = (reaction: NormalizedReaction) => Promise | void; export type OnGroupsCallback = (groups: Record) => Promise | void; export type OnQrCallback = (qr: string) => Promise | void; export type OnStatusCallback = (status: 'connected' | 'disconnected' | 'logged_out') => Promise | void; export async function createWhatsAppSession( accountId: string, sessionPath: string, onMessage: OnMessageCallback, onReaction: OnReactionCallback, onGroups: OnGroupsCallback, onReconnect?: (newSocket: WASocket) => void, onQr?: OnQrCallback, onStatus?: OnStatusCallback, ): Promise { 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; export type PoolReactionCallback = (reaction: NormalizedReaction, accountId: string) => Promise | void; export type PoolGroupsCallback = (groups: any, accountId: string) => Promise | void; export type PoolQrCallback = (qr: string, accountId: string) => Promise | void; export type PoolStatusCallback = (status: string, accountId: string) => Promise | void; export class WhatsAppSessionPool { private sessions = new Map(); async add( accountId: string, sessionPath: string, onMessage: PoolMessageCallback, onReaction: PoolReactionCallback, onGroups: PoolGroupsCallback, onQr?: PoolQrCallback, onStatus?: PoolStatusCallback, ): Promise { 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 { return this.sessions; } async sendMessage(accountId: string, groupJid: string, text: string): Promise { 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 { const sock = this.sessions.get(accountId); if (sock) { await sock.logout().catch(() => {}); this.sessions.delete(accountId); logger.info({ accountId }, 'Session removed'); } } async closeAll(): Promise { 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>(); 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); }); 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 { return this.prisma.account.findMany({ orderBy: { createdAt: 'asc' }, select: { id: true, platform: true, jid: true, displayName: true, status: true }, }); } async getQr(id: string): Promise { 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); }); 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(); expect(screen.getByText('My Account')).toBeInTheDocument(); expect(screen.getByText('Connected')).toBeInTheDocument(); }); it('falls back to jid when displayName is null', () => { render(); 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(); expect(screen.getByText('Awaiting scan')).toBeInTheDocument(); }); it('does not fetch QR when account is ACTIVE', () => { render(); 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(); 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(); 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(); 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(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 (

{account.displayName ?? account.jid}

{account.jid}

{account.status === 'ACTIVE' ? 'Connected' : 'Awaiting scan'}
{isDisconnected && qrDataUrl && (

Open WhatsApp → Linked Devices → Link a Device → scan below

WhatsApp QR Code
)} {isDisconnected && !qrDataUrl && (

Waiting for QR code from worker...

)}
); } ``` - [ ] **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 (

Accounts

{accounts.length === 0 ? (

No accounts found.

) : (
{accounts.map((a) => ( ))}
)}
); } ``` - [ ] **Step 6: Add Accounts link to layout.tsx** Open `apps/web/app/layout.tsx`. Find the `