@open-mercato/core 0.6.6-develop.6198.1.0822f97cc6 → 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 (25) hide show
  1. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js +81 -37
  2. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js.map +2 -2
  3. package/dist/modules/integrations/backend/integrations/page.js +45 -24
  4. package/dist/modules/integrations/backend/integrations/page.js.map +2 -2
  5. package/dist/modules/notifications/api/[id]/action/route.js +18 -3
  6. package/dist/modules/notifications/api/[id]/action/route.js.map +2 -2
  7. package/dist/modules/notifications/api/[id]/restore/route.js +19 -3
  8. package/dist/modules/notifications/api/[id]/restore/route.js.map +2 -2
  9. package/dist/modules/notifications/api/mark-all-read/route.js +18 -4
  10. package/dist/modules/notifications/api/mark-all-read/route.js.map +2 -2
  11. package/dist/modules/notifications/api/route.js +17 -4
  12. package/dist/modules/notifications/api/route.js.map +2 -2
  13. package/dist/modules/notifications/api/settings/route.js +26 -5
  14. package/dist/modules/notifications/api/settings/route.js.map +2 -2
  15. package/dist/modules/notifications/lib/routeHelpers.js +64 -5
  16. package/dist/modules/notifications/lib/routeHelpers.js.map +2 -2
  17. package/package.json +7 -7
  18. package/src/modules/integrations/backend/integrations/bundle/[id]/page.tsx +78 -35
  19. package/src/modules/integrations/backend/integrations/page.tsx +42 -21
  20. package/src/modules/notifications/api/[id]/action/route.ts +17 -2
  21. package/src/modules/notifications/api/[id]/restore/route.ts +19 -3
  22. package/src/modules/notifications/api/mark-all-read/route.ts +18 -4
  23. package/src/modules/notifications/api/route.ts +16 -3
  24. package/src/modules/notifications/api/settings/route.ts +26 -5
  25. package/src/modules/notifications/lib/routeHelpers.ts +101 -4
@@ -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') },
@@ -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 notifications = await service[serviceMethod](parsed.data as never, scope)
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 service[serviceMethod](id, scope)
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