@open-mercato/core 0.6.6-develop.6171.1.17de2dc37a → 0.6.6-develop.6176.1.4507b99c2f
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/AGENTS.md +5 -3
- package/agentic/standalone-guide.md +4 -2
- package/dist/modules/customers/api/interactions/encryptedSortPage.js +24 -0
- package/dist/modules/customers/api/interactions/encryptedSortPage.js.map +7 -0
- package/dist/modules/customers/api/interactions/route.js +160 -82
- package/dist/modules/customers/api/interactions/route.js.map +2 -2
- package/dist/modules/customers/api/labels/route.js +24 -2
- package/dist/modules/customers/api/labels/route.js.map +2 -2
- package/dist/modules/resources/backend/resources/resource-types/page.js +25 -2
- package/dist/modules/resources/backend/resources/resource-types/page.js.map +2 -2
- package/dist/modules/resources/backend/resources/resources/[id]/page.js +88 -37
- package/dist/modules/resources/backend/resources/resources/[id]/page.js.map +2 -2
- package/dist/modules/resources/backend/resources/resources/page.js +27 -4
- package/dist/modules/resources/backend/resources/resources/page.js.map +2 -2
- package/dist/modules/resources/components/detail/activitiesAdapter.js +30 -13
- package/dist/modules/resources/components/detail/activitiesAdapter.js.map +2 -2
- package/dist/modules/resources/components/detail/notesAdapter.js +45 -29
- package/dist/modules/resources/components/detail/notesAdapter.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/customers/api/interactions/encryptedSortPage.ts +34 -0
- package/src/modules/customers/api/interactions/route.ts +195 -99
- package/src/modules/customers/api/labels/route.ts +27 -3
- package/src/modules/resources/backend/resources/resource-types/page.tsx +38 -4
- package/src/modules/resources/backend/resources/resources/[id]/page.tsx +102 -37
- package/src/modules/resources/backend/resources/resources/page.tsx +40 -6
- package/src/modules/resources/components/detail/activitiesAdapter.ts +46 -13
- package/src/modules/resources/components/detail/notesAdapter.ts +61 -29
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/resources/components/detail/activitiesAdapter.ts"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'\nimport { createCrud, updateCrud, deleteCrud } from '@open-mercato/ui/backend/utils/crud'\nimport type { ActivitiesDataAdapter, ActivitySummary } from '@open-mercato/ui/backend/detail'\n\ntype Translator = (key: string, fallback?: string, params?: Record<string, string | number>) => string\n\nexport function createResourceActivitiesAdapter(translator: Translator): ActivitiesDataAdapter {\n return {\n list: async ({ entityId }) => {\n const params = new URLSearchParams({\n pageSize: '50',\n sortField: 'occurredAt',\n sortDir: 'desc',\n })\n if (entityId) params.set('entityId', entityId)\n const payload = await readApiResultOrThrow<Record<string, unknown>>(\n `/api/resources/activities?${params.toString()}`,\n undefined,\n { errorMessage: translator('resources.resources.detail.activities.loadError', 'Failed to load activities.') },\n )\n return Array.isArray(payload?.items) ? (payload.items as ActivitySummary[]) : []\n },\n create: async ({ entityId, activityType, subject, body, occurredAt, customFields }) => {\n
|
|
5
|
-
"mappings": ";AAEA,SAAS,4BAA4B;AACrC,SAAS,YAAY,YAAY,kBAAkB;
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'\nimport { createCrud, updateCrud, deleteCrud } from '@open-mercato/ui/backend/utils/crud'\nimport type { ActivitiesDataAdapter, ActivitySummary } from '@open-mercato/ui/backend/detail'\n\ntype Translator = (key: string, fallback?: string, params?: Record<string, string | number>) => string\n\nexport type ResourceActivitiesGuardedMutation = <T>(\n runner: () => Promise<T>,\n payload?: Record<string, unknown>,\n) => Promise<T>\n\nexport type CreateResourceActivitiesAdapterOptions = {\n runMutation?: ResourceActivitiesGuardedMutation\n}\n\nexport function createResourceActivitiesAdapter(\n translator: Translator,\n options: CreateResourceActivitiesAdapterOptions = {},\n): ActivitiesDataAdapter {\n const runWrite = async <T,>(\n runner: () => Promise<T>,\n payload: Record<string, unknown>,\n ): Promise<T> => {\n if (options.runMutation) {\n return options.runMutation(runner, payload)\n }\n return runner()\n }\n\n return {\n list: async ({ entityId }) => {\n const params = new URLSearchParams({\n pageSize: '50',\n sortField: 'occurredAt',\n sortDir: 'desc',\n })\n if (entityId) params.set('entityId', entityId)\n const payload = await readApiResultOrThrow<Record<string, unknown>>(\n `/api/resources/activities?${params.toString()}`,\n undefined,\n { errorMessage: translator('resources.resources.detail.activities.loadError', 'Failed to load activities.') },\n )\n return Array.isArray(payload?.items) ? (payload.items as ActivitySummary[]) : []\n },\n create: async ({ entityId, activityType, subject, body, occurredAt, customFields }) => {\n const payload = {\n entityId,\n activityType,\n subject: subject ?? undefined,\n body: body ?? undefined,\n occurredAt: occurredAt ?? undefined,\n ...(customFields ? { customFields } : {}),\n }\n await runWrite(\n () => createCrud('resources/activities', payload, {\n errorMessage: translator('resources.resources.detail.activities.error', 'Failed to save activity'),\n }),\n { operation: 'createActivity', entityId, activityType },\n )\n },\n update: async ({ id, patch }) => {\n const payload = {\n id,\n entityId: patch.entityId,\n activityType: patch.activityType,\n subject: patch.subject ?? undefined,\n body: patch.body ?? undefined,\n occurredAt: patch.occurredAt ?? undefined,\n ...(patch.customFields ? { customFields: patch.customFields } : {}),\n }\n await runWrite(\n () => updateCrud('resources/activities', payload, {\n errorMessage: translator('resources.resources.detail.activities.error', 'Failed to save activity'),\n }),\n { operation: 'updateActivity', id },\n )\n },\n delete: async ({ id }) => {\n await runWrite(\n () => deleteCrud('resources/activities', {\n id,\n errorMessage: translator('resources.resources.detail.activities.deleteError', 'Failed to delete activity.'),\n }),\n { operation: 'deleteActivity', id },\n )\n },\n }\n}\n"],
|
|
5
|
+
"mappings": ";AAEA,SAAS,4BAA4B;AACrC,SAAS,YAAY,YAAY,kBAAkB;AAc5C,SAAS,gCACd,YACA,UAAkD,CAAC,GAC5B;AACvB,QAAM,WAAW,OACf,QACA,YACe;AACf,QAAI,QAAQ,aAAa;AACvB,aAAO,QAAQ,YAAY,QAAQ,OAAO;AAAA,IAC5C;AACA,WAAO,OAAO;AAAA,EAChB;AAEA,SAAO;AAAA,IACL,MAAM,OAAO,EAAE,SAAS,MAAM;AAC5B,YAAM,SAAS,IAAI,gBAAgB;AAAA,QACjC,UAAU;AAAA,QACV,WAAW;AAAA,QACX,SAAS;AAAA,MACX,CAAC;AACD,UAAI,SAAU,QAAO,IAAI,YAAY,QAAQ;AAC7C,YAAM,UAAU,MAAM;AAAA,QACpB,6BAA6B,OAAO,SAAS,CAAC;AAAA,QAC9C;AAAA,QACA,EAAE,cAAc,WAAW,mDAAmD,4BAA4B,EAAE;AAAA,MAC9G;AACA,aAAO,MAAM,QAAQ,SAAS,KAAK,IAAK,QAAQ,QAA8B,CAAC;AAAA,IACjF;AAAA,IACA,QAAQ,OAAO,EAAE,UAAU,cAAc,SAAS,MAAM,YAAY,aAAa,MAAM;AACrF,YAAM,UAAU;AAAA,QACd;AAAA,QACA;AAAA,QACA,SAAS,WAAW;AAAA,QACpB,MAAM,QAAQ;AAAA,QACd,YAAY,cAAc;AAAA,QAC1B,GAAI,eAAe,EAAE,aAAa,IAAI,CAAC;AAAA,MACzC;AACA,YAAM;AAAA,QACJ,MAAM,WAAW,wBAAwB,SAAS;AAAA,UAChD,cAAc,WAAW,+CAA+C,yBAAyB;AAAA,QACnG,CAAC;AAAA,QACD,EAAE,WAAW,kBAAkB,UAAU,aAAa;AAAA,MACxD;AAAA,IACF;AAAA,IACA,QAAQ,OAAO,EAAE,IAAI,MAAM,MAAM;AAC/B,YAAM,UAAU;AAAA,QACd;AAAA,QACA,UAAU,MAAM;AAAA,QAChB,cAAc,MAAM;AAAA,QACpB,SAAS,MAAM,WAAW;AAAA,QAC1B,MAAM,MAAM,QAAQ;AAAA,QACpB,YAAY,MAAM,cAAc;AAAA,QAChC,GAAI,MAAM,eAAe,EAAE,cAAc,MAAM,aAAa,IAAI,CAAC;AAAA,MACnE;AACA,YAAM;AAAA,QACJ,MAAM,WAAW,wBAAwB,SAAS;AAAA,UAChD,cAAc,WAAW,+CAA+C,yBAAyB;AAAA,QACnG,CAAC;AAAA,QACD,EAAE,WAAW,kBAAkB,GAAG;AAAA,MACpC;AAAA,IACF;AAAA,IACA,QAAQ,OAAO,EAAE,GAAG,MAAM;AACxB,YAAM;AAAA,QACJ,MAAM,WAAW,wBAAwB;AAAA,UACvC;AAAA,UACA,cAAc,WAAW,qDAAqD,4BAA4B;AAAA,QAC5G,CAAC;AAAA,QACD,EAAE,WAAW,kBAAkB,GAAG;AAAA,MACpC;AAAA,IACF;AAAA,EACF;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { apiCallOrThrow, readApiResultOrThrow } from "@open-mercato/ui/backend/utils/apiCall";
|
|
3
3
|
import { mapCommentSummary } from "@open-mercato/ui/backend/detail/NotesSection";
|
|
4
|
-
function createResourceNotesAdapter(translator) {
|
|
4
|
+
function createResourceNotesAdapter(translator, options = {}) {
|
|
5
|
+
const runWrite = async (runner, payload) => {
|
|
6
|
+
if (options.runMutation) {
|
|
7
|
+
return options.runMutation(runner, payload);
|
|
8
|
+
}
|
|
9
|
+
return runner();
|
|
10
|
+
};
|
|
5
11
|
return {
|
|
6
12
|
list: async ({ entityId }) => {
|
|
7
13
|
const params = new URLSearchParams();
|
|
@@ -15,19 +21,23 @@ function createResourceNotesAdapter(translator) {
|
|
|
15
21
|
return items.map(mapCommentSummary);
|
|
16
22
|
},
|
|
17
23
|
create: async ({ entityId, body, appearanceIcon, appearanceColor }) => {
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
24
|
+
const requestBody = {
|
|
25
|
+
entityId,
|
|
26
|
+
body,
|
|
27
|
+
appearanceIcon: appearanceIcon ?? void 0,
|
|
28
|
+
appearanceColor: appearanceColor ?? void 0
|
|
29
|
+
};
|
|
30
|
+
const response = await runWrite(
|
|
31
|
+
() => apiCallOrThrow(
|
|
32
|
+
"/api/resources/comments",
|
|
33
|
+
{
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers: { "content-type": "application/json" },
|
|
36
|
+
body: JSON.stringify(requestBody)
|
|
37
|
+
},
|
|
38
|
+
{ errorMessage: translator("resources.resources.detail.notes.error", "Failed to save note.") }
|
|
39
|
+
),
|
|
40
|
+
{ operation: "createNote", entityId }
|
|
31
41
|
);
|
|
32
42
|
return response.result ?? {};
|
|
33
43
|
},
|
|
@@ -36,24 +46,30 @@ function createResourceNotesAdapter(translator) {
|
|
|
36
46
|
if (patch.body !== void 0) payload.body = patch.body;
|
|
37
47
|
if (patch.appearanceIcon !== void 0) payload.appearanceIcon = patch.appearanceIcon;
|
|
38
48
|
if (patch.appearanceColor !== void 0) payload.appearanceColor = patch.appearanceColor;
|
|
39
|
-
await
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
49
|
+
await runWrite(
|
|
50
|
+
() => apiCallOrThrow(
|
|
51
|
+
"/api/resources/comments",
|
|
52
|
+
{
|
|
53
|
+
method: "PUT",
|
|
54
|
+
headers: { "content-type": "application/json" },
|
|
55
|
+
body: JSON.stringify(payload)
|
|
56
|
+
},
|
|
57
|
+
{ errorMessage: translator("resources.resources.detail.notes.updateError", "Failed to update note.") }
|
|
58
|
+
),
|
|
59
|
+
{ operation: "updateNote", id }
|
|
47
60
|
);
|
|
48
61
|
},
|
|
49
62
|
delete: async ({ id }) => {
|
|
50
|
-
await
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
63
|
+
await runWrite(
|
|
64
|
+
() => apiCallOrThrow(
|
|
65
|
+
`/api/resources/comments?id=${encodeURIComponent(id)}`,
|
|
66
|
+
{
|
|
67
|
+
method: "DELETE",
|
|
68
|
+
headers: { "content-type": "application/json" }
|
|
69
|
+
},
|
|
70
|
+
{ errorMessage: translator("resources.resources.detail.notes.deleteError", "Failed to delete note") }
|
|
71
|
+
),
|
|
72
|
+
{ operation: "deleteNote", id }
|
|
57
73
|
);
|
|
58
74
|
}
|
|
59
75
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/resources/components/detail/notesAdapter.ts"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport { apiCallOrThrow, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'\nimport { mapCommentSummary, type NotesDataAdapter } from '@open-mercato/ui/backend/detail/NotesSection'\n\ntype Translator = (key: string, fallback?: string, params?: Record<string, string | number>) => string\n\nexport function createResourceNotesAdapter(translator: Translator): NotesDataAdapter {\n return {\n list: async ({ entityId }) => {\n const params = new URLSearchParams()\n if (entityId) params.set('entityId', entityId)\n const payload = await readApiResultOrThrow<Record<string, unknown>>(\n `/api/resources/comments?${params.toString()}`,\n undefined,\n { errorMessage: translator('resources.resources.detail.notes.loadError', 'Failed to load notes.') },\n )\n const items = Array.isArray(payload?.items) ? payload.items : []\n return items.map(mapCommentSummary)\n },\n create: async ({ entityId, body, appearanceIcon, appearanceColor }) => {\n const response = await apiCallOrThrow<Record<string, unknown>>(\n
|
|
5
|
-
"mappings": ";AAEA,SAAS,gBAAgB,4BAA4B;AACrD,SAAS,yBAAgD;
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport { apiCallOrThrow, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'\nimport { mapCommentSummary, type NotesDataAdapter } from '@open-mercato/ui/backend/detail/NotesSection'\n\ntype Translator = (key: string, fallback?: string, params?: Record<string, string | number>) => string\n\nexport type ResourceNotesGuardedMutation = <T>(\n runner: () => Promise<T>,\n payload?: Record<string, unknown>,\n) => Promise<T>\n\nexport type CreateResourceNotesAdapterOptions = {\n runMutation?: ResourceNotesGuardedMutation\n}\n\nexport function createResourceNotesAdapter(\n translator: Translator,\n options: CreateResourceNotesAdapterOptions = {},\n): NotesDataAdapter {\n const runWrite = async <T,>(\n runner: () => Promise<T>,\n payload: Record<string, unknown>,\n ): Promise<T> => {\n if (options.runMutation) {\n return options.runMutation(runner, payload)\n }\n return runner()\n }\n\n return {\n list: async ({ entityId }) => {\n const params = new URLSearchParams()\n if (entityId) params.set('entityId', entityId)\n const payload = await readApiResultOrThrow<Record<string, unknown>>(\n `/api/resources/comments?${params.toString()}`,\n undefined,\n { errorMessage: translator('resources.resources.detail.notes.loadError', 'Failed to load notes.') },\n )\n const items = Array.isArray(payload?.items) ? payload.items : []\n return items.map(mapCommentSummary)\n },\n create: async ({ entityId, body, appearanceIcon, appearanceColor }) => {\n const requestBody = {\n entityId,\n body,\n appearanceIcon: appearanceIcon ?? undefined,\n appearanceColor: appearanceColor ?? undefined,\n }\n const response = await runWrite(\n () => apiCallOrThrow<Record<string, unknown>>(\n '/api/resources/comments',\n {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(requestBody),\n },\n { errorMessage: translator('resources.resources.detail.notes.error', 'Failed to save note.') },\n ),\n { operation: 'createNote', entityId },\n )\n return response.result ?? {}\n },\n update: async ({ id, patch }) => {\n const payload: Record<string, unknown> = { id }\n if (patch.body !== undefined) payload.body = patch.body\n if (patch.appearanceIcon !== undefined) payload.appearanceIcon = patch.appearanceIcon\n if (patch.appearanceColor !== undefined) payload.appearanceColor = patch.appearanceColor\n await runWrite(\n () => apiCallOrThrow(\n '/api/resources/comments',\n {\n method: 'PUT',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(payload),\n },\n { errorMessage: translator('resources.resources.detail.notes.updateError', 'Failed to update note.') },\n ),\n { operation: 'updateNote', id },\n )\n },\n delete: async ({ id }) => {\n await runWrite(\n () => apiCallOrThrow(\n `/api/resources/comments?id=${encodeURIComponent(id)}`,\n {\n method: 'DELETE',\n headers: { 'content-type': 'application/json' },\n },\n { errorMessage: translator('resources.resources.detail.notes.deleteError', 'Failed to delete note') },\n ),\n { operation: 'deleteNote', id },\n )\n },\n }\n}\n"],
|
|
5
|
+
"mappings": ";AAEA,SAAS,gBAAgB,4BAA4B;AACrD,SAAS,yBAAgD;AAalD,SAAS,2BACd,YACA,UAA6C,CAAC,GAC5B;AAClB,QAAM,WAAW,OACf,QACA,YACe;AACf,QAAI,QAAQ,aAAa;AACvB,aAAO,QAAQ,YAAY,QAAQ,OAAO;AAAA,IAC5C;AACA,WAAO,OAAO;AAAA,EAChB;AAEA,SAAO;AAAA,IACL,MAAM,OAAO,EAAE,SAAS,MAAM;AAC5B,YAAM,SAAS,IAAI,gBAAgB;AACnC,UAAI,SAAU,QAAO,IAAI,YAAY,QAAQ;AAC7C,YAAM,UAAU,MAAM;AAAA,QACpB,2BAA2B,OAAO,SAAS,CAAC;AAAA,QAC5C;AAAA,QACA,EAAE,cAAc,WAAW,8CAA8C,uBAAuB,EAAE;AAAA,MACpG;AACA,YAAM,QAAQ,MAAM,QAAQ,SAAS,KAAK,IAAI,QAAQ,QAAQ,CAAC;AAC/D,aAAO,MAAM,IAAI,iBAAiB;AAAA,IACpC;AAAA,IACA,QAAQ,OAAO,EAAE,UAAU,MAAM,gBAAgB,gBAAgB,MAAM;AACrE,YAAM,cAAc;AAAA,QAClB;AAAA,QACA;AAAA,QACA,gBAAgB,kBAAkB;AAAA,QAClC,iBAAiB,mBAAmB;AAAA,MACtC;AACA,YAAM,WAAW,MAAM;AAAA,QACrB,MAAM;AAAA,UACJ;AAAA,UACA;AAAA,YACE,QAAQ;AAAA,YACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,YAC9C,MAAM,KAAK,UAAU,WAAW;AAAA,UAClC;AAAA,UACA,EAAE,cAAc,WAAW,0CAA0C,sBAAsB,EAAE;AAAA,QAC/F;AAAA,QACA,EAAE,WAAW,cAAc,SAAS;AAAA,MACtC;AACA,aAAO,SAAS,UAAU,CAAC;AAAA,IAC7B;AAAA,IACA,QAAQ,OAAO,EAAE,IAAI,MAAM,MAAM;AAC/B,YAAM,UAAmC,EAAE,GAAG;AAC9C,UAAI,MAAM,SAAS,OAAW,SAAQ,OAAO,MAAM;AACnD,UAAI,MAAM,mBAAmB,OAAW,SAAQ,iBAAiB,MAAM;AACvE,UAAI,MAAM,oBAAoB,OAAW,SAAQ,kBAAkB,MAAM;AACzE,YAAM;AAAA,QACJ,MAAM;AAAA,UACJ;AAAA,UACA;AAAA,YACE,QAAQ;AAAA,YACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,YAC9C,MAAM,KAAK,UAAU,OAAO;AAAA,UAC9B;AAAA,UACA,EAAE,cAAc,WAAW,gDAAgD,wBAAwB,EAAE;AAAA,QACvG;AAAA,QACA,EAAE,WAAW,cAAc,GAAG;AAAA,MAChC;AAAA,IACF;AAAA,IACA,QAAQ,OAAO,EAAE,GAAG,MAAM;AACxB,YAAM;AAAA,QACJ,MAAM;AAAA,UACJ,8BAA8B,mBAAmB,EAAE,CAAC;AAAA,UACpD;AAAA,YACE,QAAQ;AAAA,YACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,UAChD;AAAA,UACA,EAAE,cAAc,WAAW,gDAAgD,uBAAuB,EAAE;AAAA,QACtG;AAAA,QACA,EAAE,WAAW,cAAc,GAAG;AAAA,MAChC;AAAA,IACF;AAAA,EACF;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.6.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.6176.1.4507b99c2f",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -246,16 +246,16 @@
|
|
|
246
246
|
"zod": "^4.4.3"
|
|
247
247
|
},
|
|
248
248
|
"peerDependencies": {
|
|
249
|
-
"@open-mercato/ai-assistant": "0.6.6-develop.
|
|
250
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
251
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
249
|
+
"@open-mercato/ai-assistant": "0.6.6-develop.6176.1.4507b99c2f",
|
|
250
|
+
"@open-mercato/shared": "0.6.6-develop.6176.1.4507b99c2f",
|
|
251
|
+
"@open-mercato/ui": "0.6.6-develop.6176.1.4507b99c2f",
|
|
252
252
|
"react": "^19.0.0",
|
|
253
253
|
"react-dom": "^19.0.0"
|
|
254
254
|
},
|
|
255
255
|
"devDependencies": {
|
|
256
|
-
"@open-mercato/ai-assistant": "0.6.6-develop.
|
|
257
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
258
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
256
|
+
"@open-mercato/ai-assistant": "0.6.6-develop.6176.1.4507b99c2f",
|
|
257
|
+
"@open-mercato/shared": "0.6.6-develop.6176.1.4507b99c2f",
|
|
258
|
+
"@open-mercato/ui": "0.6.6-develop.6176.1.4507b99c2f",
|
|
259
259
|
"@testing-library/dom": "^10.4.1",
|
|
260
260
|
"@testing-library/jest-dom": "^6.9.1",
|
|
261
261
|
"@testing-library/react": "^16.3.1",
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { mapWithConcurrency } from '@open-mercato/shared/lib/query/bounded-decrypt'
|
|
2
|
+
import { sortRowsInMemory } from '@open-mercato/shared/lib/query/encrypted-sort'
|
|
3
|
+
import { SortDir } from '@open-mercato/shared/lib/query/types'
|
|
4
|
+
|
|
5
|
+
const DECRYPT_CONCURRENCY = 8
|
|
6
|
+
|
|
7
|
+
export type EncryptedSortCandidate = { id: string } & Record<string, unknown>
|
|
8
|
+
|
|
9
|
+
// Re-sorts the bounded candidate set by plaintext on every call and resumes
|
|
10
|
+
// from the cursor's id position — SQL keyset comparison against ciphertext
|
|
11
|
+
// is meaningless for ordering.
|
|
12
|
+
export async function resolveEncryptedSortPage<T extends EncryptedSortCandidate>(params: {
|
|
13
|
+
candidates: readonly T[]
|
|
14
|
+
decryptRow: (row: T) => Promise<T>
|
|
15
|
+
sortField: string
|
|
16
|
+
sortDir: 'asc' | 'desc'
|
|
17
|
+
cursorId: string | null
|
|
18
|
+
limit: number
|
|
19
|
+
}): Promise<{ pageIds: string[]; hasMore: boolean }> {
|
|
20
|
+
const decrypted = await mapWithConcurrency(params.candidates, DECRYPT_CONCURRENCY, params.decryptRow)
|
|
21
|
+
const ordered = sortRowsInMemory(decrypted, [
|
|
22
|
+
{ field: params.sortField, dir: params.sortDir === 'desc' ? SortDir.Desc : SortDir.Asc },
|
|
23
|
+
])
|
|
24
|
+
let start = 0
|
|
25
|
+
if (params.cursorId) {
|
|
26
|
+
const idx = ordered.findIndex((row) => row.id === params.cursorId)
|
|
27
|
+
start = idx >= 0 ? idx + 1 : 0
|
|
28
|
+
}
|
|
29
|
+
const page = ordered.slice(start, start + params.limit)
|
|
30
|
+
return {
|
|
31
|
+
pageIds: page.map((row) => row.id),
|
|
32
|
+
hasMore: start + params.limit < ordered.length,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -9,6 +9,8 @@ import { normalizeCustomFieldResponse } from '@open-mercato/shared/lib/custom-fi
|
|
|
9
9
|
import { applyResponseEnrichers } from '@open-mercato/shared/lib/crud/enricher-runner'
|
|
10
10
|
import type { EnricherContext } from '@open-mercato/shared/lib/crud/response-enricher'
|
|
11
11
|
import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
12
|
+
import { resolveTenantEncryptionService } from '@open-mercato/shared/lib/encryption/customFieldValues'
|
|
13
|
+
import { resolveEncryptedSortFields, resolveEncryptedSortMaxRows } from '@open-mercato/shared/lib/query/encrypted-sort'
|
|
12
14
|
import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
|
|
13
15
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
14
16
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
@@ -24,6 +26,7 @@ import {
|
|
|
24
26
|
} from '../openapi'
|
|
25
27
|
import { CUSTOMER_INTERACTION_ENTITY_ID } from '../../lib/interactionCompatibility'
|
|
26
28
|
import { applyEmailVisibilityFilter } from '../../lib/visibilityFilter'
|
|
29
|
+
import { resolveEncryptedSortPage } from './encryptedSortPage'
|
|
27
30
|
import { resolveCanonicalActivityTargetId } from '../../lib/legacyActivityBridge'
|
|
28
31
|
import type { CommandBus, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
|
|
29
32
|
|
|
@@ -284,6 +287,85 @@ function buildSortSql(
|
|
|
284
287
|
return `coalesce(${config.column}, ${sentinel})`
|
|
285
288
|
}
|
|
286
289
|
|
|
290
|
+
const INTERACTION_LIST_COLUMNS = [
|
|
291
|
+
'id',
|
|
292
|
+
'entity_id',
|
|
293
|
+
'deal_id',
|
|
294
|
+
'interaction_type',
|
|
295
|
+
'title',
|
|
296
|
+
'body',
|
|
297
|
+
'status',
|
|
298
|
+
'scheduled_at',
|
|
299
|
+
'occurred_at',
|
|
300
|
+
'priority',
|
|
301
|
+
'author_user_id',
|
|
302
|
+
'owner_user_id',
|
|
303
|
+
'appearance_icon',
|
|
304
|
+
'appearance_color',
|
|
305
|
+
'source',
|
|
306
|
+
'duration_minutes',
|
|
307
|
+
'location',
|
|
308
|
+
'all_day',
|
|
309
|
+
'recurrence_rule',
|
|
310
|
+
'recurrence_end',
|
|
311
|
+
'participants',
|
|
312
|
+
'reminder_minutes',
|
|
313
|
+
'visibility',
|
|
314
|
+
'linked_entities',
|
|
315
|
+
'guest_permissions',
|
|
316
|
+
'pinned',
|
|
317
|
+
'organization_id',
|
|
318
|
+
'tenant_id',
|
|
319
|
+
'created_at',
|
|
320
|
+
'updated_at',
|
|
321
|
+
] as const
|
|
322
|
+
|
|
323
|
+
// Shared by both the SQL keyset path and the encrypted-sort candidate scan
|
|
324
|
+
// so the two paths never drift on which rows are in scope.
|
|
325
|
+
function applyInteractionListFilters(
|
|
326
|
+
baseQuery: any,
|
|
327
|
+
params: {
|
|
328
|
+
tenantId: string
|
|
329
|
+
organizationIds: string[]
|
|
330
|
+
query: z.infer<typeof listSchema>
|
|
331
|
+
},
|
|
332
|
+
): any {
|
|
333
|
+
let q = baseQuery.where('deleted_at', 'is', null).where('tenant_id', '=', params.tenantId)
|
|
334
|
+
if (params.organizationIds.length > 0) q = q.where('organization_id', 'in', params.organizationIds)
|
|
335
|
+
const { query } = params
|
|
336
|
+
if (query.entityId) q = q.where('entity_id', '=', query.entityId)
|
|
337
|
+
if (query.dealId) q = q.where('deal_id', '=', query.dealId)
|
|
338
|
+
if (query.status) q = q.where('status', '=', query.status)
|
|
339
|
+
if (query.interactionType) q = q.where('interaction_type', '=', query.interactionType)
|
|
340
|
+
if (query.type) {
|
|
341
|
+
const types = query.type.split(',').map((t) => t.trim()).filter(Boolean)
|
|
342
|
+
if (types.length > 0) q = q.where('interaction_type', 'in', types)
|
|
343
|
+
}
|
|
344
|
+
if (query.pinned === 'true') {
|
|
345
|
+
q = q.where('pinned', '=', true)
|
|
346
|
+
} else if (query.pinned === 'false') {
|
|
347
|
+
q = q.where('pinned', '=', false)
|
|
348
|
+
}
|
|
349
|
+
if (query.excludeInteractionType) q = q.where('interaction_type', '!=', query.excludeInteractionType)
|
|
350
|
+
if (query.search) {
|
|
351
|
+
// NOTE: for tenants with data encryption enabled, `title`/`body` are
|
|
352
|
+
// ciphertext at rest (see encryption.ts), so this ILIKE matches encrypted
|
|
353
|
+
// bytes and returns no rows — substring search over encrypted free-text
|
|
354
|
+
// columns is unsupported, the same documented limitation as
|
|
355
|
+
// customer_activity / customer_comment. The returned page's title/body are
|
|
356
|
+
// still decrypted for display further below.
|
|
357
|
+
const searchTerm = `%${escapeLikePattern(query.search)}%`
|
|
358
|
+
q = q.where(sql<boolean>`coalesce(title, '') ilike ${searchTerm} or coalesce(body, '') ilike ${searchTerm}`)
|
|
359
|
+
}
|
|
360
|
+
if (query.from) {
|
|
361
|
+
q = q.where(sql<boolean>`coalesce(occurred_at, scheduled_at, created_at) >= ${query.from}`)
|
|
362
|
+
}
|
|
363
|
+
if (query.to) {
|
|
364
|
+
q = q.where(sql<boolean>`coalesce(occurred_at, scheduled_at, created_at) <= ${query.to}`)
|
|
365
|
+
}
|
|
366
|
+
return q
|
|
367
|
+
}
|
|
368
|
+
|
|
287
369
|
async function resolveUserFeatures(
|
|
288
370
|
container: { resolve: (name: string) => unknown },
|
|
289
371
|
userId: string,
|
|
@@ -366,94 +448,6 @@ export async function GET(req: Request) {
|
|
|
366
448
|
})
|
|
367
449
|
}
|
|
368
450
|
|
|
369
|
-
let rowsQuery = db
|
|
370
|
-
.selectFrom('customer_interactions')
|
|
371
|
-
.select([
|
|
372
|
-
'id',
|
|
373
|
-
'entity_id',
|
|
374
|
-
'deal_id',
|
|
375
|
-
'interaction_type',
|
|
376
|
-
'title',
|
|
377
|
-
'body',
|
|
378
|
-
'status',
|
|
379
|
-
'scheduled_at',
|
|
380
|
-
'occurred_at',
|
|
381
|
-
'priority',
|
|
382
|
-
'author_user_id',
|
|
383
|
-
'owner_user_id',
|
|
384
|
-
'appearance_icon',
|
|
385
|
-
'appearance_color',
|
|
386
|
-
'source',
|
|
387
|
-
'duration_minutes',
|
|
388
|
-
'location',
|
|
389
|
-
'all_day',
|
|
390
|
-
'recurrence_rule',
|
|
391
|
-
'recurrence_end',
|
|
392
|
-
'participants',
|
|
393
|
-
'reminder_minutes',
|
|
394
|
-
'visibility',
|
|
395
|
-
'linked_entities',
|
|
396
|
-
'guest_permissions',
|
|
397
|
-
'pinned',
|
|
398
|
-
'organization_id',
|
|
399
|
-
'tenant_id',
|
|
400
|
-
'created_at',
|
|
401
|
-
'updated_at',
|
|
402
|
-
sql`${sql.raw(sortSql)}`.as('__sort_value'),
|
|
403
|
-
])
|
|
404
|
-
.where('deleted_at', 'is', null)
|
|
405
|
-
.where('tenant_id', '=', auth.tenantId)
|
|
406
|
-
.limit(query.limit + 1)
|
|
407
|
-
|
|
408
|
-
if (organizationIds.length > 0) {
|
|
409
|
-
rowsQuery = rowsQuery.where('organization_id', 'in', organizationIds)
|
|
410
|
-
}
|
|
411
|
-
if (query.entityId) rowsQuery = rowsQuery.where('entity_id', '=', query.entityId)
|
|
412
|
-
if (query.dealId) rowsQuery = rowsQuery.where('deal_id', '=', query.dealId)
|
|
413
|
-
if (query.status) rowsQuery = rowsQuery.where('status', '=', query.status)
|
|
414
|
-
if (query.interactionType) rowsQuery = rowsQuery.where('interaction_type', '=', query.interactionType)
|
|
415
|
-
if (query.type) {
|
|
416
|
-
const types = query.type.split(',').map((t) => t.trim()).filter(Boolean)
|
|
417
|
-
if (types.length > 0) {
|
|
418
|
-
rowsQuery = rowsQuery.where('interaction_type', 'in', types)
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
if (query.pinned === 'true') {
|
|
422
|
-
rowsQuery = rowsQuery.where('pinned', '=', true)
|
|
423
|
-
} else if (query.pinned === 'false') {
|
|
424
|
-
rowsQuery = rowsQuery.where('pinned', '=', false)
|
|
425
|
-
}
|
|
426
|
-
if (query.excludeInteractionType) rowsQuery = rowsQuery.where('interaction_type', '!=', query.excludeInteractionType)
|
|
427
|
-
if (query.search) {
|
|
428
|
-
// NOTE: for tenants with data encryption enabled, `title`/`body` are
|
|
429
|
-
// ciphertext at rest (see encryption.ts), so this ILIKE matches encrypted
|
|
430
|
-
// bytes and returns no rows — substring search over encrypted free-text
|
|
431
|
-
// columns is unsupported, the same documented limitation as
|
|
432
|
-
// customer_activity / customer_comment. The returned page's title/body are
|
|
433
|
-
// still decrypted for display further below.
|
|
434
|
-
const searchTerm = `%${escapeLikePattern(query.search)}%`
|
|
435
|
-
rowsQuery = rowsQuery.where(sql<boolean>`coalesce(title, '') ilike ${searchTerm} or coalesce(body, '') ilike ${searchTerm}`)
|
|
436
|
-
}
|
|
437
|
-
if (query.from) {
|
|
438
|
-
rowsQuery = rowsQuery.where(sql<boolean>`coalesce(occurred_at, scheduled_at, created_at) >= ${query.from}`)
|
|
439
|
-
}
|
|
440
|
-
if (query.to) {
|
|
441
|
-
rowsQuery = rowsQuery.where(sql<boolean>`coalesce(occurred_at, scheduled_at, created_at) <= ${query.to}`)
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
if (cursor) {
|
|
445
|
-
const op = sortDir === 'asc' ? '>' : '<'
|
|
446
|
-
const opRaw = sql.raw(op)
|
|
447
|
-
const sortRaw = sql.raw(sortSql)
|
|
448
|
-
rowsQuery = rowsQuery.where((eb: any) => eb.or([
|
|
449
|
-
sql<boolean>`${sortRaw} ${opRaw} ${cursor.sortValue}`,
|
|
450
|
-
eb.and([
|
|
451
|
-
sql<boolean>`${sortRaw} = ${cursor.sortValue}`,
|
|
452
|
-
eb('id', op, cursor.id),
|
|
453
|
-
]),
|
|
454
|
-
]))
|
|
455
|
-
}
|
|
456
|
-
|
|
457
451
|
// ── Email visibility filter (2026-05-27) ──────────────────────────────
|
|
458
452
|
// Non-email interactions pass through; email rows with visibility='private'
|
|
459
453
|
// are filtered out unless the caller is the author or has admin bypass.
|
|
@@ -461,19 +455,121 @@ export async function GET(req: Request) {
|
|
|
461
455
|
// viewer to null so they never gain the author bypass and only see shared
|
|
462
456
|
// emails (fail-closed). Mirrors counts/people/activities routes.
|
|
463
457
|
const viewerUserId = auth.isApiKey ? null : (auth.sub ?? null)
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
458
|
+
const encryptionService = resolveTenantEncryptionService(em)
|
|
459
|
+
// Encrypted sort columns can't use SQL keyset ordering on ciphertext, so an
|
|
460
|
+
// encrypted sort field takes a bounded candidate-scan + in-memory-sort path
|
|
461
|
+
// instead of the SQL path below. Resolved alongside the independent
|
|
462
|
+
// visibility-feature lookup rather than after it.
|
|
463
|
+
const [callerUserFeatures, encryptedSortFields] = await Promise.all([
|
|
464
|
+
viewerUserId
|
|
465
|
+
? resolveUserFeatures(container, viewerUserId, auth.tenantId ?? null, selectedOrganizationId)
|
|
466
|
+
: Promise.resolve(undefined),
|
|
467
|
+
resolveEncryptedSortFields(
|
|
468
|
+
encryptionService,
|
|
469
|
+
CUSTOMER_INTERACTION_ENTITY_ID,
|
|
470
|
+
[sortConfig.column],
|
|
471
|
+
auth.tenantId,
|
|
472
|
+
selectedOrganizationId,
|
|
473
|
+
),
|
|
474
|
+
])
|
|
475
|
+
const sortFieldIsEncrypted = encryptedSortFields.has(sortConfig.column)
|
|
471
476
|
|
|
472
|
-
|
|
477
|
+
let pageRows: InteractionListRow[]
|
|
478
|
+
let hasMore: boolean
|
|
473
479
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
480
|
+
if (sortFieldIsEncrypted) {
|
|
481
|
+
let candidateQuery = applyInteractionListFilters(
|
|
482
|
+
db.selectFrom('customer_interactions').select(['id', sortConfig.column]),
|
|
483
|
+
{ tenantId: auth.tenantId, organizationIds, query },
|
|
484
|
+
)
|
|
485
|
+
candidateQuery = applyEmailVisibilityFilter(candidateQuery as any, {
|
|
486
|
+
currentUserId: viewerUserId,
|
|
487
|
+
userFeatures: callerUserFeatures,
|
|
488
|
+
})
|
|
489
|
+
const cap = resolveEncryptedSortMaxRows()
|
|
490
|
+
if (cap !== null) {
|
|
491
|
+
candidateQuery = candidateQuery.limit(cap).orderBy('id', 'asc')
|
|
492
|
+
}
|
|
493
|
+
const candidateRows = await candidateQuery.execute() as Array<{ id: string } & Record<string, unknown>>
|
|
494
|
+
if (cap !== null && candidateRows.length >= cap) {
|
|
495
|
+
console.warn('[customers/interactions.GET] encrypted sort candidate scan hit OM_ENCRYPTED_SORT_MAX_ROWS cap; results may be incomplete', {
|
|
496
|
+
cap,
|
|
497
|
+
sortField: sortConfig.column,
|
|
498
|
+
tenantId: auth.tenantId,
|
|
499
|
+
})
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const decryptPayload = encryptionService?.decryptEntityPayload?.bind(encryptionService)
|
|
503
|
+
const { pageIds, hasMore: encryptedHasMore } = await resolveEncryptedSortPage({
|
|
504
|
+
candidates: candidateRows,
|
|
505
|
+
decryptRow: async (row) => {
|
|
506
|
+
if (!decryptPayload) return row
|
|
507
|
+
try {
|
|
508
|
+
const decrypted = await decryptPayload(CUSTOMER_INTERACTION_ENTITY_ID, row, auth.tenantId, selectedOrganizationId)
|
|
509
|
+
return { ...row, ...decrypted }
|
|
510
|
+
} catch (err) {
|
|
511
|
+
console.error('[customers/interactions.GET] error decrypting sort candidate', err)
|
|
512
|
+
return row
|
|
513
|
+
}
|
|
514
|
+
},
|
|
515
|
+
sortField: sortConfig.column,
|
|
516
|
+
sortDir,
|
|
517
|
+
cursorId: cursor?.id ?? null,
|
|
518
|
+
limit: query.limit,
|
|
519
|
+
})
|
|
520
|
+
hasMore = encryptedHasMore
|
|
521
|
+
|
|
522
|
+
if (pageIds.length === 0) {
|
|
523
|
+
pageRows = []
|
|
524
|
+
} else {
|
|
525
|
+
let pageQuery = applyInteractionListFilters(
|
|
526
|
+
db.selectFrom('customer_interactions').select([...INTERACTION_LIST_COLUMNS, sql`${sql.raw(sortSql)}`.as('__sort_value')]),
|
|
527
|
+
{ tenantId: auth.tenantId, organizationIds, query },
|
|
528
|
+
)
|
|
529
|
+
pageQuery = applyEmailVisibilityFilter(pageQuery as any, {
|
|
530
|
+
currentUserId: viewerUserId,
|
|
531
|
+
userFeatures: callerUserFeatures,
|
|
532
|
+
})
|
|
533
|
+
pageQuery = pageQuery.where('id', 'in', pageIds)
|
|
534
|
+
const rawPageRows = await pageQuery.execute() as InteractionListRow[]
|
|
535
|
+
const byId = new Map(rawPageRows.map((row) => [row.id, row]))
|
|
536
|
+
pageRows = pageIds
|
|
537
|
+
.map((id) => byId.get(id))
|
|
538
|
+
.filter((row): row is InteractionListRow => row != null)
|
|
539
|
+
}
|
|
540
|
+
} else {
|
|
541
|
+
let rowsQuery = applyInteractionListFilters(
|
|
542
|
+
db
|
|
543
|
+
.selectFrom('customer_interactions')
|
|
544
|
+
.select([...INTERACTION_LIST_COLUMNS, sql`${sql.raw(sortSql)}`.as('__sort_value')])
|
|
545
|
+
.limit(query.limit + 1),
|
|
546
|
+
{ tenantId: auth.tenantId, organizationIds, query },
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
if (cursor) {
|
|
550
|
+
const op = sortDir === 'asc' ? '>' : '<'
|
|
551
|
+
const opRaw = sql.raw(op)
|
|
552
|
+
const sortRaw = sql.raw(sortSql)
|
|
553
|
+
rowsQuery = rowsQuery.where((eb: any) => eb.or([
|
|
554
|
+
sql<boolean>`${sortRaw} ${opRaw} ${cursor.sortValue}`,
|
|
555
|
+
eb.and([
|
|
556
|
+
sql<boolean>`${sortRaw} = ${cursor.sortValue}`,
|
|
557
|
+
eb('id', op, cursor.id),
|
|
558
|
+
]),
|
|
559
|
+
]))
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
rowsQuery = applyEmailVisibilityFilter(rowsQuery as any, {
|
|
563
|
+
currentUserId: viewerUserId,
|
|
564
|
+
userFeatures: callerUserFeatures,
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
rowsQuery = rowsQuery.orderBy(sql`${sql.raw(sortSql)} ${sql.raw(sortDir)}`).orderBy('id', sortDir)
|
|
568
|
+
|
|
569
|
+
const rows = await rowsQuery.execute() as InteractionListRow[]
|
|
570
|
+
pageRows = rows.slice(0, query.limit)
|
|
571
|
+
hasMore = rows.length > query.limit
|
|
572
|
+
}
|
|
477
573
|
|
|
478
574
|
const authorIds = Array.from(
|
|
479
575
|
new Set(
|
|
@@ -11,6 +11,10 @@ import type { CommandBus } from '@open-mercato/shared/lib/commands'
|
|
|
11
11
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
12
12
|
import { serializeOperationMetadata } from '@open-mercato/shared/lib/commands/operationMetadata'
|
|
13
13
|
import { findWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
14
|
+
import { decryptEntitiesWithFallbackScope } from '@open-mercato/shared/lib/encryption/subscriber'
|
|
15
|
+
import { mapWithConcurrency } from '@open-mercato/shared/lib/query/bounded-decrypt'
|
|
16
|
+
import { resolveEncryptedSortMaxRows, sortRowsInMemory } from '@open-mercato/shared/lib/query/encrypted-sort'
|
|
17
|
+
import { SortDir } from '@open-mercato/shared/lib/query/types'
|
|
14
18
|
import { resolveLabelActorUserId } from './auth'
|
|
15
19
|
import {
|
|
16
20
|
runCrudMutationGuardAfterSuccess,
|
|
@@ -28,6 +32,8 @@ export const metadata = {
|
|
|
28
32
|
POST: { requireAuth: true, requireFeatures: ['customers.people.manage'] },
|
|
29
33
|
}
|
|
30
34
|
|
|
35
|
+
const DECRYPT_CONCURRENCY = 8
|
|
36
|
+
|
|
31
37
|
const querySchema = z.object({
|
|
32
38
|
entityId: z.string().uuid().optional(),
|
|
33
39
|
organizationId: z.string().uuid().optional(),
|
|
@@ -72,12 +78,30 @@ export async function GET(req: Request) {
|
|
|
72
78
|
throw new CrudHttpError(400, { error: translate('customers.errors.organization_required', 'Organization context is required') })
|
|
73
79
|
}
|
|
74
80
|
|
|
75
|
-
//
|
|
76
|
-
|
|
81
|
+
// `label` is encrypted at rest; SQL can't sort it meaningfully, so the
|
|
82
|
+
// candidate set is decrypted and sorted in memory below.
|
|
83
|
+
const cap = resolveEncryptedSortMaxRows()
|
|
84
|
+
const candidateLabels = await em.find(CustomerLabel, {
|
|
77
85
|
tenantId: auth.tenantId,
|
|
78
86
|
organizationId,
|
|
79
87
|
userId: actorUserId,
|
|
80
|
-
}
|
|
88
|
+
} as FilterQuery<CustomerLabel>, cap !== null ? { limit: cap, orderBy: { id: 'asc' } } : {})
|
|
89
|
+
if (cap !== null && candidateLabels.length >= cap) {
|
|
90
|
+
console.warn('[customers/labels.GET] encrypted sort candidate scan hit OM_ENCRYPTED_SORT_MAX_ROWS cap; results may be incomplete', {
|
|
91
|
+
cap,
|
|
92
|
+
tenantId: auth.tenantId,
|
|
93
|
+
organizationId,
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
await mapWithConcurrency(candidateLabels, DECRYPT_CONCURRENCY, (label) =>
|
|
98
|
+
decryptEntitiesWithFallbackScope(label, { em, tenantId: auth.tenantId, organizationId }),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
const labels = sortRowsInMemory(
|
|
102
|
+
candidateLabels as unknown as Record<string, unknown>[],
|
|
103
|
+
[{ field: 'label', dir: SortDir.Asc }],
|
|
104
|
+
) as unknown as CustomerLabel[]
|
|
81
105
|
|
|
82
106
|
// If entityId provided, also load assignments for that entity
|
|
83
107
|
let assignedLabelIds: string[] = []
|