Files
tower/docs/ARCHITECTURE.md
T
maaz519 6de78fe270 docs: add full architecture reference for onboarding developers
826-line doc covering: tech stack, tenancy hierarchy, full message
pipeline (G1+G2), all 55 DB models with field descriptions, 4 auth
realms, all API endpoints, member portal routes, AI dev handoff spec
(queue payloads + how to write ContentDraft output back), environment
variables, repo structure, migration workflow, and known gaps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 20:45:47 +05:30

27 KiB
Raw Blame History

TOWER — System Architecture Reference

Current state as of June 2026. Written for developers joining the project. Owner: Insignia / UP Parivaar deployment.


1. What is TOWER?

TOWER is a WhatsApp community intelligence platform. It ingests messages from WhatsApp groups, runs them through guardrails and classification, surfaces the content to community members via a portal, and hands clean message streams to an AI agent layer for further processing (events, FAQs, digests, threads).

The system is designed for multi-tenant deployment. One top-level organisation (e.g. UP Parivaar national) contains multiple chapters/chapters (Dalls), each operating independently with shared organisation-level policies.


2. Tech Stack

Layer Technology
API server NestJS (Node.js) — apps/api
Worker / pipeline Node.js (standalone) — apps/worker
Web frontend Next.js 14 App Router — apps/web
Database PostgreSQL 17 via Prisma ORM
Queue fabric BullMQ backed by Redis 7
Full-text search Meilisearch v1.11
LLM calls OpenRouter HTTP API → google/gemini-3.5-flash
WhatsApp adapter Baileys (non-production prototype)
Styling Tailwind CSS v4 (hand-rolled, no shadcn/ui yet)
Monorepo tooling pnpm workspaces + Turborepo
Shared packages @tower/types, @tower/config, @tower/logger, @tower/search

Infrastructure (docker-compose)

postgres:5432   — primary database
redis:6379      — BullMQ queue backend + session cache
meilisearch:7700 — full-text message search index
api:3001        — NestJS REST API
worker          — BullMQ workers + WhatsApp session pool
web:3000        — Next.js frontend (served separately)

3. Tenancy Hierarchy

SuperAdmin
  └── Organization          (e.g. UP Parivaar national)
        ├── OrgAdmin         (national-level admins)
        ├── OrgRule[]        (routing rules that cascade to all Dalls)
        └── Tenant[]         (each Dall / chapter, e.g. UP Parivaar Dallas)
              ├── Admin[]    (Dall-level admins: OWNER / ADMIN / VIEWER)
              ├── TowerUser[] (members of this Dall)
              ├── Group[]    (WhatsApp groups claimed by this Dall)
              ├── TenantRule[] (Dall-specific routing rules)
              └── ... (all content models below)

Key rule: every data model carries tenantId. All queries are tenant-scoped. A member's portal shows only their Dall's data.


4. Message Pipeline (G1 + G2 — your responsibility)

This is the section the ingestion/pipeline team owns. The AI dev picks up after G2.

4.1 Full flow

WhatsApp group message
    │
    ▼
[ADAPTER] baileys-adapter.ts
    Receives raw Baileys message → normalises to NormalizedMessage
    { platformMsgId, sourceGroupJid, senderJid, senderName,
      content, accountId, quotedPlatformMsgId? }
    │
    ▼
[G1 — PRE-INGEST SPAM FILTER]  main.ts
    detectSpam(content)  — emoji-only, short-greeting, link-only
    checkDuplicateHash() — SHA-256 content hash, 1h window
    → if spam: log to tower.classify.spam, DROP (never stored to DB)
    │
    ▼
[G1 — RULE ENGINE]  main.ts + match-rules.ts
    Loads TenantRule rows from DB
    matchContentRules(content, rules) → { tags, effectiveAction }
    Actions: FLAG | AUTO_APPROVE | SKIP | REJECT | P1
    → if no rule matches and not event/FAQ candidate: DROP
    │
    ▼
