@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/customers/api/activities/route.js +20 -26
- package/dist/modules/customers/api/activities/route.js.map +2 -2
- package/dist/modules/customers/api/interactions/route.js +9 -19
- package/dist/modules/customers/api/interactions/route.js.map +2 -2
- package/dist/modules/customers/api/people/[id]/route.js +4 -1
- package/dist/modules/customers/api/people/[id]/route.js.map +2 -2
- package/dist/modules/customers/commands/interactions.js +26 -3
- package/dist/modules/customers/commands/interactions.js.map +2 -2
- package/dist/modules/customers/lib/interactionReadModel.js +5 -19
- package/dist/modules/customers/lib/interactionReadModel.js.map +2 -2
- package/dist/modules/customers/lib/interactionUsers.js +56 -0
- package/dist/modules/customers/lib/interactionUsers.js.map +7 -0
- package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js +21 -21
- package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/customers/api/activities/route.ts +23 -26
- package/src/modules/customers/api/interactions/route.ts +9 -21
- package/src/modules/customers/api/people/[id]/route.ts +4 -1
- package/src/modules/customers/commands/interactions.ts +33 -3
- package/src/modules/customers/lib/interactionReadModel.ts +5 -22
- package/src/modules/customers/lib/interactionUsers.ts +79 -0
- 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, {
|
|
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
|
|
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 {
|
|
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 [
|
|
150
|
-
|
|
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:
|
|
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
|