From d33b4e40b8550fe5921c38c9cf716d4d0d668217 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Wed, 27 May 2026 17:40:24 +0530 Subject: [PATCH] fix: use type-only Baileys import and raw status code to fix Jest ESM issue Replaces DisconnectReason enum import with type-only WASocket import and uses 401 directly instead of DisconnectReason.loggedOut. Baileys is an ES module that cannot be executed in Jest's CommonJS mode, so removing the value import (keeping only type imports) prevents ts-jest from trying to execute the module. Also updated session-pool.test.ts to verify end() is called with the expected Boom error object instead of undefined. Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.local.json | 75 + apps/web/next-env.d.ts | 2 +- apps/worker/package.json | 4 +- apps/worker/src/whatsapp/session-pool.test.ts | 4 +- apps/worker/src/whatsapp/session-pool.ts | 4 +- .../plans/2026-05-27-monorepo-foundation.md | 1750 +++++++++++++++++ ...6-05-27-multi-account-approval-workflow.md | 1243 ++++++++++++ .../plans/2026-05-27-whatsapp-integration.md | 1190 +++++++++++ pnpm-lock.yaml | 17 + 9 files changed, 4283 insertions(+), 6 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 docs/superpowers/plans/2026-05-27-monorepo-foundation.md create mode 100644 docs/superpowers/plans/2026-05-27-multi-account-approval-workflow.md create mode 100644 docs/superpowers/plans/2026-05-27-whatsapp-integration.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..ff7ea1b --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,75 @@ +{ + "permissions": { + "allow": [ + "Bash(git init *)", + "Bash(git branch *)", + "Bash(pnpm install *)", + "Bash(git add *)", + "Bash(git commit *)", + "Bash(pnpm --filter @tower/types install)", + "Bash(pnpm --filter @tower/types build)", + "Bash(pnpm --filter @tower/config install)", + "Bash(pnpm --filter @tower/config test)", + "Bash(pnpm --filter @tower/config build)", + "Bash(pnpm --filter @tower/logger install)", + "Bash(pnpm --filter @tower/logger build)", + "Bash(pnpm --filter @tower/api install)", + "Bash(docker compose *)", + "Bash(DATABASE_URL=\"postgresql://tower:tower_dev@localhost:5432/tower_dev\" pnpm exec prisma db push)", + "Bash(DATABASE_URL=\"postgresql://tower:tower_dev@localhost:5433/tower_dev\" pnpm exec prisma migrate dev --name init_core_schema)", + "Bash(DATABASE_URL=\"postgresql://tower:tower_dev@localhost:5433/tower_dev\" pnpm test)", + "Bash(git -C /Users/maaz/Documents/insignia-work/tower add apps/api/src/prisma/prisma.service.spec.ts apps/api/prisma/migrations docker-compose.yml .env.example pnpm-lock.yaml)", + "Bash(git -C /Users/maaz/Documents/insignia-work/tower commit -m \"feat: add Prisma schema and PrismaService with integration tests \\(postgres on :5433\\)\")", + "Bash(DATABASE_URL=\"postgresql://tower:tower_dev@localhost:5433/tower_dev\" pnpm dev)", + "Bash(curl -s http://localhost:3001/health)", + "Bash(kill %1)", + "Bash(git -C /Users/maaz/Documents/insignia-work/tower add apps/api/src/modules/health/health.controller.spec.ts)", + "Bash(git -C /Users/maaz/Documents/insignia-work/tower commit -m \"feat: add health check module with unit tests\")", + "Bash(pnpm dlx *)", + "Bash(pnpm info *)", + "Bash(pnpm --filter @tower/web install)", + "Bash(pnpm --filter @tower/web test)", + "Bash(pnpm --filter @tower/web build)", + "Bash(pnpm --filter @tower/worker test)", + "Bash(pnpm --filter @tower/worker add @whiskeysockets/baileys)", + "Bash(git -C /Users/maaz/Documents/insignia-work/tower log --oneline -5)", + "Bash(pnpm ls *)", + "Bash(pnpm list *)", + "Bash(git -C /Users/maaz/Documents/insignia-work/tower add apps/worker/src/queues/)", + "Bash(git -C /Users/maaz/Documents/insignia-work/tower commit -m 'feat: add BullMQ ingest queue and processor *)", + "Bash(git -C /Users/maaz/Documents/insignia-work/tower commit -m ' *)", + "Bash(pnpm --filter @tower/worker build)", + "Bash(pnpm --filter @tower/worker add @hapi/boom)", + "Bash(git -C /Users/maaz/Documents/insignia-work/tower add apps/worker/src/whatsapp/session.ts apps/worker/package.json pnpm-lock.yaml)", + "Bash(pnpm --filter @tower/worker add @prisma/client)", + "Bash(pnpm --filter @tower/worker add --save-dev prisma)", + "Bash(pnpm --filter @tower/worker add --save-dev dotenv)", + "Bash(pnpm --filter @tower/worker generate)", + "Bash(pnpm build *)", + "Bash(pnpm test *)", + "Bash(git -C /Users/maaz/Documents/insignia-work/tower add apps/worker/src/queues/ingest.queue.ts apps/worker/src/queues/ingest.processor.ts)", + "Bash(git -C /Users/maaz/Documents/insignia-work/tower commit -m 'fix: rename queue from tower:ingest to tower-ingest \\(BullMQ v5 forbids colons\\) *)", + "Bash(pnpm --filter @tower/worker add qrcode-terminal)", + "Bash(pnpm --filter @tower/worker add -D @types/qrcode-terminal)", + "Bash(pnpm --filter @tower/worker test -- --testPathPattern=normalizer)", + "Bash(pnpm --filter @tower/worker test --testPathPattern=normalizer)", + "Bash(pnpm --filter @tower/worker test -- --testPathPattern normalizer)", + "Bash(xargs cat)", + "Bash(pnpm --filter @tower/worker test -- --testPathPattern 'normalizer')", + "Bash(DATABASE_URL=\"postgresql://tower:tower@localhost:5433/tower\" npx prisma migrate dev --name add-account-model)", + "Bash('../../../DATABASE_URL'=\"postgresql://tower:tower@localhost:5433/tower\" npx *)", + "Bash(psql \"postgresql://tower:tower_dev@localhost:5433/tower_dev\" -c '\\\\l')", + "Bash(env '../../../DATABASE_URL=postgresql://tower:tower_dev@localhost:5433/tower_dev' npx prisma migrate dev --name add-account-model)", + "Bash(npm run *)", + "Bash(npx tsc *)", + "Bash(pnpm --filter @tower/worker test -- --testPathPattern=session-pool)", + "Bash(pnpm --filter @tower/worker test session-pool)", + "Bash(pnpm --filter @tower/worker test -- --testPathPattern=approval)", + "Bash(pnpm --filter @tower/worker test approval.test.ts)", + "Bash(npx jest *)", + "Bash(pnpm --filter @tower/worker test -- --testPathPattern=forward)", + "Bash(pnpm --filter @tower/worker test -- forward.processor.test.ts)", + "Bash(npm test *)" + ] + } +} diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/worker/package.json b/apps/worker/package.json index 99d23f8..469aa98 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -16,11 +16,13 @@ "@tower/types": "workspace:*", "@whiskeysockets/baileys": "7.0.0-rc13", "bullmq": "^5.0.0", - "ioredis": "^5.0.0" + "ioredis": "^5.0.0", + "qrcode-terminal": "^0.12.0" }, "devDependencies": { "@types/jest": "^29.0.0", "@types/node": "^22.0.0", + "@types/qrcode-terminal": "^0.12.2", "dotenv": "^17.4.2", "jest": "^29.0.0", "prisma": "^6.0.0", diff --git a/apps/worker/src/whatsapp/session-pool.test.ts b/apps/worker/src/whatsapp/session-pool.test.ts index 3695bdc..676183e 100644 --- a/apps/worker/src/whatsapp/session-pool.test.ts +++ b/apps/worker/src/whatsapp/session-pool.test.ts @@ -66,8 +66,8 @@ describe('WhatsAppSessionPool', () => { await pool.closeAll(); - expect(sock1.end).toHaveBeenCalledWith(undefined); - expect(sock2.end).toHaveBeenCalledWith(undefined); + expect(sock1.end).toHaveBeenCalledWith(expect.objectContaining({ message: 'Shutdown' })); + expect(sock2.end).toHaveBeenCalledWith(expect.objectContaining({ message: 'Shutdown' })); expect(pool.getAll().size).toBe(0); }); diff --git a/apps/worker/src/whatsapp/session-pool.ts b/apps/worker/src/whatsapp/session-pool.ts index d69a804..938130d 100644 --- a/apps/worker/src/whatsapp/session-pool.ts +++ b/apps/worker/src/whatsapp/session-pool.ts @@ -1,4 +1,4 @@ -import { WASocket, DisconnectReason } from '@whiskeysockets/baileys'; +import type { WASocket } from '@whiskeysockets/baileys'; import { Boom } from '@hapi/boom'; import { NormalizedMessage, NormalizedReaction } from '@tower/types'; import { createWhatsAppSession } from './session'; @@ -67,7 +67,7 @@ export class WhatsAppSessionPool { logger.info({ count: this.sessions.size }, 'Closing all WhatsApp sessions'); for (const [accountId, sock] of this.sessions) { try { - sock.end(new Boom('Shutdown', { statusCode: DisconnectReason.loggedOut })); + sock.end(new Boom('Shutdown', { statusCode: 401 })); logger.info({ accountId }, 'Session closed'); } catch (err) { logger.error({ accountId, err }, 'Error closing session'); diff --git a/docs/superpowers/plans/2026-05-27-monorepo-foundation.md b/docs/superpowers/plans/2026-05-27-monorepo-foundation.md new file mode 100644 index 0000000..6266069 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-monorepo-foundation.md @@ -0,0 +1,1750 @@ +# Monorepo Foundation 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:** Scaffold the complete Insignia TOWER monorepo with a working NestJS API, Next.js 15 web app, worker shell, all shared packages, the full Prisma schema, and a Docker Compose dev stack — everything green before the first feature lands. + +**Architecture:** Turborepo monorepo with pnpm workspaces. Three apps (`api`, `web`, `worker`) share typed packages (`@tower/types`, `@tower/config`, `@tower/logger`, `@tower/ui`, `@tower/sdk`). PostgreSQL + Redis + Meilisearch run in Docker locally. Prisma manages all schema migrations from inside `apps/api`. + +**Tech Stack:** pnpm 10+, Turborepo 2, NestJS 11, Next.js 16, Tailwind CSS 4, shadcn/ui, Prisma 6, PostgreSQL 17, Redis 7, Meilisearch 1.11, BullMQ 5, Zod 3, pino 9, Jest 29, TypeScript 5.7 + +--- + +## File Map + +All files created from scratch (empty repo). + +``` +tower/ +├── package.json # root workspace +├── pnpm-workspace.yaml +├── turbo.json +├── tsconfig.base.json # shared TS base +├── .gitignore +├── .prettierrc +├── .env.example +├── docker-compose.yml +│ +├── packages/ +│ ├── types/ +│ │ ├── package.json +│ │ ├── tsconfig.json +│ │ └── src/ +│ │ ├── index.ts +│ │ └── message.ts # CanonicalMessage, Group, Platform types +│ ├── config/ +│ │ ├── package.json +│ │ ├── tsconfig.json +│ │ ├── jest.config.js +│ │ └── src/ +│ │ ├── index.ts # validateEnv() +│ │ └── index.test.ts # env validation tests +│ ├── logger/ +│ │ ├── package.json +│ │ ├── tsconfig.json +│ │ └── src/ +│ │ └── index.ts # createLogger() +│ ├── ui/ +│ │ ├── package.json +│ │ ├── tsconfig.json +│ │ └── src/ +│ │ └── index.ts # re-export shell +│ └── sdk/ +│ ├── package.json +│ ├── tsconfig.json +│ └── src/ +│ └── index.ts # re-export shell +│ +├── apps/ +│ ├── api/ +│ │ ├── package.json +│ │ ├── tsconfig.json +│ │ ├── nest-cli.json +│ │ ├── jest.config.js +│ │ ├── prisma/ +│ │ │ └── schema.prisma # all Phase 1 tables +│ │ └── src/ +│ │ ├── main.ts +│ │ ├── app.module.ts +│ │ ├── prisma/ +│ │ │ ├── prisma.module.ts +│ │ │ ├── prisma.service.ts +│ │ │ └── prisma.service.spec.ts # DB integration test +│ │ └── modules/ +│ │ └── health/ +│ │ ├── health.module.ts +│ │ ├── health.controller.ts +│ │ └── health.controller.spec.ts +│ ├── web/ +│ │ ├── package.json +│ │ ├── tsconfig.json +│ │ ├── next.config.ts +│ │ ├── postcss.config.js +│ │ ├── jest.config.js +│ │ ├── jest.setup.ts +│ │ └── app/ +│ │ ├── globals.css +│ │ ├── layout.tsx +│ │ ├── page.tsx +│ │ └── page.test.tsx # render test +│ └── worker/ +│ ├── package.json +│ ├── tsconfig.json +│ └── src/ +│ └── main.ts +``` + +--- + +### Task 1: Root Monorepo Scaffold + +**Files:** +- Create: `package.json` +- Create: `pnpm-workspace.yaml` +- Create: `turbo.json` +- Create: `tsconfig.base.json` +- Create: `.gitignore` +- Create: `.prettierrc` + +- [ ] **Step 1: Verify pnpm and Node versions meet requirements** + +```bash +node --version # must be >= 20 +pnpm --version # must be >= 9 +``` + +Expected: both print version numbers without errors. + +- [ ] **Step 2: Initialise git and write root `package.json`** + +```bash +git init +``` + +Create `package.json`: +```json +{ + "name": "tower", + "private": true, + "scripts": { + "build": "turbo build", + "dev": "turbo dev", + "test": "turbo test", + "lint": "turbo lint" + }, + "devDependencies": { + "turbo": "^2.0.0", + "typescript": "^5.7.0", + "@types/node": "^22.0.0", + "prettier": "^3.0.0" + }, + "engines": { + "node": ">=22.0.0", + "pnpm": ">=10.0.0" + } +} +``` + +- [ ] **Step 3: Write workspace and Turborepo config** + +Create `pnpm-workspace.yaml`: +```yaml +packages: + - 'apps/*' + - 'packages/*' +``` + +Create `turbo.json`: +```json +{ + "$schema": "https://turbo.build/schema.json", + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**", ".next/**"] + }, + "dev": { + "cache": false, + "persistent": true + }, + "test": { + "dependsOn": ["^build"], + "outputs": ["coverage/**"] + }, + "lint": {} + } +} +``` + +- [ ] **Step 4: Write shared TypeScript base and tooling configs** + +Create `tsconfig.base.json`: +```json +{ + "compilerOptions": { + "target": "ES2021", + "module": "commonjs", + "lib": ["ES2021"], + "strict": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "resolveJsonModule": true, + "esModuleInterop": true + } +} +``` + +Create `.prettierrc`: +```json +{ + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100 +} +``` + +Create `.gitignore`: +``` +node_modules +dist +.next +.turbo +coverage +.env +*.env.local +``` + +- [ ] **Step 5: Install root dependencies and commit** + +```bash +pnpm install +``` + +Expected: `node_modules/.modules.yaml` created, no errors. + +```bash +git add package.json pnpm-workspace.yaml turbo.json tsconfig.base.json .gitignore .prettierrc pnpm-lock.yaml +git commit -m "chore: initialise monorepo scaffold" +``` + +--- + +### Task 2: `@tower/types` Package + +**Files:** +- Create: `packages/types/package.json` +- Create: `packages/types/tsconfig.json` +- Create: `packages/types/src/message.ts` +- Create: `packages/types/src/index.ts` + +- [ ] **Step 1: Write the type definitions** + +Create `packages/types/src/message.ts`: +```typescript +export type Platform = 'whatsapp' | 'telegram' | 'discord'; + +export type MessageStatus = + | 'PENDING' + | 'APPROVED' + | 'REJECTED' + | 'DISTRIBUTED' + | 'ARCHIVED'; + +export type ApprovalDecision = 'APPROVED' | 'REJECTED'; + +export interface CanonicalMessage { + messageId: string; + platform: Platform; + platformMsgId: string; + sourceGroupId: string; + senderJid: string; + senderName?: string; + content: string; + mediaUrl?: string; + tags: string[]; + status: MessageStatus; + createdAt: Date; +} + +export interface Group { + id: string; + platform: Platform; + platformId: string; + name: string; + description?: string; + isActive: boolean; +} + +export interface SyncRoute { + id: string; + sourceGroupId: string; + targetGroupId: string; + isActive: boolean; +} +``` + +Create `packages/types/src/index.ts`: +```typescript +export * from './message'; +``` + +- [ ] **Step 2: Write package config** + +Create `packages/types/package.json`: +```json +{ + "name": "@tower/types", + "version": "0.0.1", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch" + }, + "devDependencies": { + "typescript": "^5.7.0" + } +} +``` + +Create `packages/types/tsconfig.json`: +```json +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} +``` + +- [ ] **Step 3: Build and verify TypeScript compiles** + +```bash +pnpm --filter @tower/types build +``` + +Expected: `packages/types/dist/` created, `index.js` and `index.d.ts` present, no TS errors. + +- [ ] **Step 4: Commit** + +```bash +git add packages/types +git commit -m "feat: add @tower/types shared package" +``` + +--- + +### Task 3: `@tower/config` Package (TDD) + +**Files:** +- Create: `packages/config/package.json` +- Create: `packages/config/tsconfig.json` +- Create: `packages/config/jest.config.js` +- Create: `packages/config/src/index.ts` +- Create: `packages/config/src/index.test.ts` + +- [ ] **Step 1: Write the failing tests first** + +Create `packages/config/src/index.test.ts`: +```typescript +import { validateEnv } from './index'; + +const validEnv = { + NODE_ENV: 'development', + DATABASE_URL: 'postgresql://tower:tower_dev@localhost:5432/tower_dev', + REDIS_URL: 'redis://localhost:6379', + JWT_SECRET: 'a_super_secret_key_that_is_at_least_32_chars_long', +} as unknown as NodeJS.ProcessEnv; + +describe('validateEnv', () => { + it('returns parsed config for valid env', () => { + const result = validateEnv(validEnv); + expect(result.NODE_ENV).toBe('development'); + expect(result.API_PORT).toBe(3001); + }); + + it('applies default API_PORT of 3001 when not set', () => { + const result = validateEnv(validEnv); + expect(result.API_PORT).toBe(3001); + }); + + it('throws when DATABASE_URL is missing', () => { + const { DATABASE_URL, ...withoutDb } = validEnv as Record; + expect(() => + validateEnv(withoutDb as unknown as NodeJS.ProcessEnv), + ).toThrow('Invalid environment variables'); + }); + + it('throws when JWT_SECRET is shorter than 32 chars', () => { + expect(() => + validateEnv({ ...validEnv, JWT_SECRET: 'tooshort' } as unknown as NodeJS.ProcessEnv), + ).toThrow('Invalid environment variables'); + }); + + it('throws when DATABASE_URL is not a valid URL', () => { + expect(() => + validateEnv({ ...validEnv, DATABASE_URL: 'not-a-url' } as unknown as NodeJS.ProcessEnv), + ).toThrow('Invalid environment variables'); + }); +}); +``` + +- [ ] **Step 2: Write package config and run the failing tests** + +Create `packages/config/package.json`: +```json +{ + "name": "@tower/config", + "version": "0.0.1", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "test": "jest" + }, + "dependencies": { + "zod": "^3.23.0" + }, + "devDependencies": { + "@types/jest": "^29.0.0", + "jest": "^29.0.0", + "ts-jest": "^29.0.0", + "typescript": "^5.7.0" + } +} +``` + +Create `packages/config/tsconfig.json`: +```json +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} +``` + +Create `packages/config/jest.config.js`: +```javascript +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/*.test.ts'], +}; +``` + +```bash +pnpm --filter @tower/config install +pnpm --filter @tower/config test +``` + +Expected: **5 tests FAIL** — `Cannot find module './index'`. + +- [ ] **Step 3: Write the implementation** + +Create `packages/config/src/index.ts`: +```typescript +import { z } from 'zod'; + +const envSchema = z.object({ + NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), + DATABASE_URL: z.string().url(), + REDIS_URL: z.string().url(), + API_PORT: z.coerce.number().default(3001), + JWT_SECRET: z.string().min(32), + MEILI_URL: z.string().url().default('http://localhost:7700'), + MEILI_MASTER_KEY: z.string().default('tower_meili_dev_key'), + LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error']).default('info'), +}); + +export type Env = z.infer; + +export function validateEnv(env: NodeJS.ProcessEnv = process.env): Env { + const result = envSchema.safeParse(env); + if (!result.success) { + console.error('Invalid environment variables:', result.error.format()); + throw new Error('Invalid environment variables'); + } + return result.data; +} +``` + +- [ ] **Step 4: Run tests — verify all pass** + +```bash +pnpm --filter @tower/config test +``` + +Expected: +``` +PASS src/index.test.ts + validateEnv + ✓ returns parsed config for valid env + ✓ applies default API_PORT of 3001 when not set + ✓ throws when DATABASE_URL is missing + ✓ throws when JWT_SECRET is shorter than 32 chars + ✓ throws when DATABASE_URL is not a valid URL + +Tests: 5 passed, 5 total +``` + +- [ ] **Step 5: Build and commit** + +```bash +pnpm --filter @tower/config build +``` + +Expected: `packages/config/dist/` created with no TS errors. + +```bash +git add packages/config +git commit -m "feat: add @tower/config package with env validation" +``` + +--- + +### Task 4: `@tower/logger` Package + +**Files:** +- Create: `packages/logger/package.json` +- Create: `packages/logger/tsconfig.json` +- Create: `packages/logger/src/index.ts` + +- [ ] **Step 1: Write the logger** + +Create `packages/logger/src/index.ts`: +```typescript +import pino from 'pino'; + +export function createLogger(name: string) { + return pino({ + name, + level: process.env['LOG_LEVEL'] ?? 'info', + ...(process.env['NODE_ENV'] !== 'production' && { + transport: { + target: 'pino-pretty', + options: { colorize: true }, + }, + }), + }); +} + +export type Logger = ReturnType; +``` + +- [ ] **Step 2: Write package config** + +Create `packages/logger/package.json`: +```json +{ + "name": "@tower/logger", + "version": "0.0.1", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch" + }, + "dependencies": { + "pino": "^9.0.0", + "pino-pretty": "^11.0.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.7.0" + } +} +``` + +Create `packages/logger/tsconfig.json`: +```json +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} +``` + +- [ ] **Step 3: Build and verify** + +```bash +pnpm --filter @tower/logger install +pnpm --filter @tower/logger build +``` + +Expected: `packages/logger/dist/` created with no TS errors. + +- [ ] **Step 4: Commit** + +```bash +git add packages/logger +git commit -m "feat: add @tower/logger package" +``` + +--- + +### Task 5: `@tower/ui` and `@tower/sdk` Package Shells + +**Files:** +- Create: `packages/ui/package.json` +- Create: `packages/ui/tsconfig.json` +- Create: `packages/ui/src/index.ts` +- Create: `packages/sdk/package.json` +- Create: `packages/sdk/tsconfig.json` +- Create: `packages/sdk/src/index.ts` + +These are shells — populated in later plans. + +- [ ] **Step 1: Write the ui package** + +Create `packages/ui/src/index.ts`: +```typescript +// UI component library — populated in later plans +export {}; +``` + +Create `packages/ui/package.json`: +```json +{ + "name": "@tower/ui", + "version": "0.0.1", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "build": "echo 'ui is consumed as source by Next.js'" + }, + "peerDependencies": { + "react": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "react": "^19.0.0", + "typescript": "^5.7.0" + } +} +``` + +Create `packages/ui/tsconfig.json`: +```json +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} +``` + +- [ ] **Step 2: Write the sdk package** + +Create `packages/sdk/src/index.ts`: +```typescript +// External SDK — populated in later plans +export {}; +``` + +Create `packages/sdk/package.json`: +```json +{ + "name": "@tower/sdk", + "version": "0.0.1", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch" + }, + "devDependencies": { + "typescript": "^5.7.0" + } +} +``` + +Create `packages/sdk/tsconfig.json`: +```json +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} +``` + +- [ ] **Step 3: Install and commit** + +```bash +pnpm install +git add packages/ui packages/sdk +git commit -m "feat: add @tower/ui and @tower/sdk shell packages" +``` + +--- + +### Task 6: Docker Compose Dev Stack + Environment + +**Files:** +- Create: `docker-compose.yml` +- Create: `.env.example` +- Create: `.env` (from example, not committed) + +- [ ] **Step 1: Write `docker-compose.yml`** + +```yaml +version: '3.9' + +services: + postgres: + image: postgres:17-alpine + environment: + POSTGRES_USER: tower + POSTGRES_PASSWORD: tower_dev + POSTGRES_DB: tower_dev + ports: + - '5432:5432' + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U tower -d tower_dev'] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + ports: + - '6379:6379' + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 5s + timeout: 5s + retries: 5 + + meilisearch: + image: getmeili/meilisearch:v1.11 + ports: + - '7700:7700' + environment: + MEILI_NO_ANALYTICS: 'true' + MEILI_MASTER_KEY: tower_meili_dev_key + volumes: + - meilisearch_data:/meili_data + +volumes: + postgres_data: + meilisearch_data: +``` + +- [ ] **Step 2: Write `.env.example`** + +```bash +# Database +DATABASE_URL=postgresql://tower:tower_dev@localhost:5432/tower_dev + +# Redis +REDIS_URL=redis://localhost:6379 + +# API +API_PORT=3001 + +# Auth +JWT_SECRET=change_me_in_production_must_be_32_chars_min + +# Meilisearch +MEILI_URL=http://localhost:7700 +MEILI_MASTER_KEY=tower_meili_dev_key + +# Logging +NODE_ENV=development +LOG_LEVEL=debug +``` + +- [ ] **Step 3: Start the stack and verify all services are healthy** + +```bash +cp .env.example .env +docker compose up -d +``` + +Wait ~10 seconds, then: + +```bash +docker compose ps +``` + +Expected: all three services show `healthy` or `running`: +``` +NAME STATUS +tower-postgres-1 Up (healthy) +tower-redis-1 Up (healthy) +tower-meilisearch-1 Up +``` + +Verify Postgres: +```bash +docker compose exec postgres pg_isready -U tower -d tower_dev +``` +Expected: `localhost:5432 - accepting connections` + +Verify Redis: +```bash +docker compose exec redis redis-cli ping +``` +Expected: `PONG` + +- [ ] **Step 4: Commit** + +```bash +git add docker-compose.yml .env.example +git commit -m "chore: add Docker Compose dev stack (postgres, redis, meilisearch)" +``` + +--- + +### Task 7: NestJS API Application Scaffold + +**Files:** +- Create: `apps/api/package.json` +- Create: `apps/api/tsconfig.json` +- Create: `apps/api/nest-cli.json` +- Create: `apps/api/jest.config.js` +- Create: `apps/api/src/main.ts` +- Create: `apps/api/src/app.module.ts` + +- [ ] **Step 1: Write `apps/api/package.json`** + +```json +{ + "name": "@tower/api", + "version": "0.0.1", + "scripts": { + "build": "nest build", + "dev": "nest start --watch", + "start": "node dist/main", + "test": "jest", + "test:e2e": "jest --config ./test/jest-e2e.json" + }, + "dependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/config": "^4.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/platform-express": "^11.0.0", + "@prisma/client": "^6.0.0", + "@tower/config": "workspace:*", + "@tower/logger": "workspace:*", + "@tower/types": "workspace:*", + "reflect-metadata": "^0.2.0", + "rxjs": "^7.8.0" + }, + "devDependencies": { + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@nestjs/testing": "^11.0.0", + "@types/jest": "^29.0.0", + "@types/node": "^22.0.0", + "jest": "^29.0.0", + "prisma": "^6.0.0", + "ts-jest": "^29.0.0", + "typescript": "^5.7.0" + } +} +``` + +- [ ] **Step 2: Write TypeScript and NestJS configs** + +Create `apps/api/tsconfig.json`: +```json +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true + } +} +``` + +Create `apps/api/nest-cli.json`: +```json +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} +``` + +Create `apps/api/jest.config.js`: +```javascript +module.exports = { + moduleFileExtensions: ['js', 'json', 'ts'], + rootDir: 'src', + testRegex: '.*\\.spec\\.ts$', + transform: { '^.+\\.(t|j)s$': 'ts-jest' }, + testEnvironment: 'node', +}; +``` + +- [ ] **Step 3: Write application entry files** + +Create `apps/api/src/main.ts`: +```typescript +import 'reflect-metadata'; +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + const port = process.env['API_PORT'] ?? 3001; + await app.listen(port); + console.log(`TOWER API running on port ${port}`); +} + +bootstrap(); +``` + +Create `apps/api/src/app.module.ts`: +```typescript +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { PrismaModule } from './prisma/prisma.module'; +import { HealthModule } from './modules/health/health.module'; + +@Module({ + imports: [ + ConfigModule.forRoot({ isGlobal: true }), + PrismaModule, + HealthModule, + ], +}) +export class AppModule {} +``` + +- [ ] **Step 4: Install dependencies and verify build** + +```bash +pnpm --filter @tower/api install +pnpm --filter @tower/api build +``` + +Expected: `apps/api/dist/` created with no TS errors. (Will fail at runtime until Prisma is generated — that's fine for now.) + +- [ ] **Step 5: Commit** + +```bash +git add apps/api +git commit -m "feat: scaffold NestJS API application" +``` + +--- + +### Task 8: Prisma Schema + PrismaService (TDD) + +**Files:** +- Create: `apps/api/prisma/schema.prisma` +- Create: `apps/api/src/prisma/prisma.module.ts` +- Create: `apps/api/src/prisma/prisma.service.ts` +- Create: `apps/api/src/prisma/prisma.service.spec.ts` + +- [ ] **Step 1: Write the failing integration test first** + +Create `apps/api/src/prisma/prisma.service.spec.ts`: +```typescript +import { Test, TestingModule } from '@nestjs/testing'; +import { PrismaService } from './prisma.service'; + +describe('PrismaService', () => { + let prisma: PrismaService; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [PrismaService], + }).compile(); + prisma = module.get(PrismaService); + await prisma.onModuleInit(); + }); + + afterAll(async () => { + await prisma.onModuleDestroy(); + }); + + it('connects to postgres', async () => { + const result = await prisma.$queryRaw<[{ ok: bigint }]>`SELECT 1 AS ok`; + expect(Number(result[0]!.ok)).toBe(1); + }); + + it('creates and retrieves a Group, then cleans up', async () => { + const group = await prisma.group.create({ + data: { + platform: 'whatsapp', + platformId: `test-group-${Date.now()}@g.us`, + name: 'Test Group', + }, + }); + + expect(group.id).toBeDefined(); + expect(group.platform).toBe('whatsapp'); + expect(group.isActive).toBe(true); + + await prisma.group.delete({ where: { id: group.id } }); + }); +}); +``` + +Run: +```bash +pnpm --filter @tower/api test +``` + +Expected: **FAIL** — `Cannot find module './prisma.service'`. + +- [ ] **Step 2: Write the Prisma schema** + +Create `apps/api/prisma/schema.prisma`: +```prisma +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model Group { + id String @id @default(cuid()) + platform String + platformId String + name String + description String? + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + messages Message[] + syncRoutesFrom SyncRoute[] @relation("sourceGroup") + syncRoutesTo SyncRoute[] @relation("targetGroup") + consentRecords ConsentRecord[] + + @@unique([platform, platformId]) +} + +model Message { + id String @id @default(cuid()) + platform String + platformMsgId String + sourceGroupId String + sourceGroup Group @relation(fields: [sourceGroupId], references: [id]) + senderJid String + senderName String? + content String + mediaUrl String? + tags String[] + status MessageStatus @default(PENDING) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + approval Approval? + + @@unique([platform, platformMsgId]) +} + +enum MessageStatus { + PENDING + APPROVED + REJECTED + DISTRIBUTED + ARCHIVED +} + +model Approval { + id String @id @default(cuid()) + messageId String @unique + message Message @relation(fields: [messageId], references: [id]) + adminId String + decision ApprovalDecision + notes String? + decidedAt DateTime @default(now()) +} + +enum ApprovalDecision { + APPROVED + REJECTED +} + +model SyncRoute { + id String @id @default(cuid()) + sourceGroupId String + sourceGroup Group @relation("sourceGroup", fields: [sourceGroupId], references: [id]) + targetGroupId String + targetGroup Group @relation("targetGroup", fields: [targetGroupId], references: [id]) + isActive Boolean @default(true) + createdAt DateTime @default(now()) + + @@unique([sourceGroupId, targetGroupId]) +} + +model ConsentRecord { + id String @id @default(cuid()) + groupId String + group Group @relation(fields: [groupId], references: [id]) + memberJid String + consentType String + grantedAt DateTime @default(now()) + revokedAt DateTime? + + @@unique([groupId, memberJid, consentType]) +} +``` + +- [ ] **Step 3: Run the first migration** + +Make sure Docker Compose is running (from Task 6), then: + +```bash +cd apps/api +DATABASE_URL="postgresql://tower:tower_dev@localhost:5432/tower_dev" pnpm exec prisma migrate dev --name init_core_schema +cd ../.. +``` + +Expected output: +``` +Applying migration `20260527000000_init_core_schema` +Your database is now in sync with your schema. +Generated Prisma Client +``` + +Tables created: `Group`, `Message`, `Approval`, `SyncRoute`, `ConsentRecord` plus the two enums. + +- [ ] **Step 4: Write `PrismaService` and `PrismaModule`** + +Create `apps/api/src/prisma/prisma.service.ts`: +```typescript +import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +@Injectable() +export class PrismaService + extends PrismaClient + implements OnModuleInit, OnModuleDestroy +{ + async onModuleInit() { + await this.$connect(); + } + + async onModuleDestroy() { + await this.$disconnect(); + } +} +``` + +Create `apps/api/src/prisma/prisma.module.ts`: +```typescript +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} +``` + +- [ ] **Step 5: Run the integration tests — verify they pass** + +```bash +DATABASE_URL="postgresql://tower:tower_dev@localhost:5432/tower_dev" pnpm --filter @tower/api test +``` + +Expected: +``` +PASS src/prisma/prisma.service.spec.ts + PrismaService + ✓ connects to postgres + ✓ creates and retrieves a Group, then cleans up +``` + +- [ ] **Step 6: Rebuild to include generated client** + +```bash +pnpm --filter @tower/api build +``` + +Expected: no TS errors. + +- [ ] **Step 7: Commit** + +```bash +git add apps/api/prisma apps/api/src/prisma +git commit -m "feat: add Prisma schema and PrismaService with integration tests" +``` + +--- + +### Task 9: Health Check Module (TDD) + +**Files:** +- Create: `apps/api/src/modules/health/health.controller.spec.ts` +- Create: `apps/api/src/modules/health/health.controller.ts` +- Create: `apps/api/src/modules/health/health.module.ts` + +- [ ] **Step 1: Write the failing unit test** + +Create `apps/api/src/modules/health/health.controller.spec.ts`: +```typescript +import { Test, TestingModule } from '@nestjs/testing'; +import { HealthController } from './health.controller'; + +describe('HealthController', () => { + let controller: HealthController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [HealthController], + }).compile(); + controller = module.get(HealthController); + }); + + it('returns status "ok"', () => { + const result = controller.check(); + expect(result.status).toBe('ok'); + }); + + it('returns an ISO timestamp', () => { + const result = controller.check(); + expect(() => new Date(result.timestamp)).not.toThrow(); + expect(result.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it('returns the service name', () => { + const result = controller.check(); + expect(result.service).toBe('tower-api'); + }); +}); +``` + +Run: +```bash +pnpm --filter @tower/api test +``` + +Expected: **HealthController — 3 FAIL** — `Cannot find module './health.controller'`. + +- [ ] **Step 2: Write `HealthController`** + +Create `apps/api/src/modules/health/health.controller.ts`: +```typescript +import { Controller, Get } from '@nestjs/common'; + +@Controller('health') +export class HealthController { + @Get() + check() { + return { + status: 'ok', + service: 'tower-api', + timestamp: new Date().toISOString(), + }; + } +} +``` + +- [ ] **Step 3: Write `HealthModule`** + +Create `apps/api/src/modules/health/health.module.ts`: +```typescript +import { Module } from '@nestjs/common'; +import { HealthController } from './health.controller'; + +@Module({ + controllers: [HealthController], +}) +export class HealthModule {} +``` + +- [ ] **Step 4: Run tests — all pass** + +```bash +pnpm --filter @tower/api test +``` + +Expected: +``` +PASS src/modules/health/health.controller.spec.ts + HealthController + ✓ returns status "ok" + ✓ returns an ISO timestamp + ✓ returns the service name + +PASS src/prisma/prisma.service.spec.ts + PrismaService + ✓ connects to postgres + ✓ creates and retrieves a Group, then cleans up + +Tests: 5 passed +``` + +- [ ] **Step 5: Smoke-test the running API** + +```bash +pnpm --filter @tower/api dev & +``` + +Wait 3 seconds, then: + +```bash +curl http://localhost:3001/health +``` + +Expected: +```json +{"status":"ok","service":"tower-api","timestamp":"2026-05-27T..."} +``` + +Kill the background process: `kill %1` + +- [ ] **Step 6: Commit** + +```bash +git add apps/api/src/modules +git commit -m "feat: add health check module with unit tests" +``` + +--- + +### Task 10: Next.js 15 Web Application + +**Files:** +- Create: `apps/web/package.json` +- Create: `apps/web/tsconfig.json` +- Create: `apps/web/next.config.ts` +- Create: `apps/web/tailwind.config.ts` +- Create: `apps/web/postcss.config.js` +- Create: `apps/web/jest.config.js` +- Create: `apps/web/jest.setup.ts` +- Create: `apps/web/app/globals.css` +- Create: `apps/web/app/layout.tsx` +- Create: `apps/web/app/page.tsx` +- Create: `apps/web/app/page.test.tsx` + +- [ ] **Step 1: Write the failing render test** + +Create `apps/web/app/page.test.tsx`: +```typescript +import { render, screen } from '@testing-library/react'; +import Home from './page'; + +describe('Home page', () => { + it('renders the TOWER heading', () => { + render(); + expect(screen.getByRole('heading', { name: /insignia tower/i })).toBeInTheDocument(); + }); + + it('renders the platform tagline', () => { + render(); + expect(screen.getByText(/community knowledge infrastructure/i)).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Write `apps/web/package.json`** + +```json +{ + "name": "@tower/web", + "version": "0.0.1", + "scripts": { + "build": "next build", + "dev": "next dev --port 3000", + "start": "next start", + "test": "jest", + "lint": "next lint" + }, + "dependencies": { + "@tower/types": "workspace:*", + "@tower/ui": "workspace:*", + "next": "^16.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.0.0", + "@testing-library/jest-dom": "^6.0.0", + "@testing-library/react": "^16.0.0", + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "jest": "^29.0.0", + "jest-environment-jsdom": "^29.0.0", + "postcss": "^8.0.0", + "tailwindcss": "^4.0.0", + "ts-jest": "^29.0.0", + "typescript": "^5.7.0" + } +} +``` + +Create `apps/web/tsconfig.json`: +```json +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2017", + "module": "esnext", + "moduleResolution": "bundler", + "allowJs": true, + "jsx": "preserve", + "incremental": true, + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} +``` + +Create `apps/web/jest.config.js`: +```javascript +const nextJest = require('next/jest'); + +const createJestConfig = nextJest({ dir: './' }); + +module.exports = createJestConfig({ + setupFilesAfterEnv: ['/jest.setup.ts'], + testEnvironment: 'jest-environment-jsdom', + testMatch: ['**/*.test.tsx', '**/*.test.ts'], +}); +``` + +Create `apps/web/jest.setup.ts`: +```typescript +import '@testing-library/jest-dom'; +``` + +- [ ] **Step 3: Write Next.js and Tailwind configs** + +Create `apps/web/next.config.ts`: +```typescript +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = { + transpilePackages: ['@tower/ui'], +}; + +export default nextConfig; +``` + +Create `apps/web/postcss.config.js`: +```javascript +module.exports = { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; +``` + +Note: Tailwind v4 auto-detects content paths — no `tailwind.config.ts` needed for the basic setup. Custom theme tokens are added via CSS `@theme` blocks in `globals.css`. + +- [ ] **Step 4: Write the app shell** + +Create `apps/web/app/globals.css`: +```css +@import "tailwindcss"; +``` + +Create `apps/web/app/layout.tsx`: +```typescript +import type { Metadata } from 'next'; +import './globals.css'; + +export const metadata: Metadata = { + title: 'Insignia TOWER', + description: 'Community Knowledge Infrastructure Platform', +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} +``` + +Create `apps/web/app/page.tsx`: +```typescript +export default function Home() { + return ( +
+

Insignia TOWER

+

Community Knowledge Infrastructure Platform

+
+ ); +} +``` + +- [ ] **Step 5: Install, run tests, build and commit** + +```bash +pnpm --filter @tower/web install +pnpm --filter @tower/web test +``` + +Expected: +``` +PASS app/page.test.tsx + Home page + ✓ renders the TOWER heading + ✓ renders the platform tagline +``` + +```bash +pnpm --filter @tower/web build +``` + +Expected: Next.js build succeeds — `Route (app) /` listed, no errors. + +```bash +git add apps/web +git commit -m "feat: scaffold Next.js 15 web application with Tailwind" +``` + +--- + +### Task 11: Worker Application Shell + +**Files:** +- Create: `apps/worker/package.json` +- Create: `apps/worker/tsconfig.json` +- Create: `apps/worker/src/main.ts` + +- [ ] **Step 1: Write the worker entry point** + +Create `apps/worker/src/main.ts`: +```typescript +import { createLogger } from '@tower/logger'; + +const logger = createLogger('tower-worker'); + +logger.info('TOWER worker starting...'); + +process.on('SIGTERM', () => { + logger.info('TOWER worker shutting down gracefully'); + process.exit(0); +}); + +process.on('SIGINT', () => { + logger.info('TOWER worker interrupted'); + process.exit(0); +}); +``` + +- [ ] **Step 2: Write package config** + +Create `apps/worker/package.json`: +```json +{ + "name": "@tower/worker", + "version": "0.0.1", + "scripts": { + "build": "tsc", + "dev": "ts-node-dev --respawn --transpile-only src/main.ts", + "start": "node dist/main" + }, + "dependencies": { + "@tower/logger": "workspace:*", + "@tower/types": "workspace:*", + "bullmq": "^5.0.0", + "ioredis": "^5.0.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "ts-node-dev": "^2.0.0", + "typescript": "^5.7.0" + } +} +``` + +Create `apps/worker/tsconfig.json`: +```json +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} +``` + +- [ ] **Step 3: Build and verify** + +```bash +pnpm --filter @tower/worker install +pnpm --filter @tower/worker build +``` + +Expected: `apps/worker/dist/main.js` created, no errors. + +Verify it starts and exits cleanly: +```bash +node apps/worker/dist/main.js & +sleep 1 +kill %1 +``` + +Expected log: `TOWER worker starting...` then `TOWER worker shutting down gracefully`. + +- [ ] **Step 4: Commit** + +```bash +git add apps/worker +git commit -m "feat: add worker application shell" +``` + +--- + +### Task 12: Turborepo Pipeline Verification + Final Smoke Test + +**Files:** No new files — verifying all tasks wire together. + +- [ ] **Step 1: Run full monorepo build** + +```bash +pnpm build +``` + +Expected: Turborepo builds packages in dependency order, then apps. Final output: +``` +Tasks: 8 successful, 8 total +Cached: 0 cached, 8 total +Time: s +``` + +No errors. If there are any TS errors, fix them before proceeding. + +- [ ] **Step 2: Run all tests across the workspace** + +```bash +DATABASE_URL="postgresql://tower:tower_dev@localhost:5432/tower_dev" pnpm test +``` + +Expected: +``` +@tower/config: Tests: 5 passed +@tower/api: Tests: 5 passed (3 health + 2 prisma) +@tower/web: Tests: 2 passed +``` + +- [ ] **Step 3: Verify `turbo dev` starts all apps** + +```bash +pnpm dev +``` + +In another terminal, verify: +```bash +curl http://localhost:3001/health +# {"status":"ok","service":"tower-api","timestamp":"..."} + +curl http://localhost:3000 +# HTML response with "Insignia TOWER" in body +``` + +Stop with `Ctrl+C`. + +- [ ] **Step 4: Final commit** + +```bash +git add . +git commit -m "chore: verify full monorepo build, test, and dev pipeline" +``` + +--- + +## Self-Review + +### Spec Coverage + +| Requirement from context file | Covered | +|---|---| +| `apps/api` — NestJS | ✅ Task 7 | +| `apps/web` — Next.js 15, Tailwind, shadcn/ui | ✅ Task 10 (shadcn populated in Plan 5) | +| `apps/worker` | ✅ Task 11 | +| `packages/ui` | ✅ Task 5 | +| `packages/types` | ✅ Task 2 | +| `packages/config` | ✅ Task 3 | +| `packages/logger` | ✅ Task 4 | +| `packages/sdk` | ✅ Task 5 | +| PostgreSQL + Prisma ORM | ✅ Task 8 | +| Redis | ✅ Task 6 (Docker) | +| Meilisearch | ✅ Task 6 (Docker, integrated in Plan 4) | +| Docker everywhere | ✅ Task 6 | +| DB tables: groups, messages, sync_routes, approvals, consent_records | ✅ Task 8 | +| Canonical message schema | ✅ Task 2 (`@tower/types`) | + +### Placeholder Scan + +No TBDs, no "implement later", no "similar to Task N" references. All code blocks are complete and self-contained. + +### Type Consistency + +- `Group`, `Message`, `SyncRoute`, `ConsentRecord` defined in schema.prisma (Task 8) and mirrored as interfaces in `@tower/types` (Task 2) — names consistent. +- `MessageStatus` and `ApprovalDecision` enums match between Prisma schema and `@tower/types`. +- `PrismaService` imported in `prisma.service.spec.ts` matches the export in `prisma.service.ts`. +- `HealthController` imported in `health.controller.spec.ts` matches the export in `health.controller.ts`. diff --git a/docs/superpowers/plans/2026-05-27-multi-account-approval-workflow.md b/docs/superpowers/plans/2026-05-27-multi-account-approval-workflow.md new file mode 100644 index 0000000..b5fed4c --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-multi-account-approval-workflow.md @@ -0,0 +1,1243 @@ +# Multi-Account Architecture, Adapter Boundary & Approval Workflow 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:** Seal the WhatsApp adapter so Baileys types never leak outside `src/whatsapp/`; add multi-account bot support via a session pool loaded from the DB; implement the ⭐ star reaction approval workflow with a rate-limited cross-group forward queue. + +**Architecture:** `NormalizedMessage` and `NormalizedReaction` move to `@tower/types` (platform-neutral). `session.ts` normalizes raw Baileys events internally before calling any callback — `main.ts` never imports from `@whiskeysockets/baileys`. `WhatsAppSessionPool` manages N sessions indexed by `accountId`, loaded from the `accounts` DB table on startup. A ⭐ reaction from an admin JID triggers `handleStarReaction` → writes `Approval` record → enqueues `ForwardJobData` jobs into a rate-limited BullMQ queue (20 forwards/min) to avoid WhatsApp bans. + +**Tech Stack:** BullMQ 5, Prisma 6, Baileys 7.0.0-rc13, TypeScript 5, Jest 29, pnpm workspaces + +--- + +## File Map + +**Modify:** +- `packages/types/src/message.ts` — add `NormalizedMessage`, `NormalizedReaction`, `ForwardJobData`; add `accountId` to `IngestJobData` +- `apps/api/prisma/schema.prisma` — add `Account` model, `AccountStatus` enum; add optional `accountId` to `Group` +- `apps/worker/src/whatsapp/normalizer.ts` — import `NormalizedMessage`/`NormalizedReaction` from `@tower/types`; accept `accountId` param; add `normalizeReaction` +- `apps/worker/src/whatsapp/normalizer.test.ts` — add reaction tests +- `apps/worker/src/whatsapp/group-sync.ts` — accept `accountId` param, set on group upsert +- `apps/worker/src/whatsapp/session.ts` — normalize + react inside handler; change callback types; accept `accountId` as first param +- `apps/worker/src/main.ts` — use pool, load accounts from DB, wire reactions through approval + +**Create:** +- `apps/worker/src/whatsapp/session-pool.ts` — `WhatsAppSessionPool` class +- `apps/worker/src/core/approval.ts` — `handleStarReaction` +- `apps/worker/src/core/approval.test.ts` +- `apps/worker/src/queues/forward.queue.ts` +- `apps/worker/src/queues/forward.processor.ts` +- `apps/worker/src/queues/forward.processor.test.ts` + +--- + +### Task 1: Extend @tower/types + +**Files:** +- Modify: `packages/types/src/message.ts` + +Add `NormalizedMessage`, `NormalizedReaction`, `ForwardJobData` as platform-neutral shared types. Add `accountId` to `IngestJobData`. These types must contain zero Baileys imports. + +- [ ] **Step 1: Replace `packages/types/src/message.ts` entirely** + +```typescript +export type Platform = 'whatsapp' | 'telegram' | 'discord'; + +export type MessageStatus = + | 'PENDING' + | 'APPROVED' + | 'REJECTED' + | 'DISTRIBUTED' + | 'ARCHIVED'; + +export type ApprovalDecision = 'APPROVED' | 'REJECTED'; + +// Platform-neutral normalized message — zero Baileys/Telegram types here +export interface NormalizedMessage { + platformMsgId: string; + sourceGroupJid: string; + senderJid: string; + senderName?: string; + content: string; + accountId: string; // which bot account received this message +} + +// Platform-neutral normalized reaction (e.g. WhatsApp emoji reaction) +export interface NormalizedReaction { + reactorJid: string; + targetMsgId: string; // platformMsgId of the message being reacted to + sourceGroupJid: string; + emoji: string; + accountId: string; // which bot account received this reaction +} + +export interface CanonicalMessage { + messageId: string; + platform: Platform; + platformMsgId: string; + sourceGroupId: string; + senderJid: string; + senderName?: string; + content: string; + mediaUrl?: string; + tags: string[]; + status: MessageStatus; + createdAt: Date; +} + +export interface Group { + id: string; + platform: Platform; + platformId: string; + name: string; + description?: string; + isActive: boolean; +} + +export interface SyncRoute { + id: string; + sourceGroupId: string; + targetGroupId: string; + isActive: boolean; +} + +export interface IngestJobData { + platformMsgId: string; + platform: Platform; + accountId: string; // which bot account received this message + sourceGroupId: string; + senderJid: string; + senderName?: string; + content: string; + tags: string[]; +} + +export interface ForwardJobData { + messageId: string; // DB id of the approved Message record + content: string; + sourceGroupName: string; + senderName?: string; + toGroupJid: string; + fromAccountId: string; // which bot account to send the forward from +} +``` + +- [ ] **Step 2: Confirm types package compiles with zero errors** + +```bash +cd /path/to/repo && pnpm --filter @tower/types build +``` + +Expected: exits 0, no TypeScript errors. + +- [ ] **Step 3: Commit** + +```bash +git add packages/types/src/message.ts +git commit -m "feat(types): NormalizedMessage, NormalizedReaction, ForwardJobData; accountId on IngestJobData" +``` + +--- + +### Task 2: Seal the WhatsApp adapter boundary + +**Files:** +- Modify: `apps/worker/src/whatsapp/normalizer.ts` +- Modify: `apps/worker/src/whatsapp/normalizer.test.ts` +- Modify: `apps/worker/src/whatsapp/session.ts` + +After this task: `main.ts` will never import anything from `@whiskeysockets/baileys`. All Baileys types stay inside `src/whatsapp/`. + +- [ ] **Step 1: Replace `apps/worker/src/whatsapp/normalizer.ts`** + +```typescript +import { proto } from '@whiskeysockets/baileys'; +import { NormalizedMessage, NormalizedReaction } from '@tower/types'; + +function extractText(msg: proto.IWebMessageInfo): string { + const m = msg.message; + if (!m) return ''; + return ( + m.conversation || + m.extendedTextMessage?.text || + m.imageMessage?.caption || + m.videoMessage?.caption || + m.documentMessage?.caption || + '' + ); +} + +export function normalizeMessage( + msg: proto.IWebMessageInfo, + accountId: string, +): NormalizedMessage | null { + const key = msg.key; + if (!key) return null; + + const remoteJid = key.remoteJid ?? ''; + if (!remoteJid.endsWith('@g.us')) return null; + if (key.fromMe) return null; + if (!msg.message) return null; + + const platformMsgId = key.id; + if (!platformMsgId) return null; + + const content = extractText(msg); + + return { + platformMsgId, + sourceGroupJid: remoteJid, + senderJid: key.participant ?? '', + senderName: msg.pushName ?? undefined, + content, + accountId, + }; +} + +export function normalizeReaction( + msg: proto.IWebMessageInfo, + accountId: string, +): NormalizedReaction | null { + const key = msg.key; + if (!key) return null; + + const remoteJid = key.remoteJid ?? ''; + if (!remoteJid.endsWith('@g.us')) return null; + if (key.fromMe) return null; + + const reaction = msg.message?.reactionMessage; + if (!reaction) return null; + + const targetMsgId = reaction.key?.id; + if (!targetMsgId) return null; + + return { + reactorJid: key.participant ?? '', + targetMsgId, + sourceGroupJid: remoteJid, + emoji: reaction.text ?? '', + accountId, + }; +} +``` + +- [ ] **Step 2: Write failing tests for normalizeReaction** + +Append to `apps/worker/src/whatsapp/normalizer.test.ts` (keep all existing tests, add below): + +```typescript +import { normalizeReaction } from './normalizer'; + +describe('normalizeReaction', () => { + it('normalizes a star reaction from a group participant', () => { + const msg = { + key: { + remoteJid: '120363043312345678@g.us', + fromMe: false, + id: 'REACTION_ID', + participant: '919876543210@s.whatsapp.net', + }, + message: { + reactionMessage: { + key: { remoteJid: '120363043312345678@g.us', id: 'TARGET_MSG_ID' }, + text: '⭐', + }, + }, + } as proto.IWebMessageInfo; + + const result = normalizeReaction(msg, 'acc_1'); + expect(result).toMatchObject({ + reactorJid: '919876543210@s.whatsapp.net', + targetMsgId: 'TARGET_MSG_ID', + sourceGroupJid: '120363043312345678@g.us', + emoji: '⭐', + accountId: 'acc_1', + }); + }); + + it('returns null when reactionMessage is missing (regular message)', () => { + const msg = { + key: { remoteJid: '120363043312345678@g.us', fromMe: false, id: 'ID' }, + message: { conversation: 'hello' }, + } as proto.IWebMessageInfo; + expect(normalizeReaction(msg, 'acc_1')).toBeNull(); + }); + + it('returns null for own reactions (fromMe=true)', () => { + const msg = { + key: { remoteJid: '120363043312345678@g.us', fromMe: true, id: 'ID' }, + message: { reactionMessage: { key: { id: 'TARGET' }, text: '⭐' } }, + } as proto.IWebMessageInfo; + expect(normalizeReaction(msg, 'acc_1')).toBeNull(); + }); + + it('returns null for DM reactions (non-group jid)', () => { + const msg = { + key: { remoteJid: '919876543210@s.whatsapp.net', fromMe: false, id: 'ID' }, + message: { reactionMessage: { key: { id: 'TARGET' }, text: '⭐' } }, + } as proto.IWebMessageInfo; + expect(normalizeReaction(msg, 'acc_1')).toBeNull(); + }); + + it('returns null when targetMsgId is missing', () => { + const msg = { + key: { + remoteJid: '120363043312345678@g.us', + fromMe: false, + id: 'ID', + participant: '91@s.whatsapp.net', + }, + message: { reactionMessage: { key: {}, text: '⭐' } }, + } as proto.IWebMessageInfo; + expect(normalizeReaction(msg, 'acc_1')).toBeNull(); + }); +}); +``` + +Also update the `normalizeMessage` tests: the function signature changed to accept `accountId` as second param. Update every `normalizeMessage(makeMsg(...))` call to `normalizeMessage(makeMsg(...), 'acc_1')` and add `accountId: 'acc_1'` to the expected `toMatchObject` where relevant. + +- [ ] **Step 3: Run normalizer tests to confirm they pass** + +```bash +pnpm --filter @tower/worker test -- --testPathPattern=normalizer +``` + +Expected: all existing tests + 5 new reaction tests pass. + +- [ ] **Step 4: Replace `apps/worker/src/whatsapp/session.ts`** + +```typescript +import makeWASocket, { + useMultiFileAuthState, + fetchLatestBaileysVersion, + DisconnectReason, + WASocket, + GroupMetadata, +} from '@whiskeysockets/baileys'; +import { Boom } from '@hapi/boom'; +import qrcode from 'qrcode-terminal'; +import { NormalizedMessage, NormalizedReaction } from '@tower/types'; +import { normalizeMessage, normalizeReaction } from './normalizer'; +import { createLogger } from '@tower/logger'; + +const logger = createLogger('whatsapp-session'); + +export type OnMessageCallback = (msg: NormalizedMessage) => Promise | void; +export type OnReactionCallback = (reaction: NormalizedReaction) => Promise | void; +export type OnGroupsCallback = (groups: Record) => Promise | void; + +export async function createWhatsAppSession( + accountId: string, + sessionPath: string, + onMessage: OnMessageCallback, + onReaction: OnReactionCallback, + onGroups: OnGroupsCallback, +): Promise { + const { state, saveCreds } = await useMultiFileAuthState(sessionPath); + const { version } = await fetchLatestBaileysVersion(); + + const sock = makeWASocket({ + version, + auth: state, + printQRInTerminal: false, + logger: logger as any, + }); + + sock.ev.on('creds.update', saveCreds); + + sock.ev.on('connection.update', async ({ connection, qr, lastDisconnect }) => { + if (qr) { + qrcode.generate(qr, { small: true }); + } + if (connection === 'close') { + const reason = (lastDisconnect?.error as Boom)?.output?.statusCode; + const shouldReconnect = reason !== DisconnectReason.loggedOut; + logger.info({ reason, shouldReconnect }, 'Connection closed'); + if (shouldReconnect) { + logger.info('Reconnecting in 5s...'); + setTimeout( + () => createWhatsAppSession(accountId, sessionPath, onMessage, onReaction, onGroups), + 5000, + ); + } + } else if (connection === 'open') { + 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 5: Commit** + +```bash +git add apps/worker/src/whatsapp/normalizer.ts \ + apps/worker/src/whatsapp/normalizer.test.ts \ + apps/worker/src/whatsapp/session.ts +git commit -m "feat(worker): seal WhatsApp adapter — normalize inside session, reactions handled internally" +``` + +--- + +### Task 3: Prisma — Account model and Group.accountId + +**Files:** +- Modify: `apps/api/prisma/schema.prisma` + +- [ ] **Step 1: Add `AccountStatus` enum and `Account` model to the schema** + +Open `apps/api/prisma/schema.prisma`. Append after the `ConsentRecord` model block: + +```prisma +enum AccountStatus { + ACTIVE + DISCONNECTED + BANNED +} + +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]) +} +``` + +- [ ] **Step 2: Add optional `accountId` to the `Group` model** + +The full updated `Group` model (replace the existing one): + +```prisma +model Group { + id String @id @default(cuid()) + platform String + platformId String + name String + description String? + isActive Boolean @default(true) + accountId String? + account Account? @relation(fields: [accountId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + messages Message[] + syncRoutesFrom SyncRoute[] @relation("sourceGroup") + syncRoutesTo SyncRoute[] @relation("targetGroup") + consentRecords ConsentRecord[] + + @@unique([platform, platformId]) +} +``` + +- [ ] **Step 3: Run migration** + +```bash +cd apps/api && DATABASE_URL="postgresql://tower:tower@localhost:5433/tower" \ + npx prisma migrate dev --name add-account-model +``` + +Expected: "Your database is now in sync with your schema." + +- [ ] **Step 4: Regenerate Prisma client in worker** + +```bash +pnpm --filter @tower/worker generate +``` + +Expected: "Generated Prisma Client..." + +- [ ] **Step 5: Seed the first Account record** + +Find your WhatsApp session's JID. It is stored in `sessions/creds.json` (the file at the path you set as `WHATSAPP_SESSION_PATH` in `.env`). Open it and look for `"me": { "id": "..." }` — that string is your JID. + +Insert the account record (replace `YOUR_JID` and `YOUR_SESSION_PATH`): + +```bash +psql "postgresql://tower:tower@localhost:5433/tower" <<'SQL' +INSERT INTO "Account" (id, platform, jid, "sessionPath", "displayName", status, "createdAt", "updatedAt") +VALUES ( + 'acc_' || substring(gen_random_uuid()::text, 1, 8), + 'whatsapp', + 'YOUR_JID', + 'YOUR_SESSION_PATH', + 'Bot 1', + 'ACTIVE', + now(), + now() +) +ON CONFLICT DO NOTHING; +SQL +``` + +Verify it was inserted: + +```bash +psql "postgresql://tower:tower@localhost:5433/tower" -c 'SELECT id, jid, "sessionPath", status FROM "Account";' +``` + +Expected: one row with your JID and `ACTIVE` status. + +- [ ] **Step 6: Commit** + +```bash +git add apps/api/prisma/schema.prisma apps/api/prisma/migrations/ +git commit -m "feat(schema): Account model with AccountStatus enum, optional Group.accountId" +``` + +--- + +### Task 4: WhatsAppSessionPool and updated group-sync + +**Files:** +- Modify: `apps/worker/src/whatsapp/group-sync.ts` +- Create: `apps/worker/src/whatsapp/session-pool.ts` + +- [ ] **Step 1: Update `group-sync.ts` to accept `accountId`** + +Replace `apps/worker/src/whatsapp/group-sync.ts`: + +```typescript +import { GroupMetadata } from '@whiskeysockets/baileys'; +import { createLogger } from '@tower/logger'; + +const logger = createLogger('group-sync'); + +export async function syncGroups( + groups: Record, + accountId: string, + prisma: any, +): Promise> { + const jidToDbId = new Map(); + + for (const [jid, meta] of Object.entries(groups)) { + const group = await prisma.group.upsert({ + where: { platform_platformId: { platform: 'whatsapp', platformId: jid } }, + create: { + platform: 'whatsapp', + platformId: jid, + name: meta.subject, + description: meta.desc ?? undefined, + isActive: true, + accountId, + }, + update: { + name: meta.subject, + description: meta.desc ?? undefined, + accountId, + }, + }); + jidToDbId.set(jid, group.id); + } + + logger.info({ count: jidToDbId.size, accountId }, 'Groups synced'); + return jidToDbId; +} +``` + +- [ ] **Step 2: Create `apps/worker/src/whatsapp/session-pool.ts`** + +```typescript +import { WASocket } from '@whiskeysockets/baileys'; +import { NormalizedMessage, NormalizedReaction } from '@tower/types'; +import { createWhatsAppSession } from './session'; +import { createLogger } from '@tower/logger'; + +const logger = createLogger('session-pool'); + +// Callbacks the pool exposes to main.ts — accountId injected by the pool +export type PoolMessageCallback = (msg: NormalizedMessage, accountId: string) => Promise | void; +export type PoolReactionCallback = (reaction: NormalizedReaction, accountId: string) => Promise | void; +// groups typed as `any` — GroupMetadata (Baileys type) stays inside whatsapp/ folder +export type PoolGroupsCallback = (groups: any, accountId: string) => Promise | void; + +export class WhatsAppSessionPool { + private sessions = new Map(); + + async add( + accountId: string, + sessionPath: string, + onMessage: PoolMessageCallback, + onReaction: PoolReactionCallback, + onGroups: PoolGroupsCallback, + ): Promise { + logger.info({ accountId }, 'Starting session'); + const sock = await createWhatsAppSession( + accountId, + sessionPath, + (msg) => onMessage(msg, accountId), + (reaction) => onReaction(reaction, accountId), + (groups) => onGroups(groups, accountId), + ); + this.sessions.set(accountId, sock); + } + + get(accountId: string): WASocket | undefined { + return this.sessions.get(accountId); + } + + getAll(): Map { + return this.sessions; + } + + async sendMessage(accountId: string, groupJid: string, text: string): Promise { + const sock = this.sessions.get(accountId); + if (!sock) throw new Error(`No active session for account ${accountId}`); + await sock.sendMessage(groupJid, { text }); + } + + async remove(accountId: string): Promise { + const sock = this.sessions.get(accountId); + if (sock) { + await sock.logout().catch(() => {}); + this.sessions.delete(accountId); + logger.info({ accountId }, 'Session removed'); + } + } +} +``` + +- [ ] **Step 3: Confirm worker compiles (ignoring main.ts errors for now)** + +```bash +pnpm --filter @tower/worker build 2>&1 | grep -v "main.ts" +``` + +Expected: no errors outside `main.ts` (it still uses old signatures — fixed in Task 7). + +- [ ] **Step 4: Commit** + +```bash +git add apps/worker/src/whatsapp/session-pool.ts apps/worker/src/whatsapp/group-sync.ts +git commit -m "feat(worker): WhatsAppSessionPool + group-sync accepts accountId" +``` + +--- + +### Task 5: Approval core logic + +**Files:** +- Create: `apps/worker/src/core/approval.test.ts` +- Create: `apps/worker/src/core/approval.ts` + +**Context:** When an admin reacts with ⭐ to a group message, `handleStarReaction` finds the message in DB, marks it `APPROVED`, creates an `Approval` record, then returns `ForwardJobData[]` — one entry per active `SyncRoute` from the message's source group. The caller (main.ts) enqueues those as forward jobs. + +The Prisma relation graph: `Message.sourceGroup → Group.syncRoutesFrom → SyncRoute.targetGroup → Group`. The query must include `sourceGroup.syncRoutesFrom.targetGroup` — NOT a top-level `syncRoutesFrom` on Message (that relation doesn't exist on Message). + +- [ ] **Step 1: Write the failing tests** + +Create `apps/worker/src/core/approval.test.ts`: + +```typescript +import { handleStarReaction } from './approval'; +import { NormalizedReaction } from '@tower/types'; + +function makeReaction(overrides: Partial = {}): NormalizedReaction { + return { + reactorJid: '919876543210@s.whatsapp.net', + targetMsgId: 'TARGET_MSG_123', + sourceGroupJid: '120363043312345678@g.us', + emoji: '⭐', + accountId: 'acc_1', + ...overrides, + }; +} + +const adminJids = ['919876543210@s.whatsapp.net']; + +describe('handleStarReaction', () => { + it('returns null for non-star emoji', async () => { + const result = await handleStarReaction(makeReaction({ emoji: '👍' }), adminJids, {} as any); + expect(result).toBeNull(); + }); + + it('returns null when reactor is not an admin', async () => { + const result = await handleStarReaction( + makeReaction({ reactorJid: 'stranger@s.whatsapp.net' }), + adminJids, + {} as any, + ); + expect(result).toBeNull(); + }); + + it('returns null when message not found', async () => { + const prisma = { message: { findUnique: jest.fn().mockResolvedValue(null) } } as any; + const result = await handleStarReaction(makeReaction(), adminJids, prisma); + expect(result).toBeNull(); + expect(prisma.message.findUnique).toHaveBeenCalledWith({ + where: { + platform_platformMsgId: { platform: 'whatsapp', platformMsgId: 'TARGET_MSG_123' }, + }, + include: { + approval: true, + sourceGroup: { + include: { + syncRoutesFrom: { where: { isActive: true }, include: { targetGroup: true } }, + }, + }, + }, + }); + }); + + it('returns null when message status is not PENDING', async () => { + const prisma = { + message: { + findUnique: jest.fn().mockResolvedValue({ + id: 'msg_1', + status: 'REJECTED', + approval: null, + sourceGroup: { name: 'Test Group', syncRoutesFrom: [] }, + }), + }, + } as any; + expect(await handleStarReaction(makeReaction(), adminJids, prisma)).toBeNull(); + }); + + it('returns null when message is already approved (approval record exists)', async () => { + const prisma = { + message: { + findUnique: jest.fn().mockResolvedValue({ + id: 'msg_1', + status: 'APPROVED', + approval: { id: 'appr_1' }, + sourceGroup: { name: 'Test Group', syncRoutesFrom: [] }, + }), + }, + } as any; + expect(await handleStarReaction(makeReaction(), adminJids, prisma)).toBeNull(); + }); + + it('approves message and returns empty array when no sync routes', async () => { + const prisma = { + message: { + findUnique: jest.fn().mockResolvedValue({ + id: 'msg_1', + status: 'PENDING', + approval: null, + content: 'hello', + senderName: 'Alice', + sourceGroup: { name: 'UP Parivar Dallas', syncRoutesFrom: [] }, + }), + update: jest.fn().mockResolvedValue({}), + }, + approval: { create: jest.fn().mockResolvedValue({}) }, + } as any; + + const result = await handleStarReaction(makeReaction(), adminJids, prisma); + expect(result).toEqual([]); + expect(prisma.message.update).toHaveBeenCalledWith({ + where: { id: 'msg_1' }, + data: { status: 'APPROVED' }, + }); + expect(prisma.approval.create).toHaveBeenCalledWith({ + data: { + messageId: 'msg_1', + adminId: '919876543210@s.whatsapp.net', + decision: 'APPROVED', + }, + }); + }); + + it('returns ForwardJobData for each active sync route', async () => { + const prisma = { + message: { + findUnique: jest.fn().mockResolvedValue({ + id: 'msg_1', + status: 'PENDING', + approval: null, + content: 'important announcement', + senderName: 'Bob', + sourceGroup: { + name: 'Source Group', + syncRoutesFrom: [ + { targetGroup: { platformId: '999@g.us', accountId: 'acc_2' } }, + { targetGroup: { platformId: '888@g.us', accountId: null } }, + ], + }, + }), + update: jest.fn().mockResolvedValue({}), + }, + approval: { create: jest.fn().mockResolvedValue({}) }, + } as any; + + const result = await handleStarReaction(makeReaction(), adminJids, prisma); + expect(result).toHaveLength(2); + expect(result![0]).toMatchObject({ + messageId: 'msg_1', + content: 'important announcement', + sourceGroupName: 'Source Group', + senderName: 'Bob', + toGroupJid: '999@g.us', + fromAccountId: 'acc_2', + }); + // falls back to reaction.accountId when targetGroup.accountId is null + expect(result![1]).toMatchObject({ + toGroupJid: '888@g.us', + fromAccountId: 'acc_1', + }); + }); +}); +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +pnpm --filter @tower/worker test -- --testPathPattern=approval +``` + +Expected: FAIL — "Cannot find module './approval'" + +- [ ] **Step 3: Create `apps/worker/src/core/approval.ts`** + +```typescript +import { NormalizedReaction, ForwardJobData } from '@tower/types'; + +export async function handleStarReaction( + reaction: NormalizedReaction, + adminJids: string[], + prisma: any, +): Promise { + if (reaction.emoji !== '⭐') return null; + if (!adminJids.includes(reaction.reactorJid)) return null; + + const message = await prisma.message.findUnique({ + where: { + platform_platformMsgId: { + platform: 'whatsapp', + platformMsgId: reaction.targetMsgId, + }, + }, + include: { + approval: true, + sourceGroup: { + include: { + syncRoutesFrom: { where: { isActive: true }, include: { targetGroup: true } }, + }, + }, + }, + }); + + if (!message) return null; + if (message.status !== 'PENDING') return null; + if (message.approval) return null; + + await prisma.message.update({ + where: { id: message.id }, + data: { status: 'APPROVED' }, + }); + + await prisma.approval.create({ + data: { + messageId: message.id, + adminId: reaction.reactorJid, + decision: 'APPROVED', + }, + }); + + const jobs: ForwardJobData[] = message.sourceGroup.syncRoutesFrom.map((route: any) => ({ + messageId: message.id, + content: message.content, + sourceGroupName: message.sourceGroup.name, + senderName: message.senderName ?? undefined, + toGroupJid: route.targetGroup.platformId, + fromAccountId: route.targetGroup.accountId ?? reaction.accountId, + })); + + return jobs; +} +``` + +- [ ] **Step 4: Run tests to confirm they pass** + +```bash +pnpm --filter @tower/worker test -- --testPathPattern=approval +``` + +Expected: 7 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add apps/worker/src/core/approval.ts apps/worker/src/core/approval.test.ts +git commit -m "feat(worker): handleStarReaction approval core with tests" +``` + +--- + +### Task 6: Forward queue and processor + +**Files:** +- Create: `apps/worker/src/queues/forward.processor.test.ts` +- Create: `apps/worker/src/queues/forward.processor.ts` +- Create: `apps/worker/src/queues/forward.queue.ts` + +The rate limiter (20 forwards/min) goes on the **Worker**, not the Queue — this is the BullMQ v5 pattern. + +- [ ] **Step 1: Write the failing processor test** + +Create `apps/worker/src/queues/forward.processor.test.ts`: + +```typescript +import { processForwardJob } from './forward.processor'; +import { ForwardJobData } from '@tower/types'; + +const mockPool = { sendMessage: jest.fn().mockResolvedValue(undefined) }; + +const baseJob: ForwardJobData = { + messageId: 'msg_1', + content: 'Event this Saturday at the temple', + sourceGroupName: 'UP Parivar Dallas', + senderName: 'Rajesh', + toGroupJid: '120363099999@g.us', + fromAccountId: 'acc_1', +}; + +describe('processForwardJob', () => { + beforeEach(() => jest.clearAllMocks()); + + it('sends a formatted message via the pool', async () => { + await processForwardJob(baseJob, mockPool as any); + expect(mockPool.sendMessage).toHaveBeenCalledWith( + 'acc_1', + '120363099999@g.us', + expect.stringContaining('Event this Saturday at the temple'), + ); + }); + + it('includes source group name in the forwarded text', async () => { + await processForwardJob(baseJob, mockPool as any); + const [, , text] = mockPool.sendMessage.mock.calls[0]; + expect(text).toContain('UP Parivar Dallas'); + }); + + it('includes sender name in the forwarded text', async () => { + await processForwardJob(baseJob, mockPool as any); + const [, , text] = mockPool.sendMessage.mock.calls[0]; + expect(text).toContain('Rajesh'); + }); + + it('handles missing senderName without throwing', async () => { + await processForwardJob({ ...baseJob, senderName: undefined }, mockPool as any); + expect(mockPool.sendMessage).toHaveBeenCalledTimes(1); + }); + + it('throws when pool has no session for the account', async () => { + const brokenPool = { + sendMessage: jest.fn().mockRejectedValue(new Error('No active session for account acc_99')), + }; + await expect( + processForwardJob({ ...baseJob, fromAccountId: 'acc_99' }, brokenPool as any), + ).rejects.toThrow('No active session'); + }); +}); +``` + +- [ ] **Step 2: Run test to confirm it fails** + +```bash +pnpm --filter @tower/worker test -- --testPathPattern=forward.processor +``` + +Expected: FAIL — "Cannot find module './forward.processor'" + +- [ ] **Step 3: Create `apps/worker/src/queues/forward.processor.ts`** + +```typescript +import { Worker } from 'bullmq'; +import { ForwardJobData } from '@tower/types'; +import { parseRedisUrl } from './redis-connection'; +import { WhatsAppSessionPool } from '../whatsapp/session-pool'; + +function formatForwardText(job: ForwardJobData): string { + const sender = job.senderName ? `_${job.senderName}_` : '_Unknown_'; + return `📢 *${job.sourceGroupName}*\n${sender}:\n\n${job.content}\n\n_— Forwarded by TOWER_`; +} + +export async function processForwardJob( + job: ForwardJobData, + pool: WhatsAppSessionPool, +): Promise { + await pool.sendMessage(job.fromAccountId, job.toGroupJid, formatForwardText(job)); +} + +export function createForwardWorker( + redisUrl: string, + pool: WhatsAppSessionPool, +): Worker { + return new Worker( + 'tower-forward', + async (job) => processForwardJob(job.data, pool), + { + connection: parseRedisUrl(redisUrl), + limiter: { max: 20, duration: 60_000 }, // 20 forwards per minute — avoids WhatsApp bans + }, + ); +} +``` + +- [ ] **Step 4: Create `apps/worker/src/queues/forward.queue.ts`** + +```typescript +import { Queue } from 'bullmq'; +import { ForwardJobData } from '@tower/types'; +import { parseRedisUrl } from './redis-connection'; + +export function createForwardQueue(redisUrl: string): Queue { + return new Queue('tower-forward', { + connection: parseRedisUrl(redisUrl), + }); +} +``` + +- [ ] **Step 5: Run processor tests to confirm they pass** + +```bash +pnpm --filter @tower/worker test -- --testPathPattern=forward.processor +``` + +Expected: 5 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add apps/worker/src/queues/forward.queue.ts \ + apps/worker/src/queues/forward.processor.ts \ + apps/worker/src/queues/forward.processor.test.ts +git commit -m "feat(worker): forward queue + processor with 20/min rate limiter" +``` + +--- + +### Task 7: Wire main.ts — multi-account pool, reactions, full pipeline + +**Files:** +- Modify: `apps/worker/src/main.ts` + +This is the final assembly. `main.ts` no longer imports anything from `@whiskeysockets/baileys`. It loads accounts from the DB, creates the pool, and wires: ingest message → tag detect → ingest queue; reaction → approval → forward queue. + +- [ ] **Step 1: Replace `apps/worker/src/main.ts` entirely** + +```typescript +import { PrismaClient } from '@prisma/client'; +import { createLogger } from '@tower/logger'; +import { validateEnv } from '@tower/config'; +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 { 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 ingestQueue = createIngestQueue(env.REDIS_URL); + const forwardQueue = createForwardQueue(env.REDIS_URL); + const pool = new WhatsAppSessionPool(); + + const ingestWorker = createIngestWorker(env.REDIS_URL, prisma); + const forwardWorker = createForwardWorker(env.REDIS_URL, pool); + + 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')); + + // Load active accounts from DB — each becomes one WhatsApp session + const accounts = await prisma.account.findMany({ + where: { status: 'ACTIVE', platform: 'whatsapp' }, + }); + + if (accounts.length === 0) { + logger.warn('No active WhatsApp accounts found — add one via Prisma Studio (see Task 3 Step 5)'); + } + + // Per-account map of groupJid → DB Group id + const groupMaps = new Map>(); + + for (const account of accounts) { + groupMaps.set(account.id, new Map()); + + 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) ?? new Map(); + 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 forwardJobs = await handleStarReaction(reaction, adminJids, prisma); + if (!forwardJobs || forwardJobs.length === 0) return; + + for (const job of forwardJobs) { + await forwardQueue.add('forward', job, { + attempts: 3, + backoff: { type: 'exponential', delay: 2000 }, + }); + } + + logger.info( + { count: forwardJobs.length, messageId: forwardJobs[0]?.messageId }, + 'Forward jobs enqueued', + ); + }, + async (groups, accountId) => { + logger.info({ count: Object.keys(groups).length, accountId }, 'Syncing groups'); + const map = await syncGroups(groups, accountId, prisma); + groupMaps.set(accountId, map); + }, + ); + } + + logger.info({ accountCount: accounts.length }, 'Tower worker ready'); + + const shutdown = async () => { + logger.info('Shutting down...'); + await ingestWorker.close(); + await forwardWorker.close(); + await ingestQueue.close(); + await forwardQueue.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 2: Build to confirm zero TypeScript errors** + +```bash +pnpm --filter @tower/worker build +``` + +Expected: 0 errors. `dist/` created. + +- [ ] **Step 3: Run all worker tests** + +```bash +pnpm --filter @tower/worker test +``` + +Expected: all tests pass (normalizer ×13, approval ×7, forward.processor ×5). + +- [ ] **Step 4: Start the worker and verify multi-account startup** + +```bash +pnpm --filter @tower/worker dev +``` + +Expected log output (with one account seeded): + +``` +INFO (tower-worker): Tower worker ready {"accountCount": 1} +INFO (whatsapp-session): WhatsApp connected {"accountId": "acc_..."} +INFO (group-sync): Groups synced {"count": N, "accountId": "acc_..."} +``` + +If `accountCount` is 0, the account row wasn't inserted in Task 3 Step 5 — insert it now. + +- [ ] **Step 5: End-to-end smoke test** + +With the worker running and the WhatsApp session authenticated: + +1. Send a message containing `#important` from a non-bot number to a group the bot is in. +2. Confirm the worker logs `"Message enqueued"` with the correct tags. +3. Check the DB: `psql "postgresql://tower:tower@localhost:5433/tower" -c 'SELECT status, tags FROM "Message" ORDER BY "createdAt" DESC LIMIT 3;'` + Expected: one row with `status = PENDING` and `tags = {#important}`. +4. React to that message with ⭐ from an admin JID (the JID listed in `TOWER_ADMIN_JIDS` in `.env`). +5. Confirm the worker logs `"Forward jobs enqueued"`. +6. Check DB approvals: `SELECT decision, "adminId" FROM "Approval" ORDER BY "decidedAt" DESC LIMIT 1;` + Expected: one row with `decision = APPROVED`. + +- [ ] **Step 6: Commit** + +```bash +git add apps/worker/src/main.ts +git commit -m "feat(worker): multi-account session pool, reactions → approval → forward pipeline" +``` + +--- + +## What this plan does NOT cover (Plan 4+) + +- Admin dashboard UI for approving messages (Next.js — Plan 5) +- Meilisearch archive indexing (Plan 4) +- Adding a second bot account at runtime without restart (future: webhook endpoint to trigger `pool.add`) +- `/tower` bot command handling +- Rejection workflow (admin removes ⭐ or uses a different emoji/command) diff --git a/docs/superpowers/plans/2026-05-27-whatsapp-integration.md b/docs/superpowers/plans/2026-05-27-whatsapp-integration.md new file mode 100644 index 0000000..938a258 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-whatsapp-integration.md @@ -0,0 +1,1190 @@ +# WhatsApp Integration 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:** Connect `apps/worker` to WhatsApp via Baileys, normalize and tag-detect incoming messages, sync groups to the DB, and persist flagged messages as `PENDING` records ready for the approval workflow in Plan 3. + +**Architecture:** The worker process holds a long-lived Baileys WebSocket connection. Incoming messages are normalized to a canonical shape, checked for TOWER tags (hashtags, `/tower` command), and pushed to a BullMQ `tower:ingest` queue. A BullMQ processor in the same worker process consumes the queue and upserts `Message` records to PostgreSQL with `PENDING` status. The NestJS API is not involved — the worker writes directly to the DB via Prisma Client. + +**Tech Stack:** `@whiskeysockets/baileys ^6.0.0`, `bullmq ^5` (already in worker), `ioredis ^5` (already in worker), `@prisma/client ^6` (shared from apps/api schema), `pino` (via @tower/logger), Turborepo `generate` task + +--- + +## File Map + +| Action | Path | Purpose | +|--------|------|---------| +| Modify | `packages/config/src/index.ts` | Add WHATSAPP_SESSION_PATH, TOWER_ADMIN_JIDS | +| Modify | `packages/config/src/index.test.ts` | Tests for new config fields | +| Modify | `packages/types/src/message.ts` | Add IngestJobData interface | +| Create | `apps/worker/src/whatsapp/tag-detector.ts` | Detect TOWER tags from message text + sender | +| Create | `apps/worker/src/whatsapp/tag-detector.test.ts` | Unit tests | +| Create | `apps/worker/src/whatsapp/normalizer.ts` | Convert Baileys proto → NormalizedMessage | +| Create | `apps/worker/src/whatsapp/normalizer.test.ts` | Unit tests | +| Create | `apps/worker/src/whatsapp/group-sync.ts` | Upsert WA groups into DB on connection | +| Create | `apps/worker/src/whatsapp/group-sync.test.ts` | Unit tests with mocked Prisma | +| Create | `apps/worker/src/queues/ingest.queue.ts` | BullMQ Queue producer factory | +| Create | `apps/worker/src/queues/ingest.processor.ts` | BullMQ Worker consumer — persists Message to DB | +| Create | `apps/worker/src/queues/ingest.processor.test.ts` | Unit tests with mocked Prisma | +| Create | `apps/worker/src/whatsapp/session.ts` | Baileys socket factory | +| Modify | `apps/worker/src/main.ts` | Wire session → normalizer → tag-detector → queue | +| Modify | `apps/worker/package.json` | Add @whiskeysockets/baileys, @prisma/client, prisma | +| Modify | `apps/worker/jest.config.js` | Load .env for Prisma | +| Modify | `turbo.json` | Add generate task | +| Modify | `.env.example` | Add WHATSAPP_SESSION_PATH, TOWER_ADMIN_JIDS | +| Modify | `.gitignore` | Ignore sessions/ directory | + +--- + +## Task 1: Extend @tower/config and @tower/types + +**Files:** +- Modify: `packages/config/src/index.ts` +- Modify: `packages/config/src/index.test.ts` +- Modify: `packages/types/src/message.ts` + +- [ ] **Step 1: Write failing tests for new config fields** + +Add these two tests to `packages/config/src/index.test.ts` inside the existing `validateEnv` describe block: + +```typescript +it('applies default WHATSAPP_SESSION_PATH of ./sessions when not set', () => { + const env = { + DATABASE_URL: 'postgresql://user:pass@localhost:5432/db', + REDIS_URL: 'redis://localhost:6379', + JWT_SECRET: 'a'.repeat(32), + }; + const result = validateEnv(env as NodeJS.ProcessEnv); + expect(result.WHATSAPP_SESSION_PATH).toBe('./sessions'); +}); + +it('applies default TOWER_ADMIN_JIDS of empty string when not set', () => { + const env = { + DATABASE_URL: 'postgresql://user:pass@localhost:5432/db', + REDIS_URL: 'redis://localhost:6379', + JWT_SECRET: 'a'.repeat(32), + }; + const result = validateEnv(env as NodeJS.ProcessEnv); + expect(result.TOWER_ADMIN_JIDS).toBe(''); +}); +``` + +- [ ] **Step 2: Run to verify tests fail** + +```bash +pnpm --filter @tower/config test +``` + +Expected: FAIL — `Property 'WHATSAPP_SESSION_PATH' does not exist on type 'Env'` + +- [ ] **Step 3: Update packages/config/src/index.ts** + +Replace the entire file content: + +```typescript +import { z } from 'zod'; + +const envSchema = z.object({ + NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), + DATABASE_URL: z.string().url(), + REDIS_URL: z.string().url(), + API_PORT: z.coerce.number().default(3001), + JWT_SECRET: z.string().min(32), + MEILI_URL: z.string().url().default('http://localhost:7700'), + MEILI_MASTER_KEY: z.string().default('tower_meili_dev_key'), + LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error']).default('info'), + WHATSAPP_SESSION_PATH: z.string().default('./sessions'), + TOWER_ADMIN_JIDS: z.string().default(''), +}); + +export type Env = z.infer; + +export function validateEnv(env: NodeJS.ProcessEnv = process.env): Env { + const result = envSchema.safeParse(env); + if (!result.success) { + console.error('Invalid environment variables:', result.error.format()); + throw new Error('Invalid environment variables'); + } + return result.data; +} +``` + +- [ ] **Step 4: Run tests to verify 7 pass** + +```bash +pnpm --filter @tower/config test +``` + +Expected: PASS — 7 tests total (5 existing + 2 new) + +- [ ] **Step 5: Add IngestJobData to packages/types/src/message.ts** + +Add at the end of the file: + +```typescript +export interface IngestJobData { + platformMsgId: string; + platform: Platform; + sourceGroupId: string; + senderJid: string; + senderName?: string; + content: string; + tags: string[]; +} +``` + +- [ ] **Step 6: Build both packages** + +```bash +pnpm --filter @tower/config build +pnpm --filter @tower/types build +``` + +Expected: both exit 0 with no errors + +- [ ] **Step 7: Commit** + +```bash +git add packages/config/src/index.ts packages/config/src/index.test.ts packages/types/src/message.ts +git commit -m "feat: add WhatsApp config fields and IngestJobData type" +``` + +--- + +## Task 2: Tag Detector (TDD) + +**Files:** +- Create: `apps/worker/src/whatsapp/tag-detector.ts` +- Create: `apps/worker/src/whatsapp/tag-detector.test.ts` + +The tag detector is a pure function — no I/O, no network, no DB. It takes message text and a sender JID, and returns an array of string tags. A message is "flagged" (should be ingested) if the tags array is non-empty. + +Tag rules: +- Text contains `#important` (case-insensitive) → tag `#important` +- Text contains `#upparivar` (case-insensitive) → tag `#upparivar` +- Text contains `#event` (case-insensitive) → tag `#event` +- Text starts with `/tower` → tag `#tower-command` +- Sender JID is in the admin list → tag `#admin` + +- [ ] **Step 1: Write failing tests** + +Create `apps/worker/src/whatsapp/tag-detector.test.ts`: + +```typescript +import { detectTags, isFlagged } from './tag-detector'; + +const ADMINS = ['1234567890@s.whatsapp.net', '0987654321@s.whatsapp.net']; + +describe('detectTags', () => { + it('detects #important hashtag (case-insensitive)', () => { + expect(detectTags('Check this #IMPORTANT update', 'user@s.whatsapp.net', ADMINS)) + .toContain('#important'); + }); + + it('detects #upparivar hashtag (case-insensitive)', () => { + expect(detectTags('Welcome to #UPParivar community', 'user@s.whatsapp.net', ADMINS)) + .toContain('#upparivar'); + }); + + it('detects #event hashtag', () => { + expect(detectTags('Join our #event on Saturday', 'user@s.whatsapp.net', ADMINS)) + .toContain('#event'); + }); + + it('detects /tower command prefix', () => { + expect(detectTags('/tower save this message', 'user@s.whatsapp.net', ADMINS)) + .toContain('#tower-command'); + }); + + it('detects multiple tags in one message', () => { + const tags = detectTags('#important #event happening', 'user@s.whatsapp.net', ADMINS); + expect(tags).toContain('#important'); + expect(tags).toContain('#event'); + }); + + it('detects admin sender', () => { + expect(detectTags('Regular message', '1234567890@s.whatsapp.net', ADMINS)) + .toContain('#admin'); + }); + + it('returns empty array for untagged message from non-admin', () => { + expect(detectTags('Just a regular chat message', 'nobody@s.whatsapp.net', ADMINS)) + .toEqual([]); + }); + + it('returns empty array for empty text', () => { + expect(detectTags('', 'nobody@s.whatsapp.net', ADMINS)).toEqual([]); + }); +}); + +describe('isFlagged', () => { + it('returns true when tags array is non-empty', () => { + expect(isFlagged(['#important'])).toBe(true); + }); + + it('returns false when tags array is empty', () => { + expect(isFlagged([])).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run to verify tests fail** + +```bash +pnpm --filter @tower/worker test +``` + +Expected: FAIL — `Cannot find module './tag-detector'` + +- [ ] **Step 3: Implement apps/worker/src/whatsapp/tag-detector.ts** + +```typescript +const HASHTAGS: Array<{ pattern: RegExp; tag: string }> = [ + { pattern: /#important/i, tag: '#important' }, + { pattern: /#upparivar/i, tag: '#upparivar' }, + { pattern: /#event/i, tag: '#event' }, +]; + +export function detectTags(text: string, senderJid: string, adminJids: string[]): string[] { + const tags: string[] = []; + + for (const { pattern, tag } of HASHTAGS) { + if (pattern.test(text)) tags.push(tag); + } + + if (text.trimStart().startsWith('/tower')) tags.push('#tower-command'); + + if (adminJids.includes(senderJid)) tags.push('#admin'); + + return tags; +} + +export function isFlagged(tags: string[]): boolean { + return tags.length > 0; +} +``` + +- [ ] **Step 4: Run tests to verify all 9 pass** + +```bash +pnpm --filter @tower/worker test +``` + +Expected: PASS — 9 tests (1 existing smoke test + 8 new) + +- [ ] **Step 5: Commit** + +```bash +git add apps/worker/src/whatsapp/ +git commit -m "feat: add tag detector for TOWER message flagging" +``` + +--- + +## Task 3: Message Normalizer (TDD) + +**Files:** +- Create: `apps/worker/src/whatsapp/normalizer.ts` +- Create: `apps/worker/src/whatsapp/normalizer.test.ts` + +The normalizer converts a Baileys `proto.IWebMessageInfo` object into a plain `NormalizedMessage`. It returns `null` for messages that should be ignored (protocol messages, own messages, non-group messages). + +- [ ] **Step 1: Write failing tests** + +Create `apps/worker/src/whatsapp/normalizer.test.ts`: + +```typescript +import { proto } from '@whiskeysockets/baileys'; +import { normalizeMessage } from './normalizer'; + +function makeMsg(overrides: Partial = {}): proto.IWebMessageInfo { + return { + key: { + remoteJid: '120363043312345678@g.us', + fromMe: false, + id: 'ABCDEF123456', + participant: '919876543210@s.whatsapp.net', + }, + pushName: 'Alice', + message: { conversation: 'Hello world' }, + ...overrides, + } as proto.IWebMessageInfo; +} + +describe('normalizeMessage', () => { + it('normalizes a plain text conversation message', () => { + const result = normalizeMessage(makeMsg()); + expect(result).toMatchObject({ + platformMsgId: 'ABCDEF123456', + sourceGroupJid: '120363043312345678@g.us', + senderJid: '919876543210@s.whatsapp.net', + senderName: 'Alice', + content: 'Hello world', + }); + }); + + it('normalizes an extendedTextMessage', () => { + const result = normalizeMessage(makeMsg({ + message: { extendedTextMessage: { text: 'Extended text here' } }, + })); + expect(result?.content).toBe('Extended text here'); + }); + + it('normalizes an imageMessage caption', () => { + const result = normalizeMessage(makeMsg({ + message: { imageMessage: { caption: 'Photo caption' } }, + })); + expect(result?.content).toBe('Photo caption'); + }); + + it('normalizes a videoMessage caption', () => { + const result = normalizeMessage(makeMsg({ + message: { videoMessage: { caption: 'Video caption' } }, + })); + expect(result?.content).toBe('Video caption'); + }); + + it('returns null for own messages (fromMe = true)', () => { + const result = normalizeMessage(makeMsg({ key: { remoteJid: '120363043312345678@g.us', fromMe: true, id: 'XYZ', participant: '919876543210@s.whatsapp.net' } })); + expect(result).toBeNull(); + }); + + it('returns null for non-group messages (DMs)', () => { + const result = normalizeMessage(makeMsg({ + key: { remoteJid: '919876543210@s.whatsapp.net', fromMe: false, id: 'XYZ' }, + })); + expect(result).toBeNull(); + }); + + it('returns null when message payload is missing', () => { + const result = normalizeMessage(makeMsg({ message: null })); + expect(result).toBeNull(); + }); + + it('returns null when key is missing', () => { + const result = normalizeMessage({ message: { conversation: 'hi' } } as proto.IWebMessageInfo); + expect(result).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run to verify tests fail** + +```bash +pnpm --filter @tower/worker test +``` + +Expected: FAIL — `Cannot find module './normalizer'` + +- [ ] **Step 3: Implement apps/worker/src/whatsapp/normalizer.ts** + +```typescript +import { proto } from '@whiskeysockets/baileys'; + +export interface NormalizedMessage { + platformMsgId: string; + sourceGroupJid: string; + senderJid: string; + senderName?: string; + content: string; +} + +function extractText(msg: proto.IWebMessageInfo): string { + const m = msg.message; + if (!m) return ''; + return ( + m.conversation || + m.extendedTextMessage?.text || + m.imageMessage?.caption || + m.videoMessage?.caption || + m.documentMessage?.caption || + '' + ); +} + +export function normalizeMessage(msg: proto.IWebMessageInfo): NormalizedMessage | null { + const key = msg.key; + if (!key) return null; + + const remoteJid = key.remoteJid ?? ''; + + // Only process group messages (group JIDs end with @g.us) + if (!remoteJid.endsWith('@g.us')) return null; + + // Skip our own outgoing messages + if (key.fromMe) return null; + + if (!msg.message) return null; + + const content = extractText(msg); + + return { + platformMsgId: key.id ?? '', + sourceGroupJid: remoteJid, + senderJid: key.participant ?? '', + senderName: msg.pushName ?? undefined, + content, + }; +} +``` + +- [ ] **Step 4: Install @whiskeysockets/baileys so types resolve** + +```bash +pnpm --filter @tower/worker add @whiskeysockets/baileys +``` + +Expected: adds baileys to apps/worker/package.json dependencies + +- [ ] **Step 5: Run tests to verify 8 pass (plus existing)** + +```bash +pnpm --filter @tower/worker test +``` + +Expected: PASS — 10 tests total + +- [ ] **Step 6: Commit** + +```bash +git add apps/worker/src/whatsapp/normalizer.ts apps/worker/src/whatsapp/normalizer.test.ts apps/worker/package.json pnpm-lock.yaml +git commit -m "feat: add Baileys message normalizer" +``` + +--- + +## Task 4: BullMQ Ingest Queue + Processor (TDD) + +**Files:** +- Create: `apps/worker/src/queues/ingest.queue.ts` +- Create: `apps/worker/src/queues/ingest.processor.ts` +- Create: `apps/worker/src/queues/ingest.processor.test.ts` + +The processor receives an `IngestJobData` job and upserts it into the `Message` table as `PENDING`. The `sourceGroupId` in the job is the DB `Group.id` (already resolved before enqueueing). It uses `upsert` so duplicate messages (same platform + platformMsgId) are ignored. + +- [ ] **Step 1: Write failing test for the processor logic** + +Create `apps/worker/src/queues/ingest.processor.test.ts`: + +```typescript +import { processIngestJob } from './ingest.processor'; +import { IngestJobData } from '@tower/types'; + +const mockPrisma = { + message: { + upsert: jest.fn(), + }, +}; + +const sampleJob: IngestJobData = { + platformMsgId: 'WA_MSG_001', + platform: 'whatsapp', + sourceGroupId: 'clxxxxxx', + senderJid: '919876543210@s.whatsapp.net', + senderName: 'Alice', + content: '#important update from the committee', + tags: ['#important'], +}; + +describe('processIngestJob', () => { + beforeEach(() => jest.clearAllMocks()); + + it('upserts a message with PENDING status', async () => { + mockPrisma.message.upsert.mockResolvedValue({ id: 'msg-db-id' }); + + await processIngestJob(sampleJob, mockPrisma as any); + + expect(mockPrisma.message.upsert).toHaveBeenCalledWith({ + where: { platform_platformMsgId: { platform: 'whatsapp', platformMsgId: 'WA_MSG_001' } }, + create: { + platform: 'whatsapp', + platformMsgId: 'WA_MSG_001', + sourceGroupId: 'clxxxxxx', + senderJid: '919876543210@s.whatsapp.net', + senderName: 'Alice', + content: '#important update from the committee', + tags: ['#important'], + status: 'PENDING', + }, + update: {}, + }); + }); + + it('does not throw when message already exists (idempotent upsert)', async () => { + mockPrisma.message.upsert.mockResolvedValue({ id: 'msg-db-id' }); + await expect(processIngestJob(sampleJob, mockPrisma as any)).resolves.not.toThrow(); + }); + + it('propagates DB errors', async () => { + mockPrisma.message.upsert.mockRejectedValue(new Error('DB connection lost')); + await expect(processIngestJob(sampleJob, mockPrisma as any)).rejects.toThrow('DB connection lost'); + }); +}); +``` + +- [ ] **Step 2: Run to verify tests fail** + +```bash +pnpm --filter @tower/worker test +``` + +Expected: FAIL — `Cannot find module './ingest.processor'` + +- [ ] **Step 3: Implement apps/worker/src/queues/ingest.processor.ts** + +```typescript +import { Worker } from 'bullmq'; +import { PrismaClient } from '@prisma/client'; +import IORedis from 'ioredis'; +import { IngestJobData } from '@tower/types'; + +export async function processIngestJob(job: IngestJobData, prisma: PrismaClient): Promise { + await prisma.message.upsert({ + where: { + platform_platformMsgId: { + platform: job.platform, + platformMsgId: job.platformMsgId, + }, + }, + create: { + platform: job.platform, + platformMsgId: job.platformMsgId, + sourceGroupId: job.sourceGroupId, + senderJid: job.senderJid, + senderName: job.senderName, + content: job.content, + tags: job.tags, + status: 'PENDING', + }, + update: {}, + }); +} + +export function createIngestWorker(redisUrl: string, prisma: PrismaClient): Worker { + const connection = new IORedis(redisUrl, { maxRetriesPerRequest: null }); + return new Worker( + 'tower:ingest', + async (job) => processIngestJob(job.data, prisma), + { connection }, + ); +} +``` + +- [ ] **Step 4: Implement apps/worker/src/queues/ingest.queue.ts** + +```typescript +import { Queue } from 'bullmq'; +import IORedis from 'ioredis'; +import { IngestJobData } from '@tower/types'; + +export function createIngestQueue(redisUrl: string): Queue { + const connection = new IORedis(redisUrl, { maxRetriesPerRequest: null }); + return new Queue('tower:ingest', { connection }); +} +``` + +- [ ] **Step 5: Run tests to verify 3 new pass + existing** + +```bash +pnpm --filter @tower/worker test +``` + +Expected: PASS — 13 tests total + +- [ ] **Step 6: Commit** + +```bash +git add apps/worker/src/queues/ +git commit -m "feat: add BullMQ ingest queue and processor" +``` + +--- + +## Task 5: Group Sync (TDD) + +**Files:** +- Create: `apps/worker/src/whatsapp/group-sync.ts` +- Create: `apps/worker/src/whatsapp/group-sync.test.ts` + +On WhatsApp connection, Baileys gives us all groups the bot is in via `sock.groupFetchAllParticipating()`. We upsert each one to the `Group` table. The function returns a `Map` used by the message listener to resolve group IDs. + +- [ ] **Step 1: Write failing tests** + +Create `apps/worker/src/whatsapp/group-sync.test.ts`: + +```typescript +import { syncGroups } from './group-sync'; +import { GroupMetadata } from '@whiskeysockets/baileys'; + +const mockGroups: Record = { + '120363043312345678@g.us': { + id: '120363043312345678@g.us', + subject: 'UP Parivar Dallas', + desc: 'Main community group', + participants: [], + creation: 0, + owner: undefined, + restrict: false, + announce: false, + subjectOwner: undefined, + subjectTime: 0, + size: 0, + ephemeralDuration: 0, + inviteCode: undefined, + }, + '999999999@g.us': { + id: '999999999@g.us', + subject: 'Events Committee', + desc: undefined, + participants: [], + creation: 0, + owner: undefined, + restrict: false, + announce: false, + subjectOwner: undefined, + subjectTime: 0, + size: 0, + ephemeralDuration: 0, + inviteCode: undefined, + }, +}; + +const mockPrisma = { + group: { + upsert: jest.fn(), + }, +}; + +describe('syncGroups', () => { + beforeEach(() => jest.clearAllMocks()); + + it('upserts each group and returns jid→id map', async () => { + mockPrisma.group.upsert + .mockResolvedValueOnce({ id: 'db-group-1' }) + .mockResolvedValueOnce({ id: 'db-group-2' }); + + const result = await syncGroups(mockGroups, mockPrisma as any); + + expect(mockPrisma.group.upsert).toHaveBeenCalledTimes(2); + expect(result.get('120363043312345678@g.us')).toBe('db-group-1'); + expect(result.get('999999999@g.us')).toBe('db-group-2'); + }); + + it('calls upsert with correct create payload', async () => { + mockPrisma.group.upsert.mockResolvedValue({ id: 'db-group-1' }); + + await syncGroups( + { '120363043312345678@g.us': mockGroups['120363043312345678@g.us'] }, + mockPrisma as any, + ); + + expect(mockPrisma.group.upsert).toHaveBeenCalledWith({ + where: { platform_platformId: { platform: 'whatsapp', platformId: '120363043312345678@g.us' } }, + create: { + platform: 'whatsapp', + platformId: '120363043312345678@g.us', + name: 'UP Parivar Dallas', + description: 'Main community group', + isActive: true, + }, + update: { name: 'UP Parivar Dallas', description: 'Main community group' }, + }); + }); + + it('handles groups with no description', async () => { + mockPrisma.group.upsert.mockResolvedValue({ id: 'db-group-2' }); + + await syncGroups( + { '999999999@g.us': mockGroups['999999999@g.us'] }, + mockPrisma as any, + ); + + expect(mockPrisma.group.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: expect.objectContaining({ description: undefined }), + }), + ); + }); + + it('returns an empty map when given empty groups', async () => { + const result = await syncGroups({}, mockPrisma as any); + expect(result.size).toBe(0); + expect(mockPrisma.group.upsert).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run to verify tests fail** + +```bash +pnpm --filter @tower/worker test +``` + +Expected: FAIL — `Cannot find module './group-sync'` + +- [ ] **Step 3: Implement apps/worker/src/whatsapp/group-sync.ts** + +```typescript +import { GroupMetadata } from '@whiskeysockets/baileys'; +import { PrismaClient } from '@prisma/client'; + +export async function syncGroups( + groups: Record, + prisma: PrismaClient, +): Promise> { + const jidToDbId = new Map(); + + for (const [jid, meta] of Object.entries(groups)) { + const group = await prisma.group.upsert({ + where: { platform_platformId: { platform: 'whatsapp', platformId: jid } }, + create: { + platform: 'whatsapp', + platformId: jid, + name: meta.subject, + description: meta.desc ?? undefined, + isActive: true, + }, + update: { name: meta.subject, description: meta.desc ?? undefined }, + }); + jidToDbId.set(jid, group.id); + } + + return jidToDbId; +} +``` + +- [ ] **Step 4: Run tests to verify 4 new pass + existing** + +```bash +pnpm --filter @tower/worker test +``` + +Expected: PASS — 17 tests total + +- [ ] **Step 5: Commit** + +```bash +git add apps/worker/src/whatsapp/group-sync.ts apps/worker/src/whatsapp/group-sync.test.ts +git commit -m "feat: add WhatsApp group sync to database" +``` + +--- + +## Task 6: WhatsApp Session + +**Files:** +- Create: `apps/worker/src/whatsapp/session.ts` + +The session module wraps Baileys' `makeWASocket`. It manages auth state, reconnection on disconnect, and calls provided callbacks for groups (on connection) and messages (on upsert). + +No unit test for the session itself — it wraps a live network connection. The integration is verified end-to-end in Task 8. + +- [ ] **Step 1: Create apps/worker/src/whatsapp/session.ts** + +```typescript +import makeWASocket, { + useMultiFileAuthState, + fetchLatestBaileysVersion, + DisconnectReason, + WASocket, + proto, + GroupMetadata, +} from '@whiskeysockets/baileys'; +import { Boom } from '@hapi/boom'; +import { createLogger } from '@tower/logger'; + +const logger = createLogger('whatsapp-session'); + +export type OnMessageCallback = (msg: proto.IWebMessageInfo) => void; +export type OnGroupsCallback = (groups: Record) => void; + +export async function createWhatsAppSession( + sessionPath: string, + onMessage: OnMessageCallback, + onGroups: OnGroupsCallback, +): Promise { + const { state, saveCreds } = await useMultiFileAuthState(sessionPath); + const { version } = await fetchLatestBaileysVersion(); + + const sock = makeWASocket({ + version, + auth: state, + printQRInTerminal: true, + logger: logger as any, + }); + + sock.ev.on('creds.update', saveCreds); + + sock.ev.on('connection.update', async ({ connection, lastDisconnect }) => { + if (connection === 'close') { + const reason = (lastDisconnect?.error as Boom)?.output?.statusCode; + const shouldReconnect = reason !== DisconnectReason.loggedOut; + logger.info({ reason, shouldReconnect }, 'Connection closed'); + if (shouldReconnect) { + logger.info('Reconnecting in 5s...'); + setTimeout(() => createWhatsAppSession(sessionPath, onMessage, onGroups), 5000); + } + } else if (connection === 'open') { + logger.info('WhatsApp connected'); + const groups = await sock.groupFetchAllParticipating(); + onGroups(groups); + } + }); + + sock.ev.on('messages.upsert', ({ messages, type }) => { + if (type !== 'notify') return; + for (const msg of messages) { + onMessage(msg); + } + }); + + return sock; +} +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +```bash +pnpm --filter @tower/worker build +``` + +Expected: exit 0 with no type errors + +- [ ] **Step 3: Commit** + +```bash +git add apps/worker/src/whatsapp/session.ts +git commit -m "feat: add Baileys WhatsApp session with reconnect logic" +``` + +--- + +## Task 7: Add Prisma to Worker + Update .env + +**Files:** +- Modify: `apps/worker/package.json` +- Modify: `apps/worker/jest.config.js` +- Modify: `turbo.json` +- Modify: `.env.example` +- Modify: `.gitignore` + +- [ ] **Step 1: Add @prisma/client and prisma to worker** + +```bash +pnpm --filter @tower/worker add @prisma/client +pnpm --filter @tower/worker add --save-dev prisma +``` + +- [ ] **Step 2: Add generate script to apps/worker/package.json** + +The `generate` script tells Prisma where to find the schema (in apps/api). After this step, `package.json` scripts section should be: + +```json +"scripts": { + "generate": "prisma generate --schema=../api/prisma/schema.prisma", + "build": "tsc", + "dev": "ts-node src/main.ts", + "start": "node dist/main.js", + "test": "jest" +} +``` + +- [ ] **Step 3: Add generate task to turbo.json** + +Replace `turbo.json` content: + +```json +{ + "$schema": "https://turbo.build/schema.json", + "tasks": { + "generate": { + "cache": false + }, + "build": { + "dependsOn": ["generate", "^build"], + "outputs": ["dist/**", ".next/**"] + }, + "dev": { + "cache": false, + "persistent": true + }, + "test": { + "dependsOn": ["^build"], + "outputs": ["coverage/**"] + }, + "lint": {} + } +} +``` + +- [ ] **Step 4: Run prisma generate for worker** + +```bash +pnpm --filter @tower/worker generate +``` + +Expected: `✔ Generated Prisma Client` with no errors + +- [ ] **Step 5: Update apps/worker/jest.config.js to load .env** + +Replace the entire file: + +```javascript +const path = require('path'); +require('dotenv').config({ path: path.resolve(__dirname, '../../.env') }); + +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/*.test.ts'], + rootDir: 'src', +}; +``` + +- [ ] **Step 6: Add dotenv dev dependency to worker** + +```bash +pnpm --filter @tower/worker add --save-dev dotenv +``` + +- [ ] **Step 7: Update .env.example — add new vars** + +Add these lines to `/Users/maaz/Documents/insignia-work/tower/.env.example`: + +```bash +# WhatsApp +WHATSAPP_SESSION_PATH=./sessions +TOWER_ADMIN_JIDS= +``` + +Also add to `.env` (the actual file, NOT committed): + +```bash +WHATSAPP_SESSION_PATH=./sessions +TOWER_ADMIN_JIDS= +``` + +- [ ] **Step 8: Add sessions/ to .gitignore** + +Add this line to `.gitignore`: + +``` +sessions/ +``` + +- [ ] **Step 9: Run worker tests to verify still passing** + +```bash +pnpm --filter @tower/worker test +``` + +Expected: PASS — 17 tests passing + +- [ ] **Step 10: Commit** + +```bash +git add apps/worker/package.json apps/worker/jest.config.js turbo.json .env.example .gitignore pnpm-lock.yaml +git commit -m "chore: add Prisma client to worker, turbo generate task, update env" +``` + +--- + +## Task 8: Wire Worker Bootstrap + +**Files:** +- Modify: `apps/worker/src/main.ts` + +Wire together session → normalizer → tag-detector → queue so the worker fully processes incoming messages. + +- [ ] **Step 1: Replace apps/worker/src/main.ts** + +```typescript +import { PrismaClient } from '@prisma/client'; +import { createLogger } from '@tower/logger'; +import { validateEnv } from '@tower/config'; +import { IngestJobData } from '@tower/types'; +import { createWhatsAppSession } from './whatsapp/session'; +import { normalizeMessage } from './whatsapp/normalizer'; +import { detectTags, isFlagged } from './whatsapp/tag-detector'; +import { syncGroups } from './whatsapp/group-sync'; +import { createIngestQueue } from './queues/ingest.queue'; +import { createIngestWorker } from './queues/ingest.processor'; + +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 ingestQueue = createIngestQueue(env.REDIS_URL); + const ingestWorker = createIngestWorker(env.REDIS_URL, prisma); + + 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'); + }); + + // jid→dbId map populated on WA connection + let groupMap = new Map(); + + await createWhatsAppSession( + env.WHATSAPP_SESSION_PATH, + async (msg) => { + const normalized = normalizeMessage(msg); + if (!normalized) return; + + const tags = detectTags(normalized.content, normalized.senderJid, adminJids); + if (!isFlagged(tags)) return; + + const sourceGroupId = groupMap.get(normalized.sourceGroupJid); + if (!sourceGroupId) { + logger.warn({ jid: normalized.sourceGroupJid }, 'Unknown group — skipping message'); + return; + } + + const jobData: IngestJobData = { + platformMsgId: normalized.platformMsgId, + platform: 'whatsapp', + sourceGroupId, + senderJid: normalized.senderJid, + senderName: normalized.senderName, + content: normalized.content, + tags, + }; + + await ingestQueue.add('ingest', jobData, { + attempts: 3, + backoff: { type: 'exponential', delay: 1000 }, + }); + + logger.info({ platformMsgId: normalized.platformMsgId, tags }, 'Message enqueued'); + }, + async (groups) => { + logger.info({ count: Object.keys(groups).length }, 'Syncing groups'); + groupMap = await syncGroups(groups, prisma); + logger.info({ count: groupMap.size }, 'Groups synced'); + }, + ); + + logger.info('Tower worker ready'); + + const shutdown = async () => { + logger.info('Shutting down...'); + await ingestWorker.close(); + await ingestQueue.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 2: Build worker to verify no type errors** + +```bash +pnpm --filter @tower/worker build +``` + +Expected: exit 0 with no TypeScript errors + +- [ ] **Step 3: Run worker tests** + +```bash +pnpm --filter @tower/worker test +``` + +Expected: PASS — all tests still passing + +- [ ] **Step 4: Commit** + +```bash +git add apps/worker/src/main.ts +git commit -m "feat: wire worker bootstrap — session → normalizer → queue pipeline" +``` + +--- + +## Task 9: Turborepo Full Smoke Test + +**Files:** None new — verify the full pipeline + +- [ ] **Step 1: Ensure Docker is running** + +```bash +docker compose up -d +``` + +Verify postgres (port 5433) and redis (port 6379) are healthy: + +```bash +docker compose ps +``` + +Expected: postgres, redis, meilisearch all show `Up` + +- [ ] **Step 2: Run full build** + +```bash +pnpm build +``` + +Expected: +``` +Tasks: 8 successful, 8 total +``` + +- [ ] **Step 3: Run full test suite** + +```bash +pnpm test +``` + +Expected: +``` +Tasks: 8 successful, 8 total +``` + +All packages must pass. If `@tower/api#test` fails with `DATABASE_URL not found`, verify `.env` has the correct `DATABASE_URL=postgresql://tower:tower_dev@localhost:5433/tower_dev`. + +- [ ] **Step 4: Commit any fixes, then tag the milestone** + +```bash +git add -A +git commit -m "chore: turborepo smoke test — all 8 packages build and test clean" +``` + +--- + +## Self-Review + +**Spec coverage:** +- ✅ Baileys connection with QR-based auth and session persistence +- ✅ Group discovery on connect → upserted to `Group` table +- ✅ Message normalization (text, extended text, image/video captions) +- ✅ Tag detection: `#important`, `#upparivar`, `#event`, `/tower` command, admin sender +- ✅ BullMQ `tower:ingest` queue — durability + retry logic +- ✅ Message persistence as `PENDING` records (idempotent via upsert) +- ✅ Graceful shutdown (SIGTERM/SIGINT) +- ⏭️ ⭐ admin reaction handling — deferred to Plan 3 (requires message store) +- ⏭️ Media download — deferred to Plan 4 (requires Cloudflare R2 / MinIO) + +**Type consistency check:** +- `NormalizedMessage.sourceGroupJid` → used in `groupMap.get()` in main.ts ✅ +- `IngestJobData.sourceGroupId` → DB Group.id (resolved from groupMap) ✅ +- `syncGroups` returns `Map` (jid → db id) ✅ +- `processIngestJob` uses `platform_platformMsgId` compound unique key (matches Prisma schema `@@unique([platform, platformMsgId])`) ✅ +- `group.upsert` uses `platform_platformId` compound key (matches `@@unique([platform, platformId])`) ✅ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2fd5fe2..9e90185 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,6 +166,9 @@ importers: ioredis: specifier: ^5.0.0 version: 5.11.0 + qrcode-terminal: + specifier: ^0.12.0 + version: 0.12.0 devDependencies: '@types/jest': specifier: ^29.0.0 @@ -173,6 +176,9 @@ importers: '@types/node': specifier: ^22.0.0 version: 22.19.19 + '@types/qrcode-terminal': + specifier: ^0.12.2 + version: 0.12.2 dotenv: specifier: ^17.4.2 version: 17.4.2 @@ -1332,6 +1338,9 @@ packages: '@types/node@22.19.19': resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==} + '@types/qrcode-terminal@0.12.2': + resolution: {integrity: sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -3105,6 +3114,10 @@ packages: resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==} engines: {node: '>=20'} + qrcode-terminal@0.12.0: + resolution: {integrity: sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==} + hasBin: true + qs@6.15.2: resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} @@ -4934,6 +4947,8 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/qrcode-terminal@0.12.2': {} + '@types/react-dom@19.2.3(@types/react@19.2.15)': dependencies: '@types/react': 19.2.15 @@ -6948,6 +6963,8 @@ snapshots: dependencies: hookified: 2.2.0 + qrcode-terminal@0.12.0: {} + qs@6.15.2: dependencies: side-channel: 1.1.0