# 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% 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`): ```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:@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) |