@desplega.ai/agent-swarm 1.80.2 → 1.81.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 (40) hide show
  1. package/README.md +3 -0
  2. package/openapi.json +486 -29
  3. package/package.json +3 -3
  4. package/plugin/commands/user-management.md +85 -46
  5. package/plugin/pi-skills/user-management/SKILL.md +85 -46
  6. package/src/agentmail/handlers.ts +25 -3
  7. package/src/agentmail/types.ts +1 -0
  8. package/src/be/db.ts +33 -109
  9. package/src/be/migrations/067_users_first_class.sql +185 -0
  10. package/src/be/migrations/068_profile_changed_event_type.sql +56 -0
  11. package/src/be/unmapped-identities.ts +98 -0
  12. package/src/be/users.ts +531 -0
  13. package/src/github/handlers.ts +67 -7
  14. package/src/gitlab/handlers.ts +73 -5
  15. package/src/http/operator-actor.ts +59 -0
  16. package/src/http/users.ts +611 -21
  17. package/src/http/webhooks.ts +9 -0
  18. package/src/http/workflows.ts +2 -15
  19. package/src/linear/oauth.ts +61 -1
  20. package/src/linear/sync.ts +134 -21
  21. package/src/slack/actions.ts +8 -2
  22. package/src/slack/assistant.ts +12 -9
  23. package/src/slack/enrich.ts +162 -0
  24. package/src/slack/handlers.ts +11 -19
  25. package/src/tests/agentmail-handlers.test.ts +166 -0
  26. package/src/tests/github-handlers.test.ts +290 -0
  27. package/src/tests/gitlab-handlers.test.ts +293 -1
  28. package/src/tests/http-api-integration.test.ts +8 -4
  29. package/src/tests/http-users.test.ts +605 -0
  30. package/src/tests/linear-sync-identity.test.ts +427 -0
  31. package/src/tests/mcp-tools-user.test.ts +292 -0
  32. package/src/tests/slack-identity-resolution.test.ts +349 -0
  33. package/src/tests/user-identity.test.ts +351 -81
  34. package/src/tests/workflow-triggers-v2.test.ts +261 -20
  35. package/src/tools/manage-user.ts +119 -24
  36. package/src/tools/resolve-user.ts +43 -29
  37. package/src/types.ts +26 -4
  38. package/src/utils/secret-scrubber.ts +5 -0
  39. package/src/workflows/input.ts +7 -2
  40. package/src/workflows/triggers.ts +89 -9
package/src/http/users.ts CHANGED
@@ -1,17 +1,80 @@
1
+ /**
2
+ * Operator-facing HTTP surface for the People page + Unmapped triage.
3
+ *
4
+ * Every endpoint goes through the `route()` factory so OpenAPI picks it up
5
+ * automatically (`scripts/generate-openapi.ts` imports this file). All
6
+ * mutation paths read the operator's fingerprint via `getOperatorActor()` and
7
+ * pass it as the `IdentityActor` arg to the helpers in `src/be/users.ts` — so
8
+ * every identity mutation lands an event row tagged `op:<sha256-16>`.
9
+ *
10
+ * Endpoint set (Core Req #6, minus `POST/DELETE /users/:id/mcp-tokens` which
11
+ * are deferred to the MCP plan):
12
+ *
13
+ * GET /api/users
14
+ * POST /api/users
15
+ * GET /api/users/unmapped (must precede /:id)
16
+ * POST /api/users/unmapped/:kind/:externalId/resolve
17
+ * GET /api/users/:id
18
+ * PATCH /api/users/:id
19
+ * POST /api/users/:id/merge
20
+ * GET /api/users/:id/events
21
+ * POST /api/users/:id/identities
22
+ * DELETE /api/users/:id/identities/:kind/:externalId
23
+ */
24
+
1
25
  import type { IncomingMessage, ServerResponse } from "node:http";
2
26
  import { z } from "zod";
3
- import { createUser, getAllUsers, getUserById, updateUser } from "../be/db";
27
+ import {
28
+ createUser,
29
+ deleteKv,
30
+ deleteUser,
31
+ getAllUsers,
32
+ getUserById,
33
+ listKv,
34
+ updateUser,
35
+ } from "../be/db";
36
+ import {
37
+ getUserIdentities,
38
+ type IdentityEvent,
39
+ linkIdentity,
40
+ listUserEvents,
41
+ listUserTokens,
42
+ recordIdentityEvent,
43
+ unlinkIdentity,
44
+ } from "../be/users";
45
+ import { getOperatorActor } from "./operator-actor";
4
46
  import { route } from "./route-def";
