@open-mercato/core 0.10.1-develop.7774.1.9a13ef7580 → 0.10.1-develop.7776.1.3032ea0741

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.
@@ -0,0 +1,312 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
5
+ import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
6
+ import { serializeOperationMetadata } from '@open-mercato/shared/lib/commands/operationMetadata'
7
+ import type { CommandBus, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
8
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
9
+ import { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
10
+ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
11
+ import {
12
+ bridgeLegacyGuard,
13
+ runMutationGuards,
14
+ type MutationGuard,
15
+ type MutationGuardAfterInput,
16
+ type MutationGuardInput,
17
+ } from '@open-mercato/shared/lib/crud/mutation-guard-registry'
18
+ import { getAllMutationGuardInstances } from '@open-mercato/shared/lib/crud/mutation-guard-store'
19
+ import { createLogger } from '@open-mercato/shared/lib/logger'
20
+ import { getCommandInterceptorHttpRejection } from '@open-mercato/shared/lib/commands/errors'
21
+ import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
22
+ import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
23
+ import { orderLineBulkUpsertSchema } from '../../../data/validators'
24
+ import { withScopedPayload } from '../../utils'
25
+
26
+ const logger = createLogger('sales')
27
+
28
+ function reportBatchRouteError(
29
+ error: unknown,
30
+ code: 'sales.order_lines_batch_failed' | 'sales.order_lines_guard_after_success_failed',
31
+ attributes?: Record<string, string | number | boolean | undefined>,
32
+ ): void {
33
+ try {
34
+ getTelemetryRuntime()?.reportError(
35
+ error,
36
+ attributes ? { module: 'sales', code, attributes } : { module: 'sales', code },
37
+ )
38
+ } catch (reportingError) {
39
+ logger.warn('Failed to report batch order-line error', { code, reportingError })
40
+ }
41
+ }
42
+
43
+ /**
44
+ * The bulk schema tags its own refusals with a translation key in `params`
45
+ * because it runs outside a request and cannot translate. Resolve those here,
46
+ * leaving zod's own built-in issues as they are.
47
+ */
48
+ function localizeIssues(
49
+ issues: z.ZodError['issues'],
50
+ translate: (key: string, fallback?: string) => string,
51
+ ): z.ZodError['issues'] {
52
+ return issues.map((issue) => {
53
+ const key = (issue as { params?: { omKey?: unknown } }).params?.omKey
54
+ if (typeof key !== 'string') return issue
55
+ return { ...issue, message: translate(key, issue.message) }
56
+ })
57
+ }
58
+
59
+ const COMMAND_ID = 'sales.orders.lines.upsert_many'
60
+ const RESOURCE_KIND = 'sales.order'
61
+
62
+ export const metadata = {
63
+ POST: { requireAuth: true, requireFeatures: ['sales.orders.manage'] },
64
+ }
65
+
66
+ type BulkUpsertResult = { orderId: string; lineIds: string[] }
67
+
68
+ type RbacServiceLike = {
69
+ getGrantedFeatures?: (
70
+ userId: string,
71
+ scope: { tenantId?: string | null; organizationId?: string | null },
72
+ ) => Promise<string[] | undefined>
73
+ }
74
+
75
+ /**
76
+ * The features a guard's `features` gate is checked against. Read from RBAC
77
+ * rather than off the token's `features` claim: a guard that only runs for
78
+ * holders of a feature must not be skippable by a caller who can shape their
79
+ * own claim.
80
+ */
81
+ async function resolveGrantedFeatures(ctx: CommandRuntimeContext): Promise<string[]> {
82
+ if (!ctx.auth?.sub) return []
83
+ try {
84
+ const rbac = ctx.container.resolve('rbacService') as RbacServiceLike | undefined
85
+ const features = await rbac?.getGrantedFeatures?.(ctx.auth.sub, {
86
+ tenantId: ctx.auth.tenantId,
87
+ organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId,
88
+ })
89
+ return features ?? []
90
+ } catch {
91
+ // rbacService not registered — guards without a feature gate still run.
92
+ return []
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Every guard the shared CRUD factory would run for this mutation: the ones
98
+ * modules register through `registerMutationGuards`, plus the legacy DI guard
99
+ * bridged on top. Consulting only the legacy bridge let a registered policy
100
+ * that denies an ordinary order write be bypassed through this route.
101
+ */
102
+ async function runGuards(
103
+ ctx: CommandRuntimeContext,
104
+ input: MutationGuardInput,
105
+ ): Promise<{
106
+ ok: boolean
107
+ errorBody?: Record<string, unknown>
108
+ errorStatus?: number
109
+ modifiedPayload?: Record<string, unknown>
110
+ afterSuccessCallbacks: Array<{ guard: MutationGuard; metadata: Record<string, unknown> | null }>
111
+ }> {
112
+ const guards = [...getAllMutationGuardInstances()]
113
+ const legacyGuard = bridgeLegacyGuard(ctx.container)
114
+ if (legacyGuard) guards.push(legacyGuard)
115
+ if (!guards.length || !ctx.auth?.tenantId) {
116
+ return { ok: true, afterSuccessCallbacks: [] }
117
+ }
118
+
119
+ return runMutationGuards(guards, input, {
120
+ userFeatures: await resolveGrantedFeatures(ctx),
121
+ })
122
+ }
123
+
124
+ /**
125
+ * These run after the batch has committed and after the success response —
126
+ * including its undo metadata — has been built. A throwing callback must not
127
+ * turn a committed write into a reported failure: the caller would retry, and
128
+ * entries without an id would append a second time. Each one is isolated and
129
+ * logged, exactly as the shared CRUD factory does.
130
+ */
131
+ async function runGuardAfterSuccessCallbacks(
132
+ callbacks: Array<{ guard: MutationGuard; metadata: Record<string, unknown> | null }>,
133
+ input: Omit<MutationGuardAfterInput, 'metadata'>,
134
+ ): Promise<void> {
135
+ for (const callback of callbacks) {
136
+ if (!callback.guard.afterSuccess) continue
137
+ try {
138
+ await callback.guard.afterSuccess({
139
+ ...input,
140
+ metadata: callback.metadata ?? null,
141
+ })
142
+ } catch (err) {
143
+ logger.error('Mutation guard afterSuccess failed', { guardId: callback.guard.id, err })
144
+ reportBatchRouteError(err, 'sales.order_lines_guard_after_success_failed', {
145
+ guardId: callback.guard.id,
146
+ })
147
+ }
148
+ }
149
+ }
150
+
151
+ async function resolveRequestContext(req: Request): Promise<CommandRuntimeContext> {
152
+ const container = await createRequestContainer()
153
+ const auth = await getAuthFromRequest(req)
154
+ const { translate } = await resolveTranslations()
155
+
156
+ if (!auth || !auth.tenantId) {
157
+ throw new CrudHttpError(401, { error: translate('sales.documents.errors.unauthorized', 'Unauthorized') })
158
+ }
159
+
160
+ const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })
161
+ if (scope?.selectionRejected) {
162
+ throw new CrudHttpError(422, {
163
+ error: translate(
164
+ 'sales.documents.errors.organization_selection_invalid',
165
+ 'Your selected organization is no longer available. Please re-select an organization and try again.',
166
+ ),
167
+ code: 'organization_selection_invalid',
168
+ })
169
+ }
170
+ const organizationId = scope ? scope.selectedId : auth.orgId ?? null
171
+ if (!organizationId) {
172
+ throw new CrudHttpError(400, {
173
+ error: translate('sales.documents.errors.organization_required', 'Organization context is required'),
174
+ })
175
+ }
176
+
177
+ return {
178
+ container,
179
+ auth,
180
+ organizationScope: scope,
181
+ selectedOrganizationId: organizationId,
182
+ organizationIds: scope?.filterIds ?? (auth.orgId ? [auth.orgId] : null),
183
+ request: req,
184
+ }
185
+ }
186
+
187
+ export async function POST(req: Request) {
188
+ try {
189
+ const ctx = await resolveRequestContext(req)
190
+ const { translate } = await resolveTranslations()
191
+ const payload = await readJsonSafe(req, {})
192
+ const scoped = withScopedPayload(payload ?? {}, ctx, translate)
193
+ let input = orderLineBulkUpsertSchema.parse(scoped)
194
+
195
+ const guardScope = {
196
+ tenantId: ctx.auth?.tenantId ?? '',
197
+ organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
198
+ userId: ctx.auth?.sub ?? '',
199
+ resourceKind: RESOURCE_KIND,
200
+ resourceId: input.orderId,
201
+ operation: 'update' as const,
202
+ requestMethod: req.method,
203
+ requestHeaders: req.headers,
204
+ } satisfies Omit<MutationGuardAfterInput, 'metadata'>
205
+ const guardResult = await runGuards(ctx, {
206
+ ...guardScope,
207
+ mutationPayload: input as Record<string, unknown>,
208
+ })
209
+ if (!guardResult.ok) {
210
+ return NextResponse.json(
211
+ guardResult.errorBody ?? { error: translate('sales.documents.errors.guard_blocked', 'Operation blocked by guard') },
212
+ { status: guardResult.errorStatus ?? 422 },
213
+ )
214
+ }
215
+ if (guardResult.modifiedPayload) {
216
+ input = orderLineBulkUpsertSchema.parse({ ...input, ...guardResult.modifiedPayload })
217
+ }
218
+
219
+ const commandBus = ctx.container.resolve('commandBus') as CommandBus
220
+ const { result, logEntry } = await commandBus.execute<
221
+ { body: z.infer<typeof orderLineBulkUpsertSchema> },
222
+ BulkUpsertResult
223
+ >(COMMAND_ID, { input: { body: input }, ctx })
224
+
225
+ const orderId = result?.orderId ?? input.orderId
226
+ const jsonResponse = NextResponse.json({ orderId, lineIds: result?.lineIds ?? [] })
227
+
228
+ if (logEntry?.undoToken && logEntry?.id && logEntry?.commandId) {
229
+ jsonResponse.headers.set(
230
+ 'x-om-operation',
231
+ serializeOperationMetadata({
232
+ id: logEntry.id,
233
+ undoToken: logEntry.undoToken,
234
+ commandId: logEntry.commandId,
235
+ actionLabel: logEntry.actionLabel ?? null,
236
+ resourceKind: logEntry.resourceKind ?? RESOURCE_KIND,
237
+ resourceId: logEntry.resourceId ?? orderId,
238
+ executedAt: logEntry.createdAt instanceof Date
239
+ ? logEntry.createdAt.toISOString()
240
+ : typeof logEntry.createdAt === 'string'
241
+ ? logEntry.createdAt
242
+ : new Date().toISOString(),
243
+ })
244
+ )
245
+ }
246
+
247
+ if (guardResult.afterSuccessCallbacks.length) {
248
+ await runGuardAfterSuccessCallbacks(guardResult.afterSuccessCallbacks, guardScope)
249
+ }
250
+
251
+ return jsonResponse
252
+ } catch (err) {
253
+ if (isCrudHttpError(err)) {
254
+ return NextResponse.json(err.body, { status: err.status })
255
+ }
256
+ if (err instanceof z.ZodError) {
257
+ const { translate } = await resolveTranslations()
258
+ return NextResponse.json(
259
+ {
260
+ error: translate('sales.documents.items.bulk.errorInvalidInput', 'Invalid input'),
261
+ details: localizeIssues(err.issues, translate),
262
+ },
263
+ { status: 400 },
264
+ )
265
+ }
266
+ const interceptorRejection = getCommandInterceptorHttpRejection(err)
267
+ if (interceptorRejection) {
268
+ return NextResponse.json(interceptorRejection.body, { status: interceptorRejection.status })
269
+ }
270
+ const { translate } = await resolveTranslations()
271
+ logger.error('sales.orders.lines.upsert_many failed', { err })
272
+ reportBatchRouteError(err, 'sales.order_lines_batch_failed')
273
+ return NextResponse.json(
274
+ { error: translate('sales.documents.items.errorSaveBatch', 'Failed to save order lines.') },
275
+ { status: 400 }
276
+ )
277
+ }
278
+ }
279
+
280
+ const bulkUpsertResponseSchema = z.object({
281
+ orderId: z.string().uuid(),
282
+ lineIds: z.array(z.string().uuid()),
283
+ })
284
+
285
+ export const openApi: OpenApiRouteDoc = {
286
+ tag: 'Sales',
287
+ summary: 'Bulk upsert order lines',
288
+ methods: {
289
+ POST: {
290
+ summary: 'Upsert and delete order lines in one pass',
291
+ description:
292
+ 'Writes a whole order line set in one aggregate load: entries carrying an id update that line, '
293
+ + 'entries without one are appended, and deleteIds removes lines. The batch is all-or-nothing — '
294
+ + 'any refused entry aborts it — and the order is renumbered 1..n, its totals recalculated and '
295
+ + 'one undoable audit entry recorded.',
296
+ requestBody: {
297
+ contentType: 'application/json',
298
+ schema: orderLineBulkUpsertSchema,
299
+ },
300
+ responses: [
301
+ { status: 200, description: 'Batch applied', schema: bulkUpsertResponseSchema },
302
+ { status: 400, description: 'Invalid payload', schema: z.object({ error: z.string(), details: z.array(z.unknown()).optional() }) },
303
+ { status: 401, description: 'Unauthorized', schema: z.object({ error: z.string() }) },
304
+ { status: 403, description: 'Forbidden', schema: z.object({ error: z.string() }) },
305
+ { status: 404, description: 'Order or line not found', schema: z.object({ error: z.string() }) },
306
+ { status: 409, description: 'Conflict detected', schema: z.object({ error: z.string(), code: z.string().optional() }) },
307
+ { status: 422, description: 'Selected organization is no longer available', schema: z.object({ error: z.string(), code: z.literal('organization_selection_invalid') }) },
308
+ { status: 423, description: 'Record locked', schema: z.object({ error: z.string(), code: z.string().optional() }) },
309
+ ],
310
+ },
311
+ },
312
+ }