@open-mercato/core 0.6.8-develop.7004.1.7e7dd67d56 → 0.6.8-develop.7008.1.1ab31c0ca9
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/dist/modules/customers/components/detail/EntityTagsDialog.js +35 -11
- package/dist/modules/customers/components/detail/EntityTagsDialog.js.map +2 -2
- package/dist/modules/customers/components/detail/hooks/usePersonTasks.js +19 -13
- package/dist/modules/customers/components/detail/hooks/usePersonTasks.js.map +2 -2
- package/dist/modules/query_index/lib/engine.js +42 -3
- package/dist/modules/query_index/lib/engine.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/customers/components/detail/EntityTagsDialog.tsx +58 -12
- package/src/modules/customers/components/detail/hooks/usePersonTasks.ts +23 -14
- 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/query_index/lib/engine.ts +93 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.6.8-develop.
|
|
3
|
+
"version": "0.6.8-develop.7008.1.1ab31c0ca9",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -254,16 +254,16 @@
|
|
|
254
254
|
"zod": "^4.4.3"
|
|
255
255
|
},
|
|
256
256
|
"peerDependencies": {
|
|
257
|
-
"@open-mercato/ai-assistant": "0.6.8-develop.
|
|
258
|
-
"@open-mercato/shared": "0.6.8-develop.
|
|
259
|
-
"@open-mercato/ui": "0.6.8-develop.
|
|
257
|
+
"@open-mercato/ai-assistant": "0.6.8-develop.7008.1.1ab31c0ca9",
|
|
258
|
+
"@open-mercato/shared": "0.6.8-develop.7008.1.1ab31c0ca9",
|
|
259
|
+
"@open-mercato/ui": "0.6.8-develop.7008.1.1ab31c0ca9",
|
|
260
260
|
"react": "^19.0.0",
|
|
261
261
|
"react-dom": "^19.0.0"
|
|
262
262
|
},
|
|
263
263
|
"devDependencies": {
|
|
264
|
-
"@open-mercato/ai-assistant": "0.6.8-develop.
|
|
265
|
-
"@open-mercato/shared": "0.6.8-develop.
|
|
266
|
-
"@open-mercato/ui": "0.6.8-develop.
|
|
264
|
+
"@open-mercato/ai-assistant": "0.6.8-develop.7008.1.1ab31c0ca9",
|
|
265
|
+
"@open-mercato/shared": "0.6.8-develop.7008.1.1ab31c0ca9",
|
|
266
|
+
"@open-mercato/ui": "0.6.8-develop.7008.1.1ab31c0ca9",
|
|
267
267
|
"@testing-library/dom": "^10.4.1",
|
|
268
268
|
"@testing-library/jest-dom": "^7.0.0",
|
|
269
269
|
"@testing-library/react": "^16.3.1",
|
|
@@ -6,6 +6,7 @@ import { EmptyState } from '@open-mercato/ui/primitives/empty-state'
|
|
|
6
6
|
import { VisuallyHidden } from '@radix-ui/react-visually-hidden'
|
|
7
7
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
8
8
|
import { cn, slugifyTagLabel } from '@open-mercato/shared/lib/utils'
|
|
9
|
+
import { hasMoreFromPage } from '@open-mercato/shared/lib/pagination/load-more'
|
|
9
10
|
import { apiCall, apiCallOrThrow, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
|
|
10
11
|
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
11
12
|
import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
|
|
@@ -388,8 +389,19 @@ export function EntityTagsDialog({
|
|
|
388
389
|
const [creatingKind, setCreatingKind] = React.useState<string | null>(null)
|
|
389
390
|
const [manageTagsOpen, setManageTagsOpen] = React.useState(false)
|
|
390
391
|
const [activeCategoryPage, setActiveCategoryPage] = React.useState(1)
|
|
391
|
-
|
|
392
|
+
// Short-page termination instead of `page < totalPages` — see
|
|
393
|
+
// `hasMoreFromPage`. Like the page number it belongs to whichever category is
|
|
394
|
+
// active: the fetch effect below rewrites it on every category, search and
|
|
395
|
+
// page change, and the reset effect clears it alongside the page so a
|
|
396
|
+
// category with more entries never leaves the affordance behind on one
|
|
397
|
+
// without.
|
|
398
|
+
const [activeCategoryHasMore, setActiveCategoryHasMore] = React.useState(false)
|
|
392
399
|
const [activeCategoryLoading, setActiveCategoryLoading] = React.useState(false)
|
|
400
|
+
// A transport failure is not an end-of-list signal: it leaves the affordance
|
|
401
|
+
// in place and turns the next click into a retry of the page that failed,
|
|
402
|
+
// rather than advancing past it.
|
|
403
|
+
const [activeCategoryLoadFailed, setActiveCategoryLoadFailed] = React.useState(false)
|
|
404
|
+
const [activeCategoryReloadToken, setActiveCategoryReloadToken] = React.useState(0)
|
|
393
405
|
const creationInFlightRef = React.useRef<string | null>(null)
|
|
394
406
|
const mutationContextId = React.useMemo(
|
|
395
407
|
() => `customer-tags:${entityType}:${entityId}`,
|
|
@@ -713,12 +725,15 @@ export function EntityTagsDialog({
|
|
|
713
725
|
React.useEffect(() => {
|
|
714
726
|
if (!open) return
|
|
715
727
|
setActiveCategoryPage(1)
|
|
728
|
+
setActiveCategoryHasMore(false)
|
|
729
|
+
setActiveCategoryLoadFailed(false)
|
|
716
730
|
}, [activeCategoryKind, open, searchValue])
|
|
717
731
|
|
|
718
732
|
React.useEffect(() => {
|
|
719
733
|
if (!open || !activeCategoryKindValue || (activeCategorySource !== 'tags' && activeCategorySource !== 'labels')) {
|
|
720
734
|
setActiveCategoryLoading(false)
|
|
721
|
-
|
|
735
|
+
setActiveCategoryHasMore(false)
|
|
736
|
+
setActiveCategoryLoadFailed(false)
|
|
722
737
|
return
|
|
723
738
|
}
|
|
724
739
|
|
|
@@ -752,16 +767,36 @@ export function EntityTagsDialog({
|
|
|
752
767
|
}))
|
|
753
768
|
|
|
754
769
|
setActiveCategoryLoading(true)
|
|
755
|
-
void apiCall<{ items?: Array<DictEntry | LabelItem>;
|
|
770
|
+
void apiCall<{ items?: Array<DictEntry | LabelItem>; page?: number }>(endpoint, {
|
|
756
771
|
cache: 'no-store',
|
|
757
772
|
headers: { 'x-om-unauthorized-redirect': '0' },
|
|
758
773
|
})
|
|
759
774
|
.then((response) => {
|
|
760
|
-
if (
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
775
|
+
if (cancelled) return
|
|
776
|
+
if (!response.ok) {
|
|
777
|
+
setActiveCategoryLoadFailed(true)
|
|
778
|
+
return
|
|
779
|
+
}
|
|
780
|
+
setActiveCategoryLoadFailed(false)
|
|
781
|
+
const servedEntries = Array.isArray(response.result?.items) ? response.result.items : []
|
|
782
|
+
const fetchedEntries = mapEntries(servedEntries)
|
|
783
|
+
// The two sources paginate differently. `/api/customers/tags` is a
|
|
784
|
+
// query-engine list, so a page past the end comes back empty and the
|
|
785
|
+
// served count alone terminates the sequence. `/api/customers/labels`
|
|
786
|
+
// clamps the requested page to the last one and re-serves it in full
|
|
787
|
+
// forever, so short-page termination needs the second half of the
|
|
788
|
+
// helper's obligation 2: take an echoed page below the one asked for as
|
|
789
|
+
// the end of the list. Same guard as `AttachmentsSection`, whose
|
|
790
|
+
// endpoint clamps the same way.
|
|
791
|
+
const returnedPage =
|
|
792
|
+
typeof response.result?.page === 'number' ? response.result.page : activeCategoryPage
|
|
793
|
+
const servedRequestedPage = returnedPage >= activeCategoryPage
|
|
794
|
+
// Measured on what the endpoint served, before `mergeOptions` folds the
|
|
795
|
+
// page into the entries already on screen.
|
|
796
|
+
setActiveCategoryHasMore(
|
|
797
|
+
servedRequestedPage && hasMoreFromPage(servedEntries.length, REMOTE_CATEGORY_PAGE_SIZE),
|
|
764
798
|
)
|
|
799
|
+
if (!servedRequestedPage) return
|
|
765
800
|
updateCategoryEntries(activeCategoryKindValue, (currentEntries) =>
|
|
766
801
|
activeCategoryPage <= 1
|
|
767
802
|
? mergeOptions(seedEntries, fetchedEntries)
|
|
@@ -770,8 +805,10 @@ export function EntityTagsDialog({
|
|
|
770
805
|
})
|
|
771
806
|
.catch(() => {
|
|
772
807
|
if (cancelled) return
|
|
773
|
-
|
|
774
|
-
|
|
808
|
+
setActiveCategoryLoadFailed(true)
|
|
809
|
+
if (activeCategoryPage <= 1) {
|
|
810
|
+
updateCategoryEntries(activeCategoryKindValue, () => seedEntries)
|
|
811
|
+
}
|
|
775
812
|
})
|
|
776
813
|
.finally(() => {
|
|
777
814
|
if (!cancelled) {
|
|
@@ -785,6 +822,7 @@ export function EntityTagsDialog({
|
|
|
785
822
|
}, [
|
|
786
823
|
activeCategoryKindValue,
|
|
787
824
|
activeCategoryPage,
|
|
825
|
+
activeCategoryReloadToken,
|
|
788
826
|
activeCategorySource,
|
|
789
827
|
entityId,
|
|
790
828
|
entityOrganizationId,
|
|
@@ -1250,15 +1288,23 @@ export function EntityTagsDialog({
|
|
|
1250
1288
|
{t('customers.personTags.loading', 'Loading...')}
|
|
1251
1289
|
</div>
|
|
1252
1290
|
) : null}
|
|
1253
|
-
{(activeCategory.source === 'tags' || activeCategory.source === 'labels') &&
|
|
1291
|
+
{(activeCategory.source === 'tags' || activeCategory.source === 'labels') && activeCategoryHasMore ? (
|
|
1254
1292
|
<Button
|
|
1255
1293
|
type="button"
|
|
1256
1294
|
variant="outline"
|
|
1257
1295
|
size="sm"
|
|
1258
1296
|
className="rounded-lg px-3 text-xs"
|
|
1259
|
-
onClick={() =>
|
|
1297
|
+
onClick={() => {
|
|
1298
|
+
if (activeCategoryLoadFailed) {
|
|
1299
|
+
setActiveCategoryReloadToken((current) => current + 1)
|
|
1300
|
+
return
|
|
1301
|
+
}
|
|
1302
|
+
setActiveCategoryPage((current) => current + 1)
|
|
1303
|
+
}}
|
|
1260
1304
|
>
|
|
1261
|
-
{
|
|
1305
|
+
{activeCategoryLoadFailed
|
|
1306
|
+
? t('customers.personTags.retry', 'Retry')
|
|
1307
|
+
: t('customers.activities.loadMore', 'Load more')}
|
|
1262
1308
|
</Button>
|
|
1263
1309
|
) : null}
|
|
1264
1310
|
</div>
|
|
@@ -7,6 +7,7 @@ import { resolveTodoApiPath } from '../utils'
|
|
|
7
7
|
import type { TodoLinkSummary } from '../types'
|
|
8
8
|
import { generateTempId } from '@open-mercato/core/modules/customers/lib/detailHelpers'
|
|
9
9
|
import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
|
|
10
|
+
import { hasMoreFromPage } from '@open-mercato/shared/lib/pagination/load-more'
|
|
10
11
|
import { CUSTOMER_INTERACTION_TASK_SOURCE } from '../../../lib/interactionCompatibility'
|
|
11
12
|
|
|
12
13
|
const DEFAULT_TODO_SOURCE = CUSTOMER_INTERACTION_TASK_SOURCE
|
|
@@ -29,12 +30,15 @@ type CustomerTodoRow = {
|
|
|
29
30
|
createdAt: string
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
// `/api/customers/todos` also reports `totalPages`, deliberately left out of
|
|
34
|
+
// this type: the load-more affordance terminates on a short page instead — see
|
|
35
|
+
// `hasMoreFromPage`. `total` stays because it feeds the section's count badge,
|
|
36
|
+
// where an under-report is cosmetic rather than a way to strand rows.
|
|
32
37
|
type CustomerTodosResponse = {
|
|
33
38
|
items: CustomerTodoRow[]
|
|
34
39
|
total: number
|
|
35
40
|
page: number
|
|
36
41
|
pageSize: number
|
|
37
|
-
totalPages: number
|
|
38
42
|
}
|
|
39
43
|
|
|
40
44
|
export type TaskFormPayload = {
|
|
@@ -172,9 +176,9 @@ export function usePersonTasks({
|
|
|
172
176
|
pageSize = 20,
|
|
173
177
|
}: UsePersonTasksOptions): UsePersonTasksResult {
|
|
174
178
|
const [tasks, setTasks] = React.useState<TodoLinkSummary[]>(initialTasks)
|
|
175
|
-
const [pageInfo, setPageInfo] = React.useState<{ page: number;
|
|
179
|
+
const [pageInfo, setPageInfo] = React.useState<{ page: number; hasMore: boolean; total: number }>({
|
|
176
180
|
page: 1,
|
|
177
|
-
|
|
181
|
+
hasMore: false,
|
|
178
182
|
total: initialTasks.length,
|
|
179
183
|
})
|
|
180
184
|
const [isInitialLoading, setIsInitialLoading] = React.useState<boolean>(() => Boolean(entityId))
|
|
@@ -187,12 +191,18 @@ export function usePersonTasks({
|
|
|
187
191
|
const mapped = Array.isArray(payload.items) ? payload.items.map(mapRowToSummary) : []
|
|
188
192
|
setPageInfo({
|
|
189
193
|
page: payload.page ?? 1,
|
|
190
|
-
|
|
194
|
+
// Short-page termination instead of `page < totalPages` — see
|
|
195
|
+
// `hasMoreFromPage`. `mapRowToSummary` is a 1:1 map and the `mergeUnique`
|
|
196
|
+
// dedupe happens after this, so `mapped.length` is what the server served.
|
|
197
|
+
// Measured against the page size the server echoed rather than the one
|
|
198
|
+
// requested, so a page size the endpoint narrows server-side cannot make a
|
|
199
|
+
// full page read as short and silently end the sequence.
|
|
200
|
+
hasMore: hasMoreFromPage(mapped.length, payload.pageSize ?? pageSize),
|
|
191
201
|
total: payload.total ?? mapped.length,
|
|
192
202
|
})
|
|
193
203
|
setError(null)
|
|
194
204
|
return mapped
|
|
195
|
-
}, [])
|
|
205
|
+
}, [pageSize])
|
|
196
206
|
|
|
197
207
|
const fetchPage = React.useCallback(
|
|
198
208
|
async (page: number): Promise<CustomerTodosResponse> => {
|
|
@@ -202,7 +212,6 @@ export function usePersonTasks({
|
|
|
202
212
|
total: 0,
|
|
203
213
|
page: 1,
|
|
204
214
|
pageSize,
|
|
205
|
-
totalPages: 1,
|
|
206
215
|
}
|
|
207
216
|
}
|
|
208
217
|
const params = new URLSearchParams({
|
|
@@ -222,7 +231,7 @@ export function usePersonTasks({
|
|
|
222
231
|
const refresh = React.useCallback(async () => {
|
|
223
232
|
if (!entityId) {
|
|
224
233
|
setTasks([])
|
|
225
|
-
setPageInfo({ page: 1,
|
|
234
|
+
setPageInfo({ page: 1, hasMore: false, total: 0 })
|
|
226
235
|
return
|
|
227
236
|
}
|
|
228
237
|
setIsInitialLoading(true)
|
|
@@ -242,7 +251,7 @@ export function usePersonTasks({
|
|
|
242
251
|
const loadMore = React.useCallback(async () => {
|
|
243
252
|
if (!entityId) return
|
|
244
253
|
if (isLoadingMore) return
|
|
245
|
-
if (pageInfo.
|
|
254
|
+
if (!pageInfo.hasMore) return
|
|
246
255
|
setIsLoadingMore(true)
|
|
247
256
|
try {
|
|
248
257
|
const payload = await fetchPage(pageInfo.page + 1)
|
|
@@ -255,12 +264,12 @@ export function usePersonTasks({
|
|
|
255
264
|
} finally {
|
|
256
265
|
setIsLoadingMore(false)
|
|
257
266
|
}
|
|
258
|
-
}, [entityId, fetchPage, isLoadingMore, mapResponse, pageInfo.
|
|
267
|
+
}, [entityId, fetchPage, isLoadingMore, mapResponse, pageInfo.hasMore, pageInfo.page])
|
|
259
268
|
|
|
260
269
|
React.useEffect(() => {
|
|
261
270
|
if (!entityId) {
|
|
262
271
|
setTasks([])
|
|
263
|
-
setPageInfo({ page: 1,
|
|
272
|
+
setPageInfo({ page: 1, hasMore: false, total: 0 })
|
|
264
273
|
setError(null)
|
|
265
274
|
setIsInitialLoading(false)
|
|
266
275
|
return
|
|
@@ -268,7 +277,7 @@ export function usePersonTasks({
|
|
|
268
277
|
setTasks(initialTasks)
|
|
269
278
|
setPageInfo({
|
|
270
279
|
page: 1,
|
|
271
|
-
|
|
280
|
+
hasMore: false,
|
|
272
281
|
total: initialTasks.length,
|
|
273
282
|
})
|
|
274
283
|
setError(null)
|
|
@@ -342,7 +351,7 @@ export function usePersonTasks({
|
|
|
342
351
|
setTasks((prev) => [newTask, ...prev])
|
|
343
352
|
setPageInfo((prev) => ({
|
|
344
353
|
page: 1,
|
|
345
|
-
|
|
354
|
+
hasMore: prev.hasMore,
|
|
346
355
|
total: prev.total + 1,
|
|
347
356
|
}))
|
|
348
357
|
await refresh()
|
|
@@ -466,7 +475,7 @@ export function usePersonTasks({
|
|
|
466
475
|
setTasks((prev) => prev.filter((item) => item.id !== task.id))
|
|
467
476
|
setPageInfo((prev) => ({
|
|
468
477
|
page: prev.page,
|
|
469
|
-
|
|
478
|
+
hasMore: prev.hasMore,
|
|
470
479
|
total: Math.max(0, prev.total - 1),
|
|
471
480
|
}))
|
|
472
481
|
} finally {
|
|
@@ -476,7 +485,7 @@ export function usePersonTasks({
|
|
|
476
485
|
[],
|
|
477
486
|
)
|
|
478
487
|
|
|
479
|
-
const hasMore = entityId != null && pageInfo.
|
|
488
|
+
const hasMore = entityId != null && pageInfo.hasMore
|
|
480
489
|
|
|
481
490
|
return {
|
|
482
491
|
tasks,
|
|
@@ -2409,6 +2409,7 @@
|
|
|
2409
2409
|
"customers.personTags.newLabel": "New label",
|
|
2410
2410
|
"customers.personTags.newLabelPlaceholder": "Label name...",
|
|
2411
2411
|
"customers.personTags.newTag": "Neuer Tag",
|
|
2412
|
+
"customers.personTags.retry": "Erneut versuchen",
|
|
2412
2413
|
"customers.personTags.save": "Save",
|
|
2413
2414
|
"customers.personTags.saveError": "Failed to save tags",
|
|
2414
2415
|
"customers.personTags.saveSuccess": "Tags updated.",
|
|
@@ -2409,6 +2409,7 @@
|
|
|
2409
2409
|
"customers.personTags.newLabel": "New label",
|
|
2410
2410
|
"customers.personTags.newLabelPlaceholder": "Label name...",
|
|
2411
2411
|
"customers.personTags.newTag": "New tag",
|
|
2412
|
+
"customers.personTags.retry": "Retry",
|
|
2412
2413
|
"customers.personTags.save": "Save",
|
|
2413
2414
|
"customers.personTags.saveError": "Failed to save tags",
|
|
2414
2415
|
"customers.personTags.saveSuccess": "Tags updated.",
|
|
@@ -2409,6 +2409,7 @@
|
|
|
2409
2409
|
"customers.personTags.newLabel": "New label",
|
|
2410
2410
|
"customers.personTags.newLabelPlaceholder": "Label name...",
|
|
2411
2411
|
"customers.personTags.newTag": "Nueva etiqueta",
|
|
2412
|
+
"customers.personTags.retry": "Reintentar",
|
|
2412
2413
|
"customers.personTags.save": "Save",
|
|
2413
2414
|
"customers.personTags.saveError": "Failed to save tags",
|
|
2414
2415
|
"customers.personTags.saveSuccess": "Tags updated.",
|
|
@@ -2409,6 +2409,7 @@
|
|
|
2409
2409
|
"customers.personTags.newLabel": "새 라벨",
|
|
2410
2410
|
"customers.personTags.newLabelPlaceholder": "라벨 이름...",
|
|
2411
2411
|
"customers.personTags.newTag": "새 태그",
|
|
2412
|
+
"customers.personTags.retry": "다시 시도",
|
|
2412
2413
|
"customers.personTags.save": "저장",
|
|
2413
2414
|
"customers.personTags.saveError": "태그 저장에 실패했습니다",
|
|
2414
2415
|
"customers.personTags.saveSuccess": "태그가 업데이트되었습니다.",
|
|
@@ -2409,6 +2409,7 @@
|
|
|
2409
2409
|
"customers.personTags.newLabel": "Nowa etykieta",
|
|
2410
2410
|
"customers.personTags.newLabelPlaceholder": "Nazwa etykiety...",
|
|
2411
2411
|
"customers.personTags.newTag": "Nowy tag",
|
|
2412
|
+
"customers.personTags.retry": "Ponów próbę",
|
|
2412
2413
|
"customers.personTags.save": "Zapisz",
|
|
2413
2414
|
"customers.personTags.saveError": "Nie udało się zapisać tagów",
|
|
2414
2415
|
"customers.personTags.saveSuccess": "Tagi zostały zaktualizowane.",
|
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
type ResolvedJoin,
|
|
21
21
|
} from '@open-mercato/shared/lib/query/join-utils'
|
|
22
22
|
import { resolveSearchConfig, type SearchConfig } from '@open-mercato/shared/lib/search/config'
|
|
23
|
+
import { isEncryptedLikeField, resolveEncryptedLikeFieldSet } from '@open-mercato/shared/lib/query/engine'
|
|
24
|
+
import { isTenantDataEncryptionEnabled } from '@open-mercato/shared/lib/encryption/toggles'
|
|
23
25
|
import {
|
|
24
26
|
createSearchTokenAvailability,
|
|
25
27
|
isSearchFilterOp,
|
|
@@ -148,13 +150,25 @@ type SearchRuntime = {
|
|
|
148
150
|
organizationScope?: { ids: string[]; includeNull: boolean } | null
|
|
149
151
|
tenantId?: string | null
|
|
150
152
|
searchSources?: SearchTokenSource[]
|
|
153
|
+
/**
|
|
154
|
+
* Base-column fields whose stored value is ciphertext, so a like/ilike on them can only be
|
|
155
|
+
* answered via search tokens. A plaintext column keeps exact SQL ILIKE instead: the token
|
|
156
|
+
* rewrite is approximate -- it splits on non-alphanumerics and drops tokens shorter than
|
|
157
|
+
* minTokenLength, so a document-number search like "ZK 1/2026" degrades to the tokens
|
|
158
|
+
* {202, 2026} and matches every record from that year, and an all-short term like "ZK"
|
|
159
|
+
* produces no tokens and silently drops the predicate. `null`/absent = the encryption
|
|
160
|
+
* service could not answer (or a caller predates this field); keep the old rewrite then,
|
|
161
|
+
* because guessing "plaintext" would turn encrypted-column search into an
|
|
162
|
+
* ILIKE-on-ciphertext that matches nothing.
|
|
163
|
+
*/
|
|
164
|
+
encryptedFields?: Set<string> | null
|
|
151
165
|
/** Per-`query()` alias minter for `search_tokens` subqueries (see #2738). */
|
|
152
166
|
mintAlias: () => string
|
|
153
167
|
}
|
|
154
168
|
|
|
155
169
|
type EncryptionResolver = () => {
|
|
156
170
|
decryptEntityPayload?: (entityId: EntityId, payload: Record<string, unknown>, tenantId?: string | null, organizationId?: string | null) => Promise<Record<string, unknown>>
|
|
157
|
-
getEncryptedFieldNames?: (entityId: EntityId, tenantId?: string | null, organizationId?: string | null) => Promise<readonly string[]>
|
|
171
|
+
getEncryptedFieldNames?: (entityId: EntityId, tenantId?: string | null, organizationId?: string | null, options?: { ignoreRuntimeHealth?: boolean }) => Promise<readonly string[]>
|
|
158
172
|
isEnabled?: () => boolean
|
|
159
173
|
} | null
|
|
160
174
|
|
|
@@ -496,6 +510,51 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
496
510
|
? await this.searchAvailability().anySourceHasTokens(searchSources, opts.tenantId ?? null, orgScope)
|
|
497
511
|
: false
|
|
498
512
|
const searchRuntime: SearchRuntime = { ...searchRuntimeBase, searchSources, enabled: searchEnabled && hasSearchTokens }
|
|
513
|
+
if (
|
|
514
|
+
searchRuntime.enabled &&
|
|
515
|
+
searchConfig.useIlikeForNonEncryptedFields === true &&
|
|
516
|
+
sourceSearchFilters.some((filter) => !String(filter.field).startsWith('cf:'))
|
|
517
|
+
) {
|
|
518
|
+
// `ignoreRuntimeHealth` asks the on-disk question -- a column holds ciphertext even while
|
|
519
|
+
// the KMS is down -- so an outage keeps encrypted columns on the token path (#4622).
|
|
520
|
+
// `organizationId: null` is deliberate, not an omission: the service then unions in every
|
|
521
|
+
// organization's map (`fetchAllOrganizationFieldNames`), so a field any org encrypts stays
|
|
522
|
+
// on the token path -- a wider set fails safe. Passing the request's org instead would
|
|
523
|
+
// silently break encrypted-column search for orgs without their own map. That union is an
|
|
524
|
+
// UNCACHED `encryption_maps` read, one extra round-trip per searched list request.
|
|
525
|
+
try {
|
|
526
|
+
const encryptionService = this.getEncryptionService()
|
|
527
|
+
const readEncryptedFieldNames = encryptionService?.getEncryptedFieldNames?.bind(encryptionService)
|
|
528
|
+
if (readEncryptedFieldNames) {
|
|
529
|
+
searchRuntime.encryptedFields = await resolveEncryptedLikeFieldSet(
|
|
530
|
+
() => readEncryptedFieldNames(
|
|
531
|
+
entity as EntityId,
|
|
532
|
+
opts.tenantId ?? null,
|
|
533
|
+
null,
|
|
534
|
+
{ ignoreRuntimeHealth: true },
|
|
535
|
+
),
|
|
536
|
+
String(entity),
|
|
537
|
+
opts.tenantId ?? null,
|
|
538
|
+
)
|
|
539
|
+
} else if (isTenantDataEncryptionEnabled()) {
|
|
540
|
+
// Encryption is on but the service is unreachable (a swallowed DI failure looks
|
|
541
|
+
// exactly like "no service"): treat the map as UNKNOWN and keep the token rewrite,
|
|
542
|
+
// rather than guessing "plaintext" and running ILIKE against ciphertext.
|
|
543
|
+
searchRuntime.encryptedFields = null
|
|
544
|
+
} else {
|
|
545
|
+
// Encryption disabled: nothing is ciphertext at rest, exact ILIKE is always right.
|
|
546
|
+
searchRuntime.encryptedFields = new Set()
|
|
547
|
+
}
|
|
548
|
+
} catch (err) {
|
|
549
|
+
// The fallback is safe (the old rewrite-everything behavior), but taking it silently
|
|
550
|
+
// would hide that the gate has stopped working.
|
|
551
|
+
logger.warn('search: encrypted-field map unavailable; keeping the token rewrite for all columns', {
|
|
552
|
+
entity: String(entity),
|
|
553
|
+
error: err instanceof Error ? err.message : String(err),
|
|
554
|
+
})
|
|
555
|
+
searchRuntime.encryptedFields = null
|
|
556
|
+
}
|
|
557
|
+
}
|
|
499
558
|
if (searchFilters.length) {
|
|
500
559
|
this.logSearchDebug('search:init', {
|
|
501
560
|
entity,
|
|
@@ -1743,11 +1802,13 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
1743
1802
|
return this.buildIndexDocFilterExpression(eb, 'ei', entity, fieldName, filter.op, filter.value, 'b.id', searchRuntime)
|
|
1744
1803
|
}
|
|
1745
1804
|
// For like/ilike with active search-tokens, route through hashed-token EXISTS subquery
|
|
1746
|
-
// so encrypted-at-rest columns can still be searched.
|
|
1805
|
+
// so encrypted-at-rest columns can still be searched. Plaintext base columns keep exact
|
|
1806
|
+
// SQL ILIKE -- see SearchRuntime.encryptedFields.
|
|
1747
1807
|
if (
|
|
1748
1808
|
(filter.op === 'like' || filter.op === 'ilike') &&
|
|
1749
1809
|
searchRuntime?.enabled &&
|
|
1750
|
-
typeof filter.value === 'string'
|
|
1810
|
+
typeof filter.value === 'string' &&
|
|
1811
|
+
(searchRuntime.encryptedFields == null || isEncryptedLikeField(searchRuntime.encryptedFields, fieldName))
|
|
1751
1812
|
) {
|
|
1752
1813
|
const tokens = tokenizeText(String(filter.value), searchRuntime.config)
|
|
1753
1814
|
if (tokens.hashes.length) {
|
|
@@ -1771,10 +1832,14 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
1771
1832
|
)
|
|
1772
1833
|
}
|
|
1773
1834
|
}
|
|
1774
|
-
// Tokenizer produced no hashes (e.g. value too short).
|
|
1775
|
-
//
|
|
1776
|
-
//
|
|
1777
|
-
|
|
1835
|
+
// Tokenizer produced no hashes (e.g. value too short) or no source is usable. For a
|
|
1836
|
+
// column KNOWN to be encrypted, `false` is the honest answer for an OR leaf -- `true`
|
|
1837
|
+
// would widen the whole disjunction to match everything, on exactly the columns ILIKE
|
|
1838
|
+
// cannot serve. Every other case (gate off, custom-entity runtime, resolution failure)
|
|
1839
|
+
// keeps the legacy predicate-skipping `true`.
|
|
1840
|
+
return searchRuntime?.encryptedFields != null && isEncryptedLikeField(searchRuntime.encryptedFields, fieldName)
|
|
1841
|
+
? sql<boolean>`false`
|
|
1842
|
+
: sql<boolean>`true`
|
|
1778
1843
|
}
|
|
1779
1844
|
return this.buildColumnFilterExpression(eb, qualify(baseField), filter.op, filter.value)
|
|
1780
1845
|
}
|
|
@@ -1853,6 +1918,9 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
1853
1918
|
const hasSearchTokens = searchEnabled && hasSearchFilter(normalizedFilters)
|
|
1854
1919
|
? await this.searchAvailability().hasTokens(entity, opts.tenantId ?? null, orgScope)
|
|
1855
1920
|
: false
|
|
1921
|
+
// `encryptedFields` is deliberately NOT resolved here: custom-entity rows live in the
|
|
1922
|
+
// `entity_indexes` doc store, whose fields the base-column encryption map does not describe,
|
|
1923
|
+
// so the ILIKE gate stays inert on this path and like/ilike keeps its previous semantics.
|
|
1856
1924
|
const searchRuntime: SearchRuntime = {
|
|
1857
1925
|
enabled: searchEnabled && hasSearchTokens,
|
|
1858
1926
|
config: searchConfig,
|
|
@@ -2384,7 +2452,11 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
2384
2452
|
if (
|
|
2385
2453
|
(filter.op === 'like' || filter.op === 'ilike') &&
|
|
2386
2454
|
search?.enabled &&
|
|
2387
|
-
typeof filter.value === 'string'
|
|
2455
|
+
typeof filter.value === 'string' &&
|
|
2456
|
+
// Plaintext base columns keep exact SQL ILIKE -- see SearchRuntime.encryptedFields.
|
|
2457
|
+
// Membership runs across name-shape candidates: maps may declare `displayName` while the
|
|
2458
|
+
// filter carries the column name `display_name`.
|
|
2459
|
+
(search.encryptedFields == null || isEncryptedLikeField(search.encryptedFields, search.field))
|
|
2388
2460
|
) {
|
|
2389
2461
|
const tokens = tokenizeText(String(filter.value), search.config)
|
|
2390
2462
|
const hashes = tokens.hashes
|
|
@@ -2413,10 +2485,23 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
2413
2485
|
})
|
|
2414
2486
|
return q
|
|
2415
2487
|
}
|
|
2488
|
+
// Hashes exist but no usable search source: same reasoning as the no-hash branch below --
|
|
2489
|
+
// a KNOWN-encrypted column must fail closed rather than drop the predicate (which would
|
|
2490
|
+
// return the full list on exactly the columns ILIKE cannot serve).
|
|
2491
|
+
if (search.encryptedFields != null && isEncryptedLikeField(search.encryptedFields, search.field)) {
|
|
2492
|
+
return q.where(sql<boolean>`false`)
|
|
2493
|
+
}
|
|
2416
2494
|
} else {
|
|
2417
2495
|
this.logSearchDebug('search:skip-empty-hashes', {
|
|
2418
2496
|
entity: search.entity, field: search.field, value: filter.value,
|
|
2419
2497
|
})
|
|
2498
|
+
// A column KNOWN to be encrypted has no way to match the term except the token index:
|
|
2499
|
+
// dropping the predicate would return every row for a term merely too short to tokenize.
|
|
2500
|
+
// Every other case (gate off, custom-entity runtime, resolution failure) keeps the
|
|
2501
|
+
// legacy behavior of skipping the predicate.
|
|
2502
|
+
if (search.encryptedFields != null && isEncryptedLikeField(search.encryptedFields, search.field)) {
|
|
2503
|
+
return q.where(sql<boolean>`false`)
|
|
2504
|
+
}
|
|
2420
2505
|
}
|
|
2421
2506
|
return q
|
|
2422
2507
|
}
|