[INGEST QUEUE]  tower.ingest.v1  (BullMQ)
    Payload: IngestJobData { tenantId, platformMsgId, platform,
      accountId, sourceGroupId, senderJid, senderName,
      content, tags, effectiveAction }
    │
    ▼
[ingest.processor.ts]
    Creates Message record in DB with status=RAW
    Enqueues to tower.classify.v1
    │
    ▼
[classify.processor.ts]  ← G2 BOUNDARY
    Re-runs full classification from DB message
    ┌─ spam/duplicate  → tower.classify.spam  (status=DNC)
    ├─ SKIP/REJECT     → tower.policy.dnc.v1  (status=DNC)
    ├─ P1              → tower.urgent.p1.v1   (status=PENDING)
    ├─ FLAG            → tower.review.v1      (status=PENDING)
    └─ AUTO_APPROVE    → approve + forward + index (status=APPROVED→DISTRIBUTED)
    │
    ▼  (runs in parallel for all non-DNC/non-spam messages)
[G2 — CONTENT CANDIDATE DETECTION]  classify.processor.ts
    isEventCandidate(content)
      signals: #event hashtag, date+time pattern, date+location, RSVP phrases
      → tower.event.extract
    isFaqCandidate(content)
      signals: question pattern (?, how-to, what-is) + message length > 80 chars
      → tower.faq.candidate

4.2 AI handoff point — the clean stream

After G2, the pipeline publishes to two dedicated queues that the AI dev consumes:

Queue What's in it Consumer
tower.event.extract Messages likely containing an event announcement AI: Event Extractor agent
tower.faq.candidate Messages likely containing a Q&A pair AI: FAQ Curator agent

Handoff payload (same for both queues):

{
  tenantId:  string,   // which Dall
  messageId: string,   // DB Message.id — query our DB for full context
  content:   string,   // raw message text
  traceId?:  string
}

Note for AI dev: The messageId is a DB primary key. You can query our Postgres Message table for senderName, sourceGroupId, tags, platform, createdAt and any other fields you need. Redis is at redis:6379, the queue names use BullMQ protocol.

4.3 Active queue lanes (11 of 22 planned)

Queue name Purpose
tower.ingest.v1 Raw message ingest from adapter
tower.classify.v1 Full rule + candidate classification
tower.classify.spam Spam/duplicate quarantine logging
tower.policy.dnc.v1 Do-not-classify — rule-rejected messages
tower.urgent.p1.v1 P1 priority — emergency messages
tower.review.v1 Flagged messages awaiting human review
tower.forward.v1 Send approved messages to target WhatsApp groups
tower.index.v1 Index approved messages into Meilisearch
tower.digest.v1 LLM digest generation (scheduled or manual)
tower.thread.v1 Thread resolution from quoted replies
tower.event.extract Event candidate → AI Event Extractor
tower.faq.candidate FAQ candidate → AI FAQ Curator

4.4 Spam detection signals (spam-detector.ts)

Signal Logic
emoji_only Message matches /^[\p{Emoji}\s]+$/u
short_greeting < 4 tokens AND matches greeting phrase list (incl. Hindi)
link_only Strip URLs → < 4 tokens remaining
duplicate_hash SHA-256(content) seen in same group within 1 hour

5. Database Schema

PostgreSQL via Prisma. All IDs are cuid(). All timestamps UTC.

5.1 Identity & Tenancy

Organization

Top-level entity. One per national organisation (UP Parivaar).

id, slug, name, settings: Json, createdAt, updatedAt
→ has many: Tenant, OrgAdmin, OrgRule

OrgAdmin

National-level admins who manage all Dalls.

id, organizationId, email, passwordHash, name
role: ORG_OWNER | ORG_ADMIN

Auth realm: OrgAdminGuard — JWT with kind: 'orgadmin'.

Tenant

One per Dall (chapter). The primary multi-tenancy boundary.

id, slug, name
isActive: Boolean
isForwardingPaused: Boolean   ← pauses all WhatsApp forwards
settings: Json
organizationId?               ← null for standalone tenants

