name: B2B Org Refactor Plan overview: Phased refactor of imasD Portal to support VENDOR (ImasD)/PARTNER/CLIENT company kinds, unified onboarding with immutable PARTNER role, Position-based org context, L1/L2 support assignments, agent routing with user availability/ranking, Bull Board queue isolation, and segregated permission layers—while preserving Kafka sync compatibility. todos:


B2B Platform Refactor: Vendors, Partners & End Clients

Terminology (locked)

Actor Company.kind Role(s) Notes
ImasD / PBO VENDOR System roles + User.admin Platform operator; sole Bull Board access
Reseller PARTNER Immutable PARTNER role + ADMIN/MANAGER/USER Created via vendor-only partner onboarding
End customer CLIENT ADMIN/MANAGER/USER + SUPPORT (L1/L2 agents) Created via vendor or partner onboarding

Naming guardrail: Role.PARTNER (RBAC) != PositionType org enum. Recommend renaming position enum to ORG_PARTNER / ORG_PBO in schema to avoid code collisions.


Current baseline (schema.prisma)


Target architecture

[Diagram]
┌─────────────────────────────────────────────────────────────────┐
│                        UNIFIED ONBOARDING                        │
├─────────────────────────────────────────────────────────────────┤
│  Generate Link (auth-gated)                                      │
│    ├─ kind=PARTNER  → only VENDOR users (system perms)           │
│    └─ kind=CLIENT   → VENDOR users OR PARTNER role holders       │
│                          (+ L1 partner ref if from partner)      │
├─────────────────────────────────────────────────────────────────┤
│  Same wizard UI (/onboarding) — token carries onboardingKind     │
├─────────────────────────────────────────────────────────────────┤
│  Complete (branch by kind)                                       │
│    PARTNER: Company.kind=PARTNER + PartnerRegistry + locked role │
│    CLIENT:  Company.kind=CLIENT + ClientSupport + SUPPORT role   │
└─────────────────────────────────────────────────────────────────┘

1. Database schema (PostgreSQL / Prisma)

1.1 Enums

CREATE TYPE "CompanyKind" AS ENUM ('VENDOR', 'PARTNER', 'CLIENT');
CREATE TYPE "PositionType" AS ENUM ('INTERNAL', 'EXTERNAL', 'ORG_PARTNER', 'PBO');
CREATE TYPE "OnboardingKind" AS ENUM ('PARTNER', 'CLIENT');
CREATE TYPE "PermissionLayer" AS ENUM ('GENERAL', 'SYSTEM', 'PARTNER');
CREATE TYPE "SupportTier" AS ENUM ('L1', 'L2');

1.2 Core extensions

company

user (abstract presence/ranking — not support-named)

role

permission

1.3 New tables

team / sub_team

model Team {
  id           String
  companyId    String
  departmentId String?
  name         String
  isDefault    Boolean @default(false)
  deletedAt    DateTime?
}
model SubTeam { /* teamId, companyId, departmentId, name, isDefault */ }

position (org only — no permissions)

model Position {
  id           String @id
  userId       String
  companyId    String
  departmentId String?
  teamId       String?
  subTeamId    String?
  type         PositionType
  title        String?        // e.g. "CTO"
  createdBy    String?
  createdAt    DateTime
  updatedAt    DateTime
  deletedAt    DateTime?
  @@index([userId, type])
  @@index([companyId])
}

Constraint (app-level + partial unique index): one INTERNAL per userId where deletedAt IS NULL.

partner_registry

model PartnerRegistry {
  vendorCompanyId  String   // VENDOR (ImasD)
  partnerCompanyId String   // PARTNER
  createdAt        DateTime
  @@id([vendorCompanyId, partnerCompanyId])
}

client_support (one row per tier per client — exactly one L1 and one L2)

model ClientSupport {
  id                String      @id
  clientCompanyId   String      // CLIENT company receiving support
  tier              SupportTier // L1 or L2
  providerCompanyId String      // company responsible for this tier
  createdAt         DateTime
  updatedAt         DateTime

  @@unique([clientCompanyId, tier])  // enforces exactly one L1 + one L2 per client
  @@index([providerCompanyId])
}

Tier semantics and validation (app + DB checks):

Tier providerCompanyId must be Typical case
L1 PARTNER or VENDOR Partner reseller, or ImasD on direct sale
L2 VENDOR only Always ImasD (escalation)

