@open-mercato/core 0.6.6-develop.6229.1.ebe6706732 → 0.6.6-develop.6241.1.47c01ad4ed
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/auth/commands/users.js +1 -0
- package/dist/modules/auth/commands/users.js.map +2 -2
- package/dist/modules/communication_channels/data/enrichers.js +9 -1
- package/dist/modules/communication_channels/data/enrichers.js.map +2 -2
- package/dist/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.client.js +3 -5
- package/dist/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.client.js.map +2 -2
- package/dist/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.js.map +2 -2
- package/dist/modules/customers/api/pipeline-stages/reorder/route.js +39 -1
- package/dist/modules/customers/api/pipeline-stages/reorder/route.js.map +2 -2
- package/dist/modules/customers/api/pipeline-stages/route.js +107 -12
- package/dist/modules/customers/api/pipeline-stages/route.js.map +2 -2
- package/dist/modules/customers/api/pipelines/route.js +107 -12
- package/dist/modules/customers/api/pipelines/route.js.map +2 -2
- package/dist/modules/customers/api/settings/address-format/route.js +33 -1
- package/dist/modules/customers/api/settings/address-format/route.js.map +2 -2
- package/dist/modules/customers/api/tags/assign/route.js +37 -0
- package/dist/modules/customers/api/tags/assign/route.js.map +2 -2
- package/dist/modules/customers/api/tags/unassign/route.js +37 -0
- package/dist/modules/customers/api/tags/unassign/route.js.map +2 -2
- package/dist/modules/entities/api/encryption.js +109 -38
- package/dist/modules/entities/api/encryption.js.map +2 -2
- package/dist/modules/entities/components/EncryptionManager.js +22 -9
- package/dist/modules/entities/components/EncryptionManager.js.map +2 -2
- package/dist/modules/integrations/api/[id]/credentials/route.js +23 -1
- package/dist/modules/integrations/api/[id]/credentials/route.js.map +2 -2
- package/dist/modules/integrations/api/[id]/route.js +10 -3
- package/dist/modules/integrations/api/[id]/route.js.map +2 -2
- package/dist/modules/integrations/api/[id]/state/route.js +20 -5
- package/dist/modules/integrations/api/[id]/state/route.js.map +2 -2
- package/dist/modules/integrations/api/[id]/version/route.js +17 -1
- package/dist/modules/integrations/api/[id]/version/route.js.map +2 -2
- package/dist/modules/integrations/api/route.js +2 -0
- package/dist/modules/integrations/api/route.js.map +2 -2
- package/dist/modules/integrations/backend/integrations/[id]/page.js +36 -19
- package/dist/modules/integrations/backend/integrations/[id]/page.js.map +2 -2
- package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js +13 -9
- package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js.map +2 -2
- package/dist/modules/integrations/backend/integrations/page.js +4 -3
- package/dist/modules/integrations/backend/integrations/page.js.map +2 -2
- package/dist/modules/integrations/data/entities.js +2 -2
- package/dist/modules/integrations/data/entities.js.map +2 -2
- package/dist/modules/integrations/lib/credentials-service.js +32 -0
- package/dist/modules/integrations/lib/credentials-service.js.map +2 -2
- package/dist/modules/integrations/lib/state-service.js +4 -2
- package/dist/modules/integrations/lib/state-service.js.map +2 -2
- package/dist/modules/notifications/lib/notificationService.js +42 -10
- package/dist/modules/notifications/lib/notificationService.js.map +2 -2
- package/dist/modules/payment_gateways/api/status/route.js +91 -6
- package/dist/modules/payment_gateways/api/status/route.js.map +2 -2
- package/dist/modules/payment_gateways/backend/payment-gateways/page.js +25 -11
- package/dist/modules/payment_gateways/backend/payment-gateways/page.js.map +2 -2
- package/dist/modules/sales/backend/sales/channels/offers/page.js +25 -7
- package/dist/modules/sales/backend/sales/channels/offers/page.js.map +2 -2
- package/dist/modules/sales/backend/sales/channels/page.js +25 -7
- package/dist/modules/sales/backend/sales/channels/page.js.map +2 -2
- package/dist/modules/sales/components/AdjustmentKindSettings.js +52 -24
- package/dist/modules/sales/components/AdjustmentKindSettings.js.map +2 -2
- package/dist/modules/sales/components/DocumentNumberSettings.js +26 -9
- package/dist/modules/sales/components/DocumentNumberSettings.js.map +2 -2
- package/dist/modules/sales/components/OrderEditingSettings.js +26 -9
- package/dist/modules/sales/components/OrderEditingSettings.js.map +2 -2
- package/dist/modules/sales/components/StatusSettings.js +66 -31
- package/dist/modules/sales/components/StatusSettings.js.map +2 -2
- package/dist/modules/sales/components/channels/SalesChannelOffersPanel.js +25 -7
- package/dist/modules/sales/components/channels/SalesChannelOffersPanel.js.map +2 -2
- package/dist/modules/shipping_carriers/api/cancel/route.js +38 -0
- package/dist/modules/shipping_carriers/api/cancel/route.js.map +2 -2
- package/dist/modules/shipping_carriers/api/shipments/route.js +12 -5
- package/dist/modules/shipping_carriers/api/shipments/route.js.map +2 -2
- package/dist/modules/staff/lib/timesheets-ui/TimerBar.js +7 -8
- package/dist/modules/staff/lib/timesheets-ui/TimerBar.js.map +2 -2
- package/dist/modules/staff/lib/timesheets-ui/timerErrors.js +12 -0
- package/dist/modules/staff/lib/timesheets-ui/timerErrors.js.map +7 -0
- package/dist/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.js +4 -3
- package/dist/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.js.map +2 -2
- package/dist/modules/workflows/backend/definitions/page.js +19 -10
- package/dist/modules/workflows/backend/definitions/page.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/auth/commands/users.ts +1 -0
- package/src/modules/communication_channels/data/enrichers.ts +25 -0
- package/src/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.client.tsx +7 -6
- package/src/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.ts +5 -2
- package/src/modules/customers/api/pipeline-stages/reorder/route.ts +41 -1
- package/src/modules/customers/api/pipeline-stages/route.ts +112 -13
- package/src/modules/customers/api/pipelines/route.ts +112 -13
- package/src/modules/customers/api/settings/address-format/route.ts +36 -1
- package/src/modules/customers/api/tags/assign/route.ts +39 -0
- package/src/modules/customers/api/tags/unassign/route.ts +39 -0
- package/src/modules/customers/i18n/de.json +1 -0
- package/src/modules/customers/i18n/en.json +1 -0
- package/src/modules/customers/i18n/es.json +1 -0
- package/src/modules/customers/i18n/pl.json +1 -0
- package/src/modules/entities/api/encryption.ts +122 -39
- package/src/modules/entities/components/EncryptionManager.tsx +28 -9
- package/src/modules/integrations/api/[id]/credentials/route.ts +23 -0
- package/src/modules/integrations/api/[id]/route.ts +8 -1
- package/src/modules/integrations/api/[id]/state/route.ts +20 -4
- package/src/modules/integrations/api/[id]/version/route.ts +18 -1
- package/src/modules/integrations/api/route.ts +3 -0
- package/src/modules/integrations/backend/integrations/[id]/page.tsx +39 -20
- package/src/modules/integrations/backend/integrations/bundle/[id]/page.tsx +19 -11
- package/src/modules/integrations/backend/integrations/page.tsx +8 -5
- package/src/modules/integrations/data/entities.ts +2 -2
- package/src/modules/integrations/lib/credentials-service.ts +35 -0
- package/src/modules/integrations/lib/state-service.ts +3 -0
- package/src/modules/notifications/lib/notificationService.ts +74 -11
- package/src/modules/payment_gateways/api/status/route.ts +97 -5
- package/src/modules/payment_gateways/backend/payment-gateways/page.tsx +27 -11
- package/src/modules/sales/backend/sales/channels/offers/page.tsx +31 -7
- package/src/modules/sales/backend/sales/channels/page.tsx +31 -7
- package/src/modules/sales/components/AdjustmentKindSettings.tsx +60 -24
- package/src/modules/sales/components/DocumentNumberSettings.tsx +32 -10
- package/src/modules/sales/components/OrderEditingSettings.tsx +32 -10
- package/src/modules/sales/components/StatusSettings.tsx +74 -33
- package/src/modules/sales/components/channels/SalesChannelOffersPanel.tsx +30 -6
- package/src/modules/shipping_carriers/api/cancel/route.ts +46 -0
- package/src/modules/shipping_carriers/api/shipments/route.ts +21 -6
- package/src/modules/staff/i18n/de.json +5 -5
- package/src/modules/staff/i18n/es.json +5 -5
- package/src/modules/staff/i18n/pl.json +5 -5
- package/src/modules/staff/lib/timesheets-ui/TimerBar.tsx +7 -8
- package/src/modules/staff/lib/timesheets-ui/timerErrors.ts +23 -0
- package/src/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.tsx +4 -3
- package/src/modules/workflows/backend/definitions/page.tsx +19 -10
|
@@ -9,8 +9,14 @@ import { tagAssignmentSchema, type TagAssignmentInput } from '../../../data/vali
|
|
|
9
9
|
import { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
10
10
|
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
11
11
|
import { withScopedPayload } from '../../utils'
|
|
12
|
+
import {
|
|
13
|
+
runCrudMutationGuardAfterSuccess,
|
|
14
|
+
validateCrudMutationGuard,
|
|
15
|
+
} from '@open-mercato/shared/lib/crud/mutation-guard'
|
|
12
16
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
13
17
|
|
|
18
|
+
const TAG_ASSIGNMENT_RESOURCE_KIND = 'customers.tagAssignment'
|
|
19
|
+
|
|
14
20
|
export const metadata = {
|
|
15
21
|
POST: { requireAuth: true, requireFeatures: ['customers.activities.manage'] },
|
|
16
22
|
}
|
|
@@ -37,15 +43,48 @@ async function buildContext(
|
|
|
37
43
|
export async function POST(req: Request) {
|
|
38
44
|
try {
|
|
39
45
|
const { ctx, auth, translate } = await buildContext(req)
|
|
46
|
+
const tenantId = auth!.tenantId
|
|
47
|
+
const organizationId = ctx.selectedOrganizationId
|
|
48
|
+
if (!tenantId || !organizationId) {
|
|
49
|
+
return NextResponse.json({ error: translate('customers.errors.context_required', 'Organization and tenant context required') }, { status: 400 })
|
|
50
|
+
}
|
|
40
51
|
const body = await req.json().catch(() => ({}))
|
|
41
52
|
const scoped = withScopedPayload(body, ctx, translate)
|
|
42
53
|
const input = tagAssignmentSchema.parse(scoped)
|
|
43
54
|
|
|
55
|
+
const guardResult = await validateCrudMutationGuard(ctx.container, {
|
|
56
|
+
tenantId,
|
|
57
|
+
organizationId,
|
|
58
|
+
userId: auth!.sub,
|
|
59
|
+
resourceKind: TAG_ASSIGNMENT_RESOURCE_KIND,
|
|
60
|
+
resourceId: input.entityId,
|
|
61
|
+
operation: 'custom',
|
|
62
|
+
requestMethod: req.method,
|
|
63
|
+
requestHeaders: req.headers,
|
|
64
|
+
mutationPayload: input,
|
|
65
|
+
})
|
|
66
|
+
if (guardResult && !guardResult.ok) {
|
|
67
|
+
return NextResponse.json(guardResult.body, { status: guardResult.status })
|
|
68
|
+
}
|
|
69
|
+
|
|
44
70
|
const commandBus = (ctx.container.resolve('commandBus') as CommandBus)
|
|
45
71
|
const { result, logEntry } = await commandBus.execute<TagAssignmentInput, { assignmentId: string | null }>(
|
|
46
72
|
'customers.tags.unassign',
|
|
47
73
|
{ input, ctx },
|
|
48
74
|
)
|
|
75
|
+
if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
|
|
76
|
+
await runCrudMutationGuardAfterSuccess(ctx.container, {
|
|
77
|
+
tenantId,
|
|
78
|
+
organizationId,
|
|
79
|
+
userId: auth!.sub,
|
|
80
|
+
resourceKind: TAG_ASSIGNMENT_RESOURCE_KIND,
|
|
81
|
+
resourceId: input.entityId,
|
|
82
|
+
operation: 'custom',
|
|
83
|
+
requestMethod: req.method,
|
|
84
|
+
requestHeaders: req.headers,
|
|
85
|
+
metadata: guardResult.metadata ?? null,
|
|
86
|
+
})
|
|
87
|
+
}
|
|
49
88
|
const response = NextResponse.json({ id: result?.assignmentId ?? null })
|
|
50
89
|
if (logEntry?.undoToken && logEntry?.id && logEntry?.commandId) {
|
|
51
90
|
response.headers.set(
|
|
@@ -1505,6 +1505,7 @@
|
|
|
1505
1505
|
"customers.errors.company_not_found": "Unternehmen nicht gefunden",
|
|
1506
1506
|
"customers.errors.company_people_load_failed": "Verknüpfte Personen konnten nicht geladen werden",
|
|
1507
1507
|
"customers.errors.company_required": "Unternehmens-ID ist erforderlich",
|
|
1508
|
+
"customers.errors.context_required": "Organisations- und Mandantenkontext erforderlich",
|
|
1508
1509
|
"customers.errors.customer_label_tables_missing": "Kundenlabel-Tabellen fehlen. Führen Sie yarn db:migrate aus.",
|
|
1509
1510
|
"customers.errors.customer_not_found": "Kunde nicht gefunden",
|
|
1510
1511
|
"customers.errors.deal_companies_load_failed": "Verknüpfte Unternehmen konnten nicht geladen werden",
|
|
@@ -1505,6 +1505,7 @@
|
|
|
1505
1505
|
"customers.errors.company_not_found": "Company not found",
|
|
1506
1506
|
"customers.errors.company_people_load_failed": "Failed to load linked people",
|
|
1507
1507
|
"customers.errors.company_required": "Company id is required",
|
|
1508
|
+
"customers.errors.context_required": "Organization and tenant context required",
|
|
1508
1509
|
"customers.errors.customer_label_tables_missing": "Customer label tables are missing. Run yarn db:migrate.",
|
|
1509
1510
|
"customers.errors.customer_not_found": "Customer not found",
|
|
1510
1511
|
"customers.errors.deal_companies_load_failed": "Failed to load linked companies",
|
|
@@ -1505,6 +1505,7 @@
|
|
|
1505
1505
|
"customers.errors.company_not_found": "Empresa no encontrada",
|
|
1506
1506
|
"customers.errors.company_people_load_failed": "No se pudieron cargar las personas vinculadas",
|
|
1507
1507
|
"customers.errors.company_required": "Se requiere el ID de la empresa",
|
|
1508
|
+
"customers.errors.context_required": "Se requiere contexto de organización e inquilino",
|
|
1508
1509
|
"customers.errors.customer_label_tables_missing": "Faltan las tablas de etiquetas de cliente. Ejecute yarn db:migrate.",
|
|
1509
1510
|
"customers.errors.customer_not_found": "Cliente no encontrado",
|
|
1510
1511
|
"customers.errors.deal_companies_load_failed": "No se pudieron cargar las empresas vinculadas",
|
|
@@ -1505,6 +1505,7 @@
|
|
|
1505
1505
|
"customers.errors.company_not_found": "Nie znaleziono firmy",
|
|
1506
1506
|
"customers.errors.company_people_load_failed": "Nie udało się załadować powiązanych osób",
|
|
1507
1507
|
"customers.errors.company_required": "Wymagany identyfikator firmy",
|
|
1508
|
+
"customers.errors.context_required": "Wymagany jest kontekst organizacji i najemcy",
|
|
1508
1509
|
"customers.errors.customer_label_tables_missing": "Brakuje tabel etykiet klientów. Uruchom yarn db:migrate.",
|
|
1509
1510
|
"customers.errors.customer_not_found": "Nie znaleziono klienta",
|
|
1510
1511
|
"customers.errors.deal_companies_load_failed": "Nie udało się załadować powiązanych firm",
|
|
@@ -4,8 +4,16 @@ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
|
4
4
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
5
5
|
import { EncryptionMap } from '@open-mercato/core/modules/entities/data/entities'
|
|
6
6
|
import { upsertEncryptionMapSchema } from '@open-mercato/core/modules/entities/data/validators'
|
|
7
|
+
import { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
8
|
+
import { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
|
|
9
|
+
import {
|
|
10
|
+
runCrudMutationGuardAfterSuccess,
|
|
11
|
+
validateCrudMutationGuard,
|
|
12
|
+
} from '@open-mercato/shared/lib/crud/mutation-guard'
|
|
7
13
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
8
14
|
|
|
15
|
+
const ENCRYPTION_MAP_RESOURCE_KIND = 'entities.encryption_map'
|
|
16
|
+
|
|
9
17
|
export const metadata = {
|
|
10
18
|
GET: { requireAuth: true, requireFeatures: ['entities.definitions.manage'] },
|
|
11
19
|
POST: { requireAuth: true, requireFeatures: ['entities.definitions.manage'] },
|
|
@@ -18,6 +26,16 @@ function resolveScope(auth: { tenantId?: string | null; orgId?: string | null })
|
|
|
18
26
|
}
|
|
19
27
|
}
|
|
20
28
|
|
|
29
|
+
function toIsoOrNull(value: Date | string | null | undefined): string | null {
|
|
30
|
+
if (value == null) return null
|
|
31
|
+
if (value instanceof Date) {
|
|
32
|
+
const ms = value.getTime()
|
|
33
|
+
return Number.isFinite(ms) ? new Date(ms).toISOString() : null
|
|
34
|
+
}
|
|
35
|
+
const trimmed = String(value).trim()
|
|
36
|
+
return trimmed.length ? trimmed : null
|
|
37
|
+
}
|
|
38
|
+
|
|
21
39
|
export async function GET(req: Request) {
|
|
22
40
|
const url = new URL(req.url)
|
|
23
41
|
const entityId = url.searchParams.get('entityId') || ''
|
|
@@ -26,8 +44,8 @@ export async function GET(req: Request) {
|
|
|
26
44
|
if (!auth?.tenantId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
27
45
|
const { tenantId, organizationId } = resolveScope(auth)
|
|
28
46
|
|
|
29
|
-
const
|
|
30
|
-
const em = resolve('em') as any
|
|
47
|
+
const container = await createRequestContainer()
|
|
48
|
+
const em = container.resolve('em') as any
|
|
31
49
|
const repo = em.getRepository(EncryptionMap)
|
|
32
50
|
// Prefer tenant+org, then tenant-global, then global
|
|
33
51
|
const candidates = [
|
|
@@ -51,52 +69,114 @@ export async function GET(req: Request) {
|
|
|
51
69
|
organizationId,
|
|
52
70
|
fields: record?.fieldsJson ?? [],
|
|
53
71
|
isActive: record?.isActive ?? true,
|
|
72
|
+
updatedAt: toIsoOrNull(record?.updatedAt),
|
|
54
73
|
})
|
|
55
74
|
}
|
|
56
75
|
|
|
57
76
|
export async function POST(req: Request) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
77
|
+
try {
|
|
78
|
+
const body = await req.json().catch(() => ({}))
|
|
79
|
+
const parsed = upsertEncryptionMapSchema.safeParse(body)
|
|
80
|
+
if (!parsed.success) {
|
|
81
|
+
return NextResponse.json({ error: 'Invalid payload', details: parsed.error.flatten() }, { status: 400 })
|
|
82
|
+
}
|
|
83
|
+
const auth = await getAuthFromRequest(req)
|
|
84
|
+
if (!auth?.tenantId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
85
|
+
const scope = resolveScope(auth)
|
|
86
|
+
const payload = parsed.data
|
|
87
|
+
const tenantId: string = auth.tenantId
|
|
88
|
+
const organizationId = scope.organizationId
|
|
69
89
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
90
|
+
const container = await createRequestContainer()
|
|
91
|
+
const em = container.resolve('em') as any
|
|
92
|
+
const repo = em.getRepository(EncryptionMap)
|
|
93
|
+
const existing = await repo.findOne({ entityId: payload.entityId, tenantId, organizationId, deletedAt: null })
|
|
94
|
+
|
|
95
|
+
// Reject stale writes: a save started from an older tab must not silently
|
|
96
|
+
// overwrite a newer encryption configuration. No-op when the client did not
|
|
97
|
+
// send the expected-version header (strictly additive).
|
|
98
|
+
if (existing) {
|
|
99
|
+
enforceCommandOptimisticLock({
|
|
100
|
+
resourceKind: ENCRYPTION_MAP_RESOURCE_KIND,
|
|
101
|
+
resourceId: existing.id,
|
|
102
|
+
current: existing.updatedAt,
|
|
103
|
+
request: req,
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Mutation-guard contract for custom write routes. The resource is the
|
|
108
|
+
// encryption map for this entity scoped to the tenant/organization.
|
|
109
|
+
const guardResult = await validateCrudMutationGuard(container, {
|
|
82
110
|
tenantId,
|
|
83
111
|
organizationId,
|
|
84
|
-
|
|
85
|
-
|
|
112
|
+
userId: auth.sub,
|
|
113
|
+
resourceKind: ENCRYPTION_MAP_RESOURCE_KIND,
|
|
114
|
+
resourceId: existing?.id ?? payload.entityId,
|
|
115
|
+
operation: existing ? 'update' : 'create',
|
|
116
|
+
requestMethod: req.method,
|
|
117
|
+
requestHeaders: req.headers,
|
|
118
|
+
mutationPayload: payload,
|
|
86
119
|
})
|
|
87
|
-
|
|
88
|
-
|
|
120
|
+
if (guardResult && !guardResult.ok) {
|
|
121
|
+
return NextResponse.json(guardResult.body, { status: guardResult.status })
|
|
122
|
+
}
|
|
89
123
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
124
|
+
let saved: any
|
|
125
|
+
if (existing) {
|
|
126
|
+
existing.fieldsJson = payload.fields
|
|
127
|
+
existing.isActive = payload.isActive ?? true
|
|
128
|
+
existing.updatedAt = new Date()
|
|
129
|
+
await em.persist(existing).flush()
|
|
130
|
+
saved = existing
|
|
131
|
+
} else {
|
|
132
|
+
const map = repo.create({
|
|
133
|
+
entityId: payload.entityId,
|
|
134
|
+
tenantId,
|
|
135
|
+
organizationId,
|
|
136
|
+
fieldsJson: payload.fields,
|
|
137
|
+
isActive: payload.isActive ?? true,
|
|
138
|
+
})
|
|
139
|
+
await em.persist(map).flush()
|
|
140
|
+
saved = map
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
|
|
144
|
+
await runCrudMutationGuardAfterSuccess(container, {
|
|
145
|
+
tenantId,
|
|
146
|
+
organizationId,
|
|
147
|
+
userId: auth.sub,
|
|
148
|
+
resourceKind: ENCRYPTION_MAP_RESOURCE_KIND,
|
|
149
|
+
resourceId: saved?.id ?? payload.entityId,
|
|
150
|
+
operation: existing ? 'update' : 'create',
|
|
151
|
+
requestMethod: req.method,
|
|
152
|
+
requestHeaders: req.headers,
|
|
153
|
+
metadata: guardResult.metadata ?? null,
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const svc = container.resolve('tenantEncryptionService') as { invalidateMap?: (e: string, t: string | null, o: string | null) => Promise<void> }
|
|
159
|
+
await svc?.invalidateMap?.(payload.entityId, tenantId, organizationId)
|
|
160
|
+
} catch {
|
|
161
|
+
// best-effort cache bust
|
|
162
|
+
}
|
|
96
163
|
|
|
97
|
-
|
|
164
|
+
return NextResponse.json({ ok: true, updatedAt: toIsoOrNull(saved?.updatedAt) })
|
|
165
|
+
} catch (err) {
|
|
166
|
+
if (isCrudHttpError(err)) {
|
|
167
|
+
return NextResponse.json(err.body, { status: err.status })
|
|
168
|
+
}
|
|
169
|
+
throw err
|
|
170
|
+
}
|
|
98
171
|
}
|
|
99
172
|
|
|
173
|
+
const conflictResponseSchema = z.object({
|
|
174
|
+
error: z.string(),
|
|
175
|
+
code: z.string(),
|
|
176
|
+
currentUpdatedAt: z.string(),
|
|
177
|
+
expectedUpdatedAt: z.string(),
|
|
178
|
+
})
|
|
179
|
+
|
|
100
180
|
export const openApi: OpenApiRouteDoc = {
|
|
101
181
|
tag: 'Entities',
|
|
102
182
|
summary: 'Manage encryption maps',
|
|
@@ -105,13 +185,16 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
105
185
|
summary: 'Fetch encryption map',
|
|
106
186
|
description: 'Returns the encrypted field map for the current tenant/organization scope.',
|
|
107
187
|
query: z.object({ entityId: z.string() }),
|
|
108
|
-
responses: [{ status: 200, description: 'Map', schema: z.object({ entityId: z.string(), fields: z.array(z.object({ field: z.string(), hashField: z.string().nullable().optional() })), isActive: z.boolean().optional() }) }],
|
|
188
|
+
responses: [{ status: 200, description: 'Map', schema: z.object({ entityId: z.string(), fields: z.array(z.object({ field: z.string(), hashField: z.string().nullable().optional() })), isActive: z.boolean().optional(), updatedAt: z.string().nullable().optional() }) }],
|
|
109
189
|
},
|
|
110
190
|
POST: {
|
|
111
191
|
summary: 'Upsert encryption map',
|
|
112
|
-
description: 'Creates or updates the encryption map for the current tenant/organization scope.',
|
|
192
|
+
description: 'Creates or updates the encryption map for the current tenant/organization scope. Enforces optimistic locking when the caller sends the expected version header.',
|
|
113
193
|
requestBody: { contentType: 'application/json', schema: upsertEncryptionMapSchema },
|
|
114
|
-
responses: [
|
|
194
|
+
responses: [
|
|
195
|
+
{ status: 200, description: 'Saved', schema: z.object({ ok: z.boolean(), updatedAt: z.string().nullable().optional() }) },
|
|
196
|
+
{ status: 409, description: 'Optimistic-lock conflict (stale write)', schema: conflictResponseSchema },
|
|
197
|
+
],
|
|
115
198
|
},
|
|
116
199
|
},
|
|
117
200
|
}
|
|
@@ -13,8 +13,10 @@ import {
|
|
|
13
13
|
} from '@open-mercato/ui/primitives/select'
|
|
14
14
|
import { LoadingMessage, ErrorMessage } from '@open-mercato/ui/backend/detail'
|
|
15
15
|
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
16
|
-
import { apiCall, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
|
|
16
|
+
import { apiCall, readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
|
|
17
17
|
import { raiseCrudError } from '@open-mercato/ui/backend/utils/serverErrors'
|
|
18
|
+
import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
|
|
19
|
+
import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
|
|
18
20
|
import { useCustomFieldDefs, type CustomFieldDefDto } from '@open-mercato/ui/backend/utils/customFieldDefs'
|
|
19
21
|
import { Plus, Save, Trash2 } from 'lucide-react'
|
|
20
22
|
import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
|
|
@@ -34,6 +36,7 @@ type EncryptionMapResponse = {
|
|
|
34
36
|
entityId: string
|
|
35
37
|
fields?: Array<{ field: string; hashField?: string | null }>
|
|
36
38
|
isActive?: boolean
|
|
39
|
+
updatedAt?: string | null
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
type CanonicalOption = { value: string; label?: string }
|
|
@@ -225,6 +228,8 @@ export function EncryptionManager() {
|
|
|
225
228
|
lastMapSignatureRef.current = signature
|
|
226
229
|
}, [mapSignature, map, canonicalOptions, hasUserEdited])
|
|
227
230
|
|
|
231
|
+
const { runMutation } = useGuardedMutation({ contextId: 'entities.encryption-map' })
|
|
232
|
+
|
|
228
233
|
const mutation = useMutation({
|
|
229
234
|
mutationFn: async () => {
|
|
230
235
|
const trimmed = fields
|
|
@@ -243,15 +248,29 @@ export function EncryptionManager() {
|
|
|
243
248
|
if (!trimmed.length) {
|
|
244
249
|
throw new Error(t('entities.encryption.errors.noFields', 'Add at least one field to encrypt'))
|
|
245
250
|
}
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
251
|
+
const payload = { entityId: selectedEntityId, fields: trimmed, isActive }
|
|
252
|
+
// Route the write through the guarded mutation helper so global injection
|
|
253
|
+
// modules (mutation guard, record-lock conflict handling) can run, and send
|
|
254
|
+
// the loaded map's version so a stale tab cannot silently overwrite a newer
|
|
255
|
+
// encryption configuration (the server rejects mismatches with a 409).
|
|
256
|
+
return runMutation({
|
|
257
|
+
operation: async () => {
|
|
258
|
+
const res = await withScopedApiRequestHeaders(
|
|
259
|
+
buildOptimisticLockHeader(map?.updatedAt ?? null),
|
|
260
|
+
() => apiCall('/api/entities/encryption', {
|
|
261
|
+
method: 'POST',
|
|
262
|
+
headers: { 'content-type': 'application/json' },
|
|
263
|
+
body: JSON.stringify(payload),
|
|
264
|
+
}),
|
|
265
|
+
)
|
|
266
|
+
if (!res.ok) {
|
|
267
|
+
await raiseCrudError(res.response, t('entities.encryption.errors.save', 'Failed to save encryption map'))
|
|
268
|
+
}
|
|
269
|
+
return true
|
|
270
|
+
},
|
|
271
|
+
context: {},
|
|
272
|
+
mutationPayload: payload,
|
|
250
273
|
})
|
|
251
|
-
if (!res.ok) {
|
|
252
|
-
await raiseCrudError(res.response, t('entities.encryption.errors.save', 'Failed to save encryption map'))
|
|
253
|
-
}
|
|
254
|
-
return true
|
|
255
274
|
},
|
|
256
275
|
onSuccess: () => {
|
|
257
276
|
flash(t('entities.encryption.flash.saved', 'Encryption map saved'), 'success')
|
|
@@ -3,6 +3,8 @@ import { z } from 'zod'
|
|
|
3
3
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
4
4
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
5
5
|
import { getIntegration } from '@open-mercato/shared/modules/integrations/types'
|
|
6
|
+
import { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
|
|
7
|
+
import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
6
8
|
import { emitIntegrationsEvent } from '../../../events'
|
|
7
9
|
import { saveCredentialsSchema } from '../../../data/validators'
|
|
8
10
|
import {
|
|
@@ -58,8 +60,10 @@ export async function GET(req: Request, ctx: { params?: Promise<{ id?: string }>
|
|
|
58
60
|
const scope = { organizationId: auth.orgId as string, tenantId: auth.tenantId }
|
|
59
61
|
|
|
60
62
|
let values: Record<string, unknown> | null
|
|
63
|
+
let updatedAt: Date | null
|
|
61
64
|
try {
|
|
62
65
|
values = await credentialsService.resolve(integration.id, scope)
|
|
66
|
+
updatedAt = await credentialsService.resolveUpdatedAt(integration.id, scope)
|
|
63
67
|
} catch (error) {
|
|
64
68
|
if (isCredentialsEncryptionUnavailableError(error)) {
|
|
65
69
|
return NextResponse.json({ error: 'Integration credentials encryption is unavailable' }, { status: 503 })
|
|
@@ -71,6 +75,7 @@ export async function GET(req: Request, ctx: { params?: Promise<{ id?: string }>
|
|
|
71
75
|
integrationId: integration.id,
|
|
72
76
|
schema: credentialsService.getSchema(integration.id),
|
|
73
77
|
credentials: values ?? {},
|
|
78
|
+
updatedAt: updatedAt?.toISOString() ?? null,
|
|
74
79
|
})
|
|
75
80
|
}
|
|
76
81
|
|
|
@@ -130,6 +135,24 @@ export async function PUT(req: Request, ctx: { params?: Promise<{ id?: string }>
|
|
|
130
135
|
const credentialsService = container.resolve('integrationCredentialsService') as CredentialsService
|
|
131
136
|
const scope = { organizationId: auth.orgId as string, tenantId: auth.tenantId }
|
|
132
137
|
|
|
138
|
+
try {
|
|
139
|
+
const currentUpdatedAt = await credentialsService.resolveUpdatedAt(integration.id, scope)
|
|
140
|
+
enforceCommandOptimisticLock({
|
|
141
|
+
resourceKind: 'integrations.integration',
|
|
142
|
+
resourceId: integration.id,
|
|
143
|
+
current: currentUpdatedAt,
|
|
144
|
+
request: req,
|
|
145
|
+
})
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (isCrudHttpError(error)) {
|
|
148
|
+
return NextResponse.json(error.body, { status: error.status })
|
|
149
|
+
}
|
|
150
|
+
if (isCredentialsEncryptionUnavailableError(error)) {
|
|
151
|
+
return NextResponse.json({ error: 'Integration credentials encryption is unavailable' }, { status: 503 })
|
|
152
|
+
}
|
|
153
|
+
throw error
|
|
154
|
+
}
|
|
155
|
+
|
|
133
156
|
const credentialFieldErrors = collectCredentialUrlValidationErrors(
|
|
134
157
|
credentialsService.getSchema(integration.id),
|
|
135
158
|
payloadData.credentials,
|
|
@@ -59,11 +59,15 @@ export async function GET(req: Request, ctx: { params?: Promise<{ id?: string }>
|
|
|
59
59
|
const logService = container.resolve('integrationLogService') as IntegrationLogService
|
|
60
60
|
const scope = { organizationId: auth.orgId as string, tenantId: auth.tenantId }
|
|
61
61
|
|
|
62
|
-
const [credentials, state, analyticsMap] = await Promise.all([
|
|
62
|
+
const [credentials, credentialsUpdatedAt, state, analyticsMap] = await Promise.all([
|
|
63
63
|
credentialsService.resolve(integration.id, scope).catch((err) => {
|
|
64
64
|
if (err instanceof CredentialsEncryptionUnavailableError) return null
|
|
65
65
|
throw err
|
|
66
66
|
}),
|
|
67
|
+
credentialsService.resolveUpdatedAt(integration.id, scope).catch((err) => {
|
|
68
|
+
if (err instanceof CredentialsEncryptionUnavailableError) return null
|
|
69
|
+
throw err
|
|
70
|
+
}),
|
|
67
71
|
stateService.resolveState(integration.id, scope),
|
|
68
72
|
logService.aggregateAnalytics([integration.id], scope, 30),
|
|
69
73
|
])
|
|
@@ -102,6 +106,7 @@ export async function GET(req: Request, ctx: { params?: Promise<{ id?: string }>
|
|
|
102
106
|
lastHealthCheckedAt: resolvedState.lastHealthCheckedAt?.toISOString() ?? null,
|
|
103
107
|
lastHealthLatencyMs: resolvedState.lastHealthLatencyMs,
|
|
104
108
|
enabledAt: resolvedState.enabledAt?.toISOString() ?? null,
|
|
109
|
+
updatedAt: resolvedState.updatedAt?.toISOString() ?? null,
|
|
105
110
|
},
|
|
106
111
|
}
|
|
107
112
|
}),
|
|
@@ -132,8 +137,10 @@ export async function GET(req: Request, ctx: { params?: Promise<{ id?: string }>
|
|
|
132
137
|
lastHealthCheckedAt: state.lastHealthCheckedAt?.toISOString() ?? null,
|
|
133
138
|
lastHealthLatencyMs: state.lastHealthLatencyMs,
|
|
134
139
|
enabledAt: state.enabledAt?.toISOString() ?? null,
|
|
140
|
+
updatedAt: state.updatedAt?.toISOString() ?? null,
|
|
135
141
|
},
|
|
136
142
|
hasCredentials,
|
|
143
|
+
credentialsUpdatedAt: credentialsUpdatedAt?.toISOString() ?? null,
|
|
137
144
|
healthStatus,
|
|
138
145
|
analytics,
|
|
139
146
|
},
|
|
@@ -3,6 +3,8 @@ import { z } from 'zod'
|
|
|
3
3
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
4
4
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
5
5
|
import { getIntegration } from '@open-mercato/shared/modules/integrations/types'
|
|
6
|
+
import { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
|
|
7
|
+
import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
6
8
|
import { emitIntegrationsEvent } from '../../../events'
|
|
7
9
|
import { updateStateSchema } from '../../../data/validators'
|
|
8
10
|
import type { IntegrationStateService } from '../../../lib/state-service'
|
|
@@ -80,6 +82,22 @@ export async function PUT(req: Request, ctx: { params?: Promise<{ id?: string }>
|
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
const stateService = container.resolve('integrationStateService') as IntegrationStateService
|
|
85
|
+
const stateScope = { organizationId: auth.orgId as string, tenantId: auth.tenantId }
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const current = await stateService.resolveState(integration.id, stateScope)
|
|
89
|
+
enforceCommandOptimisticLock({
|
|
90
|
+
resourceKind: 'integrations.integration',
|
|
91
|
+
resourceId: integration.id,
|
|
92
|
+
current: current.updatedAt,
|
|
93
|
+
request: req,
|
|
94
|
+
})
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (isCrudHttpError(error)) {
|
|
97
|
+
return NextResponse.json(error.body, { status: error.status })
|
|
98
|
+
}
|
|
99
|
+
throw error
|
|
100
|
+
}
|
|
83
101
|
|
|
84
102
|
const state = await stateService.upsert(
|
|
85
103
|
integration.id,
|
|
@@ -87,10 +105,7 @@ export async function PUT(req: Request, ctx: { params?: Promise<{ id?: string }>
|
|
|
87
105
|
isEnabled: payloadData.isEnabled,
|
|
88
106
|
reauthRequired: payloadData.reauthRequired,
|
|
89
107
|
},
|
|
90
|
-
|
|
91
|
-
organizationId: auth.orgId as string,
|
|
92
|
-
tenantId: auth.tenantId,
|
|
93
|
-
},
|
|
108
|
+
stateScope,
|
|
94
109
|
)
|
|
95
110
|
|
|
96
111
|
await emitIntegrationsEvent('integrations.state.updated', {
|
|
@@ -117,5 +132,6 @@ export async function PUT(req: Request, ctx: { params?: Promise<{ id?: string }>
|
|
|
117
132
|
isEnabled: state.isEnabled,
|
|
118
133
|
reauthRequired: state.reauthRequired,
|
|
119
134
|
apiVersion: state.apiVersion ?? null,
|
|
135
|
+
updatedAt: state.updatedAt?.toISOString() ?? null,
|
|
120
136
|
})
|
|
121
137
|
}
|
|
@@ -3,6 +3,8 @@ import { z } from 'zod'
|
|
|
3
3
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
4
4
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
5
5
|
import { getIntegration } from '@open-mercato/shared/modules/integrations/types'
|
|
6
|
+
import { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
|
|
7
|
+
import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
6
8
|
import { emitIntegrationsEvent } from '../../../events'
|
|
7
9
|
import { updateVersionSchema } from '../../../data/validators'
|
|
8
10
|
import type { IntegrationStateService } from '../../../lib/state-service'
|
|
@@ -102,7 +104,22 @@ export async function PUT(req: Request, ctx: { params?: Promise<{ id?: string }>
|
|
|
102
104
|
const stateService = container.resolve('integrationStateService') as IntegrationStateService
|
|
103
105
|
const scope = { organizationId: auth.orgId as string, tenantId: auth.tenantId }
|
|
104
106
|
|
|
105
|
-
const
|
|
107
|
+
const currentState = await stateService.resolveState(integration.id, scope)
|
|
108
|
+
try {
|
|
109
|
+
enforceCommandOptimisticLock({
|
|
110
|
+
resourceKind: 'integrations.integration',
|
|
111
|
+
resourceId: integration.id,
|
|
112
|
+
current: currentState.updatedAt,
|
|
113
|
+
request: req,
|
|
114
|
+
})
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (isCrudHttpError(error)) {
|
|
117
|
+
return NextResponse.json(error.body, { status: error.status })
|
|
118
|
+
}
|
|
119
|
+
throw error
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const before = currentState.apiVersion
|
|
106
123
|
await stateService.upsert(integration.id, { apiVersion: payloadData.apiVersion }, scope)
|
|
107
124
|
|
|
108
125
|
await emitIntegrationsEvent('integrations.version.changed', {
|
|
@@ -93,6 +93,7 @@ export async function GET(req: Request) {
|
|
|
93
93
|
lastHealthCheckedAt: string | null
|
|
94
94
|
lastHealthLatencyMs: number | null
|
|
95
95
|
enabledAt: string | null
|
|
96
|
+
stateUpdatedAt: string | null
|
|
96
97
|
sortEnabledAtMs: number
|
|
97
98
|
sortTitle: string
|
|
98
99
|
sortCategory: string
|
|
@@ -140,6 +141,7 @@ export async function GET(req: Request) {
|
|
|
140
141
|
lastHealthCheckedAt: state.lastHealthCheckedAt?.toISOString() ?? null,
|
|
141
142
|
lastHealthLatencyMs: state.lastHealthLatencyMs,
|
|
142
143
|
enabledAt: state.enabledAt?.toISOString() ?? null,
|
|
144
|
+
stateUpdatedAt: state.updatedAt?.toISOString() ?? null,
|
|
143
145
|
sortEnabledAtMs: enabledAtMs,
|
|
144
146
|
sortTitle: integration.title.toLowerCase(),
|
|
145
147
|
sortCategory: (integration.category ?? '').toLowerCase(),
|
|
@@ -220,6 +222,7 @@ export async function GET(req: Request) {
|
|
|
220
222
|
lastHealthCheckedAt: row.lastHealthCheckedAt,
|
|
221
223
|
lastHealthLatencyMs: row.lastHealthLatencyMs,
|
|
222
224
|
enabledAt: row.enabledAt,
|
|
225
|
+
stateUpdatedAt: row.stateUpdatedAt,
|
|
223
226
|
analytics: analytics ?? {
|
|
224
227
|
lastActivityAt: null,
|
|
225
228
|
totalCount: 0,
|