@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/auth/api/users/acl/route.js.map +2 -2
- package/dist/modules/auth/components/AclEditor.js +21 -3
- package/dist/modules/auth/components/AclEditor.js.map +2 -2
- package/dist/modules/customers/backend/customers/companies/page.js +6 -4
- package/dist/modules/customers/backend/customers/companies/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/map/components/DealsMapView.js +6 -4
- package/dist/modules/customers/backend/customers/deals/map/components/DealsMapView.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/page.js +6 -4
- package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.js +5 -3
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js +6 -4
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/people/page.js +6 -4
- package/dist/modules/customers/backend/customers/people/page.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/PeopleField.js +9 -2
- package/dist/modules/customers/components/calendar/editor/PeopleField.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/hooks.js +4 -2
- package/dist/modules/customers/components/calendar/editor/hooks.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/lookups.js +7 -3
- package/dist/modules/customers/components/calendar/editor/lookups.js.map +2 -2
- package/dist/modules/customers/components/detail/AssignRoleDialog.js +5 -2
- package/dist/modules/customers/components/detail/AssignRoleDialog.js.map +2 -2
- package/dist/modules/customers/components/detail/RoleAssignmentRow.js +4 -2
- package/dist/modules/customers/components/detail/RoleAssignmentRow.js.map +2 -2
- package/dist/modules/customers/components/detail/ScheduleActivityDialog.js +8 -5
- package/dist/modules/customers/components/detail/ScheduleActivityDialog.js.map +2 -2
- package/dist/modules/customers/components/detail/schedule/DateTimeFields.js +10 -7
- package/dist/modules/customers/components/detail/schedule/DateTimeFields.js.map +2 -2
- package/dist/modules/customers/components/detail/schedule/ParticipantsField.js +4 -2
- package/dist/modules/customers/components/detail/schedule/ParticipantsField.js.map +2 -2
- package/dist/modules/customers/components/detail/schedule/fieldConfig.js +9 -0
- package/dist/modules/customers/components/detail/schedule/fieldConfig.js.map +2 -2
- package/dist/modules/customers/components/detail/schedule/index.js +3 -1
- package/dist/modules/customers/components/detail/schedule/index.js.map +2 -2
- package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js +11 -4
- package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js.map +2 -2
- package/dist/modules/customers/components/formConfig.js +17 -9
- package/dist/modules/customers/components/formConfig.js.map +2 -2
- package/dist/modules/customers/data/validators.js +12 -7
- package/dist/modules/customers/data/validators.js.map +2 -2
- package/dist/modules/customers/lib/assignableStaff.js +103 -22
- package/dist/modules/customers/lib/assignableStaff.js.map +2 -2
- package/dist/modules/customers/setup.js +10 -2
- package/dist/modules/customers/setup.js.map +2 -2
- package/dist/modules/data_sync/api/schedules/[id]/route.js +5 -0
- package/dist/modules/data_sync/api/schedules/[id]/route.js.map +2 -2
- package/dist/modules/data_sync/api/schedules/route.js +5 -0
- package/dist/modules/data_sync/api/schedules/route.js.map +2 -2
- package/dist/modules/data_sync/backend/data-sync/page.js +29 -5
- package/dist/modules/data_sync/backend/data-sync/page.js.map +2 -2
- package/dist/modules/data_sync/components/IntegrationScheduleTab.js +48 -11
- package/dist/modules/data_sync/components/IntegrationScheduleTab.js.map +2 -2
- package/dist/modules/data_sync/data/validators.js +13 -2
- package/dist/modules/data_sync/data/validators.js.map +2 -2
- package/dist/modules/data_sync/lib/schedule-value.js +30 -0
- package/dist/modules/data_sync/lib/schedule-value.js.map +7 -0
- package/dist/modules/eudr/data/validators.js.map +2 -2
- package/dist/modules/portal/frontend/[orgSlug]/portal/dashboard/page.js +3 -4
- package/dist/modules/portal/frontend/[orgSlug]/portal/dashboard/page.js.map +2 -2
- package/dist/modules/portal/frontend/[orgSlug]/portal/invite/page.js +3 -1
- package/dist/modules/portal/frontend/[orgSlug]/portal/invite/page.js.map +2 -2
- package/dist/modules/portal/frontend/[orgSlug]/portal/login/page.js +2 -1
- package/dist/modules/portal/frontend/[orgSlug]/portal/login/page.js.map +2 -2
- package/dist/modules/portal/frontend/[orgSlug]/portal/page.js +3 -4
- package/dist/modules/portal/frontend/[orgSlug]/portal/page.js.map +2 -2
- package/dist/modules/portal/frontend/[orgSlug]/portal/profile/page.js +3 -4
- package/dist/modules/portal/frontend/[orgSlug]/portal/profile/page.js.map +2 -2
- package/dist/modules/portal/lib/navigation.js +3 -4
- package/dist/modules/portal/lib/navigation.js.map +2 -2
- package/dist/modules/query_index/lib/engine.js +155 -15
- package/dist/modules/query_index/lib/engine.js.map +2 -2
- package/dist/modules/staff/backend/staff/time-tracking/page.js +122 -86
- package/dist/modules/staff/backend/staff/time-tracking/page.js.map +2 -2
- package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js +38 -17
- package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js.map +2 -2
- package/dist/modules/staff/backend/staff/time-tracking/reports/[id]/page.js +45 -18
- package/dist/modules/staff/backend/staff/time-tracking/reports/[id]/page.js.map +2 -2
- package/dist/modules/staff/extension-points.js +16 -0
- package/dist/modules/staff/extension-points.js.map +2 -2
- package/dist/modules/staff/frontend/[orgSlug]/portal/time-reports/[id]/page.js +3 -4
- package/dist/modules/staff/frontend/[orgSlug]/portal/time-reports/[id]/page.js.map +2 -2
- package/dist/modules/staff/frontend/[orgSlug]/portal/time-reports/page.js +3 -4
- package/dist/modules/staff/frontend/[orgSlug]/portal/time-reports/page.js.map +2 -2
- package/dist/modules/staff/lib/time-tracking-ui/ReportSheet.js +14 -13
- package/dist/modules/staff/lib/time-tracking-ui/ReportSheet.js.map +2 -2
- package/dist/modules/staff/lib/time-tracking-ui/TaskListView.js +60 -41
- package/dist/modules/staff/lib/time-tracking-ui/TaskListView.js.map +2 -2
- package/dist/modules/warranty_claims/api/ai/assess/route.js +2 -2
- package/dist/modules/warranty_claims/api/ai/assess/route.js.map +2 -2
- package/dist/modules/warranty_claims/api/ai/draft-reply/route.js +3 -4
- package/dist/modules/warranty_claims/api/ai/draft-reply/route.js.map +3 -3
- package/dist/modules/warranty_claims/api/ai/suggest/route.js +3 -3
- package/dist/modules/warranty_claims/api/ai/suggest/route.js.map +2 -2
- package/dist/modules/warranty_claims/api/assign/route.js +3 -3
- package/dist/modules/warranty_claims/api/assign/route.js.map +2 -2
- package/dist/modules/warranty_claims/api/credit-memo/route.js +2 -2
- package/dist/modules/warranty_claims/api/credit-memo/route.js.map +2 -2
- package/dist/modules/warranty_claims/api/entitlement/route.js +2 -2
- package/dist/modules/warranty_claims/api/entitlement/route.js.map +2 -2
- package/dist/modules/warranty_claims/api/events/route.js +4 -4
- package/dist/modules/warranty_claims/api/events/route.js.map +2 -2
- package/dist/modules/warranty_claims/api/external/claims/route.js +3 -3
- package/dist/modules/warranty_claims/api/external/claims/route.js.map +2 -2
- package/dist/modules/warranty_claims/api/portal/claims/[id]/submit/route.js +2 -2
- package/dist/modules/warranty_claims/api/portal/claims/[id]/submit/route.js.map +2 -2
- package/dist/modules/warranty_claims/api/portal/claims/[id]/withdraw/route.js +2 -2
- package/dist/modules/warranty_claims/api/portal/claims/[id]/withdraw/route.js.map +2 -2
- package/dist/modules/warranty_claims/api/portal/claims/route.js +2 -2
- package/dist/modules/warranty_claims/api/portal/claims/route.js.map +2 -2
- package/dist/modules/warranty_claims/api/portal/events/route.js +2 -2
- package/dist/modules/warranty_claims/api/portal/events/route.js.map +2 -2
- package/dist/modules/warranty_claims/api/receiving/route.js +2 -2
- package/dist/modules/warranty_claims/api/receiving/route.js.map +2 -2
- package/dist/modules/warranty_claims/api/replacement-order/route.js +2 -2
- package/dist/modules/warranty_claims/api/replacement-order/route.js.map +2 -2
- package/dist/modules/warranty_claims/api/return-label/route.js +2 -2
- package/dist/modules/warranty_claims/api/return-label/route.js.map +2 -2
- package/dist/modules/warranty_claims/api/risk/route.js +2 -2
- package/dist/modules/warranty_claims/api/risk/route.js.map +2 -2
- package/dist/modules/warranty_claims/api/sales-return/route.js +2 -2
- package/dist/modules/warranty_claims/api/sales-return/route.js.map +2 -2
- package/dist/modules/warranty_claims/api/settings-general/route.js +4 -4
- package/dist/modules/warranty_claims/api/settings-general/route.js.map +2 -2
- package/dist/modules/warranty_claims/api/stats/route.js +2 -2
- package/dist/modules/warranty_claims/api/stats/route.js.map +2 -2
- package/dist/modules/warranty_claims/api/submit/route.js +3 -3
- package/dist/modules/warranty_claims/api/submit/route.js.map +2 -2
- package/dist/modules/warranty_claims/api/transition/route.js +3 -3
- package/dist/modules/warranty_claims/api/transition/route.js.map +2 -2
- package/dist/modules/warranty_claims/api/vendor-recovery/route.js +3 -3
- package/dist/modules/warranty_claims/api/vendor-recovery/route.js.map +2 -2
- package/dist/modules/warranty_claims/api/vendor-recovery-suggestions/route.js +2 -2
- package/dist/modules/warranty_claims/api/vendor-recovery-suggestions/route.js.map +2 -2
- package/dist/modules/warranty_claims/backend/page.js +4 -2
- package/dist/modules/warranty_claims/backend/page.js.map +2 -2
- package/dist/modules/warranty_claims/backend/warranty_claims/[id]/page.js +9 -2
- package/dist/modules/warranty_claims/backend/warranty_claims/[id]/page.js.map +2 -2
- package/dist/modules/warranty_claims/backend/warranty_claims/settings/page.js +4 -2
- package/dist/modules/warranty_claims/backend/warranty_claims/settings/page.js.map +2 -2
- package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.js +3 -4
- package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.js.map +2 -2
- package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/new/page.js +3 -2
- package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/new/page.js.map +2 -2
- package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/page.js +3 -2
- package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/page.js.map +2 -2
- package/dist/modules/workflows/components/DefinitionTriggersEditor.js +1 -1
- package/dist/modules/workflows/components/DefinitionTriggersEditor.js.map +2 -2
- package/dist/modules/workflows/frontend/[orgSlug]/portal/tasks/[id]/page.js +2 -2
- package/dist/modules/workflows/frontend/[orgSlug]/portal/tasks/[id]/page.js.map +2 -2
- package/dist/modules/workflows/lib/event-trigger-service.js +56 -1
- package/dist/modules/workflows/lib/event-trigger-service.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/auth/api/users/acl/route.ts +4 -3
- package/src/modules/auth/components/AclEditor.tsx +38 -2
- package/src/modules/auth/i18n/de.json +5 -1
- package/src/modules/auth/i18n/en.json +5 -1
- package/src/modules/auth/i18n/es.json +5 -1
- package/src/modules/auth/i18n/ko.json +5 -1
- package/src/modules/auth/i18n/pl.json +5 -1
- package/src/modules/customers/backend/customers/companies/page.tsx +6 -4
- package/src/modules/customers/backend/customers/deals/map/components/DealsMapView.tsx +6 -4
- package/src/modules/customers/backend/customers/deals/page.tsx +6 -4
- package/src/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.tsx +5 -3
- package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +6 -4
- package/src/modules/customers/backend/customers/people/page.tsx +6 -4
- package/src/modules/customers/components/calendar/editor/PeopleField.tsx +9 -2
- package/src/modules/customers/components/calendar/editor/hooks.ts +4 -2
- package/src/modules/customers/components/calendar/editor/lookups.ts +12 -4
- package/src/modules/customers/components/detail/AssignRoleDialog.tsx +4 -1
- package/src/modules/customers/components/detail/RoleAssignmentRow.tsx +4 -2
- package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +15 -7
- package/src/modules/customers/components/detail/schedule/DateTimeFields.tsx +16 -7
- package/src/modules/customers/components/detail/schedule/ParticipantsField.tsx +4 -2
- package/src/modules/customers/components/detail/schedule/fieldConfig.ts +15 -0
- package/src/modules/customers/components/detail/schedule/index.ts +1 -1
- package/src/modules/customers/components/detail/schedule/useScheduleFormState.ts +16 -9
- package/src/modules/customers/components/formConfig.tsx +35 -12
- package/src/modules/customers/data/validators.ts +14 -8
- package/src/modules/customers/i18n/de.json +1 -0
- package/src/modules/customers/i18n/en.json +1 -0
- package/src/modules/customers/i18n/es.json +1 -0
- package/src/modules/customers/i18n/ko.json +1 -0
- package/src/modules/customers/i18n/pl.json +1 -0
- package/src/modules/customers/lib/assignableStaff.ts +172 -34
- package/src/modules/customers/setup.ts +8 -0
- package/src/modules/data_sync/api/schedules/[id]/route.ts +5 -0
- package/src/modules/data_sync/api/schedules/route.ts +5 -0
- package/src/modules/data_sync/backend/data-sync/page.tsx +48 -4
- package/src/modules/data_sync/components/IntegrationScheduleTab.tsx +61 -2
- package/src/modules/data_sync/data/validators.ts +26 -2
- package/src/modules/data_sync/i18n/de.json +2 -0
- package/src/modules/data_sync/i18n/en.json +2 -0
- package/src/modules/data_sync/i18n/es.json +2 -0
- package/src/modules/data_sync/i18n/ko.json +2 -0
- package/src/modules/data_sync/i18n/pl.json +2 -0
- package/src/modules/data_sync/lib/schedule-value.ts +45 -0
- package/src/modules/eudr/data/validators.ts +3 -4
- package/src/modules/portal/frontend/[orgSlug]/portal/dashboard/page.tsx +5 -4
- package/src/modules/portal/frontend/[orgSlug]/portal/invite/page.tsx +3 -1
- package/src/modules/portal/frontend/[orgSlug]/portal/login/page.tsx +2 -1
- package/src/modules/portal/frontend/[orgSlug]/portal/page.tsx +5 -5
- package/src/modules/portal/frontend/[orgSlug]/portal/profile/page.tsx +5 -4
- package/src/modules/portal/i18n/de.json +1 -0
- package/src/modules/portal/i18n/en.json +1 -0
- package/src/modules/portal/i18n/es.json +1 -0
- package/src/modules/portal/i18n/ko.json +1 -0
- package/src/modules/portal/i18n/pl.json +1 -0
- package/src/modules/portal/lib/navigation.ts +8 -3
- package/src/modules/query_index/lib/engine.ts +238 -17
- package/src/modules/staff/backend/staff/time-tracking/page.tsx +161 -130
- package/src/modules/staff/backend/staff/time-tracking/projects/[id]/page.tsx +55 -43
- package/src/modules/staff/backend/staff/time-tracking/reports/[id]/page.tsx +61 -38
- package/src/modules/staff/extension-points.ts +16 -0
- package/src/modules/staff/frontend/[orgSlug]/portal/time-reports/[id]/page.tsx +3 -4
- package/src/modules/staff/frontend/[orgSlug]/portal/time-reports/page.tsx +3 -4
- package/src/modules/staff/i18n/de.json +3 -0
- package/src/modules/staff/i18n/en.json +3 -0
- package/src/modules/staff/i18n/es.json +3 -0
- package/src/modules/staff/i18n/ko.json +3 -0
- package/src/modules/staff/i18n/pl.json +3 -0
- package/src/modules/staff/lib/time-tracking-ui/ReportSheet.tsx +32 -29
- package/src/modules/staff/lib/time-tracking-ui/TaskListView.tsx +89 -79
- package/src/modules/warranty_claims/api/ai/assess/route.ts +2 -2
- package/src/modules/warranty_claims/api/ai/draft-reply/route.ts +2 -3
- package/src/modules/warranty_claims/api/ai/suggest/route.ts +3 -3
- package/src/modules/warranty_claims/api/assign/route.ts +3 -3
- package/src/modules/warranty_claims/api/credit-memo/route.ts +2 -2
- package/src/modules/warranty_claims/api/entitlement/route.ts +2 -2
- package/src/modules/warranty_claims/api/events/route.ts +4 -4
- package/src/modules/warranty_claims/api/external/claims/route.ts +3 -3
- package/src/modules/warranty_claims/api/portal/claims/[id]/submit/route.ts +2 -2
- package/src/modules/warranty_claims/api/portal/claims/[id]/withdraw/route.ts +2 -2
- package/src/modules/warranty_claims/api/portal/claims/route.ts +2 -2
- package/src/modules/warranty_claims/api/portal/events/route.ts +2 -2
- package/src/modules/warranty_claims/api/receiving/route.ts +2 -2
- package/src/modules/warranty_claims/api/replacement-order/route.ts +2 -2
- package/src/modules/warranty_claims/api/return-label/route.ts +2 -2
- package/src/modules/warranty_claims/api/risk/route.ts +2 -2
- package/src/modules/warranty_claims/api/sales-return/route.ts +2 -2
- package/src/modules/warranty_claims/api/settings-general/route.ts +4 -4
- package/src/modules/warranty_claims/api/stats/route.ts +2 -2
- package/src/modules/warranty_claims/api/submit/route.ts +3 -3
- package/src/modules/warranty_claims/api/transition/route.ts +3 -3
- package/src/modules/warranty_claims/api/vendor-recovery/route.ts +3 -3
- package/src/modules/warranty_claims/api/vendor-recovery-suggestions/route.ts +2 -2
- package/src/modules/warranty_claims/backend/page.tsx +4 -2
- package/src/modules/warranty_claims/backend/warranty_claims/[id]/page.tsx +9 -2
- package/src/modules/warranty_claims/backend/warranty_claims/settings/page.tsx +4 -2
- package/src/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.tsx +5 -4
- package/src/modules/warranty_claims/frontend/[orgSlug]/portal/claims/new/page.tsx +5 -2
- package/src/modules/warranty_claims/frontend/[orgSlug]/portal/claims/page.tsx +5 -2
- package/src/modules/workflows/components/DefinitionTriggersEditor.tsx +1 -1
- package/src/modules/workflows/frontend/[orgSlug]/portal/tasks/[id]/page.tsx +2 -2
- package/src/modules/workflows/i18n/de.json +1 -1
- package/src/modules/workflows/i18n/en.json +1 -1
- package/src/modules/workflows/i18n/es.json +1 -1
- package/src/modules/workflows/i18n/ko.json +1 -1
- package/src/modules/workflows/i18n/pl.json +1 -1
- 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
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
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 {
|
|
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
|
-
|
|
53
|
+
replaceWithPageReload(`/${params.orgSlug}/portal/login`)
|
|
53
54
|
}
|
|
54
|
-
}, [loading, user,
|
|
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 ===
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
26
|
+
replaceWithPageReload(`/${orgSlug}/portal/dashboard`)
|
|
27
27
|
}
|
|
28
|
-
}, [auth.loading, auth.user,
|
|
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 {
|
|
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
|
-
|
|
24
|
+
replaceWithPageReload(`/${params.orgSlug}/portal/login`)
|
|
24
25
|
}
|
|
25
|
-
}, [loading, user,
|
|
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
|
-
|
|
2
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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<
|
|
2214
|
-
const
|
|
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
|
-
|
|
2218
|
-
|
|
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.
|
|
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
|