Files
tower/apps/api/prisma/schema.prisma
T
maaz519 5801cbf85b feat: member portal Sprint 10 — Knowledge Base
- KnowledgeBoard + KnowledgeItem models + migration (DRAFT/PUBLISHED/ARCHIVED status, slug per tenant)
- KnowledgeModule: admin CRUD for boards + items (auto-slug, status workflow)
- Member endpoint GET /my/knowledge?q= — published items grouped by board, optional text search over question/answer
- /my/knowledge page: search box + collapsible boards with accordion FAQ items
- KnowledgeItem accordion + KnowledgeSearch (debounced URL-driven) client components
- Knowledge nav item; register KnowledgeModule

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 17:06:26 +05:30

802 lines
22 KiB
Plaintext

generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ============================================================================
// Organization layer (above Tenancy)
// ============================================================================
enum OrgAdminRole {
ORG_OWNER
ORG_ADMIN
}
model Organization {
id String @id @default(cuid())
slug String @unique
name String
settings Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenants Tenant[]
orgAdmins OrgAdmin[]
orgRules OrgRule[]
}
model OrgAdmin {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id])
email String
passwordHash String
name String?
role OrgAdminRole @default(ORG_ADMIN)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([organizationId, email])
@@index([organizationId])
}
model OrgRule {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id])
matchType RuleMatchType
matchValue String
action RuleAction
priority Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([organizationId, matchType, matchValue])
@@index([organizationId, isActive])
}
// ============================================================================
// Tenancy
// ============================================================================
model Tenant {
id String @id @default(cuid())
slug String @unique
name String
isActive Boolean @default(true)
isForwardingPaused Boolean @default(false)
settings Json @default("{}")
organizationId String?
organization Organization? @relation(fields: [organizationId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
admins Admin[]
tenantBots TenantBot[]
groups Group[]
messages Message[]
approvals Approval[]
syncRoutes SyncRoute[]
consentRecords ConsentRecord[]
memberOptOuts MemberOptOut[]
towerUsers TowerUser[]
auditEvents AuditEvent[]
rules TenantRule[]
groupAccesses GroupAccess[]
digestConfig DigestConfig?
digests Digest[]
events Event[]
threads Thread[]
sevaOpportunities SevaOpportunity[]
gamificationEvents GamificationEvent[]
circles Circle[]
askAIQueries AskAIQuery[]
knowledgeBoards KnowledgeBoard[]
knowledgeItems KnowledgeItem[]
}
enum AdminRole {
OWNER
ADMIN
VIEWER
}
model Admin {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
email String
passwordHash String
role AdminRole @default(ADMIN)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
claimedGroups Group[] @relation("claimer")
@@unique([tenantId, email])
@@index([tenantId])
}
model SuperAdmin {
id String @id @default(cuid())
email String @unique
passwordHash String
name String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum ActorType {
ADMIN
SYSTEM
ADAPTER
MEMBER
}
model AuditEvent {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
actorType ActorType
actorId String?
action String
resourceType String
resourceId String
payload Json @default("{}")
traceId String?
createdAt DateTime @default(now())
@@index([tenantId, createdAt])
@@index([resourceType, resourceId])
}
// ============================================================================
// WhatsApp accounts (Phase 2B: bots only, tenant-less, accessed via TenantBot)
// ============================================================================
enum AccountStatus {
ACTIVE
DISCONNECTED
BANNED
PAIRING
}
model Account {
id String @id @default(cuid())
// tenantId REMOVED in Phase 2B — bots are global, access scoped via TenantBot
platform String
jid String
sessionPath String
displayName String?
status AccountStatus @default(ACTIVE)
qrCode String?
isBot Boolean @default(true)
pairingToken String? @unique
pairingExpiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenants TenantBot[]
groups Group[]
@@unique([platform, jid])
@@index([isBot])
@@index([status])
}
// Many-to-many: which tenants may claim groups from which bot.
// Phase 2B ships with implicit "all tenants" (UI auto-grants on first claim),
// but the table is wired so multi-bot + restricted sharing works in later phases.
model TenantBot {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
accountId String
account Account @relation(fields: [accountId], references: [id])
isActive Boolean @default(true)
createdAt DateTime @default(now())
@@unique([tenantId, accountId])
@@index([accountId])
}
// ============================================================================
// Groups + claim lifecycle
// ============================================================================
enum GroupClaimStatus {
PENDING_CLAIM
CLAIMED
RELEASED
EXPIRED
}
model Group {
id String @id @default(cuid())
// tenantId nullable: null while PENDING_CLAIM/EXPIRED, set once CLAIMED
tenantId String?
tenant Tenant? @relation(fields: [tenantId], references: [id])
platform String
platformId String
name String
description String?
isActive Boolean @default(true)
accountId String?
account Account? @relation(fields: [accountId], references: [id])
claimStatus GroupClaimStatus @default(PENDING_CLAIM)
claimedAt DateTime?
claimedByAdminId String?
claimedByAdmin Admin? @relation("claimer", fields: [claimedByAdminId], references: [id])
claimExpiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
messages Message[]
syncRoutesFrom SyncRoute[] @relation("sourceGroup")
syncRoutesTo SyncRoute[] @relation("targetGroup")
consents ConsentRecord[]
claimTokens GroupClaimToken[]
groupAccesses GroupAccess[]
threads Thread[]
@@unique([platform, platformId])
@@index([accountId])
@@index([tenantId])
@@index([claimStatus])
}
// ============================================================================
// Message ingest + approval
// ============================================================================
model Message {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
platform String
platformMsgId String
sourceGroupId String
sourceGroup Group @relation(fields: [sourceGroupId], references: [id])
senderJid String
senderName String?
senderTowerUserId String?
senderTowerUser TowerUser? @relation("senderTowerUser", fields: [senderTowerUserId], references: [id])
content String
mediaUrl String?
tags String[]
status MessageStatus @default(PENDING)
expiresAt DateTime?
quotedPlatformMsgId String?
threadId String?
thread Thread? @relation(fields: [threadId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
approval Approval?
@@unique([platform, platformMsgId])
@@index([tenantId])
@@index([senderTowerUserId])
@@index([status, expiresAt])
@@index([threadId])
}
enum MessageStatus {
RAW
PENDING
APPROVED
REJECTED
DISTRIBUTED
ARCHIVED
DNC
}
model Thread {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
sourceGroupId String
sourceGroup Group @relation(fields: [sourceGroupId], references: [id])
lastActivityAt DateTime @default(now())
messageCount Int @default(0)
topic String?
createdAt DateTime @default(now())
messages Message[]
@@index([tenantId])
@@index([sourceGroupId, lastActivityAt])
}
model Approval {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
messageId String @unique
message Message @relation(fields: [messageId], references: [id])
adminId String
decision ApprovalDecision
notes String?
decidedAt DateTime @default(now())
@@index([tenantId])
}
enum ApprovalDecision {
APPROVED
REJECTED
}
model SyncRoute {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
sourceGroupId String
sourceGroup Group @relation("sourceGroup", fields: [sourceGroupId], references: [id])
targetGroupId String
targetGroup Group @relation("targetGroup", fields: [targetGroupId], references: [id])
isActive Boolean @default(true)
createdAt DateTime @default(now())
@@unique([sourceGroupId, targetGroupId])
@@index([tenantId])
}
// ============================================================================
// Group claiming + sharing
// ============================================================================
model GroupClaimToken {
id String @id @default(cuid())
groupId String
group Group @relation(fields: [groupId], references: [id])
token String @unique
creatorJid String
expiresAt DateTime
consumedAt DateTime?
createdAt DateTime @default(now())
@@index([expiresAt])
}
model GroupAccess {
id String @id @default(cuid())
groupId String
group Group @relation(fields: [groupId], references: [id])
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
grantedBy String
createdAt DateTime @default(now())
@@unique([groupId, tenantId])
@@index([tenantId])
}
// ============================================================================
// Member onboarding (Phase 2B)
// ============================================================================
enum ConsentScope {
INGEST
ARCHIVE
REPLICATE
DISPLAY
}
enum ConsentStatus {
GRANTED
REVOKED
}
enum MemberOptOutReason {
STOP_KEYWORD
SELF_PORTAL
ADMIN_ACTION
}
// Hashed identity: SHA-256 of E.164 phone number (pepper via JWT_SECRET).
model TowerUser {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
phoneHash String
jid String
displayName String?
avatar String?
hometown String?
currentLocation String?
interests String[]
language String @default("en")
digestPreference String @default("daily")
directoryVisible Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
consents ConsentRecord[]
optOuts MemberOptOut[]
sessions TowerSession[]
messages Message[] @relation("senderTowerUser")
rsvps EventRsvp[]
sevaEntries SevaEntry[]
gamificationEvents GamificationEvent[]
circleMemberships CircleMembership[]
askAIQueries AskAIQuery[]
@@unique([tenantId, phoneHash])
@@index([phoneHash])
@@index([tenantId])
@@index([jid])
}
model TowerSession {
id String @id @default(cuid())
userId String
user TowerUser @relation(fields: [userId], references: [id])
tokenHash String @unique
expiresAt DateTime
createdAt DateTime @default(now())
@@index([userId])
@@index([expiresAt])
}
model ConsentRecord {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
groupId String
group Group @relation(fields: [groupId], references: [id])
userId String
user TowerUser @relation(fields: [userId], references: [id])
scopes ConsentScope[]
retentionDays Int @default(90)
policyVersion String
status ConsentStatus @default(GRANTED)
proofEventId String
effectiveAt DateTime @default(now())
revokedAt DateTime?
createdAt DateTime @default(now())
@@index([tenantId, groupId, userId])
@@index([status])
@@index([userId])
}
model MemberOptOut {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
userId String
user TowerUser @relation(fields: [userId], references: [id])
groupId String?
reason MemberOptOutReason
notes String?
createdAt DateTime @default(now())
@@index([tenantId, userId])
@@index([groupId])
@@index([userId])
}
model OtpChallenge {
id String @id @default(cuid())
tenantId String
jid String
phoneHash String
code String
scopes ConsentScope[]
retentionDays Int @default(90)
policyVersion String
groupId String
expiresAt DateTime
consumedAt DateTime?
sentAt DateTime?
createdAt DateTime @default(now())
@@index([tenantId, jid])
@@index([expiresAt])
@@index([sentAt])
}
// ============================================================================
// Tenant Rules Engine
// ============================================================================
enum RuleMatchType {
HASHTAG
PREFIX
REACTION_EMOJI
}
enum RuleAction {
FLAG
AUTO_APPROVE
SKIP
REJECT
}
model TenantRule {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
matchType RuleMatchType
matchValue String
action RuleAction
priority Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([tenantId, matchType, matchValue])
@@index([tenantId, isActive])
@@index([tenantId, matchType])
}
// ============================================================================
// Digest
// ============================================================================
model DigestConfig {
id String @id @default(cuid())
tenantId String @unique
tenant Tenant @relation(fields: [tenantId], references: [id])
targetGroupJid String
targetAccountId String
scheduleHour Int @default(20)
scheduleMinute Int @default(0)
isActive Boolean @default(true)
lastSentAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Digest {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
content String
messageIds String[]
digestDate DateTime
sentAt DateTime @default(now())
@@unique([tenantId, digestDate])
@@index([tenantId])
}
// ============================================================================
// Events + RSVP
// ============================================================================
enum RsvpStatus {
GOING
NOT_GOING
MAYBE
}
model Event {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
title String
description String?
location String?
startsAt DateTime
endsAt DateTime?
createdBy String
isPublished Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
rsvps EventRsvp[]
@@index([tenantId, startsAt])
@@index([tenantId, isPublished])
}
model EventRsvp {
id String @id @default(cuid())
eventId String
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
userId String
user TowerUser @relation(fields: [userId], references: [id])
status RsvpStatus
note String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([eventId, userId])
@@index([userId])
}
// ============================================================================
// Seva (volunteering) + Gamification
// ============================================================================
enum SevaStatus {
OPEN
CLOSED
}
enum SevaEntryStatus {
SIGNED_UP
COMPLETED
CANCELLED
}
model SevaOpportunity {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
title String
description String?
location String?
slots Int?
startsAt DateTime?
pointsAward Int @default(20)
status SevaStatus @default(OPEN)
createdBy String
isPublished Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
entries SevaEntry[]
@@index([tenantId, status])
@@index([tenantId, isPublished])
}
model SevaEntry {
id String @id @default(cuid())
opportunityId String
opportunity SevaOpportunity @relation(fields: [opportunityId], references: [id], onDelete: Cascade)
userId String
user TowerUser @relation(fields: [userId], references: [id])
status SevaEntryStatus @default(SIGNED_UP)
hours Float?
note String?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([opportunityId, userId])
@@index([userId])
}
// Multi-signal gamification — points come from many event types, not just seva.
enum GamificationEventType {
HELPFUL_ANSWER
SEVA_COMPLETED
EVENT_ATTENDANCE
FAQ_APPROVED
WELCOME
PROFILE_COMPLETED
BUSINESS_ADDED
}
model GamificationEvent {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
userId String
user TowerUser @relation(fields: [userId], references: [id])
type GamificationEventType
points Int
refType String?
refId String?
createdAt DateTime @default(now())
@@index([tenantId, userId])
@@index([userId, createdAt])
// One award per (user, type, ref) — prevents double-awarding the same source.
@@unique([userId, type, refId])
}
// ============================================================================
// Circles (interest / affinity sub-groups within a chapter)
// ============================================================================
enum CircleRole {
MEMBER
LEAD
}
model Circle {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
name String
description String?
isPublic Boolean @default(true)
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
memberships CircleMembership[]
@@unique([tenantId, name])
@@index([tenantId, isPublic])
}
model CircleMembership {
id String @id @default(cuid())
circleId String
circle Circle @relation(fields: [circleId], references: [id], onDelete: Cascade)
userId String
user TowerUser @relation(fields: [userId], references: [id])
role CircleRole @default(MEMBER)
createdAt DateTime @default(now())
@@unique([circleId, userId])
@@index([userId])
}
// ============================================================================
// Ask AI (RAG over the message archive)
// ============================================================================
model AskAIQuery {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
userId String
user TowerUser @relation(fields: [userId], references: [id])
question String
answer String
citations Json @default("[]")
createdAt DateTime @default(now())
@@index([tenantId, createdAt])
@@index([userId, createdAt])
}
// ============================================================================
// Knowledge Base (admin-curated FAQ boards)
// ============================================================================
enum KnowledgeItemStatus {
DRAFT
PUBLISHED
ARCHIVED
}
model KnowledgeBoard {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
name String
description String?
slug String
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
items KnowledgeItem[]
@@unique([tenantId, slug])
@@index([tenantId])
}
model KnowledgeItem {
id String @id @default(cuid())
boardId String
board KnowledgeBoard @relation(fields: [boardId], references: [id], onDelete: Cascade)
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
question String
answer String
tags String[]
status KnowledgeItemStatus @default(DRAFT)
sortOrder Int @default(0)
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId, status])
@@index([boardId, status])
}