@naisys/erp 3.0.0-beta.9 → 3.0.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 (83) hide show
  1. package/.env.example +5 -0
  2. package/client-dist/assets/index-CSiMTJfw.css +14 -0
  3. package/client-dist/assets/index-D6lSIioV.js +11167 -0
  4. package/client-dist/assets/rolldown-runtime-CvHMtSRF.js +33 -0
  5. package/client-dist/assets/vendor-CJ0ET9hP.js +75181 -0
  6. package/client-dist/assets/vendor-CLUPjUnv.css +8747 -0
  7. package/client-dist/favicon-16x16.png +0 -0
  8. package/client-dist/favicon-32x32.png +0 -0
  9. package/client-dist/favicon.ico +0 -0
  10. package/client-dist/index.html +17 -2
  11. package/dist/{dbConfig.js → database/dbConfig.js} +1 -1
  12. package/dist/{erpDb.js → database/erpDb.js} +1 -1
  13. package/dist/erpRoutes.js +115 -0
  14. package/dist/erpServer.js +85 -161
  15. package/dist/error-handler.js +3 -0
  16. package/dist/generated/prisma/internal/class.js +4 -4
  17. package/dist/generated/prisma/internal/prismaNamespace.js +3 -1
  18. package/dist/middleware/auth-middleware.js +146 -0
  19. package/dist/route-helpers.js +2 -2
  20. package/dist/routes/admin.js +15 -7
  21. package/dist/routes/audit.js +1 -1
  22. package/dist/routes/{item-fields.js → items/item-fields.js} +24 -22
  23. package/dist/routes/{item-instances.js → items/item-instances.js} +42 -24
  24. package/dist/routes/{items.js → items/items.js} +35 -33
  25. package/dist/routes/{operation-dependencies.js → operations/operation-dependencies.js} +6 -6
  26. package/dist/routes/{operation-field-refs.js → operations/operation-field-refs.js} +6 -6
  27. package/dist/routes/{operation-run-comments.js → operations/operation-run-comments.js} +5 -5
  28. package/dist/routes/{operation-run-transitions.js → operations/operation-run-transitions.js} +29 -13
  29. package/dist/routes/{operation-runs.js → operations/operation-runs.js} +48 -10
  30. package/dist/routes/{operations.js → operations/operations.js} +6 -6
  31. package/dist/routes/{order-revision-transitions.js → orders/order-revision-transitions.js} +4 -4
  32. package/dist/routes/{order-revisions.js → orders/order-revisions.js} +6 -6
  33. package/dist/routes/{order-run-transitions.js → orders/order-run-transitions.js} +11 -5
  34. package/dist/routes/{order-runs.js → orders/order-runs.js} +7 -5
  35. package/dist/routes/{orders.js → orders/orders.js} +15 -11
  36. package/dist/routes/{dispatch.js → production/dispatch.js} +88 -7
  37. package/dist/routes/{inventory.js → production/inventory.js} +33 -10
  38. package/dist/routes/{labor-tickets.js → production/labor-tickets.js} +7 -7
  39. package/dist/routes/{work-centers.js → production/work-centers.js} +29 -29
  40. package/dist/routes/root.js +1 -1
  41. package/dist/routes/{step-field-attachments.js → steps/step-field-attachments.js} +8 -8
  42. package/dist/routes/{step-fields.js → steps/step-fields.js} +6 -6
  43. package/dist/routes/{step-run-fields.js → steps/step-run-fields.js} +9 -9
  44. package/dist/routes/{step-run-transitions.js → steps/step-run-transitions.js} +6 -6
  45. package/dist/routes/{step-runs.js → steps/step-runs.js} +7 -7
  46. package/dist/routes/{steps.js → steps/steps.js} +5 -5
  47. package/dist/routes/{auth.js → users/auth.js} +11 -23
  48. package/dist/routes/{user-permissions.js → users/user-permissions.js} +21 -7
  49. package/dist/routes/{users.js → users/users.js} +42 -20
  50. package/dist/services/attachment-service.js +2 -2
  51. package/dist/services/{item-instance-service.js → inventory/item-instance-service.js} +2 -2
  52. package/dist/services/{item-service.js → inventory/item-service.js} +2 -2
  53. package/dist/services/{operation-dependency-service.js → operations/operation-dependency-service.js} +1 -1
  54. package/dist/services/{operation-run-comment-service.js → operations/operation-run-comment-service.js} +1 -1
  55. package/dist/services/{operation-run-service.js → operations/operation-run-service.js} +15 -4
  56. package/dist/services/{operation-service.js → operations/operation-service.js} +2 -2
  57. package/dist/services/{step-run-service.js → operations/step-run-service.js} +1 -1
  58. package/dist/services/{step-service.js → operations/step-service.js} +2 -2
  59. package/dist/services/{order-revision-service.js → orders/order-revision-service.js} +4 -5
  60. package/dist/services/{order-run-service.js → orders/order-run-service.js} +68 -22
  61. package/dist/services/{order-service.js → orders/order-service.js} +11 -2
  62. package/dist/services/{revision-diff-service.js → orders/revision-diff-service.js} +11 -10
  63. package/dist/services/{field-ref-service.js → production/field-ref-service.js} +1 -1
  64. package/dist/services/{field-service.js → production/field-service.js} +2 -2
  65. package/dist/services/{field-value-service.js → production/field-value-service.js} +27 -3
  66. package/dist/services/production/labor-ticket-backfill.js +67 -0
  67. package/dist/services/{labor-ticket-service.js → production/labor-ticket-service.js} +21 -15
  68. package/dist/services/{work-center-service.js → production/work-center-service.js} +2 -2
  69. package/dist/services/user-service.js +94 -28
  70. package/dist/version.js +12 -0
  71. package/npm-shrinkwrap.json +3000 -0
  72. package/package.json +11 -9
  73. package/prisma/migrations/20260427010000_hash_user_api_keys/migration.sql +10 -0
  74. package/prisma/migrations/20260427020000_nullable_user_password_hash/migration.sql +39 -0
  75. package/prisma/migrations/20260517000000_add_op_run_tokens/migration.sql +2 -0
  76. package/prisma/schema.prisma +4 -2
  77. package/client-dist/assets/index-45dVo30p.css +0 -1
  78. package/client-dist/assets/index-C9uuPHLH.js +0 -168
  79. package/dist/auth-middleware.js +0 -203
  80. package/dist/userService.js +0 -118
  81. /package/bin/{naisys-erp → naisys-erp.js} +0 -0
  82. /package/dist/{supervisorAuth.js → middleware/supervisorAuth.js} +0 -0
  83. /package/dist/{audit.js → services/audit.js} +0 -0