Admin

Dall-level admin. Can manage their Tenant's groups, rules, messages.

id, tenantId, email, passwordHash
role: OWNER | ADMIN | VIEWER

Auth realm: JwtAuthGuard + RolesGuard.

SuperAdmin

Platform operator. Can create orgs, tenants, manage bots.

id, email, passwordHash, name

Auth realm: SuperAdminGuard.


5.2 WhatsApp Accounts & Groups

Account

A WhatsApp bot account (phone number + session).

id, platform, jid, sessionPath, displayName
status: ACTIVE | DISCONNECTED | BANNED | PAIRING
qrCode?          ← set during pairing, cleared on connect
isBot: Boolean
pairingToken?    ← one-time token for claiming

Bot accounts are global — not owned by any single tenant. Access is granted via TenantBot.

TenantBot

Many-to-many: which tenants may use which bots.

id, tenantId, accountId, isActive

Group

A WhatsApp group seen by a bot.

id, tenantId?, platform, platformId, name, description
claimStatus: PENDING_CLAIM | CLAIMED | RELEASED | EXPIRED
claimedByAdminId?   ← which admin claimed it
accountId?          ← which bot sees this group

tenantId is null until a tenant admin claims the group.

SyncRoute

Defines which groups messages are forwarded to after approval.

id, tenantId, sourceGroupId → targetGroupId, isActive

One source group can route to many targets.

GroupAccess

Allows a tenant to read messages from a group they don't own (shared groups).

id, groupId, tenantId, grantedBy

5.3 Message Pipeline

Message

Core record. Created by ingest.processor.ts.

id, tenantId, platform, platformMsgId (unique per platform)
sourceGroupId, senderJid, senderName
senderTowerUserId?    ← linked if sender is a known member
content, mediaUrl?
tags: String[]        ← matched rule values (e.g. "#event")
status: RAW → PENDING → APPROVED → DISTRIBUTED | REJECTED | DNC | ARCHIVED
expiresAt?            ← RAW/DNC messages expire after 72h
quotedPlatformMsgId?  ← for thread resolution
threadId?             ← linked Thread if resolved

MessageStatus enum

Status Meaning
RAW Just ingested, not yet classified
PENDING Awaiting admin approval (flagged or P1)
APPROVED Admin-approved
DISTRIBUTED Forwarded to target groups
REJECTED Admin-rejected
DNC Do-not-classify (spam, no rule match)
ARCHIVED Retained past distribution

Thread

A group of related messages (resolved from quoted replies).

id, tenantId, sourceGroupId
lastActivityAt, messageCount, topic?
→ has many: Message

Approval

Records the admin decision on a message.

id, tenantId, messageId (unique), adminId
decision: APPROVED | REJECTED, notes?, decidedAt

5.4 Rules Engine

TenantRule

Dall-level routing rules. Org-level rules (OrgRule) have the same structure but scope to the Organisation and cascade to all Dalls.

id, tenantId, matchType, matchValue, action, priority, isActive
matchType: HASHTAG | PREFIX | REACTION_EMOJI
action: FLAG | AUTO_APPROVE | SKIP | REJECT | P1

Rule precedence (highest wins): SKIP > REJECT > P1 > AUTO_APPROVE > FLAG

Org rules are evaluated first and are authoritative. Tenant rules are fallback.


5.5 Member Identity

TowerUser

A community member. Identity is privacy-preserving — phone number is never stored, only a salted SHA-256 hash.

id, tenantId
phoneHash     ← SHA-256(E.164 phone, pepper=JWT_SECRET) — never store raw phone
jid           ← WhatsApp JID (used for OTP delivery only)
displayName?, avatar?, hometown?, currentLocation?
interests: String[]
language: String (default "en")
digestPreference: String (default "daily")
directoryVisible: Boolean ← controls visibility in member directory

One member per Dall. If a person is in two Dalls, they have two TowerUser records.

TowerSession

Member auth session (JWT-backed).

id, userId, tokenHash (unique), expiresAt

