@open-mercato/core 0.6.6-develop.6184.1.b7e55f8d61 → 0.6.6-develop.6201.1.8ceb502c4b

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.
Files changed (63) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/frontend/login.js +5 -5
  3. package/dist/modules/auth/frontend/login.js.map +2 -2
  4. package/dist/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.js +3 -2
  5. package/dist/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.js.map +2 -2
  6. package/dist/modules/catalog/widgets/injection/product-seo/widget.client.js +26 -14
  7. package/dist/modules/catalog/widgets/injection/product-seo/widget.client.js.map +2 -2
  8. package/dist/modules/customers/api/deals/[id]/route.js +169 -149
  9. package/dist/modules/customers/api/deals/[id]/route.js.map +3 -3
  10. package/dist/modules/customers/components/detail/InlineEditors.js +9 -4
  11. package/dist/modules/customers/components/detail/InlineEditors.js.map +2 -2
  12. package/dist/modules/integrations/backend/integrations/[id]/page.js +13 -9
  13. package/dist/modules/integrations/backend/integrations/[id]/page.js.map +2 -2
  14. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js +81 -37
  15. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js.map +2 -2
  16. package/dist/modules/integrations/backend/integrations/detail-page-refresh.js +17 -0
  17. package/dist/modules/integrations/backend/integrations/detail-page-refresh.js.map +7 -0
  18. package/dist/modules/integrations/backend/integrations/page.js +45 -24
  19. package/dist/modules/integrations/backend/integrations/page.js.map +2 -2
  20. package/dist/modules/notifications/api/[id]/action/route.js +18 -3
  21. package/dist/modules/notifications/api/[id]/action/route.js.map +2 -2
  22. package/dist/modules/notifications/api/[id]/restore/route.js +19 -3
  23. package/dist/modules/notifications/api/[id]/restore/route.js.map +2 -2
  24. package/dist/modules/notifications/api/mark-all-read/route.js +18 -4
  25. package/dist/modules/notifications/api/mark-all-read/route.js.map +2 -2
  26. package/dist/modules/notifications/api/route.js +17 -4
  27. package/dist/modules/notifications/api/route.js.map +2 -2
  28. package/dist/modules/notifications/api/settings/route.js +26 -5
  29. package/dist/modules/notifications/api/settings/route.js.map +2 -2
  30. package/dist/modules/notifications/lib/routeHelpers.js +64 -5
  31. package/dist/modules/notifications/lib/routeHelpers.js.map +2 -2
  32. package/dist/modules/sales/components/documents/SalesDocumentForm.js +299 -297
  33. package/dist/modules/sales/components/documents/SalesDocumentForm.js.map +2 -2
  34. package/dist/modules/workflows/components/EdgeEditDialog.js +120 -141
  35. package/dist/modules/workflows/components/EdgeEditDialog.js.map +2 -2
  36. package/dist/modules/workflows/components/NodeEditDialog.js +114 -118
  37. package/dist/modules/workflows/components/NodeEditDialog.js.map +2 -2
  38. package/dist/modules/workflows/frontend/checkout-demo/page.js +155 -260
  39. package/dist/modules/workflows/frontend/checkout-demo/page.js.map +2 -2
  40. package/package.json +7 -7
  41. package/src/modules/auth/frontend/login.tsx +5 -5
  42. package/src/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.tsx +4 -3
  43. package/src/modules/catalog/widgets/injection/product-seo/widget.client.tsx +27 -17
  44. package/src/modules/customers/api/deals/[id]/route.ts +208 -174
  45. package/src/modules/customers/components/detail/InlineEditors.tsx +6 -10
  46. package/src/modules/integrations/backend/integrations/[id]/page.tsx +13 -9
  47. package/src/modules/integrations/backend/integrations/bundle/[id]/page.tsx +78 -35
  48. package/src/modules/integrations/backend/integrations/detail-page-refresh.ts +36 -0
  49. package/src/modules/integrations/backend/integrations/page.tsx +42 -21
  50. package/src/modules/notifications/api/[id]/action/route.ts +17 -2
  51. package/src/modules/notifications/api/[id]/restore/route.ts +19 -3
  52. package/src/modules/notifications/api/mark-all-read/route.ts +18 -4
  53. package/src/modules/notifications/api/route.ts +16 -3
  54. package/src/modules/notifications/api/settings/route.ts +26 -5
  55. package/src/modules/notifications/lib/routeHelpers.ts +101 -4
  56. package/src/modules/sales/components/documents/SalesDocumentForm.tsx +351 -327
  57. package/src/modules/workflows/components/EdgeEditDialog.tsx +132 -157
  58. package/src/modules/workflows/components/NodeEditDialog.tsx +116 -121
  59. package/src/modules/workflows/frontend/checkout-demo/page.tsx +172 -239
  60. package/src/modules/workflows/i18n/de.json +2 -0
  61. package/src/modules/workflows/i18n/en.json +2 -0
  62. package/src/modules/workflows/i18n/es.json +2 -0
  63. package/src/modules/workflows/i18n/pl.json +2 -0
