figmanage 1.0.1 → 1.2.0

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.
Files changed (98) hide show
  1. package/README.md +74 -59
  2. package/dist/cli/analytics.d.ts +3 -0
  3. package/dist/cli/analytics.js +48 -0
  4. package/dist/cli/branching.d.ts +3 -0
  5. package/dist/cli/branching.js +56 -0
  6. package/dist/cli/comments.d.ts +3 -0
  7. package/dist/cli/comments.js +86 -0
  8. package/dist/cli/completion.d.ts +7 -0
  9. package/dist/cli/completion.js +160 -0
  10. package/dist/cli/components.d.ts +3 -0
  11. package/dist/cli/components.js +82 -0
  12. package/dist/cli/compound-commands.d.ts +14 -0
  13. package/dist/cli/compound-commands.js +291 -0
  14. package/dist/cli/export.d.ts +3 -0
  15. package/dist/cli/export.js +51 -0
  16. package/dist/cli/files.d.ts +3 -0
  17. package/dist/cli/files.js +156 -0
  18. package/dist/cli/format.js +147 -2
  19. package/dist/cli/helpers.d.ts +7 -0
  20. package/dist/cli/helpers.js +43 -0
  21. package/dist/cli/index.js +68 -89
  22. package/dist/cli/libraries.d.ts +3 -0
  23. package/dist/cli/libraries.js +26 -0
  24. package/dist/cli/navigate.d.ts +3 -0
  25. package/dist/cli/navigate.js +192 -0
  26. package/dist/cli/org.d.ts +3 -0
  27. package/dist/cli/org.js +227 -0
  28. package/dist/cli/permissions.d.ts +3 -0
  29. package/dist/cli/permissions.js +133 -0
  30. package/dist/cli/projects.d.ts +3 -0
  31. package/dist/cli/projects.js +110 -0
  32. package/dist/cli/reading.d.ts +3 -0
  33. package/dist/cli/reading.js +51 -0
  34. package/dist/cli/teams.d.ts +3 -0
  35. package/dist/cli/teams.js +56 -0
  36. package/dist/cli/variables.d.ts +3 -0
  37. package/dist/cli/variables.js +80 -0
  38. package/dist/cli/versions.d.ts +3 -0
  39. package/dist/cli/versions.js +46 -0
  40. package/dist/cli/webhooks.d.ts +3 -0
  41. package/dist/cli/webhooks.js +100 -0
  42. package/dist/operations/analytics.d.ts +10 -0
  43. package/dist/operations/analytics.js +15 -0
  44. package/dist/operations/branching.d.ts +24 -0
  45. package/dist/operations/branching.js +41 -0
  46. package/dist/operations/comments.d.ts +43 -0
  47. package/dist/operations/comments.js +65 -0
  48. package/dist/operations/components.d.ts +24 -0
  49. package/dist/operations/components.js +30 -0
  50. package/dist/operations/compound-manager.d.ts +101 -0
  51. package/dist/operations/compound-manager.js +629 -0
  52. package/dist/operations/compound.d.ts +102 -0
  53. package/dist/operations/compound.js +595 -0
  54. package/dist/operations/export.d.ts +19 -0
  55. package/dist/operations/export.js +27 -0
  56. package/dist/operations/files.d.ts +55 -0
  57. package/dist/operations/files.js +89 -0
  58. package/dist/operations/libraries.d.ts +5 -0
  59. package/dist/operations/libraries.js +10 -0
  60. package/dist/operations/navigate.d.ts +99 -0
  61. package/dist/operations/navigate.js +266 -0
  62. package/dist/operations/org.d.ts +95 -0
  63. package/dist/operations/org.js +205 -0
  64. package/dist/operations/permissions.d.ts +59 -0
  65. package/dist/operations/permissions.js +112 -0
  66. package/dist/operations/projects.d.ts +29 -0
  67. package/dist/operations/projects.js +40 -0
  68. package/dist/operations/reading.d.ts +12 -0
  69. package/dist/operations/reading.js +20 -0
  70. package/dist/operations/teams.d.ts +17 -0
  71. package/dist/operations/teams.js +17 -0
  72. package/dist/operations/variables.d.ts +17 -0
  73. package/dist/operations/variables.js +39 -0
  74. package/dist/operations/versions.d.ts +23 -0
  75. package/dist/operations/versions.js +27 -0
  76. package/dist/operations/webhooks.d.ts +25 -0
  77. package/dist/operations/webhooks.js +38 -0
  78. package/dist/tools/analytics.js +6 -16
  79. package/dist/tools/branching.js +7 -36
  80. package/dist/tools/comments.js +9 -56
  81. package/dist/tools/components.js +7 -19
  82. package/dist/tools/compound-manager.js +21 -644
  83. package/dist/tools/compound.js +32 -566
  84. package/dist/tools/export.js +4 -23
  85. package/dist/tools/files.js +21 -68
  86. package/dist/tools/libraries.js +4 -11
  87. package/dist/tools/navigate.js +23 -246
  88. package/dist/tools/org.js +29 -245
  89. package/dist/tools/permissions.js +18 -97
  90. package/dist/tools/projects.js +8 -27
  91. package/dist/tools/reading.js +5 -15
  92. package/dist/tools/teams.js +8 -16
  93. package/dist/tools/variables.js +13 -30
  94. package/dist/tools/versions.js +6 -24
  95. package/dist/tools/webhooks.js +7 -24
  96. package/package.json +1 -1
  97. package/dist/cli/commands.d.ts +0 -47
  98. package/dist/cli/commands.js +0 -1204
