@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.
Files changed (220) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/generated/entities/carrier_shipment_idempotency_key/index.js +19 -0
  3. package/dist/generated/entities/carrier_shipment_idempotency_key/index.js.map +7 -0
  4. package/dist/generated/entities.ids.generated.js +2 -1
  5. package/dist/generated/entities.ids.generated.js.map +2 -2
  6. package/dist/generated/entity-fields-registry.js +10 -0
  7. package/dist/generated/entity-fields-registry.js.map +2 -2
  8. package/dist/modules/catalog/lib/productResolution.js +45 -0
  9. package/dist/modules/catalog/lib/productResolution.js.map +7 -0
  10. package/dist/modules/communication_channels/workers/channel-import-history.js +1 -1
  11. package/dist/modules/communication_channels/workers/channel-import-history.js.map +2 -2
  12. package/dist/modules/currencies/api/fetch-configs/route.js +34 -0
  13. package/dist/modules/currencies/api/fetch-configs/route.js.map +2 -2
  14. package/dist/modules/currencies/components/CurrencyFetchingConfig.js +17 -15
  15. package/dist/modules/currencies/components/CurrencyFetchingConfig.js.map +2 -2
  16. package/dist/modules/customers/api/companies/[id]/route.js +176 -161
  17. package/dist/modules/customers/api/companies/[id]/route.js.map +2 -2
  18. package/dist/modules/customers/api/people/[id]/route.js +75 -59
  19. package/dist/modules/customers/api/people/[id]/route.js.map +2 -2
  20. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.js +17 -10
  21. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.js.map +2 -2
  22. package/dist/modules/customers/components/detail/ActivitiesAddNewMenu.js +6 -4
  23. package/dist/modules/customers/components/detail/ActivitiesAddNewMenu.js.map +2 -2
  24. package/dist/modules/customers/components/detail/AiActionChips.js +5 -2
  25. package/dist/modules/customers/components/detail/AiActionChips.js.map +2 -2
  26. package/dist/modules/customers/components/detail/schedule/ParticipantsField.js +19 -21
  27. package/dist/modules/customers/components/detail/schedule/ParticipantsField.js.map +2 -2
  28. package/dist/modules/data_sync/api/runs/[id]/cancel.js +1 -1
  29. package/dist/modules/data_sync/api/runs/[id]/cancel.js.map +2 -2
  30. package/dist/modules/data_sync/backend/data-sync/page.js +9 -45
  31. package/dist/modules/data_sync/backend/data-sync/page.js.map +2 -2
  32. package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js +11 -17
  33. package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js.map +2 -2
  34. package/dist/modules/data_sync/components/IntegrationScheduleTab.js +12 -10
  35. package/dist/modules/data_sync/components/IntegrationScheduleTab.js.map +2 -2
  36. package/dist/modules/data_sync/lib/id-mapping.js +46 -1
  37. package/dist/modules/data_sync/lib/id-mapping.js.map +2 -2
  38. package/dist/modules/data_sync/lib/sync-engine.js +2 -2
  39. package/dist/modules/data_sync/lib/sync-engine.js.map +2 -2
  40. package/dist/modules/data_sync/lib/syncRunStatus.js +30 -0
  41. package/dist/modules/data_sync/lib/syncRunStatus.js.map +7 -0
  42. package/dist/modules/data_sync/workers/sync-scheduled.js +13 -14
  43. package/dist/modules/data_sync/workers/sync-scheduled.js.map +2 -2
  44. package/dist/modules/directory/components/TenantSelect.js +29 -16
  45. package/dist/modules/directory/components/TenantSelect.js.map +2 -2
  46. package/dist/modules/entities/api/records.js +6 -0
  47. package/dist/modules/entities/api/records.js.map +2 -2
  48. package/dist/modules/feature_toggles/api/global/route.js +17 -15
  49. package/dist/modules/feature_toggles/api/global/route.js.map +2 -2
  50. package/dist/modules/feature_toggles/api/openapi.js +2 -2
  51. package/dist/modules/feature_toggles/api/openapi.js.map +2 -2
  52. package/dist/modules/feature_toggles/commands/global.js +14 -0
  53. package/dist/modules/feature_toggles/commands/global.js.map +2 -2
  54. package/dist/modules/inbox_ops/backend/inbox-ops/page.js +6 -0
  55. package/dist/modules/inbox_ops/backend/inbox-ops/page.js.map +2 -2
  56. package/dist/modules/messages/components/message-detail/hooks/useMessageDetails.js +3 -1
  57. package/dist/modules/messages/components/message-detail/hooks/useMessageDetails.js.map +2 -2
  58. package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsActions.js +12 -5
  59. package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsActions.js.map +2 -2
  60. package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsQueries.js +10 -1
  61. package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsQueries.js.map +2 -2
  62. package/dist/modules/payment_gateways/api/cancel/route.js +5 -0
  63. package/dist/modules/payment_gateways/api/cancel/route.js.map +2 -2
  64. package/dist/modules/payment_gateways/api/capture/route.js +5 -0
  65. package/dist/modules/payment_gateways/api/capture/route.js.map +2 -2
  66. package/dist/modules/payment_gateways/api/refund/route.js +5 -0
  67. package/dist/modules/payment_gateways/api/refund/route.js.map +2 -2
  68. package/dist/modules/payment_gateways/lib/gateway-service.js +52 -25
  69. package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
  70. package/dist/modules/payment_gateways/lib/status-machine.js +13 -0
  71. package/dist/modules/payment_gateways/lib/status-machine.js.map +2 -2
  72. package/dist/modules/perspectives/api/[tableId]/route.js +8 -2
  73. package/dist/modules/perspectives/api/[tableId]/route.js.map +2 -2
  74. package/dist/modules/perspectives/data/entities.js +33 -3
  75. package/dist/modules/perspectives/data/entities.js.map +2 -2
  76. package/dist/modules/perspectives/migrations/Migration20260619120000_perspectives_live_row_uniqueness.js +63 -0
  77. package/dist/modules/perspectives/migrations/Migration20260619120000_perspectives_live_row_uniqueness.js.map +7 -0
  78. package/dist/modules/perspectives/services/perspectiveService.js +17 -0
  79. package/dist/modules/perspectives/services/perspectiveService.js.map +2 -2
  80. package/dist/modules/planner/commands/availability-weekly.js +33 -1
  81. package/dist/modules/planner/commands/availability-weekly.js.map +2 -2
  82. package/dist/modules/planner/components/AvailabilityRulesEditor.js +14 -2
  83. package/dist/modules/planner/components/AvailabilityRulesEditor.js.map +2 -2
  84. package/dist/modules/portal/frontend/[orgSlug]/portal/dashboard/hiddenWidgetsStorage.js +35 -0
  85. package/dist/modules/portal/frontend/[orgSlug]/portal/dashboard/hiddenWidgetsStorage.js.map +7 -0
  86. package/dist/modules/portal/frontend/[orgSlug]/portal/dashboard/page.js +18 -19
  87. package/dist/modules/portal/frontend/[orgSlug]/portal/dashboard/page.js.map +2 -2
  88. package/dist/modules/progress/api/active/route.js +1 -1
  89. package/dist/modules/progress/api/active/route.js.map +2 -2
  90. package/dist/modules/progress/lib/progressService.js.map +1 -1
  91. package/dist/modules/progress/lib/progressServiceImpl.js +33 -8
  92. package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
  93. package/dist/modules/query_index/api/status.js +19 -24
  94. package/dist/modules/query_index/api/status.js.map +3 -3
  95. package/dist/modules/query_index/lib/coverage.js +35 -0
  96. package/dist/modules/query_index/lib/coverage.js.map +2 -2
  97. package/dist/modules/query_index/lib/engine.js +11 -6
  98. package/dist/modules/query_index/lib/engine.js.map +2 -2
  99. package/dist/modules/resources/api/resource-types.js +5 -37
  100. package/dist/modules/resources/api/resource-types.js.map +2 -2
  101. package/dist/modules/resources/api/resourceTypeCounts.js +35 -0
  102. package/dist/modules/resources/api/resourceTypeCounts.js.map +7 -0
  103. package/dist/modules/resources/backend/resources/resource-types/[id]/edit/page.js +1 -1
  104. package/dist/modules/resources/backend/resources/resource-types/[id]/edit/page.js.map +1 -1
  105. package/dist/modules/resources/backend/resources/resource-types/page.js +2 -1
  106. package/dist/modules/resources/backend/resources/resource-types/page.js.map +2 -2
  107. package/dist/modules/sales/components/documents/ReturnDialog.js +4 -1
  108. package/dist/modules/sales/components/documents/ReturnDialog.js.map +2 -2
  109. package/dist/modules/sales/components/documents/ShipmentDialog.js +17 -2
  110. package/dist/modules/sales/components/documents/ShipmentDialog.js.map +2 -2
  111. package/dist/modules/sales/di.js +4 -0
  112. package/dist/modules/sales/di.js.map +2 -2
  113. package/dist/modules/sales/services/salesOrderService.js +26 -0
  114. package/dist/modules/sales/services/salesOrderService.js.map +7 -0
  115. package/dist/modules/shipping_carriers/api/shipments/route.js +8 -0
  116. package/dist/modules/shipping_carriers/api/shipments/route.js.map +2 -2
  117. package/dist/modules/shipping_carriers/data/entities.js +38 -0
  118. package/dist/modules/shipping_carriers/data/entities.js.map +2 -2
  119. package/dist/modules/shipping_carriers/data/validators.js +2 -1
  120. package/dist/modules/shipping_carriers/data/validators.js.map +2 -2
  121. package/dist/modules/shipping_carriers/lib/shipment-idempotency.js +76 -0
  122. package/dist/modules/shipping_carriers/lib/shipment-idempotency.js.map +7 -0
  123. package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/shipmentApi.js +2 -1
  124. package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/shipmentApi.js.map +2 -2
  125. package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/useShipmentWizard.js +20 -2
  126. package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/useShipmentWizard.js.map +2 -2
  127. package/dist/modules/shipping_carriers/lib/shipping-service.js +82 -43
  128. package/dist/modules/shipping_carriers/lib/shipping-service.js.map +2 -2
  129. package/dist/modules/shipping_carriers/migrations/Migration20260618173616_shipping_carriers.js +14 -0
  130. package/dist/modules/shipping_carriers/migrations/Migration20260618173616_shipping_carriers.js.map +7 -0
  131. package/dist/modules/staff/lib/timesheets-ui/ListView.js +15 -6
  132. package/dist/modules/staff/lib/timesheets-ui/ListView.js.map +2 -2
  133. package/dist/modules/translations/components/TranslationManager.js +9 -18
  134. package/dist/modules/translations/components/TranslationManager.js.map +2 -2
  135. package/generated/entities/carrier_shipment_idempotency_key/index.ts +8 -0
  136. package/generated/entities.ids.generated.ts +2 -1
  137. package/generated/entity-fields-registry.ts +10 -0
  138. package/package.json +9 -8
  139. package/src/modules/catalog/lib/productResolution.ts +75 -0
  140. package/src/modules/communication_channels/workers/channel-import-history.ts +1 -1
  141. package/src/modules/currencies/api/fetch-configs/route.ts +36 -0
  142. package/src/modules/currencies/components/CurrencyFetchingConfig.tsx +20 -17
  143. package/src/modules/currencies/i18n/de.json +2 -1
  144. package/src/modules/currencies/i18n/en.json +2 -1
  145. package/src/modules/currencies/i18n/es.json +2 -1
  146. package/src/modules/currencies/i18n/pl.json +2 -1
  147. package/src/modules/customers/api/companies/[id]/route.ts +214 -189
  148. package/src/modules/customers/api/people/[id]/route.ts +106 -80
  149. package/src/modules/customers/backend/config/customers/pipeline-stages/page.tsx +19 -12
  150. package/src/modules/customers/components/detail/ActivitiesAddNewMenu.tsx +8 -6
  151. package/src/modules/customers/components/detail/AiActionChips.tsx +6 -3
  152. package/src/modules/customers/components/detail/schedule/ParticipantsField.tsx +16 -18
  153. package/src/modules/data_sync/api/runs/[id]/cancel.ts +1 -1
  154. package/src/modules/data_sync/backend/data-sync/page.tsx +11 -58
  155. package/src/modules/data_sync/backend/data-sync/runs/[id]/page.tsx +13 -20
  156. package/src/modules/data_sync/components/IntegrationScheduleTab.tsx +10 -8
  157. package/src/modules/data_sync/lib/id-mapping.ts +58 -1
  158. package/src/modules/data_sync/lib/sync-engine.ts +2 -2
  159. package/src/modules/data_sync/lib/syncRunStatus.ts +39 -0
  160. package/src/modules/data_sync/workers/sync-scheduled.ts +13 -14
  161. package/src/modules/directory/components/TenantSelect.tsx +33 -16
  162. package/src/modules/entities/api/records.ts +14 -0
  163. package/src/modules/feature_toggles/api/global/route.ts +16 -14
  164. package/src/modules/feature_toggles/api/openapi.ts +2 -2
  165. package/src/modules/feature_toggles/commands/global.ts +15 -0
  166. package/src/modules/inbox_ops/backend/inbox-ops/page.tsx +7 -0
  167. package/src/modules/messages/components/message-detail/hooks/useMessageDetails.ts +2 -0
  168. package/src/modules/messages/components/message-detail/hooks/useMessageDetailsActions.ts +14 -4
  169. package/src/modules/messages/components/message-detail/hooks/useMessageDetailsQueries.ts +15 -1
  170. package/src/modules/payment_gateways/api/cancel/route.ts +5 -0
  171. package/src/modules/payment_gateways/api/capture/route.ts +5 -0
  172. package/src/modules/payment_gateways/api/refund/route.ts +5 -0
  173. package/src/modules/payment_gateways/lib/gateway-service.ts +58 -25
  174. package/src/modules/payment_gateways/lib/status-machine.ts +16 -0
  175. package/src/modules/perspectives/api/[tableId]/route.ts +8 -2
  176. package/src/modules/perspectives/data/entities.ts +43 -3
  177. package/src/modules/perspectives/migrations/.snapshot-open-mercato.json +69 -25
  178. package/src/modules/perspectives/migrations/Migration20260619120000_perspectives_live_row_uniqueness.ts +65 -0
  179. package/src/modules/perspectives/services/perspectiveService.ts +17 -0
  180. package/src/modules/planner/commands/availability-weekly.ts +48 -1
  181. package/src/modules/planner/components/AvailabilityRulesEditor.tsx +31 -10
  182. package/src/modules/portal/frontend/[orgSlug]/portal/dashboard/hiddenWidgetsStorage.ts +36 -0
  183. package/src/modules/portal/frontend/[orgSlug]/portal/dashboard/page.tsx +22 -23
  184. package/src/modules/progress/api/active/route.ts +1 -1
  185. package/src/modules/progress/lib/progressService.ts +2 -2
  186. package/src/modules/progress/lib/progressServiceImpl.ts +33 -8
  187. package/src/modules/query_index/api/status.ts +28 -32
  188. package/src/modules/query_index/lib/coverage.ts +63 -0
  189. package/src/modules/query_index/lib/engine.ts +37 -24
  190. package/src/modules/resources/api/resource-types.ts +4 -44
  191. package/src/modules/resources/api/resourceTypeCounts.ts +74 -0
  192. package/src/modules/resources/backend/resources/resource-types/[id]/edit/page.tsx +1 -1
  193. package/src/modules/resources/backend/resources/resource-types/page.tsx +1 -0
  194. package/src/modules/sales/components/documents/ReturnDialog.tsx +4 -1
  195. package/src/modules/sales/components/documents/ShipmentDialog.tsx +20 -2
  196. package/src/modules/sales/di.ts +6 -0
  197. package/src/modules/sales/services/salesOrderService.ts +38 -0
  198. package/src/modules/shipping_carriers/api/shipments/route.ts +8 -0
  199. package/src/modules/shipping_carriers/data/entities.ts +33 -0
  200. package/src/modules/shipping_carriers/data/validators.ts +1 -0
  201. package/src/modules/shipping_carriers/i18n/de.json +1 -0
  202. package/src/modules/shipping_carriers/i18n/en.json +1 -0
  203. package/src/modules/shipping_carriers/i18n/es.json +1 -0
  204. package/src/modules/shipping_carriers/i18n/pl.json +1 -0
  205. package/src/modules/shipping_carriers/lib/shipment-idempotency.ts +119 -0
  206. package/src/modules/shipping_carriers/lib/shipment-wizard/hooks/shipmentApi.ts +3 -1
  207. package/src/modules/shipping_carriers/lib/shipment-wizard/hooks/useShipmentWizard.ts +24 -2
  208. package/src/modules/shipping_carriers/lib/shipping-service.ts +87 -43
  209. package/src/modules/shipping_carriers/migrations/.snapshot-open-mercato.json +197 -0
  210. package/src/modules/shipping_carriers/migrations/Migration20260618173616_shipping_carriers.ts +14 -0
  211. package/src/modules/staff/i18n/de.json +1 -0
  212. package/src/modules/staff/i18n/en.json +1 -0
  213. package/src/modules/staff/i18n/es.json +1 -0
  214. package/src/modules/staff/i18n/pl.json +1 -0
  215. package/src/modules/staff/lib/timesheets-ui/ListView.tsx +15 -7
  216. package/src/modules/translations/components/TranslationManager.tsx +17 -24
  217. package/src/modules/translations/i18n/de.json +1 -0
  218. package/src/modules/translations/i18n/en.json +1 -0
  219. package/src/modules/translations/i18n/es.json +1 -0
  220. 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: () => apiCallOrThrow('/api/planner/availability-weekly', {
866
- method: 'POST',
867
- headers: { 'Content-Type': 'application/json' },
868
- body: JSON.stringify({
869
- subjectType: subjectForRules,
870
- subjectId: subjectIdForRules,
871
- timezone,
872
- windows,
873
- }),
874
- }, { errorMessage: listLabels.saveWeeklyError }),
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 [hiddenWidgets, setHiddenWidgets] = useState<Set<string>>(() => loadHiddenWidgets())
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, { id: jobId, tenantId: ctx.tenantId })
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, { id: jobId, tenantId: ctx.tenantId })
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, { id: jobId, tenantId: ctx.tenantId })
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, { id: jobId, tenantId: ctx.tenantId })
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, { id: jobId, tenantId: ctx.tenantId })
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, { id: jobId, tenantId })
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 { readCoverageSnapshot, refreshCoverageSnapshot } from '../lib/coverage'
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 coverageSnapshots: Array<Awaited<ReturnType<typeof readCoverageSnapshot>>> = []
293
+ const coverageScope = {
294
+ tenantId: tenantId ?? null,
295
+ organizationId,
296
+ withDeleted: false,
297
+ } as const
292
298
  const entitiesNeedingRefresh = new Set<string>()
