diff --git a/.changeset/adr-0057-d12-primary-business-unit.md b/.changeset/adr-0057-d12-primary-business-unit.md new file mode 100644 index 0000000000..904164df44 --- /dev/null +++ b/.changeset/adr-0057-d12-primary-business-unit.md @@ -0,0 +1,19 @@ +--- +"@objectstack/platform-objects": minor +"@objectstack/plugin-sharing": minor +--- + +Add `sys_user.primary_business_unit_id` projection (ADR-0057 addendum D12). + +Adds a denormalised `primary_business_unit_id` lookup to `sys_user`, maintained +by plugin-sharing as a projection of `sys_business_unit_member.is_primary` +(insert/update/delete hooks + a boot-time backfill). This makes "pick people by +business unit" — the Dataverse *filtered lookup* / ServiceNow *reference +qualifier* interaction — expressible as a plain `where: { primary_business_unit_id: X }` +(and thus as a `lookupFilters` picker filter) with **zero** query-engine change, +without traversing the membership junction. `sys_business_unit_member` remains +the effective-dated, matrix-friendly source of truth; the new column is a +maintained projection, not a second source. Home is plugin-sharing (always +loaded, owns the BU graph) rather than plugin-org-scoping, so the projection +works in single-tenant deployments too. Picker filtering by BU is therefore an +**open** (non-enterprise) capability — only hierarchy *rollup* stays paid. diff --git a/packages/dogfood/test/primary-bu-projection.dogfood.test.ts b/packages/dogfood/test/primary-bu-projection.dogfood.test.ts new file mode 100644 index 0000000000..26fb79f427 --- /dev/null +++ b/packages/dogfood/test/primary-bu-projection.dogfood.test.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0057 addendum D12 — sys_user.primary_business_unit_id projection. + * + * Proves the server-side capability D12 actually delivers: the denormalised + * `primary_business_unit_id` column is maintained by plugin-sharing's hooks as + * `sys_business_unit_member.is_primary` changes, which is what makes + * "pick people by business unit" expressible as a plain + * `where: { primary_business_unit_id: X }` query (and therefore as a + * `lookupFilters` picker filter — that part is a client-side hint, so it is the + * column + its maintenance, not lookupFilters enforcement, that we assert here). + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +const SYS = { context: { isSystem: true } } as const; +const EMAIL = 'd12-primary-bu@verify.test'; + +describe('ADR-0057 D12: sys_user.primary_business_unit_id projection', () => { + let stack: VerifyStack; + let ql: any; + let orgId: string; + let uid: string; + + const sys = (o: string, d: any) => ql.insert(o, d, SYS); + const findOne = async (o: string, where: any, fields: string[]) => + (await ql.find(o, { where, fields, limit: 1, context: { isSystem: true } }))?.[0]; + const primaryBuOf = async (id: string) => + (await findOne('sys_user', { id }, ['id', 'primary_business_unit_id']))?.primary_business_unit_id ?? null; + + beforeAll(async () => { + stack = await bootStack(showcaseStack, {}); + await stack.signIn(); + await stack.signUp(EMAIL); + ql = await stack.kernel.getServiceAsync('objectql'); + + const org = await findOne('sys_organization', {}, ['id']); + orgId = org?.id ?? 'org_d12'; + if (!org) await sys('sys_organization', { id: orgId, name: 'D12 Org', slug: 'd12' }); + + await sys('sys_business_unit', { id: 'bu_d12_a', name: 'Unit A', kind: 'department', organization_id: orgId, active: true }); + await sys('sys_business_unit', { id: 'bu_d12_b', name: 'Unit B', kind: 'department', organization_id: orgId, active: true }); + + uid = (await findOne('sys_user', { email: EMAIL }, ['id']))?.id; + expect(uid, 'signed-up user resolves').toBeTruthy(); + }, 120_000); + + afterAll(async () => { await stack?.stop?.(); }); + + it('sets primary_business_unit_id when a primary member is inserted', async () => { + await sys('sys_business_unit_member', { id: 'mem_d12_a', user_id: uid, business_unit_id: 'bu_d12_a', is_primary: true }); + expect(await primaryBuOf(uid)).toBe('bu_d12_a'); + }); + + it('makes "pick people by BU" expressible — sys_user filters by primary_business_unit_id', async () => { + const inA = (await ql.find('sys_user', { where: { primary_business_unit_id: 'bu_d12_a' }, fields: ['id'], limit: 100, context: { isSystem: true } })).map((u: any) => u.id); + const inB = (await ql.find('sys_user', { where: { primary_business_unit_id: 'bu_d12_b' }, fields: ['id'], limit: 100, context: { isSystem: true } })).map((u: any) => u.id); + expect(inA).toContain(uid); + expect(inB).not.toContain(uid); + }); + + it('follows the primary flag when it moves to another business unit', async () => { + await ql.update('sys_business_unit_member', { id: 'mem_d12_a', is_primary: false }, SYS); + await sys('sys_business_unit_member', { id: 'mem_d12_b', user_id: uid, business_unit_id: 'bu_d12_b', is_primary: true }); + expect(await primaryBuOf(uid)).toBe('bu_d12_b'); + }); + + it('clears the projection when the primary member is deleted', async () => { + await ql.delete('sys_business_unit_member', { where: { id: 'mem_d12_b' }, context: { isSystem: true } }); + expect(await primaryBuOf(uid)).toBeNull(); + }); +}); diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 0e471edfad..41cfea702c 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -48,6 +48,9 @@ export const enObjects: NonNullable = { manager_id: { label: "Manager" }, + primary_business_unit_id: { + label: "Primary Business Unit" + }, id: { label: "User ID" }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index a21f3348b0..1db91e352b 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -48,6 +48,9 @@ export const esESObjects: NonNullable = { manager_id: { label: "Gerente" }, + primary_business_unit_id: { + label: "Unidad de negocio principal" + }, id: { label: "ID de usuario" }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index df1bac7ce9..eec7b774dd 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -48,6 +48,9 @@ export const jaJPObjects: NonNullable = { manager_id: { label: "マネージャー" }, + primary_business_unit_id: { + label: "主所属ビジネスユニット" + }, id: { label: "ユーザー ID" }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index 2911786e5b..ad7fcb943f 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -48,6 +48,9 @@ export const zhCNObjects: NonNullable = { manager_id: { label: "经理" }, + primary_business_unit_id: { + label: "主属业务单元" + }, id: { label: "用户 ID" }, diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index 3730932f82..8a603e822b 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -410,6 +410,13 @@ export const SysUser = ObjectSchema.create({ description: "This user's direct manager. Forms the reporting chain the `own_and_reports` hierarchy scope walks (ADR-0057 / @objectstack/security-enterprise).", }), + primary_business_unit_id: Field.lookup('sys_business_unit', { + label: 'Primary Business Unit', + required: false, + group: 'Organization', + description: "The user's primary business unit — a denormalised projection of sys_business_unit_member.is_primary, maintained by plugin-sharing (ADR-0057 addendum D12). Lets a user-lookup filter candidates by business unit without traversing the membership junction. Do not edit directly; set it via business-unit membership.", + }), + // ── System (auto-managed, hidden from create/edit forms) ───── id: Field.text({ label: 'User ID', diff --git a/packages/plugins/plugin-sharing/src/primary-bu-projection.ts b/packages/plugins/plugin-sharing/src/primary-bu-projection.ts new file mode 100644 index 0000000000..a913d9a8ca --- /dev/null +++ b/packages/plugins/plugin-sharing/src/primary-bu-projection.ts @@ -0,0 +1,152 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * sys_user.primary_business_unit_id projection (ADR-0057 addendum D12). + * + * `sys_business_unit_member` is the effective-dated, matrix-friendly source of + * truth for "which business units a user belongs to". But a lookup field can + * only filter on the *target object's own columns* (`lookupFilters` / + * `dependsOn`), and ObjectQL cannot traverse the membership junction inside a + * single filter. So "pick people by business unit" — the Dataverse *filtered + * lookup* / ServiceNow *reference qualifier* interaction — is not expressible + * against `sys_user` unless the user row carries its BU directly. + * + * This module maintains a denormalised `sys_user.primary_business_unit_id` + * (the member row flagged `is_primary`) so a plain `where: + * { primary_business_unit_id: X }` works with **zero** query-engine change. + * It is a *projection*, not a second source of truth: `sys_business_unit_member` + * still owns matrix / effective-dated membership. + * + * Home: plugin-sharing — always loaded, owns the BU graph domain + * (`BusinessUnitGraphService`), and already binds engine hooks on + * `kernel:ready`. NOT plugin-org-scoping (that is multi-tenant-only; BU + * membership is usable single-tenant too). + */ + +const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const; + +export const PRIMARY_BU_HOOK_PACKAGE = 'plugin-sharing:primary-bu'; + +/** Shared-hookContext key: beforeDelete stashes the doomed row's user_id here + * because afterDelete exposes neither `previous` nor the (now-gone) row. */ +const STASH_KEY = '__primaryBuUserId'; + +interface MinimalEngine { + registerHook( + event: string, + handler: (ctx: any) => any | Promise, + options?: { object?: string | string[]; priority?: number; packageId?: string }, + ): void; + unregisterHooksByPackage(packageId: string): number; + find(object: string, query?: any, options?: any): Promise; + update(object: string, data: any, options?: any): Promise; +} + +interface MinimalLogger { + info?: (msg: any, ...rest: any[]) => void; + warn?: (msg: any, ...rest: any[]) => void; +} + +/** Recompute one user's primary_business_unit_id from their `is_primary` member + * row (null when they have none). Idempotent. */ +async function recompute(engine: MinimalEngine, userId: string, logger?: MinimalLogger): Promise { + if (!userId) return; + let buId: string | null = null; + try { + const rows = await engine.find('sys_business_unit_member', { + where: { user_id: userId, is_primary: true }, + fields: ['business_unit_id'], + limit: 1, + context: SYSTEM_CTX, + }); + buId = rows?.[0]?.business_unit_id ?? null; + } catch (err: any) { + logger?.warn?.('[primary-bu] member lookup failed', { userId, error: err?.message }); + return; + } + try { + await engine.update('sys_user', { id: userId, primary_business_unit_id: buId }, { context: SYSTEM_CTX }); + } catch (err: any) { + logger?.warn?.('[primary-bu] sys_user update failed', { userId, error: err?.message }); + } +} + +/** Affected user_ids reachable from a member-write hook context. */ +function collectUserIds(ctx: any): string[] { + const ids = new Set(); + const add = (v: unknown) => { if (v != null && v !== '') ids.add(String(v)); }; + add(ctx?.result?.user_id); + add(ctx?.previous?.user_id); + add((ctx?.input?.data ?? ctx?.input?.doc)?.user_id); + add(ctx?.[STASH_KEY]); + return [...ids]; +} + +/** + * Bind insert/update/delete hooks on `sys_business_unit_member` that keep the + * `sys_user.primary_business_unit_id` projection in step. Unlike the + * sharing-rule hooks, these run for **system-context writes too** — the + * projection must stay correct regardless of who mutates membership (seeds, + * HRIS sync, admin UI). + */ +export function bindPrimaryBuHooks(engine: MinimalEngine, logger?: MinimalLogger): void { + if (typeof engine.registerHook !== 'function') return; + if (typeof engine.unregisterHooksByPackage === 'function') { + engine.unregisterHooksByPackage(PRIMARY_BU_HOOK_PACKAGE); + } + const opts = { object: 'sys_business_unit_member', packageId: PRIMARY_BU_HOOK_PACKAGE, priority: 150 }; + + // afterDelete loses the row; capture user_id while it still exists. Same + // hookContext instance is reused for before/afterDelete (engine.ts), so the + // stash survives into the afterDelete handler below. + engine.registerHook('beforeDelete', async (ctx: any) => { + const id = ctx?.input?.id; + if (!id) return; + try { + const rows = await engine.find('sys_business_unit_member', { + where: { id }, fields: ['user_id'], limit: 1, context: SYSTEM_CTX, + }); + const uid = rows?.[0]?.user_id; + if (uid) ctx[STASH_KEY] = String(uid); + } catch { /* best-effort — projection self-heals on next member write or boot backfill */ } + }, opts); + + const sync = async (ctx: any) => { + for (const uid of collectUserIds(ctx)) await recompute(engine, uid, logger); + }; + engine.registerHook('afterInsert', sync, opts); + engine.registerHook('afterUpdate', sync, opts); + engine.registerHook('afterDelete', sync, opts); + + logger?.info?.('[primary-bu] projection hooks bound on sys_business_unit_member'); +} + +/** + * One-time boot reconcile: set every user's primary_business_unit_id from their + * `is_primary` member row, so pre-existing memberships (seeds, prior data) + * project even though their inserts pre-dated the hooks. Idempotent. + */ +export async function backfillPrimaryBu(engine: MinimalEngine, logger?: MinimalLogger): Promise<{ updated: number }> { + let rows: any[] = []; + try { + rows = await engine.find('sys_business_unit_member', { + where: { is_primary: true }, + fields: ['user_id', 'business_unit_id'], + limit: 10000, + context: SYSTEM_CTX, + }); + } catch (err: any) { + logger?.warn?.('[primary-bu] backfill scan failed', { error: err?.message }); + return { updated: 0 }; + } + let updated = 0; + for (const m of rows ?? []) { + if (!m?.user_id) continue; + try { + await engine.update('sys_user', { id: m.user_id, primary_business_unit_id: m.business_unit_id }, { context: SYSTEM_CTX }); + updated++; + } catch { /* skip one bad row, keep going */ } + } + if (updated > 0) logger?.info?.('[primary-bu] backfilled projection', { updated }); + return { updated }; +} diff --git a/packages/plugins/plugin-sharing/src/sharing-plugin.ts b/packages/plugins/plugin-sharing/src/sharing-plugin.ts index 435b4d6c9c..27fe66d9ff 100644 --- a/packages/plugins/plugin-sharing/src/sharing-plugin.ts +++ b/packages/plugins/plugin-sharing/src/sharing-plugin.ts @@ -10,6 +10,7 @@ import { SharingRuleService } from './sharing-rule-service.js'; import { ShareLinkService } from './share-link-service.js'; import { registerShareLinkRoutes } from './share-link-routes.js'; import { bindRuleHooks, unbindAllRuleHooks } from './rule-hooks.js'; +import { bindPrimaryBuHooks, backfillPrimaryBu } from './primary-bu-projection.js'; import { bootstrapDeclaredSharingRules } from './bootstrap-declared-sharing-rules.js'; export interface SharingPluginOptions { @@ -146,6 +147,19 @@ export class SharingServicePlugin implements Plugin { }); ctx.registerService('sharing', this.service); + // [ADR-0057 D12] Maintain sys_user.primary_business_unit_id as a + // denormalised projection of sys_business_unit_member.is_primary so a + // user-lookup can filter candidates by business unit. Bound regardless of + // `enforce` — it is a data projection, not an access-control surface. + try { + if (typeof engine.registerHook === 'function' && typeof engine.unregisterHooksByPackage === 'function') { + bindPrimaryBuHooks(engine, ctx.logger as any); + await backfillPrimaryBu(engine, ctx.logger as any); + } + } catch (err: any) { + ctx.logger.warn('SharingServicePlugin: primary-bu projection not started', { error: err?.message }); + } + // Enforcement (read-filter middleware + sharing-rule hooks) is opt-out // via `enforce: false`. The share-link service below is registered // REGARDLESS — capability-token sharing does not depend on principal-