Skip to content

Commit 1968cb8

Browse files
hotlongCopilot
andcommitted
feat(sharing): M10.17.1 split sys_department from sys_team
Better-auth's sys_team is now used only for flat collaboration groupings, matching the upstream contract. The enterprise org chart moves to a dedicated sys_department + sys_department_member pair, eliminating the M10.17 overload of sys_team.parent_team_id (which risked colliding with future better-auth schema changes and conflated two distinct lifecycles). New tables (platform-objects/identity): - sys_department: recursive (parent_department_id), tenant-scoped, kind enum (company|division|department|team|office|cost_center), manager_user_id, active flag, effective_from/effective_to, external_ref for HRIS sync. - sys_department_member: matrix-org friendly (multiple memberships, one is_primary), role_in_department (member|lead|deputy), effective dates. Schema cleanup: - Dropped sys_team.parent_team_id (better-auth contract restored). - sys_sharing_rule.recipient_type enum gains 'department' (default flipped from 'team' to 'department'). plugin-sharing: - DepartmentGraphService implements IDepartmentGraphService — BFS over parent_department_id (inactive subtrees stop descent), expandUsers via sys_department_member, headOf via manager_user_id. - TeamGraphService flattened: expandUsers returns flat members. - expandPrincipal dispatcher covers user/team/department/role/manager/ field/queue. - SharingRuleService.expandRecipient routes 'department' to dept graph. - SharingServicePlugin manifest registers both new objects. plugin-approvals: - ApprovalService.expandApprovers adds 'department'/'dept:' BFS branch; 'team:' is now flat (no parent_team_id walk); 'role:'/'manager:'/ 'field:'/'user:' unchanged. Unknown rows still echo 'type:value' for legacy storage compatibility. Contracts (@objectstack/spec): - ITeamGraphService reduced to flat semantics. - IDepartmentGraphService added (descendants/expandUsers/headOf/ managerOf). - ApproverType enum adds 'team' and 'department'. - SharingRuleRecipientType adds 'department'. Tests: 47/47 plugin-sharing (6 new), 32/32 plugin-approvals (3 new), 74/74 rest, 85/85 platform-objects. Migration: callers that wrote to sys_team.parent_team_id in M10.17 must move that data into sys_department; sharing rules with recipient_type= 'team' that relied on team hierarchy must either flatten or switch to recipient_type='department'. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b994f71 commit 1968cb8

17 files changed

