@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
|
@@ -5,6 +5,7 @@ import { buildOptimisticLockHeader } from "@open-mercato/ui/backend/utils/optimi
|
|
|
5
5
|
import { resolveTodoApiPath } from "../utils.js";
|
|
6
6
|
import { generateTempId } from "@open-mercato/core/modules/customers/lib/detailHelpers";
|
|
7
7
|
import { parseBooleanToken } from "@open-mercato/shared/lib/boolean";
|
|
8
|
+
import { hasMoreFromPage } from "@open-mercato/shared/lib/pagination/load-more";
|
|
8
9
|
import { CUSTOMER_INTERACTION_TASK_SOURCE } from "../../../lib/interactionCompatibility.js";
|
|
9
10
|
const DEFAULT_TODO_SOURCE = CUSTOMER_INTERACTION_TASK_SOURCE;
|
|
10
11
|
function mapRowToSummary(row) {
|
|
@@ -97,7 +98,7 @@ function usePersonTasks({
|
|
|
97
98
|
const [tasks, setTasks] = React.useState(initialTasks);
|
|
98
99
|
const [pageInfo, setPageInfo] = React.useState({
|
|
99
100
|
page: 1,
|
|
100
|
-
|
|
101
|
+
hasMore: false,
|
|
101
102
|
total: initialTasks.length
|
|
102
103
|
});
|
|
103
104
|
const [isInitialLoading, setIsInitialLoading] = React.useState(() => Boolean(entityId));
|
|
@@ -109,12 +110,18 @@ function usePersonTasks({
|
|
|
109
110
|
const mapped = Array.isArray(payload.items) ? payload.items.map(mapRowToSummary) : [];
|
|
110
111
|
setPageInfo({
|
|
111
112
|
page: payload.page ?? 1,
|
|
112
|
-
|
|
113
|
+
// Short-page termination instead of `page < totalPages` — see
|
|
114
|
+
// `hasMoreFromPage`. `mapRowToSummary` is a 1:1 map and the `mergeUnique`
|
|
115
|
+
// dedupe happens after this, so `mapped.length` is what the server served.
|
|
116
|
+
// Measured against the page size the server echoed rather than the one
|
|
117
|
+
// requested, so a page size the endpoint narrows server-side cannot make a
|
|
118
|
+
// full page read as short and silently end the sequence.
|
|
119
|
+
hasMore: hasMoreFromPage(mapped.length, payload.pageSize ?? pageSize),
|
|
113
120
|
total: payload.total ?? mapped.length
|
|
114
121
|
});
|
|
115
122
|
setError(null);
|
|
116
123
|
return mapped;
|
|
117
|
-
}, []);
|
|
124
|
+
}, [pageSize]);
|
|
118
125
|
const fetchPage = React.useCallback(
|
|
119
126
|
async (page) => {
|
|
120
127
|
if (!entityId) {
|
|
@@ -122,8 +129,7 @@ function usePersonTasks({
|
|
|
122
129
|
items: [],
|
|
123
130
|
total: 0,
|
|
124
131
|
page: 1,
|
|
125
|
-
pageSize
|
|
126
|
-
totalPages: 1
|
|
132
|
+
pageSize
|
|
127
133
|
};
|
|
128
134
|
}
|
|
129
135
|
const params = new URLSearchParams({
|
|
@@ -142,7 +148,7 @@ function usePersonTasks({
|
|
|
142
148
|
const refresh = React.useCallback(async () => {
|
|
143
149
|
if (!entityId) {
|
|
144
150
|
setTasks([]);
|
|
145
|
-
setPageInfo({ page: 1,
|
|
151
|
+
setPageInfo({ page: 1, hasMore: false, total: 0 });
|
|
146
152
|
return;
|
|
147
153
|
}
|
|
148
154
|
setIsInitialLoading(true);
|
|
@@ -161,7 +167,7 @@ function usePersonTasks({
|
|
|
161
167
|
const loadMore = React.useCallback(async () => {
|
|
162
168
|
if (!entityId) return;
|
|
163
169
|
if (isLoadingMore) return;
|
|
164
|
-
if (pageInfo.
|
|
170
|
+
if (!pageInfo.hasMore) return;
|
|
165
171
|
setIsLoadingMore(true);
|
|
166
172
|
try {
|
|
167
173
|
const payload = await fetchPage(pageInfo.page + 1);
|
|
@@ -174,11 +180,11 @@ function usePersonTasks({
|
|
|
174
180
|
} finally {
|
|
175
181
|
setIsLoadingMore(false);
|
|
176
182
|
}
|
|
177
|
-
}, [entityId, fetchPage, isLoadingMore, mapResponse, pageInfo.
|
|
183
|
+
}, [entityId, fetchPage, isLoadingMore, mapResponse, pageInfo.hasMore, pageInfo.page]);
|
|
178
184
|
React.useEffect(() => {
|
|
179
185
|
if (!entityId) {
|
|
180
186
|
setTasks([]);
|
|
181
|
-
setPageInfo({ page: 1,
|
|
187
|
+
setPageInfo({ page: 1, hasMore: false, total: 0 });
|
|
182
188
|
setError(null);
|
|
183
189
|
setIsInitialLoading(false);
|
|
184
190
|
return;
|
|
@@ -186,7 +192,7 @@ function usePersonTasks({
|
|
|
186
192
|
setTasks(initialTasks);
|
|
187
193
|
setPageInfo({
|
|
188
194
|
page: 1,
|
|
189
|
-
|
|
195
|
+
hasMore: false,
|
|
190
196
|
total: initialTasks.length
|
|
191
197
|
});
|
|
192
198
|
setError(null);
|
|
@@ -255,7 +261,7 @@ function usePersonTasks({
|
|
|
255
261
|
setTasks((prev) => [newTask, ...prev]);
|
|
256
262
|
setPageInfo((prev) => ({
|
|
257
263
|
page: 1,
|
|
258
|
-
|
|
264
|
+
hasMore: prev.hasMore,
|
|
259
265
|
total: prev.total + 1
|
|
260
266
|
}));
|
|
261
267
|
await refresh();
|
|
@@ -361,7 +367,7 @@ function usePersonTasks({
|
|
|
361
367
|
setTasks((prev) => prev.filter((item) => item.id !== task.id));
|
|
362
368
|
setPageInfo((prev) => ({
|
|
363
369
|
page: prev.page,
|
|
364
|
-
|
|
370
|
+
hasMore: prev.hasMore,
|
|
365
371
|
total: Math.max(0, prev.total - 1)
|
|
366
372
|
}));
|
|
367
373
|
} finally {
|
|
@@ -370,7 +376,7 @@ function usePersonTasks({
|
|
|
370
376
|
},
|
|
371
377
|
[]
|
|
372
378
|
);
|
|
373
|
-
const hasMore = entityId != null && pageInfo.
|
|
379
|
+
const hasMore = entityId != null && pageInfo.hasMore;
|
|
374
380
|
return {
|
|
375
381
|
tasks,
|
|
376
382
|
isInitialLoading,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/customers/components/detail/hooks/usePersonTasks.ts"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { apiCallOrThrow, readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'\nimport { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'\nimport { resolveTodoApiPath } from '../utils'\nimport type { TodoLinkSummary } from '../types'\nimport { generateTempId } from '@open-mercato/core/modules/customers/lib/detailHelpers'\nimport { parseBooleanToken } from '@open-mercato/shared/lib/boolean'\nimport { CUSTOMER_INTERACTION_TASK_SOURCE } from '../../../lib/interactionCompatibility'\n\nconst DEFAULT_TODO_SOURCE = CUSTOMER_INTERACTION_TASK_SOURCE\n\ntype CustomerTodoRow = {\n id: string\n todoId: string\n todoSource: string\n todoTitle: string | null\n todoIsDone: boolean | null\n todoPriority: number | null\n todoSeverity: string | null\n todoDescription: string | null\n todoDueAt: string | null\n todoCustomValues: Record<string, unknown> | null\n todoOrganizationId: string | null\n todoUpdatedAt?: string | null\n organizationId: string\n tenantId: string\n createdAt: string\n}\n\ntype CustomerTodosResponse = {\n items: CustomerTodoRow[]\n total: number\n page: number\n pageSize: number\n totalPages: number\n}\n\nexport type TaskFormPayload = {\n base: {\n title: string\n is_done?: boolean\n status?: string\n description?: string | null\n priority?: number | null\n scheduledAt?: string | null\n }\n custom: Record<string, unknown>\n}\n\nexport type UsePersonTasksOptions = {\n entityId: string | null\n initialTasks?: TodoLinkSummary[]\n pageSize?: number\n}\n\nexport type UsePersonTasksResult = {\n tasks: TodoLinkSummary[]\n isInitialLoading: boolean\n isLoadingMore: boolean\n isMutating: boolean\n hasMore: boolean\n loadMore: () => Promise<void>\n refresh: () => Promise<void>\n createTask: (payload: TaskFormPayload) => Promise<void>\n updateTask: (task: TodoLinkSummary, payload: TaskFormPayload) => Promise<void>\n toggleTask: (task: TodoLinkSummary, nextIsDone: boolean) => Promise<void>\n unlinkTask: (task: TodoLinkSummary) => Promise<void>\n pendingTaskId: string | null\n totalCount: number\n error: string | null\n}\n\nfunction mapRowToSummary(row: CustomerTodoRow): TodoLinkSummary {\n return {\n id: row.id,\n todoId: row.todoId,\n todoSource: row.todoSource || DEFAULT_TODO_SOURCE,\n createdAt: row.createdAt,\n title: row.todoTitle ?? null,\n isDone: row.todoIsDone ?? null,\n status: row.todoIsDone ? 'done' : 'planned',\n priority: row.todoPriority ?? null,\n severity: row.todoSeverity ?? null,\n description: row.todoDescription ?? null,\n dueAt: row.todoDueAt ?? null,\n todoOrganizationId: row.todoOrganizationId ?? null,\n updatedAt: row.todoUpdatedAt ?? null,\n customValues: row.todoCustomValues ?? null,\n }\n}\n\nfunction mergeUnique(existing: TodoLinkSummary[], incoming: TodoLinkSummary[]): TodoLinkSummary[] {\n if (!existing.length) return incoming\n if (!incoming.length) return existing\n const byId = new Map<string, TodoLinkSummary>()\n const result: TodoLinkSummary[] = []\n for (const item of existing) {\n byId.set(item.id, item)\n result.push(item)\n }\n for (const item of incoming) {\n if (byId.has(item.id)) {\n const index = result.findIndex((entry) => entry.id === item.id)\n if (index !== -1) result[index] = item\n } else {\n byId.set(item.id, item)\n result.push(item)\n }\n }\n return result\n}\n\nfunction normalizeBoolean(value: unknown): boolean | undefined {\n if (value === null || value === undefined) return undefined\n if (typeof value === 'boolean') return value\n if (typeof value === 'string') {\n const parsed = parseBooleanToken(value)\n return parsed === null ? undefined : parsed\n }\n return undefined\n}\n\nfunction normalizeNumber(value: unknown): number | null | undefined {\n if (value === null || value === undefined || value === '') return undefined\n if (typeof value === 'number' && Number.isFinite(value)) return value\n if (typeof value === 'string') {\n const trimmed = value.trim()\n if (!trimmed.length) return undefined\n const parsed = Number(trimmed)\n if (!Number.isNaN(parsed)) return parsed\n }\n return null\n}\n\nfunction normalizeString(value: unknown): string | null | undefined {\n if (value === null || value === undefined) return undefined\n if (typeof value === 'string') {\n const trimmed = value.trim()\n return trimmed.length ? trimmed : null\n }\n return String(value)\n}\n\nfunction buildLegacyTaskCustomValues(\n payload: TaskFormPayload,\n options?: { includeEmptyFields?: boolean },\n): Record<string, unknown> {\n const includeEmptyFields = options?.includeEmptyFields === true\n const custom = { ...payload.custom }\n\n const assignValue = (key: string, value: unknown) => {\n if (value === undefined) return\n if (value === null) {\n if (includeEmptyFields) custom[key] = null\n return\n }\n custom[key] = value\n }\n\n assignValue('priority', payload.base.priority ?? null)\n assignValue('description', payload.base.description ?? null)\n assignValue('due_at', payload.base.scheduledAt ?? null)\n\n return custom\n}\n\nexport function usePersonTasks({\n entityId,\n initialTasks = [],\n pageSize = 20,\n}: UsePersonTasksOptions): UsePersonTasksResult {\n const [tasks, setTasks] = React.useState<TodoLinkSummary[]>(initialTasks)\n const [pageInfo, setPageInfo] = React.useState<{ page: number; totalPages: number; total: number }>({\n page: 1,\n totalPages: 1,\n total: initialTasks.length,\n })\n const [isInitialLoading, setIsInitialLoading] = React.useState<boolean>(() => Boolean(entityId))\n const [isLoadingMore, setIsLoadingMore] = React.useState(false)\n const [isMutating, setIsMutating] = React.useState(false)\n const [pendingTaskId, setPendingTaskId] = React.useState<string | null>(null)\n const [error, setError] = React.useState<string | null>(null)\n\n const mapResponse = React.useCallback((payload: CustomerTodosResponse) => {\n const mapped = Array.isArray(payload.items) ? payload.items.map(mapRowToSummary) : []\n setPageInfo({\n page: payload.page ?? 1,\n totalPages: payload.totalPages ?? 0,\n total: payload.total ?? mapped.length,\n })\n setError(null)\n return mapped\n }, [])\n\n const fetchPage = React.useCallback(\n async (page: number): Promise<CustomerTodosResponse> => {\n if (!entityId) {\n return {\n items: [],\n total: 0,\n page: 1,\n pageSize,\n totalPages: 1,\n }\n }\n const params = new URLSearchParams({\n page: String(page),\n pageSize: String(pageSize),\n entityId,\n })\n return readApiResultOrThrow<CustomerTodosResponse>(\n `/api/customers/todos?${params.toString()}`,\n undefined,\n { errorMessage: 'Failed to load tasks.' },\n )\n },\n [entityId, pageSize],\n )\n\n const refresh = React.useCallback(async () => {\n if (!entityId) {\n setTasks([])\n setPageInfo({ page: 1, totalPages: 1, total: 0 })\n return\n }\n setIsInitialLoading(true)\n try {\n const payload = await fetchPage(1)\n const mapped = mapResponse(payload)\n setTasks(mapped)\n } catch (err) {\n const message = err instanceof Error ? err.message : 'Failed to load tasks.'\n setError(message)\n throw err\n } finally {\n setIsInitialLoading(false)\n }\n }, [entityId, fetchPage, mapResponse])\n\n const loadMore = React.useCallback(async () => {\n if (!entityId) return\n if (isLoadingMore) return\n if (pageInfo.page >= pageInfo.totalPages) return\n setIsLoadingMore(true)\n try {\n const payload = await fetchPage(pageInfo.page + 1)\n const mapped = mapResponse(payload)\n setTasks((prev) => mergeUnique(prev, mapped))\n } catch (err) {\n const message = err instanceof Error ? err.message : 'Failed to load tasks.'\n setError(message)\n throw err\n } finally {\n setIsLoadingMore(false)\n }\n }, [entityId, fetchPage, isLoadingMore, mapResponse, pageInfo.page, pageInfo.totalPages])\n\n React.useEffect(() => {\n if (!entityId) {\n setTasks([])\n setPageInfo({ page: 1, totalPages: 1, total: 0 })\n setError(null)\n setIsInitialLoading(false)\n return\n }\n setTasks(initialTasks)\n setPageInfo({\n page: 1,\n totalPages: 1,\n total: initialTasks.length,\n })\n setError(null)\n let cancelled = false\n setIsInitialLoading(true)\n fetchPage(1)\n .then((payload) => {\n if (cancelled) return\n const mapped = mapResponse(payload)\n setTasks(mapped)\n })\n .catch((err) => {\n if (cancelled) return\n const message = err instanceof Error ? err.message : 'Failed to load tasks.'\n setError(message)\n })\n .finally(() => {\n if (!cancelled) setIsInitialLoading(false)\n })\n return () => {\n cancelled = true\n }\n }, [entityId, initialTasks, fetchPage, mapResponse])\n\n const createTask = React.useCallback(\n async ({ base, custom }: TaskFormPayload) => {\n if (!entityId) throw new Error('Task creation requires an entity id')\n setIsMutating(true)\n try {\n const customValues = buildLegacyTaskCustomValues({ base, custom })\n const payload: Record<string, unknown> = {\n entityId,\n title: base.title,\n }\n const normalizedDone = normalizeBoolean(base.is_done)\n if (normalizedDone !== undefined) payload.isDone = normalizedDone\n if (Object.keys(customValues).length) payload.todoCustom = customValues\n\n const response = await apiCallOrThrow<{ linkId?: string; todoId?: string }>(\n '/api/customers/todos',\n {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(payload),\n },\n { errorMessage: 'Failed to create task.' },\n )\n const body = response.result ?? {}\n const linkId = typeof body.linkId === 'string' && body.linkId.length ? body.linkId : generateTempId()\n const todoId = typeof body.todoId === 'string' && body.todoId.length ? body.todoId : generateTempId()\n const createdAt = new Date().toISOString()\n const persistedCustomValues = Object.keys(customValues).length ? { ...customValues } : null\n const priority = normalizeNumber(base.priority ?? customValues.priority)\n const severity = normalizeString(customValues.severity) ?? null\n const description = normalizeString(base.description ?? customValues.description) ?? null\n const newTask: TodoLinkSummary = {\n id: linkId,\n todoId,\n todoSource: DEFAULT_TODO_SOURCE,\n createdAt,\n title: base.title,\n isDone: normalizedDone ?? false,\n status: normalizedDone ? 'done' : 'planned',\n priority: priority === undefined ? null : priority,\n severity,\n description,\n dueAt: normalizeString(base.scheduledAt ?? customValues.due_at) ?? normalizeString(customValues.dueAt) ?? null,\n todoOrganizationId: null,\n customValues: persistedCustomValues,\n }\n setTasks((prev) => [newTask, ...prev])\n setPageInfo((prev) => ({\n page: 1,\n totalPages: prev.totalPages,\n total: prev.total + 1,\n }))\n await refresh()\n } finally {\n setIsMutating(false)\n }\n },\n [entityId, refresh],\n )\n\n const updateTask = React.useCallback(\n async (task: TodoLinkSummary, { base, custom }: TaskFormPayload) => {\n if (!task.todoId) throw new Error('Task is missing todo id')\n const apiPath = resolveTodoApiPath(task.todoSource || DEFAULT_TODO_SOURCE)\n if (!apiPath) throw new Error('Unsupported task source')\n setIsMutating(true)\n try {\n const customValues = buildLegacyTaskCustomValues({ base, custom }, { includeEmptyFields: true })\n const body: Record<string, unknown> = {\n id: task.todoId,\n linkId: task.id,\n }\n if (typeof base.title === 'string' && base.title.trim().length) {\n body.title = base.title.trim()\n }\n const normalizedDone = normalizeBoolean(base.is_done)\n if (normalizedDone !== undefined) body.is_done = normalizedDone\n if (Object.keys(customValues).length) {\n body.customFields = customValues\n }\n // Send the optimistic-lock header (task's loaded updatedAt) so a stale\n // edit \u2014 or an edit after the task was deleted in another tab \u2014 surfaces\n // the unified conflict bar (409) instead of silently overwriting or\n // returning a bare \"Interaction not found\" 404 (#2055).\n await withScopedApiRequestHeaders(\n buildOptimisticLockHeader(task.updatedAt ?? null),\n () => apiCallOrThrow(\n apiPath,\n {\n method: 'PUT',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(body),\n },\n { errorMessage: 'Failed to update task.' },\n ),\n )\n setTasks((prev) =>\n prev.map((item) => {\n if (item.id !== task.id) return item\n const nextCustomValues = { ...(item.customValues ?? {}) }\n for (const [key, value] of Object.entries(customValues)) {\n nextCustomValues[key] = value === undefined ? null : value\n }\n return {\n ...item,\n title: typeof base.title === 'string' && base.title.trim().length ? base.title.trim() : item.title,\n isDone: normalizedDone !== undefined ? normalizedDone : item.isDone,\n status: normalizedDone !== undefined ? (normalizedDone ? 'done' : 'planned') : item.status ?? null,\n priority:\n normalizeNumber(base.priority ?? customValues.priority) ??\n (base.priority === undefined && customValues.priority === undefined ? item.priority ?? null : null),\n severity:\n normalizeString(customValues.severity) ??\n (customValues.severity === undefined ? item.severity ?? null : null),\n description:\n normalizeString(base.description ?? customValues.description) ??\n (base.description === undefined && customValues.description === undefined ? item.description ?? null : null),\n dueAt:\n normalizeString(base.scheduledAt ?? customValues.due_at) ??\n normalizeString(customValues.dueAt) ??\n (base.scheduledAt === undefined && customValues.due_at === undefined && customValues.dueAt === undefined\n ? item.dueAt ?? null\n : null),\n customValues: Object.keys(nextCustomValues).length ? nextCustomValues : null,\n }\n }),\n )\n } finally {\n setIsMutating(false)\n }\n },\n [],\n )\n\n const toggleTask = React.useCallback(\n async (task: TodoLinkSummary, nextIsDone: boolean) => {\n if (!task.todoId) {\n throw new Error('Task is missing todo id')\n }\n const apiPath = resolveTodoApiPath(task.todoSource || DEFAULT_TODO_SOURCE)\n if (!apiPath) {\n throw new Error('Unsupported task source')\n }\n setPendingTaskId(task.todoId)\n try {\n await updateTask(task, { base: { title: task.title ?? '', is_done: nextIsDone }, custom: {} })\n } finally {\n setPendingTaskId(null)\n }\n },\n [updateTask],\n )\n\n const unlinkTask = React.useCallback(\n async (task: TodoLinkSummary) => {\n if (!task.id) throw new Error('Task link id missing')\n setIsMutating(true)\n try {\n await withScopedApiRequestHeaders(\n buildOptimisticLockHeader(task.updatedAt ?? null),\n () => apiCallOrThrow(\n '/api/customers/todos',\n {\n method: 'DELETE',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ id: task.id }),\n },\n { errorMessage: 'Failed to remove task.' },\n ),\n )\n setTasks((prev) => prev.filter((item) => item.id !== task.id))\n setPageInfo((prev) => ({\n page: prev.page,\n totalPages: prev.totalPages,\n total: Math.max(0, prev.total - 1),\n }))\n } finally {\n setIsMutating(false)\n }\n },\n [],\n )\n\n const hasMore = entityId != null && pageInfo.page < pageInfo.totalPages\n\n return {\n tasks,\n isInitialLoading,\n isLoadingMore,\n isMutating,\n hasMore,\n loadMore,\n refresh,\n createTask,\n updateTask,\n toggleTask,\n unlinkTask,\n pendingTaskId,\n totalCount: pageInfo.total,\n error,\n }\n}\n"],
|
|
5
|
-
"mappings": ";AAEA,YAAY,WAAW;AACvB,SAAS,gBAAgB,sBAAsB,mCAAmC;AAClF,SAAS,iCAAiC;AAC1C,SAAS,0BAA0B;AAEnC,SAAS,sBAAsB;AAC/B,SAAS,yBAAyB;AAClC,SAAS,wCAAwC;AAEjD,MAAM,sBAAsB;
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { apiCallOrThrow, readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'\nimport { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'\nimport { resolveTodoApiPath } from '../utils'\nimport type { TodoLinkSummary } from '../types'\nimport { generateTempId } from '@open-mercato/core/modules/customers/lib/detailHelpers'\nimport { parseBooleanToken } from '@open-mercato/shared/lib/boolean'\nimport { hasMoreFromPage } from '@open-mercato/shared/lib/pagination/load-more'\nimport { CUSTOMER_INTERACTION_TASK_SOURCE } from '../../../lib/interactionCompatibility'\n\nconst DEFAULT_TODO_SOURCE = CUSTOMER_INTERACTION_TASK_SOURCE\n\ntype CustomerTodoRow = {\n id: string\n todoId: string\n todoSource: string\n todoTitle: string | null\n todoIsDone: boolean | null\n todoPriority: number | null\n todoSeverity: string | null\n todoDescription: string | null\n todoDueAt: string | null\n todoCustomValues: Record<string, unknown> | null\n todoOrganizationId: string | null\n todoUpdatedAt?: string | null\n organizationId: string\n tenantId: string\n createdAt: string\n}\n\n// `/api/customers/todos` also reports `totalPages`, deliberately left out of\n// this type: the load-more affordance terminates on a short page instead \u2014 see\n// `hasMoreFromPage`. `total` stays because it feeds the section's count badge,\n// where an under-report is cosmetic rather than a way to strand rows.\ntype CustomerTodosResponse = {\n items: CustomerTodoRow[]\n total: number\n page: number\n pageSize: number\n}\n\nexport type TaskFormPayload = {\n base: {\n title: string\n is_done?: boolean\n status?: string\n description?: string | null\n priority?: number | null\n scheduledAt?: string | null\n }\n custom: Record<string, unknown>\n}\n\nexport type UsePersonTasksOptions = {\n entityId: string | null\n initialTasks?: TodoLinkSummary[]\n pageSize?: number\n}\n\nexport type UsePersonTasksResult = {\n tasks: TodoLinkSummary[]\n isInitialLoading: boolean\n isLoadingMore: boolean\n isMutating: boolean\n hasMore: boolean\n loadMore: () => Promise<void>\n refresh: () => Promise<void>\n createTask: (payload: TaskFormPayload) => Promise<void>\n updateTask: (task: TodoLinkSummary, payload: TaskFormPayload) => Promise<void>\n toggleTask: (task: TodoLinkSummary, nextIsDone: boolean) => Promise<void>\n unlinkTask: (task: TodoLinkSummary) => Promise<void>\n pendingTaskId: string | null\n totalCount: number\n error: string | null\n}\n\nfunction mapRowToSummary(row: CustomerTodoRow): TodoLinkSummary {\n return {\n id: row.id,\n todoId: row.todoId,\n todoSource: row.todoSource || DEFAULT_TODO_SOURCE,\n createdAt: row.createdAt,\n title: row.todoTitle ?? null,\n isDone: row.todoIsDone ?? null,\n status: row.todoIsDone ? 'done' : 'planned',\n priority: row.todoPriority ?? null,\n severity: row.todoSeverity ?? null,\n description: row.todoDescription ?? null,\n dueAt: row.todoDueAt ?? null,\n todoOrganizationId: row.todoOrganizationId ?? null,\n updatedAt: row.todoUpdatedAt ?? null,\n customValues: row.todoCustomValues ?? null,\n }\n}\n\nfunction mergeUnique(existing: TodoLinkSummary[], incoming: TodoLinkSummary[]): TodoLinkSummary[] {\n if (!existing.length) return incoming\n if (!incoming.length) return existing\n const byId = new Map<string, TodoLinkSummary>()\n const result: TodoLinkSummary[] = []\n for (const item of existing) {\n byId.set(item.id, item)\n result.push(item)\n }\n for (const item of incoming) {\n if (byId.has(item.id)) {\n const index = result.findIndex((entry) => entry.id === item.id)\n if (index !== -1) result[index] = item\n } else {\n byId.set(item.id, item)\n result.push(item)\n }\n }\n return result\n}\n\nfunction normalizeBoolean(value: unknown): boolean | undefined {\n if (value === null || value === undefined) return undefined\n if (typeof value === 'boolean') return value\n if (typeof value === 'string') {\n const parsed = parseBooleanToken(value)\n return parsed === null ? undefined : parsed\n }\n return undefined\n}\n\nfunction normalizeNumber(value: unknown): number | null | undefined {\n if (value === null || value === undefined || value === '') return undefined\n if (typeof value === 'number' && Number.isFinite(value)) return value\n if (typeof value === 'string') {\n const trimmed = value.trim()\n if (!trimmed.length) return undefined\n const parsed = Number(trimmed)\n if (!Number.isNaN(parsed)) return parsed\n }\n return null\n}\n\nfunction normalizeString(value: unknown): string | null | undefined {\n if (value === null || value === undefined) return undefined\n if (typeof value === 'string') {\n const trimmed = value.trim()\n return trimmed.length ? trimmed : null\n }\n return String(value)\n}\n\nfunction buildLegacyTaskCustomValues(\n payload: TaskFormPayload,\n options?: { includeEmptyFields?: boolean },\n): Record<string, unknown> {\n const includeEmptyFields = options?.includeEmptyFields === true\n const custom = { ...payload.custom }\n\n const assignValue = (key: string, value: unknown) => {\n if (value === undefined) return\n if (value === null) {\n if (includeEmptyFields) custom[key] = null\n return\n }\n custom[key] = value\n }\n\n assignValue('priority', payload.base.priority ?? null)\n assignValue('description', payload.base.description ?? null)\n assignValue('due_at', payload.base.scheduledAt ?? null)\n\n return custom\n}\n\nexport function usePersonTasks({\n entityId,\n initialTasks = [],\n pageSize = 20,\n}: UsePersonTasksOptions): UsePersonTasksResult {\n const [tasks, setTasks] = React.useState<TodoLinkSummary[]>(initialTasks)\n const [pageInfo, setPageInfo] = React.useState<{ page: number; hasMore: boolean; total: number }>({\n page: 1,\n hasMore: false,\n total: initialTasks.length,\n })\n const [isInitialLoading, setIsInitialLoading] = React.useState<boolean>(() => Boolean(entityId))\n const [isLoadingMore, setIsLoadingMore] = React.useState(false)\n const [isMutating, setIsMutating] = React.useState(false)\n const [pendingTaskId, setPendingTaskId] = React.useState<string | null>(null)\n const [error, setError] = React.useState<string | null>(null)\n\n const mapResponse = React.useCallback((payload: CustomerTodosResponse) => {\n const mapped = Array.isArray(payload.items) ? payload.items.map(mapRowToSummary) : []\n setPageInfo({\n page: payload.page ?? 1,\n // Short-page termination instead of `page < totalPages` \u2014 see\n // `hasMoreFromPage`. `mapRowToSummary` is a 1:1 map and the `mergeUnique`\n // dedupe happens after this, so `mapped.length` is what the server served.\n // Measured against the page size the server echoed rather than the one\n // requested, so a page size the endpoint narrows server-side cannot make a\n // full page read as short and silently end the sequence.\n hasMore: hasMoreFromPage(mapped.length, payload.pageSize ?? pageSize),\n total: payload.total ?? mapped.length,\n })\n setError(null)\n return mapped\n }, [pageSize])\n\n const fetchPage = React.useCallback(\n async (page: number): Promise<CustomerTodosResponse> => {\n if (!entityId) {\n return {\n items: [],\n total: 0,\n page: 1,\n pageSize,\n }\n }\n const params = new URLSearchParams({\n page: String(page),\n pageSize: String(pageSize),\n entityId,\n })\n return readApiResultOrThrow<CustomerTodosResponse>(\n `/api/customers/todos?${params.toString()}`,\n undefined,\n { errorMessage: 'Failed to load tasks.' },\n )\n },\n [entityId, pageSize],\n )\n\n const refresh = React.useCallback(async () => {\n if (!entityId) {\n setTasks([])\n setPageInfo({ page: 1, hasMore: false, total: 0 })\n return\n }\n setIsInitialLoading(true)\n try {\n const payload = await fetchPage(1)\n const mapped = mapResponse(payload)\n setTasks(mapped)\n } catch (err) {\n const message = err instanceof Error ? err.message : 'Failed to load tasks.'\n setError(message)\n throw err\n } finally {\n setIsInitialLoading(false)\n }\n }, [entityId, fetchPage, mapResponse])\n\n const loadMore = React.useCallback(async () => {\n if (!entityId) return\n if (isLoadingMore) return\n if (!pageInfo.hasMore) return\n setIsLoadingMore(true)\n try {\n const payload = await fetchPage(pageInfo.page + 1)\n const mapped = mapResponse(payload)\n setTasks((prev) => mergeUnique(prev, mapped))\n } catch (err) {\n const message = err instanceof Error ? err.message : 'Failed to load tasks.'\n setError(message)\n throw err\n } finally {\n setIsLoadingMore(false)\n }\n }, [entityId, fetchPage, isLoadingMore, mapResponse, pageInfo.hasMore, pageInfo.page])\n\n React.useEffect(() => {\n if (!entityId) {\n setTasks([])\n setPageInfo({ page: 1, hasMore: false, total: 0 })\n setError(null)\n setIsInitialLoading(false)\n return\n }\n setTasks(initialTasks)\n setPageInfo({\n page: 1,\n hasMore: false,\n total: initialTasks.length,\n })\n setError(null)\n let cancelled = false\n setIsInitialLoading(true)\n fetchPage(1)\n .then((payload) => {\n if (cancelled) return\n const mapped = mapResponse(payload)\n setTasks(mapped)\n })\n .catch((err) => {\n if (cancelled) return\n const message = err instanceof Error ? err.message : 'Failed to load tasks.'\n setError(message)\n })\n .finally(() => {\n if (!cancelled) setIsInitialLoading(false)\n })\n return () => {\n cancelled = true\n }\n }, [entityId, initialTasks, fetchPage, mapResponse])\n\n const createTask = React.useCallback(\n async ({ base, custom }: TaskFormPayload) => {\n if (!entityId) throw new Error('Task creation requires an entity id')\n setIsMutating(true)\n try {\n const customValues = buildLegacyTaskCustomValues({ base, custom })\n const payload: Record<string, unknown> = {\n entityId,\n title: base.title,\n }\n const normalizedDone = normalizeBoolean(base.is_done)\n if (normalizedDone !== undefined) payload.isDone = normalizedDone\n if (Object.keys(customValues).length) payload.todoCustom = customValues\n\n const response = await apiCallOrThrow<{ linkId?: string; todoId?: string }>(\n '/api/customers/todos',\n {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(payload),\n },\n { errorMessage: 'Failed to create task.' },\n )\n const body = response.result ?? {}\n const linkId = typeof body.linkId === 'string' && body.linkId.length ? body.linkId : generateTempId()\n const todoId = typeof body.todoId === 'string' && body.todoId.length ? body.todoId : generateTempId()\n const createdAt = new Date().toISOString()\n const persistedCustomValues = Object.keys(customValues).length ? { ...customValues } : null\n const priority = normalizeNumber(base.priority ?? customValues.priority)\n const severity = normalizeString(customValues.severity) ?? null\n const description = normalizeString(base.description ?? customValues.description) ?? null\n const newTask: TodoLinkSummary = {\n id: linkId,\n todoId,\n todoSource: DEFAULT_TODO_SOURCE,\n createdAt,\n title: base.title,\n isDone: normalizedDone ?? false,\n status: normalizedDone ? 'done' : 'planned',\n priority: priority === undefined ? null : priority,\n severity,\n description,\n dueAt: normalizeString(base.scheduledAt ?? customValues.due_at) ?? normalizeString(customValues.dueAt) ?? null,\n todoOrganizationId: null,\n customValues: persistedCustomValues,\n }\n setTasks((prev) => [newTask, ...prev])\n setPageInfo((prev) => ({\n page: 1,\n hasMore: prev.hasMore,\n total: prev.total + 1,\n }))\n await refresh()\n } finally {\n setIsMutating(false)\n }\n },\n [entityId, refresh],\n )\n\n const updateTask = React.useCallback(\n async (task: TodoLinkSummary, { base, custom }: TaskFormPayload) => {\n if (!task.todoId) throw new Error('Task is missing todo id')\n const apiPath = resolveTodoApiPath(task.todoSource || DEFAULT_TODO_SOURCE)\n if (!apiPath) throw new Error('Unsupported task source')\n setIsMutating(true)\n try {\n const customValues = buildLegacyTaskCustomValues({ base, custom }, { includeEmptyFields: true })\n const body: Record<string, unknown> = {\n id: task.todoId,\n linkId: task.id,\n }\n if (typeof base.title === 'string' && base.title.trim().length) {\n body.title = base.title.trim()\n }\n const normalizedDone = normalizeBoolean(base.is_done)\n if (normalizedDone !== undefined) body.is_done = normalizedDone\n if (Object.keys(customValues).length) {\n body.customFields = customValues\n }\n // Send the optimistic-lock header (task's loaded updatedAt) so a stale\n // edit \u2014 or an edit after the task was deleted in another tab \u2014 surfaces\n // the unified conflict bar (409) instead of silently overwriting or\n // returning a bare \"Interaction not found\" 404 (#2055).\n await withScopedApiRequestHeaders(\n buildOptimisticLockHeader(task.updatedAt ?? null),\n () => apiCallOrThrow(\n apiPath,\n {\n method: 'PUT',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(body),\n },\n { errorMessage: 'Failed to update task.' },\n ),\n )\n setTasks((prev) =>\n prev.map((item) => {\n if (item.id !== task.id) return item\n const nextCustomValues = { ...(item.customValues ?? {}) }\n for (const [key, value] of Object.entries(customValues)) {\n nextCustomValues[key] = value === undefined ? null : value\n }\n return {\n ...item,\n title: typeof base.title === 'string' && base.title.trim().length ? base.title.trim() : item.title,\n isDone: normalizedDone !== undefined ? normalizedDone : item.isDone,\n status: normalizedDone !== undefined ? (normalizedDone ? 'done' : 'planned') : item.status ?? null,\n priority:\n normalizeNumber(base.priority ?? customValues.priority) ??\n (base.priority === undefined && customValues.priority === undefined ? item.priority ?? null : null),\n severity:\n normalizeString(customValues.severity) ??\n (customValues.severity === undefined ? item.severity ?? null : null),\n description:\n normalizeString(base.description ?? customValues.description) ??\n (base.description === undefined && customValues.description === undefined ? item.description ?? null : null),\n dueAt:\n normalizeString(base.scheduledAt ?? customValues.due_at) ??\n normalizeString(customValues.dueAt) ??\n (base.scheduledAt === undefined && customValues.due_at === undefined && customValues.dueAt === undefined\n ? item.dueAt ?? null\n : null),\n customValues: Object.keys(nextCustomValues).length ? nextCustomValues : null,\n }\n }),\n )\n } finally {\n setIsMutating(false)\n }\n },\n [],\n )\n\n const toggleTask = React.useCallback(\n async (task: TodoLinkSummary, nextIsDone: boolean) => {\n if (!task.todoId) {\n throw new Error('Task is missing todo id')\n }\n const apiPath = resolveTodoApiPath(task.todoSource || DEFAULT_TODO_SOURCE)\n if (!apiPath) {\n throw new Error('Unsupported task source')\n }\n setPendingTaskId(task.todoId)\n try {\n await updateTask(task, { base: { title: task.title ?? '', is_done: nextIsDone }, custom: {} })\n } finally {\n setPendingTaskId(null)\n }\n },\n [updateTask],\n )\n\n const unlinkTask = React.useCallback(\n async (task: TodoLinkSummary) => {\n if (!task.id) throw new Error('Task link id missing')\n setIsMutating(true)\n try {\n await withScopedApiRequestHeaders(\n buildOptimisticLockHeader(task.updatedAt ?? null),\n () => apiCallOrThrow(\n '/api/customers/todos',\n {\n method: 'DELETE',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ id: task.id }),\n },\n { errorMessage: 'Failed to remove task.' },\n ),\n )\n setTasks((prev) => prev.filter((item) => item.id !== task.id))\n setPageInfo((prev) => ({\n page: prev.page,\n hasMore: prev.hasMore,\n total: Math.max(0, prev.total - 1),\n }))\n } finally {\n setIsMutating(false)\n }\n },\n [],\n )\n\n const hasMore = entityId != null && pageInfo.hasMore\n\n return {\n tasks,\n isInitialLoading,\n isLoadingMore,\n isMutating,\n hasMore,\n loadMore,\n refresh,\n createTask,\n updateTask,\n toggleTask,\n unlinkTask,\n pendingTaskId,\n totalCount: pageInfo.total,\n error,\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
resolveJoins
|
|
14
14
|
} from "@open-mercato/shared/lib/query/join-utils";
|
|
15
15
|
import { resolveSearchConfig } from "@open-mercato/shared/lib/search/config";
|
|
16
|
+
import { isEncryptedLikeField, resolveEncryptedLikeFieldSet } from "@open-mercato/shared/lib/query/engine";
|
|
17
|
+
import { isTenantDataEncryptionEnabled } from "@open-mercato/shared/lib/encryption/toggles";
|
|
16
18
|
import {
|
|
17
19
|
createSearchTokenAvailability,
|
|
18
20
|
isSearchFilterOp,
|
|
@@ -387,6 +389,34 @@ class HybridQueryEngine {
|
|
|
387
389
|
].filter((filter) => isSearchFilterOp(filter.op));
|
|
388
390
|
const hasSearchTokens = searchEnabled && searchSources.length && sourceSearchFilters.length ? await this.searchAvailability().anySourceHasTokens(searchSources, opts.tenantId ?? null, orgScope) : false;
|
|
389
391
|
const searchRuntime = { ...searchRuntimeBase, searchSources, enabled: searchEnabled && hasSearchTokens };
|
|
392
|
+
if (searchRuntime.enabled && searchConfig.useIlikeForNonEncryptedFields === true && sourceSearchFilters.some((filter) => !String(filter.field).startsWith("cf:"))) {
|
|
393
|
+
try {
|
|
394
|
+
const encryptionService = this.getEncryptionService();
|
|
395
|
+
const readEncryptedFieldNames = encryptionService?.getEncryptedFieldNames?.bind(encryptionService);
|
|
396
|
+
if (readEncryptedFieldNames) {
|
|
397
|
+
searchRuntime.encryptedFields = await resolveEncryptedLikeFieldSet(
|
|
398
|
+
() => readEncryptedFieldNames(
|
|
399
|
+
entity,
|
|
400
|
+
opts.tenantId ?? null,
|
|
401
|
+
null,
|
|
402
|
+
{ ignoreRuntimeHealth: true }
|
|
403
|
+
),
|
|
404
|
+
String(entity),
|
|
405
|
+
opts.tenantId ?? null
|
|
406
|
+
);
|
|
407
|
+
} else if (isTenantDataEncryptionEnabled()) {
|
|
408
|
+
searchRuntime.encryptedFields = null;
|
|
409
|
+
} else {
|
|
410
|
+
searchRuntime.encryptedFields = /* @__PURE__ */ new Set();
|
|
411
|
+
}
|
|
412
|
+
} catch (err) {
|
|
413
|
+
logger.warn("search: encrypted-field map unavailable; keeping the token rewrite for all columns", {
|
|
414
|
+
entity: String(entity),
|
|
415
|
+
error: err instanceof Error ? err.message : String(err)
|
|
416
|
+
});
|
|
417
|
+
searchRuntime.encryptedFields = null;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
390
420
|
if (searchFilters.length) {
|
|
391
421
|
this.logSearchDebug("search:init", {
|
|
392
422
|
entity,
|
|
@@ -1419,7 +1449,7 @@ class HybridQueryEngine {
|
|
|
1419
1449
|
if (!baseField) {
|
|
1420
1450
|
return this.buildIndexDocFilterExpression(eb, "ei", entity, fieldName, filter.op, filter.value, "b.id", searchRuntime);
|
|
1421
1451
|
}
|
|
1422
|
-
if ((filter.op === "like" || filter.op === "ilike") && searchRuntime?.enabled && typeof filter.value === "string") {
|
|
1452
|
+
if ((filter.op === "like" || filter.op === "ilike") && searchRuntime?.enabled && typeof filter.value === "string" && (searchRuntime.encryptedFields == null || isEncryptedLikeField(searchRuntime.encryptedFields, fieldName))) {
|
|
1423
1453
|
const tokens = tokenizeText(String(filter.value), searchRuntime.config);
|
|
1424
1454
|
if (tokens.hashes.length) {
|
|
1425
1455
|
const sources = (searchRuntime.searchSources && searchRuntime.searchSources.length ? searchRuntime.searchSources : [{ entity: String(entity), recordIdColumn: "b.id" }]).filter((src) => src.recordIdColumn && src.entity);
|
|
@@ -1439,7 +1469,7 @@ class HybridQueryEngine {
|
|
|
1439
1469
|
);
|
|
1440
1470
|
}
|
|
1441
1471
|
}
|
|
1442
|
-
return sql`true`;
|
|
1472
|
+
return searchRuntime?.encryptedFields != null && isEncryptedLikeField(searchRuntime.encryptedFields, fieldName) ? sql`false` : sql`true`;
|
|
1443
1473
|
}
|
|
1444
1474
|
return this.buildColumnFilterExpression(eb, qualify(baseField), filter.op, filter.value);
|
|
1445
1475
|
}
|
|
@@ -1979,7 +2009,10 @@ class HybridQueryEngine {
|
|
|
1979
2009
|
logger.debug("Search debug event", { event, payload });
|
|
1980
2010
|
}
|
|
1981
2011
|
applyColumnFilter(q, column, filter, search) {
|
|
1982
|
-
if ((filter.op === "like" || filter.op === "ilike") && search?.enabled && typeof filter.value === "string"
|
|
2012
|
+
if ((filter.op === "like" || filter.op === "ilike") && search?.enabled && typeof filter.value === "string" && // Plaintext base columns keep exact SQL ILIKE -- see SearchRuntime.encryptedFields.
|
|
2013
|
+
// Membership runs across name-shape candidates: maps may declare `displayName` while the
|
|
2014
|
+
// filter carries the column name `display_name`.
|
|
2015
|
+
(search.encryptedFields == null || isEncryptedLikeField(search.encryptedFields, search.field))) {
|
|
1983
2016
|
const tokens = tokenizeText(String(filter.value), search.config);
|
|
1984
2017
|
const hashes = tokens.hashes;
|
|
1985
2018
|
if (hashes.length) {
|
|
@@ -2009,12 +2042,18 @@ class HybridQueryEngine {
|
|
|
2009
2042
|
});
|
|
2010
2043
|
return q;
|
|
2011
2044
|
}
|
|
2045
|
+
if (search.encryptedFields != null && isEncryptedLikeField(search.encryptedFields, search.field)) {
|
|
2046
|
+
return q.where(sql`false`);
|
|
2047
|
+
}
|
|
2012
2048
|
} else {
|
|
2013
2049
|
this.logSearchDebug("search:skip-empty-hashes", {
|
|
2014
2050
|
entity: search.entity,
|
|
2015
2051
|
field: search.field,
|
|
2016
2052
|
value: filter.value
|
|
2017
2053
|
});
|
|
2054
|
+
if (search.encryptedFields != null && isEncryptedLikeField(search.encryptedFields, search.field)) {
|
|
2055
|
+
return q.where(sql`false`);
|
|
2056
|
+
}
|
|
2018
2057
|
}
|
|
2019
2058
|
return q;
|
|
2020
2059
|
}
|