diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..c1e8260 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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% 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) |