83 KiB
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— addqrCode String?to Account modelapps/worker/src/whatsapp/session.ts— addonQr/onStatuscallbacks (with JID); onloggedOutclear session files and restartapps/worker/src/whatsapp/session-pool.ts— threadonQr/onStatus(with JID) callbacks throughadd()apps/worker/src/main.ts— Prisma-writing handlers + extractstartAccount()helper + 30s polling loop; initial load includes DISCONNECTED accountsapps/api/src/modules/accounts/accounts.service.ts— list + QR + create accountapps/api/src/modules/accounts/accounts.controller.ts—GET /accounts,GET /accounts/:id/qr,POST /accountsapps/api/src/app.module.ts— addAccountsModuleapps/web/app/api/accounts/route.ts— Next.js proxy →GET+POST /accountsapps/web/app/layout.tsx— add Accounts nav link
Created:
apps/api/src/modules/accounts/accounts.module.ts— NestJS moduleapps/api/src/modules/accounts/accounts.service.spec.ts— unit testsapps/api/src/modules/accounts/accounts.controller.spec.ts— unit testsapps/web/app/api/accounts/[id]/qr/route.ts— Next.js proxy →GET /accounts/:id/qrapps/web/app/accounts/AccountCard.tsx— client component with status badge + QR pollingapps/web/app/accounts/AccountCard.test.tsx— unit testsapps/web/app/accounts/AccountsList.tsx— client component: manages accounts state + add account formapps/web/app/accounts/AccountsList.test.tsx— unit testsapps/web/app/accounts/page.tsx— server component
Test files updated:
apps/worker/src/whatsapp/session.test.ts— updateonStatus('connected')assertion to include jidapps/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
qrCodefield to Account model in schema.prismaOpen
apps/api/prisma/schema.prisma. TheAccountmodel currently ends with: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:
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
cd apps/api && pnpm exec prisma migrate dev --name add_account_qr_codeExpected 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 devautomatically regenerates the Prisma client after migration. No separateprisma generateneeded. -
Step 3: Commit
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: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
cd apps/worker && pnpm test -- --testPathPattern=session.test.ts --no-coverageExpected: FAIL —
session.test.tshas import errors or assertion failures sincesession.tsdoesn'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: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
cd apps/worker && pnpm test -- --testPathPattern=session.test.ts --no-coverageExpected: PASS — 6 tests pass.
-
Step 5: Commit
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 thedescribeblock (before the closing}):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
cd apps/worker && pnpm test -- --testPathPattern=session-pool.test.ts --no-coverageExpected: FAIL — the new
add() injects accountId into onQr callbacktest fails becausepool.add()doesn't yet acceptonQr. -
Step 3: Update session-pool.ts to thread onQr and onStatus
Replace
apps/worker/src/whatsapp/session-pool.tswith: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
cd apps/worker && pnpm test -- --testPathPattern=session-pool.test.ts --no-coverageExpected: 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 thepool.add(...)call (around line 62). It currently has 5 arguments (accountId, sessionPath, onMessage, onReaction, onGroups). Add two more callbacks after the groups callback: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.tsafter this change: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
cd apps/worker && pnpm test --no-coverageExpected: PASS — all worker tests pass.
-
Step 7: Commit
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
qrcodein the APIpnpm add qrcode --filter @tower/api pnpm add @types/qrcode --filter @tower/api --save-devExpected:
qrcodeand@types/qrcodeadded toapps/api/package.json. -
Step 2: Write the failing service tests
Create
apps/api/src/modules/accounts/accounts.service.spec.ts: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
cd apps/api && pnpm test -- --testPathPattern=accounts.service.spec.ts --no-coverageExpected: FAIL —
accounts.service.tsdoes not exist yet. -
Step 4: Implement accounts.service.ts
Create
apps/api/src/modules/accounts/accounts.service.ts: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
cd apps/api && pnpm test -- --testPathPattern=accounts.service.spec.ts --no-coverageExpected: PASS — 4 tests pass.
-
Step 6: Write the failing controller tests
Create
apps/api/src/modules/accounts/accounts.controller.spec.ts: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
cd apps/api && pnpm test -- --testPathPattern=accounts.controller.spec.ts --no-coverageExpected: FAIL —
accounts.controller.tsdoes not exist yet. -
Step 8: Implement accounts.controller.ts and accounts.module.ts
Create
apps/api/src/modules/accounts/accounts.controller.ts: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: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: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
cd apps/api && pnpm test --no-coverageExpected: PASS — all tests pass including the new accounts tests.
-
Step 11: Commit
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: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: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
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: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
cd apps/web && pnpm test -- --testPathPattern=AccountCard.test.tsx --no-coverageExpected: FAIL —
AccountCard.tsxdoes not exist yet. -
Step 3: Implement AccountCard.tsx
Create
apps/web/app/accounts/AccountCard.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
cd apps/web && pnpm test -- --testPathPattern=AccountCard.test.tsx --no-coverageExpected: PASS — all 7 tests pass.
-
Step 5: Implement accounts/page.tsx
Create
apps/web/app/accounts/page.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:import type { Metadata } from 'next'; import Link from 'next/link'; import './globals.css'; export const metadata: Metadata = { title: 'Insignia TOWER', description: 'Community Knowledge Infrastructure Platform', }; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en"> <body className="flex min-h-screen bg-gray-50 text-gray-900 antialiased"> <nav className="w-52 shrink-0 bg-white border-r border-gray-200 p-4 flex flex-col gap-1"> <span className="font-bold text-base mb-4">TOWER</span> <Link href="/search" className="rounded px-3 py-2 text-sm hover:bg-gray-100"> Search </Link> <Link href="/groups" className="rounded px-3 py-2 text-sm hover:bg-gray-100"> Groups & Routes </Link> <Link href="/accounts" className="rounded px-3 py-2 text-sm hover:bg-gray-100"> Accounts </Link> </nav> <main className="flex-1 overflow-auto p-6">{children}</main> </body> </html> ); } -
Step 7: Run all web tests
cd apps/web && pnpm test --no-coverageExpected: PASS — all web tests pass including AccountCard tests.
-
Step 8: Run the full test suite
cd /path/to/tower && pnpm test --no-coverageExpected: PASS — all test suites across worker, API, and web pass.
-
Step 9: Commit
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
-
Normal operation: Account has
status: ACTIVE,qrCode: null. Dashboard shows green "Connected" badge. -
WhatsApp logout detected: Worker's
session.tsreceivesDisconnectReason.loggedOut, callsonStatus('logged_out'), deletes session files, recreates the directory, restarts Baileys after 1 second.main.tsupdatesAccount.status = DISCONNECTEDin DB. -
New QR generated: Baileys fires
qrevent with a fresh QR string. Worker callsonQr(qrString).main.tswritesqrStringtoAccount.qrCodein DB. -
Admin opens dashboard: Navigates to
http://localhost:3000/accounts. Server component fetchesGET /accountsfrom API. AccountCard renders with "Awaiting scan" badge. -
QR polling: AccountCard's
useEffectcallsGET /api/accounts/:id/qrimmediately and every 5 seconds. API'sAccountsService.getQr()readsAccount.qrCodefrom DB, converts to PNG data URL viaqrcode, returns it. AccountCard shows<img src="data:image/png;base64,...">. -
Admin scans: Scans QR with WhatsApp (Linked Devices → Link a Device). Baileys fires
connection: 'open'. Worker callsonStatus('connected', jid).main.tsclearsAccount.qrCode = null, setsAccount.status = ACTIVE, and updatesAccount.jidwith the real WhatsApp JID. Next poll showsqrDataUrl: nulland the card updates to show "Connected".
Extension: Add Account from Dashboard
Tasks 7–9 add the ability to create new WhatsApp accounts from the dashboard UI and have the worker automatically pick them up without a restart.
Task 7: Worker — JID Update on Connect + Poll for New Accounts
Files:
-
Modify:
apps/worker/src/whatsapp/session.ts— passsock.user?.id(JID) toonStatus('connected') -
Modify:
apps/worker/src/whatsapp/session.test.ts— update assertion foronStatus('connected')to include JID -
Modify:
apps/worker/src/whatsapp/session-pool.ts— updatePoolStatusCallbackto accept optionaljid -
Modify:
apps/worker/src/whatsapp/session-pool.test.ts— add status JID threading test -
Modify:
apps/worker/src/main.ts— updateonStatushandler to update JID; extractstartAccount()helper; add 30s polling; change initial load to include DISCONNECTED accounts -
Step 1: Update
OnStatusCallbackin session.ts to carry optional JIDOpen
apps/worker/src/whatsapp/session.ts. Change the type and theconnection === 'open'handler:// Change this type: export type OnStatusCallback = (status: 'connected' | 'disconnected' | 'logged_out', jid?: string) => Promise<void> | void;In the
connection.updatehandler, change theconnection === 'open'branch from:} else if (connection === 'open') { await onStatus?.('connected');to:
} else if (connection === 'open') { await onStatus?.('connected', sock.user?.id ?? undefined); -
Step 2: Update session.ts test —
onStatus('connected')now includes jidOpen
apps/worker/src/whatsapp/session.test.ts. Find: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:
expect(onStatus).toHaveBeenCalledWith('connected', undefined);(
mockSock.useris not set in the mock sosock.user?.idisundefined.) -
Step 3: Run session tests to verify they pass
cd apps/worker && pnpm test -- --testPathPattern=session.test.ts --no-coverageExpected: PASS — all 6 session tests pass.
-
Step 4: Update
PoolStatusCallbackin session-pool.tsOpen
apps/worker/src/whatsapp/session-pool.ts. Change:export type PoolStatusCallback = (status: string, accountId: string) => Promise<void> | void;to:
export type PoolStatusCallback = (status: string, accountId: string, jid?: string) => Promise<void> | void;In the
add()method, change theonStatuswrapper from:onStatus ? (status) => onStatus(status, accountId) : undefined,to:
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 thedescribeblock: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
cd apps/worker && pnpm test -- --testPathPattern=session-pool.test.ts --no-coverageExpected: 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: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
cd apps/worker && pnpm test --no-coverageExpected: PASS — all worker tests pass.
-
Step 9: Commit
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— addcreate()with ConfigService -
Modify:
apps/api/src/modules/accounts/accounts.service.spec.ts— add create() tests -
Modify:
apps/api/src/modules/accounts/accounts.controller.ts— addPOST /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. AddConfigServiceto the mock providers and a newdescribe('create()')block.The full updated file:
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
cd apps/api && pnpm test -- --testPathPattern=accounts.service.spec.ts --no-coverageExpected: 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: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
cd apps/api && pnpm test -- --testPathPattern=accounts.service.spec.ts --no-coverageExpected: 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: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 forcreate():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: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
cd apps/api && pnpm test --no-coverageExpected: PASS — all tests pass.
-
Step 8: Commit
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.tsxis already created in Task 6.AccountsListuses 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: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: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
cd apps/web && pnpm test -- --testPathPattern=AccountsList.test.tsx --no-coverageExpected: FAIL —
AccountsList.tsxdoes not exist yet. -
Step 4: Implement AccountsList.tsx
Create
apps/web/app/accounts/AccountsList.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
cd apps/web && pnpm test -- --testPathPattern=AccountsList.test.tsx --no-coverageExpected: PASS — all 6 tests pass.
-
Step 6: Update accounts/page.tsx to use AccountsList
Replace
apps/web/app/accounts/page.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
cd apps/web && pnpm test --no-coverageExpected: PASS — all web tests pass.
-
Step 8: Run the full test suite
pnpm test --no-coverageExpected: PASS — all test suites across worker, API, and web pass.
-
Step 9: Commit
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:
- Admin opens
http://localhost:3000/accounts, types a name (optional), clicks "Add Account" POST /api/accounts→POST /accounts→ DB record created with placeholder JID and a fresh session path,status: ACTIVE- Worker's 30s polling detects the new account (not yet in pool), calls
startAccount() - Baileys starts with empty session dir → fires
qrevent → QR written to DB - Within 30s the account card appears and shows the QR (polls every 5s)
- Admin scans → Baileys fires
connection: 'open'→ real JID written to DB,qrCodecleared,status: ACTIVE - Card shows green "Connected" badge