@open-mercato/core 0.6.6-develop.6126.1.0b9392fdae → 0.6.6-develop.6133.1.f01540250e

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.
@@ -8,75 +8,27 @@ import {
8
8
  MessageReaction,
9
9
  } from './entities'
10
10
 
11
- /**
12
- * Resolve channels by `id` (not `providerKey`) — multi-user channels share the
13
- * same `providerKey` (e.g. two users with Gmail), so a providerKey-keyed Map
14
- * collapses them and returns the wrong owner's capabilities snapshot. The hop
15
- * goes `MessageChannelLink → ExternalConversation → CommunicationChannel`.
16
- *
17
- * Returns a Map keyed by the platform Message id so enrichers can read
18
- * `channelByMessageId.get(record.id)` without further joins.
19
- */
20
- async function resolveChannelsByMessageId(
21
- em: EntityManager,
22
- links: MessageChannelLink[],
23
- tenantId: string,
24
- organizationId: string | null,
25
- ): Promise<Map<string, CommunicationChannel>> {
26
- if (links.length === 0) return new Map()
27
- const conversationIds = Array.from(
28
- new Set(
29
- links.map((l) => (l as { externalConversationId?: string }).externalConversationId).filter(
30
- (id): id is string => typeof id === 'string' && id.length > 0,
31
- ),
32
- ),
33
- )
34
- if (conversationIds.length === 0) return new Map()
35
- const dscope = { tenantId, organizationId }
36
- const conversations = await findWithDecryption(
37
- em,
38
- ExternalConversation,
39
- { id: { $in: conversationIds }, tenantId, organizationId },
40
- undefined,
41
- dscope,
42
- )
43
- const channelIdByConversation = new Map<string, string>()
44
- for (const conv of conversations) {
45
- channelIdByConversation.set(conv.id, conv.channelId)
46
- }
47
- const channelIds = Array.from(new Set(Array.from(channelIdByConversation.values())))
48
- if (channelIds.length === 0) return new Map()
49
- const channels = await findWithDecryption(
50
- em,
51
- CommunicationChannel,
52
- { id: { $in: channelIds }, tenantId, organizationId, deletedAt: null },
53
- undefined,
54
- dscope,
55
- )
56
- const channelsById = new Map<string, CommunicationChannel>(
57
- channels.map((c) => [c.id, c]),
58
- )
59
- const result = new Map<string, CommunicationChannel>()
60
- for (const link of links) {
61
- const channelId = link.externalConversationId
62
- ? channelIdByConversation.get(link.externalConversationId)
63
- : undefined
64
- const channel = channelId ? channelsById.get(channelId) : undefined
65
- if (channel) result.set(link.messageId, channel)
66
- }
67
- return result
68
- }
69
-
70
11
  /**
71
12
  * Response enrichers for the messages.message entity.
72
13
  *
73
- * The hub declares 4 enrichers; downstream hosts (Messages module's `/api/messages`
14
+ * The hub declares 2 enrichers; downstream hosts (Messages module's `/api/messages`
74
15
  * CRUD route + future provider routes) opt in via `makeCrudRoute({ enrichers: { entityId: 'messages.message' } })`.
75
16
  *
17
+ * - `messageChannelEnricher` → `_channel`, `_channelPayload`, `_channelContact`
18
+ * - `messageReactionsEnricher` → `_reactions`
19
+ *
20
+ * The channel/payload/contact enrichments were three separate enrichers, each
21
+ * independently issuing its own `MessageChannelLink` `$in` query (and two of them
22
+ * an `ExternalConversation` query) for the same message page. Because the shared
23
+ * enricher runner executes active enrichers sequentially with no per-pass shared
24
+ * context, those lookups were repeated for every page (#3183). They are merged
25
+ * into one batched enricher so the link batch (and the conversation batch) is
26
+ * loaded once per pass, while preserving the public enriched field names:
27
+ *
76
28
  * - `_channel` → channel metadata + capabilities snapshot
77
29
  * - `_channelPayload` → channel-native payload (Block Kit / interactive / email MIME / …)
78
- * - `_reactions` → grouped emoji counts + users + reactedByMe
79
30
  * - `_channelContact` → CRM person preview (email + display name)
31
+ * - `_reactions` → grouped emoji counts + users + reactedByMe
80
32
  *
81
33
  * Per `packages/shared/lib/crud/response-enricher` rules:
82
34
  * - `enrichMany` is implemented for every enricher (N+1 prevention).
@@ -98,15 +50,43 @@ function ctxEm(ctx: EnricherContext): EntityManager {
98
50
  return ctx.em as EntityManager
99
51
  }
100
52
 
101
- // ── _channel ────────────────────────────────────────────────────────────────
53
+ export type ChannelEnrichment = {
54
+ providerKey: string
55
+ channelType: string
56
+ direction: 'inbound' | 'outbound' | string
57
+ deliveryStatus: string | null
58
+ capabilities: Record<string, unknown> | null
59
+ }
102
60
 
103
- const messageChannelEnricher: ResponseEnricher<MessageRecord, { _channel?: ChannelEnrichment | null }> = {
61
+ export type ChannelPayloadEnrichment = {
62
+ channelContentType: string | null
63
+ channelPayload: Record<string, unknown> | null
64
+ interactiveState: Record<string, unknown> | null
65
+ channelMetadata: Record<string, unknown> | null
66
+ }
67
+
68
+ export type ChannelContactEnrichment = {
69
+ contactPersonId: string | null
70
+ assignedUserId: string | null
71
+ subject: string | null
72
+ }
73
+
74
+ // ── _channel + _channelPayload + _channelContact ──────────────────────────────
75
+
76
+ const messageChannelEnricher: ResponseEnricher<
77
+ MessageRecord,
78
+ {
79
+ _channel?: ChannelEnrichment | null
80
+ _channelPayload?: ChannelPayloadEnrichment | null
81
+ _channelContact?: ChannelContactEnrichment | null
82
+ }
83
+ > = {
104
84
  id: 'communication_channels.message-channel',
105
85
  targetEntity: 'messages.message',
106
86
  features: ['communication_channels.view'],
107
87
  priority: 30,
108
- timeout: 1500,
109
- fallback: { _channel: null },
88
+ timeout: 2000,
89
+ fallback: { _channel: null, _channelPayload: null, _channelContact: null },
110
90
  critical: false,
111
91
 
112
92
  async enrichOne(record, ctx) {
@@ -118,117 +98,127 @@ const messageChannelEnricher: ResponseEnricher<MessageRecord, { _channel?: Chann
118
98
  if (records.length === 0) return records
119
99
  const messageIds = records.map((r) => r.id)
120
100
  const em = ctxEm(ctx)
121
- const dscope = { tenantId: ctx.tenantId, organizationId: ctx.organizationId ?? null }
101
+ const tenantId = ctx.tenantId as string
102
+ const organizationId = ctx.organizationId ?? null
103
+ const dscope = { tenantId, organizationId }
104
+
105
+ // 1) MessageChannelLink — one bounded `$in` query for the whole page, shared by
106
+ // all three enrichments (channel metadata, channel payload, conversation
107
+ // contact). The result set is already bounded by the `messageId $in messageIds`
108
+ // filter (the host list endpoint caps the page at 100), so no separate row
109
+ // limit is needed.
122
110
  const links = await findWithDecryption(
123
111
  em,
124
112
  MessageChannelLink,
125
113
  {
126
114
  messageId: { $in: messageIds },
127
- tenantId: ctx.tenantId,
128
- organizationId: ctx.organizationId ?? null,
115
+ tenantId,
116
+ organizationId,
129
117
  },
130
- // Result set is already bounded by the `messageId $in messageIds` filter
131
- // (the host list endpoint caps the page at 100), matching the other
132
- // enrichers — no separate row limit needed.
133
118
  undefined,
134
119
  dscope,
135
120
  )
121
+ const linksByMessage = new Map<string, MessageChannelLink>()
122
+ for (const link of links) linksByMessage.set(link.messageId, link)
136
123
 
137
- // Resolve channels via the `link ExternalConversation CommunicationChannel`
138
- // path. Keys are platform Message ids — direct mapping per row.
139
- const channelByMessageId = await resolveChannelsByMessageId(
140
- em,
141
- links,
142
- ctx.tenantId as string,
143
- ctx.organizationId ?? null,
124
+ // 2) ExternalConversation one query, shared by the channel-capabilities hop and
125
+ // the conversation-contact enrichment.
126
+ const conversationIds = Array.from(
127
+ new Set(
128
+ links
129
+ .map((l) => l.externalConversationId)
130
+ .filter((id): id is string => typeof id === 'string' && id.length > 0),
131
+ ),
144
132
  )
133
+ const conversationsById = new Map<string, ExternalConversation>()
134
+ if (conversationIds.length > 0) {
135
+ const conversations = await findWithDecryption(
136
+ em,
137
+ ExternalConversation,
138
+ { id: { $in: conversationIds }, tenantId, organizationId },
139
+ undefined,
140
+ dscope,
141
+ )
142
+ for (const conversation of conversations) {
143
+ conversationsById.set(conversation.id, conversation)
144
+ }
145
+ }
145
146
 
146
- const linksByMessage = new Map<string, MessageChannelLink>()
147
- for (const link of links) linksByMessage.set(link.messageId, link)
147
+ // 3) CommunicationChannel capabilities snapshot. Resolve by `id` (not
148
+ // `providerKey`): multi-user channels share the same `providerKey` (e.g. two
149
+ // users with Gmail), so a providerKey-keyed map collapses them and returns the
150
+ // wrong owner's capabilities. The hop is `link → ExternalConversation → CommunicationChannel`.
151
+ const channelIds = Array.from(
152
+ new Set(
153
+ Array.from(conversationsById.values())
154
+ .map((conversation) => conversation.channelId)
155
+ .filter((id): id is string => typeof id === 'string' && id.length > 0),
156
+ ),
157
+ )
158
+ const channelsById = new Map<string, CommunicationChannel>()
159
+ if (channelIds.length > 0) {
160
+ const channels = await findWithDecryption(
161
+ em,
162
+ CommunicationChannel,
163
+ { id: { $in: channelIds }, tenantId, organizationId, deletedAt: null },
164
+ undefined,
165
+ dscope,
166
+ )
167
+ for (const channel of channels) channelsById.set(channel.id, channel)
168
+ }
169
+
170
+ const channelByMessageId = new Map<string, CommunicationChannel>()
171
+ for (const link of links) {
172
+ if (!link.externalConversationId) continue
173
+ const conversation = conversationsById.get(link.externalConversationId)
174
+ if (!conversation) continue
175
+ const channel = conversation.channelId ? channelsById.get(conversation.channelId) : undefined
176
+ if (channel) channelByMessageId.set(link.messageId, channel)
177
+ }
148
178
 
149
179
  return records.map((r) => {
150
180
  const link = linksByMessage.get(r.id)
151
- if (!link) return { ...r, _channel: null }
181
+ if (!link) {
182
+ return { ...r, _channel: null, _channelPayload: null, _channelContact: null }
183
+ }
184
+
152
185
  const channel = channelByMessageId.get(r.id)
153
- const enrichment: ChannelEnrichment = {
186
+ const channelEnrichment: ChannelEnrichment = {
154
187
  providerKey: link.providerKey,
155
188
  channelType: link.channelType,
156
189
  direction: link.direction,
157
190
  deliveryStatus: link.deliveryStatus ?? null,
158
191
  capabilities: (channel?.capabilities as Record<string, unknown> | null) ?? null,
159
192
  }
160
- return { ...r, _channel: enrichment }
161
- })
162
- },
163
- }
164
-
165
- export type ChannelEnrichment = {
166
- providerKey: string
167
- channelType: string
168
- direction: 'inbound' | 'outbound' | string
169
- deliveryStatus: string | null
170
- capabilities: Record<string, unknown> | null
171
- }
172
-
173
- // ── _channelPayload ─────────────────────────────────────────────────────────
174
193
 
175
- const messageChannelPayloadEnricher: ResponseEnricher<
176
- MessageRecord,
177
- { _channelPayload?: ChannelPayloadEnrichment | null }
178
- > = {
179
- id: 'communication_channels.message-channel-payload',
180
- targetEntity: 'messages.message',
181
- features: ['communication_channels.view'],
182
- priority: 20,
183
- timeout: 1500,
184
- fallback: { _channelPayload: null },
185
- critical: false,
186
-
187
- async enrichOne(record, ctx) {
188
- const [out] = await this.enrichMany!([record], ctx)
189
- return out
190
- },
191
-
192
- async enrichMany(records, ctx) {
193
- if (records.length === 0) return records
194
- const messageIds = records.map((r) => r.id)
195
- const em = ctxEm(ctx)
196
- const dscope = { tenantId: ctx.tenantId, organizationId: ctx.organizationId ?? null }
197
- const links = await findWithDecryption(
198
- em,
199
- MessageChannelLink,
200
- {
201
- messageId: { $in: messageIds },
202
- tenantId: ctx.tenantId,
203
- organizationId: ctx.organizationId ?? null,
204
- },
205
- undefined,
206
- dscope,
207
- )
208
- const byMessage = new Map<string, MessageChannelLink>()
209
- for (const link of links) byMessage.set(link.messageId, link)
210
-
211
- return records.map((r) => {
212
- const link = byMessage.get(r.id)
213
- if (!link) return { ...r, _channelPayload: null }
214
- const enrichment: ChannelPayloadEnrichment = {
194
+ const payloadEnrichment: ChannelPayloadEnrichment = {
215
195
  channelContentType: link.channelContentType ?? null,
216
196
  channelPayload: link.channelPayload ?? null,
217
197
  interactiveState: link.interactiveState ?? null,
218
198
  channelMetadata: link.channelMetadata ?? null,
219
199
  }
220
- return { ...r, _channelPayload: enrichment }
200
+
201
+ const conversation = link.externalConversationId
202
+ ? conversationsById.get(link.externalConversationId)
203
+ : undefined
204
+ const contactEnrichment: ChannelContactEnrichment | null = conversation
205
+ ? {
206
+ contactPersonId: conversation.contactPersonId ?? null,
207
+ assignedUserId: conversation.assignedUserId ?? null,
208
+ subject: conversation.subject ?? null,
209
+ }
210
+ : null
211
+
212
+ return {
213
+ ...r,
214
+ _channel: channelEnrichment,
215
+ _channelPayload: payloadEnrichment,
216
+ _channelContact: contactEnrichment,
217
+ }
221
218
  })
222
219
  },
223
220
  }
224
221
 
225
- export type ChannelPayloadEnrichment = {
226
- channelContentType: string | null
227
- channelPayload: Record<string, unknown> | null
228
- interactiveState: Record<string, unknown> | null
229
- channelMetadata: Record<string, unknown> | null
230
- }
231
-
232
222
  // ── _reactions ──────────────────────────────────────────────────────────────
233
223
 
234
224
  const messageReactionsEnricher: ResponseEnricher<
@@ -323,91 +313,11 @@ function groupReactions(rows: MessageReaction[], currentUserId: string): Reactio
323
313
  return Array.from(map.values()).sort((a, b) => b.count - a.count)
324
314
  }
325
315
 
326
- // ── _channelContact ─────────────────────────────────────────────────────────
327
-
328
- const conversationContactEnricher: ResponseEnricher<
329
- MessageRecord,
330
- { _channelContact?: ChannelContactEnrichment | null }
331
- > = {
332
- id: 'communication_channels.conversation-contact',
333
- targetEntity: 'messages.message',
334
- features: ['communication_channels.view'],
335
- priority: 15,
336
- timeout: 2000,
337
- fallback: { _channelContact: null },
338
- critical: false,
339
-
340
- async enrichOne(record, ctx) {
341
- const [out] = await this.enrichMany!([record], ctx)
342
- return out
343
- },
344
-
345
- async enrichMany(records, ctx) {
346
- if (records.length === 0) return records
347
- const messageIds = records.map((r) => r.id)
348
- const em = ctxEm(ctx)
349
- const dscope = { tenantId: ctx.tenantId, organizationId: ctx.organizationId ?? null }
350
- const links = await findWithDecryption(
351
- em,
352
- MessageChannelLink,
353
- {
354
- messageId: { $in: messageIds },
355
- tenantId: ctx.tenantId,
356
- organizationId: ctx.organizationId ?? null,
357
- },
358
- undefined,
359
- dscope,
360
- )
361
- const conversationIds = Array.from(
362
- new Set(links.map((l) => l.externalConversationId).filter(Boolean)),
363
- )
364
- let conversationsById = new Map<string, ExternalConversation>()
365
- if (conversationIds.length > 0) {
366
- const conversations = await findWithDecryption(
367
- em,
368
- ExternalConversation,
369
- {
370
- id: { $in: conversationIds },
371
- tenantId: ctx.tenantId,
372
- organizationId: ctx.organizationId ?? null,
373
- },
374
- undefined,
375
- dscope,
376
- )
377
- conversationsById = new Map(conversations.map((c) => [c.id, c]))
378
- }
379
-
380
- const linksByMessage = new Map<string, MessageChannelLink>()
381
- for (const link of links) linksByMessage.set(link.messageId, link)
382
-
383
- return records.map((r) => {
384
- const link = linksByMessage.get(r.id)
385
- if (!link) return { ...r, _channelContact: null }
386
- const conversation = conversationsById.get(link.externalConversationId)
387
- if (!conversation) return { ...r, _channelContact: null }
388
- const enrichment: ChannelContactEnrichment = {
389
- contactPersonId: conversation.contactPersonId ?? null,
390
- assignedUserId: conversation.assignedUserId ?? null,
391
- subject: conversation.subject ?? null,
392
- }
393
- return { ...r, _channelContact: enrichment }
394
- })
395
- },
396
- }
397
-
398
- export type ChannelContactEnrichment = {
399
- contactPersonId: string | null
400
- assignedUserId: string | null
401
- subject: string | null
402
- }
403
-
404
316
  // ── Export ──────────────────────────────────────────────────────────────────
405
317
 
406
318
  export const enrichers: ResponseEnricher[] = [
407
319
  messageChannelEnricher as unknown as ResponseEnricher,
408
- messageChannelPayloadEnricher as unknown as ResponseEnricher,
409
320
  messageReactionsEnricher as unknown as ResponseEnricher,
410
- conversationContactEnricher as unknown as ResponseEnricher,
411
321
  ]
412
322
 
413
323
  export default enrichers
@@ -8,6 +8,9 @@ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuarde
8
8
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
9
9
  import { useT } from '@open-mercato/shared/lib/i18n/context'
10
10
  import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
11
+ import { hasFeature } from '@open-mercato/shared/security/features'
12
+
13
+ const MANAGE_FEATURE = 'configs.cache.manage'
11
14
 
12
15
  const API_PATH = '/api/configs/cache'
13
16
  const CACHE_MUTATION_CONTEXT_ID = 'configs-cache-panel'
@@ -87,7 +90,7 @@ export function CachePanel() {
87
90
  {
88
91
  method: 'POST',
89
92
  headers: { 'content-type': 'application/json' },
90
- body: JSON.stringify({ features: ['configs.cache.manage'] }),
93
+ body: JSON.stringify({ features: [MANAGE_FEATURE] }),
91
94
  },
92
95
  {
93
96
  errorMessage: t('configs.cache.loadError', 'Failed to load cache statistics.'),
@@ -98,8 +101,8 @@ export function CachePanel() {
98
101
  const granted = Array.isArray(payload?.granted)
99
102
  ? (payload.granted as unknown[]).filter((feature) => typeof feature === 'string') as string[]
100
103
  : []
101
- const hasFeature = payload?.ok === true || granted.includes('configs.cache.manage')
102
- setCanManage(hasFeature)
104
+ const canManageFeature = payload?.ok === true || hasFeature(granted, MANAGE_FEATURE)
105
+ setCanManage(canManageFeature)
103
106
  } catch {
104
107
  if (!cancelled) setCanManage(false)
105
108
  } finally {
@@ -7,6 +7,7 @@ import {
7
7
  overrideListQuerySchema,
8
8
  getToggleOverrideQuerySchema,
9
9
  featureToggleOverrideResponseSchema,
10
+ overrideRowIdSchema,
10
11
  } from '../data/validators'
11
12
 
12
13
  export const featureTogglesTag = 'Feature Toggles'
@@ -42,16 +43,20 @@ export const featureToggleListResponseSchema = z.object({
42
43
 
43
44
  export { toggleCreateSchema, toggleUpdateSchema }
44
45
 
46
+ // Documents the actual override-list row shape returned by GET /api/feature_toggles/overrides
47
+ // (see lib/queries.ts → getOverrides). Each row reports whether the tenant has a
48
+ // per-tenant override via `isOverride`; inherited rows (no override) carry an
49
+ // empty-string `id` sentinel instead of a UUID, hence `overrideRowIdSchema`.
45
50
  export const featureToggleOverrideSchema = z
46
51
  .object({
47
- id: z.string().uuid(),
52
+ id: overrideRowIdSchema,
48
53
  toggleId: z.string().uuid(),
49
- overrideState: z.enum(['enabled', 'disabled', 'inherit']),
54
+ tenantName: z.string(),
55
+ tenantId: z.string().uuid(),
50
56
  identifier: z.string(),
51
57
  name: z.string(),
52
- category: z.string().nullable().optional(),
53
- defaultState: z.boolean(),
54
- tenantName: z.string().nullable().optional(),
58
+ category: z.string(),
59
+ isOverride: z.boolean(),
55
60
  })
56
61
  .passthrough()
57
62
 
@@ -90,8 +90,13 @@ export const featureToggleSchema = z.object({
90
90
  updatedAt: z.string().optional(),
91
91
  })
92
92
 
93
+ // Inherited rows (no per-tenant override) intentionally carry an empty-string
94
+ // `id` sentinel instead of a UUID, so the row id must accept both a UUID (real
95
+ // override rows) and the empty inherited sentinel. See lib/queries.ts.
96
+ export const overrideRowIdSchema = z.union([z.string().uuid(), z.literal('')])
97
+
93
98
  export const overrideListResponseSchema = z.object({
94
- id: z.string().uuid(),
99
+ id: overrideRowIdSchema,
95
100
  toggleId: z.string().uuid(),
96
101
  tenantName: z.string(),
97
102
  tenantId: z.string().uuid(),
@@ -10,10 +10,10 @@ import { cn } from '@open-mercato/shared/lib/utils'
10
10
  type IntegrationsData = Record<string, ExternalIdMapping>
11
11
 
12
12
  const SYNC_STATUS_STYLES: Record<ExternalIdMapping['syncStatus'], { dot: string; label: string }> = {
13
- synced: { dot: 'bg-green-500', label: 'integrations.syncStatus.synced' },
14
- pending: { dot: 'bg-yellow-500', label: 'integrations.syncStatus.pending' },
15
- error: { dot: 'bg-red-500', label: 'integrations.syncStatus.error' },
16
- not_synced: { dot: 'bg-gray-400', label: 'integrations.syncStatus.notSynced' },
13
+ synced: { dot: 'bg-status-success-icon', label: 'integrations.syncStatus.synced' },
14
+ pending: { dot: 'bg-status-warning-icon', label: 'integrations.syncStatus.pending' },
15
+ error: { dot: 'bg-status-error-icon', label: 'integrations.syncStatus.error' },
16
+ not_synced: { dot: 'bg-status-neutral-icon', label: 'integrations.syncStatus.notSynced' },
17
17
  }
18
18
 
19
19
  function SyncStatusBadge({ status, lastSynced }: { status: ExternalIdMapping['syncStatus']; lastSynced?: string }) {