Lines changed: 802 additions & 159 deletions

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added — M10.17.1 dedicated org-skeleton (`sys_department`) ⭐
11+
- **Architectural split** — better-auth's `sys_team` is now used *only* for flat collaboration groupings (matching the upstream contract). The enterprise org chart moves to a dedicated **`sys_department`** + **`sys_department_member`** pair (`packages/platform-objects/src/identity/`). This removes the M10.17 overload where `sys_team.parent_team_id` was doing double duty and risks colliding with future better-auth schema changes.
12+
- **`sys_department`** — recursive (`parent_department_id` self-lookup), tenant-scoped (`organization_id`), with `kind` enum (`company | division | department | team | office | cost_center`), `manager_user_id` for department head, `active` flag, effective-dated (`effective_from` / `effective_to`), and `external_ref` for HRIS sync (Workday / SAP HR / 北森).
13+
- **`sys_department_member`** — many-to-many user ↔ department supporting matrix orgs (multiple memberships per user, one `is_primary`), `role_in_department` (`member | lead | deputy`), and effective dates so historical reports can reconstruct who reported where.
14+
- **Schema cleanup** — dropped `sys_team.parent_team_id` (better-auth contract restored to vanilla); both new schemas registered automatically by `SharingServicePlugin.init()`.
15+
- **`DepartmentGraphService implements IDepartmentGraphService`** — new file `packages/plugins/plugin-sharing/src/department-graph.ts`. BFS over `parent_department_id` (active-only — inactive subtrees stop descent), `expandUsers` via `sys_department_member`, `headOf` reads `manager_user_id`, proxies `managerOf` to `TeamGraphService` so callers need only one service.
16+
- **`TeamGraphService` flattened** — removed the BFS descendant walk; `expandUsers(teamId)` now returns flat members of one `sys_team`. Added a top-level `expandPrincipal(input, ctx)` helper that dispatches `user | team | department | role | manager | field | queue` to the right service (legacy `team:`/`role:`/`manager:` substring fallback retained for back-compat).
17+
- **`SharingRuleService.expandRecipient`** — now routes `recipient_type='department'` through `DepartmentGraphService`. `sys_sharing_rule.recipient_type` enum bumped to `'user' | 'team' | 'department' | 'role' | 'queue'` (default flipped from `team``department` to nudge users toward the org skeleton).
18+
- **`ApproverType` extended**`packages/spec/src/automation/approval.zod.ts` adds `'team'` and `'department'` to the enum (previously only `'role'` covered grouped approvers).
19+
- **`ApprovalService.expandApprovers`**`team:` is now flat (was previously walking `parent_team_id`); new `department:` / `dept:` prefix walks `sys_department` BFS + members via `sys_department_member`; `role:` / `manager:` / `field:` / `user:` unchanged. Unknown / missing-row fallbacks still echo `type:value` for legacy storage compatibility.
20+
- **Contracts (`@objectstack/spec/contracts/sharing-service.ts`)** — added `IDepartmentGraphService`; `ITeamGraphService` reduced to flat semantics (`descendants` removed); `SharingRuleRecipientType` includes `'department'`.
21+
- **Tests** — 47/47 (`plugin-sharing` — 20 sharing-rule incl. dept-rule reconcile + 27 share enforcement, with 6 new tests covering BFS / inactive-subtree skip / cross-tenant guard / head lookup / dispatcher fallback), 32/32 (`plugin-approvals` — 3 new tests: flat team expansion, dept BFS expansion, dept fallback to prefixed literal), 74/74 (`rest`), 85/85 (`platform-objects`).
22+
- **Migration note** — any caller that wrote to `sys_team.parent_team_id` in M10.17 needs to migrate that data into `sys_department`; sharing rules with `recipient_type='team'` that previously relied on team hierarchy must either flatten or be switched to `recipient_type='department'`.
23+
1024
### Added — M10.17 declarative sharing rules + team hierarchy ⭐
1125
- **`sys_sharing_rule`** — new tenant-scoped object (`packages/platform-objects/src/security/sys-sharing-rule.object.ts`) describing "any record of object O matching FilterCondition C is granted access level A to recipient R (user/team/role/queue)". Salesforce-style criteria-based sharing, with criteria stored as a JSON `FilterCondition` so the engine's native `find()` can evaluate matches without a separate predicate runtime.
1226
- **Team hierarchy (`sys_team.parent_team_id`)** — added an optional self-lookup to `sys_team` so descendants can be walked into a flat user set. `sys_team` continues to conform to better-auth's organization plugin schema (extension columns are ignored by better-auth selects).

