@open-mercato/core 0.6.3-develop.3876.1.d40fe4ec2d → 0.6.3-develop.3881.1.0b590ac4eb

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 (107) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/attachments/api/file/[id]/route.js +7 -2
  3. package/dist/modules/attachments/api/file/[id]/route.js.map +2 -2
  4. package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js +7 -4
  5. package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js.map +2 -2
  6. package/dist/modules/audit_logs/services/accessLogService.js +127 -8
  7. package/dist/modules/audit_logs/services/accessLogService.js.map +2 -2
  8. package/dist/modules/auth/di.js +17 -3
  9. package/dist/modules/auth/di.js.map +2 -2
  10. package/dist/modules/auth/services/rbacDefaultCache.js +110 -0
  11. package/dist/modules/auth/services/rbacDefaultCache.js.map +7 -0
  12. package/dist/modules/currencies/api/currencies/route.js +3 -4
  13. package/dist/modules/currencies/api/currencies/route.js.map +2 -2
  14. package/dist/modules/currencies/api/exchange-rates/route.js +3 -4
  15. package/dist/modules/currencies/api/exchange-rates/route.js.map +2 -2
  16. package/dist/modules/customers/api/people/route.js +26 -24
  17. package/dist/modules/customers/api/people/route.js.map +2 -2
  18. package/dist/modules/directory/subscribers/invalidateOrgScopeCache.js +26 -0
  19. package/dist/modules/directory/subscribers/invalidateOrgScopeCache.js.map +7 -0
  20. package/dist/modules/directory/utils/organizationScope.js +85 -0
  21. package/dist/modules/directory/utils/organizationScope.js.map +2 -2
  22. package/dist/modules/workflows/backend/definitions/[id]/page.js +2 -1
  23. package/dist/modules/workflows/backend/definitions/[id]/page.js.map +2 -2
  24. package/dist/modules/workflows/backend/definitions/create/page.js +4 -2
  25. package/dist/modules/workflows/backend/definitions/create/page.js.map +2 -2
  26. package/dist/modules/workflows/backend/definitions/visual-editor/page.js +20 -3
  27. package/dist/modules/workflows/backend/definitions/visual-editor/page.js.map +2 -2
  28. package/dist/modules/workflows/components/ActivitiesEditor.js +34 -1
  29. package/dist/modules/workflows/components/ActivitiesEditor.js.map +2 -2
  30. package/dist/modules/workflows/components/NodeEditDialog.js +153 -17
  31. package/dist/modules/workflows/components/NodeEditDialog.js.map +2 -2
  32. package/dist/modules/workflows/components/StepsEditor.js +31 -0
  33. package/dist/modules/workflows/components/StepsEditor.js.map +2 -2
  34. package/dist/modules/workflows/components/WorkflowGraph.js +3 -2
  35. package/dist/modules/workflows/components/WorkflowGraph.js.map +2 -2
  36. package/dist/modules/workflows/components/nodes/WaitForTimerNode.js +54 -0
  37. package/dist/modules/workflows/components/nodes/WaitForTimerNode.js.map +7 -0
  38. package/dist/modules/workflows/components/nodes/index.js +3 -1
  39. package/dist/modules/workflows/components/nodes/index.js.map +2 -2
  40. package/dist/modules/workflows/data/validators.js +117 -0
  41. package/dist/modules/workflows/data/validators.js.map +2 -2
  42. package/dist/modules/workflows/di.js +5 -1
  43. package/dist/modules/workflows/di.js.map +2 -2
  44. package/dist/modules/workflows/lib/activity-executor.js +42 -1
  45. package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
  46. package/dist/modules/workflows/lib/activity-queue-types.js.map +2 -2
  47. package/dist/modules/workflows/lib/activity-worker-handler.js +24 -0
  48. package/dist/modules/workflows/lib/activity-worker-handler.js.map +2 -2
  49. package/dist/modules/workflows/lib/duration.js +32 -0
  50. package/dist/modules/workflows/lib/duration.js.map +7 -0
  51. package/dist/modules/workflows/lib/event-logger.js +1 -0
  52. package/dist/modules/workflows/lib/event-logger.js.map +2 -2
  53. package/dist/modules/workflows/lib/format-validation-error.js +12 -0
  54. package/dist/modules/workflows/lib/format-validation-error.js.map +7 -0
  55. package/dist/modules/workflows/lib/graph-utils.js +6 -3
  56. package/dist/modules/workflows/lib/graph-utils.js.map +2 -2
  57. package/dist/modules/workflows/lib/node-type-icons.js +9 -5
  58. package/dist/modules/workflows/lib/node-type-icons.js.map +2 -2
  59. package/dist/modules/workflows/lib/signal-handler.js +55 -23
  60. package/dist/modules/workflows/lib/signal-handler.js.map +2 -2
  61. package/dist/modules/workflows/lib/step-handler.js +79 -29
  62. package/dist/modules/workflows/lib/step-handler.js.map +2 -2
  63. package/dist/modules/workflows/lib/timer-handler.js +159 -0
  64. package/dist/modules/workflows/lib/timer-handler.js.map +7 -0
  65. package/dist/modules/workflows/lib/workflow-executor.js +1 -1
  66. package/dist/modules/workflows/lib/workflow-executor.js.map +2 -2
  67. package/dist/modules/workflows/workers/workflow-activities.worker.js +20 -4
  68. package/dist/modules/workflows/workers/workflow-activities.worker.js.map +2 -2
  69. package/package.json +7 -7
  70. package/src/modules/attachments/api/file/[id]/route.ts +7 -2
  71. package/src/modules/attachments/api/image/[id]/[[...slug]]/route.ts +7 -4
  72. package/src/modules/audit_logs/services/accessLogService.ts +179 -15
  73. package/src/modules/auth/di.ts +26 -3
  74. package/src/modules/auth/services/rbacDefaultCache.ts +145 -0
  75. package/src/modules/currencies/api/currencies/route.ts +3 -4
  76. package/src/modules/currencies/api/exchange-rates/route.ts +3 -4
  77. package/src/modules/customers/api/people/route.ts +27 -25
  78. package/src/modules/directory/subscribers/invalidateOrgScopeCache.ts +39 -0
  79. package/src/modules/directory/utils/organizationScope.ts +121 -0
  80. package/src/modules/workflows/backend/definitions/[id]/page.tsx +3 -2
  81. package/src/modules/workflows/backend/definitions/create/page.tsx +4 -2
  82. package/src/modules/workflows/backend/definitions/visual-editor/page.tsx +18 -1
  83. package/src/modules/workflows/components/ActivitiesEditor.tsx +40 -0
  84. package/src/modules/workflows/components/NodeEditDialog.tsx +218 -30
  85. package/src/modules/workflows/components/StepsEditor.tsx +36 -0
  86. package/src/modules/workflows/components/WorkflowGraph.tsx +2 -1
  87. package/src/modules/workflows/components/nodes/WaitForTimerNode.tsx +70 -0
  88. package/src/modules/workflows/components/nodes/index.ts +3 -0
  89. package/src/modules/workflows/data/validators.ts +121 -0
  90. package/src/modules/workflows/di.ts +4 -0
  91. package/src/modules/workflows/i18n/de.json +10 -1
  92. package/src/modules/workflows/i18n/en.json +10 -1
  93. package/src/modules/workflows/i18n/es.json +10 -1
  94. package/src/modules/workflows/i18n/pl.json +10 -1
  95. package/src/modules/workflows/lib/activity-executor.ts +86 -2
  96. package/src/modules/workflows/lib/activity-queue-types.ts +18 -11
  97. package/src/modules/workflows/lib/activity-worker-handler.ts +29 -0
  98. package/src/modules/workflows/lib/duration.ts +51 -0
  99. package/src/modules/workflows/lib/event-logger.ts +1 -0
  100. package/src/modules/workflows/lib/format-validation-error.ts +30 -0
  101. package/src/modules/workflows/lib/graph-utils.ts +3 -0
  102. package/src/modules/workflows/lib/node-type-icons.ts +6 -2
  103. package/src/modules/workflows/lib/signal-handler.ts +62 -24
  104. package/src/modules/workflows/lib/step-handler.ts +107 -50
  105. package/src/modules/workflows/lib/timer-handler.ts +213 -0
  106. package/src/modules/workflows/lib/workflow-executor.ts +1 -1
  107. package/src/modules/workflows/workers/workflow-activities.worker.ts +33 -7
