@open-mercato/core 0.10.1-develop.7785.1.ec0898da36 → 0.10.1-develop.7787.1.96c7cfc66f

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.
@@ -7,10 +7,7 @@ import { resolveSingleOrganizationIdOrDeny } from "@open-mercato/core/modules/di
7
7
  import { serializeOperationMetadata } from "@open-mercato/shared/lib/commands/operationMetadata";
8
8
  import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
9
9
  import { CrudHttpError, isCrudHttpError } from "@open-mercato/shared/lib/crud/errors";
10
- import {
11
- bridgeLegacyGuard,
12
- runMutationGuards
13
- } from "@open-mercato/shared/lib/crud/mutation-guard-registry";
10
+ import { runRouteMutationGuards } from "@open-mercato/shared/lib/crud/route-mutation-guard";
14
11
  import { withScopedPayload } from "../../utils.js";
15
12
  import { createLogger } from "@open-mercato/shared/lib/logger";
16
13
  import { getCommandInterceptorHttpRejection } from "@open-mercato/shared/lib/commands/errors";
@@ -23,29 +20,6 @@ const convertSchema = z.object({
23
20
  const metadata = {
24
21
  POST: { requireAuth: true, requireFeatures: ["sales.quotes.manage", "sales.orders.manage"] }
25
22
  };
26
- function resolveUserFeatures(auth) {
27
- const features = auth?.features;
28
- if (!Array.isArray(features)) return [];
29
- return features.filter((value) => typeof value === "string");
30
- }
31
- async function runGuards(ctx, input) {
32
- const legacyGuard = bridgeLegacyGuard(ctx.container);
33
- if (!legacyGuard) {
34
- return { ok: true, afterSuccessCallbacks: [] };
35
- }
36
- return runMutationGuards([legacyGuard], input, {
37
- userFeatures: resolveUserFeatures(ctx.auth)
38
- });
39
- }
40
- async function runGuardAfterSuccessCallbacks(callbacks, input) {
41
- for (const callback of callbacks) {
42
- if (!callback.guard.afterSuccess) continue;
43
- await callback.guard.afterSuccess({
44
- ...input,
45
- metadata: callback.metadata ?? null
46
- });
47
- }
48
- }
49
23
  async function resolveRequestContext(req) {
50
24
  const container = await createRequestContainer();
51
25
  const auth = await getAuthFromRequest(req);
@@ -77,18 +51,23 @@ async function POST(req) {
77
51
  const payload = await req.json().catch(() => ({}));
78
52
  const scoped = withScopedPayload(payload ?? {}, ctx, translate);
79
53
  const input = convertSchema.parse(scoped);
80
- const guardResult = await runGuards(ctx, {
81
- tenantId: ctx.auth?.tenantId ?? "",
82
- organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
83
- userId: ctx.auth?.sub ?? "",
84
- resourceKind: "sales.quote",
85
- resourceId: input.quoteId,
86
- operation: "update",
87
- requestMethod: req.method,
88
- requestHeaders: req.headers
54
+ const guardResult = await runRouteMutationGuards({
55
+ container: ctx.container,
56
+ req,
57
+ auth: {
58
+ tenantId: ctx.auth?.tenantId ?? "",
59
+ organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
60
+ userId: ctx.auth?.sub ?? ""
61
+ },
62
+ input: {
63
+ resourceKind: "sales.quote",
64
+ resourceId: input.quoteId,
65
+ operation: "update",
66
+ mutationPayload: input
67
+ }
89
68
  });
90
69
  if (!guardResult.ok) {
91
- return NextResponse.json(guardResult.errorBody ?? { error: "Operation blocked by guard" }, { status: guardResult.errorStatus ?? 422 });
70
+ return guardResult.response;
92
71
  }
93
72
  const commandBus = ctx.container.resolve("commandBus");
94
73
  const { result, logEntry } = await commandBus.execute("sales.quotes.convert_to_order", { input, ctx });
@@ -108,18 +87,7 @@ async function POST(req) {
108
87
  })
109
88
  );
110
89
  }
111
- if (guardResult.afterSuccessCallbacks.length) {
112
- await runGuardAfterSuccessCallbacks(guardResult.afterSuccessCallbacks, {
113
- tenantId: ctx.auth?.tenantId ?? "",
114
- organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
115
- userId: ctx.auth?.sub ?? "",
116
- resourceKind: "sales.quote",
117
- resourceId: input.quoteId,
118
- operation: "update",
119
- requestMethod: req.method,
120
- requestHeaders: req.headers
121
- });
122
- }
90
+ await guardResult.runAfterSuccess();
123
91
  return jsonResponse;
124
92
  } catch (err) {
125
93
  if (isCrudHttpError(err)) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../src/modules/sales/api/quotes/convert/route.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { resolveSingleOrganizationIdOrDeny } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'\nimport { serializeOperationMetadata } from '@open-mercato/shared/lib/commands/operationMetadata'\nimport type { CommandBus, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport {\n bridgeLegacyGuard,\n runMutationGuards,\n type MutationGuard,\n type MutationGuardInput,\n} from '@open-mercato/shared/lib/crud/mutation-guard-registry'\nimport { withScopedPayload } from '../../utils'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { getCommandInterceptorHttpRejection } from '@open-mercato/shared/lib/commands/errors'\n\nconst logger = createLogger('sales')\n\nconst convertSchema = z.object({\n quoteId: z.string().uuid(),\n orderId: z.string().uuid().optional(),\n orderNumber: z.string().trim().max(191).optional(),\n})\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['sales.quotes.manage', 'sales.orders.manage'] },\n}\n\ntype RequestContext = {\n ctx: CommandRuntimeContext\n}\n\nfunction resolveUserFeatures(auth: unknown): string[] {\n const features = (auth as { features?: unknown })?.features\n if (!Array.isArray(features)) return []\n return features.filter((value): value is string => typeof value === 'string')\n}\n\nasync function runGuards(\n ctx: CommandRuntimeContext,\n input: MutationGuardInput,\n): Promise<{\n ok: boolean\n errorBody?: Record<string, unknown>\n errorStatus?: number\n afterSuccessCallbacks: Array<{ guard: MutationGuard; metadata: Record<string, unknown> | null }>\n}> {\n const legacyGuard = bridgeLegacyGuard(ctx.container)\n if (!legacyGuard) {\n return { ok: true, afterSuccessCallbacks: [] }\n }\n\n return runMutationGuards([legacyGuard], input, {\n userFeatures: resolveUserFeatures(ctx.auth),\n })\n}\n\nasync function runGuardAfterSuccessCallbacks(\n callbacks: Array<{ guard: MutationGuard; metadata: Record<string, unknown> | null }>,\n input: {\n tenantId: string\n organizationId: string | null\n userId: string\n resourceKind: string\n resourceId: string\n operation: 'create' | 'update' | 'delete'\n requestMethod: string\n requestHeaders: Headers\n },\n): Promise<void> {\n for (const callback of callbacks) {\n if (!callback.guard.afterSuccess) continue\n await callback.guard.afterSuccess({\n ...input,\n metadata: callback.metadata ?? null,\n })\n }\n}\n\nasync function resolveRequestContext(req: Request): Promise<RequestContext> {\n const container = await createRequestContainer()\n const auth = await getAuthFromRequest(req)\n const { translate } = await resolveTranslations()\n\n if (!auth || !auth.tenantId) {\n throw new CrudHttpError(401, { error: translate('sales.documents.errors.unauthorized', 'Unauthorized') })\n }\n\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n const organizationId = resolveSingleOrganizationIdOrDeny(scope, auth) ?? null\n if (!organizationId) {\n throw new CrudHttpError(400, {\n error: translate('sales.documents.errors.organization_required', 'Organization context is required'),\n })\n }\n\n const ctx: CommandRuntimeContext = {\n container,\n auth,\n organizationScope: scope,\n selectedOrganizationId: organizationId,\n organizationIds: scope?.filterIds ?? (auth.orgId ? [auth.orgId] : null),\n request: req,\n }\n\n return { ctx }\n}\n\nexport async function POST(req: Request) {\n try {\n const { ctx } = await resolveRequestContext(req)\n const { translate } = await resolveTranslations()\n const payload = await req.json().catch(() => ({}))\n const scoped = withScopedPayload(payload ?? {}, ctx, translate)\n const input = convertSchema.parse(scoped)\n const guardResult = await runGuards(ctx, {\n tenantId: ctx.auth?.tenantId ?? '',\n organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,\n userId: ctx.auth?.sub ?? '',\n resourceKind: 'sales.quote',\n resourceId: input.quoteId,\n operation: 'update',\n requestMethod: req.method,\n requestHeaders: req.headers,\n })\n if (!guardResult.ok) {\n return NextResponse.json(guardResult.errorBody ?? { error: 'Operation blocked by guard' }, { status: guardResult.errorStatus ?? 422 })\n }\n const commandBus = ctx.container.resolve('commandBus') as CommandBus\n const { result, logEntry } = await commandBus.execute<\n { quoteId: string; orderId?: string; orderNumber?: string },\n { orderId: string }\n >('sales.quotes.convert_to_order', { input, ctx })\n\n const orderId = result?.orderId ?? input.orderId ?? input.quoteId\n const jsonResponse = NextResponse.json({ orderId })\n\n if (logEntry?.undoToken && logEntry?.id && logEntry?.commandId) {\n jsonResponse.headers.set(\n 'x-om-operation',\n serializeOperationMetadata({\n id: logEntry.id,\n undoToken: logEntry.undoToken,\n commandId: logEntry.commandId,\n actionLabel: logEntry.actionLabel ?? null,\n resourceKind: logEntry.resourceKind ?? 'sales.order',\n resourceId: logEntry.resourceId ?? orderId,\n executedAt: logEntry.createdAt instanceof Date\n ? logEntry.createdAt.toISOString()\n : typeof logEntry.createdAt === 'string'\n ? logEntry.createdAt\n : new Date().toISOString(),\n })\n )\n }\n\n if (guardResult.afterSuccessCallbacks.length) {\n await runGuardAfterSuccessCallbacks(guardResult.afterSuccessCallbacks, {\n tenantId: ctx.auth?.tenantId ?? '',\n organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,\n userId: ctx.auth?.sub ?? '',\n resourceKind: 'sales.quote',\n resourceId: input.quoteId,\n operation: 'update',\n requestMethod: req.method,\n requestHeaders: req.headers,\n })\n }\n\n return jsonResponse\n } catch (err) {\n if (isCrudHttpError(err)) {\n return NextResponse.json(err.body, { status: err.status })\n }\n const interceptorRejection = getCommandInterceptorHttpRejection(err)\n if (interceptorRejection) {\n return NextResponse.json(interceptorRejection.body, { status: interceptorRejection.status })\n }\n const { translate } = await resolveTranslations()\n logger.error('sales.quotes.convert failed', { err })\n return NextResponse.json(\n { error: translate('sales.documents.detail.convertError', 'Failed to convert quote.') },\n { status: 400 }\n )\n }\n}\n\nconst convertResponseSchema = z.object({\n orderId: z.string().uuid(),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Sales',\n summary: 'Convert quote to order',\n methods: {\n POST: {\n summary: 'Convert quote',\n description: 'Creates a sales order from a quote and removes the original quote record.',\n requestBody: {\n contentType: 'application/json',\n schema: convertSchema,\n },\n responses: [\n { status: 200, description: 'Conversion succeeded', schema: convertResponseSchema },\n { status: 400, description: 'Invalid payload', schema: z.object({ error: z.string() }) },\n { status: 401, description: 'Unauthorized', schema: z.object({ error: z.string() }) },\n { status: 403, description: 'Forbidden', schema: z.object({ error: z.string() }) },\n { status: 409, description: 'Conflict detected', schema: z.object({ error: z.string(), code: z.string().optional() }) },\n { status: 423, description: 'Record locked', schema: z.object({ error: z.string(), code: z.string().optional() }) },\n ],\n },\n },\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { resolveSingleOrganizationIdOrDeny } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'\nimport { serializeOperationMetadata } from '@open-mercato/shared/lib/commands/operationMetadata'\nimport type { CommandBus, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { runRouteMutationGuards } from '@open-mercato/shared/lib/crud/route-mutation-guard'\nimport { withScopedPayload } from '../../utils'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { getCommandInterceptorHttpRejection } from '@open-mercato/shared/lib/commands/errors'\n\nconst logger = createLogger('sales')\n\nconst convertSchema = z.object({\n quoteId: z.string().uuid(),\n orderId: z.string().uuid().optional(),\n orderNumber: z.string().trim().max(191).optional(),\n})\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['sales.quotes.manage', 'sales.orders.manage'] },\n}\n\ntype RequestContext = {\n ctx: CommandRuntimeContext\n}\n\nasync function resolveRequestContext(req: Request): Promise<RequestContext> {\n const container = await createRequestContainer()\n const auth = await getAuthFromRequest(req)\n const { translate } = await resolveTranslations()\n\n if (!auth || !auth.tenantId) {\n throw new CrudHttpError(401, { error: translate('sales.documents.errors.unauthorized', 'Unauthorized') })\n }\n\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n const organizationId = resolveSingleOrganizationIdOrDeny(scope, auth) ?? null\n if (!organizationId) {\n throw new CrudHttpError(400, {\n error: translate('sales.documents.errors.organization_required', 'Organization context is required'),\n })\n }\n\n const ctx: CommandRuntimeContext = {\n container,\n auth,\n organizationScope: scope,\n selectedOrganizationId: organizationId,\n organizationIds: scope?.filterIds ?? (auth.orgId ? [auth.orgId] : null),\n request: req,\n }\n\n return { ctx }\n}\n\nexport async function POST(req: Request) {\n try {\n const { ctx } = await resolveRequestContext(req)\n const { translate } = await resolveTranslations()\n const payload = await req.json().catch(() => ({}))\n const scoped = withScopedPayload(payload ?? {}, ctx, translate)\n const input = convertSchema.parse(scoped)\n const guardResult = await runRouteMutationGuards({\n container: ctx.container,\n req,\n auth: {\n tenantId: ctx.auth?.tenantId ?? '',\n organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,\n userId: ctx.auth?.sub ?? '',\n },\n input: {\n resourceKind: 'sales.quote',\n resourceId: input.quoteId,\n operation: 'update',\n mutationPayload: input,\n },\n })\n if (!guardResult.ok) {\n return guardResult.response\n }\n const commandBus = ctx.container.resolve('commandBus') as CommandBus\n const { result, logEntry } = await commandBus.execute<\n { quoteId: string; orderId?: string; orderNumber?: string },\n { orderId: string }\n >('sales.quotes.convert_to_order', { input, ctx })\n\n const orderId = result?.orderId ?? input.orderId ?? input.quoteId\n const jsonResponse = NextResponse.json({ orderId })\n\n if (logEntry?.undoToken && logEntry?.id && logEntry?.commandId) {\n jsonResponse.headers.set(\n 'x-om-operation',\n serializeOperationMetadata({\n id: logEntry.id,\n undoToken: logEntry.undoToken,\n commandId: logEntry.commandId,\n actionLabel: logEntry.actionLabel ?? null,\n resourceKind: logEntry.resourceKind ?? 'sales.order',\n resourceId: logEntry.resourceId ?? orderId,\n executedAt: logEntry.createdAt instanceof Date\n ? logEntry.createdAt.toISOString()\n : typeof logEntry.createdAt === 'string'\n ? logEntry.createdAt\n : new Date().toISOString(),\n })\n )\n }\n\n await guardResult.runAfterSuccess()\n\n return jsonResponse\n } catch (err) {\n if (isCrudHttpError(err)) {\n return NextResponse.json(err.body, { status: err.status })\n }\n const interceptorRejection = getCommandInterceptorHttpRejection(err)\n if (interceptorRejection) {\n return NextResponse.json(interceptorRejection.body, { status: interceptorRejection.status })\n }\n const { translate } = await resolveTranslations()\n logger.error('sales.quotes.convert failed', { err })\n return NextResponse.json(\n { error: translate('sales.documents.detail.convertError', 'Failed to convert quote.') },\n { status: 400 }\n )\n }\n}\n\nconst convertResponseSchema = z.object({\n orderId: z.string().uuid(),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Sales',\n summary: 'Convert quote to order',\n methods: {\n POST: {\n summary: 'Convert quote',\n description: 'Creates a sales order from a quote and removes the original quote record.',\n requestBody: {\n contentType: 'application/json',\n schema: convertSchema,\n },\n responses: [\n { status: 200, description: 'Conversion succeeded', schema: convertResponseSchema },\n { status: 400, description: 'Invalid payload', schema: z.object({ error: z.string() }) },\n { status: 401, description: 'Unauthorized', schema: z.object({ error: z.string() }) },\n { status: 403, description: 'Forbidden', schema: z.object({ error: z.string() }) },\n { status: 409, description: 'Conflict detected', schema: z.object({ error: z.string(), code: z.string().optional() }) },\n { status: 423, description: 'Record locked', schema: z.object({ error: z.string(), code: z.string().optional() }) },\n ],\n },\n },\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -6,10 +6,7 @@ import { resolveOrganizationScopeForRequest } from "@open-mercato/core/modules/d
6
6
  import { resolveSingleOrganizationIdOrDeny } from "@open-mercato/core/modules/directory/utils/organizationScopeFilter";
7
7
  import { resolveTranslations, detectLocale } from "@open-mercato/shared/lib/i18n/server";
8
8
  import { CrudHttpError, isCrudHttpError, notFound } from "@open-mercato/shared/lib/crud/errors";
9
- import {
10
- bridgeLegacyGuard,
11
- runMutationGuards
12
- } from "@open-mercato/shared/lib/crud/mutation-guard-registry";
9
+ import { runRouteMutationGuards } from "@open-mercato/shared/lib/crud/route-mutation-guard";
13
10
  import crypto from "node:crypto";
14
11
  import { withScopedPayload } from "../../utils.js";
15
12
  import { hashAuthToken } from "../../../../auth/lib/tokenHash.js";
@@ -25,29 +22,6 @@ const logger = createLogger("sales");
25
22
  const metadata = {
26
23
  POST: { requireAuth: true, requireFeatures: ["sales.quotes.manage"] }
27
24
  };
28
- function resolveUserFeatures(auth) {
29
- const features = auth?.features;
30
- if (!Array.isArray(features)) return [];
31
- return features.filter((value) => typeof value === "string");
32
- }
33
- async function runGuards(ctx, input) {
34
- const legacyGuard = bridgeLegacyGuard(ctx.container);
35
- if (!legacyGuard) {
36
- return { ok: true, afterSuccessCallbacks: [] };
37
- }
38
- return runMutationGuards([legacyGuard], input, {
39
- userFeatures: resolveUserFeatures(ctx.auth)
40
- });
41
- }
42
- async function runGuardAfterSuccessCallbacks(callbacks, input) {
43
- for (const callback of callbacks) {
44
- if (!callback.guard.afterSuccess) continue;
45
- await callback.guard.afterSuccess({
46
- ...input,
47
- metadata: callback.metadata ?? null
48
- });
49
- }
50
- }
51
25
  async function resolveRequestContext(req) {
52
26
  const container = await createRequestContainer();
53
27
  const auth = await getAuthFromRequest(req);
@@ -95,18 +69,23 @@ async function POST(req) {
95
69
  const payload = await req.json().catch(() => ({}));
96
70
  const scoped = withScopedPayload(payload ?? {}, ctx, translate);
97
71
  const input = quoteSendSchema.parse(scoped);
98
- const guardResult = await runGuards(ctx, {
99
- tenantId: ctx.auth?.tenantId ?? "",
100
- organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
101
- userId: ctx.auth?.sub ?? "",
102
- resourceKind: "sales.quote",
103
- resourceId: input.quoteId,
104
- operation: "update",
105
- requestMethod: req.method,
106
- requestHeaders: req.headers
72
+ const guardResult = await runRouteMutationGuards({
73
+ container: ctx.container,
74
+ req,
75
+ auth: {
76
+ tenantId: ctx.auth?.tenantId ?? "",
77
+ organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
78
+ userId: ctx.auth?.sub ?? ""
79
+ },
80
+ input: {
81
+ resourceKind: "sales.quote",
82
+ resourceId: input.quoteId,
83
+ operation: "update",
84
+ mutationPayload: input
85
+ }
107
86
  });
108
87
  if (!guardResult.ok) {
109
- return NextResponse.json(guardResult.errorBody ?? { error: "Operation blocked by guard" }, { status: guardResult.errorStatus ?? 422 });
88
+ return guardResult.response;
110
89
  }
111
90
  const em = ctx.container.resolve("em").fork();
112
91
  const tenantScope = ctx.auth?.tenantId ? { tenantId: ctx.auth.tenantId, organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null } : void 0;
@@ -167,18 +146,7 @@ async function POST(req) {
167
146
  tenantId: quote.tenantId,
168
147
  organizationId: quote.organizationId
169
148
  });
170
- if (guardResult.afterSuccessCallbacks.length) {
171
- await runGuardAfterSuccessCallbacks(guardResult.afterSuccessCallbacks, {
172
- tenantId: ctx.auth?.tenantId ?? "",
173
- organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
174
- userId: ctx.auth?.sub ?? "",
175
- resourceKind: "sales.quote",
176
- resourceId: input.quoteId,
177
- operation: "update",
178
- requestMethod: req.method,
179
- requestHeaders: req.headers
180
- });
181
- }
149
+ await guardResult.runAfterSuccess();
182
150
  return NextResponse.json({ ok: true });
183
151
  } catch (err) {
184
152
  if (isCrudHttpError(err)) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../src/modules/sales/api/quotes/send/route.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { resolveSingleOrganizationIdOrDeny } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'\nimport type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { resolveTranslations, detectLocale } from '@open-mercato/shared/lib/i18n/server'\nimport { CrudHttpError, isCrudHttpError, notFound } from '@open-mercato/shared/lib/crud/errors'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport {\n bridgeLegacyGuard,\n runMutationGuards,\n type MutationGuard,\n type MutationGuardInput,\n} from '@open-mercato/shared/lib/crud/mutation-guard-registry'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport crypto from 'node:crypto'\nimport { withScopedPayload } from '../../utils'\nimport { hashAuthToken } from '../../../../auth/lib/tokenHash'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { parseDecryptedFieldValue } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'\nimport { SalesQuote } from '../../../data/entities'\nimport { quoteSendSchema } from '../../../data/validators'\nimport { sendEmail } from '@open-mercato/shared/lib/email/send'\nimport { resolveStatusEntryIdByValue } from '../../../lib/statusHelpers'\nimport { QuoteSentEmail } from '../../../emails/QuoteSentEmail'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('sales')\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['sales.quotes.manage'] },\n}\n\ntype RequestContext = {\n ctx: CommandRuntimeContext\n}\n\nfunction resolveUserFeatures(auth: unknown): string[] {\n const features = (auth as { features?: unknown })?.features\n if (!Array.isArray(features)) return []\n return features.filter((value): value is string => typeof value === 'string')\n}\n\nasync function runGuards(\n ctx: CommandRuntimeContext,\n input: MutationGuardInput,\n): Promise<{\n ok: boolean\n errorBody?: Record<string, unknown>\n errorStatus?: number\n afterSuccessCallbacks: Array<{ guard: MutationGuard; metadata: Record<string, unknown> | null }>\n}> {\n const legacyGuard = bridgeLegacyGuard(ctx.container)\n if (!legacyGuard) {\n return { ok: true, afterSuccessCallbacks: [] }\n }\n\n return runMutationGuards([legacyGuard], input, {\n userFeatures: resolveUserFeatures(ctx.auth),\n })\n}\n\nasync function runGuardAfterSuccessCallbacks(\n callbacks: Array<{ guard: MutationGuard; metadata: Record<string, unknown> | null }>,\n input: {\n tenantId: string\n organizationId: string | null\n userId: string\n resourceKind: string\n resourceId: string\n operation: 'create' | 'update' | 'delete'\n requestMethod: string\n requestHeaders: Headers\n },\n): Promise<void> {\n for (const callback of callbacks) {\n if (!callback.guard.afterSuccess) continue\n await callback.guard.afterSuccess({\n ...input,\n metadata: callback.metadata ?? null,\n })\n }\n}\n\nasync function resolveRequestContext(req: Request): Promise<RequestContext> {\n const container = await createRequestContainer()\n const auth = await getAuthFromRequest(req)\n const { translate } = await resolveTranslations()\n\n if (!auth || !auth.tenantId) {\n throw new CrudHttpError(401, { error: translate('sales.documents.errors.unauthorized', 'Unauthorized') })\n }\n\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n const organizationId = resolveSingleOrganizationIdOrDeny(scope, auth) ?? null\n if (!organizationId) {\n throw new CrudHttpError(400, {\n error: translate('sales.documents.errors.organization_required', 'Organization context is required'),\n })\n }\n\n const ctx: CommandRuntimeContext = {\n container,\n auth,\n organizationScope: scope,\n selectedOrganizationId: organizationId,\n organizationIds: scope?.filterIds ?? (auth.orgId ? [auth.orgId] : null),\n request: req,\n }\n\n return { ctx }\n}\n\nfunction resolveQuoteEmail(quote: SalesQuote): string | null {\n const normalizeRecord = (value: unknown): Record<string, unknown> | null => {\n if (value && typeof value === 'object' && !Array.isArray(value)) return value as Record<string, unknown>\n if (typeof value !== 'string') return null\n const parsed = parseDecryptedFieldValue(value)\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed)\n ? parsed as Record<string, unknown>\n : null\n }\n const snapshot = normalizeRecord(quote.customerSnapshot)\n const metadata = normalizeRecord(quote.metadata)\n const contact = snapshot?.contact as Record<string, unknown> | undefined\n const customer = snapshot?.customer as Record<string, unknown> | undefined\n const candidate =\n (typeof contact?.email === 'string' && contact.email.trim()) ||\n (typeof customer?.primaryEmail === 'string' && customer.primaryEmail.trim()) ||\n (typeof metadata?.customerEmail === 'string' && metadata.customerEmail.trim()) ||\n null\n if (!candidate) return null\n const parsed = z.string().email().safeParse(candidate)\n return parsed.success ? parsed.data : null\n}\n\nexport async function POST(req: Request) {\n try {\n const { ctx } = await resolveRequestContext(req)\n const { translate } = await resolveTranslations()\n const payload = await req.json().catch(() => ({}))\n const scoped = withScopedPayload(payload ?? {}, ctx, translate)\n const input = quoteSendSchema.parse(scoped)\n const guardResult = await runGuards(ctx, {\n tenantId: ctx.auth?.tenantId ?? '',\n organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,\n userId: ctx.auth?.sub ?? '',\n resourceKind: 'sales.quote',\n resourceId: input.quoteId,\n operation: 'update',\n requestMethod: req.method,\n requestHeaders: req.headers,\n })\n if (!guardResult.ok) {\n return NextResponse.json(guardResult.errorBody ?? { error: 'Operation blocked by guard' }, { status: guardResult.errorStatus ?? 422 })\n }\n\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n const tenantScope = ctx.auth?.tenantId\n ? { tenantId: ctx.auth.tenantId, organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null }\n : undefined\n const quote = await findOneWithDecryption(em, SalesQuote, { id: input.quoteId, deletedAt: null }, {}, tenantScope)\n if (!quote) {\n throw notFound(translate('sales.documents.detail.error', 'Document not found or inaccessible.'))\n }\n if (quote.tenantId !== ctx.auth?.tenantId || quote.organizationId !== ctx.selectedOrganizationId) {\n throw new CrudHttpError(403, { error: translate('sales.documents.errors.forbidden', 'Forbidden') })\n }\n\n if ((quote.status ?? null) === 'canceled') {\n throw new CrudHttpError(400, { error: translate('sales.quotes.send.canceled', 'Canceled quotes cannot be sent.') })\n }\n\n const email = resolveQuoteEmail(quote)\n if (!email) {\n throw new CrudHttpError(400, { error: translate('sales.quotes.send.missingEmail', 'Customer email is required to send a quote.') })\n }\n\n const now = new Date()\n const validUntil = new Date(now)\n validUntil.setUTCDate(validUntil.getUTCDate() + input.validForDays)\n\n const rawAcceptanceToken = crypto.randomUUID()\n\n // Persist the send state (status/token/sentAt) atomically and commit it\n // BEFORE the email goes out, so a customer never receives a link whose\n // acceptance token was not durably stored.\n await em.transactional(async (tx) => {\n quote.validUntil = validUntil\n quote.acceptanceToken = hashAuthToken(rawAcceptanceToken)\n quote.sentAt = now\n quote.status = 'sent'\n quote.statusEntryId = await resolveStatusEntryIdByValue(tx, {\n tenantId: quote.tenantId,\n organizationId: quote.organizationId,\n value: 'sent',\n })\n quote.updatedAt = now\n tx.persist(quote)\n })\n\n const appUrl = process.env.APP_URL || ''\n const url = appUrl ? `${appUrl.replace(/\\/$/, '')}/quote/${rawAcceptanceToken}` : `/quote/${rawAcceptanceToken}`\n\n const locale = await detectLocale()\n const validUntilFormatted = validUntil.toLocaleDateString(locale, {\n year: 'numeric',\n month: 'long',\n day: 'numeric',\n })\n\n const copy = {\n preview: translate('sales.quotes.email.preview', 'Quote {quoteNumber} is ready for review', { quoteNumber: quote.quoteNumber }),\n heading: translate('sales.quotes.email.heading', 'Quote {quoteNumber}', { quoteNumber: quote.quoteNumber }),\n total: translate('sales.quotes.email.total', 'Total: {amount} {currency}', {\n amount: quote.grandTotalGrossAmount ?? quote.grandTotalNetAmount ?? '0',\n currency: quote.currencyCode,\n }),\n validUntil: translate('sales.quotes.email.validUntil', 'Valid until: {date}', { date: validUntilFormatted }),\n cta: translate('sales.quotes.email.cta', 'View quote'),\n footer: translate('sales.quotes.email.footer', 'Open Mercato'),\n }\n\n // Side effect after commit: an email failure must not roll back the send state.\n await sendEmail({\n to: email,\n subject: translate('sales.quotes.email.subject', 'Quote {quoteNumber}', { quoteNumber: quote.quoteNumber }),\n react: QuoteSentEmail({ url, copy }),\n tenantId: quote.tenantId,\n organizationId: quote.organizationId,\n })\n\n if (guardResult.afterSuccessCallbacks.length) {\n await runGuardAfterSuccessCallbacks(guardResult.afterSuccessCallbacks, {\n tenantId: ctx.auth?.tenantId ?? '',\n organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,\n userId: ctx.auth?.sub ?? '',\n resourceKind: 'sales.quote',\n resourceId: input.quoteId,\n operation: 'update',\n requestMethod: req.method,\n requestHeaders: req.headers,\n })\n }\n\n return NextResponse.json({ ok: true })\n } catch (err) {\n if (isCrudHttpError(err)) {\n return NextResponse.json(err.body, { status: err.status })\n }\n const { translate } = await resolveTranslations()\n logger.error('sales.quotes.send failed', { err })\n return NextResponse.json(\n { error: translate('sales.quotes.send.failed', 'Failed to send quote.') },\n { status: 400 }\n )\n }\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Sales',\n summary: 'Send quote to customer',\n methods: {\n POST: {\n summary: 'Send quote',\n requestBody: {\n contentType: 'application/json',\n schema: quoteSendSchema,\n },\n responses: [\n { status: 200, description: 'Email queued', schema: z.object({ ok: z.literal(true) }) },\n { status: 400, description: 'Invalid payload', schema: z.object({ error: z.string() }) },\n { status: 401, description: 'Unauthorized', schema: z.object({ error: z.string() }) },\n { status: 403, description: 'Forbidden', schema: z.object({ error: z.string() }) },\n { status: 404, description: 'Not found', schema: z.object({ error: z.string() }) },\n { status: 409, description: 'Conflict detected', schema: z.object({ error: z.string(), code: z.string().optional() }) },\n { status: 423, description: 'Record locked', schema: z.object({ error: z.string(), code: z.string().optional() }) },\n ],\n },\n },\n}\n"],
5
- "mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAClB,SAAS,8BAA8B;AACvC,SAAS,0BAA0B;AACnC,SAAS,0CAA0C;AACnD,SAAS,yCAAyC;AAElD,SAAS,qBAAqB,oBAAoB;AAClD,SAAS,eAAe,iBAAiB,gBAAgB;AAEzD;AAAA,EACE;AAAA,EACA;AAAA,OAGK;AAEP,OAAO,YAAY;AACnB,SAAS,yBAAyB;AAClC,SAAS,qBAAqB;AAC9B,SAAS,6BAA6B;AACtC,SAAS,gCAAgC;AACzC,SAAS,kBAAkB;AAC3B,SAAS,uBAAuB;AAChC,SAAS,iBAAiB;AAC1B,SAAS,mCAAmC;AAC5C,SAAS,sBAAsB;AAC/B,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,OAAO;AAE5B,MAAM,WAAW;AAAA,EACtB,MAAM,EAAE,aAAa,MAAM,iBAAiB,CAAC,qBAAqB,EAAE;AACtE;AAMA,SAAS,oBAAoB,MAAyB;AACpD,QAAM,WAAY,MAAiC;AACnD,MAAI,CAAC,MAAM,QAAQ,QAAQ,EAAG,QAAO,CAAC;AACtC,SAAO,SAAS,OAAO,CAAC,UAA2B,OAAO,UAAU,QAAQ;AAC9E;AAEA,eAAe,UACb,KACA,OAMC;AACD,QAAM,cAAc,kBAAkB,IAAI,SAAS;AACnD,MAAI,CAAC,aAAa;AAChB,WAAO,EAAE,IAAI,MAAM,uBAAuB,CAAC,EAAE;AAAA,EAC/C;AAEA,SAAO,kBAAkB,CAAC,WAAW,GAAG,OAAO;AAAA,IAC7C,cAAc,oBAAoB,IAAI,IAAI;AAAA,EAC5C,CAAC;AACH;AAEA,eAAe,8BACb,WACA,OAUe;AACf,aAAW,YAAY,WAAW;AAChC,QAAI,CAAC,SAAS,MAAM,aAAc;AAClC,UAAM,SAAS,MAAM,aAAa;AAAA,MAChC,GAAG;AAAA,MACH,UAAU,SAAS,YAAY;AAAA,IACjC,CAAC;AAAA,EACH;AACF;AAEA,eAAe,sBAAsB,KAAuC;AAC1E,QAAM,YAAY,MAAM,uBAAuB;AAC/C,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,QAAM,EAAE,UAAU,IAAI,MAAM,oBAAoB;AAEhD,MAAI,CAAC,QAAQ,CAAC,KAAK,UAAU;AAC3B,UAAM,IAAI,cAAc,KAAK,EAAE,OAAO,UAAU,uCAAuC,cAAc,EAAE,CAAC;AAAA,EAC1G;AAEA,QAAM,QAAQ,MAAM,mCAAmC,EAAE,WAAW,MAAM,SAAS,IAAI,CAAC;AACxF,QAAM,iBAAiB,kCAAkC,OAAO,IAAI,KAAK;AACzE,MAAI,CAAC,gBAAgB;AACnB,UAAM,IAAI,cAAc,KAAK;AAAA,MAC3B,OAAO,UAAU,gDAAgD,kCAAkC;AAAA,IACrG,CAAC;AAAA,EACH;AAEA,QAAM,MAA6B;AAAA,IACjC;AAAA,IACA;AAAA,IACA,mBAAmB;AAAA,IACnB,wBAAwB;AAAA,IACxB,iBAAiB,OAAO,cAAc,KAAK,QAAQ,CAAC,KAAK,KAAK,IAAI;AAAA,IAClE,SAAS;AAAA,EACX;AAEA,SAAO,EAAE,IAAI;AACf;AAEA,SAAS,kBAAkB,OAAkC;AAC3D,QAAM,kBAAkB,CAAC,UAAmD;AAC1E,QAAI,SAAS,OAAO,UAAU,YAAY,CAAC,MAAM,QAAQ,KAAK,EAAG,QAAO;AACxE,QAAI,OAAO,UAAU,SAAU,QAAO;AACtC,UAAMA,UAAS,yBAAyB,KAAK;AAC7C,WAAOA,WAAU,OAAOA,YAAW,YAAY,CAAC,MAAM,QAAQA,OAAM,IAChEA,UACA;AAAA,EACN;AACA,QAAM,WAAW,gBAAgB,MAAM,gBAAgB;AACvD,QAAMC,YAAW,gBAAgB,MAAM,QAAQ;AAC/C,QAAM,UAAU,UAAU;AAC1B,QAAM,WAAW,UAAU;AAC3B,QAAM,YACH,OAAO,SAAS,UAAU,YAAY,QAAQ,MAAM,KAAK,KACzD,OAAO,UAAU,iBAAiB,YAAY,SAAS,aAAa,KAAK,KACzE,OAAOA,WAAU,kBAAkB,YAAYA,UAAS,cAAc,KAAK,KAC5E;AACF,MAAI,CAAC,UAAW,QAAO;AACvB,QAAM,SAAS,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,SAAS;AACrD,SAAO,OAAO,UAAU,OAAO,OAAO;AACxC;AAEA,eAAsB,KAAK,KAAc;AACvC,MAAI;AACF,UAAM,EAAE,IAAI,IAAI,MAAM,sBAAsB,GAAG;AAC/C,UAAM,EAAE,UAAU,IAAI,MAAM,oBAAoB;AAChD,UAAM,UAAU,MAAM,IAAI,KAAK,EAAE,MAAM,OAAO,CAAC,EAAE;AACjD,UAAM,SAAS,kBAAkB,WAAW,CAAC,GAAG,KAAK,SAAS;AAC9D,UAAM,QAAQ,gBAAgB,MAAM,MAAM;AAC1C,UAAM,cAAc,MAAM,UAAU,KAAK;AAAA,MACvC,UAAU,IAAI,MAAM,YAAY;AAAA,MAChC,gBAAgB,IAAI,0BAA0B,IAAI,MAAM,SAAS;AAAA,MACjE,QAAQ,IAAI,MAAM,OAAO;AAAA,MACzB,cAAc;AAAA,MACd,YAAY,MAAM;AAAA,MAClB,WAAW;AAAA,MACX,eAAe,IAAI;AAAA,MACnB,gBAAgB,IAAI;AAAA,IACtB,CAAC;AACD,QAAI,CAAC,YAAY,IAAI;AACnB,aAAO,aAAa,KAAK,YAAY,aAAa,EAAE,OAAO,6BAA6B,GAAG,EAAE,QAAQ,YAAY,eAAe,IAAI,CAAC;AAAA,IACvI;AAEA,UAAM,KAAM,IAAI,UAAU,QAAQ,IAAI,EAAoB,KAAK;AAC/D,UAAM,cAAc,IAAI,MAAM,WAC1B,EAAE,UAAU,IAAI,KAAK,UAAU,gBAAgB,IAAI,0BAA0B,IAAI,KAAK,SAAS,KAAK,IACpG;AACJ,UAAM,QAAQ,MAAM,sBAAsB,IAAI,YAAY,EAAE,IAAI,MAAM,SAAS,WAAW,KAAK,GAAG,CAAC,GAAG,WAAW;AACjH,QAAI,CAAC,OAAO;AACV,YAAM,SAAS,UAAU,gCAAgC,qCAAqC,CAAC;AAAA,IACjG;AACA,QAAI,MAAM,aAAa,IAAI,MAAM,YAAY,MAAM,mBAAmB,IAAI,wBAAwB;AAChG,YAAM,IAAI,cAAc,KAAK,EAAE,OAAO,UAAU,oCAAoC,WAAW,EAAE,CAAC;AAAA,IACpG;AAEA,SAAK,MAAM,UAAU,UAAU,YAAY;AACzC,YAAM,IAAI,cAAc,KAAK,EAAE,OAAO,UAAU,8BAA8B,iCAAiC,EAAE,CAAC;AAAA,IACpH;AAEA,UAAM,QAAQ,kBAAkB,KAAK;AACrC,QAAI,CAAC,OAAO;AACV,YAAM,IAAI,cAAc,KAAK,EAAE,OAAO,UAAU,kCAAkC,6CAA6C,EAAE,CAAC;AAAA,IACpI;AAEA,UAAM,MAAM,oBAAI,KAAK;AACrB,UAAM,aAAa,IAAI,KAAK,GAAG;AAC/B,eAAW,WAAW,WAAW,WAAW,IAAI,MAAM,YAAY;AAElE,UAAM,qBAAqB,OAAO,WAAW;AAK7C,UAAM,GAAG,cAAc,OAAO,OAAO;AACnC,YAAM,aAAa;AACnB,YAAM,kBAAkB,cAAc,kBAAkB;AACxD,YAAM,SAAS;AACf,YAAM,SAAS;AACf,YAAM,gBAAgB,MAAM,4BAA4B,IAAI;AAAA,QAC1D,UAAU,MAAM;AAAA,QAChB,gBAAgB,MAAM;AAAA,QACtB,OAAO;AAAA,MACT,CAAC;AACD,YAAM,YAAY;AAClB,SAAG,QAAQ,KAAK;AAAA,IAClB,CAAC;AAED,UAAM,SAAS,QAAQ,IAAI,WAAW;AACtC,UAAM,MAAM,SAAS,GAAG,OAAO,QAAQ,OAAO,EAAE,CAAC,UAAU,kBAAkB,KAAK,UAAU,kBAAkB;AAE9G,UAAM,SAAS,MAAM,aAAa;AAClC,UAAM,sBAAsB,WAAW,mBAAmB,QAAQ;AAAA,MAChE,MAAM;AAAA,MACN,OAAO;AAAA,MACP,KAAK;AAAA,IACP,CAAC;AAED,UAAM,OAAO;AAAA,MACX,SAAS,UAAU,8BAA8B,2CAA2C,EAAE,aAAa,MAAM,YAAY,CAAC;AAAA,MAC9H,SAAS,UAAU,8BAA8B,uBAAuB,EAAE,aAAa,MAAM,YAAY,CAAC;AAAA,MAC1G,OAAO,UAAU,4BAA4B,8BAA8B;AAAA,QACzE,QAAQ,MAAM,yBAAyB,MAAM,uBAAuB;AAAA,QACpE,UAAU,MAAM;AAAA,MAClB,CAAC;AAAA,MACD,YAAY,UAAU,iCAAiC,uBAAuB,EAAE,MAAM,oBAAoB,CAAC;AAAA,MAC3G,KAAK,UAAU,0BAA0B,YAAY;AAAA,MACrD,QAAQ,UAAU,6BAA6B,cAAc;AAAA,IAC/D;AAGA,UAAM,UAAU;AAAA,MACd,IAAI;AAAA,MACJ,SAAS,UAAU,8BAA8B,uBAAuB,EAAE,aAAa,MAAM,YAAY,CAAC;AAAA,MAC1G,OAAO,eAAe,EAAE,KAAK,KAAK,CAAC;AAAA,MACnC,UAAU,MAAM;AAAA,MAChB,gBAAgB,MAAM;AAAA,IACxB,CAAC;AAED,QAAI,YAAY,sBAAsB,QAAQ;AAC5C,YAAM,8BAA8B,YAAY,uBAAuB;AAAA,QACrE,UAAU,IAAI,MAAM,YAAY;AAAA,QAChC,gBAAgB,IAAI,0BAA0B,IAAI,MAAM,SAAS;AAAA,QACjE,QAAQ,IAAI,MAAM,OAAO;AAAA,QACzB,cAAc;AAAA,QACd,YAAY,MAAM;AAAA,QAClB,WAAW;AAAA,QACX,eAAe,IAAI;AAAA,QACnB,gBAAgB,IAAI;AAAA,MACtB,CAAC;AAAA,IACH;AAEA,WAAO,aAAa,KAAK,EAAE,IAAI,KAAK,CAAC;AAAA,EACvC,SAAS,KAAK;AACZ,QAAI,gBAAgB,GAAG,GAAG;AACxB,aAAO,aAAa,KAAK,IAAI,MAAM,EAAE,QAAQ,IAAI,OAAO,CAAC;AAAA,IAC3D;AACA,UAAM,EAAE,UAAU,IAAI,MAAM,oBAAoB;AAChD,WAAO,MAAM,4BAA4B,EAAE,IAAI,CAAC;AAChD,WAAO,aAAa;AAAA,MAClB,EAAE,OAAO,UAAU,4BAA4B,uBAAuB,EAAE;AAAA,MACxE,EAAE,QAAQ,IAAI;AAAA,IAChB;AAAA,EACF;AACF;AAEO,MAAM,UAA2B;AAAA,EACtC,KAAK;AAAA,EACL,SAAS;AAAA,EACT,SAAS;AAAA,IACP,MAAM;AAAA,MACJ,SAAS;AAAA,MACT,aAAa;AAAA,QACX,aAAa;AAAA,QACb,QAAQ;AAAA,MACV;AAAA,MACA,WAAW;AAAA,QACT,EAAE,QAAQ,KAAK,aAAa,gBAAgB,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,IAAI,EAAE,CAAC,EAAE;AAAA,QACtF,EAAE,QAAQ,KAAK,aAAa,mBAAmB,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE;AAAA,QACvF,EAAE,QAAQ,KAAK,aAAa,gBAAgB,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE;AAAA,QACpF,EAAE,QAAQ,KAAK,aAAa,aAAa,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE;AAAA,QACjF,EAAE,QAAQ,KAAK,aAAa,aAAa,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE;AAAA,QACjF,EAAE,QAAQ,KAAK,aAAa,qBAAqB,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,GAAG,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,EAAE;AAAA,QACtH,EAAE,QAAQ,KAAK,aAAa,iBAAiB,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,GAAG,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,EAAE;AAAA,MACpH;AAAA,IACF;AAAA,EACF;AACF;",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { resolveSingleOrganizationIdOrDeny } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'\nimport type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { resolveTranslations, detectLocale } from '@open-mercato/shared/lib/i18n/server'\nimport { CrudHttpError, isCrudHttpError, notFound } from '@open-mercato/shared/lib/crud/errors'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { runRouteMutationGuards } from '@open-mercato/shared/lib/crud/route-mutation-guard'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport crypto from 'node:crypto'\nimport { withScopedPayload } from '../../utils'\nimport { hashAuthToken } from '../../../../auth/lib/tokenHash'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { parseDecryptedFieldValue } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'\nimport { SalesQuote } from '../../../data/entities'\nimport { quoteSendSchema } from '../../../data/validators'\nimport { sendEmail } from '@open-mercato/shared/lib/email/send'\nimport { resolveStatusEntryIdByValue } from '../../../lib/statusHelpers'\nimport { QuoteSentEmail } from '../../../emails/QuoteSentEmail'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('sales')\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['sales.quotes.manage'] },\n}\n\ntype RequestContext = {\n ctx: CommandRuntimeContext\n}\n\nasync function resolveRequestContext(req: Request): Promise<RequestContext> {\n const container = await createRequestContainer()\n const auth = await getAuthFromRequest(req)\n const { translate } = await resolveTranslations()\n\n if (!auth || !auth.tenantId) {\n throw new CrudHttpError(401, { error: translate('sales.documents.errors.unauthorized', 'Unauthorized') })\n }\n\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n const organizationId = resolveSingleOrganizationIdOrDeny(scope, auth) ?? null\n if (!organizationId) {\n throw new CrudHttpError(400, {\n error: translate('sales.documents.errors.organization_required', 'Organization context is required'),\n })\n }\n\n const ctx: CommandRuntimeContext = {\n container,\n auth,\n organizationScope: scope,\n selectedOrganizationId: organizationId,\n organizationIds: scope?.filterIds ?? (auth.orgId ? [auth.orgId] : null),\n request: req,\n }\n\n return { ctx }\n}\n\nfunction resolveQuoteEmail(quote: SalesQuote): string | null {\n const normalizeRecord = (value: unknown): Record<string, unknown> | null => {\n if (value && typeof value === 'object' && !Array.isArray(value)) return value as Record<string, unknown>\n if (typeof value !== 'string') return null\n const parsed = parseDecryptedFieldValue(value)\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed)\n ? parsed as Record<string, unknown>\n : null\n }\n const snapshot = normalizeRecord(quote.customerSnapshot)\n const metadata = normalizeRecord(quote.metadata)\n const contact = snapshot?.contact as Record<string, unknown> | undefined\n const customer = snapshot?.customer as Record<string, unknown> | undefined\n const candidate =\n (typeof contact?.email === 'string' && contact.email.trim()) ||\n (typeof customer?.primaryEmail === 'string' && customer.primaryEmail.trim()) ||\n (typeof metadata?.customerEmail === 'string' && metadata.customerEmail.trim()) ||\n null\n if (!candidate) return null\n const parsed = z.string().email().safeParse(candidate)\n return parsed.success ? parsed.data : null\n}\n\nexport async function POST(req: Request) {\n try {\n const { ctx } = await resolveRequestContext(req)\n const { translate } = await resolveTranslations()\n const payload = await req.json().catch(() => ({}))\n const scoped = withScopedPayload(payload ?? {}, ctx, translate)\n const input = quoteSendSchema.parse(scoped)\n const guardResult = await runRouteMutationGuards({\n container: ctx.container,\n req,\n auth: {\n tenantId: ctx.auth?.tenantId ?? '',\n organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,\n userId: ctx.auth?.sub ?? '',\n },\n input: {\n resourceKind: 'sales.quote',\n resourceId: input.quoteId,\n operation: 'update',\n mutationPayload: input,\n },\n })\n if (!guardResult.ok) {\n return guardResult.response\n }\n\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n const tenantScope = ctx.auth?.tenantId\n ? { tenantId: ctx.auth.tenantId, organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null }\n : undefined\n const quote = await findOneWithDecryption(em, SalesQuote, { id: input.quoteId, deletedAt: null }, {}, tenantScope)\n if (!quote) {\n throw notFound(translate('sales.documents.detail.error', 'Document not found or inaccessible.'))\n }\n if (quote.tenantId !== ctx.auth?.tenantId || quote.organizationId !== ctx.selectedOrganizationId) {\n throw new CrudHttpError(403, { error: translate('sales.documents.errors.forbidden', 'Forbidden') })\n }\n\n if ((quote.status ?? null) === 'canceled') {\n throw new CrudHttpError(400, { error: translate('sales.quotes.send.canceled', 'Canceled quotes cannot be sent.') })\n }\n\n const email = resolveQuoteEmail(quote)\n if (!email) {\n throw new CrudHttpError(400, { error: translate('sales.quotes.send.missingEmail', 'Customer email is required to send a quote.') })\n }\n\n const now = new Date()\n const validUntil = new Date(now)\n validUntil.setUTCDate(validUntil.getUTCDate() + input.validForDays)\n\n const rawAcceptanceToken = crypto.randomUUID()\n\n // Persist the send state (status/token/sentAt) atomically and commit it\n // BEFORE the email goes out, so a customer never receives a link whose\n // acceptance token was not durably stored.\n await em.transactional(async (tx) => {\n quote.validUntil = validUntil\n quote.acceptanceToken = hashAuthToken(rawAcceptanceToken)\n quote.sentAt = now\n quote.status = 'sent'\n quote.statusEntryId = await resolveStatusEntryIdByValue(tx, {\n tenantId: quote.tenantId,\n organizationId: quote.organizationId,\n value: 'sent',\n })\n quote.updatedAt = now\n tx.persist(quote)\n })\n\n const appUrl = process.env.APP_URL || ''\n const url = appUrl ? `${appUrl.replace(/\\/$/, '')}/quote/${rawAcceptanceToken}` : `/quote/${rawAcceptanceToken}`\n\n const locale = await detectLocale()\n const validUntilFormatted = validUntil.toLocaleDateString(locale, {\n year: 'numeric',\n month: 'long',\n day: 'numeric',\n })\n\n const copy = {\n preview: translate('sales.quotes.email.preview', 'Quote {quoteNumber} is ready for review', { quoteNumber: quote.quoteNumber }),\n heading: translate('sales.quotes.email.heading', 'Quote {quoteNumber}', { quoteNumber: quote.quoteNumber }),\n total: translate('sales.quotes.email.total', 'Total: {amount} {currency}', {\n amount: quote.grandTotalGrossAmount ?? quote.grandTotalNetAmount ?? '0',\n currency: quote.currencyCode,\n }),\n validUntil: translate('sales.quotes.email.validUntil', 'Valid until: {date}', { date: validUntilFormatted }),\n cta: translate('sales.quotes.email.cta', 'View quote'),\n footer: translate('sales.quotes.email.footer', 'Open Mercato'),\n }\n\n // Side effect after commit: an email failure must not roll back the send state.\n await sendEmail({\n to: email,\n subject: translate('sales.quotes.email.subject', 'Quote {quoteNumber}', { quoteNumber: quote.quoteNumber }),\n react: QuoteSentEmail({ url, copy }),\n tenantId: quote.tenantId,\n organizationId: quote.organizationId,\n })\n\n await guardResult.runAfterSuccess()\n\n return NextResponse.json({ ok: true })\n } catch (err) {\n if (isCrudHttpError(err)) {\n return NextResponse.json(err.body, { status: err.status })\n }\n const { translate } = await resolveTranslations()\n logger.error('sales.quotes.send failed', { err })\n return NextResponse.json(\n { error: translate('sales.quotes.send.failed', 'Failed to send quote.') },\n { status: 400 }\n )\n }\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Sales',\n summary: 'Send quote to customer',\n methods: {\n POST: {\n summary: 'Send quote',\n requestBody: {\n contentType: 'application/json',\n schema: quoteSendSchema,\n },\n responses: [\n { status: 200, description: 'Email queued', schema: z.object({ ok: z.literal(true) }) },\n { status: 400, description: 'Invalid payload', schema: z.object({ error: z.string() }) },\n { status: 401, description: 'Unauthorized', schema: z.object({ error: z.string() }) },\n { status: 403, description: 'Forbidden', schema: z.object({ error: z.string() }) },\n { status: 404, description: 'Not found', schema: z.object({ error: z.string() }) },\n { status: 409, description: 'Conflict detected', schema: z.object({ error: z.string(), code: z.string().optional() }) },\n { status: 423, description: 'Record locked', schema: z.object({ error: z.string(), code: z.string().optional() }) },\n ],\n },\n },\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["parsed", "metadata"]
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.10.1-develop.7785.1.ec0898da36",
3
+ "version": "0.10.1-develop.7787.1.96c7cfc66f",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -266,16 +266,16 @@
266
266
  "zod": "^4.4.3"
267
267
  },
268
268
  "peerDependencies": {
269
- "@open-mercato/ai-assistant": "0.10.1-develop.7785.1.ec0898da36",
270
- "@open-mercato/shared": "0.10.1-develop.7785.1.ec0898da36",
271
- "@open-mercato/ui": "0.10.1-develop.7785.1.ec0898da36",
269
+ "@open-mercato/ai-assistant": "0.10.1-develop.7787.1.96c7cfc66f",
270
+ "@open-mercato/shared": "0.10.1-develop.7787.1.96c7cfc66f",
271
+ "@open-mercato/ui": "0.10.1-develop.7787.1.96c7cfc66f",
272
272
  "react": "^19.0.0",
273
273
  "react-dom": "^19.0.0"
274
274
  },
275
275
  "devDependencies": {
276
- "@open-mercato/ai-assistant": "0.10.1-develop.7785.1.ec0898da36",
277
- "@open-mercato/shared": "0.10.1-develop.7785.1.ec0898da36",
278
- "@open-mercato/ui": "0.10.1-develop.7785.1.ec0898da36",
276
+ "@open-mercato/ai-assistant": "0.10.1-develop.7787.1.96c7cfc66f",
277
+ "@open-mercato/shared": "0.10.1-develop.7787.1.96c7cfc66f",
278
+ "@open-mercato/ui": "0.10.1-develop.7787.1.96c7cfc66f",
279
279
  "@testing-library/dom": "^10.4.1",
280
280
  "@testing-library/jest-dom": "^7.0.1",
281
281
  "@testing-library/react": "^16.3.3",
@@ -5,11 +5,7 @@ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
5
5
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
6
6
  import { runWithCacheTenant, type CacheStrategy } from '@open-mercato/cache'
7
7
  import { collectCrudCacheStats, purgeCrudCacheSegment } from '@open-mercato/shared/lib/crud/cache-stats'
8
- import {
9
- bridgeLegacyGuard,
10
- runMutationGuards,
11
- type MutationGuardInput,
12
- } from '@open-mercato/shared/lib/crud/mutation-guard-registry'
8
+ import { runRouteMutationGuards } from '@open-mercato/shared/lib/crud/route-mutation-guard'
13
9
  import {
14
10
  configsTag,
15
11
  cacheStatsResponseSchema,
@@ -27,11 +23,6 @@ export const metadata = {
27
23
  POST: { requireAuth: true, requireFeatures: ['configs.cache.manage'] },
28
24
  } as const
29
25
 
30
- function resolveUserFeatures(auth: unknown): string[] {
31
- const features = (auth as { features?: unknown } | null)?.features
32
- return Array.isArray(features) ? features.filter((value): value is string => typeof value === 'string') : []
33
- }
34
-
35
26
  export async function GET(req: Request) {
36
27
  const auth = await getAuthFromRequest(req)
37
28
  if (!auth?.sub) {
@@ -107,30 +98,23 @@ export async function POST(req: Request) {
107
98
  // → API Routes). A cache purge carries no per-record optimistic-lock version, so
108
99
  // the default OSS guard short-circuits; wiring it keeps the route on the shared
109
100
  // write-guard interception path for any tenant-registered guard.
110
- const guardInput: MutationGuardInput = {
111
- tenantId: tenantScope ?? '',
112
- organizationId: auth.orgId ?? null,
113
- userId: auth.sub,
114
- resourceKind: 'configs.cache',
115
- resourceId: segment ?? tenantScope ?? 'all',
116
- operation: 'delete',
117
- requestMethod: req.method,
118
- requestHeaders: req.headers,
119
- mutationPayload: { action, ...(segment ? { segment } : {}) },
120
- }
121
- const guard = bridgeLegacyGuard(container)
122
- let afterSuccessCallbacks: Awaited<ReturnType<typeof runMutationGuards>>['afterSuccessCallbacks'] = []
123
- if (guard) {
124
- const guardResult = await runMutationGuards([guard], guardInput, {
125
- userFeatures: resolveUserFeatures(auth),
126
- })
127
- if (!guardResult.ok) {
128
- return NextResponse.json(
129
- guardResult.errorBody ?? { error: translate('configs.cache.purgeError', 'Failed to purge cache segment.') },
130
- { status: guardResult.errorStatus ?? 422 },
131
- )
132
- }
133
- afterSuccessCallbacks = guardResult.afterSuccessCallbacks
101
+ const guardResult = await runRouteMutationGuards({
102
+ container,
103
+ req,
104
+ auth: {
105
+ tenantId: tenantScope ?? '',
106
+ organizationId: auth.orgId ?? null,
107
+ userId: auth.sub,
108
+ },
109
+ input: {
110
+ resourceKind: 'configs.cache',
111
+ resourceId: segment ?? tenantScope ?? 'all',
112
+ operation: 'delete',
113
+ mutationPayload: { action, ...(segment ? { segment } : {}) },
114
+ },
115
+ })
116
+ if (!guardResult.ok) {
117
+ return guardResult.response
134
118
  }
135
119
 
136
120
  let responseBody: Record<string, unknown>
@@ -144,20 +128,7 @@ export async function POST(req: Request) {
144
128
  responseBody = { action: 'purgeAll', stats }
145
129
  }
146
130
 
147
- for (const callback of afterSuccessCallbacks) {
148
- if (!callback.guard.afterSuccess) continue
149
- await callback.guard.afterSuccess({
150
- tenantId: tenantScope ?? '',
151
- organizationId: auth.orgId ?? null,
152
- userId: auth.sub,
153
- resourceKind: 'configs.cache',
154
- resourceId: segment ?? tenantScope ?? 'all',
155
- operation: 'delete',
156
- requestMethod: req.method,
157
- requestHeaders: req.headers,
158
- metadata: callback.metadata ?? null,
159
- })
160
- }
131
+ await guardResult.runAfterSuccess()
161
132
 
162
133
  return NextResponse.json(responseBody)
163
134
  } catch (error) {
@@ -5,12 +5,18 @@ import {
5
5
  type MutationGuard,
6
6
  type MutationGuardInput,
7
7
  } from '@open-mercato/shared/lib/crud/mutation-guard-registry'
8
+ import { getAllMutationGuardInstances } from '@open-mercato/shared/lib/crud/mutation-guard-store'
9
+ import { createLogger } from '@open-mercato/shared/lib/logger'
10
+ import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
11
+
12
+ const logger = createLogger('integrations')
8
13
 
9
14
  type GuardAfterCallback = {
10
15
  guard: MutationGuard
11
16
  metadata: Record<string, unknown> | null
12
17
  }
13
18
 
19
+ /** @deprecated Mutation guard grants are resolved from RBAC by `runIntegrationMutationGuards`. */
14
20
  export function resolveUserFeatures(auth: unknown): string[] {
15
21
  const features = (auth as { features?: unknown })?.features
16
22
  if (!Array.isArray(features)) return []
@@ -20,7 +26,7 @@ export function resolveUserFeatures(auth: unknown): string[] {
20
26
  export async function runIntegrationMutationGuards(
21
27
  container: AwilixContainer,
22
28
  input: MutationGuardInput,
23
- userFeatures: string[],
29
+ extraFeatures: string[] = [],
24
30
  ): Promise<{
25
31
  ok: boolean
26
32
  errorBody?: Record<string, unknown>
@@ -28,12 +34,35 @@ export async function runIntegrationMutationGuards(
28
34
  modifiedPayload?: Record<string, unknown>
29
35
  afterSuccessCallbacks: GuardAfterCallback[]
30
36
  }> {
37
+ const allGuards: MutationGuard[] = [...getAllMutationGuardInstances()]
31
38
  const legacyGuard = bridgeLegacyGuard(container)
32
- if (!legacyGuard) {
39
+ if (legacyGuard) allGuards.push(legacyGuard)
40
+ if (!allGuards.length) {
33
41
  return { ok: true, afterSuccessCallbacks: [] }
34
42
  }
35
43
 
36
- return runMutationGuards([legacyGuard], input, { userFeatures })
44
+ let grantedFeatures: string[] = []
45
+ try {
46
+ const rbacService = container.resolve('rbacService') as {
47
+ getGrantedFeatures?: (
48
+ userId: string,
49
+ scope: { tenantId: string | null; organizationId: string | null },
50
+ ) => Promise<string[]>
51
+ } | undefined
52
+ grantedFeatures = await rbacService?.getGrantedFeatures?.(input.userId, {
53
+ tenantId: input.tenantId,
54
+ organizationId: input.organizationId,
55
+ }) ?? []
56
+ } catch (err) {
57
+ logger.error('Failed to resolve mutation guard feature grants', { err })
58
+ getTelemetryRuntime()?.reportError(err, {
59
+ module: 'integrations',
60
+ code: 'integrations.mutation_guard_feature_resolution_failed',
61
+ })
62
+ }
63
+
64
+ const userFeatures = Array.from(new Set([...grantedFeatures, ...extraFeatures]))
65
+ return runMutationGuards(allGuards, input, { userFeatures })
37
66
  }
38
67
 
39
68
  export async function runIntegrationMutationGuardAfterSuccess(
@@ -51,9 +80,18 @@ export async function runIntegrationMutationGuardAfterSuccess(
51
80
  ): Promise<void> {
52
81
  for (const callback of callbacks) {
53
82
  if (!callback.guard.afterSuccess) continue
54
- await callback.guard.afterSuccess({
55
- ...input,
56
- metadata: callback.metadata ?? null,
57
- })
83
+ try {
84
+ await callback.guard.afterSuccess({
85
+ ...input,
86
+ metadata: callback.metadata ?? null,
87
+ })
88
+ } catch (err) {
89
+ logger.error('Mutation guard afterSuccess failed', { guardId: callback.guard.id, err })
90
+ getTelemetryRuntime()?.reportError(err, {
91
+ module: 'integrations',
92
+ code: 'integrations.mutation_guard_after_success_failed',
93
+ attributes: { guardId: callback.guard.id },
94
+ })
95
+ }
58
96
  }
59
97
  }