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

83 KiB
Raw Permalink Blame History

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.tsGET /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:

    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_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

    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-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:

    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-coverage
    

    Expected: 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 the describe block (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-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:

    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-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:

    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:

    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-coverage
    

    Expected: 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 qrcode in the API

    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:

    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-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:

    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-coverage
    

    Expected: 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-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:

    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-coverage
    

    Expected: 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-coverage
    

    Expected: FAIL — AccountCard.tsx does 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-coverage
    

    Expected: 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 &amp; Routes
              </Link>
              <Link href="/accounts" className="rounded px-3 py-2 text-sm hover:bg-gray-100">
                Accounts
              </Link>
            </nav>
            <main className="flex-1 overflow-auto p-6">{children}</main>
          </body>
        </html>
      );
    }
    
  • Step 7: Run all web tests

    cd apps/web && pnpm test --no-coverage
    

    Expected: PASS — all web tests pass including AccountCard tests.

  • Step 8: Run the full test suite

    cd /path/to/tower && pnpm test --no-coverage
    

    Expected: 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

  1. Normal operation: Account has status: ACTIVE, qrCode: null. Dashboard shows green "Connected" badge.

  2. WhatsApp logout detected: Worker's session.ts receives DisconnectReason.loggedOut, calls onStatus('logged_out'), deletes session files, recreates the directory, restarts Baileys after 1 second. main.ts updates Account.status = DISCONNECTED in DB.

  3. New QR generated: Baileys fires qr event with a fresh QR string. Worker calls onQr(qrString). main.ts writes qrString to Account.qrCode in DB.

  4. Admin opens dashboard: Navigates to http://localhost:3000/accounts. Server component fetches GET /accounts from API. AccountCard renders with "Awaiting scan" badge.

  5. QR polling: AccountCard's useEffect calls GET /api/accounts/:id/qr immediately and every 5 seconds. API's AccountsService.getQr() reads Account.qrCode from DB, converts to PNG data URL via qrcode, returns it. AccountCard shows <img src="data:image/png;base64,...">.

  6. Admin scans: Scans QR with WhatsApp (Linked Devices → Link a Device). Baileys fires connection: 'open'. Worker calls onStatus('connected', jid). main.ts clears Account.qrCode = null, sets Account.status = ACTIVE, and updates Account.jid with the real WhatsApp JID. Next poll shows qrDataUrl: null and the card updates to show "Connected".


Extension: Add Account from Dashboard

Tasks 79 add the ability to create new WhatsApp accounts from the dashboard UI and have the worker automatically pick them up without a restart.


Task 7: Worker — JID Update on Connect + Poll for New Accounts

Files:

  • Modify: apps/worker/src/whatsapp/session.ts — pass sock.user?.id (JID) to onStatus('connected')

  • Modify: apps/worker/src/whatsapp/session.test.ts — update assertion for onStatus('connected') to include JID

  • Modify: apps/worker/src/whatsapp/session-pool.ts — update PoolStatusCallback to accept optional jid

  • Modify: apps/worker/src/whatsapp/session-pool.test.ts — add status JID threading test

  • Modify: apps/worker/src/main.ts — update onStatus handler to update JID; extract startAccount() helper; add 30s polling; change initial load to include DISCONNECTED accounts

  • Step 1: Update OnStatusCallback in session.ts to carry optional JID

    Open apps/worker/src/whatsapp/session.ts. Change the type and the connection === 'open' handler:

    // Change this type:
    export type OnStatusCallback = (status: 'connected' | 'disconnected' | 'logged_out', jid?: string) => Promise<void> | void;
    

    In the connection.update handler, change the connection === 'open' branch from:

    } 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 jid

    Open 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.user is not set in the mock so sock.user?.id is undefined.)

  • Step 3: Run session tests to verify they pass

    cd apps/worker && pnpm test -- --testPathPattern=session.test.ts --no-coverage
    

    Expected: PASS — all 6 session tests pass.

  • Step 4: Update PoolStatusCallback in session-pool.ts

    Open apps/worker/src/whatsapp/session-pool.ts. Change:

    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 the onStatus wrapper 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 the describe block:

    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-coverage
    

    Expected: PASS — all tests pass including the new status threading test.

  • Step 7: Rewrite main.ts with startAccount() helper + polling + JID update

    Replace the full contents of apps/worker/src/main.ts:

    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-coverage
    

    Expected: 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 — add create() with ConfigService

  • Modify: apps/api/src/modules/accounts/accounts.service.spec.ts — add create() tests

  • Modify: apps/api/src/modules/accounts/accounts.controller.ts — add POST /accounts

  • Modify: apps/api/src/modules/accounts/accounts.controller.spec.ts — add controller test

  • Modify: apps/api/src/modules/accounts/accounts.module.ts — add ConfigModule import

  • Step 1: Write failing tests for create()

    Open apps/api/src/modules/accounts/accounts.service.spec.ts. Add ConfigService to the mock providers and a new describe('create()') block.

    The full updated file:

    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-coverage
    

    Expected: FAIL — create() method does not exist yet.

  • Step 3: Update accounts.service.ts to add create()

    Replace apps/api/src/modules/accounts/accounts.service.ts:

    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-coverage
    

    Expected: PASS — all 9 tests pass.

  • Step 5: Add POST handler to accounts.controller.ts and controller test

    Replace apps/api/src/modules/accounts/accounts.controller.ts:

    import { Body, Controller, Get, Param, Post } from '@nestjs/common';
    import { AccountsService } from './accounts.service';
    
    @Controller('accounts')
    export class AccountsController {
      constructor(private readonly service: AccountsService) {}
    
      @Get()
      list() {
        return this.service.list();
      }
    
      @Get(':id/qr')
      getQr(@Param('id') id: string) {
        return this.service.getQr(id);
      }
    
      @Post()
      create(@Body() body: { displayName?: string }) {
        return this.service.create(body.displayName);
      }
    }
    

    Open apps/api/src/modules/accounts/accounts.controller.spec.ts. Add a test for create():

    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-coverage
    

    Expected: 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.tsx is already created in Task 6. AccountsList uses it.

  • Step 1: Add POST proxy to the accounts route handler

    Open apps/web/app/api/accounts/route.ts (created in Task 5). Replace its contents with:

    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-coverage
    

    Expected: FAIL — AccountsList.tsx does 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-coverage
    

    Expected: 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-coverage
    

    Expected: PASS — all web tests pass.

  • Step 8: Run the full test suite

    pnpm test --no-coverage
    

    Expected: 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:

  1. Admin opens http://localhost:3000/accounts, types a name (optional), clicks "Add Account"
  2. POST /api/accountsPOST /accounts → DB record created with placeholder JID and a fresh session path, status: ACTIVE
  3. Worker's 30s polling detects the new account (not yet in pool), calls startAccount()
  4. Baileys starts with empty session dir → fires qr event → QR written to DB
  5. Within 30s the account card appears and shows the QR (polls every 5s)
  6. Admin scans → Baileys fires connection: 'open' → real JID written to DB, qrCode cleared, status: ACTIVE
  7. Card shows green "Connected" badge