@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/cart/lib/promotionContext.js +3 -2
- package/dist/modules/cart/lib/promotionContext.js.map +2 -2
- package/dist/modules/promotions/lib/promotions-service.js +5 -1
- package/dist/modules/promotions/lib/promotions-service.js.map +2 -2
- package/dist/modules/promotions/services/promotion-cache.js +35 -2
- package/dist/modules/promotions/services/promotion-cache.js.map +3 -3
- package/dist/modules/sales/api/order-lines/batch/route.js +227 -0
- package/dist/modules/sales/api/order-lines/batch/route.js.map +7 -0
- package/dist/modules/sales/commands/documents.js +356 -129
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/sales/data/validators.js +53 -0
- package/dist/modules/sales/data/validators.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/cart/lib/promotionContext.ts +3 -7
- package/src/modules/promotions/lib/promotions-service.ts +5 -3
- package/src/modules/promotions/services/promotion-cache.ts +41 -4
- package/src/modules/sales/api/order-lines/batch/route.ts +312 -0
- package/src/modules/sales/commands/documents.ts +504 -179
- package/src/modules/sales/data/validators.ts +71 -0
- package/src/modules/sales/i18n/de.json +9 -0
- package/src/modules/sales/i18n/en.json +9 -0
- package/src/modules/sales/i18n/es.json +9 -0
- package/src/modules/sales/i18n/ko.json +9 -0
- package/src/modules/sales/i18n/pl.json +9 -0
|
@@ -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
|
+
}
|