@open-mercato/core 0.6.7-develop.6676.1.7dad6df292 → 0.6.7-develop.6685.1.77c0a5591b

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 (71) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/generated/entities/gateway_payment_operation/index.js +2 -0
  3. package/dist/generated/entities/gateway_payment_operation/index.js.map +2 -2
  4. package/dist/generated/entities/gateway_transaction/index.js +2 -0
  5. package/dist/generated/entities/gateway_transaction/index.js.map +2 -2
  6. package/dist/generated/entity-fields-registry.js +2 -0
  7. package/dist/generated/entity-fields-registry.js.map +2 -2
  8. package/dist/modules/payment_gateways/api/capture/route.js +1 -1
  9. package/dist/modules/payment_gateways/api/capture/route.js.map +2 -2
  10. package/dist/modules/payment_gateways/data/entities.js +7 -0
  11. package/dist/modules/payment_gateways/data/entities.js.map +2 -2
  12. package/dist/modules/payment_gateways/lib/capture-ledger.js +183 -0
  13. package/dist/modules/payment_gateways/lib/capture-ledger.js.map +7 -0
  14. package/dist/modules/payment_gateways/lib/gateway-service.js +70 -8
  15. package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
  16. package/dist/modules/payment_gateways/lib/payment-operation-idempotency.js +1 -0
  17. package/dist/modules/payment_gateways/lib/payment-operation-idempotency.js.map +2 -2
  18. package/dist/modules/payment_gateways/migrations/Migration20260725101500_payment_gateways.js +16 -0
  19. package/dist/modules/payment_gateways/migrations/Migration20260725101500_payment_gateways.js.map +7 -0
  20. package/dist/modules/query_index/lib/indexer.js +12 -2
  21. package/dist/modules/query_index/lib/indexer.js.map +2 -2
  22. package/dist/modules/workflows/backend/definitions/visual-editor/page.js +5 -2
  23. package/dist/modules/workflows/backend/definitions/visual-editor/page.js.map +2 -2
  24. package/dist/modules/workflows/components/ActivitiesEditor.js +67 -11
  25. package/dist/modules/workflows/components/ActivitiesEditor.js.map +2 -2
  26. package/dist/modules/workflows/components/TransitionsEditor.js +205 -163
  27. package/dist/modules/workflows/components/TransitionsEditor.js.map +2 -2
  28. package/dist/modules/workflows/data/validators.js +34 -1
  29. package/dist/modules/workflows/data/validators.js.map +3 -3
  30. package/dist/modules/workflows/lib/activity-executor.js +18 -2
  31. package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
  32. package/dist/modules/workflows/lib/format-validation-error.js +46 -1
  33. package/dist/modules/workflows/lib/format-validation-error.js.map +2 -2
  34. package/generated/entities/gateway_payment_operation/index.ts +1 -0
  35. package/generated/entities/gateway_transaction/index.ts +1 -0
  36. package/generated/entity-fields-registry.ts +2 -0
  37. package/package.json +7 -7
  38. package/src/modules/attachments/i18n/de.json +12 -0
  39. package/src/modules/attachments/i18n/en.json +12 -0
  40. package/src/modules/attachments/i18n/es.json +12 -0
  41. package/src/modules/attachments/i18n/pl.json +12 -0
  42. package/src/modules/auth/i18n/de.json +1 -0
  43. package/src/modules/auth/i18n/en.json +1 -0
  44. package/src/modules/auth/i18n/es.json +1 -0
  45. package/src/modules/auth/i18n/pl.json +1 -0
  46. package/src/modules/catalog/i18n/de.json +18 -0
  47. package/src/modules/catalog/i18n/en.json +18 -0
  48. package/src/modules/catalog/i18n/es.json +18 -0
  49. package/src/modules/catalog/i18n/pl.json +18 -0
  50. package/src/modules/customers/i18n/de.json +44 -0
  51. package/src/modules/customers/i18n/en.json +44 -0
  52. package/src/modules/customers/i18n/es.json +44 -0
  53. package/src/modules/customers/i18n/pl.json +44 -0
  54. package/src/modules/payment_gateways/api/capture/route.ts +1 -1
  55. package/src/modules/payment_gateways/data/entities.ts +8 -2
  56. package/src/modules/payment_gateways/lib/capture-ledger.ts +260 -0
  57. package/src/modules/payment_gateways/lib/gateway-service.ts +83 -10
  58. package/src/modules/payment_gateways/lib/payment-operation-idempotency.ts +1 -0
  59. package/src/modules/payment_gateways/migrations/.snapshot-open-mercato.json +34 -0
  60. package/src/modules/payment_gateways/migrations/Migration20260725101500_payment_gateways.ts +21 -0
  61. package/src/modules/query_index/lib/indexer.ts +25 -2
  62. package/src/modules/workflows/backend/definitions/visual-editor/page.tsx +12 -3
  63. package/src/modules/workflows/components/ActivitiesEditor.tsx +96 -12
  64. package/src/modules/workflows/components/TransitionsEditor.tsx +75 -17
  65. package/src/modules/workflows/data/validators.ts +52 -1
  66. package/src/modules/workflows/i18n/de.json +2 -0
  67. package/src/modules/workflows/i18n/en.json +2 -0
  68. package/src/modules/workflows/i18n/es.json +2 -0
  69. package/src/modules/workflows/i18n/pl.json +2 -0
  70. package/src/modules/workflows/lib/activity-executor.ts +38 -3
  71. package/src/modules/workflows/lib/format-validation-error.ts +60 -0