Provisioning rules:

support_agent (users designated as agents within a provider company)

model SupportAgent {
  id                String  @id
  userId            String
  providerCompanyId String  // PARTNER (L1) or VENDOR (L2) company the agent belongs to
  clientCompanyId   String? // null = pool agent for all clients under this provider
  priorityOverride  Int?    // optional; falls back to user.priorityRank
  active            Boolean @default(true)
  createdAt         DateTime

  @@unique([userId, providerCompanyId, clientCompanyId])
  @@index([providerCompanyId, active])
  @@index([clientCompanyId])
}

invitation_token extension

1.4 ER diagram (ASCII)

Client 1──* Company (kind: VENDOR|PARTNER|CLIENT)
Company 1──* Department 1──* Team 1──* SubTeam
User 1──* Position *──1 Company/Department/Team/SubTeam
User 1──* UserRole *──1 Role (isLocked?)
PartnerRegistry: VENDOR.company ── PARTNER.company
ClientSupport: CLIENT.company × {L1, L2} ──► provider Company (PARTNER or VENDOR)
SupportAgent: User ── provider Company ──? specific CLIENT.company
SupportTicket ── CLIENT.company (existing)

1.5 Migration / backfill (prisma/migrations)

  1. Add columns/tables with nullable defaults
  2. Seed ImasD company as sole kind=VENDOR (env IMASD_VENDOR_COMPANY_ID)
  3. Backfill existing companies as CLIENT (or infer PARTNER later manually)
  4. Migrate department_userposition (type=INTERNAL or EXTERNAL)
  5. Seed permission layers + locked PARTNER/SUPPORT role templates
  6. Remove auto-ADMIN grants for @imasdconsult.com in new onboardings (retain explicit ClientSupport + SupportAgent rows for legacy via script)

2. Permission model (three layers)

Extend portal-permissions.ts:

Layer Key prefix Assignable on Examples
GENERAL portal: All companies user:view, ticket:create
SYSTEM portal:system: VENDOR only onboarding:partner, onboarding:client, queue:view, config:edit
PARTNER portal:partner: PARTNER only (requires partner_registry) onboarding:client, client:view (scoped)

Immutable PARTNER role (on PARTNER companies):

SUPPORT role (on CLIENT companies):

Middleware (rbac/http/context.ts):


3. Unified onboarding (onboarding.controller.ts)

3.1 Link generation

Extend onboardingGenerateLinkSchema:

onboardingKind: z.enum(['PARTNER', 'CLIENT'])
sponsorPartnerCompanyId: z.string().uuid().optional() // required when generator is PARTNER
onboardingKind Who can generate Result
PARTNER VENDOR + portal:system:onboarding:partner New PARTNER company
CLIENT VENDOR + portal:system:onboarding:client OR PARTNER + portal:partner:onboarding:client New CLIENT company

Replace assertInternalAdmin in onboarding-invites.ts with permission + kind checks.

3.2 Complete flow branches

PARTNER completion:

  1. Company.kind = PARTNER
  2. PartnerRegistry(vendor=ImasD, partner=newCo)
  3. createDefaultRolesForCompany + createLockedPartnerRole
  4. Assign initial admin: INTERNAL position + PARTNER role (locked) + ADMIN
  5. No ClientSupport, no SUPPORT role, no auto-ADMIN for ImasD staff

CLIENT completion:

  1. Company.kind = CLIENT
  2. ensureClientSupportCoverage(client, tier=L1, provider=sponsorPartner ?? ImasD) + ensureClientSupportCoverage(client, tier=L2, provider=ImasD) — two rows, @@unique([clientCompanyId, tier]) guarantees no duplicates
  3. createDefaultRolesForCompany + createLockedSupportRole
  4. Assign initial admin: INTERNAL + ADMIN
  5. Resolve L1 agents: active SupportAgent rows where providerCompanyId = L1.providerCompanyId and (clientCompanyId = client OR clientCompanyId IS NULL)
  6. Resolve L2 agents: same pattern for L2.providerCompanyId (VENDOR pool)
  7. Assign SUPPORT role to resolved L1/L2 users in client company scope
  8. No PARTNER role on CLIENT company

3.3 UI (onboarding/page.tsx)


4. Session & data isolation

