@objectstack/plugin-sharing 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,158 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import type { ITeamGraphService } from '@objectstack/spec/contracts';
4
+ import type { SharingEngine } from './sharing-service.js';
5
+
6
+ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const;
7
+
8
+ type Cache = {
9
+ expandUsers?: Map<string, string[]>;
10
+ expandRole?: Map<string, string[]>;
11
+ manager?: Map<string, string | null>;
12
+ };
13
+
14
+ export interface TeamGraphOptions {
15
+ engine: SharingEngine;
16
+ /** Optional tenant scope; null means cross-tenant lookups. */
17
+ organizationId?: string | null;
18
+ /** Optional shared cache across one evaluator pass. */
19
+ cache?: Cache;
20
+ }
21
+
22
+ /**
23
+ * Default {@link ITeamGraphService} implementation backed by
24
+ * `sys_team` + `sys_team_member` (better-auth's flat collaboration
25
+ * grouping) plus `sys_member.role` for tenant role expansion.
26
+ *
27
+ * **This service does NOT walk a hierarchy.** Teams here are flat —
28
+ * the enterprise org chart lives in `sys_department` and is served by
29
+ * {@link DepartmentGraphService}.
30
+ *
31
+ * All queries elevate to {@link SYSTEM_CTX} since the graph is platform
32
+ * metadata; callers (sharing rule evaluator, approval engine) own their
33
+ * own enforcement.
34
+ */
35
+ export class TeamGraphService implements ITeamGraphService {
36
+ private readonly engine: SharingEngine;
37
+ private readonly organizationId: string | null;
38
+ private readonly cache: Cache;
39
+
40
+ constructor(opts: TeamGraphOptions) {
41
+ this.engine = opts.engine;
42
+ this.organizationId = opts.organizationId ?? null;
43
+ this.cache = opts.cache ?? {};
44
+ this.cache.expandUsers ??= new Map();
45
+ this.cache.expandRole ??= new Map();
46
+ this.cache.manager ??= new Map();
47
+ }
48
+
49
+ async expandUsers(teamId: string): Promise<string[]> {
50
+ if (!teamId) return [];
51
+ const cached = this.cache.expandUsers!.get(teamId);
52
+ if (cached) return cached;
53
+
54
+ let rows: any[] = [];
55
+ try {
56
+ rows = await this.engine.find('sys_team_member', {
57
+ filter: { team_id: teamId },
58
+ fields: ['user_id'],
59
+ limit: 10000,
60
+ context: SYSTEM_CTX,
61
+ });
62
+ } catch {
63
+ rows = [];
64
+ }
65
+ const users = Array.from(new Set((rows ?? []).map((r: any) => String(r.user_id ?? '')).filter(Boolean)));
66
+ this.cache.expandUsers!.set(teamId, users);
67
+ return users;
68
+ }
69
+
70
+ async expandRoleUsers(roleName: string, organizationId?: string): Promise<string[]> {
71
+ if (!roleName) return [];
72
+ const key = `${organizationId ?? this.organizationId ?? '*'}::${roleName}`;
73
+ const cached = this.cache.expandRole!.get(key);
74
+ if (cached) return cached;
75
+ const filter: Record<string, unknown> = { role: roleName };
76
+ const org = organizationId ?? this.organizationId;
77
+ if (org) filter.organization_id = org;
78
+ let rows: any[] = [];
79
+ try {
80
+ rows = await this.engine.find('sys_member', {
81
+ filter,
82
+ fields: ['user_id'],
83
+ limit: 10000,
84
+ context: SYSTEM_CTX,
85
+ });
86
+ } catch {
87
+ rows = [];
88
+ }
89
+ const users = Array.from(new Set((rows ?? []).map((r: any) => String(r.user_id ?? '')).filter(Boolean)));
90
+ this.cache.expandRole!.set(key, users);
91
+ return users;
92
+ }
93
+
94
+ async managerOf(userId: string, _organizationId?: string): Promise<string | null> {
95
+ if (!userId) return null;
96
+ if (this.cache.manager!.has(userId)) return this.cache.manager!.get(userId) ?? null;
97
+ let row: any = null;
98
+ try {
99
+ const rows = await this.engine.find('sys_user', {
100
+ filter: { id: userId },
101
+ fields: ['id', 'manager_id'],
102
+ limit: 1,
103
+ context: SYSTEM_CTX,
104
+ });
105
+ row = Array.isArray(rows) ? rows[0] : null;
106
+ } catch {
107
+ row = null;
108
+ }
109
+ const mgr = row?.manager_id ? String(row.manager_id) : null;
110
+ this.cache.manager!.set(userId, mgr);
111
+ return mgr;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Convenience helper used by the sharing-rule evaluator + approval
117
+ * engine: expand an approver / recipient descriptor of the form
118
+ * `{type, value}` into a flat list of user IDs by routing to the
119
+ * appropriate graph service.
120
+ *
121
+ * `team` → flat team members (this service).
122
+ * `department` → recursive department members (delegated; requires a
123
+ * {@link IDepartmentGraphService} instance passed in `opts.dept`).
124
+ * `role` → tenant role members.
125
+ * `manager` → submitter's manager via `record[value] ?? record.owner_id`.
126
+ * `field` → literal user id stored in `record[value]`.
127
+ * `user` → literal value.
128
+ * Anything else echoes `type:value` for back-compat with legacy
129
+ * substring-match approver flows.
130
+ */
131
+ export async function expandPrincipal(
132
+ input: { type: string; value: string; record?: any },
133
+ ctx: { team: TeamGraphService; dept?: { expandUsers(id: string): Promise<string[]> }; organizationId?: string | null },
134
+ ): Promise<string[]> {
135
+ const t = input.type;
136
+ const v = String(input.value ?? '');
137
+ if (!v) return [];
138
+ if (t === 'user') return [v];
139
+ if (t === 'team') return ctx.team.expandUsers(v);
140
+ if (t === 'department' || t === 'dept') {
141
+ if (ctx.dept) return ctx.dept.expandUsers(v);
142
+ return [`${t}:${v}`];
143
+ }
144
+ if (t === 'role') return ctx.team.expandRoleUsers(v, ctx.organizationId ?? undefined);
145
+ if (t === 'field' && input.record) {
146
+ const fv = (input.record as any)[v];
147
+ return fv ? [String(fv)] : [];
148
+ }
149
+ if (t === 'manager' && input.record) {
150
+ const subject = (input.record as any)[v] ?? (input.record as any).owner_id;
151
+ if (!subject) return [];
152
+ const mgr = await ctx.team.managerOf(String(subject), ctx.organizationId ?? undefined);
153
+ return mgr ? [mgr] : [];
154
+ }
155
+ // queue / unknown — fall back to raw prefix string so existing
156
+ // string-match approver flows keep working.
157
+ return [`${t}:${v}`];
158
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "../../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "types": ["node"]
7
+ },
8
+ "include": ["src/**/*"],
9
+ "exclude": ["dist", "node_modules", "**/*.test.ts"]
10
+ }