8.7 KiB
8.7 KiB
Phase 2B — Bot-Number Model (shared, hidden, multi-tenant-ready)
Date: 2026-06-04
Status: In progress
Supersedes: Self-scan account model (current Account rows + POST /accounts)
Goals
- Migrate TOWER from self-scan (admin pairs personal WhatsApp) to a dedicated bot-number model.
- Bot is shared across all tenants. Schema is multi-bot-ready but ships with one bot.
- Bot's phone number is hidden from public web surfaces. Admins see it in
/settings/botbehind a reveal toggle. - Hard-delete all data from self-scan era. Preserve
Tenant,Admin, pre-migrationAuditEvent. - Add member onboarding so group members become first-class TOWER users with
/myportal + opt-out.
Locked decisions
| Decision | Choice |
|---|---|
| Which tenants get claim notifications | All OWNERs of all tenants |
| Unclaimed group TTL (7 days) | Mark EXPIRED, bot stops listening |
| Welcome message timing | Generic intro on join, full onboarding link after claim |
| Migration of tenants/admins | Preserve Tenant + Admin; truncate data |
1. Schema changes
model Account {
id String @id @default(cuid())
platform String
jid String
sessionPath String
displayName String?
status AccountStatus @default(ACTIVE)
qrCode String?
isBot Boolean @default(true)
pairingToken String? @unique
pairingExpiresAt DateTime?
tenants TenantBot[]
groups Group[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([platform, jid])
@@index([isBot])
}
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])
}
model Group {
id String @id @default(cuid())
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?
claimExpiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
messages Message[]
syncRoutesFrom SyncRoute[] @relation("sourceGroup")
syncRoutesTo SyncRoute[] @relation("targetGroup")
consents ConsentRecord[]
@@unique([platform, platformId])
@@index([accountId])
@@index([tenantId])
@@index([claimStatus])
}
enum GroupClaimStatus {
PENDING_CLAIM
CLAIMED
RELEASED
EXPIRED
}
enum ConsentScope { INGEST ARCHIVE REPLICATE DISPLAY }
enum ConsentStatus { GRANTED REVOKED }
enum MemberOptOutReason { STOP_KEYWORD SELF_PORTAL ADMIN_ACTION }
model TowerUser {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
phoneHash String
jid String
displayName String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
consents ConsentRecord[]
optOuts MemberOptOut[]
sessions TowerSession[]
messages Message[] @relation("senderTowerUser")
@@unique([tenantId, phoneHash])
@@index([phoneHash])
@@index([tenantId])
}
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])
}
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])
}
model MemberOptOut {
id String @id @default(cuid())
tenantId String
userId String
user TowerUser @relation(fields: [userId], references: [id])
groupId String?
reason MemberOptOutReason
notes String?
createdAt DateTime @default(now())
@@index([tenantId, userId])
@@index([groupId])
}
Message gets senderTowerUserId String? + senderTowerUser TowerUser? @relation(...).
Tenant back-relations: tenantBots TenantBot[], towerUsers TowerUser[], consentRecords ConsentRecord[] (already), memberOptOuts MemberOptOut[].
2. Migration
apps/api/prisma/migrations/<ts>_phase2b_bot_claim/migration.sql:
-- Hard delete data; preserve Tenant, Admin, AuditEvent
TRUNCATE TABLE
"Approval", "SyncRoute", "Message", "ConsentRecord",
"MemberOptOut", "TowerSession", "TowerUser",
"Group", "Account", "TenantBot"
RESTART IDENTITY CASCADE;
-- DDL handled by Prisma migrate diff
Pre-migration: scripts/backup-before-phase2b.sh (pg_dump data tables only).
3. API surface
Removed
POST /accountsGET /accounts/:id/qrTOWER_ADMIN_JIDSenv var
Bot management (OWNER)
| Method | Path | Purpose |
|---|---|---|
| POST | /admin/bot/initiate |
Create bot row + return pairingToken |
| GET | /admin/bot/qr/:token |
Poll QR |
| POST | /admin/bot/confirm |
Mark paired, create TenantBot for caller's tenant |
| GET | /admin/bot |
Current bot summary |
| POST | /admin/bot/reveal |
Returns jid (audit-logged) |
| DELETE | /admin/bot/:id |
Logout + delete session |
Group claim (OWNER)
| Method | Path | Purpose |
|---|---|---|
| GET | /admin/groups/pending |
List PENDING_CLAIM groups |
| POST | /admin/groups/:id/claim |
First-claim-wins |
| POST | /admin/groups/:id/release |
Back to PENDING_CLAIM |
| GET | /admin/groups |
`?status=pending |
Public auth (no JWT)
| Method | Path | Purpose |
|---|---|---|
| POST | /public/auth/request-otp |
Send OTP via bot DM |
| POST | /public/auth/verify-otp |
Issue member JWT |
| GET | /public/onboard/:token |
Group + tenant name only |
Member portal (member JWT)
| Method | Path | Purpose |
|---|---|---|
| GET | /my/profile |
Self view |
| GET | /my/groups |
Groups the user is in |
| GET | /my/groups/:id |
Group metadata + scopes |
| POST | /my/opt-out |
Body { groupId?, scopes? } |
| POST | /my/opt-in |
Re-grant |
| DELETE | /my/account |
Hard delete self |
4. Worker changes
main.ts: readisBot=trueaccounts at startup; resolvetenantIdfromGroup.tenantIdper message.group-sync.ts: ongroup-participants.updatewith bot JID → upsert withclaimStatus=PENDING_CLAIM,claimExpiresAt=now+7d,tenantId=null. Post generic intro. Emit audit.claim-expiry.processor.ts(NEW): hourly BullMQ repeatable job, mark expired.otp-sender.ts(NEW): DM OTP via pool.command-handler.ts(NEW):STOP/START/PORTALkeyword handler.ingest.ts: filter non-CLAIMED, opt-out aware, setsenderTowerUserId.
5. Web UI
/settings/bot: pairing UI, status, reveal number toggle, copy, remove./groups: tabsMy Tenant | Pending Claim | All Claimed. Claim/release buttons./onboard(public): OTP flow, consent scope picker./my/*(member): dashboard, group detail with opt-out, settings, delete account.
6. Risks / explicit deviations from PDF
- Baileys vs WhatsApp Business Platform (PDF line 62-68): user chose Baileys.
- Opt-in vs opt-out default: tenant-scoped
consentModetoggle, defaultOPT_OUT. ADR. - ABAC PDP not implemented (RBAC + scopes).
- OpenLineage / OpenTelemetry not in Phase 2B.
7. Execution order
- Backup script
- Schema + migration + truncate
- Bot service + /admin/bot/* API
- Worker: read isBot=true accounts
- Pair the new SIM (manual)
- group-sync PENDING_CLAIM + intro
- /admin/groups/pending + claim/release API + UI tabs
- Claim-expiry worker
- Member models + TowerUser/ConsentRecord/OptOut
- OTP + /public/auth/* + /onboard
- /my/* portal + opt-out
- Command handler
- Ingest filter
- Reveal-number audit
- Test + lint + typecheck