@naisys/erp 3.0.0-beta.6

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 (150) hide show
  1. package/bin/naisys-erp +2 -0
  2. package/client-dist/android-chrome-192x192.png +0 -0
  3. package/client-dist/android-chrome-512x512.png +0 -0
  4. package/client-dist/apple-touch-icon.png +0 -0
  5. package/client-dist/assets/index-45dVo30p.css +1 -0
  6. package/client-dist/assets/index-Dffms7F_.js +168 -0
  7. package/client-dist/assets/naisys-logo-CzoPnn5I.webp +0 -0
  8. package/client-dist/favicon.ico +0 -0
  9. package/client-dist/index.html +42 -0
  10. package/client-dist/site.webmanifest +22 -0
  11. package/dist/api-reference.js +101 -0
  12. package/dist/audit.js +14 -0
  13. package/dist/auth-middleware.js +203 -0
  14. package/dist/dbConfig.js +10 -0
  15. package/dist/erpDb.js +34 -0
  16. package/dist/erpServer.js +321 -0
  17. package/dist/error-handler.js +17 -0
  18. package/dist/generated/prisma/client.js +35 -0
  19. package/dist/generated/prisma/commonInputTypes.js +11 -0
  20. package/dist/generated/prisma/enums.js +60 -0
  21. package/dist/generated/prisma/internal/class.js +50 -0
  22. package/dist/generated/prisma/internal/prismaNamespace.js +366 -0
  23. package/dist/generated/prisma/models/Attachment.js +2 -0
  24. package/dist/generated/prisma/models/AuditLog.js +2 -0
  25. package/dist/generated/prisma/models/Field.js +2 -0
  26. package/dist/generated/prisma/models/FieldAttachment.js +2 -0
  27. package/dist/generated/prisma/models/FieldRecord.js +2 -0
  28. package/dist/generated/prisma/models/FieldSet.js +2 -0
  29. package/dist/generated/prisma/models/FieldValue.js +2 -0
  30. package/dist/generated/prisma/models/Item.js +2 -0
  31. package/dist/generated/prisma/models/ItemInstance.js +2 -0
  32. package/dist/generated/prisma/models/LaborTicket.js +2 -0
  33. package/dist/generated/prisma/models/Operation.js +2 -0
  34. package/dist/generated/prisma/models/OperationDependency.js +2 -0
  35. package/dist/generated/prisma/models/OperationFieldRef.js +2 -0
  36. package/dist/generated/prisma/models/OperationRun.js +2 -0
  37. package/dist/generated/prisma/models/OperationRunComment.js +2 -0
  38. package/dist/generated/prisma/models/Order.js +2 -0
  39. package/dist/generated/prisma/models/OrderRevision.js +2 -0
  40. package/dist/generated/prisma/models/OrderRun.js +2 -0
  41. package/dist/generated/prisma/models/SchemaVersion.js +2 -0
  42. package/dist/generated/prisma/models/Session.js +2 -0
  43. package/dist/generated/prisma/models/Step.js +2 -0
  44. package/dist/generated/prisma/models/StepRun.js +2 -0
  45. package/dist/generated/prisma/models/User.js +2 -0
  46. package/dist/generated/prisma/models/UserPermission.js +2 -0
  47. package/dist/generated/prisma/models/WorkCenter.js +2 -0
  48. package/dist/generated/prisma/models/WorkCenterUser.js +2 -0
  49. package/dist/generated/prisma/models.js +2 -0
  50. package/dist/hateoas.js +61 -0
  51. package/dist/route-helpers.js +220 -0
  52. package/dist/routes/admin.js +147 -0
  53. package/dist/routes/audit.js +36 -0
  54. package/dist/routes/auth.js +112 -0
  55. package/dist/routes/dispatch.js +174 -0
  56. package/dist/routes/inventory.js +70 -0
  57. package/dist/routes/item-fields.js +220 -0
  58. package/dist/routes/item-instances.js +426 -0
  59. package/dist/routes/items.js +252 -0
  60. package/dist/routes/labor-tickets.js +268 -0
  61. package/dist/routes/operation-dependencies.js +170 -0
  62. package/dist/routes/operation-field-refs.js +263 -0
  63. package/dist/routes/operation-run-comments.js +108 -0
  64. package/dist/routes/operation-run-transitions.js +249 -0
  65. package/dist/routes/operation-runs.js +299 -0
  66. package/dist/routes/operations.js +283 -0
  67. package/dist/routes/order-revision-transitions.js +86 -0
  68. package/dist/routes/order-revisions.js +327 -0
  69. package/dist/routes/order-run-transitions.js +215 -0
  70. package/dist/routes/order-runs.js +335 -0
  71. package/dist/routes/orders.js +262 -0
  72. package/dist/routes/root.js +123 -0
  73. package/dist/routes/schemas.js +31 -0
  74. package/dist/routes/step-field-attachments.js +231 -0
  75. package/dist/routes/step-fields.js +315 -0
  76. package/dist/routes/step-run-fields.js +438 -0
  77. package/dist/routes/step-run-transitions.js +113 -0
  78. package/dist/routes/step-runs.js +324 -0
  79. package/dist/routes/steps.js +283 -0
  80. package/dist/routes/user-permissions.js +100 -0
  81. package/dist/routes/users.js +381 -0
  82. package/dist/routes/work-centers.js +280 -0
  83. package/dist/schema-registry.js +45 -0
  84. package/dist/services/attachment-service.js +118 -0
  85. package/dist/services/field-ref-service.js +74 -0
  86. package/dist/services/field-service.js +114 -0
  87. package/dist/services/field-value-service.js +256 -0
  88. package/dist/services/item-instance-service.js +155 -0
  89. package/dist/services/item-service.js +56 -0
  90. package/dist/services/labor-ticket-service.js +148 -0
  91. package/dist/services/log-file-service.js +11 -0
  92. package/dist/services/operation-dependency-service.js +30 -0
  93. package/dist/services/operation-run-comment-service.js +26 -0
  94. package/dist/services/operation-run-service.js +347 -0
  95. package/dist/services/operation-service.js +132 -0
  96. package/dist/services/order-revision-service.js +264 -0
  97. package/dist/services/order-run-service.js +356 -0
  98. package/dist/services/order-service.js +68 -0
  99. package/dist/services/revision-diff-service.js +194 -0
  100. package/dist/services/step-run-service.js +106 -0
  101. package/dist/services/step-service.js +89 -0
  102. package/dist/services/user-service.js +132 -0
  103. package/dist/services/work-center-service.js +106 -0
  104. package/dist/supervisorAuth.js +16 -0
  105. package/dist/userService.js +118 -0
  106. package/package.json +75 -0
  107. package/prisma/migrations/20260212170352_init/migration.sql +125 -0
  108. package/prisma/migrations/20260308000000_multi_session/migration.sql +23 -0
  109. package/prisma/migrations/20260309000000_add_user_api_key/migration.sql +5 -0
  110. package/prisma/migrations/20260309010000_add_plan_operations/migration.sql +21 -0
  111. package/prisma/migrations/20260309020000_rename_exec_orders_to_order_runs/migration.sql +13 -0
  112. package/prisma/migrations/20260310000000_rename_plan_order_revs_to_order_revisions/migration.sql +22 -0
  113. package/prisma/migrations/20260310100000_rename_plan_orders_to_orders/migration.sql +23 -0
  114. package/prisma/migrations/20260310200000_rename_order_no_to_run_no/migration.sql +3 -0
  115. package/prisma/migrations/20260312000000_add_user_permissions/migration.sql +16 -0
  116. package/prisma/migrations/20260313000000_rename_plan_operations_to_operations/migration.sql +2 -0
  117. package/prisma/migrations/20260313100000_add_steps/migration.sql +20 -0
  118. package/prisma/migrations/20260314000000_add_step_fields/migration.sql +22 -0
  119. package/prisma/migrations/20260315000000_add_operation_runs/migration.sql +24 -0
  120. package/prisma/migrations/20260315100000_add_step_runs/migration.sql +40 -0
  121. package/prisma/migrations/20260316000000_drop_order_name/migration.sql +12 -0
  122. package/prisma/migrations/20260317000000_add_attachments/migration.sql +28 -0
  123. package/prisma/migrations/20260317000000_add_items/migration.sql +21 -0
  124. package/prisma/migrations/20260317100000_add_order_item_id/migration.sql +8 -0
  125. package/prisma/migrations/20260318000000_add_labor_tickets/migration.sql +27 -0
  126. package/prisma/migrations/20260319000000_add_operation_dependencies/migration.sql +17 -0
  127. package/prisma/migrations/20260320000000_step_field_is_array/migration.sql +5 -0
  128. package/prisma/migrations/20260320100000_rename_is_array_to_multi_value/migration.sql +2 -0
  129. package/prisma/migrations/20260320200000_add_field_types/migration.sql +2 -0
  130. package/prisma/migrations/20260321000000_add_multi_set/migration.sql +13 -0
  131. package/prisma/migrations/20260322000000_add_field_sets/migration.sql +90 -0
  132. package/prisma/migrations/20260323000000_add_item_instances/migration.sql +18 -0
  133. package/prisma/migrations/20260324000000_add_field_records/migration.sql +77 -0
  134. package/prisma/migrations/20260325000000_add_run_costs/migration.sql +3 -0
  135. package/prisma/migrations/20260326000000_add_comments/migration.sql +16 -0
  136. package/prisma/migrations/20260327000000_move_assigned_to_op_run/migration.sql +3 -0
  137. package/prisma/migrations/20260328000000_add_step_completion_note/migration.sql +2 -0
  138. package/prisma/migrations/20260328000000_add_step_title/migration.sql +2 -0
  139. package/prisma/migrations/20260329000000_rename_notes_to_release_note/migration.sql +2 -0
  140. package/prisma/migrations/20260329000000_simplify_order_run_dates/migration.sql +5 -0
  141. package/prisma/migrations/20260330000000_add_operation_run_completion_note/migration.sql +2 -0
  142. package/prisma/migrations/20260331000000_add_work_centers/migration.sql +30 -0
  143. package/prisma/migrations/20260401000000_fix_field_values_column_shift/migration.sql +26 -0
  144. package/prisma/migrations/20260402000000_rename_completion_note_to_status_note/migration.sql +5 -0
  145. package/prisma/migrations/20260403000000_add_operation_field_refs/migration.sql +16 -0
  146. package/prisma/migrations/20260404000000_rename_multi_value_to_is_array/migration.sql +2 -0
  147. package/prisma/migrations/20260404100000_add_attachment_public_id/migration.sql +8 -0
  148. package/prisma/migrations/migration_lock.toml +3 -0
  149. package/prisma/schema.prisma +595 -0
  150. package/prisma.config.ts +18 -0