@@ -427,37 +427,39 @@ const crud = makeCrudRoute({
427
427
  tenantId: ctx.auth?.tenantId ?? null,
428
428
  organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
429
429
  }
430
- const entities = await findWithDecryption(
431
- em,
432
- CustomerEntity,
433
- {
434
- id: { $in: ids },
435
- deletedAt: null,
436
- kind: 'person',
437
- } as FilterQuery<CustomerEntity>,
438
- undefined,
439
- decryptionScope,
440
- )
441
- const entitiesById = new Map<string, CustomerEntity>()
442
- for (const entity of entities) {
443
- entitiesById.set(entity.id, entity)
444
- }
445
-
446
- const where: Record<string, unknown> = {
430
+ const profileWhere: Record<string, unknown> = {
447
431
  entity: { $in: ids },
448
432
  tenantId: ctx.auth?.tenantId ?? null,
449
433
  }
450
434
  if (ctx.selectedOrganizationId) {
451
- where.organizationId = ctx.selectedOrganizationId
435
+ profileWhere.organizationId = ctx.selectedOrganizationId
452
436
  }
453
437
 
454
- const profiles = await findWithDecryption(
455
- em,
456
- CustomerPersonProfile,
457
- where as FilterQuery<CustomerPersonProfile>,
458
- { populate: ['entity', 'company'] },
459
- decryptionScope,
460
- )
438
+ const [entities, profiles] = await Promise.all([
439
+ findWithDecryption(
440
+ em,
441
+ CustomerEntity,
442
+ {
443
+ id: { $in: ids },
444
+ deletedAt: null,
445
+ kind: 'person',
446
+ } as FilterQuery<CustomerEntity>,
447
+ undefined,
448
+ decryptionScope,
449
+ ),
450
+ findWithDecryption(
451
+ em,
452
+ CustomerPersonProfile,
453
+ profileWhere as FilterQuery<CustomerPersonProfile>,
454
+ { populate: ['entity', 'company'] },
455
+ decryptionScope,
456
+ ),
457
+ ])
458
+
459
+ const entitiesById = new Map<string, CustomerEntity>()
460
+ for (const entity of entities) {
461
+ entitiesById.set(entity.id, entity)
462
+ }
461
463
 
462
464
  const profilesByEntityId = new Map<string, CustomerPersonProfile>()
463
465
  for (const profile of profiles) {
@@ -0,0 +1,39 @@
1
+ // Invalidate the OrganizationScope cache when an organization mutates.
2
+ //
3
+ // resolveOrganizationScopeForRequest caches its result with a short TTL
4
+ // (default 60s, OM_ORG_SCOPE_CACHE_TTL_MS). When an organization is
5
+ // created/updated/deleted, the cached scope for users of the affected
6
+ // tenant may be stale (visibility set or descendant tree changed). We
7
+ // drop every cache entry tagged for that tenant; the TTL is the backstop
8
+ // for races where the event fires after a request reads the cache.
9
+
10
+ type CacheService = {
11
+ deleteByTags(tags: string[]): Promise<number>
12
+ }
13
+
14
+ export const metadata = {
15
+ event: 'directory.organization.*',
16
+ persistent: false,
17
+ id: 'directory:invalidate-org-scope-cache',
18
+ }
19
+
20
+ export default async function handle(
21
+ payload: unknown,
22
+ ctx: { resolve: <T = unknown>(name: string) => T },
23
+ ): Promise<void> {
24
+ const data = (payload ?? {}) as Record<string, unknown>
25
+ const tenantId = typeof data.tenantId === 'string' ? data.tenantId : null
26
+ if (!tenantId) return
27
+ let cache: CacheService | null = null
28
+ try {
29
+ cache = ctx.resolve<CacheService>('cache')
30
+ } catch {
31
+ return
32
+ }
33
+ if (!cache) return
34
+ try {
35
+ await cache.deleteByTags([`org-scope:tenant:${tenantId}`])
36
+ } catch {
37
+ // best-effort; TTL is the backstop.
38
+ }
39
+ }
@@ -5,6 +5,7 @@ import { Organization } from '@open-mercato/core/modules/directory/data/entities
5
5
  import { isAllOrganizationsSelection } from '@open-mercato/core/modules/directory/constants'
6
6
  import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
7
7
  import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
8
+ import type { CacheStrategy } from '@open-mercato/cache'
8
9
  import { parseSelectedOrganizationCookie, parseSelectedTenantCookie } from './scopeCookies'
9
10
 
10
11
  export { parseSelectedOrganizationCookie, parseSelectedTenantCookie }
@@ -16,6 +17,90 @@ export type OrganizationScope = {
16
17
  tenantId: string | null
17
18
  }
18
19
 
20
+ // Phase 4 — short-TTL cache for resolveOrganizationScopeForRequest.
21
+ // OrganizationScope is a pure function of (userId, tenantId, selectedOrgId,
22
+ // requestedTenant) between membership changes; caching it bypasses 1
23
+ // SELECT on `organizations` per CRUD request. TTL is short (60s default)
24
+ // to keep staleness bounded for membership/visibility changes. Tag-based
25
+ // invalidation kicks the cache when user_organizations or organizations
26
+ // mutate (wired via invalidateOrganizationScopeCacheFor).
27
+ const ORG_SCOPE_CACHE_KEY_PREFIX = 'org-scope'
28
+ // Phase 4 default-off until the same readiness probe (`GET /api/customers/people`)
29
+ // stays green with the cache layer engaged. Set `OM_ORG_SCOPE_CACHE_TTL_MS=60000`
30
+ // (or any positive integer) to opt in once cross-request safety is re-verified.
31
+ const ORG_SCOPE_DEFAULT_TTL_MS = 0
32
+
33
+ function resolveOrgScopeTtlMs(): number {
34
+ const raw = process.env.OM_ORG_SCOPE_CACHE_TTL_MS
35
+ if (raw === undefined) return ORG_SCOPE_DEFAULT_TTL_MS
36
+ const parsed = Number(raw)
37
+ if (!Number.isFinite(parsed) || parsed < 0) return ORG_SCOPE_DEFAULT_TTL_MS
38
+ return parsed
39
+ }
40
+
41
+ function buildOrgScopeCacheKey(parts: {
42
+ userId: string
43
+ effectiveTenantId: string
44
+ selectedOrgId: string | null
45
+ requestedTenantId: string | null
46
+ }): string {
47
+ const selected = parts.selectedOrgId ?? 'none'
48
+ const requested = parts.requestedTenantId ?? 'none'
49
+ return `${ORG_SCOPE_CACHE_KEY_PREFIX}:${parts.userId}:${parts.effectiveTenantId}:${selected}:${requested}`
50
+ }
51
+
52
+ function buildOrgScopeCacheTags(parts: { userId: string; effectiveTenantId: string }): string[] {
53
+ return [
54
+ `${ORG_SCOPE_CACHE_KEY_PREFIX}:user:${parts.userId}`,
55
+ `${ORG_SCOPE_CACHE_KEY_PREFIX}:tenant:${parts.effectiveTenantId}`,
56
+ ]
57
+ }
58
+
59
+ function isValidCachedScope(value: unknown): value is OrganizationScope {
60
+ if (typeof value !== 'object' || value === null) return false
61
+ const record = value as Partial<OrganizationScope>
62
+ const idOk = (v: unknown) => v === null || typeof v === 'string'
63
+ const arrOk = (v: unknown) => v === null || (Array.isArray(v) && v.every((entry) => typeof entry === 'string'))
64
+ return idOk(record.selectedId) && idOk(record.tenantId) && arrOk(record.filterIds) && arrOk(record.allowedIds)
65
+ }
66
+
67
+ function resolveCacheFromContainer(container: AwilixContainer | null | undefined): CacheStrategy | null {
68
+ if (!container) return null
69
+ try {
70
+ const c = container.resolve('cache') as CacheStrategy | undefined
71
+ if (c && typeof c.get === 'function' && typeof c.set === 'function') return c
72
+ } catch {
73
+ return null
74
+ }
75
+ return null
76
+ }
77
+
78
+ export async function invalidateOrganizationScopeCacheForUser(
79
+ container: AwilixContainer,
80
+ userId: string,
81
+ ): Promise<void> {
82
+ const cache = resolveCacheFromContainer(container)
83
+ if (!cache?.deleteByTags) return
84
+ try {
85
+ await cache.deleteByTags([`${ORG_SCOPE_CACHE_KEY_PREFIX}:user:${userId}`])
86
+ } catch (err) {
87
+ console.warn('[org-scope:cache] invalidate user failed', err)
88
+ }
89
+ }
90
+
91
+ export async function invalidateOrganizationScopeCacheForTenant(
92
+ container: AwilixContainer,
93
+ tenantId: string,
94
+ ): Promise<void> {
95
+ const cache = resolveCacheFromContainer(container)
96
+ if (!cache?.deleteByTags) return
97
+ try {
98
+ await cache.deleteByTags([`${ORG_SCOPE_CACHE_KEY_PREFIX}:tenant:${tenantId}`])
99
+ } catch (err) {
100
+ console.warn('[org-scope:cache] invalidate tenant failed', err)
101
+ }
102
+ }
103
+
19
104
  function normalizeOrganizationId(value: unknown): string | null {
20
105
  if (typeof value !== 'string') return null
21
106
  const trimmed = value.trim()
@@ -270,6 +355,31 @@ export async function resolveOrganizationScopeForRequest({
270
355
  }
271
356
 
272
357
  const rawSelected = selectedId !== undefined ? selectedId : (request ? getSelectedOrganizationFromRequest(request) : null)
358
+ const normalizedSelectedId = typeof rawSelected === 'string' && rawSelected.trim().length > 0
359
+ ? rawSelected.trim()
360
+ : null
361
+
362
+ const userId = typeof auth.sub === 'string' && auth.sub.length > 0 ? auth.sub : null
363
+ const ttlMs = resolveOrgScopeTtlMs()
364
+ const cache = ttlMs > 0 ? resolveCacheFromContainer(container) : null
365
+ const cacheKey = userId
366
+ ? buildOrgScopeCacheKey({
367
+ userId,
368
+ effectiveTenantId,
369
+ selectedOrgId: normalizedSelectedId,
370
+ requestedTenantId: requestedTenantId ?? null,
371
+ })
372
+ : null
373
+
374
+ if (cache && cacheKey && typeof cache.get === 'function') {
375
+ try {
376
+ const cached = await cache.get(cacheKey)
377
+ if (isValidCachedScope(cached)) return cached
378
+ } catch (err) {
379
+ console.warn('[org-scope:cache] read failed', err)
380
+ }
381
+ }
382
+
273
383
  const baseScope = await resolveOrganizationScope({
274
384
  em,
275
385
  rbac,
@@ -278,6 +388,17 @@ export async function resolveOrganizationScopeForRequest({
278
388
  tenantId: effectiveTenantId,
279
389
  })
280
390
 
391
+ if (cache && cacheKey && userId && typeof cache.set === 'function') {
392
+ try {
393
+ await cache.set(cacheKey, baseScope, {
394
+ ttl: ttlMs,
395
+ tags: buildOrgScopeCacheTags({ userId, effectiveTenantId }),
396
+ })
397
+ } catch (err) {
398
+ console.warn('[org-scope:cache] write failed', err)
399
+ }
400
+ }
401
+
281
402
  return baseScope
282
403
  }
283
404
 
@@ -10,6 +10,7 @@ import { Button } from '@open-mercato/ui/primitives/button'
10
10
  import { Alert, AlertDescription } from '@open-mercato/ui/primitives/alert'
11
11
  import { apiFetch } from '@open-mercato/ui/backend/utils/api'
12
12
  import { readJsonSafe } from '@open-mercato/ui/backend/utils/serverErrors'
13
+ import { formatWorkflowValidationError } from '../../../lib/format-validation-error'
13
14
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
14
15
  import { useT } from '@open-mercato/shared/lib/i18n/context'
15
16
  import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
@@ -94,8 +95,8 @@ export default function EditWorkflowDefinitionPage() {
94
95
  body: JSON.stringify(payload),
95
96
  })
96
97
  if (!response.ok) {
97
- const errorBody = await readJsonSafe<{ error?: string }>(response, null)
98
- throw new Error(errorBody?.error || t('workflows.errors.updateFailed'))
98
+ const errorBody = await readJsonSafe<{ error?: string; details?: Array<{ path?: Array<string | number>; message?: string }> }>(response, null)
99
+ throw new Error(formatWorkflowValidationError(errorBody, t('workflows.errors.updateFailed')))
99
100
  }
100
101
  return response
101
102
  },
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'
5
5
  import { Page, PageBody } from '@open-mercato/ui/backend/Page'
6
6
  import { CrudForm } from '@open-mercato/ui/backend/CrudForm'
7
7
  import { apiFetch } from '@open-mercato/ui/backend/utils/api'
8
+ import { readJsonSafe } from '@open-mercato/ui/backend/utils/serverErrors'
8
9
  import { useT } from '@open-mercato/shared/lib/i18n/context'
9
10
  import {
10
11
  workflowDefinitionFormSchema,
@@ -18,6 +19,7 @@ import { StepsEditor } from '../../../components/StepsEditor'
18
19
  import { TransitionsEditor } from '../../../components/TransitionsEditor'
19
20
  import { Alert, AlertDescription, AlertTitle } from '@open-mercato/ui/primitives/alert'
20
21
  import { Zap } from 'lucide-react'
22
+ import { formatWorkflowValidationError } from '../../../lib/format-validation-error'
21
23
 
22
24
  export default function CreateWorkflowDefinitionPage() {
23
25
  const router = useRouter()
@@ -33,8 +35,8 @@ export default function CreateWorkflowDefinitionPage() {
33
35
  })
34
36
 
35
37
  if (!response.ok) {
36
- const error = await response.json()
37
- throw new Error(error.error || t('workflows.errors.createFailed'))
38
+ const errorBody = await readJsonSafe<{ error?: string; details?: Array<{ path?: Array<string | number>; message?: string }> }>(response, null)
39
+ throw new Error(formatWorkflowValidationError(errorBody, t('workflows.errors.createFailed')))
38
40
  }
39
41
 
40
42
  router.push('/backend/definitions')
@@ -989,7 +989,7 @@ export default function VisualEditorPage() {
989
989
  <p className="mb-3 text-xs text-muted-foreground">{t('workflows.visualEditor.tapToAdd')}</p>
990
990
 
991
991
  <div className="flex gap-2 overflow-x-auto pb-1">
992
- {(['start', 'userTask', 'automated', 'waitForSignal', 'subWorkflow', 'end'] as const).map((nodeType) => {
992
+ {(['start', 'userTask', 'automated', 'waitForSignal', 'waitForTimer', 'subWorkflow', 'end'] as const).map((nodeType) => {
993
993
  const Icon = NODE_TYPE_ICONS[nodeType]
994
994
  return (
995
995
  <button
@@ -1078,6 +1078,21 @@ export default function VisualEditorPage() {
1078
1078
  <div className="mt-0.5 text-xs text-muted-foreground">{NODE_TYPE_LABELS.waitForSignal.description}</div>
1079
1079
  </button>
1080
1080
 
1081
+ {/* WAIT_FOR_TIMER Step */}
1082
+ <button
1083
+ onClick={() => handleAddNode('waitForTimer')}
1084
+ className="group relative w-full cursor-pointer rounded-xl border-2 border-border bg-background px-4 py-3 text-left transition-all hover:border-muted-foreground/30 hover:shadow-md"
1085
+ >
1086
+ <div className={`absolute right-2 top-2 ${NODE_TYPE_COLORS.waitForTimer} opacity-60 transition-opacity group-hover:opacity-100`}>
1087
+ {(() => {
1088
+ const Icon = NODE_TYPE_ICONS.waitForTimer
1089
+ return <Icon className="h-4 w-4" />
1090
+ })()}
1091
+ </div>
1092
+ <div className="text-sm font-semibold text-foreground">{NODE_TYPE_LABELS.waitForTimer.title}</div>
1093
+ <div className="mt-0.5 text-xs text-muted-foreground">{NODE_TYPE_LABELS.waitForTimer.description}</div>
1094
+ </button>
1095
+
1081
1096
  {/* SUB_WORKFLOW Step */}
1082
1097
  <button
1083
1098
  onClick={() => handleAddNode('subWorkflow')}
@@ -1182,6 +1197,7 @@ function getDefaultLabel(nodeType: string): string {
1182
1197
  automated: 'New Automated Task',
1183
1198
  decision: 'Decision Point',
1184
1199
  waitForSignal: 'Wait for Signal',
1200
+ waitForTimer: 'Wait for Timer',
1185
1201
  }
1186
1202
  return labels[nodeType] || 'New Step'
1187
1203
  }
@@ -1194,6 +1210,7 @@ function getDefaultBadge(nodeType: string): string {
1194
1210
  automated: 'Automated',
1195
1211
  decision: 'Decision',
1196
1212
  waitForSignal: 'Wait for Signal',
1213
+ waitForTimer: 'Wait for Timer',
1197
1214
  }
1198
1215
  return badges[nodeType] || 'Task'
1199
1216
  }
@@ -273,6 +273,45 @@ export function ActivitiesEditor({ value = [], onChange, error }: ActivitiesEdit
273
273
  </div>
274
274
  </div>
275
275
 
276
+ {activity.activityType === 'WAIT' && (
277
+ <div className="space-y-3">
278
+ <div>
279
+ <Label htmlFor={`activity-${index}-duration`} className="text-xs">
280
+ {t('workflows.activities.waitDuration')}
281
+ </Label>
282
+ <Input
283
+ id={`activity-${index}-duration`}
284
+ value={activity.config?.duration || ''}
285
+ onChange={(e) => updateActivity(index, 'config', { ...activity.config, duration: e.target.value, until: undefined })}
286
+ placeholder={t('workflows.activities.waitDurationPlaceholder')}
287
+ disabled={!!activity.config?.until}
288
+ className="mt-1"
289
+ />
290
+ <p className="text-xs text-muted-foreground mt-1">
291
+ {t('workflows.activities.waitDurationDescription')}
292
+ </p>
293
+ </div>
294
+ <div className="text-xs text-center text-muted-foreground">{t('workflows.activities.waitOr')}</div>
295
+ <div>
296
+ <Label htmlFor={`activity-${index}-until`} className="text-xs">
297
+ {t('workflows.activities.waitUntil')}
298
+ </Label>
299
+ <Input
300
+ id={`activity-${index}-until`}
301
+ type="datetime-local"
302
+ value={activity.config?.until ? activity.config.until.slice(0, 16) : ''}
303
+ onChange={(e) => updateActivity(index, 'config', { ...activity.config, until: e.target.value ? new Date(e.target.value).toISOString() : undefined, duration: undefined })}
304
+ disabled={!!activity.config?.duration}
305
+ className="mt-1"
306
+ />
307
+ <p className="text-xs text-muted-foreground mt-1">
308
+ {t('workflows.activities.waitUntilDescription')}
309
+ </p>
310
+ </div>
311
+ </div>
312
+ )}
313
+
314
+ {activity.activityType !== 'WAIT' && (
276
315
  <div>
277
316
  <Label htmlFor={`activity-${index}-config`} className="text-xs">
278
317
  {t('workflows.activities.config')} (JSON)
@@ -293,6 +332,7 @@ export function ActivitiesEditor({ value = [], onChange, error }: ActivitiesEdit
293
332
  className="mt-1 font-mono text-xs"
294
333
  />
295
334
  </div>
335
+ )}
296
336
  </div>
297
337
  </div>
298
338
  ))}