@open-mercato/core 0.6.7-develop.6842.1.8cd8a8883c → 0.6.7-develop.6851.1.c1680b00d8
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/modules/entities/api/definitions.js +2 -0
- package/dist/modules/entities/api/definitions.js.map +2 -2
- package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js +18 -3
- package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js.map +2 -2
- package/dist/modules/entities/components/RecordListRelationCell.js +63 -0
- package/dist/modules/entities/components/RecordListRelationCell.js.map +7 -0
- package/dist/modules/entities/components/recordListRelationDisplays.js +80 -0
- package/dist/modules/entities/components/recordListRelationDisplays.js.map +7 -0
- package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.js +14 -1
- package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.js.map +2 -2
- package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js +22 -17
- package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js.map +2 -2
- package/dist/modules/query_index/api/reindex.js +10 -0
- package/dist/modules/query_index/api/reindex.js.map +2 -2
- package/dist/modules/query_index/cli.js +125 -1
- package/dist/modules/query_index/cli.js.map +2 -2
- package/dist/modules/query_index/lib/coverage.js +19 -7
- package/dist/modules/query_index/lib/coverage.js.map +2 -2
- package/dist/modules/query_index/lib/reindexer.js +43 -37
- package/dist/modules/query_index/lib/reindexer.js.map +2 -2
- package/dist/modules/sales/commands/documents.js +27 -0
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/sales/data/validators.js +2 -0
- package/dist/modules/sales/data/validators.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/entities/api/definitions.ts +4 -0
- package/src/modules/entities/backend/entities/user/[entityId]/records/page.tsx +94 -77
- package/src/modules/entities/components/RecordListRelationCell.tsx +90 -0
- package/src/modules/entities/components/recordListRelationDisplays.ts +109 -0
- package/src/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.ts +17 -1
- package/src/modules/inbox_ops/components/proposals/EditActionDialog.tsx +21 -16
- package/src/modules/inbox_ops/components/proposals/types.ts +1 -0
- package/src/modules/query_index/api/reindex.ts +15 -0
- package/src/modules/query_index/cli.ts +180 -1
- package/src/modules/query_index/lib/coverage.ts +38 -8
- package/src/modules/query_index/lib/reindexer.ts +62 -40
- package/src/modules/sales/commands/documents.ts +49 -0
- package/src/modules/sales/data/validators.ts +2 -0
|
@@ -4,7 +4,7 @@ import { resolveExtensionPointPattern } from '@open-mercato/shared/modules/widge
|
|
|
4
4
|
import { extensionPoints } from '@open-mercato/core/modules/entities/extension-points'
|
|
5
5
|
import { useSearchParams } from 'next/navigation'
|
|
6
6
|
import type { ColumnDef, SortingState } from '@tanstack/react-table'
|
|
7
|
-
import { filterCustomFieldDefs, useCustomFieldDefs } from '@open-mercato/ui/backend/utils/customFieldDefs'
|
|
7
|
+
import { filterCustomFieldDefs, useCustomFieldDefs, type CustomFieldDefDto } from '@open-mercato/ui/backend/utils/customFieldDefs'
|
|
8
8
|
import { Page, PageBody } from '@open-mercato/ui/backend/Page'
|
|
9
9
|
import { DataTable, type DataTableExportFormat } from '@open-mercato/ui/backend/DataTable'
|
|
10
10
|
import type { PreparedExport } from '@open-mercato/shared/lib/crud/exporters'
|
|
@@ -23,6 +23,11 @@ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuarde
|
|
|
23
23
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
24
24
|
import { ErrorMessage, LoadingMessage } from '@open-mercato/ui/backend/detail'
|
|
25
25
|
import { useRecordsEntityGuard } from '@open-mercato/core/modules/entities/components/useRecordsEntityGuard'
|
|
26
|
+
import {
|
|
27
|
+
RelationCell,
|
|
28
|
+
RelationDisplaysProvider,
|
|
29
|
+
useRecordListRelationDisplays,
|
|
30
|
+
} from '@open-mercato/core/modules/entities/components/RecordListRelationCell'
|
|
26
31
|
|
|
27
32
|
type RecordsResponse = {
|
|
28
33
|
items: any[]
|
|
@@ -98,6 +103,13 @@ function RecordsPageInner({ params }: { params: { entityId?: string } }) {
|
|
|
98
103
|
enabled: Boolean(entityId),
|
|
99
104
|
keyExtras: [scopeVersion],
|
|
100
105
|
})
|
|
106
|
+
const relationDefinitions = React.useMemo(
|
|
107
|
+
() => filterCustomFieldDefs(cfDefs, 'list').filter(
|
|
108
|
+
(definition): definition is CustomFieldDefDto => definition.kind === 'relation',
|
|
109
|
+
),
|
|
110
|
+
[cfDefs],
|
|
111
|
+
)
|
|
112
|
+
const relationDisplaysByField = useRecordListRelationDisplays(relationDefinitions, rawData)
|
|
101
113
|
|
|
102
114
|
// Fields searched server-side: every visible column plus the base `id`.
|
|
103
115
|
const searchableFields = React.useMemo(() => {
|
|
@@ -182,7 +194,10 @@ function RecordsPageInner({ params }: { params: { entityId?: string } }) {
|
|
|
182
194
|
meta: { priority: idx < 4 ? 1 : idx < 6 ? 2 : idx < 8 ? 3 : idx < maxVisible ? 4 : 5 },
|
|
183
195
|
cell: ({ getValue }: { getValue: () => unknown }) => {
|
|
184
196
|
const v = getValue() as any
|
|
185
|
-
|
|
197
|
+
if (d.kind === 'relation') {
|
|
198
|
+
return <RelationCell fieldKey={d.key} value={v} fallbackText={normalizeCell(v)} />
|
|
199
|
+
}
|
|
200
|
+
return <span className="inline-block max-w-60 truncate align-top" title={normalizeCell(v)}>{normalizeCell(v)}</span>
|
|
186
201
|
},
|
|
187
202
|
}))
|
|
188
203
|
// Ensure hidden 'id' column exists for sorting/state
|
|
@@ -378,81 +393,83 @@ export RECORD_ID="<record uuid>"`}</code></pre>
|
|
|
378
393
|
</div>
|
|
379
394
|
</div>
|
|
380
395
|
</ContextHelp>
|
|
381
|
-
<
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
{
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
396
|
+
<RelationDisplaysProvider displaysByField={relationDisplaysByField}>
|
|
397
|
+
<DataTable
|
|
398
|
+
stickyActionsColumn
|
|
399
|
+
title={`Records: ${entityId}`}
|
|
400
|
+
entityId={entityId}
|
|
401
|
+
actions={actions}
|
|
402
|
+
columns={columns}
|
|
403
|
+
data={data}
|
|
404
|
+
perspective={{
|
|
405
|
+
tableId: resolveExtensionPointPattern(extensionPoints.hosts.userRecordsTable.pattern, { entityId }),
|
|
406
|
+
}}
|
|
407
|
+
exporter={exportConfig}
|
|
408
|
+
filters={baseFilters}
|
|
409
|
+
filterValues={filterValues}
|
|
410
|
+
rowActions={(row) => (
|
|
411
|
+
<RowActions
|
|
412
|
+
items={[
|
|
413
|
+
{ id: 'edit', label: 'Edit', href: `/backend/entities/user/${encodeURIComponent(entityId)}/records/${encodeURIComponent(String((row as any).id))}` },
|
|
414
|
+
{ id: 'delete', label: 'Delete', destructive: true, onSelect: async () => {
|
|
415
|
+
const recordId = String((row as any).id)
|
|
416
|
+
try {
|
|
417
|
+
const confirmed = await confirm({
|
|
418
|
+
title: 'Delete this record?',
|
|
419
|
+
variant: 'destructive',
|
|
420
|
+
})
|
|
421
|
+
if (!confirmed) return
|
|
422
|
+
await runDeleteMutation({
|
|
423
|
+
operation: async () => {
|
|
424
|
+
const deleteCall = await withScopedApiRequestHeaders(
|
|
425
|
+
buildOptimisticLockHeader((row as any).updatedAt),
|
|
426
|
+
() => apiCall(
|
|
427
|
+
`/api/entities/records?entityId=${encodeURIComponent(entityId)}&recordId=${encodeURIComponent(recordId)}`,
|
|
428
|
+
{ method: 'DELETE' },
|
|
429
|
+
),
|
|
430
|
+
)
|
|
431
|
+
if (!deleteCall.ok) {
|
|
432
|
+
await raiseCrudError(deleteCall.response, 'Failed to delete record')
|
|
433
|
+
}
|
|
434
|
+
},
|
|
435
|
+
context: {
|
|
436
|
+
formId: deleteMutationContextId,
|
|
437
|
+
resourceKind: 'entities.record',
|
|
438
|
+
resourceId: recordId,
|
|
439
|
+
retryLastMutation: retryDeleteMutation,
|
|
440
|
+
},
|
|
441
|
+
})
|
|
442
|
+
const j = await readApiResultOrThrow<RecordsResponse>(
|
|
443
|
+
`/api/entities/records?entityId=${encodeURIComponent(entityId)}&page=${page}&pageSize=${pageSize}`,
|
|
444
|
+
undefined,
|
|
445
|
+
{
|
|
446
|
+
errorMessage: 'Failed to reload records',
|
|
447
|
+
fallback: { items: [], total: 0, page, pageSize, totalPages: 1 },
|
|
448
|
+
},
|
|
449
|
+
)
|
|
450
|
+
setRawData(j.items || [])
|
|
451
|
+
setTotal(j.total || 0)
|
|
452
|
+
setTotalPages(j.totalPages || 1)
|
|
453
|
+
flash('Record has been removed', 'success')
|
|
454
|
+
} catch (error) {
|
|
455
|
+
const message = error instanceof Error ? error.message : 'Failed to delete record'
|
|
456
|
+
flash(message, 'error')
|
|
457
|
+
}
|
|
458
|
+
} },
|
|
459
|
+
]}
|
|
460
|
+
/>
|
|
461
|
+
)}
|
|
462
|
+
sortable
|
|
463
|
+
sorting={sorting}
|
|
464
|
+
onSortingChange={setSorting}
|
|
465
|
+
searchValue={search}
|
|
466
|
+
onSearchChange={(v) => { setSearch(v); setPage(1) }}
|
|
467
|
+
onFiltersApply={(vals) => { setFilterValues(vals); setPage(1) }}
|
|
468
|
+
onFiltersClear={() => { setFilterValues({}); setPage(1) }}
|
|
469
|
+
pagination={{ page, pageSize, total, totalPages, onPageChange: setPage }}
|
|
470
|
+
isLoading={loading}
|
|
471
|
+
/>
|
|
472
|
+
</RelationDisplaysProvider>
|
|
456
473
|
</PageBody>
|
|
457
474
|
{ConfirmDialogElement}
|
|
458
475
|
</Page>
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import Link from 'next/link'
|
|
4
|
+
import type { CustomFieldDefDto } from '@open-mercato/ui/backend/utils/customFieldDefs'
|
|
5
|
+
import {
|
|
6
|
+
getRecordListRelationDisplays,
|
|
7
|
+
resolveRecordListRelationDisplays,
|
|
8
|
+
type RelationDisplaysByField,
|
|
9
|
+
} from './recordListRelationDisplays'
|
|
10
|
+
|
|
11
|
+
const RelationDisplaysContext = React.createContext<RelationDisplaysByField>({})
|
|
12
|
+
|
|
13
|
+
export function RelationDisplaysProvider({
|
|
14
|
+
displaysByField,
|
|
15
|
+
children,
|
|
16
|
+
}: {
|
|
17
|
+
displaysByField: RelationDisplaysByField
|
|
18
|
+
children: React.ReactNode
|
|
19
|
+
}) {
|
|
20
|
+
return (
|
|
21
|
+
<RelationDisplaysContext.Provider value={displaysByField}>
|
|
22
|
+
{children}
|
|
23
|
+
</RelationDisplaysContext.Provider>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function useRecordListRelationDisplays(
|
|
28
|
+
definitions: CustomFieldDefDto[],
|
|
29
|
+
records: Array<Record<string, unknown>>,
|
|
30
|
+
): RelationDisplaysByField {
|
|
31
|
+
const [displaysByField, setDisplaysByField] = React.useState<RelationDisplaysByField>({})
|
|
32
|
+
|
|
33
|
+
React.useEffect(() => {
|
|
34
|
+
const abortController = new AbortController()
|
|
35
|
+
setDisplaysByField((current) => (Object.keys(current).length ? {} : current))
|
|
36
|
+
if (!definitions.length || !records.length) {
|
|
37
|
+
return () => abortController.abort()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
void resolveRecordListRelationDisplays(definitions, records, abortController.signal)
|
|
41
|
+
.then((resolvedDisplays) => {
|
|
42
|
+
if (!abortController.signal.aborted) setDisplaysByField(resolvedDisplays)
|
|
43
|
+
})
|
|
44
|
+
.catch(() => {
|
|
45
|
+
if (!abortController.signal.aborted) setDisplaysByField({})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
return () => abortController.abort()
|
|
49
|
+
}, [definitions, records])
|
|
50
|
+
|
|
51
|
+
return displaysByField
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function RelationCell({
|
|
55
|
+
fieldKey,
|
|
56
|
+
value,
|
|
57
|
+
fallbackText,
|
|
58
|
+
}: {
|
|
59
|
+
fieldKey: string
|
|
60
|
+
value: unknown
|
|
61
|
+
fallbackText: string
|
|
62
|
+
}) {
|
|
63
|
+
const displaysByField = React.useContext(RelationDisplaysContext)
|
|
64
|
+
const resolvedDisplays = getRecordListRelationDisplays(value, displaysByField[fieldKey])
|
|
65
|
+
const rawValue = resolvedDisplays.map((display) => display.id).join(', ') || fallbackText
|
|
66
|
+
|
|
67
|
+
if (!resolvedDisplays.length) {
|
|
68
|
+
return <span className="inline-block max-w-60 truncate align-top" title={rawValue}>{rawValue}</span>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<span className="inline-block max-w-60 truncate align-top" title={rawValue}>
|
|
73
|
+
{resolvedDisplays.map((display, index) => (
|
|
74
|
+
<React.Fragment key={`${display.id}-${index}`}>
|
|
75
|
+
{index > 0 ? ', ' : null}
|
|
76
|
+
{display.href ? (
|
|
77
|
+
<Link
|
|
78
|
+
href={display.href}
|
|
79
|
+
className="font-medium text-primary underline-offset-2 hover:underline focus-visible:underline"
|
|
80
|
+
onClick={(event) => event.stopPropagation()}
|
|
81
|
+
onKeyDown={(event) => event.stopPropagation()}
|
|
82
|
+
>
|
|
83
|
+
{display.label}
|
|
84
|
+
</Link>
|
|
85
|
+
) : display.label}
|
|
86
|
+
</React.Fragment>
|
|
87
|
+
))}
|
|
88
|
+
</span>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { CustomFieldDefDto } from '@open-mercato/ui/backend/utils/customFieldDefs'
|
|
2
|
+
import {
|
|
3
|
+
buildRelationHref,
|
|
4
|
+
collectRelationValueIds,
|
|
5
|
+
fetchRelationRecordDisplays,
|
|
6
|
+
parseRelationOptionsMetadata,
|
|
7
|
+
type RelationOptionsMetadata,
|
|
8
|
+
type ResolvedValueDisplay,
|
|
9
|
+
} from '@open-mercato/ui/backend/utils/customFieldRelationDisplay'
|
|
10
|
+
|
|
11
|
+
export type RelationDisplaysByField = Record<string, Record<string, ResolvedValueDisplay>>
|
|
12
|
+
|
|
13
|
+
type RelationLookup = {
|
|
14
|
+
optionsUrl: string
|
|
15
|
+
relation: RelationOptionsMetadata
|
|
16
|
+
recordIds: Set<string>
|
|
17
|
+
recordIdsByField: Map<string, Set<string>>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function resolveRelationOptionsUrl(definition: CustomFieldDefDto): string | null {
|
|
21
|
+
if (definition.kind !== 'relation') return null
|
|
22
|
+
|
|
23
|
+
const configuredUrl = definition.optionsUrl?.trim()
|
|
24
|
+
if (configuredUrl && parseRelationOptionsMetadata(configuredUrl)) return configuredUrl
|
|
25
|
+
|
|
26
|
+
const relatedEntityId = definition.relatedEntityId?.trim()
|
|
27
|
+
if (!relatedEntityId) return null
|
|
28
|
+
return `/api/entities/relations/options?entityId=${encodeURIComponent(relatedEntityId)}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function collectRelationLookups(
|
|
32
|
+
definitions: CustomFieldDefDto[],
|
|
33
|
+
records: Array<Record<string, unknown>>,
|
|
34
|
+
): RelationLookup[] {
|
|
35
|
+
const lookups = new Map<string, RelationLookup>()
|
|
36
|
+
|
|
37
|
+
definitions.forEach((definition) => {
|
|
38
|
+
const optionsUrl = resolveRelationOptionsUrl(definition)
|
|
39
|
+
if (!optionsUrl) return
|
|
40
|
+
const relation = parseRelationOptionsMetadata(optionsUrl)
|
|
41
|
+
if (!relation) return
|
|
42
|
+
|
|
43
|
+
const recordIds = new Set(
|
|
44
|
+
records.flatMap((record) => collectRelationValueIds(record[definition.key])),
|
|
45
|
+
)
|
|
46
|
+
if (!recordIds.size) return
|
|
47
|
+
|
|
48
|
+
const lookupKey = JSON.stringify([relation.entityId, optionsUrl])
|
|
49
|
+
const lookup = lookups.get(lookupKey) ?? {
|
|
50
|
+
optionsUrl,
|
|
51
|
+
relation,
|
|
52
|
+
recordIds: new Set<string>(),
|
|
53
|
+
recordIdsByField: new Map<string, Set<string>>(),
|
|
54
|
+
}
|
|
55
|
+
recordIds.forEach((recordId) => lookup.recordIds.add(recordId))
|
|
56
|
+
lookup.recordIdsByField.set(definition.key, recordIds)
|
|
57
|
+
lookups.set(lookupKey, lookup)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
return Array.from(lookups.values())
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function resolveRecordListRelationDisplays(
|
|
64
|
+
definitions: CustomFieldDefDto[],
|
|
65
|
+
records: Array<Record<string, unknown>>,
|
|
66
|
+
signal?: AbortSignal,
|
|
67
|
+
): Promise<RelationDisplaysByField> {
|
|
68
|
+
const displaysByField: RelationDisplaysByField = {}
|
|
69
|
+
const lookups = collectRelationLookups(definitions, records)
|
|
70
|
+
|
|
71
|
+
await Promise.all(lookups.map(async (lookup) => {
|
|
72
|
+
let fetchedDisplays: Record<string, ResolvedValueDisplay> = {}
|
|
73
|
+
try {
|
|
74
|
+
fetchedDisplays = await fetchRelationRecordDisplays(
|
|
75
|
+
lookup.optionsUrl,
|
|
76
|
+
lookup.relation,
|
|
77
|
+
Array.from(lookup.recordIds),
|
|
78
|
+
signal,
|
|
79
|
+
)
|
|
80
|
+
} catch {
|
|
81
|
+
fetchedDisplays = {}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (signal?.aborted) return
|
|
85
|
+
lookup.recordIdsByField.forEach((recordIds, fieldKey) => {
|
|
86
|
+
const fieldDisplays: Record<string, ResolvedValueDisplay> = {}
|
|
87
|
+
recordIds.forEach((recordId) => {
|
|
88
|
+
fieldDisplays[recordId] = fetchedDisplays[recordId] ?? {
|
|
89
|
+
label: recordId,
|
|
90
|
+
href: buildRelationHref(lookup.relation.entityId, recordId),
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
displaysByField[fieldKey] = fieldDisplays
|
|
94
|
+
})
|
|
95
|
+
}))
|
|
96
|
+
|
|
97
|
+
return displaysByField
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function getRecordListRelationDisplays(
|
|
101
|
+
value: unknown,
|
|
102
|
+
displays: Record<string, ResolvedValueDisplay> | undefined,
|
|
103
|
+
): Array<{ id: string; label: string; href?: string }> {
|
|
104
|
+
return collectRelationValueIds(value).map((recordId) => ({
|
|
105
|
+
id: recordId,
|
|
106
|
+
label: displays?.[recordId]?.label ?? recordId,
|
|
107
|
+
href: displays?.[recordId]?.href,
|
|
108
|
+
}))
|
|
109
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
2
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
3
3
|
import { runWithCacheTenant } from '@open-mercato/cache'
|
|
4
|
+
import { enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
|
|
5
|
+
import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
4
6
|
import {
|
|
5
7
|
runCrudMutationGuardAfterSuccess,
|
|
6
8
|
validateCrudMutationGuard,
|
|
@@ -40,6 +42,20 @@ export async function PATCH(req: Request) {
|
|
|
40
42
|
return NextResponse.json({ error: 'Action already processed' }, { status: 409 })
|
|
41
43
|
}
|
|
42
44
|
|
|
45
|
+
// Optimistic lock: refuse a stale overwrite when two tabs edit the same action
|
|
46
|
+
// payload. Strictly additive — a no-op without the expected-version header.
|
|
47
|
+
try {
|
|
48
|
+
await enforceCommandOptimisticLockWithGuards(ctx.container, {
|
|
49
|
+
resourceKind: 'inbox_ops.action',
|
|
50
|
+
resourceId: action.id,
|
|
51
|
+
current: action.updatedAt ?? null,
|
|
52
|
+
request: req,
|
|
53
|
+
})
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })
|
|
56
|
+
throw err
|
|
57
|
+
}
|
|
58
|
+
|
|
43
59
|
const mergedPayload = { ...action.payload as Record<string, unknown>, ...parsed.data.payload }
|
|
44
60
|
const payloadValidation = validateActionPayloadForType(action.actionType, mergedPayload)
|
|
45
61
|
if (!payloadValidation.success) {
|
|
@@ -108,7 +124,7 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
108
124
|
responses: [
|
|
109
125
|
{ status: 200, description: 'Action updated' },
|
|
110
126
|
{ status: 404, description: 'Action not found' },
|
|
111
|
-
{ status: 409, description: 'Action already processed' },
|
|
127
|
+
{ status: 409, description: 'Action already processed or stale (optimistic-lock conflict)' },
|
|
112
128
|
],
|
|
113
129
|
},
|
|
114
130
|
},
|
|
@@ -9,8 +9,8 @@ import {
|
|
|
9
9
|
SelectTrigger,
|
|
10
10
|
SelectValue,
|
|
11
11
|
} from '@open-mercato/ui/primitives/select'
|
|
12
|
-
import {
|
|
13
|
-
import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
|
|
12
|
+
import { apiCallOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
|
|
13
|
+
import { buildOptimisticLockHeader, extractOptimisticLockConflict } from '@open-mercato/ui/backend/utils/optimisticLock'
|
|
14
14
|
import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
|
|
15
15
|
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
16
16
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
@@ -461,25 +461,30 @@ export function EditActionDialog({
|
|
|
461
461
|
}
|
|
462
462
|
|
|
463
463
|
setIsSaving(true)
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
464
|
+
try {
|
|
465
|
+
await runMutation({
|
|
466
|
+
operation: () => withScopedApiRequestHeaders(
|
|
467
|
+
buildOptimisticLockHeader(action.updatedAt),
|
|
468
|
+
() => apiCallOrThrow<{ ok: boolean; error?: string }>(
|
|
469
|
+
`/api/inbox_ops/proposals/${action.proposalId}/actions/${action.id}`,
|
|
470
|
+
{ method: 'PATCH', body: JSON.stringify({ payload: finalPayload }) },
|
|
471
|
+
),
|
|
471
472
|
),
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
})
|
|
475
|
-
if (result?.ok && result.result?.ok) {
|
|
473
|
+
context: {},
|
|
474
|
+
})
|
|
476
475
|
flash(t('inbox_ops.edit_dialog.saved', 'Action updated successfully'), 'success')
|
|
477
476
|
onSaved()
|
|
478
477
|
onClose()
|
|
479
|
-
}
|
|
480
|
-
|
|
478
|
+
} catch (error) {
|
|
479
|
+
if (extractOptimisticLockConflict(error)) {
|
|
480
|
+
onClose()
|
|
481
|
+
return
|
|
482
|
+
}
|
|
483
|
+
const message = error instanceof Error ? error.message.trim() : ''
|
|
484
|
+
flash(message || t('inbox_ops.flash.save_failed', 'Failed to save'), 'error')
|
|
485
|
+
} finally {
|
|
486
|
+
setIsSaving(false)
|
|
481
487
|
}
|
|
482
|
-
setIsSaving(false)
|
|
483
488
|
}, [action, payload, jsonMode, jsonText, t, onSaved, onClose, runMutation])
|
|
484
489
|
|
|
485
490
|
React.useEffect(() => {
|
|
@@ -85,6 +85,21 @@ export async function POST(req: Request) {
|
|
|
85
85
|
},
|
|
86
86
|
},
|
|
87
87
|
).catch(() => undefined)
|
|
88
|
+
// Queued once here rather than from a partition: the purge deletes every vector for the
|
|
89
|
+
// scope, so emitting it from inside the fan-out would race the per-record vectorize jobs
|
|
90
|
+
// its siblings are already queueing and drop vectors this run had just rebuilt.
|
|
91
|
+
if (force && partitions.length > 1) {
|
|
92
|
+
try {
|
|
93
|
+
await bus.emitEvent(
|
|
94
|
+
'query_index.vectorize_purge',
|
|
95
|
+
{ entityType, tenantId: auth.tenantId ?? null, organizationId: auth.orgId ?? null },
|
|
96
|
+
{ persistent: true, deliverInline: false },
|
|
97
|
+
)
|
|
98
|
+
} catch {
|
|
99
|
+
// Best effort: a failed purge leaves stale vectors, which the orphan sweep still prunes.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
88
103
|
try {
|
|
89
104
|
await Promise.all(
|
|
90
105
|
partitions.map((part) => {
|