@open-mercato/core 0.8.1-develop.7275.1.f772b944d4 → 0.8.1-develop.7295.1.d0e0e33014

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 (261) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/api/users/acl/route.js.map +2 -2
  3. package/dist/modules/auth/components/AclEditor.js +21 -3
  4. package/dist/modules/auth/components/AclEditor.js.map +2 -2
  5. package/dist/modules/customers/backend/customers/companies/page.js +6 -4
  6. package/dist/modules/customers/backend/customers/companies/page.js.map +2 -2
  7. package/dist/modules/customers/backend/customers/deals/map/components/DealsMapView.js +6 -4
  8. package/dist/modules/customers/backend/customers/deals/map/components/DealsMapView.js.map +2 -2
  9. package/dist/modules/customers/backend/customers/deals/page.js +6 -4
  10. package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
  11. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.js +5 -3
  12. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.js.map +2 -2
  13. package/dist/modules/customers/backend/customers/deals/pipeline/page.js +6 -4
  14. package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +2 -2
  15. package/dist/modules/customers/backend/customers/people/page.js +6 -4
  16. package/dist/modules/customers/backend/customers/people/page.js.map +2 -2
  17. package/dist/modules/customers/components/calendar/editor/PeopleField.js +9 -2
  18. package/dist/modules/customers/components/calendar/editor/PeopleField.js.map +2 -2
  19. package/dist/modules/customers/components/calendar/editor/hooks.js +4 -2
  20. package/dist/modules/customers/components/calendar/editor/hooks.js.map +2 -2
  21. package/dist/modules/customers/components/calendar/editor/lookups.js +7 -3
  22. package/dist/modules/customers/components/calendar/editor/lookups.js.map +2 -2
  23. package/dist/modules/customers/components/detail/AssignRoleDialog.js +5 -2
  24. package/dist/modules/customers/components/detail/AssignRoleDialog.js.map +2 -2
  25. package/dist/modules/customers/components/detail/RoleAssignmentRow.js +4 -2
  26. package/dist/modules/customers/components/detail/RoleAssignmentRow.js.map +2 -2
  27. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js +8 -5
  28. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js.map +2 -2
  29. package/dist/modules/customers/components/detail/schedule/DateTimeFields.js +10 -7
  30. package/dist/modules/customers/components/detail/schedule/DateTimeFields.js.map +2 -2
  31. package/dist/modules/customers/components/detail/schedule/ParticipantsField.js +4 -2
  32. package/dist/modules/customers/components/detail/schedule/ParticipantsField.js.map +2 -2
  33. package/dist/modules/customers/components/detail/schedule/fieldConfig.js +9 -0
  34. package/dist/modules/customers/components/detail/schedule/fieldConfig.js.map +2 -2
  35. package/dist/modules/customers/components/detail/schedule/index.js +3 -1
  36. package/dist/modules/customers/components/detail/schedule/index.js.map +2 -2
  37. package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js +11 -4
  38. package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js.map +2 -2
  39. package/dist/modules/customers/components/formConfig.js +17 -9
  40. package/dist/modules/customers/components/formConfig.js.map +2 -2
  41. package/dist/modules/customers/data/validators.js +12 -7
  42. package/dist/modules/customers/data/validators.js.map +2 -2
  43. package/dist/modules/customers/lib/assignableStaff.js +103 -22
  44. package/dist/modules/customers/lib/assignableStaff.js.map +2 -2
  45. package/dist/modules/customers/setup.js +10 -2
  46. package/dist/modules/customers/setup.js.map +2 -2
  47. package/dist/modules/data_sync/api/schedules/[id]/route.js +5 -0
  48. package/dist/modules/data_sync/api/schedules/[id]/route.js.map +2 -2
  49. package/dist/modules/data_sync/api/schedules/route.js +5 -0
  50. package/dist/modules/data_sync/api/schedules/route.js.map +2 -2
  51. package/dist/modules/data_sync/backend/data-sync/page.js +29 -5
  52. package/dist/modules/data_sync/backend/data-sync/page.js.map +2 -2
  53. package/dist/modules/data_sync/components/IntegrationScheduleTab.js +48 -11
  54. package/dist/modules/data_sync/components/IntegrationScheduleTab.js.map +2 -2
  55. package/dist/modules/data_sync/data/validators.js +13 -2
  56. package/dist/modules/data_sync/data/validators.js.map +2 -2
  57. package/dist/modules/data_sync/lib/schedule-value.js +30 -0
  58. package/dist/modules/data_sync/lib/schedule-value.js.map +7 -0
  59. package/dist/modules/eudr/data/validators.js.map +2 -2
  60. package/dist/modules/portal/frontend/[orgSlug]/portal/dashboard/page.js +3 -4
  61. package/dist/modules/portal/frontend/[orgSlug]/portal/dashboard/page.js.map +2 -2
  62. package/dist/modules/portal/frontend/[orgSlug]/portal/invite/page.js +3 -1
  63. package/dist/modules/portal/frontend/[orgSlug]/portal/invite/page.js.map +2 -2
  64. package/dist/modules/portal/frontend/[orgSlug]/portal/login/page.js +2 -1
  65. package/dist/modules/portal/frontend/[orgSlug]/portal/login/page.js.map +2 -2
  66. package/dist/modules/portal/frontend/[orgSlug]/portal/page.js +3 -4
  67. package/dist/modules/portal/frontend/[orgSlug]/portal/page.js.map +2 -2
  68. package/dist/modules/portal/frontend/[orgSlug]/portal/profile/page.js +3 -4
  69. package/dist/modules/portal/frontend/[orgSlug]/portal/profile/page.js.map +2 -2
  70. package/dist/modules/portal/lib/navigation.js +3 -4
  71. package/dist/modules/portal/lib/navigation.js.map +2 -2
  72. package/dist/modules/query_index/lib/engine.js +155 -15
  73. package/dist/modules/query_index/lib/engine.js.map +2 -2
  74. package/dist/modules/staff/backend/staff/time-tracking/page.js +122 -86
  75. package/dist/modules/staff/backend/staff/time-tracking/page.js.map +2 -2
  76. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js +38 -17
  77. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js.map +2 -2
  78. package/dist/modules/staff/backend/staff/time-tracking/reports/[id]/page.js +45 -18
  79. package/dist/modules/staff/backend/staff/time-tracking/reports/[id]/page.js.map +2 -2
  80. package/dist/modules/staff/extension-points.js +16 -0
  81. package/dist/modules/staff/extension-points.js.map +2 -2
  82. package/dist/modules/staff/frontend/[orgSlug]/portal/time-reports/[id]/page.js +3 -4
  83. package/dist/modules/staff/frontend/[orgSlug]/portal/time-reports/[id]/page.js.map +2 -2
  84. package/dist/modules/staff/frontend/[orgSlug]/portal/time-reports/page.js +3 -4
  85. package/dist/modules/staff/frontend/[orgSlug]/portal/time-reports/page.js.map +2 -2
  86. package/dist/modules/staff/lib/time-tracking-ui/ReportSheet.js +14 -13
  87. package/dist/modules/staff/lib/time-tracking-ui/ReportSheet.js.map +2 -2
  88. package/dist/modules/staff/lib/time-tracking-ui/TaskListView.js +60 -41
  89. package/dist/modules/staff/lib/time-tracking-ui/TaskListView.js.map +2 -2
  90. package/dist/modules/warranty_claims/api/ai/assess/route.js +2 -2
  91. package/dist/modules/warranty_claims/api/ai/assess/route.js.map +2 -2
  92. package/dist/modules/warranty_claims/api/ai/draft-reply/route.js +3 -4
  93. package/dist/modules/warranty_claims/api/ai/draft-reply/route.js.map +3 -3
  94. package/dist/modules/warranty_claims/api/ai/suggest/route.js +3 -3
  95. package/dist/modules/warranty_claims/api/ai/suggest/route.js.map +2 -2
  96. package/dist/modules/warranty_claims/api/assign/route.js +3 -3
  97. package/dist/modules/warranty_claims/api/assign/route.js.map +2 -2
  98. package/dist/modules/warranty_claims/api/credit-memo/route.js +2 -2
  99. package/dist/modules/warranty_claims/api/credit-memo/route.js.map +2 -2
  100. package/dist/modules/warranty_claims/api/entitlement/route.js +2 -2
  101. package/dist/modules/warranty_claims/api/entitlement/route.js.map +2 -2
  102. package/dist/modules/warranty_claims/api/events/route.js +4 -4
  103. package/dist/modules/warranty_claims/api/events/route.js.map +2 -2
  104. package/dist/modules/warranty_claims/api/external/claims/route.js +3 -3
  105. package/dist/modules/warranty_claims/api/external/claims/route.js.map +2 -2
  106. package/dist/modules/warranty_claims/api/portal/claims/[id]/submit/route.js +2 -2
  107. package/dist/modules/warranty_claims/api/portal/claims/[id]/submit/route.js.map +2 -2
  108. package/dist/modules/warranty_claims/api/portal/claims/[id]/withdraw/route.js +2 -2
  109. package/dist/modules/warranty_claims/api/portal/claims/[id]/withdraw/route.js.map +2 -2
  110. package/dist/modules/warranty_claims/api/portal/claims/route.js +2 -2
  111. package/dist/modules/warranty_claims/api/portal/claims/route.js.map +2 -2
  112. package/dist/modules/warranty_claims/api/portal/events/route.js +2 -2
  113. package/dist/modules/warranty_claims/api/portal/events/route.js.map +2 -2
  114. package/dist/modules/warranty_claims/api/receiving/route.js +2 -2
  115. package/dist/modules/warranty_claims/api/receiving/route.js.map +2 -2
  116. package/dist/modules/warranty_claims/api/replacement-order/route.js +2 -2
  117. package/dist/modules/warranty_claims/api/replacement-order/route.js.map +2 -2
  118. package/dist/modules/warranty_claims/api/return-label/route.js +2 -2
  119. package/dist/modules/warranty_claims/api/return-label/route.js.map +2 -2
  120. package/dist/modules/warranty_claims/api/risk/route.js +2 -2
  121. package/dist/modules/warranty_claims/api/risk/route.js.map +2 -2
  122. package/dist/modules/warranty_claims/api/sales-return/route.js +2 -2
  123. package/dist/modules/warranty_claims/api/sales-return/route.js.map +2 -2
  124. package/dist/modules/warranty_claims/api/settings-general/route.js +4 -4
  125. package/dist/modules/warranty_claims/api/settings-general/route.js.map +2 -2
  126. package/dist/modules/warranty_claims/api/stats/route.js +2 -2
  127. package/dist/modules/warranty_claims/api/stats/route.js.map +2 -2
  128. package/dist/modules/warranty_claims/api/submit/route.js +3 -3
  129. package/dist/modules/warranty_claims/api/submit/route.js.map +2 -2
  130. package/dist/modules/warranty_claims/api/transition/route.js +3 -3
  131. package/dist/modules/warranty_claims/api/transition/route.js.map +2 -2
  132. package/dist/modules/warranty_claims/api/vendor-recovery/route.js +3 -3
  133. package/dist/modules/warranty_claims/api/vendor-recovery/route.js.map +2 -2
  134. package/dist/modules/warranty_claims/api/vendor-recovery-suggestions/route.js +2 -2
  135. package/dist/modules/warranty_claims/api/vendor-recovery-suggestions/route.js.map +2 -2
  136. package/dist/modules/warranty_claims/backend/page.js +4 -2
  137. package/dist/modules/warranty_claims/backend/page.js.map +2 -2
  138. package/dist/modules/warranty_claims/backend/warranty_claims/[id]/page.js +9 -2
  139. package/dist/modules/warranty_claims/backend/warranty_claims/[id]/page.js.map +2 -2
  140. package/dist/modules/warranty_claims/backend/warranty_claims/settings/page.js +4 -2
  141. package/dist/modules/warranty_claims/backend/warranty_claims/settings/page.js.map +2 -2
  142. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.js +3 -4
  143. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.js.map +2 -2
  144. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/new/page.js +3 -2
  145. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/new/page.js.map +2 -2
  146. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/page.js +3 -2
  147. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/page.js.map +2 -2
  148. package/dist/modules/workflows/components/DefinitionTriggersEditor.js +1 -1
  149. package/dist/modules/workflows/components/DefinitionTriggersEditor.js.map +2 -2
  150. package/dist/modules/workflows/frontend/[orgSlug]/portal/tasks/[id]/page.js +2 -2
  151. package/dist/modules/workflows/frontend/[orgSlug]/portal/tasks/[id]/page.js.map +2 -2
  152. package/dist/modules/workflows/lib/event-trigger-service.js +56 -1
  153. package/dist/modules/workflows/lib/event-trigger-service.js.map +2 -2
  154. package/package.json +7 -7
  155. package/src/modules/auth/api/users/acl/route.ts +4 -3
  156. package/src/modules/auth/components/AclEditor.tsx +38 -2
  157. package/src/modules/auth/i18n/de.json +5 -1
  158. package/src/modules/auth/i18n/en.json +5 -1
  159. package/src/modules/auth/i18n/es.json +5 -1
  160. package/src/modules/auth/i18n/ko.json +5 -1
  161. package/src/modules/auth/i18n/pl.json +5 -1
  162. package/src/modules/customers/backend/customers/companies/page.tsx +6 -4
  163. package/src/modules/customers/backend/customers/deals/map/components/DealsMapView.tsx +6 -4
  164. package/src/modules/customers/backend/customers/deals/page.tsx +6 -4
  165. package/src/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.tsx +5 -3
  166. package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +6 -4
  167. package/src/modules/customers/backend/customers/people/page.tsx +6 -4
  168. package/src/modules/customers/components/calendar/editor/PeopleField.tsx +9 -2
  169. package/src/modules/customers/components/calendar/editor/hooks.ts +4 -2
  170. package/src/modules/customers/components/calendar/editor/lookups.ts +12 -4
  171. package/src/modules/customers/components/detail/AssignRoleDialog.tsx +4 -1
  172. package/src/modules/customers/components/detail/RoleAssignmentRow.tsx +4 -2
  173. package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +15 -7
  174. package/src/modules/customers/components/detail/schedule/DateTimeFields.tsx +16 -7
  175. package/src/modules/customers/components/detail/schedule/ParticipantsField.tsx +4 -2
  176. package/src/modules/customers/components/detail/schedule/fieldConfig.ts +15 -0
  177. package/src/modules/customers/components/detail/schedule/index.ts +1 -1
  178. package/src/modules/customers/components/detail/schedule/useScheduleFormState.ts +16 -9
  179. package/src/modules/customers/components/formConfig.tsx +35 -12
  180. package/src/modules/customers/data/validators.ts +14 -8
  181. package/src/modules/customers/i18n/de.json +1 -0
  182. package/src/modules/customers/i18n/en.json +1 -0
  183. package/src/modules/customers/i18n/es.json +1 -0
  184. package/src/modules/customers/i18n/ko.json +1 -0
  185. package/src/modules/customers/i18n/pl.json +1 -0
  186. package/src/modules/customers/lib/assignableStaff.ts +172 -34
  187. package/src/modules/customers/setup.ts +8 -0
  188. package/src/modules/data_sync/api/schedules/[id]/route.ts +5 -0
  189. package/src/modules/data_sync/api/schedules/route.ts +5 -0
  190. package/src/modules/data_sync/backend/data-sync/page.tsx +48 -4
  191. package/src/modules/data_sync/components/IntegrationScheduleTab.tsx +61 -2
  192. package/src/modules/data_sync/data/validators.ts +26 -2
  193. package/src/modules/data_sync/i18n/de.json +2 -0
  194. package/src/modules/data_sync/i18n/en.json +2 -0
  195. package/src/modules/data_sync/i18n/es.json +2 -0
  196. package/src/modules/data_sync/i18n/ko.json +2 -0
  197. package/src/modules/data_sync/i18n/pl.json +2 -0
  198. package/src/modules/data_sync/lib/schedule-value.ts +45 -0
  199. package/src/modules/eudr/data/validators.ts +3 -4
  200. package/src/modules/portal/frontend/[orgSlug]/portal/dashboard/page.tsx +5 -4
  201. package/src/modules/portal/frontend/[orgSlug]/portal/invite/page.tsx +3 -1
  202. package/src/modules/portal/frontend/[orgSlug]/portal/login/page.tsx +2 -1
  203. package/src/modules/portal/frontend/[orgSlug]/portal/page.tsx +5 -5
  204. package/src/modules/portal/frontend/[orgSlug]/portal/profile/page.tsx +5 -4
  205. package/src/modules/portal/i18n/de.json +1 -0
  206. package/src/modules/portal/i18n/en.json +1 -0
  207. package/src/modules/portal/i18n/es.json +1 -0
  208. package/src/modules/portal/i18n/ko.json +1 -0
  209. package/src/modules/portal/i18n/pl.json +1 -0
  210. package/src/modules/portal/lib/navigation.ts +8 -3
  211. package/src/modules/query_index/lib/engine.ts +238 -17
  212. package/src/modules/staff/backend/staff/time-tracking/page.tsx +161 -130
  213. package/src/modules/staff/backend/staff/time-tracking/projects/[id]/page.tsx +55 -43
  214. package/src/modules/staff/backend/staff/time-tracking/reports/[id]/page.tsx +61 -38
  215. package/src/modules/staff/extension-points.ts +16 -0
  216. package/src/modules/staff/frontend/[orgSlug]/portal/time-reports/[id]/page.tsx +3 -4
  217. package/src/modules/staff/frontend/[orgSlug]/portal/time-reports/page.tsx +3 -4
  218. package/src/modules/staff/i18n/de.json +3 -0
  219. package/src/modules/staff/i18n/en.json +3 -0
  220. package/src/modules/staff/i18n/es.json +3 -0
  221. package/src/modules/staff/i18n/ko.json +3 -0
  222. package/src/modules/staff/i18n/pl.json +3 -0
  223. package/src/modules/staff/lib/time-tracking-ui/ReportSheet.tsx +32 -29
  224. package/src/modules/staff/lib/time-tracking-ui/TaskListView.tsx +89 -79
  225. package/src/modules/warranty_claims/api/ai/assess/route.ts +2 -2
  226. package/src/modules/warranty_claims/api/ai/draft-reply/route.ts +2 -3
  227. package/src/modules/warranty_claims/api/ai/suggest/route.ts +3 -3
  228. package/src/modules/warranty_claims/api/assign/route.ts +3 -3
  229. package/src/modules/warranty_claims/api/credit-memo/route.ts +2 -2
  230. package/src/modules/warranty_claims/api/entitlement/route.ts +2 -2
  231. package/src/modules/warranty_claims/api/events/route.ts +4 -4
  232. package/src/modules/warranty_claims/api/external/claims/route.ts +3 -3
  233. package/src/modules/warranty_claims/api/portal/claims/[id]/submit/route.ts +2 -2
  234. package/src/modules/warranty_claims/api/portal/claims/[id]/withdraw/route.ts +2 -2
  235. package/src/modules/warranty_claims/api/portal/claims/route.ts +2 -2
  236. package/src/modules/warranty_claims/api/portal/events/route.ts +2 -2
  237. package/src/modules/warranty_claims/api/receiving/route.ts +2 -2
  238. package/src/modules/warranty_claims/api/replacement-order/route.ts +2 -2
  239. package/src/modules/warranty_claims/api/return-label/route.ts +2 -2
  240. package/src/modules/warranty_claims/api/risk/route.ts +2 -2
  241. package/src/modules/warranty_claims/api/sales-return/route.ts +2 -2
  242. package/src/modules/warranty_claims/api/settings-general/route.ts +4 -4
  243. package/src/modules/warranty_claims/api/stats/route.ts +2 -2
  244. package/src/modules/warranty_claims/api/submit/route.ts +3 -3
  245. package/src/modules/warranty_claims/api/transition/route.ts +3 -3
  246. package/src/modules/warranty_claims/api/vendor-recovery/route.ts +3 -3
  247. package/src/modules/warranty_claims/api/vendor-recovery-suggestions/route.ts +2 -2
  248. package/src/modules/warranty_claims/backend/page.tsx +4 -2
  249. package/src/modules/warranty_claims/backend/warranty_claims/[id]/page.tsx +9 -2
  250. package/src/modules/warranty_claims/backend/warranty_claims/settings/page.tsx +4 -2
  251. package/src/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.tsx +5 -4
  252. package/src/modules/warranty_claims/frontend/[orgSlug]/portal/claims/new/page.tsx +5 -2
  253. package/src/modules/warranty_claims/frontend/[orgSlug]/portal/claims/page.tsx +5 -2
  254. package/src/modules/workflows/components/DefinitionTriggersEditor.tsx +1 -1
  255. package/src/modules/workflows/frontend/[orgSlug]/portal/tasks/[id]/page.tsx +2 -2
  256. package/src/modules/workflows/i18n/de.json +1 -1
  257. package/src/modules/workflows/i18n/en.json +1 -1
  258. package/src/modules/workflows/i18n/es.json +1 -1
  259. package/src/modules/workflows/i18n/ko.json +1 -1
  260. package/src/modules/workflows/i18n/pl.json +1 -1
  261. package/src/modules/workflows/lib/event-trigger-service.ts +139 -0
