Files
tower/docs/DATA_MODELS.md
T
maaz519 d21bd6bcb4 feat: add Redis Stream handoff for AI dev + monitor all messages
- Publish every non-spam message to tower:stream:messages immediately
  after DB insert in ingest.processor — before any admin approval or
  routing decision
- Remove early tags.length === 0 drop in main.ts so no-rule-match
  messages are now stored and streamed (monitor-all behaviour)
- Wire classify worker, review/p1/dnc queues into main.ts
- Add streams/message-stream.ts with XADD wrapper (capped at ~50k entries)
- Add docs/DATA_MODELS.md — full data model reference for all 29 models

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 13:56:10 +05:30

1142 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TOWER — Data Model Reference
> Source of truth: `apps/api/prisma/schema.prisma`
> Database: PostgreSQL 17 · ORM: Prisma · All IDs: cuid() · All timestamps: UTC
---
## Table of Contents
1. [Entity Relationship Overview](#1-entity-relationship-overview)
2. [Enums](#2-enums)
3. [Organisation Layer](#3-organisation-layer)
4. [Tenancy & Admins](#4-tenancy--admins)
5. [WhatsApp Accounts & Groups](#5-whatsapp-accounts--groups)
6. [Message Pipeline](#6-message-pipeline)
7. [Member Identity & Consent](#7-member-identity--consent)
8. [Rules Engine](#8-rules-engine)
9. [Digest](#9-digest)
10. [Community Content](#10-community-content)
11. [AI Draft Pipeline](#11-ai-draft-pipeline)
12. [Audit](#12-audit)
13. [Indexes Reference](#13-indexes-reference)
14. [Relationship Map](#14-relationship-map)
---
## 1. Entity Relationship Overview
```
SuperAdmin (platform operator)
Organization ─────────────────────────────────────────────────┐
├── OrgAdmin[] (national admins) │
├── OrgRule[] (routing rules, cascade to all Tenants) │
└── Tenant[] ───────────────────────────────────────────────┘
├── Admin[] (Dall admins)
├── TenantBot[] (bot access grants)
├── TenantRule[] (Dall-specific routing rules)
├── Group[] (WhatsApp groups)
│ └── Message[]
│ ├── Approval?
│ ├── ContentDraft[]
│ └── Thread?
├── TowerUser[] (members)
│ ├── TowerSession[]
│ ├── ConsentRecord[]
│ ├── EventRsvp[]
│ ├── SevaEntry[]
│ ├── GamificationEvent[]
│ ├── CircleMembership[]
│ └── AskAIQuery[]
├── DigestConfig?
├── Digest[]
├── Event[]
├── SevaOpportunity[]
├── Circle[]
├── KnowledgeBoard[]
├── KnowledgeItem[]
├── GalleryAlbum[]
├── GalleryItem[]
├── ContentDraft[]
└── AuditEvent[]
Account (global bot) ──── TenantBot ──── Tenant
└─── Group
```
---
## 2. Enums
### `OrgAdminRole`
| Value | Meaning |
|---|---|
| `ORG_OWNER` | Full access to organisation including billing/settings |
| `ORG_ADMIN` | Manage chapters and org rules, cannot delete org |
### `AdminRole`
Tenant-level admin roles.
| Value | Meaning |
|---|---|
| `OWNER` | Full tenant control, can delete tenant |
| `ADMIN` | Manage groups, rules, messages, content |
| `VIEWER` | Read-only access to messages and stats |
### `AccountStatus`
WhatsApp bot account status.
| Value | Meaning |
|---|---|
| `ACTIVE` | Session connected and healthy |
| `DISCONNECTED` | Session lost, awaiting reconnect or re-scan |
| `BANNED` | Number banned by WhatsApp |
| `PAIRING` | QR code displayed, waiting for scan |
### `GroupClaimStatus`
Lifecycle of a WhatsApp group in the system.
| Value | Meaning |
|---|---|
| `PENDING_CLAIM` | Bot sees the group, no tenant has claimed it |
| `CLAIMED` | A tenant admin has claimed it — `tenantId` is set |
| `RELEASED` | Admin explicitly released the group |
| `EXPIRED` | Claim token expired before being used |
### `MessageStatus`
Lifecycle of a single message through the pipeline.
| Value | Meaning |
|---|---|
| `RAW` | Just ingested, not yet classified |
| `PENDING` | Flagged or P1 — awaiting admin review |
| `APPROVED` | Admin approved, ready to forward |
| `REJECTED` | Admin rejected |
| `DISTRIBUTED` | Forwarded to all target groups |
| `DNC` | Do-Not-Classify — spam, no rule match, or rule said SKIP/REJECT |
| `ARCHIVED` | Retained past distribution window |
### `ApprovalDecision`
| Value | Meaning |
|---|---|
| `APPROVED` | Admin approved the message |
| `REJECTED` | Admin rejected the message |
### `ActorType`
Who performed an audited action.
| Value | Meaning |
|---|---|
| `ADMIN` | Tenant admin |
| `SYSTEM` | Automated pipeline action |
| `ADAPTER` | WhatsApp adapter (ingest events) |
| `MEMBER` | Community member via portal |
### `RuleMatchType`
How a routing rule matches message content.
| Value | Meaning |
|---|---|
| `HASHTAG` | Message contains the hashtag as a standalone token (e.g. `#Event`) |
| `PREFIX` | Message text starts with the prefix |
| `REACTION_EMOJI` | An emoji reaction on a message matches this value |
### `RuleAction`
What happens when a rule matches.
| Value | Meaning |
|---|---|
| `FLAG` | Route to review queue — human decision required |
| `AUTO_APPROVE` | Auto-approve if sender is a group admin, else FLAG |
| `SKIP` | Silently discard — do not store |
| `REJECT` | Store as DNC, do not process further |
| `P1` | Emergency priority — immediate admin alert |
Precedence (highest wins when multiple rules match): `SKIP > REJECT > P1 > AUTO_APPROVE > FLAG`
### `ConsentScope`
What a member has consented to for their messages.
| Value | Meaning |
|---|---|
| `INGEST` | Messages may be ingested into TOWER |
| `ARCHIVE` | Messages may be stored beyond the pipeline window |
| `REPLICATE` | Messages may be forwarded to other groups |
| `DISPLAY` | Messages may be shown in the member portal / digest |
### `ConsentStatus`
| Value | Meaning |
|---|---|
| `GRANTED` | Consent is active |
| `REVOKED` | Member revoked consent |
### `MemberOptOutReason`
| Value | Meaning |
|---|---|
| `STOP_KEYWORD` | Member sent STOP in a group |
| `SELF_PORTAL` | Member opted out via portal settings |
| `ADMIN_ACTION` | Admin manually opted the member out |
### `RsvpStatus`
| Value | Meaning |
|---|---|
| `GOING` | Member confirmed attendance |
| `NOT_GOING` | Member declined |
| `MAYBE` | Member is undecided |
### `SevaStatus`
| Value | Meaning |
|---|---|
| `OPEN` | Accepting signups |
| `CLOSED` | No longer accepting signups |
### `SevaEntryStatus`
| Value | Meaning |
|---|---|
| `SIGNED_UP` | Member has signed up |
| `COMPLETED` | Seva was completed — triggers gamification award |
| `CANCELLED` | Member cancelled their signup |
### `GamificationEventType`
Points awarded per event type. Time-decay: full ≤30d, 50% 3190d, 25% >90d.
| Value | Points | Trigger |
|---|---|---|
| `HELPFUL_ANSWER` | 10 | Admin marks a member's answer as helpful |
| `SEVA_COMPLETED` | 20 | SevaEntry status → COMPLETED |
| `EVENT_ATTENDANCE` | 5 | Member checks in / RSVPs GOING to an event |
| `FAQ_APPROVED` | 15 | A FAQ item attributed to a member is approved |
| `WELCOME` | 3 | Member completes onboarding |
| `PROFILE_COMPLETED` | 5 | Member fills name + hometown + at least one interest |
| `BUSINESS_ADDED` | 5 | Member adds a business to the bazaar (Sprint 12) |
### `CircleRole`
| Value | Meaning |
|---|---|
| `MEMBER` | Regular circle member |
| `LEAD` | Circle leader — can manage membership |
### `KnowledgeItemStatus`
| Value | Meaning |
|---|---|
| `DRAFT` | Not visible to members |
| `PUBLISHED` | Visible in member portal and Ask AI |
| `ARCHIVED` | Hidden from portal, retained in DB |
### `DraftType`
Type of AI-proposed content draft.
| Value | Meaning |
|---|---|
| `EVENT` | An event extracted from a WhatsApp message |
| `SEVA_TASK` | A volunteer task extracted as an action item within an event |
| `FAQ_ITEM` | A Q&A pair extracted from a WhatsApp message |
### `DraftStatus`
| Value | Meaning |
|---|---|
| `PENDING_REVIEW` | Waiting for admin decision |
| `APPROVED` | Admin approved — published record created |
| `REJECTED` | Admin rejected — nothing published |
---
## 3. Organisation Layer
### `Organization`
Top-level entity. One per national body (e.g. UP Parivaar).
| Field | Type | Notes |
|---|---|---|
| `id` | String (cuid) | PK |
| `slug` | String | URL-safe unique identifier |
| `name` | String | Display name |
| `settings` | Json | Org-level config (default `{}`) |
| `createdAt` | DateTime | |
| `updatedAt` | DateTime | |
**Relations:**
- `tenants``Tenant[]`
- `orgAdmins``OrgAdmin[]`
- `orgRules``OrgRule[]`
---
### `OrgAdmin`
Admin user at the organisation level.
| Field | Type | Notes |
|---|---|---|
| `id` | String (cuid) | PK |
| `organizationId` | String | FK → Organization |
| `email` | String | Unique per org |
| `passwordHash` | String | bcrypt |
| `name` | String? | Display name |
| `role` | OrgAdminRole | `ORG_OWNER` \| `ORG_ADMIN` |
| `createdAt` | DateTime | |
| `updatedAt` | DateTime | |
**Constraints:** `UNIQUE(organizationId, email)`
---
### `OrgRule`
Routing rule scoped to an organisation. Cascades to all Tenants under it. Evaluated before TenantRules — org rules are authoritative.
| Field | Type | Notes |
|---|---|---|
| `id` | String (cuid) | PK |
| `organizationId` | String | FK → Organization |
| `matchType` | RuleMatchType | `HASHTAG` \| `PREFIX` \| `REACTION_EMOJI` |
| `matchValue` | String | e.g. `#Event`, `URGENT:`, `👍` |
| `action` | RuleAction | What to do when matched |
| `priority` | Int | Lower = higher priority (default 0) |
| `isActive` | Boolean | Soft-disable without deleting |
| `createdAt` | DateTime | |
| `updatedAt` | DateTime | |
**Constraints:** `UNIQUE(organizationId, matchType, matchValue)`
---
## 4. Tenancy & Admins
### `Tenant`
One per Dall/chapter. The primary isolation boundary — every query is scoped by `tenantId`.
| Field | Type | Notes |
|---|---|---|
| `id` | String (cuid) | PK |
| `slug` | String | Globally unique URL slug |
| `name` | String | Display name (e.g. "UP Parivaar Dallas") |
| `isActive` | Boolean | Inactive tenants drop all messages |
| `isForwardingPaused` | Boolean | Pause message forwarding without stopping ingest |
| `settings` | Json | Tenant-specific config (default `{}`) |
| `organizationId` | String? | FK → Organization (null for standalone tenants) |
| `createdAt` | DateTime | |
| `updatedAt` | DateTime | |
**Relations:** all content models reference Tenant via `tenantId`.
---
### `Admin`
Tenant-level admin user.
| Field | Type | Notes |
|---|---|---|
| `id` | String (cuid) | PK |
| `tenantId` | String | FK → Tenant |
| `email` | String | Unique per tenant |
| `passwordHash` | String | bcrypt |
| `role` | AdminRole | `OWNER` \| `ADMIN` \| `VIEWER` |
| `createdAt` | DateTime | |
| `updatedAt` | DateTime | |
**Constraints:** `UNIQUE(tenantId, email)`
**Relations:** `claimedGroups``Group[]` (groups this admin claimed)
---
### `SuperAdmin`
Platform-level operator. Can create and manage organisations, tenants, and bot accounts.
| Field | Type | Notes |
|---|---|---|
| `id` | String (cuid) | PK |
| `email` | String | Globally unique |
| `passwordHash` | String | bcrypt |
| `name` | String? | |
| `createdAt` | DateTime | |
| `updatedAt` | DateTime | |
---
### `AuditEvent`
Append-only audit log. Written on every significant action across all realms.
| Field | Type | Notes |
|---|---|---|
| `id` | String (cuid) | PK |
| `tenantId` | String | FK → Tenant |
| `actorType` | ActorType | Who performed the action |
| `actorId` | String? | Admin/member ID (null for SYSTEM) |
| `action` | String | e.g. `MESSAGE_APPROVED`, `DRAFT_APPROVED` |
| `resourceType` | String | e.g. `Message`, `ContentDraft` |
| `resourceId` | String | ID of the affected record |
| `payload` | Json | Additional context (default `{}`) |
| `traceId` | String? | Distributed trace ID |
| `createdAt` | DateTime | |
**Indexes:** `(tenantId, createdAt)`, `(resourceType, resourceId)`
---
## 5. WhatsApp Accounts & Groups
### `Account`
A WhatsApp bot phone number. Global — not owned by any tenant. Access is granted per-tenant via `TenantBot`.
| Field | Type | Notes |
|---|---|---|
| `id` | String (cuid) | PK |
| `platform` | String | `"whatsapp"` currently |
| `jid` | String | WhatsApp JID (phone@s.whatsapp.net) — unique per platform |
| `sessionPath` | String | Filesystem path to Baileys session files |
| `displayName` | String? | Bot's WhatsApp display name |
| `status` | AccountStatus | Current connection state |
| `qrCode` | String? | Base64 QR — set during PAIRING, cleared on ACTIVE |
| `isBot` | Boolean | Always true for bot accounts |
| `pairingToken` | String? | One-time token for admin to initiate pairing |
| `pairingExpiresAt` | DateTime? | Token expiry |
| `createdAt` | DateTime | |
| `updatedAt` | DateTime | |
**Constraints:** `UNIQUE(platform, jid)`
---
### `TenantBot`
Junction table granting a tenant access to a bot account.
| Field | Type | Notes |
|---|---|---|
| `id` | String (cuid) | PK |
| `tenantId` | String | FK → Tenant |
| `accountId` | String | FK → Account |
| `isActive` | Boolean | |
| `createdAt` | DateTime | |
**Constraints:** `UNIQUE(tenantId, accountId)`
---
### `Group`
A WhatsApp group detected by a bot. Goes through a claim lifecycle before being associated with a tenant.
| Field | Type | Notes |
|---|---|---|
| `id` | String (cuid) | PK |
| `tenantId` | String? | FK → Tenant — **null until CLAIMED** |
| `platform` | String | `"whatsapp"` |
| `platformId` | String | WhatsApp group JID |
| `name` | String | Group display name |
| `description` | String? | Group description |
| `isActive` | Boolean | |
| `accountId` | String? | FK → Account (which bot sees this group) |
| `claimStatus` | GroupClaimStatus | `PENDING_CLAIM``CLAIMED` \| `RELEASED` \| `EXPIRED` |
| `claimedAt` | DateTime? | When it was claimed |
| `claimedByAdminId` | String? | FK → Admin |
| `claimExpiresAt` | DateTime? | Claim token expiry |
| `createdAt` | DateTime | |
| `updatedAt` | DateTime | |
**Constraints:** `UNIQUE(platform, platformId)`
**Relations:**
- `messages``Message[]`
- `syncRoutesFrom``SyncRoute[]` (this group is a source)
- `syncRoutesTo``SyncRoute[]` (this group is a target)
- `consents``ConsentRecord[]`
- `claimTokens``GroupClaimToken[]`
- `groupAccesses``GroupAccess[]`
- `threads``Thread[]`
---
### `SyncRoute`
Defines forwarding: approved messages from `sourceGroup` are forwarded to `targetGroup`.
| Field | Type | Notes |
|---|---|---|
| `id` | String (cuid) | PK |
| `tenantId` | String | FK → Tenant |
| `sourceGroupId` | String | FK → Group |
| `targetGroupId` | String | FK → Group |
| `isActive` | Boolean | |
| `createdAt` | DateTime | |
**Constraints:** `UNIQUE(sourceGroupId, targetGroupId)`
---
### `GroupClaimToken`
One-time token sent inside a WhatsApp group to claim it for a tenant.
| Field | Type | Notes |
|---|---|---|
| `id` | String (cuid) | PK |
| `groupId` | String | FK → Group |
| `token` | String | Unique random token |
| `creatorJid` | String | JID of admin who initiated the claim |
| `expiresAt` | DateTime | |
| `consumedAt` | DateTime? | Set when token is used |
| `createdAt` | DateTime | |
---
### `GroupAccess`
Grants a tenant read access to a group they do not own (shared group).
| Field | Type | Notes |
|---|---|---|
| `id` | String (cuid) | PK |
| `groupId` | String | FK → Group |
| `tenantId` | String | FK → Tenant |
| `grantedBy` | String | Admin ID who granted access |
| `createdAt` | DateTime | |
**Constraints:** `UNIQUE(groupId, tenantId)`
---
## 6. Message Pipeline
### `Message`
Central record. Created by `ingest.processor.ts` when a WhatsApp message passes pre-ingest spam checks.
| Field | Type | Notes |
|---|---|---|
| `id` | String (cuid) | PK |
| `tenantId` | String | FK → Tenant |
| `platform` | String | `"whatsapp"` |
| `platformMsgId` | String | Platform-native message ID — unique per platform |
| `sourceGroupId` | String | FK → Group |
| `senderJid` | String | WhatsApp JID of sender |
| `senderName` | String? | Display name at time of send |
| `senderTowerUserId` | String? | FK → TowerUser (null if sender not onboarded) |
| `content` | String | Raw message text |
| `mediaUrl` | String? | URL of attached media (stored, not processed currently) |
| `tags` | String[] | Rule match values (e.g. `["#Event"]`) |
| `status` | MessageStatus | Pipeline state — see enum |
| `expiresAt` | DateTime? | RAW/DNC messages expire after 72h |
| `quotedPlatformMsgId` | String? | Platform ID of quoted/replied-to message |
| `threadId` | String? | FK → Thread (set after thread resolution) |
| `createdAt` | DateTime | |
| `updatedAt` | DateTime | |
**Constraints:** `UNIQUE(platform, platformMsgId)`
**Relations:**
- `approval``Approval?`
- `contentDrafts``ContentDraft[]`
**Status lifecycle:**
```
ingest → RAW
classify → PENDING (if flagged/P1) | DNC (if spam/no match/SKIP)
admin approve → APPROVED
forward worker → DISTRIBUTED
admin reject → REJECTED
archive job → ARCHIVED
```
---
### `Thread`
A group of related messages — resolved from quoted replies.
| Field | Type | Notes |
|---|---|---|
| `id` | String (cuid) | PK |
| `tenantId` | String | FK → Tenant |
| `sourceGroupId` | String | FK → Group |
| `lastActivityAt` | DateTime | Updated on each new message in thread |
| `messageCount` | Int | Denormalised count |
| `topic` | String? | LLM-inferred topic label |
| `createdAt` | DateTime | |
**Relations:** `messages``Message[]`
---
### `Approval`
Records the admin decision for a message. One per message.
| Field | Type | Notes |
|---|---|---|
| `id` | String (cuid) | PK |
| `tenantId` | String | FK → Tenant |
| `messageId` | String | FK → Message **(unique)** |
| `adminId` | String | Admin who decided |
| `decision` | ApprovalDecision | `APPROVED` \| `REJECTED` |
| `notes` | String? | Optional admin note |
| `decidedAt` | DateTime | |
---
## 7. Member Identity & Consent
### `TowerUser`
A community member. Phone number is **never stored** — only a salted SHA-256 hash.
| Field | Type | Notes |
|---|---|---|
| `id` | String (cuid) | PK |
| `tenantId` | String | FK → Tenant |
| `phoneHash` | String | `SHA-256(E.164_phone + JWT_SECRET)` — 64 hex chars |
| `jid` | String | WhatsApp JID — used for OTP delivery only |
| `displayName` | String? | Member's chosen name |
| `avatar` | String? | Profile image URL |
| `hometown` | String? | Where they're originally from |
| `currentLocation` | String? | Where they live now |
| `interests` | String[] | Self-declared interest tags |
| `language` | String | `"en"` \| `"hi"` (default `"en"`) |
| `digestPreference` | String | `"daily"` \| `"weekly"` \| `"off"` (default `"daily"`) |
| `directoryVisible` | Boolean | Show in member directory (default `true`) |
| `createdAt` | DateTime | |
| `updatedAt` | DateTime | |
**Constraints:** `UNIQUE(tenantId, phoneHash)` — one member per Dall
**Relations:**
- `consents``ConsentRecord[]`
- `optOuts``MemberOptOut[]`
- `sessions``TowerSession[]`
- `messages``Message[]` (messages sent by this member)
- `rsvps``EventRsvp[]`
- `sevaEntries``SevaEntry[]`
- `gamificationEvents``GamificationEvent[]`
- `circleMemberships``CircleMembership[]`
- `askAIQueries``AskAIQuery[]`
---
### `TowerSession`
JWT-backed member session. One session per login.
| Field | Type | Notes |
|---|---|---|
| `id` | String (cuid) | PK |
| `userId` | String | FK → TowerUser |
| `tokenHash` | String | SHA-256 of the JWT — unique |
| `expiresAt` | DateTime | 30 days from creation |
| `createdAt` | DateTime | |
Cookie name: `tower_member_token` (HttpOnly, SameSite=Lax)
---
### `ConsentRecord`
GDPR-style consent per (member × group × scope set). Created during onboarding OTP flow.
| Field | Type | Notes |
|---|---|---|
| `id` | String (cuid) | PK |
| `tenantId` | String | FK → Tenant |
| `groupId` | String | FK → Group (which group consent applies to) |
| `userId` | String | FK → TowerUser |
| `scopes` | ConsentScope[] | Which processing activities are consented to |
| `retentionDays` | Int | How long to retain data (default 90) |
| `policyVersion` | String | Consent policy version string |
| `status` | ConsentStatus | `GRANTED` \| `REVOKED` |
| `proofEventId` | String | Platform event ID proving consent was given |
| `effectiveAt` | DateTime | When consent became active |
| `revokedAt` | DateTime? | When revoked (null if still active) |
| `createdAt` | DateTime | |
---
### `MemberOptOut`
Records a member opting out of processing.
| Field | Type | Notes |
|---|---|---|
| `id` | String (cuid) | PK |
| `tenantId` | String | FK → Tenant |
| `userId` | String | FK → TowerUser |
| `groupId` | String? | Specific group opt-out (null = all groups) |
| `reason` | MemberOptOutReason | How they opted out |
| `notes` | String? | |
| `createdAt` | DateTime | |
---
### `OtpChallenge`
Short-lived OTP for member login. Delivered via WhatsApp DM.
| Field | Type | Notes |
|---|---|---|
| `id` | String (cuid) | PK |
| `tenantId` | String | |
| `jid` | String | WhatsApp JID to deliver OTP to |
| `phoneHash` | String | Member identity hash |
| `code` | String | 6-digit OTP |
| `scopes` | ConsentScope[] | Scopes being consented to |
| `retentionDays` | Int | default 90 |
| `policyVersion` | String | |
| `groupId` | String | Which group the member was in when they triggered onboarding |
| `expiresAt` | DateTime | 10 minutes from creation |
| `consumedAt` | DateTime? | Set when OTP is verified |
| `sentAt` | DateTime? | Set when WhatsApp DM is delivered |
| `createdAt` | DateTime | |
---
## 8. Rules Engine
### `TenantRule`
Tenant-level routing rule. Evaluated as fallback if no OrgRule matches.
| Field | Type | Notes |
|---|---|---|
| `id` | String (cuid) | PK |
| `tenantId` | String | FK → Tenant |
| `matchType` | RuleMatchType | `HASHTAG` \| `PREFIX` \| `REACTION_EMOJI` |
| `matchValue` | String | Pattern to match (e.g. `#Event`, `URGENT:`, `👍`) |
| `action` | RuleAction | What to do when matched |
| `priority` | Int | Lower number = higher priority (default 0) |
| `isActive` | Boolean | Soft-disable |
| `createdAt` | DateTime | |
| `updatedAt` | DateTime | |
**Constraints:** `UNIQUE(tenantId, matchType, matchValue)`
**Rule evaluation order:**
1. Load OrgRules for this tenant's org (ordered by priority ASC)
2. Load TenantRules (ordered by priority ASC)
3. Run OrgRules first — if any match, use that effectiveAction (org is authoritative)
4. If no OrgRule matched, run TenantRules
5. Highest-precedence action wins across all matching rules
---
## 9. Digest
### `DigestConfig`
One digest schedule per tenant. Controls when and where the daily digest is sent.
| Field | Type | Notes |
|---|---|---|
| `id` | String (cuid) | PK |
| `tenantId` | String | FK → Tenant **(unique)** — one config per tenant |
| `targetGroupJid` | String | WhatsApp group to send digest to |
| `targetAccountId` | String | Bot account to send from |
| `scheduleHour` | Int | Hour (023, default 20) |
| `scheduleMinute` | Int | Minute (059, default 0) |
| `isActive` | Boolean | Enable/disable schedule |
| `lastSentAt` | DateTime? | Timestamp of last successful send |
| `createdAt` | DateTime | |
| `updatedAt` | DateTime | |
---
### `Digest`
A generated and sent digest. One per (tenant, date).
| Field | Type | Notes |
|---|---|---|
| `id` | String (cuid) | PK |
| `tenantId` | String | FK → Tenant |
| `content` | String | LLM-generated digest text |
| `messageIds` | String[] | IDs of messages included in this digest |
| `digestDate` | DateTime | The day this digest covers |
| `sentAt` | DateTime | When it was sent |
**Constraints:** `UNIQUE(tenantId, digestDate)`
---
## 10. Community Content
### `Event`
A community event. Populated either by admin approval of a `ContentDraft` (AI-extracted) or directly via admin API.
| Field | Type | Notes |
|---|---|---|
| `id` | String (cuid) | PK |
| `tenantId` | String | FK → Tenant |
| `title` | String | Event name |
| `description` | String? | |
| `location` | String? | Venue or address |
| `startsAt` | DateTime | Event start time |
| `endsAt` | DateTime? | Event end time |
| `createdBy` | String | Admin ID who created/approved it |
| `isPublished` | Boolean | Visible to members (default `false`) |
| `createdAt` | DateTime | |
| `updatedAt` | DateTime | |
**Relations:** `rsvps``EventRsvp[]`
---
### `EventRsvp`
A member's RSVP to an event. Upserted (one per member per event).
| Field | Type | Notes |
|---|---|---|
| `id` | String (cuid) | PK |
| `eventId` | String | FK → Event (cascade delete) |
| `userId` | String | FK → TowerUser |
| `status` | RsvpStatus | `GOING` \| `NOT_GOING` \| `MAYBE` |
| `note` | String? | Optional member note |
| `createdAt` | DateTime | |
| `updatedAt` | DateTime | |
**Constraints:** `UNIQUE(eventId, userId)`
---
### `SevaOpportunity`
A volunteer opportunity. May be auto-extracted as an action item within an event message.
| Field | Type | Notes |
|---|---|---|
| `id` | String (cuid) | PK |
| `tenantId` | String | FK → Tenant |
| `title` | String | Task name |
| `description` | String? | |
| `location` | String? | |
| `slots` | Int? | Max volunteers (null = unlimited) |
| `startsAt` | DateTime? | When the task takes place |
| `pointsAward` | Int | Gamification points on completion (default 20) |
| `status` | SevaStatus | `OPEN` \| `CLOSED` |
| `createdBy` | String | Admin ID |
| `isPublished` | Boolean | Visible to members (default `false`) |
| `createdAt` | DateTime | |
| `updatedAt` | DateTime | |
**Relations:** `entries``SevaEntry[]`
---
### `SevaEntry`
A member's signup for a seva opportunity.
| Field | Type | Notes |
|---|---|---|
| `id` | String (cuid) | PK |
| `opportunityId` | String | FK → SevaOpportunity (cascade delete) |
| `userId` | String | FK → TowerUser |
| `status` | SevaEntryStatus | `SIGNED_UP``COMPLETED` \| `CANCELLED` |
| `hours` | Float? | Hours logged on completion |
| `note` | String? | |
| `completedAt` | DateTime? | Set when status → COMPLETED |
| `createdAt` | DateTime | |
| `updatedAt` | DateTime | |
**Constraints:** `UNIQUE(opportunityId, userId)`
---
### `GamificationEvent`
Points ledger. Each record represents one award. Idempotent — duplicate awards are silently ignored (P2002 caught).
| Field | Type | Notes |
|---|---|---|
| `id` | String (cuid) | PK |
| `tenantId` | String | FK → Tenant |
| `userId` | String | FK → TowerUser |
| `type` | GamificationEventType | What triggered the award |
| `points` | Int | Points awarded at time of event |
| `refType` | String? | Type of the reference record (e.g. `"SevaEntry"`) |
| `refId` | String? | ID of the reference record |
| `createdAt` | DateTime | |
**Constraints:** `UNIQUE(userId, type, refId)` — prevents double-awarding the same source
**Score calculation:**
```
lifetimeScore = SUM(points)
recentScore = SUM(points × decayFactor(ageDays))
where decayFactor: ≤30d → 1.0, 3190d → 0.5, >90d → 0.25
```
---
### `Circle`
An interest or affinity sub-group within a Dall. Members can join/leave circles.
| Field | Type | Notes |
|---|---|---|
| `id` | String (cuid) | PK |
| `tenantId` | String | FK → Tenant |
| `name` | String | Unique per tenant |
| `description` | String? | |
| `isPublic` | Boolean | Public = any member can join; private = invite-only (default `true`) |
| `createdBy` | String | Admin ID |
| `createdAt` | DateTime | |
| `updatedAt` | DateTime | |
**Constraints:** `UNIQUE(tenantId, name)`
**Relations:** `memberships``CircleMembership[]`
---
### `CircleMembership`
Junction between a member and a circle.
| Field | Type | Notes |
|---|---|---|
| `id` | String (cuid) | PK |
| `circleId` | String | FK → Circle (cascade delete) |
| `userId` | String | FK → TowerUser |
| `role` | CircleRole | `MEMBER` \| `LEAD` |
| `createdAt` | DateTime | |
**Constraints:** `UNIQUE(circleId, userId)`
---
### `KnowledgeBoard`
A category/board that groups related FAQ items.
| Field | Type | Notes |
|---|---|---|
| `id` | String (cuid) | PK |
| `tenantId` | String | FK → Tenant |
| `name` | String | Board title |
| `description` | String? | |
| `slug` | String | URL-safe slug, unique per tenant |
| `sortOrder` | Int | Display order (default 0) |
| `createdAt` | DateTime | |
| `updatedAt` | DateTime | |
**Constraints:** `UNIQUE(tenantId, slug)`
**Relations:** `items``KnowledgeItem[]`
---
### `KnowledgeItem`
A single FAQ entry. Visible to members when status=PUBLISHED. Used as context source for Ask AI.
| Field | Type | Notes |
|---|---|---|
| `id` | String (cuid) | PK |
| `boardId` | String | FK → KnowledgeBoard (cascade delete) |
| `tenantId` | String | FK → Tenant |
| `question` | String | The question |
| `answer` | String | The answer |
| `tags` | String[] | Topic tags for search/filter |
| `status` | KnowledgeItemStatus | `DRAFT` \| `PUBLISHED` \| `ARCHIVED` |
| `sortOrder` | Int | Display order within board (default 0) |
| `createdBy` | String | Admin ID |
| `createdAt` | DateTime | |
| `updatedAt` | DateTime | |
---
### `GalleryAlbum`
A photo album (event memories, chapter highlights).
| Field | Type | Notes |
|---|---|---|
| `id` | String (cuid) | PK |
| `tenantId` | String | FK → Tenant |
| `title` | String | Album name |
| `description` | String? | |
| `coverUrl` | String? | URL of cover photo |
| `isPublished` | Boolean | Visible to members (default `false`) |
| `createdBy` | String | Admin ID |
| `createdAt` | DateTime | |
| `updatedAt` | DateTime | |
**Relations:** `items``GalleryItem[]`
---
### `GalleryItem`
A single photo within an album.
| Field | Type | Notes |
|---|---|---|
| `id` | String (cuid) | PK |
| `albumId` | String | FK → GalleryAlbum (cascade delete) |
| `tenantId` | String | FK → Tenant |
| `mediaUrl` | String | Photo URL |
| `caption` | String? | |
| `sortOrder` | Int | Display order within album (default 0) |
| `createdAt` | DateTime | |
---
### `AskAIQuery`
Log of every Ask AI question and answer. Used for query history in the member portal.
| Field | Type | Notes |
|---|---|---|
| `id` | String (cuid) | PK |
| `tenantId` | String | FK → Tenant |
| `userId` | String | FK → TowerUser |
| `question` | String | Member's question |
| `answer` | String | LLM-generated answer |
| `citations` | Json | Array of citation objects (see shape below) |
| `createdAt` | DateTime | |
**Citations shape:**
```json
[
{
"messageId": "string",
"snippet": "string",
"senderName": "string",
"sourceGroupName": "string",
"approvedAt": 1234567890
}
]
```
---
## 11. AI Draft Pipeline
### `ContentDraft`
An AI-proposed community content item pending admin review. Created by the Event Extractor (`tower.event.extract` worker) or FAQ Curator (`tower.faq.candidate` worker).
| Field | Type | Notes |
|---|---|---|
| `id` | String (cuid) | PK |
| `tenantId` | String | FK → Tenant |
| `type` | DraftType | `EVENT` \| `SEVA_TASK` \| `FAQ_ITEM` |
| `status` | DraftStatus | `PENDING_REVIEW` \| `APPROVED` \| `REJECTED` |
| `sourceMessageId` | String | FK → Message (the WhatsApp message that triggered extraction) |
| `proposedJson` | Json | AI-proposed content — shape depends on `type` (see below) |
| `confidence` | Float? | Extraction confidence 01 |
| `reviewedBy` | String? | Admin ID who approved/rejected |
| `reviewedAt` | DateTime? | When the decision was made |
| `publishedId` | String? | ID of the created Event/SevaOpportunity/KnowledgeItem on approval |
| `createdAt` | DateTime | |
| `updatedAt` | DateTime | |
**`proposedJson` shapes by type:**
```json
// EVENT
{
"title": "string",
"date": "YYYY-MM-DD or null",
"time": "HH:MM or null",
"location": "string or null",
"description": "string",
"rsvpNeeded": true,
"sevaActionItems": [
{ "title": "string", "slots": 10, "skills": ["cooking", "setup"] }
]
}
// SEVA_TASK (auto-created when EVENT has sevaActionItems)
{
"title": "string",
"slots": 5,
"skills": ["string"],
"date": "YYYY-MM-DD or null",
"parentEventTitle": "string"
}
// FAQ_ITEM
{
"question": "string",
"answer": "string",
"tags": ["string"]
}
```
**On approval:**
- `EVENT` → creates `Event` record with `isPublished=true`
- `SEVA_TASK` → creates `SevaOpportunity` record with `isPublished=true`
- `FAQ_ITEM` → creates `KnowledgeItem` in default board (creates board if none exists)
**Confidence scoring:**
- EVENT: `filledFields / 5` (title, date, time, location, description)
- SEVA_TASK: `parent event confidence × 0.9`
- FAQ_ITEM: fixed `0.8`
---
## 12. Audit
See [Section 4 — AuditEvent](#auitevent).
**All current action values:**
| Action | Triggered by |
|---|---|
| `AUTH_LOGIN` | Admin login |
| `AUTH_LOGIN_FAILED` | Failed login attempt |
| `AUTH_LOGOUT` | Admin logout |
| `AUTH_SIGNUP` | New admin created |
| `ROUTE_CREATED / ROUTE_DELETED` | SyncRoute changes |
| `ACCOUNT_CREATED` | New bot account |
| `MESSAGE_INGESTED` | Message stored |
| `MESSAGE_APPROVED / REJECTED / FORWARDED / INDEXED` | Message lifecycle |
| `BOT_INITIATED / PAIRED / REVEALED / REMOVED` | Bot lifecycle |
| `GROUP_*` | Group claim/release/share lifecycle |
| `MEMBER_ONBOARDED / OPT_OUT / OPT_IN / DELETED` | Member lifecycle |
| `OTP_REQUESTED / OTP_VERIFIED` | Member auth |
| `EVENT_CREATED / UPDATED / DELETED` | Event management |
| `DIGEST_SENT` | Digest delivery |
| `SEVA_CREATED / DELETED / COMPLETED` | Seva lifecycle |
| `CIRCLE_CREATED / DELETED` | Circle management |
| `DRAFT_APPROVED / DRAFT_REJECTED` | AI draft review |
---
## 13. Indexes Reference
| Model | Index fields | Purpose |
|---|---|---|
| OrgAdmin | `(organizationId)` | List admins by org |
| OrgRule | `(organizationId, isActive)` | Load active org rules |
| Account | `(isBot)`, `(status)` | Find active bots |
| TenantBot | `(accountId)` | Find tenants per bot |
| Group | `(accountId)`, `(tenantId)`, `(claimStatus)` | Group lookups |
| GroupClaimToken | `(expiresAt)` | Expire old tokens |
| GroupAccess | `(tenantId)` | Find shared groups |
| Message | `(tenantId)`, `(senderTowerUserId)`, `(status, expiresAt)`, `(threadId)` | Pipeline queries |
| Thread | `(tenantId)`, `(sourceGroupId, lastActivityAt)` | Thread browsing |
| Approval | `(tenantId)` | |
| SyncRoute | `(tenantId)` | |
| TowerUser | `(phoneHash)`, `(tenantId)`, `(jid)` | Member lookup by phone/JID |
| TowerSession | `(userId)`, `(expiresAt)` | Session cleanup |
| ConsentRecord | `(tenantId, groupId, userId)`, `(status)`, `(userId)` | Consent checks |
| MemberOptOut | `(tenantId, userId)`, `(groupId)`, `(userId)` | Opt-out checks |
| OtpChallenge | `(tenantId, jid)`, `(expiresAt)`, `(sentAt)` | OTP lookup + cleanup |
| TenantRule | `(tenantId, isActive)`, `(tenantId, matchType)` | Rule engine |
| Digest | `(tenantId)` | |
| Event | `(tenantId, startsAt)`, `(tenantId, isPublished)` | Event listing |
| EventRsvp | `(userId)` | Member RSVP history |
| SevaOpportunity | `(tenantId, status)`, `(tenantId, isPublished)` | Seva listing |
| SevaEntry | `(userId)` | Member seva history |
| GamificationEvent | `(tenantId, userId)`, `(userId, createdAt)` | Score calculation |
| Circle | `(tenantId, isPublic)` | Circle browser |
| CircleMembership | `(userId)` | Member's circles |
| AskAIQuery | `(tenantId, createdAt)`, `(userId, createdAt)` | History |
| KnowledgeBoard | `(tenantId)` | |
| KnowledgeItem | `(tenantId, status)`, `(boardId, status)` | FAQ listing |
| GalleryAlbum | `(tenantId, isPublished)` | Album listing |
| GalleryItem | `(albumId)`, `(tenantId)` | Photo listing |
| ContentDraft | `(tenantId, status)`, `(tenantId, type, status)`, `(sourceMessageId)` | Draft review |
| AuditEvent | `(tenantId, createdAt)`, `(resourceType, resourceId)` | Audit log |
---
## 14. Relationship Map
```
Organization ──────── OrgAdmin (many)
──────── OrgRule (many)
──────── Tenant (many)
Tenant ─────────────── Admin (many)
─────────────── TenantBot (many) ──── Account (global, many Tenants)
─────────────── TenantRule (many)
─────────────── Group (many) ─────── Message (many)
│ │
│ ├── Approval (one)
│ └── ContentDraft (many)
├── TowerUser (many)
│ ├── TowerSession (many)
│ ├── ConsentRecord (many)
│ ├── MemberOptOut (many)
│ ├── EventRsvp (many) ────── Event (many, per Tenant)
│ ├── SevaEntry (many) ────── SevaOpportunity (many, per Tenant)
│ ├── GamificationEvent (many)
│ ├── CircleMembership (many) ── Circle (many, per Tenant)
│ └── AskAIQuery (many)
├── DigestConfig (one)
├── Digest (many)
├── Event (many) ──────────── EventRsvp (many)
├── SevaOpportunity (many) ── SevaEntry (many)
├── Circle (many) ─────────── CircleMembership (many)
├── KnowledgeBoard (many) ─── KnowledgeItem (many)
├── GalleryAlbum (many) ───── GalleryItem (many)
├── ContentDraft (many) ────── Message (source)
│ on approve →
│ creates Event OR SevaOpportunity OR KnowledgeItem
└── AuditEvent (many)
```