@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.
Files changed (28) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +5 -3
  3. package/agentic/standalone-guide.md +4 -2
  4. package/dist/modules/customers/api/interactions/encryptedSortPage.js +24 -0
  5. package/dist/modules/customers/api/interactions/encryptedSortPage.js.map +7 -0
  6. package/dist/modules/customers/api/interactions/route.js +160 -82
  7. package/dist/modules/customers/api/interactions/route.js.map +2 -2
  8. package/dist/modules/customers/api/labels/route.js +24 -2
  9. package/dist/modules/customers/api/labels/route.js.map +2 -2
  10. package/dist/modules/resources/backend/resources/resource-types/page.js +25 -2
  11. package/dist/modules/resources/backend/resources/resource-types/page.js.map +2 -2
  12. package/dist/modules/resources/backend/resources/resources/[id]/page.js +88 -37
  13. package/dist/modules/resources/backend/resources/resources/[id]/page.js.map +2 -2
  14. package/dist/modules/resources/backend/resources/resources/page.js +27 -4
  15. package/dist/modules/resources/backend/resources/resources/page.js.map +2 -2
  16. package/dist/modules/resources/components/detail/activitiesAdapter.js +30 -13
  17. package/dist/modules/resources/components/detail/activitiesAdapter.js.map +2 -2
  18. package/dist/modules/resources/components/detail/notesAdapter.js +45 -29
  19. package/dist/modules/resources/components/detail/notesAdapter.js.map +2 -2
  20. package/package.json +7 -7
  21. package/src/modules/customers/api/interactions/encryptedSortPage.ts +34 -0
  22. package/src/modules/customers/api/interactions/route.ts +195 -99
  23. package/src/modules/customers/api/labels/route.ts +27 -3
  24. package/src/modules/resources/backend/resources/resource-types/page.tsx +38 -4
  25. package/src/modules/resources/backend/resources/resources/[id]/page.tsx +102 -37
  26. package/src/modules/resources/backend/resources/resources/page.tsx +40 -6
  27. package/src/modules/resources/components/detail/activitiesAdapter.ts +46 -13
  28. 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 await createCrud('resources/activities', {\n entityId,\n activityType,\n subject: subject ?? undefined,\n body: body ?? undefined,\n occurredAt: occurredAt ?? undefined,\n ...(customFields ? { customFields } : {}),\n }, {\n errorMessage: translator('resources.resources.detail.activities.error', 'Failed to save activity'),\n })\n },\n update: async ({ id, patch }) => {\n await updateCrud('resources/activities', {\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 errorMessage: translator('resources.resources.detail.activities.error', 'Failed to save activity'),\n })\n },\n delete: async ({ id }) => {\n await deleteCrud('resources/activities', {\n id,\n errorMessage: translator('resources.resources.detail.activities.deleteError', 'Failed to delete activity.'),\n })\n },\n }\n}\n"],
