@open-mercato/core 0.6.6-develop.6257.1.6d0af84d26 → 0.6.6-develop.6290.1.4bb5a8ba3f
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 +2 -2
- package/dist/generated/entities/carrier_shipment_idempotency_key/index.js +19 -0
- package/dist/generated/entities/carrier_shipment_idempotency_key/index.js.map +7 -0
- package/dist/generated/entities.ids.generated.js +2 -1
- package/dist/generated/entities.ids.generated.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +10 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/catalog/lib/productResolution.js +45 -0
- package/dist/modules/catalog/lib/productResolution.js.map +7 -0
- package/dist/modules/communication_channels/workers/channel-import-history.js +1 -1
- package/dist/modules/communication_channels/workers/channel-import-history.js.map +2 -2
- package/dist/modules/currencies/api/fetch-configs/route.js +34 -0
- package/dist/modules/currencies/api/fetch-configs/route.js.map +2 -2
- package/dist/modules/currencies/components/CurrencyFetchingConfig.js +17 -15
- package/dist/modules/currencies/components/CurrencyFetchingConfig.js.map +2 -2
- package/dist/modules/customers/api/companies/[id]/route.js +176 -161
- package/dist/modules/customers/api/companies/[id]/route.js.map +2 -2
- package/dist/modules/customers/api/people/[id]/route.js +75 -59
- package/dist/modules/customers/api/people/[id]/route.js.map +2 -2
- package/dist/modules/customers/backend/config/customers/pipeline-stages/page.js +17 -10
- package/dist/modules/customers/backend/config/customers/pipeline-stages/page.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivitiesAddNewMenu.js +6 -4
- package/dist/modules/customers/components/detail/ActivitiesAddNewMenu.js.map +2 -2
- package/dist/modules/customers/components/detail/AiActionChips.js +5 -2
- package/dist/modules/customers/components/detail/AiActionChips.js.map +2 -2
- package/dist/modules/customers/components/detail/schedule/ParticipantsField.js +19 -21
- package/dist/modules/customers/components/detail/schedule/ParticipantsField.js.map +2 -2
- package/dist/modules/data_sync/api/runs/[id]/cancel.js +1 -1
- package/dist/modules/data_sync/api/runs/[id]/cancel.js.map +2 -2
- package/dist/modules/data_sync/backend/data-sync/page.js +9 -45
- package/dist/modules/data_sync/backend/data-sync/page.js.map +2 -2
- package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js +11 -17
- package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js.map +2 -2
- package/dist/modules/data_sync/components/IntegrationScheduleTab.js +12 -10
- package/dist/modules/data_sync/components/IntegrationScheduleTab.js.map +2 -2
- package/dist/modules/data_sync/lib/id-mapping.js +46 -1
- package/dist/modules/data_sync/lib/id-mapping.js.map +2 -2
- package/dist/modules/data_sync/lib/sync-engine.js +2 -2
- package/dist/modules/data_sync/lib/sync-engine.js.map +2 -2
- package/dist/modules/data_sync/lib/syncRunStatus.js +30 -0
- package/dist/modules/data_sync/lib/syncRunStatus.js.map +7 -0
- package/dist/modules/data_sync/workers/sync-scheduled.js +13 -14
- package/dist/modules/data_sync/workers/sync-scheduled.js.map +2 -2
- package/dist/modules/directory/components/TenantSelect.js +29 -16
- package/dist/modules/directory/components/TenantSelect.js.map +2 -2
- package/dist/modules/entities/api/records.js +6 -0
- package/dist/modules/entities/api/records.js.map +2 -2
- package/dist/modules/feature_toggles/api/global/route.js +17 -15
- package/dist/modules/feature_toggles/api/global/route.js.map +2 -2
- package/dist/modules/feature_toggles/api/openapi.js +2 -2
- package/dist/modules/feature_toggles/api/openapi.js.map +2 -2
- package/dist/modules/feature_toggles/commands/global.js +14 -0
- package/dist/modules/feature_toggles/commands/global.js.map +2 -2
- package/dist/modules/inbox_ops/backend/inbox-ops/page.js +6 -0
- package/dist/modules/inbox_ops/backend/inbox-ops/page.js.map +2 -2
- package/dist/modules/messages/components/message-detail/hooks/useMessageDetails.js +3 -1
- package/dist/modules/messages/components/message-detail/hooks/useMessageDetails.js.map +2 -2
- package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsActions.js +12 -5
- package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsActions.js.map +2 -2
- package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsQueries.js +10 -1
- package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsQueries.js.map +2 -2
- package/dist/modules/payment_gateways/api/cancel/route.js +5 -0
- package/dist/modules/payment_gateways/api/cancel/route.js.map +2 -2
- package/dist/modules/payment_gateways/api/capture/route.js +5 -0
- package/dist/modules/payment_gateways/api/capture/route.js.map +2 -2
- package/dist/modules/payment_gateways/api/refund/route.js +5 -0
- package/dist/modules/payment_gateways/api/refund/route.js.map +2 -2
- package/dist/modules/payment_gateways/lib/gateway-service.js +52 -25
- package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
- package/dist/modules/payment_gateways/lib/status-machine.js +13 -0
- package/dist/modules/payment_gateways/lib/status-machine.js.map +2 -2
- package/dist/modules/perspectives/api/[tableId]/route.js +8 -2
- package/dist/modules/perspectives/api/[tableId]/route.js.map +2 -2
- package/dist/modules/perspectives/data/entities.js +33 -3
- package/dist/modules/perspectives/data/entities.js.map +2 -2
- package/dist/modules/perspectives/migrations/Migration20260619120000_perspectives_live_row_uniqueness.js +63 -0
- package/dist/modules/perspectives/migrations/Migration20260619120000_perspectives_live_row_uniqueness.js.map +7 -0
- package/dist/modules/perspectives/services/perspectiveService.js +17 -0
- package/dist/modules/perspectives/services/perspectiveService.js.map +2 -2
- package/dist/modules/planner/commands/availability-weekly.js +33 -1
- package/dist/modules/planner/commands/availability-weekly.js.map +2 -2
- package/dist/modules/planner/components/AvailabilityRulesEditor.js +14 -2
- package/dist/modules/planner/components/AvailabilityRulesEditor.js.map +2 -2
- package/dist/modules/portal/frontend/[orgSlug]/portal/dashboard/hiddenWidgetsStorage.js +35 -0
- package/dist/modules/portal/frontend/[orgSlug]/portal/dashboard/hiddenWidgetsStorage.js.map +7 -0
- package/dist/modules/portal/frontend/[orgSlug]/portal/dashboard/page.js +18 -19
- package/dist/modules/portal/frontend/[orgSlug]/portal/dashboard/page.js.map +2 -2
- package/dist/modules/progress/api/active/route.js +1 -1
- package/dist/modules/progress/api/active/route.js.map +2 -2
- package/dist/modules/progress/lib/progressService.js.map +1 -1
- package/dist/modules/progress/lib/progressServiceImpl.js +33 -8
- package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
- package/dist/modules/query_index/api/status.js +19 -24
- package/dist/modules/query_index/api/status.js.map +3 -3
- package/dist/modules/query_index/lib/coverage.js +35 -0
- package/dist/modules/query_index/lib/coverage.js.map +2 -2
- package/dist/modules/query_index/lib/engine.js +11 -6
- package/dist/modules/query_index/lib/engine.js.map +2 -2
- package/dist/modules/resources/api/resource-types.js +5 -37
- package/dist/modules/resources/api/resource-types.js.map +2 -2
- package/dist/modules/resources/api/resourceTypeCounts.js +35 -0
- package/dist/modules/resources/api/resourceTypeCounts.js.map +7 -0
- package/dist/modules/resources/backend/resources/resource-types/[id]/edit/page.js +1 -1
- package/dist/modules/resources/backend/resources/resource-types/[id]/edit/page.js.map +1 -1
- package/dist/modules/resources/backend/resources/resource-types/page.js +2 -1
- package/dist/modules/resources/backend/resources/resource-types/page.js.map +2 -2
- package/dist/modules/sales/components/documents/ReturnDialog.js +4 -1
- package/dist/modules/sales/components/documents/ReturnDialog.js.map +2 -2
- package/dist/modules/sales/components/documents/ShipmentDialog.js +17 -2
- package/dist/modules/sales/components/documents/ShipmentDialog.js.map +2 -2
- package/dist/modules/sales/di.js +4 -0
- package/dist/modules/sales/di.js.map +2 -2
- package/dist/modules/sales/services/salesOrderService.js +26 -0
- package/dist/modules/sales/services/salesOrderService.js.map +7 -0
- package/dist/modules/shipping_carriers/api/shipments/route.js +8 -0
- package/dist/modules/shipping_carriers/api/shipments/route.js.map +2 -2
- package/dist/modules/shipping_carriers/data/entities.js +38 -0
- package/dist/modules/shipping_carriers/data/entities.js.map +2 -2
- package/dist/modules/shipping_carriers/data/validators.js +2 -1
- package/dist/modules/shipping_carriers/data/validators.js.map +2 -2
- package/dist/modules/shipping_carriers/lib/shipment-idempotency.js +76 -0
- package/dist/modules/shipping_carriers/lib/shipment-idempotency.js.map +7 -0
- package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/shipmentApi.js +2 -1
- package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/shipmentApi.js.map +2 -2
- package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/useShipmentWizard.js +20 -2
- package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/useShipmentWizard.js.map +2 -2
- package/dist/modules/shipping_carriers/lib/shipping-service.js +82 -43
- package/dist/modules/shipping_carriers/lib/shipping-service.js.map +2 -2
- package/dist/modules/shipping_carriers/migrations/Migration20260618173616_shipping_carriers.js +14 -0
- package/dist/modules/shipping_carriers/migrations/Migration20260618173616_shipping_carriers.js.map +7 -0
- package/dist/modules/staff/lib/timesheets-ui/ListView.js +15 -6
- package/dist/modules/staff/lib/timesheets-ui/ListView.js.map +2 -2
- package/dist/modules/translations/components/TranslationManager.js +9 -18
- package/dist/modules/translations/components/TranslationManager.js.map +2 -2
- package/generated/entities/carrier_shipment_idempotency_key/index.ts +8 -0
- package/generated/entities.ids.generated.ts +2 -1
- package/generated/entity-fields-registry.ts +10 -0
- package/package.json +9 -8
- package/src/modules/catalog/lib/productResolution.ts +75 -0
- package/src/modules/communication_channels/workers/channel-import-history.ts +1 -1
- package/src/modules/currencies/api/fetch-configs/route.ts +36 -0
- package/src/modules/currencies/components/CurrencyFetchingConfig.tsx +20 -17
- package/src/modules/currencies/i18n/de.json +2 -1
- package/src/modules/currencies/i18n/en.json +2 -1
- package/src/modules/currencies/i18n/es.json +2 -1
- package/src/modules/currencies/i18n/pl.json +2 -1
- package/src/modules/customers/api/companies/[id]/route.ts +214 -189
- package/src/modules/customers/api/people/[id]/route.ts +106 -80
- package/src/modules/customers/backend/config/customers/pipeline-stages/page.tsx +19 -12
- package/src/modules/customers/components/detail/ActivitiesAddNewMenu.tsx +8 -6
- package/src/modules/customers/components/detail/AiActionChips.tsx +6 -3
- package/src/modules/customers/components/detail/schedule/ParticipantsField.tsx +16 -18
- package/src/modules/data_sync/api/runs/[id]/cancel.ts +1 -1
- package/src/modules/data_sync/backend/data-sync/page.tsx +11 -58
- package/src/modules/data_sync/backend/data-sync/runs/[id]/page.tsx +13 -20
- package/src/modules/data_sync/components/IntegrationScheduleTab.tsx +10 -8
- package/src/modules/data_sync/lib/id-mapping.ts +58 -1
- package/src/modules/data_sync/lib/sync-engine.ts +2 -2
- package/src/modules/data_sync/lib/syncRunStatus.ts +39 -0
- package/src/modules/data_sync/workers/sync-scheduled.ts +13 -14
- package/src/modules/directory/components/TenantSelect.tsx +33 -16
- package/src/modules/entities/api/records.ts +14 -0
- package/src/modules/feature_toggles/api/global/route.ts +16 -14
- package/src/modules/feature_toggles/api/openapi.ts +2 -2
- package/src/modules/feature_toggles/commands/global.ts +15 -0
- package/src/modules/inbox_ops/backend/inbox-ops/page.tsx +7 -0
- package/src/modules/messages/components/message-detail/hooks/useMessageDetails.ts +2 -0
- package/src/modules/messages/components/message-detail/hooks/useMessageDetailsActions.ts +14 -4
- package/src/modules/messages/components/message-detail/hooks/useMessageDetailsQueries.ts +15 -1
- package/src/modules/payment_gateways/api/cancel/route.ts +5 -0
- package/src/modules/payment_gateways/api/capture/route.ts +5 -0
- package/src/modules/payment_gateways/api/refund/route.ts +5 -0
- package/src/modules/payment_gateways/lib/gateway-service.ts +58 -25
- package/src/modules/payment_gateways/lib/status-machine.ts +16 -0
- package/src/modules/perspectives/api/[tableId]/route.ts +8 -2
- package/src/modules/perspectives/data/entities.ts +43 -3
- package/src/modules/perspectives/migrations/.snapshot-open-mercato.json +69 -25
- package/src/modules/perspectives/migrations/Migration20260619120000_perspectives_live_row_uniqueness.ts +65 -0
- package/src/modules/perspectives/services/perspectiveService.ts +17 -0
- package/src/modules/planner/commands/availability-weekly.ts +48 -1
- package/src/modules/planner/components/AvailabilityRulesEditor.tsx +31 -10
- package/src/modules/portal/frontend/[orgSlug]/portal/dashboard/hiddenWidgetsStorage.ts +36 -0
- package/src/modules/portal/frontend/[orgSlug]/portal/dashboard/page.tsx +22 -23
- package/src/modules/progress/api/active/route.ts +1 -1
- package/src/modules/progress/lib/progressService.ts +2 -2
- package/src/modules/progress/lib/progressServiceImpl.ts +33 -8
- package/src/modules/query_index/api/status.ts +28 -32
- package/src/modules/query_index/lib/coverage.ts +63 -0
- package/src/modules/query_index/lib/engine.ts +37 -24
- package/src/modules/resources/api/resource-types.ts +4 -44
- package/src/modules/resources/api/resourceTypeCounts.ts +74 -0
- package/src/modules/resources/backend/resources/resource-types/[id]/edit/page.tsx +1 -1
- package/src/modules/resources/backend/resources/resource-types/page.tsx +1 -0
- package/src/modules/sales/components/documents/ReturnDialog.tsx +4 -1
- package/src/modules/sales/components/documents/ShipmentDialog.tsx +20 -2
- package/src/modules/sales/di.ts +6 -0
- package/src/modules/sales/services/salesOrderService.ts +38 -0
- package/src/modules/shipping_carriers/api/shipments/route.ts +8 -0
- package/src/modules/shipping_carriers/data/entities.ts +33 -0
- package/src/modules/shipping_carriers/data/validators.ts +1 -0
- package/src/modules/shipping_carriers/i18n/de.json +1 -0
- package/src/modules/shipping_carriers/i18n/en.json +1 -0
- package/src/modules/shipping_carriers/i18n/es.json +1 -0
- package/src/modules/shipping_carriers/i18n/pl.json +1 -0
- package/src/modules/shipping_carriers/lib/shipment-idempotency.ts +119 -0
- package/src/modules/shipping_carriers/lib/shipment-wizard/hooks/shipmentApi.ts +3 -1
- package/src/modules/shipping_carriers/lib/shipment-wizard/hooks/useShipmentWizard.ts +24 -2
- package/src/modules/shipping_carriers/lib/shipping-service.ts +87 -43
- package/src/modules/shipping_carriers/migrations/.snapshot-open-mercato.json +197 -0
- package/src/modules/shipping_carriers/migrations/Migration20260618173616_shipping_carriers.ts +14 -0
- package/src/modules/staff/i18n/de.json +1 -0
- package/src/modules/staff/i18n/en.json +1 -0
- package/src/modules/staff/i18n/es.json +1 -0
- package/src/modules/staff/i18n/pl.json +1 -0
- package/src/modules/staff/lib/timesheets-ui/ListView.tsx +15 -7
- package/src/modules/translations/components/TranslationManager.tsx +17 -24
- package/src/modules/translations/i18n/de.json +1 -0
- package/src/modules/translations/i18n/en.json +1 -0
- package/src/modules/translations/i18n/es.json +1 -0
- package/src/modules/translations/i18n/pl.json +1 -0
|
@@ -2,17 +2,27 @@ import type { CommandHandler } from '@open-mercato/shared/lib/commands'
|
|
|
2
2
|
import { registerCommand } from '@open-mercato/shared/lib/commands'
|
|
3
3
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
4
|
import { parseAvailabilityRuleWindow } from '@open-mercato/core/modules/planner/lib/availabilitySchedule'
|
|
5
|
-
import { PlannerAvailabilityRule } from '../data/entities'
|
|
5
|
+
import { PlannerAvailabilityRule, PlannerAvailabilityRuleSet } from '../data/entities'
|
|
6
6
|
import {
|
|
7
7
|
plannerAvailabilityWeeklyReplaceSchema,
|
|
8
8
|
type PlannerAvailabilityWeeklyReplaceInput,
|
|
9
9
|
} from '../data/validators'
|
|
10
10
|
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
11
11
|
import type { PlannerAvailabilityKind, PlannerAvailabilitySubjectType } from '../data/entities'
|
|
12
|
+
import {
|
|
13
|
+
enforceCommandOptimisticLock,
|
|
14
|
+
enforceRecordGoneIsConflict,
|
|
15
|
+
} from '@open-mercato/shared/lib/crud/optimistic-lock-command'
|
|
12
16
|
import { ensureOrganizationScope, ensureTenantScope, extractUndoPayload } from './shared'
|
|
13
17
|
|
|
14
18
|
const AVAILABILITY_RULE_RESOURCE_KIND = 'planner.availability.rule'
|
|
15
19
|
|
|
20
|
+
// Canonical resource kind for the parent rule set, matching the tag the CRUD
|
|
21
|
+
// factory derives for `planner.availability-rule-sets.*` commands. Weekly
|
|
22
|
+
// replace mutates the rule set's child `availability_rules`, so the parent is
|
|
23
|
+
// the optimistic-lock consistency boundary (document-aggregate pattern).
|
|
24
|
+
const AVAILABILITY_RULE_SET_RESOURCE_KIND = 'planner.availability.rule.set'
|
|
25
|
+
|
|
16
26
|
const DAY_CODES = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']
|
|
17
27
|
|
|
18
28
|
type AvailabilityRuleSnapshot = {
|
|
@@ -164,6 +174,38 @@ const replaceWeeklyAvailabilityCommand: CommandHandler<PlannerAvailabilityWeekly
|
|
|
164
174
|
const now = new Date()
|
|
165
175
|
|
|
166
176
|
await em.transactional(async (trx) => {
|
|
177
|
+
// The weekly rules of a rule set are a sub-resource of that rule set: the
|
|
178
|
+
// parent is the optimistic-lock consistency boundary. Guard the parent's
|
|
179
|
+
// version (so a stale weekly save loses to a concurrent rule-set
|
|
180
|
+
// change/delete) and bump its `updated_at` after the replace (so a
|
|
181
|
+
// concurrent rule-set delete/update with a stale token conflicts). See #2927.
|
|
182
|
+
let ruleSet: PlannerAvailabilityRuleSet | null = null
|
|
183
|
+
if (parsed.subjectType === 'ruleset') {
|
|
184
|
+
ruleSet = await trx.findOne(PlannerAvailabilityRuleSet, {
|
|
185
|
+
id: parsed.subjectId,
|
|
186
|
+
tenantId: parsed.tenantId,
|
|
187
|
+
organizationId: parsed.organizationId,
|
|
188
|
+
deletedAt: null,
|
|
189
|
+
})
|
|
190
|
+
if (ruleSet) {
|
|
191
|
+
enforceCommandOptimisticLock({
|
|
192
|
+
resourceKind: AVAILABILITY_RULE_SET_RESOURCE_KIND,
|
|
193
|
+
resourceId: ruleSet.id,
|
|
194
|
+
current: ruleSet.updatedAt,
|
|
195
|
+
request: ctx.request ?? null,
|
|
196
|
+
})
|
|
197
|
+
} else {
|
|
198
|
+
// The rule set was deleted concurrently. When the client opted into
|
|
199
|
+
// optimistic locking, surface the unified conflict instead of
|
|
200
|
+
// silently writing orphan rules; otherwise preserve legacy behavior.
|
|
201
|
+
enforceRecordGoneIsConflict({
|
|
202
|
+
resourceKind: AVAILABILITY_RULE_SET_RESOURCE_KIND,
|
|
203
|
+
resourceId: parsed.subjectId,
|
|
204
|
+
request: ctx.request ?? null,
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
167
209
|
const existing = await trx.find(PlannerAvailabilityRule, {
|
|
168
210
|
tenantId: parsed.tenantId,
|
|
169
211
|
organizationId: parsed.organizationId,
|
|
@@ -207,6 +249,11 @@ const replaceWeeklyAvailabilityCommand: CommandHandler<PlannerAvailabilityWeekly
|
|
|
207
249
|
trx.persist(record)
|
|
208
250
|
})
|
|
209
251
|
|
|
252
|
+
if (ruleSet) {
|
|
253
|
+
ruleSet.updatedAt = now
|
|
254
|
+
trx.persist(ruleSet)
|
|
255
|
+
}
|
|
256
|
+
|
|
210
257
|
await trx.flush()
|
|
211
258
|
})
|
|
212
259
|
|
|
@@ -857,21 +857,31 @@ export function AvailabilityRulesEditor({
|
|
|
857
857
|
if (!subjectIdForRules) return
|
|
858
858
|
if (weeklyHasErrors) return
|
|
859
859
|
|
|
860
|
+
// Weekly rules of a rule set are a sub-resource of that rule set. Send the
|
|
861
|
+
// parent's expected version so a concurrent rule-set change/delete is
|
|
862
|
+
// detected, and refresh it afterwards because the server bumps the rule
|
|
863
|
+
// set's `updated_at` on a successful replace (#2927).
|
|
864
|
+
const parentRuleSet = subjectForRules === 'ruleset'
|
|
865
|
+
? (ruleSets.find((entry) => entry.id === subjectIdForRules) ?? null)
|
|
866
|
+
: null
|
|
867
|
+
|
|
860
868
|
const shouldSkipRefresh = Boolean(options?.skipRefresh)
|
|
861
869
|
setIsWeeklyAutoSaving(options?.silentSuccess === true)
|
|
862
870
|
try {
|
|
863
871
|
const windows = buildWeeklyPayload(normalizeWeeklyWindows(weeklyWindowsRef.current))
|
|
864
872
|
await runMutation({
|
|
865
|
-
operation: () =>
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
873
|
+
operation: () => withOptimisticLockForRuleSet(parentRuleSet, () => (
|
|
874
|
+
apiCallOrThrow('/api/planner/availability-weekly', {
|
|
875
|
+
method: 'POST',
|
|
876
|
+
headers: { 'Content-Type': 'application/json' },
|
|
877
|
+
body: JSON.stringify({
|
|
878
|
+
subjectType: subjectForRules,
|
|
879
|
+
subjectId: subjectIdForRules,
|
|
880
|
+
timezone,
|
|
881
|
+
windows,
|
|
882
|
+
}),
|
|
883
|
+
}, { errorMessage: listLabels.saveWeeklyError })
|
|
884
|
+
)),
|
|
875
885
|
context: mutationContext,
|
|
876
886
|
mutationPayload: { action: 'save-weekly', subjectType: subjectForRules, subjectId: subjectIdForRules },
|
|
877
887
|
})
|
|
@@ -884,7 +894,13 @@ export function AvailabilityRulesEditor({
|
|
|
884
894
|
await refreshAvailability()
|
|
885
895
|
await refreshRuleSetRules()
|
|
886
896
|
}
|
|
897
|
+
if (parentRuleSet) {
|
|
898
|
+
await refreshRuleSets()
|
|
899
|
+
}
|
|
887
900
|
} catch (error) {
|
|
901
|
+
if (surfaceRecordConflict(error, t)) {
|
|
902
|
+
return
|
|
903
|
+
}
|
|
888
904
|
const message = error instanceof Error ? error.message : listLabels.saveWeeklyError
|
|
889
905
|
flash(message, 'error')
|
|
890
906
|
} finally {
|
|
@@ -895,9 +911,12 @@ export function AvailabilityRulesEditor({
|
|
|
895
911
|
listLabels.saveWeeklySuccess,
|
|
896
912
|
refreshAvailability,
|
|
897
913
|
refreshRuleSetRules,
|
|
914
|
+
refreshRuleSets,
|
|
915
|
+
ruleSets,
|
|
898
916
|
effectiveRulesetId,
|
|
899
917
|
subjectId,
|
|
900
918
|
subjectType,
|
|
919
|
+
t,
|
|
901
920
|
timezone,
|
|
902
921
|
usingRuleSet,
|
|
903
922
|
weeklyHasErrors,
|
|
@@ -1161,6 +1180,7 @@ export function AvailabilityRulesEditor({
|
|
|
1161
1180
|
refreshRuleSets,
|
|
1162
1181
|
onSuccess: () => flash(listLabels.ruleSetDeleteSuccess, 'success'),
|
|
1163
1182
|
onError: (error) => {
|
|
1183
|
+
if (surfaceRecordConflict(error, t)) return
|
|
1164
1184
|
console.error('planner.availability-rule-sets.delete', error)
|
|
1165
1185
|
const normalized = normalizeCrudServerError(error)
|
|
1166
1186
|
flash(normalized.message ?? listLabels.ruleSetDeleteError, 'error')
|
|
@@ -1178,6 +1198,7 @@ export function AvailabilityRulesEditor({
|
|
|
1178
1198
|
refreshRuleSets,
|
|
1179
1199
|
ruleSets,
|
|
1180
1200
|
rulesetId,
|
|
1201
|
+
t,
|
|
1181
1202
|
mutationContext,
|
|
1182
1203
|
runMutation,
|
|
1183
1204
|
])
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import {
|
|
2
|
+
readVersionedPreference,
|
|
3
|
+
writeVersionedPreference,
|
|
4
|
+
clearVersionedPreference,
|
|
5
|
+
} from '@open-mercato/shared/lib/browser/versionedPreference'
|
|
6
|
+
|
|
7
|
+
const LEGACY_HIDDEN_WIDGETS_KEY = 'om:portal:dashboard:hidden'
|
|
8
|
+
const HIDDEN_WIDGETS_KEY_PREFIX = 'om:portal:dashboard:hidden:v1:'
|
|
9
|
+
const HIDDEN_WIDGETS_VERSION = 1
|
|
10
|
+
|
|
11
|
+
function buildHiddenWidgetsKey(orgSlug: string, userId: string): string {
|
|
12
|
+
return `${HIDDEN_WIDGETS_KEY_PREFIX}${orgSlug}:${userId}`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isStringArray(value: unknown): value is string[] {
|
|
16
|
+
return Array.isArray(value) && value.every((id) => typeof id === 'string')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function loadHiddenWidgets(orgSlug: string, userId: string): Set<string> {
|
|
20
|
+
const hidden = readVersionedPreference<string[]>(
|
|
21
|
+
buildHiddenWidgetsKey(orgSlug, userId),
|
|
22
|
+
HIDDEN_WIDGETS_VERSION,
|
|
23
|
+
isStringArray,
|
|
24
|
+
[],
|
|
25
|
+
)
|
|
26
|
+
return new Set(hidden)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function saveHiddenWidgets(orgSlug: string, userId: string, hidden: Set<string>): void {
|
|
30
|
+
writeVersionedPreference(buildHiddenWidgetsKey(orgSlug, userId), HIDDEN_WIDGETS_VERSION, Array.from(hidden))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Drops the pre-scoping global key so stale preferences stop leaking across org/user contexts. */
|
|
34
|
+
export function clearLegacyHiddenWidgetsKey(): void {
|
|
35
|
+
clearVersionedPreference(LEGACY_HIDDEN_WIDGETS_KEY)
|
|
36
|
+
}
|
|
@@ -11,29 +11,14 @@ import { PortalEmptyState } from '@open-mercato/ui/portal/components/PortalEmpty
|
|
|
11
11
|
import { usePortalDashboardWidgets } from '@open-mercato/ui/portal/hooks/usePortalDashboardWidgets'
|
|
12
12
|
import { InjectionSpot } from '@open-mercato/ui/backend/injection/InjectionSpot'
|
|
13
13
|
import { PortalInjectionSpots } from '@open-mercato/ui/backend/injection/spotIds'
|
|
14
|
+
import {
|
|
15
|
+
loadHiddenWidgets,
|
|
16
|
+
saveHiddenWidgets,
|
|
17
|
+
clearLegacyHiddenWidgetsKey,
|
|
18
|
+
} from './hiddenWidgetsStorage'
|
|
14
19
|
|
|
15
20
|
type Props = { params: { orgSlug: string } }
|
|
16
21
|
|
|
17
|
-
const HIDDEN_WIDGETS_KEY = 'om:portal:dashboard:hidden'
|
|
18
|
-
|
|
19
|
-
function loadHiddenWidgets(): Set<string> {
|
|
20
|
-
try {
|
|
21
|
-
const raw = localStorage.getItem(HIDDEN_WIDGETS_KEY)
|
|
22
|
-
if (!raw) return new Set()
|
|
23
|
-
return new Set(JSON.parse(raw))
|
|
24
|
-
} catch {
|
|
25
|
-
return new Set()
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function saveHiddenWidgets(hidden: Set<string>) {
|
|
30
|
-
try {
|
|
31
|
-
localStorage.setItem(HIDDEN_WIDGETS_KEY, JSON.stringify(Array.from(hidden)))
|
|
32
|
-
} catch {
|
|
33
|
-
// best effort
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
22
|
function WidgetIcon({ className }: { className?: string }) {
|
|
38
23
|
return (
|
|
39
24
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
|
@@ -49,17 +34,31 @@ export default function PortalDashboardPage({ params }: Props) {
|
|
|
49
34
|
const { user, loading } = auth
|
|
50
35
|
|
|
51
36
|
const [editing, setEditing] = useState(false)
|
|
52
|
-
const [
|
|
37
|
+
const [hiddenWidgetsScopeKey, setHiddenWidgetsScopeKey] = useState<string | null>(null)
|
|
38
|
+
const [hiddenWidgets, setHiddenWidgets] = useState<Set<string>>(() => new Set())
|
|
53
39
|
|
|
54
40
|
const { widgets: dashboardWidgets, isLoading: widgetsLoading } = usePortalDashboardWidgets('portal:dashboard:sections' as any)
|
|
55
41
|
|
|
42
|
+
// Recompute synchronously during render (not in an effect) so a freshly
|
|
43
|
+
// resolved `user` never paints a frame with the previous scope's hidden set.
|
|
44
|
+
const activeHiddenWidgetsScopeKey = user ? `${params.orgSlug}:${user.id}` : null
|
|
45
|
+
if (activeHiddenWidgetsScopeKey !== hiddenWidgetsScopeKey) {
|
|
46
|
+
setHiddenWidgetsScopeKey(activeHiddenWidgetsScopeKey)
|
|
47
|
+
setHiddenWidgets(user ? loadHiddenWidgets(params.orgSlug, user.id) : new Set())
|
|
48
|
+
}
|
|
49
|
+
|
|
56
50
|
useEffect(() => {
|
|
57
51
|
if (!loading && !user) {
|
|
58
52
|
router.replace(`/${params.orgSlug}/portal/login`)
|
|
59
53
|
}
|
|
60
54
|
}, [loading, user, router, params.orgSlug])
|
|
61
55
|
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
clearLegacyHiddenWidgetsKey()
|
|
58
|
+
}, [])
|
|
59
|
+
|
|
62
60
|
const toggleWidget = useCallback((widgetId: string) => {
|
|
61
|
+
if (!user) return
|
|
63
62
|
setHiddenWidgets((prev) => {
|
|
64
63
|
const next = new Set(prev)
|
|
65
64
|
if (next.has(widgetId)) {
|
|
@@ -67,10 +66,10 @@ export default function PortalDashboardPage({ params }: Props) {
|
|
|
67
66
|
} else {
|
|
68
67
|
next.add(widgetId)
|
|
69
68
|
}
|
|
70
|
-
saveHiddenWidgets(next)
|
|
69
|
+
saveHiddenWidgets(params.orgSlug, user.id, next)
|
|
71
70
|
return next
|
|
72
71
|
})
|
|
73
|
-
}, [])
|
|
72
|
+
}, [params.orgSlug, user?.id])
|
|
74
73
|
|
|
75
74
|
const visibleWidgets = useMemo(
|
|
76
75
|
() => dashboardWidgets.filter((w) => !hiddenWidgets.has(w.metadata.id)),
|
|
@@ -20,7 +20,7 @@ export async function GET(req: Request) {
|
|
|
20
20
|
|
|
21
21
|
const ctx = { tenantId: auth.tenantId, organizationId: auth.orgId }
|
|
22
22
|
|
|
23
|
-
await progressService.markStaleJobsFailed(auth.tenantId)
|
|
23
|
+
await progressService.markStaleJobsFailed(auth.tenantId, undefined, auth.orgId)
|
|
24
24
|
|
|
25
25
|
const [jobs, recentlyCompleted] = await Promise.all([
|
|
26
26
|
progressService.getActiveJobs(ctx),
|
|
@@ -16,11 +16,11 @@ export interface ProgressService {
|
|
|
16
16
|
failJob(jobId: string, input: FailJobInput, ctx: ProgressServiceContext): Promise<ProgressJob>
|
|
17
17
|
cancelJob(jobId: string, ctx: ProgressServiceContext): Promise<ProgressJob>
|
|
18
18
|
markCancelled(jobId: string, ctx: ProgressServiceContext): Promise<ProgressJob>
|
|
19
|
-
isCancellationRequested(jobId: string, tenantId: string): Promise<boolean>
|
|
19
|
+
isCancellationRequested(jobId: string, tenantId: string, organizationId?: string | null): Promise<boolean>
|
|
20
20
|
getActiveJobs(ctx: ProgressServiceContext): Promise<ProgressJob[]>
|
|
21
21
|
getRecentlyCompletedJobs(ctx: ProgressServiceContext, sinceSeconds?: number): Promise<ProgressJob[]>
|
|
22
22
|
getJob(jobId: string, ctx: ProgressServiceContext): Promise<ProgressJob | null>
|
|
23
|
-
markStaleJobsFailed(tenantId: string, timeoutSeconds?: number): Promise<number>
|
|
23
|
+
markStaleJobsFailed(tenantId: string, timeoutSeconds?: number, organizationId?: string | null): Promise<number>
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export const HEARTBEAT_INTERVAL_MS = 5000
|
|
@@ -54,7 +54,11 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
|
|
|
54
54
|
},
|
|
55
55
|
|
|
56
56
|
async startJob(jobId, ctx) {
|
|
57
|
-
const job = await em.findOneOrFail(ProgressJob, {
|
|
57
|
+
const job = await em.findOneOrFail(ProgressJob, {
|
|
58
|
+
id: jobId,
|
|
59
|
+
tenantId: ctx.tenantId,
|
|
60
|
+
...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),
|
|
61
|
+
})
|
|
58
62
|
if (job.status === 'cancelled') {
|
|
59
63
|
return job
|
|
60
64
|
}
|
|
@@ -120,7 +124,11 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
|
|
|
120
124
|
},
|
|
121
125
|
|
|
122
126
|
async incrementProgress(jobId, delta, ctx) {
|
|
123
|
-
const job = await em.findOneOrFail(ProgressJob, {
|
|
127
|
+
const job = await em.findOneOrFail(ProgressJob, {
|
|
128
|
+
id: jobId,
|
|
129
|
+
tenantId: ctx.tenantId,
|
|
130
|
+
...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),
|
|
131
|
+
})
|
|
124
132
|
if (job.status === 'completed' || job.status === 'failed' || job.status === 'cancelled') {
|
|
125
133
|
return job
|
|
126
134
|
}
|
|
@@ -147,7 +155,11 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
|
|
|
147
155
|
},
|
|
148
156
|
|
|
149
157
|
async completeJob(jobId, input, ctx) {
|
|
150
|
-
const job = await em.findOne(ProgressJob, {
|
|
158
|
+
const job = await em.findOne(ProgressJob, {
|
|
159
|
+
id: jobId,
|
|
160
|
+
tenantId: ctx.tenantId,
|
|
161
|
+
...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),
|
|
162
|
+
})
|
|
151
163
|
if (!job) throw new Error(`Job ${jobId} not found`)
|
|
152
164
|
if (job.status === 'cancelled') {
|
|
153
165
|
return job
|
|
@@ -174,7 +186,11 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
|
|
|
174
186
|
},
|
|
175
187
|
|
|
176
188
|
async failJob(jobId, input, ctx) {
|
|
177
|
-
const job = await em.findOne(ProgressJob, {
|
|
189
|
+
const job = await em.findOne(ProgressJob, {
|
|
190
|
+
id: jobId,
|
|
191
|
+
tenantId: ctx.tenantId,
|
|
192
|
+
...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),
|
|
193
|
+
})
|
|
178
194
|
if (!job) throw new Error(`Job ${jobId} not found`)
|
|
179
195
|
if (job.status === 'cancelled') {
|
|
180
196
|
return job
|
|
@@ -226,7 +242,11 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
|
|
|
226
242
|
},
|
|
227
243
|
|
|
228
244
|
async markCancelled(jobId, ctx) {
|
|
229
|
-
const job = await em.findOne(ProgressJob, {
|
|
245
|
+
const job = await em.findOne(ProgressJob, {
|
|
246
|
+
id: jobId,
|
|
247
|
+
tenantId: ctx.tenantId,
|
|
248
|
+
...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),
|
|
249
|
+
})
|
|
230
250
|
if (!job) throw new Error(`Job ${jobId} not found`)
|
|
231
251
|
if (job.status === 'cancelled') {
|
|
232
252
|
return job
|
|
@@ -249,8 +269,12 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
|
|
|
249
269
|
return job
|
|
250
270
|
},
|
|
251
271
|
|
|
252
|
-
async isCancellationRequested(jobId, tenantId) {
|
|
253
|
-
const job = await findOneWithDecryption(em, ProgressJob, {
|
|
272
|
+
async isCancellationRequested(jobId, tenantId, organizationId) {
|
|
273
|
+
const job = await findOneWithDecryption(em, ProgressJob, {
|
|
274
|
+
id: jobId,
|
|
275
|
+
tenantId,
|
|
276
|
+
...(organizationId ? { organizationId } : {}),
|
|
277
|
+
})
|
|
254
278
|
return job?.cancelRequestedAt != null
|
|
255
279
|
},
|
|
256
280
|
|
|
@@ -288,11 +312,12 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
|
|
|
288
312
|
})
|
|
289
313
|
},
|
|
290
314
|
|
|
291
|
-
async markStaleJobsFailed(tenantId: string, timeoutSeconds = STALE_JOB_TIMEOUT_SECONDS) {
|
|
315
|
+
async markStaleJobsFailed(tenantId: string, timeoutSeconds = STALE_JOB_TIMEOUT_SECONDS, organizationId?: string | null) {
|
|
292
316
|
const cutoff = new Date(Date.now() - timeoutSeconds * 1000)
|
|
293
317
|
|
|
294
318
|
const staleJobs = await em.find(ProgressJob, {
|
|
295
319
|
tenantId,
|
|
320
|
+
...(organizationId ? { organizationId } : {}),
|
|
296
321
|
status: 'running',
|
|
297
322
|
$or: [
|
|
298
323
|
{ heartbeatAt: { $lt: cutoff } },
|
|
@@ -4,7 +4,8 @@ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
|
4
4
|
import { getEntityIds } from '@open-mercato/shared/lib/encryption/entityIds'
|
|
5
5
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
6
6
|
import { sql } from 'kysely'
|
|
7
|
-
import {
|
|
7
|
+
import { readCoverageSnapshots, refreshCoverageSnapshot } from '../lib/coverage'
|
|
8
|
+
import { mapWithConcurrency } from '@open-mercato/shared/lib/query/bounded-decrypt'
|
|
8
9
|
import type { FullTextSearchStrategy } from '@open-mercato/search/strategies'
|
|
9
10
|
import type { SearchModuleConfig } from '@open-mercato/shared/modules/search'
|
|
10
11
|
import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
@@ -146,6 +147,7 @@ export async function GET(req: Request) {
|
|
|
146
147
|
|
|
147
148
|
const HEARTBEAT_STALE_MS = 60_000
|
|
148
149
|
const COVERAGE_STALE_MS = 60_000
|
|
150
|
+
const COVERAGE_REFRESH_CONCURRENCY = 8
|
|
149
151
|
|
|
150
152
|
async function fetchJobSummary(entityType: string, tenantIdParam: string | null, organizationIdParam: string | null) {
|
|
151
153
|
try {
|
|
@@ -288,40 +290,31 @@ export async function GET(req: Request) {
|
|
|
288
290
|
return Number.isFinite(parsed) ? parsed : null
|
|
289
291
|
}
|
|
290
292
|
|
|
291
|
-
const
|
|
293
|
+
const coverageScope = {
|
|
294
|
+
tenantId: tenantId ?? null,
|
|
295
|
+
organizationId,
|
|
296
|
+
withDeleted: false,
|
|
297
|
+
} as const
|
|
292
298
|
const entitiesNeedingRefresh = new Set<string>()
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
if (forceRefresh || stale) {
|
|
309
|
-
await refreshCoverageSnapshot(em, scope).catch(() => undefined)
|
|
310
|
-
snapshot = await readCoverageSnapshot(db, scope)
|
|
311
|
-
}
|
|
312
|
-
const finalRefreshed = snapshot?.refreshed_at instanceof Date
|
|
313
|
-
? snapshot.refreshed_at
|
|
314
|
-
: snapshot?.refreshed_at
|
|
315
|
-
? new Date(snapshot.refreshed_at)
|
|
316
|
-
: null
|
|
317
|
-
if (!snapshot || !finalRefreshed || (Date.now() - finalRefreshed.getTime() > COVERAGE_STALE_MS)) {
|
|
318
|
-
entitiesNeedingRefresh.add(entityId)
|
|
319
|
-
}
|
|
320
|
-
return snapshot
|
|
321
|
-
}
|
|
322
|
-
coverageSnapshots.push(await ensureSnapshot())
|
|
299
|
+
|
|
300
|
+
// Read every entity's coverage snapshot in a single batched query. This endpoint is
|
|
301
|
+
// polled by the status table every few seconds, so the poll path must stay read-cheap:
|
|
302
|
+
// stale snapshots are refreshed asynchronously via the query_index.coverage.refresh
|
|
303
|
+
// event emitted below, never inline per entity.
|
|
304
|
+
const snapshotByEntity = await readCoverageSnapshots(db, { entityTypes: entityIds, ...coverageScope })
|
|
305
|
+
|
|
306
|
+
// An explicit refresh action (?refresh) is allowed to block: recompute every entity's
|
|
307
|
+
// coverage with bounded concurrency, then re-read the freshly written snapshots.
|
|
308
|
+
if (forceRefresh && entityIds.length > 0) {
|
|
309
|
+
await mapWithConcurrency(entityIds, COVERAGE_REFRESH_CONCURRENCY, (entityId) =>
|
|
310
|
+
refreshCoverageSnapshot(em, { entityType: entityId, ...coverageScope }).catch(() => undefined),
|
|
311
|
+
)
|
|
312
|
+
const refreshed = await readCoverageSnapshots(db, { entityTypes: entityIds, ...coverageScope })
|
|
313
|
+
for (const [entityId, snapshot] of refreshed) snapshotByEntity.set(entityId, snapshot)
|
|
323
314
|
}
|
|
324
315
|
|
|
316
|
+
const coverageSnapshots = entityIds.map((entityId) => snapshotByEntity.get(entityId) ?? null)
|
|
317
|
+
|
|
325
318
|
const jobs = await Promise.all(entityIds.map((eid) => fetchJobSummary(eid, tenantId, organizationId)))
|
|
326
319
|
|
|
327
320
|
const items: any[] = []
|
|
@@ -467,6 +460,9 @@ export async function GET(req: Request) {
|
|
|
467
460
|
|
|
468
461
|
const response = NextResponse.json({ items, errors, logs })
|
|
469
462
|
const partial = items.find((item) => {
|
|
463
|
+
// Coverage not computed yet (no snapshot) — pending an async refresh, not a partial
|
|
464
|
+
// index. Do not raise the partial-index warning while counts are still unknown.
|
|
465
|
+
if (item.baseCount == null && item.indexCount == null) return false
|
|
470
466
|
if (item.baseCount == null || item.indexCount == null) return true
|
|
471
467
|
return item.baseCount !== item.indexCount
|
|
472
468
|
})
|
|
@@ -16,6 +16,19 @@ type CoverageRow = {
|
|
|
16
16
|
refreshed_at: Date | string | null
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
export type CoverageSnapshot = CoverageRow & {
|
|
20
|
+
baseCount: number
|
|
21
|
+
indexedCount: number
|
|
22
|
+
vectorIndexedCount: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type CoverageBatchScope = {
|
|
26
|
+
entityTypes: readonly string[]
|
|
27
|
+
tenantId?: string | null
|
|
28
|
+
organizationId?: string | null
|
|
29
|
+
withDeleted?: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
19
32
|
export type CoverageAdjustment = {
|
|
20
33
|
entityType: string
|
|
21
34
|
tenantId: string | null
|
|
@@ -186,6 +199,56 @@ export async function readCoverageSnapshot(
|
|
|
186
199
|
}
|
|
187
200
|
}
|
|
188
201
|
|
|
202
|
+
export async function readCoverageSnapshots(
|
|
203
|
+
db: Kysely<any>,
|
|
204
|
+
batch: CoverageBatchScope
|
|
205
|
+
): Promise<Map<string, CoverageSnapshot>> {
|
|
206
|
+
const entityTypes = Array.from(
|
|
207
|
+
new Set((batch.entityTypes ?? []).map((id) => String(id || '')).filter((id) => id.length > 0))
|
|
208
|
+
)
|
|
209
|
+
const result = new Map<string, CoverageSnapshot>()
|
|
210
|
+
if (entityTypes.length === 0) return result
|
|
211
|
+
|
|
212
|
+
const withDeleted = batch.withDeleted === true
|
|
213
|
+
let query = db
|
|
214
|
+
.selectFrom('entity_index_coverage' as any)
|
|
215
|
+
.select([
|
|
216
|
+
'entity_type' as any,
|
|
217
|
+
'base_count' as any,
|
|
218
|
+
'indexed_count' as any,
|
|
219
|
+
'vector_indexed_count' as any,
|
|
220
|
+
'refreshed_at' as any,
|
|
221
|
+
'organization_id' as any,
|
|
222
|
+
])
|
|
223
|
+
.where('entity_type' as any, 'in', entityTypes)
|
|
224
|
+
.where('with_deleted' as any, '=', withDeleted)
|
|
225
|
+
.orderBy('refreshed_at' as any, 'desc')
|
|
226
|
+
query = batch.tenantId == null
|
|
227
|
+
? query.where('tenant_id' as any, 'is', null as any)
|
|
228
|
+
: query.where('tenant_id' as any, '=', batch.tenantId)
|
|
229
|
+
query = applyOrganizationCondition(query as any, 'organization_id', batch.organizationId ?? null)
|
|
230
|
+
|
|
231
|
+
const rows = await query.execute() as Array<CoverageRow & { entity_type: string }>
|
|
232
|
+
for (const row of rows ?? []) {
|
|
233
|
+
const entityType = String(row.entity_type || '')
|
|
234
|
+
// Rows are ordered by refreshed_at desc, so the first row seen per entity is the latest.
|
|
235
|
+
if (!entityType || result.has(entityType)) continue
|
|
236
|
+
const refreshedAt = row.refreshed_at instanceof Date
|
|
237
|
+
? row.refreshed_at
|
|
238
|
+
: (row.refreshed_at ? new Date(row.refreshed_at) : null)
|
|
239
|
+
result.set(entityType, {
|
|
240
|
+
base_count: row.base_count,
|
|
241
|
+
indexed_count: row.indexed_count,
|
|
242
|
+
vector_indexed_count: row.vector_indexed_count,
|
|
243
|
+
refreshed_at: refreshedAt ?? null,
|
|
244
|
+
baseCount: toCount(row.base_count),
|
|
245
|
+
indexedCount: toCount(row.indexed_count),
|
|
246
|
+
vectorIndexedCount: toCount(row.vector_indexed_count),
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
return result
|
|
250
|
+
}
|
|
251
|
+
|
|
189
252
|
export async function applyCoverageAdjustments(
|
|
190
253
|
em: EntityManager,
|
|
191
254
|
adjustments: CoverageAdjustment[]
|
|
@@ -3,6 +3,7 @@ import { SortDir } from '@open-mercato/shared/lib/query/types'
|
|
|
3
3
|
import type { EntityId } from '@open-mercato/shared/modules/entities'
|
|
4
4
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
5
5
|
import { BasicQueryEngine, resolveEntityTableName, resolveRegisteredEntityTableName } from '@open-mercato/shared/lib/query/engine'
|
|
6
|
+
import { isOrmBackedSystemEntityId } from '@open-mercato/shared/lib/data/engine'
|
|
6
7
|
import { type Kysely, sql, type RawBuilder } from 'kysely'
|
|
7
8
|
import type { EventBus } from '@open-mercato/events'
|
|
8
9
|
import { readCoverageSnapshot, refreshCoverageSnapshot } from './coverage'
|
|
@@ -1064,32 +1065,44 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
1064
1065
|
if (cached !== undefined) return cached
|
|
1065
1066
|
let result = false
|
|
1066
1067
|
try {
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
.
|
|
1071
|
-
|
|
1072
|
-
.
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
//
|
|
1078
|
-
// inference: stray `custom_entities_storage` rows for such an id (e.g. written
|
|
1079
|
-
// through the generic entities data engine) must not hijack every list/detail
|
|
1080
|
-
// read for the whole entity type away from its base table (#2939). Surfaces
|
|
1081
|
-
// that intentionally read doc records for a dual-declared id pass
|
|
1082
|
-
// `forceCustomEntityStorage` in QueryOptions instead.
|
|
1068
|
+
if (isOrmBackedSystemEntityId(this.em, entity)) {
|
|
1069
|
+
// An id backed by a registered ORM table is never doc-storage-backed. Classify it
|
|
1070
|
+
// as its base table BEFORE probing `custom_entities`, so neither a stray
|
|
1071
|
+
// `custom_entities_storage` row nor an active `custom_entities` row (e.g. a
|
|
1072
|
+
// presentation-metadata overlay for a system entity) can hijack every list/detail
|
|
1073
|
+
// read for the whole entity type away from its base table (#2939). This mirrors the
|
|
1074
|
+
// ORM-backed-first ordering already used by the records API (`classifyRecordsEntity`)
|
|
1075
|
+
// and the doc-storage write guard (`assertCustomEntityStorageEntityId`); without it
|
|
1076
|
+
// the read classifier alone trusted an active registration row over ORM-table
|
|
1077
|
+
// resolution. Surfaces that intentionally read doc records for a dual-declared id
|
|
1078
|
+
// pass `forceCustomEntityStorage` in QueryOptions instead.
|
|
1083
1079
|
result = false
|
|
1084
1080
|
} else {
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1081
|
+
const db = this.getDb() as any
|
|
1082
|
+
const row = await db
|
|
1083
|
+
.selectFrom('custom_entities')
|
|
1084
|
+
.select('id')
|
|
1085
|
+
.where('entity_id', '=', entity)
|
|
1086
|
+
.where('is_active', '=', true)
|
|
1087
|
+
.executeTakeFirst()
|
|
1088
|
+
if (row) {
|
|
1089
|
+
result = true
|
|
1090
|
+
} else if (resolveRegisteredEntityTableName(this.em, entity) !== null) {
|
|
1091
|
+
// A non-registry id whose entity segment collides with an ORM class name (e.g.
|
|
1092
|
+
// `user:todo` vs the example module's `Todo`) resolves to a table but is NOT an
|
|
1093
|
+
// ORM-backed system entity, so it is not short-circuited above. Stray
|
|
1094
|
+
// `custom_entities_storage` rows for such an id must not hijack reads either.
|
|
1095
|
+
result = false
|
|
1096
|
+
} else {
|
|
1097
|
+
// Read/write symmetry. Records written through the entities data engine
|
|
1098
|
+
// (`de.createCustomEntityRecord`) always land in `custom_entities_storage`,
|
|
1099
|
+
// even for module-declared custom entities whose id is also a frozen system
|
|
1100
|
+
// id — those are NEVER registered in `custom_entities` (install treats a
|
|
1101
|
+
// system id as non-registrable). Without this fallback the query routes to
|
|
1102
|
+
// the empty ORM/index path and those records are write-only (created with
|
|
1103
|
+
// 200 but unreadable on the edit form).
|
|
1104
|
+
result = await this.hasCustomEntityStorageRows(entity)
|
|
1105
|
+
}
|
|
1093
1106
|
}
|
|
1094
1107
|
} catch {
|
|
1095
1108
|
result = false
|