@naisys/erp 3.0.2 → 3.0.3

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 (32) hide show
  1. package/README.md +60 -0
  2. package/client-dist/assets/{index-D5R6NBeW.js → index-CiuQ3CYv.js} +192 -87
  3. package/client-dist/index.html +1 -1
  4. package/dist/database/dbConfig.js +1 -1
  5. package/dist/erpServer.js +2 -3
  6. package/dist/generated/prisma/internal/class.js +4 -4
  7. package/dist/generated/prisma/internal/prismaNamespace.js +1 -0
  8. package/dist/middleware/auth-middleware.js +39 -7
  9. package/dist/route-helpers.js +4 -2
  10. package/dist/routes/operations/operation-dependencies.js +1 -0
  11. package/dist/routes/operations/operation-field-refs.js +1 -0
  12. package/dist/routes/operations/operation-run-comments.js +1 -0
  13. package/dist/routes/operations/operation-runs.js +3 -0
  14. package/dist/routes/production/dispatch.js +15 -9
  15. package/dist/routes/production/labor-tickets.js +1 -0
  16. package/dist/routes/production/work-centers.js +2 -0
  17. package/dist/routes/users/auth.js +4 -1
  18. package/dist/routes/users/users.js +28 -16
  19. package/dist/services/operations/operation-dependency-service.js +1 -1
  20. package/dist/services/operations/operation-run-comment-service.js +1 -1
  21. package/dist/services/operations/operation-run-service.js +6 -6
  22. package/dist/services/operations/step-run-service.js +4 -4
  23. package/dist/services/orders/order-run-service.js +2 -2
  24. package/dist/services/production/field-ref-service.js +1 -1
  25. package/dist/services/production/labor-ticket-service.js +3 -3
  26. package/dist/services/production/work-center-service.js +2 -2
  27. package/dist/services/user-service.js +58 -9
  28. package/npm-shrinkwrap.json +28 -28
  29. package/package.json +11 -6
  30. package/prisma/migrations/20260528000000_add_user_title/migration.sql +1 -0
  31. package/prisma/schema.prisma +1 -0
  32. package/dist/services/production/labor-ticket-backfill.js +0 -67
