@open-mercato/core 0.6.5-develop.5382.1.f542de69af → 0.6.5

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 (237) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/bootstrap.js +46 -6
  3. package/dist/bootstrap.js.map +2 -2
  4. package/dist/generated/entities/organization/index.js +2 -0
  5. package/dist/generated/entities/organization/index.js.map +2 -2
  6. package/dist/generated/entity-fields-registry.js +1 -0
  7. package/dist/generated/entity-fields-registry.js.map +2 -2
  8. package/dist/helpers/integration/crmFixtures.js +4 -0
  9. package/dist/helpers/integration/crmFixtures.js.map +2 -2
  10. package/dist/modules/attachments/api/route.js +2 -0
  11. package/dist/modules/attachments/api/route.js.map +2 -2
  12. package/dist/modules/attachments/lib/access.js +18 -0
  13. package/dist/modules/attachments/lib/access.js.map +2 -2
  14. package/dist/modules/audit_logs/data/entities.js +2 -1
  15. package/dist/modules/audit_logs/data/entities.js.map +2 -2
  16. package/dist/modules/audit_logs/migrations/Migration20260611104500.js +13 -0
  17. package/dist/modules/audit_logs/migrations/Migration20260611104500.js.map +7 -0
  18. package/dist/modules/audit_logs/services/accessLogService.js +10 -0
  19. package/dist/modules/audit_logs/services/accessLogService.js.map +2 -2
  20. package/dist/modules/auth/api/admin/nav.js +9 -0
  21. package/dist/modules/auth/api/admin/nav.js.map +2 -2
  22. package/dist/modules/auth/api/login.js +4 -13
  23. package/dist/modules/auth/api/login.js.map +2 -2
  24. package/dist/modules/auth/data/entities.js +3 -1
  25. package/dist/modules/auth/data/entities.js.map +2 -2
  26. package/dist/modules/auth/lib/backendChrome.js +35 -2
  27. package/dist/modules/auth/lib/backendChrome.js.map +2 -2
  28. package/dist/modules/auth/lib/consentIntegrity.js +3 -3
  29. package/dist/modules/auth/lib/consentIntegrity.js.map +2 -2
  30. package/dist/modules/auth/migrations/Migration20260611103000.js +15 -0
  31. package/dist/modules/auth/migrations/Migration20260611103000.js.map +7 -0
  32. package/dist/modules/auth/services/authService.js +5 -3
  33. package/dist/modules/auth/services/authService.js.map +2 -2
  34. package/dist/modules/auth/services/rbacService.js +3 -2
  35. package/dist/modules/auth/services/rbacService.js.map +2 -2
  36. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.js +0 -3
  37. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.js.map +2 -2
  38. package/dist/modules/customers/api/deals/route.js +43 -2
  39. package/dist/modules/customers/api/deals/route.js.map +2 -2
  40. package/dist/modules/customers/api/deals/summary/route.js +402 -0
  41. package/dist/modules/customers/api/deals/summary/route.js.map +7 -0
  42. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealActivities.js +16 -5
  43. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealActivities.js.map +2 -2
  44. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealData.js +22 -5
  45. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealData.js.map +2 -2
  46. package/dist/modules/customers/backend/customers/deals/[id]/page.js +12 -2
  47. package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
  48. package/dist/modules/customers/backend/customers/deals/page.js +221 -56
  49. package/dist/modules/customers/backend/customers/deals/page.js.map +3 -3
  50. package/dist/modules/customers/backend/customers/deals/pipeline/page.js +1 -1
  51. package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +2 -2
  52. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js +18 -0
  53. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js.map +2 -2
  54. package/dist/modules/customers/cli.js +15 -9
  55. package/dist/modules/customers/cli.js.map +2 -2
  56. package/dist/modules/customers/components/DealsKpiStrip.js +282 -0
  57. package/dist/modules/customers/components/DealsKpiStrip.js.map +7 -0
  58. package/dist/modules/customers/components/detail/ConfirmDealLostDialog.js +0 -1
  59. package/dist/modules/customers/components/detail/ConfirmDealLostDialog.js.map +2 -2
  60. package/dist/modules/customers/components/detail/DealForm.js +100 -17
  61. package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
  62. package/dist/modules/customers/components/detail/PersonDetailTabs.js +11 -3
  63. package/dist/modules/customers/components/detail/PersonDetailTabs.js.map +2 -2
  64. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js +1 -2
  65. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js.map +2 -2
  66. package/dist/modules/customers/components/kpi/PipelineStageBar.js +63 -0
  67. package/dist/modules/customers/components/kpi/PipelineStageBar.js.map +7 -0
  68. package/dist/modules/customers/lib/dealsMetrics.js +82 -0
  69. package/dist/modules/customers/lib/dealsMetrics.js.map +7 -0
  70. package/dist/modules/directory/api/organization-branding/route.js +214 -0
  71. package/dist/modules/directory/api/organization-branding/route.js.map +7 -0
  72. package/dist/modules/directory/api/organizations/route.js +7 -0
  73. package/dist/modules/directory/api/organizations/route.js.map +3 -3
  74. package/dist/modules/directory/backend/directory/branding/page.js +214 -0
  75. package/dist/modules/directory/backend/directory/branding/page.js.map +7 -0
  76. package/dist/modules/directory/backend/directory/branding/page.meta.js +26 -0
  77. package/dist/modules/directory/backend/directory/branding/page.meta.js.map +7 -0
  78. package/dist/modules/directory/commands/organizations.js +8 -1
  79. package/dist/modules/directory/commands/organizations.js.map +2 -2
  80. package/dist/modules/directory/data/entities.js +3 -0
  81. package/dist/modules/directory/data/entities.js.map +2 -2
  82. package/dist/modules/directory/data/validators.js +9 -0
  83. package/dist/modules/directory/data/validators.js.map +2 -2
  84. package/dist/modules/directory/migrations/Migration20260607222259_directory.js +13 -0
  85. package/dist/modules/directory/migrations/Migration20260607222259_directory.js.map +7 -0
  86. package/dist/modules/directory/subscribers/invalidateOrgScopeCache.js +2 -1
  87. package/dist/modules/directory/subscribers/invalidateOrgScopeCache.js.map +2 -2
  88. package/dist/modules/directory/utils/organizationScope.js +59 -27
  89. package/dist/modules/directory/utils/organizationScope.js.map +2 -2
  90. package/dist/modules/entities/api/definitions.batch.js +2 -1
  91. package/dist/modules/entities/api/definitions.batch.js.map +2 -2
  92. package/dist/modules/entities/api/entities.js +7 -0
  93. package/dist/modules/entities/api/entities.js.map +2 -2
  94. package/dist/modules/entities/api/records.js +26 -15
  95. package/dist/modules/entities/api/records.js.map +2 -2
  96. package/dist/modules/entities/backend/entities/user/[entityId]/records/[recordId]/page.js +14 -0
  97. package/dist/modules/entities/backend/entities/user/[entityId]/records/[recordId]/page.js.map +2 -2
  98. package/dist/modules/entities/backend/entities/user/[entityId]/records/create/page.js +14 -0
  99. package/dist/modules/entities/backend/entities/user/[entityId]/records/create/page.js.map +2 -2
  100. package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js +12 -0
  101. package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js.map +2 -2
  102. package/dist/modules/entities/components/useRecordsEntityGuard.js +30 -0
  103. package/dist/modules/entities/components/useRecordsEntityGuard.js.map +7 -0
  104. package/dist/modules/query_index/data/entities.js +2 -1
  105. package/dist/modules/query_index/data/entities.js.map +2 -2
  106. package/dist/modules/query_index/lib/engine.js +4 -2
  107. package/dist/modules/query_index/lib/engine.js.map +2 -2
  108. package/dist/modules/query_index/migrations/Migration20260611103000_query_index.js +16 -0
  109. package/dist/modules/query_index/migrations/Migration20260611103000_query_index.js.map +7 -0
  110. package/dist/modules/sales/commands/documents.js +7 -5
  111. package/dist/modules/sales/commands/documents.js.map +2 -2
  112. package/dist/modules/sales/components/documents/SalesDocumentsTable.js +2 -1
  113. package/dist/modules/sales/components/documents/SalesDocumentsTable.js.map +2 -2
  114. package/dist/modules/sales/components/documents/salesDocumentsColumns.js +10 -0
  115. package/dist/modules/sales/components/documents/salesDocumentsColumns.js.map +7 -0
  116. package/dist/modules/staff/api/team-members.js +9 -2
  117. package/dist/modules/staff/api/team-members.js.map +2 -2
  118. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js +24 -1
  119. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js.map +2 -2
  120. package/dist/modules/staff/backend/staff/team-members/[id]/page.js +11 -6
  121. package/dist/modules/staff/backend/staff/team-members/[id]/page.js.map +2 -2
  122. package/dist/modules/staff/commands/team-members.js +1 -1
  123. package/dist/modules/staff/commands/team-members.js.map +2 -2
  124. package/dist/modules/staff/components/TeamMemberForm.js +1 -1
  125. package/dist/modules/staff/components/TeamMemberForm.js.map +2 -2
  126. package/dist/modules/staff/lib/scheduleSwitch.js +23 -0
  127. package/dist/modules/staff/lib/scheduleSwitch.js.map +7 -0
  128. package/dist/modules/workflows/backend/definitions/create/page.js +1 -2
  129. package/dist/modules/workflows/backend/definitions/create/page.js.map +2 -2
  130. package/dist/modules/workflows/backend/definitions/visual-editor/page.js +1 -2
  131. package/dist/modules/workflows/backend/definitions/visual-editor/page.js.map +2 -2
  132. package/dist/modules/workflows/components/DefinitionTriggersEditor.js +1 -2
  133. package/dist/modules/workflows/components/DefinitionTriggersEditor.js.map +2 -2
  134. package/dist/modules/workflows/components/NodeEditDialog.js +4 -13
  135. package/dist/modules/workflows/components/NodeEditDialog.js.map +2 -2
  136. package/dist/modules/workflows/components/NodeEditDialogCrudForm.js +4 -13
  137. package/dist/modules/workflows/components/NodeEditDialogCrudForm.js.map +2 -2
  138. package/dist/modules/workflows/components/WorkflowGraphImpl.js +1 -4
  139. package/dist/modules/workflows/components/WorkflowGraphImpl.js.map +2 -2
  140. package/dist/modules/workflows/components/fields/FormFieldArrayEditor.js +2 -5
  141. package/dist/modules/workflows/components/fields/FormFieldArrayEditor.js.map +2 -2
  142. package/generated/entities/organization/index.ts +1 -0
  143. package/generated/entity-fields-registry.ts +1 -0
  144. package/package.json +11 -12
  145. package/src/bootstrap.ts +65 -7
  146. package/src/helpers/integration/crmFixtures.ts +21 -1
  147. package/src/modules/attachments/AGENTS.md +79 -0
  148. package/src/modules/attachments/api/route.ts +2 -0
  149. package/src/modules/attachments/lib/access.ts +36 -0
  150. package/src/modules/audit_logs/data/entities.ts +1 -0
  151. package/src/modules/audit_logs/migrations/.snapshot-open-mercato.json +10 -0
  152. package/src/modules/audit_logs/migrations/Migration20260611104500.ts +13 -0
  153. package/src/modules/audit_logs/services/accessLogService.ts +15 -0
  154. package/src/modules/auth/api/admin/nav.ts +9 -0
  155. package/src/modules/auth/api/login.ts +13 -13
  156. package/src/modules/auth/data/entities.ts +2 -0
  157. package/src/modules/auth/i18n/de.json +0 -1
  158. package/src/modules/auth/i18n/en.json +0 -1
  159. package/src/modules/auth/i18n/es.json +0 -1
  160. package/src/modules/auth/i18n/pl.json +0 -1
  161. package/src/modules/auth/lib/backendChrome.tsx +37 -1
  162. package/src/modules/auth/lib/consentIntegrity.ts +6 -3
  163. package/src/modules/auth/migrations/.snapshot-open-mercato.json +20 -0
  164. package/src/modules/auth/migrations/Migration20260611103000.ts +21 -0
  165. package/src/modules/auth/services/authService.ts +24 -4
  166. package/src/modules/auth/services/rbacService.ts +11 -2
  167. package/src/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.tsx +0 -3
  168. package/src/modules/customers/api/deals/route.ts +51 -2
  169. package/src/modules/customers/api/deals/summary/route.ts +496 -0
  170. package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealActivities.ts +28 -6
  171. package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealData.ts +33 -6
  172. package/src/modules/customers/backend/customers/deals/[id]/page.tsx +17 -2
  173. package/src/modules/customers/backend/customers/deals/page.tsx +254 -66
  174. package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +1 -2
  175. package/src/modules/customers/backend/customers/people-v2/[id]/page.tsx +18 -0
  176. package/src/modules/customers/cli.ts +15 -15
  177. package/src/modules/customers/components/DealsKpiStrip.tsx +389 -0
  178. package/src/modules/customers/components/detail/ConfirmDealLostDialog.tsx +0 -1
  179. package/src/modules/customers/components/detail/DealForm.tsx +121 -19
  180. package/src/modules/customers/components/detail/PersonDetailTabs.tsx +12 -2
  181. package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +1 -2
  182. package/src/modules/customers/components/kpi/PipelineStageBar.tsx +77 -0
  183. package/src/modules/customers/i18n/de.json +43 -0
  184. package/src/modules/customers/i18n/en.json +43 -0
  185. package/src/modules/customers/i18n/es.json +43 -0
  186. package/src/modules/customers/i18n/pl.json +43 -0
  187. package/src/modules/customers/lib/dealsMetrics.ts +159 -0
  188. package/src/modules/directory/api/organization-branding/route.ts +238 -0
  189. package/src/modules/directory/api/organizations/route.ts +7 -0
  190. package/src/modules/directory/backend/directory/branding/page.meta.ts +24 -0
  191. package/src/modules/directory/backend/directory/branding/page.tsx +248 -0
  192. package/src/modules/directory/commands/organizations.ts +9 -1
  193. package/src/modules/directory/data/entities.ts +3 -0
  194. package/src/modules/directory/data/validators.ts +12 -0
  195. package/src/modules/directory/i18n/de.json +21 -0
  196. package/src/modules/directory/i18n/en.json +21 -0
  197. package/src/modules/directory/i18n/es.json +21 -0
  198. package/src/modules/directory/i18n/pl.json +21 -0
  199. package/src/modules/directory/migrations/.snapshot-open-mercato.json +40 -0
  200. package/src/modules/directory/migrations/Migration20260607222259_directory.ts +13 -0
  201. package/src/modules/directory/subscribers/invalidateOrgScopeCache.ts +3 -1
  202. package/src/modules/directory/utils/organizationScope.ts +85 -30
  203. package/src/modules/entities/api/definitions.batch.ts +11 -7
  204. package/src/modules/entities/api/entities.ts +11 -0
  205. package/src/modules/entities/api/records.ts +46 -25
  206. package/src/modules/entities/backend/entities/user/[entityId]/records/[recordId]/page.tsx +15 -0
  207. package/src/modules/entities/backend/entities/user/[entityId]/records/create/page.tsx +15 -0
  208. package/src/modules/entities/backend/entities/user/[entityId]/records/page.tsx +23 -0
  209. package/src/modules/entities/components/useRecordsEntityGuard.ts +41 -0
  210. package/src/modules/entities/i18n/de.json +1 -0
  211. package/src/modules/entities/i18n/en.json +1 -0
  212. package/src/modules/entities/i18n/es.json +1 -0
  213. package/src/modules/entities/i18n/pl.json +1 -0
  214. package/src/modules/query_index/data/entities.ts +1 -0
  215. package/src/modules/query_index/lib/engine.ts +11 -5
  216. package/src/modules/query_index/migrations/.snapshot-open-mercato.json +11 -0
  217. package/src/modules/query_index/migrations/Migration20260611103000_query_index.ts +29 -0
  218. package/src/modules/sales/commands/documents.ts +7 -5
  219. package/src/modules/sales/components/documents/SalesDocumentsTable.tsx +2 -1
  220. package/src/modules/sales/components/documents/salesDocumentsColumns.ts +6 -0
  221. package/src/modules/staff/api/team-members.ts +9 -2
  222. package/src/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.ts +31 -1
  223. package/src/modules/staff/backend/staff/team-members/[id]/page.tsx +18 -8
  224. package/src/modules/staff/commands/team-members.ts +5 -2
  225. package/src/modules/staff/components/TeamMemberForm.tsx +4 -1
  226. package/src/modules/staff/i18n/de.json +1 -0
  227. package/src/modules/staff/i18n/en.json +1 -0
  228. package/src/modules/staff/i18n/es.json +1 -0
  229. package/src/modules/staff/i18n/pl.json +1 -0
  230. package/src/modules/staff/lib/scheduleSwitch.ts +46 -0
  231. package/src/modules/workflows/backend/definitions/create/page.tsx +1 -2
  232. package/src/modules/workflows/backend/definitions/visual-editor/page.tsx +1 -2
  233. package/src/modules/workflows/components/DefinitionTriggersEditor.tsx +1 -2
  234. package/src/modules/workflows/components/NodeEditDialog.tsx +1 -4
  235. package/src/modules/workflows/components/NodeEditDialogCrudForm.tsx +4 -7
  236. package/src/modules/workflows/components/WorkflowGraphImpl.tsx +1 -2
  237. package/src/modules/workflows/components/fields/FormFieldArrayEditor.tsx +2 -3
