@open-mercato/core 0.10.1-develop.7672.1.c2e4c0b5df → 0.10.1-develop.7675.1.fb7858c0a2
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/dist/helpers/integration/queue.js +22 -1
- package/dist/helpers/integration/queue.js.map +2 -2
- package/dist/modules/customers/ai-tools/activities-tasks-pack.js +64 -66
- package/dist/modules/customers/ai-tools/activities-tasks-pack.js.map +2 -2
- package/dist/modules/customers/ai-tools/activity-read-model.js +193 -0
- package/dist/modules/customers/ai-tools/activity-read-model.js.map +7 -0
- package/dist/modules/customers/commands/companies.js +6 -0
- package/dist/modules/customers/commands/companies.js.map +2 -2
- package/dist/modules/customers/commands/people.js +6 -0
- package/dist/modules/customers/commands/people.js.map +2 -2
- package/package.json +7 -7
- package/src/helpers/integration/queue.ts +25 -1
- package/src/modules/customers/ai-tools/activities-tasks-pack.ts +65 -75
- package/src/modules/customers/ai-tools/activity-read-model.ts +228 -0
- package/src/modules/customers/commands/companies.ts +9 -0
- package/src/modules/customers/commands/people.ts +9 -0
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process'
|
|
2
2
|
import { createRequire } from 'node:module'
|
|
3
3
|
import path from 'node:path'
|
|
4
|
-
import {
|
|
4
|
+
import { expect } from '@playwright/test'
|
|
5
|
+
import { createQueue, getQueuePendingProbe } from '@open-mercato/queue'
|
|
6
|
+
import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
|
|
5
7
|
|
|
6
8
|
type DrainQueueOptions = {
|
|
7
9
|
appRoot?: string
|
|
@@ -15,6 +17,7 @@ function resolveAppRoot(input?: string): string {
|
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
async function drainQueueInCurrentProcess(queueName: string, options: Required<DrainQueueOptions>): Promise<number> {
|
|
20
|
+
const { drainQueueFromAppRoot } = await import('./queue-runner')
|
|
18
21
|
return drainQueueFromAppRoot(queueName, options)
|
|
19
22
|
}
|
|
20
23
|
|
|
@@ -69,6 +72,23 @@ function drainQueueInAppProcess(queueName: string, options: Required<DrainQueueO
|
|
|
69
72
|
})
|
|
70
73
|
}
|
|
71
74
|
|
|
75
|
+
async function waitForManagedQueue(queueName: string, options: Required<DrainQueueOptions>): Promise<number> {
|
|
76
|
+
const baseDir = process.env.QUEUE_BASE_DIR?.trim() || path.resolve(options.appRoot, '.mercato/queue')
|
|
77
|
+
const queue = createQueue(queueName, 'local', { baseDir })
|
|
78
|
+
try {
|
|
79
|
+
const initial = await queue.getJobCounts()
|
|
80
|
+
await expect.poll(async () => {
|
|
81
|
+
const pending = await getQueuePendingProbe(queueName, 'local', { baseDir })
|
|
82
|
+
if (pending.error) throw new Error(`[internal] Cannot observe integration queue ${queueName}: ${pending.errorMessage}`)
|
|
83
|
+
return pending.ready + pending.active
|
|
84
|
+
}, { message: `Integration queue ${queueName} should finish processing` }).toBe(0)
|
|
85
|
+
const final = await queue.getJobCounts()
|
|
86
|
+
return final.completed + final.failed - initial.completed - initial.failed
|
|
87
|
+
} finally {
|
|
88
|
+
await queue.close()
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
72
92
|
export async function drainIntegrationQueue(queueName: string, options: DrainQueueOptions = {}): Promise<number> {
|
|
73
93
|
const resolved = {
|
|
74
94
|
appRoot: resolveAppRoot(options.appRoot),
|
|
@@ -77,5 +97,9 @@ export async function drainIntegrationQueue(queueName: string, options: DrainQue
|
|
|
77
97
|
if (process.env.OM_TEST_APP_ROOT?.trim()) {
|
|
78
98
|
return drainQueueInAppProcess(queueName, resolved)
|
|
79
99
|
}
|
|
100
|
+
const managed = parseBooleanToken(process.env.AUTO_SPAWN_WORKERS)
|
|
101
|
+
?? parseBooleanToken(process.env.OM_AUTO_SPAWN_WORKERS)
|
|
102
|
+
?? true
|
|
103
|
+
if (managed) return waitForManagedQueue(queueName, resolved)
|
|
80
104
|
return drainQueueInCurrentProcess(queueName, resolved)
|
|
81
105
|
}
|
|
@@ -10,12 +10,16 @@ import type { EntityManager } from '@mikro-orm/postgresql'
|
|
|
10
10
|
import { z } from 'zod'
|
|
11
11
|
import {
|
|
12
12
|
createAiApiOperationRunner,
|
|
13
|
+
type AiApiOperationRequest,
|
|
13
14
|
type AiToolExecutionContext,
|
|
14
15
|
} from '@open-mercato/ai-assistant/modules/ai_assistant/lib/ai-api-operation-runner'
|
|
15
16
|
import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
17
|
+
import { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'
|
|
16
18
|
import { TERMINAL_INTERACTION_STATUS_LIST } from '../lib/interactionStatus'
|
|
19
|
+
import { resolveCustomerInteractionFeatureFlags } from '../lib/interactionFeatureFlags'
|
|
20
|
+
import { CUSTOMER_INTERACTION_ACTIVITY_ADAPTER_SOURCE } from '../lib/interactionCompatibility'
|
|
21
|
+
import { listActivitiesForScope, loadActivityForScope, type ActivityRecord } from './activity-read-model'
|
|
17
22
|
import {
|
|
18
|
-
CustomerActivity,
|
|
19
23
|
CustomerComment,
|
|
20
24
|
CustomerDeal,
|
|
21
25
|
CustomerDealCompanyLink,
|
|
@@ -149,35 +153,51 @@ async function loadCommentForScope(
|
|
|
149
153
|
return row
|
|
150
154
|
}
|
|
151
155
|
|
|
152
|
-
async function
|
|
153
|
-
|
|
154
|
-
ctx
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
)
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
156
|
+
async function runActivityOperation(ctx: CustomersToolContext, request: AiApiOperationRequest) {
|
|
157
|
+
const { tenantId } = assertTenantScope(ctx)
|
|
158
|
+
const flags = await resolveCustomerInteractionFeatureFlags(ctx.container, tenantId)
|
|
159
|
+
const executionContext = ctx as AiToolExecutionContext
|
|
160
|
+
if (flags.legacyAdapters) {
|
|
161
|
+
return createAiApiOperationRunner(executionContext).run(request)
|
|
162
|
+
}
|
|
163
|
+
const requiredFeatures = ['customers.interactions.manage']
|
|
164
|
+
if (!authorizeFeatures(requiredFeatures, {
|
|
165
|
+
grantedFeatures: ctx.userFeatures,
|
|
166
|
+
unrestricted: ctx.isSuperAdmin,
|
|
167
|
+
})) {
|
|
168
|
+
throw new Error('[internal] Canonical interaction management permission is required')
|
|
169
|
+
}
|
|
170
|
+
const { activityType, subject, ...body } = request.body ?? {}
|
|
171
|
+
const runner = createAiApiOperationRunner({
|
|
172
|
+
...executionContext,
|
|
173
|
+
tool: { ...executionContext.tool, requiredFeatures },
|
|
174
|
+
})
|
|
175
|
+
return runner.run({
|
|
176
|
+
...request,
|
|
177
|
+
path: '/customers/interactions',
|
|
178
|
+
body: {
|
|
179
|
+
...body,
|
|
180
|
+
...(activityType !== undefined ? { interactionType: activityType } : {}),
|
|
181
|
+
...(subject !== undefined ? { title: subject } : {}),
|
|
182
|
+
...(request.method === 'POST' ? {
|
|
183
|
+
source: CUSTOMER_INTERACTION_ACTIVITY_ADAPTER_SOURCE,
|
|
184
|
+
status: body.occurredAt ? 'done' : 'planned',
|
|
185
|
+
} : {}),
|
|
186
|
+
...(request.method === 'PUT' && body.occurredAt ? { status: 'done' } : {}),
|
|
187
|
+
},
|
|
188
|
+
})
|
|
170
189
|
}
|
|
171
190
|
|
|
172
|
-
function
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
191
|
+
function activitySnapshot(activity: ActivityRecord | null) {
|
|
192
|
+
return activity ? {
|
|
193
|
+
activityType: activity.activityType,
|
|
194
|
+
subject: activity.subject,
|
|
195
|
+
body: activity.body,
|
|
196
|
+
occurredAt: activity.occurredAt?.toISOString() ?? null,
|
|
197
|
+
} : null
|
|
178
198
|
}
|
|
179
199
|
|
|
180
|
-
function
|
|
200
|
+
function commentEntityIdOf(row: CustomerComment): string | null {
|
|
181
201
|
const ent = (row as any).entity
|
|
182
202
|
if (!ent) return null
|
|
183
203
|
if (typeof ent === 'string') return ent
|
|
@@ -210,33 +230,17 @@ const listActivitiesTool: CustomersAiToolDefinition = {
|
|
|
210
230
|
const em = resolveEm(ctx)
|
|
211
231
|
const limit = input.limit ?? 50
|
|
212
232
|
const offset = input.offset ?? 0
|
|
213
|
-
const
|
|
214
|
-
if (ctx.organizationId) where.organizationId = ctx.organizationId
|
|
215
|
-
const entityId = input.personId ?? input.companyId ?? null
|
|
216
|
-
if (entityId) where.entity = entityId
|
|
217
|
-
if (input.dealId) where.deal = input.dealId
|
|
218
|
-
if (input.activityType) where.activityType = input.activityType
|
|
219
|
-
const [rows, total] = await Promise.all([
|
|
220
|
-
findWithDecryption<CustomerActivity>(
|
|
221
|
-
em,
|
|
222
|
-
CustomerActivity,
|
|
223
|
-
where as any,
|
|
224
|
-
{ limit, offset, orderBy: { occurredAt: 'desc', createdAt: 'desc' } as any } as any,
|
|
225
|
-
buildScope(ctx, tenantId),
|
|
226
|
-
),
|
|
227
|
-
em.count(CustomerActivity, where as any),
|
|
228
|
-
])
|
|
229
|
-
const filtered = rows.filter((row) => row.tenantId === tenantId)
|
|
233
|
+
const { items: rows, total } = await listActivitiesForScope(em, ctx, tenantId, { ...input, limit, offset })
|
|
230
234
|
return {
|
|
231
|
-
items:
|
|
235
|
+
items: rows.map((row) => ({
|
|
232
236
|
id: row.id,
|
|
233
237
|
activityType: row.activityType,
|
|
234
238
|
subject: row.subject ?? null,
|
|
235
239
|
body: row.body ?? null,
|
|
236
240
|
occurredAt: row.occurredAt ? new Date(row.occurredAt).toISOString() : null,
|
|
237
241
|
authorUserId: row.authorUserId ?? null,
|
|
238
|
-
entityId:
|
|
239
|
-
dealId:
|
|
242
|
+
entityId: row.entityId,
|
|
243
|
+
dealId: row.dealId,
|
|
240
244
|
organizationId: row.organizationId ?? null,
|
|
241
245
|
tenantId: row.tenantId ?? null,
|
|
242
246
|
createdAt: row.createdAt ? new Date(row.createdAt).toISOString() : null,
|
|
@@ -645,16 +649,14 @@ const manageDealActivityTool: CustomersAiToolDefinition = {
|
|
|
645
649
|
if (!existing) return null
|
|
646
650
|
return {
|
|
647
651
|
recordId: existing.id,
|
|
648
|
-
entityType:
|
|
652
|
+
entityType: existing.entityType,
|
|
649
653
|
recordVersion: recordVersionFromUpdatedAt(existing.updatedAt),
|
|
650
654
|
before: {
|
|
651
655
|
activityType: existing.activityType,
|
|
652
656
|
subject: existing.subject ?? null,
|
|
653
657
|
body: existing.body ?? null,
|
|
654
658
|
occurredAt: existing.occurredAt ? new Date(existing.occurredAt).toISOString() : null,
|
|
655
|
-
dealId:
|
|
656
|
-
? (existing as any).deal.id
|
|
657
|
-
: (existing as any).deal ?? null,
|
|
659
|
+
dealId: existing.dealId,
|
|
658
660
|
},
|
|
659
661
|
}
|
|
660
662
|
},
|
|
@@ -662,7 +664,6 @@ const manageDealActivityTool: CustomersAiToolDefinition = {
|
|
|
662
664
|
const { tenantId } = assertTenantScope(ctx)
|
|
663
665
|
const input: ManageDealActivityInput = manageDealActivityInput.parse(rawInput)
|
|
664
666
|
const em = resolveEm(ctx)
|
|
665
|
-
const runner = createAiApiOperationRunner(ctx as unknown as AiToolExecutionContext)
|
|
666
667
|
|
|
667
668
|
if (input.operation === 'create') {
|
|
668
669
|
const deal = await loadDealForScope(em, ctx, tenantId, input.dealId!)
|
|
@@ -686,23 +687,19 @@ const manageDealActivityTool: CustomersAiToolDefinition = {
|
|
|
686
687
|
if (input.subject) body.subject = input.subject
|
|
687
688
|
if (input.body) body.body = input.body
|
|
688
689
|
if (input.occurredAt) body.occurredAt = input.occurredAt
|
|
689
|
-
const response = await
|
|
690
|
+
const response = await runActivityOperation(ctx, { method: 'POST', path: '/customers/activities', body })
|
|
690
691
|
if (!response.success) {
|
|
691
692
|
throw new Error(response.error ?? 'Failed to create activity')
|
|
692
693
|
}
|
|
693
694
|
const result = (response.data ?? {}) as { id?: string | null }
|
|
695
|
+
const created = result.id ? await loadActivityForScope(em, ctx, tenantId, result.id) : null
|
|
694
696
|
return {
|
|
695
697
|
operation: 'create' as const,
|
|
696
698
|
activityId: result.id ?? null,
|
|
697
699
|
dealId: deal.id,
|
|
698
700
|
commandName: 'customers.activities.create',
|
|
699
701
|
before: null,
|
|
700
|
-
after:
|
|
701
|
-
activityType: input.activityType ?? null,
|
|
702
|
-
subject: input.subject ?? null,
|
|
703
|
-
body: input.body ?? null,
|
|
704
|
-
occurredAt: input.occurredAt ?? null,
|
|
705
|
-
},
|
|
702
|
+
after: activitySnapshot(created),
|
|
706
703
|
}
|
|
707
704
|
}
|
|
708
705
|
|
|
@@ -716,7 +713,7 @@ const manageDealActivityTool: CustomersAiToolDefinition = {
|
|
|
716
713
|
if (input.subject !== undefined) body.subject = input.subject
|
|
717
714
|
if (input.body !== undefined) body.body = input.body
|
|
718
715
|
if (input.occurredAt !== undefined) body.occurredAt = input.occurredAt
|
|
719
|
-
const response = await
|
|
716
|
+
const response = await runActivityOperation(ctx, { method: 'PUT', path: '/customers/activities', body })
|
|
720
717
|
if (!response.success) {
|
|
721
718
|
throw new Error(response.error ?? `Failed to update activity "${existing.id}"`)
|
|
722
719
|
}
|
|
@@ -746,7 +743,7 @@ const manageDealActivityTool: CustomersAiToolDefinition = {
|
|
|
746
743
|
const existing = await loadActivityForScope(em, ctx, tenantId, input.activityId!)
|
|
747
744
|
if (!existing) throw new Error(`Activity "${input.activityId}" is not accessible to the caller.`)
|
|
748
745
|
const body: Record<string, unknown> = { id: existing.id }
|
|
749
|
-
const response = await
|
|
746
|
+
const response = await runActivityOperation(ctx, { method: 'DELETE', path: '/customers/activities', body })
|
|
750
747
|
if (!response.success) {
|
|
751
748
|
throw new Error(response.error ?? `Failed to delete activity "${existing.id}"`)
|
|
752
749
|
}
|
|
@@ -1131,17 +1128,15 @@ const manageRecordActivityTool: CustomersAiToolDefinition = {
|
|
|
1131
1128
|
if (!existing) return null
|
|
1132
1129
|
return {
|
|
1133
1130
|
recordId: existing.id,
|
|
1134
|
-
entityType:
|
|
1131
|
+
entityType: existing.entityType,
|
|
1135
1132
|
recordVersion: recordVersionFromUpdatedAt(existing.updatedAt),
|
|
1136
1133
|
before: {
|
|
1137
1134
|
activityType: existing.activityType,
|
|
1138
1135
|
subject: existing.subject ?? null,
|
|
1139
1136
|
body: existing.body ?? null,
|
|
1140
1137
|
occurredAt: existing.occurredAt ? new Date(existing.occurredAt).toISOString() : null,
|
|
1141
|
-
entityId:
|
|
1142
|
-
dealId:
|
|
1143
|
-
? (existing as any).deal.id
|
|
1144
|
-
: (existing as any).deal ?? null,
|
|
1138
|
+
entityId: existing.entityId,
|
|
1139
|
+
dealId: existing.dealId,
|
|
1145
1140
|
},
|
|
1146
1141
|
}
|
|
1147
1142
|
},
|
|
@@ -1149,7 +1144,6 @@ const manageRecordActivityTool: CustomersAiToolDefinition = {
|
|
|
1149
1144
|
const { tenantId } = assertTenantScope(ctx)
|
|
1150
1145
|
const input: ManageRecordActivityInput = manageRecordActivityInput.parse(rawInput)
|
|
1151
1146
|
const em = resolveEm(ctx)
|
|
1152
|
-
const runner = createAiApiOperationRunner(ctx as unknown as AiToolExecutionContext)
|
|
1153
1147
|
|
|
1154
1148
|
if (input.operation === 'create') {
|
|
1155
1149
|
const entityId = input.personId ?? input.companyId!
|
|
@@ -1174,11 +1168,12 @@ const manageRecordActivityTool: CustomersAiToolDefinition = {
|
|
|
1174
1168
|
if (input.subject) body.subject = input.subject
|
|
1175
1169
|
if (input.body) body.body = input.body
|
|
1176
1170
|
if (input.occurredAt) body.occurredAt = input.occurredAt
|
|
1177
|
-
const response = await
|
|
1171
|
+
const response = await runActivityOperation(ctx, { method: 'POST', path: '/customers/activities', body })
|
|
1178
1172
|
if (!response.success) {
|
|
1179
1173
|
throw new Error(response.error ?? 'Failed to create activity')
|
|
1180
1174
|
}
|
|
1181
1175
|
const result = (response.data ?? {}) as { id?: string | null }
|
|
1176
|
+
const created = result.id ? await loadActivityForScope(em, ctx, tenantId, result.id) : null
|
|
1182
1177
|
return {
|
|
1183
1178
|
operation: 'create' as const,
|
|
1184
1179
|
activityId: result.id ?? null,
|
|
@@ -1186,12 +1181,7 @@ const manageRecordActivityTool: CustomersAiToolDefinition = {
|
|
|
1186
1181
|
dealId,
|
|
1187
1182
|
commandName: 'customers.activities.create',
|
|
1188
1183
|
before: null,
|
|
1189
|
-
after:
|
|
1190
|
-
activityType: input.activityType ?? null,
|
|
1191
|
-
subject: input.subject ?? null,
|
|
1192
|
-
body: input.body ?? null,
|
|
1193
|
-
occurredAt: input.occurredAt ?? null,
|
|
1194
|
-
},
|
|
1184
|
+
after: activitySnapshot(created),
|
|
1195
1185
|
}
|
|
1196
1186
|
}
|
|
1197
1187
|
|
|
@@ -1205,7 +1195,7 @@ const manageRecordActivityTool: CustomersAiToolDefinition = {
|
|
|
1205
1195
|
if (input.subject !== undefined) body.subject = input.subject
|
|
1206
1196
|
if (input.body !== undefined) body.body = input.body
|
|
1207
1197
|
if (input.occurredAt !== undefined) body.occurredAt = input.occurredAt
|
|
1208
|
-
const response = await
|
|
1198
|
+
const response = await runActivityOperation(ctx, { method: 'PUT', path: '/customers/activities', body })
|
|
1209
1199
|
if (!response.success) {
|
|
1210
1200
|
throw new Error(response.error ?? `Failed to update activity "${existing.id}"`)
|
|
1211
1201
|
}
|
|
@@ -1235,7 +1225,7 @@ const manageRecordActivityTool: CustomersAiToolDefinition = {
|
|
|
1235
1225
|
const existing = await loadActivityForScope(em, ctx, tenantId, input.activityId!)
|
|
1236
1226
|
if (!existing) throw new Error(`Activity "${input.activityId}" is not accessible to the caller.`)
|
|
1237
1227
|
const body: Record<string, unknown> = { id: existing.id }
|
|
1238
|
-
const response = await
|
|
1228
|
+
const response = await runActivityOperation(ctx, { method: 'DELETE', path: '/customers/activities', body })
|
|
1239
1229
|
if (!response.success) {
|
|
1240
1230
|
throw new Error(response.error ?? `Failed to delete activity "${existing.id}"`)
|
|
1241
1231
|
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'
|
|
2
|
+
import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
3
|
+
import { CustomerActivity, CustomerInteraction } from '../data/entities'
|
|
4
|
+
import { listGrantsForViewer, listSharedChannelIds } from '../lib/conversationShares'
|
|
5
|
+
import { CUSTOMER_INTERACTION_ACTIVITY_ADAPTER_SOURCE } from '../lib/interactionCompatibility'
|
|
6
|
+
import { resolveCustomerInteractionFeatureFlags } from '../lib/interactionFeatureFlags'
|
|
7
|
+
import { buildEmailVisibilityMikroFilter, isEmailHiddenFrom } from '../lib/visibilityFilter'
|
|
8
|
+
import type { CustomersToolContext } from './types'
|
|
9
|
+
|
|
10
|
+
export type ActivityRecord = {
|
|
11
|
+
id: string
|
|
12
|
+
activityType: string
|
|
13
|
+
subject: string | null
|
|
14
|
+
body: string | null
|
|
15
|
+
occurredAt: Date | null
|
|
16
|
+
authorUserId: string | null
|
|
17
|
+
entityId: string | null
|
|
18
|
+
dealId: string | null
|
|
19
|
+
organizationId: string
|
|
20
|
+
tenantId: string
|
|
21
|
+
createdAt: Date
|
|
22
|
+
updatedAt: Date | null
|
|
23
|
+
entityType: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type ActivityListInput = {
|
|
27
|
+
personId?: string
|
|
28
|
+
companyId?: string
|
|
29
|
+
dealId?: string
|
|
30
|
+
activityType?: string
|
|
31
|
+
limit: number
|
|
32
|
+
offset: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const activityOrder = { occurredAt: 'DESC NULLS LAST', createdAt: 'DESC', id: 'DESC' } as const
|
|
36
|
+
|
|
37
|
+
function buildScope(ctx: CustomersToolContext, tenantId: string) {
|
|
38
|
+
return { tenantId, ...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}) }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isInScope(row: { tenantId: string; organizationId: string }, ctx: CustomersToolContext, tenantId: string) {
|
|
42
|
+
return row.tenantId === tenantId && (!ctx.organizationId || row.organizationId === ctx.organizationId)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function relationId(value: unknown): string | null {
|
|
46
|
+
if (typeof value === 'string') return value
|
|
47
|
+
if (value && typeof value === 'object' && 'id' in value && typeof value.id === 'string') return value.id
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function copyDate(value: Date | null | undefined): Date | null {
|
|
52
|
+
return value ? new Date(value) : null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function canonicalActivity(row: CustomerInteraction): ActivityRecord {
|
|
56
|
+
return {
|
|
57
|
+
id: row.id,
|
|
58
|
+
activityType: row.interactionType,
|
|
59
|
+
subject: row.title ?? null,
|
|
60
|
+
body: row.body ?? null,
|
|
61
|
+
occurredAt: copyDate(row.occurredAt),
|
|
62
|
+
authorUserId: row.authorUserId ?? null,
|
|
63
|
+
entityId: relationId(row.entity),
|
|
64
|
+
dealId: row.dealId ?? null,
|
|
65
|
+
organizationId: row.organizationId,
|
|
66
|
+
tenantId: row.tenantId,
|
|
67
|
+
createdAt: new Date(row.createdAt),
|
|
68
|
+
updatedAt: copyDate(row.updatedAt),
|
|
69
|
+
entityType: 'customers.customer_interaction',
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function legacyActivity(row: CustomerActivity): ActivityRecord {
|
|
74
|
+
return {
|
|
75
|
+
id: row.id,
|
|
76
|
+
activityType: row.activityType,
|
|
77
|
+
subject: row.subject ?? null,
|
|
78
|
+
body: row.body ?? null,
|
|
79
|
+
occurredAt: copyDate(row.occurredAt),
|
|
80
|
+
authorUserId: row.authorUserId ?? null,
|
|
81
|
+
entityId: relationId(row.entity),
|
|
82
|
+
dealId: relationId(row.deal),
|
|
83
|
+
organizationId: row.organizationId,
|
|
84
|
+
tenantId: row.tenantId,
|
|
85
|
+
createdAt: new Date(row.createdAt),
|
|
86
|
+
updatedAt: copyDate(row.updatedAt),
|
|
87
|
+
entityType: 'customers.customer_activity',
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function resolveVisibility(em: EntityManager, ctx: CustomersToolContext, tenantId: string) {
|
|
92
|
+
const currentUserId = ctx.userId?.startsWith('api_key:') ? null : ctx.userId
|
|
93
|
+
const scope = { tenantId, organizationId: ctx.organizationId }
|
|
94
|
+
const [sharedConversations, sharedChannelIds] = await Promise.all([
|
|
95
|
+
listGrantsForViewer(em, scope, currentUserId),
|
|
96
|
+
listSharedChannelIds(em, scope, currentUserId),
|
|
97
|
+
])
|
|
98
|
+
return { currentUserId, userFeatures: ctx.userFeatures, sharedConversations, sharedChannelIds }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function loadActivityForScope(
|
|
102
|
+
em: EntityManager,
|
|
103
|
+
ctx: CustomersToolContext,
|
|
104
|
+
tenantId: string,
|
|
105
|
+
activityId: string,
|
|
106
|
+
): Promise<ActivityRecord | null> {
|
|
107
|
+
const scope = buildScope(ctx, tenantId)
|
|
108
|
+
const canonical = await findOneWithDecryption(
|
|
109
|
+
em,
|
|
110
|
+
CustomerInteraction,
|
|
111
|
+
{ ...scope, id: activityId },
|
|
112
|
+
{ refresh: true },
|
|
113
|
+
scope,
|
|
114
|
+
)
|
|
115
|
+
if (canonical) {
|
|
116
|
+
if (!isInScope(canonical, ctx, tenantId) || canonical.deletedAt || canonical.interactionType === 'task') return null
|
|
117
|
+
const visibility = await resolveVisibility(em, ctx, tenantId)
|
|
118
|
+
if (isEmailHiddenFrom({
|
|
119
|
+
...visibility,
|
|
120
|
+
interactionType: canonical.interactionType,
|
|
121
|
+
visibility: canonical.visibility,
|
|
122
|
+
authorUserId: canonical.authorUserId,
|
|
123
|
+
personEntityId: relationId(canonical.entity),
|
|
124
|
+
channelId: canonical.channelId,
|
|
125
|
+
})) return null
|
|
126
|
+
return canonicalActivity(canonical)
|
|
127
|
+
}
|
|
128
|
+
const flags = await resolveCustomerInteractionFeatureFlags(ctx.container, tenantId)
|
|
129
|
+
if (flags.unified || !flags.legacyAdapters) return null
|
|
130
|
+
const legacy = await findOneWithDecryption(
|
|
131
|
+
em,
|
|
132
|
+
CustomerActivity,
|
|
133
|
+
{ ...scope, id: activityId },
|
|
134
|
+
{ refresh: true },
|
|
135
|
+
scope,
|
|
136
|
+
)
|
|
137
|
+
return legacy && isInScope(legacy, ctx, tenantId) ? legacyActivity(legacy) : null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function compareActivities(left: ActivityRecord, right: ActivityRecord): number {
|
|
141
|
+
const leftOccurredAt = left.occurredAt?.getTime() ?? Number.NEGATIVE_INFINITY
|
|
142
|
+
const rightOccurredAt = right.occurredAt?.getTime() ?? Number.NEGATIVE_INFINITY
|
|
143
|
+
if (leftOccurredAt !== rightOccurredAt) return rightOccurredAt - leftOccurredAt
|
|
144
|
+
const createdAtDifference = right.createdAt.getTime() - left.createdAt.getTime()
|
|
145
|
+
return createdAtDifference || right.id.localeCompare(left.id)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function listActivitiesForScope(
|
|
149
|
+
em: EntityManager,
|
|
150
|
+
ctx: CustomersToolContext,
|
|
151
|
+
tenantId: string,
|
|
152
|
+
input: ActivityListInput,
|
|
153
|
+
): Promise<{ items: ActivityRecord[]; total: number }> {
|
|
154
|
+
const scope = buildScope(ctx, tenantId)
|
|
155
|
+
const [flags, visibility] = await Promise.all([
|
|
156
|
+
resolveCustomerInteractionFeatureFlags(ctx.container, tenantId),
|
|
157
|
+
resolveVisibility(em, ctx, tenantId),
|
|
158
|
+
])
|
|
159
|
+
const includeLegacy = !flags.unified && flags.legacyAdapters
|
|
160
|
+
const entityId = input.personId ?? input.companyId
|
|
161
|
+
const canonicalWhere: FilterQuery<CustomerInteraction> = {
|
|
162
|
+
...scope,
|
|
163
|
+
...buildEmailVisibilityMikroFilter(visibility),
|
|
164
|
+
deletedAt: null,
|
|
165
|
+
interactionType: input.activityType ?? { $ne: 'task' },
|
|
166
|
+
...(entityId ? { entity: entityId } : {}),
|
|
167
|
+
...(input.dealId ? { dealId: input.dealId } : {}),
|
|
168
|
+
...(includeLegacy ? { source: CUSTOMER_INTERACTION_ACTIVITY_ADAPTER_SOURCE } : {}),
|
|
169
|
+
}
|
|
170
|
+
const loadCanonical = async (offset: number, limit: number): Promise<ActivityRecord[]> => {
|
|
171
|
+
if (input.activityType === 'task') return []
|
|
172
|
+
const rows = await findWithDecryption(
|
|
173
|
+
em, CustomerInteraction, canonicalWhere, { limit, offset, orderBy: activityOrder, refresh: true }, scope,
|
|
174
|
+
)
|
|
175
|
+
return rows.filter((row) => isInScope(row, ctx, tenantId)).map(canonicalActivity)
|
|
176
|
+
}
|
|
177
|
+
const countCanonical = () => input.activityType === 'task' ? Promise.resolve(0) : em.count(CustomerInteraction, canonicalWhere)
|
|
178
|
+
if (!includeLegacy) {
|
|
179
|
+
const [items, total] = await Promise.all([
|
|
180
|
+
loadCanonical(input.offset, input.limit),
|
|
181
|
+
countCanonical(),
|
|
182
|
+
])
|
|
183
|
+
return { items, total }
|
|
184
|
+
}
|
|
185
|
+
const canonicalIds = em.createQueryBuilder(CustomerInteraction).select('id').where(scope)
|
|
186
|
+
const legacyWhere: FilterQuery<CustomerActivity> = {
|
|
187
|
+
...scope,
|
|
188
|
+
id: { $nin: canonicalIds },
|
|
189
|
+
...(entityId ? { entity: entityId } : {}),
|
|
190
|
+
...(input.dealId ? { deal: input.dealId } : {}),
|
|
191
|
+
...(input.activityType ? { activityType: input.activityType } : {}),
|
|
192
|
+
}
|
|
193
|
+
const loadLegacy = async (offset: number, limit: number): Promise<ActivityRecord[]> => {
|
|
194
|
+
const rows = await findWithDecryption(
|
|
195
|
+
em, CustomerActivity, legacyWhere, { limit, offset, orderBy: activityOrder, refresh: true }, scope,
|
|
196
|
+
)
|
|
197
|
+
return rows.filter((row) => isInScope(row, ctx, tenantId)).map(legacyActivity)
|
|
198
|
+
}
|
|
199
|
+
const [canonicalTotal, legacyTotal] = await Promise.all([
|
|
200
|
+
countCanonical(),
|
|
201
|
+
em.count(CustomerActivity, legacyWhere),
|
|
202
|
+
])
|
|
203
|
+
const total = canonicalTotal + legacyTotal
|
|
204
|
+
if (input.offset >= total) return { items: [], total }
|
|
205
|
+
let lowerCanonicalOffset = Math.max(0, input.offset - legacyTotal)
|
|
206
|
+
let upperCanonicalOffset = Math.min(input.offset, canonicalTotal)
|
|
207
|
+
while (lowerCanonicalOffset < upperCanonicalOffset) {
|
|
208
|
+
const canonicalOffset = Math.floor((lowerCanonicalOffset + upperCanonicalOffset) / 2)
|
|
209
|
+
const legacyOffset = input.offset - canonicalOffset
|
|
210
|
+
const [canonicalNext, legacyPrevious] = await Promise.all([
|
|
211
|
+
loadCanonical(canonicalOffset, 1),
|
|
212
|
+
loadLegacy(legacyOffset - 1, 1),
|
|
213
|
+
])
|
|
214
|
+
if (canonicalNext[0] && legacyPrevious[0] && compareActivities(canonicalNext[0], legacyPrevious[0]) < 0) {
|
|
215
|
+
lowerCanonicalOffset = canonicalOffset + 1
|
|
216
|
+
} else {
|
|
217
|
+
upperCanonicalOffset = canonicalOffset
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const [canonicalItems, legacyItems] = await Promise.all([
|
|
221
|
+
loadCanonical(lowerCanonicalOffset, input.limit),
|
|
222
|
+
loadLegacy(input.offset - lowerCanonicalOffset, input.limit),
|
|
223
|
+
])
|
|
224
|
+
return {
|
|
225
|
+
items: [...canonicalItems, ...legacyItems].sort(compareActivities).slice(0, input.limit),
|
|
226
|
+
total,
|
|
227
|
+
}
|
|
228
|
+
}
|
|
@@ -138,8 +138,11 @@ type CompanyAddressSnapshot = {
|
|
|
138
138
|
id: string
|
|
139
139
|
name: string | null
|
|
140
140
|
purpose: string | null
|
|
141
|
+
companyName?: string | null
|
|
141
142
|
addressLine1: string
|
|
142
143
|
addressLine2: string | null
|
|
144
|
+
buildingNumber?: string | null
|
|
145
|
+
flatNumber?: string | null
|
|
143
146
|
city: string | null
|
|
144
147
|
region: string | null
|
|
145
148
|
postalCode: string | null
|
|
@@ -359,8 +362,11 @@ async function loadCompanySnapshot(
|
|
|
359
362
|
id: address.id,
|
|
360
363
|
name: address.name ?? null,
|
|
361
364
|
purpose: address.purpose ?? null,
|
|
365
|
+
companyName: address.companyName ?? null,
|
|
362
366
|
addressLine1: address.addressLine1,
|
|
363
367
|
addressLine2: address.addressLine2 ?? null,
|
|
368
|
+
buildingNumber: address.buildingNumber ?? null,
|
|
369
|
+
flatNumber: address.flatNumber ?? null,
|
|
364
370
|
city: address.city ?? null,
|
|
365
371
|
region: address.region ?? null,
|
|
366
372
|
postalCode: address.postalCode ?? null,
|
|
@@ -1387,8 +1393,11 @@ const deleteCompanyCommand: CommandHandler<
|
|
|
1387
1393
|
entity,
|
|
1388
1394
|
name: address.name,
|
|
1389
1395
|
purpose: address.purpose,
|
|
1396
|
+
companyName: address.companyName ?? null,
|
|
1390
1397
|
addressLine1: address.addressLine1,
|
|
1391
1398
|
addressLine2: address.addressLine2,
|
|
1399
|
+
buildingNumber: address.buildingNumber ?? null,
|
|
1400
|
+
flatNumber: address.flatNumber ?? null,
|
|
1392
1401
|
city: address.city,
|
|
1393
1402
|
region: address.region,
|
|
1394
1403
|
postalCode: address.postalCode,
|
|
@@ -84,8 +84,11 @@ type PersonAddressSnapshot = {
|
|
|
84
84
|
id: string
|
|
85
85
|
name: string | null
|
|
86
86
|
purpose: string | null
|
|
87
|
+
companyName?: string | null
|
|
87
88
|
addressLine1: string
|
|
88
89
|
addressLine2: string | null
|
|
90
|
+
buildingNumber?: string | null
|
|
91
|
+
flatNumber?: string | null
|
|
89
92
|
city: string | null
|
|
90
93
|
region: string | null
|
|
91
94
|
postalCode: string | null
|
|
@@ -344,8 +347,11 @@ function serializePersonSnapshot(
|
|
|
344
347
|
id: address.id,
|
|
345
348
|
name: address.name ?? null,
|
|
346
349
|
purpose: address.purpose ?? null,
|
|
350
|
+
companyName: address.companyName ?? null,
|
|
347
351
|
addressLine1: address.addressLine1,
|
|
348
352
|
addressLine2: address.addressLine2 ?? null,
|
|
353
|
+
buildingNumber: address.buildingNumber ?? null,
|
|
354
|
+
flatNumber: address.flatNumber ?? null,
|
|
349
355
|
city: address.city ?? null,
|
|
350
356
|
region: address.region ?? null,
|
|
351
357
|
postalCode: address.postalCode ?? null,
|
|
@@ -1684,8 +1690,11 @@ const deletePersonCommand: CommandHandler<
|
|
|
1684
1690
|
entity,
|
|
1685
1691
|
name: address.name,
|
|
1686
1692
|
purpose: address.purpose,
|
|
1693
|
+
companyName: address.companyName ?? null,
|
|
1687
1694
|
addressLine1: address.addressLine1,
|
|
1688
1695
|
addressLine2: address.addressLine2,
|
|
1696
|
+
buildingNumber: address.buildingNumber ?? null,
|
|
1697
|
+
flatNumber: address.flatNumber ?? null,
|
|
1689
1698
|
city: address.city,
|
|
1690
1699
|
region: address.region,
|
|
1691
1700
|
postalCode: address.postalCode,
|