@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/generated/entities/gateway_payment_operation/index.js +2 -0
- package/dist/generated/entities/gateway_payment_operation/index.js.map +2 -2
- package/dist/generated/entities/gateway_transaction/index.js +2 -0
- package/dist/generated/entities/gateway_transaction/index.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +2 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/payment_gateways/api/capture/route.js +1 -1
- package/dist/modules/payment_gateways/api/capture/route.js.map +2 -2
- package/dist/modules/payment_gateways/data/entities.js +7 -0
- package/dist/modules/payment_gateways/data/entities.js.map +2 -2
- package/dist/modules/payment_gateways/lib/capture-ledger.js +183 -0
- package/dist/modules/payment_gateways/lib/capture-ledger.js.map +7 -0
- package/dist/modules/payment_gateways/lib/gateway-service.js +70 -8
- package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
- package/dist/modules/payment_gateways/lib/payment-operation-idempotency.js +1 -0
- package/dist/modules/payment_gateways/lib/payment-operation-idempotency.js.map +2 -2
- package/dist/modules/payment_gateways/migrations/Migration20260725101500_payment_gateways.js +16 -0
- package/dist/modules/payment_gateways/migrations/Migration20260725101500_payment_gateways.js.map +7 -0
- package/dist/modules/query_index/lib/indexer.js +12 -2
- package/dist/modules/query_index/lib/indexer.js.map +2 -2
- package/dist/modules/workflows/backend/definitions/visual-editor/page.js +5 -2
- package/dist/modules/workflows/backend/definitions/visual-editor/page.js.map +2 -2
- package/dist/modules/workflows/components/ActivitiesEditor.js +67 -11
- package/dist/modules/workflows/components/ActivitiesEditor.js.map +2 -2
- package/dist/modules/workflows/components/TransitionsEditor.js +205 -163
- package/dist/modules/workflows/components/TransitionsEditor.js.map +2 -2
- package/dist/modules/workflows/data/validators.js +34 -1
- package/dist/modules/workflows/data/validators.js.map +3 -3
- package/dist/modules/workflows/lib/activity-executor.js +18 -2
- package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
- package/dist/modules/workflows/lib/format-validation-error.js +46 -1
- package/dist/modules/workflows/lib/format-validation-error.js.map +2 -2
- package/generated/entities/gateway_payment_operation/index.ts +1 -0
- package/generated/entities/gateway_transaction/index.ts +1 -0
- package/generated/entity-fields-registry.ts +2 -0
- package/package.json +7 -7
- package/src/modules/attachments/i18n/de.json +12 -0
- package/src/modules/attachments/i18n/en.json +12 -0
- package/src/modules/attachments/i18n/es.json +12 -0
- package/src/modules/attachments/i18n/pl.json +12 -0
- package/src/modules/auth/i18n/de.json +1 -0
- package/src/modules/auth/i18n/en.json +1 -0
- package/src/modules/auth/i18n/es.json +1 -0
- package/src/modules/auth/i18n/pl.json +1 -0
- package/src/modules/catalog/i18n/de.json +18 -0
- package/src/modules/catalog/i18n/en.json +18 -0
- package/src/modules/catalog/i18n/es.json +18 -0
- package/src/modules/catalog/i18n/pl.json +18 -0
- package/src/modules/customers/i18n/de.json +44 -0
- package/src/modules/customers/i18n/en.json +44 -0
- package/src/modules/customers/i18n/es.json +44 -0
- package/src/modules/customers/i18n/pl.json +44 -0
- package/src/modules/payment_gateways/api/capture/route.ts +1 -1
- package/src/modules/payment_gateways/data/entities.ts +8 -2
- package/src/modules/payment_gateways/lib/capture-ledger.ts +260 -0
- package/src/modules/payment_gateways/lib/gateway-service.ts +83 -10
- package/src/modules/payment_gateways/lib/payment-operation-idempotency.ts +1 -0
- package/src/modules/payment_gateways/migrations/.snapshot-open-mercato.json +34 -0
- package/src/modules/payment_gateways/migrations/Migration20260725101500_payment_gateways.ts +21 -0
- package/src/modules/query_index/lib/indexer.ts +25 -2
- package/src/modules/workflows/backend/definitions/visual-editor/page.tsx +12 -3
- package/src/modules/workflows/components/ActivitiesEditor.tsx +96 -12
- package/src/modules/workflows/components/TransitionsEditor.tsx +75 -17
- package/src/modules/workflows/data/validators.ts +52 -1
- package/src/modules/workflows/i18n/de.json +2 -0
- package/src/modules/workflows/i18n/en.json +2 -0
- package/src/modules/workflows/i18n/es.json +2 -0
- package/src/modules/workflows/i18n/pl.json +2 -0
- package/src/modules/workflows/lib/activity-executor.ts +38 -3
- 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
|
-
|
|
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) =>
|
|
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
|
|
364
|
-
|
|
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
|
-
|
|
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.
|
|
215
|
-
onChange={(e) => updateActivity(index, '
|
|
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={
|
|
322
|
-
onChange={(e) =>
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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
|
-
|
|
375
|
-
|
|
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.
|
|
465
|
-
onChange={(e) => updateActivity(index, activityIndex, '
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
|
|
548
|
-
)
|
|
605
|
+
)
|
|
606
|
+
})}
|
|
549
607
|
</div>
|
|
550
608
|
</div>
|
|
551
609
|
</div>
|