@@ -17,10 +17,19 @@ import type { CredentialsService } from '../../integrations/lib/credentials-serv
17
17
  import type { IntegrationStateService } from '../../integrations/lib/state-service'
18
18
  import type { IntegrationLogService } from '../../integrations/lib/log-service'
19
19
  import { conflict } from '@open-mercato/shared/lib/crud/errors'
20
- import { GatewaySessionInitialization, GatewayTransaction } from '../data/entities'
20
+ import { GatewayPaymentOperation, GatewaySessionInitialization, GatewayTransaction } from '../data/entities'
21
21
  import { canApplyManualAction, isValidTransition, type ManualGatewayAction } from './status-machine'
22
22
  import { emitPaymentGatewayEvent } from '../events'
23
23
  import { readGatewayMetadata, readWebhookLog } from './transaction-fields'
24
+ import {
25
+ alignCapturedAmountWithStatus,
26
+ assertCaptureWithinRemaining,
27
+ formatAmountUnits,
28
+ parseAmountUnits,
29
+ releaseCaptureAmount,
30
+ reserveCaptureAmount,
31
+ settleCapturedAmount,
32
+ } from './capture-ledger'
24
33
  import {
25
34
  completePaymentOperation,
26
35
  failPaymentOperation,
@@ -45,12 +54,6 @@ function assertManualActionAllowed(action: ManualGatewayAction, transaction: Gat
45
54
  }
46
55
  }
47
56
 
