@open-mercato/core 0.6.6-develop.6205.1.109e4b6a84 → 0.6.6-develop.6228.1.cf483c68b5
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/currencies/backend/currencies/[id]/page.js +33 -8
- package/dist/modules/currencies/backend/currencies/[id]/page.js.map +2 -2
- package/dist/modules/currencies/backend/currencies/page.js +63 -27
- package/dist/modules/currencies/backend/currencies/page.js.map +2 -2
- package/dist/modules/currencies/backend/exchange-rates/page.js +35 -14
- package/dist/modules/currencies/backend/exchange-rates/page.js.map +2 -2
- package/dist/modules/currencies/components/CurrencyFetchingConfig.js +104 -41
- package/dist/modules/currencies/components/CurrencyFetchingConfig.js.map +2 -2
- package/dist/modules/customer_accounts/api/admin/roles/[id]/acl.js +57 -8
- package/dist/modules/customer_accounts/api/admin/roles/[id]/acl.js.map +2 -2
- package/dist/modules/customer_accounts/backend/customer_accounts/roles/[id]/page.js +5 -2
- package/dist/modules/customer_accounts/backend/customer_accounts/roles/[id]/page.js.map +2 -2
- package/dist/modules/customer_accounts/backend/customer_accounts/roles/page.js +30 -14
- package/dist/modules/customer_accounts/backend/customer_accounts/roles/page.js.map +2 -2
- package/dist/modules/customer_accounts/widgets/injection/account-status/widget.client.js +29 -19
- package/dist/modules/customer_accounts/widgets/injection/account-status/widget.client.js.map +2 -2
- package/dist/modules/customers/backend/config/customers/pipeline-stages/page.js +166 -64
- package/dist/modules/customers/backend/config/customers/pipeline-stages/page.js.map +2 -2
- package/dist/modules/customers/components/AddressFormatSettings.js +31 -16
- package/dist/modules/customers/components/AddressFormatSettings.js.map +2 -2
- package/dist/modules/customers/components/DictionarySettings.js +57 -31
- package/dist/modules/customers/components/DictionarySettings.js.map +2 -2
- package/dist/modules/customers/components/PipelineSettings.js +156 -76
- package/dist/modules/customers/components/PipelineSettings.js.map +2 -2
- package/dist/modules/customers/components/calendar/AgendaList.js +10 -4
- package/dist/modules/customers/components/calendar/AgendaList.js.map +2 -2
- package/dist/modules/customers/components/calendar/CalendarSettingsModal.js +18 -2
- package/dist/modules/customers/components/calendar/CalendarSettingsModal.js.map +2 -2
- package/dist/modules/customers/components/calendar/MonthGrid.js +5 -2
- package/dist/modules/customers/components/calendar/MonthGrid.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/PeopleField.js +6 -0
- package/dist/modules/customers/components/calendar/editor/PeopleField.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/RelatedToField.js +2 -0
- package/dist/modules/customers/components/calendar/editor/RelatedToField.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/ScheduleSection.js +4 -1
- package/dist/modules/customers/components/calendar/editor/ScheduleSection.js.map +2 -2
- package/dist/modules/customers/lib/calendar/labels.js +33 -0
- package/dist/modules/customers/lib/calendar/labels.js.map +7 -0
- package/dist/modules/dashboards/components/WidgetVisibilityEditor.js +32 -11
- package/dist/modules/dashboards/components/WidgetVisibilityEditor.js.map +2 -2
- package/dist/modules/data_sync/api/mappings/[id]/route.js +59 -0
- package/dist/modules/data_sync/api/mappings/[id]/route.js.map +2 -2
- package/dist/modules/data_sync/api/mappings/route.js +44 -0
- package/dist/modules/data_sync/api/mappings/route.js.map +2 -2
- package/dist/modules/data_sync/api/run.js +31 -0
- package/dist/modules/data_sync/api/run.js.map +2 -2
- package/dist/modules/data_sync/api/runs/[id]/cancel.js +31 -0
- package/dist/modules/data_sync/api/runs/[id]/cancel.js.map +2 -2
- package/dist/modules/data_sync/api/runs/[id]/retry.js +31 -0
- package/dist/modules/data_sync/api/runs/[id]/retry.js.map +2 -2
- package/dist/modules/data_sync/api/schedules/[id]/route.js +59 -3
- package/dist/modules/data_sync/api/schedules/[id]/route.js.map +2 -2
- package/dist/modules/data_sync/api/schedules/route.js +33 -4
- package/dist/modules/data_sync/api/schedules/route.js.map +2 -2
- package/dist/modules/dictionaries/fields/dictionary.js +29 -4
- package/dist/modules/dictionaries/fields/dictionary.js.map +2 -2
- package/dist/modules/directory/api/organization-branding/route.js +54 -2
- package/dist/modules/directory/api/organization-branding/route.js.map +2 -2
- package/dist/modules/directory/backend/directory/branding/page.js +15 -9
- package/dist/modules/directory/backend/directory/branding/page.js.map +2 -2
- package/dist/modules/directory/backend/directory/organizations/page.js +28 -9
- package/dist/modules/directory/backend/directory/organizations/page.js.map +2 -2
- package/dist/modules/directory/backend/directory/tenants/page.js +29 -13
- package/dist/modules/directory/backend/directory/tenants/page.js.map +2 -2
- package/dist/modules/entities/api/definitions.batch.js +15 -0
- package/dist/modules/entities/api/definitions.batch.js.map +2 -2
- package/dist/modules/entities/api/definitions.js +26 -0
- package/dist/modules/entities/api/definitions.js.map +2 -2
- package/dist/modules/entities/api/definitions.mutation-guard.js +57 -0
- package/dist/modules/entities/api/definitions.mutation-guard.js.map +7 -0
- package/dist/modules/entities/api/definitions.restore.js +15 -0
- package/dist/modules/entities/api/definitions.restore.js.map +2 -2
- package/dist/modules/entities/api/entities.js +31 -3
- package/dist/modules/entities/api/entities.js.map +2 -2
- package/dist/modules/entities/api/records.js +18 -0
- package/dist/modules/entities/api/records.js.map +2 -2
- package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js +28 -10
- package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js.map +2 -2
- package/dist/modules/feature_toggles/api/overrides/route.js +38 -1
- package/dist/modules/feature_toggles/api/overrides/route.js.map +2 -2
- package/dist/modules/inbox_ops/api/emails/[id]/route.js +30 -0
- package/dist/modules/inbox_ops/api/emails/[id]/route.js.map +2 -2
- package/dist/modules/inbox_ops/api/proposals/[id]/accept-all/route.js +30 -0
- package/dist/modules/inbox_ops/api/proposals/[id]/accept-all/route.js.map +2 -2
- package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.js +31 -0
- package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.js.map +2 -2
- package/dist/modules/inbox_ops/api/proposals/[id]/categorize/route.js +31 -0
- package/dist/modules/inbox_ops/api/proposals/[id]/categorize/route.js.map +2 -2
- package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsActions.js +68 -39
- package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsActions.js.map +2 -2
- package/dist/modules/notifications/frontend/NotificationSettingsPageClient.js +14 -4
- package/dist/modules/notifications/frontend/NotificationSettingsPageClient.js.map +2 -2
- package/dist/modules/payment_gateways/api/cancel/route.js +37 -0
- package/dist/modules/payment_gateways/api/cancel/route.js.map +2 -2
- package/dist/modules/payment_gateways/api/capture/route.js +37 -0
- package/dist/modules/payment_gateways/api/capture/route.js.map +2 -2
- package/dist/modules/payment_gateways/api/guards.js +31 -0
- package/dist/modules/payment_gateways/api/guards.js.map +7 -0
- package/dist/modules/payment_gateways/api/refund/route.js +37 -0
- package/dist/modules/payment_gateways/api/refund/route.js.map +2 -2
- package/dist/modules/payment_gateways/api/sessions/route.js +37 -0
- package/dist/modules/payment_gateways/api/sessions/route.js.map +2 -2
- package/dist/modules/planner/api/availability-date-specific.js +11 -1
- package/dist/modules/planner/api/availability-date-specific.js.map +2 -2
- package/dist/modules/planner/backend/planner/availability-rulesets/page.js +20 -3
- package/dist/modules/planner/backend/planner/availability-rulesets/page.js.map +2 -2
- package/dist/modules/planner/commands/availability-date-specific.js +16 -0
- package/dist/modules/planner/commands/availability-date-specific.js.map +2 -2
- package/dist/modules/planner/components/AvailabilityRulesEditor.js +109 -40
- package/dist/modules/planner/components/AvailabilityRulesEditor.js.map +2 -2
- package/dist/modules/query_index/api/purge.js +35 -3
- package/dist/modules/query_index/api/purge.js.map +2 -2
- package/dist/modules/query_index/api/reindex.js +41 -3
- package/dist/modules/query_index/api/reindex.js.map +2 -2
- package/dist/modules/query_index/components/QueryIndexesTable.js +57 -24
- package/dist/modules/query_index/components/QueryIndexesTable.js.map +2 -2
- package/dist/modules/shipping_carriers/api/shipments/route.js +31 -0
- package/dist/modules/shipping_carriers/api/shipments/route.js.map +2 -2
- package/dist/modules/shipping_carriers/api/tracking/refresh/route.js +55 -0
- package/dist/modules/shipping_carriers/api/tracking/refresh/route.js.map +7 -0
- package/dist/modules/shipping_carriers/data/validators.js +6 -1
- package/dist/modules/shipping_carriers/data/validators.js.map +2 -2
- package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/useShipmentWizard.js +28 -8
- package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/useShipmentWizard.js.map +2 -2
- package/dist/modules/shipping_carriers/lib/shipping-service.js +37 -7
- package/dist/modules/shipping_carriers/lib/shipping-service.js.map +2 -2
- package/dist/modules/shipping_carriers/workers/status-poller.js +1 -1
- package/dist/modules/shipping_carriers/workers/status-poller.js.map +2 -2
- package/dist/modules/translations/components/TranslationManager.js +49 -20
- package/dist/modules/translations/components/TranslationManager.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/currencies/backend/currencies/[id]/page.tsx +40 -10
- package/src/modules/currencies/backend/currencies/page.tsx +68 -29
- package/src/modules/currencies/backend/exchange-rates/page.tsx +40 -15
- package/src/modules/currencies/components/CurrencyFetchingConfig.tsx +110 -41
- package/src/modules/customer_accounts/api/admin/roles/[id]/acl.ts +69 -7
- package/src/modules/customer_accounts/backend/customer_accounts/roles/[id]/page.tsx +16 -8
- package/src/modules/customer_accounts/backend/customer_accounts/roles/page.tsx +32 -14
- package/src/modules/customer_accounts/widgets/injection/account-status/widget.client.tsx +32 -20
- package/src/modules/customers/backend/config/customers/pipeline-stages/page.tsx +171 -64
- package/src/modules/customers/components/AddressFormatSettings.tsx +39 -19
- package/src/modules/customers/components/DictionarySettings.tsx +63 -29
- package/src/modules/customers/components/PipelineSettings.tsx +165 -80
- package/src/modules/customers/components/calendar/AgendaList.tsx +13 -8
- package/src/modules/customers/components/calendar/CalendarSettingsModal.tsx +16 -2
- package/src/modules/customers/components/calendar/MonthGrid.tsx +5 -2
- package/src/modules/customers/components/calendar/editor/PeopleField.tsx +6 -0
- package/src/modules/customers/components/calendar/editor/RelatedToField.tsx +2 -0
- package/src/modules/customers/components/calendar/editor/ScheduleSection.tsx +7 -0
- package/src/modules/customers/i18n/de.json +11 -7
- package/src/modules/customers/i18n/en.json +6 -2
- package/src/modules/customers/i18n/es.json +11 -7
- package/src/modules/customers/i18n/pl.json +11 -7
- package/src/modules/customers/lib/calendar/labels.ts +42 -0
- package/src/modules/dashboards/components/WidgetVisibilityEditor.tsx +39 -11
- package/src/modules/data_sync/api/mappings/[id]/route.ts +63 -0
- package/src/modules/data_sync/api/mappings/route.ts +48 -0
- package/src/modules/data_sync/api/run.ts +33 -0
- package/src/modules/data_sync/api/runs/[id]/cancel.ts +34 -0
- package/src/modules/data_sync/api/runs/[id]/retry.ts +33 -0
- package/src/modules/data_sync/api/schedules/[id]/route.ts +64 -2
- package/src/modules/data_sync/api/schedules/route.ts +36 -4
- package/src/modules/dictionaries/fields/dictionary.tsx +31 -8
- package/src/modules/dictionaries/i18n/de.json +1 -0
- package/src/modules/dictionaries/i18n/en.json +1 -0
- package/src/modules/dictionaries/i18n/es.json +1 -0
- package/src/modules/dictionaries/i18n/pl.json +1 -0
- package/src/modules/directory/api/organization-branding/route.ts +61 -0
- package/src/modules/directory/backend/directory/branding/page.tsx +16 -10
- package/src/modules/directory/backend/directory/organizations/page.tsx +35 -8
- package/src/modules/directory/backend/directory/tenants/page.tsx +37 -13
- package/src/modules/entities/api/definitions.batch.ts +17 -0
- package/src/modules/entities/api/definitions.mutation-guard.ts +80 -0
- package/src/modules/entities/api/definitions.restore.ts +17 -0
- package/src/modules/entities/api/definitions.ts +30 -0
- package/src/modules/entities/api/entities.ts +35 -3
- package/src/modules/entities/api/records.ts +20 -0
- package/src/modules/entities/backend/entities/user/[entityId]/records/page.tsx +33 -10
- package/src/modules/feature_toggles/api/overrides/route.ts +44 -1
- package/src/modules/inbox_ops/api/emails/[id]/route.ts +32 -0
- package/src/modules/inbox_ops/api/proposals/[id]/accept-all/route.ts +32 -0
- package/src/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.ts +33 -0
- package/src/modules/inbox_ops/api/proposals/[id]/categorize/route.ts +33 -0
- package/src/modules/messages/components/message-detail/hooks/useMessageDetailsActions.ts +80 -42
- package/src/modules/notifications/frontend/NotificationSettingsPageClient.tsx +21 -4
- package/src/modules/payment_gateways/api/cancel/route.ts +39 -0
- package/src/modules/payment_gateways/api/capture/route.ts +39 -0
- package/src/modules/payment_gateways/api/guards.ts +59 -0
- package/src/modules/payment_gateways/api/refund/route.ts +39 -0
- package/src/modules/payment_gateways/api/sessions/route.ts +40 -0
- package/src/modules/planner/api/availability-date-specific.ts +10 -0
- package/src/modules/planner/backend/planner/availability-rulesets/page.tsx +26 -5
- package/src/modules/planner/commands/availability-date-specific.ts +24 -0
- package/src/modules/planner/components/AvailabilityRulesEditor.tsx +122 -41
- package/src/modules/query_index/api/purge.ts +37 -3
- package/src/modules/query_index/api/reindex.ts +43 -3
- package/src/modules/query_index/components/QueryIndexesTable.tsx +66 -24
- package/src/modules/shipping_carriers/api/shipments/route.ts +31 -0
- package/src/modules/shipping_carriers/api/tracking/refresh/route.ts +53 -0
- package/src/modules/shipping_carriers/data/validators.ts +5 -0
- package/src/modules/shipping_carriers/lib/shipment-wizard/hooks/useShipmentWizard.ts +29 -8
- package/src/modules/shipping_carriers/lib/shipping-service.ts +43 -7
- package/src/modules/shipping_carriers/workers/status-poller.ts +1 -1
- package/src/modules/translations/components/TranslationManager.tsx +65 -21
|
@@ -7,6 +7,10 @@ import type { SyncScheduleService } from '../../lib/sync-schedule-service'
|
|
|
7
7
|
import { serializeSchedule } from './serialize'
|
|
8
8
|
import { readOptimisticLockExpected } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
|
|
9
9
|
import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
10
|
+
import {
|
|
11
|
+
runCrudMutationGuardAfterSuccess,
|
|
12
|
+
validateCrudMutationGuard,
|
|
13
|
+
} from '@open-mercato/shared/lib/crud/mutation-guard'
|
|
10
14
|
|
|
11
15
|
export const metadata = {
|
|
12
16
|
GET: { requireAuth: true, requireFeatures: ['data_sync.configure'] },
|
|
@@ -65,15 +69,43 @@ export async function POST(req: Request) {
|
|
|
65
69
|
|
|
66
70
|
const container = await createRequestContainer()
|
|
67
71
|
const scheduleService = container.resolve('dataSyncScheduleService') as SyncScheduleService
|
|
72
|
+
const scope = { organizationId: auth.orgId as string, tenantId: auth.tenantId }
|
|
73
|
+
|
|
74
|
+
const guardResult = await validateCrudMutationGuard(container, {
|
|
75
|
+
tenantId: auth.tenantId,
|
|
76
|
+
organizationId: scope.organizationId,
|
|
77
|
+
userId: auth.sub,
|
|
78
|
+
resourceKind: 'data_sync.schedule',
|
|
79
|
+
resourceId: scope.organizationId,
|
|
80
|
+
operation: 'create',
|
|
81
|
+
requestMethod: req.method,
|
|
82
|
+
requestHeaders: req.headers,
|
|
83
|
+
mutationPayload: parsed.data,
|
|
84
|
+
})
|
|
85
|
+
if (guardResult && !guardResult.ok) {
|
|
86
|
+
return NextResponse.json(guardResult.body, { status: guardResult.status })
|
|
87
|
+
}
|
|
68
88
|
|
|
69
89
|
try {
|
|
70
90
|
const schedule = await scheduleService.saveSchedule({
|
|
71
91
|
...parsed.data,
|
|
72
92
|
expectedUpdatedAt: readOptimisticLockExpected(req),
|
|
73
|
-
},
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
93
|
+
}, scope)
|
|
94
|
+
|
|
95
|
+
if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
|
|
96
|
+
await runCrudMutationGuardAfterSuccess(container, {
|
|
97
|
+
tenantId: auth.tenantId,
|
|
98
|
+
organizationId: scope.organizationId,
|
|
99
|
+
userId: auth.sub,
|
|
100
|
+
resourceKind: 'data_sync.schedule',
|
|
101
|
+
resourceId: schedule.id,
|
|
102
|
+
operation: 'create',
|
|
103
|
+
requestMethod: req.method,
|
|
104
|
+
requestHeaders: req.headers,
|
|
105
|
+
metadata: guardResult.metadata ?? null,
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
77
109
|
return NextResponse.json(serializeSchedule(schedule), { status: 201 })
|
|
78
110
|
} catch (error) {
|
|
79
111
|
if (isCrudHttpError(error)) {
|
|
@@ -19,6 +19,7 @@ import { useDictionaryEntries } from '../components/hooks/useDictionaryEntries'
|
|
|
19
19
|
type DictionaryFieldDefinition = {
|
|
20
20
|
dictionaryId?: string
|
|
21
21
|
dictionaryInlineCreate?: boolean
|
|
22
|
+
multi?: boolean
|
|
22
23
|
defaultValue?: string
|
|
23
24
|
}
|
|
24
25
|
|
|
@@ -84,9 +85,12 @@ function DictionaryFieldDefEditor({ def, onChange }: { def: { configJson?: Dicti
|
|
|
84
85
|
const [items, setItems] = React.useState<DictionarySummary[]>([])
|
|
85
86
|
const [loading, setLoading] = React.useState(false)
|
|
86
87
|
const [error, setError] = React.useState<string | null>(null)
|
|
88
|
+
const multiId = React.useId()
|
|
87
89
|
const inlineCreateId = React.useId()
|
|
88
90
|
const selectedId = typeof def?.configJson?.dictionaryId === 'string' ? def?.configJson?.dictionaryId : ''
|
|
89
91
|
const inlineCreate = def?.configJson?.dictionaryInlineCreate !== false
|
|
92
|
+
const isMulti = def?.configJson?.multi === true
|
|
93
|
+
const errorLoadLabel = t('dictionaries.customFields.errorLoad', 'Failed to load dictionaries.')
|
|
90
94
|
|
|
91
95
|
React.useEffect(() => {
|
|
92
96
|
let cancelled = false
|
|
@@ -116,7 +120,7 @@ function DictionaryFieldDefEditor({ def, onChange }: { def: { configJson?: Dicti
|
|
|
116
120
|
} catch (err) {
|
|
117
121
|
if (!cancelled) {
|
|
118
122
|
console.error('Failed to load dictionaries list', err)
|
|
119
|
-
setError(
|
|
123
|
+
setError(errorLoadLabel)
|
|
120
124
|
}
|
|
121
125
|
} finally {
|
|
122
126
|
if (!cancelled) {
|
|
@@ -128,7 +132,7 @@ function DictionaryFieldDefEditor({ def, onChange }: { def: { configJson?: Dicti
|
|
|
128
132
|
return () => {
|
|
129
133
|
cancelled = true
|
|
130
134
|
}
|
|
131
|
-
}, [
|
|
135
|
+
}, [errorLoadLabel])
|
|
132
136
|
|
|
133
137
|
const manageHref = '/backend/config/dictionaries'
|
|
134
138
|
|
|
@@ -176,19 +180,38 @@ function DictionaryFieldDefEditor({ def, onChange }: { def: { configJson?: Dicti
|
|
|
176
180
|
) : null}
|
|
177
181
|
<div className="inline-flex items-center gap-2 text-xs">
|
|
178
182
|
<Checkbox
|
|
179
|
-
id={
|
|
180
|
-
checked={
|
|
181
|
-
onCheckedChange={(checked) =>
|
|
183
|
+
id={multiId}
|
|
184
|
+
checked={isMulti}
|
|
185
|
+
onCheckedChange={(checked) => {
|
|
186
|
+
const enabled = checked === true
|
|
187
|
+
onChange({ multi: enabled, defaultValue: enabled ? undefined : def?.configJson?.defaultValue })
|
|
188
|
+
}}
|
|
182
189
|
disabled={!selectedId}
|
|
183
190
|
/>
|
|
184
191
|
<label
|
|
185
|
-
htmlFor={
|
|
192
|
+
htmlFor={multiId}
|
|
186
193
|
className={selectedId ? 'cursor-pointer select-none' : 'cursor-not-allowed opacity-60'}
|
|
187
194
|
>
|
|
188
|
-
{t('dictionaries.customFields.
|
|
195
|
+
{t('dictionaries.customFields.allowMultiple', 'Allow selecting multiple entries')}
|
|
189
196
|
</label>
|
|
190
197
|
</div>
|
|
191
|
-
{
|
|
198
|
+
{!isMulti ? (
|
|
199
|
+
<div className="inline-flex items-center gap-2 text-xs">
|
|
200
|
+
<Checkbox
|
|
201
|
+
id={inlineCreateId}
|
|
202
|
+
checked={inlineCreate}
|
|
203
|
+
onCheckedChange={(checked) => onChange({ dictionaryInlineCreate: checked === true })}
|
|
204
|
+
disabled={!selectedId}
|
|
205
|
+
/>
|
|
206
|
+
<label
|
|
207
|
+
htmlFor={inlineCreateId}
|
|
208
|
+
className={selectedId ? 'cursor-pointer select-none' : 'cursor-not-allowed opacity-60'}
|
|
209
|
+
>
|
|
210
|
+
{t('dictionaries.customFields.allowInlineCreate', 'Allow inline creation inside forms')}
|
|
211
|
+
</label>
|
|
212
|
+
</div>
|
|
213
|
+
) : null}
|
|
214
|
+
{selectedId && !isMulti ? (
|
|
192
215
|
<DictionaryDefaultSelector
|
|
193
216
|
dictionaryId={selectedId}
|
|
194
217
|
defaultValue={typeof def?.configJson?.defaultValue === 'string' ? def.configJson.defaultValue : ''}
|
|
@@ -80,6 +80,7 @@
|
|
|
80
80
|
"dictionaries.config.success.delete": "Wörterbuch gelöscht.",
|
|
81
81
|
"dictionaries.config.success.update": "Wörterbuch aktualisiert.",
|
|
82
82
|
"dictionaries.customFields.allowInlineCreate": "Inline-Erstellung in Formularen erlauben",
|
|
83
|
+
"dictionaries.customFields.allowMultiple": "Mehrere Einträge auswählen erlauben",
|
|
83
84
|
"dictionaries.customFields.defaultValue": "Standardwert",
|
|
84
85
|
"dictionaries.customFields.defaultValueNone": "Kein Standardwert",
|
|
85
86
|
"dictionaries.customFields.defaultValueStale": "Standardeintrag nicht gefunden — er wurde möglicherweise gelöscht oder umbenannt.",
|
|
@@ -80,6 +80,7 @@
|
|
|
80
80
|
"dictionaries.config.success.delete": "Dictionary deleted.",
|
|
81
81
|
"dictionaries.config.success.update": "Dictionary updated.",
|
|
82
82
|
"dictionaries.customFields.allowInlineCreate": "Allow inline creation inside forms",
|
|
83
|
+
"dictionaries.customFields.allowMultiple": "Allow selecting multiple entries",
|
|
83
84
|
"dictionaries.customFields.defaultValue": "Default value",
|
|
84
85
|
"dictionaries.customFields.defaultValueNone": "No default",
|
|
85
86
|
"dictionaries.customFields.defaultValueStale": "Default entry not found — it may have been deleted or renamed.",
|
|
@@ -80,6 +80,7 @@
|
|
|
80
80
|
"dictionaries.config.success.delete": "Diccionario eliminado.",
|
|
81
81
|
"dictionaries.config.success.update": "Diccionario actualizado.",
|
|
82
82
|
"dictionaries.customFields.allowInlineCreate": "Permitir creación en línea dentro de los formularios",
|
|
83
|
+
"dictionaries.customFields.allowMultiple": "Permitir seleccionar varias entradas",
|
|
83
84
|
"dictionaries.customFields.defaultValue": "Valor predeterminado",
|
|
84
85
|
"dictionaries.customFields.defaultValueNone": "Sin valor predeterminado",
|
|
85
86
|
"dictionaries.customFields.defaultValueStale": "No se encontró la entrada predeterminada; puede haber sido eliminada o renombrada.",
|
|
@@ -80,6 +80,7 @@
|
|
|
80
80
|
"dictionaries.config.success.delete": "Słownik został usunięty.",
|
|
81
81
|
"dictionaries.config.success.update": "Słownik został zaktualizowany.",
|
|
82
82
|
"dictionaries.customFields.allowInlineCreate": "Zezwól na tworzenie w formularzach",
|
|
83
|
+
"dictionaries.customFields.allowMultiple": "Zezwól na wybór wielu wpisów",
|
|
83
84
|
"dictionaries.customFields.defaultValue": "Wartość domyślna",
|
|
84
85
|
"dictionaries.customFields.defaultValueNone": "Brak wartości domyślnej",
|
|
85
86
|
"dictionaries.customFields.defaultValueStale": "Nie znaleziono domyślnego wpisu — mógł zostać usunięty albo przemianowany.",
|
|
@@ -7,6 +7,11 @@ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
|
7
7
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
8
8
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
9
9
|
import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
10
|
+
import {
|
|
11
|
+
runCrudMutationGuardAfterSuccess,
|
|
12
|
+
validateCrudMutationGuard,
|
|
13
|
+
} from '@open-mercato/shared/lib/crud/mutation-guard'
|
|
14
|
+
import { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
|
|
10
15
|
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
11
16
|
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
12
17
|
import { Organization } from '@open-mercato/core/modules/directory/data/entities'
|
|
@@ -24,6 +29,7 @@ const brandingResponseSchema = z.object({
|
|
|
24
29
|
organizationName: z.string(),
|
|
25
30
|
tenantId: z.string().uuid(),
|
|
26
31
|
logoUrl: z.string().nullable(),
|
|
32
|
+
updatedAt: z.string().nullable(),
|
|
27
33
|
})
|
|
28
34
|
|
|
29
35
|
const brandingUpdateSchema = z.object({
|
|
@@ -105,12 +111,25 @@ async function resolveCurrentOrganization(req: Request) {
|
|
|
105
111
|
return { auth, container, organization, organizationId, tenantId, translate }
|
|
106
112
|
}
|
|
107
113
|
|
|
114
|
+
function toIsoOrNull(value: Date | string | null | undefined): string | null {
|
|
115
|
+
if (value == null) return null
|
|
116
|
+
if (value instanceof Date) {
|
|
117
|
+
const ms = value.getTime()
|
|
118
|
+
return Number.isFinite(ms) ? new Date(ms).toISOString() : null
|
|
119
|
+
}
|
|
120
|
+
const trimmed = String(value).trim()
|
|
121
|
+
if (!trimmed) return null
|
|
122
|
+
const parsed = new Date(trimmed)
|
|
123
|
+
return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : null
|
|
124
|
+
}
|
|
125
|
+
|
|
108
126
|
function toResponsePayload(organization: Organization, tenantId: string) {
|
|
109
127
|
return {
|
|
110
128
|
organizationId: String(organization.id),
|
|
111
129
|
organizationName: organization.name,
|
|
112
130
|
tenantId,
|
|
113
131
|
logoUrl: organization.logoUrl ?? null,
|
|
132
|
+
updatedAt: toIsoOrNull(organization.updatedAt),
|
|
114
133
|
}
|
|
115
134
|
}
|
|
116
135
|
|
|
@@ -169,6 +188,32 @@ export async function PUT(req: Request) {
|
|
|
169
188
|
}
|
|
170
189
|
|
|
171
190
|
try {
|
|
191
|
+
// Refuse a stale overwrite when two tabs edit the same organization branding.
|
|
192
|
+
// Strictly additive — a no-op when the client omits the expected-version header.
|
|
193
|
+
enforceCommandOptimisticLock({
|
|
194
|
+
resourceKind: 'directory.organization',
|
|
195
|
+
resourceId: resolved.organizationId,
|
|
196
|
+
current: resolved.organization.updatedAt ?? null,
|
|
197
|
+
request: req,
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
// Mutation-guard contract for custom write routes: validate before the write,
|
|
201
|
+
// then run the after-success hook once the command persists.
|
|
202
|
+
const guardResult = await validateCrudMutationGuard(resolved.container, {
|
|
203
|
+
tenantId: resolved.tenantId,
|
|
204
|
+
organizationId: resolved.organizationId,
|
|
205
|
+
userId: resolved.auth.sub,
|
|
206
|
+
resourceKind: 'directory.organization',
|
|
207
|
+
resourceId: resolved.organizationId,
|
|
208
|
+
operation: 'update',
|
|
209
|
+
requestMethod: req.method,
|
|
210
|
+
requestHeaders: req.headers,
|
|
211
|
+
mutationPayload: { logoUrl: parsed.data.logoUrl ?? null },
|
|
212
|
+
})
|
|
213
|
+
if (guardResult && !guardResult.ok) {
|
|
214
|
+
return NextResponse.json(guardResult.body, { status: guardResult.status })
|
|
215
|
+
}
|
|
216
|
+
|
|
172
217
|
const commandBus = resolved.container.resolve('commandBus') as CommandBus
|
|
173
218
|
const ctx = buildCommandContext(
|
|
174
219
|
resolved.container,
|
|
@@ -188,6 +233,21 @@ export async function PUT(req: Request) {
|
|
|
188
233
|
ctx,
|
|
189
234
|
},
|
|
190
235
|
)
|
|
236
|
+
|
|
237
|
+
if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
|
|
238
|
+
await runCrudMutationGuardAfterSuccess(resolved.container, {
|
|
239
|
+
tenantId: resolved.tenantId,
|
|
240
|
+
organizationId: resolved.organizationId,
|
|
241
|
+
userId: resolved.auth.sub,
|
|
242
|
+
resourceKind: 'directory.organization',
|
|
243
|
+
resourceId: resolved.organizationId,
|
|
244
|
+
operation: 'update',
|
|
245
|
+
requestMethod: req.method,
|
|
246
|
+
requestHeaders: req.headers,
|
|
247
|
+
metadata: guardResult.metadata ?? null,
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
|
|
191
251
|
await invalidateSidebarBrandingCache(resolved.container, resolved.organizationId, resolved.tenantId)
|
|
192
252
|
return NextResponse.json(toResponsePayload(result, resolved.tenantId))
|
|
193
253
|
} catch (err) {
|
|
@@ -231,6 +291,7 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
231
291
|
errors: [
|
|
232
292
|
{ status: 400, description: 'Save failed', schema: errorSchema },
|
|
233
293
|
{ status: 401, description: 'Unauthorized', schema: errorSchema },
|
|
294
|
+
{ status: 409, description: 'Organization branding changed since it was loaded', schema: errorSchema },
|
|
234
295
|
{ status: 422, description: 'Invalid logo URL', schema: errorSchema },
|
|
235
296
|
],
|
|
236
297
|
},
|
|
@@ -7,7 +7,9 @@ import { Page, PageBody, PageHeader } from '@open-mercato/ui/backend/Page'
|
|
|
7
7
|
import { LoadingMessage, ErrorMessage } from '@open-mercato/ui/backend/detail'
|
|
8
8
|
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
9
9
|
import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
|
|
10
|
-
import { apiCallOrThrow, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
|
|
10
|
+
import { apiCallOrThrow, readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
|
|
11
|
+
import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
|
|
12
|
+
import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
|
|
11
13
|
import { Button } from '@open-mercato/ui/primitives/button'
|
|
12
14
|
import { Input } from '@open-mercato/ui/primitives/input'
|
|
13
15
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
@@ -17,6 +19,7 @@ type BrandingPayload = {
|
|
|
17
19
|
organizationName: string
|
|
18
20
|
tenantId: string
|
|
19
21
|
logoUrl: string | null
|
|
22
|
+
updatedAt: string | null
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
type UploadPayload = {
|
|
@@ -98,15 +101,17 @@ export default function OrganizationBrandingPage() {
|
|
|
98
101
|
operation: async () => {
|
|
99
102
|
const uploadedLogoUrl = shouldUpload ? await uploadLogo(data.organizationId) : null
|
|
100
103
|
const resolvedLogoUrl = uploadedLogoUrl ?? nextLogoUrl ?? logoUrl.trim()
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
104
|
+
const response = await withScopedApiRequestHeaders(
|
|
105
|
+
buildOptimisticLockHeader(data.updatedAt),
|
|
106
|
+
() => apiCallOrThrow<BrandingPayload>(
|
|
107
|
+
BRANDING_API,
|
|
108
|
+
{
|
|
109
|
+
method: 'PUT',
|
|
110
|
+
headers: { 'content-type': 'application/json' },
|
|
111
|
+
body: JSON.stringify({ logoUrl: resolvedLogoUrl || null }),
|
|
112
|
+
},
|
|
113
|
+
{ errorMessage: t('directory.branding.errors.save', 'Failed to update organization branding') },
|
|
114
|
+
),
|
|
110
115
|
)
|
|
111
116
|
return response.result
|
|
112
117
|
},
|
|
@@ -127,6 +132,7 @@ export default function OrganizationBrandingPage() {
|
|
|
127
132
|
if (fileInputRef.current) fileInputRef.current.value = ''
|
|
128
133
|
flash(t('directory.branding.flash.saved', 'Organization branding updated'), 'success')
|
|
129
134
|
} catch (err: unknown) {
|
|
135
|
+
if (surfaceRecordConflict(err, t)) return
|
|
130
136
|
const fallback = t('directory.branding.errors.save', 'Failed to update organization branding')
|
|
131
137
|
const message = err instanceof Error ? err.message : fallback
|
|
132
138
|
flash(message, 'error')
|
|
@@ -13,6 +13,8 @@ import { Button } from '@open-mercato/ui/primitives/button'
|
|
|
13
13
|
import { apiCall, apiCallOrThrow, readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
|
|
14
14
|
import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
|
|
15
15
|
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
16
|
+
import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
|
|
17
|
+
import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
|
|
16
18
|
import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
|
|
17
19
|
import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
|
|
18
20
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
@@ -177,6 +179,17 @@ export default function DirectoryOrganizationsPage() {
|
|
|
177
179
|
const total = data?.total ?? 0
|
|
178
180
|
const totalPages = data?.totalPages ?? 0
|
|
179
181
|
|
|
182
|
+
const deleteMutationContextId = 'directory-organizations-list:single-delete'
|
|
183
|
+
const { runMutation: runDeleteMutation, retryLastMutation: retryDeleteMutation } = useGuardedMutation<{
|
|
184
|
+
formId: string
|
|
185
|
+
resourceKind: string
|
|
186
|
+
resourceId: string
|
|
187
|
+
retryLastMutation: () => Promise<boolean>
|
|
188
|
+
}>({
|
|
189
|
+
contextId: deleteMutationContextId,
|
|
190
|
+
blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
|
|
191
|
+
})
|
|
192
|
+
|
|
180
193
|
const handleDelete = React.useCallback(async (org: OrganizationRow) => {
|
|
181
194
|
const confirmLabel = t('directory.organizations.list.confirmDelete', 'Archive organization "{{name}}"?', { name: org.name })
|
|
182
195
|
const confirmed = await confirm({
|
|
@@ -187,22 +200,36 @@ export default function DirectoryOrganizationsPage() {
|
|
|
187
200
|
if (!confirmed) return
|
|
188
201
|
|
|
189
202
|
try {
|
|
190
|
-
await
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
203
|
+
await runDeleteMutation({
|
|
204
|
+
operation: () => withScopedApiRequestHeaders(
|
|
205
|
+
buildOptimisticLockHeader(org.updatedAt),
|
|
206
|
+
() => apiCallOrThrow(
|
|
207
|
+
`/api/directory/organizations?id=${encodeURIComponent(org.id)}`,
|
|
208
|
+
{ method: 'DELETE' },
|
|
209
|
+
{ errorMessage: t('directory.organizations.list.error.delete', 'Failed to delete organization') },
|
|
210
|
+
),
|
|
196
211
|
),
|
|
197
|
-
|
|
212
|
+
context: {
|
|
213
|
+
formId: deleteMutationContextId,
|
|
214
|
+
resourceKind: 'directory.organization',
|
|
215
|
+
resourceId: org.id,
|
|
216
|
+
retryLastMutation: retryDeleteMutation,
|
|
217
|
+
},
|
|
218
|
+
})
|
|
198
219
|
await queryClient.invalidateQueries({ queryKey: ['directory-organizations'] })
|
|
199
220
|
flash(t('directory.organizations.flash.deleted', 'Organization deleted'), 'success')
|
|
200
221
|
} catch (err: unknown) {
|
|
222
|
+
// A stale delete surfaces the unified conflict bar (via the guarded
|
|
223
|
+
// mutation) — skip the generic error flash to avoid a double message.
|
|
224
|
+
if (surfaceRecordConflict(err, t)) {
|
|
225
|
+
await queryClient.invalidateQueries({ queryKey: ['directory-organizations'] })
|
|
226
|
+
return
|
|
227
|
+
}
|
|
201
228
|
const fallback = t('directory.organizations.list.error.delete', 'Failed to delete organization')
|
|
202
229
|
const message = err instanceof Error ? err.message : fallback
|
|
203
230
|
flash(message, 'error')
|
|
204
231
|
}
|
|
205
|
-
}, [confirm, queryClient, t])
|
|
232
|
+
}, [confirm, deleteMutationContextId, queryClient, retryDeleteMutation, runDeleteMutation, t])
|
|
206
233
|
|
|
207
234
|
return (
|
|
208
235
|
<Page>
|
|
@@ -10,10 +10,11 @@ import { RowActions } from '@open-mercato/ui/backend/RowActions'
|
|
|
10
10
|
import { BooleanIcon } from '@open-mercato/ui/backend/ValueIcons'
|
|
11
11
|
import type { FilterValues } from '@open-mercato/ui/backend/FilterBar'
|
|
12
12
|
import { Button } from '@open-mercato/ui/primitives/button'
|
|
13
|
-
import { apiCall, readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
|
|
13
|
+
import { apiCall, apiCallOrThrow, readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
|
|
14
14
|
import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
|
|
15
15
|
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
16
|
-
import {
|
|
16
|
+
import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
|
|
17
|
+
import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
|
|
17
18
|
import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
|
|
18
19
|
import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
|
|
19
20
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
@@ -117,6 +118,17 @@ export default function DirectoryTenantsPage() {
|
|
|
117
118
|
const total = data?.total ?? 0
|
|
118
119
|
const totalPages = data?.totalPages ?? 0
|
|
119
120
|
|
|
121
|
+
const deleteMutationContextId = 'directory-tenants-list:single-delete'
|
|
122
|
+
const { runMutation: runDeleteMutation, retryLastMutation: retryDeleteMutation } = useGuardedMutation<{
|
|
123
|
+
formId: string
|
|
124
|
+
resourceKind: string
|
|
125
|
+
resourceId: string
|
|
126
|
+
retryLastMutation: () => Promise<boolean>
|
|
127
|
+
}>({
|
|
128
|
+
contextId: deleteMutationContextId,
|
|
129
|
+
blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
|
|
130
|
+
})
|
|
131
|
+
|
|
120
132
|
const handleDelete = React.useCallback(async (tenant: TenantRow) => {
|
|
121
133
|
const confirmMessage = t('directory.tenants.list.confirmDelete', 'Delete tenant "{{name}}"? This will archive it.').replace('{{name}}', tenant.name)
|
|
122
134
|
const confirmed = await confirm({
|
|
@@ -127,23 +139,35 @@ export default function DirectoryTenantsPage() {
|
|
|
127
139
|
if (!confirmed) return
|
|
128
140
|
|
|
129
141
|
try {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
142
|
+
await runDeleteMutation({
|
|
143
|
+
operation: () => withScopedApiRequestHeaders(
|
|
144
|
+
buildOptimisticLockHeader(tenant.updatedAt),
|
|
145
|
+
() => apiCallOrThrow(
|
|
146
|
+
`/api/directory/tenants?id=${encodeURIComponent(tenant.id)}`,
|
|
147
|
+
{ method: 'DELETE' },
|
|
148
|
+
{ errorMessage: t('directory.tenants.list.error.delete', 'Failed to delete tenant') },
|
|
149
|
+
),
|
|
135
150
|
),
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
151
|
+
context: {
|
|
152
|
+
formId: deleteMutationContextId,
|
|
153
|
+
resourceKind: 'directory.tenant',
|
|
154
|
+
resourceId: tenant.id,
|
|
155
|
+
retryLastMutation: retryDeleteMutation,
|
|
156
|
+
},
|
|
157
|
+
})
|
|
140
158
|
await queryClient.invalidateQueries({ queryKey: ['directory-tenants'] })
|
|
141
159
|
flash(t('directory.tenants.list.success.delete', 'Tenant deleted'), 'success')
|
|
142
|
-
} catch (err:
|
|
160
|
+
} catch (err: unknown) {
|
|
161
|
+
// A stale delete surfaces the unified conflict bar (via the guarded
|
|
162
|
+
// mutation) — skip the generic error flash to avoid a double message.
|
|
163
|
+
if (surfaceRecordConflict(err, t)) {
|
|
164
|
+
await queryClient.invalidateQueries({ queryKey: ['directory-tenants'] })
|
|
165
|
+
return
|
|
166
|
+
}
|
|
143
167
|
const message = err instanceof Error ? err.message : t('directory.tenants.list.error.delete', 'Failed to delete tenant')
|
|
144
168
|
flash(message, 'error')
|
|
145
169
|
}
|
|
146
|
-
}, [confirm, queryClient, t])
|
|
170
|
+
}, [confirm, deleteMutationContextId, queryClient, retryDeleteMutation, runDeleteMutation, t])
|
|
147
171
|
|
|
148
172
|
return (
|
|
149
173
|
<Page>
|
|
@@ -8,6 +8,10 @@ import { z } from 'zod'
|
|
|
8
8
|
import { invalidateDefinitionsCache } from './definitions.cache'
|
|
9
9
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
10
10
|
import { mergeEntityFieldsetConfig, normalizeEntityFieldsetConfig } from '../lib/fieldsets'
|
|
11
|
+
import {
|
|
12
|
+
beginEntitiesMutationGuard,
|
|
13
|
+
FIELD_DEFINITION_RESOURCE_KIND,
|
|
14
|
+
} from './definitions.mutation-guard'
|
|
11
15
|
|
|
12
16
|
export const metadata = {
|
|
13
17
|
POST: { requireAuth: true, requireFeatures: ['entities.definitions.manage'] },
|
|
@@ -67,6 +71,17 @@ export async function POST(req: Request) {
|
|
|
67
71
|
cache = resolve('cache') as CacheStrategy
|
|
68
72
|
} catch {}
|
|
69
73
|
|
|
74
|
+
const guard = await beginEntitiesMutationGuard({
|
|
75
|
+
container,
|
|
76
|
+
auth,
|
|
77
|
+
req,
|
|
78
|
+
resourceKind: FIELD_DEFINITION_RESOURCE_KIND,
|
|
79
|
+
resourceId: entityId,
|
|
80
|
+
operation: 'custom',
|
|
81
|
+
mutationPayload: { entityId, definitionCount: definitions.length },
|
|
82
|
+
})
|
|
83
|
+
if (guard.blockedResponse) return guard.blockedResponse
|
|
84
|
+
|
|
70
85
|
await em.begin()
|
|
71
86
|
try {
|
|
72
87
|
// Prefetch every existing definition for this entity in a single query, then index
|
|
@@ -134,6 +149,8 @@ export async function POST(req: Request) {
|
|
|
134
149
|
return NextResponse.json({ error: 'Failed to save definitions batch' }, { status: 500 })
|
|
135
150
|
}
|
|
136
151
|
|
|
152
|
+
await guard.runAfterSuccess()
|
|
153
|
+
|
|
137
154
|
await invalidateDefinitionsCache(cache, {
|
|
138
155
|
tenantId: auth.tenantId ?? null,
|
|
139
156
|
organizationId: auth.orgId ?? null,
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import type { AwilixContainer } from 'awilix'
|
|
3
|
+
import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
|
|
4
|
+
import {
|
|
5
|
+
runCrudMutationGuardAfterSuccess,
|
|
6
|
+
validateCrudMutationGuard,
|
|
7
|
+
} from '@open-mercato/shared/lib/crud/mutation-guard'
|
|
8
|
+
|
|
9
|
+
export const ENTITY_DEFINITION_RESOURCE_KIND = 'entities.entity'
|
|
10
|
+
export const FIELD_DEFINITION_RESOURCE_KIND = 'entities.field_definition'
|
|
11
|
+
|
|
12
|
+
type AuthenticatedContext = NonNullable<AuthContext>
|
|
13
|
+
|
|
14
|
+
type GuardOperation = 'create' | 'update' | 'delete' | 'custom'
|
|
15
|
+
|
|
16
|
+
type EntitiesMutationGuardContext = {
|
|
17
|
+
container: AwilixContainer
|
|
18
|
+
auth: AuthenticatedContext
|
|
19
|
+
req: Request
|
|
20
|
+
resourceKind: string
|
|
21
|
+
resourceId: string
|
|
22
|
+
operation: GuardOperation
|
|
23
|
+
mutationPayload?: Record<string, unknown> | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type EntitiesMutationGuardHandle = {
|
|
27
|
+
blockedResponse: NextResponse | null
|
|
28
|
+
runAfterSuccess: () => Promise<void>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function resolveActorUserId(auth: AuthenticatedContext): string {
|
|
32
|
+
return auth.userId ?? auth.sub
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function beginEntitiesMutationGuard(
|
|
36
|
+
ctx: EntitiesMutationGuardContext,
|
|
37
|
+
): Promise<EntitiesMutationGuardHandle> {
|
|
38
|
+
const tenantId = ctx.auth.tenantId
|
|
39
|
+
const userId = resolveActorUserId(ctx.auth)
|
|
40
|
+
if (!tenantId) {
|
|
41
|
+
return { blockedResponse: null, runAfterSuccess: async () => undefined }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const guardResult = await validateCrudMutationGuard(ctx.container, {
|
|
45
|
+
tenantId,
|
|
46
|
+
organizationId: ctx.auth.orgId ?? null,
|
|
47
|
+
userId,
|
|
48
|
+
resourceKind: ctx.resourceKind,
|
|
49
|
+
resourceId: ctx.resourceId,
|
|
50
|
+
operation: ctx.operation,
|
|
51
|
+
requestMethod: ctx.req.method,
|
|
52
|
+
requestHeaders: ctx.req.headers,
|
|
53
|
+
mutationPayload: ctx.mutationPayload ?? null,
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
if (guardResult && !guardResult.ok) {
|
|
57
|
+
return {
|
|
58
|
+
blockedResponse: NextResponse.json(guardResult.body, { status: guardResult.status }),
|
|
59
|
+
runAfterSuccess: async () => undefined,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
blockedResponse: null,
|
|
65
|
+
runAfterSuccess: async () => {
|
|
66
|
+
if (!guardResult?.ok || !guardResult.shouldRunAfterSuccess) return
|
|
67
|
+
await runCrudMutationGuardAfterSuccess(ctx.container, {
|
|
68
|
+
tenantId,
|
|
69
|
+
organizationId: ctx.auth.orgId ?? null,
|
|
70
|
+
userId,
|
|
71
|
+
resourceKind: ctx.resourceKind,
|
|
72
|
+
resourceId: ctx.resourceId,
|
|
73
|
+
operation: ctx.operation,
|
|
74
|
+
requestMethod: ctx.req.method,
|
|
75
|
+
requestHeaders: ctx.req.headers,
|
|
76
|
+
metadata: guardResult.metadata ?? null,
|
|
77
|
+
})
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -6,6 +6,10 @@ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
|
6
6
|
import { CustomFieldDef } from '@open-mercato/core/modules/entities/data/entities'
|
|
7
7
|
import { invalidateDefinitionsCache } from './definitions.cache'
|
|
8
8
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
9
|
+
import {
|
|
10
|
+
beginEntitiesMutationGuard,
|
|
11
|
+
FIELD_DEFINITION_RESOURCE_KIND,
|
|
12
|
+
} from './definitions.mutation-guard'
|
|
9
13
|
|
|
10
14
|
export const metadata = {
|
|
11
15
|
POST: { requireAuth: true, requireFeatures: ['entities.definitions.manage'] },
|
|
@@ -30,11 +34,24 @@ export async function POST(req: Request) {
|
|
|
30
34
|
const where: any = { entityId, key, organizationId: auth.orgId ?? null, tenantId: auth.tenantId ?? null }
|
|
31
35
|
const def = await em.findOne(CustomFieldDef, where)
|
|
32
36
|
if (!def) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
37
|
+
|
|
38
|
+
const guard = await beginEntitiesMutationGuard({
|
|
39
|
+
container,
|
|
40
|
+
auth,
|
|
41
|
+
req,
|
|
42
|
+
resourceKind: FIELD_DEFINITION_RESOURCE_KIND,
|
|
43
|
+
resourceId: def.id,
|
|
44
|
+
operation: 'custom',
|
|
45
|
+
mutationPayload: { entityId, key },
|
|
46
|
+
})
|
|
47
|
+
if (guard.blockedResponse) return guard.blockedResponse
|
|
48
|
+
|
|
33
49
|
;(def as any).deletedAt = null
|
|
34
50
|
;(def as any).isActive = true
|
|
35
51
|
;(def as any).updatedAt = new Date()
|
|
36
52
|
em.persist(def)
|
|
37
53
|
await em.flush()
|
|
54
|
+
await guard.runAfterSuccess()
|
|
38
55
|
await invalidateDefinitionsCache(cache, {
|
|
39
56
|
tenantId: auth.tenantId ?? null,
|
|
40
57
|
organizationId: auth.orgId ?? null,
|