@open-mercato/core 0.10.1-develop.7799.1.d9cd0c8e41 → 0.10.1-develop.7801.1.52db3dc0dd

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/customers/api/activities/route.js +20 -26
  3. package/dist/modules/customers/api/activities/route.js.map +2 -2
  4. package/dist/modules/customers/api/interactions/route.js +9 -19
  5. package/dist/modules/customers/api/interactions/route.js.map +2 -2
  6. package/dist/modules/customers/api/people/[id]/route.js +4 -1
  7. package/dist/modules/customers/api/people/[id]/route.js.map +2 -2
  8. package/dist/modules/customers/commands/interactions.js +26 -3
  9. package/dist/modules/customers/commands/interactions.js.map +2 -2
  10. package/dist/modules/customers/lib/interactionReadModel.js +5 -19
  11. package/dist/modules/customers/lib/interactionReadModel.js.map +2 -2
  12. package/dist/modules/customers/lib/interactionUsers.js +56 -0
  13. package/dist/modules/customers/lib/interactionUsers.js.map +7 -0
  14. package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js +21 -21
  15. package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js.map +2 -2
  16. package/package.json +7 -7
  17. package/src/modules/customers/api/activities/route.ts +23 -26
  18. package/src/modules/customers/api/interactions/route.ts +9 -21
  19. package/src/modules/customers/api/people/[id]/route.ts +4 -1
  20. package/src/modules/customers/commands/interactions.ts +33 -3
  21. package/src/modules/customers/lib/interactionReadModel.ts +5 -22
  22. package/src/modules/customers/lib/interactionUsers.ts +79 -0
  23. package/src/modules/entities/backend/entities/user/[entityId]/records/page.tsx +21 -24
