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