@open-mercato/core 0.10.1-develop.7741.1.61a2652671 → 0.10.1-develop.7749.1.ea3edf75e3

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 (159) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/generated/entities/attachment_partition/index.js +2 -0
  3. package/dist/generated/entities/attachment_partition/index.js.map +2 -2
  4. package/dist/generated/entity-fields-registry.js +1 -0
  5. package/dist/generated/entity-fields-registry.js.map +2 -2
  6. package/dist/helpers/integration/attachmentAccessFixtures.js +144 -0
  7. package/dist/helpers/integration/attachmentAccessFixtures.js.map +7 -0
  8. package/dist/modules/attachments/api/file/[id]/route.js +16 -10
  9. package/dist/modules/attachments/api/file/[id]/route.js.map +2 -2
  10. package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js +15 -9
  11. package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js.map +2 -2
  12. package/dist/modules/attachments/api/library/[id]/route.js +100 -56
  13. package/dist/modules/attachments/api/library/[id]/route.js.map +3 -3
  14. package/dist/modules/attachments/api/library/route.js +56 -17
  15. package/dist/modules/attachments/api/library/route.js.map +2 -2
  16. package/dist/modules/attachments/api/partitions/route.js +2 -0
  17. package/dist/modules/attachments/api/partitions/route.js.map +2 -2
  18. package/dist/modules/attachments/api/route.js +74 -34
  19. package/dist/modules/attachments/api/route.js.map +3 -3
  20. package/dist/modules/attachments/api/transfer/route.js +63 -37
  21. package/dist/modules/attachments/api/transfer/route.js.map +2 -2
  22. package/dist/modules/attachments/data/entities.js +3 -0
  23. package/dist/modules/attachments/data/entities.js.map +2 -2
  24. package/dist/modules/attachments/data/validators.js +41 -0
  25. package/dist/modules/attachments/data/validators.js.map +7 -0
  26. package/dist/modules/attachments/di.js +5 -2
  27. package/dist/modules/attachments/di.js.map +2 -2
  28. package/dist/modules/attachments/generators.js +29 -0
  29. package/dist/modules/attachments/generators.js.map +7 -0
  30. package/dist/modules/attachments/index.js +8 -1
  31. package/dist/modules/attachments/index.js.map +2 -2
  32. package/dist/modules/attachments/lib/access-errors.js +35 -0
  33. package/dist/modules/attachments/lib/access-errors.js.map +7 -0
  34. package/dist/modules/attachments/lib/access-mutation.js +23 -0
  35. package/dist/modules/attachments/lib/access-mutation.js.map +7 -0
  36. package/dist/modules/attachments/lib/access-protection.js +73 -0
  37. package/dist/modules/attachments/lib/access-protection.js.map +7 -0
  38. package/dist/modules/attachments/lib/access-query.js +74 -0
  39. package/dist/modules/attachments/lib/access-query.js.map +7 -0
  40. package/dist/modules/attachments/lib/access-registry.js +87 -0
  41. package/dist/modules/attachments/lib/access-registry.js.map +7 -0
  42. package/dist/modules/attachments/lib/access-runner.js +161 -0
  43. package/dist/modules/attachments/lib/access-runner.js.map +7 -0
  44. package/dist/modules/attachments/lib/access-types.js +1 -0
  45. package/dist/modules/attachments/lib/access-types.js.map +7 -0
  46. package/dist/modules/attachments/lib/attachment-service.js +14 -6
  47. package/dist/modules/attachments/lib/attachment-service.js.map +2 -2
  48. package/dist/modules/attachments/lib/createFromBuffer.js +2 -0
  49. package/dist/modules/attachments/lib/createFromBuffer.js.map +2 -2
  50. package/dist/modules/attachments/lib/partitions.js +3 -2
  51. package/dist/modules/attachments/lib/partitions.js.map +2 -2
  52. package/dist/modules/attachments/lib/scoped-upload-service.js +2 -0
  53. package/dist/modules/attachments/lib/scoped-upload-service.js.map +2 -2
  54. package/dist/modules/attachments/lib/target-access-service.js +12 -3
  55. package/dist/modules/attachments/lib/target-access-service.js.map +2 -2
  56. package/dist/modules/attachments/migrations/Migration20261001120000_attachments.js +38 -0
  57. package/dist/modules/attachments/migrations/Migration20261001120000_attachments.js.map +7 -0
  58. package/dist/modules/attachments/setup.js +10 -0
  59. package/dist/modules/attachments/setup.js.map +2 -2
  60. package/dist/modules/notifications/api/[id]/action/route.js +8 -0
  61. package/dist/modules/notifications/api/[id]/action/route.js.map +2 -2
  62. package/dist/modules/notifications/api/route.js +17 -0
  63. package/dist/modules/notifications/api/route.js.map +2 -2
  64. package/dist/modules/notifications/data/validators.js +1 -1
  65. package/dist/modules/notifications/data/validators.js.map +2 -2
  66. package/dist/modules/notifications/lib/notificationService.js +45 -3
  67. package/dist/modules/notifications/lib/notificationService.js.map +2 -2
  68. package/dist/modules/notifications/lib/routeHelpers.js +27 -0
  69. package/dist/modules/notifications/lib/routeHelpers.js.map +2 -2
  70. package/dist/modules/payment_gateways/lib/gateway-service.js +50 -34
  71. package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
  72. package/dist/modules/payment_gateways/lib/status-machine.js +10 -1
  73. package/dist/modules/payment_gateways/lib/status-machine.js.map +2 -2
  74. package/dist/modules/payment_gateways/lib/webhook-processor.js +1 -1
  75. package/dist/modules/payment_gateways/lib/webhook-processor.js.map +2 -2
  76. package/dist/modules/staff/notifications.js +4 -2
  77. package/dist/modules/staff/notifications.js.map +2 -2
  78. package/dist/modules/workflows/data/entities.js.map +2 -2
  79. package/dist/modules/workflows/lib/activity-executor.js +18 -4
  80. package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
  81. package/dist/modules/workflows/lib/activity-types.js +2 -2
  82. package/dist/modules/workflows/lib/activity-types.js.map +2 -2
  83. package/dist/modules/workflows/lib/condition-handler.js +7 -1
  84. package/dist/modules/workflows/lib/condition-handler.js.map +2 -2
  85. package/dist/modules/workflows/lib/duration.js +13 -0
  86. package/dist/modules/workflows/lib/duration.js.map +2 -2
  87. package/dist/modules/workflows/lib/step-handler.js +9 -2
  88. package/dist/modules/workflows/lib/step-handler.js.map +2 -2
  89. package/dist/modules/workflows/lib/task-quick-action.js +13 -10
  90. package/dist/modules/workflows/lib/task-quick-action.js.map +2 -2
  91. package/dist/modules/workflows/lib/transition-handler.js +2 -1
  92. package/dist/modules/workflows/lib/transition-handler.js.map +2 -2
  93. package/dist/modules/workflows/notifications.js +4 -0
  94. package/dist/modules/workflows/notifications.js.map +2 -2
  95. package/generated/entities/attachment_partition/index.ts +1 -0
  96. package/generated/entity-fields-registry.ts +1 -0
  97. package/package.json +7 -7
  98. package/src/helpers/integration/attachmentAccessFixtures.ts +141 -0
  99. package/src/modules/attachments/api/file/[id]/route.ts +12 -9
  100. package/src/modules/attachments/api/image/[id]/[[...slug]]/route.ts +10 -8
  101. package/src/modules/attachments/api/library/[id]/route.ts +87 -58
  102. package/src/modules/attachments/api/library/route.ts +40 -11
  103. package/src/modules/attachments/api/partitions/route.ts +2 -0
  104. package/src/modules/attachments/api/route.ts +68 -35
  105. package/src/modules/attachments/api/transfer/route.ts +65 -42
  106. package/src/modules/attachments/data/entities.ts +4 -0
  107. package/src/modules/attachments/data/validators.ts +36 -0
  108. package/src/modules/attachments/di.ts +4 -1
  109. package/src/modules/attachments/generators.ts +27 -0
  110. package/src/modules/attachments/i18n/de.json +4 -0
  111. package/src/modules/attachments/i18n/en.json +4 -0
  112. package/src/modules/attachments/i18n/es.json +4 -0
  113. package/src/modules/attachments/i18n/ko.json +4 -0
  114. package/src/modules/attachments/i18n/pl.json +4 -0
  115. package/src/modules/attachments/index.ts +18 -0
  116. package/src/modules/attachments/lib/access-errors.ts +32 -0
  117. package/src/modules/attachments/lib/access-mutation.ts +26 -0
  118. package/src/modules/attachments/lib/access-protection.ts +83 -0
  119. package/src/modules/attachments/lib/access-query.ts +86 -0
  120. package/src/modules/attachments/lib/access-registry.ts +117 -0
  121. package/src/modules/attachments/lib/access-runner.ts +183 -0
  122. package/src/modules/attachments/lib/access-types.ts +78 -0
  123. package/src/modules/attachments/lib/attachment-service.ts +8 -4
  124. package/src/modules/attachments/lib/createFromBuffer.ts +3 -0
  125. package/src/modules/attachments/lib/partitions.ts +3 -2
  126. package/src/modules/attachments/lib/scoped-upload-service.ts +2 -0
  127. package/src/modules/attachments/lib/target-access-service.ts +10 -3
  128. package/src/modules/attachments/migrations/.snapshot-open-mercato.json +17 -0
  129. package/src/modules/attachments/migrations/Migration20261001120000_attachments.ts +35 -0
  130. package/src/modules/attachments/setup.ts +10 -0
  131. package/src/modules/auth/i18n/de.json +3 -0
  132. package/src/modules/auth/i18n/en.json +3 -0
  133. package/src/modules/auth/i18n/es.json +3 -0
  134. package/src/modules/auth/i18n/ko.json +3 -0
  135. package/src/modules/auth/i18n/pl.json +3 -0
  136. package/src/modules/notifications/api/[id]/action/route.ts +8 -0
  137. package/src/modules/notifications/api/route.ts +17 -0
  138. package/src/modules/notifications/data/validators.ts +1 -1
  139. package/src/modules/notifications/i18n/de.json +2 -0
  140. package/src/modules/notifications/i18n/en.json +2 -0
  141. package/src/modules/notifications/i18n/es.json +2 -0
  142. package/src/modules/notifications/i18n/ko.json +2 -0
  143. package/src/modules/notifications/i18n/pl.json +2 -0
  144. package/src/modules/notifications/lib/notificationService.ts +67 -1
  145. package/src/modules/notifications/lib/routeHelpers.ts +38 -0
  146. package/src/modules/payment_gateways/lib/gateway-service.ts +50 -33
  147. package/src/modules/payment_gateways/lib/status-machine.ts +19 -0
  148. package/src/modules/payment_gateways/lib/webhook-processor.ts +1 -1
  149. package/src/modules/staff/notifications.ts +2 -0
  150. package/src/modules/workflows/AGENTS.md +2 -2
  151. package/src/modules/workflows/data/entities.ts +6 -0
  152. package/src/modules/workflows/lib/activity-executor.ts +36 -4
  153. package/src/modules/workflows/lib/activity-types.ts +2 -2
  154. package/src/modules/workflows/lib/condition-handler.ts +16 -1
  155. package/src/modules/workflows/lib/duration.ts +25 -0
  156. package/src/modules/workflows/lib/step-handler.ts +16 -1
  157. package/src/modules/workflows/lib/task-quick-action.ts +19 -10
  158. package/src/modules/workflows/lib/transition-handler.ts +2 -1
  159. package/src/modules/workflows/notifications.ts +4 -0
