269 lines
8.7 KiB
Markdown
269 lines
8.7 KiB
Markdown
# 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/<ts>_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
|