@open-mercato/core 0.6.6-develop.6130.1.7e07d10dbb → 0.6.6-develop.6135.1.95b98004bd
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/data/enrichers.js +67 -162
- package/dist/modules/communication_channels/data/enrichers.js.map +2 -2
- package/dist/modules/customer_accounts/api/admin/users-invite.js +9 -0
- package/dist/modules/customer_accounts/api/admin/users-invite.js.map +2 -2
- package/dist/modules/customer_accounts/api/portal/users-invite.js +9 -0
- package/dist/modules/customer_accounts/api/portal/users-invite.js.map +2 -2
- package/dist/modules/customer_accounts/events.js +1 -0
- package/dist/modules/customer_accounts/events.js.map +2 -2
- package/dist/modules/notifications/api/unread-count/route.js +6 -3
- package/dist/modules/notifications/api/unread-count/route.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/communication_channels/data/enrichers.ts +134 -224
- package/src/modules/customer_accounts/AGENTS.md +1 -0
- package/src/modules/customer_accounts/api/admin/users-invite.ts +10 -0
- package/src/modules/customer_accounts/api/portal/users-invite.ts +10 -0
- package/src/modules/customer_accounts/events.ts +1 -0
- package/src/modules/notifications/api/unread-count/route.ts +6 -4
|
@@ -5,48 +5,6 @@ import {
|
|
|
5
5
|
MessageChannelLink,
|
|
6
6
|
MessageReaction
|
|
7
7
|
} from "./entities.js";
|
|
8
|
-
async function resolveChannelsByMessageId(em, links, tenantId, organizationId) {
|
|
9
|
-
if (links.length === 0) return /* @__PURE__ */ new Map();
|
|
10
|
-
const conversationIds = Array.from(
|
|
11
|
-
new Set(
|
|
12
|
-
links.map((l) => l.externalConversationId).filter(
|
|
13
|
-
(id) => typeof id === "string" && id.length > 0
|
|
14
|
-
)
|
|
15
|
-
)
|
|
16
|
-
);
|
|
17
|
-
if (conversationIds.length === 0) return /* @__PURE__ */ new Map();
|
|
18
|
-
const dscope = { tenantId, organizationId };
|
|
19
|
-
const conversations = await findWithDecryption(
|
|
20
|
-
em,
|
|
21
|
-
ExternalConversation,
|
|
22
|
-
{ id: { $in: conversationIds }, tenantId, organizationId },
|
|
23
|
-
void 0,
|
|
24
|
-
dscope
|
|
25
|
-
);
|
|
26
|
-
const channelIdByConversation = /* @__PURE__ */ new Map();
|
|
27
|
-
for (const conv of conversations) {
|
|
28
|
-
channelIdByConversation.set(conv.id, conv.channelId);
|
|
29
|
-
}
|
|
30
|
-
const channelIds = Array.from(new Set(Array.from(channelIdByConversation.values())));
|
|
31
|
-
if (channelIds.length === 0) return /* @__PURE__ */ new Map();
|
|
32
|
-
const channels = await findWithDecryption(
|
|
33
|
-
em,
|
|
34
|
-
CommunicationChannel,
|
|
35
|
-
{ id: { $in: channelIds }, tenantId, organizationId, deletedAt: null },
|
|
36
|
-
void 0,
|
|
37
|
-
dscope
|
|
38
|
-
);
|
|
39
|
-
const channelsById = new Map(
|
|
40
|
-
channels.map((c) => [c.id, c])
|
|
41
|
-
);
|
|
42
|
-
const result = /* @__PURE__ */ new Map();
|
|
43
|
-
for (const link of links) {
|
|
44
|
-
const channelId = link.externalConversationId ? channelIdByConversation.get(link.externalConversationId) : void 0;
|
|
45
|
-
const channel = channelId ? channelsById.get(channelId) : void 0;
|
|
46
|
-
if (channel) result.set(link.messageId, channel);
|
|
47
|
-
}
|
|
48
|
-
return result;
|
|
49
|
-
}
|
|
50
8
|
function ctxEm(ctx) {
|
|
51
9
|
return ctx.em;
|
|
52
10
|
}
|
|
@@ -55,8 +13,8 @@ const messageChannelEnricher = {
|
|
|
55
13
|
targetEntity: "messages.message",
|
|
56
14
|
features: ["communication_channels.view"],
|
|
57
15
|
priority: 30,
|
|
58
|
-
timeout:
|
|
59
|
-
fallback: { _channel: null },
|
|
16
|
+
timeout: 2e3,
|
|
17
|
+
fallback: { _channel: null, _channelPayload: null, _channelContact: null },
|
|
60
18
|
critical: false,
|
|
61
19
|
async enrichOne(record, ctx) {
|
|
62
20
|
const [out] = await this.enrichMany([record], ctx);
|
|
@@ -66,84 +24,95 @@ const messageChannelEnricher = {
|
|
|
66
24
|
if (records.length === 0) return records;
|
|
67
25
|
const messageIds = records.map((r) => r.id);
|
|
68
26
|
const em = ctxEm(ctx);
|
|
69
|
-
const
|
|
27
|
+
const tenantId = ctx.tenantId;
|
|
28
|
+
const organizationId = ctx.organizationId ?? null;
|
|
29
|
+
const dscope = { tenantId, organizationId };
|
|
70
30
|
const links = await findWithDecryption(
|
|
71
31
|
em,
|
|
72
32
|
MessageChannelLink,
|
|
73
33
|
{
|
|
74
34
|
messageId: { $in: messageIds },
|
|
75
|
-
tenantId
|
|
76
|
-
organizationId
|
|
35
|
+
tenantId,
|
|
36
|
+
organizationId
|
|
77
37
|
},
|
|
78
|
-
// Result set is already bounded by the `messageId $in messageIds` filter
|
|
79
|
-
// (the host list endpoint caps the page at 100), matching the other
|
|
80
|
-
// enrichers — no separate row limit needed.
|
|
81
38
|
void 0,
|
|
82
39
|
dscope
|
|
83
40
|
);
|
|
84
|
-
const channelByMessageId = await resolveChannelsByMessageId(
|
|
85
|
-
em,
|
|
86
|
-
links,
|
|
87
|
-
ctx.tenantId,
|
|
88
|
-
ctx.organizationId ?? null
|
|
89
|
-
);
|
|
90
41
|
const linksByMessage = /* @__PURE__ */ new Map();
|
|
91
42
|
for (const link of links) linksByMessage.set(link.messageId, link);
|
|
43
|
+
const conversationIds = Array.from(
|
|
44
|
+
new Set(
|
|
45
|
+
links.map((l) => l.externalConversationId).filter((id) => typeof id === "string" && id.length > 0)
|
|
46
|
+
)
|
|
47
|
+
);
|
|
48
|
+
const conversationsById = /* @__PURE__ */ new Map();
|
|
49
|
+
if (conversationIds.length > 0) {
|
|
50
|
+
const conversations = await findWithDecryption(
|
|
51
|
+
em,
|
|
52
|
+
ExternalConversation,
|
|
53
|
+
{ id: { $in: conversationIds }, tenantId, organizationId },
|
|
54
|
+
void 0,
|
|
55
|
+
dscope
|
|
56
|
+
);
|
|
57
|
+
for (const conversation of conversations) {
|
|
58
|
+
conversationsById.set(conversation.id, conversation);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const channelIds = Array.from(
|
|
62
|
+
new Set(
|
|
63
|
+
Array.from(conversationsById.values()).map((conversation) => conversation.channelId).filter((id) => typeof id === "string" && id.length > 0)
|
|
64
|
+
)
|
|
65
|
+
);
|
|
66
|
+
const channelsById = /* @__PURE__ */ new Map();
|
|
67
|
+
if (channelIds.length > 0) {
|
|
68
|
+
const channels = await findWithDecryption(
|
|
69
|
+
em,
|
|
70
|
+
CommunicationChannel,
|
|
71
|
+
{ id: { $in: channelIds }, tenantId, organizationId, deletedAt: null },
|
|
72
|
+
void 0,
|
|
73
|
+
dscope
|
|
74
|
+
);
|
|
75
|
+
for (const channel of channels) channelsById.set(channel.id, channel);
|
|
76
|
+
}
|
|
77
|
+
const channelByMessageId = /* @__PURE__ */ new Map();
|
|
78
|
+
for (const link of links) {
|
|
79
|
+
if (!link.externalConversationId) continue;
|
|
80
|
+
const conversation = conversationsById.get(link.externalConversationId);
|
|
81
|
+
if (!conversation) continue;
|
|
82
|
+
const channel = conversation.channelId ? channelsById.get(conversation.channelId) : void 0;
|
|
83
|
+
if (channel) channelByMessageId.set(link.messageId, channel);
|
|
84
|
+
}
|
|
92
85
|
return records.map((r) => {
|
|
93
86
|
const link = linksByMessage.get(r.id);
|
|
94
|
-
if (!link)
|
|
87
|
+
if (!link) {
|
|
88
|
+
return { ...r, _channel: null, _channelPayload: null, _channelContact: null };
|
|
89
|
+
}
|
|
95
90
|
const channel = channelByMessageId.get(r.id);
|
|
96
|
-
const
|
|
91
|
+
const channelEnrichment = {
|
|
97
92
|
providerKey: link.providerKey,
|
|
98
93
|
channelType: link.channelType,
|
|
99
94
|
direction: link.direction,
|
|
100
95
|
deliveryStatus: link.deliveryStatus ?? null,
|
|
101
96
|
capabilities: channel?.capabilities ?? null
|
|
102
97
|
};
|
|
103
|
-
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
};
|
|
107
|
-
const messageChannelPayloadEnricher = {
|
|
108
|
-
id: "communication_channels.message-channel-payload",
|
|
109
|
-
targetEntity: "messages.message",
|
|
110
|
-
features: ["communication_channels.view"],
|
|
111
|
-
priority: 20,
|
|
112
|
-
timeout: 1500,
|
|
113
|
-
fallback: { _channelPayload: null },
|
|
114
|
-
critical: false,
|
|
115
|
-
async enrichOne(record, ctx) {
|
|
116
|
-
const [out] = await this.enrichMany([record], ctx);
|
|
117
|
-
return out;
|
|
118
|
-
},
|
|
119
|
-
async enrichMany(records, ctx) {
|
|
120
|
-
if (records.length === 0) return records;
|
|
121
|
-
const messageIds = records.map((r) => r.id);
|
|
122
|
-
const em = ctxEm(ctx);
|
|
123
|
-
const dscope = { tenantId: ctx.tenantId, organizationId: ctx.organizationId ?? null };
|
|
124
|
-
const links = await findWithDecryption(
|
|
125
|
-
em,
|
|
126
|
-
MessageChannelLink,
|
|
127
|
-
{
|
|
128
|
-
messageId: { $in: messageIds },
|
|
129
|
-
tenantId: ctx.tenantId,
|
|
130
|
-
organizationId: ctx.organizationId ?? null
|
|
131
|
-
},
|
|
132
|
-
void 0,
|
|
133
|
-
dscope
|
|
134
|
-
);
|
|
135
|
-
const byMessage = /* @__PURE__ */ new Map();
|
|
136
|
-
for (const link of links) byMessage.set(link.messageId, link);
|
|
137
|
-
return records.map((r) => {
|
|
138
|
-
const link = byMessage.get(r.id);
|
|
139
|
-
if (!link) return { ...r, _channelPayload: null };
|
|
140
|
-
const enrichment = {
|
|
98
|
+
const payloadEnrichment = {
|
|
141
99
|
channelContentType: link.channelContentType ?? null,
|
|
142
100
|
channelPayload: link.channelPayload ?? null,
|
|
143
101
|
interactiveState: link.interactiveState ?? null,
|
|
144
102
|
channelMetadata: link.channelMetadata ?? null
|
|
145
103
|
};
|
|
146
|
-
|
|
104
|
+
const conversation = link.externalConversationId ? conversationsById.get(link.externalConversationId) : void 0;
|
|
105
|
+
const contactEnrichment = conversation ? {
|
|
106
|
+
contactPersonId: conversation.contactPersonId ?? null,
|
|
107
|
+
assignedUserId: conversation.assignedUserId ?? null,
|
|
108
|
+
subject: conversation.subject ?? null
|
|
109
|
+
} : null;
|
|
110
|
+
return {
|
|
111
|
+
...r,
|
|
112
|
+
_channel: channelEnrichment,
|
|
113
|
+
_channelPayload: payloadEnrichment,
|
|
114
|
+
_channelContact: contactEnrichment
|
|
115
|
+
};
|
|
147
116
|
});
|
|
148
117
|
}
|
|
149
118
|
};
|
|
@@ -210,73 +179,9 @@ function groupReactions(rows, currentUserId) {
|
|
|
210
179
|
}
|
|
211
180
|
return Array.from(map.values()).sort((a, b) => b.count - a.count);
|
|
212
181
|
}
|
|
213
|
-
const conversationContactEnricher = {
|
|
214
|
-
id: "communication_channels.conversation-contact",
|
|
215
|
-
targetEntity: "messages.message",
|
|
216
|
-
features: ["communication_channels.view"],
|
|
217
|
-
priority: 15,
|
|
218
|
-
timeout: 2e3,
|
|
219
|
-
fallback: { _channelContact: null },
|
|
220
|
-
critical: false,
|
|
221
|
-
async enrichOne(record, ctx) {
|
|
222
|
-
const [out] = await this.enrichMany([record], ctx);
|
|
223
|
-
return out;
|
|
224
|
-
},
|
|
225
|
-
async enrichMany(records, ctx) {
|
|
226
|
-
if (records.length === 0) return records;
|
|
227
|
-
const messageIds = records.map((r) => r.id);
|
|
228
|
-
const em = ctxEm(ctx);
|
|
229
|
-
const dscope = { tenantId: ctx.tenantId, organizationId: ctx.organizationId ?? null };
|
|
230
|
-
const links = await findWithDecryption(
|
|
231
|
-
em,
|
|
232
|
-
MessageChannelLink,
|
|
233
|
-
{
|
|
234
|
-
messageId: { $in: messageIds },
|
|
235
|
-
tenantId: ctx.tenantId,
|
|
236
|
-
organizationId: ctx.organizationId ?? null
|
|
237
|
-
},
|
|
238
|
-
void 0,
|
|
239
|
-
dscope
|
|
240
|
-
);
|
|
241
|
-
const conversationIds = Array.from(
|
|
242
|
-
new Set(links.map((l) => l.externalConversationId).filter(Boolean))
|
|
243
|
-
);
|
|
244
|
-
let conversationsById = /* @__PURE__ */ new Map();
|
|
245
|
-
if (conversationIds.length > 0) {
|
|
246
|
-
const conversations = await findWithDecryption(
|
|
247
|
-
em,
|
|
248
|
-
ExternalConversation,
|
|
249
|
-
{
|
|
250
|
-
id: { $in: conversationIds },
|
|
251
|
-
tenantId: ctx.tenantId,
|
|
252
|
-
organizationId: ctx.organizationId ?? null
|
|
253
|
-
},
|
|
254
|
-
void 0,
|
|
255
|
-
dscope
|
|
256
|
-
);
|
|
257
|
-
conversationsById = new Map(conversations.map((c) => [c.id, c]));
|
|
258
|
-
}
|
|
259
|
-
const linksByMessage = /* @__PURE__ */ new Map();
|
|
260
|
-
for (const link of links) linksByMessage.set(link.messageId, link);
|
|
261
|
-
return records.map((r) => {
|
|
262
|
-
const link = linksByMessage.get(r.id);
|
|
263
|
-
if (!link) return { ...r, _channelContact: null };
|
|
264
|
-
const conversation = conversationsById.get(link.externalConversationId);
|
|
265
|
-
if (!conversation) return { ...r, _channelContact: null };
|
|
266
|
-
const enrichment = {
|
|
267
|
-
contactPersonId: conversation.contactPersonId ?? null,
|
|
268
|
-
assignedUserId: conversation.assignedUserId ?? null,
|
|
269
|
-
subject: conversation.subject ?? null
|
|
270
|
-
};
|
|
271
|
-
return { ...r, _channelContact: enrichment };
|
|
272
|
-
});
|
|
273
|
-
}
|
|
274
|
-
};
|
|
275
182
|
const enrichers = [
|
|
276
183
|
messageChannelEnricher,
|
|
277
|
-
|
|
278
|
-
messageReactionsEnricher,
|
|
279
|
-
conversationContactEnricher
|
|
184
|
+
messageReactionsEnricher
|
|
280
185
|
];
|
|
281
186
|
var enrichers_default = enrichers;
|
|
282
187
|
export {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/communication_channels/data/enrichers.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { EnricherContext, ResponseEnricher } from '@open-mercato/shared/lib/crud/response-enricher'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport {\n CommunicationChannel,\n ExternalConversation,\n MessageChannelLink,\n MessageReaction,\n} from './entities'\n\n/**\n * Resolve channels by `id` (not `providerKey`) \u2014 multi-user channels share the\n * same `providerKey` (e.g. two users with Gmail), so a providerKey-keyed Map\n * collapses them and returns the wrong owner's capabilities snapshot. The hop\n * goes `MessageChannelLink \u2192 ExternalConversation \u2192 CommunicationChannel`.\n *\n * Returns a Map keyed by the platform Message id so enrichers can read\n * `channelByMessageId.get(record.id)` without further joins.\n */\nasync function resolveChannelsByMessageId(\n em: EntityManager,\n links: MessageChannelLink[],\n tenantId: string,\n organizationId: string | null,\n): Promise<Map<string, CommunicationChannel>> {\n if (links.length === 0) return new Map()\n const conversationIds = Array.from(\n new Set(\n links.map((l) => (l as { externalConversationId?: string }).externalConversationId).filter(\n (id): id is string => typeof id === 'string' && id.length > 0,\n ),\n ),\n )\n if (conversationIds.length === 0) return new Map()\n const dscope = { tenantId, organizationId }\n const conversations = await findWithDecryption(\n em,\n ExternalConversation,\n { id: { $in: conversationIds }, tenantId, organizationId },\n undefined,\n dscope,\n )\n const channelIdByConversation = new Map<string, string>()\n for (const conv of conversations) {\n channelIdByConversation.set(conv.id, conv.channelId)\n }\n const channelIds = Array.from(new Set(Array.from(channelIdByConversation.values())))\n if (channelIds.length === 0) return new Map()\n const channels = await findWithDecryption(\n em,\n CommunicationChannel,\n { id: { $in: channelIds }, tenantId, organizationId, deletedAt: null },\n undefined,\n dscope,\n )\n const channelsById = new Map<string, CommunicationChannel>(\n channels.map((c) => [c.id, c]),\n )\n const result = new Map<string, CommunicationChannel>()\n for (const link of links) {\n const channelId = link.externalConversationId\n ? channelIdByConversation.get(link.externalConversationId)\n : undefined\n const channel = channelId ? channelsById.get(channelId) : undefined\n if (channel) result.set(link.messageId, channel)\n }\n return result\n}\n\n/**\n * Response enrichers for the messages.message entity.\n *\n * The hub declares 4 enrichers; downstream hosts (Messages module's `/api/messages`\n * CRUD route + future provider routes) opt in via `makeCrudRoute({ enrichers: { entityId: 'messages.message' } })`.\n *\n * - `_channel` \u2192 channel metadata + capabilities snapshot\n * - `_channelPayload` \u2192 channel-native payload (Block Kit / interactive / email MIME / \u2026)\n * - `_reactions` \u2192 grouped emoji counts + users + reactedByMe\n * - `_channelContact` \u2192 CRM person preview (email + display name)\n *\n * Per `packages/shared/lib/crud/response-enricher` rules:\n * - `enrichMany` is implemented for every enricher (N+1 prevention).\n * - Enriched fields are namespaced with `_channel*` / `_reactions` prefixes.\n * - Enrichers are read-only; no writes via the EntityManager.\n * - Each enricher is feature-gated by `communication_channels.view`.\n */\n\ntype MessageRecord = Record<string, unknown> & {\n id: string\n threadId?: string | null\n}\n\ntype ResolvedCtx = EnricherContext & {\n em: EntityManager\n}\n\nfunction ctxEm(ctx: EnricherContext): EntityManager {\n return ctx.em as EntityManager\n}\n\n// \u2500\u2500 _channel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst messageChannelEnricher: ResponseEnricher<MessageRecord, { _channel?: ChannelEnrichment | null }> = {\n id: 'communication_channels.message-channel',\n targetEntity: 'messages.message',\n features: ['communication_channels.view'],\n priority: 30,\n timeout: 1500,\n fallback: { _channel: null },\n critical: false,\n\n async enrichOne(record, ctx) {\n const [out] = await this.enrichMany!([record], ctx)\n return out\n },\n\n async enrichMany(records, ctx) {\n if (records.length === 0) return records\n const messageIds = records.map((r) => r.id)\n const em = ctxEm(ctx)\n const dscope = { tenantId: ctx.tenantId, organizationId: ctx.organizationId ?? null }\n const links = await findWithDecryption(\n em,\n MessageChannelLink,\n {\n messageId: { $in: messageIds },\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? null,\n },\n // Result set is already bounded by the `messageId $in messageIds` filter\n // (the host list endpoint caps the page at 100), matching the other\n // enrichers \u2014 no separate row limit needed.\n undefined,\n dscope,\n )\n\n // Resolve channels via the `link \u2192 ExternalConversation \u2192 CommunicationChannel`\n // path. Keys are platform Message ids \u2014 direct mapping per row.\n const channelByMessageId = await resolveChannelsByMessageId(\n em,\n links,\n ctx.tenantId as string,\n ctx.organizationId ?? null,\n )\n\n const linksByMessage = new Map<string, MessageChannelLink>()\n for (const link of links) linksByMessage.set(link.messageId, link)\n\n return records.map((r) => {\n const link = linksByMessage.get(r.id)\n if (!link) return { ...r, _channel: null }\n const channel = channelByMessageId.get(r.id)\n const enrichment: ChannelEnrichment = {\n providerKey: link.providerKey,\n channelType: link.channelType,\n direction: link.direction,\n deliveryStatus: link.deliveryStatus ?? null,\n capabilities: (channel?.capabilities as Record<string, unknown> | null) ?? null,\n }\n return { ...r, _channel: enrichment }\n })\n },\n}\n\nexport type ChannelEnrichment = {\n providerKey: string\n channelType: string\n direction: 'inbound' | 'outbound' | string\n deliveryStatus: string | null\n capabilities: Record<string, unknown> | null\n}\n\n// \u2500\u2500 _channelPayload \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst messageChannelPayloadEnricher: ResponseEnricher<\n MessageRecord,\n { _channelPayload?: ChannelPayloadEnrichment | null }\n> = {\n id: 'communication_channels.message-channel-payload',\n targetEntity: 'messages.message',\n features: ['communication_channels.view'],\n priority: 20,\n timeout: 1500,\n fallback: { _channelPayload: null },\n critical: false,\n\n async enrichOne(record, ctx) {\n const [out] = await this.enrichMany!([record], ctx)\n return out\n },\n\n async enrichMany(records, ctx) {\n if (records.length === 0) return records\n const messageIds = records.map((r) => r.id)\n const em = ctxEm(ctx)\n const dscope = { tenantId: ctx.tenantId, organizationId: ctx.organizationId ?? null }\n const links = await findWithDecryption(\n em,\n MessageChannelLink,\n {\n messageId: { $in: messageIds },\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n const byMessage = new Map<string, MessageChannelLink>()\n for (const link of links) byMessage.set(link.messageId, link)\n\n return records.map((r) => {\n const link = byMessage.get(r.id)\n if (!link) return { ...r, _channelPayload: null }\n const enrichment: ChannelPayloadEnrichment = {\n channelContentType: link.channelContentType ?? null,\n channelPayload: link.channelPayload ?? null,\n interactiveState: link.interactiveState ?? null,\n channelMetadata: link.channelMetadata ?? null,\n }\n return { ...r, _channelPayload: enrichment }\n })\n },\n}\n\nexport type ChannelPayloadEnrichment = {\n channelContentType: string | null\n channelPayload: Record<string, unknown> | null\n interactiveState: Record<string, unknown> | null\n channelMetadata: Record<string, unknown> | null\n}\n\n// \u2500\u2500 _reactions \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst messageReactionsEnricher: ResponseEnricher<\n MessageRecord,\n { _reactions?: ReactionGroup[] }\n> = {\n id: 'communication_channels.message-reactions',\n targetEntity: 'messages.message',\n features: ['communication_channels.view'],\n priority: 25,\n timeout: 1500,\n fallback: { _reactions: [] },\n critical: false,\n\n async enrichOne(record, ctx) {\n const [out] = await this.enrichMany!([record], ctx)\n return out\n },\n\n async enrichMany(records, ctx) {\n if (records.length === 0) return records\n const messageIds = records.map((r) => r.id)\n const em = ctxEm(ctx)\n const dscope = { tenantId: ctx.tenantId, organizationId: ctx.organizationId ?? null }\n const reactions = await findWithDecryption(\n em,\n MessageReaction,\n {\n messageId: { $in: messageIds },\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n\n const byMessage = new Map<string, MessageReaction[]>()\n for (const reaction of reactions) {\n const existing = byMessage.get(reaction.messageId) ?? []\n existing.push(reaction)\n byMessage.set(reaction.messageId, existing)\n }\n\n return records.map((r) => {\n const rows = byMessage.get(r.id) ?? []\n const grouped = groupReactions(rows, ctx.userId)\n return { ...r, _reactions: grouped }\n })\n },\n}\n\nexport type ReactionGroup = {\n emoji: string\n count: number\n users: Array<{\n userId?: string | null\n externalId?: string | null\n displayName?: string | null\n providerKey?: string | null\n }>\n reactedByMe: boolean\n /**\n * MessageReaction.id of the current user's reaction row, if any. Exposed so\n * the reaction-bar UI can issue\n * `DELETE /api/communication_channels/messages/{messageId}/reactions/{myReactionId}`\n * when the user toggles their own reaction off. Null when `reactedByMe` is\n * false or the row was added by an external participant (provider-side).\n */\n myReactionId: string | null\n}\n\nfunction groupReactions(rows: MessageReaction[], currentUserId: string): ReactionGroup[] {\n const map = new Map<string, ReactionGroup>()\n for (const row of rows) {\n const key = row.emoji\n if (!map.has(key)) {\n map.set(key, { emoji: key, count: 0, users: [], reactedByMe: false, myReactionId: null })\n }\n const group = map.get(key)!\n group.count += 1\n group.users.push({\n userId: row.reactedByUserId ?? null,\n externalId: row.reactedByExternalId ?? null,\n displayName: row.reactedByDisplayName ?? null,\n providerKey: row.providerKey ?? null,\n })\n if (row.reactedByUserId && row.reactedByUserId === currentUserId) {\n group.reactedByMe = true\n if (!group.myReactionId) group.myReactionId = row.id ?? null\n }\n }\n return Array.from(map.values()).sort((a, b) => b.count - a.count)\n}\n\n// \u2500\u2500 _channelContact \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst conversationContactEnricher: ResponseEnricher<\n MessageRecord,\n { _channelContact?: ChannelContactEnrichment | null }\n> = {\n id: 'communication_channels.conversation-contact',\n targetEntity: 'messages.message',\n features: ['communication_channels.view'],\n priority: 15,\n timeout: 2000,\n fallback: { _channelContact: null },\n critical: false,\n\n async enrichOne(record, ctx) {\n const [out] = await this.enrichMany!([record], ctx)\n return out\n },\n\n async enrichMany(records, ctx) {\n if (records.length === 0) return records\n const messageIds = records.map((r) => r.id)\n const em = ctxEm(ctx)\n const dscope = { tenantId: ctx.tenantId, organizationId: ctx.organizationId ?? null }\n const links = await findWithDecryption(\n em,\n MessageChannelLink,\n {\n messageId: { $in: messageIds },\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n const conversationIds = Array.from(\n new Set(links.map((l) => l.externalConversationId).filter(Boolean)),\n )\n let conversationsById = new Map<string, ExternalConversation>()\n if (conversationIds.length > 0) {\n const conversations = await findWithDecryption(\n em,\n ExternalConversation,\n {\n id: { $in: conversationIds },\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n conversationsById = new Map(conversations.map((c) => [c.id, c]))\n }\n\n const linksByMessage = new Map<string, MessageChannelLink>()\n for (const link of links) linksByMessage.set(link.messageId, link)\n\n return records.map((r) => {\n const link = linksByMessage.get(r.id)\n if (!link) return { ...r, _channelContact: null }\n const conversation = conversationsById.get(link.externalConversationId)\n if (!conversation) return { ...r, _channelContact: null }\n const enrichment: ChannelContactEnrichment = {\n contactPersonId: conversation.contactPersonId ?? null,\n assignedUserId: conversation.assignedUserId ?? null,\n subject: conversation.subject ?? null,\n }\n return { ...r, _channelContact: enrichment }\n })\n },\n}\n\nexport type ChannelContactEnrichment = {\n contactPersonId: string | null\n assignedUserId: string | null\n subject: string | null\n}\n\n// \u2500\u2500 Export \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport const enrichers: ResponseEnricher[] = [\n messageChannelEnricher as unknown as ResponseEnricher,\n messageChannelPayloadEnricher as unknown as ResponseEnricher,\n messageReactionsEnricher as unknown as ResponseEnricher,\n conversationContactEnricher as unknown as ResponseEnricher,\n]\n\nexport default enrichers\n"],
|
|
5
|
-
"mappings": "AAEA,SAAS,0BAA0B;AACnC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { EnricherContext, ResponseEnricher } from '@open-mercato/shared/lib/crud/response-enricher'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport {\n CommunicationChannel,\n ExternalConversation,\n MessageChannelLink,\n MessageReaction,\n} from './entities'\n\n/**\n * Response enrichers for the messages.message entity.\n *\n * The hub declares 2 enrichers; downstream hosts (Messages module's `/api/messages`\n * CRUD route + future provider routes) opt in via `makeCrudRoute({ enrichers: { entityId: 'messages.message' } })`.\n *\n * - `messageChannelEnricher` \u2192 `_channel`, `_channelPayload`, `_channelContact`\n * - `messageReactionsEnricher` \u2192 `_reactions`\n *\n * The channel/payload/contact enrichments were three separate enrichers, each\n * independently issuing its own `MessageChannelLink` `$in` query (and two of them\n * an `ExternalConversation` query) for the same message page. Because the shared\n * enricher runner executes active enrichers sequentially with no per-pass shared\n * context, those lookups were repeated for every page (#3183). They are merged\n * into one batched enricher so the link batch (and the conversation batch) is\n * loaded once per pass, while preserving the public enriched field names:\n *\n * - `_channel` \u2192 channel metadata + capabilities snapshot\n * - `_channelPayload` \u2192 channel-native payload (Block Kit / interactive / email MIME / \u2026)\n * - `_channelContact` \u2192 CRM person preview (email + display name)\n * - `_reactions` \u2192 grouped emoji counts + users + reactedByMe\n *\n * Per `packages/shared/lib/crud/response-enricher` rules:\n * - `enrichMany` is implemented for every enricher (N+1 prevention).\n * - Enriched fields are namespaced with `_channel*` / `_reactions` prefixes.\n * - Enrichers are read-only; no writes via the EntityManager.\n * - Each enricher is feature-gated by `communication_channels.view`.\n */\n\ntype MessageRecord = Record<string, unknown> & {\n id: string\n threadId?: string | null\n}\n\ntype ResolvedCtx = EnricherContext & {\n em: EntityManager\n}\n\nfunction ctxEm(ctx: EnricherContext): EntityManager {\n return ctx.em as EntityManager\n}\n\nexport type ChannelEnrichment = {\n providerKey: string\n channelType: string\n direction: 'inbound' | 'outbound' | string\n deliveryStatus: string | null\n capabilities: Record<string, unknown> | null\n}\n\nexport type ChannelPayloadEnrichment = {\n channelContentType: string | null\n channelPayload: Record<string, unknown> | null\n interactiveState: Record<string, unknown> | null\n channelMetadata: Record<string, unknown> | null\n}\n\nexport type ChannelContactEnrichment = {\n contactPersonId: string | null\n assignedUserId: string | null\n subject: string | null\n}\n\n// \u2500\u2500 _channel + _channelPayload + _channelContact \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst messageChannelEnricher: ResponseEnricher<\n MessageRecord,\n {\n _channel?: ChannelEnrichment | null\n _channelPayload?: ChannelPayloadEnrichment | null\n _channelContact?: ChannelContactEnrichment | null\n }\n> = {\n id: 'communication_channels.message-channel',\n targetEntity: 'messages.message',\n features: ['communication_channels.view'],\n priority: 30,\n timeout: 2000,\n fallback: { _channel: null, _channelPayload: null, _channelContact: null },\n critical: false,\n\n async enrichOne(record, ctx) {\n const [out] = await this.enrichMany!([record], ctx)\n return out\n },\n\n async enrichMany(records, ctx) {\n if (records.length === 0) return records\n const messageIds = records.map((r) => r.id)\n const em = ctxEm(ctx)\n const tenantId = ctx.tenantId as string\n const organizationId = ctx.organizationId ?? null\n const dscope = { tenantId, organizationId }\n\n // 1) MessageChannelLink \u2014 one bounded `$in` query for the whole page, shared by\n // all three enrichments (channel metadata, channel payload, conversation\n // contact). The result set is already bounded by the `messageId $in messageIds`\n // filter (the host list endpoint caps the page at 100), so no separate row\n // limit is needed.\n const links = await findWithDecryption(\n em,\n MessageChannelLink,\n {\n messageId: { $in: messageIds },\n tenantId,\n organizationId,\n },\n undefined,\n dscope,\n )\n const linksByMessage = new Map<string, MessageChannelLink>()\n for (const link of links) linksByMessage.set(link.messageId, link)\n\n // 2) ExternalConversation \u2014 one query, shared by the channel-capabilities hop and\n // the conversation-contact enrichment.\n const conversationIds = Array.from(\n new Set(\n links\n .map((l) => l.externalConversationId)\n .filter((id): id is string => typeof id === 'string' && id.length > 0),\n ),\n )\n const conversationsById = new Map<string, ExternalConversation>()\n if (conversationIds.length > 0) {\n const conversations = await findWithDecryption(\n em,\n ExternalConversation,\n { id: { $in: conversationIds }, tenantId, organizationId },\n undefined,\n dscope,\n )\n for (const conversation of conversations) {\n conversationsById.set(conversation.id, conversation)\n }\n }\n\n // 3) CommunicationChannel \u2014 capabilities snapshot. Resolve by `id` (not\n // `providerKey`): multi-user channels share the same `providerKey` (e.g. two\n // users with Gmail), so a providerKey-keyed map collapses them and returns the\n // wrong owner's capabilities. The hop is `link \u2192 ExternalConversation \u2192 CommunicationChannel`.\n const channelIds = Array.from(\n new Set(\n Array.from(conversationsById.values())\n .map((conversation) => conversation.channelId)\n .filter((id): id is string => typeof id === 'string' && id.length > 0),\n ),\n )\n const channelsById = new Map<string, CommunicationChannel>()\n if (channelIds.length > 0) {\n const channels = await findWithDecryption(\n em,\n CommunicationChannel,\n { id: { $in: channelIds }, tenantId, organizationId, deletedAt: null },\n undefined,\n dscope,\n )\n for (const channel of channels) channelsById.set(channel.id, channel)\n }\n\n const channelByMessageId = new Map<string, CommunicationChannel>()\n for (const link of links) {\n if (!link.externalConversationId) continue\n const conversation = conversationsById.get(link.externalConversationId)\n if (!conversation) continue\n const channel = conversation.channelId ? channelsById.get(conversation.channelId) : undefined\n if (channel) channelByMessageId.set(link.messageId, channel)\n }\n\n return records.map((r) => {\n const link = linksByMessage.get(r.id)\n if (!link) {\n return { ...r, _channel: null, _channelPayload: null, _channelContact: null }\n }\n\n const channel = channelByMessageId.get(r.id)\n const channelEnrichment: ChannelEnrichment = {\n providerKey: link.providerKey,\n channelType: link.channelType,\n direction: link.direction,\n deliveryStatus: link.deliveryStatus ?? null,\n capabilities: (channel?.capabilities as Record<string, unknown> | null) ?? null,\n }\n\n const payloadEnrichment: ChannelPayloadEnrichment = {\n channelContentType: link.channelContentType ?? null,\n channelPayload: link.channelPayload ?? null,\n interactiveState: link.interactiveState ?? null,\n channelMetadata: link.channelMetadata ?? null,\n }\n\n const conversation = link.externalConversationId\n ? conversationsById.get(link.externalConversationId)\n : undefined\n const contactEnrichment: ChannelContactEnrichment | null = conversation\n ? {\n contactPersonId: conversation.contactPersonId ?? null,\n assignedUserId: conversation.assignedUserId ?? null,\n subject: conversation.subject ?? null,\n }\n : null\n\n return {\n ...r,\n _channel: channelEnrichment,\n _channelPayload: payloadEnrichment,\n _channelContact: contactEnrichment,\n }\n })\n },\n}\n\n// \u2500\u2500 _reactions \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst messageReactionsEnricher: ResponseEnricher<\n MessageRecord,\n { _reactions?: ReactionGroup[] }\n> = {\n id: 'communication_channels.message-reactions',\n targetEntity: 'messages.message',\n features: ['communication_channels.view'],\n priority: 25,\n timeout: 1500,\n fallback: { _reactions: [] },\n critical: false,\n\n async enrichOne(record, ctx) {\n const [out] = await this.enrichMany!([record], ctx)\n return out\n },\n\n async enrichMany(records, ctx) {\n if (records.length === 0) return records\n const messageIds = records.map((r) => r.id)\n const em = ctxEm(ctx)\n const dscope = { tenantId: ctx.tenantId, organizationId: ctx.organizationId ?? null }\n const reactions = await findWithDecryption(\n em,\n MessageReaction,\n {\n messageId: { $in: messageIds },\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n\n const byMessage = new Map<string, MessageReaction[]>()\n for (const reaction of reactions) {\n const existing = byMessage.get(reaction.messageId) ?? []\n existing.push(reaction)\n byMessage.set(reaction.messageId, existing)\n }\n\n return records.map((r) => {\n const rows = byMessage.get(r.id) ?? []\n const grouped = groupReactions(rows, ctx.userId)\n return { ...r, _reactions: grouped }\n })\n },\n}\n\nexport type ReactionGroup = {\n emoji: string\n count: number\n users: Array<{\n userId?: string | null\n externalId?: string | null\n displayName?: string | null\n providerKey?: string | null\n }>\n reactedByMe: boolean\n /**\n * MessageReaction.id of the current user's reaction row, if any. Exposed so\n * the reaction-bar UI can issue\n * `DELETE /api/communication_channels/messages/{messageId}/reactions/{myReactionId}`\n * when the user toggles their own reaction off. Null when `reactedByMe` is\n * false or the row was added by an external participant (provider-side).\n */\n myReactionId: string | null\n}\n\nfunction groupReactions(rows: MessageReaction[], currentUserId: string): ReactionGroup[] {\n const map = new Map<string, ReactionGroup>()\n for (const row of rows) {\n const key = row.emoji\n if (!map.has(key)) {\n map.set(key, { emoji: key, count: 0, users: [], reactedByMe: false, myReactionId: null })\n }\n const group = map.get(key)!\n group.count += 1\n group.users.push({\n userId: row.reactedByUserId ?? null,\n externalId: row.reactedByExternalId ?? null,\n displayName: row.reactedByDisplayName ?? null,\n providerKey: row.providerKey ?? null,\n })\n if (row.reactedByUserId && row.reactedByUserId === currentUserId) {\n group.reactedByMe = true\n if (!group.myReactionId) group.myReactionId = row.id ?? null\n }\n }\n return Array.from(map.values()).sort((a, b) => b.count - a.count)\n}\n\n// \u2500\u2500 Export \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport const enrichers: ResponseEnricher[] = [\n messageChannelEnricher as unknown as ResponseEnricher,\n messageReactionsEnricher as unknown as ResponseEnricher,\n]\n\nexport default enrichers\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
|
|
4
4
|
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
5
|
+
import { emitCustomerAccountsEvent } from "@open-mercato/core/modules/customer_accounts/events";
|
|
5
6
|
import { inviteUserSchema } from "@open-mercato/core/modules/customer_accounts/data/validators";
|
|
6
7
|
import { rateLimitErrorSchema } from "@open-mercato/shared/lib/ratelimit/helpers";
|
|
7
8
|
import {
|
|
@@ -51,6 +52,14 @@ async function POST(req) {
|
|
|
51
52
|
displayName: parsed.data.displayName || null
|
|
52
53
|
}
|
|
53
54
|
);
|
|
55
|
+
void emitCustomerAccountsEvent("customer_accounts.user.invited", {
|
|
56
|
+
invitationId: invitation.id,
|
|
57
|
+
email: invitation.email,
|
|
58
|
+
customerEntityId: invitation.customerEntityId || null,
|
|
59
|
+
invitedByType: "staff",
|
|
60
|
+
tenantId: auth.tenantId,
|
|
61
|
+
organizationId: auth.orgId
|
|
62
|
+
}).catch(() => void 0);
|
|
54
63
|
return NextResponse.json({
|
|
55
64
|
ok: true,
|
|
56
65
|
invitation: {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/customer_accounts/api/admin/users-invite.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc, OpenApiMethodDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { CustomerInvitationService } from '@open-mercato/core/modules/customer_accounts/services/customerInvitationService'\nimport { inviteUserSchema } from '@open-mercato/core/modules/customer_accounts/data/validators'\nimport { rateLimitErrorSchema } from '@open-mercato/shared/lib/ratelimit/helpers'\nimport {\n checkAuthRateLimit,\n customerInviteRateLimitConfig,\n customerInviteIpRateLimitConfig,\n} from '@open-mercato/core/modules/customer_accounts/lib/rateLimiter'\nimport { readNormalizedEmailFromJsonRequest } from '@open-mercato/core/modules/customer_accounts/lib/rateLimitIdentifier'\n\nexport const metadata = {}\n\nexport async function POST(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth) {\n return NextResponse.json({ ok: false, error: 'Authentication required' }, { status: 401 })\n }\n\n const rateLimitEmail = await readNormalizedEmailFromJsonRequest(req)\n const { error: rateLimitError } = await checkAuthRateLimit({\n req,\n ipConfig: customerInviteIpRateLimitConfig,\n compoundConfig: customerInviteRateLimitConfig,\n compoundIdentifier: rateLimitEmail,\n })\n if (rateLimitError) return rateLimitError\n\n const container = await createRequestContainer()\n const rbacService = container.resolve('rbacService') as RbacService\n const hasAccess = await rbacService.userHasAllFeatures(auth.sub, ['customer_accounts.invite'], { tenantId: auth.tenantId, organizationId: auth.orgId })\n if (!hasAccess) {\n return NextResponse.json({ ok: false, error: 'Insufficient permissions' }, { status: 403 })\n }\n\n let body: unknown\n try {\n body = await req.json()\n } catch {\n return NextResponse.json({ ok: false, error: 'Invalid request body' }, { status: 400 })\n }\n\n const parsed = inviteUserSchema.safeParse(body)\n if (!parsed.success) {\n return NextResponse.json({ ok: false, error: 'Validation failed', details: parsed.error.flatten().fieldErrors }, { status: 400 })\n }\n\n const customerInvitationService = container.resolve('customerInvitationService') as CustomerInvitationService\n\n const { invitation } = await customerInvitationService.createInvitation(\n parsed.data.email,\n { tenantId: auth.tenantId!, organizationId: auth.orgId! },\n {\n customerEntityId: parsed.data.customerEntityId || null,\n roleIds: parsed.data.roleIds,\n invitedByUserId: auth.sub,\n displayName: parsed.data.displayName || null,\n },\n )\n\n return NextResponse.json({\n ok: true,\n invitation: {\n id: invitation.id,\n email: invitation.email,\n expiresAt: invitation.expiresAt,\n },\n }, { status: 201 })\n}\n\nconst successSchema = z.object({\n ok: z.literal(true),\n invitation: z.object({\n id: z.string().uuid(),\n email: z.string(),\n expiresAt: z.string().datetime(),\n }),\n})\nconst errorSchema = z.object({ ok: z.literal(false), error: z.string() })\n\nconst methodDoc: OpenApiMethodDoc = {\n summary: 'Invite customer user (admin)',\n description: 'Creates a staff-initiated invitation for a new customer user. The invitedByUserId is set from the staff auth context.',\n tags: ['Customer Accounts Admin'],\n requestBody: { schema: inviteUserSchema },\n responses: [{ status: 201, description: 'Invitation created', schema: successSchema }],\n errors: [\n { status: 400, description: 'Validation failed', schema: errorSchema },\n { status: 401, description: 'Not authenticated', schema: errorSchema },\n { status: 403, description: 'Insufficient permissions', schema: errorSchema },\n { status: 429, description: 'Too many invitation requests', schema: rateLimitErrorSchema },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Invite customer user (admin)',\n methods: { POST: methodDoc },\n}\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc, OpenApiMethodDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { CustomerInvitationService } from '@open-mercato/core/modules/customer_accounts/services/customerInvitationService'\nimport { emitCustomerAccountsEvent } from '@open-mercato/core/modules/customer_accounts/events'\nimport { inviteUserSchema } from '@open-mercato/core/modules/customer_accounts/data/validators'\nimport { rateLimitErrorSchema } from '@open-mercato/shared/lib/ratelimit/helpers'\nimport {\n checkAuthRateLimit,\n customerInviteRateLimitConfig,\n customerInviteIpRateLimitConfig,\n} from '@open-mercato/core/modules/customer_accounts/lib/rateLimiter'\nimport { readNormalizedEmailFromJsonRequest } from '@open-mercato/core/modules/customer_accounts/lib/rateLimitIdentifier'\n\nexport const metadata = {}\n\nexport async function POST(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth) {\n return NextResponse.json({ ok: false, error: 'Authentication required' }, { status: 401 })\n }\n\n const rateLimitEmail = await readNormalizedEmailFromJsonRequest(req)\n const { error: rateLimitError } = await checkAuthRateLimit({\n req,\n ipConfig: customerInviteIpRateLimitConfig,\n compoundConfig: customerInviteRateLimitConfig,\n compoundIdentifier: rateLimitEmail,\n })\n if (rateLimitError) return rateLimitError\n\n const container = await createRequestContainer()\n const rbacService = container.resolve('rbacService') as RbacService\n const hasAccess = await rbacService.userHasAllFeatures(auth.sub, ['customer_accounts.invite'], { tenantId: auth.tenantId, organizationId: auth.orgId })\n if (!hasAccess) {\n return NextResponse.json({ ok: false, error: 'Insufficient permissions' }, { status: 403 })\n }\n\n let body: unknown\n try {\n body = await req.json()\n } catch {\n return NextResponse.json({ ok: false, error: 'Invalid request body' }, { status: 400 })\n }\n\n const parsed = inviteUserSchema.safeParse(body)\n if (!parsed.success) {\n return NextResponse.json({ ok: false, error: 'Validation failed', details: parsed.error.flatten().fieldErrors }, { status: 400 })\n }\n\n const customerInvitationService = container.resolve('customerInvitationService') as CustomerInvitationService\n\n const { invitation } = await customerInvitationService.createInvitation(\n parsed.data.email,\n { tenantId: auth.tenantId!, organizationId: auth.orgId! },\n {\n customerEntityId: parsed.data.customerEntityId || null,\n roleIds: parsed.data.roleIds,\n invitedByUserId: auth.sub,\n displayName: parsed.data.displayName || null,\n },\n )\n\n void emitCustomerAccountsEvent('customer_accounts.user.invited', {\n invitationId: invitation.id,\n email: invitation.email,\n customerEntityId: invitation.customerEntityId || null,\n invitedByType: 'staff',\n tenantId: auth.tenantId!,\n organizationId: auth.orgId!,\n }).catch(() => undefined)\n\n return NextResponse.json({\n ok: true,\n invitation: {\n id: invitation.id,\n email: invitation.email,\n expiresAt: invitation.expiresAt,\n },\n }, { status: 201 })\n}\n\nconst successSchema = z.object({\n ok: z.literal(true),\n invitation: z.object({\n id: z.string().uuid(),\n email: z.string(),\n expiresAt: z.string().datetime(),\n }),\n})\nconst errorSchema = z.object({ ok: z.literal(false), error: z.string() })\n\nconst methodDoc: OpenApiMethodDoc = {\n summary: 'Invite customer user (admin)',\n description: 'Creates a staff-initiated invitation for a new customer user. The invitedByUserId is set from the staff auth context.',\n tags: ['Customer Accounts Admin'],\n requestBody: { schema: inviteUserSchema },\n responses: [{ status: 201, description: 'Invitation created', schema: successSchema }],\n errors: [\n { status: 400, description: 'Validation failed', schema: errorSchema },\n { status: 401, description: 'Not authenticated', schema: errorSchema },\n { status: 403, description: 'Insufficient permissions', schema: errorSchema },\n { status: 429, description: 'Too many invitation requests', schema: rateLimitErrorSchema },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Invite customer user (admin)',\n methods: { POST: methodDoc },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { getCustomerAuthFromRequest, requireCustomerFeature } from "@open-mercato/core/modules/customer_accounts/lib/customerAuth";
|
|
4
4
|
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
5
|
+
import { emitCustomerAccountsEvent } from "@open-mercato/core/modules/customer_accounts/events";
|
|
5
6
|
import { CustomerRole } from "@open-mercato/core/modules/customer_accounts/data/entities";
|
|
6
7
|
import { inviteUserSchema } from "@open-mercato/core/modules/customer_accounts/data/validators";
|
|
7
8
|
import { findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
|
|
@@ -76,6 +77,14 @@ async function POST(req) {
|
|
|
76
77
|
displayName: parsed.data.displayName || null
|
|
77
78
|
}
|
|
78
79
|
);
|
|
80
|
+
void emitCustomerAccountsEvent("customer_accounts.user.invited", {
|
|
81
|
+
invitationId: invitation.id,
|
|
82
|
+
email: invitation.email,
|
|
83
|
+
customerEntityId: invitation.customerEntityId || null,
|
|
84
|
+
invitedByType: "portal",
|
|
85
|
+
tenantId: auth.tenantId,
|
|
86
|
+
organizationId: auth.orgId
|
|
87
|
+
}).catch(() => void 0);
|
|
79
88
|
return NextResponse.json({
|
|
80
89
|
ok: true,
|
|
81
90
|
invitation: {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/customer_accounts/api/portal/users-invite.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc, OpenApiMethodDoc } from '@open-mercato/shared/lib/openapi'\nimport { getCustomerAuthFromRequest, requireCustomerFeature } from '@open-mercato/core/modules/customer_accounts/lib/customerAuth'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { CustomerInvitationService } from '@open-mercato/core/modules/customer_accounts/services/customerInvitationService'\nimport { CustomerRbacService } from '@open-mercato/core/modules/customer_accounts/services/customerRbacService'\nimport { CustomerRole } from '@open-mercato/core/modules/customer_accounts/data/entities'\nimport { inviteUserSchema } from '@open-mercato/core/modules/customer_accounts/data/validators'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { rateLimitErrorSchema } from '@open-mercato/shared/lib/ratelimit/helpers'\nimport {\n checkAuthRateLimit,\n customerInviteRateLimitConfig,\n customerInviteIpRateLimitConfig,\n} from '@open-mercato/core/modules/customer_accounts/lib/rateLimiter'\nimport { readNormalizedEmailFromJsonRequest } from '@open-mercato/core/modules/customer_accounts/lib/rateLimitIdentifier'\n\nexport const metadata: { path?: string; requireAuth?: boolean } = { requireAuth: false }\n\nexport async function POST(req: Request) {\n const auth = await getCustomerAuthFromRequest(req)\n if (!auth) {\n return NextResponse.json({ ok: false, error: 'Authentication required' }, { status: 401 })\n }\n\n const rateLimitEmail = await readNormalizedEmailFromJsonRequest(req)\n const { error: rateLimitError } = await checkAuthRateLimit({\n req,\n ipConfig: customerInviteIpRateLimitConfig,\n compoundConfig: customerInviteRateLimitConfig,\n compoundIdentifier: rateLimitEmail,\n })\n if (rateLimitError) return rateLimitError\n\n const container = await createRequestContainer()\n const customerRbacService = container.resolve('customerRbacService') as CustomerRbacService\n\n try {\n await requireCustomerFeature(auth, ['portal.users.manage'], customerRbacService)\n } catch (response) {\n return response as NextResponse\n }\n\n if (!auth.customerEntityId) {\n return NextResponse.json({ ok: false, error: 'No company association' }, { status: 403 })\n }\n\n let body: unknown\n try {\n body = await req.json()\n } catch {\n return NextResponse.json({ ok: false, error: 'Invalid request body' }, { status: 400 })\n }\n\n const parsed = inviteUserSchema.safeParse(body)\n if (!parsed.success) {\n return NextResponse.json({ ok: false, error: 'Validation failed', details: parsed.error.flatten().fieldErrors }, { status: 400 })\n }\n\n const em = container.resolve('em') as import('@mikro-orm/postgresql').EntityManager\n\n // Validate all roles are customer_assignable\n const requestedRoleIds = parsed.data.roleIds\n const roles = requestedRoleIds.length > 0\n ? await findWithDecryption(\n em,\n CustomerRole,\n { id: { $in: requestedRoleIds }, tenantId: auth.tenantId, deletedAt: null } as any,\n undefined,\n { tenantId: auth.tenantId, organizationId: auth.orgId },\n )\n : []\n const rolesById = new Map(roles.map((role) => [role.id, role]))\n for (const roleId of requestedRoleIds) {\n const role = rolesById.get(roleId)\n if (!role) {\n return NextResponse.json({ ok: false, error: `Role ${roleId} not found` }, { status: 400 })\n }\n if (!role.customerAssignable) {\n return NextResponse.json({ ok: false, error: `Role \"${role.name}\" cannot be assigned by portal users` }, { status: 403 })\n }\n }\n\n const customerInvitationService = container.resolve('customerInvitationService') as CustomerInvitationService\n\n const { invitation } = await customerInvitationService.createInvitation(\n parsed.data.email,\n { tenantId: auth.tenantId, organizationId: auth.orgId },\n {\n customerEntityId: auth.customerEntityId,\n roleIds: parsed.data.roleIds,\n invitedByCustomerUserId: auth.sub,\n displayName: parsed.data.displayName || null,\n },\n )\n\n return NextResponse.json({\n ok: true,\n invitation: {\n id: invitation.id,\n email: invitation.email,\n expiresAt: invitation.expiresAt,\n },\n }, { status: 201 })\n}\n\nconst successSchema = z.object({\n ok: z.literal(true),\n invitation: z.object({\n id: z.string().uuid(),\n email: z.string(),\n expiresAt: z.string().datetime(),\n }),\n})\nconst errorSchema = z.object({ ok: z.literal(false), error: z.string() })\n\nconst methodDoc: OpenApiMethodDoc = {\n summary: 'Invite a user to the company portal',\n description: 'Creates an invitation for a new user to join the company portal.',\n tags: ['Customer Portal'],\n requestBody: { schema: inviteUserSchema },\n responses: [{ status: 201, description: 'Invitation created', schema: successSchema }],\n errors: [\n { status: 400, description: 'Validation failed', schema: errorSchema },\n { status: 401, description: 'Not authenticated', schema: errorSchema },\n { status: 403, description: 'Insufficient permissions or non-assignable role', schema: errorSchema },\n { status: 429, description: 'Too many invitation requests', schema: rateLimitErrorSchema },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Invite portal user',\n methods: { POST: methodDoc },\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,4BAA4B,8BAA8B;AACnE,SAAS,8BAA8B;
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc, OpenApiMethodDoc } from '@open-mercato/shared/lib/openapi'\nimport { getCustomerAuthFromRequest, requireCustomerFeature } from '@open-mercato/core/modules/customer_accounts/lib/customerAuth'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { CustomerInvitationService } from '@open-mercato/core/modules/customer_accounts/services/customerInvitationService'\nimport { emitCustomerAccountsEvent } from '@open-mercato/core/modules/customer_accounts/events'\nimport { CustomerRbacService } from '@open-mercato/core/modules/customer_accounts/services/customerRbacService'\nimport { CustomerRole } from '@open-mercato/core/modules/customer_accounts/data/entities'\nimport { inviteUserSchema } from '@open-mercato/core/modules/customer_accounts/data/validators'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { rateLimitErrorSchema } from '@open-mercato/shared/lib/ratelimit/helpers'\nimport {\n checkAuthRateLimit,\n customerInviteRateLimitConfig,\n customerInviteIpRateLimitConfig,\n} from '@open-mercato/core/modules/customer_accounts/lib/rateLimiter'\nimport { readNormalizedEmailFromJsonRequest } from '@open-mercato/core/modules/customer_accounts/lib/rateLimitIdentifier'\n\nexport const metadata: { path?: string; requireAuth?: boolean } = { requireAuth: false }\n\nexport async function POST(req: Request) {\n const auth = await getCustomerAuthFromRequest(req)\n if (!auth) {\n return NextResponse.json({ ok: false, error: 'Authentication required' }, { status: 401 })\n }\n\n const rateLimitEmail = await readNormalizedEmailFromJsonRequest(req)\n const { error: rateLimitError } = await checkAuthRateLimit({\n req,\n ipConfig: customerInviteIpRateLimitConfig,\n compoundConfig: customerInviteRateLimitConfig,\n compoundIdentifier: rateLimitEmail,\n })\n if (rateLimitError) return rateLimitError\n\n const container = await createRequestContainer()\n const customerRbacService = container.resolve('customerRbacService') as CustomerRbacService\n\n try {\n await requireCustomerFeature(auth, ['portal.users.manage'], customerRbacService)\n } catch (response) {\n return response as NextResponse\n }\n\n if (!auth.customerEntityId) {\n return NextResponse.json({ ok: false, error: 'No company association' }, { status: 403 })\n }\n\n let body: unknown\n try {\n body = await req.json()\n } catch {\n return NextResponse.json({ ok: false, error: 'Invalid request body' }, { status: 400 })\n }\n\n const parsed = inviteUserSchema.safeParse(body)\n if (!parsed.success) {\n return NextResponse.json({ ok: false, error: 'Validation failed', details: parsed.error.flatten().fieldErrors }, { status: 400 })\n }\n\n const em = container.resolve('em') as import('@mikro-orm/postgresql').EntityManager\n\n // Validate all roles are customer_assignable\n const requestedRoleIds = parsed.data.roleIds\n const roles = requestedRoleIds.length > 0\n ? await findWithDecryption(\n em,\n CustomerRole,\n { id: { $in: requestedRoleIds }, tenantId: auth.tenantId, deletedAt: null } as any,\n undefined,\n { tenantId: auth.tenantId, organizationId: auth.orgId },\n )\n : []\n const rolesById = new Map(roles.map((role) => [role.id, role]))\n for (const roleId of requestedRoleIds) {\n const role = rolesById.get(roleId)\n if (!role) {\n return NextResponse.json({ ok: false, error: `Role ${roleId} not found` }, { status: 400 })\n }\n if (!role.customerAssignable) {\n return NextResponse.json({ ok: false, error: `Role \"${role.name}\" cannot be assigned by portal users` }, { status: 403 })\n }\n }\n\n const customerInvitationService = container.resolve('customerInvitationService') as CustomerInvitationService\n\n const { invitation } = await customerInvitationService.createInvitation(\n parsed.data.email,\n { tenantId: auth.tenantId, organizationId: auth.orgId },\n {\n customerEntityId: auth.customerEntityId,\n roleIds: parsed.data.roleIds,\n invitedByCustomerUserId: auth.sub,\n displayName: parsed.data.displayName || null,\n },\n )\n\n void emitCustomerAccountsEvent('customer_accounts.user.invited', {\n invitationId: invitation.id,\n email: invitation.email,\n customerEntityId: invitation.customerEntityId || null,\n invitedByType: 'portal',\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n }).catch(() => undefined)\n\n return NextResponse.json({\n ok: true,\n invitation: {\n id: invitation.id,\n email: invitation.email,\n expiresAt: invitation.expiresAt,\n },\n }, { status: 201 })\n}\n\nconst successSchema = z.object({\n ok: z.literal(true),\n invitation: z.object({\n id: z.string().uuid(),\n email: z.string(),\n expiresAt: z.string().datetime(),\n }),\n})\nconst errorSchema = z.object({ ok: z.literal(false), error: z.string() })\n\nconst methodDoc: OpenApiMethodDoc = {\n summary: 'Invite a user to the company portal',\n description: 'Creates an invitation for a new user to join the company portal.',\n tags: ['Customer Portal'],\n requestBody: { schema: inviteUserSchema },\n responses: [{ status: 201, description: 'Invitation created', schema: successSchema }],\n errors: [\n { status: 400, description: 'Validation failed', schema: errorSchema },\n { status: 401, description: 'Not authenticated', schema: errorSchema },\n { status: 403, description: 'Insufficient permissions or non-assignable role', schema: errorSchema },\n { status: 429, description: 'Too many invitation requests', schema: rateLimitErrorSchema },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Invite portal user',\n methods: { POST: methodDoc },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -15,6 +15,7 @@ const events = [
|
|
|
15
15
|
{ id: "customer_accounts.role.created", label: "Customer Role Created", entity: "role", category: "crud" },
|
|
16
16
|
{ id: "customer_accounts.role.updated", label: "Customer Role Updated", entity: "role", category: "crud", portalBroadcast: true },
|
|
17
17
|
{ id: "customer_accounts.role.deleted", label: "Customer Role Deleted", entity: "role", category: "crud" },
|
|
18
|
+
{ id: "customer_accounts.user.invited", label: "Customer User Invited", entity: "user", category: "lifecycle", clientBroadcast: true },
|
|
18
19
|
{ id: "customer_accounts.invitation.accepted", label: "Customer Invitation Accepted", category: "lifecycle", clientBroadcast: true },
|
|
19
20
|
{ id: "customer_accounts.password_reset.requested", label: "Customer Password Reset Requested", category: "lifecycle" },
|
|
20
21
|
// Custom domain mapping lifecycle (see .ai/specs/implemented/2026-04-08-portal-custom-domain-routing.md)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/modules/customer_accounts/events.ts"],
|
|
4
|
-
"sourcesContent": ["import { createModuleEvents } from '@open-mercato/shared/modules/events'\n\nconst events = [\n { id: 'customer_accounts.user.created', label: 'Customer User Created', entity: 'user', category: 'crud', clientBroadcast: true },\n { id: 'customer_accounts.user.updated', label: 'Customer User Updated', entity: 'user', category: 'crud', portalBroadcast: true },\n { id: 'customer_accounts.user.deleted', label: 'Customer User Deleted', entity: 'user', category: 'crud' },\n { id: 'customer_accounts.user.locked', label: 'Customer User Locked', entity: 'user', category: 'lifecycle', portalBroadcast: true },\n { id: 'customer_accounts.user.unlocked', label: 'Customer User Unlocked', entity: 'user', category: 'lifecycle', portalBroadcast: true },\n { id: 'customer_accounts.login.success', label: 'Customer Login Successful', category: 'lifecycle' },\n { id: 'customer_accounts.login.failed', label: 'Customer Login Failed', category: 'lifecycle' },\n { id: 'customer_accounts.magic_link.requested', label: 'Customer Magic Link Requested', category: 'lifecycle' },\n { id: 'customer_accounts.email.verified', label: 'Customer Email Verified', category: 'lifecycle', portalBroadcast: true },\n { id: 'customer_accounts.password.reset_requested', label: 'Customer Password Reset Requested', category: 'lifecycle' },\n { id: 'customer_accounts.password.reset', label: 'Customer Password Reset', category: 'lifecycle', portalBroadcast: true },\n { id: 'customer_accounts.password.changed', label: 'Customer Password Changed', category: 'lifecycle' },\n { id: 'customer_accounts.role.created', label: 'Customer Role Created', entity: 'role', category: 'crud' },\n { id: 'customer_accounts.role.updated', label: 'Customer Role Updated', entity: 'role', category: 'crud', portalBroadcast: true },\n { id: 'customer_accounts.role.deleted', label: 'Customer Role Deleted', entity: 'role', category: 'crud' },\n { id: 'customer_accounts.invitation.accepted', label: 'Customer Invitation Accepted', category: 'lifecycle', clientBroadcast: true },\n { id: 'customer_accounts.password_reset.requested', label: 'Customer Password Reset Requested', category: 'lifecycle' },\n // Custom domain mapping lifecycle (see .ai/specs/implemented/2026-04-08-portal-custom-domain-routing.md)\n { id: 'customer_accounts.domain_mapping.created', label: 'Custom Domain Registered', entity: 'domain_mapping', category: 'crud', clientBroadcast: true },\n { id: 'customer_accounts.domain_mapping.verified', label: 'Custom Domain DNS Verified', entity: 'domain_mapping', category: 'lifecycle', clientBroadcast: true },\n { id: 'customer_accounts.domain_mapping.activated', label: 'Custom Domain Active', entity: 'domain_mapping', category: 'lifecycle', clientBroadcast: true },\n { id: 'customer_accounts.domain_mapping.dns_failed', label: 'Custom Domain DNS Verification Failed', entity: 'domain_mapping', category: 'lifecycle', clientBroadcast: true },\n { id: 'customer_accounts.domain_mapping.tls_failed', label: 'Custom Domain SSL Provisioning Failed', entity: 'domain_mapping', category: 'lifecycle', clientBroadcast: true },\n { id: 'customer_accounts.domain_mapping.deleted', label: 'Custom Domain Removed', entity: 'domain_mapping', category: 'crud', clientBroadcast: true },\n { id: 'customer_accounts.domain_mapping.replaced', label: 'Custom Domain Replaced', entity: 'domain_mapping', category: 'lifecycle', clientBroadcast: true },\n] as const\n\nexport const eventsConfig = createModuleEvents({\n moduleId: 'customer_accounts',\n events,\n})\n\nexport const emitCustomerAccountsEvent = eventsConfig.emit\n\nexport type CustomerAccountsEventId = typeof events[number]['id']\n\nexport default eventsConfig\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,0BAA0B;AAEnC,MAAM,SAAS;AAAA,EACb,EAAE,IAAI,kCAAkC,OAAO,yBAAyB,QAAQ,QAAQ,UAAU,QAAQ,iBAAiB,KAAK;AAAA,EAChI,EAAE,IAAI,kCAAkC,OAAO,yBAAyB,QAAQ,QAAQ,UAAU,QAAQ,iBAAiB,KAAK;AAAA,EAChI,EAAE,IAAI,kCAAkC,OAAO,yBAAyB,QAAQ,QAAQ,UAAU,OAAO;AAAA,EACzG,EAAE,IAAI,iCAAiC,OAAO,wBAAwB,QAAQ,QAAQ,UAAU,aAAa,iBAAiB,KAAK;AAAA,EACnI,EAAE,IAAI,mCAAmC,OAAO,0BAA0B,QAAQ,QAAQ,UAAU,aAAa,iBAAiB,KAAK;AAAA,EACvI,EAAE,IAAI,mCAAmC,OAAO,6BAA6B,UAAU,YAAY;AAAA,EACnG,EAAE,IAAI,kCAAkC,OAAO,yBAAyB,UAAU,YAAY;AAAA,EAC9F,EAAE,IAAI,0CAA0C,OAAO,iCAAiC,UAAU,YAAY;AAAA,EAC9G,EAAE,IAAI,oCAAoC,OAAO,2BAA2B,UAAU,aAAa,iBAAiB,KAAK;AAAA,EACzH,EAAE,IAAI,8CAA8C,OAAO,qCAAqC,UAAU,YAAY;AAAA,EACtH,EAAE,IAAI,oCAAoC,OAAO,2BAA2B,UAAU,aAAa,iBAAiB,KAAK;AAAA,EACzH,EAAE,IAAI,sCAAsC,OAAO,6BAA6B,UAAU,YAAY;AAAA,EACtG,EAAE,IAAI,kCAAkC,OAAO,yBAAyB,QAAQ,QAAQ,UAAU,OAAO;AAAA,EACzG,EAAE,IAAI,kCAAkC,OAAO,yBAAyB,QAAQ,QAAQ,UAAU,QAAQ,iBAAiB,KAAK;AAAA,EAChI,EAAE,IAAI,kCAAkC,OAAO,yBAAyB,QAAQ,QAAQ,UAAU,OAAO;AAAA,EACzG,EAAE,IAAI,yCAAyC,OAAO,gCAAgC,UAAU,aAAa,iBAAiB,KAAK;AAAA,EACnI,EAAE,IAAI,8CAA8C,OAAO,qCAAqC,UAAU,YAAY;AAAA;AAAA,EAEtH,EAAE,IAAI,4CAA4C,OAAO,4BAA4B,QAAQ,kBAAkB,UAAU,QAAQ,iBAAiB,KAAK;AAAA,EACvJ,EAAE,IAAI,6CAA6C,OAAO,8BAA8B,QAAQ,kBAAkB,UAAU,aAAa,iBAAiB,KAAK;AAAA,EAC/J,EAAE,IAAI,8CAA8C,OAAO,wBAAwB,QAAQ,kBAAkB,UAAU,aAAa,iBAAiB,KAAK;AAAA,EAC1J,EAAE,IAAI,+CAA+C,OAAO,yCAAyC,QAAQ,kBAAkB,UAAU,aAAa,iBAAiB,KAAK;AAAA,EAC5K,EAAE,IAAI,+CAA+C,OAAO,yCAAyC,QAAQ,kBAAkB,UAAU,aAAa,iBAAiB,KAAK;AAAA,EAC5K,EAAE,IAAI,4CAA4C,OAAO,yBAAyB,QAAQ,kBAAkB,UAAU,QAAQ,iBAAiB,KAAK;AAAA,EACpJ,EAAE,IAAI,6CAA6C,OAAO,0BAA0B,QAAQ,kBAAkB,UAAU,aAAa,iBAAiB,KAAK;AAC7J;AAEO,MAAM,eAAe,mBAAmB;AAAA,EAC7C,UAAU;AAAA,EACV;AACF,CAAC;AAEM,MAAM,4BAA4B,aAAa;AAItD,IAAO,iBAAQ;",
|
|
4
|
+
"sourcesContent": ["import { createModuleEvents } from '@open-mercato/shared/modules/events'\n\nconst events = [\n { id: 'customer_accounts.user.created', label: 'Customer User Created', entity: 'user', category: 'crud', clientBroadcast: true },\n { id: 'customer_accounts.user.updated', label: 'Customer User Updated', entity: 'user', category: 'crud', portalBroadcast: true },\n { id: 'customer_accounts.user.deleted', label: 'Customer User Deleted', entity: 'user', category: 'crud' },\n { id: 'customer_accounts.user.locked', label: 'Customer User Locked', entity: 'user', category: 'lifecycle', portalBroadcast: true },\n { id: 'customer_accounts.user.unlocked', label: 'Customer User Unlocked', entity: 'user', category: 'lifecycle', portalBroadcast: true },\n { id: 'customer_accounts.login.success', label: 'Customer Login Successful', category: 'lifecycle' },\n { id: 'customer_accounts.login.failed', label: 'Customer Login Failed', category: 'lifecycle' },\n { id: 'customer_accounts.magic_link.requested', label: 'Customer Magic Link Requested', category: 'lifecycle' },\n { id: 'customer_accounts.email.verified', label: 'Customer Email Verified', category: 'lifecycle', portalBroadcast: true },\n { id: 'customer_accounts.password.reset_requested', label: 'Customer Password Reset Requested', category: 'lifecycle' },\n { id: 'customer_accounts.password.reset', label: 'Customer Password Reset', category: 'lifecycle', portalBroadcast: true },\n { id: 'customer_accounts.password.changed', label: 'Customer Password Changed', category: 'lifecycle' },\n { id: 'customer_accounts.role.created', label: 'Customer Role Created', entity: 'role', category: 'crud' },\n { id: 'customer_accounts.role.updated', label: 'Customer Role Updated', entity: 'role', category: 'crud', portalBroadcast: true },\n { id: 'customer_accounts.role.deleted', label: 'Customer Role Deleted', entity: 'role', category: 'crud' },\n { id: 'customer_accounts.user.invited', label: 'Customer User Invited', entity: 'user', category: 'lifecycle', clientBroadcast: true },\n { id: 'customer_accounts.invitation.accepted', label: 'Customer Invitation Accepted', category: 'lifecycle', clientBroadcast: true },\n { id: 'customer_accounts.password_reset.requested', label: 'Customer Password Reset Requested', category: 'lifecycle' },\n // Custom domain mapping lifecycle (see .ai/specs/implemented/2026-04-08-portal-custom-domain-routing.md)\n { id: 'customer_accounts.domain_mapping.created', label: 'Custom Domain Registered', entity: 'domain_mapping', category: 'crud', clientBroadcast: true },\n { id: 'customer_accounts.domain_mapping.verified', label: 'Custom Domain DNS Verified', entity: 'domain_mapping', category: 'lifecycle', clientBroadcast: true },\n { id: 'customer_accounts.domain_mapping.activated', label: 'Custom Domain Active', entity: 'domain_mapping', category: 'lifecycle', clientBroadcast: true },\n { id: 'customer_accounts.domain_mapping.dns_failed', label: 'Custom Domain DNS Verification Failed', entity: 'domain_mapping', category: 'lifecycle', clientBroadcast: true },\n { id: 'customer_accounts.domain_mapping.tls_failed', label: 'Custom Domain SSL Provisioning Failed', entity: 'domain_mapping', category: 'lifecycle', clientBroadcast: true },\n { id: 'customer_accounts.domain_mapping.deleted', label: 'Custom Domain Removed', entity: 'domain_mapping', category: 'crud', clientBroadcast: true },\n { id: 'customer_accounts.domain_mapping.replaced', label: 'Custom Domain Replaced', entity: 'domain_mapping', category: 'lifecycle', clientBroadcast: true },\n] as const\n\nexport const eventsConfig = createModuleEvents({\n moduleId: 'customer_accounts',\n events,\n})\n\nexport const emitCustomerAccountsEvent = eventsConfig.emit\n\nexport type CustomerAccountsEventId = typeof events[number]['id']\n\nexport default eventsConfig\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,0BAA0B;AAEnC,MAAM,SAAS;AAAA,EACb,EAAE,IAAI,kCAAkC,OAAO,yBAAyB,QAAQ,QAAQ,UAAU,QAAQ,iBAAiB,KAAK;AAAA,EAChI,EAAE,IAAI,kCAAkC,OAAO,yBAAyB,QAAQ,QAAQ,UAAU,QAAQ,iBAAiB,KAAK;AAAA,EAChI,EAAE,IAAI,kCAAkC,OAAO,yBAAyB,QAAQ,QAAQ,UAAU,OAAO;AAAA,EACzG,EAAE,IAAI,iCAAiC,OAAO,wBAAwB,QAAQ,QAAQ,UAAU,aAAa,iBAAiB,KAAK;AAAA,EACnI,EAAE,IAAI,mCAAmC,OAAO,0BAA0B,QAAQ,QAAQ,UAAU,aAAa,iBAAiB,KAAK;AAAA,EACvI,EAAE,IAAI,mCAAmC,OAAO,6BAA6B,UAAU,YAAY;AAAA,EACnG,EAAE,IAAI,kCAAkC,OAAO,yBAAyB,UAAU,YAAY;AAAA,EAC9F,EAAE,IAAI,0CAA0C,OAAO,iCAAiC,UAAU,YAAY;AAAA,EAC9G,EAAE,IAAI,oCAAoC,OAAO,2BAA2B,UAAU,aAAa,iBAAiB,KAAK;AAAA,EACzH,EAAE,IAAI,8CAA8C,OAAO,qCAAqC,UAAU,YAAY;AAAA,EACtH,EAAE,IAAI,oCAAoC,OAAO,2BAA2B,UAAU,aAAa,iBAAiB,KAAK;AAAA,EACzH,EAAE,IAAI,sCAAsC,OAAO,6BAA6B,UAAU,YAAY;AAAA,EACtG,EAAE,IAAI,kCAAkC,OAAO,yBAAyB,QAAQ,QAAQ,UAAU,OAAO;AAAA,EACzG,EAAE,IAAI,kCAAkC,OAAO,yBAAyB,QAAQ,QAAQ,UAAU,QAAQ,iBAAiB,KAAK;AAAA,EAChI,EAAE,IAAI,kCAAkC,OAAO,yBAAyB,QAAQ,QAAQ,UAAU,OAAO;AAAA,EACzG,EAAE,IAAI,kCAAkC,OAAO,yBAAyB,QAAQ,QAAQ,UAAU,aAAa,iBAAiB,KAAK;AAAA,EACrI,EAAE,IAAI,yCAAyC,OAAO,gCAAgC,UAAU,aAAa,iBAAiB,KAAK;AAAA,EACnI,EAAE,IAAI,8CAA8C,OAAO,qCAAqC,UAAU,YAAY;AAAA;AAAA,EAEtH,EAAE,IAAI,4CAA4C,OAAO,4BAA4B,QAAQ,kBAAkB,UAAU,QAAQ,iBAAiB,KAAK;AAAA,EACvJ,EAAE,IAAI,6CAA6C,OAAO,8BAA8B,QAAQ,kBAAkB,UAAU,aAAa,iBAAiB,KAAK;AAAA,EAC/J,EAAE,IAAI,8CAA8C,OAAO,wBAAwB,QAAQ,kBAAkB,UAAU,aAAa,iBAAiB,KAAK;AAAA,EAC1J,EAAE,IAAI,+CAA+C,OAAO,yCAAyC,QAAQ,kBAAkB,UAAU,aAAa,iBAAiB,KAAK;AAAA,EAC5K,EAAE,IAAI,+CAA+C,OAAO,yCAAyC,QAAQ,kBAAkB,UAAU,aAAa,iBAAiB,KAAK;AAAA,EAC5K,EAAE,IAAI,4CAA4C,OAAO,yBAAyB,QAAQ,kBAAkB,UAAU,QAAQ,iBAAiB,KAAK;AAAA,EACpJ,EAAE,IAAI,6CAA6C,OAAO,0BAA0B,QAAQ,kBAAkB,UAAU,aAAa,iBAAiB,KAAK;AAC7J;AAEO,MAAM,eAAe,mBAAmB;AAAA,EAC7C,UAAU;AAAA,EACV;AACF,CAAC;AAEM,MAAM,4BAA4B,aAAa;AAItD,IAAO,iBAAQ;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -22,9 +22,12 @@ async function GET(req) {
|
|
|
22
22
|
const cache = userId && isCrudCacheEnabled() ? resolveCrudCache(ctx.container) : null;
|
|
23
23
|
const cacheKey = cache && userId ? buildUnreadCountCacheKey(userId) : null;
|
|
24
24
|
if (cache && cacheKey) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
try {
|
|
26
|
+
const cached = await runWithCacheTenant(scope.tenantId, () => cache.get(cacheKey));
|
|
27
|
+
if (typeof cached === "number") {
|
|
28
|
+
return Response.json({ unreadCount: cached });
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
28
31
|
}
|
|
29
32
|
}
|
|
30
33
|
const count = await em.count(Notification, {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/notifications/api/unread-count/route.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/core'\nimport { runWithCacheTenant } from '@open-mercato/cache'\nimport {\n buildCollectionTags,\n isCrudCacheEnabled,\n resolveCrudCache,\n} from '@open-mercato/shared/lib/crud/cache'\nimport { Notification } from '../../data/entities'\nimport { unreadCountResponseSchema } from '../openapi'\nimport { resolveNotificationContext } from '../../lib/routeHelpers'\n\nexport const metadata = {\n GET: { requireAuth: true },\n}\n\nconst UNREAD_COUNT_RESOURCE = 'notifications.notification'\nconst UNREAD_COUNT_TTL_MS = 10_000\n\nfunction buildUnreadCountCacheKey(userId: string): string {\n return `notifications:unread-count:u=${userId}`\n}\n\nexport async function GET(req: Request) {\n const { scope, ctx } = await resolveNotificationContext(req)\n const em = ctx.container.resolve('em') as EntityManager\n\n const userId = scope.userId\n const cache = userId && isCrudCacheEnabled() ? resolveCrudCache(ctx.container) : null\n const cacheKey = cache && userId ? buildUnreadCountCacheKey(userId) : null\n\n if (cache && cacheKey) {\n const cached = await runWithCacheTenant(scope.tenantId, () => cache.get(cacheKey))\n
|
|
5
|
-
"mappings": "AACA,SAAS,0BAA0B;AACnC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,oBAAoB;AAC7B,SAAS,iCAAiC;AAC1C,SAAS,kCAAkC;AAEpC,MAAM,WAAW;AAAA,EACtB,KAAK,EAAE,aAAa,KAAK;AAC3B;AAEA,MAAM,wBAAwB;AAC9B,MAAM,sBAAsB;AAE5B,SAAS,yBAAyB,QAAwB;AACxD,SAAO,gCAAgC,MAAM;AAC/C;AAEA,eAAsB,IAAI,KAAc;AACtC,QAAM,EAAE,OAAO,IAAI,IAAI,MAAM,2BAA2B,GAAG;AAC3D,QAAM,KAAK,IAAI,UAAU,QAAQ,IAAI;AAErC,QAAM,SAAS,MAAM;AACrB,QAAM,QAAQ,UAAU,mBAAmB,IAAI,iBAAiB,IAAI,SAAS,IAAI;AACjF,QAAM,WAAW,SAAS,SAAS,yBAAyB,MAAM,IAAI;AAEtE,MAAI,SAAS,UAAU;AACrB,
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/core'\nimport { runWithCacheTenant } from '@open-mercato/cache'\nimport {\n buildCollectionTags,\n isCrudCacheEnabled,\n resolveCrudCache,\n} from '@open-mercato/shared/lib/crud/cache'\nimport { Notification } from '../../data/entities'\nimport { unreadCountResponseSchema } from '../openapi'\nimport { resolveNotificationContext } from '../../lib/routeHelpers'\n\nexport const metadata = {\n GET: { requireAuth: true },\n}\n\nconst UNREAD_COUNT_RESOURCE = 'notifications.notification'\nconst UNREAD_COUNT_TTL_MS = 10_000\n\nfunction buildUnreadCountCacheKey(userId: string): string {\n return `notifications:unread-count:u=${userId}`\n}\n\nexport async function GET(req: Request) {\n const { scope, ctx } = await resolveNotificationContext(req)\n const em = ctx.container.resolve('em') as EntityManager\n\n const userId = scope.userId\n const cache = userId && isCrudCacheEnabled() ? resolveCrudCache(ctx.container) : null\n const cacheKey = cache && userId ? buildUnreadCountCacheKey(userId) : null\n\n if (cache && cacheKey) {\n try {\n const cached = await runWithCacheTenant(scope.tenantId, () => cache.get(cacheKey))\n if (typeof cached === 'number') {\n return Response.json({ unreadCount: cached })\n }\n } catch {}\n }\n\n const count = await em.count(Notification, {\n recipientUserId: userId,\n tenantId: scope.tenantId,\n status: 'unread',\n })\n\n if (cache && cacheKey) {\n try {\n await runWithCacheTenant(scope.tenantId, () =>\n cache.set(cacheKey, count, {\n ttl: UNREAD_COUNT_TTL_MS,\n tags: buildCollectionTags(UNREAD_COUNT_RESOURCE, scope.tenantId, [null]),\n }),\n )\n } catch {}\n }\n\n return Response.json({ unreadCount: count })\n}\n\nexport const openApi = {\n GET: {\n summary: 'Get unread notification count',\n tags: ['Notifications'],\n responses: {\n 200: {\n description: 'Unread count',\n content: {\n 'application/json': {\n schema: unreadCountResponseSchema,\n },\n },\n },\n },\n },\n}\n"],
|
|
5
|
+
"mappings": "AACA,SAAS,0BAA0B;AACnC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,oBAAoB;AAC7B,SAAS,iCAAiC;AAC1C,SAAS,kCAAkC;AAEpC,MAAM,WAAW;AAAA,EACtB,KAAK,EAAE,aAAa,KAAK;AAC3B;AAEA,MAAM,wBAAwB;AAC9B,MAAM,sBAAsB;AAE5B,SAAS,yBAAyB,QAAwB;AACxD,SAAO,gCAAgC,MAAM;AAC/C;AAEA,eAAsB,IAAI,KAAc;AACtC,QAAM,EAAE,OAAO,IAAI,IAAI,MAAM,2BAA2B,GAAG;AAC3D,QAAM,KAAK,IAAI,UAAU,QAAQ,IAAI;AAErC,QAAM,SAAS,MAAM;AACrB,QAAM,QAAQ,UAAU,mBAAmB,IAAI,iBAAiB,IAAI,SAAS,IAAI;AACjF,QAAM,WAAW,SAAS,SAAS,yBAAyB,MAAM,IAAI;AAEtE,MAAI,SAAS,UAAU;AACrB,QAAI;AACF,YAAM,SAAS,MAAM,mBAAmB,MAAM,UAAU,MAAM,MAAM,IAAI,QAAQ,CAAC;AACjF,UAAI,OAAO,WAAW,UAAU;AAC9B,eAAO,SAAS,KAAK,EAAE,aAAa,OAAO,CAAC;AAAA,MAC9C;AAAA,IACF,QAAQ;AAAA,IAAC;AAAA,EACX;AAEA,QAAM,QAAQ,MAAM,GAAG,MAAM,cAAc;AAAA,IACzC,iBAAiB;AAAA,IACjB,UAAU,MAAM;AAAA,IAChB,QAAQ;AAAA,EACV,CAAC;AAED,MAAI,SAAS,UAAU;AACrB,QAAI;AACF,YAAM;AAAA,QAAmB,MAAM;AAAA,QAAU,MACvC,MAAM,IAAI,UAAU,OAAO;AAAA,UACzB,KAAK;AAAA,UACL,MAAM,oBAAoB,uBAAuB,MAAM,UAAU,CAAC,IAAI,CAAC;AAAA,QACzE,CAAC;AAAA,MACH;AAAA,IACF,QAAQ;AAAA,IAAC;AAAA,EACX;AAEA,SAAO,SAAS,KAAK,EAAE,aAAa,MAAM,CAAC;AAC7C;AAEO,MAAM,UAAU;AAAA,EACrB,KAAK;AAAA,IACH,SAAS;AAAA,IACT,MAAM,CAAC,eAAe;AAAA,IACtB,WAAW;AAAA,MACT,KAAK;AAAA,QACH,aAAa;AAAA,QACb,SAAS;AAAA,UACP,oBAAoB;AAAA,YAClB,QAAQ;AAAA,UACV;AAAA,QACF;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.6.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.6135.1.95b98004bd",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -246,16 +246,16 @@
|
|
|
246
246
|
"zod": "^4.4.3"
|
|
247
247
|
},
|
|
248
248
|
"peerDependencies": {
|
|
249
|
-
"@open-mercato/ai-assistant": "0.6.6-develop.
|
|
250
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
251
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
249
|
+
"@open-mercato/ai-assistant": "0.6.6-develop.6135.1.95b98004bd",
|
|
250
|
+
"@open-mercato/shared": "0.6.6-develop.6135.1.95b98004bd",
|
|
251
|
+
"@open-mercato/ui": "0.6.6-develop.6135.1.95b98004bd",
|
|
252
252
|
"react": "^19.0.0",
|
|
253
253
|
"react-dom": "^19.0.0"
|
|
254
254
|
},
|
|
255
255
|
"devDependencies": {
|
|
256
|
-
"@open-mercato/ai-assistant": "0.6.6-develop.
|
|
257
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
258
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
256
|
+
"@open-mercato/ai-assistant": "0.6.6-develop.6135.1.95b98004bd",
|
|
257
|
+
"@open-mercato/shared": "0.6.6-develop.6135.1.95b98004bd",
|
|
258
|
+
"@open-mercato/ui": "0.6.6-develop.6135.1.95b98004bd",
|
|
259
259
|
"@testing-library/dom": "^10.4.1",
|
|
260
260
|
"@testing-library/jest-dom": "^6.9.1",
|
|
261
261
|
"@testing-library/react": "^16.3.1",
|
|
@@ -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
|
|
@@ -262,6 +262,7 @@ Declared in `events.ts` via `createModuleEvents`. Emit with `emitCustomerAccount
|
|
|
262
262
|
| `customer_accounts.role.created` | crud | No |
|
|
263
263
|
| `customer_accounts.role.updated` | crud | No |
|
|
264
264
|
| `customer_accounts.role.deleted` | crud | No |
|
|
265
|
+
| `customer_accounts.user.invited` | lifecycle | Yes |
|
|
265
266
|
| `customer_accounts.invitation.accepted` | lifecycle | Yes |
|
|
266
267
|
|
|
267
268
|
## Subscribers
|
|
@@ -5,6 +5,7 @@ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
|
5
5
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
6
6
|
import { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
7
7
|
import { CustomerInvitationService } from '@open-mercato/core/modules/customer_accounts/services/customerInvitationService'
|
|
8
|
+
import { emitCustomerAccountsEvent } from '@open-mercato/core/modules/customer_accounts/events'
|
|
8
9
|
import { inviteUserSchema } from '@open-mercato/core/modules/customer_accounts/data/validators'
|
|
9
10
|
import { rateLimitErrorSchema } from '@open-mercato/shared/lib/ratelimit/helpers'
|
|
10
11
|
import {
|
|
@@ -63,6 +64,15 @@ export async function POST(req: Request) {
|
|
|
63
64
|
},
|
|
64
65
|
)
|
|
65
66
|
|
|
67
|
+
void emitCustomerAccountsEvent('customer_accounts.user.invited', {
|
|
68
|
+
invitationId: invitation.id,
|
|
69
|
+
email: invitation.email,
|
|
70
|
+
customerEntityId: invitation.customerEntityId || null,
|
|
71
|
+
invitedByType: 'staff',
|
|
72
|
+
tenantId: auth.tenantId!,
|
|
73
|
+
organizationId: auth.orgId!,
|
|
74
|
+
}).catch(() => undefined)
|
|
75
|
+
|
|
66
76
|
return NextResponse.json({
|
|
67
77
|
ok: true,
|
|
68
78
|
invitation: {
|
|
@@ -4,6 +4,7 @@ import type { OpenApiRouteDoc, OpenApiMethodDoc } from '@open-mercato/shared/lib
|
|
|
4
4
|
import { getCustomerAuthFromRequest, requireCustomerFeature } from '@open-mercato/core/modules/customer_accounts/lib/customerAuth'
|
|
5
5
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
6
6
|
import { CustomerInvitationService } from '@open-mercato/core/modules/customer_accounts/services/customerInvitationService'
|
|
7
|
+
import { emitCustomerAccountsEvent } from '@open-mercato/core/modules/customer_accounts/events'
|
|
7
8
|
import { CustomerRbacService } from '@open-mercato/core/modules/customer_accounts/services/customerRbacService'
|
|
8
9
|
import { CustomerRole } from '@open-mercato/core/modules/customer_accounts/data/entities'
|
|
9
10
|
import { inviteUserSchema } from '@open-mercato/core/modules/customer_accounts/data/validators'
|
|
@@ -95,6 +96,15 @@ export async function POST(req: Request) {
|
|
|
95
96
|
},
|
|
96
97
|
)
|
|
97
98
|
|
|
99
|
+
void emitCustomerAccountsEvent('customer_accounts.user.invited', {
|
|
100
|
+
invitationId: invitation.id,
|
|
101
|
+
email: invitation.email,
|
|
102
|
+
customerEntityId: invitation.customerEntityId || null,
|
|
103
|
+
invitedByType: 'portal',
|
|
104
|
+
tenantId: auth.tenantId,
|
|
105
|
+
organizationId: auth.orgId,
|
|
106
|
+
}).catch(() => undefined)
|
|
107
|
+
|
|
98
108
|
return NextResponse.json({
|
|
99
109
|
ok: true,
|
|
100
110
|
invitation: {
|
|
@@ -16,6 +16,7 @@ const events = [
|
|
|
16
16
|
{ id: 'customer_accounts.role.created', label: 'Customer Role Created', entity: 'role', category: 'crud' },
|
|
17
17
|
{ id: 'customer_accounts.role.updated', label: 'Customer Role Updated', entity: 'role', category: 'crud', portalBroadcast: true },
|
|
18
18
|
{ id: 'customer_accounts.role.deleted', label: 'Customer Role Deleted', entity: 'role', category: 'crud' },
|
|
19
|
+
{ id: 'customer_accounts.user.invited', label: 'Customer User Invited', entity: 'user', category: 'lifecycle', clientBroadcast: true },
|
|
19
20
|
{ id: 'customer_accounts.invitation.accepted', label: 'Customer Invitation Accepted', category: 'lifecycle', clientBroadcast: true },
|
|
20
21
|
{ id: 'customer_accounts.password_reset.requested', label: 'Customer Password Reset Requested', category: 'lifecycle' },
|
|
21
22
|
// Custom domain mapping lifecycle (see .ai/specs/implemented/2026-04-08-portal-custom-domain-routing.md)
|
|
@@ -29,10 +29,12 @@ export async function GET(req: Request) {
|
|
|
29
29
|
const cacheKey = cache && userId ? buildUnreadCountCacheKey(userId) : null
|
|
30
30
|
|
|
31
31
|
if (cache && cacheKey) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
try {
|
|
33
|
+
const cached = await runWithCacheTenant(scope.tenantId, () => cache.get(cacheKey))
|
|
34
|
+
if (typeof cached === 'number') {
|
|
35
|
+
return Response.json({ unreadCount: cached })
|
|
36
|
+
}
|
|
37
|
+
} catch {}
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
const count = await em.count(Notification, {
|