5
- "mappings": ";AAEA,SAAS,4BAA4B;AACrC,SAAS,YAAY,YAAY,kBAAkB;AAK5C,SAAS,gCAAgC,YAA+C;AAC7F,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,WAAW,wBAAwB;AAAA,QACvC;AAAA,QACA;AAAA,QACA,SAAS,WAAW;AAAA,QACpB,MAAM,QAAQ;AAAA,QACd,YAAY,cAAc;AAAA,QAC1B,GAAI,eAAe,EAAE,aAAa,IAAI,CAAC;AAAA,MACzC,GAAG;AAAA,QACD,cAAc,WAAW,+CAA+C,yBAAyB;AAAA,MACnG,CAAC;AAAA,IACH;AAAA,IACA,QAAQ,OAAO,EAAE,IAAI,MAAM,MAAM;AAC/B,YAAM,WAAW,wBAAwB;AAAA,QACvC;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,GAAG;AAAA,QACD,cAAc,WAAW,+CAA+C,yBAAyB;AAAA,MACnG,CAAC;AAAA,IACH;AAAA,IACA,QAAQ,OAAO,EAAE,GAAG,MAAM;AACxB,YAAM,WAAW,wBAAwB;AAAA,QACvC;AAAA,QACA,cAAc,WAAW,qDAAqD,4BAA4B;AAAA,MAC5G,CAAC;AAAA,IACH;AAAA,EACF;AACF;",
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 response = await apiCallOrThrow(
19
- "/api/resources/comments",
20
- {
21
- method: "POST",
22
- headers: { "content-type": "application/json" },
23
- body: JSON.stringify({
24
- entityId,
25
- body,
26
- appearanceIcon: appearanceIcon ?? void 0,
27
- appearanceColor: appearanceColor ?? void 0
28
- })
29
- },
30
- { errorMessage: translator("resources.resources.detail.notes.error", "Failed to save note.") }
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 apiCallOrThrow(
40
- "/api/resources/comments",
41
- {
42
- method: "PUT",
43
- headers: { "content-type": "application/json" },
44
- body: JSON.stringify(payload)
45
- },
46
- { errorMessage: translator("resources.resources.detail.notes.updateError", "Failed to update note.") }
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 apiCallOrThrow(
51
- `/api/resources/comments?id=${encodeURIComponent(id)}`,
52
- {
53
- method: "DELETE",
54
- headers: { "content-type": "application/json" }
55
- },
56
- { errorMessage: translator("resources.resources.detail.notes.deleteError", "Failed to delete note") }
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 '/api/resources/comments',\n {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n entityId,\n body,\n appearanceIcon: appearanceIcon ?? undefined,\n appearanceColor: appearanceColor ?? undefined,\n }),\n },\n { errorMessage: translator('resources.resources.detail.notes.error', 'Failed to save note.') },\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 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 },\n delete: async ({ id }) => {\n await 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 },\n }\n}\n"],
5
- "mappings": ";AAEA,SAAS,gBAAgB,4BAA4B;AACrD,SAAS,yBAAgD;AAIlD,SAAS,2BAA2B,YAA0C;AACnF,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,WAAW,MAAM;AAAA,QACrB;AAAA,QACA;AAAA,UACE,QAAQ;AAAA,UACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,UAC9C,MAAM,KAAK,UAAU;AAAA,YACnB;AAAA,YACA;AAAA,YACA,gBAAgB,kBAAkB;AAAA,YAClC,iBAAiB,mBAAmB;AAAA,UACtC,CAAC;AAAA,QACH;AAAA,QACA,EAAE,cAAc,WAAW,0CAA0C,sBAAsB,EAAE;AAAA,MAC/F;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;AAAA,QACA;AAAA,UACE,QAAQ;AAAA,UACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,UAC9C,MAAM,KAAK,UAAU,OAAO;AAAA,QAC9B;AAAA,QACA,EAAE,cAAc,WAAW,gDAAgD,wBAAwB,EAAE;AAAA,MACvG;AAAA,IACF;AAAA,IACA,QAAQ,OAAO,EAAE,GAAG,MAAM;AACxB,YAAM;AAAA,QACJ,8BAA8B,mBAAmB,EAAE,CAAC;AAAA,QACpD;AAAA,UACE,QAAQ;AAAA,UACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,QAChD;AAAA,QACA,EAAE,cAAc,WAAW,gDAAgD,uBAAuB,EAAE;AAAA,MACtG;AAAA,IACF;AAAA,EACF;AACF;",
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.6171.1.17de2dc37a",
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.6171.1.17de2dc37a",
250
- "@open-mercato/shared": "0.6.6-develop.6171.1.17de2dc37a",
251
- "@open-mercato/ui": "0.6.6-develop.6171.1.17de2dc37a",
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.6171.1.17de2dc37a",
257
- "@open-mercato/shared": "0.6.6-develop.6171.1.17de2dc37a",
258
- "@open-mercato/ui": "0.6.6-develop.6171.1.17de2dc37a",
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 callerUserFeatures = viewerUserId
465
- ? await resolveUserFeatures(container, viewerUserId, auth.tenantId ?? null, selectedOrganizationId)
466
- : undefined
467
- rowsQuery = applyEmailVisibilityFilter(rowsQuery as any, {
468
- currentUserId: viewerUserId,
469
- userFeatures: callerUserFeatures,
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
- rowsQuery = rowsQuery.orderBy(sql`${sql.raw(sortSql)} ${sql.raw(sortDir)}`).orderBy('id', sortDir)
477
+ let pageRows: InteractionListRow[]
478
+ let hasMore: boolean
473
479
 
474
- const rows = await rowsQuery.execute() as InteractionListRow[]
475
- const pageRows = rows.slice(0, query.limit)
476
- const hasMore = rows.length > query.limit
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
- // Load all labels for this user
76
- const labels = await findWithDecryption(em, CustomerLabel, {
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
- }, { orderBy: { label: 'asc' } }, { tenantId: auth.tenantId, organizationId })
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[] = []