@@ -282,6 +282,7 @@ export const UserScalarFieldEnum = {
282
282
  id: 'id',
283
283
  uuid: 'uuid',
284
284
  username: 'username',
285
+ title: 'title',
285
286
  passwordHash: 'passwordHash',
286
287
  apiKeyHash: 'apiKeyHash',
287
288
  isAgent: 'isAgent',
@@ -1,6 +1,6 @@
1
1
  import { AuthCache, urlMatchesPrefix } from "@naisys/common";
2
2
  import { extractBearerToken, hashToken, SESSION_COOKIE_NAME, } from "@naisys/common-node";
3
- import { findAgentByApiKey } from "@naisys/hub-database";
3
+ import { findAgentByApiKey, findHubUserTitleByUuid } from "@naisys/hub-database";
4
4
  import { findSession, findUserByApiKey } from "@naisys/supervisor-database";
5
5
  import erpDb from "../database/erpDb.js";
6
6
  import { isSupervisorAuth } from "./supervisorAuth.js";
@@ -17,6 +17,7 @@ async function materializeErpUser(localUser) {
17
17
  return {
18
18
  id: localUser.id,
19
19
  username: localUser.username,
20
+ title: localUser.title,
20
21
  permissions: await loadPermissions(localUser.id),
21
22
  };
22
23
  }
@@ -33,11 +34,26 @@ async function loadCookieUserSso(tokenHash) {
33
34
  const session = await findSession(tokenHash);
34
35
  if (!session)
35
36
  return null;
36
- return erpDb.user.upsert({
37
+ const existing = await erpDb.user.findUnique({
37
38
  where: { uuid: session.uuid },
38
- create: { uuid: session.uuid, username: session.username },
39
- update: {},
40
39
  });
40
+ if (!existing) {
41
+ const title = (await findHubUserTitleByUuid(session.uuid)) ?? "";
42
+ return erpDb.user.create({
43
+ data: { uuid: session.uuid, username: session.username, title },
44
+ });
45
+ }
46
+ // Agents mirror hub title; non-agents own their title locally.
47
+ if (existing.isAgent) {
48
+ const title = (await findHubUserTitleByUuid(session.uuid)) ?? "";
49
+ if (title !== existing.title) {
50
+ return erpDb.user.update({
51
+ where: { id: existing.id },
52
+ data: { title },
53
+ });
54
+ }
55
+ }
56
+ return existing;
41
57
  }
42
58
  async function loadCookieUserStandalone(tokenHash) {
43
59
  const session = await erpDb.session.findUnique({
@@ -64,11 +80,27 @@ async function loadApiKeyUserSso(apiKey) {
64
80
  if (!match)
65
81
  return null;
66
82
  const isAgent = supervisorUser?.isAgent ?? !!hubAgent;
67
- return erpDb.user.upsert({
83
+ const existing = await erpDb.user.findUnique({
68
84
  where: { uuid: match.uuid },
69
- create: { uuid: match.uuid, username: match.username, isAgent },
70
- update: {},
71
85
  });
86
+ const fetchTitle = async () => hubAgent?.title ?? (await findHubUserTitleByUuid(match.uuid)) ?? "";
87
+ if (!existing) {
88
+ const title = await fetchTitle();
89
+ return erpDb.user.create({
90
+ data: { uuid: match.uuid, username: match.username, isAgent, title },
91
+ });
92
+ }
93
+ // Agents mirror hub title; non-agents own their title locally.
94
+ if (existing.isAgent) {
95
+ const title = await fetchTitle();
96
+ if (title !== existing.title) {
97
+ return erpDb.user.update({
98
+ where: { id: existing.id },
99
+ data: { title },
100
+ });
101
+ }
102
+ }
103
+ return existing;
72
104
  }
73
105
  export function hasPermission(user, permission) {
74
106
  if (!user)
@@ -37,16 +37,18 @@ export function mutationResult(request, reply, full, slim) {
37
37
  }
38
38
  // --- Shared Prisma include for audit user fields ---
39
39
  export const includeUsers = {
40
- createdBy: { select: { username: true } },
41
- updatedBy: { select: { username: true } },
40
+ createdBy: { select: { username: true, title: true } },
41
+ updatedBy: { select: { username: true, title: true } },
42
42
  };
43
43
  // --- Formatting helpers ---
44
44
  export function formatAuditFields(item) {
45
45
  return {
46
46
  createdAt: item.createdAt.toISOString(),
47
47
  createdBy: item.createdBy.username,
48
+ createdByTitle: item.createdBy.title,
48
49
  updatedAt: item.updatedAt.toISOString(),
49
50
  updatedBy: item.updatedBy.username,
51
+ updatedByTitle: item.updatedBy.title,
50
52
  };
51
53
  }
52
54
  export function formatDate(d) {
@@ -27,6 +27,7 @@ function formatDependency(dep) {
27
27
  predecessorTitle: dep.predecessor.title,
28
28
  createdAt: dep.createdAt.toISOString(),
29
29
  createdBy: dep.createdBy.username,
30
+ createdByTitle: dep.createdBy.title,
30
31
  };
31
32
  }
32
33
  function depActionTemplates(basePath, revStatus, user) {
@@ -37,6 +37,7 @@ function formatFieldRef(orderKey, revNo, seqNo, revStatus, user, ref) {
37
37
  })),
38
38
  createdAt: ref.createdAt.toISOString(),
39
39
  createdBy: ref.createdBy.username,
40
+ createdByTitle: ref.createdBy.title,
40
41
  _links: childItemLinks(base, ref.seqNo, "Field Refs", `/orders/${orderKey}/revs/${revNo}/ops/${seqNo}`, "Operation", "FieldRef"),
41
42
  _actions: deleteAction(`${API_PREFIX}${base}/${ref.seqNo}`, revStatus, user),
42
43
  };
@@ -27,6 +27,7 @@ function formatComment(orderKey, runNo, seqNo, comment) {
27
27
  body: comment.body,
28
28
  createdAt: comment.createdAt.toISOString(),
29
29
  createdBy: comment.createdBy.username,
30
+ createdByTitle: comment.createdBy.title,
30
31
  _links: [
31
32
  selfLink(`/${commentResource(orderKey, runNo, seqNo)}/${comment.id}`),
32
33
  ],
@@ -156,6 +156,7 @@ export async function formatOpRun(orderKey, runNo, user, opRun) {
156
156
  workCenterKey: opRun.operation.workCenter?.key ?? null,
157
157
  status: opRun.status,
158
158
  assignedTo: opRun.assignedTo?.username ?? null,
159
+ assignedToTitle: opRun.assignedTo?.title ?? null,
159
160
  cost: opRun.cost,
160
161
  tokens: opRun.tokens,
161
162
  note: opRun.statusNote ?? null,
@@ -203,6 +204,7 @@ export async function formatOpRunTransition(orderKey, runNo, user, opRun) {
203
204
  id: opRun.id,
204
205
  status: opRun.status,
205
206
  assignedTo: opRun.assignedTo?.username ?? null,
207
+ assignedToTitle: opRun.assignedTo?.title ?? null,
206
208
  cost: opRun.cost,
207
209
  tokens: opRun.tokens,
208
210
  note: opRun.statusNote ?? null,
@@ -225,6 +227,7 @@ function formatListOpRun(opRun) {
225
227
  workCenterKey: opRun.operation.workCenter?.key ?? null,
226
228
  status: opRun.status,
227
229
  assignedTo: opRun.assignedTo?.username ?? null,
230
+ assignedToTitle: opRun.assignedTo?.title ?? null,
228
231
  cost: opRun.cost,
229
232
  tokens: opRun.tokens,
230
233
  note: opRun.statusNote ?? null,
@@ -20,7 +20,7 @@ export default function dispatchRoutes(fastify) {
20
20
  },
21
21
  },
22
22
  handler: async (request) => {
23
- const { page, pageSize, status, priority, search, viewAs, canWork, clockedIn, } = request.query;
23
+ const { page, pageSize, status, workCenter, search, viewAs, canWork, clockedIn, } = request.query;
24
24
  // Resolve the perspective user for canWork computation
25
25
  let perspectiveUserId = request.erpUser?.id;
26
26
  let perspectivePerms = new Set(request.erpUser?.permissions ?? []);
@@ -48,9 +48,9 @@ export default function dispatchRoutes(fastify) {
48
48
  status: { in: status ? [status] : DEFAULT_OP_STATUSES },
49
49
  orderRun: {
50
50
  status: { in: OPEN_ORDER_STATUSES },
51
- ...(priority ? { priority } : {}),
52
51
  },
53
52
  };
53
+ const operationFilters = {};
54
54
  // canWork filter: only show ops where the perspective user can work
55
55
  if (canWork) {
56
56
  // Restrict to statuses the user has permission for
@@ -67,11 +67,18 @@ export default function dispatchRoutes(fastify) {
67
67
  where.status = { in: filteredStatuses };
68
68
  // Work center access
69
69
  if (userWcIds.length > 0) {
70
- where.operation = {
71
- OR: [{ workCenterId: null }, { workCenterId: { in: userWcIds } }],
72
- };
70
+ operationFilters.OR = [
71
+ { workCenterId: null },
72
+ { workCenterId: { in: userWcIds } },
73
+ ];
73
74
  }
74
75
  }
76
+ if (workCenter) {
77
+ operationFilters.workCenter = { key: workCenter };
78
+ }
79
+ if (Object.keys(operationFilters).length > 0) {
80
+ where.operation = operationFilters;
81
+ }
75
82
  if (search) {
76
83
  where.OR = [
77
84
  { operation: { title: { contains: search } } },
@@ -93,11 +100,10 @@ export default function dispatchRoutes(fastify) {
93
100
  workCenter: { select: { key: true, id: true } },
94
101
  },
95
102
  },
96
- assignedTo: { select: { username: true } },
103
+ assignedTo: { select: { username: true, title: true } },
97
104
  orderRun: {
98
105
  select: {
99
106
  runNo: true,
100
- priority: true,
101
107
  dueAt: true,
102
108
  order: { select: { key: true } },
103
109
  orderRev: { select: { revNo: true } },
@@ -127,8 +133,8 @@ export default function dispatchRoutes(fastify) {
127
133
  workCenterKey: opRun.operation.workCenter?.key ?? null,
128
134
  canWork: itemCanWork,
129
135
  status: opRun.status,
130
- priority: opRun.orderRun.priority,
131
136
  assignedTo: opRun.assignedTo?.username ?? null,
137
+ assignedToTitle: opRun.assignedTo?.title ?? null,
132
138
  dueAt: opRun.orderRun.dueAt,
133
139
  createdAt: opRun.createdAt.toISOString(),
134
140
  };
@@ -139,7 +145,7 @@ export default function dispatchRoutes(fastify) {
139
145
  _links: [
140
146
  ...paginationLinks("dispatch", page, pageSize, total, {
141
147
  status,
142
- priority,
148
+ workCenter,
143
149
  search,
144
150
  viewAs,
145
151
  canWork: canWork ? "true" : undefined,
@@ -88,6 +88,7 @@ function formatLaborTicket(orderKey, runNo, seqNo, ticket) {
88
88
  operationRunId: ticket.operationRunId,
89
89
  userId: ticket.userId,
90
90
  username: ticket.user.username,
91
+ userTitle: ticket.user.title,
91
92
  runId: ticket.runId,
92
93
  clockIn: ticket.clockIn.toISOString(),
93
94
  clockOut: formatDate(ticket.clockOut),
@@ -58,8 +58,10 @@ function formatWorkCenter(wc, user) {
58
58
  userAssignments: wc.userAssignments.map((a) => ({
59
59
  userId: a.user.id,
60
60
  username: a.user.username,
61
+ userTitle: a.user.title,
61
62
  createdAt: a.createdAt.toISOString(),
62
63
  createdBy: a.createdBy?.username ?? null,
64
+ createdByTitle: a.createdBy?.title ?? null,
63
65
  _actions: [
64
66
  {
65
67
  rel: "remove",
@@ -52,7 +52,9 @@ export default function authRoutes(fastify) {
52
52
  data: { userId: user.id, tokenHash, expiresAt },
53
53
  });
54
54
  reply.setCookie(SESSION_COOKIE_NAME, token, sessionCookieOptions(expiresAt));
55
- return { user: { id: user.id, username: user.username } };
55
+ return {
56
+ user: { id: user.id, username: user.username, title: user.title },
57
+ };
56
58
  },
57
59
  });
58
60
  // LOGOUT
@@ -92,6 +94,7 @@ export default function authRoutes(fastify) {
92
94
  return {
93
95
  id: request.erpUser.id,
94
96
  username: request.erpUser.username,
97
+ title: request.erpUser.title,
95
98
  permissions: request.erpUser.permissions,
96
99
  };
97
100
  },
@@ -13,9 +13,15 @@ function userItemLinks(username) {
13
13
  schemaLink("UpdateUser"),
14
14
  ];
15
15
  }
16
- function userActions(username, isSelf, isAdmin) {
16
+ function userActions(username, isAgent, isSelf, isAdmin) {
17
17
  const href = `${API_PREFIX}/users/${username}`;
18
18
  const actions = [];
19
+ // In SSO mode the supervisor owns passwords (passkey-only) and external API
20
+ // keys, so ERP doesn't expose its own change-password / rotate-key actions.
21
+ const sso = isSupervisorAuth();
22
+ // Title is editable when the user isn't an SSO-managed agent (whose title
23
+ // is mirrored from the hub on every auth).
24
+ const titleEditable = !isAgent || !sso;
19
25
  if (isAdmin) {
20
26
  actions.push({
21
27
  rel: "update",
@@ -23,12 +29,9 @@ function userActions(username, isSelf, isAdmin) {
23
29
  method: "PUT",
24
30
  title: "Update",
25
31
  schema: `${API_PREFIX}/schemas/UpdateUser`,
26
- body: { username: "" },
32
+ body: { username: "", ...(titleEditable ? { title: "" } : {}) },
27
33
  });
28
34
  }
29
- // In SSO mode the supervisor owns passwords (passkey-only) and external API
30
- // keys, so ERP doesn't expose its own change-password / rotate-key actions.
31
- const sso = isSupervisorAuth();
32
35
  if (isSelf && !sso) {
33
36
  actions.push({
34
37
  rel: "change-password",
@@ -90,6 +93,7 @@ export function formatUser(user, currentUserId, currentUserPermissions, options)
90
93
  return {
91
94
  id: user.id,
92
95
  username: user.username,
96
+ title: user.title,
93
97
  isAgent: user.isAgent,
94
98
  createdAt: user.createdAt.toISOString(),
95
99
  updatedAt: user.updatedAt.toISOString(),
@@ -101,13 +105,14 @@ export function formatUser(user, currentUserId, currentUserPermissions, options)
101
105
  _actions: permissionActions(user.username, p.permission, isSelf, isAdmin),
102
106
  })),
103
107
  _links: userItemLinks(user.username),
104
- _actions: userActions(user.username, isSelf, isAdmin),
108
+ _actions: userActions(user.username, user.isAgent, isSelf, isAdmin),
105
109
  };
106
110
  }
107
111
  function formatListUser(user) {
108
112
  return {
109
113
  id: user.id,
110
114
  username: user.username,
115
+ title: user.title,
111
116
  isAgent: user.isAgent,
112
117
  createdAt: user.createdAt.toISOString(),
113
118
  permissionCount: user.permissions.length,
@@ -293,7 +298,7 @@ export default function userRoutes(fastify) {
293
298
  };
294
299
  }
295
300
  try {
296
- const user = await createUserForAgent(hubAgent.username, hubAgent.uuid);
301
+ const user = await createUserForAgent(hubAgent.username, hubAgent.uuid, hubAgent.title);
297
302
  const full = formatUser(user, request.erpUser.id, request.erpUser.permissions);
298
303
  reply.code(201);
299
304
  return mutationResult(request, reply, full, {
@@ -350,16 +355,23 @@ export default function userRoutes(fastify) {
350
355
  return { success: false, message: "User not found" };
351
356
  }
352
357
  const isAdmin = hasPermission(request.erpUser, "erp_admin");
353
- // In SSO mode the supervisor owns passwords, so we strip any password
354
- // field before forwarding the update — even from admins.
355
358
  const sso = isSupervisorAuth();
356
- const body = isAdmin
357
- ? sso
358
- ? { username: request.body.username }
359
- : request.body
360
- : sso
361
- ? {}
362
- : { password: request.body.password };
359
+ // Field-level gating:
360
+ // - username: admin-only.
361
+ // - password: non-SSO only (supervisor owns passwords in SSO mode).
362
+ // - title: editable unless the target is an SSO-managed agent (whose
363
+ // title is mirrored from the hub on every auth).
364
+ const titleEditable = !targetUser.isAgent || !sso;
365
+ const body = {};
366
+ if (isAdmin && request.body.username !== undefined) {
367
+ body.username = request.body.username;
368
+ }
369
+ if (!sso && request.body.password !== undefined) {
370
+ body.password = request.body.password;
371
+ }
372
+ if (titleEditable && request.body.title !== undefined) {
373
+ body.title = request.body.title;
374
+ }
363
375
  try {
364
376
  const user = await updateUser(targetUser.id, body);
365
377
  authCache.clear();
@@ -1,7 +1,7 @@
1
1
  import erpDb from "../../database/erpDb.js";
2
2
  const depInclude = {
3
3
  predecessor: { select: { seqNo: true, title: true } },
4
- createdBy: { select: { username: true } },
4
+ createdBy: { select: { username: true, title: true } },
5
5
  };
6
6
  export async function listDependencies(operationId) {
7
7
  return erpDb.operationDependency.findMany({
@@ -1,7 +1,7 @@
1
1
  import erpDb from "../../database/erpDb.js";
2
2
  // --- Prisma include & result type ---
3
3
  export const includeComment = {
4
- createdBy: { select: { username: true } },
4
+ createdBy: { select: { username: true, title: true } },
5
5
  };
6
6
  // --- Lookups ---
7
7
  export async function listComments(operationRunId) {
@@ -20,9 +20,9 @@ export const includeOp = {
20
20
  orderRev: { select: { revNo: true, description: true } },
21
21
  },
22
22
  },
23
- assignedTo: { select: { username: true } },
24
- createdBy: { select: { username: true } },
25
- updatedBy: { select: { username: true } },
23
+ assignedTo: { select: { username: true, title: true } },
24
+ createdBy: { select: { username: true, title: true } },
25
+ updatedBy: { select: { username: true, title: true } },
26
26
  };
27
27
  // --- Lookups ---
28
28
  export async function listOpRuns(runId) {
@@ -47,9 +47,9 @@ export async function listOpRuns(runId) {
47
47
  },
48
48
  },
49
49
  _count: { select: { stepRuns: true } },
50
- assignedTo: { select: { username: true } },
51
- createdBy: { select: { username: true } },
52
- updatedBy: { select: { username: true } },
50
+ assignedTo: { select: { username: true, title: true } },
51
+ createdBy: { select: { username: true, title: true } },
52
+ updatedBy: { select: { username: true, title: true } },
53
53
  },
54
54
  orderBy: { operation: { seqNo: "asc" } },
55
55
  });
@@ -44,8 +44,8 @@ export const includeStepRunWithFields = {
44
44
  },
45
45
  },
46
46
  },
47
- createdBy: { select: { username: true } },
48
- updatedBy: { select: { username: true } },
47
+ createdBy: { select: { username: true, title: true } },
48
+ updatedBy: { select: { username: true, title: true } },
49
49
  };
50
50
  // --- Lightweight include (step metadata only, no field values) ---
51
51
  export const includeStepRun = {
@@ -62,8 +62,8 @@ export const includeStepRun = {
62
62
  },
63
63
  },
64
64
  },
65
- createdBy: { select: { username: true } },
66
- updatedBy: { select: { username: true } },
65
+ createdBy: { select: { username: true, title: true } },
66
+ updatedBy: { select: { username: true, title: true } },
67
67
  };
68
68
  // --- Lookups ---
69
69
  export async function listStepRuns(opRunId) {
@@ -8,8 +8,8 @@ export const includeRev = {
8
8
  orderRev: { select: { revNo: true, description: true } },
9
9
  order: { select: { item: { select: { key: true } } } },
10
10
  itemInstances: { select: { id: true, key: true }, take: 1 },
11
- createdBy: { select: { username: true } },
12
- updatedBy: { select: { username: true } },
11
+ createdBy: { select: { username: true, title: true } },
12
+ updatedBy: { select: { username: true, title: true } },
13
13
  };
14
14
  // --- Lookups ---
15
15
  export async function listOrderRuns(where, page, pageSize) {
@@ -15,7 +15,7 @@ const includeFieldRef = {
15
15
  },
16
16
  },
17
17
  },
18
- createdBy: { select: { username: true } },
18
+ createdBy: { select: { username: true, title: true } },
19
19
  };
20
20
  export async function listFieldRefs(operationId) {
21
21
  return erpDb.operationFieldRef.findMany({
@@ -3,9 +3,9 @@ import erpDb from "../../database/erpDb.js";
3
3
  import { writeAuditEntry } from "../audit.js";
4
4
  // --- Prisma include & result type ---
5
5
  export const includeLaborTicket = {
6
- user: { select: { username: true } },
7
- createdBy: { select: { username: true } },
8
- updatedBy: { select: { username: true } },
6
+ user: { select: { username: true, title: true } },
7
+ createdBy: { select: { username: true, title: true } },
8
+ updatedBy: { select: { username: true, title: true } },
9
9
  };
10
10
  // --- Helpers ---
11
11
  /**
@@ -5,8 +5,8 @@ const includeDetail = {
5
5
  ...includeUsers,
6
6
  userAssignments: {
7
7
  include: {
8
- user: { select: { id: true, username: true } },
9
- createdBy: { select: { username: true } },
8
+ user: { select: { id: true, username: true, title: true } },
9
+ createdBy: { select: { username: true, title: true } },
10
10
  },
11
11
  orderBy: { user: { username: "asc" } },
12
12
  },
@@ -1,5 +1,6 @@
1
1
  import { SUPER_ADMIN_USERNAME } from "@naisys/common";
2
2
  import { generatePersistentUserApiKey } from "@naisys/common-node";
3
+ import { findHubUserTitleByUuid, findHubUserTitlesByUuids, } from "@naisys/hub-database";
3
4
  import { ensureSuperAdmin } from "@naisys/supervisor-database";
4
5
  import bcrypt from "bcryptjs";
5
6
  import { randomUUID } from "crypto";
@@ -52,11 +53,12 @@ export async function getUserByUuid(uuid) {
52
53
  include: includePermissions,
53
54
  });
54
55
  }
55
- export async function createUserForAgent(username, uuid) {
56
+ export async function createUserForAgent(username, uuid, title) {
56
57
  return erpDb.user.create({
57
58
  data: {
58
59
  username,
59
60
  uuid,
61
+ title,
60
62
  isAgent: true,
61
63
  },
62
64
  include: includePermissions,
@@ -80,6 +82,9 @@ export async function updateUser(id, data) {
80
82
  if (data.username !== undefined) {
81
83
  updateData.username = data.username;
82
84
  }
85
+ if (data.title !== undefined) {
86
+ updateData.title = data.title;
87
+ }
83
88
  if (data.password !== undefined) {
84
89
  updateData.passwordHash = await bcrypt.hash(data.password, SALT_ROUNDS);
85
90
  }
@@ -165,16 +170,29 @@ export async function ensureLocalSuperAdmin(password) {
165
170
  */
166
171
  export async function ensureSupervisorSuperAdmin() {
167
172
  const result = await ensureSuperAdmin();
168
- await erpDb.user.upsert({
173
+ // Super admin is a non-agent — seed title from hub on first bootstrap,
174
+ // but leave it alone afterwards so local edits stick.
175
+ const existing = await erpDb.user.findUnique({
169
176
  where: { uuid: result.user.uuid },
170
- create: {
171
- uuid: result.user.uuid,
172
- username: result.user.username,
173
- },
174
- update: {
175
- username: result.user.username,
176
- },
177
177
  });
178
+ if (existing) {
179
+ if (existing.username !== result.user.username) {
180
+ await erpDb.user.update({
181
+ where: { id: existing.id },
182
+ data: { username: result.user.username },
183
+ });
184
+ }
185
+ }
186
+ else {
187
+ const title = (await findHubUserTitleByUuid(result.user.uuid)) ?? "";
188
+ await erpDb.user.create({
189
+ data: {
190
+ uuid: result.user.uuid,
191
+ username: result.user.username,
192
+ title,
193
+ },
194
+ });
195
+ }
178
196
  const localSuperAdmin = await erpDb.user.findUnique({
179
197
  where: { uuid: result.user.uuid },
180
198
  });
@@ -182,6 +200,37 @@ export async function ensureSupervisorSuperAdmin() {
182
200
  await ensureErpAdminPermission(localSuperAdmin.id);
183
201
  }
184
202
  }
203
+ /**
204
+ * Pull the latest title from the hub for every agent user in ERP and update
205
+ * any drift. Non-agents are skipped — they own their title locally. Runs at
206
+ * ERP startup (SSO mode) so agent titles stay fresh even if the agent hasn't
207
+ * authenticated recently to trigger the per-request refresh in auth-middleware.
208
+ */
209
+ export async function syncAgentTitlesFromHub() {
210
+ const agents = await erpDb.user.findMany({
211
+ where: { isAgent: true },
212
+ select: { id: true, uuid: true, title: true },
213
+ });
214
+ if (agents.length === 0)
215
+ return;
216
+ const hubTitles = await findHubUserTitlesByUuids(agents.map((a) => a.uuid));
217
+ let updated = 0;
218
+ for (const agent of agents) {
219
+ const hubTitle = hubTitles.get(agent.uuid);
220
+ if (hubTitle === undefined)
221
+ continue; // not in hub, leave as-is
222
+ if (hubTitle === agent.title)
223
+ continue;
224
+ await erpDb.user.update({
225
+ where: { id: agent.id },
226
+ data: { title: hubTitle },
227
+ });
228
+ updated++;
229
+ }
230
+ if (updated > 0) {
231
+ console.log(`[ERP] Synced ${updated} agent title(s) from hub.`);
232
+ }
233
+ }
185
234
  /**
186
235
  * Ensure a user has the erp_admin permission.
187
236
  */