293
- for (const entityId of entityIds) {
294
- const scope = {
295
- entityType: entityId,
296
- tenantId: tenantId ?? null,
297
- organizationId,
298
- withDeleted: false,
299
- } as const
300
- const ensureSnapshot = async () => {
301
- let snapshot = await readCoverageSnapshot(db, scope)
302
- const refreshedAt = snapshot?.refreshed_at instanceof Date
303
- ? snapshot.refreshed_at
304
- : snapshot?.refreshed_at
305
- ? new Date(snapshot.refreshed_at)
306
- : null
307
- const stale = !snapshot || !refreshedAt || (Date.now() - refreshedAt.getTime() > COVERAGE_STALE_MS)
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
- const db = this.getDb() as any
1068
- const row = await db
1069
- .selectFrom('custom_entities')
1070
- .select('id')
1071
- .where('entity_id', '=', entity)
1072
- .where('is_active', '=', true)
1073
- .executeTakeFirst()
1074
- if (row) {
1075
- result = true
1076
- } else if (resolveRegisteredEntityTableName(this.em, entity) !== null) {
1077
- // An id backed by a registered ORM table is never doc-storage-backed by
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
- // Read/write symmetry. Records written through the entities data engine
1086
- // (`de.createCustomEntityRecord`) always land in `custom_entities_storage`,
1087
- // even for module-declared custom entities whose id is also a frozen system
1088
- // id — those are NEVER registered in `custom_entities` (install treats a
1089
- // system id as non-registrable). Without this fallback the query routes to
1090
- // the empty ORM/index path and those records are write-only (created with
1091
- // 200 but unreadable on the edit form).
1092
- result = await this.hasCustomEntityStorageRows(entity)
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