@@ -771,7 +771,10 @@ export async function GET(_req: Request, ctx: { params?: { id?: string } }) {
771
771
  if (viewerUserId) authorIds.add(viewerUserId)
772
772
 
773
773
  if (authorIds.size) {
774
- const users = await findWithDecryption(em, User, { id: { $in: Array.from(authorIds) } }, {}, { tenantId: person.tenantId ?? auth.tenantId ?? null, organizationId: person.organizationId ?? auth.orgId ?? null })
774
+ const users = await findWithDecryption(em, User, {
775
+ id: { $in: Array.from(authorIds) },
776
+ tenantId: person.tenantId,
777
+ }, {}, { tenantId: person.tenantId, organizationId: person.organizationId })
775
778
  userMap = new Map(
776
779
  users.map((user) => [
777
780
  user.id,
@@ -12,7 +12,8 @@ import {
12
12
  import { DefaultDataEngine, type DataEngine } from '@open-mercato/shared/lib/data/engine'
13
13
  import type { EntityManager } from '@mikro-orm/postgresql'
14
14
  import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
15
- import { CustomerInteraction, CustomerEntity } from '../data/entities'
15
+ import { CustomerActivity, CustomerInteraction, CustomerEntity } from '../data/entities'
16
+ import { User } from '@open-mercato/core/modules/auth/data/entities'
16
17
  import {
17
18
  interactionCreateSchema,
18
19
  interactionUpdateSchema,
@@ -38,7 +39,7 @@ import {
38
39
  loadCustomFieldSnapshot,
39
40
  buildCustomFieldResetMap,
40
41
  } from '@open-mercato/shared/lib/commands/customFieldSnapshots'
41
- import { CrudHttpError, notFound } from '@open-mercato/shared/lib/crud/errors'
42
+ import { badRequest, CrudHttpError, notFound } from '@open-mercato/shared/lib/crud/errors'
42
43
  import { enforceRecordGoneIsConflict, enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
43
44
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
44
45
  import type { CrudIndexerConfig, CrudEventsConfig } from '@open-mercato/shared/lib/crud/types'
@@ -57,6 +58,8 @@ import { resolveCatalogEventType, resolveScopedCalendarEventTypes } from '../lib
57
58
  import { clearInapplicableCoreFields, findInapplicableCoreFields, omitHiddenCoreClears, preserveHiddenCoreValuesOnSameTypeEdit } from '../lib/calendar/interactionApplicability'
58
59
  import { calendarEventTypes, type CalendarEventTypeBehavior } from '../calendar-event-types'
59
60
  import { canonicalCalendarTimezone } from '../lib/calendar/timezone'
61
+ import { findInteractionUserInScope, requireInteractionUsersInScope } from '../lib/interactionUsers'
62
+ import { CUSTOMER_INTERACTION_ACTIVITY_ADAPTER_SOURCE } from '../lib/interactionCompatibility'
60
63
 
61
64
  const logger = createLogger('customers')
62
65
 
@@ -205,6 +208,16 @@ async function ensureInteractionRestoreAccess(
205
208
  await ensureInteractionPrivacyChangeAccess(ctx, current, { ...restored, visibility: restored.visibility ?? null })
206
209
  }
207
210
  await ensureInteractionMutationAccess(ctx, em, { ...restored, channelId: current?.channelId })
211
+ const userIds = new Set([restored.authorUserId, restored.ownerUserId].filter((value): value is string => !!value))
212
+ for (const userId of userIds) {
213
+ const user = await findOneWithDecryption(em, User, {
214
+ id: userId,
215
+ tenantId: restored.tenantId,
216
+ }, undefined, { tenantId: restored.tenantId, organizationId: restored.organizationId })
217
+ if (user) continue
218
+ const { translate } = await resolveTranslations()
219
+ throw badRequest(translate('auth.users.form.errors.notFound', 'User not found'))
220
+ }
208
221
  }
209
222
 
210
223
  async function loadInteractionSnapshot(em: EntityManager, id: string, ctx?: CommandRuntimeContext): Promise<InteractionSnapshot | null> {
@@ -559,12 +572,28 @@ const createInteractionCommand: CommandHandler<InteractionCreateInput, { interac
559
572
  const { parsed, custom } = parseWithCustomFields(interactionCreateSchema, rawInput)
560
573
 
561
574
  const em = ctx.transactionalEm ?? (ctx.container.resolve('em') as EntityManager).fork()
562
- const normalizedAuthor = normalizeAuthorUserId(parsed.authorUserId ?? null, ctx.auth)
575
+ const defaultAuthor = normalizeAuthorUserId(parsed.authorUserId ?? null, ctx.auth)
563
576
  const { interaction, entityId, nextInteractionId } = await runInTransaction(em, async (trx) => {
564
577
  const entity = await requireTimelineParentEntity(trx, parsed.entityId, { tenantId: parsed.tenantId, organizationId: parsed.organizationId })
565
578
  ensureTenantScope(ctx, entity.tenantId)
566
579
  ensureOrganizationScope(ctx, entity.organizationId)
567
580
 
581
+ const legacyActivity = parsed.id && parsed.source === CUSTOMER_INTERACTION_ACTIVITY_ADAPTER_SOURCE
582
+ ? await findOneWithDecryption(trx, CustomerActivity, {
583
+ id: parsed.id,
584
+ entity: entity.id,
585
+ tenantId: entity.tenantId,
586
+ organizationId: entity.organizationId,
587
+ }, undefined, entity)
588
+ : null
589
+ const preservesHistoricalAuthor = !!legacyActivity && parsed.authorUserId === legacyActivity.authorUserId
590
+ await requireInteractionUsersInScope(trx, entity, parsed, legacyActivity ?? {})
591
+ const normalizedAuthor = preservesHistoricalAuthor
592
+ ? legacyActivity.authorUserId ?? null
593
+ : defaultAuthor && await findInteractionUserInScope(trx, defaultAuthor, entity)
594
+ ? defaultAuthor
595
+ : null
596
+
568
597
  if (parsed.dealId) {
569
598
  await requireDealInScope(trx, parsed.dealId, entity.tenantId, entity.organizationId)
570
599
  }
@@ -865,6 +894,7 @@ const updateInteractionCommand: CommandHandler<InteractionUpdateInput, { interac
865
894
  })
866
895
 
867
896
  await ensureInteractionPrivacyChangeAccess(ctx, interaction, parsed)
897
+ await requireInteractionUsersInScope(trx, interaction, parsed, interaction)
868
898
 
869
899
  const nextType = parsed.interactionType ?? interaction.interactionType
870
900
  const changingType = nextType !== interaction.interactionType
@@ -5,7 +5,7 @@ import { normalizeCustomFieldResponse } from '@open-mercato/shared/lib/custom-fi
5
5
  import type { EnricherContext } from '@open-mercato/shared/lib/crud/response-enricher'
6
6
  import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
7
7
  import { CustomerDeal, CustomerEntity, CustomerInteraction } from '../data/entities'
8
- import { User } from '@open-mercato/core/modules/auth/data/entities'
8
+ import { interactionUserKey, loadInteractionAuthors } from './interactionUsers'
9
9
  import {
10
10
  CUSTOMER_INTERACTION_ENTITY_ID,
11
11
  type InteractionRecord,
@@ -128,14 +128,6 @@ export async function hydrateCanonicalInteractions({
128
128
  }: HydrateCanonicalInteractionsInput): Promise<InteractionRecord[]> {
129
129
  if (interactions.length === 0) return []
130
130
 
131
- const authorIds = Array.from(
132
- new Set(
133
- interactions
134
- .map((interaction) =>
135
- typeof interaction.authorUserId === 'string' ? interaction.authorUserId : null)
136
- .filter((value): value is string => !!value),
137
- ),
138
- )
139
131
  const dealIds = Array.from(
140
132
  new Set(
141
133
  interactions
@@ -146,8 +138,8 @@ export async function hydrateCanonicalInteractions({
146
138
 
147
139
  const tenantId = auth.tenantId ?? null
148
140
  const organizationId = selectedOrganizationId ?? null
149
- const [users, deals, customFieldValues] = await Promise.all([
150
- authorIds.length > 0 ? findWithDecryption(em, User, { id: { $in: authorIds } }, undefined, { tenantId, organizationId }) : Promise.resolve([]),
141
+ const [userMap, deals, customFieldValues] = await Promise.all([
142
+ loadInteractionAuthors(em, interactions),
151
143
  dealIds.length > 0 ? findWithDecryption(em, CustomerDeal, { id: { $in: dealIds } }, undefined, { tenantId, organizationId }) : Promise.resolve([]),
152
144
  loadCustomFieldValues({
153
145
  em,
@@ -159,15 +151,6 @@ export async function hydrateCanonicalInteractions({
159
151
  }),
160
152
  ])
161
153
 
162
- const userMap = new Map(
163
- users.map((user) => [
164
- user.id,
165
- {
166
- name: user.name ?? null,
167
- email: user.email ?? null,
168
- },
169
- ]),
170
- )
171
154
  const dealMap = new Map(deals.map((deal) => [deal.id, deal.title]))
172
155
 
173
156
  const baseItems: InteractionRecord[] = interactions.map((interaction) => {
@@ -203,8 +186,8 @@ export async function hydrateCanonicalInteractions({
203
186
  tenantId: interaction.tenantId,
204
187
  createdAt: interaction.createdAt.toISOString(),
205
188
  updatedAt: interaction.updatedAt.toISOString(),
206
- authorName: interaction.authorUserId ? userMap.get(interaction.authorUserId)?.name ?? null : null,
207
- authorEmail: interaction.authorUserId ? userMap.get(interaction.authorUserId)?.email ?? null : null,
189
+ authorName: interaction.authorUserId ? userMap.get(interactionUserKey(interaction, interaction.authorUserId))?.name ?? null : null,
190
+ authorEmail: interaction.authorUserId ? userMap.get(interactionUserKey(interaction, interaction.authorUserId))?.email ?? null : null,
208
191
  dealTitle: interaction.dealId ? dealMap.get(interaction.dealId) ?? null : null,
209
192
  customValues: normalizeInteractionCustomValues(customFieldValues[interaction.id]),
210
193
  }
@@ -0,0 +1,79 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { User } from '@open-mercato/core/modules/auth/data/entities'
3
+ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
4
+ import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
5
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
6
+
7
+ type InteractionUserScope = {
8
+ tenantId?: string | null
9
+ organizationId?: string | null
10
+ }
11
+
12
+ type InteractionUserReferences = {
13
+ authorUserId?: string | null
14
+ ownerUserId?: string | null
15
+ }
16
+
17
+ type AuthorProfile = { name: string | null; email: string | null }
18
+
19
+ export function interactionUserKey(scope: InteractionUserScope, userId: string): string {
20
+ return `${scope.tenantId ?? ''}:${scope.organizationId ?? ''}:${userId}`
21
+ }
22
+
23
+ export async function findInteractionUserInScope(
24
+ em: EntityManager,
25
+ userId: string,
26
+ scope: InteractionUserScope,
27
+ options: { includeDeleted?: boolean } = {},
28
+ ): Promise<User | null> {
29
+ if (!scope.tenantId || !scope.organizationId) return null
30
+ return findOneWithDecryption(em, User, {
31
+ id: userId,
32
+ tenantId: scope.tenantId,
33
+ ...(options.includeDeleted ? {} : { deletedAt: null }),
34
+ }, undefined, scope)
35
+ }
36
+
37
+ export async function requireInteractionUsersInScope(
38
+ em: EntityManager,
39
+ scope: InteractionUserScope,
40
+ references: InteractionUserReferences,
41
+ previousReferences: InteractionUserReferences = {},
42
+ ): Promise<void> {
43
+ for (const field of ['authorUserId', 'ownerUserId'] as const) {
44
+ const userId = references[field]
45
+ if (!userId) continue
46
+ if (await findInteractionUserInScope(em, userId, scope, { includeDeleted: userId === previousReferences[field] })) continue
47
+ const { translate } = await resolveTranslations()
48
+ throw new CrudHttpError(400, { error: translate('auth.users.form.errors.notFound', 'User not found') })
49
+ }
50
+ }
51
+
52
+ export async function loadInteractionAuthors(
53
+ em: EntityManager,
54
+ references: Array<InteractionUserScope & { authorUserId?: string | null }>,
55
+ ): Promise<Map<string, AuthorProfile>> {
56
+ const groups = new Map<string, { tenantId: string; organizationId: string; userIds: Set<string> }>()
57
+ for (const reference of references) {
58
+ if (!reference.tenantId || !reference.organizationId || !reference.authorUserId) continue
59
+ const key = interactionUserKey(reference, '')
60
+ let group = groups.get(key)
61
+ if (!group) {
62
+ group = { tenantId: reference.tenantId, organizationId: reference.organizationId, userIds: new Set() }
63
+ groups.set(key, group)
64
+ }
65
+ group.userIds.add(reference.authorUserId)
66
+ }
67
+ const profiles = new Map<string, AuthorProfile>()
68
+ await Promise.all(Array.from(groups.values(), async ({ tenantId, organizationId, userIds }) => {
69
+ const scope = { tenantId, organizationId }
70
+ const users = await findWithDecryption(em, User, {
71
+ id: { $in: Array.from(userIds) },
72
+ tenantId,
73
+ }, undefined, scope)
74
+ for (const user of users) {
75
+ profiles.set(interactionUserKey(scope, user.id), { name: user.name ?? null, email: user.email ?? null })
76
+ }
77
+ }))
78
+ return profiles
79
+ }
@@ -84,7 +84,6 @@ function RecordsPageInner({ params }: { params: { entityId?: string } }) {
84
84
  const [pageSize, setPageSize] = React.useState(50)
85
85
  const [search, setSearch] = React.useState('')
86
86
  const [filterValues, setFilterValues] = React.useState<Record<string, any>>({})
87
- const [columns, setColumns] = React.useState<ColumnDef<any>[]>([])
88
87
  const [rawData, setRawData] = React.useState<any[]>([])
89
88
  const [total, setTotal] = React.useState(0)
90
89
  const [totalPages, setTotalPages] = React.useState(1)
@@ -102,10 +101,30 @@ function RecordsPageInner({ params }: { params: { entityId?: string } }) {
102
101
  contextId: deleteMutationContextId,
103
102
  blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
104
103
  })
105
- const { data: cfDefs = [] } = useCustomFieldDefs(entityId, {
104
+ const { data: customFieldDefs } = useCustomFieldDefs(entityId, {
106
105
  enabled: Boolean(entityId),
107
106
  keyExtras: [scopeVersion],
108
107
  })
108
+ const cfDefs = React.useMemo(() => customFieldDefs ?? [], [customFieldDefs])
109
+ const columns = React.useMemo<ColumnDef<any>[]>(() => {
110
+ const visibleDefs = filterCustomFieldDefs(cfDefs, 'list') as any
111
+ const maxVisible = 10
112
+ const cols: ColumnDef<any>[] = visibleDefs.map((d: any, idx: number) => ({
113
+ accessorKey: d.key,
114
+ header: d.label || d.key,
115
+ meta: { priority: idx < 4 ? 1 : idx < 6 ? 2 : idx < 8 ? 3 : idx < maxVisible ? 4 : 5 },
116
+ cell: ({ getValue }: { getValue: () => unknown }) => {
117
+ const v = getValue() as any
118
+ if (d.kind === 'relation') {
119
+ return <RelationCell fieldKey={d.key} value={v} fallbackText={normalizeCell(v)} />
120
+ }
121
+ return <span className="inline-block max-w-60 truncate align-top" title={normalizeCell(v)}>{normalizeCell(v)}</span>
122
+ },
123
+ }))
124
+ const hasIdCol = cols.some((c) => (c as any).accessorKey === 'id' || (c as any).id === 'id')
125
+ if (!hasIdCol) cols.unshift({ accessorKey: 'id', header: 'ID', meta: { hidden: true, priority: 6 } } as any)
126
+ return cols
127
+ }, [cfDefs])
109
128
  const relationDefinitions = React.useMemo(
110
129
  () => filterCustomFieldDefs(cfDefs, 'list').filter(
111
130
  (definition): definition is CustomFieldDefDto => definition.kind === 'relation',
@@ -188,28 +207,6 @@ function RecordsPageInner({ params }: { params: { entityId?: string } }) {
188
207
  return () => { cancelled = true }
189
208
  }, [entityId, page, pageSize, sorting, filterValues, scopeVersion, search, searchableFields])
190
209
 
191
- // Build columns from custom field definitions only (no data round-trip)
192
- React.useEffect(() => {
193
- const visibleDefs = filterCustomFieldDefs(cfDefs, 'list') as any
194
- const maxVisible = 10
195
- const cols: ColumnDef<any>[] = visibleDefs.map((d: any, idx: number) => ({
196
- accessorKey: d.key,
197
- header: d.label || d.key,
198
- meta: { priority: idx < 4 ? 1 : idx < 6 ? 2 : idx < 8 ? 3 : idx < maxVisible ? 4 : 5 },
199
- cell: ({ getValue }: { getValue: () => unknown }) => {
200
- const v = getValue() as any
201
- if (d.kind === 'relation') {
202
- return <RelationCell fieldKey={d.key} value={v} fallbackText={normalizeCell(v)} />
203
- }
204
- return <span className="inline-block max-w-60 truncate align-top" title={normalizeCell(v)}>{normalizeCell(v)}</span>
205
- },
206
- }))
207
- // Ensure hidden 'id' column exists for sorting/state
208
- const hasIdCol = cols.some((c) => (c as any).accessorKey === 'id' || (c as any).id === 'id')
209
- if (!hasIdCol) cols.unshift({ accessorKey: 'id', header: 'ID', meta: { hidden: true, priority: 6 } } as any)
210
- setColumns(cols)
211
- }, [cfDefs])
212
-
213
210
  // Search is server-side (see fetch effect); render the fetched page as-is so
214
211
  // pagination totals and exports stay consistent with the active search (#3229).
215
212
  const data = rawData