@@ -1,7 +1,8 @@
1
+ import { mapDefined } from "@naisys/common";
1
2
  import { RevisionStatus as RevisionStatusValues, } from "@naisys/erp-shared";
3
+ import erpDb from "../../database/erpDb.js";
4
+ import { includeUsers } from "../../route-helpers.js";
2
5
  import { writeAuditEntry } from "../audit.js";
3
- import erpDb from "../erpDb.js";
4
- import { includeUsers } from "../route-helpers.js";
5
6
  // --- Prisma include & result type ---
6
7
  const includeRevisionRelations = {
7
8
  ...includeUsers,
@@ -230,9 +231,7 @@ export async function deleteRevision(id) {
230
231
  where: { operationId: { in: opIds } },
231
232
  select: { id: true, fieldSetId: true },
232
233
  });
233
- const fieldSetIds = steps
234
- .map((s) => s.fieldSetId)
235
- .filter((id) => id !== null);
234
+ const fieldSetIds = mapDefined(steps, (s) => s.fieldSetId);
236
235
  // Steps reference field_sets via FK, so delete steps first
237
236
  await erpTx.step.deleteMany({
238
237
  where: { operationId: { in: opIds } },
@@ -1,9 +1,11 @@
1
+ import { keyBy, mapDefined } from "@naisys/common";
1
2
  import { OperationRunStatus as OperationRunStatusValues, OrderRunStatus as OrderRunStatusValues, } from "@naisys/erp-shared";
3
+ import erpDb from "../../database/erpDb.js";
2
4
  import { writeAuditEntry } from "../audit.js";
3
- import erpDb from "../erpDb.js";
5
+ import { deserializeFieldValue, upsertFieldValue, validateFieldSet, } from "../production/field-value-service.js";
4
6
  // --- Prisma include & result type ---
5
7
  export const includeRev = {
6
- orderRev: { select: { revNo: true } },
8
+ orderRev: { select: { revNo: true, description: true } },
7
9
  order: { select: { item: { select: { key: true } } } },
8
10
  itemInstances: { select: { id: true, key: true }, take: 1 },
9
11
  createdBy: { select: { username: true } },
@@ -187,9 +189,7 @@ export async function deleteOrderRun(id) {
187
189
  where: { operationRunId: { in: opRunIds } },
188
190
  select: { fieldRecordId: true },
189
191
  });
190
- const fieldRecordIds = stepRuns
191
- .map((s) => s.fieldRecordId)
192
- .filter((id) => id !== null);
192
+ const fieldRecordIds = mapDefined(stepRuns, (s) => s.fieldRecordId);
193
193
  if (fieldRecordIds.length > 0) {
194
194
  await tx.fieldValue.deleteMany({
195
195
  where: { fieldRecordId: { in: fieldRecordIds } },
@@ -259,7 +259,7 @@ async function autoGenerateInstanceKey(erpTx, itemId) {
259
259
  }
260
260
  export async function completeOrderRun(orderRunId, orderId, data, userId) {
261
261
  return erpDb.$transaction(async (erpTx) => {
262
- // Load the order with its item
262
+ // Load the order with its item and full item field definitions.
263
263
  const order = await erpTx.order.findUniqueOrThrow({
264
264
  where: { id: orderId },
265
265
  select: {
@@ -270,7 +270,15 @@ export async function completeOrderRun(orderRunId, orderId, data, userId) {
270
270
  fieldSet: {
271
271
  select: {
272
272
  fields: {
273
- select: { id: true },
273
+ select: {
274
+ id: true,
275
+ seqNo: true,
276
+ label: true,
277
+ type: true,
278
+ isArray: true,
279
+ required: true,
280
+ },
281
+ orderBy: { seqNo: "asc" },
274
282
  },
275
283
  },
276
284
  },
@@ -279,14 +287,60 @@ export async function completeOrderRun(orderRunId, orderId, data, userId) {
279
287
  },
280
288
  });
281
289
  if (!order.item) {
282
- return { error: "Order has no item assigned — cannot complete" };
290
+ return {
291
+ error: "Order has no item assigned — cannot complete",
292
+ status: 422,
293
+ };
294
+ }
295
+ const itemFields = order.item.fieldSet?.fields ?? [];
296
+ const fieldsBySeqNo = keyBy(itemFields, (f) => f.seqNo);
297
+ const fieldsById = keyBy(itemFields, (f) => f.id);
298
+ // Validate caller-supplied fieldSeqNos exist on the item.
299
+ const callerValues = data.fieldValues ?? [];
300
+ for (const fv of callerValues) {
301
+ if (!fieldsBySeqNo.has(fv.fieldSeqNo)) {
302
+ return {
303
+ error: `Unknown item field seqNo ${fv.fieldSeqNo} — item has no field with that sequence number`,
304
+ status: 400,
305
+ };
306
+ }
307
+ }
308
+ const resolved = new Map();
309
+ const keyOf = (fieldId, setIndex) => `${fieldId}:${setIndex}`;
310
+ for (const fv of callerValues) {
311
+ const def = fieldsBySeqNo.get(fv.fieldSeqNo);
312
+ const setIndex = fv.setIndex ?? 0;
313
+ resolved.set(keyOf(def.id, setIndex), {
314
+ fieldId: def.id,
315
+ value: fv.value,
316
+ setIndex,
317
+ });
318
+ }
319
+ // Validate all item fields against caller-supplied values at setIndex 0.
320
+ // `fieldValues[]` on CompleteOrderRun is flat (no multi-set support), so
321
+ // we only validate set 0. Flags both missing-required and type-invalid.
322
+ const failures = validateFieldSet(itemFields, [0], (fieldId, setIndex) => {
323
+ const def = fieldsById.get(fieldId);
324
+ const r = resolved.get(keyOf(fieldId, setIndex));
325
+ if (r)
326
+ return deserializeFieldValue(r.value, def.isArray);
327
+ return def.isArray ? [] : "";
328
+ });
329
+ if (failures.length > 0) {
330
+ return {
331
+ error: `Cannot complete order run: ${failures
332
+ .map((f) => `${f.label} — ${f.error}`)
333
+ .join("; ")}. Provide values via fieldValues[] using fieldSeqNo.`,
334
+ status: 400,
335
+ missingFields: failures.map((f) => f.label),
336
+ };
283
337
  }
284
338
  // Determine instance key
285
339
  let instanceKey = data.instanceKey;
286
340
  if (!instanceKey) {
287
341
  const result = await autoGenerateInstanceKey(erpTx, order.item.id);
288
342
  if ("error" in result)
289
- return result;
343
+ return { error: result.error, status: 422 };
290
344
  instanceKey = result.key;
291
345
  }
292
346
  // Check for duplicate key
@@ -296,6 +350,7 @@ export async function completeOrderRun(orderRunId, orderId, data, userId) {
296
350
  if (existing) {
297
351
  return {
298
352
  error: `Instance key "${instanceKey}" already exists for this item`,
353
+ status: 422,
299
354
  };
300
355
  }
301
356
  // Create the item instance
@@ -309,8 +364,8 @@ export async function completeOrderRun(orderRunId, orderId, data, userId) {
309
364
  updatedById: userId,
310
365
  },
311
366
  });
312
- // Create field record and field values if item has a field set
313
- if (order.item.fieldSetId && (data.fieldValues?.length ?? 0) > 0) {
367
+ // Create field record and field values if we have any to write.
368
+ if (order.item.fieldSetId && resolved.size > 0) {
314
369
  const fieldRecord = await erpTx.fieldRecord.create({
315
370
  data: {
316
371
  fieldSetId: order.item.fieldSetId,
@@ -321,17 +376,8 @@ export async function completeOrderRun(orderRunId, orderId, data, userId) {
321
376
  where: { id: instance.id },
322
377
  data: { fieldRecordId: fieldRecord.id },
323
378
  });
324
- for (const fv of data.fieldValues ?? []) {
325
- await erpTx.fieldValue.create({
326
- data: {
327
- fieldRecordId: fieldRecord.id,
328
- fieldId: fv.fieldId,
329
- setIndex: fv.setIndex ?? 0,
330
- value: fv.value,
331
- createdById: userId,
332
- updatedById: userId,
333
- },
334
- });
379
+ for (const fv of resolved.values()) {
380
+ await upsertFieldValue(fieldRecord.id, fv.fieldId, fv.setIndex, fv.value, userId, erpTx);
335
381
  }
336
382
  }
337
383
  // Sum operation run costs and transition run to closed
@@ -1,5 +1,6 @@
1
- import erpDb from "../erpDb.js";
2
- import { includeUsers } from "../route-helpers.js";
1
+ import { RevisionStatus } from "@naisys/erp-shared";
2
+ import erpDb from "../../database/erpDb.js";
3
+ import { includeUsers } from "../../route-helpers.js";
3
4
  // --- Prisma include & result type ---
4
5
  const includeOrderRelations = {
5
6
  ...includeUsers,
@@ -31,6 +32,14 @@ export async function checkHasRevisions(orderId) {
31
32
  });
32
33
  return revisionCount > 0;
33
34
  }
35
+ // --- Derived fields ---
36
+ export async function getLatestApprovedRevNo(orderId) {
37
+ const result = await erpDb.orderRevision.aggregate({
38
+ where: { orderId, status: RevisionStatus.approved },
39
+ _max: { revNo: true },
40
+ });
41
+ return result._max.revNo ?? null;
42
+ }
34
43
  // --- Mutations ---
35
44
  export async function resolveItemKey(itemKey) {
36
45
  if (!itemKey)
@@ -1,4 +1,5 @@
1
- import erpDb from "../erpDb.js";
1
+ import { keyBy, unique } from "@naisys/common";
2
+ import erpDb from "../../database/erpDb.js";
2
3
  // --- Prisma deep include for full revision tree ---
3
4
  const includeFullTree = {
4
5
  operations: {
@@ -41,9 +42,9 @@ function compareProps(pairs) {
41
42
  return changes;
42
43
  }
43
44
  function diffFields(fromFields, toFields) {
44
- const fromMap = new Map(fromFields.map((f) => [f.seqNo, f]));
45
- const toMap = new Map(toFields.map((f) => [f.seqNo, f]));
46
- const allSeqNos = new Set([...fromMap.keys(), ...toMap.keys()]);
45
+ const fromMap = keyBy(fromFields, (f) => f.seqNo);
46
+ const toMap = keyBy(toFields, (f) => f.seqNo);
47
+ const allSeqNos = unique([...fromMap.keys(), ...toMap.keys()]);
47
48
  const result = [];
48
49
  for (const seqNo of [...allSeqNos].sort((a, b) => a - b)) {
49
50
  const from = fromMap.get(seqNo);
@@ -72,9 +73,9 @@ function diffFields(fromFields, toFields) {
72
73
  return result;
73
74
  }
74
75
  function diffSteps(fromSteps, toSteps) {
75
- const fromMap = new Map(fromSteps.map((s) => [s.seqNo, s]));
76
- const toMap = new Map(toSteps.map((s) => [s.seqNo, s]));
77
- const allSeqNos = new Set([...fromMap.keys(), ...toMap.keys()]);
76
+ const fromMap = keyBy(fromSteps, (s) => s.seqNo);
77
+ const toMap = keyBy(toSteps, (s) => s.seqNo);
78
+ const allSeqNos = unique([...fromMap.keys(), ...toMap.keys()]);
78
79
  const result = [];
79
80
  for (const seqNo of [...allSeqNos].sort((a, b) => a - b)) {
80
81
  const from = fromMap.get(seqNo);
@@ -138,9 +139,9 @@ function diffDeps(fromDeps, toDeps) {
138
139
  return result;
139
140
  }
140
141
  function diffOperations(fromOps, toOps) {
141
- const fromMap = new Map(fromOps.map((op) => [op.seqNo, op]));
142
- const toMap = new Map(toOps.map((op) => [op.seqNo, op]));
143
- const allSeqNos = new Set([...fromMap.keys(), ...toMap.keys()]);
142
+ const fromMap = keyBy(fromOps, (op) => op.seqNo);
143
+ const toMap = keyBy(toOps, (op) => op.seqNo);
144
+ const allSeqNos = unique([...fromMap.keys(), ...toMap.keys()]);
144
145
  const result = [];
145
146
  for (const seqNo of [...allSeqNos].sort((a, b) => a - b)) {
146
147
  const from = fromMap.get(seqNo);
@@ -1,4 +1,4 @@
1
- import erpDb from "../erpDb.js";
1
+ import erpDb from "../../database/erpDb.js";
2
2
  const includeFieldRef = {
3
3
  sourceStep: {
4
4
  select: {
@@ -1,6 +1,6 @@
1
1
  import { FieldType } from "@naisys/erp-shared";
2
- import erpDb from "../erpDb.js";
3
- import { calcNextSeqNo, includeUsers, } from "../route-helpers.js";
2
+ import erpDb from "../../database/erpDb.js";
3
+ import { calcNextSeqNo, includeUsers, } from "../../route-helpers.js";
4
4
  // --- Lookups ---
5
5
  export async function listFields(fieldSetId) {
6
6
  return erpDb.field.findMany({
@@ -1,6 +1,6 @@
1
1
  import { formatFileSize } from "@naisys/common";
2
2
  import { FieldType, } from "@naisys/erp-shared";
3
- import erpDb from "../erpDb.js";
3
+ import erpDb from "../../database/erpDb.js";
4
4
  // --- Lookups ---
5
5
  export async function findStepRunWithField(id, opRunId, fieldSeqNo) {
6
6
  const stepRun = await erpDb.stepRun.findUnique({
@@ -113,6 +113,30 @@ export function checkFieldValueShape(label, type, isArray, value) {
113
113
  }
114
114
  return null;
115
115
  }
116
+ /**
117
+ * Validate a list of field definitions across one or more set indexes, given a
118
+ * way to look up the current value for each (fieldId, setIndex) pair. Returns
119
+ * one entry per invalid cell; callers format as appropriate. Iteration order
120
+ * is ascending setIndex, then the order of `fieldDefs` as given.
121
+ */
122
+ export function validateFieldSet(fieldDefs, setIndexes, getValue) {
123
+ const failures = [];
124
+ for (const si of [...setIndexes].sort((a, b) => a - b)) {
125
+ for (const def of fieldDefs) {
126
+ const value = getValue(def.id, si);
127
+ const result = validateFieldValue(def.type, def.isArray, def.required, value);
128
+ if (!result.valid) {
129
+ failures.push({
130
+ fieldId: def.id,
131
+ label: def.label,
132
+ setIndex: si,
133
+ error: result.error,
134
+ });
135
+ }
136
+ }
137
+ }
138
+ return failures;
139
+ }
116
140
  export function validateFieldValue(type, isArray, required, value) {
117
141
  const shapeErr = checkFieldValueShape("field", type, isArray, value);
118
142
  if (shapeErr)
@@ -223,9 +247,9 @@ export async function clearAttachmentFieldValue(fieldRecordId, fieldId, setIndex
223
247
  await upsertFieldValue(fieldRecordId, fieldId, setIndex, empty, userId);
224
248
  }
225
249
  // --- Mutations ---
226
- export async function upsertFieldValue(fieldRecordId, fieldId, setIndex, value, userId) {
250
+ export async function upsertFieldValue(fieldRecordId, fieldId, setIndex, value, userId, tx = erpDb) {
227
251
  const dbValue = serializeFieldValue(value);
228
- await erpDb.fieldValue.upsert({
252
+ await tx.fieldValue.upsert({
229
253
  where: {
230
254
  fieldRecordId_fieldId_setIndex: { fieldRecordId, fieldId, setIndex },
231
255
  },
@@ -0,0 +1,67 @@
1
+ import { sumAgentMetricsByUuid } from "@naisys/hub-database";
2
+ import { OperationRunStatus } from "@naisys/erp-shared";
3
+ import erpDb from "../../database/erpDb.js";
4
+ /**
5
+ * Populate `tokens` on labor tickets and operation runs finalized before the
6
+ * column existed. Idempotent — only touches rows where `tokens IS NULL`, and
7
+ * only for agent users (others can't have tokens, so their rows stay NULL
8
+ * and the next startup ignores them too). Requires the hub DB client.
9
+ */
10
+ export async function backfillOpRunTokens() {
11
+ const tickets = await erpDb.laborTicket.findMany({
12
+ where: {
13
+ tokens: null,
14
+ clockOut: { not: null },
15
+ operationRun: {
16
+ status: {
17
+ in: [
18
+ OperationRunStatus.completed,
19
+ OperationRunStatus.skipped,
20
+ OperationRunStatus.failed,
21
+ ],
22
+ },
23
+ },
24
+ user: { isAgent: true },
25
+ },
26
+ select: {
27
+ id: true,
28
+ operationRunId: true,
29
+ clockIn: true,
30
+ clockOut: true,
31
+ user: { select: { uuid: true } },
32
+ },
33
+ });
34
+ if (tickets.length === 0)
35
+ return;
36
+ const dirtyOpRunIds = new Set();
37
+ for (const ticket of tickets) {
38
+ const { tokens } = await sumAgentMetricsByUuid(ticket.user.uuid, ticket.clockIn, ticket.clockOut);
39
+ await erpDb.laborTicket.update({
40
+ where: { id: ticket.id },
41
+ data: { tokens: Math.round(tokens) },
42
+ });
43
+ dirtyOpRunIds.add(ticket.operationRunId);
44
+ }
45
+ // Re-aggregate touched op_runs. Skip rows that already have a snapshot so
46
+ // we never clobber a value set by the normal transition path.
47
+ for (const opRunId of dirtyOpRunIds) {
48
+ const opRun = await erpDb.operationRun.findUnique({
49
+ where: { id: opRunId },
50
+ select: { tokens: true },
51
+ });
52
+ if (!opRun || opRun.tokens !== null)
53
+ continue;
54
+ const agg = await erpDb.laborTicket.aggregate({
55
+ where: { operationRunId: opRunId },
56
+ _sum: { tokens: true },
57
+ });
58
+ const total = agg._sum.tokens ?? 0;
59
+ if (total > 0) {
60
+ await erpDb.operationRun.update({
61
+ where: { id: opRunId },
62
+ data: { tokens: total },
63
+ });
64
+ }
65
+ }
66
+ }
67
+ //# sourceMappingURL=labor-ticket-backfill.js.map
@@ -1,6 +1,6 @@
1
- import { getLatestRunInfoByUuid, sumCostsByUuid } from "@naisys/hub-database";
1
+ import { getLatestRunInfoByUuid, sumAgentMetricsByUuid, } from "@naisys/hub-database";
2
+ import erpDb from "../../database/erpDb.js";
2
3
  import { writeAuditEntry } from "../audit.js";
3
- import erpDb from "../erpDb.js";
4
4
  // --- Prisma include & result type ---
5
5
  export const includeLaborTicket = {
6
6
  user: { select: { username: true } },
@@ -9,19 +9,22 @@ export const includeLaborTicket = {
9
9
  };
10
10
  // --- Helpers ---
11
11
  /**
12
- * Compute the cost for a labor ticket at clock-out time.
12
+ * Compute cost + tokens for a labor ticket at clock-out time.
13
13
  * Agents: sum of hub cost entries for the user within the clock-in/out window.
14
- * Non-agents: 0.
14
+ * Non-agents: zeros.
15
15
  */
16
- async function computeCost(userId, clockIn, clockOut) {
16
+ async function computeAgentMetrics(userId, clockIn, clockOut) {
17
17
  const user = await erpDb.user.findUnique({
18
18
  where: { id: userId },
19
19
  select: { isAgent: true, uuid: true },
20
20
  });
21
21
  if (!user?.isAgent)
22
- return 0;
23
- const cost = await sumCostsByUuid(user.uuid, clockIn, clockOut);
24
- return Math.round(cost * 100) / 100;
22
+ return { cost: 0, tokens: 0 };
23
+ const { cost, tokens } = await sumAgentMetricsByUuid(user.uuid, clockIn, clockOut);
24
+ return {
25
+ cost: Math.round(cost * 100) / 100,
26
+ tokens: Math.round(tokens),
27
+ };
25
28
  }
26
29
  /**
27
30
  * Get the current hub run info (run_id + session start) for an agent user.
@@ -67,10 +70,10 @@ export async function clockIn(operationRunId, userId, actorId) {
67
70
  where: { userId, clockOut: null },
68
71
  });
69
72
  for (const ticket of openTickets) {
70
- const cost = await computeCost(userId, ticket.clockIn, now);
73
+ const { cost, tokens } = await computeAgentMetrics(userId, ticket.clockIn, now);
71
74
  await tx.laborTicket.update({
72
75
  where: { id: ticket.id },
73
- data: { clockOut: now, cost, updatedById: actorId },
76
+ data: { clockOut: now, cost, tokens, updatedById: actorId },
74
77
  });
75
78
  }
76
79
  // If no tickets were auto-closed and this is the first ticket for the
@@ -118,10 +121,10 @@ export async function clockOut(operationRunId, opts, actorId) {
118
121
  const openTickets = await tx.laborTicket.findMany({ where });
119
122
  const updated = [];
120
123
  for (const ticket of openTickets) {
121
- const cost = await computeCost(ticket.userId, ticket.clockIn, now);
124
+ const { cost, tokens } = await computeAgentMetrics(ticket.userId, ticket.clockIn, now);
122
125
  const result = await tx.laborTicket.update({
123
126
  where: { id: ticket.id },
124
- data: { clockOut: now, cost, updatedById: actorId },
127
+ data: { clockOut: now, cost, tokens, updatedById: actorId },
125
128
  include: includeLaborTicket,
126
129
  });
127
130
  updated.push(result);
@@ -132,12 +135,15 @@ export async function clockOut(operationRunId, opts, actorId) {
132
135
  export async function clockOutAllForOpRun(operationRunId, actorId) {
133
136
  await clockOut(operationRunId, {}, actorId);
134
137
  }
135
- export async function sumLaborTicketCosts(operationRunId) {
138
+ export async function sumLaborTicketMetrics(operationRunId) {
136
139
  const result = await erpDb.laborTicket.aggregate({
137
140
  where: { operationRunId },
138
- _sum: { cost: true },
141
+ _sum: { cost: true, tokens: true },
139
142
  });
140
- return Math.round((result._sum.cost ?? 0) * 100) / 100;
143
+ return {
144
+ cost: Math.round((result._sum.cost ?? 0) * 100) / 100,
145
+ tokens: result._sum.tokens ?? 0,
146
+ };
141
147
  }
142
148
  export async function deleteLaborTicket(ticketId, actorId) {
143
149
  await erpDb.$transaction(async (tx) => {
@@ -1,5 +1,5 @@
1
- import erpDb from "../erpDb.js";
2
- import { includeUsers } from "../route-helpers.js";
1
+ import erpDb from "../../database/erpDb.js";
2
+ import { includeUsers } from "../../route-helpers.js";
3
3
  // --- Prisma include & result type ---
4
4
  const includeDetail = {
5
5
  ...includeUsers,
@@ -1,8 +1,9 @@
1
- import { getAgentApiKeyByUuid, rotateAgentApiKeyByUuid, } from "@naisys/hub-database";
1
+ import { SUPER_ADMIN_USERNAME } from "@naisys/common";
2
+ import { generatePersistentUserApiKey } from "@naisys/common-node";
3
+ import { ensureSuperAdmin } from "@naisys/supervisor-database";
2
4
  import bcrypt from "bcryptjs";
3
- import { randomBytes, randomUUID } from "crypto";
4
- import erpDb from "../erpDb.js";
5
- import { isSupervisorAuth } from "../supervisorAuth.js";
5
+ import { randomUUID } from "crypto";
6
+ import erpDb from "../database/erpDb.js";
6
7
  // --- Prisma include & result type ---
7
8
  export const includePermissions = {
8
9
  permissions: true,
@@ -37,19 +38,12 @@ export async function getUserById(id) {
37
38
  include: includePermissions,
38
39
  });
39
40
  }
40
- export async function getUserApiKey(id) {
41
+ export async function hasUserApiKey(id) {
41
42
  const user = await erpDb.user.findUnique({
42
43
  where: { id },
43
- select: { isAgent: true, uuid: true, apiKey: true },
44
+ select: { apiKeyHash: true },
44
45
  });
45
- if (!user)
46
- return null;
47
- if (user.isAgent && isSupervisorAuth()) {
48
- return getAgentApiKeyByUuid(user.uuid);
49
- }
50
- else {
51
- return user.apiKey ?? null;
52
- }
46
+ return !!user?.apiKeyHash;
53
47
  }
54
48
  // --- Mutations ---
55
49
  export async function getUserByUuid(uuid) {
@@ -63,7 +57,6 @@ export async function createUserForAgent(username, uuid) {
63
57
  data: {
64
58
  username,
65
59
  uuid,
66
- passwordHash: "",
67
60
  isAgent: true,
68
61
  },
69
62
  include: includePermissions,
@@ -78,7 +71,6 @@ export async function createUserWithPassword(data) {
78
71
  uuid,
79
72
  passwordHash,
80
73
  isAgent: false,
81
- apiKey: randomBytes(32).toString("hex"),
82
74
  },
83
75
  include: includePermissions,
84
76
  });
@@ -111,22 +103,96 @@ export async function revokePermission(userId, permission) {
111
103
  });
112
104
  }
113
105
  export async function rotateUserApiKey(id) {
114
- const newKey = randomBytes(32).toString("hex");
115
- const user = await erpDb.user.findUnique({
116
- where: { id },
117
- select: { isAgent: true, uuid: true },
106
+ return generatePersistentUserApiKey(id, {
107
+ userExists: async (userId) => (await erpDb.user.findUnique({
108
+ where: { id: userId },
109
+ select: { id: true },
110
+ })) !== null,
111
+ updateApiKeyHash: (userId, apiKeyHash) => erpDb.user.update({
112
+ where: { id: userId },
113
+ data: { apiKeyHash },
114
+ }),
115
+ });
116
+ }
117
+ // --- Superadmin bootstrap ---
118
+ /**
119
+ * Ensure a superadmin user exists in the local ERP database.
120
+ * If a password is supplied, it is used on create and updates the existing one if present.
121
+ * For standalone mode (no supervisor auth).
122
+ */
123
+ export async function ensureLocalSuperAdmin(password) {
124
+ const existing = await erpDb.user.findUnique({
125
+ where: { username: SUPER_ADMIN_USERNAME },
118
126
  });
119
- if (!user)
120
- throw new Error("User not found");
121
- if (user.isAgent && isSupervisorAuth()) {
122
- await rotateAgentApiKeyByUuid(user.uuid, newKey);
127
+ if (existing) {
128
+ await ensureErpAdminPermission(existing.id);
129
+ if (password) {
130
+ const hash = await bcrypt.hash(password, SALT_ROUNDS);
131
+ await erpDb.user.update({
132
+ where: { id: existing.id },
133
+ data: { passwordHash: hash },
134
+ });
135
+ }
123
136
  }
124
137
  else {
125
- await erpDb.user.update({
126
- where: { id },
127
- data: { apiKey: newKey },
138
+ const finalPassword = password || randomUUID().slice(0, 8);
139
+ const hash = await bcrypt.hash(finalPassword, SALT_ROUNDS);
140
+ const user = await erpDb.user.create({
141
+ data: {
142
+ uuid: randomUUID(),
143
+ username: SUPER_ADMIN_USERNAME,
144
+ passwordHash: hash,
145
+ },
146
+ });
147
+ await ensureErpAdminPermission(user.id);
148
+ if (!password) {
149
+ console.log(`\n ${SUPER_ADMIN_USERNAME} user created. Password: ${finalPassword}`);
150
+ console.log(` Change it via the admin UI or with --setup\n`);
151
+ }
152
+ }
153
+ // Warn if agent users exist without supervisor auth
154
+ const agentCount = await erpDb.user.count({ where: { isAgent: true } });
155
+ if (agentCount > 0) {
156
+ console.warn(`[ERP] Warning: ${agentCount} agent user(s) found but supervisor auth is disabled. ` +
157
+ `Agent API key lookups and authentication will not work. ` +
158
+ `Set SUPERVISOR_AUTH=true to enable.`);
159
+ }
160
+ }
161
+ /**
162
+ * Sync superadmin from supervisor into ERP DB and ensure permissions.
163
+ * For supervisor auth mode. Supervisor uses passkey-only auth — the
164
+ * mirrored ERP row has no passwordHash.
165
+ */
166
+ export async function ensureSupervisorSuperAdmin() {
167
+ const result = await ensureSuperAdmin();
168
+ await erpDb.user.upsert({
169
+ 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
+ });
178
+ const localSuperAdmin = await erpDb.user.findUnique({
179
+ where: { uuid: result.user.uuid },
180
+ });
181
+ if (localSuperAdmin) {
182
+ await ensureErpAdminPermission(localSuperAdmin.id);
183
+ }
184
+ }
185
+ /**
186
+ * Ensure a user has the erp_admin permission.
187
+ */
188
+ export async function ensureErpAdminPermission(userId) {
189
+ const existing = await erpDb.userPermission.findUnique({
190
+ where: { userId_permission: { userId, permission: "erp_admin" } },
191
+ });
192
+ if (!existing) {
193
+ await erpDb.userPermission.create({
194
+ data: { userId, permission: "erp_admin" },
128
195
  });
129
196
  }
130
- return newKey;
131
197
  }
132
198
  //# sourceMappingURL=user-service.js.map
@@ -0,0 +1,12 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { getGitCommitHash } from "@naisys/common-node";
5
+ const __dirname = dirname(fileURLToPath(import.meta.url));
6
+ // Read the server's own package.json (one level up from dist/)
7
+ const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
8
+ const commitHash = getGitCommitHash(__dirname);
9
+ export function getPackageVersion() {
10
+ return commitHash ? `${pkg.version}/${commitHash}` : pkg.version;
11
+ }
12
+ //# sourceMappingURL=version.js.map