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>
27 KiB
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
messageIdis a DB primary key. You can query our PostgresMessagetable forsenderName,sourceGroupId,tags,platform,createdAtand any other fields you need. Redis is atredis: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% 31–90d, 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).
5.7 Knowledge & Gallery
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? ← 0–1 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:
- Search Meilisearch
tower-messagesindex for top-8 relevant approved messages (tenant-scoped filter) - Build a grounded prompt:
context snippets + member question - Call OpenRouter LLM (
google/gemini-3.5-flashviacallLLM()) - Parse response + citations → return answer + source list
- Store in
AskAIQueryfor 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.
- Load all
APPROVEDmessages for the tenant in the last 24h - Build a prompt:
"Summarise these community messages into a concise digest..." - Call
callLLM()via OpenRouter - Store
Digestrecord, send via WhatsApp toDigestConfig.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) |