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:
@@ -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:<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) |
|
||||
Reference in New Issue
Block a user