From d21bd6bcb45f8d1831f625de0c9e0d0cfed11fef Mon Sep 17 00:00:00 2001 From: maaz519 Date: Fri, 19 Jun 2026 13:56:10 +0530 Subject: [PATCH] feat: add Redis Stream handoff for AI dev + monitor all messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/worker/src/main.ts | 36 +- apps/worker/src/queues/ingest.processor.ts | 22 +- apps/worker/src/streams/message-stream.ts | 48 + docs/DATA_MODELS.md | 1141 ++++++++++++++++++++ 4 files changed, 1241 insertions(+), 6 deletions(-) create mode 100644 apps/worker/src/streams/message-stream.ts create mode 100644 docs/DATA_MODELS.md diff --git a/apps/worker/src/main.ts b/apps/worker/src/main.ts index 11441b0..ec0c21d 100644 --- a/apps/worker/src/main.ts +++ b/apps/worker/src/main.ts @@ -1,13 +1,19 @@ +import Redis from 'ioredis'; import { PrismaClient } from '@prisma/client'; import { createLogger } from '@tower/logger'; import { validateEnv } from '@tower/config'; import { createMeiliClient, configureIndex } from '@tower/search'; import { createIngestQueue } from './queues/ingest.queue'; import { createIngestWorker } from './queues/ingest.processor'; +import { createClassifyQueue } from './queues/classify.queue'; +import { createClassifyWorker } from './queues/classify.processor'; import { createForwardQueue } from './queues/forward.queue'; import { createForwardWorker } from './queues/forward.processor'; import { createIndexQueue } from './queues/index.queue'; import { createIndexWorker } from './queues/index.processor'; +import { createReviewQueue } from './queues/review.queue'; +import { createP1Queue } from './queues/p1.queue'; +import { createDncQueue } from './queues/dnc.queue'; import { createSpamQueue } from './queues/spam.queue'; import { createSpamWorker } from './queues/spam.processor'; import { createEventExtractQueue } from './queues/event-extract.queue'; @@ -35,14 +41,28 @@ async function bootstrap() { ); const ingestQueue = createIngestQueue(env.REDIS_URL); + const classifyQueue = createClassifyQueue(env.REDIS_URL); const forwardQueue = createForwardQueue(env.REDIS_URL); const indexQueue = createIndexQueue(env.REDIS_URL); + const reviewQueue = createReviewQueue(env.REDIS_URL); + const p1Queue = createP1Queue(env.REDIS_URL); + const dncQueue = createDncQueue(env.REDIS_URL); const spamQueue = createSpamQueue(env.REDIS_URL); const eventExtractQueue = createEventExtractQueue(env.REDIS_URL); const faqCandidateQueue = createFaqCandidateQueue(env.REDIS_URL); const pool = new WhatsAppSessionPool(); - const ingestWorker = createIngestWorker(env.REDIS_URL, prisma, pool, forwardQueue, indexQueue); + // Dedicated Redis client for XADD stream writes (separate from BullMQ connections) + const { hostname, port } = new URL(env.REDIS_URL); + const streamRedis = new Redis({ host: hostname, port: parseInt(port || '6379', 10), maxRetriesPerRequest: null, lazyConnect: true }); + await streamRedis.connect().catch((err) => logger.warn({ err }, 'Stream Redis connect failed — stream publish will be degraded')); + + const ingestWorker = createIngestWorker(env.REDIS_URL, prisma, pool, forwardQueue, indexQueue, streamRedis); + const classifyWorker = createClassifyWorker( + env.REDIS_URL, prisma, pool, + forwardQueue, indexQueue, reviewQueue, p1Queue, dncQueue, spamQueue, + eventExtractQueue, faqCandidateQueue, + ); const forwardWorker = createForwardWorker(env.REDIS_URL, pool); const indexWorker = createIndexWorker(env.REDIS_URL, meiliClient); const spamWorker = createSpamWorker(env.REDIS_URL, prisma); @@ -51,6 +71,7 @@ async function bootstrap() { ingestWorker.on('completed', (job) => logger.info({ jobId: job.id }, 'Ingest job completed')); ingestWorker.on('failed', (job, err) => logger.error({ jobId: job?.id, err }, 'Ingest job failed')); + classifyWorker.on('failed', (job, err) => logger.error({ jobId: job?.id, err }, 'Classify job failed')); forwardWorker.on('completed', (job) => logger.info({ jobId: job.id }, 'Forward job completed')); forwardWorker.on('failed', (job, err) => logger.error({ jobId: job?.id, err }, 'Forward job failed')); indexWorker.on('completed', (job) => logger.info({ jobId: job.id }, 'Index job completed')); @@ -139,14 +160,13 @@ async function bootstrap() { const { tags, effectiveAction } = matchContentRules(msg.content, ruleRows); logger.info({ tags, effectiveAction }, 'Rule match result'); - // No matching rules — drop the message - if (tags.length === 0) return; - - // SKIP action — silently drop + // SKIP action — silently drop (explicit admin instruction to ignore) if (effectiveAction === 'SKIP') { logger.info({ platformMsgId: msg.platformMsgId }, 'Message skipped by rule'); return; } + // No rule match → ingest with null effectiveAction so it's stored and streamed + // but not forwarded. This is the "monitor all" behaviour. // For AUTO_APPROVE, check if the sender is a group admin let finalAction = effectiveAction; @@ -312,17 +332,23 @@ async function bootstrap() { logger.info('Shutting down...'); await pool.closeAll(); await ingestWorker.close(); + await classifyWorker.close(); await forwardWorker.close(); await indexWorker.close(); await spamWorker.close(); await eventExtractWorker.close(); await faqCuratorWorker.close(); await ingestQueue.close(); + await classifyQueue.close(); await forwardQueue.close(); await indexQueue.close(); + await reviewQueue.close(); + await p1Queue.close(); + await dncQueue.close(); await spamQueue.close(); await eventExtractQueue.close(); await faqCandidateQueue.close(); + await streamRedis.quit(); await prisma.$disconnect(); process.exit(0); }; diff --git a/apps/worker/src/queues/ingest.processor.ts b/apps/worker/src/queues/ingest.processor.ts index 19a543c..de5f25e 100644 --- a/apps/worker/src/queues/ingest.processor.ts +++ b/apps/worker/src/queues/ingest.processor.ts @@ -1,7 +1,9 @@ import { Worker, Queue } from 'bullmq'; +import Redis from 'ioredis'; import { IngestJobData, ForwardJobData, IndexJobData } from '@tower/types'; import { parseRedisUrl } from './redis-connection'; import { approveMessage } from '../core/approve-message'; +import { publishToMessageStream } from '../streams/message-stream'; import { createLogger } from '@tower/logger'; const logger = createLogger('ingest-processor'); @@ -12,6 +14,7 @@ export async function processIngestJob( pool?: any, forwardQueue?: Queue, indexQueue?: Queue, + redis?: Redis, ): Promise { // Defensive: drop messages from non-CLAIMED groups const group = await prisma.group.findUnique({ @@ -87,6 +90,22 @@ export async function processIngestJob( update: {}, }); + // Publish to AI-dev stream immediately — before any routing decision. + // Admin approval controls forwarding only; it must never block the stream. + if (redis) { + publishToMessageStream(redis, { + messageId: msg.id, + tenantId: job.tenantId, + content: job.content, + senderJid: job.senderJid, + senderName: job.senderName, + sourceGroupId: job.sourceGroupId, + tags: job.tags, + effectiveAction: job.effectiveAction ?? null, + timestamp: new Date().toISOString(), + }).catch((err: unknown) => logger.warn({ err, messageId: msg.id }, 'Failed to publish to message stream')); + } + // For REJECT, create an approval record so it's searchable as rejected if (job.effectiveAction === 'REJECT') { await prisma.approval.upsert({ @@ -133,10 +152,11 @@ export function createIngestWorker( pool?: any, forwardQueue?: Queue, indexQueue?: Queue, + redis?: Redis, ): Worker { return new Worker( 'tower-ingest', - async (job) => processIngestJob(job.data, prisma, pool, forwardQueue, indexQueue), + async (job) => processIngestJob(job.data, prisma, pool, forwardQueue, indexQueue, redis), { connection: parseRedisUrl(redisUrl) }, ); } diff --git a/apps/worker/src/streams/message-stream.ts b/apps/worker/src/streams/message-stream.ts new file mode 100644 index 0000000..cd38a5c --- /dev/null +++ b/apps/worker/src/streams/message-stream.ts @@ -0,0 +1,48 @@ +import Redis from 'ioredis'; + +export const STREAM_KEY = 'tower:stream:messages'; + +// Approximate cap — Redis trims to ~this many entries. +// At 1000 msg/day that's ~50 days of history. +const STREAM_MAX_LEN = 50_000; + +export interface StreamMessagePayload { + messageId: string; + tenantId: string; + content: string; + senderJid: string; + senderName?: string; + sourceGroupId: string; + tags: string[]; + effectiveAction: string | null; + traceId?: string; + timestamp: string; +} + +/** + * Appends one entry to the Redis Stream used as the AI-dev handoff channel. + * XADD tower:stream:messages MAXLEN ~ 50000 * field value ... + * + * The AI developer reads with: XREAD COUNT 100 BLOCK 0 STREAMS tower:stream:messages > + * Consumer groups are supported for parallel AI workers. + */ +export async function publishToMessageStream( + redis: Redis, + payload: StreamMessagePayload, +): Promise { + await redis.xadd( + STREAM_KEY, + 'MAXLEN', '~', String(STREAM_MAX_LEN), + '*', + 'messageId', payload.messageId, + 'tenantId', payload.tenantId, + 'content', payload.content, + 'senderJid', payload.senderJid, + 'senderName', payload.senderName ?? '', + 'sourceGroupId', payload.sourceGroupId, + 'tags', JSON.stringify(payload.tags), + 'effectiveAction', payload.effectiveAction ?? '', + 'traceId', payload.traceId ?? '', + 'timestamp', payload.timestamp, + ); +} diff --git a/docs/DATA_MODELS.md b/docs/DATA_MODELS.md new file mode 100644 index 0000000..37297c6 --- /dev/null +++ b/docs/DATA_MODELS.md @@ -0,0 +1,1141 @@ +# 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) +```