Cookie name: tower_member_token (HttpOnly, 30-day TTL).

ConsentRecord

GDPR-style consent per (member, group, scope).

id, tenantId, groupId, userId
scopes: INGEST | ARCHIVE | REPLICATE | DISPLAY
status: GRANTED | REVOKED
retentionDays (default 90), policyVersion, proofEventId

OtpChallenge

WhatsApp OTP for member login.

id, tenantId, jid, phoneHash, code
scopes[], retentionDays, policyVersion, groupId
expiresAt, consumedAt?, sentAt?

5.6 Community Content (portal data)

All models below are tenant-scoped. They are populated either by admin approval of ContentDraft records (AI-proposed) or directly by admin APIs.

Event

id, tenantId, title, description?, location?
startsAt, endsAt?, createdBy, isPublished
→ has many: EventRsvp

EventRsvp

id, eventId, userId
status: GOING | NOT_GOING | MAYBE
note?

Unique per (event, user).

SevaOpportunity

Volunteer opportunity (may be auto-extracted from event messages).

id, tenantId, title, description?, location?
slots?, startsAt?, pointsAward (default 20)
status: OPEN | CLOSED
createdBy, isPublished
→ has many: SevaEntry

SevaEntry

Member signup for a seva opportunity.

id, opportunityId, userId
status: SIGNED_UP | COMPLETED | CANCELLED
hours?, note?, completedAt?

Unique per (opportunity, user).

GamificationEvent

Points ledger. One record per award.

id, tenantId, userId
type: HELPFUL_ANSWER(10) | SEVA_COMPLETED(20) | EVENT_ATTENDANCE(5)
    | FAQ_APPROVED(15) | WELCOME(3) | PROFILE_COMPLETED(5) | BUSINESS_ADDED(5)
points, refType?, refId?

Unique per (userId, type, refId) — prevents double-awarding. Time-decay: full points ≤30d, 50% 3190d, 25% >90d.

Circle

Interest/affinity sub-group within a Dall.

id, tenantId, name, description?, isPublic, createdBy
→ has many: CircleMembership

Unique per (tenantId, name).

CircleMembership

id, circleId, userId
role: MEMBER | LEAD

DigestConfig

One digest schedule per Dall.

id, tenantId (unique), targetGroupJid, targetAccountId
scheduleHour (default 20), scheduleMinute (default 0)
isActive, lastSentAt?

Digest

Sent digest record.

id, tenantId, content (LLM-generated text), messageIds: String[]
digestDate, sentAt

Unique per (tenantId, digestDate).


KnowledgeBoard

A category/board for FAQ items.

id, tenantId, name, description?, slug (unique per tenant), sortOrder
→ has many: KnowledgeItem

KnowledgeItem

A single FAQ entry.

id, boardId, tenantId, question, answer, tags: String[]
status: DRAFT | PUBLISHED | ARCHIVED
sortOrder, createdBy

GalleryAlbum

A photo album (event memories, chapter photos).

id, tenantId, title, description?, coverUrl?, isPublished, createdBy
→ has many: GalleryItem

GalleryItem

A single photo in an album.

id, albumId, tenantId, mediaUrl, caption?, sortOrder

AskAIQuery

Log of member Ask AI questions and answers.

id, tenantId, userId, question, answer
citations: Json   ← array of { messageId, snippet, senderName, sourceGroupName, approvedAt }

5.8 AI Draft Pipeline

ContentDraft

AI-proposed content pending admin review. Created by the Event Extractor and FAQ Curator workers. On admin approval, the projection builder creates the actual Event, SevaOpportunity, or KnowledgeItem record.

id, tenantId
type: EVENT | SEVA_TASK | FAQ_ITEM
status: PENDING_REVIEW | APPROVED | REJECTED
sourceMessageId   ← the Message that triggered extraction
proposedJson: Json  ← typed by type:
  EVENT:     { title, date, time, location, description, rsvpNeeded, sevaActionItems[] }
  SEVA_TASK: { title, slots, skills[], date, parentEventTitle? }
  FAQ_ITEM:  { question, answer, tags[] }