@@ -0,0 +1,220 @@
1
+ import { permGate, resolveActions as resolveActionsBase, } from "@naisys/common";
2
+ import { OperationRunStatus, OrderRunStatus, RevisionStatus, } from "@naisys/erp-shared";
3
+ import { hasPermission } from "./auth-middleware.js";
4
+ import erpDb from "./erpDb.js";
5
+ import { API_PREFIX, schemaLink, selfLink } from "./hateoas.js";
6
+ // --- Prefer: return=representation (RFC 7240) ---
7
+ /**
8
+ * Returns true when the caller wants the full entity in the mutation response.
9
+ * UI clients send `Prefer: return=representation`; agents get the slim default.
10
+ */
11
+ export function wantsFullResponse(request) {
12
+ const prefer = request.headers["prefer"];
13
+ return typeof prefer === "string" && prefer.includes("return=representation");
14
+ }
15
+ /**
16
+ * Override Fastify's schema-driven serializer so every field in the
17
+ * response object is emitted (not just the ones in the slim schema).
18
+ */
19
+ export function useFullSerializer(reply) {
20
+ reply.header("content-type", "application/json; charset=utf-8");
21
+ reply.serializer(JSON.stringify);
22
+ }
23
+ /**
24
+ * Return `slim` by default, or `full` when the caller sends
25
+ * `Prefer: return=representation`.
26
+ *
27
+ * For full responses we override the serializer so that Fastify
28
+ * emits all fields instead of stripping them down to the (slim)
29
+ * response schema.
30
+ */
31
+ export function mutationResult(request, reply, full, slim) {
32
+ if (wantsFullResponse(request)) {
33
+ useFullSerializer(reply);
34
+ return full;
35
+ }
36
+ return slim;
37
+ }
38
+ // --- Shared Prisma include for audit user fields ---
39
+ export const includeUsers = {
40
+ createdBy: { select: { username: true } },
41
+ updatedBy: { select: { username: true } },
42
+ };
43
+ // --- Formatting helpers ---
44
+ export function formatAuditFields(item) {
45
+ return {
46
+ createdAt: item.createdAt.toISOString(),
47
+ createdBy: item.createdBy.username,
48
+ updatedAt: item.updatedAt.toISOString(),
49
+ updatedBy: item.updatedBy.username,
50
+ };
51
+ }
52
+ export function formatDate(d) {
53
+ return d ? d.toISOString() : null;
54
+ }
55
+ export function calcNextSeqNo(currentMax) {
56
+ return Math.ceil((currentMax + 1) / 10) * 10;
57
+ }
58
+ // --- HATEOAS helpers ---
59
+ export function childItemLinks(basePath, itemKey, collectionTitle, parentPath, parentTitle, schemaName, parentRel = "parent") {
60
+ return [
61
+ selfLink(`${basePath}/${itemKey}`),
62
+ {
63
+ rel: "collection",
64
+ href: `${API_PREFIX}${basePath}`,
65
+ title: collectionTitle,
66
+ },
67
+ {
68
+ rel: parentRel,
69
+ href: `${API_PREFIX}${parentPath}`,
70
+ title: parentTitle,
71
+ },
72
+ schemaLink(schemaName),
73
+ ];
74
+ }
75
+ // --- Declarative action resolver (wraps @naisys/common with ERP permission types) ---
76
+ export { permGate };
77
+ export function resolveActions(defs, baseHref, ctx) {
78
+ return resolveActionsBase(defs, baseHref, ctx, (perm) => hasPermission(ctx.user, perm));
79
+ }
80
+ export function draftCrudActions(href, updateSchemaName, revStatus, user) {
81
+ return resolveActions([
82
+ {
83
+ rel: "update",
84
+ method: "PUT",
85
+ title: "Update",
86
+ schema: `${API_PREFIX}/schemas/${updateSchemaName}`,
87
+ permission: "order_planner",
88
+ disabledWhen: (ctx) => ctx.status !== RevisionStatus.draft
89
+ ? "Can only edit in draft revisions"
90
+ : null,
91
+ },
92
+ {
93
+ rel: "delete",
94
+ method: "DELETE",
95
+ title: "Delete",
96
+ permission: "order_planner",
97
+ statuses: [RevisionStatus.draft],
98
+ hideWithoutPermission: true,
99
+ },
100
+ ], href, { status: revStatus, user });
101
+ }
102
+ // --- Status guards (return error message or null) ---
103
+ export function checkOrderRunStarted(status) {
104
+ return status !== OrderRunStatus.started
105
+ ? `Order run is not started (status: ${status})`
106
+ : null;
107
+ }
108
+ export function checkOpRunInProgress(status) {
109
+ return status !== OperationRunStatus.in_progress
110
+ ? `Operation run is not in_progress (status: ${status})`
111
+ : null;
112
+ }
113
+ /**
114
+ * If the operation has a work center, check that the user is assigned to it.
115
+ * Returns an error message or null if access is allowed.
116
+ */
117
+ export async function checkWorkCenterAccess(operationId, user) {
118
+ if (hasPermission(user, "erp_admin"))
119
+ return null;
120
+ const operation = await erpDb.operation.findUnique({
121
+ where: { id: operationId },
122
+ select: {
123
+ workCenter: {
124
+ select: {
125
+ key: true,
126
+ userAssignments: {
127
+ where: { userId: user.id },
128
+ select: { userId: true },
129
+ },
130
+ },
131
+ },
132
+ },
133
+ });
134
+ if (!operation?.workCenter)
135
+ return null; // no work center = open to all
136
+ if (operation.workCenter.userAssignments.length > 0)
137
+ return null;
138
+ return `You are not assigned to work center '${operation.workCenter.key}'`;
139
+ }
140
+ // --- Resolution chains ---
141
+ export async function resolveOrder(orderKey) {
142
+ return erpDb.order.findUnique({ where: { key: orderKey } });
143
+ }
144
+ export async function resolveRevision(orderKey, revNo) {
145
+ const order = await resolveOrder(orderKey);
146
+ if (!order)
147
+ return null;
148
+ const rev = await erpDb.orderRevision.findFirst({
149
+ where: { orderId: order.id, revNo },
150
+ });
151
+ if (!rev)
152
+ return null;
153
+ return { order, rev };
154
+ }
155
+ export async function resolveOperation(orderKey, revNo, opSeqNo) {
156
+ const result = await resolveRevision(orderKey, revNo);
157
+ if (!result)
158
+ return null;
159
+ const operation = await erpDb.operation.findFirst({
160
+ where: { orderRevId: result.rev.id, seqNo: opSeqNo },
161
+ });
162
+ if (!operation)
163
+ return null;
164
+ return { ...result, operation };
165
+ }
166
+ export async function resolveStep(orderKey, revNo, opSeqNo, stepSeqNo) {
167
+ const result = await resolveOperation(orderKey, revNo, opSeqNo);
168
+ if (!result)
169
+ return null;
170
+ const step = await erpDb.step.findFirst({
171
+ where: { operationId: result.operation.id, seqNo: stepSeqNo },
172
+ });
173
+ if (!step)
174
+ return null;
175
+ return { ...result, step };
176
+ }
177
+ export async function resolveOrderRun(orderKey, runNo) {
178
+ const order = await resolveOrder(orderKey);
179
+ if (!order)
180
+ return null;
181
+ const run = await erpDb.orderRun.findUnique({
182
+ where: { orderId_runNo: { orderId: order.id, runNo } },
183
+ });
184
+ if (!run)
185
+ return null;
186
+ return { order, run };
187
+ }
188
+ export async function resolveOpRun(orderKey, runNo, seqNo) {
189
+ const result = await resolveOrderRun(orderKey, runNo);
190
+ if (!result)
191
+ return null;
192
+ const operation = await erpDb.operation.findFirst({
193
+ where: { orderRevId: result.run.orderRevId, seqNo },
194
+ });
195
+ if (!operation)
196
+ return null;
197
+ const opRun = await erpDb.operationRun.findUnique({
198
+ where: {
199
+ orderRunId_operationId: {
200
+ orderRunId: result.run.id,
201
+ operationId: operation.id,
202
+ },
203
+ },
204
+ });
205
+ if (!opRun)
206
+ return null;
207
+ return { ...result, opRun };
208
+ }
209
+ export async function resolveStepRun(orderKey, runNo, seqNo, stepSeqNo) {
210
+ const result = await resolveOpRun(orderKey, runNo, seqNo);
211
+ if (!result)
212
+ return null;
213
+ const stepRun = await erpDb.stepRun.findFirst({
214
+ where: { operationRunId: result.opRun.id, step: { seqNo: stepSeqNo } },
215
+ });
216
+ if (!stepRun)
217
+ return null;
218
+ return { ...result, stepRun };
219
+ }
220
+ //# sourceMappingURL=route-helpers.js.map
@@ -0,0 +1,147 @@
1
+ import { createReadStream, existsSync, statSync } from "node:fs";
2
+ import fs from "node:fs/promises";
3
+ import { AdminAttachmentListRequestSchema, AdminAttachmentListResponseSchema, AdminInfoResponseSchema, ErrorResponseSchema, ServerLogRequestSchema, ServerLogResponseSchema, } from "@naisys/erp-shared";
4
+ import { z } from "zod/v4";
5
+ import { hasPermission, requirePermission } from "../auth-middleware.js";
6
+ import { erpDbPath } from "../dbConfig.js";
7
+ import erpDb from "../erpDb.js";
8
+ import { notFound } from "../error-handler.js";
9
+ import { paginationLinks } from "../hateoas.js";
10
+ import { getErpLogPath, tailLogFile } from "../services/log-file-service.js";
11
+ const API_PREFIX = "/api/erp";
12
+ function adminActions(hasAdminPermission) {
13
+ const actions = [];
14
+ if (hasAdminPermission) {
15
+ actions.push({
16
+ rel: "view-logs",
17
+ href: `${API_PREFIX}/admin/logs`,
18
+ method: "GET",
19
+ title: "View Logs",
20
+ });
21
+ actions.push({
22
+ rel: "view-attachments",
23
+ href: `${API_PREFIX}/admin/attachments`,
24
+ method: "GET",
25
+ title: "View Attachments",
26
+ });
27
+ }
28
+ return actions;
29
+ }
30
+ export default function adminRoutes(fastify, _options) {
31
+ // GET / — Admin info
32
+ fastify.get("/", {
33
+ preHandler: [requirePermission("erp_admin")],
34
+ schema: {
35
+ description: "Get ERP admin system info",
36
+ tags: ["Admin"],
37
+ response: {
38
+ 200: AdminInfoResponseSchema,
39
+ 500: ErrorResponseSchema,
40
+ },
41
+ security: [{ cookieAuth: [] }],
42
+ },
43
+ }, async (request, _reply) => {
44
+ const hasAdminPerm = hasPermission(request.erpUser, "erp_admin");
45
+ const actions = adminActions(hasAdminPerm);
46
+ const dbPath = erpDbPath();
47
+ const erpDbSize = await fs
48
+ .stat(dbPath)
49
+ .then((s) => s.size)
50
+ .catch(() => undefined);
51
+ return {
52
+ erpDbPath: dbPath,
53
+ erpDbSize,
54
+ _actions: actions.length > 0 ? actions : undefined,
55
+ };
56
+ });
57
+ // GET /logs — Tail ERP log file
58
+ fastify.get("/logs", {
59
+ preHandler: [requirePermission("erp_admin")],
60
+ schema: {
61
+ description: "Get tail of the ERP server log file",
62
+ tags: ["Admin"],
63
+ querystring: ServerLogRequestSchema,
64
+ response: {
65
+ 200: ServerLogResponseSchema,
66
+ 500: ErrorResponseSchema,
67
+ },
68
+ security: [{ cookieAuth: [] }],
69
+ },
70
+ }, async (request, _reply) => {
71
+ const { lines, minLevel } = request.query;
72
+ const cappedLines = Math.min(lines, 1000);
73
+ const filePath = getErpLogPath();
74
+ const { entries, fileSize } = await tailLogFile(filePath, cappedLines, minLevel);
75
+ return {
76
+ entries,
77
+ fileName: "erp.log",
78
+ fileSize,
79
+ };
80
+ });
81
+ // GET /attachments — List all attachments
82
+ fastify.get("/attachments", {
83
+ preHandler: [requirePermission("erp_admin")],
84
+ schema: {
85
+ description: "List all uploaded attachments",
86
+ tags: ["Admin"],
87
+ querystring: AdminAttachmentListRequestSchema,
88
+ response: {
89
+ 200: AdminAttachmentListResponseSchema,
90
+ 500: ErrorResponseSchema,
91
+ },
92
+ security: [{ cookieAuth: [] }],
93
+ },
94
+ }, async (request, _reply) => {
95
+ const { page, pageSize } = request.query;
96
+ const skip = (page - 1) * pageSize;
97
+ const [rows, total] = await Promise.all([
98
+ erpDb.attachment.findMany({
99
+ orderBy: { createdAt: "desc" },
100
+ include: {
101
+ uploadedBy: { select: { username: true } },
102
+ },
103
+ skip,
104
+ take: pageSize,
105
+ }),
106
+ erpDb.attachment.count(),
107
+ ]);
108
+ return {
109
+ attachments: rows.map((r) => ({
110
+ id: r.publicId,
111
+ filename: r.filename,
112
+ fileSize: r.fileSize,
113
+ fileHash: r.fileHash,
114
+ uploadedBy: r.uploadedBy.username,
115
+ createdAt: r.createdAt.toISOString(),
116
+ })),
117
+ total,
118
+ page,
119
+ pageSize,
120
+ _links: paginationLinks("admin/attachments", page, pageSize, total),
121
+ };
122
+ });
123
+ // GET /attachments/:id — Download an attachment by ID
124
+ fastify.get("/attachments/:id", {
125
+ preHandler: [requirePermission("erp_admin")],
126
+ schema: {
127
+ description: "Download an attachment file by ID",
128
+ tags: ["Admin"],
129
+ params: z.object({ id: z.string() }),
130
+ },
131
+ }, async (request, reply) => {
132
+ const att = await erpDb.attachment.findUnique({
133
+ where: { publicId: request.params.id },
134
+ select: { filepath: true, filename: true, fileSize: true },
135
+ });
136
+ if (!att)
137
+ return notFound(reply, "Attachment not found");
138
+ if (!existsSync(att.filepath))
139
+ return notFound(reply, "Attachment file missing from disk");
140
+ const stat = statSync(att.filepath);
141
+ reply.header("content-type", "application/octet-stream");
142
+ reply.header("content-disposition", `attachment; filename="${att.filename.replace(/"/g, '\\"')}"`);
143
+ reply.header("content-length", stat.size);
144
+ return reply.send(createReadStream(att.filepath));
145
+ });
146
+ }
147
+ //# sourceMappingURL=admin.js.map
@@ -0,0 +1,36 @@
1
+ import { AuditListResponseSchema, AuditQuerySchema } from "@naisys/erp-shared";
2
+ import erpDb from "../erpDb.js";
3
+ export default function auditRoutes(fastify) {
4
+ const app = fastify.withTypeProvider();
5
+ app.get("/", {
6
+ schema: {
7
+ description: "Get audit log entries for a given entity",
8
+ tags: ["Audit"],
9
+ querystring: AuditQuerySchema,
10
+ response: {
11
+ 200: AuditListResponseSchema,
12
+ },
13
+ },
14
+ handler: async (request) => {
15
+ const { entityType, entityId } = request.query;
16
+ const items = await erpDb.auditLog.findMany({
17
+ where: { entityType, entityId },
18
+ orderBy: { createdAt: "desc" },
19
+ });
20
+ return {
21
+ items: items.map((item) => ({
22
+ id: item.id,
23
+ entityType: item.entityType,
24
+ entityId: item.entityId,
25
+ action: item.action,
26
+ field: item.field,
27
+ oldValue: item.oldValue,
28
+ newValue: item.newValue,
29
+ userId: item.userId,
30
+ createdAt: item.createdAt.toISOString(),
31
+ })),
32
+ };
33
+ },
34
+ });
35
+ }
36
+ //# sourceMappingURL=audit.js.map
@@ -0,0 +1,112 @@
1
+ import { hashToken, SESSION_COOKIE_NAME, sessionCookieOptions, } from "@naisys/common-node";
2
+ import { authenticateAndCreateSession, deleteSession, } from "@naisys/supervisor-database";
3
+ import { AuthUserSchema, ErrorResponseSchema, LoginRequestSchema, LoginResponseSchema, } from "@naisys/erp-shared";
4
+ import bcrypt from "bcryptjs";
5
+ import { randomUUID } from "crypto";
6
+ import { authCache } from "../auth-middleware.js";
7
+ import erpDb from "../erpDb.js";
8
+ import { unauthorized } from "../error-handler.js";
9
+ import { isSupervisorAuth } from "../supervisorAuth.js";
10
+ const SESSION_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
11
+ export default function authRoutes(fastify) {
12
+ const app = fastify.withTypeProvider();
13
+ // LOGIN
14
+ app.post("/login", {
15
+ config: {
16
+ rateLimit: {
17
+ max: 5,
18
+ timeWindow: "1 minute",
19
+ },
20
+ },
21
+ schema: {
22
+ description: "Authenticate with username and password",
23
+ tags: ["Auth"],
24
+ body: LoginRequestSchema,
25
+ response: {
26
+ 200: LoginResponseSchema,
27
+ 401: ErrorResponseSchema,
28
+ 429: ErrorResponseSchema,
29
+ },
30
+ },
31
+ handler: async (request, reply) => {
32
+ const { username, password } = request.body;
33
+ // SSO mode: authenticate against supervisor DB
34
+ if (isSupervisorAuth()) {
35
+ const authResult = await authenticateAndCreateSession(username, password);
36
+ if (!authResult) {
37
+ return unauthorized(reply, "Invalid username or password");
38
+ }
39
+ const ssoData = {
40
+ username,
41
+ passwordHash: authResult.user.passwordHash,
42
+ };
43
+ const user = await erpDb.user.upsert({
44
+ where: { uuid: authResult.user.uuid },
45
+ create: { uuid: authResult.user.uuid, ...ssoData },
46
+ update: ssoData,
47
+ });
48
+ reply.setCookie(SESSION_COOKIE_NAME, authResult.token, sessionCookieOptions(authResult.expiresAt));
49
+ return { user: { id: user.id, username: user.username } };
50
+ }
51
+ // Standalone mode: authenticate against local DB
52
+ const user = await erpDb.user.findUnique({ where: { username } });
53
+ if (!user) {
54
+ return unauthorized(reply, "Invalid username or password");
55
+ }
56
+ const valid = await bcrypt.compare(password, user.passwordHash);
57
+ if (!valid) {
58
+ return unauthorized(reply, "Invalid username or password");
59
+ }
60
+ const token = randomUUID();
61
+ const tokenHash = hashToken(token);
62
+ const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
63
+ await erpDb.session.create({
64
+ data: { userId: user.id, tokenHash, expiresAt },
65
+ });
66
+ reply.setCookie(SESSION_COOKIE_NAME, token, sessionCookieOptions(expiresAt));
67
+ return { user: { id: user.id, username: user.username } };
68
+ },
69
+ });
70
+ // LOGOUT
71
+ app.post("/logout", {
72
+ schema: {
73
+ description: "Log out and clear session",
74
+ tags: ["Auth"],
75
+ },
76
+ handler: async (request, reply) => {
77
+ const token = request.cookies?.[SESSION_COOKIE_NAME];
78
+ if (token) {
79
+ const tokenHash = hashToken(token);
80
+ authCache.invalidate(`cookie:${tokenHash}`);
81
+ // Delete from local ERP sessions
82
+ await erpDb.session.deleteMany({ where: { tokenHash } });
83
+ // Also delete from supervisor sessions (SSO mode)
84
+ await deleteSession(tokenHash);
85
+ }
86
+ reply.clearCookie(SESSION_COOKIE_NAME, { path: "/" });
87
+ return { ok: true };
88
+ },
89
+ });
90
+ // ME
91
+ app.get("/me", {
92
+ schema: {
93
+ description: "Get current authenticated user",
94
+ tags: ["Auth"],
95
+ response: {
96
+ 200: AuthUserSchema,
97
+ 401: ErrorResponseSchema,
98
+ },
99
+ },
100
+ handler: async (request, reply) => {
101
+ if (!request.erpUser) {
102
+ return unauthorized(reply, "Not authenticated");
103
+ }
104
+ return {
105
+ id: request.erpUser.id,
106
+ username: request.erpUser.username,
107
+ permissions: request.erpUser.permissions,
108
+ };
109
+ },
110
+ });
111
+ }
112
+ //# sourceMappingURL=auth.js.map
@@ -0,0 +1,174 @@
1
+ import { DispatchListQuerySchema, DispatchListResponseSchema, OperationRunStatus, OrderRunStatus, } from "@naisys/erp-shared";
2
+ import erpDb from "../erpDb.js";
3
+ import { API_PREFIX, paginationLinks } from "../hateoas.js";
4
+ import { getUserWorkCenterIds } from "../services/work-center-service.js";
5
+ const OPEN_ORDER_STATUSES = [OrderRunStatus.released, OrderRunStatus.started];
6
+ const DEFAULT_OP_STATUSES = [
7
+ OperationRunStatus.pending,
8
+ OperationRunStatus.in_progress,
9
+ OperationRunStatus.failed,
10
+ ];
11
+ export default function dispatchRoutes(fastify) {
12
+ const app = fastify.withTypeProvider();
13
+ app.get("/", {
14
+ schema: {
15
+ description: "List operation runs across open orders (dispatch view)",
16
+ tags: ["Dispatch"],
17
+ querystring: DispatchListQuerySchema,
18
+ response: {
19
+ 200: DispatchListResponseSchema,
20
+ },
21
+ },
22
+ handler: async (request) => {
23
+ const { page, pageSize, status, priority, search, viewAs, canWork, clockedIn, } = request.query;
24
+ // Resolve the perspective user for canWork computation
25
+ let perspectiveUserId = request.erpUser?.id;
26
+ let perspectivePerms = new Set(request.erpUser?.permissions ?? []);
27
+ if (viewAs) {
28
+ const viewAsUser = await erpDb.user.findUnique({
29
+ where: { username: viewAs },
30
+ select: {
31
+ id: true,
32
+ permissions: { select: { permission: true } },
33
+ },
34
+ });
35
+ if (viewAsUser) {
36
+ perspectiveUserId = viewAsUser.id;
37
+ perspectivePerms = new Set(viewAsUser.permissions.map((p) => p.permission));
38
+ }
39
+ }
40
+ const isExecutor = perspectivePerms.has("order_executor");
41
+ const isManager = perspectivePerms.has("order_manager");
42
+ // Pre-fetch perspective user's work center IDs for canWork computation + filtering
43
+ const userWcIds = perspectiveUserId
44
+ ? await getUserWorkCenterIds(perspectiveUserId)
45
+ : [];
46
+ const userWcIdSet = new Set(userWcIds);
47
+ const where = {
48
+ status: { in: status ? [status] : DEFAULT_OP_STATUSES },
49
+ orderRun: {
50
+ status: { in: OPEN_ORDER_STATUSES },
51
+ ...(priority ? { priority } : {}),
52
+ },
53
+ };
54
+ // canWork filter: only show ops where the perspective user can work
55
+ if (canWork) {
56
+ // Restrict to statuses the user has permission for
57
+ const workableStatuses = [];
58
+ if (isExecutor) {
59
+ workableStatuses.push(OperationRunStatus.pending, OperationRunStatus.in_progress);
60
+ }
61
+ if (isManager) {
62
+ workableStatuses.push(OperationRunStatus.failed);
63
+ }
64
+ // Intersect with the requested status filter
65
+ const currentStatuses = status ? [status] : DEFAULT_OP_STATUSES;
66
+ const filteredStatuses = currentStatuses.filter((s) => workableStatuses.includes(s));
67
+ where.status = {
68
+ in: filteredStatuses.length > 0 ? filteredStatuses : ["__none__"],
69
+ };
70
+ // Work center access
71
+ if (userWcIds.length > 0) {
72
+ where.operation = {
73
+ OR: [{ workCenterId: null }, { workCenterId: { in: userWcIds } }],
74
+ };
75
+ }
76
+ }
77
+ if (search) {
78
+ where.OR = [
79
+ { operation: { title: { contains: search } } },
80
+ { assignedTo: { username: { contains: search } } },
81
+ { orderRun: { order: { key: { contains: search } } } },
82
+ ];
83
+ }
84
+ if (clockedIn) {
85
+ where.laborTickets = { some: { clockOut: null } };
86
+ }
87
+ const [items, total] = await Promise.all([
88
+ erpDb.operationRun.findMany({
89
+ where,
90
+ include: {
91
+ operation: {
92
+ select: {
93
+ seqNo: true,
94
+ title: true,
95
+ workCenter: { select: { key: true, id: true } },
96
+ },
97
+ },
98
+ assignedTo: { select: { username: true } },
99
+ orderRun: {
100
+ select: {
101
+ runNo: true,
102
+ priority: true,
103
+ dueAt: true,
104
+ order: { select: { key: true } },
105
+ orderRev: { select: { revNo: true } },
106
+ },
107
+ },
108
+ },
109
+ skip: (page - 1) * pageSize,
110
+ take: pageSize,
111
+ orderBy: { orderRun: { dueAt: "asc" } },
112
+ }),
113
+ erpDb.operationRun.count({ where }),
114
+ ]);
115
+ return {
116
+ items: items.map((opRun) => {
117
+ const wcId = opRun.operation.workCenter?.id ?? null;
118
+ const hasWcAccess = wcId === null || userWcIdSet.has(wcId);
119
+ // canWork: work center access + permission for the op status
120
+ const hasStatusPerm = opRun.status === OperationRunStatus.failed ? isManager : isExecutor;
121
+ const itemCanWork = hasWcAccess && hasStatusPerm;
122
+ return {
123
+ id: opRun.id,
124
+ orderKey: opRun.orderRun.order.key,
125
+ revNo: opRun.orderRun.orderRev.revNo,
126
+ runNo: opRun.orderRun.runNo,
127
+ seqNo: opRun.operation.seqNo,
128
+ title: opRun.operation.title,
129
+ workCenterKey: opRun.operation.workCenter?.key ?? null,
130
+ canWork: itemCanWork,
131
+ status: opRun.status,
132
+ priority: opRun.orderRun.priority,
133
+ assignedTo: opRun.assignedTo?.username ?? null,
134
+ dueAt: opRun.orderRun.dueAt,
135
+ createdAt: opRun.createdAt.toISOString(),
136
+ };
137
+ }),
138
+ total,
139
+ page,
140
+ pageSize,
141
+ _links: [
142
+ ...paginationLinks("dispatch", page, pageSize, total, {
143
+ status,
144
+ priority,
145
+ search,
146
+ viewAs,
147
+ canWork: canWork ? "true" : undefined,
148
+ clockedIn: clockedIn ? "true" : undefined,
149
+ }),
150
+ {
151
+ rel: "work-centers",
152
+ href: `${API_PREFIX}/work-centers`,
153
+ title: "Work Centers",
154
+ },
155
+ ],
156
+ _linkTemplates: [
157
+ {
158
+ rel: "item",
159
+ hrefTemplate: `${API_PREFIX}/orders/{orderKey}/runs/{runNo}/ops/{seqNo}`,
160
+ },
161
+ ],
162
+ _actionTemplates: [
163
+ {
164
+ rel: "viewOperationRun",
165
+ hrefTemplate: `${API_PREFIX}/orders/{orderKey}/runs/{runNo}/ops/{seqNo}`,
166
+ method: "GET",
167
+ title: "View Operation Run",
168
+ },
169
+ ],
170
+ };
171
+ },
172
+ });
173
+ }
174
+ //# sourceMappingURL=dispatch.js.map