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:
| 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.
Company has no kind; org is Department + DepartmentUser only@imasdconsult.comportal:* tree in portal-permissions.ts; system: true flag exists but is not layeredUser.admin OR portal:queue:view (managers today)SUPPORT_EMAIL on submitclient | company | department (entity.ts)┌─────────────────────────────────────────────────────────────────┐
│ 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 │
└─────────────────────────────────────────────────────────────────┘
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');
company
kind CompanyKind NOT NULL DEFAULT 'CLIENT'VENDOR row (enforced in migration seed + app guard)kinduser (abstract presence/ranking — not support-named)
priorityRank Int NOT NULL DEFAULT 0 — higher = preferred for routing tie-breakspresenceActive Boolean NOT NULL DEFAULT true — general availability (OOO/off-duty); reusable beyond supportrole
isLocked Boolean NOT NULL DEFAULT false — true for system PARTNER and SUPPORT template rolespermissionLayer PermissionLayer NOT NULL DEFAULT 'GENERAL'permission
layer PermissionLayer NOT NULL DEFAULT 'GENERAL' — segregates catalog in API/UIteam / 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:
CLIENT company must have exactly 2 rows after onboarding: one L1, one L2L1.providerCompanyId = VENDOR and L2.providerCompanyId = VENDOR (same company, different tiers)L1.providerCompanyId = PARTNER, L2.providerCompanyId = VENDORensureClientSupportCoverage(clientCompanyId, l1ProviderId, l2ProviderId) creates/updates both rows atomically in a transactionsupport_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
onboardingKind OnboardingKind? — set at link generationClient 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)
kind=VENDOR (env IMASD_VENDOR_COMPANY_ID)CLIENT (or infer PARTNER later manually)department_user → position (type=INTERNAL or EXTERNAL)@imasdconsult.com in new onboardings (retain explicit ClientSupport + SupportAgent rows for legacy via script)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):
RolePermission set (layer=PARTNER)isLocked=true — block in rbac.controller.ts: updateRole, deleteRole, assignPermissionsToRole, removePermissionsFromRoleSUPPORT role (on CLIENT companies):
ticket:view (scoped), ticket:create messages — not portal:system:queue:viewMiddleware (rbac/http/context.ts):
company.kind on every permission checkkind != VENDORkind != PARTNER or missing registry rowExtend 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.
PARTNER completion:
Company.kind = PARTNERPartnerRegistry(vendor=ImasD, partner=newCo)createDefaultRolesForCompany + createLockedPartnerRoleINTERNAL position + PARTNER role (locked) + ADMINClientSupport, no SUPPORT role, no auto-ADMIN for ImasD staffCLIENT completion:
Company.kind = CLIENTensureClientSupportCoverage(client, tier=L1, provider=sponsorPartner ?? ImasD) + ensureClientSupportCoverage(client, tier=L2, provider=ImasD) — two rows, @@unique([clientCompanyId, tier]) guarantees no duplicatescreateDefaultRolesForCompany + createLockedSupportRoleINTERNAL + ADMINSupportAgent rows where providerCompanyId = L1.providerCompanyId and (clientCompanyId = client OR clientCompanyId IS NULL)L2.providerCompanyId (VENDOR pool)SUPPORT role to resolved L1/L2 users in client company scopedata-removed=PARTNERPARTNER or SUPPORT (server-enforced)current_company_id: cookie/header validated against accessible setposition WHERE type=INTERNAL (not first RBAC company)accessible_company_ids: union of position companies + RBAC grants| 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.
New rule (confirmed): access only if:
current_company_id belongs to Company.kind=VENDOR, ANDportal:system:queue:view OR User.adminRemove portal:queue:view from MANAGER default on non-VENDOR companies; migrate key to portal:system:queue:view.
Router service (new: apps/backend/src/services/support-agent-router.ts):
clientCompanyId, optional applicationIdClientSupport rows (tier=L1 and tier=L2); fail closed if either missingSupportAgent rows where providerCompanyId matches and (clientCompanyId = client OR clientCompanyId IS NULL)notifyL2OnCreate flag)support_agent.active, user.presenceActive, user.banned != trueCOALESCE(priorityOverride, user.priorityRank) DESCExtend support.controller.ts createSupportTicket to call router after existing sendSupportTicketToTeam.
Partner agent designation API (new routes under /app/support-agents):
SupportAgent — gated by portal:partner:support:manage on PARTNER companyportal:system:support:manageUpdate 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.
department_userUser.presenceActive, User.priorityRankmigration: add company kind position support tablesRole.isLocked enforcement in RBAC APIcreateLockedPartnerRole, createLockedSupportRole templatesfeat(rbac): segregate permission layers and lock system rolesonboardingKind on token + generate-link auth matrixcompleteOnboarding PARTNER vs CLIENTdata-removed=false)feat(onboarding): unified partner and client provisioningfeat(session): position-based context and tenant isolationSupportAgent CRUD APIfeat(support): agent routing and partner designationfeat(sync): publish positions and client support coveragepresenceActive toggle + priorityRank (admin-only edit)feat(frontend): partner onboarding and support agent UInode: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 |
apps/backend/src/__tests__/integration/)ClientSupport rows (L1=partner, L2=vendor); duplicate tier insert fails on @@uniqueproviderCompanyId)portal:system:queue:view in effective permissionsGET /rbac/roles for CLIENT companyAdd to apps/backend/package.json: "test": "node --test --import tsx src/**/*.test.ts"
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.
| 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 |
IMASD_VENDOR_COMPANY_ID — sole VENDOR company UUIDONBOARDING_LEGACY_IMD_ADMIN — temporary rollback for auto-ADMIN behaviorPOSITION_SESSION_ENABLED — gradual session rollout