@@ -3,15 +3,13 @@
3
3
  import * as React from 'react'
4
4
  import Link from 'next/link'
5
5
  import { AtSign, Briefcase, Loader2, Pencil, X } from 'lucide-react'
6
- import ReactMarkdown from 'react-markdown'
7
6
  import { Button } from '@open-mercato/ui/primitives/button'
8
- import type { PluggableList } from 'unified'
7
+ import { MarkdownContent } from '@open-mercato/ui/backend/markdown'
9
8
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
10
9
  import { useT } from '@open-mercato/shared/lib/i18n/context'
11
10
  import { cn } from '@open-mercato/shared/lib/utils'
12
11
  import { useQueryClient } from '@tanstack/react-query'
13
12
  import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
14
- import remarkGfm from 'remark-gfm'
15
13
  import { useEmailDuplicateCheck } from '../../backend/hooks/useEmailDuplicateCheck'
16
14
  import { lookupPhoneDuplicate } from '../../utils/phoneDuplicates'
17
15
  import {
@@ -172,8 +170,6 @@ export function InlineTextEditor(props: InlineFieldProps) {
172
170
  export const InlineMultilineEditor = UiInlineMultilineEditor
173
171
  export const InlineSelectEditor = UiInlineSelectEditor
174
172
 
175
- const MARKDOWN_PREVIEW_PLUGINS: PluggableList = [remarkGfm]
176
-
177
173
  function createSocialRenderDisplay(IconComponent: typeof Briefcase): NonNullable<InlineFieldProps['renderDisplay']> {
178
174
  // eslint-disable-next-line react/display-name
179
175
  return ({ value, emptyLabel }) => {
@@ -206,11 +202,11 @@ export const renderMultilineMarkdownDisplay: InlineMultilineDisplayRenderer = ({
206
202
  return <span className="text-muted-foreground">{emptyLabel}</span>
207
203
  }
208
204
  return (
209
- <div className="text-sm text-foreground [&>*]:mb-2 [&>*:last-child]:mb-0 [&_ul]:ml-4 [&_ul]:list-disc [&_ol]:ml-4 [&_ol]:list-decimal [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:text-xs">
210
- <ReactMarkdown remarkPlugins={MARKDOWN_PREVIEW_PLUGINS}>
211
- {raw}
212
- </ReactMarkdown>
213
- </div>
205
+ <MarkdownContent
206
+ format="markdown"
207
+ body={raw}
208
+ className="text-sm text-foreground [&>*]:mb-2 [&>*:last-child]:mb-0 [&_ul]:ml-4 [&_ul]:list-disc [&_ol]:ml-4 [&_ol]:list-decimal [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:text-xs"
209
+ />
214
210
  )
215
211
  }
216
212
 
@@ -47,6 +47,10 @@ import {
47
47
  resolveIntegrationDetailWidgetSpotId,
48
48
  resolveRequestedIntegrationDetailTab,
49
49
  } from '../detail-page-widgets'
50
+ import {
51
+ refreshIntegrationDetailPanels,
52
+ refreshIntegrationRunActivityPanels,
53
+ } from '../detail-page-refresh'
50
54
  import { isValidCredentialUrl } from '../../../lib/credentials-field-validation'
51
55
 
52
56
  type CredentialField = IntegrationCredentialField
@@ -531,8 +535,7 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
531
535
  spotId: detailWidgetSpotId,
532
536
  })
533
537
  const refreshDetail = React.useCallback(async () => {
534
- await loadDetail({ showLoading: false })
535
- await loadCredentials()
538
+ await refreshIntegrationDetailPanels({ loadDetail, loadCredentials })
536
539
  }, [loadCredentials, loadDetail])
537
540
  const refreshLogs = React.useCallback(async () => {
538
541
  await loadLogs()
@@ -549,13 +552,14 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
549
552
  }
550
553
  if (options?.showLoading) setIsRefreshingRunActivity(true)
551
554
  try {
552
- const call = await apiCall<DataSyncRunDetail>(
553
- `/api/data_sync/runs/${encodeURIComponent(runIdFromUrl)}`,
554
- undefined,
555
- { fallback: null },
556
- )
557
- await loadLogs()
558
- await loadDetail({ showLoading: false })
555
+ const [call] = await Promise.all([
556
+ apiCall<DataSyncRunDetail>(
557
+ `/api/data_sync/runs/${encodeURIComponent(runIdFromUrl)}`,
558
+ undefined,
559
+ { fallback: null },
560
+ ),
561
+ refreshIntegrationRunActivityPanels({ loadLogs, loadDetail }),
562
+ ])
559
563
  if (call.ok && call.result) {
560
564
  setActiveRunDetail(call.result)
561
565
  setActiveRunRefreshedAt(new Date().toISOString())
@@ -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
- // TODO(#2373-B): thread updatedAt — integration detail/state response does not expose a record version yet
151
- const call = await withScopedApiRequestHeaders(
152
- buildOptimisticLockHeader(undefined),
153
- () => apiCall(`/api/integrations/${encodeURIComponent(currentBundleId)}/credentials`, {
154
- method: 'PUT',
155
- headers: { 'Content-Type': 'application/json' },
156
- body: JSON.stringify({ credentials: credValues }),
157
- }, { fallback: null }),
158
- )
159
- if (call.ok) {
160
- flash(t('integrations.detail.credentials.saved'), 'success')
161
- } else {
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
- setIsSavingCreds(false)
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
- // TODO(#2373-B): thread updatedAt — integration detail/state response does not expose a record version yet
170
- const call = await withScopedApiRequestHeaders(
171
- buildOptimisticLockHeader(undefined),
172
- () => apiCall(`/api/integrations/${encodeURIComponent(integrationId)}/state`, {
173
- method: 'PUT',
174
- headers: { 'Content-Type': 'application/json' },
175
- body: JSON.stringify({ isEnabled: enabled }),
176
- }, { fallback: null }),
177
- )
178
- if (call.ok) {
179
- setDetail((prev) => {
180
- if (!prev) return prev
181
- return {
182
- ...prev,
183
- bundleIntegrations: prev.bundleIntegrations.map((item) =>
184
- item.id === integrationId ? { ...item, isEnabled: enabled } : item,
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
- } else {
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
- setTogglingIds((prev) => { const next = new Set(prev); next.delete(integrationId); return next })
192
- }, [t])
235
+ }, [runMutation, mutationContextId, retryLastMutation, t])
193
236
 
194
237
  const handleBulkToggle = React.useCallback(async (enabled: boolean) => {
195
238
  if (!detail) return
@@ -0,0 +1,36 @@
1
+ type ShowLoadingOption = { showLoading?: boolean }
2
+ type IntegrationDetailLoader = (options?: ShowLoadingOption) => Promise<void>
3
+ type IntegrationPanelLoader = () => Promise<void>
4
+
5
+ /**
6
+ * Refresh the integration detail and credential panels concurrently.
7
+ *
8
+ * Detail and credentials are served by independent endpoints with no data
9
+ * dependency between them, so the two requests start together instead of
10
+ * waiting for the detail load to resolve before credentials begins.
11
+ */
12
+ export async function refreshIntegrationDetailPanels(loaders: {
13
+ loadDetail: IntegrationDetailLoader
14
+ loadCredentials: IntegrationPanelLoader
15
+ }): Promise<void> {
16
+ await Promise.all([
17
+ loaders.loadDetail({ showLoading: false }),
18
+ loaders.loadCredentials(),
19
+ ])
20
+ }
21
+
22
+ /**
23
+ * Refresh the run-activity panels (logs and detail) concurrently.
24
+ *
25
+ * loadLogs and loadDetail hit independent endpoints and neither consumes the
26
+ * other's result, so the two reloads start together.
27
+ */
28
+ export async function refreshIntegrationRunActivityPanels(loaders: {
29
+ loadLogs: IntegrationPanelLoader
30
+ loadDetail: IntegrationDetailLoader
31
+ }): Promise<void> {
32
+ await Promise.all([
33
+ loaders.loadLogs(),
34
+ loaders.loadDetail({ showLoading: false }),
35
+ ])
36
+ }
@@ -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
- const call = await withScopedApiRequestHeaders(
206
- buildOptimisticLockHeader(updatedAt),
207
- () => apiCall(`/api/integrations/${encodeURIComponent(integrationId)}/state`, {
208
- method: 'PUT',
209
- headers: { 'Content-Type': 'application/json' },
210
- body: JSON.stringify({ isEnabled: enabled }),
211
- }, { fallback: null }),
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
- if (!call.ok) {
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
- } else {
217
- setData((prev) => {
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
- setTogglingIds((prev) => { const next = new Set(prev); next.delete(integrationId); return next })
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 { notification, result } = await service.executeAction(id, input, scope)
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 { resolveNotificationContext } from '../../../lib/routeHelpers'
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 service.restoreDismissed(id, input.status, scope)
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 { resolveNotificationContext } from '../../lib/routeHelpers'
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 count = await service.markAllAsRead(scope)
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 notification = await service.create(parsed.data, scope)
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: notification.id }, { status: 201 })
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 saveNotificationDeliveryConfig(container, parsed.data)
71
- const settings = await resolveNotificationDeliveryConfig(container, {
72
- defaultValue: DEFAULT_NOTIFICATION_DELIVERY_CONFIG,
73
- })
74
- return NextResponse.json({ ok: true, settings })
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') },