@@ -0,0 +1,13 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+
3
+ export class Migration20260607222259_directory extends Migration {
4
+
5
+ override up(): void | Promise<void> {
6
+ this.addSql(`alter table "organizations" add "logo_url" text null;`);
7
+ }
8
+
9
+ override down(): void | Promise<void> {
10
+ this.addSql(`alter table "organizations" drop column "logo_url";`);
11
+ }
12
+
13
+ }
@@ -7,6 +7,8 @@
7
7
  // drop every cache entry tagged for that tenant; the TTL is the backstop
8
8
  // for races where the event fires after a request reads the cache.
9
9
 
10
+ import { buildOrgScopeTenantCacheTag } from '@open-mercato/core/modules/directory/utils/organizationScope'
11
+
10
12
  type CacheService = {
11
13
  deleteByTags(tags: string[]): Promise<number>
12
14
  }
@@ -32,7 +34,7 @@ export default async function handle(
32
34
  }
33
35
  if (!cache) return
34
36
  try {
35
- await cache.deleteByTags([`org-scope:tenant:${tenantId}`])
37
+ await cache.deleteByTags([buildOrgScopeTenantCacheTag(tenantId)])
36
38
  } catch {
37
39
  // best-effort; TTL is the backstop.
38
40
  }
@@ -21,9 +21,11 @@ export type OrganizationScope = {
21
21
  // OrganizationScope is a pure function of (userId, tenantId, selectedOrgId,
22
22
  // requestedTenant) between membership changes; caching it bypasses 1
23
23
  // SELECT on `organizations` per CRUD request. TTL is short (60s default)
24
- // to keep staleness bounded for membership/visibility changes. Tag-based
25
- // invalidation kicks the cache when user_organizations or organizations
26
- // mutate (wired via invalidateOrganizationScopeCacheFor).
24
+ // to keep staleness bounded as a backstop. Tag-based invalidation also fires
25
+ // eagerly: per-user entries are dropped by RbacService.invalidateUserCache
26
+ // (every ACL/role grant change goes through it — see buildOrgScopeUserCacheTag)
27
+ // and per-tenant entries by the directory.organization.* subscriber plus
28
+ // RbacService.invalidateTenantCache (role-ACL changes).
27
29
  const ORG_SCOPE_CACHE_KEY_PREFIX = 'org-scope'
28
30
  // Phase 4 default-off until the same readiness probe (`GET /api/customers/people`)
29
31
  // stays green with the cache layer engaged. Set `OM_ORG_SCOPE_CACHE_TTL_MS=60000`
@@ -49,10 +51,23 @@ function buildOrgScopeCacheKey(parts: {
49
51
  return `${ORG_SCOPE_CACHE_KEY_PREFIX}:${parts.userId}:${parts.effectiveTenantId}:${selected}:${requested}`
50
52
  }
51
53
 
54
+ // Tag builders are exported so the modules that own the "this user's scope
55
+ // changed" / "this tenant's org tree changed" signals (auth RBAC invalidation,
56
+ // the directory.organization.* subscriber) can drop the matching cross-request
57
+ // cache entries without re-deriving the tag format. Keeping the format in one
58
+ // place is what lets the TTL be enabled safely (issue #2259).
59
+ export function buildOrgScopeUserCacheTag(userId: string): string {
60
+ return `${ORG_SCOPE_CACHE_KEY_PREFIX}:user:${userId}`
61
+ }
62
+
63
+ export function buildOrgScopeTenantCacheTag(tenantId: string): string {
64
+ return `${ORG_SCOPE_CACHE_KEY_PREFIX}:tenant:${tenantId}`
65
+ }
66
+
52
67
  function buildOrgScopeCacheTags(parts: { userId: string; effectiveTenantId: string }): string[] {
53
68
  return [
54
- `${ORG_SCOPE_CACHE_KEY_PREFIX}:user:${parts.userId}`,
55
- `${ORG_SCOPE_CACHE_KEY_PREFIX}:tenant:${parts.effectiveTenantId}`,
69
+ buildOrgScopeUserCacheTag(parts.userId),
70
+ buildOrgScopeTenantCacheTag(parts.effectiveTenantId),
56
71
  ]
57
72
  }
58
73
 
@@ -82,7 +97,7 @@ export async function invalidateOrganizationScopeCacheForUser(
82
97
  const cache = resolveCacheFromContainer(container)
83
98
  if (!cache?.deleteByTags) return
84
99
  try {
85
- await cache.deleteByTags([`${ORG_SCOPE_CACHE_KEY_PREFIX}:user:${userId}`])
100
+ await cache.deleteByTags([buildOrgScopeUserCacheTag(userId)])
86
101
  } catch (err) {
87
102
  console.warn('[org-scope:cache] invalidate user failed', err)
88
103
  }
@@ -95,12 +110,36 @@ export async function invalidateOrganizationScopeCacheForTenant(
95
110
  const cache = resolveCacheFromContainer(container)
96
111
  if (!cache?.deleteByTags) return
97
112
  try {
98
- await cache.deleteByTags([`${ORG_SCOPE_CACHE_KEY_PREFIX}:tenant:${tenantId}`])
113
+ await cache.deleteByTags([buildOrgScopeTenantCacheTag(tenantId)])
99
114
  } catch (err) {
100
115
  console.warn('[org-scope:cache] invalidate tenant failed', err)
101
116
  }
102
117
  }
103
118
 
119
+ // Issue #2259 — per-request memoization. resolveOrganizationScopeForRequest
120
+ // runs at least twice per CRUD request: once for the route-level feature check
121
+ // (resolveFeatureCheckContext) and once inside the shared factory's withCtx.
122
+ // Those two call sites use different request-scoped DI containers but are handed
123
+ // the SAME Request instance, so memoizing the resolved scope on a WeakMap keyed
124
+ // by that request collapses the duplicate work — and the duplicate
125
+ // `organizations` SELECT — into a single resolution. The inner map is keyed by
126
+ // the same identity tuple as the cross-request cache key, so distinct explicit
127
+ // selectedId/tenant overrides on one request stay independent. There is no
128
+ // staleness risk: the memo lives only for the lifetime of one request and is
129
+ // dropped with the request object by the GC.
130
+ const orgScopeRequestMemo = new WeakMap<object, Map<string, Promise<OrganizationScope>>>()
131
+
132
+ function getRequestScopeMemo(request: unknown): Map<string, Promise<OrganizationScope>> | null {
133
+ if (!request || (typeof request !== 'object' && typeof request !== 'function')) return null
134
+ const key = request as object
135
+ let memo = orgScopeRequestMemo.get(key)
136
+ if (!memo) {
137
+ memo = new Map<string, Promise<OrganizationScope>>()
138
+ orgScopeRequestMemo.set(key, memo)
139
+ }
140
+ return memo
141
+ }
142
+
104
143
  function normalizeOrganizationId(value: unknown): string | null {
105
144
  if (typeof value !== 'string') return null
106
145
  const trimmed = value.trim()
@@ -402,35 +441,51 @@ export async function resolveOrganizationScopeForRequest({
402
441
  })
403
442
  : null
404
443
 
405
- if (cache && cacheKey && typeof cache.get === 'function') {
406
- try {
407
- const cached = await cache.get(cacheKey)
408
- if (isValidCachedScope(cached)) return cached
409
- } catch (err) {
410
- console.warn('[org-scope:cache] read failed', err)
411
- }
444
+ const requestMemo = getRequestScopeMemo(request)
445
+ if (requestMemo && cacheKey) {
446
+ const memoized = requestMemo.get(cacheKey)
447
+ if (memoized) return memoized
412
448
  }
413
449
 
414
- const baseScope = await resolveOrganizationScope({
415
- em,
416
- rbac,
417
- auth: scopedAuth,
418
- selectedId: rawSelected,
419
- tenantId: effectiveTenantId,
420
- })
450
+ const resolveScope = async (): Promise<OrganizationScope> => {
451
+ if (cache && cacheKey && typeof cache.get === 'function') {
452
+ try {
453
+ const cached = await cache.get(cacheKey)
454
+ if (isValidCachedScope(cached)) return cached
455
+ } catch (err) {
456
+ console.warn('[org-scope:cache] read failed', err)
457
+ }
458
+ }
421
459
 
422
- if (cache && cacheKey && userId && typeof cache.set === 'function') {
423
- try {
424
- await cache.set(cacheKey, baseScope, {
425
- ttl: ttlMs,
426
- tags: buildOrgScopeCacheTags({ userId, effectiveTenantId }),
427
- })
428
- } catch (err) {
429
- console.warn('[org-scope:cache] write failed', err)
460
+ const baseScope = await resolveOrganizationScope({
461
+ em,
462
+ rbac,
463
+ auth: scopedAuth,
464
+ selectedId: rawSelected,
465
+ tenantId: effectiveTenantId,
466
+ })
467
+
468
+ if (cache && cacheKey && userId && typeof cache.set === 'function') {
469
+ try {
470
+ await cache.set(cacheKey, baseScope, {
471
+ ttl: ttlMs,
472
+ tags: buildOrgScopeCacheTags({ userId, effectiveTenantId }),
473
+ })
474
+ } catch (err) {
475
+ console.warn('[org-scope:cache] write failed', err)
476
+ }
430
477
  }
478
+
479
+ return baseScope
480
+ }
481
+
482
+ if (requestMemo && cacheKey) {
483
+ const pending = resolveScope()
484
+ requestMemo.set(cacheKey, pending)
485
+ return pending
431
486
  }
432
487
 
433
- return baseScope
488
+ return resolveScope()
434
489
  }
435
490
 
436
491
  export type FeatureCheckContext = {
@@ -13,16 +13,20 @@ export const metadata = {
13
13
  POST: { requireAuth: true, requireFeatures: ['entities.definitions.manage'] },
14
14
  }
15
15
 
16
+ const MAX_DEFINITIONS_PER_BATCH = 1000
17
+
16
18
  const batchSchema = z
17
19
  .object({
18
20
  entityId: z.string().regex(/^[a-z0-9_]+:[a-z0-9_]+$/),
19
- definitions: z.array(
20
- upsertCustomFieldDefSchema
21
- .omit({ entityId: true })
22
- .extend({
23
- configJson: z.any().optional(),
24
- })
25
- ),
21
+ definitions: z
22
+ .array(
23
+ upsertCustomFieldDefSchema
24
+ .omit({ entityId: true })
25
+ .extend({
26
+ configJson: z.any().optional(),
27
+ })
28
+ )
29
+ .max(MAX_DEFINITIONS_PER_BATCH),
26
30
  })
27
31
  .extend(customFieldEntityConfigSchema.shape)
28
32
 
@@ -7,6 +7,7 @@ import { getEntityIds } from '@open-mercato/shared/lib/encryption/entityIds'
7
7
  import { upsertCustomEntitySchema } from '@open-mercato/core/modules/entities/data/validators'
8
8
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
9
9
  import { isSystemEntitySelectable } from '@open-mercato/shared/lib/entities/system-entities'
10
+ import { SYSTEM_ENTITY_RECORDS_BLOCKED_CODE, isOrmBackedSystemEntityId } from '@open-mercato/shared/lib/data/engine'
10
11
 
11
12
  export const metadata = {
12
13
  GET: { requireAuth: true },
@@ -107,6 +108,16 @@ export async function POST(req: Request) {
107
108
  const { resolve } = await createRequestContainer()
108
109
  const em = resolve('em') as any
109
110
 
111
+ // A registration for a module-declared, table-backed system entity would flip
112
+ // query-engine classification to doc storage for the whole entity type (#2939's
113
+ // failure mode via another door) — refuse to create one.
114
+ if (isOrmBackedSystemEntityId(em, input.entityId)) {
115
+ return NextResponse.json(
116
+ { error: 'System entities cannot be registered as custom entities', code: SYSTEM_ENTITY_RECORDS_BLOCKED_CODE, entityId: input.entityId },
117
+ { status: 400 },
118
+ )
119
+ }
120
+
110
121
  const where: any = { entityId: input.entityId, organizationId: auth.orgId ?? null, tenantId: auth.tenantId ?? null }
111
122
  let ent = await em.findOne(CustomEntity, where)
112
123
  if (!ent) ent = em.create(CustomEntity, { ...where, createdAt: new Date() })
@@ -6,6 +6,7 @@ import type { QueryEngine, QueryOptions, Where, Sort } from '@open-mercato/share
6
6
  import { normalizeExportFormat, serializeExport, defaultExportFilename, ensureColumns } from '@open-mercato/shared/lib/crud/exporters'
7
7
  import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
8
8
  import { resolveOrganizationScope, getSelectedOrganizationFromRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
9
+ import { SYSTEM_ENTITY_RECORDS_BLOCKED_CODE, isOrmBackedSystemEntityId } from '@open-mercato/shared/lib/data/engine'
9
10
  import { parseBooleanToken, parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
10
11
  import { setRecordCustomFields } from '../lib/helpers'
11
12
  import { CustomFieldValue } from '../data/entities'
@@ -36,24 +37,34 @@ function isDeclaredCustomEntity(entityId: string): boolean {
36
37
 
37
38
  const CUSTOM_ENTITY_RECORD_RESOURCE_KIND = 'entities.record'
38
39
 
39
- async function detectCustomEntity(em: any, entityId: string): Promise<boolean> {
40
- if (isDeclaredCustomEntity(entityId)) return true
40
+ type RecordsEntityKind = 'system' | 'custom' | 'unknown'
41
+
42
+ // This surface manages doc-storage records, which exist for CUSTOM entities only.
43
+ // Module-declared ids backed by a registered ORM table are system entities — their
44
+ // records live in their own module tables/APIs, and stray doc rows for them poisoned
45
+ // read-path classification platform-wide (#2939) — so they are rejected outright. The
46
+ // previous fallback that classified an entity by the mere presence of
47
+ // `custom_entities_storage` rows is gone: within the allowed set, declaration (ce.ts)
48
+ // or an active `custom_entities` registration is authoritative.
49
+ async function classifyRecordsEntity(em: any, entityId: string): Promise<RecordsEntityKind> {
50
+ if (isOrmBackedSystemEntityId(em, entityId)) return 'system'
51
+ if (isDeclaredCustomEntity(entityId)) return 'custom'
41
52
  try {
42
53
  const { CustomEntity } = await import('../data/entities')
43
- const found = await em.findOne(CustomEntity as any, { entityId, isActive: true })
44
- if (found) return true
54
+ // Any registration row active or soft-deleted proves the id is a custom
55
+ // entity. Records persist beyond the definition's soft delete (TC-ENTITIES-006)
56
+ // and must stay readable/deletable, e.g. for the restore flow and cleanup.
57
+ const found = await em.findOne(CustomEntity as any, { entityId })
58
+ if (found) return 'custom'
45
59
  } catch {}
46
- try {
47
- const db = em.getKysely()
48
- const row = await db
49
- .selectFrom('custom_entities_storage' as any)
50
- .select(['entity_id' as any])
51
- .where('entity_type' as any, '=', entityId)
52
- .limit(1)
53
- .executeTakeFirst()
54
- return !!row
55
- } catch {}
56
- return false
60
+ return 'unknown'
61
+ }
62
+
63
+ function systemEntityRecordsRejection(entityId: string) {
64
+ return NextResponse.json(
65
+ { error: 'Records are available for custom entities only', code: SYSTEM_ENTITY_RECORDS_BLOCKED_CODE, entityId },
66
+ { status: 400 },
67
+ )
57
68
  }
58
69
 
59
70
  async function readCustomEntityRecordUpdatedAt(
@@ -148,13 +159,13 @@ export async function GET(req: Request) {
148
159
  const rbac = resolve('rbacService') as RbacService
149
160
  const scope = await resolveOrganizationScope({ em, rbac, auth, selectedId: getSelectedOrganizationFromRequest(req) })
150
161
  let organizationIds: string[] | null = scope.filterIds
151
- // Read/write symmetry: this endpoint writes every record to custom_entities_storage
152
- // via the data engine, including module-declared custom entities whose id is a
153
- // frozen system id and therefore never registered in `custom_entities`. detectCustomEntity
154
- // covers the declared-entity registry plus the custom_entities / doc-storage fallbacks
155
- // (mirrors HybridQueryEngine.isCustomEntity) so mapRow strips the cf_ prefix and the edit
156
- // form can read back saved values.
157
- const isCustomEntity = await detectCustomEntity(em, entityId)
162
+ // Module-declared custom entities (ce.ts) carry frozen system-style ids and are never
163
+ // registered in `custom_entities`, so classification checks the declared registry plus
164
+ // active registrations. System (table-backed) ids are rejected above; for the allowed
165
+ // set `isCustomEntity` drives mapRow's cf_ stripping so the edit form reads back values.
166
+ const entityKind = await classifyRecordsEntity(em, entityId)
167
+ if (entityKind === 'system') return systemEntityRecordsRejection(entityId)
168
+ const isCustomEntity = entityKind === 'custom'
158
169
  await assertEntityAclForRequest({ auth, entityId, action: 'view', isCustomEntity, rbac })
159
170
  if (organizationIds && organizationIds.length === 0) {
160
171
  return NextResponse.json({ items: [], total: 0, page, pageSize, totalPages: 0 })
@@ -230,6 +241,10 @@ export async function GET(req: Request) {
230
241
  if (organizationIds && organizationIds.length) {
231
242
  qopts.organizationIds = organizationIds
232
243
  }
244
+ // Allowed entities are doc-storage-backed by definition (system ids were rejected
245
+ // above) — direct the engine to doc storage explicitly so reads stay deterministic
246
+ // even before the first record exists.
247
+ if (isCustomEntity) qopts.forceCustomEntityStorage = true
233
248
  for (const [k, v] of qpEntries) buildFilter(k, v, isCustomEntity)
234
249
  const res = await qe.query(entityId as any, qopts)
235
250
  const rawItems = res.items || []
@@ -363,7 +378,9 @@ export async function POST(req: Request) {
363
378
  const scope = await resolveOrganizationScope({ em, rbac, auth, selectedId: getSelectedOrganizationFromRequest(req) })
364
379
  const targetOrgId = scope.selectedId ?? auth.orgId
365
380
  if (!targetOrgId) return NextResponse.json({ error: 'Organization context is required' }, { status: 400 })
366
- const isCustomEntity = await detectCustomEntity(em, entityId)
381
+ const entityKind = await classifyRecordsEntity(em, entityId)
382
+ if (entityKind === 'system') return systemEntityRecordsRejection(entityId)
383
+ const isCustomEntity = entityKind === 'custom'
367
384
  await assertEntityAclForRequest({ auth, entityId, action: 'manage', isCustomEntity, rbac })
368
385
  const norm = normalizeValues(values)
369
386
 
@@ -427,7 +444,9 @@ export async function PUT(req: Request) {
427
444
  const scope = await resolveOrganizationScope({ em, rbac, auth, selectedId: getSelectedOrganizationFromRequest(req) })
428
445
  const targetOrgId = scope.selectedId ?? auth.orgId
429
446
  if (!targetOrgId) return NextResponse.json({ error: 'Organization context is required' }, { status: 400 })
430
- const isCustomEntity = await detectCustomEntity(em, entityId)
447
+ const entityKind = await classifyRecordsEntity(em, entityId)
448
+ if (entityKind === 'system') return systemEntityRecordsRejection(entityId)
449
+ const isCustomEntity = entityKind === 'custom'
431
450
  await assertEntityAclForRequest({ auth, entityId, action: 'manage', isCustomEntity, rbac })
432
451
  const norm = normalizeValues(values)
433
452
 
@@ -516,7 +535,9 @@ export async function DELETE(req: Request) {
516
535
  const scope = await resolveOrganizationScope({ em, rbac, auth, selectedId: getSelectedOrganizationFromRequest(req) })
517
536
  const targetOrgId = scope.selectedId ?? auth.orgId
518
537
  if (!targetOrgId) return NextResponse.json({ error: 'Organization context is required' }, { status: 400 })
519
- const isCustomEntity = await detectCustomEntity(em, entityId)
538
+ const entityKind = await classifyRecordsEntity(em, entityId)
539
+ if (entityKind === 'system') return systemEntityRecordsRejection(entityId)
540
+ const isCustomEntity = entityKind === 'custom'
520
541
  await assertEntityAclForRequest({ auth, entityId, action: 'manage', isCustomEntity, rbac })
521
542
  await de.deleteCustomEntityRecord({ entityId, recordId, organizationId: targetOrgId, tenantId: auth.tenantId!, soft: true })
522
543
  return NextResponse.json({ ok: true })
@@ -6,6 +6,8 @@ import { z } from 'zod'
6
6
  import { apiCall, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
7
7
  import { updateCrud } from '@open-mercato/ui/backend/utils/crud'
8
8
  import { createCrudFormError, raiseCrudError } from '@open-mercato/ui/backend/utils/serverErrors'
9
+ import { ErrorMessage, LoadingMessage } from '@open-mercato/ui/backend/detail'
10
+ import { useRecordsEntityGuard } from '@open-mercato/core/modules/entities/components/useRecordsEntityGuard'
9
11
 
10
12
  type UpdateRecordRequest = (payload: { entityId: string; recordId: string; values: Record<string, unknown> }) => Promise<void>
11
13
 
@@ -38,6 +40,19 @@ export async function submitCustomEntityRecordUpdate(options: {
38
40
  type RecordsResponse = { items: any[] }
39
41
 
40
42
  export default function EditRecordPage({ params }: { params: { entityId?: string; recordId?: string } }) {
43
+ const t = useT()
44
+ const entityId = decodeURIComponent(params?.entityId || '')
45
+ const guard = useRecordsEntityGuard(entityId)
46
+ if (guard === 'blocked') {
47
+ return <ErrorMessage label={t('entities.userEntities.records.errors.systemEntity', 'This entity is system-managed. Records are available for custom entities only.')} />
48
+ }
49
+ if (guard === 'checking') {
50
+ return <LoadingMessage label={t('entities.userEntities.records.loading', 'Loading records...')} />
51
+ }
52
+ return <EditRecordPageInner params={params} />
53
+ }
54
+
55
+ function EditRecordPageInner({ params }: { params: { entityId?: string; recordId?: string } }) {
41
56
  const t = useT()
42
57
  const entityId = decodeURIComponent(params?.entityId || '')
43
58
  const recordId = decodeURIComponent(params?.recordId || '')
@@ -6,6 +6,8 @@ import { CrudForm, type CrudField } from '@open-mercato/ui/backend/CrudForm'
6
6
  import { z } from 'zod'
7
7
  import { createCrud } from '@open-mercato/ui/backend/utils/crud'
8
8
  import { createCrudFormError } from '@open-mercato/ui/backend/utils/serverErrors'
9
+ import { ErrorMessage, LoadingMessage } from '@open-mercato/ui/backend/detail'
10
+ import { useRecordsEntityGuard } from '@open-mercato/core/modules/entities/components/useRecordsEntityGuard'
9
11
 
10
12
  type CreateRecordRequest = (payload: { entityId: string; values: Record<string, unknown> }) => Promise<void>
11
13
 
@@ -30,6 +32,19 @@ export async function submitCustomEntityRecord(options: {
30
32
  }
31
33
 
32
34
  export default function CreateRecordPage({ params }: { params: { entityId?: string } }) {
35
+ const t = useT()
36
+ const entityId = decodeURIComponent(params?.entityId || '')
37
+ const guard = useRecordsEntityGuard(entityId)
38
+ if (guard === 'blocked') {
39
+ return <ErrorMessage label={t('entities.userEntities.records.errors.systemEntity', 'This entity is system-managed. Records are available for custom entities only.')} />
40
+ }
41
+ if (guard === 'checking') {
42
+ return <LoadingMessage label={t('entities.userEntities.records.loading', 'Loading records...')} />
43
+ }
44
+ return <CreateRecordPageInner params={params} />
45
+ }
46
+
47
+ function CreateRecordPageInner({ params }: { params: { entityId?: string } }) {
33
48
  const t = useT()
34
49
  const router = useRouter()
35
50
  const entityId = decodeURIComponent(params?.entityId || '')
@@ -17,6 +17,9 @@ import { flash } from '@open-mercato/ui/backend/FlashMessages'
17
17
  import { raiseCrudError } from '@open-mercato/ui/backend/utils/serverErrors'
18
18
  import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
19
19
  import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
20
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
21
+ import { ErrorMessage, LoadingMessage } from '@open-mercato/ui/backend/detail'
22
+ import { useRecordsEntityGuard } from '@open-mercato/core/modules/entities/components/useRecordsEntityGuard'
20
23
 
21
24
  type RecordsResponse = {
22
25
  items: any[]
@@ -44,6 +47,26 @@ function normalizeCell(v: any): string {
44
47
  }
45
48
 
46
49
  export default function RecordsPage({ params }: { params: { entityId?: string } }) {
50
+ const t = useT()
51
+ const entityId = decodeURIComponent(params?.entityId || '')
52
+ const guard = useRecordsEntityGuard(entityId)
53
+ if (guard !== 'allowed') {
54
+ return (
55
+ <Page>
56
+ <PageBody>
57
+ {guard === 'blocked' ? (
58
+ <ErrorMessage label={t('entities.userEntities.records.errors.systemEntity', 'This entity is system-managed. Records are available for custom entities only.')} />
59
+ ) : (
60
+ <LoadingMessage label={t('entities.userEntities.records.loading', 'Loading records...')} />
61
+ )}
62
+ </PageBody>
63
+ </Page>
64
+ )
65
+ }
66
+ return <RecordsPageInner params={params} />
67
+ }
68
+
69
+ function RecordsPageInner({ params }: { params: { entityId?: string } }) {
47
70
  const entityId = decodeURIComponent(params?.entityId || '')
48
71
  const [sorting, setSorting] = React.useState<SortingState>([{ id: 'id', desc: false }])
49
72
  const [page, setPage] = React.useState(1)
@@ -0,0 +1,41 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
4
+
5
+ export type RecordsEntityGuardState = 'checking' | 'blocked' | 'allowed'
6
+
7
+ // Mirrors SYSTEM_ENTITY_RECORDS_BLOCKED_CODE from @open-mercato/shared/lib/data/engine —
8
+ // kept as a literal so client bundles do not pull the server-side data engine in.
9
+ const SYSTEM_ENTITY_RECORDS_BLOCKED_CODE = 'system_entity_records_blocked'
10
+
11
+ /**
12
+ * The records surface serves custom entities only; the API rejects system
13
+ * (table-backed) entity ids with 400 + `system_entity_records_blocked` (#2939
14
+ * hardening). Records pages are URL-addressable for any entity id, so they probe
15
+ * once and render a dedicated error state instead of a broken table/form.
16
+ * Fails open on transport errors — the page's own data calls surface those.
17
+ */
18
+ export function useRecordsEntityGuard(entityId: string): RecordsEntityGuardState {
19
+ const [state, setState] = React.useState<RecordsEntityGuardState>(entityId ? 'checking' : 'allowed')
20
+ React.useEffect(() => {
21
+ if (!entityId) {
22
+ setState('allowed')
23
+ return
24
+ }
25
+ let cancelled = false
26
+ setState('checking')
27
+ apiCall<{ code?: string }>(`/api/entities/records?entityId=${encodeURIComponent(entityId)}&page=1&pageSize=1`)
28
+ .then((res) => {
29
+ if (cancelled) return
30
+ const blocked = res.status === 400 && res.result?.code === SYSTEM_ENTITY_RECORDS_BLOCKED_CODE
31
+ setState(blocked ? 'blocked' : 'allowed')
32
+ })
33
+ .catch(() => {
34
+ if (!cancelled) setState('allowed')
35
+ })
36
+ return () => {
37
+ cancelled = true
38
+ }
39
+ }, [entityId])
40
+ return state
41
+ }
@@ -82,6 +82,7 @@
82
82
  "entities.userEntities.records.errors.deleteFailed": "Datensatz konnte nicht gelöscht werden",
83
83
  "entities.userEntities.records.errors.entityIdRequired": "Entitätskennung ist erforderlich",
84
84
  "entities.userEntities.records.errors.recordIdRequired": "Datensatzkennung ist erforderlich",
85
+ "entities.userEntities.records.errors.systemEntity": "Diese Entität wird vom System verwaltet. Datensätze sind nur für benutzerdefinierte Entitäten verfügbar.",
85
86
  "entities.userEntities.records.form.createTitle": "Datensatz erstellen",
86
87
  "entities.userEntities.records.form.editTitle": "Datensatz bearbeiten",
87
88
  "entities.userEntities.records.form.submitCreate": "Erstellen",
@@ -82,6 +82,7 @@
82
82
  "entities.userEntities.records.errors.deleteFailed": "Failed to delete record",
83
83
  "entities.userEntities.records.errors.entityIdRequired": "Entity identifier is required",
84
84
  "entities.userEntities.records.errors.recordIdRequired": "Record identifier is required",
85
+ "entities.userEntities.records.errors.systemEntity": "This entity is system-managed. Records are available for custom entities only.",
85
86
  "entities.userEntities.records.form.createTitle": "Create record",
86
87
  "entities.userEntities.records.form.editTitle": "Edit record",
87
88
  "entities.userEntities.records.form.submitCreate": "Create",
@@ -82,6 +82,7 @@
82
82
  "entities.userEntities.records.errors.deleteFailed": "No se pudo eliminar el registro",
83
83
  "entities.userEntities.records.errors.entityIdRequired": "Se requiere el identificador de la entidad",
84
84
  "entities.userEntities.records.errors.recordIdRequired": "Se requiere el identificador del registro",
85
+ "entities.userEntities.records.errors.systemEntity": "Esta entidad está gestionada por el sistema. Los registros solo están disponibles para entidades personalizadas.",
85
86
  "entities.userEntities.records.form.createTitle": "Crear registro",
86
87
  "entities.userEntities.records.form.editTitle": "Editar registro",
87
88
  "entities.userEntities.records.form.submitCreate": "Crear",
@@ -82,6 +82,7 @@
82
82
  "entities.userEntities.records.errors.deleteFailed": "Nie udało się usunąć rekordu",
83
83
  "entities.userEntities.records.errors.entityIdRequired": "Wymagany jest identyfikator encji",
84
84
  "entities.userEntities.records.errors.recordIdRequired": "Wymagany jest identyfikator rekordu",
85
+ "entities.userEntities.records.errors.systemEntity": "Ta encja jest zarządzana systemowo. Rekordy są dostępne wyłącznie dla encji niestandardowych.",
85
86
  "entities.userEntities.records.form.createTitle": "Utwórz rekord",
86
87
  "entities.userEntities.records.form.editTitle": "Edytuj rekord",
87
88
  "entities.userEntities.records.form.submitCreate": "Utwórz",
@@ -254,6 +254,7 @@ export class IndexerStatusLog {
254
254
  @Entity({ tableName: 'search_tokens' })
255
255
  @Index({ name: 'search_tokens_lookup_idx', properties: ['entityType', 'field', 'tokenHash', 'tenantId', 'organizationId'] })
256
256
  @Index({ name: 'search_tokens_entity_idx', properties: ['entityType', 'entityId'] })
257
+ @Index({ name: 'search_tokens_tenant_token_hash_idx', properties: ['tenantId', 'tokenHash'] })
257
258
  export class SearchToken {
258
259
  @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
259
260
  id!: string
@@ -2,7 +2,7 @@ import type { QueryEngine, QueryOptions, QueryResult, FilterOp, Filter, QueryCus
2
2
  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
- import { BasicQueryEngine, resolveEntityTableName } from '@open-mercato/shared/lib/query/engine'
5
+ import { BasicQueryEngine, resolveEntityTableName, resolveRegisteredEntityTableName } from '@open-mercato/shared/lib/query/engine'
6
6
  import { type Kysely, sql, type RawBuilder } from 'kysely'
7
7
  import type { EventBus } from '@open-mercato/events'
8
8
  import { readCoverageSnapshot, refreshCoverageSnapshot } from './coverage'
@@ -219,7 +219,7 @@ export class HybridQueryEngine implements QueryEngine {
219
219
  const debugEnabled = this.isDebugVerbosity()
220
220
  if (debugEnabled) this.debug('query:start', { entity })
221
221
 
222
- const isCustom = await this.isCustomEntity(entity)
222
+ const isCustom = opts.forceCustomEntityStorage === true || await this.isCustomEntity(entity)
223
223
  if (isCustom) {
224
224
  if (debugEnabled) this.debug('query:custom-entity', { entity })
225
225
  const section = profiler.section('custom_entity')
@@ -1005,6 +1005,14 @@ export class HybridQueryEngine implements QueryEngine {
1005
1005
  .executeTakeFirst()
1006
1006
  if (row) {
1007
1007
  result = true
1008
+ } else if (resolveRegisteredEntityTableName(this.em, entity) !== null) {
1009
+ // An id backed by a registered ORM table is never doc-storage-backed by
1010
+ // inference: stray `custom_entities_storage` rows for such an id (e.g. written
1011
+ // through the generic entities data engine) must not hijack every list/detail
1012
+ // read for the whole entity type away from its base table (#2939). Surfaces
1013
+ // that intentionally read doc records for a dual-declared id pass
1014
+ // `forceCustomEntityStorage` in QueryOptions instead.
1015
+ result = false
1008
1016
  } else {
1009
1017
  // Read/write symmetry. Records written through the entities data engine
1010
1018
  // (`de.createCustomEntityRecord`) always land in `custom_entities_storage`,
@@ -1012,9 +1020,7 @@ export class HybridQueryEngine implements QueryEngine {
1012
1020
  // id — those are NEVER registered in `custom_entities` (install treats a
1013
1021
  // system id as non-registrable). Without this fallback the query routes to
1014
1022
  // the empty ORM/index path and those records are write-only (created with
1015
- // 200 but unreadable on the edit form). A real ORM entity never writes rows
1016
- // to `custom_entities_storage`, so this can only ever re-classify genuine
1017
- // doc-storage entities — it cannot misroute table-backed entities.
1023
+ // 200 but unreadable on the edit form).
1018
1024
  result = await this.hasCustomEntityStorageRows(entity)
1019
1025
  }
1020
1026
  } catch {
@@ -1354,6 +1354,17 @@
1354
1354
  "primary": false,
1355
1355
  "unique": false
1356
1356
  },
1357
+ {
1358
+ "columnNames": [
1359
+ "tenant_id",
1360
+ "token_hash"
1361
+ ],
1362
+ "composite": true,
1363
+ "constraint": false,
1364
+ "keyName": "search_tokens_tenant_token_hash_idx",
1365
+ "primary": false,
1366
+ "unique": false
1367
+ },
1357
1368
  {
1358
1369
  "columnNames": [
1359
1370
  "id"