good forst commit
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,203 @@
|
||||
# Phase 1 — Tenants, JWT Auth, Audit Log
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Introduce multi-tenancy and JWT-based admin authentication across the entire TOWER stack. Every existing model, endpoint, and queue payload is retrofitted to be tenant-scoped. An `AuditEvent` log captures every privileged action. The web app gains a `/login` page and a bearer-token cookie session.
|
||||
|
||||
**Architecture:** All Prisma models gain a non-nullable `tenantId` foreign key. The API mounts a global `JwtAuthGuard` that decodes the bearer token, populates `request.tenantContext`, and rejects unauthenticated requests (with `@Public()` opt-out for `/health`). The web app stores the JWT in an HTTP-only `tower_token` cookie and forwards it on the server side via an `apiFetch` helper. New `Auth` and `Audit` modules live under `apps/api/src/modules/`.
|
||||
|
||||
**Tech Stack:** NestJS 11, Passport JWT, bcryptjs (pure JS, no native binding), Prisma 6, Next.js 16, React 19, Jest 29, TypeScript 5.7.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
**Created**
|
||||
|
||||
```
|
||||
apps/api/prisma/seed.ts # default tenant + OWNER admin
|
||||
apps/api/prisma/migrations/20260602070712_phase1_tenants_auth_audit/migration.sql # hand-edited for backfill
|
||||
apps/api/src/common/tenant-context.ts
|
||||
apps/api/src/modules/auth/password.util.ts # bcryptjs hash/verify
|
||||
apps/api/src/modules/auth/password.util.spec.ts
|
||||
apps/api/src/modules/auth/public.decorator.ts # @Public()
|
||||
apps/api/src/modules/auth/current-admin.decorator.ts # @CurrentAdmin()
|
||||
apps/api/src/modules/auth/current-tenant.decorator.ts # @CurrentTenantContext()
|
||||
apps/api/src/modules/auth/roles.decorator.ts # @Roles(...)
|
||||
apps/api/src/modules/auth/jwt.strategy.ts
|
||||
apps/api/src/modules/auth/jwt-auth.guard.ts
|
||||
apps/api/src/modules/auth/roles.guard.ts
|
||||
apps/api/src/modules/auth/auth.service.ts
|
||||
apps/api/src/modules/auth/auth.service.spec.ts
|
||||
apps/api/src/modules/auth/auth.controller.ts
|
||||
apps/api/src/modules/auth/auth.module.ts
|
||||
apps/api/src/modules/auth/dto/login.dto.ts
|
||||
apps/api/src/modules/audit/audit.types.ts # AuditAction constants
|
||||
apps/api/src/modules/audit/audit.service.ts
|
||||
apps/api/src/modules/audit/audit.service.spec.ts
|
||||
apps/api/src/modules/audit/audit.module.ts # @Global()
|
||||
packages/types/src/auth.ts # AdminRole, JwtPayload, LoginRequest/Response
|
||||
apps/web/app/_lib/api.ts # server-side apiFetch with bearer
|
||||
apps/web/app/_lib/auth-context.tsx # client AuthProvider
|
||||
apps/web/app/_lib/auth-context.test.tsx
|
||||
apps/web/app/_lib/sidebar.tsx # auth-aware nav + Sign out
|
||||
apps/web/app/login/page.tsx
|
||||
apps/web/app/login/page.test.tsx
|
||||
apps/web/app/api/auth/login/route.ts
|
||||
apps/web/app/api/auth/logout/route.ts
|
||||
apps/web/app/api/auth/me/route.ts
|
||||
```
|
||||
|
||||
**Modified**
|
||||
|
||||
```
|
||||
apps/api/prisma/schema.prisma # Tenant, Admin, AuditEvent, tenantId on 6 models
|
||||
apps/api/package.json # bcryptjs, passport-jwt, @nestjs/jwt/passport, db:seed
|
||||
apps/api/src/main.ts # global ValidationPipe + global JwtAuthGuard
|
||||
apps/api/src/app.module.ts # imports AuthModule, AuditModule
|
||||
apps/api/src/modules/health/health.controller.ts # @Public()
|
||||
apps/api/src/modules/groups/{controller,service,*.spec}.ts # tenant filter
|
||||
apps/api/src/modules/routes/{controller,service,*.spec}.ts # tenant filter + audit
|
||||
apps/api/src/modules/accounts/{controller,service,*.spec,module}.ts # tenant filter + audit + @Roles on create
|
||||
apps/api/src/modules/search/{controller,service,*.spec}.ts # tenant filter
|
||||
apps/api/src/prisma/prisma.service.spec.ts # backfill tenant on test data
|
||||
packages/types/src/message.ts # tenantId on IngestJobData/ForwardJobData/IndexJobData
|
||||
packages/types/src/index.ts # re-export ./auth
|
||||
packages/config/src/index.ts # BCRYPT_ROUNDS, JWT_EXPIRES_IN
|
||||
packages/search/src/index.ts # MeiliDocument.tenantId; configureIndex adds filterable
|
||||
packages/search/src/index.test.ts # tenantId in fixture + filterable assertion
|
||||
apps/worker/src/main.ts # tenantId on startAccount + ingest job
|
||||
apps/worker/src/queues/ingest.processor.ts + .test.ts # writes tenantId to Message
|
||||
apps/worker/src/queues/index.processor.ts + .test.ts # writes tenantId to MeiliDocument
|
||||
apps/worker/src/queues/forward.processor.test.ts # tenantId in baseJob
|
||||
apps/worker/src/core/approval.ts # propagates tenantId to Approval/forwardJobs/indexDoc
|
||||
apps/worker/src/whatsapp/group-sync.ts # resolves tenantId from Account
|
||||
apps/web/app/api/accounts/route.ts # uses apiFetch
|
||||
apps/web/app/api/accounts/[id]/qr/route.ts # uses apiFetch
|
||||
apps/web/app/api/routes/route.ts # uses apiFetch
|
||||
apps/web/app/api/routes/[id]/route.ts # uses apiFetch
|
||||
apps/web/app/layout.tsx # AuthProvider + Sidebar
|
||||
.env.example # BCRYPT_ROUNDS, JWT_EXPIRES_IN, SEED_ADMIN_*
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1: Add Tenant model + tenantId on every existing model
|
||||
|
||||
- [x] Add `Tenant { id, slug (unique), name, createdAt, updatedAt }` to schema
|
||||
- [x] Add non-nullable `tenantId` FK on Group, Message, Approval, SyncRoute, ConsentRecord, Account
|
||||
- [x] Hand-edit the auto-generated migration to: create Tenant, INSERT default tenant, ADD COLUMN nullable, UPDATE backfill, SET NOT NULL, add FK
|
||||
- [x] Apply migration; verify existing rows have `tenantId = '<default tenant id>'`
|
||||
|
||||
### Task 2: Add Admin model + password util
|
||||
|
||||
- [x] `Admin { id, tenantId, email (unique per tenant), name, role: AdminRole, passwordHash, createdAt, updatedAt }` + `AdminRole { OWNER, ADMIN, VIEWER }` enum
|
||||
- [x] `password.util.ts` exporting `hashPassword(plain, rounds)` and `verifyPassword(plain, hash)` using `bcryptjs`
|
||||
- [x] `password.util.spec.ts` (mocks bcryptjs)
|
||||
|
||||
### Task 3: Add AuditEvent model + AuditService
|
||||
|
||||
- [x] `AuditEvent { id, tenantId, actorType: ActorType, actorId?, action, resourceType, resourceId, payload (Json), traceId?, createdAt }` + `ActorType { ADMIN, SYSTEM, WORKER }`
|
||||
- [x] `audit.types.ts` exporting `AuditAction` constants (`AUTH_LOGIN`, `AUTH_LOGOUT`, `ROUTE_CREATED`, `ROUTE_DELETED`, `ACCOUNT_CREATED`, etc.)
|
||||
- [x] `AuditService` with `.log({ action, resourceType, resourceId, actorType?, actorId?, payload?, traceId?, tenantId? })`
|
||||
- Throws if no `tenantId` can be resolved (from input override or injected `TenantContext`)
|
||||
- [x] `AuditModule` registered as `@Global()`
|
||||
|
||||
### Task 4: TenantContext type
|
||||
|
||||
- [x] `apps/api/src/common/tenant-context.ts` exporting `interface TenantContext { tenantId: string; adminId: string; role: AdminRole }`
|
||||
|
||||
### Task 5: Auth guards, strategy, decorators
|
||||
|
||||
- [x] `JwtStrategy` (passport-jwt) — `validate()` returns `{ sub, email, tenantId, role }`
|
||||
- [x] `JwtAuthGuard` extends `AuthGuard('jwt')` — checks `@Public()` via Reflector; `handleRequest` populates `request.tenantContext`
|
||||
- [x] `RolesGuard` reads `@Roles(...)` metadata; throws `ForbiddenException` on mismatch
|
||||
- [x] `@Public()`, `@CurrentAdmin()`, `@CurrentTenantContext()`, `@Roles(... @Input)` decorators
|
||||
|
||||
### Task 6: AuthModule + endpoints
|
||||
|
||||
- [x] `AuthService.login(email, password)` — finds Admin, verifies hash, signs JWT, returns `{ token, admin }`, writes `AUTH_LOGIN` audit
|
||||
- [x] `AuthService.me(adminId)` — returns admin profile (with tenant)
|
||||
- [x] `AuthService.logout(adminId)` — writes `AUTH_LOGOUT` audit
|
||||
- [x] `AuthController`: `POST /auth/login` (public), `POST /auth/logout` (auth), `GET /auth/me` (auth)
|
||||
- [x] `LoginDto` with `class-validator` (`@IsEmail`, `@IsString`, `@MinLength`)
|
||||
- [x] `auth.service.spec.ts`
|
||||
|
||||
### Task 7: Wire global guards in main.ts
|
||||
|
||||
- [x] `app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }))`
|
||||
- [x] `app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)))`
|
||||
- [x] `app.useGlobalGuards(new JwtAuthGuard(app.get(Reflector)))`
|
||||
- [x] Mark `HealthController` with `@Public()`
|
||||
|
||||
### Task 8: Retrofit existing controllers/services
|
||||
|
||||
- [x] Groups: read `@CurrentTenantContext()`, filter by `tenantId`
|
||||
- [x] Routes: read `@CurrentTenantContext()`, filter by `tenantId`, write `ROUTE_CREATED` / `ROUTE_DELETED` audit
|
||||
- [x] Accounts: read `@CurrentTenantContext()`, filter by `tenantId`, write `ACCOUNT_CREATED` audit, gate `POST` with `@Roles('OWNER', 'ADMIN')`
|
||||
- [x] Search: ALWAYS filter Meilisearch query by `tenantId` (non-negotiable)
|
||||
- [x] Updated spec files for all four
|
||||
|
||||
### Task 9: Seed script
|
||||
|
||||
- [x] `apps/api/prisma/seed.ts` upserts `'default'` tenant and an OWNER admin from `SEED_ADMIN_EMAIL` / `SEED_ADMIN_PASSWORD` env vars
|
||||
- [x] `db:seed` script in `apps/api/package.json`
|
||||
- [x] `SEED_ADMIN_EMAIL` / `SEED_ADMIN_PASSWORD` in `.env.example`
|
||||
|
||||
### Task 10: MeiliDocument tenantId
|
||||
|
||||
- [x] `MeiliDocument.tenantId: string`
|
||||
- [x] `configureIndex` adds `tenantId` to `filterableAttributes`
|
||||
- [x] `index.test.ts` asserts `tenantId` in filterable list
|
||||
|
||||
### Task 11: Tenant propagation through the worker
|
||||
|
||||
- [x] `IngestJobData.tenantId`, `ForwardJobData.tenantId`, `IndexJobData.tenantId`
|
||||
- [x] `index.processor.ts` writes `tenantId` to `MeiliDocument`
|
||||
- [x] `ingest.processor.ts` writes `tenantId` to `Message`
|
||||
- [x] `group-sync.ts` resolves `tenantId` from the `Account` record before upserting groups
|
||||
- [x] `approval.ts` propagates `tenantId` to `Approval`, `forwardJobs`, and `indexDoc`
|
||||
- [x] `worker/main.ts` includes `tenantId` in `startAccount` and in the ingest job data
|
||||
|
||||
### Task 12: Web login page + AuthProvider
|
||||
|
||||
- [x] `apps/web/app/_lib/auth-context.tsx` — `AuthProvider` + `useAuth()` exposing `{ admin, loading, error, refresh, logout }`
|
||||
- [x] `apps/web/app/login/page.tsx` — form posting to `/api/auth/login`, redirects to `?next=…` on success
|
||||
- Split into `LoginForm` wrapped in `<Suspense>` (required by `useSearchParams` in static gen)
|
||||
- [x] `auth-context.test.tsx` (3 tests) + `login/page.test.tsx` (3 tests)
|
||||
|
||||
### Task 13: Web auth API routes + apiFetch
|
||||
|
||||
- [x] `apps/web/app/_lib/api.ts` — `apiFetch(path, init)` reads `tower_token` cookie, sets `Authorization: Bearer …`, forwards to `API_URL`
|
||||
- [x] `apps/web/app/api/auth/login/route.ts` — POST credentials to API, on success sets HTTP-only `tower_token` cookie
|
||||
- [x] `apps/web/app/api/auth/logout/route.ts` — POST clears cookie
|
||||
- [x] `apps/web/app/api/auth/me/route.ts` — GET forwards to API
|
||||
- [x] Retrofit existing route handlers (`/api/accounts`, `/api/accounts/[id]/qr`, `/api/routes`, `/api/routes/[id]`) to use `apiFetch`
|
||||
|
||||
### Task 14: Config + env
|
||||
|
||||
- [x] `BCRYPT_ROUNDS` (default 10), `JWT_EXPIRES_IN` (default `7d`) in `@tower/config`
|
||||
- [x] `SEED_ADMIN_EMAIL`, `SEED_ADMIN_PASSWORD` in `.env.example`
|
||||
|
||||
### Task 15: Verification
|
||||
|
||||
- [x] `pnpm turbo build` — green across all 15 packages
|
||||
- [x] `pnpm turbo test` — 13 API suites (58 tests) + 10 worker suites (68 tests) + 7 web suites (34 tests) all green
|
||||
- [x] Seed ran: `admin@tower.local` / `OWNER` on `default` tenant
|
||||
- [x] Migration backfilled 99 groups, 3 messages, 1 approval, 1 sync route, 1 account to `default` tenant
|
||||
- [x] Sidebar shows admin name, tenant, role, and Sign out button; redirects to `/login?next=…` when unauthenticated
|
||||
- [x] This plan doc written to `docs/superpowers/plans/2026-06-02-phase1-tenants-auth-audit.md`
|
||||
|
||||
---
|
||||
|
||||
## Notes / Gotchas
|
||||
|
||||
- **bcrypt 5.x native binding** fails to load in the pnpm environment. Switched to **`bcryptjs`** (pure JS, identical API).
|
||||
- **Prisma migration backfill**: Prisma's default migration can't add a non-nullable FK to a table that already has rows. Hand-edited the generated `migration.sql` to: create Tenant → INSERT default → ADD COLUMN nullable → UPDATE backfill → SET NOT NULL → add FK.
|
||||
- **JWT_EXPIRES_IN typing**: `string` is not assignable to `@nestjs/jwt`'s strict `StringValue` template literal type. Cast at the call site: `process.env['JWT_EXPIRES_IN'] as \`${number}d\` | ...`.
|
||||
- **passport-jwt user cast**: TypeScript needs `user as unknown as JwtPayload` in `JwtAuthGuard.handleRequest` because the generic `TUser` doesn't structurally overlap with `JwtPayload`.
|
||||
- **bcryptjs mock**: `jest.mock('bcryptjs')` alone doesn't expose `jest.fn()`s. Use a factory: `jest.mock('bcryptjs', () => ({ __esModule: true, hash: jest.fn(), compare: jest.fn() }))`.
|
||||
- **`useSearchParams` static gen**: requires a `<Suspense>` boundary around the component that uses it. Split `LoginPage` into `LoginPage` (shell) and `LoginForm` (inner, wrapped in Suspense).
|
||||
- **Worker is uncommitted-modification territory**: `selfJid` handling in `normalizer.ts` was changed recently. The retrofit was careful not to touch that file or behavior.
|
||||
@@ -0,0 +1,268 @@
|
||||
# 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
|
||||
Binary file not shown.
Reference in New Issue
Block a user