@open-mercato/core 0.6.6-develop.6198.1.0822f97cc6 → 0.6.6-develop.6204.1.30b1f58642
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/dist/modules/catalog/backend/catalog/products/[id]/page.js +52 -45
- package/dist/modules/catalog/backend/catalog/products/[id]/page.js.map +2 -2
- package/dist/modules/catalog/backend/catalog/products/[productId]/variants/[variantId]/page.js +9 -13
- package/dist/modules/catalog/backend/catalog/products/[productId]/variants/[variantId]/page.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivitiesSection.js +17 -12
- package/dist/modules/customers/components/detail/ActivitiesSection.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivityHistorySection.js +11 -7
- package/dist/modules/customers/components/detail/ActivityHistorySection.js.map +2 -2
- package/dist/modules/dictionaries/components/DictionariesManager.js +75 -33
- package/dist/modules/dictionaries/components/DictionariesManager.js.map +2 -2
- package/dist/modules/dictionaries/components/DictionaryEntriesEditor.js +95 -43
- package/dist/modules/dictionaries/components/DictionaryEntriesEditor.js.map +2 -2
- package/dist/modules/dictionaries/components/DictionarySelectControl.js +40 -19
- package/dist/modules/dictionaries/components/DictionarySelectControl.js.map +2 -2
- package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js +81 -37
- package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js.map +2 -2
- package/dist/modules/integrations/backend/integrations/page.js +45 -24
- package/dist/modules/integrations/backend/integrations/page.js.map +2 -2
- package/dist/modules/notifications/api/[id]/action/route.js +18 -3
- package/dist/modules/notifications/api/[id]/action/route.js.map +2 -2
- package/dist/modules/notifications/api/[id]/restore/route.js +19 -3
- package/dist/modules/notifications/api/[id]/restore/route.js.map +2 -2
- package/dist/modules/notifications/api/mark-all-read/route.js +18 -4
- package/dist/modules/notifications/api/mark-all-read/route.js.map +2 -2
- package/dist/modules/notifications/api/route.js +17 -4
- package/dist/modules/notifications/api/route.js.map +2 -2
- package/dist/modules/notifications/api/settings/route.js +26 -5
- package/dist/modules/notifications/api/settings/route.js.map +2 -2
- package/dist/modules/notifications/lib/routeHelpers.js +64 -5
- package/dist/modules/notifications/lib/routeHelpers.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/catalog/backend/catalog/products/[id]/page.tsx +58 -48
- package/src/modules/catalog/backend/catalog/products/[productId]/variants/[variantId]/page.tsx +14 -13
- package/src/modules/customers/components/detail/ActivitiesSection.tsx +22 -13
- package/src/modules/customers/components/detail/ActivityHistorySection.tsx +13 -7
- package/src/modules/dictionaries/components/DictionariesManager.tsx +82 -34
- package/src/modules/dictionaries/components/DictionaryEntriesEditor.tsx +99 -42
- package/src/modules/dictionaries/components/DictionarySelectControl.tsx +43 -18
- package/src/modules/integrations/backend/integrations/bundle/[id]/page.tsx +78 -35
- package/src/modules/integrations/backend/integrations/page.tsx +42 -21
- package/src/modules/notifications/api/[id]/action/route.ts +17 -2
- package/src/modules/notifications/api/[id]/restore/route.ts +19 -3
- package/src/modules/notifications/api/mark-all-read/route.ts +18 -4
- package/src/modules/notifications/api/route.ts +16 -3
- package/src/modules/notifications/api/settings/route.ts +26 -5
- package/src/modules/notifications/lib/routeHelpers.ts +101 -4
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
import { Spinner } from '@open-mercato/ui/primitives/spinner'
|
|
19
19
|
import { apiCall, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
|
|
20
20
|
import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
|
|
21
|
+
import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
|
|
21
22
|
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
22
23
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
23
24
|
import type { CredentialFieldType, IntegrationCredentialField } from '@open-mercato/shared/modules/integrations/types'
|
|
@@ -88,6 +89,14 @@ export default function BundleConfigPage({ params }: BundleConfigPageProps) {
|
|
|
88
89
|
const [isSavingCreds, setIsSavingCreds] = React.useState(false)
|
|
89
90
|
const [togglingIds, setTogglingIds] = React.useState<Set<string>>(new Set())
|
|
90
91
|
|
|
92
|
+
const mutationContextId = React.useMemo(
|
|
93
|
+
() => `integrations.bundle:${bundleId ?? 'unknown'}`,
|
|
94
|
+
[bundleId],
|
|
95
|
+
)
|
|
96
|
+
const { runMutation, retryLastMutation } = useGuardedMutation<Record<string, unknown>>({
|
|
97
|
+
contextId: mutationContextId,
|
|
98
|
+
})
|
|
99
|
+
|
|
91
100
|
const resolveCurrentBundleId = React.useCallback(() => {
|
|
92
101
|
return bundleId ?? (
|
|
93
102
|
typeof window !== 'undefined'
|
|
@@ -147,49 +156,83 @@ export default function BundleConfigPage({ params }: BundleConfigPageProps) {
|
|
|
147
156
|
const currentBundleId = resolveCurrentBundleId()
|
|
148
157
|
if (!currentBundleId) return
|
|
149
158
|
setIsSavingCreds(true)
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
159
|
+
try {
|
|
160
|
+
const call = await runMutation({
|
|
161
|
+
mutationPayload: { bundleId: currentBundleId, credentials: credValues },
|
|
162
|
+
context: {
|
|
163
|
+
formId: mutationContextId,
|
|
164
|
+
operation: 'update',
|
|
165
|
+
actionId: 'save-credentials',
|
|
166
|
+
resourceKind: 'integrations.bundle',
|
|
167
|
+
resourceId: currentBundleId,
|
|
168
|
+
bundleId: currentBundleId,
|
|
169
|
+
retryLastMutation,
|
|
170
|
+
},
|
|
171
|
+
// TODO(#2373-B): thread updatedAt — integration detail/state response does not expose a record version yet
|
|
172
|
+
operation: () => withScopedApiRequestHeaders(
|
|
173
|
+
buildOptimisticLockHeader(undefined),
|
|
174
|
+
() => apiCall(`/api/integrations/${encodeURIComponent(currentBundleId)}/credentials`, {
|
|
175
|
+
method: 'PUT',
|
|
176
|
+
headers: { 'Content-Type': 'application/json' },
|
|
177
|
+
body: JSON.stringify({ credentials: credValues }),
|
|
178
|
+
}, { fallback: null }),
|
|
179
|
+
),
|
|
180
|
+
})
|
|
181
|
+
if (call.ok) {
|
|
182
|
+
flash(t('integrations.detail.credentials.saved'), 'success')
|
|
183
|
+
} else {
|
|
184
|
+
flash(t('integrations.detail.credentials.saveError'), 'error')
|
|
185
|
+
}
|
|
186
|
+
} catch {
|
|
162
187
|
flash(t('integrations.detail.credentials.saveError'), 'error')
|
|
188
|
+
} finally {
|
|
189
|
+
setIsSavingCreds(false)
|
|
163
190
|
}
|
|
164
|
-
|
|
165
|
-
}, [resolveCurrentBundleId, credValues, t])
|
|
191
|
+
}, [resolveCurrentBundleId, runMutation, mutationContextId, retryLastMutation, credValues, t])
|
|
166
192
|
|
|
167
193
|
const handleToggle = React.useCallback(async (integrationId: string, enabled: boolean) => {
|
|
168
194
|
setTogglingIds((prev) => new Set(prev).add(integrationId))
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
195
|
+
try {
|
|
196
|
+
const call = await runMutation({
|
|
197
|
+
mutationPayload: { integrationId, isEnabled: enabled },
|
|
198
|
+
context: {
|
|
199
|
+
formId: mutationContextId,
|
|
200
|
+
operation: 'update',
|
|
201
|
+
actionId: 'toggle-state',
|
|
202
|
+
resourceKind: 'integrations.integration',
|
|
203
|
+
resourceId: integrationId,
|
|
204
|
+
integrationId,
|
|
205
|
+
retryLastMutation,
|
|
206
|
+
},
|
|
207
|
+
// TODO(#2373-B): thread updatedAt — integration detail/state response does not expose a record version yet
|
|
208
|
+
operation: () => withScopedApiRequestHeaders(
|
|
209
|
+
buildOptimisticLockHeader(undefined),
|
|
210
|
+
() => apiCall(`/api/integrations/${encodeURIComponent(integrationId)}/state`, {
|
|
211
|
+
method: 'PUT',
|
|
212
|
+
headers: { 'Content-Type': 'application/json' },
|
|
213
|
+
body: JSON.stringify({ isEnabled: enabled }),
|
|
214
|
+
}, { fallback: null }),
|
|
215
|
+
),
|
|
187
216
|
})
|
|
188
|
-
|
|
217
|
+
if (call.ok) {
|
|
218
|
+
setDetail((prev) => {
|
|
219
|
+
if (!prev) return prev
|
|
220
|
+
return {
|
|
221
|
+
...prev,
|
|
222
|
+
bundleIntegrations: prev.bundleIntegrations.map((item) =>
|
|
223
|
+
item.id === integrationId ? { ...item, isEnabled: enabled } : item,
|
|
224
|
+
),
|
|
225
|
+
}
|
|
226
|
+
})
|
|
227
|
+
} else {
|
|
228
|
+
flash(t('integrations.detail.stateError'), 'error')
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
189
231
|
flash(t('integrations.detail.stateError'), 'error')
|
|
232
|
+
} finally {
|
|
233
|
+
setTogglingIds((prev) => { const next = new Set(prev); next.delete(integrationId); return next })
|
|
190
234
|
}
|
|
191
|
-
|
|
192
|
-
}, [t])
|
|
235
|
+
}, [runMutation, mutationContextId, retryLastMutation, t])
|
|
193
236
|
|
|
194
237
|
const handleBulkToggle = React.useCallback(async (enabled: boolean) => {
|
|
195
238
|
if (!detail) return
|
|
@@ -9,6 +9,7 @@ import { Input } from '@open-mercato/ui/primitives/input'
|
|
|
9
9
|
import { Spinner } from '@open-mercato/ui/primitives/spinner'
|
|
10
10
|
import { apiCall, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
|
|
11
11
|
import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
|
|
12
|
+
import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
|
|
12
13
|
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
13
14
|
import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
|
|
14
15
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
@@ -145,6 +146,9 @@ export default function IntegrationsMarketplacePage() {
|
|
|
145
146
|
const [togglingIds, setTogglingIds] = React.useState<Set<string>>(new Set())
|
|
146
147
|
const scopeVersion = useOrganizationScopeVersion()
|
|
147
148
|
const t = useT()
|
|
149
|
+
const { runMutation, retryLastMutation } = useGuardedMutation<Record<string, unknown>>({
|
|
150
|
+
contextId: 'integrations.marketplace',
|
|
151
|
+
})
|
|
148
152
|
|
|
149
153
|
React.useEffect(() => {
|
|
150
154
|
const timer = setTimeout(() => setDebouncedSearch(searchInput.trim()), 300)
|
|
@@ -202,30 +206,47 @@ export default function IntegrationsMarketplacePage() {
|
|
|
202
206
|
|
|
203
207
|
const handleToggle = React.useCallback(async (integrationId: string, enabled: boolean, updatedAt?: string | null) => {
|
|
204
208
|
setTogglingIds((prev) => new Set(prev).add(integrationId))
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
209
|
+
try {
|
|
210
|
+
const call = await runMutation({
|
|
211
|
+
mutationPayload: { integrationId, isEnabled: enabled },
|
|
212
|
+
context: {
|
|
213
|
+
formId: 'integrations.marketplace',
|
|
214
|
+
operation: 'update',
|
|
215
|
+
actionId: 'toggle-state',
|
|
216
|
+
resourceKind: 'integrations.integration',
|
|
217
|
+
resourceId: integrationId,
|
|
218
|
+
integrationId,
|
|
219
|
+
retryLastMutation,
|
|
220
|
+
},
|
|
221
|
+
operation: () => withScopedApiRequestHeaders(
|
|
222
|
+
buildOptimisticLockHeader(updatedAt),
|
|
223
|
+
() => apiCall(`/api/integrations/${encodeURIComponent(integrationId)}/state`, {
|
|
224
|
+
method: 'PUT',
|
|
225
|
+
headers: { 'Content-Type': 'application/json' },
|
|
226
|
+
body: JSON.stringify({ isEnabled: enabled }),
|
|
227
|
+
}, { fallback: null }),
|
|
228
|
+
),
|
|
229
|
+
})
|
|
213
230
|
|
|
214
|
-
|
|
231
|
+
if (!call.ok) {
|
|
232
|
+
flash(t('integrations.detail.stateError'), 'error')
|
|
233
|
+
} else {
|
|
234
|
+
setData((prev) => {
|
|
235
|
+
if (!prev) return prev
|
|
236
|
+
return {
|
|
237
|
+
...prev,
|
|
238
|
+
items: prev.items.map((item) =>
|
|
239
|
+
item.id === integrationId ? { ...item, isEnabled: enabled } : item,
|
|
240
|
+
),
|
|
241
|
+
}
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
} catch {
|
|
215
245
|
flash(t('integrations.detail.stateError'), 'error')
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
if (!prev) return prev
|
|
219
|
-
return {
|
|
220
|
-
...prev,
|
|
221
|
-
items: prev.items.map((item) =>
|
|
222
|
-
item.id === integrationId ? { ...item, isEnabled: enabled } : item,
|
|
223
|
-
),
|
|
224
|
-
}
|
|
225
|
-
})
|
|
246
|
+
} finally {
|
|
247
|
+
setTogglingIds((prev) => { const next = new Set(prev); next.delete(integrationId); return next })
|
|
226
248
|
}
|
|
227
|
-
|
|
228
|
-
}, [t])
|
|
249
|
+
}, [retryLastMutation, runMutation, t])
|
|
229
250
|
|
|
230
251
|
const grouped = React.useMemo(() => {
|
|
231
252
|
if (!data) return { bundles: [] as Array<BundleItem & { integrations: IntegrationItem[] }>, standalone: [] as IntegrationItem[] }
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { executeActionSchema } from '../../../data/validators'
|
|
2
2
|
import { actionResultResponseSchema, errorResponseSchema } from '../../openapi'
|
|
3
3
|
import {
|
|
4
|
+
NOTIFICATION_RESOURCE_KIND,
|
|
4
5
|
notificationCrudErrorResponse,
|
|
5
6
|
notificationValidationErrorResponse,
|
|
6
7
|
resolveNotificationContext,
|
|
8
|
+
runGuardedNotificationWrite,
|
|
7
9
|
} from '../../../lib/routeHelpers'
|
|
8
10
|
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
9
11
|
|
|
@@ -13,7 +15,7 @@ export const metadata = {
|
|
|
13
15
|
|
|
14
16
|
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
15
17
|
const { id } = await params
|
|
16
|
-
const { service, scope } = await resolveNotificationContext(req)
|
|
18
|
+
const { service, scope, ctx } = await resolveNotificationContext(req)
|
|
17
19
|
|
|
18
20
|
const body = await req.json().catch(() => ({}))
|
|
19
21
|
const parsed = executeActionSchema.safeParse(body)
|
|
@@ -23,7 +25,20 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
|
|
|
23
25
|
const input = parsed.data
|
|
24
26
|
|
|
25
27
|
try {
|
|
26
|
-
const
|
|
28
|
+
const guarded = await runGuardedNotificationWrite(
|
|
29
|
+
ctx.container,
|
|
30
|
+
scope,
|
|
31
|
+
req,
|
|
32
|
+
{
|
|
33
|
+
resourceKind: NOTIFICATION_RESOURCE_KIND,
|
|
34
|
+
resourceId: id,
|
|
35
|
+
operation: 'custom',
|
|
36
|
+
payload: input as Record<string, unknown>,
|
|
37
|
+
},
|
|
38
|
+
() => service.executeAction(id, input, scope),
|
|
39
|
+
)
|
|
40
|
+
if (!guarded.ok) return guarded.response
|
|
41
|
+
const { notification, result } = guarded.result
|
|
27
42
|
|
|
28
43
|
const action = notification.actionData?.actions?.find((a) => a.id === input.actionId)
|
|
29
44
|
const href = action?.href?.replace('{sourceEntityId}', notification.sourceEntityId ?? '')
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
import { restoreNotificationSchema } from '../../../data/validators'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
NOTIFICATION_RESOURCE_KIND,
|
|
5
|
+
resolveNotificationContext,
|
|
6
|
+
runGuardedNotificationWrite,
|
|
7
|
+
} from '../../../lib/routeHelpers'
|
|
4
8
|
|
|
5
9
|
export const metadata = {
|
|
6
10
|
PUT: { requireAuth: true },
|
|
@@ -8,12 +12,24 @@ export const metadata = {
|
|
|
8
12
|
|
|
9
13
|
export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
10
14
|
const { id } = await params
|
|
11
|
-
const { service, scope } = await resolveNotificationContext(req)
|
|
15
|
+
const { service, scope, ctx } = await resolveNotificationContext(req)
|
|
12
16
|
|
|
13
17
|
const body = await req.json().catch(() => ({}))
|
|
14
18
|
const input = restoreNotificationSchema.parse(body)
|
|
15
19
|
|
|
16
|
-
await
|
|
20
|
+
const guarded = await runGuardedNotificationWrite(
|
|
21
|
+
ctx.container,
|
|
22
|
+
scope,
|
|
23
|
+
req,
|
|
24
|
+
{
|
|
25
|
+
resourceKind: NOTIFICATION_RESOURCE_KIND,
|
|
26
|
+
resourceId: id,
|
|
27
|
+
operation: 'update',
|
|
28
|
+
payload: input as Record<string, unknown>,
|
|
29
|
+
},
|
|
30
|
+
() => service.restoreDismissed(id, input.status, scope),
|
|
31
|
+
)
|
|
32
|
+
if (!guarded.ok) return guarded.response
|
|
17
33
|
|
|
18
34
|
return Response.json({ ok: true })
|
|
19
35
|
}
|
|
@@ -1,16 +1,30 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
NOTIFICATION_RESOURCE_KIND,
|
|
4
|
+
resolveNotificationContext,
|
|
5
|
+
runGuardedNotificationWrite,
|
|
6
|
+
} from '../../lib/routeHelpers'
|
|
3
7
|
|
|
4
8
|
export const metadata = {
|
|
5
9
|
PUT: { requireAuth: true },
|
|
6
10
|
}
|
|
7
11
|
|
|
8
12
|
export async function PUT(req: Request) {
|
|
9
|
-
const { service, scope } = await resolveNotificationContext(req)
|
|
13
|
+
const { service, scope, ctx } = await resolveNotificationContext(req)
|
|
10
14
|
|
|
11
|
-
const
|
|
15
|
+
const guarded = await runGuardedNotificationWrite(
|
|
16
|
+
ctx.container,
|
|
17
|
+
scope,
|
|
18
|
+
req,
|
|
19
|
+
{
|
|
20
|
+
resourceKind: NOTIFICATION_RESOURCE_KIND,
|
|
21
|
+
operation: 'update',
|
|
22
|
+
},
|
|
23
|
+
() => service.markAllAsRead(scope),
|
|
24
|
+
)
|
|
25
|
+
if (!guarded.ok) return guarded.response
|
|
12
26
|
|
|
13
|
-
return Response.json({ ok: true, count })
|
|
27
|
+
return Response.json({ ok: true, count: guarded.result })
|
|
14
28
|
}
|
|
15
29
|
|
|
16
30
|
export const openApi = {
|
|
@@ -4,9 +4,11 @@ import { Notification } from '../data/entities'
|
|
|
4
4
|
import { listNotificationsSchema, createNotificationSchema } from '../data/validators'
|
|
5
5
|
import { toNotificationDto } from '../lib/notificationMapper'
|
|
6
6
|
import {
|
|
7
|
+
NOTIFICATION_RESOURCE_KIND,
|
|
7
8
|
notificationCrudErrorResponse,
|
|
8
9
|
notificationValidationErrorResponse,
|
|
9
10
|
resolveNotificationContext,
|
|
11
|
+
runGuardedNotificationWrite,
|
|
10
12
|
} from '../lib/routeHelpers'
|
|
11
13
|
import {
|
|
12
14
|
buildNotificationsCrudOpenApi,
|
|
@@ -74,7 +76,7 @@ export async function GET(req: Request) {
|
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
export async function POST(req: Request) {
|
|
77
|
-
const { service, scope } = await resolveNotificationContext(req)
|
|
79
|
+
const { service, scope, ctx } = await resolveNotificationContext(req)
|
|
78
80
|
|
|
79
81
|
const body = await req.json().catch(() => ({}))
|
|
80
82
|
const parsed = createNotificationSchema.safeParse(body)
|
|
@@ -83,9 +85,20 @@ export async function POST(req: Request) {
|
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
try {
|
|
86
|
-
const
|
|
88
|
+
const guarded = await runGuardedNotificationWrite(
|
|
89
|
+
ctx.container,
|
|
90
|
+
scope,
|
|
91
|
+
req,
|
|
92
|
+
{
|
|
93
|
+
resourceKind: NOTIFICATION_RESOURCE_KIND,
|
|
94
|
+
operation: 'create',
|
|
95
|
+
payload: parsed.data as Record<string, unknown>,
|
|
96
|
+
},
|
|
97
|
+
() => service.create(parsed.data, scope),
|
|
98
|
+
)
|
|
99
|
+
if (!guarded.ok) return guarded.response
|
|
87
100
|
|
|
88
|
-
return Response.json({ id:
|
|
101
|
+
return Response.json({ id: guarded.result.id }, { status: 201 })
|
|
89
102
|
} catch (error) {
|
|
90
103
|
const errorResponse = notificationCrudErrorResponse(error)
|
|
91
104
|
if (errorResponse) return errorResponse
|
|
@@ -13,6 +13,10 @@ import {
|
|
|
13
13
|
resolveNotificationDeliveryConfig,
|
|
14
14
|
saveNotificationDeliveryConfig,
|
|
15
15
|
} from '../../lib/deliveryConfig'
|
|
16
|
+
import {
|
|
17
|
+
NOTIFICATION_SETTINGS_RESOURCE_KIND,
|
|
18
|
+
runGuardedNotificationWrite,
|
|
19
|
+
} from '../../lib/routeHelpers'
|
|
16
20
|
|
|
17
21
|
export const metadata = {
|
|
18
22
|
GET: { requireAuth: true, requireFeatures: ['notifications.manage'] },
|
|
@@ -67,11 +71,28 @@ export async function POST(req: Request) {
|
|
|
67
71
|
|
|
68
72
|
const container = await createRequestContainer()
|
|
69
73
|
try {
|
|
70
|
-
await
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
74
|
+
const guarded = await runGuardedNotificationWrite(
|
|
75
|
+
container,
|
|
76
|
+
{
|
|
77
|
+
tenantId: auth.tenantId ?? '',
|
|
78
|
+
organizationId: auth.orgId ?? null,
|
|
79
|
+
userId: auth.sub ?? null,
|
|
80
|
+
},
|
|
81
|
+
req,
|
|
82
|
+
{
|
|
83
|
+
resourceKind: NOTIFICATION_SETTINGS_RESOURCE_KIND,
|
|
84
|
+
operation: 'update',
|
|
85
|
+
payload: parsed.data as Record<string, unknown>,
|
|
86
|
+
},
|
|
87
|
+
async () => {
|
|
88
|
+
await saveNotificationDeliveryConfig(container, parsed.data)
|
|
89
|
+
return resolveNotificationDeliveryConfig(container, {
|
|
90
|
+
defaultValue: DEFAULT_NOTIFICATION_DELIVERY_CONFIG,
|
|
91
|
+
})
|
|
92
|
+
},
|
|
93
|
+
)
|
|
94
|
+
if (!guarded.ok) return guarded.response
|
|
95
|
+
return NextResponse.json({ ok: true, settings: guarded.result })
|
|
75
96
|
} catch (error) {
|
|
76
97
|
return NextResponse.json(
|
|
77
98
|
{ error: error instanceof Error ? error.message : t('api.errors.internal', 'Internal error') },
|
|
@@ -1,9 +1,24 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
+
import type { AwilixContainer } from 'awilix'
|
|
2
3
|
import { resolveRequestContext } from '@open-mercato/shared/lib/api/context'
|
|
3
4
|
import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
5
|
+
import {
|
|
6
|
+
runCrudMutationGuardAfterSuccess,
|
|
7
|
+
validateCrudMutationGuard,
|
|
8
|
+
} from '@open-mercato/shared/lib/crud/mutation-guard'
|
|
4
9
|
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
5
10
|
import { resolveNotificationService, type NotificationService } from './notificationService'
|
|
6
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Mutation-guard resource kind for notification rows.
|
|
14
|
+
*/
|
|
15
|
+
export const NOTIFICATION_RESOURCE_KIND = 'notifications.notification'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Mutation-guard resource kind for notification delivery settings.
|
|
19
|
+
*/
|
|
20
|
+
export const NOTIFICATION_SETTINGS_RESOURCE_KIND = 'notifications.settings'
|
|
21
|
+
|
|
7
22
|
/**
|
|
8
23
|
* Notification scope context for service calls
|
|
9
24
|
*/
|
|
@@ -63,6 +78,65 @@ export async function resolveNotificationContext(req: Request): Promise<Notifica
|
|
|
63
78
|
}
|
|
64
79
|
}
|
|
65
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Mutation-guard options for a notification write.
|
|
83
|
+
*/
|
|
84
|
+
export interface NotificationMutationGuardOptions {
|
|
85
|
+
resourceKind: string
|
|
86
|
+
resourceId?: string | null
|
|
87
|
+
operation: 'create' | 'update' | 'delete' | 'custom'
|
|
88
|
+
payload?: Record<string, unknown> | null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type GuardedNotificationWriteResult<T> =
|
|
92
|
+
| { ok: true; result: T }
|
|
93
|
+
| { ok: false; response: Response }
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Run a notification write through the mutation guard lifecycle.
|
|
97
|
+
* Validates before the mutation, performs the write, then runs after-success
|
|
98
|
+
* hooks only when the write succeeded and the guard requested them. Returns the
|
|
99
|
+
* guard's own block response when validation fails so authorization behavior and
|
|
100
|
+
* conflict shapes are preserved.
|
|
101
|
+
*/
|
|
102
|
+
export async function runGuardedNotificationWrite<T>(
|
|
103
|
+
container: AwilixContainer,
|
|
104
|
+
scope: NotificationScope,
|
|
105
|
+
req: Request,
|
|
106
|
+
options: NotificationMutationGuardOptions,
|
|
107
|
+
write: () => Promise<T>,
|
|
108
|
+
): Promise<GuardedNotificationWriteResult<T>> {
|
|
109
|
+
const guardScope = {
|
|
110
|
+
tenantId: scope.tenantId,
|
|
111
|
+
organizationId: scope.organizationId,
|
|
112
|
+
userId: scope.userId ?? '',
|
|
113
|
+
resourceKind: options.resourceKind,
|
|
114
|
+
resourceId: options.resourceId ?? '',
|
|
115
|
+
operation: options.operation,
|
|
116
|
+
requestMethod: req.method,
|
|
117
|
+
requestHeaders: req.headers,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const guardResult = await validateCrudMutationGuard(container, {
|
|
121
|
+
...guardScope,
|
|
122
|
+
mutationPayload: options.payload ?? null,
|
|
123
|
+
})
|
|
124
|
+
if (guardResult && !guardResult.ok) {
|
|
125
|
+
return { ok: false, response: Response.json(guardResult.body, { status: guardResult.status }) }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const result = await write()
|
|
129
|
+
|
|
130
|
+
if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
|
|
131
|
+
await runCrudMutationGuardAfterSuccess(container, {
|
|
132
|
+
...guardScope,
|
|
133
|
+
metadata: guardResult.metadata ?? null,
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { ok: true, result }
|
|
138
|
+
}
|
|
139
|
+
|
|
66
140
|
/**
|
|
67
141
|
* Create a POST handler for bulk notification creation routes.
|
|
68
142
|
* Used by batch, role, and feature notification endpoints.
|
|
@@ -72,7 +146,7 @@ export function createBulkNotificationRoute<TSchema extends z.ZodTypeAny>(
|
|
|
72
146
|
serviceMethod: 'createBatch' | 'createForRole' | 'createForFeature'
|
|
73
147
|
) {
|
|
74
148
|
return async function POST(req: Request) {
|
|
75
|
-
const { service, scope } = await resolveNotificationContext(req)
|
|
149
|
+
const { service, scope, ctx } = await resolveNotificationContext(req)
|
|
76
150
|
|
|
77
151
|
const body = await req.json().catch(() => ({}))
|
|
78
152
|
const parsed = schema.safeParse(body)
|
|
@@ -81,7 +155,19 @@ export function createBulkNotificationRoute<TSchema extends z.ZodTypeAny>(
|
|
|
81
155
|
}
|
|
82
156
|
|
|
83
157
|
try {
|
|
84
|
-
const
|
|
158
|
+
const guarded = await runGuardedNotificationWrite(
|
|
159
|
+
ctx.container,
|
|
160
|
+
scope,
|
|
161
|
+
req,
|
|
162
|
+
{
|
|
163
|
+
resourceKind: NOTIFICATION_RESOURCE_KIND,
|
|
164
|
+
operation: 'create',
|
|
165
|
+
payload: parsed.data as Record<string, unknown>,
|
|
166
|
+
},
|
|
167
|
+
() => service[serviceMethod](parsed.data as never, scope),
|
|
168
|
+
)
|
|
169
|
+
if (!guarded.ok) return guarded.response
|
|
170
|
+
const notifications = guarded.result
|
|
85
171
|
|
|
86
172
|
return Response.json({
|
|
87
173
|
ok: true,
|
|
@@ -144,10 +230,21 @@ export function createSingleNotificationActionRoute(
|
|
|
144
230
|
) {
|
|
145
231
|
return async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
146
232
|
const { id } = await params
|
|
147
|
-
const { service, scope } = await resolveNotificationContext(req)
|
|
233
|
+
const { service, scope, ctx } = await resolveNotificationContext(req)
|
|
148
234
|
|
|
149
235
|
try {
|
|
150
|
-
await
|
|
236
|
+
const guarded = await runGuardedNotificationWrite(
|
|
237
|
+
ctx.container,
|
|
238
|
+
scope,
|
|
239
|
+
req,
|
|
240
|
+
{
|
|
241
|
+
resourceKind: NOTIFICATION_RESOURCE_KIND,
|
|
242
|
+
resourceId: id,
|
|
243
|
+
operation: 'update',
|
|
244
|
+
},
|
|
245
|
+
() => service[serviceMethod](id, scope),
|
|
246
|
+
)
|
|
247
|
+
if (!guarded.ok) return guarded.response
|
|
151
248
|
} catch (error) {
|
|
152
249
|
const errorResponse = notificationCrudErrorResponse(error)
|
|
153
250
|
if (errorResponse) return errorResponse
|