confidence: Float?  ← 01 score from extraction
reviewedBy?, reviewedAt?, publishedId?

Draft lifecycle:

Message → classify.processor detects candidate
  → tower.event.extract or tower.faq.candidate queue
  → LLM extraction worker creates ContentDraft (status=PENDING_REVIEW)
  → Admin reviews at /drafts
  → Approve: creates Event/SevaOpportunity/KnowledgeItem, sets publishedId
  → Reject:  sets status=REJECTED, nothing published

5.9 Audit

AuditEvent

Immutable audit log. Written on every significant admin action.

id, tenantId, actorType (ADMIN|SYSTEM|ADAPTER|MEMBER)
actorId?, action, resourceType, resourceId, payload: Json, traceId?

Actions include: AUTH_LOGIN, MESSAGE_APPROVED, RULE_CREATED, GROUP_CLAIMED, DRAFT_APPROVED, DIGEST_SENT, SEVA_COMPLETED, etc.


6. Auth Realms (4 independent JWT systems)

Realm Guard Cookie/Header Issued by
Tenant Admin JwtAuthGuard + RolesGuard Bearer token POST /auth/login
Member MemberAuthGuard tower_member_token (cookie) POST /onboarding/verify (OTP)
SuperAdmin SuperAdminGuard Bearer token POST /super-admin/login
OrgAdmin OrgAdminGuard Bearer token POST /org/login

JWT payload shapes (@tower/types):

AdminJwtPayload     { kind: 'admin',    sub, tenantId, email, role }
MemberJwtPayload    { kind: 'member',   sub, tenantId, jid, phoneHash }
SuperAdminJwtPayload{ kind: 'superadmin', sub, email }
OrgAdminJwtPayload  { kind: 'orgadmin', sub, organizationId, email, role }

7. API Endpoints (NestJS — port 3001)

