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