@@ -107,6 +107,8 @@
107
107
  "auth.acl.features.channel_resend.view": "Wyświetl konfigurację kanału Resend",
108
108
  "auth.acl.features.channel_ses.configure": "Konfiguruj domyślne ustawienia kanału Amazon SES",
109
109
  "auth.acl.features.channel_ses.view": "Wyświetl konfigurację kanału Amazon SES",
110
+ "auth.acl.features.channel_smtp.configure": "Konfiguruj domyślne ustawienia kanału SMTP",
111
+ "auth.acl.features.channel_smtp.view": "Wyświetl konfigurację kanału SMTP",
110
112
  "auth.acl.features.checkout.create": "Twórz linki i szablony płatności",
111
113
  "auth.acl.features.checkout.delete": "Usuwaj linki i szablony płatności",
112
114
  "auth.acl.features.checkout.edit": "Edytuj linki i szablony płatności",
@@ -442,6 +444,7 @@
442
444
  "auth.acl.modules.channel_imap": "Kanał e-mail IMAP + SMTP",
443
445
  "auth.acl.modules.channel_resend": "Kanał e-mail Resend",
444
446
  "auth.acl.modules.channel_ses": "Kanał e-mail Amazon SES",
447
+ "auth.acl.modules.channel_smtp": "Kanał e-mail SMTP",
445
448
  "auth.acl.modules.checkout": "Kasa",
