# 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 1. Migrate TOWER from self-scan (admin pairs personal WhatsApp) to a dedicated **bot-number** model. 2. Bot is **shared** across all tenants. Schema is multi-bot-ready but ships with one bot. 3. Bot's phone number is **hidden** from public web surfaces. Admins see it in `/settings/bot` behind a reveal toggle. 4. **Hard-delete** all data from self-scan era. Preserve `Tenant`, `Admin`, pre-migration `AuditEvent`. 5. Add **member onboarding** so group members become first-class TOWER users with `/my` portal + 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 ```prisma 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/_phase2b_bot_claim/migration.sql`: ```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 /accounts` - `GET /accounts/:id/qr` - `TOWER_ADMIN_JIDS` env 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|claimed|expired` | ### 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`: read `isBot=true` accounts at startup; resolve `tenantId` from `Group.tenantId` per message. - `group-sync.ts`: on `group-participants.update` with bot JID → upsert with `claimStatus=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` / `PORTAL` keyword handler. - `ingest.ts`: filter non-CLAIMED, opt-out aware, set `senderTowerUserId`. ## 5. Web UI - `/settings/bot`: pairing UI, status, reveal number toggle, copy, remove. - `/groups`: tabs `My 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 `consentMode` toggle, default `OPT_OUT`. ADR. - ABAC PDP not implemented (RBAC + scopes). - OpenLineage / OpenTelemetry not in Phase 2B. ## 7. Execution order 1. Backup script 2. Schema + migration + truncate 3. Bot service + /admin/bot/* API 4. Worker: read isBot=true accounts 5. Pair the new SIM (manual) 6. group-sync PENDING_CLAIM + intro 7. /admin/groups/pending + claim/release API + UI tabs 8. Claim-expiry worker 9. Member models + TowerUser/ConsentRecord/OptOut 10. OTP + /public/auth/* + /onboard 11. /my/* portal + opt-out 12. Command handler 13. Ingest filter 14. Reveal-number audit 15. Test + lint + typecheck