ROADMAP.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,7 @@ D9 / D10 / D11 (resolved through M9 sub-tasks above)
564564
- [x] M10.15 — Workflow / approval engine — delivered as M11.C15 (`@objectstack/plugin-approvals` + `sys_approval_process` / `sys_approval_request` / `sys_approval_action`, tenant-scoped REST surface, autopilot lifecycle hooks).
565565
- [x] M10.16 — Saved reports + scheduled email — delivered as M11.C16 (`@objectstack/plugin-reports` + matrix `groupBy` with `dateGranularity`).
566566
- [x] M10.17 — Record-level sharing rules (`sys_sharing_rule`) + team hierarchy (`sys_team.parent_team_id`). Declarative criteria-based sharing materialised into `sys_record_share` with `source='rule'` and reconciled by `SharingRuleService`; team graph used by both rule evaluator and `ApprovalService.expandApprovers()` for `team:` / `role:` / `manager:` approver expansion. REST surface at `/api/v1/data/sharing/rules`; auto-loaded by the CLI capability resolver when `requires: ['…, 'sharing']`.
567+
- [x] M10.17.1 — Split enterprise org skeleton (`sys_department` + `sys_department_member`) from better-auth's flat `sys_team`. Dropped `sys_team.parent_team_id` (better-auth contract restored); new `DepartmentGraphService` BFS over `parent_department_id` with active-only subtree, effective dates, `kind` enum (`company|division|department|team|office|cost_center`) for HRIS-grade modelling. `ApprovalService` `department:`/`dept:` approver prefix + `SharingRuleService` `recipient_type='department'` now resolve through the dept graph; `team:` is flat. Contracts split (`ITeamGraphService` flat, `IDepartmentGraphService` hierarchical).
567568
- [ ] M10.18 — Tags (`sys_tag`).
568569
- [ ] M10.19 — Re-enable GraphQL (currently 501) and OpenAPI spec endpoint (currently 404).
569570
- [ ] M10.20 — Realtime channels (collaborative editing on the same opportunity).

packages/platform-objects/src/identity/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ export { SysInvitation } from './sys-invitation.object.js';
1919
export { SysTeam } from './sys-team.object.js';
2020
export { SysTeamMember } from './sys-team-member.object.js';
2121