446
449
  "auth.acl.modules.communication_channels": "Kanały komunikacji",
447
450
  "auth.acl.modules.configs": "Konfiguracja",
@@ -97,6 +97,14 @@ export const openApi = {
97
97
  },
98
98
  },
99
99
  },
100
+ 403: {
101
+ description: 'The stored action command is not declared by the notification type with the same action id and command, or the user lacks the features the declared action requires (`code: notification_action_forbidden`)',
102
+ content: {
103
+ 'application/json': {
104
+ schema: actionConflictResponseSchema,
105
+ },
106
+ },
107
+ },
100
108
  400: {
101
109
  description: 'Action not found or failed',
102
110
  content: {
@@ -9,6 +9,7 @@ import {
9
9
  resolveCrudCache,
10
10
  } from '@open-mercato/shared/lib/crud/cache'
11
11
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi/types'
12
+ import { NOTIFICATION_ACTION_COMMAND_NOT_ALLOWED_ERROR_CODE } from '@open-mercato/shared/modules/notifications/types'
12
13
  import { Notification } from '../data/entities'
13
14
  import { listNotificationsSchema, createNotificationSchema } from '../data/validators'
14
15
  import { toNotificationDto } from '../lib/notificationMapper'
@@ -21,12 +22,14 @@ import {
21
22
  NOTIFICATION_RESOURCE_KIND,
22
23
  notificationCrudErrorResponse,
23
24
  notificationValidationErrorResponse,
25
+ rejectNotificationActionCommands,
24
26
  resolveGuardedNotificationContext,
25
27
  resolveNotificationContext,
26
28
  runGuardedNotificationWrite,
27
29
  TENANT_SCOPE_REQUIRED_ERROR_CODE,
28
30
  } from '../lib/routeHelpers'
29
31
  import {
32
+ actionConflictResponseSchema,
30
33
  buildNotificationsCrudOpenApi,
31
34
  createPagedListResponseSchema,
32
35
  notificationItemSchema,
@@ -216,6 +219,8 @@ export async function POST(req: Request) {
216
219
  if (!parsed.success) {
217
220
  return notificationValidationErrorResponse(parsed.error)
218
221
  }
222
+ const commandRejection = await rejectNotificationActionCommands(parsed.data)
223
+ if (commandRejection) return commandRejection
219
224
 
220
225
  try {
221
226
  const guarded = await runGuardedNotificationWrite(
@@ -251,6 +256,7 @@ const notificationsCrudOpenApi = buildNotificationsCrudOpenApi({
251
256
  })
252
257
 
253
258
  const notificationsCrudGet = notificationsCrudOpenApi.methods?.GET ?? {}
259
+ const notificationsCrudPost = notificationsCrudOpenApi.methods?.POST ?? {}
254
260
 
255
261
  // The CRUD factory documents errors only for DELETE, and POST already gets an auto-generated 403
256
262
  // from its `requireFeatures` metadata. GET is authenticated-only, so its tenant-scope rejection has
@@ -270,5 +276,16 @@ export const openApi: OpenApiRouteDoc = {
270
276
  },
271
277
  ],
272
278
  },
279
+ POST: {
280
+ ...notificationsCrudPost,
281
+ errors: [
282
+ ...(notificationsCrudPost.errors ?? []),
283
+ {
284
+ status: 400,
285
+ description: `Invalid payload, or an action carries a \`commandId\` (code: ${NOTIFICATION_ACTION_COMMAND_NOT_ALLOWED_ERROR_CODE})`,
286
+ schema: actionConflictResponseSchema,
287
+ },
288
+ ],
289
+ },
273
290
  },
274
291
  }
@@ -15,7 +15,7 @@ export const notificationActionSchema = z.object({
15
15
  labelKey: z.string().optional(),
16
16
  variant: z.enum(['default', 'secondary', 'destructive', 'outline', 'ghost']).optional(),
17
17
  icon: z.string().optional(),
18
- commandId: z.string().optional(),
18
+ commandId: z.string().optional().describe('Refused by the HTTP create routes (400 notification_action_command_not_allowed); command actions are attached by server code from the notification type definition'),
19
19
  href: safeRelativeHrefSchema.optional(),
20
20
  confirmRequired: z.boolean().optional(),
21
21
  confirmMessage: z.string().optional(),
@@ -14,6 +14,8 @@
14
14
  "notifications.delivery.settings.invalid": "Ungültige Zustelleinstellungen",
15
15
  "notifications.empty": "Keine Benachrichtigungen",
16
16
  "notifications.error.action": "Aktion konnte nicht ausgeführt werden",
17
+ "notifications.error.actionCommandNotAllowed": "Über die API erstellte Benachrichtigungsaktionen dürfen keine Befehle ausführen",
18
+ "notifications.error.actionForbidden": "Sie dürfen diese Benachrichtigungsaktion nicht ausführen",
17
19
  "notifications.error.load": "Benachrichtigungen konnten nicht geladen werden",
18
20
  "notifications.filters.actionRequired": "Aktion erforderlich",
19
21
  "notifications.filters.all": "Alle",
@@ -14,6 +14,8 @@
14
14
  "notifications.delivery.settings.invalid": "Invalid delivery settings",
15
15
  "notifications.empty": "No notifications",
16
16
  "notifications.error.action": "Failed to execute action",
17
+ "notifications.error.actionCommandNotAllowed": "Notification actions created through the API cannot run commands",
18
+ "notifications.error.actionForbidden": "You are not allowed to run this notification action",
17
19
  "notifications.error.load": "Failed to load notifications",
18
20
  "notifications.filters.actionRequired": "Action Required",
19
21
  "notifications.filters.all": "All",
@@ -14,6 +14,8 @@
14
14
  "notifications.delivery.settings.invalid": "Configuración de entrega no válida",
15
15
  "notifications.empty": "Sin notificaciones",
16
16
  "notifications.error.action": "No se pudo ejecutar la acción",
17
+ "notifications.error.actionCommandNotAllowed": "Las acciones de notificación creadas mediante la API no pueden ejecutar comandos",
18
+ "notifications.error.actionForbidden": "No tienes permiso para ejecutar esta acción de notificación",
17
19
  "notifications.error.load": "No se pudieron cargar las notificaciones",
18
20
  "notifications.filters.actionRequired": "Acción requerida",
19
21
  "notifications.filters.all": "Todas",
@@ -14,6 +14,8 @@
14
14
  "notifications.delivery.settings.invalid": "잘못된 전달 설정입니다",
15
15
  "notifications.empty": "알림 없음",
16
16
  "notifications.error.action": "작업을 실행하지 못했습니다",
17
+ "notifications.error.actionCommandNotAllowed": "API로 생성한 알림 작업은 명령을 실행할 수 없습니다",
18
+ "notifications.error.actionForbidden": "이 알림 작업을 실행할 권한이 없습니다",
17
19
  "notifications.error.load": "알림을 불러오지 못했습니다",
18
20
  "notifications.filters.actionRequired": "조치 필요",
19
21
  "notifications.filters.all": "전체",
@@ -14,6 +14,8 @@
14
14
  "notifications.delivery.settings.invalid": "Nieprawidłowe ustawienia dostarczania",
15
15
  "notifications.empty": "Brak powiadomień",
16
16
  "notifications.error.action": "Nie udało się wykonać akcji",
17
+ "notifications.error.actionCommandNotAllowed": "Akcje powiadomień utworzone przez API nie mogą uruchamiać poleceń",
18
+ "notifications.error.actionForbidden": "Nie masz uprawnień do wykonania tej akcji powiadomienia",
17
19
  "notifications.error.load": "Nie udało się załadować powiadomień",
18
20
  "notifications.filters.actionRequired": "Wymagana akcja",
19
21
  "notifications.filters.all": "Wszystkie",
@@ -6,8 +6,13 @@ import { Notification, type NotificationStatus } from '../data/entities'
6
6
  import type { CreateNotificationInput, CreateBatchNotificationInput, CreateRoleNotificationInput, CreateFeatureNotificationInput, ExecuteActionInput } from '../data/validators'
7
7
  import {
8
8
  NOTIFICATION_ACTION_ALREADY_EXECUTED_ERROR_CODE,
9
+ NOTIFICATION_ACTION_FORBIDDEN_ERROR_CODE,
10
+ type NotificationAction,
9
11
  type NotificationPollData,
12
+ type NotificationTypeAction,
10
13
  } from '@open-mercato/shared/modules/notifications/types'
14
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
15
+ import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
11
16
  import { NOTIFICATION_EVENTS, NOTIFICATION_SSE_EVENTS } from './events'
12
17
  import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
13
18
  import {
@@ -97,6 +102,27 @@ function actionAlreadyExecuted(): CrudHttpError {
97
102
  })
98
103
  }
99
104
 
105
+ async function actionForbidden(): Promise<CrudHttpError> {
106
+ const { translate } = await resolveTranslations()
107
+ return new CrudHttpError(403, {
108
+ error: translate('notifications.error.actionForbidden', 'You are not allowed to run this notification action'),
109
+ code: NOTIFICATION_ACTION_FORBIDDEN_ERROR_CODE,
110
+ })
111
+ }
112
+
113
+ /**
114
+ * The command action the notification's registered type declares for a stored action: same action
115
+ * id (across `actions` and `optionalActions`) and same `commandId`. Anything else is not dispatched.
116
+ */
117
+ function findDeclaredCommandAction(type: string, action: NotificationAction): NotificationTypeAction | null {
118
+ const definition = getNotificationType(type)
119
+ if (!definition) return null
120
+ const declared = [...definition.actions, ...(definition.optionalActions ?? [])]
121
+ .find((candidate) => candidate.id === action.id)
122
+ if (!declared?.commandId || declared.commandId !== action.commandId) return null
123
+ return declared
124
+ }
125
+
100
126
  function applyNotificationContent(
101
127
  notification: Notification,
102
128
  input: NotificationContentInput,
@@ -285,6 +311,39 @@ export interface NotificationServiceDeps {
285
311
  export function createNotificationService(deps: NotificationServiceDeps): NotificationService {
286
312
  const { em: rootEm, eventBus, commandBus, container } = deps
287
313
 
314
+ /**
315
+ * Whether the clicking user holds every feature a declared notification action requires.
316
+ * Fails closed: no user, no container, or a failing RBAC check denies the action.
317
+ */
318
+ const userHoldsActionFeatures = async (
319
+ requiredFeatures: string[] | undefined,
320
+ scopeCtx: NotificationServiceContext,
321
+ ): Promise<boolean> => {
322
+ if (!requiredFeatures || requiredFeatures.length === 0) return true
323
+ if (!scopeCtx.userId || !container) return false
324
+ try {
325
+ const rbacService = container.resolve('rbacService') as {
326
+ userHasAllFeatures: (
327
+ userId: string,
328
+ features: string[],
329
+ scope: { tenantId: string | null; organizationId: string | null },
330
+ ) => Promise<boolean>
331
+ }
332
+ const allowed = await rbacService.userHasAllFeatures(
333
+ scopeCtx.userId,
334
+ requiredFeatures,
335
+ { tenantId: scopeCtx.tenantId, organizationId: scopeCtx.organizationId ?? null },
336
+ )
337
+ return allowed === true
338
+ } catch (err) {
339
+ getTelemetryRuntime()?.reportError(err, {
340
+ module: 'notifications',
341
+ code: 'notifications.action_feature_check_failed',
342
+ })
343
+ return false
344
+ }
345
+ }
346
+
288
347
  /**
289
348
  * Resolves the authoritative delivery-channel set for one recipient at create time — the single
290
349
  * gate that folds per-send target, per-type eligibility, registered strategies, and the
@@ -696,6 +755,13 @@ export function createNotificationService(deps: NotificationServiceDeps): Notifi
696
755
  throw actionAlreadyExecuted()
697
756
  }
698
757
 
758
+ if (action.commandId) {
759
+ const declared = findDeclaredCommandAction(notification.type, action)
760
+ if (!declared || !(await userHoldsActionFeatures(declared.requiredFeatures, ctx))) {
761
+ throw await actionForbidden()
762
+ }
763
+ }
764
+
699
765
  const actionedAt = new Date()
700
766
  const previousStatus = notification.status
701
767
  const previousActionedAt = notification.actionedAt ?? null
@@ -749,8 +815,8 @@ export function createNotificationService(deps: NotificationServiceDeps): Notifi
749
815
 
750
816
  if (action.commandId && commandBus && container) {
751
817
  const commandInput = {
752
- id: notification.sourceEntityId,
753
818
  ...input.payload,
819
+ id: notification.sourceEntityId,
754
820
  }
755
821
 
756
822
  // Build a CommandRuntimeContext from the notification service context
@@ -5,7 +5,9 @@ import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/d
5
5
  import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
6
6
  import { runRouteMutationGuards } from '@open-mercato/shared/lib/crud/route-mutation-guard'
7
7
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
8
+ import { NOTIFICATION_ACTION_COMMAND_NOT_ALLOWED_ERROR_CODE } from '@open-mercato/shared/modules/notifications/types'
8
9
  import { resolveNotificationService, type NotificationService } from './notificationService'
10
+ import { actionConflictResponseSchema } from '../api/openapi'
9
11
 
10
12
  /**
11
13
  * Mutation-guard resource kind for notification rows.
@@ -60,6 +62,32 @@ export async function notificationValidationErrorResponse(error: z.ZodError): Pr
60
62
  )
61
63
  }
62
64
 
65
+ export type NotificationActionsPayload = { actions?: Array<{ commandId?: string | null }> | null }
66
+
67
+ /**
68
+ * Reject an API-supplied action that carries a `commandId`.
69
+ *
70
+ * Command-backed actions run with the clicking user's identity, so only server code may attach
71
+ * them, from the notification type definition. Called after payload validation and before the
72
+ * mutation guard or the service write.
73
+ */
74
+ export async function rejectNotificationActionCommands(
75
+ data: NotificationActionsPayload,
76
+ ): Promise<Response | null> {
77
+ if (!data.actions?.some((action) => action.commandId)) return null
78
+ const { t } = await resolveTranslations()
79
+ return Response.json(
80
+ {
81
+ error: t(
82
+ 'notifications.error.actionCommandNotAllowed',
83
+ 'Notification actions created through the API cannot run commands',
84
+ ),
85
+ code: NOTIFICATION_ACTION_COMMAND_NOT_ALLOWED_ERROR_CODE,
86
+ },
87
+ { status: 400 },
88
+ )
89
+ }
90
+
63
91
  export function notificationCrudErrorResponse(error: unknown): Response | null {
64
92
  if (!isCrudHttpError(error)) return null
65
93
  return Response.json(error.body ?? { error: 'Notification request failed' }, { status: error.status })
@@ -235,6 +263,8 @@ export function createBulkNotificationRoute<TSchema extends z.ZodTypeAny>(
235
263
  if (!parsed.success) {
236
264
  return notificationValidationErrorResponse(parsed.error)
237
265
  }
266
+ const commandRejection = await rejectNotificationActionCommands(parsed.data as NotificationActionsPayload)
267
+ if (commandRejection) return commandRejection
238
268
 
239
269
  try {
240
270
  const guarded = await runGuardedNotificationWrite(
@@ -298,6 +328,14 @@ export function createBulkNotificationOpenApi<TSchema extends z.ZodTypeAny>(
298
328
  },
299
329
  },
300
330
  },
331
+ 400: {
332
+ description: `Invalid payload, or an action carries a \`commandId\` (code: ${NOTIFICATION_ACTION_COMMAND_NOT_ALLOWED_ERROR_CODE})`,
333
+ content: {
334
+ 'application/json': {
335
+ schema: actionConflictResponseSchema,
336
+ },
337
+ },
338
+ },
301
339
  },
302
340
  },
303
341
  }
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto'
2
- import { QueryOrder } from '@mikro-orm/core'
2
+ import { LockMode, QueryOrder } from '@mikro-orm/core'
3
3
  import type { EntityManager } from '@mikro-orm/postgresql'
4
4
  import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
5
5
  import { createLogger } from '@open-mercato/shared/lib/logger'
@@ -21,7 +21,7 @@ import type { IntegrationStateService } from '../../integrations/lib/state-servi
21
21
  import type { IntegrationLogService } from '../../integrations/lib/log-service'
22
22
  import { conflict, CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
23
23
  import { GatewayPaymentOperation, GatewaySessionInitialization, GatewayTransaction } from '../data/entities'
24
- import { canApplyManualAction, isValidTransition, type ManualGatewayAction } from './status-machine'
24
+ import { canApplyManualAction, isValidTransition, resolveImpliedCaptureStatus, type ManualGatewayAction } from './status-machine'
25
25
  import { emitPaymentGatewayEvent } from '../events'
26
26
  import { readGatewayMetadata, readWebhookLog } from './transaction-fields'
27
27
  import { reconcileSessionAmountWithOrder } from './order-amount-reconciliation'
@@ -124,6 +124,7 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
124
124
  transactionId: string,
125
125
  scope: { organizationId: string; tenantId: string },
126
126
  targetEm: EntityManager = em,
127
+ options?: { lockMode: LockMode.PESSIMISTIC_WRITE },
127
128
  ): Promise<GatewayTransaction> {
128
129
  const transaction = await findOneWithDecryption(
129
130
  targetEm,
@@ -134,7 +135,7 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
134
135
  tenantId: scope.tenantId,
135
136
  deletedAt: null,
136
137
  },
137
- undefined,
138
+ options,
138
139
  scope,
139
140
  )
140
141
  if (!transaction) {
@@ -765,42 +766,57 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
765
766
  receivedAt?: string
766
767
  }
767
768
  }, scope: { organizationId: string; tenantId: string }): Promise<void> {
768
- const transaction = await findTransactionOrThrow(transactionId, scope)
769
- const currentStatus = transaction.unifiedStatus as UnifiedPaymentStatus
770
- const canTransition = isValidTransition(currentStatus, update.unifiedStatus)
771
- const shouldApplyStatus = canTransition && update.unifiedStatus !== currentStatus
772
- const previousStatus = transaction.unifiedStatus
773
- if (shouldApplyStatus) {
774
- transaction.unifiedStatus = update.unifiedStatus
775
- alignCapturedAmountWithStatus(transaction, update.unifiedStatus)
776
- }
777
- if (update.providerStatus) {
778
- transaction.gatewayStatus = update.providerStatus
779
- }
780
- if (update.providerData) {
781
- transaction.gatewayMetadata = { ...readGatewayMetadata(transaction.gatewayMetadata), ...update.providerData }
782
- }
783
- if (update.webhookEvent) {
784
- const webhookLog = readWebhookLog(transaction.webhookLog)
785
- webhookLog.push({
786
- eventType: update.webhookEvent.eventType,
787
- receivedAt: update.webhookEvent.receivedAt ?? new Date().toISOString(),
788
- idempotencyKey: update.webhookEvent.idempotencyKey,
789
- unifiedStatus: update.unifiedStatus,
790
- processed: update.webhookEvent.processed,
791
- })
792
- transaction.webhookLog = webhookLog
793
- }
794
- transaction.lastWebhookAt = new Date()
795
- await em.flush()
769
+ const { transaction, previousStatus, impliedCaptureStatus, shouldApplyStatus } = await em.fork().transactional(async (tx) => {
770
+ const locked = await findTransactionOrThrow(transactionId, scope, tx, { lockMode: LockMode.PESSIMISTIC_WRITE })
771
+ const currentStatus = locked.unifiedStatus as UnifiedPaymentStatus
772
+ const implied = resolveImpliedCaptureStatus(currentStatus, update.unifiedStatus)
773
+ const canTransition = isValidTransition(currentStatus, update.unifiedStatus) || implied !== null
774
+ const applies = canTransition && update.unifiedStatus !== currentStatus
775
+ if (applies) {
776
+ if (implied) alignCapturedAmountWithStatus(locked, implied)
777
+ locked.unifiedStatus = update.unifiedStatus
778
+ alignCapturedAmountWithStatus(locked, update.unifiedStatus)
779
+ }
780
+ if (update.providerStatus) {
781
+ locked.gatewayStatus = update.providerStatus
782
+ }
783
+ if (update.providerData) {
784
+ locked.gatewayMetadata = { ...readGatewayMetadata(locked.gatewayMetadata), ...update.providerData }
785
+ }
786
+ if (update.webhookEvent) {
787
+ const webhookLog = readWebhookLog(locked.webhookLog)
788
+ webhookLog.push({
789
+ eventType: update.webhookEvent.eventType,
790
+ receivedAt: update.webhookEvent.receivedAt ?? new Date().toISOString(),
791
+ idempotencyKey: update.webhookEvent.idempotencyKey,
792
+ unifiedStatus: update.unifiedStatus,
793
+ processed: update.webhookEvent.processed,
794
+ })
795
+ locked.webhookLog = webhookLog
796
+ }
797
+ locked.lastWebhookAt = new Date()
798
+ await tx.flush()
799
+ return {
800
+ transaction: locked,
801
+ previousStatus: currentStatus,
802
+ impliedCaptureStatus: applies ? implied : null,
803
+ shouldApplyStatus: applies,
804
+ }
805
+ })
796
806
  if (shouldApplyStatus) {
797
- await emitStatusEvent(update.unifiedStatus, {
807
+ const eventPayload = {
798
808
  transactionId: transaction.id,
799
809
  paymentId: transaction.paymentId,
800
810
  providerKey: transaction.providerKey,
801
- previousStatus,
802
811
  organizationId: transaction.organizationId,
803
812
  tenantId: transaction.tenantId,
813
+ }
814
+ if (impliedCaptureStatus) {
815
+ await emitStatusEvent(impliedCaptureStatus, { ...eventPayload, previousStatus })
816
+ }
817
+ await emitStatusEvent(update.unifiedStatus, {
818
+ ...eventPayload,
819
+ previousStatus: impliedCaptureStatus ?? previousStatus,
804
820
  })
805
821
  }
806
822
  await writeTransactionLog(
@@ -811,6 +827,7 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
811
827
  shouldApplyStatus ? 'Payment status synchronized from webhook' : 'Webhook received with no status transition',
812
828
  {
813
829
  previousStatus,
830
+ ...(impliedCaptureStatus ? { impliedStatus: impliedCaptureStatus } : {}),
814
831
  nextStatus: update.unifiedStatus,
815
832
  providerStatus: update.providerStatus ?? null,
816
833
  eventType: update.webhookEvent?.eventType ?? null,
@@ -42,3 +42,22 @@ export function canApplyManualAction(action: ManualGatewayAction, from: UnifiedP
42
42
  if (targets.includes(from)) return true
43
43
  return targets.some((target) => isValidTransition(from, target))
44
44
  }
45
+
46
+ const REFUND_STATUSES: Set<UnifiedPaymentStatus> = new Set(['refunded', 'partially_refunded'])
47
+
48
+ /**
49
+ * A provider-confirmed refund proves the payment was captured. When a refund status reaches a
50
+ * transaction whose capture was not recorded yet (the capture webhook is late, or was never
51
+ * delivered), the transaction passes through `captured` on its way to the refund status instead
52
+ * of dropping the refund. Returns that intermediate status, or `null` when no bridge applies.
53
+ */
54
+ export function resolveImpliedCaptureStatus(
55
+ from: UnifiedPaymentStatus,
56
+ to: UnifiedPaymentStatus,
57
+ ): UnifiedPaymentStatus | null {
58
+ if (!REFUND_STATUSES.has(to)) return null
59
+ if (isValidTransition(from, to)) return null
60
+ if (!isValidTransition(from, 'captured')) return null
61
+ if (!isValidTransition('captured', to)) return null
62
+ return 'captured'
63
+ }
@@ -103,7 +103,7 @@ export async function processPaymentGatewayWebhookJob(
103
103
  }
104
104
 
105
105
  const providerStatus = typeof event.data.status === 'string' ? event.data.status : ''
106
- const unifiedStatus = adapter.mapStatus(providerStatus, event.eventType)
106
+ const unifiedStatus = adapter.mapStatus(providerStatus, event.eventType, { ...event, timestamp: new Date(event.timestamp) })
107
107
  await writeTransactionLog(integrationLogService, providerKey, scope, transaction.id, 'info', 'Payment gateway webhook received', {
108
108
  eventType: event.eventType,
109
109
  providerStatus,
@@ -17,6 +17,7 @@ export const notificationTypes: NotificationTypeDefinition[] = [
17
17
  variant: 'default',
18
18
  icon: 'check',
19
19
  commandId: 'staff.leave-requests.accept',
20
+ requiredFeatures: ['staff.leave_requests.manage'],
20
21
  },
21
22
  {
22
23
  id: 'reject',
@@ -24,6 +25,7 @@ export const notificationTypes: NotificationTypeDefinition[] = [
24
25
  variant: 'destructive',
25
26
  icon: 'x',
26
27
  commandId: 'staff.leave-requests.reject',
28
+ requiredFeatures: ['staff.leave_requests.manage'],
27
29
  },
28
30
  ],
29
31
  primaryActionId: 'approve',
@@ -100,7 +100,7 @@ Definition → startWorkflow() → Instance → executeWorkflow() loop
100
100
  | `UPDATE_ENTITY` | Mutate an entity via the command bus. `CommandBus.execute` does not declare a signal; a write that has already begun cannot be cancelled on timeout. |
101
101
  | `EMIT_EVENT` | Emit a domain event to the event bus. `eventBus.emitEvent` does not declare a signal; an emit that has already begun cannot be cancelled on timeout. |
102
102
  | `EXECUTE_FUNCTION` | Run a registered custom function. Functions receive `(args, context, signal?: AbortSignal)` — the third arg is additive; existing two-arg functions are unaffected. |
103
- | `WAIT` | Delay execution for a configured duration |
103
+ | `WAIT` | Delay execution for a configured duration. A sync WAIT over `MAX_INLINE_WAIT_MS` (60 s) that ENDS its transition is queued like an async one (`executeActivities` `queueLongInlineWaits`, opted into by the transition handler only — the AUTOMATED-step caller does not park on a queued result). An in-process WAIT never hands `setTimeout` more than `MAX_TIMER_DELAY_MS` (2^31−1 ms): Node would fire it after 1 ms. |
104
104
  | `SET_VARIABLE` | Write values into workflow context at dot paths (assignments land at top-level context, not namespaced under the activity) |
105
105
 
106
106
  ## Context Backbone (contextSchema · ledger · picker · samples · test step)
@@ -934,7 +934,7 @@ Treat a regression here as a broken feature, not a nit.
934
934
  | Variable | Effect | Default |
935
935
  |----------|--------|---------|
936
936
  | `OM_WORKFLOWS_ALLOW_PRIVATE_URLS` | When `1`/`true`/`yes`, bypasses the SSRF guard in `CALL_WEBHOOK` so workflow authors can hit `localhost`, RFC1918, and `.internal` targets. For dev only — MUST remain unset in production. | unset (guard enforced) |
937
- | `OM_WORKFLOWS_MAX_CONDITION_ATTEMPTS` | Hard cap on WAIT_FOR_CONDITION poll re-enqueues; reaching it forces `CONDITION_TIMED_OUT` so a never-satisfiable predicate cannot saturate the queue. | `1000` |
937
+ | `OM_WORKFLOWS_MAX_CONDITION_ATTEMPTS` | Floor of the cap on WAIT_FOR_CONDITION poll re-enqueues; reaching the cap forces `CONDITION_TIMED_OUT` so a never-satisfiable predicate cannot saturate the queue. Each waiter's cap is raised to the polls its own timeout needs (`resolveConditionAttemptCap`: `timeoutMs / pollIntervalMs + 1`), so a long timeout is never cut short by the floor. | `1000` |
938
938
  | `OM_WORKFLOWS_ENV_INTERPOLATION_ALLOWLIST` | Comma-separated non-secret process env keys allowed for `{{env.*}}` interpolation in workflow activity config. `APP_URL` is always allowed. Never include secrets. | unset (`APP_URL` only) |
939
939
 
940
940
  ## DI Services
@@ -675,6 +675,12 @@ export class StepInstance {
675
675
  @Property({ name: 'exited_at', type: Date, nullable: true })
676
676
  exitedAt?: Date | null
677
677
 
678
+ /**
679
+ * @deprecated Written on the COMPLETED path only and `null` for any step that
680
+ * lasted 2^31 ms (~24.86 days) or longer, which a Postgres `integer` cannot
681
+ * hold. Derive the duration from `exitedAt - enteredAt` instead; the column
682
+ * is scheduled for removal no earlier than 0.11.0 (see UPGRADE_NOTES.md).
683
+ */
678
684
  @Property({ name: 'execution_time_ms', type: 'integer', nullable: true })
679
685
  executionTimeMs?: number | null
680
686
 
@@ -62,7 +62,7 @@ import './activity-registry-bootstrap'
62
62
  import { bindActivityExecutor } from './activity-types'
63
63
  import { getActivityType } from './activity-registry'
64
64
  import { logWorkflowEvent } from './event-logger'
65
- import { calculateWaitDelayMs, parseDuration } from './duration'
65
+ import { MAX_INLINE_WAIT_MS, assertInProcessWaitDelay, calculateWaitDelayMs, parseDuration } from './duration'
66
66
 
67
67
  export { calculateWaitDelayMs } from './duration'
68
68
  import { resolveActivityTimeoutMs } from './activityTimeoutFields'
@@ -683,6 +683,31 @@ export async function executeActivity(
683
683
  }
684
684
  }
685
685
 
686
+ export interface ExecuteActivitiesOptions {
687
+ /**
688
+ * Hand a synchronous activity with a long `enqueueDelayMs` (a WAIT over
689
+ * `MAX_INLINE_WAIT_MS`) to the activity queue instead of sleeping in-process.
690
+ * Only the transition's LAST activity qualifies, so nothing authored after the
691
+ * wait can run before it elapses. Only callers that park on an `async` result
692
+ * (the transition handler) may opt in — a caller that does not would let the
693
+ * run move on without waiting at all.
694
+ */
695
+ queueLongInlineWaits?: boolean
696
+ }
697
+
698
+ function isLongInlineWait(activity: ActivityDefinition, context: ActivityContext): boolean {
699
+ const entry = getActivityType(activity.activityType)
700
+ if (!entry?.enqueueDelayMs || entry.async.capable === false) return false
701
+ try {
702
+ const delayMs = entry.enqueueDelayMs(
703
+ interpolateActivityConfig(activity.config, context, activity.activityType, activity.activityName)
704
+ )
705
+ return delayMs !== null && delayMs > MAX_INLINE_WAIT_MS
706
+ } catch {
707
+ return false
708
+ }
709
+ }
710
+
686
711
  /**
687
712
  * Execute multiple activities in sequence
688
713
  * Supports both synchronous and asynchronous (queued) execution
@@ -697,7 +722,8 @@ export async function executeActivities(
697
722
  em: EntityManager,
698
723
  container: AwilixContainer,
699
724
  activities: ActivityDefinition[],
700
- context: ActivityContext
725
+ context: ActivityContext,
726
+ options: ExecuteActivitiesOptions = {}
701
727
  ): Promise<ActivityExecutionResult[]> {
702
728
  const results: ActivityExecutionResult[] = []
703
729
 
@@ -709,9 +735,15 @@ export async function executeActivities(
709
735
 
710
736
  for (let i = 0; i < activities.length; i++) {
711
737
  const activity = activities[i]
738
+ const isLastActivity = i === activities.length - 1
739
+ const queueAsLongWait =
740
+ !activity.async &&
741
+ options.queueLongInlineWaits === true &&
742
+ isLastActivity &&
743
+ isLongInlineWait(activity, context)
712
744
 
713
745
  // Check if activity should run async
714
- if (activity.async && !forceSynchronous) {
746
+ if ((activity.async || queueAsLongWait) && !forceSynchronous) {
715
747
  // Enqueue for background execution
716
748
  const jobId = await enqueueActivity(em, activity, context)
717
749
 
@@ -1389,7 +1421,7 @@ export async function executeFunction(
1389
1421
  * this handler returns immediately when called from the worker
1390
1422
  */
1391
1423
  export async function executeWait(config: any): Promise<any> {
1392
- const durationMs = calculateWaitDelayMs(config)
1424
+ const durationMs = assertInProcessWaitDelay(calculateWaitDelayMs(config))
1393
1425
 
1394
1426
  // In sync mode, actually sleep for the duration
1395
1427
  // In async mode (called from worker), the delay already happened via queue scheduling
@@ -23,7 +23,7 @@
23
23
  import { z, type ZodTypeAny } from 'zod'
24
24
  import { commandRegistry } from '@open-mercato/shared/lib/commands/registry'
25
25
  import { getActivityType, registerActivityType } from './activity-registry'
26
- import { calculateWaitDelayMs } from './duration'
26
+ import { assertInProcessWaitDelay, calculateWaitDelayMs } from './duration'
27
27
  import {
28
28
  callApiConfigSchema,
29
29
  callWebhookConfigSchema,
@@ -342,7 +342,7 @@ export function registerBuiltinActivityTypes(): void {
342
342
  { id: 'until', component: 'datetime' },
343
343
  ],
344
344
  execute: (config) => {
345
- const durationMs = calculateWaitDelayMs(config)
345
+ const durationMs = assertInProcessWaitDelay(calculateWaitDelayMs(config))
346
346
  return new Promise((resolve) => {
347
347
  setTimeout(() => resolve({ waited: true, durationMs }), durationMs)
348
348
  })