@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.
@@ -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 { drainQueueFromAppRoot } from './queue-runner'
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 loadActivityForScope(
153
- em: EntityManager,
154
- ctx: CustomersToolContext,
155
- tenantId: string,
156
- activityId: string,
157
- ): Promise<CustomerActivity | null> {
158
- const where: Record<string, unknown> = { id: activityId, tenantId }
159
- if (ctx.organizationId) where.organizationId = ctx.organizationId
160
- const row = await findOneWithDecryption<CustomerActivity>(
161
- em,
162
- CustomerActivity,
163
- where as any,
164
- undefined,
165
- buildScope(ctx, tenantId),
166
- )
167
- if (!row || row.tenantId !== tenantId) return null
168
- if (ctx.organizationId && row.organizationId !== ctx.organizationId) return null
169
- return row
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 commentEntityIdOf(row: CustomerComment): string | null {
173
- const ent = (row as any).entity
174
- if (!ent) return null
175
- if (typeof ent === 'string') return ent
176
- if (typeof ent === 'object' && typeof ent.id === 'string') return ent.id
177
- return null
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 activityEntityIdOf(row: CustomerActivity): string | null {
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 where: Record<string, unknown> = { tenantId }
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: filtered.map((row) => ({
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: activityEntityIdOf(row),
239
- dealId: (row as any).deal && typeof (row as any).deal === 'object' ? (row as any).deal.id : (row as any).deal ?? null,
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: 'customers.customer_activity',
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: (existing as any).deal && typeof (existing as any).deal === 'object'
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 runner.run({ method: 'POST', path: '/customers/activities', body })
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 runner.run({ method: 'PUT', path: '/customers/activities', body })
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 runner.run({ method: 'DELETE', path: '/customers/activities', body })
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: 'customers.customer_activity',
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: activityEntityIdOf(existing),
1142
- dealId: (existing as any).deal && typeof (existing as any).deal === 'object'
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 runner.run({ method: 'POST', path: '/customers/activities', body })
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 runner.run({ method: 'PUT', path: '/customers/activities', body })
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 runner.run({ method: 'DELETE', path: '/customers/activities', body })
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,