Files
tower/docs/superpowers/plans/2026-06-04-phase2b-bot-shared-hidden.md
2026-06-09 02:02:40 +05:30

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

  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

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 /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

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