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>
This commit is contained in:
2026-06-18 20:45:47 +05:30
parent 39ca91f07a
commit 6de78fe270
+826
View File
@@ -0,0 +1,826 @@
# 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):
```typescript
{
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).
---
### 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? ← 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`):
```typescript
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:
```json
{
"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:
```sql
-- 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:
```sql
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:
```json
// 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
```bash
# 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:
```bash
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) |