22+
// ── Org Skeleton (hierarchical, distinct from flat sys_team) ────────────────
23+
export { SysDepartment } from './sys-department.object.js';
24+
export { SysDepartmentMember } from './sys-department-member.object.js';
25+
2226
// ── Additional Auth Objects ────────────────────────────────────────────────
2327
export { SysApiKey } from './sys-api-key.object.js';
2428
export { SysTwoFactor } from './sys-two-factor.object.js';
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { ObjectSchema, Field } from '@objectstack/spec/data';
4+
5+
/**
6+
* sys_department_member — User ↔ Department Assignment
7+
*
8+
* Many-to-many between `sys_user` and `sys_department`. A user can belong
9+
* to multiple departments (matrix orgs) but exactly one is marked
10+
* `is_primary` to drive the default reporting view.
11+
*
12+
* Effective-dated so that historical reports & audits can reconstruct
13+
* who reported to which unit at any point in time.
14+
*
15+
* @namespace sys
16+
*/
17+
export const SysDepartmentMember = ObjectSchema.create({
18+
name: 'sys_department_member',
19+
label: 'Department Member',
20+
pluralLabel: 'Department Members',
21+
icon: 'user-cog',
22+
isSystem: true,
23+
managedBy: 'platform',
24+
description: 'User assignment to a department (matrix-org friendly, effective-dated).',
25+
titleFormat: '{user_id} in {department_id}',
26+
compactLayout: ['user_id', 'department_id', 'role_in_department', 'is_primary'],
27+
28+
fields: {
29+
id: Field.text({
30+
label: 'Member ID',
31+
required: true,
32+
readonly: true,
33+
group: 'System',
34+
}),
35+
36+
department_id: Field.lookup('sys_department', {
37+
label: 'Department',
38+
required: true,
39+
group: 'Assignment',
40+
}),
41+
42+
user_id: Field.lookup('sys_user', {
43+
label: 'User',
44+
required: true,
45+
group: 'Assignment',
46+
}),
47+
48+
role_in_department: Field.select(
49+
['member', 'lead', 'deputy'],
50+
{
51+
label: 'Role in Department',
52+
required: false,
53+
defaultValue: 'member',
54+
description: '`lead` is the day-to-day head; `deputy` may stand in for the lead in approval routing.',
55+
group: 'Assignment',
56+
},
57+
),
58+
59+
is_primary: Field.boolean({
60+
label: 'Primary Assignment',
61+
required: false,
62+
defaultValue: true,
63+
description: 'When the user is in multiple departments, this marks the canonical one for reporting.',
64+
group: 'Assignment',
65+
}),
66+
67+
effective_from: Field.datetime({
68+
label: 'Effective From',
69+
required: false,
70+
group: 'Lifecycle',
71+
}),
72+
73+
effective_to: Field.datetime({
74+
label: 'Effective To',
75+
required: false,
76+
group: 'Lifecycle',
77+
}),
78+
79+
created_at: Field.datetime({
80+
label: 'Created At',
81+
defaultValue: 'NOW()',
82+
readonly: true,
83+
group: 'System',
84+
}),
85+
86+
updated_at: Field.datetime({
87+
label: 'Updated At',
88+
defaultValue: 'NOW()',
89+
readonly: true,
90+
group: 'System',
91+
}),
92+
},
93+
94+
indexes: [
95+
{ fields: ['department_id', 'user_id'], unique: true },
96+
{ fields: ['user_id'] },
97+
{ fields: ['is_primary'] },
98+
],
99+
100+
enable: {
101+
trackHistory: true,
102+
searchable: true,
103+
apiEnabled: true,
104+
apiMethods: ['get', 'list', 'create', 'update', 'delete'],
105+
trash: true,
106+
mru: false,
107+
},
108+
});
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { ObjectSchema, Field } from '@objectstack/spec/data';
4+
5+
/**
6+
* sys_department — Enterprise Org-Skeleton Node
7+
*
8+
* The persistent, hierarchical org chart node. **This is distinct from
9+
* `sys_team`** (which is the flat better-auth collaboration grouping).
10+
*
11+
* A single tenant typically has one `kind='company'` root, then nested
12+
* `division` / `department` / `team` / `office` nodes underneath. The
13+
* `kind` enum is purely a display/categorisation hint — the recursive
14+
* structure works identically regardless of value.
15+
*
16+
* Drives:
17+
* - `recipient_type='department'` sharing rules
18+
* - `dept:` approver prefix in the approval engine
19+
* - Report rollups and manager chains in CRM/PM apps
20+
*
21+
* @namespace sys
22+
*/
23+
export const SysDepartment = ObjectSchema.create({
24+
name: 'sys_department',
25+
label: 'Department',
26+
pluralLabel: 'Departments',
27+
icon: 'building',
28+
isSystem: true,
29+
managedBy: 'platform',
30+
description: 'Hierarchical org-skeleton node (department / division / business unit / office).',
31+
displayNameField: 'name',
32+
titleFormat: '{name}',
33+
compactLayout: ['name', 'kind', 'parent_department_id', 'manager_user_id'],
34+
35+
fields: {
36+
// ── Identity ─────────────────────────────────────────────────
37+
name: Field.text({
38+
label: 'Name',
39+
required: true,
40+
searchable: true,
41+
maxLength: 255,
42+
group: 'Identity',
43+
}),
44+
45+
code: Field.text({
46+
label: 'Code',
47+
required: false,
48+
searchable: true,
49+
maxLength: 64,
50+
description: 'Short stable code (e.g. EMEA-SALES). Unique within tenant.',
51+
group: 'Identity',
52+
}),
53+
54+
kind: Field.select(
55+
['company', 'division', 'department', 'team', 'office', 'cost_center'],
56+
{
57+
label: 'Kind',
58+
required: true,
59+
defaultValue: 'department',
60+
description: 'Categorisation hint — does not change graph semantics.',
61+
group: 'Identity',
62+
},
63+
),
64+
65+
// ── Hierarchy ────────────────────────────────────────────────
66+
parent_department_id: Field.lookup('sys_department', {
67+
label: 'Parent Department',
68+
required: false,
69+
description: 'Self-reference for the org tree. Null = root of tenant.',
70+
group: 'Hierarchy',
71+
}),
72+
73+
organization_id: Field.lookup('sys_organization', {
74+
label: 'Organization',
75+
required: true,
76+
description: 'Tenant scope.',
77+
group: 'Hierarchy',
78+
}),
79+
80+
// ── Leadership ───────────────────────────────────────────────
81+
manager_user_id: Field.lookup('sys_user', {
82+
label: 'Department Head',
83+
required: false,
84+
description: 'User responsible for this org unit (department head / lead).',
85+
group: 'Leadership',
86+
}),
87+
88+
// ── Lifecycle ────────────────────────────────────────────────
89+
active: Field.boolean({
90+
label: 'Active',
91+
required: false,
92+
defaultValue: true,
93+
description: 'When false, members are not expanded by graph queries.',
94+
group: 'Lifecycle',
95+
}),
96+
97+
effective_from: Field.datetime({
98+
label: 'Effective From',
99+
required: false,
100+
description: 'When this department came into existence (HRIS sync).',
101+
group: 'Lifecycle',
102+
}),
103+
104+
effective_to: Field.datetime({
105+
label: 'Effective To',
106+
required: false,
107+
description: 'When this department was retired (HRIS sync).',
108+
group: 'Lifecycle',
109+
}),
110+
111+
external_ref: Field.text({
112+
label: 'External Reference',
113+
required: false,
114+
maxLength: 200,
115+
description: 'ID in upstream HRIS (Workday / SAP HR / 北森).',
116+
group: 'Lifecycle',
117+
}),
118+
119+
// ── System ───────────────────────────────────────────────────
120+
id: Field.text({
121+
label: 'Department ID',
122+
required: true,
123+
readonly: true,
124+
group: 'System',
125+
}),
126+
127+
created_at: Field.datetime({
128+
label: 'Created At',
129+
defaultValue: 'NOW()',
130+
readonly: true,
131+
group: 'System',
132+
}),
133+
134+
updated_at: Field.datetime({
135+
label: 'Updated At',
136+
defaultValue: 'NOW()',
137+
readonly: true,
138+
group: 'System',
139+
}),
140+
},
141+
142+
indexes: [
143+
{ fields: ['organization_id'] },
144+
{ fields: ['parent_department_id'] },
145+
{ fields: ['code', 'organization_id'], unique: true },
146+
{ fields: ['active'] },
147+
],
148+
149+
enable: {
150+
trackHistory: true,
151+
searchable: true,
152+
apiEnabled: true,
153+
apiMethods: ['get', 'list', 'create', 'update', 'delete'],
154+
trash: true,
155+
mru: false,
156+
},
157+
});