@@ -31,6 +31,8 @@
31
31
  "data_sync.dashboard.schedule.interval": "간격",
32
32
  "data_sync.dashboard.schedule.intervalHelp": "예: 반복 간격으로 `1h`, `6h`, `24h`를 사용하세요.",
33
33
  "data_sync.dashboard.schedule.intervalValue": "간격",
34
+ "data_sync.dashboard.schedule.invalidCron": "스케줄러가 해석할 수 있는 cron 표현식을 입력하세요. 예: `0 * * * *`.",
35
+ "data_sync.dashboard.schedule.invalidInterval": "`15m`, `1h`, `24h`처럼 정수 뒤에 s, m, h 또는 d를 붙여 1분 이상으로 입력하세요.",
34
36
  "data_sync.dashboard.schedule.invalidValue": "저장하기 전에 일정 값을 입력하세요.",
35
37
  "data_sync.dashboard.schedule.lastRun": "마지막 예약 실행: {value}",
36
38
  "data_sync.dashboard.schedule.neverRun": "저장되었지만 아직 완료된 예약 실행이 없습니다.",
@@ -31,6 +31,8 @@
31
31
  "data_sync.dashboard.schedule.interval": "Interwał",
32
32
  "data_sync.dashboard.schedule.intervalHelp": "Przykład: `1h`, `6h` lub `24h` dla powtarzalnych interwałów.",
