- 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>
37 KiB
TOWER — Data Model Reference
Source of truth:
apps/api/prisma/schema.prismaDatabase: PostgreSQL 17 · ORM: Prisma · All IDs: cuid() · All timestamps: UTC
Table of Contents
- Entity Relationship Overview
- Enums
- Organisation Layer
- Tenancy & Admins
- WhatsApp Accounts & Groups
- Message Pipeline
- Member Identity & Consent
- Rules Engine
- Digest
- Community Content
- AI Draft Pipeline
- Audit
- Indexes Reference
- 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:
- Load OrgRules for this tenant's org (ordered by priority ASC)
- Load TenantRules (ordered by priority ASC)
- Run OrgRules first — if any match, use that effectiveAction (org is authoritative)
- If no OrgRule matched, run TenantRules
- 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:
[
{
"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:
// 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→ createsEventrecord withisPublished=trueSEVA_TASK→ createsSevaOpportunityrecord withisPublished=trueFAQ_ITEM→ createsKnowledgeItemin 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
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)