packages/platform-objects/src/identity/sys-team.object.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,6 @@ export const SysTeam = ObjectSchema.create({
3939
group: 'Identity',
4040
}),
4141

42-
parent_team_id: Field.lookup('sys_team', {
43-
label: 'Parent Team',
44-
required: false,
45-
description: 'Optional parent team for hierarchical team structures. Better-auth does not require this column; ObjectStack uses it for the team graph expansion in sharing rules and approval routing.',
46-
group: 'Identity',
47-
}),
48-
4942
// ── System ───────────────────────────────────────────────────
5043
id: Field.text({
5144
label: 'Team ID',

packages/platform-objects/src/security/sys-sharing-rule.object.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,12 @@ export const SysSharingRule = ObjectSchema.create({
8383
}),
8484

8585
recipient_type: Field.select(
86-
['user', 'team', 'role', 'queue'],
86+
['user', 'team', 'department', 'role', 'queue'],
8787
{
8888
label: 'Recipient Type',
8989
required: true,
90-
defaultValue: 'team',
91-
description: 'Kind of principal that receives access — expanded to user grants at evaluation time',
90+
defaultValue: 'department',
91+
description: 'Kind of principal that receives access — expanded to user grants at evaluation time. `department` walks the parent_department_id tree; `team` is flat (better-auth).',
9292
group: 'Recipient',
9393
},
9494
),
@@ -97,7 +97,7 @@ export const SysSharingRule = ObjectSchema.create({
9797
label: 'Recipient',
9898
required: true,
9999
maxLength: 200,
100-
description: 'team id / role name / queue name / user id depending on recipient_type',
100+
description: 'department id / team id / role name / queue name / user id depending on recipient_type',
101101
group: 'Recipient',
102102
}),
103103

0 commit comments

Comments
 (0)