33
33
  "data_sync.dashboard.schedule.intervalValue": "Interwał",
34
+ "data_sync.dashboard.schedule.invalidCron": "Podaj wyrażenie cron, które scheduler potrafi przetworzyć, na przykład `0 * * * *`.",
35
+ "data_sync.dashboard.schedule.invalidInterval": "Podaj liczbę całkowitą z jednostką s, m, h lub d (na przykład `15m`, `1h` lub `24h`), o długości co najmniej jednej minuty.",
34
36
  "data_sync.dashboard.schedule.invalidValue": "Podaj wartość harmonogramu przed zapisaniem.",
35
37
  "data_sync.dashboard.schedule.lastRun": "Ostatni zaplanowany przebieg: {value}",
36
38
  "data_sync.dashboard.schedule.neverRun": "Zapisano, ale żadne zaplanowane wykonanie jeszcze się nie zakończyło.",
@@ -0,0 +1,45 @@
1
+ import type { ScheduleValueKind } from '@open-mercato/shared/lib/schedule/invalidScheduleValue'
2
+
3
+ export const SCHEDULE_VALUE_FIELD = 'scheduleValue'
4
+
5
+ export const SCHEDULE_VALUE_FORMAT_MESSAGES: Record<ScheduleValueKind, string> = {
6
+ interval: 'Interval must be a whole number followed by s, m, h or d (for example 15m, 1h or 24h) and cover at least one minute.',
7
+ cron: 'Cron expression could not be parsed. Use the five-field format, for example "0 * * * *".',
8
+ }
9
+
10
+ export type ScheduleValueErrorBody = {
11
+ error: string
12
+ details: {
13
+ formErrors: string[]
14
+ fieldErrors: Record<string, string[]>
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Mirrors zod's `flatten()` shape so a scheduler-reported format failure and a
20
+ * schema-reported one reach the client as the same structured field error
21
+ * instead of an internal scheduler message.
22
+ */
23
+ export function buildScheduleValueErrorBody(scheduleType: ScheduleValueKind): ScheduleValueErrorBody {
24
+ return {
25
+ error: 'Invalid payload',
26
+ details: {
27
+ formErrors: [],
28
+ fieldErrors: { [SCHEDULE_VALUE_FIELD]: [SCHEDULE_VALUE_FORMAT_MESSAGES[scheduleType]] },
29
+ },
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Lets a form render its own translated hint next to the field instead of
35
+ * flashing the API's English message as a toast.
36
+ */
37
+ export function hasScheduleValueFieldError(payload: unknown): boolean {
38
+ if (typeof payload !== 'object' || payload === null) return false
39
+ const details = (payload as { details?: unknown }).details
40
+ if (typeof details !== 'object' || details === null) return false
41
+ const fieldErrors = (details as { fieldErrors?: unknown }).fieldErrors
42
+ if (typeof fieldErrors !== 'object' || fieldErrors === null) return false
43
+ const messages = (fieldErrors as Record<string, unknown>)[SCHEDULE_VALUE_FIELD]
44
+ return Array.isArray(messages) && messages.length > 0
45
+ }
@@ -127,10 +127,9 @@ const pastOrNowIsoDateTimeSchema = () => isoDateTimeSchema().refine((value) => v
127
127
  const isoDateOrDateTimeSchema = () => z.union([isoDateSchema(), isoDateTimeSchema()])
128
128
 
129
129
  // Country codes are validated through ICU rather than the shared country helper.
130
- // That helper imports language-subtag-registry's JSON, and the build strips the
131
- // import attribute Node's ESM loader requires, so importing it from server-side
132
- // code (CLI, workers) fails with ERR_IMPORT_ATTRIBUTE_MISSING. UI components can
133
- // keep using the helper because bundlers resolve the JSON themselves.
130
+ // ICU keeps this schema self-contained (no dependency on the generated country
131
+ // list) and accepts any region code Node's DisplayNames can resolve, including
132
+ // codes the shared helper supplements separately (e.g. XK).
134
133
  const countryDisplayNames = typeof Intl !== 'undefined' && typeof Intl.DisplayNames !== 'undefined'
135
134
  ? new Intl.DisplayNames(['en'], { type: 'region' })
136
135
  : null
@@ -1,7 +1,7 @@
1
1
  "use client"
2
2
  import React, { useEffect, useMemo, useState, useCallback } from 'react'
3
3
  import { extensionPoints } from '@open-mercato/core/modules/portal/extension-points'
4
- import { useRouter } from 'next/navigation'
4
+ import { replaceWithPageReload } from '@open-mercato/core/modules/portal/lib/navigation'
5
5
  import { useT } from '@open-mercato/shared/lib/i18n/context'
6
6
  import { Button } from '@open-mercato/ui/primitives/button'
7
7
  import { Spinner } from '@open-mercato/ui/primitives/spinner'
@@ -29,7 +29,6 @@ function WidgetIcon({ className }: { className?: string }) {
29
29
 
30
30
  export default function PortalDashboardPage({ params }: Props) {
31
31
  const t = useT()
32
- const router = useRouter()
33
32
  const { auth } = usePortalContext()
34
33
  const { user, loading } = auth
35
34
 
@@ -47,11 +46,13 @@ export default function PortalDashboardPage({ params }: Props) {
47
46
  setHiddenWidgets(user ? loadHiddenWidgets(params.orgSlug, user.id) : new Set())
48
47
  }
49
48
 
49
+ // Leaving an authenticated page for the login page crosses the public/authenticated
50
+ // boundary, so it must be a full page load — see `replaceWithPageReload`.
50
51
  useEffect(() => {
51
52
  if (!loading && !user) {
52
- router.replace(`/${params.orgSlug}/portal/login`)
53
+ replaceWithPageReload(`/${params.orgSlug}/portal/login`)
53
54
  }
54
- }, [loading, user, router, params.orgSlug])
55
+ }, [loading, user, params.orgSlug])
55
56
 
56
57
  useEffect(() => {
57
58
  clearLegacyHiddenWidgetsKey()
@@ -79,7 +79,9 @@ export default function PortalInvitePage({ params }: Props) {
79
79
  return
80
80
  }
81
81
 
82
- if (result.status === 400) {
82
+ if (result.status === 409) {
83
+ setError(t('portal.invite.error.accountExists', 'This email address already has a portal account. Sign in below instead of accepting the invitation.'))
84
+ } else if (result.status === 400) {
83
85
  setError(t('portal.invite.error.invalidToken', 'Invalid or expired invitation.'))
84
86
  } else {
85
87
  setError(result.result?.error || t('portal.invite.error.generic', 'Invitation acceptance failed. Please try again.'))
@@ -12,6 +12,7 @@ import { EmptyState } from '@open-mercato/ui/primitives/empty-state'
12
12
  import { SearchX } from 'lucide-react'
13
13
  import { Spinner } from '@open-mercato/ui/primitives/spinner'
14
14
  import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
15
+ import { navigateWithPageReload } from '@open-mercato/core/modules/portal/lib/navigation'
15
16
  import { usePortalContext } from '@open-mercato/ui/portal/PortalContext'
16
17
  import { InjectionSpot } from '@open-mercato/ui/backend/injection/InjectionSpot'
17
18
 
@@ -47,7 +48,7 @@ export default function PortalLoginPage({ params }: Props) {
47
48
  })
48
49
 
49
50
  if (result.ok && result.result?.ok) {
50
- window.location.assign(`/${orgSlug}/portal/dashboard`)
51
+ navigateWithPageReload(`/${orgSlug}/portal/dashboard`)
51
52
  return
52
53
  }
53
54
 
@@ -2,7 +2,7 @@
2
2
  import { useEffect, useMemo } from 'react'
3
3
  import { extensionPoints } from '@open-mercato/core/modules/portal/extension-points'
4
4
  import Link from 'next/link'
5
- import { useRouter } from 'next/navigation'
5
+ import { replaceWithPageReload } from '@open-mercato/core/modules/portal/lib/navigation'
6
6
  import { useT } from '@open-mercato/shared/lib/i18n/context'
7
7
  import { Button } from '@open-mercato/ui/primitives/button'
8
8
  import { Spinner } from '@open-mercato/ui/primitives/spinner'
@@ -16,16 +16,16 @@ type Props = { params: { orgSlug: string } }
16
16
 
17
17
  export default function PortalLandingPage({ params }: Props) {
18
18
  const t = useT()
19
- const router = useRouter()
20
19
  const orgSlug = params.orgSlug
21
20
  const { auth, tenant } = usePortalContext()
22
21
 
23
- // Redirect authenticated users to dashboard
22
+ // Redirect authenticated users to dashboard. This crosses the public/authenticated
23
+ // boundary, so it must be a full page load — see `replaceWithPageReload`.
24
24
  useEffect(() => {
25
25
  if (!auth.loading && auth.user) {
26
- router.replace(`/${orgSlug}/portal/dashboard`)
26
+ replaceWithPageReload(`/${orgSlug}/portal/dashboard`)
27
27
  }
28
- }, [auth.loading, auth.user, router, orgSlug])
28
+ }, [auth.loading, auth.user, orgSlug])
29
29
 
30
30
  const injectionContext = useMemo(
31
31
  () => ({ orgSlug }),
@@ -1,7 +1,7 @@
1
1
  "use client"
2
2
  import { useEffect, useMemo } from 'react'
3
3
  import { extensionPoints } from '@open-mercato/core/modules/portal/extension-points'
4
- import { useRouter } from 'next/navigation'
4
+ import { replaceWithPageReload } from '@open-mercato/core/modules/portal/lib/navigation'
5
5
  import { useT } from '@open-mercato/shared/lib/i18n/context'
6
6
  import { Badge } from '@open-mercato/ui/primitives/badge'
7
7
  import { Spinner } from '@open-mercato/ui/primitives/spinner'
@@ -14,15 +14,16 @@ type Props = { params: { orgSlug: string } }
14
14
 
15
15
  export default function PortalProfilePage({ params }: Props) {
16
16
  const t = useT()
17
- const router = useRouter()
18
17
  const { auth } = usePortalContext()
19
18
  const { user, roles, resolvedFeatures, isPortalAdmin, loading } = auth
20
19
 
20
+ // Leaving an authenticated page for the login page crosses the public/authenticated
21
+ // boundary, so it must be a full page load — see `replaceWithPageReload`.
21
22
  useEffect(() => {
22
23
  if (!loading && !user) {
23
- router.replace(`/${params.orgSlug}/portal/login`)
24
+ replaceWithPageReload(`/${params.orgSlug}/portal/login`)
24
25
  }
25
- }, [loading, user, router, params.orgSlug])
26
+ }, [loading, user, params.orgSlug])
26
27
 
27
28
  const injectionContext = useMemo(
28
29
  () => ({ orgSlug: params.orgSlug, user, roles, resolvedFeatures, isPortalAdmin }),
@@ -27,6 +27,7 @@
27
27
  "portal.invite.confirmPassword.placeholder": "••••••••",
28
28
  "portal.invite.description": "Erstellen Sie Ihr Portalkonto, um diese Einladung anzunehmen.",
29
29
  "portal.invite.displayName": "Anzeigename",
30
+ "portal.invite.error.accountExists": "Diese E-Mail-Adresse hat bereits ein Portalkonto. Melden Sie sich unten an, anstatt die Einladung anzunehmen.",
30
31
  "portal.invite.error.displayNameRequired": "Der Anzeigename ist erforderlich.",
31
32
  "portal.invite.error.generic": "Annahme der Einladung fehlgeschlagen. Bitte versuchen Sie es erneut.",
32
33
  "portal.invite.error.invalidToken": "Ungültige oder abgelaufene Einladung.",
@@ -27,6 +27,7 @@
27
27
  "portal.invite.confirmPassword.placeholder": "••••••••",
28
28
  "portal.invite.description": "Create your portal account to accept this invitation.",
29
29
  "portal.invite.displayName": "Display Name",
30
+ "portal.invite.error.accountExists": "This email address already has a portal account. Sign in below instead of accepting the invitation.",
30
31
  "portal.invite.error.displayNameRequired": "Display name is required.",
31
32
  "portal.invite.error.generic": "Invitation acceptance failed. Please try again.",
32
33
  "portal.invite.error.invalidToken": "Invalid or expired invitation.",
@@ -27,6 +27,7 @@
27
27
  "portal.invite.confirmPassword.placeholder": "••••••••",
28
28
  "portal.invite.description": "Crea tu cuenta del portal para aceptar esta invitación.",
29
29
  "portal.invite.displayName": "Nombre para mostrar",
30
+ "portal.invite.error.accountExists": "Esta dirección de correo electrónico ya tiene una cuenta del portal. Inicia sesión abajo en lugar de aceptar la invitación.",
30
31
  "portal.invite.error.displayNameRequired": "El nombre para mostrar es obligatorio.",
31
32
  "portal.invite.error.generic": "Error al aceptar la invitación. Inténtalo de nuevo.",
32
33
  "portal.invite.error.invalidToken": "Invitación no válida o expirada.",
@@ -27,6 +27,7 @@
27
27
  "portal.invite.confirmPassword.placeholder": "••••••••",
28
28
  "portal.invite.description": "이 초대를 수락하려면 포털 계정을 만드세요.",
29
29
  "portal.invite.displayName": "표시 이름",
30
+ "portal.invite.error.accountExists": "이 이메일 주소에는 이미 포털 계정이 있습니다. 초대를 수락하는 대신 아래에서 로그인하세요.",
30
31
  "portal.invite.error.displayNameRequired": "표시 이름이 필요합니다.",
31
32
  "portal.invite.error.generic": "초대 수락에 실패했습니다. 다시 시도해 주세요.",
32
33
  "portal.invite.error.invalidToken": "초대가 유효하지 않거나 만료되었습니다.",
@@ -27,6 +27,7 @@
27
27
  "portal.invite.confirmPassword.placeholder": "••••••••",
28
28
  "portal.invite.description": "Utwórz konto w portalu, aby przyjąć to zaproszenie.",
29
29
  "portal.invite.displayName": "Nazwa wyświetlana",
30
+ "portal.invite.error.accountExists": "Ten adres e-mail ma już konto w portalu. Zaloguj się poniżej zamiast przyjmować zaproszenie.",
30
31
  "portal.invite.error.displayNameRequired": "Nazwa wyświetlana jest wymagana.",
31
32
  "portal.invite.error.generic": "Nie udało się przyjąć zaproszenia. Spróbuj ponownie.",
32
33
  "portal.invite.error.invalidToken": "Nieprawidłowe lub wygasłe zaproszenie.",
@@ -1,3 +1,8 @@
1
- export function navigateWithPageReload(path: string): void {
2
- window.location.assign(path)
3
- }
1
+ /**
2
+ * Portal-local import path for the boundary-crossing navigation helpers.
3
+ *
4
+ * The implementation lives in `@open-mercato/shared/lib/navigation/pageReload` so `@open-mercato/ui`
5
+ * — which does not depend on `@open-mercato/core` — can reach it from the portal shell and hooks.
6
+ * This re-export keeps the published import path stable for existing callers.
7
+ */
8
+ export { navigateWithPageReload, replaceWithPageReload } from '@open-mercato/shared/lib/navigation/pageReload'
@@ -10,6 +10,14 @@ import { readCoverageSnapshot, refreshCoverageSnapshot } from './coverage'
10
10
  import { createProfiler, shouldEnableProfiler, type Profiler } from '@open-mercato/shared/lib/profiler'
11
11
  import type { VectorIndexService } from '@open-mercato/search/vector'
12
12
  import { decryptIndexDocCustomFields } from '@open-mercato/shared/lib/encryption/indexDoc'
13
+ import {
14
+ buildCustomFieldKindIndex,
15
+ createCustomFieldKindMapResolver,
16
+ emptyCustomFieldKindIndex,
17
+ mergeCustomFieldKindIndexes,
18
+ type CustomFieldKindIndex,
19
+ type CustomFieldKindMapResolver,
20
+ } from '@open-mercato/shared/lib/custom-fields/kinds'
13
21
  import { parseBooleanToken, parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
14
22
  import {
15
23
  applyJoinFilters,
@@ -31,10 +39,12 @@ import {
31
39
  type SearchTokenProbeQueryBuilder,
32
40
  } from '@open-mercato/shared/lib/search/availability'
33
41
  import { tokenizeText } from '@open-mercato/shared/lib/search/tokenize'
42
+ import { buildContainmentPatterns } from '@open-mercato/shared/lib/search/containment'
34
43
  import { runBeforeQueryPipeline, runAfterQueryPipeline, type QueryExtensionContext } from '@open-mercato/shared/lib/query/query-extension-runner'
35
44
  import { warnOnCiphertextLikeFallback } from '@open-mercato/shared/lib/query/ciphertext-search-warning'
36
45
  import { resolveEncryptedSortFields, resolveEncryptedSortMaxRows, sortRowsInMemory } from '@open-mercato/shared/lib/query/encrypted-sort'
37
46
  import { resolveListCountCap } from '@open-mercato/shared/lib/query/count-cap'
47
+ import { resolveCfDefIndexOrgCandidates } from '@open-mercato/shared/lib/crud/custom-field-definition-index'
38
48
  import { mapWithConcurrency } from '@open-mercato/shared/lib/query/bounded-decrypt'
39
49
  import { createLogger } from '@open-mercato/shared/lib/logger'
40
50
  import { parseNumberWithDefault } from '@open-mercato/shared/lib/number'
@@ -42,11 +52,30 @@ import { createBoundedTtlMemo } from '@open-mercato/shared/lib/query/bounded-ttl
42
52
 
43
53
  const logger = createLogger('query_index').child({ component: 'engine' })
44
54
 
55
+ /**
56
+ * Plain-row shape of the `custom_field_defs` columns the key and kind lookups read. The
57
+ * scope columns are load-bearing: a key can carry an organization-specific definition that
58
+ * overrides the tenant-wide or global one with a different `kind`, so the winner can only be
59
+ * picked once the row being decrypted is known.
60
+ */
61
+ type CustomFieldDefRow = {
62
+ entityId: string
63
+ key: string
64
+ kind: unknown
65
+ organizationId: string | null
66
+ tenantId: string | null
67
+ configJson: unknown
68
+ updatedAt: unknown
69
+ }
70
+
45
71
  /** Operators `buildCfFilterExpression` compiles; anything else yields no predicate. */
46
72
  const CF_FILTER_SUPPORTED_OPS = new Set<FilterOp>([
47
73
  'eq', 'ne', 'in', 'nin', 'like', 'ilike', 'exists', 'gt', 'gte', 'lt', 'lte',
48
74
  ])
49
75
 
76
+ /** Custom field kinds stored numerically (`custom_field_values.value_int`/`value_float`) — sort numerically, not as text (#5674). */
77
+ const NUMERIC_CF_SORT_KINDS = new Set(['integer', 'float'])
78
+
50
79
  const DECRYPT_CONCURRENCY = 8
51
80
  const AUTO_REINDEX_DEBOUNCE_DEFAULT_MS = 30_000
52
81
  const AUTO_REINDEX_DEBOUNCE_MAX_SCOPES = 10_000
@@ -219,8 +248,9 @@ function createQueryProfiler(entity: string): Profiler {
219
248
 
220
249
  export class HybridQueryEngine implements QueryEngine {
221
250
  private coverageStatsTtlMs: number
222
- private customFieldKeysCache = new Map<string, { expiresAt: number; value: string[] }>()
251
+ private customFieldDefsCache = new Map<string, { expiresAt: number; value: CustomFieldDefRow[] }>()
223
252
  private customFieldKeysTtlMs: number
253
+ private customFieldKindCache = new Map<string, { expiresAt: number; value: Map<string, string> }>()
224
254
  private columnCache = new Map<string, boolean>()
225
255
  private customEntityCache = new Map<string, boolean>()
226
256
  private debugVerbosity: boolean | null = null
@@ -544,6 +574,8 @@ export class HybridQueryEngine implements QueryEngine {
544
574
  searchConfig.useIlikeForNonEncryptedFields === true &&
545
575
  sourceSearchFilters.some((filter) => !String(filter.field).startsWith('cf:'))
546
576
  ) {
577
+ // Gated on OM_SEARCH_USE_ILIKE_FOR_NON_ENCRYPTED_FIELDS (default false per #5383; set it
578
+ // to true to opt into the #5803 fix ahead of that follow-up).
547
579
  // `ignoreRuntimeHealth` asks the on-disk question -- a column holds ciphertext even while
548
580
  // the KMS is down -- so an outage keeps encrypted columns on the token path (#4622).
549
581
  // `organizationId: null` is deliberate, not an omission: the service then unions in every
@@ -757,6 +789,18 @@ export class HybridQueryEngine implements QueryEngine {
757
789
  )
758
790
  const requiresPlaintextSort = encryptedSortFields.size > 0
759
791
 
792
+ const cfSortKeys = Array.from(new Set(
793
+ resolvedSorts.filter((sort) => sort.field.startsWith('cf:')).map((sort) => sort.field.slice(3))
794
+ ))
795
+ const cfSortKinds = cfSortKeys.length
796
+ ? await this.resolveCustomFieldSortKinds(
797
+ indexSources.map((source) => String(source.entityId)),
798
+ opts.tenantId ?? null,
799
+ resolveCfDefIndexOrgCandidates(opts.organizationIds, fallbackOrgId),
800
+ cfSortKeys,
801
+ )
802
+ : new Map<string, string>()
803
+
760
804
  // ────────────────────────────────────────────────────────────────
761
805
  // Build a reusable "applyQueryShape" function that applies every
762
806
  // WHERE/JOIN/scope to a fresh SelectQueryBuilder. We use this in
@@ -1164,7 +1208,22 @@ export class HybridQueryEngine implements QueryEngine {
1164
1208
  const textExpr = this.buildCfTextExprSql(fieldName, indexSources)
1165
1209
  if (textExpr) {
1166
1210
  const direction = sql.raw(coerceSortDirection(s.dir))
1167
- next = next.orderBy(sql`${textExpr} ${direction}`)
1211
+ const kind = cfSortKinds.get(fieldName.slice(3))
1212
+ let sortExpr: RawBuilder<unknown> = textExpr
1213
+ if (kind && NUMERIC_CF_SORT_KINDS.has(kind)) {
1214
+ const jsonExpr = this.buildCfJsonExprSql(fieldName, indexSources)
1215
+ // The index doc does not guarantee a numeric scalar for a
1216
+ // numeric-kind field (ciphertext on an encrypted field, a JSON
1217
+ // array from a multi-value/duplicated-row source, or a stale
1218
+ // value from before the kind changed) — casting unconditionally
1219
+ // turns one bad row into a 500 for the whole list. Only cast
1220
+ // when the doc value is actually a JSON number; anything else
1221
+ // sorts as NULL, same as an unset field (#5674 review).
1222
+ sortExpr = jsonExpr
1223
+ ? sql`CASE WHEN jsonb_typeof(${jsonExpr}) = 'number' THEN (${textExpr})::numeric END`
1224
+ : textExpr
1225
+ }
1226
+ next = next.orderBy(sql`${sortExpr} ${direction} NULLS LAST`)
1168
1227
  }
1169
1228
  } else {
1170
1229
  const baseField = resolveBaseColumn(fieldName)
@@ -1172,6 +1231,10 @@ export class HybridQueryEngine implements QueryEngine {
1172
1231
  next = next.orderBy(qualify(baseField), s.dir ?? SortDir.Asc)
1173
1232
  }
1174
1233
  }
1234
+ const lastSortField = resolvedSorts.length ? String(resolvedSorts[resolvedSorts.length - 1].field) : null
1235
+ if (resolvedSorts.length && lastSortField !== 'id') {
1236
+ next = next.orderBy(qualify('id'), SortDir.Asc)
1237
+ }
1175
1238
  return next
1176
1239
  }
1177
1240
 
@@ -1217,6 +1280,20 @@ export class HybridQueryEngine implements QueryEngine {
1217
1280
  const listCountCapWarning: ListCountCapWarning | undefined = counted.warning
1218
1281
 
1219
1282
  const dekKeyCache = new Map<string | null, string | null>()
1283
+ // Resolved lazily and only once per query, then memoized per scope: rows of one
1284
+ // organization share a kind map. `resolveCustomFieldKinds()` still runs for every
1285
+ // row once encryption is active, but the underlying `custom_field_defs` lookup is
1286
+ // TTL-cached, so a warm cache adds no extra query.
1287
+ let customFieldKinds: Promise<CustomFieldKindMapResolver> | null = null
1288
+ const resolveCustomFieldKinds = (): Promise<CustomFieldKindMapResolver> => {
1289
+ if (!customFieldKinds) {
1290
+ customFieldKinds = this.resolveCustomFieldKindIndex(
1291
+ Array.from(new Set(indexSources.map((src) => String(src.entityId)))),
1292
+ opts.tenantId ?? null,
1293
+ ).then((index) => createCustomFieldKindMapResolver(index))
1294
+ }
1295
+ return customFieldKinds
1296
+ }
1220
1297
 
1221
1298
  const decryptRow = async (item: Record<string, unknown>): Promise<Record<string, unknown>> => {
1222
1299
  let next = item
@@ -1237,13 +1314,16 @@ export class HybridQueryEngine implements QueryEngine {
1237
1314
  }
1238
1315
  if (encSvc) {
1239
1316
  try {
1317
+ // The kind is resolved against the very scope the value is decrypted with, so an
1318
+ // organization-specific definition can never type another organization's value.
1319
+ const cfScope = {
1320
+ tenantId: (next?.tenant_id ?? next?.tenantId ?? opts.tenantId ?? null) as string | null,
1321
+ organizationId: (next?.organization_id ?? next?.organizationId ?? fallbackOrgId ?? null) as string | null,
1322
+ }
1323
+ const resolveKinds = await resolveCustomFieldKinds()
1240
1324
  next = await decryptIndexDocCustomFields(
1241
- next,
1242
- {
1243
- tenantId: (next?.tenant_id ?? next?.tenantId ?? opts.tenantId ?? null) as string | null,
1244
- organizationId: (next?.organization_id ?? next?.organizationId ?? null) as string | null,
1245
- },
1246
- encSvc as any, dekKeyCache,
1325
+ next, cfScope, encSvc as any, dekKeyCache,
1326
+ resolveKinds(cfScope.organizationId, cfScope.tenantId),
1247
1327
  )
1248
1328
  } catch { /* keep next as-is */ }
1249
1329
  }
@@ -1954,6 +2034,20 @@ export class HybridQueryEngine implements QueryEngine {
1954
2034
  ? sql<boolean>`false`
1955
2035
  : sql<boolean>`true`
1956
2036
  }
2037
+ // Reaching here with a resolved encryption map means this is a PLAINTEXT column the gate above
2038
+ // deliberately kept off the token path. Apply the declared containment per word so the token
2039
+ // path's word-order-independent matching survives the reroute (#5803 / TC-RESO-009).
2040
+ if (
2041
+ (filter.op === 'like' || filter.op === 'ilike') &&
2042
+ typeof filter.value === 'string' &&
2043
+ searchRuntime?.enabled &&
2044
+ searchRuntime.encryptedFields != null
2045
+ ) {
2046
+ const patterns = buildContainmentPatterns(filter.value)
2047
+ if (patterns.length > 1) {
2048
+ return eb.and(patterns.map((pattern) => eb(qualify(baseField), filter.op, pattern)))
2049
+ }
2050
+ }
1957
2051
  return this.buildColumnFilterExpression(eb, qualify(baseField), filter.op, filter.value)
1958
2052
  }
1959
2053
 
@@ -2193,37 +2287,149 @@ export class HybridQueryEngine implements QueryEngine {
2193
2287
  return present
2194
2288
  }
2195
2289
 
2196
- private async resolveAvailableCustomFieldKeys(entityIds: string[], tenantId: string | null): Promise<string[]> {
2290
+ private async resolveCustomFieldDefs(
2291
+ entityIds: string[],
2292
+ tenantId: string | null,
2293
+ ): Promise<CustomFieldDefRow[]> {
2197
2294
  if (!entityIds.length) return []
2198
2295
  const cacheKey = this.customFieldKeysCacheKey(entityIds, tenantId)
2199
2296
  const now = Date.now()
2200
- const cached = this.customFieldKeysCache.get(cacheKey)
2297
+ const cached = this.customFieldDefsCache.get(cacheKey)
2201
2298
  if (cached && cached.expiresAt > now) return cached.value.slice()
2202
2299
 
2203
2300
  const db = this.getDb() as any
2204
2301
  const rows = await db
2205
2302
  .selectFrom('custom_field_defs')
2206
- .select('key')
2303
+ .select(['entity_id', 'key', 'kind', 'organization_id', 'tenant_id', 'config_json', 'updated_at'])
2207
2304
  .where('entity_id', 'in', entityIds)
2208
2305
  .where('is_active', '=', true)
2209
2306
  .where((eb: any) => eb.or([
2210
2307
  eb('tenant_id', '=', tenantId),
2211
2308
  eb('tenant_id', 'is', null),
2212
2309
  ]))
2213
- .execute() as Array<{ key: unknown }>
2214
- const keys = new Set<string>()
2310
+ .execute() as Array<Record<string, unknown>>
2311
+ const result: CustomFieldDefRow[] = []
2215
2312
  for (const row of rows) {
2216
2313
  const key = row.key
2217
- if (typeof key === 'string' && key.trim().length) keys.add(key.trim())
2218
- else if (key != null) keys.add(String(key))
2314
+ const normalized = typeof key === 'string' ? key.trim() : key == null ? '' : String(key)
2315
+ if (!normalized.length) continue
2316
+ const entityId = typeof row.entity_id === 'string' ? row.entity_id : String(row.entity_id ?? '')
2317
+ result.push({
2318
+ entityId,
2319
+ key: normalized,
2320
+ kind: row.kind,
2321
+ organizationId: typeof row.organization_id === 'string' ? row.organization_id : null,
2322
+ tenantId: typeof row.tenant_id === 'string' ? row.tenant_id : null,
2323
+ configJson: row.config_json ?? null,
2324
+ updatedAt: row.updated_at ?? null,
2325
+ })
2219
2326
  }
2220
- const result = Array.from(keys)
2221
2327
  if (this.customFieldKeysTtlMs > 0) {
2222
- this.customFieldKeysCache.set(cacheKey, { expiresAt: now + this.customFieldKeysTtlMs, value: result })
2328
+ this.customFieldDefsCache.set(cacheKey, { expiresAt: now + this.customFieldKeysTtlMs, value: result })
2223
2329
  }
2224
2330
  return result.slice()
2225
2331
  }
2226
2332
 
2333
+ /**
2334
+ * Resolve the declared `kind` for a set of `cf:` sort keys so `applySort` can
2335
+ * cast numeric kinds instead of ordering by jsonb text (#5674). A same-org
2336
+ * definition wins over a tenant-wide one, which wins over a global one, for
2337
+ * the same key — a same-key definition scoped to a *different* organization
2338
+ * in the tenant never gets to decide the kind (#5674 review).
2339
+ */
2340
+ private async resolveCustomFieldSortKinds(
2341
+ entityIds: string[],
2342
+ tenantId: string | null,
2343
+ organizationIds: string[],
2344
+ keys: string[]
2345
+ ): Promise<Map<string, string>> {
2346
+ if (!entityIds.length || !keys.length) return new Map()
2347
+ const sortedOrgIds = organizationIds.slice().sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
2348
+ const cacheKey = `${this.customFieldKeysCacheKey(entityIds, tenantId)}|org:${sortedOrgIds.join(',')}|${keys.slice().sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)).join(',')}`
2349
+ const now = Date.now()
2350
+ const cached = this.customFieldKindCache.get(cacheKey)
2351
+ if (cached && cached.expiresAt > now) return new Map(cached.value)
2352
+
2353
+ const db = this.getDb() as any
2354
+ const rows = await db
2355
+ .selectFrom('custom_field_defs')
2356
+ .select(['key', 'kind', 'organization_id', 'tenant_id'])
2357
+ .where('entity_id', 'in', entityIds)
2358
+ .where('key', 'in', keys)
2359
+ .where('is_active', '=', true)
2360
+ .where((eb: any) => eb.or([
2361
+ eb('tenant_id', '=', tenantId),
2362
+ eb('tenant_id', 'is', null),
2363
+ ]))
2364
+ .where((eb: any) => sortedOrgIds.length
2365
+ ? eb.or([
2366
+ eb('organization_id', 'is', null),
2367
+ eb('organization_id', 'in', sortedOrgIds),
2368
+ ])
2369
+ : eb('organization_id', 'is', null))
2370
+ .execute() as Array<{ key: unknown; kind: unknown; organization_id: unknown; tenant_id: unknown }>
2371
+
2372
+ const winners = new Map<string, { kind: string; specificity: number }>()
2373
+ for (const row of rows) {
2374
+ const key = typeof row.key === 'string' ? row.key : String(row.key)
2375
+ const kind = typeof row.kind === 'string' ? row.kind : null
2376
+ if (!kind) continue
2377
+ // Org-scoped beats tenant-wide beats global for the same key; rows from
2378
+ // an unrelated org were already excluded by the WHERE clause above.
2379
+ const specificity = row.organization_id != null ? 2 : row.tenant_id != null ? 1 : 0
2380
+ const existing = winners.get(key)
2381
+ if (!existing || specificity >= existing.specificity) winners.set(key, { kind, specificity })
2382
+ }
2383
+ const result = new Map<string, string>()
2384
+ for (const [key, entry] of winners) result.set(key, entry.kind)
2385
+ if (this.customFieldKeysTtlMs > 0) {
2386
+ this.customFieldKindCache.set(cacheKey, { expiresAt: now + this.customFieldKeysTtlMs, value: new Map(result) })
2387
+ }
2388
+ return result
2389
+ }
2390
+
2391
+ private async resolveAvailableCustomFieldKeys(entityIds: string[], tenantId: string | null): Promise<string[]> {
2392
+ const defs = await this.resolveCustomFieldDefs(entityIds, tenantId)
2393
+ return Array.from(new Set(defs.map((def) => def.key)))
2394
+ }
2395
+
2396
+ /**
2397
+ * Kind lookup for the `cf:`/`cf_` keys of an index document, so encrypted string-typed
2398
+ * custom fields keep their type on read instead of being `JSON.parse`d back into numbers
2399
+ * or booleans (issue #5968). Backed by the same TTL cache as the key lookup, so this adds
2400
+ * no round trip on a warm cache and fails open to an empty index on a cold-cache error.
2401
+ *
2402
+ * The index keeps every scope's definition per key rather than collapsing to one kind:
2403
+ * the caller resolves the winner against each row's own `organization_id`, the same scope
2404
+ * the row is decrypted with, so an organization's override never types another
2405
+ * organization's value.
2406
+ *
2407
+ * `entityIds` MUST arrive in `indexSources` order: `buildCfJsonExprSql` reads a `cf` value
2408
+ * with `coalesce(source0, source1, …)`, so a key defined by several sources has to be typed
2409
+ * by the same source the value came from — hence the priority-ordered merge rather than
2410
+ * whatever order the rows happen to come back in.
2411
+ */
2412
+ private async resolveCustomFieldKindIndex(
2413
+ entityIds: string[],
2414
+ tenantId: string | null,
2415
+ ): Promise<CustomFieldKindIndex> {
2416
+ try {
2417
+ const defs = await this.resolveCustomFieldDefs(entityIds, tenantId)
2418
+ const byEntityId = new Map<string, CustomFieldDefRow[]>()
2419
+ for (const def of defs) {
2420
+ const group = byEntityId.get(def.entityId) ?? []
2421
+ group.push(def)
2422
+ byEntityId.set(def.entityId, group)
2423
+ }
2424
+ return mergeCustomFieldKindIndexes(
2425
+ entityIds.map((entityId) => buildCustomFieldKindIndex(byEntityId.get(entityId) ?? [])),
2426
+ )
2427
+ } catch (err) {
2428
+ logger.warn('Failed to resolve custom field kinds', { entityIds, err })
2429
+ return emptyCustomFieldKindIndex()
2430
+ }
2431
+ }
2432
+
2227
2433
  private async entityHasActiveCustomFields(entityId: string, tenantId: string | null): Promise<boolean> {
2228
2434
  try {
2229
2435
  const keys = await this.resolveAvailableCustomFieldKeys([entityId], tenantId)
@@ -2640,6 +2846,21 @@ export class HybridQueryEngine implements QueryEngine {
2640
2846
  return q
2641
2847
  }
2642
2848
  const col: any = column
2849
+ // A PLAINTEXT column the gate kept off the token path: AND one containment predicate per word,
2850
+ // which is what the token subquery matched (#5803 / TC-RESO-009). Chained `where`s ARE the AND.
2851
+ if (
2852
+ (filter.op === 'like' || filter.op === 'ilike') &&
2853
+ typeof filter.value === 'string' &&
2854
+ search?.enabled &&
2855
+ search.encryptedFields != null
2856
+ ) {
2857
+ const patterns = buildContainmentPatterns(filter.value)
2858
+ if (patterns.length > 1) {
2859
+ let next = q
2860
+ for (const pattern of patterns) next = next.where(col, filter.op, pattern as any)
2861
+ return next
2862
+ }
2863
+ }
2643
2864
  switch (filter.op) {
2644
2865
  case 'eq':
2645
2866
  return filter.value === null