@@ -0,0 +1,629 @@
1
+ import { internalClient } from '../clients/internal-api.js';
2
+ import { requireOrgId } from '../tools/register.js';
3
+ import { levelName } from './compound.js';
4
+ // -- Shared helpers --
5
+ const BATCH_SIZE = 5;
6
+ async function batchProcess(items, fn) {
7
+ const results = [];
8
+ for (let i = 0; i < items.length; i += BATCH_SIZE) {
9
+ const batch = items.slice(i, i + BATCH_SIZE);
10
+ const batchResults = await Promise.allSettled(batch.map(fn));
11
+ results.push(...batchResults);
12
+ }
13
+ return results;
14
+ }
15
+ const MAX_REVOCATIONS = 25;
16
+ const PAID_STATUSES = {
17
+ full: { expert: 'full' },
18
+ dev: { developer: 'full' },
19
+ collab: { collaborator: 'full' },
20
+ view: { collaborator: 'starter', developer: 'starter', expert: 'starter' },
21
+ };
22
+ const LEVEL_MAP = { editor: 300, viewer: 100 };
23
+ // -- offboard_user --
24
+ export async function offboardUser(config, params) {
25
+ const { user_identifier, execute, transfer_to } = params;
26
+ const orgId = requireOrgId(config, params.org_id);
27
+ const api = internalClient(config);
28
+ // Step 1: Resolve user
29
+ const userRes = await api.get(`/api/v2/orgs/${orgId}/org_users`, {
30
+ params: user_identifier.includes('@') ? { search_query: user_identifier } : {},
31
+ });
32
+ const rawUsers = userRes.data?.meta?.users || userRes.data?.meta || userRes.data || [];
33
+ const members = Array.isArray(rawUsers) ? rawUsers : [];
34
+ const member = members.find((m) => user_identifier.includes('@')
35
+ ? m.user?.email === user_identifier
36
+ : String(m.user_id) === String(user_identifier));
37
+ if (!member) {
38
+ throw new Error(`User not found: ${user_identifier}`);
39
+ }
40
+ const userId = String(member.user_id);
41
+ // Block self-offboarding
42
+ if (userId === String(config.userId)) {
43
+ throw new Error('Cannot audit yourself for offboarding.');
44
+ }
45
+ const user = {
46
+ org_user_id: String(member.id),
47
+ user_id: userId,
48
+ name: member.user?.handle || null,
49
+ email: member.user?.email || null,
50
+ seat_type: member.active_seat_type?.key || null,
51
+ permission: member.permission || null,
52
+ };
53
+ // Step 2: Fetch all org teams
54
+ const teamsRes = await api.get(`/api/orgs/${orgId}/teams`);
55
+ const allTeams = (teamsRes.data?.meta || teamsRes.data || []);
56
+ const cappedTeams = allTeams.slice(0, 30);
57
+ // Step 3: Check team membership (batched)
58
+ const teamMemberships = [];
59
+ const memberResults = await batchProcess(cappedTeams, async (team) => {
60
+ const res = await api.get(`/api/teams/${team.id}/members`);
61
+ const teamMembers = res.data?.meta || res.data || [];
62
+ const match = teamMembers.find((m) => String(m.id) === userId);
63
+ return { team, match };
64
+ });
65
+ for (const r of memberResults) {
66
+ if (r.status === 'fulfilled' && r.value.match) {
67
+ const { team, match } = r.value;
68
+ teamMemberships.push({
69
+ team_id: String(team.id),
70
+ team_name: team.name,
71
+ role: match.team_role?.level ? levelName(match.team_role.level) : 'member',
72
+ });
73
+ }
74
+ }
75
+ // Step 4: For each team the user belongs to (cap 10), get projects
76
+ const userTeams = teamMemberships.slice(0, 10);
77
+ const allProjects = [];
78
+ const projectResults = await batchProcess(userTeams, async (tm) => {
79
+ const res = await api.get(`/api/teams/${tm.team_id}/folders`);
80
+ const raw = res.data?.meta?.folder_rows || res.data?.meta || res.data || [];
81
+ const folders = Array.isArray(raw) ? raw : [];
82
+ return { team_name: tm.team_name, folders };
83
+ });
84
+ for (const r of projectResults) {
85
+ if (r.status === 'fulfilled') {
86
+ for (const f of r.value.folders) {
87
+ if (allProjects.length >= 50)
88
+ break;
89
+ allProjects.push({
90
+ project_id: String(f.id),
91
+ project_name: f.name,
92
+ team_name: r.value.team_name,
93
+ });
94
+ }
95
+ }
96
+ }
97
+ // Step 5: Check project roles (batched)
98
+ const projectPermissions = [];
99
+ const roleResults = await batchProcess(allProjects, async (proj) => {
100
+ const res = await api.get(`/api/roles/folder/${proj.project_id}`);
101
+ const roles = res.data?.meta || [];
102
+ const match = roles.find((r) => String(r.user_id) === userId);
103
+ return { proj, match };
104
+ });
105
+ for (const r of roleResults) {
106
+ if (r.status === 'fulfilled' && r.value.match) {
107
+ const { proj, match } = r.value;
108
+ projectPermissions.push({
109
+ project_id: proj.project_id,
110
+ project_name: proj.project_name,
111
+ team_name: proj.team_name,
112
+ role: levelName(match.level),
113
+ });
114
+ }
115
+ }
116
+ // Step 6: Sample files from projects where user has access
117
+ const projectsWithAccess = projectPermissions.slice(0, 10);
118
+ const fileOwnership = [];
119
+ let totalFilesChecked = 0;
120
+ for (const proj of projectsWithAccess) {
121
+ if (totalFilesChecked >= 50)
122
+ break;
123
+ try {
124
+ const filesRes = await api.get(`/api/folders/${proj.project_id}/paginated_files`, {
125
+ params: { folderId: proj.project_id, page_size: 10 },
126
+ });
127
+ const meta = filesRes.data?.meta || filesRes.data;
128
+ const files = meta?.files || meta || [];
129
+ const fileRoleResults = await batchProcess(files.slice(0, Math.min(10, 50 - totalFilesChecked)), async (file) => {
130
+ const res = await api.get(`/api/roles/file/${file.key}`);
131
+ const roles = res.data?.meta || [];
132
+ const match = roles.find((r) => String(r.user_id) === userId && r.level === 999);
133
+ return { file, match };
134
+ });
135
+ for (const r of fileRoleResults) {
136
+ totalFilesChecked++;
137
+ if (r.status === 'fulfilled' && r.value.match) {
138
+ fileOwnership.push({
139
+ file_key: r.value.file.key,
140
+ file_name: r.value.file.name,
141
+ project_name: proj.project_name,
142
+ team_name: proj.team_name,
143
+ });
144
+ }
145
+ }
146
+ }
147
+ catch {
148
+ // Skip projects where file listing fails
149
+ }
150
+ }
151
+ const summary = {
152
+ teams: teamMemberships.length,
153
+ projects_with_access: projectPermissions.length,
154
+ files_owned: fileOwnership.length,
155
+ };
156
+ const transferPlan = [];
157
+ if (fileOwnership.length > 0) {
158
+ transferPlan.push(`${fileOwnership.length} file(s) need ownership transfer before removal.`);
159
+ }
160
+ if (teamMemberships.length > 0) {
161
+ transferPlan.push(`Remove from ${teamMemberships.length} team(s).`);
162
+ }
163
+ if (projectPermissions.length > 0) {
164
+ transferPlan.push(`Revoke access to ${projectPermissions.length} project(s).`);
165
+ }
166
+ if (user.seat_type) {
167
+ transferPlan.push(`Downgrade or remove ${user.seat_type} seat.`);
168
+ }
169
+ // Audit-only mode
170
+ if (!execute) {
171
+ return {
172
+ user,
173
+ team_memberships: teamMemberships,
174
+ project_permissions: projectPermissions,
175
+ file_ownership: fileOwnership,
176
+ summary,
177
+ transfer_plan: transferPlan,
178
+ note: 'Run with execute=true to perform offboarding. Provide transfer_to if the user owns files.',
179
+ };
180
+ }
181
+ // --- Execute mode ---
182
+ // Validate: if user owns files, transfer_to is required
183
+ if (fileOwnership.length > 0 && !transfer_to) {
184
+ throw new Error(`User owns ${fileOwnership.length} file(s). Provide transfer_to (email or user_id) to transfer ownership before revoking access.`);
185
+ }
186
+ // Resolve transfer_to user
187
+ let transferToUserId;
188
+ if (transfer_to) {
189
+ const tRes = await api.get(`/api/v2/orgs/${orgId}/org_users`, {
190
+ params: transfer_to.includes('@') ? { search_query: transfer_to } : {},
191
+ });
192
+ const tUsers = tRes.data?.meta?.users || tRes.data?.meta || tRes.data || [];
193
+ const tList = Array.isArray(tUsers) ? tUsers : [];
194
+ const tMatch = tList.find((m) => transfer_to.includes('@')
195
+ ? m.user?.email === transfer_to
196
+ : String(m.user_id) === String(transfer_to));
197
+ if (!tMatch)
198
+ throw new Error(`Transfer target not found: ${transfer_to}`);
199
+ transferToUserId = String(tMatch.user_id);
200
+ }
201
+ // Cap total mutations
202
+ const totalMutations = fileOwnership.length + teamMemberships.length + projectPermissions.length + 1;
203
+ if (totalMutations > MAX_REVOCATIONS) {
204
+ throw new Error(`Offboarding would require ${totalMutations} mutations (cap: ${MAX_REVOCATIONS}). ` +
205
+ `Reduce scope or execute manually with revoke_access and set_permissions.`);
206
+ }
207
+ const actions = [];
208
+ // Step A: Transfer file ownership
209
+ if (fileOwnership.length > 0 && transferToUserId) {
210
+ for (const file of fileOwnership) {
211
+ try {
212
+ // Get the role entry for the file
213
+ const rolesRes = await api.get(`/api/roles/file/${file.file_key}`);
214
+ const roles = rolesRes.data?.meta || [];
215
+ const ownerRole = roles.find((r) => String(r.user_id) === userId && r.level === 999);
216
+ const transfereeRole = roles.find((r) => String(r.user_id) === transferToUserId);
217
+ // Give transfer target owner access
218
+ if (transfereeRole) {
219
+ await api.put(`/api/roles/${transfereeRole.id}`, { level: 999 });
220
+ }
221
+ else {
222
+ // Invite as editor first, then promote -- can't directly set owner on uninvited user
223
+ await api.post('/api/invites', {
224
+ resource_type: 'file',
225
+ resource_id_or_key: file.file_key,
226
+ emails: [transfer_to],
227
+ level: 300,
228
+ });
229
+ // Re-fetch roles to get the new role ID
230
+ const rolesRes2 = await api.get(`/api/roles/file/${file.file_key}`);
231
+ const roles2 = rolesRes2.data?.meta || [];
232
+ const newRole = roles2.find((r) => String(r.user_id) === transferToUserId);
233
+ if (newRole) {
234
+ await api.put(`/api/roles/${newRole.id}`, { level: 999 });
235
+ }
236
+ }
237
+ // Downgrade departing user from owner to editor (can't revoke owner directly)
238
+ if (ownerRole) {
239
+ await api.put(`/api/roles/${ownerRole.id}`, { level: 300 });
240
+ }
241
+ actions.push({ action: 'transfer_ownership', status: 'done', detail: `${file.file_name} -> ${transfer_to}` });
242
+ }
243
+ catch (e) {
244
+ actions.push({ action: 'transfer_ownership', status: 'failed', detail: `${file.file_name}: ${e.response?.status || e.message}` });
245
+ }
246
+ }
247
+ }
248
+ // Step B: Revoke file access (now that they're no longer owner)
249
+ for (const file of fileOwnership) {
250
+ try {
251
+ const rolesRes = await api.get(`/api/roles/file/${file.file_key}`);
252
+ const roles = rolesRes.data?.meta || [];
253
+ const role = roles.find((r) => String(r.user_id) === userId);
254
+ if (role) {
255
+ await api.delete(`/api/roles/${role.id}`);
256
+ actions.push({ action: 'revoke_file', status: 'done', detail: file.file_name });
257
+ }
258
+ }
259
+ catch (e) {
260
+ actions.push({ action: 'revoke_file', status: 'failed', detail: `${file.file_name}: ${e.response?.status || e.message}` });
261
+ }
262
+ }
263
+ // Step C: Revoke project access
264
+ const projResults = await batchProcess(projectPermissions, async (proj) => {
265
+ const rolesRes = await api.get(`/api/roles/folder/${proj.project_id}`);
266
+ const roles = rolesRes.data?.meta || [];
267
+ const role = roles.find((r) => String(r.user_id) === userId);
268
+ if (role)
269
+ await api.delete(`/api/roles/${role.id}`);
270
+ return proj;
271
+ });
272
+ for (let i = 0; i < projectPermissions.length; i++) {
273
+ const r = projResults[i];
274
+ actions.push({
275
+ action: 'revoke_project',
276
+ status: r.status === 'fulfilled' ? 'done' : 'failed',
277
+ detail: projectPermissions[i].project_name,
278
+ });
279
+ }
280
+ // Step D: Revoke team memberships
281
+ const teamResults = await batchProcess(teamMemberships, async (tm) => {
282
+ const rolesRes = await api.get(`/api/roles/team/${tm.team_id}`);
283
+ const roles = rolesRes.data?.meta || [];
284
+ const role = roles.find((r) => String(r.user_id) === userId);
285
+ if (role)
286
+ await api.delete(`/api/roles/${role.id}`);
287
+ return tm;
288
+ });
289
+ for (let i = 0; i < teamMemberships.length; i++) {
290
+ const r = teamResults[i];
291
+ actions.push({
292
+ action: 'revoke_team',
293
+ status: r.status === 'fulfilled' ? 'done' : 'failed',
294
+ detail: teamMemberships[i].team_name,
295
+ });
296
+ }
297
+ // Step E: Downgrade seat to viewer
298
+ if (user.seat_type) {
299
+ try {
300
+ const viewStatuses = { collaborator: 'starter', developer: 'starter', expert: 'starter' };
301
+ await api.put(`/api/orgs/${orgId}/org_users`, {
302
+ org_user_ids: [user.org_user_id],
303
+ paid_statuses: viewStatuses,
304
+ entry_point: 'members_tab',
305
+ seat_increase_authorized: 'true',
306
+ seat_swap_intended: 'false',
307
+ latest_ou_update: member.updated_at,
308
+ showing_billing_groups: 'true',
309
+ }, {
310
+ 'axios-retry': { retries: 0 },
311
+ });
312
+ actions.push({ action: 'downgrade_seat', status: 'done', detail: `${user.seat_type} -> viewer` });
313
+ }
314
+ catch (e) {
315
+ actions.push({ action: 'downgrade_seat', status: 'failed', detail: e.response?.status || e.message });
316
+ }
317
+ }
318
+ const succeeded = actions.filter(a => a.status === 'done').length;
319
+ const failed = actions.filter(a => a.status === 'failed').length;
320
+ return {
321
+ user,
322
+ executed: true,
323
+ actions,
324
+ summary: { succeeded, failed, total: actions.length },
325
+ note: failed > 0
326
+ ? `${failed} action(s) failed. Review and retry manually.`
327
+ : 'Offboarding complete.',
328
+ };
329
+ }
330
+ // -- onboard_user --
331
+ export async function onboardUser(config, params) {
332
+ const { email, team_ids, role, share_files, seat_type, confirm } = params;
333
+ const orgId = requireOrgId(config, params.org_id);
334
+ // Enforce caps
335
+ if (team_ids.length > 10) {
336
+ throw new Error(`Too many teams: ${team_ids.length}. Maximum is 10.`);
337
+ }
338
+ if (share_files && share_files.length > 20) {
339
+ throw new Error(`Too many files: ${share_files.length}. Maximum is 20.`);
340
+ }
341
+ const api = internalClient(config);
342
+ const level = LEVEL_MAP[role || 'editor'];
343
+ // Validate team_ids exist
344
+ const teamsRes = await api.get(`/api/orgs/${orgId}/teams`);
345
+ const orgTeams = teamsRes.data?.meta || teamsRes.data || [];
346
+ const orgTeamMap = new Map();
347
+ for (const t of orgTeams) {
348
+ orgTeamMap.set(String(t.id), t.name);
349
+ }
350
+ const invalidTeams = team_ids.filter(id => !orgTeamMap.has(id));
351
+ if (invalidTeams.length > 0) {
352
+ throw new Error(`Team(s) not found in org: ${invalidTeams.join(', ')}`);
353
+ }
354
+ // Invite to teams (batched)
355
+ const teamsJoined = [];
356
+ const teamInviteResults = await batchProcess(team_ids, async (teamId) => {
357
+ const res = await api.post('/api/invites', {
358
+ resource_type: 'team',
359
+ resource_id_or_key: teamId,
360
+ emails: [email],
361
+ level,
362
+ });
363
+ return { teamId, res };
364
+ });
365
+ for (let i = 0; i < team_ids.length; i++) {
366
+ const r = teamInviteResults[i];
367
+ teamsJoined.push({
368
+ team_id: team_ids[i],
369
+ team_name: orgTeamMap.get(team_ids[i]) || 'unknown',
370
+ role: role || 'editor',
371
+ status: r.status === 'fulfilled' ? 'invited' : 'failed',
372
+ });
373
+ }
374
+ // Share files (batched, viewer access)
375
+ const filesShared = [];
376
+ if (share_files && share_files.length > 0) {
377
+ const fileShareResults = await batchProcess(share_files, async (fileKey) => {
378
+ await api.post('/api/invites', {
379
+ resource_type: 'file',
380
+ resource_id_or_key: fileKey,
381
+ emails: [email],
382
+ level: 100, // viewer
383
+ });
384
+ return fileKey;
385
+ });
386
+ for (let i = 0; i < share_files.length; i++) {
387
+ const r = fileShareResults[i];
388
+ filesShared.push({
389
+ file_key: share_files[i],
390
+ role: 'viewer',
391
+ status: r.status === 'fulfilled' ? 'shared' : 'failed',
392
+ });
393
+ }
394
+ }
395
+ // Seat change
396
+ let seatChange = { status: 'skipped', note: 'No seat_type specified.' };
397
+ if (seat_type && confirm) {
398
+ try {
399
+ const userRes = await api.get(`/api/v2/orgs/${orgId}/org_users`, {
400
+ params: { search_query: email },
401
+ });
402
+ const rawU = userRes.data?.meta?.users || userRes.data?.meta || userRes.data || [];
403
+ const users = Array.isArray(rawU) ? rawU : [];
404
+ const found = users.find((m) => m.user?.email === email);
405
+ if (found) {
406
+ await api.put(`/api/orgs/${orgId}/org_users`, {
407
+ org_user_ids: [String(found.id)],
408
+ paid_statuses: PAID_STATUSES[seat_type],
409
+ entry_point: 'members_tab',
410
+ seat_increase_authorized: 'true',
411
+ seat_swap_intended: 'false',
412
+ latest_ou_update: found.updated_at,
413
+ showing_billing_groups: 'true',
414
+ }, {
415
+ 'axios-retry': { retries: 0 },
416
+ });
417
+ seatChange = { status: 'changed', note: `Set to ${seat_type}.` };
418
+ }
419
+ else {
420
+ seatChange = { status: 'skipped', note: 'User not yet in org (invite pending). Seat change will need to be applied after they accept.' };
421
+ }
422
+ }
423
+ catch (e) {
424
+ seatChange = { status: 'failed', note: `Seat change failed: ${e.response?.status || e.message}` };
425
+ }
426
+ }
427
+ else if (seat_type && !confirm) {
428
+ seatChange = { status: 'skipped', note: `Set confirm: true to apply ${seat_type} seat change.` };
429
+ }
430
+ return {
431
+ user_email: email,
432
+ setup_results: {
433
+ teams_joined: teamsJoined,
434
+ files_shared: filesShared,
435
+ seat_change: seatChange,
436
+ },
437
+ next_steps: [
438
+ 'User will receive invite emails for each team.',
439
+ 'Team-level projects are automatically accessible once they accept.',
440
+ ],
441
+ };
442
+ }
443
+ // -- quarterly_design_ops_report --
444
+ export async function quarterlyDesignOpsReport(config, params) {
445
+ const { days } = params;
446
+ const orgId = requireOrgId(config, params.org_id);
447
+ const api = internalClient(config);
448
+ const now = new Date();
449
+ const periodStart = new Date(now.getTime() - days * 86400000);
450
+ // Phase 1: Parallel fetches
451
+ const [teamsResult, seatsResult, billingResult, upcomingResult, ratesResult] = await Promise.allSettled([
452
+ api.get(`/api/orgs/${orgId}/teams`, {
453
+ params: { include_member_count: true, include_project_count: true },
454
+ }),
455
+ api.get(`/api/orgs/${orgId}/org_users/filter_counts`),
456
+ api.get(`/api/orgs/${orgId}/billing_data`),
457
+ api.get(`/api/plans/organization/${orgId}/invoices/upcoming`),
458
+ api.get(`/api/pricing/contract_rates`, {
459
+ params: { plan_parent_id: orgId, plan_type: 'organization' },
460
+ }),
461
+ ]);
462
+ // Paginate all members (cursor-based, max 500)
463
+ const allMembers = [];
464
+ let cursor;
465
+ const maxPages = 20;
466
+ for (let page = 0; page < maxPages; page++) {
467
+ try {
468
+ const params = { page_size: 25 };
469
+ if (cursor)
470
+ params.cursor = cursor;
471
+ const res = await api.get(`/api/v2/orgs/${orgId}/org_users`, { params });
472
+ const meta = res.data?.meta || {};
473
+ const batch = meta.users || [];
474
+ if (!Array.isArray(batch) || batch.length === 0)
475
+ break;
476
+ allMembers.push(...batch);
477
+ cursor = Array.isArray(meta.cursor) ? meta.cursor[0] : meta.cursor;
478
+ if (!cursor || batch.length < 25)
479
+ break;
480
+ }
481
+ catch {
482
+ break;
483
+ }
484
+ }
485
+ // Process teams
486
+ const teamsRaw = teamsResult.status === 'fulfilled'
487
+ ? (teamsResult.value.data?.meta || teamsResult.value.data || [])
488
+ : [];
489
+ const teamsArray = Array.isArray(teamsRaw) ? teamsRaw : [];
490
+ const teams = teamsArray.map((t) => ({
491
+ team_name: t.name,
492
+ members: t.member_count || 0,
493
+ projects: t.project_count || 0,
494
+ }));
495
+ // Seat utilization from member list
496
+ const paidKeys = new Set(['expert', 'developer', 'collaborator']);
497
+ const cutoffMs = now.getTime() - days * 86400000;
498
+ let totalPaid = 0;
499
+ let inactivePaid = 0;
500
+ const seatCounts = {};
501
+ for (const m of allMembers) {
502
+ const seatKey = m.active_seat_type?.key;
503
+ if (seatKey && paidKeys.has(seatKey)) {
504
+ totalPaid++;
505
+ seatCounts[seatKey] = (seatCounts[seatKey] || 0) + 1;
506
+ const lastSeen = m.last_seen ? new Date(m.last_seen).getTime() : 0;
507
+ if (!m.last_seen || lastSeen < cutoffMs) {
508
+ inactivePaid++;
509
+ }
510
+ }
511
+ }
512
+ const activePaid = totalPaid - inactivePaid;
513
+ const utilizationRate = totalPaid > 0
514
+ ? Number(((activePaid / totalPaid) * 100).toFixed(1))
515
+ : 0;
516
+ // Billing
517
+ let billing = null;
518
+ const errors = [];
519
+ if (ratesResult.status === 'fulfilled') {
520
+ const prices = ratesResult.value.data?.meta?.product_prices || [];
521
+ const seatProducts = new Set(['expert', 'developer', 'collaborator']);
522
+ let monthlySpendCents = 0;
523
+ for (const p of prices) {
524
+ if (seatProducts.has(p.billable_product_key)) {
525
+ const count = seatCounts[p.billable_product_key] || 0;
526
+ monthlySpendCents += count * (p.amount || 0);
527
+ }
528
+ }
529
+ const monthlySpendDollars = Number((monthlySpendCents / 100).toFixed(2));
530
+ const costPerActiveUser = activePaid > 0
531
+ ? Number((monthlySpendDollars / activePaid).toFixed(2))
532
+ : 0;
533
+ billing = {
534
+ monthly_spend_dollars: monthlySpendDollars,
535
+ cost_per_active_user: costPerActiveUser,
536
+ };
537
+ if (upcomingResult.status === 'fulfilled') {
538
+ const upcoming = upcomingResult.value.data;
539
+ billing.upcoming_invoice_date = upcoming?.date || upcoming?.period_end || null;
540
+ billing.upcoming_amount = upcoming?.amount_due ?? upcoming?.total ?? null;
541
+ }
542
+ }
543
+ else {
544
+ errors.push('billing: rates unavailable (may require admin)');
545
+ }
546
+ if (billingResult.status === 'fulfilled' && billing) {
547
+ const rawBilling = billingResult.value.data?.meta || billingResult.value.data;
548
+ if (rawBilling) {
549
+ const { shipping_address, ...safeBilling } = rawBilling;
550
+ billing.plan_name = safeBilling.plan_name || safeBilling.name || null;
551
+ }
552
+ }
553
+ else if (billingResult.status === 'rejected') {
554
+ errors.push('billing_data: 403 (admin required)');
555
+ }
556
+ // Phase 2: Library adoption
557
+ const libraryAdoption = [];
558
+ try {
559
+ const libRes = await api.get('/api/design_systems/libraries', {
560
+ params: { org_id: orgId },
561
+ });
562
+ const libraries = libRes.data?.libraries || libRes.data?.meta?.libraries || [];
563
+ const cappedLibraries = libraries.slice(0, 5);
564
+ const endTs = Math.floor(now.getTime() / 1000);
565
+ const startTs = Math.floor(periodStart.getTime() / 1000);
566
+ const libResults = await batchProcess(cappedLibraries, async (lib) => {
567
+ const res = await api.get(`/api/dsa/library/${lib.file_key || lib.key}/team_usage`, {
568
+ params: { start_ts: startTs, end_ts: endTs },
569
+ });
570
+ return { lib, data: res.data };
571
+ });
572
+ for (const r of libResults) {
573
+ if (r.status === 'fulfilled') {
574
+ const { lib, data } = r.value;
575
+ const teamUsages = data?.rows || data?.teams || [];
576
+ let totalInsertions = 0;
577
+ for (const tu of teamUsages) {
578
+ totalInsertions += tu.insertions || tu.num_insertions || 0;
579
+ }
580
+ libraryAdoption.push({
581
+ library_name: lib.name || lib.file_name || 'unknown',
582
+ file_key: lib.file_key || lib.key,
583
+ insertions: totalInsertions,
584
+ });
585
+ }
586
+ }
587
+ }
588
+ catch {
589
+ errors.push('libraries: failed to fetch (may be 404)');
590
+ }
591
+ // Highlights
592
+ const highlights = [];
593
+ highlights.push(`${utilizationRate}% seat utilization (${activePaid} active of ${totalPaid} paid).`);
594
+ if (inactivePaid > 0) {
595
+ highlights.push(`${inactivePaid} paid seat(s) inactive for ${days}+ days.`);
596
+ }
597
+ if (teams.length > 0) {
598
+ highlights.push(`${teams.length} team(s), ${allMembers.length} total member(s).`);
599
+ }
600
+ if (libraryAdoption.length > 0) {
601
+ const totalInsertions = libraryAdoption.reduce((sum, l) => sum + l.insertions, 0);
602
+ highlights.push(`${totalInsertions} library component insertions across ${libraryAdoption.length} library/libraries.`);
603
+ }
604
+ const result = {
605
+ period: {
606
+ start: periodStart.toISOString().split('T')[0],
607
+ end: now.toISOString().split('T')[0],
608
+ days,
609
+ },
610
+ org_overview: {
611
+ total_teams: teams.length,
612
+ total_members: allMembers.length,
613
+ total_paid_seats: totalPaid,
614
+ },
615
+ seat_utilization: {
616
+ active_paid: activePaid,
617
+ inactive_paid: inactivePaid,
618
+ utilization_rate: utilizationRate,
619
+ },
620
+ teams,
621
+ billing,
622
+ library_adoption: libraryAdoption,
623
+ highlights,
624
+ };
625
+ if (errors.length > 0)
626
+ result.errors = errors;
627
+ return result;
628
+ }
629
+ //# sourceMappingURL=compound-manager.js.map