@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.
- package/dist/modules/communication_channels/api/get/channels/[id]/health/route.js +13 -19
- package/dist/modules/communication_channels/api/get/channels/[id]/health/route.js.map +2 -2
- package/dist/modules/communication_channels/data/enrichers.js +67 -162
- package/dist/modules/communication_channels/data/enrichers.js.map +2 -2
- package/dist/modules/configs/components/CachePanel.js +5 -3
- package/dist/modules/configs/components/CachePanel.js.map +2 -2
- package/dist/modules/feature_toggles/api/openapi.js +7 -6
- package/dist/modules/feature_toggles/api/openapi.js.map +2 -2
- package/dist/modules/feature_toggles/data/validators.js +3 -1
- package/dist/modules/feature_toggles/data/validators.js.map +2 -2
- package/dist/modules/integrations/widgets/injection/external-ids/widget.client.js +4 -4
- package/dist/modules/integrations/widgets/injection/external-ids/widget.client.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/communication_channels/api/get/channels/[id]/health/route.ts +29 -20
- package/src/modules/communication_channels/data/enrichers.ts +134 -224
- package/src/modules/configs/components/CachePanel.tsx +6 -3
- package/src/modules/feature_toggles/api/openapi.ts +10 -5
- package/src/modules/feature_toggles/data/validators.ts +6 -1
- package/src/modules/integrations/widgets/injection/external-ids/widget.client.tsx +4 -4
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
128
|
-
organizationId
|
|
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
|
-
//
|
|
138
|
-
//
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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)
|
|
181
|
+
if (!link) {
|
|
182
|
+
return { ...r, _channel: null, _channelPayload: null, _channelContact: null }
|
|
183
|
+
}
|
|
184
|
+
|
|
152
185
|
const channel = channelByMessageId.get(r.id)
|
|
153
|
-
const
|
|
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
|
|
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
|
-
|
|
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: [
|
|
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
|
|
102
|
-
setCanManage(
|
|
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:
|
|
52
|
+
id: overrideRowIdSchema,
|
|
48
53
|
toggleId: z.string().uuid(),
|
|
49
|
-
|
|
54
|
+
tenantName: z.string(),
|
|
55
|
+
tenantId: z.string().uuid(),
|
|
50
56
|
identifier: z.string(),
|
|
51
57
|
name: z.string(),
|
|
52
|
-
category: z.string()
|
|
53
|
-
|
|
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:
|
|
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-
|
|
14
|
-
pending: { dot: 'bg-
|
|
15
|
-
error: { dot: 'bg-
|
|
16
|
-
not_synced: { dot: 'bg-
|
|
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 }) {
|