Member portal (/my/* — MemberAuthGuard)

GET  /my/dashboard          stats: points, upcoming events, digest count, active circles
GET  /my/digest             list of sent digests
GET  /my/events             upcoming published events
POST /my/events/:id/rsvp    create/update RSVP { status: GOING|NOT_GOING|MAYBE }
GET  /my/profile            current member profile
PATCH /my/profile           update displayName, hometown, interests, etc.
GET  /my/seva               list open seva opportunities + member entry status
POST /my/seva/:id/signup    sign up for a seva opportunity
POST /my/seva/:id/cancel    cancel signup
GET  /my/circles            list circles + membership status
POST /my/circles/:id/join   join a circle
POST /my/circles/:id/leave  leave a circle
GET  /my/directory          member directory (respects directoryVisible)
GET  /my/knowledge          published FAQ items (supports ?q= search)
GET  /my/memories           published gallery albums
GET  /my/memories/:id       album detail with photo items
GET  /my/ask                Ask AI query history
POST /my/ask                submit a question → RAG pipeline → answer + citations
POST /my/opt-out            opt out of one/all groups
POST /my/opt-in             opt back in
POST /my/logout             clear session cookie

Admin console (/admin/* — JwtAuthGuard)

GET/POST /admin/messages/pending   list + approve/reject messages
GET      /admin/drafts             list AI content drafts (filter: type, status)
POST     /admin/drafts/:id/approve approve → creates Event/Seva/KnowledgeItem
POST     /admin/drafts/:id/reject  reject draft
GET/POST /admin/digest             digest config + trigger manual digest
GET/POST /admin/events             event CRUD
GET/POST /admin/knowledge          knowledge board + item CRUD
GET/POST /admin/gallery            album + photo CRUD
GET/POST /admin/circles            circle CRUD
GET/POST /admin/seva               seva opportunity CRUD
GET/POST /admin/rules              tenant rule CRUD
GET/POST /admin/groups             group management + sync routes
POST     /admin/groups/claim       claim a WhatsApp group

Org portal (/org/* — OrgAdminGuard)

GET/POST /org/chapters    list + create tenants under this org
GET/POST /org/rules       org-level routing rules (cascade to all Dalls)
GET      /org/admins      list org admins
POST     /org/login       org admin login

Super admin (/super-admin/* — SuperAdminGuard)

GET/POST /super-admin/orgs       org management
GET/POST /super-admin/tenants    tenant management
GET/POST /super-admin/bots       bot account management
POST     /super-admin/login

8. Member Portal (Next.js — /my/*)

11 pages, all tenant-scoped, all behind tower_member_token cookie.

Route Description
/my Dashboard — points score, upcoming events, recent digest, active circles
/my/digest Digest history list
/my/events Upcoming events with RSVP buttons
/my/seva Open seva opportunities with signup/cancel
/my/circles Circle browser with join/leave
/my/directory Member directory — searchable, interest filter, respects directoryVisible
/my/knowledge FAQ boards — searchable accordion
/my/memories Photo album grid → album detail
/my/ask Chat UI — question → RAG answer with citations
/my/groups Member's WhatsApp groups + consent status
/my/settings Profile edit, privacy settings, sign out

Member onboarding: /onboard — 5-step wizard (welcome → phone → consent → OTP → done).


9. Ask AI (RAG Pipeline)

Member asks a question → API does:

  1. Search Meilisearch tower-messages index for top-8 relevant approved messages (tenant-scoped filter)
  2. Build a grounded prompt: context snippets + member question
  3. Call OpenRouter LLM (google/gemini-3.5-flash via callLLM())
  4. Parse response + citations → return answer + source list
  5. Store in AskAIQuery for history

Degrades gracefully:

  • No OPENROUTER_API_KEY → returns top snippets as-is
  • No Meilisearch hits → "couldn't find anything relevant"

10. AI Dev Handoff — What You Receive

Queue connection

Redis: redis://redis:6379   (or REDIS_URL env var)
Queue: tower.event.extract  (BullMQ, standard protocol)
Queue: tower.faq.candidate  (BullMQ, standard protocol)

Job payload

Both queues deliver the same shape:

{
  "tenantId":  "cuid...",
  "messageId": "cuid...",
  "content":   "raw message text as received from WhatsApp",
  "traceId":   "optional trace string"
}

What you can query from our Postgres

Connect to postgresql://tower:<password>@postgres:5432/tower

Useful tables for your agents:

-- Full message detail
SELECT m.*, g.name AS group_name, g.platform
FROM "Message" m
JOIN "Group" g ON g.id = m."sourceGroupId"
WHERE m.id = $messageId;

-- Tenant context (org, settings)
SELECT t.*, o.name AS org_name, o.settings AS org_settings
FROM "Tenant" t
LEFT JOIN "Organization" o ON o.id = t."organizationId"
WHERE t.id = $tenantId;

-- Approved messages for context (what's already in the archive)
SELECT content, "senderName", "createdAt", tags
FROM "Message"
WHERE "tenantId" = $tenantId AND status = 'APPROVED'
ORDER BY "createdAt" DESC LIMIT 100;

How to write your output back

After your agents process a message, write a ContentDraft record:

INSERT INTO "ContentDraft"
  (id, "tenantId", type, status, "sourceMessageId", "proposedJson", confidence, "createdAt", "updatedAt")
VALUES
  (gen_random_uuid(), $tenantId, 'EVENT', 'PENDING_REVIEW', $messageId, $proposedJson::jsonb, $confidence, now(), now());

proposedJson shapes by type:

// EVENT
{ "title": "string", "date": "YYYY-MM-DD", "time": "HH:MM",
  "location": "string", "description": "string", "rsvpNeeded": true,
  "sevaActionItems": [{ "title": "string", "slots": 5, "skills": ["cooking"] }] }

// SEVA_TASK
{ "title": "string", "slots": 5, "skills": ["string"],
  "date": "YYYY-MM-DD", "parentEventTitle": "string" }

// FAQ_ITEM
{ "question": "string", "answer": "string", "tags": ["string"] }

Once written, the Dall admin will see your draft at /drafts in their portal, review it, and approve/reject. On approval the system automatically creates the Event, SevaOpportunity, or KnowledgeItem record.


11. Digest Generation (current implementation)

Triggered by: schedule (cron, configurable per Dall) or manual admin action.

  1. Load all APPROVED messages for the tenant in the last 24h
  2. Build a prompt: "Summarise these community messages into a concise digest..."
  3. Call callLLM() via OpenRouter
  4. Store Digest record, send via WhatsApp to DigestConfig.targetGroupJid

No RAG, no citations, no admin approval gate on digest output currently. This is a known gap for the AI dev to improve.


12. Environment Variables

# Database
DATABASE_URL=postgresql://tower:password@localhost:5433/tower

# Redis
REDIS_URL=redis://localhost:6379

# Meilisearch
MEILI_URL=http://localhost:7700
MEILI_MASTER_KEY=your_key

# Auth
JWT_SECRET=your_secret
JWT_EXPIRES_IN=7d
MEMBER_JWT_EXPIRES_IN=30d
BCRYPT_ROUNDS=10

# LLM (optional — pipeline degrades gracefully without it)
OPENROUTER_API_KEY=sk-or-...

# WhatsApp sessions
WHATSAPP_SESSION_PATH=/app/sessions

# Portal URL (for OTP links)
TOWER_PORTAL_BASE_URL=https://your-domain.com

# Email (optional)
SMTP_HOST=, SMTP_PORT=587, SMTP_USER=, SMTP_PASS=, SMTP_FROM=

13. Repository Structure

tower/
├── apps/
│   ├── api/            NestJS REST API + Prisma schema + migrations
│   │   └── prisma/
│   │       ├── schema.prisma    single source of truth for all models
│   │       └── migrations/      manual SQL migrations (prisma migrate deploy)
│   ├── worker/         BullMQ workers + WhatsApp session pool
│   │   └── src/
│   │       ├── queues/          one file per queue (queue.ts + processor.ts)
│   │       ├── whatsapp/        Baileys adapter, normaliser, match-rules
│   │       ├── spam/            content-aware spam detector
│   │       ├── ai/              llm-client.ts (OpenRouter HTTP wrapper)
│   │       ├── core/            approve-message, approval reaction handler
│   │       └── main.ts          bootstrap — session pool + all workers
│   └── web/            Next.js 14 App Router
│       └── app/
│           ├── _lib/            shared: api fetch helpers, auth context, sidebar
│           ├── my/              member portal pages (11 routes)
│           ├── admin/           super-admin pages
│           ├── drafts/          tenant-admin AI drafts review
│           ├── onboard/         member OTP onboarding wizard
│           └── api/             BFF route handlers (proxy to NestJS)
└── packages/
    ├── types/          shared TypeScript types (job payloads, JWT payloads, adapters)
    ├── config/         zod env validation
    ├── logger/         pino structured logger
    └── search/         Meilisearch client + index config

14. Migrations

Migrations are manual SQL files in apps/api/prisma/migrations/. Run with:

prisma migrate deploy

Never run prisma migrate dev in production — it can drop data. After adding models to schema.prisma, write the SQL manually, then run prisma generate to update the client types.


15. Known Gaps (not yet built)

Gap Impact
Official WhatsApp Business API adapter Baileys is non-production; adapter interface is abstracted and ready
Profanity filter (G1) No content profanity detection
Media split (G1) Images/video stored as mediaUrl string, never processed
Redis Streams handoff AI dev currently reads BullMQ queues; a Redis Stream would give them batch-read and replay
LLM guardrails (G3) No prompt injection / PII redaction on LLM calls
Notifications (Sprint 13) No MemberNotification model or outbox
Business Bazaar (Sprint 12) No BusinessProfile model
i18n Hindi/English (Sprint 14) English only
Observability No LLM trace logging (Opik), no task dashboard (Bull Board)