@open-mercato/core 0.6.6-develop.6174.1.8a7040f00d → 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/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/.turbo/turbo-build.log
CHANGED
package/AGENTS.md
CHANGED
|
@@ -112,9 +112,11 @@ Granting the feature to a customer role is sufficient for the entry to appear
|
|
|
112
112
|
|
|
113
113
|
All API route files MUST export an `openApi` object for automatic API documentation generation.
|
|
114
114
|
|
|
115
|
-
For custom write routes that do not use `makeCrudRoute` (`POST`/`PUT`/`PATCH`/`DELETE`), MUST wire the mutation guard
|
|
116
|
-
-
|
|
117
|
-
-
|
|
115
|
+
For custom write routes that do not use `makeCrudRoute` (`POST`/`PUT`/`PATCH`/`DELETE`), MUST wire the mutation guard registry:
|
|
116
|
+
- map the route to the closest registry operation (`create`, `update`, or `delete`; state-changing action endpoints usually use `update`)
|
|
117
|
+
- collect registered guards with `getAllMutationGuardInstances()` and append `bridgeLegacyGuard(container)` when present
|
|
118
|
+
- call `runMutationGuards(...)` from `@open-mercato/shared/lib/crud/mutation-guard-registry` before mutation logic, passing the caller's granted features as `{ userFeatures }`
|
|
119
|
+
- return `guardResult.errorBody` / `guardResult.errorStatus` when blocked, merge `guardResult.modifiedPayload` back into validated input when present, and run each returned `afterSuccessCallbacks` item after a successful mutation, catching/logging callback failures so committed writes still return successfully
|
|
118
120
|
|
|
119
121
|
### CRUD Routes
|
|
120
122
|
|
|
@@ -75,8 +75,10 @@ makeCrudRoute({
|
|
|
75
75
|
### Custom Write Routes
|
|
76
76
|
|
|
77
77
|
For non-CRUD write routes (`POST`/`PUT`/`PATCH`/`DELETE`), MUST wire mutation guards:
|
|
78
|
-
-
|
|
79
|
-
-
|
|
78
|
+
- Map the route to the closest registry operation (`create`, `update`, or `delete`; state-changing action endpoints usually use `update`)
|
|
79
|
+
- Collect registered guards with `getAllMutationGuardInstances()` and append `bridgeLegacyGuard(container)` when present
|
|
80
|
+
- Call `runMutationGuards(...)` from `@open-mercato/shared/lib/crud/mutation-guard-registry` before mutation, passing the caller's granted features as `{ userFeatures }`
|
|
81
|
+
- Return guard rejection bodies/statuses, merge `modifiedPayload` into validated input when present, and run returned `afterSuccessCallbacks` after successful mutation, catching/logging callback failures so committed writes still return successfully
|
|
80
82
|
|
|
81
83
|
## Module Setup (`setup.ts`)
|
|
82
84
|
|
|
@@ -0,0 +1,24 @@
|
|
|
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
|
+
const DECRYPT_CONCURRENCY = 8;
|
|
5
|
+
async function resolveEncryptedSortPage(params) {
|
|
6
|
+
const decrypted = await mapWithConcurrency(params.candidates, DECRYPT_CONCURRENCY, params.decryptRow);
|
|
7
|
+
const ordered = sortRowsInMemory(decrypted, [
|
|
8
|
+
{ field: params.sortField, dir: params.sortDir === "desc" ? SortDir.Desc : SortDir.Asc }
|
|
9
|
+
]);
|
|
10
|
+
let start = 0;
|
|
11
|
+
if (params.cursorId) {
|
|
12
|
+
const idx = ordered.findIndex((row) => row.id === params.cursorId);
|
|
13
|
+
start = idx >= 0 ? idx + 1 : 0;
|
|
14
|
+
}
|
|
15
|
+
const page = ordered.slice(start, start + params.limit);
|
|
16
|
+
return {
|
|
17
|
+
pageIds: page.map((row) => row.id),
|
|
18
|
+
hasMore: start + params.limit < ordered.length
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export {
|
|
22
|
+
resolveEncryptedSortPage
|
|
23
|
+
};
|
|
24
|
+
//# sourceMappingURL=encryptedSortPage.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../../src/modules/customers/api/interactions/encryptedSortPage.ts"],
|
|
4
|
+
"sourcesContent": ["import { mapWithConcurrency } from '@open-mercato/shared/lib/query/bounded-decrypt'\nimport { sortRowsInMemory } from '@open-mercato/shared/lib/query/encrypted-sort'\nimport { SortDir } from '@open-mercato/shared/lib/query/types'\n\nconst DECRYPT_CONCURRENCY = 8\n\nexport type EncryptedSortCandidate = { id: string } & Record<string, unknown>\n\n// Re-sorts the bounded candidate set by plaintext on every call and resumes\n// from the cursor's id position \u2014 SQL keyset comparison against ciphertext\n// is meaningless for ordering.\nexport async function resolveEncryptedSortPage<T extends EncryptedSortCandidate>(params: {\n candidates: readonly T[]\n decryptRow: (row: T) => Promise<T>\n sortField: string\n sortDir: 'asc' | 'desc'\n cursorId: string | null\n limit: number\n}): Promise<{ pageIds: string[]; hasMore: boolean }> {\n const decrypted = await mapWithConcurrency(params.candidates, DECRYPT_CONCURRENCY, params.decryptRow)\n const ordered = sortRowsInMemory(decrypted, [\n { field: params.sortField, dir: params.sortDir === 'desc' ? SortDir.Desc : SortDir.Asc },\n ])\n let start = 0\n if (params.cursorId) {\n const idx = ordered.findIndex((row) => row.id === params.cursorId)\n start = idx >= 0 ? idx + 1 : 0\n }\n const page = ordered.slice(start, start + params.limit)\n return {\n pageIds: page.map((row) => row.id),\n hasMore: start + params.limit < ordered.length,\n }\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,0BAA0B;AACnC,SAAS,wBAAwB;AACjC,SAAS,eAAe;AAExB,MAAM,sBAAsB;AAO5B,eAAsB,yBAA2D,QAO5B;AACnD,QAAM,YAAY,MAAM,mBAAmB,OAAO,YAAY,qBAAqB,OAAO,UAAU;AACpG,QAAM,UAAU,iBAAiB,WAAW;AAAA,IAC1C,EAAE,OAAO,OAAO,WAAW,KAAK,OAAO,YAAY,SAAS,QAAQ,OAAO,QAAQ,IAAI;AAAA,EACzF,CAAC;AACD,MAAI,QAAQ;AACZ,MAAI,OAAO,UAAU;AACnB,UAAM,MAAM,QAAQ,UAAU,CAAC,QAAQ,IAAI,OAAO,OAAO,QAAQ;AACjE,YAAQ,OAAO,IAAI,MAAM,IAAI;AAAA,EAC/B;AACA,QAAM,OAAO,QAAQ,MAAM,OAAO,QAAQ,OAAO,KAAK;AACtD,SAAO;AAAA,IACL,SAAS,KAAK,IAAI,CAAC,QAAQ,IAAI,EAAE;AAAA,IACjC,SAAS,QAAQ,OAAO,QAAQ,QAAQ;AAAA,EAC1C;AACF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -7,6 +7,8 @@ import { loadCustomFieldValues } from "@open-mercato/shared/lib/crud/custom-fiel
|
|
|
7
7
|
import { normalizeCustomFieldResponse } from "@open-mercato/shared/lib/custom-fields/normalize";
|
|
8
8
|
import { applyResponseEnrichers } from "@open-mercato/shared/lib/crud/enricher-runner";
|
|
9
9
|
import { findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
|
|
10
|
+
import { resolveTenantEncryptionService } from "@open-mercato/shared/lib/encryption/customFieldValues";
|
|
11
|
+
import { resolveEncryptedSortFields, resolveEncryptedSortMaxRows } from "@open-mercato/shared/lib/query/encrypted-sort";
|
|
10
12
|
import { escapeLikePattern } from "@open-mercato/shared/lib/db/escapeLikePattern";
|
|
11
13
|
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
12
14
|
import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
|
|
@@ -22,6 +24,7 @@ import {
|
|
|
22
24
|
} from "../openapi.js";
|
|
23
25
|
import { CUSTOMER_INTERACTION_ENTITY_ID } from "../../lib/interactionCompatibility.js";
|
|
24
26
|
import { applyEmailVisibilityFilter } from "../../lib/visibilityFilter.js";
|
|
27
|
+
import { resolveEncryptedSortPage } from "./encryptedSortPage.js";
|
|
25
28
|
import { resolveCanonicalActivityTargetId } from "../../lib/legacyActivityBridge.js";
|
|
26
29
|
const rawBodySchema = z.object({}).passthrough();
|
|
27
30
|
const interactionSortFieldSchema = z.enum([
|
|
@@ -200,6 +203,68 @@ function buildSortSql(sortField, sortDir) {
|
|
|
200
203
|
const sentinel = sortDir === "asc" ? "'~~~~~~~~~~'" : "''";
|
|
201
204
|
return `coalesce(${config.column}, ${sentinel})`;
|
|
202
205
|
}
|
|
206
|
+
const INTERACTION_LIST_COLUMNS = [
|
|
207
|
+
"id",
|
|
208
|
+
"entity_id",
|
|
209
|
+
"deal_id",
|
|
210
|
+
"interaction_type",
|
|
211
|
+
"title",
|
|
212
|
+
"body",
|
|
213
|
+
"status",
|
|
214
|
+
"scheduled_at",
|
|
215
|
+
"occurred_at",
|
|
216
|
+
"priority",
|
|
217
|
+
"author_user_id",
|
|
218
|
+
"owner_user_id",
|
|
219
|
+
"appearance_icon",
|
|
220
|
+
"appearance_color",
|
|
221
|
+
"source",
|
|
222
|
+
"duration_minutes",
|
|
223
|
+
"location",
|
|
224
|
+
"all_day",
|
|
225
|
+
"recurrence_rule",
|
|
226
|
+
"recurrence_end",
|
|
227
|
+
"participants",
|
|
228
|
+
"reminder_minutes",
|
|
229
|
+
"visibility",
|
|
230
|
+
"linked_entities",
|
|
231
|
+
"guest_permissions",
|
|
232
|
+
"pinned",
|
|
233
|
+
"organization_id",
|
|
234
|
+
"tenant_id",
|
|
235
|
+
"created_at",
|
|
236
|
+
"updated_at"
|
|
237
|
+
];
|
|
238
|
+
function applyInteractionListFilters(baseQuery, params) {
|
|
239
|
+
let q = baseQuery.where("deleted_at", "is", null).where("tenant_id", "=", params.tenantId);
|
|
240
|
+
if (params.organizationIds.length > 0) q = q.where("organization_id", "in", params.organizationIds);
|
|
241
|
+
const { query } = params;
|
|
242
|
+
if (query.entityId) q = q.where("entity_id", "=", query.entityId);
|
|
243
|
+
if (query.dealId) q = q.where("deal_id", "=", query.dealId);
|
|
244
|
+
if (query.status) q = q.where("status", "=", query.status);
|
|
245
|
+
if (query.interactionType) q = q.where("interaction_type", "=", query.interactionType);
|
|
246
|
+
if (query.type) {
|
|
247
|
+
const types = query.type.split(",").map((t) => t.trim()).filter(Boolean);
|
|
248
|
+
if (types.length > 0) q = q.where("interaction_type", "in", types);
|
|
249
|
+
}
|
|
250
|
+
if (query.pinned === "true") {
|
|
251
|
+
q = q.where("pinned", "=", true);
|
|
252
|
+
} else if (query.pinned === "false") {
|
|
253
|
+
q = q.where("pinned", "=", false);
|
|
254
|
+
}
|
|
255
|
+
if (query.excludeInteractionType) q = q.where("interaction_type", "!=", query.excludeInteractionType);
|
|
256
|
+
if (query.search) {
|
|
257
|
+
const searchTerm = `%${escapeLikePattern(query.search)}%`;
|
|
258
|
+
q = q.where(sql`coalesce(title, '') ilike ${searchTerm} or coalesce(body, '') ilike ${searchTerm}`);
|
|
259
|
+
}
|
|
260
|
+
if (query.from) {
|
|
261
|
+
q = q.where(sql`coalesce(occurred_at, scheduled_at, created_at) >= ${query.from}`);
|
|
262
|
+
}
|
|
263
|
+
if (query.to) {
|
|
264
|
+
q = q.where(sql`coalesce(occurred_at, scheduled_at, created_at) <= ${query.to}`);
|
|
265
|
+
}
|
|
266
|
+
return q;
|
|
267
|
+
}
|
|
203
268
|
async function resolveUserFeatures(container, userId, tenantId, organizationId) {
|
|
204
269
|
try {
|
|
205
270
|
const rbac = container.resolve("rbacService");
|
|
@@ -248,90 +313,103 @@ async function GET(req) {
|
|
|
248
313
|
error: translate("customers.interactions.cursor.invalid", "Invalid cursor")
|
|
249
314
|
});
|
|
250
315
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
"organization_id",
|
|
279
|
-
"tenant_id",
|
|
280
|
-
"created_at",
|
|
281
|
-
"updated_at",
|
|
282
|
-
sql`${sql.raw(sortSql)}`.as("__sort_value")
|
|
283
|
-
]).where("deleted_at", "is", null).where("tenant_id", "=", auth.tenantId).limit(query.limit + 1);
|
|
284
|
-
if (organizationIds.length > 0) {
|
|
285
|
-
rowsQuery = rowsQuery.where("organization_id", "in", organizationIds);
|
|
286
|
-
}
|
|
287
|
-
if (query.entityId) rowsQuery = rowsQuery.where("entity_id", "=", query.entityId);
|
|
288
|
-
if (query.dealId) rowsQuery = rowsQuery.where("deal_id", "=", query.dealId);
|
|
289
|
-
if (query.status) rowsQuery = rowsQuery.where("status", "=", query.status);
|
|
290
|
-
if (query.interactionType) rowsQuery = rowsQuery.where("interaction_type", "=", query.interactionType);
|
|
291
|
-
if (query.type) {
|
|
292
|
-
const types = query.type.split(",").map((t) => t.trim()).filter(Boolean);
|
|
293
|
-
if (types.length > 0) {
|
|
294
|
-
rowsQuery = rowsQuery.where("interaction_type", "in", types);
|
|
316
|
+
const viewerUserId = auth.isApiKey ? null : auth.sub ?? null;
|
|
317
|
+
const encryptionService = resolveTenantEncryptionService(em);
|
|
318
|
+
const [callerUserFeatures, encryptedSortFields] = await Promise.all([
|
|
319
|
+
viewerUserId ? resolveUserFeatures(container, viewerUserId, auth.tenantId ?? null, selectedOrganizationId) : Promise.resolve(void 0),
|
|
320
|
+
resolveEncryptedSortFields(
|
|
321
|
+
encryptionService,
|
|
322
|
+
CUSTOMER_INTERACTION_ENTITY_ID,
|
|
323
|
+
[sortConfig.column],
|
|
324
|
+
auth.tenantId,
|
|
325
|
+
selectedOrganizationId
|
|
326
|
+
)
|
|
327
|
+
]);
|
|
328
|
+
const sortFieldIsEncrypted = encryptedSortFields.has(sortConfig.column);
|
|
329
|
+
let pageRows;
|
|
330
|
+
let hasMore;
|
|
331
|
+
if (sortFieldIsEncrypted) {
|
|
332
|
+
let candidateQuery = applyInteractionListFilters(
|
|
333
|
+
db.selectFrom("customer_interactions").select(["id", sortConfig.column]),
|
|
334
|
+
{ tenantId: auth.tenantId, organizationIds, query }
|
|
335
|
+
);
|
|
336
|
+
candidateQuery = applyEmailVisibilityFilter(candidateQuery, {
|
|
337
|
+
currentUserId: viewerUserId,
|
|
338
|
+
userFeatures: callerUserFeatures
|
|
339
|
+
});
|
|
340
|
+
const cap = resolveEncryptedSortMaxRows();
|
|
341
|
+
if (cap !== null) {
|
|
342
|
+
candidateQuery = candidateQuery.limit(cap).orderBy("id", "asc");
|
|
295
343
|
}
|
|
344
|
+
const candidateRows = await candidateQuery.execute();
|
|
345
|
+
if (cap !== null && candidateRows.length >= cap) {
|
|
346
|
+
console.warn("[customers/interactions.GET] encrypted sort candidate scan hit OM_ENCRYPTED_SORT_MAX_ROWS cap; results may be incomplete", {
|
|
347
|
+
cap,
|
|
348
|
+
sortField: sortConfig.column,
|
|
349
|
+
tenantId: auth.tenantId
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
const decryptPayload = encryptionService?.decryptEntityPayload?.bind(encryptionService);
|
|
353
|
+
const { pageIds, hasMore: encryptedHasMore } = await resolveEncryptedSortPage({
|
|
354
|
+
candidates: candidateRows,
|
|
355
|
+
decryptRow: async (row) => {
|
|
356
|
+
if (!decryptPayload) return row;
|
|
357
|
+
try {
|
|
358
|
+
const decrypted = await decryptPayload(CUSTOMER_INTERACTION_ENTITY_ID, row, auth.tenantId, selectedOrganizationId);
|
|
359
|
+
return { ...row, ...decrypted };
|
|
360
|
+
} catch (err) {
|
|
361
|
+
console.error("[customers/interactions.GET] error decrypting sort candidate", err);
|
|
362
|
+
return row;
|
|
363
|
+
}
|
|
364
|
+
},
|
|
365
|
+
sortField: sortConfig.column,
|
|
366
|
+
sortDir,
|
|
367
|
+
cursorId: cursor?.id ?? null,
|
|
368
|
+
limit: query.limit
|
|
369
|
+
});
|
|
370
|
+
hasMore = encryptedHasMore;
|
|
371
|
+
if (pageIds.length === 0) {
|
|
372
|
+
pageRows = [];
|
|
373
|
+
} else {
|
|
374
|
+
let pageQuery = applyInteractionListFilters(
|
|
375
|
+
db.selectFrom("customer_interactions").select([...INTERACTION_LIST_COLUMNS, sql`${sql.raw(sortSql)}`.as("__sort_value")]),
|
|
376
|
+
{ tenantId: auth.tenantId, organizationIds, query }
|
|
377
|
+
);
|
|
378
|
+
pageQuery = applyEmailVisibilityFilter(pageQuery, {
|
|
379
|
+
currentUserId: viewerUserId,
|
|
380
|
+
userFeatures: callerUserFeatures
|
|
381
|
+
});
|
|
382
|
+
pageQuery = pageQuery.where("id", "in", pageIds);
|
|
383
|
+
const rawPageRows = await pageQuery.execute();
|
|
384
|
+
const byId = new Map(rawPageRows.map((row) => [row.id, row]));
|
|
385
|
+
pageRows = pageIds.map((id) => byId.get(id)).filter((row) => row != null);
|
|
386
|
+
}
|
|
387
|
+
} else {
|
|
388
|
+
let rowsQuery = applyInteractionListFilters(
|
|
389
|
+
db.selectFrom("customer_interactions").select([...INTERACTION_LIST_COLUMNS, sql`${sql.raw(sortSql)}`.as("__sort_value")]).limit(query.limit + 1),
|
|
390
|
+
{ tenantId: auth.tenantId, organizationIds, query }
|
|
391
|
+
);
|
|
392
|
+
if (cursor) {
|
|
393
|
+
const op = sortDir === "asc" ? ">" : "<";
|
|
394
|
+
const opRaw = sql.raw(op);
|
|
395
|
+
const sortRaw = sql.raw(sortSql);
|
|
396
|
+
rowsQuery = rowsQuery.where((eb) => eb.or([
|
|
397
|
+
sql`${sortRaw} ${opRaw} ${cursor.sortValue}`,
|
|
398
|
+
eb.and([
|
|
399
|
+
sql`${sortRaw} = ${cursor.sortValue}`,
|
|
400
|
+
eb("id", op, cursor.id)
|
|
401
|
+
])
|
|
402
|
+
]));
|
|
403
|
+
}
|
|
404
|
+
rowsQuery = applyEmailVisibilityFilter(rowsQuery, {
|
|
405
|
+
currentUserId: viewerUserId,
|
|
406
|
+
userFeatures: callerUserFeatures
|
|
407
|
+
});
|
|
408
|
+
rowsQuery = rowsQuery.orderBy(sql`${sql.raw(sortSql)} ${sql.raw(sortDir)}`).orderBy("id", sortDir);
|
|
409
|
+
const rows = await rowsQuery.execute();
|
|
410
|
+
pageRows = rows.slice(0, query.limit);
|
|
411
|
+
hasMore = rows.length > query.limit;
|
|
296
412
|
}
|
|
297
|
-
if (query.pinned === "true") {
|
|
298
|
-
rowsQuery = rowsQuery.where("pinned", "=", true);
|
|
299
|
-
} else if (query.pinned === "false") {
|
|
300
|
-
rowsQuery = rowsQuery.where("pinned", "=", false);
|
|
301
|
-
}
|
|
302
|
-
if (query.excludeInteractionType) rowsQuery = rowsQuery.where("interaction_type", "!=", query.excludeInteractionType);
|
|
303
|
-
if (query.search) {
|
|
304
|
-
const searchTerm = `%${escapeLikePattern(query.search)}%`;
|
|
305
|
-
rowsQuery = rowsQuery.where(sql`coalesce(title, '') ilike ${searchTerm} or coalesce(body, '') ilike ${searchTerm}`);
|
|
306
|
-
}
|
|
307
|
-
if (query.from) {
|
|
308
|
-
rowsQuery = rowsQuery.where(sql`coalesce(occurred_at, scheduled_at, created_at) >= ${query.from}`);
|
|
309
|
-
}
|
|
310
|
-
if (query.to) {
|
|
311
|
-
rowsQuery = rowsQuery.where(sql`coalesce(occurred_at, scheduled_at, created_at) <= ${query.to}`);
|
|
312
|
-
}
|
|
313
|
-
if (cursor) {
|
|
314
|
-
const op = sortDir === "asc" ? ">" : "<";
|
|
315
|
-
const opRaw = sql.raw(op);
|
|
316
|
-
const sortRaw = sql.raw(sortSql);
|
|
317
|
-
rowsQuery = rowsQuery.where((eb) => eb.or([
|
|
318
|
-
sql`${sortRaw} ${opRaw} ${cursor.sortValue}`,
|
|
319
|
-
eb.and([
|
|
320
|
-
sql`${sortRaw} = ${cursor.sortValue}`,
|
|
321
|
-
eb("id", op, cursor.id)
|
|
322
|
-
])
|
|
323
|
-
]));
|
|
324
|
-
}
|
|
325
|
-
const viewerUserId = auth.isApiKey ? null : auth.sub ?? null;
|
|
326
|
-
const callerUserFeatures = viewerUserId ? await resolveUserFeatures(container, viewerUserId, auth.tenantId ?? null, selectedOrganizationId) : void 0;
|
|
327
|
-
rowsQuery = applyEmailVisibilityFilter(rowsQuery, {
|
|
328
|
-
currentUserId: viewerUserId,
|
|
329
|
-
userFeatures: callerUserFeatures
|
|
330
|
-
});
|
|
331
|
-
rowsQuery = rowsQuery.orderBy(sql`${sql.raw(sortSql)} ${sql.raw(sortDir)}`).orderBy("id", sortDir);
|
|
332
|
-
const rows = await rowsQuery.execute();
|
|
333
|
-
const pageRows = rows.slice(0, query.limit);
|
|
334
|
-
const hasMore = rows.length > query.limit;
|
|
335
413
|
const authorIds = Array.from(
|
|
336
414
|
new Set(
|
|
337
415
|
pageRows.map((row) => typeof row.author_user_id === "string" ? row.author_user_id : null).filter((value) => !!value)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/customers/api/interactions/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { sql } from 'kysely'\nimport { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'\nimport { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { loadCustomFieldValues } from '@open-mercato/shared/lib/crud/custom-fields'\nimport { normalizeCustomFieldResponse } from '@open-mercato/shared/lib/custom-fields/normalize'\nimport { applyResponseEnrichers } from '@open-mercato/shared/lib/crud/enricher-runner'\nimport type { EnricherContext } from '@open-mercato/shared/lib/crud/response-enricher'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { CustomerDeal, CustomerInteraction } from '../../data/entities'\nimport { User } from '@open-mercato/core/modules/auth/data/entities'\nimport { interactionCreateSchema, interactionUpdateSchema } from '../../data/validators'\nimport { parseScopedCommandInput } from '../utils'\nimport {\n createCustomersCrudOpenApi,\n defaultOkResponseSchema,\n} from '../openapi'\nimport { CUSTOMER_INTERACTION_ENTITY_ID } from '../../lib/interactionCompatibility'\nimport { applyEmailVisibilityFilter } from '../../lib/visibilityFilter'\nimport { resolveCanonicalActivityTargetId } from '../../lib/legacyActivityBridge'\nimport type { CommandBus, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\n\nconst rawBodySchema = z.object({}).passthrough()\n\nconst interactionSortFieldSchema = z.enum([\n 'scheduledAt',\n 'occurredAt',\n 'createdAt',\n 'updatedAt',\n 'status',\n 'priority',\n 'interactionType',\n 'title',\n])\n\nexport const listSchema = z\n .object({\n limit: z.coerce.number().min(1).max(100).default(25),\n cursor: z.string().optional(),\n entityId: z.string().uuid().optional(),\n dealId: z.string().uuid().optional(),\n status: z.string().optional(),\n interactionType: z.string().optional(),\n type: z.string().optional(),\n excludeInteractionType: z.string().optional(),\n search: z.string().trim().min(1).optional(),\n from: z.coerce.date().optional(),\n to: z.coerce.date().optional(),\n pinned: z.enum(['true', 'false']).optional(),\n sortField: interactionSortFieldSchema.optional(),\n sortDir: z.enum(['asc', 'desc']).optional(),\n })\n .passthrough()\n\nconst routeMetadata = {\n GET: { requireAuth: true, requireFeatures: ['customers.interactions.view'] },\n POST: { requireAuth: true, requireFeatures: ['customers.interactions.manage'] },\n PUT: { requireAuth: true, requireFeatures: ['customers.interactions.manage'] },\n DELETE: { requireAuth: true, requireFeatures: ['customers.interactions.manage'] },\n}\n\nexport const metadata = routeMetadata\n\nconst crud = makeCrudRoute({\n metadata: routeMetadata,\n orm: {\n entity: CustomerInteraction,\n idField: 'id',\n orgField: 'organizationId',\n tenantField: 'tenantId',\n softDeleteField: 'deletedAt',\n },\n enrichers: { entityId: 'customers.interaction' },\n indexer: {\n entityType: 'customers:customer_interaction',\n },\n actions: {\n create: {\n commandId: 'customers.interactions.create',\n schema: rawBodySchema,\n mapInput: async ({ raw, ctx }) => {\n const { translate } = await resolveTranslations()\n return parseScopedCommandInput(interactionCreateSchema, raw ?? {}, ctx, translate)\n },\n response: ({ result }) => ({ id: result?.interactionId ?? result?.id ?? null }),\n status: 201,\n },\n update: {\n commandId: 'customers.interactions.update',\n schema: rawBodySchema,\n mapInput: async ({ raw, ctx }) => {\n const { translate } = await resolveTranslations()\n const parsed = parseScopedCommandInput(interactionUpdateSchema, raw ?? {}, ctx, translate)\n // Bridge legacy `customer_activities` rows into `customer_interactions`\n // before the canonical update runs so historical activities (#1807)\n // remain editable through the new dialog. No-op when the canonical\n // record already exists.\n const tenantId = ctx.auth?.tenantId ?? null\n if (typeof parsed.id === 'string' && tenantId) {\n try {\n const em = ctx.container.resolve('em') as EntityManager\n const commandBus = ctx.container.resolve('commandBus') as CommandBus\n const commandContext: CommandRuntimeContext = {\n container: ctx.container,\n auth: ctx.auth ?? null,\n organizationScope: ctx.organizationScope ?? null,\n selectedOrganizationId: ctx.selectedOrganizationId ?? null,\n organizationIds: ctx.organizationIds ?? null,\n request: ctx.request,\n }\n await resolveCanonicalActivityTargetId(em, commandBus, commandContext, parsed.id, tenantId)\n } catch (err) {\n // Bridging is best-effort; downstream lookup will surface a 404\n // when neither canonical nor legacy rows exist.\n console.warn('[customers.interactions.put] legacy bridge failed', { id: parsed.id, error: err })\n }\n }\n return parsed\n },\n response: () => ({ ok: true }),\n },\n delete: {\n commandId: 'customers.interactions.delete',\n schema: rawBodySchema,\n mapInput: async ({ parsed, ctx }) => {\n const { translate } = await resolveTranslations()\n const id =\n parsed?.body?.id ??\n parsed?.id ??\n parsed?.query?.id ??\n (ctx.request ? new URL(ctx.request.url).searchParams.get('id') : null)\n if (!id) {\n throw new CrudHttpError(400, {\n error: translate('customers.errors.interaction_required', 'Interaction id is required'),\n })\n }\n return { id }\n },\n response: () => ({ ok: true }),\n },\n },\n})\n\nconst { POST, PUT, DELETE } = crud\n\nexport { POST, PUT, DELETE }\n\ntype InteractionListRow = {\n id: string\n entity_id: string\n deal_id: string | null\n interaction_type: string\n title: string | null\n body: string | null\n status: string\n scheduled_at: Date | null\n occurred_at: Date | null\n priority: number | null\n author_user_id: string | null\n owner_user_id: string | null\n appearance_icon: string | null\n appearance_color: string | null\n source: string | null\n duration_minutes: number | null\n location: string | null\n all_day: boolean | null\n recurrence_rule: string | null\n recurrence_end: Date | null\n participants: Array<{ userId: string; name?: string; email?: string; status?: string }> | null\n reminder_minutes: number | null\n visibility: string | null\n linked_entities: Array<{ id: string; type: string; label: string }> | null\n guest_permissions: { canInviteOthers?: boolean; canModify?: boolean; canSeeList?: boolean } | null\n pinned: boolean\n organization_id: string\n tenant_id: string\n created_at: Date\n updated_at: Date\n __sort_value: string | number | Date | null\n}\n\ntype CursorPayload = {\n id: string\n sortValue: string | number | null\n}\n\ntype RbacServiceLike = {\n getGrantedFeatures?: (userId: string, input: { tenantId: string | null; organizationId: string | null }) => Promise<string[]>\n}\n\nconst cursorSchema = z.object({\n id: z.string().uuid(),\n sortValue: z.union([z.string(), z.number(), z.null()]),\n})\n\nconst interactionSortConfig = {\n scheduledAt: { column: 'scheduled_at', type: 'date' as const, defaultDir: 'asc' as const },\n occurredAt: { column: 'occurred_at', type: 'date' as const, defaultDir: 'desc' as const },\n createdAt: { column: 'created_at', type: 'date' as const, defaultDir: 'desc' as const },\n updatedAt: { column: 'updated_at', type: 'date' as const, defaultDir: 'desc' as const },\n status: { column: 'status', type: 'text' as const, defaultDir: 'asc' as const },\n priority: { column: 'priority', type: 'number' as const, defaultDir: 'desc' as const },\n interactionType: { column: 'interaction_type', type: 'text' as const, defaultDir: 'asc' as const },\n title: { column: 'title', type: 'text' as const, defaultDir: 'asc' as const },\n} as const\n\nfunction toIsoString(value: unknown): string | null {\n if (value == null) return null\n if (value instanceof Date) {\n return Number.isNaN(value.getTime()) ? null : value.toISOString()\n }\n if (typeof value === 'string') {\n const trimmed = value.trim()\n if (!trimmed.length) return null\n const parsed = new Date(trimmed)\n return Number.isNaN(parsed.getTime()) ? trimmed : parsed.toISOString()\n }\n return null\n}\n\nfunction normalizeCursorValue(\n value: string | number | Date | null,\n type: 'date' | 'number' | 'text',\n): string | number | null {\n if (value == null) return null\n if (type === 'number') {\n if (typeof value === 'number' && Number.isFinite(value)) return value\n if (typeof value === 'string') {\n const parsed = Number(value)\n return Number.isNaN(parsed) ? null : parsed\n }\n return null\n }\n if (type === 'date') {\n return toIsoString(value)\n }\n if (typeof value === 'string') return value\n if (value instanceof Date) return value.toISOString()\n return String(value)\n}\n\nfunction encodeCursor(payload: CursorPayload): string {\n return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64')\n}\n\nfunction decodeCursor(token: string | undefined, type: 'date' | 'number' | 'text'): CursorPayload | null {\n if (!token) return null\n try {\n const decoded = Buffer.from(token, 'base64').toString('utf8')\n const parsed = cursorSchema.parse(JSON.parse(decoded))\n return {\n id: parsed.id,\n sortValue: normalizeCursorValue(parsed.sortValue, type),\n }\n } catch {\n return null\n }\n}\n\nfunction buildSortSql(\n sortField: keyof typeof interactionSortConfig,\n sortDir: 'asc' | 'desc',\n): string {\n const config = interactionSortConfig[sortField]\n if (config.type === 'date') {\n const sentinel =\n sortDir === 'asc'\n ? \"timestamp with time zone '9999-12-31T23:59:59.999Z'\"\n : \"timestamp with time zone '0001-01-01T00:00:00.000Z'\"\n return `coalesce(${config.column}, ${sentinel})`\n }\n if (config.type === 'number') {\n const sentinel = sortDir === 'asc' ? '2147483647' : '-2147483648'\n return `coalesce(${config.column}, ${sentinel})`\n }\n const sentinel = sortDir === 'asc' ? \"'~~~~~~~~~~'\" : \"''\"\n return `coalesce(${config.column}, ${sentinel})`\n}\n\nasync function resolveUserFeatures(\n container: { resolve: (name: string) => unknown },\n userId: string,\n tenantId: string | null,\n organizationId: string | null,\n): Promise<string[] | undefined> {\n try {\n const rbac = container.resolve('rbacService') as RbacServiceLike | undefined\n if (!rbac?.getGrantedFeatures) return undefined\n return await rbac.getGrantedFeatures(userId, { tenantId, organizationId })\n } catch {\n return undefined\n }\n}\n\nasync function buildEnricherContext(\n container: { resolve: (name: string) => unknown },\n auth: NonNullable<Awaited<ReturnType<typeof getAuthFromRequest>>>,\n organizationId: string | null,\n precomputedUserFeatures?: { userId: string; features: string[] | undefined },\n): Promise<EnricherContext> {\n const userId =\n (typeof auth.sub === 'string' && auth.sub.trim().length > 0\n ? auth.sub\n : typeof auth.userId === 'string' && auth.userId.trim().length > 0\n ? auth.userId\n : typeof auth.keyId === 'string' && auth.keyId.trim().length > 0\n ? auth.keyId\n : 'system')\n\n // Reuse features already resolved for this same user (the GET handler resolves\n // them once for the visibility filter) to avoid a second RBAC lookup per request.\n const userFeatures =\n precomputedUserFeatures && precomputedUserFeatures.userId === userId\n ? precomputedUserFeatures.features\n : await resolveUserFeatures(container, userId, auth.tenantId ?? null, organizationId)\n\n return {\n organizationId: organizationId ?? '',\n tenantId: auth.tenantId ?? '',\n userId,\n em: container.resolve('em'),\n container,\n userFeatures,\n }\n}\n\nexport async function GET(req: Request) {\n try {\n const queryUrl = new URL(req.url)\n const query = listSchema.parse(Object.fromEntries(queryUrl.searchParams))\n const container = await createRequestContainer()\n const auth = await getAuthFromRequest(req)\n const { translate } = await resolveTranslations()\n\n if (!auth || !auth.tenantId) {\n throw new CrudHttpError(401, {\n error: translate('customers.errors.unauthorized', 'Unauthorized'),\n })\n }\n\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n const organizationIds = Array.isArray(scope?.filterIds) && scope.filterIds.length > 0\n ? scope.filterIds\n : auth.orgId\n ? [auth.orgId]\n : []\n const selectedOrganizationId = scope?.selectedId ?? auth.orgId ?? organizationIds[0] ?? null\n const em = (container.resolve('em') as EntityManager).fork()\n const db = em.getKysely<any>() as any\n\n const requestedSortField = query.sortField ?? 'scheduledAt'\n const sortConfig = interactionSortConfig[requestedSortField]\n const sortDir = query.sortDir ?? sortConfig.defaultDir\n const sortSql = buildSortSql(requestedSortField, sortDir)\n const cursor = decodeCursor(query.cursor, sortConfig.type)\n if (query.cursor && !cursor) {\n throw new CrudHttpError(400, {\n error: translate('customers.interactions.cursor.invalid', 'Invalid cursor'),\n })\n }\n\n let rowsQuery = db\n .selectFrom('customer_interactions')\n .select([\n 'id',\n 'entity_id',\n 'deal_id',\n 'interaction_type',\n 'title',\n 'body',\n 'status',\n 'scheduled_at',\n 'occurred_at',\n 'priority',\n 'author_user_id',\n 'owner_user_id',\n 'appearance_icon',\n 'appearance_color',\n 'source',\n 'duration_minutes',\n 'location',\n 'all_day',\n 'recurrence_rule',\n 'recurrence_end',\n 'participants',\n 'reminder_minutes',\n 'visibility',\n 'linked_entities',\n 'guest_permissions',\n 'pinned',\n 'organization_id',\n 'tenant_id',\n 'created_at',\n 'updated_at',\n sql`${sql.raw(sortSql)}`.as('__sort_value'),\n ])\n .where('deleted_at', 'is', null)\n .where('tenant_id', '=', auth.tenantId)\n .limit(query.limit + 1)\n\n if (organizationIds.length > 0) {\n rowsQuery = rowsQuery.where('organization_id', 'in', organizationIds)\n }\n if (query.entityId) rowsQuery = rowsQuery.where('entity_id', '=', query.entityId)\n if (query.dealId) rowsQuery = rowsQuery.where('deal_id', '=', query.dealId)\n if (query.status) rowsQuery = rowsQuery.where('status', '=', query.status)\n if (query.interactionType) rowsQuery = rowsQuery.where('interaction_type', '=', query.interactionType)\n if (query.type) {\n const types = query.type.split(',').map((t) => t.trim()).filter(Boolean)\n if (types.length > 0) {\n rowsQuery = rowsQuery.where('interaction_type', 'in', types)\n }\n }\n if (query.pinned === 'true') {\n rowsQuery = rowsQuery.where('pinned', '=', true)\n } else if (query.pinned === 'false') {\n rowsQuery = rowsQuery.where('pinned', '=', false)\n }\n if (query.excludeInteractionType) rowsQuery = rowsQuery.where('interaction_type', '!=', query.excludeInteractionType)\n if (query.search) {\n // NOTE: for tenants with data encryption enabled, `title`/`body` are\n // ciphertext at rest (see encryption.ts), so this ILIKE matches encrypted\n // bytes and returns no rows \u2014 substring search over encrypted free-text\n // columns is unsupported, the same documented limitation as\n // customer_activity / customer_comment. The returned page's title/body are\n // still decrypted for display further below.\n const searchTerm = `%${escapeLikePattern(query.search)}%`\n rowsQuery = rowsQuery.where(sql<boolean>`coalesce(title, '') ilike ${searchTerm} or coalesce(body, '') ilike ${searchTerm}`)\n }\n if (query.from) {\n rowsQuery = rowsQuery.where(sql<boolean>`coalesce(occurred_at, scheduled_at, created_at) >= ${query.from}`)\n }\n if (query.to) {\n rowsQuery = rowsQuery.where(sql<boolean>`coalesce(occurred_at, scheduled_at, created_at) <= ${query.to}`)\n }\n\n if (cursor) {\n const op = sortDir === 'asc' ? '>' : '<'\n const opRaw = sql.raw(op)\n const sortRaw = sql.raw(sortSql)\n rowsQuery = rowsQuery.where((eb: any) => eb.or([\n sql<boolean>`${sortRaw} ${opRaw} ${cursor.sortValue}`,\n eb.and([\n sql<boolean>`${sortRaw} = ${cursor.sortValue}`,\n eb('id', op, cursor.id),\n ]),\n ]))\n }\n\n // \u2500\u2500 Email visibility filter (2026-05-27) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Non-email interactions pass through; email rows with visibility='private'\n // are filtered out unless the caller is the author or has admin bypass.\n // API-key callers have no user identity (`auth.sub` undefined): resolve the\n // viewer to null so they never gain the author bypass and only see shared\n // emails (fail-closed). Mirrors counts/people/activities routes.\n const viewerUserId = auth.isApiKey ? null : (auth.sub ?? null)\n const callerUserFeatures = viewerUserId\n ? await resolveUserFeatures(container, viewerUserId, auth.tenantId ?? null, selectedOrganizationId)\n : undefined\n rowsQuery = applyEmailVisibilityFilter(rowsQuery as any, {\n currentUserId: viewerUserId,\n userFeatures: callerUserFeatures,\n })\n\n rowsQuery = rowsQuery.orderBy(sql`${sql.raw(sortSql)} ${sql.raw(sortDir)}`).orderBy('id', sortDir)\n\n const rows = await rowsQuery.execute() as InteractionListRow[]\n const pageRows = rows.slice(0, query.limit)\n const hasMore = rows.length > query.limit\n\n const authorIds = Array.from(\n new Set(\n pageRows\n .map((row) => (typeof row.author_user_id === 'string' ? row.author_user_id : null))\n .filter((value): value is string => !!value),\n ),\n )\n const dealIds = Array.from(\n new Set(\n pageRows\n .map((row) => (typeof row.deal_id === 'string' ? row.deal_id : null))\n .filter((value): value is string => !!value),\n ),\n )\n const interactionIds = pageRows.map((row) => row.id)\n\n const [users, deals, customFieldValues, interactionRecords] = await Promise.all([\n authorIds.length > 0 ? findWithDecryption(em, User, { id: { $in: authorIds } }, undefined, { tenantId: auth.tenantId, organizationId: selectedOrganizationId }) : Promise.resolve([]),\n dealIds.length > 0 ? findWithDecryption(em, CustomerDeal, { id: { $in: dealIds } }, undefined, { tenantId: auth.tenantId, organizationId: selectedOrganizationId }) : Promise.resolve([]),\n interactionIds.length > 0\n ? loadCustomFieldValues({\n em,\n entityId: CUSTOMER_INTERACTION_ENTITY_ID,\n recordIds: interactionIds,\n tenantIdByRecord: Object.fromEntries(pageRows.map((row) => [row.id, row.tenant_id])),\n organizationIdByRecord: Object.fromEntries(pageRows.map((row) => [row.id, row.organization_id])),\n tenantFallbacks: [auth.tenantId].filter((value): value is string => !!value),\n })\n : Promise.resolve<Record<string, Record<string, unknown>>>({}),\n interactionIds.length > 0\n ? findWithDecryption(em, CustomerInteraction, { id: { $in: interactionIds } } as never, undefined, { tenantId: auth.tenantId, organizationId: selectedOrganizationId })\n : Promise.resolve([]),\n ])\n\n const userMap = new Map(\n users.map((user) => [\n user.id,\n {\n name: user.name ?? null,\n email: user.email ?? null,\n },\n ]),\n )\n const dealMap = new Map(\n deals.map((deal) => [deal.id, deal.title]),\n )\n // title/body are encrypted at rest (see encryption.ts). The kysely rows above\n // carry ciphertext when tenant encryption is enabled, so override them with the\n // decrypted values from findWithDecryption for the returned page.\n const interactionContentMap = new Map(\n (interactionRecords as Array<{ id: string; title?: string | null; body?: string | null }>).map(\n (record) => [record.id, { title: record.title ?? null, body: record.body ?? null }],\n ),\n )\n\n const baseItems = pageRows.map((row) => ({\n id: row.id,\n entityId: row.entity_id,\n dealId: row.deal_id ?? null,\n interactionType: row.interaction_type,\n title: (interactionContentMap.has(row.id) ? interactionContentMap.get(row.id)!.title : row.title) ?? null,\n body: (interactionContentMap.has(row.id) ? interactionContentMap.get(row.id)!.body : row.body) ?? null,\n status: row.status,\n scheduledAt: toIsoString(row.scheduled_at),\n occurredAt: toIsoString(row.occurred_at),\n priority: row.priority ?? null,\n authorUserId: row.author_user_id ?? null,\n ownerUserId: row.owner_user_id ?? null,\n appearanceIcon: row.appearance_icon ?? null,\n appearanceColor: row.appearance_color ?? null,\n source: row.source ?? null,\n duration: row.duration_minutes ?? null,\n durationMinutes: row.duration_minutes ?? null,\n location: row.location ?? null,\n allDay: row.all_day ?? null,\n recurrenceRule: row.recurrence_rule ?? null,\n recurrenceEnd: toIsoString(row.recurrence_end),\n participants: row.participants ?? null,\n reminderMinutes: row.reminder_minutes ?? null,\n visibility: row.visibility ?? null,\n linkedEntities: row.linked_entities ?? null,\n guestPermissions: row.guest_permissions ?? null,\n pinned: row.pinned ?? false,\n organizationId: row.organization_id,\n tenantId: row.tenant_id,\n createdAt: toIsoString(row.created_at) ?? new Date().toISOString(),\n updatedAt: toIsoString(row.updated_at) ?? new Date().toISOString(),\n authorName: row.author_user_id ? userMap.get(row.author_user_id)?.name ?? null : null,\n authorEmail: row.author_user_id ? userMap.get(row.author_user_id)?.email ?? null : null,\n dealTitle: row.deal_id ? dealMap.get(row.deal_id) ?? null : null,\n customValues: normalizeCustomFieldResponse(customFieldValues[row.id]) ?? null,\n }))\n\n const enricherContext = await buildEnricherContext(\n container,\n auth,\n selectedOrganizationId,\n viewerUserId ? { userId: viewerUserId, features: callerUserFeatures } : undefined,\n )\n const enriched = await applyResponseEnrichers(baseItems, 'customers.interaction', enricherContext)\n\n let nextCursor: string | undefined\n if (hasMore && pageRows.length > 0) {\n const last = pageRows[pageRows.length - 1]\n nextCursor = encodeCursor({\n id: last.id,\n sortValue: normalizeCursorValue(last.__sort_value, sortConfig.type),\n })\n }\n\n return NextResponse.json({\n items: enriched.items,\n nextCursor,\n })\n } catch (err) {\n if (isCrudHttpError(err)) {\n return NextResponse.json(err.body, { status: err.status })\n }\n if (err instanceof z.ZodError) {\n return NextResponse.json(\n { error: 'Validation failed', details: err.issues },\n { status: 400 },\n )\n }\n console.error('customers.interactions.get failed', err)\n const { translate } = await resolveTranslations()\n return NextResponse.json(\n { error: translate('customers.interactions.load.error', 'Failed to load interactions.') },\n { status: 500 },\n )\n }\n}\n\nconst interactionListItemSchema = z\n .object({\n id: z.string().uuid(),\n entityId: z.string().uuid().nullable(),\n dealId: z.string().uuid().nullable(),\n interactionType: z.string(),\n title: z.string().nullable(),\n body: z.string().nullable(),\n status: z.string(),\n scheduledAt: z.string().nullable(),\n occurredAt: z.string().nullable(),\n priority: z.number().nullable(),\n authorUserId: z.string().uuid().nullable(),\n ownerUserId: z.string().uuid().nullable(),\n appearanceIcon: z.string().nullable().optional(),\n appearanceColor: z.string().nullable().optional(),\n source: z.string().nullable().optional(),\n duration: z.number().nullable().optional(),\n durationMinutes: z.number().nullable().optional(),\n location: z.string().nullable().optional(),\n allDay: z.boolean().nullable().optional(),\n recurrenceRule: z.string().nullable().optional(),\n recurrenceEnd: z.string().nullable().optional(),\n participants: z.array(\n z.object({\n userId: z.string().uuid(),\n name: z.string().optional(),\n email: z.string().optional(),\n status: z.string().optional(),\n }),\n ).nullable().optional(),\n reminderMinutes: z.number().nullable().optional(),\n visibility: z.string().nullable().optional(),\n linkedEntities: z.array(\n z.object({\n id: z.string().uuid(),\n type: z.string(),\n label: z.string(),\n }),\n ).nullable().optional(),\n guestPermissions: z\n .object({\n canInviteOthers: z.boolean().optional(),\n canModify: z.boolean().optional(),\n canSeeList: z.boolean().optional(),\n })\n .nullable()\n .optional(),\n organizationId: z.string().uuid().nullable().optional(),\n tenantId: z.string().uuid().nullable().optional(),\n createdAt: z.string().nullable(),\n updatedAt: z.string().nullable(),\n authorName: z.string().nullable().optional(),\n authorEmail: z.string().nullable().optional(),\n dealTitle: z.string().nullable().optional(),\n customValues: z.record(z.string(), z.unknown()).nullable().optional(),\n _integrations: z.record(z.string(), z.unknown()).optional(),\n })\n .passthrough()\n\nconst interactionListResponseSchema = z.object({\n items: z.array(interactionListItemSchema),\n nextCursor: z.string().optional(),\n})\n\nconst interactionCreateResponseSchema = z.object({\n id: z.string().uuid().nullable(),\n})\n\nexport const openApi = createCustomersCrudOpenApi({\n resourceName: 'Interaction',\n querySchema: listSchema,\n listResponseSchema: interactionListResponseSchema,\n create: {\n schema: interactionCreateSchema,\n responseSchema: interactionCreateResponseSchema,\n description: 'Creates a new interaction linked to a customer entity or deal.',\n },\n update: {\n schema: interactionUpdateSchema,\n responseSchema: defaultOkResponseSchema,\n description: 'Updates fields for an existing interaction.',\n },\n del: {\n schema: z.object({ id: z.string().uuid() }),\n responseSchema: defaultOkResponseSchema,\n description: 'Soft-deletes an interaction identified by `id`. Accepts id via body or query string.',\n },\n})\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { sql } from 'kysely'\nimport { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'\nimport { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { loadCustomFieldValues } from '@open-mercato/shared/lib/crud/custom-fields'\nimport { normalizeCustomFieldResponse } from '@open-mercato/shared/lib/custom-fields/normalize'\nimport { applyResponseEnrichers } from '@open-mercato/shared/lib/crud/enricher-runner'\nimport type { EnricherContext } from '@open-mercato/shared/lib/crud/response-enricher'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { resolveTenantEncryptionService } from '@open-mercato/shared/lib/encryption/customFieldValues'\nimport { resolveEncryptedSortFields, resolveEncryptedSortMaxRows } from '@open-mercato/shared/lib/query/encrypted-sort'\nimport { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { CustomerDeal, CustomerInteraction } from '../../data/entities'\nimport { User } from '@open-mercato/core/modules/auth/data/entities'\nimport { interactionCreateSchema, interactionUpdateSchema } from '../../data/validators'\nimport { parseScopedCommandInput } from '../utils'\nimport {\n createCustomersCrudOpenApi,\n defaultOkResponseSchema,\n} from '../openapi'\nimport { CUSTOMER_INTERACTION_ENTITY_ID } from '../../lib/interactionCompatibility'\nimport { applyEmailVisibilityFilter } from '../../lib/visibilityFilter'\nimport { resolveEncryptedSortPage } from './encryptedSortPage'\nimport { resolveCanonicalActivityTargetId } from '../../lib/legacyActivityBridge'\nimport type { CommandBus, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\n\nconst rawBodySchema = z.object({}).passthrough()\n\nconst interactionSortFieldSchema = z.enum([\n 'scheduledAt',\n 'occurredAt',\n 'createdAt',\n 'updatedAt',\n 'status',\n 'priority',\n 'interactionType',\n 'title',\n])\n\nexport const listSchema = z\n .object({\n limit: z.coerce.number().min(1).max(100).default(25),\n cursor: z.string().optional(),\n entityId: z.string().uuid().optional(),\n dealId: z.string().uuid().optional(),\n status: z.string().optional(),\n interactionType: z.string().optional(),\n type: z.string().optional(),\n excludeInteractionType: z.string().optional(),\n search: z.string().trim().min(1).optional(),\n from: z.coerce.date().optional(),\n to: z.coerce.date().optional(),\n pinned: z.enum(['true', 'false']).optional(),\n sortField: interactionSortFieldSchema.optional(),\n sortDir: z.enum(['asc', 'desc']).optional(),\n })\n .passthrough()\n\nconst routeMetadata = {\n GET: { requireAuth: true, requireFeatures: ['customers.interactions.view'] },\n POST: { requireAuth: true, requireFeatures: ['customers.interactions.manage'] },\n PUT: { requireAuth: true, requireFeatures: ['customers.interactions.manage'] },\n DELETE: { requireAuth: true, requireFeatures: ['customers.interactions.manage'] },\n}\n\nexport const metadata = routeMetadata\n\nconst crud = makeCrudRoute({\n metadata: routeMetadata,\n orm: {\n entity: CustomerInteraction,\n idField: 'id',\n orgField: 'organizationId',\n tenantField: 'tenantId',\n softDeleteField: 'deletedAt',\n },\n enrichers: { entityId: 'customers.interaction' },\n indexer: {\n entityType: 'customers:customer_interaction',\n },\n actions: {\n create: {\n commandId: 'customers.interactions.create',\n schema: rawBodySchema,\n mapInput: async ({ raw, ctx }) => {\n const { translate } = await resolveTranslations()\n return parseScopedCommandInput(interactionCreateSchema, raw ?? {}, ctx, translate)\n },\n response: ({ result }) => ({ id: result?.interactionId ?? result?.id ?? null }),\n status: 201,\n },\n update: {\n commandId: 'customers.interactions.update',\n schema: rawBodySchema,\n mapInput: async ({ raw, ctx }) => {\n const { translate } = await resolveTranslations()\n const parsed = parseScopedCommandInput(interactionUpdateSchema, raw ?? {}, ctx, translate)\n // Bridge legacy `customer_activities` rows into `customer_interactions`\n // before the canonical update runs so historical activities (#1807)\n // remain editable through the new dialog. No-op when the canonical\n // record already exists.\n const tenantId = ctx.auth?.tenantId ?? null\n if (typeof parsed.id === 'string' && tenantId) {\n try {\n const em = ctx.container.resolve('em') as EntityManager\n const commandBus = ctx.container.resolve('commandBus') as CommandBus\n const commandContext: CommandRuntimeContext = {\n container: ctx.container,\n auth: ctx.auth ?? null,\n organizationScope: ctx.organizationScope ?? null,\n selectedOrganizationId: ctx.selectedOrganizationId ?? null,\n organizationIds: ctx.organizationIds ?? null,\n request: ctx.request,\n }\n await resolveCanonicalActivityTargetId(em, commandBus, commandContext, parsed.id, tenantId)\n } catch (err) {\n // Bridging is best-effort; downstream lookup will surface a 404\n // when neither canonical nor legacy rows exist.\n console.warn('[customers.interactions.put] legacy bridge failed', { id: parsed.id, error: err })\n }\n }\n return parsed\n },\n response: () => ({ ok: true }),\n },\n delete: {\n commandId: 'customers.interactions.delete',\n schema: rawBodySchema,\n mapInput: async ({ parsed, ctx }) => {\n const { translate } = await resolveTranslations()\n const id =\n parsed?.body?.id ??\n parsed?.id ??\n parsed?.query?.id ??\n (ctx.request ? new URL(ctx.request.url).searchParams.get('id') : null)\n if (!id) {\n throw new CrudHttpError(400, {\n error: translate('customers.errors.interaction_required', 'Interaction id is required'),\n })\n }\n return { id }\n },\n response: () => ({ ok: true }),\n },\n },\n})\n\nconst { POST, PUT, DELETE } = crud\n\nexport { POST, PUT, DELETE }\n\ntype InteractionListRow = {\n id: string\n entity_id: string\n deal_id: string | null\n interaction_type: string\n title: string | null\n body: string | null\n status: string\n scheduled_at: Date | null\n occurred_at: Date | null\n priority: number | null\n author_user_id: string | null\n owner_user_id: string | null\n appearance_icon: string | null\n appearance_color: string | null\n source: string | null\n duration_minutes: number | null\n location: string | null\n all_day: boolean | null\n recurrence_rule: string | null\n recurrence_end: Date | null\n participants: Array<{ userId: string; name?: string; email?: string; status?: string }> | null\n reminder_minutes: number | null\n visibility: string | null\n linked_entities: Array<{ id: string; type: string; label: string }> | null\n guest_permissions: { canInviteOthers?: boolean; canModify?: boolean; canSeeList?: boolean } | null\n pinned: boolean\n organization_id: string\n tenant_id: string\n created_at: Date\n updated_at: Date\n __sort_value: string | number | Date | null\n}\n\ntype CursorPayload = {\n id: string\n sortValue: string | number | null\n}\n\ntype RbacServiceLike = {\n getGrantedFeatures?: (userId: string, input: { tenantId: string | null; organizationId: string | null }) => Promise<string[]>\n}\n\nconst cursorSchema = z.object({\n id: z.string().uuid(),\n sortValue: z.union([z.string(), z.number(), z.null()]),\n})\n\nconst interactionSortConfig = {\n scheduledAt: { column: 'scheduled_at', type: 'date' as const, defaultDir: 'asc' as const },\n occurredAt: { column: 'occurred_at', type: 'date' as const, defaultDir: 'desc' as const },\n createdAt: { column: 'created_at', type: 'date' as const, defaultDir: 'desc' as const },\n updatedAt: { column: 'updated_at', type: 'date' as const, defaultDir: 'desc' as const },\n status: { column: 'status', type: 'text' as const, defaultDir: 'asc' as const },\n priority: { column: 'priority', type: 'number' as const, defaultDir: 'desc' as const },\n interactionType: { column: 'interaction_type', type: 'text' as const, defaultDir: 'asc' as const },\n title: { column: 'title', type: 'text' as const, defaultDir: 'asc' as const },\n} as const\n\nfunction toIsoString(value: unknown): string | null {\n if (value == null) return null\n if (value instanceof Date) {\n return Number.isNaN(value.getTime()) ? null : value.toISOString()\n }\n if (typeof value === 'string') {\n const trimmed = value.trim()\n if (!trimmed.length) return null\n const parsed = new Date(trimmed)\n return Number.isNaN(parsed.getTime()) ? trimmed : parsed.toISOString()\n }\n return null\n}\n\nfunction normalizeCursorValue(\n value: string | number | Date | null,\n type: 'date' | 'number' | 'text',\n): string | number | null {\n if (value == null) return null\n if (type === 'number') {\n if (typeof value === 'number' && Number.isFinite(value)) return value\n if (typeof value === 'string') {\n const parsed = Number(value)\n return Number.isNaN(parsed) ? null : parsed\n }\n return null\n }\n if (type === 'date') {\n return toIsoString(value)\n }\n if (typeof value === 'string') return value\n if (value instanceof Date) return value.toISOString()\n return String(value)\n}\n\nfunction encodeCursor(payload: CursorPayload): string {\n return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64')\n}\n\nfunction decodeCursor(token: string | undefined, type: 'date' | 'number' | 'text'): CursorPayload | null {\n if (!token) return null\n try {\n const decoded = Buffer.from(token, 'base64').toString('utf8')\n const parsed = cursorSchema.parse(JSON.parse(decoded))\n return {\n id: parsed.id,\n sortValue: normalizeCursorValue(parsed.sortValue, type),\n }\n } catch {\n return null\n }\n}\n\nfunction buildSortSql(\n sortField: keyof typeof interactionSortConfig,\n sortDir: 'asc' | 'desc',\n): string {\n const config = interactionSortConfig[sortField]\n if (config.type === 'date') {\n const sentinel =\n sortDir === 'asc'\n ? \"timestamp with time zone '9999-12-31T23:59:59.999Z'\"\n : \"timestamp with time zone '0001-01-01T00:00:00.000Z'\"\n return `coalesce(${config.column}, ${sentinel})`\n }\n if (config.type === 'number') {\n const sentinel = sortDir === 'asc' ? '2147483647' : '-2147483648'\n return `coalesce(${config.column}, ${sentinel})`\n }\n const sentinel = sortDir === 'asc' ? \"'~~~~~~~~~~'\" : \"''\"\n return `coalesce(${config.column}, ${sentinel})`\n}\n\nconst INTERACTION_LIST_COLUMNS = [\n 'id',\n 'entity_id',\n 'deal_id',\n 'interaction_type',\n 'title',\n 'body',\n 'status',\n 'scheduled_at',\n 'occurred_at',\n 'priority',\n 'author_user_id',\n 'owner_user_id',\n 'appearance_icon',\n 'appearance_color',\n 'source',\n 'duration_minutes',\n 'location',\n 'all_day',\n 'recurrence_rule',\n 'recurrence_end',\n 'participants',\n 'reminder_minutes',\n 'visibility',\n 'linked_entities',\n 'guest_permissions',\n 'pinned',\n 'organization_id',\n 'tenant_id',\n 'created_at',\n 'updated_at',\n] as const\n\n// Shared by both the SQL keyset path and the encrypted-sort candidate scan\n// so the two paths never drift on which rows are in scope.\nfunction applyInteractionListFilters(\n baseQuery: any,\n params: {\n tenantId: string\n organizationIds: string[]\n query: z.infer<typeof listSchema>\n },\n): any {\n let q = baseQuery.where('deleted_at', 'is', null).where('tenant_id', '=', params.tenantId)\n if (params.organizationIds.length > 0) q = q.where('organization_id', 'in', params.organizationIds)\n const { query } = params\n if (query.entityId) q = q.where('entity_id', '=', query.entityId)\n if (query.dealId) q = q.where('deal_id', '=', query.dealId)\n if (query.status) q = q.where('status', '=', query.status)\n if (query.interactionType) q = q.where('interaction_type', '=', query.interactionType)\n if (query.type) {\n const types = query.type.split(',').map((t) => t.trim()).filter(Boolean)\n if (types.length > 0) q = q.where('interaction_type', 'in', types)\n }\n if (query.pinned === 'true') {\n q = q.where('pinned', '=', true)\n } else if (query.pinned === 'false') {\n q = q.where('pinned', '=', false)\n }\n if (query.excludeInteractionType) q = q.where('interaction_type', '!=', query.excludeInteractionType)\n if (query.search) {\n // NOTE: for tenants with data encryption enabled, `title`/`body` are\n // ciphertext at rest (see encryption.ts), so this ILIKE matches encrypted\n // bytes and returns no rows \u2014 substring search over encrypted free-text\n // columns is unsupported, the same documented limitation as\n // customer_activity / customer_comment. The returned page's title/body are\n // still decrypted for display further below.\n const searchTerm = `%${escapeLikePattern(query.search)}%`\n q = q.where(sql<boolean>`coalesce(title, '') ilike ${searchTerm} or coalesce(body, '') ilike ${searchTerm}`)\n }\n if (query.from) {\n q = q.where(sql<boolean>`coalesce(occurred_at, scheduled_at, created_at) >= ${query.from}`)\n }\n if (query.to) {\n q = q.where(sql<boolean>`coalesce(occurred_at, scheduled_at, created_at) <= ${query.to}`)\n }\n return q\n}\n\nasync function resolveUserFeatures(\n container: { resolve: (name: string) => unknown },\n userId: string,\n tenantId: string | null,\n organizationId: string | null,\n): Promise<string[] | undefined> {\n try {\n const rbac = container.resolve('rbacService') as RbacServiceLike | undefined\n if (!rbac?.getGrantedFeatures) return undefined\n return await rbac.getGrantedFeatures(userId, { tenantId, organizationId })\n } catch {\n return undefined\n }\n}\n\nasync function buildEnricherContext(\n container: { resolve: (name: string) => unknown },\n auth: NonNullable<Awaited<ReturnType<typeof getAuthFromRequest>>>,\n organizationId: string | null,\n precomputedUserFeatures?: { userId: string; features: string[] | undefined },\n): Promise<EnricherContext> {\n const userId =\n (typeof auth.sub === 'string' && auth.sub.trim().length > 0\n ? auth.sub\n : typeof auth.userId === 'string' && auth.userId.trim().length > 0\n ? auth.userId\n : typeof auth.keyId === 'string' && auth.keyId.trim().length > 0\n ? auth.keyId\n : 'system')\n\n // Reuse features already resolved for this same user (the GET handler resolves\n // them once for the visibility filter) to avoid a second RBAC lookup per request.\n const userFeatures =\n precomputedUserFeatures && precomputedUserFeatures.userId === userId\n ? precomputedUserFeatures.features\n : await resolveUserFeatures(container, userId, auth.tenantId ?? null, organizationId)\n\n return {\n organizationId: organizationId ?? '',\n tenantId: auth.tenantId ?? '',\n userId,\n em: container.resolve('em'),\n container,\n userFeatures,\n }\n}\n\nexport async function GET(req: Request) {\n try {\n const queryUrl = new URL(req.url)\n const query = listSchema.parse(Object.fromEntries(queryUrl.searchParams))\n const container = await createRequestContainer()\n const auth = await getAuthFromRequest(req)\n const { translate } = await resolveTranslations()\n\n if (!auth || !auth.tenantId) {\n throw new CrudHttpError(401, {\n error: translate('customers.errors.unauthorized', 'Unauthorized'),\n })\n }\n\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n const organizationIds = Array.isArray(scope?.filterIds) && scope.filterIds.length > 0\n ? scope.filterIds\n : auth.orgId\n ? [auth.orgId]\n : []\n const selectedOrganizationId = scope?.selectedId ?? auth.orgId ?? organizationIds[0] ?? null\n const em = (container.resolve('em') as EntityManager).fork()\n const db = em.getKysely<any>() as any\n\n const requestedSortField = query.sortField ?? 'scheduledAt'\n const sortConfig = interactionSortConfig[requestedSortField]\n const sortDir = query.sortDir ?? sortConfig.defaultDir\n const sortSql = buildSortSql(requestedSortField, sortDir)\n const cursor = decodeCursor(query.cursor, sortConfig.type)\n if (query.cursor && !cursor) {\n throw new CrudHttpError(400, {\n error: translate('customers.interactions.cursor.invalid', 'Invalid cursor'),\n })\n }\n\n // \u2500\u2500 Email visibility filter (2026-05-27) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Non-email interactions pass through; email rows with visibility='private'\n // are filtered out unless the caller is the author or has admin bypass.\n // API-key callers have no user identity (`auth.sub` undefined): resolve the\n // viewer to null so they never gain the author bypass and only see shared\n // emails (fail-closed). Mirrors counts/people/activities routes.\n const viewerUserId = auth.isApiKey ? null : (auth.sub ?? null)\n const encryptionService = resolveTenantEncryptionService(em)\n // Encrypted sort columns can't use SQL keyset ordering on ciphertext, so an\n // encrypted sort field takes a bounded candidate-scan + in-memory-sort path\n // instead of the SQL path below. Resolved alongside the independent\n // visibility-feature lookup rather than after it.\n const [callerUserFeatures, encryptedSortFields] = await Promise.all([\n viewerUserId\n ? resolveUserFeatures(container, viewerUserId, auth.tenantId ?? null, selectedOrganizationId)\n : Promise.resolve(undefined),\n resolveEncryptedSortFields(\n encryptionService,\n CUSTOMER_INTERACTION_ENTITY_ID,\n [sortConfig.column],\n auth.tenantId,\n selectedOrganizationId,\n ),\n ])\n const sortFieldIsEncrypted = encryptedSortFields.has(sortConfig.column)\n\n let pageRows: InteractionListRow[]\n let hasMore: boolean\n\n if (sortFieldIsEncrypted) {\n let candidateQuery = applyInteractionListFilters(\n db.selectFrom('customer_interactions').select(['id', sortConfig.column]),\n { tenantId: auth.tenantId, organizationIds, query },\n )\n candidateQuery = applyEmailVisibilityFilter(candidateQuery as any, {\n currentUserId: viewerUserId,\n userFeatures: callerUserFeatures,\n })\n const cap = resolveEncryptedSortMaxRows()\n if (cap !== null) {\n candidateQuery = candidateQuery.limit(cap).orderBy('id', 'asc')\n }\n const candidateRows = await candidateQuery.execute() as Array<{ id: string } & Record<string, unknown>>\n if (cap !== null && candidateRows.length >= cap) {\n console.warn('[customers/interactions.GET] encrypted sort candidate scan hit OM_ENCRYPTED_SORT_MAX_ROWS cap; results may be incomplete', {\n cap,\n sortField: sortConfig.column,\n tenantId: auth.tenantId,\n })\n }\n\n const decryptPayload = encryptionService?.decryptEntityPayload?.bind(encryptionService)\n const { pageIds, hasMore: encryptedHasMore } = await resolveEncryptedSortPage({\n candidates: candidateRows,\n decryptRow: async (row) => {\n if (!decryptPayload) return row\n try {\n const decrypted = await decryptPayload(CUSTOMER_INTERACTION_ENTITY_ID, row, auth.tenantId, selectedOrganizationId)\n return { ...row, ...decrypted }\n } catch (err) {\n console.error('[customers/interactions.GET] error decrypting sort candidate', err)\n return row\n }\n },\n sortField: sortConfig.column,\n sortDir,\n cursorId: cursor?.id ?? null,\n limit: query.limit,\n })\n hasMore = encryptedHasMore\n\n if (pageIds.length === 0) {\n pageRows = []\n } else {\n let pageQuery = applyInteractionListFilters(\n db.selectFrom('customer_interactions').select([...INTERACTION_LIST_COLUMNS, sql`${sql.raw(sortSql)}`.as('__sort_value')]),\n { tenantId: auth.tenantId, organizationIds, query },\n )\n pageQuery = applyEmailVisibilityFilter(pageQuery as any, {\n currentUserId: viewerUserId,\n userFeatures: callerUserFeatures,\n })\n pageQuery = pageQuery.where('id', 'in', pageIds)\n const rawPageRows = await pageQuery.execute() as InteractionListRow[]\n const byId = new Map(rawPageRows.map((row) => [row.id, row]))\n pageRows = pageIds\n .map((id) => byId.get(id))\n .filter((row): row is InteractionListRow => row != null)\n }\n } else {\n let rowsQuery = applyInteractionListFilters(\n db\n .selectFrom('customer_interactions')\n .select([...INTERACTION_LIST_COLUMNS, sql`${sql.raw(sortSql)}`.as('__sort_value')])\n .limit(query.limit + 1),\n { tenantId: auth.tenantId, organizationIds, query },\n )\n\n if (cursor) {\n const op = sortDir === 'asc' ? '>' : '<'\n const opRaw = sql.raw(op)\n const sortRaw = sql.raw(sortSql)\n rowsQuery = rowsQuery.where((eb: any) => eb.or([\n sql<boolean>`${sortRaw} ${opRaw} ${cursor.sortValue}`,\n eb.and([\n sql<boolean>`${sortRaw} = ${cursor.sortValue}`,\n eb('id', op, cursor.id),\n ]),\n ]))\n }\n\n rowsQuery = applyEmailVisibilityFilter(rowsQuery as any, {\n currentUserId: viewerUserId,\n userFeatures: callerUserFeatures,\n })\n\n rowsQuery = rowsQuery.orderBy(sql`${sql.raw(sortSql)} ${sql.raw(sortDir)}`).orderBy('id', sortDir)\n\n const rows = await rowsQuery.execute() as InteractionListRow[]\n pageRows = rows.slice(0, query.limit)\n hasMore = rows.length > query.limit\n }\n\n const authorIds = Array.from(\n new Set(\n pageRows\n .map((row) => (typeof row.author_user_id === 'string' ? row.author_user_id : null))\n .filter((value): value is string => !!value),\n ),\n )\n const dealIds = Array.from(\n new Set(\n pageRows\n .map((row) => (typeof row.deal_id === 'string' ? row.deal_id : null))\n .filter((value): value is string => !!value),\n ),\n )\n const interactionIds = pageRows.map((row) => row.id)\n\n const [users, deals, customFieldValues, interactionRecords] = await Promise.all([\n authorIds.length > 0 ? findWithDecryption(em, User, { id: { $in: authorIds } }, undefined, { tenantId: auth.tenantId, organizationId: selectedOrganizationId }) : Promise.resolve([]),\n dealIds.length > 0 ? findWithDecryption(em, CustomerDeal, { id: { $in: dealIds } }, undefined, { tenantId: auth.tenantId, organizationId: selectedOrganizationId }) : Promise.resolve([]),\n interactionIds.length > 0\n ? loadCustomFieldValues({\n em,\n entityId: CUSTOMER_INTERACTION_ENTITY_ID,\n recordIds: interactionIds,\n tenantIdByRecord: Object.fromEntries(pageRows.map((row) => [row.id, row.tenant_id])),\n organizationIdByRecord: Object.fromEntries(pageRows.map((row) => [row.id, row.organization_id])),\n tenantFallbacks: [auth.tenantId].filter((value): value is string => !!value),\n })\n : Promise.resolve<Record<string, Record<string, unknown>>>({}),\n interactionIds.length > 0\n ? findWithDecryption(em, CustomerInteraction, { id: { $in: interactionIds } } as never, undefined, { tenantId: auth.tenantId, organizationId: selectedOrganizationId })\n : Promise.resolve([]),\n ])\n\n const userMap = new Map(\n users.map((user) => [\n user.id,\n {\n name: user.name ?? null,\n email: user.email ?? null,\n },\n ]),\n )\n const dealMap = new Map(\n deals.map((deal) => [deal.id, deal.title]),\n )\n // title/body are encrypted at rest (see encryption.ts). The kysely rows above\n // carry ciphertext when tenant encryption is enabled, so override them with the\n // decrypted values from findWithDecryption for the returned page.\n const interactionContentMap = new Map(\n (interactionRecords as Array<{ id: string; title?: string | null; body?: string | null }>).map(\n (record) => [record.id, { title: record.title ?? null, body: record.body ?? null }],\n ),\n )\n\n const baseItems = pageRows.map((row) => ({\n id: row.id,\n entityId: row.entity_id,\n dealId: row.deal_id ?? null,\n interactionType: row.interaction_type,\n title: (interactionContentMap.has(row.id) ? interactionContentMap.get(row.id)!.title : row.title) ?? null,\n body: (interactionContentMap.has(row.id) ? interactionContentMap.get(row.id)!.body : row.body) ?? null,\n status: row.status,\n scheduledAt: toIsoString(row.scheduled_at),\n occurredAt: toIsoString(row.occurred_at),\n priority: row.priority ?? null,\n authorUserId: row.author_user_id ?? null,\n ownerUserId: row.owner_user_id ?? null,\n appearanceIcon: row.appearance_icon ?? null,\n appearanceColor: row.appearance_color ?? null,\n source: row.source ?? null,\n duration: row.duration_minutes ?? null,\n durationMinutes: row.duration_minutes ?? null,\n location: row.location ?? null,\n allDay: row.all_day ?? null,\n recurrenceRule: row.recurrence_rule ?? null,\n recurrenceEnd: toIsoString(row.recurrence_end),\n participants: row.participants ?? null,\n reminderMinutes: row.reminder_minutes ?? null,\n visibility: row.visibility ?? null,\n linkedEntities: row.linked_entities ?? null,\n guestPermissions: row.guest_permissions ?? null,\n pinned: row.pinned ?? false,\n organizationId: row.organization_id,\n tenantId: row.tenant_id,\n createdAt: toIsoString(row.created_at) ?? new Date().toISOString(),\n updatedAt: toIsoString(row.updated_at) ?? new Date().toISOString(),\n authorName: row.author_user_id ? userMap.get(row.author_user_id)?.name ?? null : null,\n authorEmail: row.author_user_id ? userMap.get(row.author_user_id)?.email ?? null : null,\n dealTitle: row.deal_id ? dealMap.get(row.deal_id) ?? null : null,\n customValues: normalizeCustomFieldResponse(customFieldValues[row.id]) ?? null,\n }))\n\n const enricherContext = await buildEnricherContext(\n container,\n auth,\n selectedOrganizationId,\n viewerUserId ? { userId: viewerUserId, features: callerUserFeatures } : undefined,\n )\n const enriched = await applyResponseEnrichers(baseItems, 'customers.interaction', enricherContext)\n\n let nextCursor: string | undefined\n if (hasMore && pageRows.length > 0) {\n const last = pageRows[pageRows.length - 1]\n nextCursor = encodeCursor({\n id: last.id,\n sortValue: normalizeCursorValue(last.__sort_value, sortConfig.type),\n })\n }\n\n return NextResponse.json({\n items: enriched.items,\n nextCursor,\n })\n } catch (err) {\n if (isCrudHttpError(err)) {\n return NextResponse.json(err.body, { status: err.status })\n }\n if (err instanceof z.ZodError) {\n return NextResponse.json(\n { error: 'Validation failed', details: err.issues },\n { status: 400 },\n )\n }\n console.error('customers.interactions.get failed', err)\n const { translate } = await resolveTranslations()\n return NextResponse.json(\n { error: translate('customers.interactions.load.error', 'Failed to load interactions.') },\n { status: 500 },\n )\n }\n}\n\nconst interactionListItemSchema = z\n .object({\n id: z.string().uuid(),\n entityId: z.string().uuid().nullable(),\n dealId: z.string().uuid().nullable(),\n interactionType: z.string(),\n title: z.string().nullable(),\n body: z.string().nullable(),\n status: z.string(),\n scheduledAt: z.string().nullable(),\n occurredAt: z.string().nullable(),\n priority: z.number().nullable(),\n authorUserId: z.string().uuid().nullable(),\n ownerUserId: z.string().uuid().nullable(),\n appearanceIcon: z.string().nullable().optional(),\n appearanceColor: z.string().nullable().optional(),\n source: z.string().nullable().optional(),\n duration: z.number().nullable().optional(),\n durationMinutes: z.number().nullable().optional(),\n location: z.string().nullable().optional(),\n allDay: z.boolean().nullable().optional(),\n recurrenceRule: z.string().nullable().optional(),\n recurrenceEnd: z.string().nullable().optional(),\n participants: z.array(\n z.object({\n userId: z.string().uuid(),\n name: z.string().optional(),\n email: z.string().optional(),\n status: z.string().optional(),\n }),\n ).nullable().optional(),\n reminderMinutes: z.number().nullable().optional(),\n visibility: z.string().nullable().optional(),\n linkedEntities: z.array(\n z.object({\n id: z.string().uuid(),\n type: z.string(),\n label: z.string(),\n }),\n ).nullable().optional(),\n guestPermissions: z\n .object({\n canInviteOthers: z.boolean().optional(),\n canModify: z.boolean().optional(),\n canSeeList: z.boolean().optional(),\n })\n .nullable()\n .optional(),\n organizationId: z.string().uuid().nullable().optional(),\n tenantId: z.string().uuid().nullable().optional(),\n createdAt: z.string().nullable(),\n updatedAt: z.string().nullable(),\n authorName: z.string().nullable().optional(),\n authorEmail: z.string().nullable().optional(),\n dealTitle: z.string().nullable().optional(),\n customValues: z.record(z.string(), z.unknown()).nullable().optional(),\n _integrations: z.record(z.string(), z.unknown()).optional(),\n })\n .passthrough()\n\nconst interactionListResponseSchema = z.object({\n items: z.array(interactionListItemSchema),\n nextCursor: z.string().optional(),\n})\n\nconst interactionCreateResponseSchema = z.object({\n id: z.string().uuid().nullable(),\n})\n\nexport const openApi = createCustomersCrudOpenApi({\n resourceName: 'Interaction',\n querySchema: listSchema,\n listResponseSchema: interactionListResponseSchema,\n create: {\n schema: interactionCreateSchema,\n responseSchema: interactionCreateResponseSchema,\n description: 'Creates a new interaction linked to a customer entity or deal.',\n },\n update: {\n schema: interactionUpdateSchema,\n responseSchema: defaultOkResponseSchema,\n description: 'Updates fields for an existing interaction.',\n },\n del: {\n schema: z.object({ id: z.string().uuid() }),\n responseSchema: defaultOkResponseSchema,\n description: 'Soft-deletes an interaction identified by `id`. Accepts id via body or query string.',\n },\n})\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["sentinel"]
|
|
7
7
|
}
|
|
@@ -8,6 +8,10 @@ import { resolveOrganizationScopeForRequest } from "@open-mercato/core/modules/d
|
|
|
8
8
|
import { CrudHttpError, isCrudHttpError } from "@open-mercato/shared/lib/crud/errors";
|
|
9
9
|
import { serializeOperationMetadata } from "@open-mercato/shared/lib/commands/operationMetadata";
|
|
10
10
|
import { findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
|
|
11
|
+
import { decryptEntitiesWithFallbackScope } from "@open-mercato/shared/lib/encryption/subscriber";
|
|
12
|
+
import { mapWithConcurrency } from "@open-mercato/shared/lib/query/bounded-decrypt";
|
|
13
|
+
import { resolveEncryptedSortMaxRows, sortRowsInMemory } from "@open-mercato/shared/lib/query/encrypted-sort";
|
|
14
|
+
import { SortDir } from "@open-mercato/shared/lib/query/types";
|
|
11
15
|
import { resolveLabelActorUserId } from "./auth.js";
|
|
12
16
|
import {
|
|
13
17
|
runCrudMutationGuardAfterSuccess,
|
|
@@ -23,6 +27,7 @@ const metadata = {
|
|
|
23
27
|
GET: { requireAuth: true, requireFeatures: ["customers.people.view"] },
|
|
24
28
|
POST: { requireAuth: true, requireFeatures: ["customers.people.manage"] }
|
|
25
29
|
};
|
|
30
|
+
const DECRYPT_CONCURRENCY = 8;
|
|
26
31
|
const querySchema = z.object({
|
|
27
32
|
entityId: z.string().uuid().optional(),
|
|
28
33
|
organizationId: z.string().uuid().optional(),
|
|
@@ -63,11 +68,28 @@ async function GET(req) {
|
|
|
63
68
|
if (!organizationId) {
|
|
64
69
|
throw new CrudHttpError(400, { error: translate("customers.errors.organization_required", "Organization context is required") });
|
|
65
70
|
}
|
|
66
|
-
const
|
|
71
|
+
const cap = resolveEncryptedSortMaxRows();
|
|
72
|
+
const candidateLabels = await em.find(CustomerLabel, {
|
|
67
73
|
tenantId: auth.tenantId,
|
|
68
74
|
organizationId,
|
|
69
75
|
userId: actorUserId
|
|
70
|
-
}, { orderBy: {
|
|
76
|
+
}, cap !== null ? { limit: cap, orderBy: { id: "asc" } } : {});
|
|
77
|
+
if (cap !== null && candidateLabels.length >= cap) {
|
|
78
|
+
console.warn("[customers/labels.GET] encrypted sort candidate scan hit OM_ENCRYPTED_SORT_MAX_ROWS cap; results may be incomplete", {
|
|
79
|
+
cap,
|
|
80
|
+
tenantId: auth.tenantId,
|
|
81
|
+
organizationId
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
await mapWithConcurrency(
|
|
85
|
+
candidateLabels,
|
|
86
|
+
DECRYPT_CONCURRENCY,
|
|
87
|
+
(label) => decryptEntitiesWithFallbackScope(label, { em, tenantId: auth.tenantId, organizationId })
|
|
88
|
+
);
|
|
89
|
+
const labels = sortRowsInMemory(
|
|
90
|
+
candidateLabels,
|
|
91
|
+
[{ field: "label", dir: SortDir.Asc }]
|
|
92
|
+
);
|
|
71
93
|
let assignedLabelIds = [];
|
|
72
94
|
if (query.entityId) {
|
|
73
95
|
const assignments = await findWithDecryption(em, CustomerLabelAssignment, {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/customers/api/labels/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { CustomerLabel, CustomerLabelAssignment } from '../../data/entities'\nimport { labelCreateCommandSchema, labelCreateSchema, type LabelCreateCommandInput } from '../../data/validators'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'\nimport type { CommandBus } from '@open-mercato/shared/lib/commands'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { serializeOperationMetadata } from '@open-mercato/shared/lib/commands/operationMetadata'\nimport { findWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { resolveLabelActorUserId } from './auth'\nimport {\n runCrudMutationGuardAfterSuccess,\n validateCrudMutationGuard,\n} from '@open-mercato/shared/lib/crud/mutation-guard'\nimport {\n createMissingCustomerLabelTablesError,\n isMissingCustomerLabelTable,\n} from './table-errors'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['customers.people.view'] },\n POST: { requireAuth: true, requireFeatures: ['customers.people.manage'] },\n}\n\nconst querySchema = z.object({\n entityId: z.string().uuid().optional(),\n organizationId: z.string().uuid().optional(),\n ids: z.string().optional(),\n page: z.coerce.number().min(1).default(1),\n pageSize: z.coerce.number().min(1).max(100).default(50),\n search: z.string().optional(),\n})\n\nconst createLabelRequestSchema = labelCreateSchema.extend({\n organizationId: z.string().uuid().optional(),\n})\n\nexport async function GET(req: Request) {\n const { translate } = await resolveTranslations()\n try {\n const container = await createRequestContainer()\n const auth = await getAuthFromRequest(req)\n const actorUserId = resolveLabelActorUserId(auth)\n if (!auth || !auth.tenantId || !actorUserId) {\n return NextResponse.json({ error: translate('customers.errors.unauthorized', 'Unauthorized') }, { status: 401 })\n }\n\n const em = container.resolve('em') as EntityManager\n const url = new URL(req.url)\n const query = querySchema.parse({\n entityId: url.searchParams.get('entityId') ?? undefined,\n organizationId: url.searchParams.get('organizationId') ?? undefined,\n ids: url.searchParams.get('ids') ?? undefined,\n page: url.searchParams.get('page') ?? undefined,\n pageSize: url.searchParams.get('pageSize') ?? undefined,\n search: url.searchParams.get('search') ?? undefined,\n })\n const scope = await resolveOrganizationScopeForRequest({\n container,\n auth,\n request: req,\n selectedId: query.organizationId ?? undefined,\n })\n const organizationId = scope?.selectedId ?? auth.orgId ?? null\n if (!organizationId) {\n throw new CrudHttpError(400, { error: translate('customers.errors.organization_required', 'Organization context is required') })\n }\n\n // Load all labels for this user\n const labels = await findWithDecryption(em, CustomerLabel, {\n tenantId: auth.tenantId,\n organizationId,\n userId: actorUserId,\n }, { orderBy: { label: 'asc' } }, { tenantId: auth.tenantId, organizationId })\n\n // If entityId provided, also load assignments for that entity\n let assignedLabelIds: string[] = []\n if (query.entityId) {\n const assignments = await findWithDecryption(em, CustomerLabelAssignment, {\n tenantId: auth.tenantId,\n organizationId,\n userId: actorUserId,\n entity: query.entityId,\n } as FilterQuery<CustomerLabelAssignment>, {}, { tenantId: auth.tenantId, organizationId })\n assignedLabelIds = assignments.map((a) => {\n try { return a.label.id } catch { return '' }\n })\n // Handle unloaded references\n if (assignedLabelIds.some((id) => !id)) {\n const loaded = await findWithDecryption(em, CustomerLabelAssignment, {\n tenantId: auth.tenantId,\n organizationId,\n userId: actorUserId,\n entity: query.entityId,\n } as FilterQuery<CustomerLabelAssignment>, { populate: ['label'] }, { tenantId: auth.tenantId, organizationId })\n assignedLabelIds = loaded.map((a) => a.label.id)\n }\n }\n\n const requestedIds = query.ids\n ? new Set(\n query.ids\n .split(',')\n .map((value) => value.trim())\n .filter(Boolean),\n )\n : null\n const scopedLabels = requestedIds\n ? labels.filter((label) => requestedIds.has(label.id))\n : labels\n const filteredLabels = query.search?.trim().length\n ? scopedLabels.filter((label) => label.label.toLowerCase().includes(query.search!.trim().toLowerCase()))\n : scopedLabels\n const total = filteredLabels.length\n const totalPages = Math.max(1, Math.ceil(total / query.pageSize))\n const page = Math.min(query.page, totalPages)\n const start = (page - 1) * query.pageSize\n const items = filteredLabels.slice(start, start + query.pageSize)\n\n return NextResponse.json({\n items: items.map((l) => ({\n id: l.id,\n slug: l.slug,\n label: l.label,\n })),\n assignedIds: assignedLabelIds.filter(Boolean),\n total,\n page,\n pageSize: query.pageSize,\n totalPages,\n })\n } catch (err) {\n if (isMissingCustomerLabelTable(err)) {\n return NextResponse.json({ items: [], assignedIds: [] })\n }\n if (isCrudHttpError(err)) {\n return NextResponse.json(err.body, { status: err.status })\n }\n console.error('[customers/labels.GET]', err)\n return NextResponse.json({ error: translate('customers.errors.labels_load_failed', 'Failed to load labels') }, { status: 500 })\n }\n}\n\nfunction slugifyLabel(value: string): string {\n return value\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n}\n\nexport async function POST(req: Request) {\n try {\n const { translate } = await resolveTranslations()\n const container = await createRequestContainer()\n const auth = await getAuthFromRequest(req)\n const actorUserId = resolveLabelActorUserId(auth)\n if (!auth || !auth.tenantId || !actorUserId) {\n return NextResponse.json({ error: translate('customers.errors.unauthorized', 'Unauthorized') }, { status: 401 })\n }\n\n const body = createLabelRequestSchema.parse(await readJsonSafe(req, {}))\n const scope = await resolveOrganizationScopeForRequest({\n container,\n auth,\n request: req,\n selectedId: body.organizationId ?? undefined,\n })\n const organizationId = scope?.selectedId ?? auth.orgId ?? null\n if (!organizationId) {\n throw new CrudHttpError(400, { error: translate('customers.errors.organization_required', 'Organization context is required') })\n }\n const slug = body.slug || slugifyLabel(body.label)\n\n const guardResult = await validateCrudMutationGuard(container, {\n tenantId: auth.tenantId,\n organizationId,\n userId: actorUserId,\n resourceKind: 'customers.label',\n resourceId: organizationId,\n operation: 'custom',\n requestMethod: req.method,\n requestHeaders: req.headers,\n mutationPayload: body,\n })\n if (guardResult && !guardResult.ok) {\n return NextResponse.json(guardResult.body, { status: guardResult.status })\n }\n\n const commandInput = labelCreateCommandSchema.parse({\n tenantId: auth.tenantId,\n organizationId,\n userId: actorUserId,\n slug,\n label: body.label,\n } satisfies LabelCreateCommandInput)\n\n const commandBus = container.resolve('commandBus') as CommandBus\n const { result, logEntry } = await commandBus.execute<LabelCreateCommandInput, { labelId: string; slug: string; label: string }>(\n 'customers.labels.create',\n {\n input: commandInput,\n ctx: {\n container,\n auth,\n organizationScope: scope,\n selectedOrganizationId: organizationId,\n organizationIds: scope?.filterIds ?? (auth.orgId ? [auth.orgId] : null),\n request: req,\n },\n },\n )\n\n if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {\n await runCrudMutationGuardAfterSuccess(container, {\n tenantId: auth.tenantId,\n organizationId,\n userId: actorUserId,\n resourceKind: 'customers.label',\n resourceId: organizationId,\n operation: 'custom',\n requestMethod: req.method,\n requestHeaders: req.headers,\n metadata: guardResult.metadata ?? null,\n })\n }\n\n const response = NextResponse.json({\n id: result.labelId,\n slug: result.slug,\n label: result.label,\n }, { status: 201 })\n if (logEntry?.undoToken && logEntry.id && logEntry.commandId) {\n response.headers.set(\n 'x-om-operation',\n serializeOperationMetadata({\n id: logEntry.id,\n undoToken: logEntry.undoToken,\n commandId: logEntry.commandId,\n actionLabel: logEntry.actionLabel ?? null,\n resourceKind: logEntry.resourceKind ?? 'customers.label',\n resourceId: logEntry.resourceId ?? result.labelId,\n executedAt: logEntry.createdAt instanceof Date ? logEntry.createdAt.toISOString() : new Date().toISOString(),\n }),\n )\n }\n return response\n } catch (err) {\n if (isMissingCustomerLabelTable(err)) {\n const migrationError = await createMissingCustomerLabelTablesError()\n return NextResponse.json(migrationError.body, { status: migrationError.status })\n }\n if (isCrudHttpError(err)) {\n return NextResponse.json(err.body, { status: err.status })\n }\n console.error('[customers/labels.POST]', err)\n return NextResponse.json({ error: 'Failed to create label' }, { status: 500 })\n }\n}\n\nconst labelSchema = z.object({\n id: z.string().uuid(),\n slug: z.string(),\n label: z.string(),\n})\n\nconst labelsListSchema = z.object({\n items: z.array(labelSchema),\n assignedIds: z.array(z.string().uuid()),\n total: z.number().int().nonnegative(),\n page: z.number().int().min(1),\n pageSize: z.number().int().min(1),\n totalPages: z.number().int().min(1),\n})\n\nconst errorSchema = z.object({ error: z.string() })\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Customers',\n summary: 'Customer labels (user- and organization-scoped)',\n methods: {\n GET: {\n summary: 'List labels',\n description: 'Returns labels for the current user within the selected organization. Optionally includes assignment status for a specific entity.',\n responses: [\n { status: 200, description: 'Labels list', schema: labelsListSchema },\n ],\n errors: [\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n ],\n },\n POST: {\n summary: 'Create label',\n description: 'Creates a new label scoped to the current user and selected organization.',\n requestBody: { contentType: 'application/json', schema: createLabelRequestSchema },\n responses: [\n { status: 201, description: 'Label created', schema: labelSchema },\n ],\n errors: [\n { status: 409, description: 'Duplicate slug', schema: errorSchema },\n ],\n },\n },\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAClB,SAAS,eAAe,+BAA+B;AACvD,SAAS,0BAA0B,yBAAuD;AAC1F,SAAS,8BAA8B;AACvC,SAAS,0BAA0B;AACnC,SAAS,0CAA0C;AACnD,SAAS,eAAe,uBAAuB;AAI/C,SAAS,kCAAkC;AAC3C,SAAS,0BAAiD;AAC1D,SAAS,+BAA+B;AACxC;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,oBAAoB;AAC7B,SAAS,2BAA2B;AAE7B,MAAM,WAAW;AAAA,EACtB,KAAK,EAAE,aAAa,MAAM,iBAAiB,CAAC,uBAAuB,EAAE;AAAA,EACrE,MAAM,EAAE,aAAa,MAAM,iBAAiB,CAAC,yBAAyB,EAAE;AAC1E;AAEA,MAAM,cAAc,EAAE,OAAO;AAAA,EAC3B,UAAU,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS;AAAA,EACrC,gBAAgB,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS;AAAA,EAC3C,KAAK,EAAE,OAAO,EAAE,SAAS;AAAA,EACzB,MAAM,EAAE,OAAO,OAAO,EAAE,IAAI,CAAC,EAAE,QAAQ,CAAC;AAAA,EACxC,UAAU,EAAE,OAAO,OAAO,EAAE,IAAI,CAAC,EAAE,IAAI,GAAG,EAAE,QAAQ,EAAE;AAAA,EACtD,QAAQ,EAAE,OAAO,EAAE,SAAS;AAC9B,CAAC;AAED,MAAM,2BAA2B,kBAAkB,OAAO;AAAA,EACxD,gBAAgB,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS;AAC7C,CAAC;AAED,eAAsB,IAAI,KAAc;AACtC,QAAM,EAAE,UAAU,IAAI,MAAM,oBAAoB;AAChD,MAAI;AACF,UAAM,YAAY,MAAM,uBAAuB;AAC/C,UAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,UAAM,cAAc,wBAAwB,IAAI;AAChD,QAAI,CAAC,QAAQ,CAAC,KAAK,YAAY,CAAC,aAAa;AAC3C,aAAO,aAAa,KAAK,EAAE,OAAO,UAAU,iCAAiC,cAAc,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,IACjH;AAEA,UAAM,KAAK,UAAU,QAAQ,IAAI;AACjC,UAAM,MAAM,IAAI,IAAI,IAAI,GAAG;AAC3B,UAAM,QAAQ,YAAY,MAAM;AAAA,MAC9B,UAAU,IAAI,aAAa,IAAI,UAAU,KAAK;AAAA,MAC9C,gBAAgB,IAAI,aAAa,IAAI,gBAAgB,KAAK;AAAA,MAC1D,KAAK,IAAI,aAAa,IAAI,KAAK,KAAK;AAAA,MACpC,MAAM,IAAI,aAAa,IAAI,MAAM,KAAK;AAAA,MACtC,UAAU,IAAI,aAAa,IAAI,UAAU,KAAK;AAAA,MAC9C,QAAQ,IAAI,aAAa,IAAI,QAAQ,KAAK;AAAA,IAC5C,CAAC;AACD,UAAM,QAAQ,MAAM,mCAAmC;AAAA,MACrD;AAAA,MACA;AAAA,MACA,SAAS;AAAA,MACT,YAAY,MAAM,kBAAkB;AAAA,IACtC,CAAC;AACD,UAAM,iBAAiB,OAAO,cAAc,KAAK,SAAS;AAC1D,QAAI,CAAC,gBAAgB;AACnB,YAAM,IAAI,cAAc,KAAK,EAAE,OAAO,UAAU,0CAA0C,kCAAkC,EAAE,CAAC;AAAA,IACjI;
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { CustomerLabel, CustomerLabelAssignment } from '../../data/entities'\nimport { labelCreateCommandSchema, labelCreateSchema, type LabelCreateCommandInput } from '../../data/validators'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'\nimport type { CommandBus } from '@open-mercato/shared/lib/commands'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { serializeOperationMetadata } from '@open-mercato/shared/lib/commands/operationMetadata'\nimport { findWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { decryptEntitiesWithFallbackScope } from '@open-mercato/shared/lib/encryption/subscriber'\nimport { mapWithConcurrency } from '@open-mercato/shared/lib/query/bounded-decrypt'\nimport { resolveEncryptedSortMaxRows, sortRowsInMemory } from '@open-mercato/shared/lib/query/encrypted-sort'\nimport { SortDir } from '@open-mercato/shared/lib/query/types'\nimport { resolveLabelActorUserId } from './auth'\nimport {\n runCrudMutationGuardAfterSuccess,\n validateCrudMutationGuard,\n} from '@open-mercato/shared/lib/crud/mutation-guard'\nimport {\n createMissingCustomerLabelTablesError,\n isMissingCustomerLabelTable,\n} from './table-errors'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['customers.people.view'] },\n POST: { requireAuth: true, requireFeatures: ['customers.people.manage'] },\n}\n\nconst DECRYPT_CONCURRENCY = 8\n\nconst querySchema = z.object({\n entityId: z.string().uuid().optional(),\n organizationId: z.string().uuid().optional(),\n ids: z.string().optional(),\n page: z.coerce.number().min(1).default(1),\n pageSize: z.coerce.number().min(1).max(100).default(50),\n search: z.string().optional(),\n})\n\nconst createLabelRequestSchema = labelCreateSchema.extend({\n organizationId: z.string().uuid().optional(),\n})\n\nexport async function GET(req: Request) {\n const { translate } = await resolveTranslations()\n try {\n const container = await createRequestContainer()\n const auth = await getAuthFromRequest(req)\n const actorUserId = resolveLabelActorUserId(auth)\n if (!auth || !auth.tenantId || !actorUserId) {\n return NextResponse.json({ error: translate('customers.errors.unauthorized', 'Unauthorized') }, { status: 401 })\n }\n\n const em = container.resolve('em') as EntityManager\n const url = new URL(req.url)\n const query = querySchema.parse({\n entityId: url.searchParams.get('entityId') ?? undefined,\n organizationId: url.searchParams.get('organizationId') ?? undefined,\n ids: url.searchParams.get('ids') ?? undefined,\n page: url.searchParams.get('page') ?? undefined,\n pageSize: url.searchParams.get('pageSize') ?? undefined,\n search: url.searchParams.get('search') ?? undefined,\n })\n const scope = await resolveOrganizationScopeForRequest({\n container,\n auth,\n request: req,\n selectedId: query.organizationId ?? undefined,\n })\n const organizationId = scope?.selectedId ?? auth.orgId ?? null\n if (!organizationId) {\n throw new CrudHttpError(400, { error: translate('customers.errors.organization_required', 'Organization context is required') })\n }\n\n // `label` is encrypted at rest; SQL can't sort it meaningfully, so the\n // candidate set is decrypted and sorted in memory below.\n const cap = resolveEncryptedSortMaxRows()\n const candidateLabels = await em.find(CustomerLabel, {\n tenantId: auth.tenantId,\n organizationId,\n userId: actorUserId,\n } as FilterQuery<CustomerLabel>, cap !== null ? { limit: cap, orderBy: { id: 'asc' } } : {})\n if (cap !== null && candidateLabels.length >= cap) {\n console.warn('[customers/labels.GET] encrypted sort candidate scan hit OM_ENCRYPTED_SORT_MAX_ROWS cap; results may be incomplete', {\n cap,\n tenantId: auth.tenantId,\n organizationId,\n })\n }\n\n await mapWithConcurrency(candidateLabels, DECRYPT_CONCURRENCY, (label) =>\n decryptEntitiesWithFallbackScope(label, { em, tenantId: auth.tenantId, organizationId }),\n )\n\n const labels = sortRowsInMemory(\n candidateLabels as unknown as Record<string, unknown>[],\n [{ field: 'label', dir: SortDir.Asc }],\n ) as unknown as CustomerLabel[]\n\n // If entityId provided, also load assignments for that entity\n let assignedLabelIds: string[] = []\n if (query.entityId) {\n const assignments = await findWithDecryption(em, CustomerLabelAssignment, {\n tenantId: auth.tenantId,\n organizationId,\n userId: actorUserId,\n entity: query.entityId,\n } as FilterQuery<CustomerLabelAssignment>, {}, { tenantId: auth.tenantId, organizationId })\n assignedLabelIds = assignments.map((a) => {\n try { return a.label.id } catch { return '' }\n })\n // Handle unloaded references\n if (assignedLabelIds.some((id) => !id)) {\n const loaded = await findWithDecryption(em, CustomerLabelAssignment, {\n tenantId: auth.tenantId,\n organizationId,\n userId: actorUserId,\n entity: query.entityId,\n } as FilterQuery<CustomerLabelAssignment>, { populate: ['label'] }, { tenantId: auth.tenantId, organizationId })\n assignedLabelIds = loaded.map((a) => a.label.id)\n }\n }\n\n const requestedIds = query.ids\n ? new Set(\n query.ids\n .split(',')\n .map((value) => value.trim())\n .filter(Boolean),\n )\n : null\n const scopedLabels = requestedIds\n ? labels.filter((label) => requestedIds.has(label.id))\n : labels\n const filteredLabels = query.search?.trim().length\n ? scopedLabels.filter((label) => label.label.toLowerCase().includes(query.search!.trim().toLowerCase()))\n : scopedLabels\n const total = filteredLabels.length\n const totalPages = Math.max(1, Math.ceil(total / query.pageSize))\n const page = Math.min(query.page, totalPages)\n const start = (page - 1) * query.pageSize\n const items = filteredLabels.slice(start, start + query.pageSize)\n\n return NextResponse.json({\n items: items.map((l) => ({\n id: l.id,\n slug: l.slug,\n label: l.label,\n })),\n assignedIds: assignedLabelIds.filter(Boolean),\n total,\n page,\n pageSize: query.pageSize,\n totalPages,\n })\n } catch (err) {\n if (isMissingCustomerLabelTable(err)) {\n return NextResponse.json({ items: [], assignedIds: [] })\n }\n if (isCrudHttpError(err)) {\n return NextResponse.json(err.body, { status: err.status })\n }\n console.error('[customers/labels.GET]', err)\n return NextResponse.json({ error: translate('customers.errors.labels_load_failed', 'Failed to load labels') }, { status: 500 })\n }\n}\n\nfunction slugifyLabel(value: string): string {\n return value\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n}\n\nexport async function POST(req: Request) {\n try {\n const { translate } = await resolveTranslations()\n const container = await createRequestContainer()\n const auth = await getAuthFromRequest(req)\n const actorUserId = resolveLabelActorUserId(auth)\n if (!auth || !auth.tenantId || !actorUserId) {\n return NextResponse.json({ error: translate('customers.errors.unauthorized', 'Unauthorized') }, { status: 401 })\n }\n\n const body = createLabelRequestSchema.parse(await readJsonSafe(req, {}))\n const scope = await resolveOrganizationScopeForRequest({\n container,\n auth,\n request: req,\n selectedId: body.organizationId ?? undefined,\n })\n const organizationId = scope?.selectedId ?? auth.orgId ?? null\n if (!organizationId) {\n throw new CrudHttpError(400, { error: translate('customers.errors.organization_required', 'Organization context is required') })\n }\n const slug = body.slug || slugifyLabel(body.label)\n\n const guardResult = await validateCrudMutationGuard(container, {\n tenantId: auth.tenantId,\n organizationId,\n userId: actorUserId,\n resourceKind: 'customers.label',\n resourceId: organizationId,\n operation: 'custom',\n requestMethod: req.method,\n requestHeaders: req.headers,\n mutationPayload: body,\n })\n if (guardResult && !guardResult.ok) {\n return NextResponse.json(guardResult.body, { status: guardResult.status })\n }\n\n const commandInput = labelCreateCommandSchema.parse({\n tenantId: auth.tenantId,\n organizationId,\n userId: actorUserId,\n slug,\n label: body.label,\n } satisfies LabelCreateCommandInput)\n\n const commandBus = container.resolve('commandBus') as CommandBus\n const { result, logEntry } = await commandBus.execute<LabelCreateCommandInput, { labelId: string; slug: string; label: string }>(\n 'customers.labels.create',\n {\n input: commandInput,\n ctx: {\n container,\n auth,\n organizationScope: scope,\n selectedOrganizationId: organizationId,\n organizationIds: scope?.filterIds ?? (auth.orgId ? [auth.orgId] : null),\n request: req,\n },\n },\n )\n\n if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {\n await runCrudMutationGuardAfterSuccess(container, {\n tenantId: auth.tenantId,\n organizationId,\n userId: actorUserId,\n resourceKind: 'customers.label',\n resourceId: organizationId,\n operation: 'custom',\n requestMethod: req.method,\n requestHeaders: req.headers,\n metadata: guardResult.metadata ?? null,\n })\n }\n\n const response = NextResponse.json({\n id: result.labelId,\n slug: result.slug,\n label: result.label,\n }, { status: 201 })\n if (logEntry?.undoToken && logEntry.id && logEntry.commandId) {\n response.headers.set(\n 'x-om-operation',\n serializeOperationMetadata({\n id: logEntry.id,\n undoToken: logEntry.undoToken,\n commandId: logEntry.commandId,\n actionLabel: logEntry.actionLabel ?? null,\n resourceKind: logEntry.resourceKind ?? 'customers.label',\n resourceId: logEntry.resourceId ?? result.labelId,\n executedAt: logEntry.createdAt instanceof Date ? logEntry.createdAt.toISOString() : new Date().toISOString(),\n }),\n )\n }\n return response\n } catch (err) {\n if (isMissingCustomerLabelTable(err)) {\n const migrationError = await createMissingCustomerLabelTablesError()\n return NextResponse.json(migrationError.body, { status: migrationError.status })\n }\n if (isCrudHttpError(err)) {\n return NextResponse.json(err.body, { status: err.status })\n }\n console.error('[customers/labels.POST]', err)\n return NextResponse.json({ error: 'Failed to create label' }, { status: 500 })\n }\n}\n\nconst labelSchema = z.object({\n id: z.string().uuid(),\n slug: z.string(),\n label: z.string(),\n})\n\nconst labelsListSchema = z.object({\n items: z.array(labelSchema),\n assignedIds: z.array(z.string().uuid()),\n total: z.number().int().nonnegative(),\n page: z.number().int().min(1),\n pageSize: z.number().int().min(1),\n totalPages: z.number().int().min(1),\n})\n\nconst errorSchema = z.object({ error: z.string() })\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Customers',\n summary: 'Customer labels (user- and organization-scoped)',\n methods: {\n GET: {\n summary: 'List labels',\n description: 'Returns labels for the current user within the selected organization. Optionally includes assignment status for a specific entity.',\n responses: [\n { status: 200, description: 'Labels list', schema: labelsListSchema },\n ],\n errors: [\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n ],\n },\n POST: {\n summary: 'Create label',\n description: 'Creates a new label scoped to the current user and selected organization.',\n requestBody: { contentType: 'application/json', schema: createLabelRequestSchema },\n responses: [\n { status: 201, description: 'Label created', schema: labelSchema },\n ],\n errors: [\n { status: 409, description: 'Duplicate slug', schema: errorSchema },\n ],\n },\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
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[] = []
|