4.1 Primary company (auth/utils/queries.ts)

4.2 Visibility rules (API query filters)

Context Rule
User lists in CLIENT app tables Exclude users whose only VENDOR positions are PBO unless viewer is PBO/VENDOR
PARTNER role Never returned in role APIs for kind=CLIENT companies
Cross-tenant All list endpoints filter by current_company_id; assert FK ownership
ImasD staff Visible in VENDOR context; hidden from CLIENT user pickers unless SUPPORT-assigned

Implement shared helper: buildCompanyScopedUserWhere(companyId, viewerContext) used in users.controller.ts.

4.3 Bull Board isolation (queues.route.ts)

New rule (confirmed): access only if:

Remove portal:queue:view from MANAGER default on non-VENDOR companies; migrate key to portal:system:queue:view.


5. Support routing & notifications

[Diagram]

Router service (new: apps/backend/src/services/support-agent-router.ts):

Extend support.controller.ts createSupportTicket to call router after existing sendSupportTicketToTeam.

Partner agent designation API (new routes under /app/support-agents):


6. Kafka sync extension (Phase 5)

Update entity.ts: add team, sub_team, position, partner_registry, client_support.

Update sync-service.ts publishers and smartfactory portal-sync consumers.

User sync payload: include positions[], primaryCompanyId (INTERNAL), companyKind per membership.


7. Execution phases

Phase 1 — Schema & migrations (no behavior change)

Phase 2 — Permission layers & locked roles

Phase 3 — Unified onboarding

Phase 4 — Session & isolation

Phase 5 — Support engine

Phase 6 — Sync & downstream apps

Phase 7 — Frontend polish


8. Testing strategy

Unit tests (node:test — extend existing pattern in rbac/tests)

Module Cases
support-agent-router No agents; all presenceActive=false; priority tie-break; L1 then L2 tier resolution; missing L1/L2 row fails closed
client-support-coverage Duplicate tier rejected; direct sale L1=L2 vendor; partner sale L1=partner L2=vendor
locked-role-guard Reject permission mutation on isLocked roles; allow user assignment
company-kind-guard SYSTEM perm rejected on CLIENT; PARTNER perm rejected without registry
position-resolver Single INTERNAL; missing INTERNAL fallback; deleted position ignored

Integration tests (new apps/backend/src/__tests__/integration/)

Security / isolation tests

Wire tests in CI

Add to apps/backend/package.json: "test": "node --test --import tsx src/**/*.test.ts"


9. Git commit & PR strategy

Branch: feat/b2b-org-refactor with stacked PRs into main.

PR Commits (Conventional) Scope
PR-1 migration: add CompanyKind Position ClientSupport tables Schema only
PR-2 feat(rbac): add permission layers and Role.isLocked RBAC catalog + guards
PR-3 feat(rbac): enforce locked role mutation guard Controller + tests
PR-4 feat(onboarding): add onboardingKind to invitation token Schemas + JWT
PR-5 feat(onboarding): partner completion provisioning Controller branch
PR-6 feat(onboarding): client completion with ClientSupport Controller branch
PR-7 fix(onboarding): remove auto imasd admin grant Breaking; flag first
PR-8 feat(auth): resolve primary company from INTERNAL position Session
PR-9 feat(users): tenant-scoped list filters Isolation
PR-10 feat(admin): restrict Bull Board to VENDOR company Queue isolation
PR-11 feat(support): agent router and email dispatch Routing engine
PR-12 feat(support): SupportAgent CRUD API Partner agents
PR-13 feat(sync): extend entity sync schema v2 Kafka
PR-14 feat(frontend): onboarding kind and agent UI UI

Deployment: feature flags per phase (POSITION_SESSION_ENABLED, ONBOARDING_KIND_ENABLED, SUPPORT_AGENT_ROUTING_ENABLED); enable in staging → prod after backfill jobs complete.


10. Risks & mitigations

Risk Mitigation
Breaking existing onboardings Feature flags; dual-read department_user during migration
Manager loses Bull Board access Expected; document migration to VENDOR-only
PARTNER/PBO name collision Rename position enums to ORG_*
Kafka consumers lag Versioned events; deploy consumers before publishers
Over-notification to agents Debounce per ticket; notifyOnNewTicket flag on SupportAgent

11. Environment variables (new)