# 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% 31–90d, 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 (0–23, default 20) | | `scheduleMinute` | Int | Minute (0–59, 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, 31–90d → 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 0–1 | | `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) ```