48
- function assertCaptureAmountAllowed(amount: number | undefined, transaction: GatewayTransaction): void {
49
- if (amount !== undefined && amount > Number(transaction.amount)) {
50
- throw conflict(`Capture amount ${amount} exceeds authorized transaction amount ${transaction.amount}`)
51
- }
52
- }
53
-
54
57
  function applyAdapterResultStatus(
55
58
  action: ManualGatewayAction,
56
59
  transaction: GatewayTransaction,
@@ -199,6 +202,12 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
199
202
  payload: Record<string, unknown>
200
203
  scope: { organizationId: string; tenantId: string }
201
204
  assertInitialAllowed?: (transaction: GatewayTransaction) => void
205
+ beforeInvoke?: (context: { transaction: GatewayTransaction; operation: GatewayPaymentOperation }) => Promise<void>
206
+ releaseOnFailure?: (context: {
207
+ transaction: GatewayTransaction
208
+ operation: GatewayPaymentOperation
209
+ providerInvoked: boolean
210
+ }) => Promise<void>
202
211
  invoke: (context: {
203
212
  adapter: GatewayAdapter
204
213
  credentials: Record<string, unknown>
@@ -231,7 +240,17 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
231
240
  return prepared.result as unknown as TResult
232
241
  }
233
242
 
243
+ const operation = prepared.claim.record
244
+ // Everything after this flips to true is post-settlement: the provider moved the money and the
245
+ // completion transaction committed it. A later event or logging failure must not undo either,
246
+ // so the failure path below only runs while the operation can still be rolled back.
247
+ let settled = false
248
+ // Flips once the provider call returned, so the failure path can tell "the provider never
249
+ // moved the money" from "the provider moved it and only our bookkeeping failed" — the two
250
+ // cases need opposite rollback decisions.
251
+ let providerInvoked = false
234
252
  try {
253
+ await input.beforeInvoke?.({ transaction, operation })
235
254
  const { adapter, credentials } = await resolveAdapterAndCredentials(
236
255
  transaction.providerKey,
237
256
  { organizationId: transaction.organizationId, tenantId: transaction.tenantId },
@@ -242,6 +261,7 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
242
261
  transaction,
243
262
  idempotencyKey: prepared.claim.providerIdempotencyKey,
244
263
  })
264
+ providerInvoked = true
245
265
  const statusChanged = await completePaymentOperation(
246
266
  em,
247
267
  prepared.claim,
@@ -253,6 +273,7 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
253
273
  return changed
254
274
  },
255
275
  )
276
+ settled = true
256
277
  if (statusChanged) {
257
278
  await emitStatusEvent(result.status, {
258
279
  transactionId: transaction.id,
@@ -265,7 +286,12 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
265
286
  await input.afterCommit(transaction, result)
266
287
  return result
267
288
  } catch (error: unknown) {
268
- await Promise.allSettled([failPaymentOperation(em, prepared.claim)])
289
+ if (!settled) {
290
+ if (input.releaseOnFailure) {
291
+ await input.releaseOnFailure({ transaction, operation, providerInvoked }).catch(() => undefined)
292
+ }
293
+ await Promise.allSettled([failPaymentOperation(em, prepared.claim)])
294
+ }
269
295
  throw error
270
296
  }
271
297
  }
@@ -285,6 +311,9 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
285
311
  ?? (session.clientSession?.type === 'redirect' ? session.clientSession.redirectUrl : null),
286
312
  clientSecret: session.clientSecret ?? null,
287
313
  amount: String(input.amount),
314
+ // An automatic-capture session comes back already captured, so the ledger has to start
315
+ // at the full amount — otherwise a later manual capture would look like the first one.
316
+ capturedAmount: session.status === 'captured' ? String(input.amount) : '0',
288
317
  currencyCode: input.currencyCode,
289
318
  gatewayMetadata: {
290
319
  ...(session.providerData ?? {}),
@@ -491,16 +520,57 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
491
520
  scope: { organizationId: string; tenantId: string },
492
521
  operationId?: string,
493
522
  ): Promise<CaptureResult> {
523
+ let reservedUnits: bigint | null = null
524
+ let providerAmount = amount
494
525
  return executeManualOperation({
495
526
  action: 'capture',
496
527
  transactionId,
497
528
  operationId,
498
529
  payload: { amount: amount ?? null },
499
530
  scope,
500
- assertInitialAllowed: (transaction) => assertCaptureAmountAllowed(amount, transaction),
531
+ assertInitialAllowed: (transaction) => { assertCaptureWithinRemaining(transaction, amount) },
532
+ beforeInvoke: async ({ transaction, operation }) => {
533
+ const alreadyCapturedUnits = parseAmountUnits(transaction.capturedAmount)
534
+ reservedUnits = await reserveCaptureAmount(em, { transaction, operation, amount, scope })
535
+ // "Capture the rest" has to name the remaining amount once part of the authorization is
536
+ // already captured, otherwise the provider would capture the full amount a second time.
537
+ if (amount === undefined && alreadyCapturedUnits > 0n) {
538
+ providerAmount = Number(formatAmountUnits(reservedUnits))
539
+ }
540
+ },
541
+ // The slice goes back only while the provider has not been called yet. Once the capture
542
+ // call returned, the money may already have moved and only the completion transaction
543
+ // failed, so releasing would reopen the authorization for a fresh operation id and allow an
544
+ // over-capture. Holding the reservation is the conservative outcome: a retry of this same
545
+ // operation id reuses both the reservation and the provider idempotency key, so the provider
546
+ // itself collapses the duplicate.
547
+ releaseOnFailure: async ({ transaction, operation, providerInvoked }) => {
548
+ if (reservedUnits === null) return
549
+ if (providerInvoked) {
550
+ await writeTransactionLog(
551
+ transaction.providerKey,
552
+ { organizationId: transaction.organizationId, tenantId: transaction.tenantId },
553
+ transaction.id,
554
+ 'warn',
555
+ 'Capture reservation stays outstanding because the provider call already returned',
556
+ { operationId: operation.operationId, reservedAmount: formatAmountUnits(reservedUnits) },
557
+ )
558
+ return
559
+ }
560
+ const released = await releaseCaptureAmount(em, { transactionId, operation, reservedUnits, scope })
561
+ if (released) return
562
+ await writeTransactionLog(
563
+ transaction.providerKey,
564
+ { organizationId: transaction.organizationId, tenantId: transaction.tenantId },
565
+ transaction.id,
566
+ 'warn',
567
+ 'Capture reservation stays outstanding after a failed capture',
568
+ { operationId: operation.operationId, reservedAmount: formatAmountUnits(reservedUnits) },
569
+ )
570
+ },
501
571
  invoke: ({ adapter, credentials, transaction, idempotencyKey }) => adapter.capture({
502
572
  sessionId: readProviderSessionId(transaction),
503
- amount,
573
+ amount: providerAmount,
504
574
  credentials,
505
575
  idempotencyKey,
506
576
  }),
@@ -509,6 +579,7 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
509
579
  ...readGatewayMetadata(transaction.gatewayMetadata),
510
580
  captureResult: result.providerData,
511
581
  }
582
+ if (reservedUnits !== null) settleCapturedAmount(transaction, reservedUnits, result.capturedAmount)
512
583
  },
513
584
  afterCommit: (transaction, result) => writeTransactionLog(
514
585
  transaction.providerKey,
@@ -604,6 +675,7 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
604
675
  if (status.status !== transaction.unifiedStatus && isValidTransition(transaction.unifiedStatus as UnifiedPaymentStatus, status.status)) {
605
676
  const previousStatus = transaction.unifiedStatus
606
677
  transaction.unifiedStatus = status.status
678
+ alignCapturedAmountWithStatus(transaction, status.status)
607
679
  transaction.gatewayStatus = status.status
608
680
  transaction.gatewayMetadata = { ...readGatewayMetadata(transaction.gatewayMetadata), statusResult: status.providerData ?? null }
609
681
  transaction.lastPolledAt = new Date()
@@ -650,6 +722,7 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
650
722
  const previousStatus = transaction.unifiedStatus
651
723
  if (shouldApplyStatus) {
652
724
  transaction.unifiedStatus = update.unifiedStatus
725
+ alignCapturedAmountWithStatus(transaction, update.unifiedStatus)
653
726
  }
654
727
  if (update.providerStatus) {
655
728
  transaction.gatewayStatus = update.providerStatus
@@ -170,6 +170,7 @@ export async function preparePaymentOperation(
170
170
  attemptToken: claim.attemptToken,
171
171
  attemptCount: 1,
172
172
  result: null,
173
+ reservedAmount: null,
173
174
  leaseExpiresAt: claim.leaseExpiresAt,
174
175
  organizationId: input.scope.organizationId,
175
176
  tenantId: input.scope.tenantId,
@@ -195,6 +195,23 @@
195
195
  "enumItems": [],
196
196
  "mappedType": "text"
197
197
  },
198
+ "reserved_amount": {
199
+ "name": "reserved_amount",
200
+ "type": "numeric(18,4)",
201
+ "unsigned": false,
202
+ "autoincrement": false,
203
+ "primary": false,
204
+ "nullable": true,
205
+ "unique": false,
206
+ "length": null,
207
+ "precision": 18,
208
+ "scale": 4,
209
+ "default": null,
210
+ "comment": null,
211
+ "collation": null,
212
+ "enumItems": [],
213
+ "mappedType": "decimal"
214
+ },
198
215
  "result": {
199
216
  "name": "result",
200
217
  "type": "jsonb",
@@ -560,6 +577,23 @@
560
577
  "enumItems": [],
561
578
  "mappedType": "decimal"
562
579
  },
580
+ "captured_amount": {
581
+ "name": "captured_amount",
582
+ "type": "numeric(18,4)",
583
+ "unsigned": false,
584
+ "autoincrement": false,
585
+ "primary": false,
586
+ "nullable": false,
587
+ "unique": false,
588
+ "length": null,
589
+ "precision": 18,
590
+ "scale": 4,
591
+ "default": "'0'",
592
+ "comment": null,
593
+ "collation": null,
594
+ "enumItems": [],
595
+ "mappedType": "decimal"
596
+ },
563
597
  "client_secret": {
564
598
  "name": "client_secret",
565
599
  "type": "text",
@@ -0,0 +1,21 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+
3
+ export class Migration20260725101500_payment_gateways extends Migration {
4
+
5
+ override up(): void | Promise<void> {
6
+ this.addSql(`alter table "gateway_transactions" add column "captured_amount" numeric(18,4) not null default '0';`);
7
+ this.addSql(`alter table "gateway_payment_operations" add column "reserved_amount" numeric(18,4) null;`);
8
+ // Backfill the captured-to-date ledger from the succeeded capture operations, which are the
9
+ // only capture path since the payment operation ledger landed. Transactions captured before
10
+ // that (no succeeded capture rows) fall back to their full amount when their status says the
11
+ // money was taken. A historical `partially_captured` transaction has no recoverable captured
12
+ // amount, so it starts at 0 and can still be over-captured once.
13
+ this.addSql(`update "gateway_transactions" as t set "captured_amount" = coalesce((select sum((o."result"->>'capturedAmount')::numeric) from "gateway_payment_operations" as o where o."transaction_id" = t."id" and o."operation_type" = 'capture' and o."status" = 'succeeded'), case when t."unified_status" in ('captured', 'refunded', 'partially_refunded') then t."amount" else 0 end);`);
14
+ }
15
+
16
+ override down(): void | Promise<void> {
17
+ this.addSql(`alter table "gateway_payment_operations" drop column "reserved_amount";`);
18
+ this.addSql(`alter table "gateway_transactions" drop column "captured_amount";`);
19
+ }
20
+
21
+ }
@@ -3,9 +3,12 @@ import { resolveEntityTableName } from '@open-mercato/shared/lib/query/engine'
3
3
  import { resolveTenantEncryptionService } from '@open-mercato/shared/lib/encryption/customFieldValues'
4
4
  import { decryptIndexDocForSearch, encryptIndexDocForStorage } from '@open-mercato/shared/lib/encryption/indexDoc'
5
5
  import { sql } from 'kysely'
6
+ import { createLogger } from '@open-mercato/shared/lib/logger'
6
7
  import { replaceSearchTokensForRecord, deleteSearchTokensForRecord } from './search-tokens'
7
8
  import { attachAggregateSearchField } from './document'
8
9
 
10
+ const logger = createLogger('query_index').child({ component: 'indexer' })
11
+
9
12
  type BuildDocParams = {
10
13
  entityType: string // '<module>:<entity>'
11
14
  recordId: string
@@ -121,8 +124,12 @@ export async function buildIndexDoc(em: EntityManager, params: BuildDocParams):
121
124
  }
122
125
  } catch {}
123
126
 
127
+ // Kept outside the guard below: a failure while building the aggregate search field is a
128
+ // bug in the aggregation or its configuration, and must surface instead of being mistaken
129
+ // for an encryption failure and silently skipping encryption.
130
+ doc = attachAggregateSearchField(doc)
131
+
124
132
  try {
125
- doc = attachAggregateSearchField(doc)
126
133
  const encryption = resolveTenantEncryptionService(em as any)
127
134
  doc = await encryptIndexDocForStorage(
128
135
  params.entityType,
@@ -130,7 +137,23 @@ export async function buildIndexDoc(em: EntityManager, params: BuildDocParams):
130
137
  { tenantId: params.tenantId ?? null, organizationId: params.organizationId ?? null },
131
138
  encryption,
132
139
  )
133
- } catch {}
140
+ } catch (error) {
141
+ // `encryptIndexDocForStorage` already returns the document untouched when encryption is
142
+ // absent or disabled, so a throw here is always a genuine encryption failure. Returning
143
+ // `doc` would hand the caller the plaintext document to write into `entity_indexes`,
144
+ // which must stay encrypted at rest; returning `null` would mean "record gone" and make
145
+ // `upsertIndexRow` delete a healthy index row. Fail loudly instead: the projection keeps
146
+ // its previous, correctly encrypted contents and the `query_index.upsert_one` subscriber
147
+ // persists the error via `recordIndexerError`.
148
+ logger.error('Index document encryption failed; refusing to return an unencrypted document', {
149
+ entityType: params.entityType,
150
+ recordId: params.recordId,
151
+ tenantId: params.tenantId ?? null,
152
+ organizationId: params.organizationId ?? null,
153
+ err: error,
154
+ })
155
+ throw error
156
+ }
134
157
 
135
158
  return doc
136
159
  }
@@ -11,6 +11,7 @@ import { useState, useCallback, useEffect } from 'react'
11
11
  import { useRouter, useSearchParams, usePathname } from 'next/navigation'
12
12
  import { graphToDefinition, definitionToGraph, validateWorkflowGraph, generateStepId, generateTransitionId, appendWorkflowEdge, ValidationError } from '../../../lib/graph-utils'
13
13
  import { performDeleteEdgeFlow, performDeleteNodeFlow } from '../../../lib/visual-editor-delete-flow'
14
+ import { humanizeDefinitionIssuePath } from '../../../lib/format-validation-error'
14
15
  import { workflowDefinitionDataSchema } from '../../../data/validators'
15
16
  import { Page } from '@open-mercato/ui/backend/Page'
16
17
  import { Button } from '@open-mercato/ui/primitives/button'
@@ -357,11 +358,19 @@ export default function VisualEditorPage() {
357
358
  triggers: triggers.length > 0 ? triggers : undefined,
358
359
  }
359
360
 
360
- // Run Zod schema validation before saving
361
+ // Run Zod schema validation before saving. Report every issue, not just the
362
+ // first: a save rejected for one missing activity field used to look like
363
+ // "nothing happened" once the operator fixed that field and hit a second
364
+ // one (#4232). Paths are humanized (steps.2.activities.0.config.endpoint →
365
+ // step 3 › activity 1 › endpoint) so the message points at the node to open.
361
366
  const schemaResult = workflowDefinitionDataSchema.safeParse(definitionData)
362
367
  if (!schemaResult.success) {
363
- const firstIssue = schemaResult.error.issues[0]
364
- flash(`Schema error: ${firstIssue.path.join('.')} - ${firstIssue.message}`, 'error')
368
+ const issues = schemaResult.error.issues
369
+ const described = issues
370
+ .slice(0, 3)
371
+ .map((issue) => `${humanizeDefinitionIssuePath(issue.path)}: ${issue.message}`)
372
+ const suffix = issues.length > described.length ? ` (+${issues.length - described.length} more)` : ''
373
+ flash(`Cannot save — ${described.join('; ')}${suffix}`, 'error')
365
374
  return
366
375
  }
367
376
 
@@ -26,7 +26,11 @@ interface Activity {
26
26
  retryDelay?: number
27
27
  backoffMultiplier?: number
28
28
  }
29
- timeout?: number
29
+ // Milliseconds, matching the executor and the definition schema. This field
30
+ // used to be written as `timeout` (a number) while the schema typed `timeout`
31
+ // as an ISO 8601 string, so saving a timeout from this editor failed
32
+ // validation outright (#4424).
33
+ timeoutMs?: number
30
34
  compensation?: Record<string, any>
31
35
  }
32
36
 
@@ -46,8 +50,59 @@ const ACTIVITY_TYPES = [
46
50
  { value: 'WAIT', label: 'Wait' },
47
51
  ]
48
52
 
53
+ /**
54
+ * Per-activity draft of the raw JSON config text (#4234).
55
+ *
56
+ * The config textarea used to be controlled directly by
57
+ * `JSON.stringify(activity.config)`, and its onChange dropped anything that
58
+ * did not parse. Every intermediate keystroke of a hand edit is invalid JSON,
59
+ * so the state never advanced and React re-rendered the previous serialized
60
+ * value — the field read as frozen/non-editable right after a config was
61
+ * pasted. Keeping the raw text locally lets the user type freely; the parsed
62
+ * object is propagated whenever the text is valid, and an inline error is shown
63
+ * while it is not.
64
+ */
65
+ type ConfigDraft = { text: string; error: string | null }
66
+
67
+ function serializeConfig(config: Record<string, unknown> | undefined): string {
68
+ return JSON.stringify(config ?? {}, null, 2)
69
+ }
70
+
49
71
  export function ActivitiesEditor({ value = [], onChange, error }: ActivitiesEditorProps) {
50
72
  const t = useT()
73
+ const [configDrafts, setConfigDrafts] = React.useState<Record<number, ConfigDraft>>({})
74
+
75
+ const configTextFor = (index: number, activity: Activity): string =>
76
+ configDrafts[index]?.text ?? serializeConfig(activity.config)
77
+
78
+ const handleConfigTextChange = (index: number, text: string) => {
79
+ let parsed: Record<string, unknown> | null = null
80
+ let parseError: string | null = null
81
+ try {
82
+ const candidate = JSON.parse(text) as unknown
83
+ if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) {
84
+ parsed = candidate as Record<string, unknown>
85
+ } else {
86
+ parseError = t('workflows.activities.configMustBeObject', 'Config must be a JSON object')
87
+ }
88
+ } catch (err) {
89
+ parseError = err instanceof Error ? err.message : t('workflows.activities.configInvalidJson', 'Invalid JSON')
90
+ }
91
+
92
+ setConfigDrafts((drafts) => ({ ...drafts, [index]: { text, error: parseError } }))
93
+ if (parsed) updateActivity(index, 'config', parsed)
94
+ }
95
+
96
+ // Drop the raw draft once the field loses focus and its content is valid, so
97
+ // the textarea goes back to mirroring the canonical (re-formatted) config.
98
+ const handleConfigBlur = (index: number) => {
99
+ setConfigDrafts((drafts) => {
100
+ if (!drafts[index] || drafts[index].error) return drafts
101
+ const next = { ...drafts }
102
+ delete next[index]
103
+ return next
104
+ })
105
+ }
51
106
 
52
107
  const addActivity = () => {
53
108
  const newActivity: Activity = {
@@ -85,6 +140,18 @@ export function ActivitiesEditor({ value = [], onChange, error }: ActivitiesEdit
85
140
 
86
141
  const removeActivity = (index: number) => {
87
142
  onChange(value.filter((_, i) => i !== index))
143
+ // Keep the index-keyed drafts aligned with the re-indexed activities:
144
+ // drop the removed row's draft and shift every later draft down by one,
145
+ // otherwise a pending (invalid) draft would render over a different row.
146
+ setConfigDrafts((drafts) => {
147
+ const next: Record<number, ConfigDraft> = {}
148
+ for (const key of Object.keys(drafts)) {
149
+ const i = Number(key)
150
+ if (i === index) continue
151
+ next[i > index ? i - 1 : i] = drafts[i]
152
+ }
153
+ return next
154
+ })
88
155
  }
89
156
 
90
157
  const moveActivity = (index: number, direction: 'up' | 'down') => {
@@ -96,6 +163,18 @@ export function ActivitiesEditor({ value = [], onChange, error }: ActivitiesEdit
96
163
  updated[index] = updated[newIndex]
97
164
  updated[newIndex] = temp
98
165
  onChange(updated)
166
+ // Swap the two rows' drafts too, so an in-progress edit follows its activity.
167
+ setConfigDrafts((drafts) => {
168
+ if (!(index in drafts) && !(newIndex in drafts)) return drafts
169
+ const next = { ...drafts }
170
+ const atIndex = drafts[index]
171
+ const atNew = drafts[newIndex]
172
+ if (atNew !== undefined) next[index] = atNew
173
+ else delete next[index]
174
+ if (atIndex !== undefined) next[newIndex] = atIndex
175
+ else delete next[newIndex]
176
+ return next
177
+ })
99
178
  }
100
179
 
101
180
  return (
@@ -211,8 +290,8 @@ export function ActivitiesEditor({ value = [], onChange, error }: ActivitiesEdit
211
290
  <Input
212
291
  id={`activity-${index}-timeout`}
213
292
  type="number"
214
- value={activity.timeout || ''}
215
- onChange={(e) => updateActivity(index, 'timeout', e.target.value ? parseInt(e.target.value) : undefined)}
293
+ value={activity.timeoutMs || ''}
294
+ onChange={(e) => updateActivity(index, 'timeoutMs', e.target.value ? parseInt(e.target.value) : undefined)}
216
295
  placeholder="30000"
217
296
  className="mt-1"
218
297
  />
@@ -318,19 +397,24 @@ export function ActivitiesEditor({ value = [], onChange, error }: ActivitiesEdit
318
397
  </Label>
319
398
  <Textarea
320
399
  id={`activity-${index}-config`}
321
- value={JSON.stringify(activity.config || {}, null, 2)}
322
- onChange={(e) => {
323
- try {
324
- const parsed = JSON.parse(e.target.value)
325
- updateActivity(index, 'config', parsed)
326
- } catch {
327
- // Invalid JSON, don't update
328
- }
329
- }}
400
+ value={configTextFor(index, activity)}
401
+ onChange={(e) => handleConfigTextChange(index, e.target.value)}
402
+ onBlur={() => handleConfigBlur(index)}
403
+ aria-invalid={configDrafts[index]?.error ? true : undefined}
404
+ aria-describedby={configDrafts[index]?.error ? `activity-${index}-config-error` : undefined}
330
405
  placeholder='{"key": "value"}'
331
406
  rows={3}
332
407
  className="mt-1 font-mono text-xs"
333
408
  />
409
+ {configDrafts[index]?.error ? (
410
+ <p
411
+ id={`activity-${index}-config-error`}
412
+ className="mt-1 text-xs text-status-error-text"
413
+ role="alert"
414
+ >
415
+ {configDrafts[index]?.error}
416
+ </p>
417
+ ) : null}
334
418
  </div>
335
419
  )}
336
420
  </div>
@@ -26,7 +26,11 @@ interface Activity {
26
26
  retryDelay?: number
27
27
  backoffMultiplier?: number
28
28
  }
29
- timeout?: number
29
+ // Milliseconds, matching the executor and the definition schema. This field
30
+ // used to be written as `timeout` (a number) while the schema typed `timeout`
31
+ // as an ISO 8601 string, so saving a timeout from this editor failed
32
+ // validation outright (#4424).
33
+ timeoutMs?: number
30
34
  compensation?: Record<string, any>
31
35
  }
32
36
 
@@ -66,8 +70,53 @@ const ACTIVITY_TYPES = [
66
70
  { value: 'WAIT', label: 'Wait' },
67
71
  ]
68
72
 
73
+ type ConfigDraft = { text: string; error: string | null }
74
+
69
75
  export function TransitionsEditor({ value = [], onChange, steps = [], error }: TransitionsEditorProps) {
70
76
  const t = useT()
77
+ const [configDrafts, setConfigDrafts] = React.useState<Record<string, ConfigDraft>>({})
78
+
79
+ const configDraftKey = (transition: Transition, activity: Activity) =>
80
+ `${transition.transitionId}:${activity.activityId}`
81
+
82
+ const handleConfigTextChange = (
83
+ transitionIndex: number,
84
+ activityIndex: number,
85
+ text: string,
86
+ ) => {
87
+ const transition = value[transitionIndex]
88
+ const activity = transition.activities?.[activityIndex]
89
+ if (!activity) return
90
+
91
+ let parsed: Record<string, unknown> | null = null
92
+ let parseError: string | null = null
93
+ try {
94
+ const candidate = JSON.parse(text) as unknown
95
+ if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) {
96
+ parsed = candidate as Record<string, unknown>
97
+ } else {
98
+ parseError = t('workflows.activities.configMustBeObject', 'Config must be a JSON object')
99
+ }
100
+ } catch (err) {
101
+ parseError = err instanceof Error
102
+ ? err.message
103
+ : t('workflows.activities.configInvalidJson', 'Invalid JSON')
104
+ }
105
+
106
+ const key = configDraftKey(transition, activity)
107
+ setConfigDrafts((drafts) => ({ ...drafts, [key]: { text, error: parseError } }))
108
+ if (parsed) updateActivity(transitionIndex, activityIndex, 'config', parsed)
109
+ }
110
+
111
+ const handleConfigBlur = (transition: Transition, activity: Activity) => {
112
+ const key = configDraftKey(transition, activity)
113
+ setConfigDrafts((drafts) => {
114
+ if (!drafts[key] || drafts[key].error) return drafts
115
+ const next = { ...drafts }
116
+ delete next[key]
117
+ return next
118
+ })
119
+ }
71
120
 
72
121
  const addTransition = () => {
73
122
  const newTransition: Transition = {
@@ -370,9 +419,12 @@ export function TransitionsEditor({ value = [], onChange, steps = [], error }: T
370
419
  )}
371
420
 
372
421
  <div className="space-y-2">
373
- {(transition.activities || []).map((activity, activityIndex) => (
374
- <div key={activityIndex} className="p-3 border rounded-md bg-muted shadow-sm border-l-4 border-l-green-500">
375
- <div className="space-y-2">
422
+ {(transition.activities || []).map((activity, activityIndex) => {
423
+ const draftKey = configDraftKey(transition, activity)
424
+ const configDraft = configDrafts[draftKey]
425
+ return (
426
+ <div key={draftKey} className="p-3 border rounded-md bg-muted shadow-sm border-l-4 border-l-green-500">
427
+ <div className="space-y-2">
376
428
  <div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
377
429
  <div className="flex-1 grid grid-cols-1 sm:grid-cols-2 gap-2">
378
430
  <div>
@@ -461,8 +513,8 @@ export function TransitionsEditor({ value = [], onChange, steps = [], error }: T
461
513
  <Input
462
514
  id={`activity-${index}-${activityIndex}-timeout`}
463
515
  type="number"
464
- value={activity.timeout || ''}
465
- onChange={(e) => updateActivity(index, activityIndex, 'timeout', e.target.value ? parseInt(e.target.value) : undefined)}
516
+ value={activity.timeoutMs || ''}
517
+ onChange={(e) => updateActivity(index, activityIndex, 'timeoutMs', e.target.value ? parseInt(e.target.value) : undefined)}
466
518
  placeholder="30000"
467
519
  className="mt-1 text-xs h-8"
468
520
  />
@@ -529,23 +581,29 @@ export function TransitionsEditor({ value = [], onChange, steps = [], error }: T
529
581
  </Label>
530
582
  <Textarea
531
583
  id={`activity-${index}-${activityIndex}-config`}
532
- value={JSON.stringify(activity.config || {}, null, 2)}
533
- onChange={(e) => {
534
- try {
535
- const parsed = JSON.parse(e.target.value)
536
- updateActivity(index, activityIndex, 'config', parsed)
537
- } catch {
538
- // Invalid JSON, don't update
539
- }
540
- }}
584
+ value={configDraft?.text ?? JSON.stringify(activity.config || {}, null, 2)}
585
+ onChange={(e) => handleConfigTextChange(index, activityIndex, e.target.value)}
586
+ onBlur={() => handleConfigBlur(transition, activity)}
587
+ aria-invalid={configDraft?.error ? true : undefined}
588
+ aria-describedby={configDraft?.error ? `activity-${index}-${activityIndex}-config-error` : undefined}
541
589
  placeholder='{"key": "value"}'
542
590
  rows={2}
543
591
  className="mt-1 font-mono text-xs"
544
592
  />
593
+ {configDraft?.error ? (
594
+ <p
595
+ id={`activity-${index}-${activityIndex}-config-error`}
596
+ className="mt-1 text-xs text-status-error-text"
597
+ role="alert"
598
+ >
599
+ {configDraft.error}
600
+ </p>
601
+ ) : null}
602
+ </div>
545
603
  </div>
546
604
  </div>
547
- </div>
548
- ))}
605
+ )
606
+ })}
549
607
  </div>
550
608
  </div>
551
609
  </div>