d21bd6bcb4
- 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>
1142 lines
37 KiB
Markdown
1142 lines
37 KiB
Markdown
# 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)
|
||
```
|