5
47
  import { json, jsonError } from "./utils";
6
48
 
49
+ // ─── Response composition ────────────────────────────────────────────────────
50
+
51
+ /**
52
+ * Compose the full People-page row for a user: base profile + identities +
53
+ * token summaries + the last N identity events. `recentEventLimit` defaults
54
+ * to 5 to keep the list-view response bounded.
55
+ */
56
+ function composeUser(userId: string, recentEventLimit = 5) {
57
+ const user = getUserById(userId);
58
+ if (!user) return null;
59
+ return {
60
+ ...user,
61
+ identities: getUserIdentities(userId),
62
+ tokens: listUserTokens(userId),
63
+ recentEvents: listUserEvents(userId, { limit: recentEventLimit }),
64
+ };
65
+ }
66
+
7
67
  // ─── Route Definitions ───────────────────────────────────────────────────────
8
68
 
9
69
  const listUsers = route({
10
70
  method: "get",
11
71
  path: "/api/users",
12
72
  pattern: ["api", "users"],
13
- summary: "List all users",
73
+ summary: "List all users with identities, token summaries and recent events",
14
74
  tags: ["Users"],
75
+ query: z.object({
76
+ recentEvents: z.coerce.number().int().min(0).max(50).optional(),
77
+ }),
15
78
  responses: {
16
79
  200: { description: "List of users" },
17
80
  401: { description: "Unauthorized" },
@@ -23,20 +86,22 @@ const createUserRoute = route({
23
86
  method: "post",
24
87
  path: "/api/users",
25
88
  pattern: ["api", "users"],
26
- summary: "Create a new user",
89
+ summary: "Create a new user (optionally with initial identity links)",
27
90
  tags: ["Users"],
28
91
  body: z.object({
29
92
  name: z.string().min(1),
30
93
  email: z.string().optional(),
31
94
  role: z.string().optional(),
32
95
  notes: z.string().optional(),
33
- slackUserId: z.string().optional(),
34
- linearUserId: z.string().optional(),
35
- githubUsername: z.string().optional(),
36
- gitlabUsername: z.string().optional(),
37
96
  emailAliases: z.array(z.string()).optional(),
38
97
  preferredChannel: z.string().optional(),
39
98
  timezone: z.string().optional(),
99
+ metadata: z.record(z.string(), z.unknown()).optional(),
100
+ dailyBudgetUsd: z.number().nullable().optional(),
101
+ status: z.enum(["invited", "active", "suspended"]).optional(),
102
+ identities: z
103
+ .array(z.object({ kind: z.string().min(1), externalId: z.string().min(1) }))
104
+ .optional(),
40
105
  }),
41
106
  responses: {
42
107
  200: { description: "User created" },
@@ -46,11 +111,66 @@ const createUserRoute = route({
46
111
  auth: { apiKey: true },
47
112
  });
48
113
 
114
+ const listUnmapped = route({
115
+ method: "get",
116
+ path: "/api/users/unmapped",
117
+ pattern: ["api", "users", "unmapped"],
118
+ summary: "List unmapped external identities (kv-backed triage queue)",
119
+ tags: ["Users"],
120
+ query: z.object({
121
+ kind: z.string().optional(),
122
+ limit: z.coerce.number().int().min(1).max(1000).optional(),
123
+ }),
124
+ responses: {
125
+ 200: { description: "List of unmapped identities sorted by count DESC, lastSeenAt DESC" },
126
+ 401: { description: "Unauthorized" },
127
+ },
128
+ auth: { apiKey: true },
129
+ });
130
+
131
+ const resolveUnmapped = route({
132
+ method: "post",
133
+ path: "/api/users/unmapped/{kind}/{externalId}/resolve",
134
+ pattern: ["api", "users", "unmapped", null, null, "resolve"],
135
+ summary: "Resolve an unmapped identity — link to an existing user or create a new one",
136
+ tags: ["Users"],
137
+ params: z.object({ kind: z.string(), externalId: z.string() }),
138
+ body: z.union([
139
+ z.object({ userId: z.string().min(1) }),
140
+ z.object({ name: z.string().min(1), email: z.string().email() }),
141
+ ]),
142
+ responses: {
143
+ 200: { description: "Identity linked + kv entries cleared" },
144
+ 400: { description: "Validation error" },
145
+ 401: { description: "Unauthorized" },
146
+ 404: { description: "Target user not found" },
147
+ },
148
+ auth: { apiKey: true },
149
+ });
150
+
151
+ const getUserRoute = route({
152
+ method: "get",
153
+ path: "/api/users/{id}",
154
+ pattern: ["api", "users", null],
155
+ summary: "Get a user by ID with identities, token summaries and recent events",
156
+ tags: ["Users"],
157
+ params: z.object({ id: z.string() }),
158
+ query: z.object({
159
+ recentEvents: z.coerce.number().int().min(0).max(200).optional(),
160
+ }),
161
+ responses: {
162
+ 200: { description: "User row" },
163
+ 401: { description: "Unauthorized" },
164
+ 404: { description: "User not found" },
165
+ },
166
+ auth: { apiKey: true },
167
+ });
168
+
49
169
  const updateUserRoute = route({
50
- method: "put",
170
+ method: "patch",
51
171
  path: "/api/users/{id}",
52
172
  pattern: ["api", "users", null],
53
- summary: "Update an existing user (partial at least one field required)",
173
+ summary: "Update an existing user (profile / budget / status / email-aliases / identities)",
54
174
  tags: ["Users"],
55
175
  params: z.object({ id: z.string() }),
56
176
  body: z
@@ -59,13 +179,17 @@ const updateUserRoute = route({
59
179
  email: z.string().optional(),
60
180
  role: z.string().optional(),
61
181
  notes: z.string().optional(),
62
- slackUserId: z.string().optional(),
63
- linearUserId: z.string().optional(),
64
- githubUsername: z.string().optional(),
65
- gitlabUsername: z.string().optional(),
66
182
  emailAliases: z.array(z.string()).optional(),
67
183
  preferredChannel: z.string().optional(),
68
184
  timezone: z.string().optional(),
185
+ metadata: z.record(z.string(), z.unknown()).nullable().optional(),
186
+ dailyBudgetUsd: z.number().nullable().optional(),
187
+ status: z.enum(["invited", "active", "suspended"]).optional(),
188
+ // Complete-list diff: passing this replaces the user's identity set,
189
+ // emitting `identity_added` / `identity_removed` for each delta.
190
+ identities: z
191
+ .array(z.object({ kind: z.string().min(1), externalId: z.string().min(1) }))
192
+ .optional(),
69
193
  })
70
194
  .refine((v) => Object.keys(v).length > 0, {
71
195
  message: "At least one field must be provided",
@@ -79,6 +203,145 @@ const updateUserRoute = route({
79
203
  auth: { apiKey: true },
80
204
  });
81
205
 
206
+ const mergeUsersRoute = route({
207
+ method: "post",
208
+ path: "/api/users/{id}/merge",
209
+ pattern: ["api", "users", null, "merge"],
210
+ summary: "Merge another user into this one — moves identities + email aliases, deletes source",
211
+ tags: ["Users"],
212
+ params: z.object({ id: z.string() }),
213
+ body: z.object({ sourceUserId: z.string().min(1) }),
214
+ responses: {
215
+ 200: { description: "Merged user" },
216
+ 400: { description: "Validation error (e.g. target == source)" },
217
+ 401: { description: "Unauthorized" },
218
+ 404: { description: "Target or source user not found" },
219
+ },
220
+ auth: { apiKey: true },
221
+ });
222
+
223
+ const listEventsRoute = route({
224
+ method: "get",
225
+ path: "/api/users/{id}/events",
226
+ pattern: ["api", "users", null, "events"],
227
+ summary: "Paginated identity-event timeline for a user (DESC by createdAt)",
228
+ tags: ["Users"],
229
+ params: z.object({ id: z.string() }),
230
+ query: z.object({
231
+ limit: z.coerce.number().int().min(1).max(200).optional(),
232
+ before: z.string().optional(),
233
+ }),
234
+ responses: {
235
+ 200: { description: "Array of identity events" },
236
+ 401: { description: "Unauthorized" },
237
+ 404: { description: "User not found" },
238
+ },
239
+ auth: { apiKey: true },
240
+ });
241
+
242
+ const addIdentityRoute = route({
243
+ method: "post",
244
+ path: "/api/users/{id}/identities",
245
+ pattern: ["api", "users", null, "identities"],
246
+ summary: "Link a new (kind, externalId) identity to this user",
247
+ tags: ["Users"],
248
+ params: z.object({ id: z.string() }),
249
+ body: z.object({ kind: z.string().min(1), externalId: z.string().min(1) }),
250
+ responses: {
251
+ 200: { description: "Updated identity list" },
252
+ 400: { description: "Validation error or PK collision" },
253
+ 401: { description: "Unauthorized" },
254
+ 404: { description: "User not found" },
255
+ },
256
+ auth: { apiKey: true },
257
+ });
258
+
259
+ const deleteIdentityRoute = route({
260
+ method: "delete",
261
+ path: "/api/users/{id}/identities/{kind}/{externalId}",
262
+ pattern: ["api", "users", null, "identities", null, null],
263
+ summary: "Remove a (kind, externalId) identity link from this user",
264
+ tags: ["Users"],
265
+ params: z.object({ id: z.string(), kind: z.string(), externalId: z.string() }),
266
+ responses: {
267
+ 200: { description: "Updated identity list" },
268
+ 401: { description: "Unauthorized" },
269
+ 404: { description: "User not found" },
270
+ },
271
+ auth: { apiKey: true },
272
+ });
273
+
274
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
275
+
276
+ const UNMAPPED_KINDS = ["slack", "github", "gitlab", "linear"] as const;
277
+
278
+ /**
279
+ * Group the two-key-per-identity kv entries (`<externalId>:meta` json +
280
+ * `<externalId>:count` integer) under a single `externalId` and return a
281
+ * unified shape ready for the People-page Unmapped tab.
282
+ */
283
+ function collectUnmappedForKind(kind: string, limit: number) {
284
+ const namespace = `integration:unmapped:${kind}`;
285
+ // listKv is bounded internally; we ask for 2x the cap so meta+count pairs
286
+ // produce up to `limit` unique externalIds.
287
+ const rows = listKv(namespace, { limit: Math.min(limit * 2, 1000), offset: 0 });
288
+ const byId = new Map<
289
+ string,
290
+ {
291
+ kind: string;
292
+ externalId: string;
293
+ lastSeenAt: string | null;
294
+ count: number;
295
+ sampleEventType: string | null;
296
+ sampleContext: unknown | null;
297
+ }
298
+ >();
299
+ for (const row of rows) {
300
+ const key = row.key;
301
+ let suffix: "meta" | "count" | null = null;
302
+ let externalId = "";
303
+ if (key.endsWith(":meta")) {
304
+ suffix = "meta";
305
+ externalId = key.slice(0, -":meta".length);
306
+ } else if (key.endsWith(":count")) {
307
+ suffix = "count";
308
+ externalId = key.slice(0, -":count".length);
309
+ } else {
310
+ // Legacy/unknown shape — skip.
311
+ continue;
312
+ }
313
+ let entry = byId.get(externalId);
314
+ if (!entry) {
315
+ entry = {
316
+ kind,
317
+ externalId,
318
+ lastSeenAt: null,
319
+ count: 0,
320
+ sampleEventType: null,
321
+ sampleContext: null,
322
+ };
323
+ byId.set(externalId, entry);
324
+ }
325
+ if (suffix === "meta") {
326
+ // Meta payload shape is producer-defined; we pull the common fields.
327
+ const meta =
328
+ row.value && typeof row.value === "object" ? (row.value as Record<string, unknown>) : null;
329
+ if (meta) {
330
+ const lastSeenAt = meta.lastSeenAt;
331
+ if (typeof lastSeenAt === "string") entry.lastSeenAt = lastSeenAt;
332
+ const sampleEventType = meta.sampleEventType ?? meta.eventType;
333
+ if (typeof sampleEventType === "string") entry.sampleEventType = sampleEventType;
334
+ entry.sampleContext = meta.sampleContext ?? meta.context ?? null;
335
+ }
336
+ } else {
337
+ // Count is stored as integer (decoded by listKv into a number); coerce.
338
+ const n = typeof row.value === "number" ? row.value : Number(row.value);
339
+ if (Number.isFinite(n)) entry.count = n;
340
+ }
341
+ }
342
+ return Array.from(byId.values());
343
+ }
344
+
82
345
  // ─── Handler ─────────────────────────────────────────────────────────────────
83
346
 
84
347
  export async function handleUsers(
@@ -87,43 +350,370 @@ export async function handleUsers(
87
350
  pathSegments: string[],
88
351
  queryParams: URLSearchParams,
89
352
  ): Promise<boolean> {
353
+ // ─── GET /api/users ────────────────────────────────────────────────────────
90
354
  if (listUsers.match(req.method, pathSegments)) {
91
355
  const parsed = await listUsers.parse(req, res, pathSegments, queryParams);
92
356
  if (!parsed) return true;
93
- const users = getAllUsers();
357
+ const recentLimit = parsed.query.recentEvents ?? 5;
358
+ const users = getAllUsers().map((u) => composeUser(u.id, recentLimit));
94
359
  json(res, { users });
95
360
  return true;
96
361
  }
97
362
 
363
+ // ─── POST /api/users ───────────────────────────────────────────────────────
98
364
  if (createUserRoute.match(req.method, pathSegments)) {
99
365
  const parsed = await createUserRoute.parse(req, res, pathSegments, queryParams);
100
366
  if (!parsed) return true;
367
+ const actor = getOperatorActor(req, res);
368
+ if (!actor) return true;
369
+
101
370
  try {
102
- const user = createUser(parsed.body);
103
- json(res, { user });
371
+ const { identities, ...userFields } = parsed.body;
372
+ const user = createUser(userFields);
373
+ for (const ident of identities ?? []) {
374
+ linkIdentity(user.id, ident.kind, ident.externalId, actor);
375
+ }
376
+ if (userFields.dailyBudgetUsd !== undefined) {
377
+ recordIdentityEvent(user.id, "budget_changed", actor, null, {
378
+ dailyBudgetUsd: userFields.dailyBudgetUsd,
379
+ });
380
+ }
381
+ const composed = composeUser(user.id);
382
+ json(res, { user: composed });
104
383
  } catch (err) {
105
384
  jsonError(res, err instanceof Error ? err.message : "Failed to create user", 500);
106
385
  }
107
386
  return true;
108
387
  }
109
388
 
389
+ // ─── GET /api/users/unmapped ───────────────────────────────────────────────
390
+ // MUST be checked before /api/users/:id — same depth so first-match wins.
391
+ if (listUnmapped.match(req.method, pathSegments)) {
392
+ const parsed = await listUnmapped.parse(req, res, pathSegments, queryParams);
393
+ if (!parsed) return true;
394
+ const limit = parsed.query.limit ?? 100;
395
+ const kinds = parsed.query.kind ? [parsed.query.kind] : UNMAPPED_KINDS;
396
+ const rows = kinds.flatMap((k) => collectUnmappedForKind(k, limit));
397
+ rows.sort((a, b) => {
398
+ if (b.count !== a.count) return b.count - a.count;
399
+ const al = a.lastSeenAt ?? "";
400
+ const bl = b.lastSeenAt ?? "";
401
+ return bl.localeCompare(al);
402
+ });
403
+ json(res, { unmapped: rows.slice(0, limit) });
404
+ return true;
405
+ }
406
+
407
+ // ─── POST /api/users/unmapped/:kind/:externalId/resolve ───────────────────
408
+ if (resolveUnmapped.match(req.method, pathSegments)) {
409
+ const parsed = await resolveUnmapped.parse(req, res, pathSegments, queryParams);
410
+ if (!parsed) return true;
411
+ const actor = getOperatorActor(req, res);
412
+ if (!actor) return true;
413
+
414
+ // Path params arrive URL-encoded — decode so externalIds with `@`, `+`, `:`
415
+ // etc. (AgentMail email-as-externalId, "@handle" Linear usernames) AND
416
+ // custom kinds containing `;`, `@`, `+` land both in user_external_ids and
417
+ // kv-delete with their real value.
418
+ const kind = decodeURIComponent(parsed.params.kind);
419
+ const externalId = decodeURIComponent(parsed.params.externalId);
420
+ try {
421
+ let targetUserId: string;
422
+ if ("userId" in parsed.body) {
423
+ const existing = getUserById(parsed.body.userId);
424
+ if (!existing) {
425
+ jsonError(res, "Target user not found", 404);
426
+ return true;
427
+ }
428
+ targetUserId = existing.id;
429
+ } else {
430
+ const created = createUser({ name: parsed.body.name, email: parsed.body.email });
431
+ targetUserId = created.id;
432
+ }
433
+ linkIdentity(targetUserId, kind, externalId, actor);
434
+ // Clear both kv rows (best-effort — DELETE is idempotent).
435
+ const ns = `integration:unmapped:${kind}`;
436
+ deleteKv(ns, `${externalId}:meta`);
437
+ deleteKv(ns, `${externalId}:count`);
438
+ const user = composeUser(targetUserId);
439
+ json(res, { user });
440
+ } catch (err) {
441
+ jsonError(res, err instanceof Error ? err.message : "Failed to resolve unmapped", 500);
442
+ }
443
+ return true;
444
+ }
445
+
446
+ // ─── GET /api/users/:id/events ─────────────────────────────────────────────
447
+ if (listEventsRoute.match(req.method, pathSegments)) {
448
+ const parsed = await listEventsRoute.parse(req, res, pathSegments, queryParams);
449
+ if (!parsed) return true;
450
+ if (!getUserById(parsed.params.id)) {
451
+ jsonError(res, "User not found", 404);
452
+ return true;
453
+ }
454
+ const events: IdentityEvent[] = listUserEvents(parsed.params.id, {
455
+ limit: parsed.query.limit,
456
+ before: parsed.query.before,
457
+ });
458
+ json(res, { events });
459
+ return true;
460
+ }
461
+
462
+ // ─── POST /api/users/:id/identities ────────────────────────────────────────
463
+ if (addIdentityRoute.match(req.method, pathSegments)) {
464
+ const parsed = await addIdentityRoute.parse(req, res, pathSegments, queryParams);
465
+ if (!parsed) return true;
466
+ const actor = getOperatorActor(req, res);
467
+ if (!actor) return true;
468
+ if (!getUserById(parsed.params.id)) {
469
+ jsonError(res, "User not found", 404);
470
+ return true;
471
+ }
472
+ try {
473
+ linkIdentity(parsed.params.id, parsed.body.kind, parsed.body.externalId, actor);
474
+ json(res, { identities: getUserIdentities(parsed.params.id) });
475
+ } catch (err) {
476
+ jsonError(res, err instanceof Error ? err.message : "Failed to link identity", 400);
477
+ }
478
+ return true;
479
+ }
480
+
481
+ // ─── DELETE /api/users/:id/identities/:kind/:externalId ────────────────────
482
+ if (deleteIdentityRoute.match(req.method, pathSegments)) {
483
+ const parsed = await deleteIdentityRoute.parse(req, res, pathSegments, queryParams);
484
+ if (!parsed) return true;
485
+ const actor = getOperatorActor(req, res);
486
+ if (!actor) return true;
487
+ if (!getUserById(parsed.params.id)) {
488
+ jsonError(res, "User not found", 404);
489
+ return true;
490
+ }
491
+ try {
492
+ // Path params arrive URL-encoded — decode so a stored `@handle` /
493
+ // email-as-externalId AND a custom kind with `;`, `@`, `+` can actually
494
+ // be unlinked from the UI.
495
+ const kind = decodeURIComponent(parsed.params.kind);
496
+ const externalId = decodeURIComponent(parsed.params.externalId);
497
+ unlinkIdentity(parsed.params.id, kind, externalId, actor);
498
+ json(res, { identities: getUserIdentities(parsed.params.id) });
499
+ } catch (err) {
500
+ jsonError(res, err instanceof Error ? err.message : "Failed to unlink identity", 500);
501
+ }
502
+ return true;
503
+ }
504
+
505
+ // ─── POST /api/users/:id/merge ─────────────────────────────────────────────
506
+ if (mergeUsersRoute.match(req.method, pathSegments)) {
507
+ const parsed = await mergeUsersRoute.parse(req, res, pathSegments, queryParams);
508
+ if (!parsed) return true;
509
+ const actor = getOperatorActor(req, res);
510
+ if (!actor) return true;
511
+
512
+ const targetId = parsed.params.id;
513
+ const sourceId = parsed.body.sourceUserId;
514
+ if (targetId === sourceId) {
515
+ jsonError(res, "Cannot merge a user into itself", 400);
516
+ return true;
517
+ }
518
+ const targetBefore = composeUser(targetId);
519
+ const sourceBefore = composeUser(sourceId);
520
+ if (!targetBefore) {
521
+ jsonError(res, "Target user not found", 404);
522
+ return true;
523
+ }
524
+ if (!sourceBefore) {
525
+ jsonError(res, "Source user not found", 404);
526
+ return true;
527
+ }
528
+
529
+ try {
530
+ // Move every identity from source → target.
531
+ for (const ident of sourceBefore.identities) {
532
+ unlinkIdentity(sourceId, ident.kind, ident.externalId, actor);
533
+ linkIdentity(targetId, ident.kind, ident.externalId, actor);
534
+ }
535
+
536
+ // Merge email aliases — append source.email + source.emailAliases into
537
+ // target.emailAliases (de-duped). Emit `email_added` per added alias.
538
+ const targetAliases = new Set(targetBefore.emailAliases ?? []);
539
+ const targetPrimary = (targetBefore.email ?? "").toLowerCase();
540
+ const newAliases: string[] = [];
541
+ const candidates = [
542
+ ...(sourceBefore.email ? [sourceBefore.email] : []),
543
+ ...(sourceBefore.emailAliases ?? []),
544
+ ];
545
+ for (const candidate of candidates) {
546
+ const lower = candidate.toLowerCase();
547
+ if (!lower || lower === targetPrimary) continue;
548
+ if (
549
+ ![...targetAliases].some((a) => a.toLowerCase() === lower) &&
550
+ !newAliases.some((a) => a.toLowerCase() === lower)
551
+ ) {
552
+ newAliases.push(candidate);
553
+ }
554
+ }
555
+ if (newAliases.length > 0) {
556
+ const merged = [...(targetBefore.emailAliases ?? []), ...newAliases];
557
+ updateUser(targetId, { emailAliases: merged });
558
+ for (const alias of newAliases) {
559
+ recordIdentityEvent(targetId, "email_added", actor, null, { email: alias });
560
+ }
561
+ }
562
+
563
+ // Delete source — CASCADE cleans up any leftover external_ids row that
564
+ // we may have missed (and clears tasks.requestedByUserId pointers).
565
+ deleteUser(sourceId);
566
+
567
+ // Single manual_merge event on target capturing the before/after rows.
568
+ // The source row is deleted above, so carry a minimal snapshot of the
569
+ // source user ({id, name, email}) inside the `after` payload under
570
+ // `source` — this lets the UI render "Merged manually from X → Y".
571
+ const targetAfter = composeUser(targetId);
572
+ recordIdentityEvent(targetId, "manual_merge", actor, targetBefore, {
573
+ ...targetAfter,
574
+ source: {
575
+ id: sourceBefore.id,
576
+ name: sourceBefore.name,
577
+ email: sourceBefore.email,
578
+ },
579
+ });
580
+
581
+ // Re-compose AFTER the event so the response surfaces the merge event in
582
+ // recentEvents (otherwise the timeline is missing the event we just wrote).
583
+ json(res, { user: composeUser(targetId) });
584
+ } catch (err) {
585
+ jsonError(res, err instanceof Error ? err.message : "Failed to merge users", 500);
586
+ }
587
+ return true;
588
+ }
589
+
590
+ // ─── GET /api/users/:id ────────────────────────────────────────────────────
591
+ if (getUserRoute.match(req.method, pathSegments)) {
592
+ const parsed = await getUserRoute.parse(req, res, pathSegments, queryParams);
593
+ if (!parsed) return true;
594
+ const composed = composeUser(parsed.params.id, parsed.query.recentEvents ?? 50);
595
+ if (!composed) {
596
+ jsonError(res, "User not found", 404);
597
+ return true;
598
+ }
599
+ json(res, { user: composed });
600
+ return true;
601
+ }
602
+
603
+ // ─── PATCH /api/users/:id ──────────────────────────────────────────────────
110
604
  if (updateUserRoute.match(req.method, pathSegments)) {
111
605
  const parsed = await updateUserRoute.parse(req, res, pathSegments, queryParams);
112
606
  if (!parsed) return true;
607
+ const actor = getOperatorActor(req, res);
608
+ if (!actor) return true;
113
609
 
114
- // 404 if user not found before update — keeps the contract honest.
115
- if (!getUserById(parsed.params.id)) {
610
+ const before = getUserById(parsed.params.id);
611
+ if (!before) {
116
612
  jsonError(res, "User not found", 404);
117
613
  return true;
118
614
  }
119
615
 
120
616
  try {
121
- const user = updateUser(parsed.params.id, parsed.body);
122
- if (!user) {
617
+ const { identities, metadata, ...rest } = parsed.body;
618
+ // updateUser doesn't accept `metadata: null` directly — passthrough via cast.
619
+ const update: Parameters<typeof updateUser>[1] = { ...rest };
620
+ if (metadata !== undefined) {
621
+ update.metadata = metadata as Record<string, unknown> | null;
622
+ }
623
+ const updated = updateUser(parsed.params.id, update);
624
+ if (!updated) {
123
625
  jsonError(res, "User not found", 404);
124
626
  return true;
125
627
  }
126
- json(res, { user });
628
+
629
+ // Budget event
630
+ if (
631
+ parsed.body.dailyBudgetUsd !== undefined &&
632
+ (before.dailyBudgetUsd ?? null) !== (parsed.body.dailyBudgetUsd ?? null)
633
+ ) {
634
+ recordIdentityEvent(
635
+ parsed.params.id,
636
+ "budget_changed",
637
+ actor,
638
+ { dailyBudgetUsd: before.dailyBudgetUsd ?? null },
639
+ { dailyBudgetUsd: parsed.body.dailyBudgetUsd ?? null },
640
+ );
641
+ }
642
+
643
+ // Status event
644
+ if (parsed.body.status !== undefined && before.status !== parsed.body.status) {
645
+ recordIdentityEvent(
646
+ parsed.params.id,
647
+ "status_changed",
648
+ actor,
649
+ { status: before.status },
650
+ { status: parsed.body.status },
651
+ );
652
+ }
653
+
654
+ // Email aliases diff — emit email_added / email_removed per Q19
655
+ if (parsed.body.emailAliases !== undefined) {
656
+ const beforeSet = new Set((before.emailAliases ?? []).map((a) => a.toLowerCase()));
657
+ const afterSet = new Set(parsed.body.emailAliases.map((a) => a.toLowerCase()));
658
+ for (const a of parsed.body.emailAliases) {
659
+ if (!beforeSet.has(a.toLowerCase())) {
660
+ recordIdentityEvent(parsed.params.id, "email_added", actor, null, { email: a });
661
+ }
662
+ }
663
+ for (const a of before.emailAliases ?? []) {
664
+ if (!afterSet.has(a.toLowerCase())) {
665
+ recordIdentityEvent(parsed.params.id, "email_removed", actor, { email: a }, null);
666
+ }
667
+ }
668
+ }
669
+
670
+ // Profile-field diffs — emit one `profile_changed` event per field that
671
+ // changed value. Status / budget / aliases / identities already emit
672
+ // their own dedicated events above; skip them here to avoid double-emit.
673
+ const PROFILE_FIELDS = [
674
+ "name",
675
+ "email",
676
+ "role",
677
+ "timezone",
678
+ "preferredChannel",
679
+ "notes",
680
+ "metadata",
681
+ ] as const;
682
+ for (const field of PROFILE_FIELDS) {
683
+ if (parsed.body[field] === undefined) continue;
684
+ const beforeVal = (before as unknown as Record<string, unknown>)[field] ?? null;
685
+ const afterVal = parsed.body[field] ?? null;
686
+ // Cheap deep-equal via JSON — fields are scalar strings or object/null.
687
+ if (JSON.stringify(beforeVal) === JSON.stringify(afterVal)) continue;
688
+ recordIdentityEvent(
689
+ parsed.params.id,
690
+ "profile_changed",
691
+ actor,
692
+ { [field]: beforeVal },
693
+ { [field]: afterVal },
694
+ );
695
+ }
696
+
697
+ // Identities diff — complete-list semantics. linkIdentity / unlinkIdentity
698
+ // already emit the right event each.
699
+ if (identities !== undefined) {
700
+ const beforeIds = getUserIdentities(parsed.params.id);
701
+ const beforeKeys = new Set(beforeIds.map((i) => `${i.kind}:${i.externalId}`));
702
+ const afterKeys = new Set(identities.map((i) => `${i.kind}:${i.externalId}`));
703
+ for (const i of identities) {
704
+ if (!beforeKeys.has(`${i.kind}:${i.externalId}`)) {
705
+ linkIdentity(parsed.params.id, i.kind, i.externalId, actor);
706
+ }
707
+ }
708
+ for (const i of beforeIds) {
709
+ if (!afterKeys.has(`${i.kind}:${i.externalId}`)) {
710
+ unlinkIdentity(parsed.params.id, i.kind, i.externalId, actor);
711
+ }
712
+ }
713
+ }
714
+
715
+ const composed = composeUser(parsed.params.id);
716
+ json(res, { user: composed });
127
717
  } catch (err) {
128
718
  jsonError(res, err instanceof Error ? err.message : "Failed to update user", 500);
129
719
  }