@open-mercato/core 0.10.1-develop.7972.1.03f813eaaf → 0.10.1-develop.7973.1.2d07035b6f
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/helpers/integration/communicationChannelsFixtures.js +11 -0
- package/dist/helpers/integration/communicationChannelsFixtures.js.map +2 -2
- package/dist/modules/communication_channels/api/post/channels/[id]/set-primary/route.js +16 -1
- package/dist/modules/communication_channels/api/post/channels/[id]/set-primary/route.js.map +2 -2
- package/dist/modules/communication_channels/api/post/test-seed/route.js +31 -0
- package/dist/modules/communication_channels/api/post/test-seed/route.js.map +2 -2
- package/dist/modules/communication_channels/backend/profile/communication-channels/page.js +1 -1
- package/dist/modules/communication_channels/backend/profile/communication-channels/page.js.map +2 -2
- package/dist/modules/communication_channels/commands/set-primary-channel.js +3 -0
- package/dist/modules/communication_channels/commands/set-primary-channel.js.map +2 -2
- package/package.json +7 -7
- package/src/helpers/integration/communicationChannelsFixtures.ts +19 -0
- package/src/modules/communication_channels/api/post/channels/[id]/set-primary/route.ts +15 -0
- package/src/modules/communication_channels/api/post/test-seed/route.ts +38 -0
- package/src/modules/communication_channels/backend/profile/communication-channels/page.tsx +1 -1
- package/src/modules/communication_channels/commands/set-primary-channel.ts +7 -0
- package/src/modules/communication_channels/i18n/de.json +1 -0
- package/src/modules/communication_channels/i18n/en.json +1 -0
- package/src/modules/communication_channels/i18n/es.json +1 -0
- package/src/modules/communication_channels/i18n/ko.json +1 -0
- package/src/modules/communication_channels/i18n/pl.json +1 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -29,6 +29,16 @@ async function seedConnectedChannel(request, token, input = {}) {
|
|
|
29
29
|
const body = await readJsonSafe(response);
|
|
30
30
|
return expectId(body?.channelId, "connect-channel response should include channelId");
|
|
31
31
|
}
|
|
32
|
+
async function disconnectSeededChannel(request, token, channelId) {
|
|
33
|
+
const response = await apiRequest(request, "POST", TEST_SEED_PATH, {
|
|
34
|
+
token,
|
|
35
|
+
data: { action: "disconnect-channel", channelId }
|
|
36
|
+
});
|
|
37
|
+
expect(
|
|
38
|
+
response.status(),
|
|
39
|
+
"POST /api/communication_channels/test-seed (disconnect-channel) should return 200"
|
|
40
|
+
).toBe(200);
|
|
41
|
+
}
|
|
32
42
|
async function seedSystemEmailChannel(request, token, input = {}) {
|
|
33
43
|
const response = await apiRequest(request, "POST", TEST_SEED_PATH, {
|
|
34
44
|
token,
|
|
@@ -183,6 +193,7 @@ async function deleteChannelIfExists(request, token, channelId) {
|
|
|
183
193
|
export {
|
|
184
194
|
clearCapturedSystemEmails,
|
|
185
195
|
deleteChannelIfExists,
|
|
196
|
+
disconnectSeededChannel,
|
|
186
197
|
ingestInboundChatMessage,
|
|
187
198
|
ingestInboundEmailMessage,
|
|
188
199
|
inspectMessageChannelLinks,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/helpers/integration/communicationChannelsFixtures.ts"],
|
|
4
|
-
"sourcesContent": ["import { expect, type APIRequestContext } from '@playwright/test';\nimport { apiRequest } from './api';\nimport { expectId, readJsonSafe } from './generalFixtures';\n\n/**\n * Communication-channels integration fixtures.\n *\n * These drive the TEST-ONLY seed endpoint `POST /api/communication_channels/test-seed`,\n * which is gated by `OM_ENABLE_TEST_CHANNEL_SEEDING` (inert/404 in production). Use\n * {@link isChannelSeedingAvailable} to skip tests when the gate is off rather than\n * failing them.\n */\n\nexport type SeedAddressField =\n | string\n | { address: string; name?: string }\n | Array<string | { address: string; name?: string }>;\n\nexport type CapturedTestSeedEmail = {\n capturedAt: string;\n externalMessageId: string;\n conversationId?: string;\n content: {\n text?: string;\n html?: string;\n bodyFormat?: 'text' | 'markdown' | 'html';\n attachments?: Array<{ url: string; mimeType: string; fileName: string; fileSize?: number; inline?: boolean }>;\n raw?: Record<string, unknown>;\n };\n credentials: Record<string, unknown>;\n scope: {\n tenantId: string;\n organizationId: string;\n };\n metadata?: Record<string, unknown>;\n};\n\nconst TEST_SEED_PATH = '/api/communication_channels/test-seed';\n\nfunction resolveSystemCaptureCredentials(): {\n accessToken: string;\n captureCorrelationToken: string;\n} {\n const accessToken = process.env.OM_TEST_EMAIL_CAPTURE_ACCESS_TOKEN?.trim();\n const captureCorrelationToken = process.env.OM_TEST_EMAIL_CAPTURE_CORRELATION_TOKEN?.trim();\n if (!accessToken || !captureCorrelationToken) {\n throw new Error('[internal] System email capture credentials are not configured by the integration harness');\n }\n return { accessToken, captureCorrelationToken };\n}\n\n/**\n * Probe whether the env-gated test-seed endpoint is enabled in the target app.\n * Returns false when the route answers 404 (flag off) so callers can `test.skip`.\n * The caller's token must hold `communication_channels.connect_user_channel`.\n */\nexport async function isChannelSeedingAvailable(\n request: APIRequestContext,\n token: string,\n): Promise<boolean> {\n // A malformed body returns 422 when the gate is ON and 404 when it is OFF.\n const response = await apiRequest(request, 'POST', TEST_SEED_PATH, {\n token,\n data: { action: '__probe__' },\n });\n return response.status() !== 404;\n}\n\n/**\n * Seed a connected, network-free `__test_seed__` channel owned by the caller.\n * Returns the new channel id. Tear down with {@link deleteChannelIfExists}.\n */\nexport async function seedConnectedChannel(\n request: APIRequestContext,\n token: string,\n input: {\n displayName?: string;\n externalIdentifier?: string;\n /**\n * `chat` connects the non-email stub instead: a channel whose senders carry\n * an opaque handle and no address, and whose `externalIdentifier` is NULL \u2014\n * the shape a real Discord/Slack channel has. Use it for anything asserting\n * the hub's sender-identity contract (#4975); the default `email` flavor\n * cannot prove it, because it can only ever supply email-shaped data.\n */\n providerFlavor?: 'email' | 'chat';\n /**\n * Relabel the connected stub channel's `provider_key`. Provider packages own\n * routes that filter on their own key, and a real channel for those providers\n * cannot be connected in CI (the Discord adapter validates its bot token\n * against the live API), so without this a provider-scoped listing can only\n * be asserted against an empty result \u2014 the assertion that stayed green\n * through #5602.\n *\n * The row is relabelled, nothing is registered: do not drive an outbound send\n * through a relabelled channel, because delivery would resolve the real\n * provider's adapter.\n */\n labelAsProviderKey?: string;\n } = {},\n): Promise<string> {\n const response = await apiRequest(request, 'POST', TEST_SEED_PATH, {\n token,\n data: { action: 'connect-channel', ...input },\n });\n expect(\n response.status(),\n 'POST /api/communication_channels/test-seed (connect-channel) should return 201',\n ).toBe(201);\n const body = await readJsonSafe<{ channelId?: string }>(response);\n return expectId(body?.channelId, 'connect-channel response should include channelId');\n}\n\nexport async function seedSystemEmailChannel(\n request: APIRequestContext,\n token: string,\n input: { displayName?: string; externalIdentifier?: string } = {},\n): Promise<string> {\n const response = await apiRequest(request, 'POST', TEST_SEED_PATH, {\n token,\n data: { action: 'seed-system-channel', ...input },\n });\n expect(\n response.status(),\n 'POST /api/communication_channels/test-seed (seed-system-channel) should return 201',\n ).toBe(201);\n const body = await readJsonSafe<{ channelId?: string }>(response);\n return expectId(body?.channelId, 'seed-system-channel response should include channelId');\n}\n\nexport async function clearCapturedSystemEmails(\n request: APIRequestContext,\n token: string,\n options: { systemRecipient?: string } = {},\n): Promise<void> {\n const captureCredentials = options.systemRecipient ? resolveSystemCaptureCredentials() : null;\n const response = await apiRequest(request, 'POST', TEST_SEED_PATH, {\n token,\n data: {\n action: 'clear-capture',\n ...options,\n ...(captureCredentials\n ? { captureCorrelationToken: captureCredentials.captureCorrelationToken }\n : {}),\n },\n headers: captureCredentials\n ? { 'x-om-test-email-capture-access-token': captureCredentials.accessToken }\n : undefined,\n });\n expect(response.ok(), 'POST /api/communication_channels/test-seed (clear-capture) should succeed').toBeTruthy();\n}\n\nexport async function listCapturedSystemEmails(\n request: APIRequestContext,\n token: string,\n options: { systemRecipient?: string } = {},\n): Promise<CapturedTestSeedEmail[]> {\n const captureCredentials = options.systemRecipient ? resolveSystemCaptureCredentials() : null;\n const response = await apiRequest(request, 'POST', TEST_SEED_PATH, {\n token,\n data: {\n action: 'list-capture',\n ...options,\n ...(captureCredentials\n ? { captureCorrelationToken: captureCredentials.captureCorrelationToken }\n : {}),\n },\n headers: captureCredentials\n ? { 'x-om-test-email-capture-access-token': captureCredentials.accessToken }\n : undefined,\n });\n expect(response.ok(), 'POST /api/communication_channels/test-seed (list-capture) should succeed').toBeTruthy();\n const body = await readJsonSafe<{ items?: CapturedTestSeedEmail[] }>(response);\n return Array.isArray(body?.items) ? body.items : [];\n}\n\nexport async function waitForCapturedSystemEmail(\n request: APIRequestContext,\n token: string,\n predicate: (email: CapturedTestSeedEmail) => boolean,\n options: {\n timeoutMs?: number;\n intervalMs?: number;\n description?: string;\n systemRecipient?: string;\n } = {},\n): Promise<CapturedTestSeedEmail> {\n const timeoutMs = options.timeoutMs ?? 20_000;\n const intervalMs = options.intervalMs ?? 1_000;\n const deadline = Date.now() + timeoutMs;\n let lastItems: CapturedTestSeedEmail[] = [];\n while (Date.now() < deadline) {\n lastItems = await listCapturedSystemEmails(request, token, {\n systemRecipient: options.systemRecipient,\n });\n const found = lastItems.find(predicate);\n if (found) return found;\n await new Promise((resolve) => setTimeout(resolve, intervalMs));\n }\n const summary = lastItems.map((item) => ({\n to: item.metadata?.to,\n from: item.metadata?.from,\n subject: item.metadata?.subject,\n scope: item.scope,\n bodyFormat: item.content?.bodyFormat,\n }));\n throw new Error(\n `Timed out waiting for captured system email${options.description ? `: ${options.description}` : ''}. Captured ${lastItems.length} message(s): ${JSON.stringify(summary)}`,\n );\n}\n\n/**\n * Seed an inbound `MessageChannelLink` for `channelId` and emit\n * `communication_channels.message.received` through the real event bus. The\n * persistent customers link-channel-message-received subscriber is enqueued to\n * the `events` queue \u2014 drain it with `drainIntegrationQueue('events')`.\n *\n * Returns the created link + message ids (the message id is the platform\n * `messages.message` id, usable as `messageThreadId` to thread a follow-up).\n *\n * Pass `createThreadMapping: true` to also seed a `ChannelThreadMapping` for the\n * thread \u2014 required before exercising the reaction (`/messages/[id]/reactions`)\n * and thread-assign (`/threads/[id]/assign`) routes, which resolve the owning\n * channel through that mapping.\n */\nexport async function seedInboundMessage(\n request: APIRequestContext,\n token: string,\n input: {\n channelId: string;\n from?: SeedAddressField;\n to?: SeedAddressField;\n cc?: SeedAddressField;\n subject?: string;\n bodyText?: string;\n messageId?: string;\n inReplyTo?: string;\n references?: string[];\n messageThreadId?: string;\n providerKey?: string;\n createThreadMapping?: boolean;\n },\n): Promise<{ channelLinkId: string; messageId: string; conversationId: string }> {\n const response = await apiRequest(request, 'POST', TEST_SEED_PATH, {\n token,\n data: { action: 'emit-inbound', ...input },\n });\n expect(\n response.status(),\n 'POST /api/communication_channels/test-seed (emit-inbound) should return 201',\n ).toBe(201);\n const body = await readJsonSafe<{\n channelLinkId?: string;\n messageId?: string;\n conversationId?: string;\n }>(response);\n return {\n channelLinkId: expectId(body?.channelLinkId, 'emit-inbound response should include channelLinkId'),\n messageId: expectId(body?.messageId, 'emit-inbound response should include messageId'),\n conversationId: expectId(\n body?.conversationId,\n 'emit-inbound response should include conversationId',\n ),\n };\n}\n\n/**\n * Best-effort hard delete of the rows one seeded or test-ingested inbound message\n * created (thread mapping, channel link, external message, conversation and\n * platform message). Run it before\n * {@link deleteChannelIfExists}: the purge is authorized against the live channel,\n * and disconnecting a channel deliberately retains its conversations and messages.\n * Safe to call with null ids in `finally`.\n */\nexport async function purgeSeededInboundMessage(\n request: APIRequestContext,\n token: string | null,\n channelId: string | null,\n seeded: { channelLinkId: string; messageId: string; conversationId: string } | null,\n): Promise<void> {\n if (!token || !channelId || !seeded) return;\n await apiRequest(request, 'POST', TEST_SEED_PATH, {\n token,\n data: { action: 'purge-inbound', channelId, ...seeded },\n }).catch(() => undefined);\n}\n\n/**\n * Ingest an inbound chat message through the REAL `ingest_inbound_message`\n * command: the stub adapter normalizes the frame and the hub composes the\n * platform message via `messages.messages.compose`.\n *\n * Unlike {@link seedInboundMessage}, nothing here is short-circuited \u2014 no row is\n * inserted behind the compose path \u2014 so a hub contract the provider cannot\n * satisfy fails the test instead of hiding behind seeded data. That bypass is\n * exactly why CI was green while every real inbound Discord message was rejected\n * (#4975).\n *\n * The sender is identified only by `senderIdentifier` (a Discord snowflake, a\n * Slack member id, \u2026). There is deliberately no way to pass an address.\n */\nexport async function ingestInboundChatMessage(\n request: APIRequestContext,\n token: string,\n input: {\n channelId: string;\n senderIdentifier: string;\n senderDisplayName?: string;\n body?: string;\n externalMessageId: string;\n externalConversationId: string;\n },\n): Promise<{\n status: string;\n messageId: string | null;\n conversationId: string | null;\n channelLinkId: string | null;\n channelType: string | null;\n}> {\n const response = await apiRequest(request, 'POST', TEST_SEED_PATH, {\n token,\n data: { action: 'ingest-inbound', ...input },\n });\n expect(\n response.status(),\n 'POST /api/communication_channels/test-seed (ingest-inbound) should return 201 \u2014 ' +\n 'a non-201 here means the hub rejected a sender with no email address',\n ).toBe(201);\n const body = await readJsonSafe<{\n status?: string;\n messageId?: string | null;\n conversationId?: string | null;\n channelLinkId?: string | null;\n channelType?: string | null;\n }>(response);\n return {\n status: body?.status ?? 'unknown',\n messageId: body?.messageId ?? null,\n conversationId: body?.conversationId ?? null,\n channelLinkId: body?.channelLinkId ?? null,\n channelType: body?.channelType ?? null,\n };\n}\n\n/**\n * Ingest an inbound email through the real `ingest_inbound_message` command on a\n * channel connected with the default `email` flavor. The stub adapter turns the\n * frame into an email-shaped inbound message whose sender is `senderAddress`, so\n * the hub derives `externalEmail` from it and composes the platform message via\n * `messages.messages.compose` exactly as a real IMAP/Gmail/Graph adapter would.\n *\n * Use this \u2014 not {@link seedInboundMessage}, which bypasses compose \u2014 for any\n * assertion about what the hub does with an inbound email after ingest (for\n * example that it is not echoed back to its sender, #6089). Prefix the supplied\n * external conversation id with `inbound-seed:` when the test will use\n * {@link purgeSeededInboundMessage} for teardown.\n */\nexport async function ingestInboundEmailMessage(\n request: APIRequestContext,\n token: string,\n input: {\n channelId: string;\n senderAddress: string;\n senderDisplayName?: string;\n subject?: string;\n body?: string;\n externalMessageId: string;\n externalConversationId: string;\n },\n): Promise<{\n status: string;\n messageId: string | null;\n conversationId: string | null;\n channelLinkId: string | null;\n channelType: string | null;\n}> {\n const { senderAddress, ...rest } = input;\n const response = await apiRequest(request, 'POST', TEST_SEED_PATH, {\n token,\n data: { action: 'ingest-inbound', senderIdentifier: senderAddress, ...rest },\n });\n expect(\n response.status(),\n 'POST /api/communication_channels/test-seed (ingest-inbound, email) should return 201',\n ).toBe(201);\n const body = await readJsonSafe<{\n status?: string;\n messageId?: string | null;\n conversationId?: string | null;\n channelLinkId?: string | null;\n channelType?: string | null;\n }>(response);\n return {\n status: body?.status ?? 'unknown',\n messageId: body?.messageId ?? null,\n conversationId: body?.conversationId ?? null,\n channelLinkId: body?.channelLinkId ?? null,\n channelType: body?.channelType ?? null,\n };\n}\n\nexport type InspectedMessageLinks = {\n links: Array<{\n id: string;\n direction: string;\n providerKey: string;\n channelType: string;\n deliveryStatus: string;\n externalConversationId: string | null;\n }>;\n threadMappings: Array<{\n id: string;\n messageThreadId: string;\n channelId: string;\n externalConversationId: string;\n }>;\n};\n\n/**\n * Report the `MessageChannelLink` rows and `ChannelThreadMapping` rows a\n * platform message carries. No product API exposes them, so this is the only way\n * to assert that a send really was routed through the hub rather than the\n * platform sender.\n */\nexport async function inspectMessageChannelLinks(\n request: APIRequestContext,\n token: string,\n messageId: string,\n): Promise<InspectedMessageLinks> {\n const response = await apiRequest(request, 'POST', TEST_SEED_PATH, {\n token,\n data: { action: 'inspect-message-links', messageId },\n });\n expect(\n response.status(),\n 'POST /api/communication_channels/test-seed (inspect-message-links) should return 200',\n ).toBe(200);\n const body = await readJsonSafe<InspectedMessageLinks>(response);\n return {\n links: Array.isArray(body?.links) ? body.links : [],\n threadMappings: Array.isArray(body?.threadMappings) ? body.threadMappings : [],\n };\n}\n\n/**\n * Best-effort delete of a seeded channel via the owner-scoped DELETE route.\n * Safe to call with a null id in `finally`.\n */\nexport async function deleteChannelIfExists(\n request: APIRequestContext,\n token: string | null,\n channelId: string | null,\n): Promise<void> {\n if (!token || !channelId) return;\n await apiRequest(\n request,\n 'DELETE',\n `/api/communication_channels/channels/${encodeURIComponent(channelId)}`,\n { token },\n ).catch(() => undefined);\n}\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["import { expect, type APIRequestContext } from '@playwright/test';\nimport { apiRequest } from './api';\nimport { expectId, readJsonSafe } from './generalFixtures';\n\n/**\n * Communication-channels integration fixtures.\n *\n * These drive the TEST-ONLY seed endpoint `POST /api/communication_channels/test-seed`,\n * which is gated by `OM_ENABLE_TEST_CHANNEL_SEEDING` (inert/404 in production). Use\n * {@link isChannelSeedingAvailable} to skip tests when the gate is off rather than\n * failing them.\n */\n\nexport type SeedAddressField =\n | string\n | { address: string; name?: string }\n | Array<string | { address: string; name?: string }>;\n\nexport type CapturedTestSeedEmail = {\n capturedAt: string;\n externalMessageId: string;\n conversationId?: string;\n content: {\n text?: string;\n html?: string;\n bodyFormat?: 'text' | 'markdown' | 'html';\n attachments?: Array<{ url: string; mimeType: string; fileName: string; fileSize?: number; inline?: boolean }>;\n raw?: Record<string, unknown>;\n };\n credentials: Record<string, unknown>;\n scope: {\n tenantId: string;\n organizationId: string;\n };\n metadata?: Record<string, unknown>;\n};\n\nconst TEST_SEED_PATH = '/api/communication_channels/test-seed';\n\nfunction resolveSystemCaptureCredentials(): {\n accessToken: string;\n captureCorrelationToken: string;\n} {\n const accessToken = process.env.OM_TEST_EMAIL_CAPTURE_ACCESS_TOKEN?.trim();\n const captureCorrelationToken = process.env.OM_TEST_EMAIL_CAPTURE_CORRELATION_TOKEN?.trim();\n if (!accessToken || !captureCorrelationToken) {\n throw new Error('[internal] System email capture credentials are not configured by the integration harness');\n }\n return { accessToken, captureCorrelationToken };\n}\n\n/**\n * Probe whether the env-gated test-seed endpoint is enabled in the target app.\n * Returns false when the route answers 404 (flag off) so callers can `test.skip`.\n * The caller's token must hold `communication_channels.connect_user_channel`.\n */\nexport async function isChannelSeedingAvailable(\n request: APIRequestContext,\n token: string,\n): Promise<boolean> {\n // A malformed body returns 422 when the gate is ON and 404 when it is OFF.\n const response = await apiRequest(request, 'POST', TEST_SEED_PATH, {\n token,\n data: { action: '__probe__' },\n });\n return response.status() !== 404;\n}\n\n/**\n * Seed a connected, network-free `__test_seed__` channel owned by the caller.\n * Returns the new channel id. Tear down with {@link deleteChannelIfExists}.\n */\nexport async function seedConnectedChannel(\n request: APIRequestContext,\n token: string,\n input: {\n displayName?: string;\n externalIdentifier?: string;\n /**\n * `chat` connects the non-email stub instead: a channel whose senders carry\n * an opaque handle and no address, and whose `externalIdentifier` is NULL \u2014\n * the shape a real Discord/Slack channel has. Use it for anything asserting\n * the hub's sender-identity contract (#4975); the default `email` flavor\n * cannot prove it, because it can only ever supply email-shaped data.\n */\n providerFlavor?: 'email' | 'chat';\n /**\n * Relabel the connected stub channel's `provider_key`. Provider packages own\n * routes that filter on their own key, and a real channel for those providers\n * cannot be connected in CI (the Discord adapter validates its bot token\n * against the live API), so without this a provider-scoped listing can only\n * be asserted against an empty result \u2014 the assertion that stayed green\n * through #5602.\n *\n * The row is relabelled, nothing is registered: do not drive an outbound send\n * through a relabelled channel, because delivery would resolve the real\n * provider's adapter.\n */\n labelAsProviderKey?: string;\n } = {},\n): Promise<string> {\n const response = await apiRequest(request, 'POST', TEST_SEED_PATH, {\n token,\n data: { action: 'connect-channel', ...input },\n });\n expect(\n response.status(),\n 'POST /api/communication_channels/test-seed (connect-channel) should return 201',\n ).toBe(201);\n const body = await readJsonSafe<{ channelId?: string }>(response);\n return expectId(body?.channelId, 'connect-channel response should include channelId');\n}\n\n/**\n * Disconnect a channel the caller owns through the real disconnect command. The\n * row stays listed with `status: 'disconnected'` (a UI disconnect deletes it).\n */\nexport async function disconnectSeededChannel(\n request: APIRequestContext,\n token: string,\n channelId: string,\n): Promise<void> {\n const response = await apiRequest(request, 'POST', TEST_SEED_PATH, {\n token,\n data: { action: 'disconnect-channel', channelId },\n });\n expect(\n response.status(),\n 'POST /api/communication_channels/test-seed (disconnect-channel) should return 200',\n ).toBe(200);\n}\n\nexport async function seedSystemEmailChannel(\n request: APIRequestContext,\n token: string,\n input: { displayName?: string; externalIdentifier?: string } = {},\n): Promise<string> {\n const response = await apiRequest(request, 'POST', TEST_SEED_PATH, {\n token,\n data: { action: 'seed-system-channel', ...input },\n });\n expect(\n response.status(),\n 'POST /api/communication_channels/test-seed (seed-system-channel) should return 201',\n ).toBe(201);\n const body = await readJsonSafe<{ channelId?: string }>(response);\n return expectId(body?.channelId, 'seed-system-channel response should include channelId');\n}\n\nexport async function clearCapturedSystemEmails(\n request: APIRequestContext,\n token: string,\n options: { systemRecipient?: string } = {},\n): Promise<void> {\n const captureCredentials = options.systemRecipient ? resolveSystemCaptureCredentials() : null;\n const response = await apiRequest(request, 'POST', TEST_SEED_PATH, {\n token,\n data: {\n action: 'clear-capture',\n ...options,\n ...(captureCredentials\n ? { captureCorrelationToken: captureCredentials.captureCorrelationToken }\n : {}),\n },\n headers: captureCredentials\n ? { 'x-om-test-email-capture-access-token': captureCredentials.accessToken }\n : undefined,\n });\n expect(response.ok(), 'POST /api/communication_channels/test-seed (clear-capture) should succeed').toBeTruthy();\n}\n\nexport async function listCapturedSystemEmails(\n request: APIRequestContext,\n token: string,\n options: { systemRecipient?: string } = {},\n): Promise<CapturedTestSeedEmail[]> {\n const captureCredentials = options.systemRecipient ? resolveSystemCaptureCredentials() : null;\n const response = await apiRequest(request, 'POST', TEST_SEED_PATH, {\n token,\n data: {\n action: 'list-capture',\n ...options,\n ...(captureCredentials\n ? { captureCorrelationToken: captureCredentials.captureCorrelationToken }\n : {}),\n },\n headers: captureCredentials\n ? { 'x-om-test-email-capture-access-token': captureCredentials.accessToken }\n : undefined,\n });\n expect(response.ok(), 'POST /api/communication_channels/test-seed (list-capture) should succeed').toBeTruthy();\n const body = await readJsonSafe<{ items?: CapturedTestSeedEmail[] }>(response);\n return Array.isArray(body?.items) ? body.items : [];\n}\n\nexport async function waitForCapturedSystemEmail(\n request: APIRequestContext,\n token: string,\n predicate: (email: CapturedTestSeedEmail) => boolean,\n options: {\n timeoutMs?: number;\n intervalMs?: number;\n description?: string;\n systemRecipient?: string;\n } = {},\n): Promise<CapturedTestSeedEmail> {\n const timeoutMs = options.timeoutMs ?? 20_000;\n const intervalMs = options.intervalMs ?? 1_000;\n const deadline = Date.now() + timeoutMs;\n let lastItems: CapturedTestSeedEmail[] = [];\n while (Date.now() < deadline) {\n lastItems = await listCapturedSystemEmails(request, token, {\n systemRecipient: options.systemRecipient,\n });\n const found = lastItems.find(predicate);\n if (found) return found;\n await new Promise((resolve) => setTimeout(resolve, intervalMs));\n }\n const summary = lastItems.map((item) => ({\n to: item.metadata?.to,\n from: item.metadata?.from,\n subject: item.metadata?.subject,\n scope: item.scope,\n bodyFormat: item.content?.bodyFormat,\n }));\n throw new Error(\n `Timed out waiting for captured system email${options.description ? `: ${options.description}` : ''}. Captured ${lastItems.length} message(s): ${JSON.stringify(summary)}`,\n );\n}\n\n/**\n * Seed an inbound `MessageChannelLink` for `channelId` and emit\n * `communication_channels.message.received` through the real event bus. The\n * persistent customers link-channel-message-received subscriber is enqueued to\n * the `events` queue \u2014 drain it with `drainIntegrationQueue('events')`.\n *\n * Returns the created link + message ids (the message id is the platform\n * `messages.message` id, usable as `messageThreadId` to thread a follow-up).\n *\n * Pass `createThreadMapping: true` to also seed a `ChannelThreadMapping` for the\n * thread \u2014 required before exercising the reaction (`/messages/[id]/reactions`)\n * and thread-assign (`/threads/[id]/assign`) routes, which resolve the owning\n * channel through that mapping.\n */\nexport async function seedInboundMessage(\n request: APIRequestContext,\n token: string,\n input: {\n channelId: string;\n from?: SeedAddressField;\n to?: SeedAddressField;\n cc?: SeedAddressField;\n subject?: string;\n bodyText?: string;\n messageId?: string;\n inReplyTo?: string;\n references?: string[];\n messageThreadId?: string;\n providerKey?: string;\n createThreadMapping?: boolean;\n },\n): Promise<{ channelLinkId: string; messageId: string; conversationId: string }> {\n const response = await apiRequest(request, 'POST', TEST_SEED_PATH, {\n token,\n data: { action: 'emit-inbound', ...input },\n });\n expect(\n response.status(),\n 'POST /api/communication_channels/test-seed (emit-inbound) should return 201',\n ).toBe(201);\n const body = await readJsonSafe<{\n channelLinkId?: string;\n messageId?: string;\n conversationId?: string;\n }>(response);\n return {\n channelLinkId: expectId(body?.channelLinkId, 'emit-inbound response should include channelLinkId'),\n messageId: expectId(body?.messageId, 'emit-inbound response should include messageId'),\n conversationId: expectId(\n body?.conversationId,\n 'emit-inbound response should include conversationId',\n ),\n };\n}\n\n/**\n * Best-effort hard delete of the rows one seeded or test-ingested inbound message\n * created (thread mapping, channel link, external message, conversation and\n * platform message). Run it before\n * {@link deleteChannelIfExists}: the purge is authorized against the live channel,\n * and disconnecting a channel deliberately retains its conversations and messages.\n * Safe to call with null ids in `finally`.\n */\nexport async function purgeSeededInboundMessage(\n request: APIRequestContext,\n token: string | null,\n channelId: string | null,\n seeded: { channelLinkId: string; messageId: string; conversationId: string } | null,\n): Promise<void> {\n if (!token || !channelId || !seeded) return;\n await apiRequest(request, 'POST', TEST_SEED_PATH, {\n token,\n data: { action: 'purge-inbound', channelId, ...seeded },\n }).catch(() => undefined);\n}\n\n/**\n * Ingest an inbound chat message through the REAL `ingest_inbound_message`\n * command: the stub adapter normalizes the frame and the hub composes the\n * platform message via `messages.messages.compose`.\n *\n * Unlike {@link seedInboundMessage}, nothing here is short-circuited \u2014 no row is\n * inserted behind the compose path \u2014 so a hub contract the provider cannot\n * satisfy fails the test instead of hiding behind seeded data. That bypass is\n * exactly why CI was green while every real inbound Discord message was rejected\n * (#4975).\n *\n * The sender is identified only by `senderIdentifier` (a Discord snowflake, a\n * Slack member id, \u2026). There is deliberately no way to pass an address.\n */\nexport async function ingestInboundChatMessage(\n request: APIRequestContext,\n token: string,\n input: {\n channelId: string;\n senderIdentifier: string;\n senderDisplayName?: string;\n body?: string;\n externalMessageId: string;\n externalConversationId: string;\n },\n): Promise<{\n status: string;\n messageId: string | null;\n conversationId: string | null;\n channelLinkId: string | null;\n channelType: string | null;\n}> {\n const response = await apiRequest(request, 'POST', TEST_SEED_PATH, {\n token,\n data: { action: 'ingest-inbound', ...input },\n });\n expect(\n response.status(),\n 'POST /api/communication_channels/test-seed (ingest-inbound) should return 201 \u2014 ' +\n 'a non-201 here means the hub rejected a sender with no email address',\n ).toBe(201);\n const body = await readJsonSafe<{\n status?: string;\n messageId?: string | null;\n conversationId?: string | null;\n channelLinkId?: string | null;\n channelType?: string | null;\n }>(response);\n return {\n status: body?.status ?? 'unknown',\n messageId: body?.messageId ?? null,\n conversationId: body?.conversationId ?? null,\n channelLinkId: body?.channelLinkId ?? null,\n channelType: body?.channelType ?? null,\n };\n}\n\n/**\n * Ingest an inbound email through the real `ingest_inbound_message` command on a\n * channel connected with the default `email` flavor. The stub adapter turns the\n * frame into an email-shaped inbound message whose sender is `senderAddress`, so\n * the hub derives `externalEmail` from it and composes the platform message via\n * `messages.messages.compose` exactly as a real IMAP/Gmail/Graph adapter would.\n *\n * Use this \u2014 not {@link seedInboundMessage}, which bypasses compose \u2014 for any\n * assertion about what the hub does with an inbound email after ingest (for\n * example that it is not echoed back to its sender, #6089). Prefix the supplied\n * external conversation id with `inbound-seed:` when the test will use\n * {@link purgeSeededInboundMessage} for teardown.\n */\nexport async function ingestInboundEmailMessage(\n request: APIRequestContext,\n token: string,\n input: {\n channelId: string;\n senderAddress: string;\n senderDisplayName?: string;\n subject?: string;\n body?: string;\n externalMessageId: string;\n externalConversationId: string;\n },\n): Promise<{\n status: string;\n messageId: string | null;\n conversationId: string | null;\n channelLinkId: string | null;\n channelType: string | null;\n}> {\n const { senderAddress, ...rest } = input;\n const response = await apiRequest(request, 'POST', TEST_SEED_PATH, {\n token,\n data: { action: 'ingest-inbound', senderIdentifier: senderAddress, ...rest },\n });\n expect(\n response.status(),\n 'POST /api/communication_channels/test-seed (ingest-inbound, email) should return 201',\n ).toBe(201);\n const body = await readJsonSafe<{\n status?: string;\n messageId?: string | null;\n conversationId?: string | null;\n channelLinkId?: string | null;\n channelType?: string | null;\n }>(response);\n return {\n status: body?.status ?? 'unknown',\n messageId: body?.messageId ?? null,\n conversationId: body?.conversationId ?? null,\n channelLinkId: body?.channelLinkId ?? null,\n channelType: body?.channelType ?? null,\n };\n}\n\nexport type InspectedMessageLinks = {\n links: Array<{\n id: string;\n direction: string;\n providerKey: string;\n channelType: string;\n deliveryStatus: string;\n externalConversationId: string | null;\n }>;\n threadMappings: Array<{\n id: string;\n messageThreadId: string;\n channelId: string;\n externalConversationId: string;\n }>;\n};\n\n/**\n * Report the `MessageChannelLink` rows and `ChannelThreadMapping` rows a\n * platform message carries. No product API exposes them, so this is the only way\n * to assert that a send really was routed through the hub rather than the\n * platform sender.\n */\nexport async function inspectMessageChannelLinks(\n request: APIRequestContext,\n token: string,\n messageId: string,\n): Promise<InspectedMessageLinks> {\n const response = await apiRequest(request, 'POST', TEST_SEED_PATH, {\n token,\n data: { action: 'inspect-message-links', messageId },\n });\n expect(\n response.status(),\n 'POST /api/communication_channels/test-seed (inspect-message-links) should return 200',\n ).toBe(200);\n const body = await readJsonSafe<InspectedMessageLinks>(response);\n return {\n links: Array.isArray(body?.links) ? body.links : [],\n threadMappings: Array.isArray(body?.threadMappings) ? body.threadMappings : [],\n };\n}\n\n/**\n * Best-effort delete of a seeded channel via the owner-scoped DELETE route.\n * Safe to call with a null id in `finally`.\n */\nexport async function deleteChannelIfExists(\n request: APIRequestContext,\n token: string | null,\n channelId: string | null,\n): Promise<void> {\n if (!token || !channelId) return;\n await apiRequest(\n request,\n 'DELETE',\n `/api/communication_channels/channels/${encodeURIComponent(channelId)}`,\n { token },\n ).catch(() => undefined);\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
|
|
4
|
+
import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
|
|
4
5
|
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
5
6
|
import {
|
|
6
7
|
COMMUNICATION_CHANNELS_SET_PRIMARY_COMMAND_ID
|
|
@@ -61,6 +62,19 @@ async function POST(req, context) {
|
|
|
61
62
|
if (result.status === "not_owner") {
|
|
62
63
|
return NextResponse.json({ error: "Channel not found" }, { status: 404 });
|
|
63
64
|
}
|
|
65
|
+
if (result.status === "disconnected") {
|
|
66
|
+
const { translate } = await resolveTranslations();
|
|
67
|
+
return NextResponse.json(
|
|
68
|
+
{
|
|
69
|
+
error: translate(
|
|
70
|
+
"communication_channels.errors.setPrimaryDisconnected",
|
|
71
|
+
"This channel is disconnected. Reconnect it before setting it as primary."
|
|
72
|
+
),
|
|
73
|
+
code: "channel_disconnected"
|
|
74
|
+
},
|
|
75
|
+
{ status: 409 }
|
|
76
|
+
);
|
|
77
|
+
}
|
|
64
78
|
if (result.status === "noop") {
|
|
65
79
|
return NextResponse.json({ channelId: id, isPrimary: true, unchanged: true }, { status: 200 });
|
|
66
80
|
}
|
|
@@ -84,7 +98,8 @@ const openApi = {
|
|
|
84
98
|
{ status: 200, description: "Channel set as primary (or already primary)" },
|
|
85
99
|
{ status: 400, description: "Invalid channel id" },
|
|
86
100
|
{ status: 401, description: "Unauthorized" },
|
|
87
|
-
{ status: 404, description: "Channel not found or not owned by current user" }
|
|
101
|
+
{ status: 404, description: "Channel not found or not owned by current user" },
|
|
102
|
+
{ status: 409, description: "Channel is disconnected and cannot be set as primary" }
|
|
88
103
|
]
|
|
89
104
|
}
|
|
90
105
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../../../src/modules/communication_channels/api/post/channels/%5Bid%5D/set-primary/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { CommandBus } from '@open-mercato/shared/lib/commands'\nimport {\n COMMUNICATION_CHANNELS_SET_PRIMARY_COMMAND_ID,\n type SetPrimaryChannelInput,\n type SetPrimaryChannelResult,\n} from '../../../../../commands/set-primary-channel'\nimport { validateRouteMutationGuard } from '../../../../../lib/route-mutation-guard'\n\nexport const metadata = {\n path: '/communication_channels/channels/[id]/set-primary',\n POST: {\n // Owner self-service: every email user controls their own channels. The\n // set-primary command enforces strict ownership (`not_owner` \u2192 404), and\n // \"primary\" only applies to per-user channels, so `connect_user_channel`\n // is the correct gate (not `manage`).\n requireAuth: true,\n requireFeatures: ['communication_channels.connect_user_channel'],\n },\n}\n\ntype RouteContext = {\n params: Promise<{ id: string }> | { id: string }\n}\n\nexport async function POST(req: Request, context: RouteContext): Promise<Response> {\n const { id } = await context.params\n if (!z.string().uuid().safeParse(id).success) {\n return NextResponse.json({ error: 'Invalid channel id' }, { status: 400 })\n }\n\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub || !auth?.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const container = await createRequestContainer()\n const guard = await validateRouteMutationGuard({\n container,\n req,\n auth,\n input: {\n resourceKind: 'communication_channels.channel',\n resourceId: id,\n operation: 'custom',\n mutationPayload: { isPrimary: true },\n },\n })\n if ('response' in guard) return guard.response\n\n const commandBus = container.resolve('commandBus') as CommandBus\n\n const input: SetPrimaryChannelInput = {\n channelId: id,\n userId: auth.sub as string,\n scope: {\n tenantId: auth.tenantId as string,\n organizationId: (auth as { orgId?: string | null }).orgId ?? null,\n },\n }\n const { result } = await commandBus.execute<\n SetPrimaryChannelInput,\n SetPrimaryChannelResult\n >(COMMUNICATION_CHANNELS_SET_PRIMARY_COMMAND_ID, {\n input,\n ctx: {\n container,\n auth: auth as never,\n organizationScope: null,\n selectedOrganizationId: (auth as { orgId?: string | null }).orgId ?? null,\n organizationIds: (auth as { orgId?: string | null }).orgId\n ? [(auth as { orgId?: string | null }).orgId!]\n : null,\n },\n })\n\n // Non-ownership maps to 404 (existence masking), consistent with the other\n // channel-scoped routes (GET [id], poll-now, import-history, test-send, DELETE).\n if (result.status === 'not_owner') {\n return NextResponse.json({ error: 'Channel not found' }, { status: 404 })\n }\n if (result.status === 'noop') {\n return NextResponse.json({ channelId: id, isPrimary: true, unchanged: true }, { status: 200 })\n }\n await guard.afterSuccess()\n return NextResponse.json(\n {\n channelId: result.channelId,\n isPrimary: true,\n previousPrimaryChannelId: result.previousPrimaryChannelId,\n },\n { status: 200 },\n )\n}\n\nexport const openApi = {\n tags: ['CommunicationChannels'],\n methods: {\n POST: {\n summary: 'Mark a per-user channel as primary',\n tags: ['CommunicationChannels'],\n responses: [\n { status: 200, description: 'Channel set as primary (or already primary)' },\n { status: 400, description: 'Invalid channel id' },\n { status: 401, description: 'Unauthorized' },\n { status: 404, description: 'Channel not found or not owned by current user' },\n ],\n },\n },\n}\nexport default POST\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAClB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AAEvC;AAAA,EACE;AAAA,OAGK;AACP,SAAS,kCAAkC;AAEpC,MAAM,WAAW;AAAA,EACtB,MAAM;AAAA,EACN,MAAM;AAAA;AAAA;AAAA;AAAA;AAAA,IAKJ,aAAa;AAAA,IACb,iBAAiB,CAAC,6CAA6C;AAAA,EACjE;AACF;AAMA,eAAsB,KAAK,KAAc,SAA0C;AACjF,QAAM,EAAE,GAAG,IAAI,MAAM,QAAQ;AAC7B,MAAI,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE,EAAE,SAAS;AAC5C,WAAO,aAAa,KAAK,EAAE,OAAO,qBAAqB,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC3E;AAEA,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,MAAI,CAAC,MAAM,OAAO,CAAC,MAAM,UAAU;AACjC,WAAO,aAAa,KAAK,EAAE,OAAO,eAAe,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACrE;AAEA,QAAM,YAAY,MAAM,uBAAuB;AAC/C,QAAM,QAAQ,MAAM,2BAA2B;AAAA,IAC7C;AAAA,IACA;AAAA,IACA;AAAA,IACA,OAAO;AAAA,MACL,cAAc;AAAA,MACd,YAAY;AAAA,MACZ,WAAW;AAAA,MACX,iBAAiB,EAAE,WAAW,KAAK;AAAA,IACrC;AAAA,EACF,CAAC;AACD,MAAI,cAAc,MAAO,QAAO,MAAM;AAEtC,QAAM,aAAa,UAAU,QAAQ,YAAY;AAEjD,QAAM,QAAgC;AAAA,IACpC,WAAW;AAAA,IACX,QAAQ,KAAK;AAAA,IACb,OAAO;AAAA,MACL,UAAU,KAAK;AAAA,MACf,gBAAiB,KAAmC,SAAS;AAAA,IAC/D;AAAA,EACF;AACA,QAAM,EAAE,OAAO,IAAI,MAAM,WAAW,QAGlC,+CAA+C;AAAA,IAC/C;AAAA,IACA,KAAK;AAAA,MACH;AAAA,MACA;AAAA,MACA,mBAAmB;AAAA,MACnB,wBAAyB,KAAmC,SAAS;AAAA,MACrE,iBAAkB,KAAmC,QACjD,CAAE,KAAmC,KAAM,IAC3C;AAAA,IACN;AAAA,EACF,CAAC;AAID,MAAI,OAAO,WAAW,aAAa;AACjC,WAAO,aAAa,KAAK,EAAE,OAAO,oBAAoB,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC1E;AACA,MAAI,OAAO,WAAW,QAAQ;AAC5B,WAAO,aAAa,KAAK,EAAE,WAAW,IAAI,WAAW,MAAM,WAAW,KAAK,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC/F;AACA,QAAM,MAAM,aAAa;AACzB,SAAO,aAAa;AAAA,IAClB;AAAA,MACE,WAAW,OAAO;AAAA,MAClB,WAAW;AAAA,MACX,0BAA0B,OAAO;AAAA,IACnC;AAAA,IACA,EAAE,QAAQ,IAAI;AAAA,EAChB;AACF;AAEO,MAAM,UAAU;AAAA,EACrB,MAAM,CAAC,uBAAuB;AAAA,EAC9B,SAAS;AAAA,IACP,MAAM;AAAA,MACJ,SAAS;AAAA,MACT,MAAM,CAAC,uBAAuB;AAAA,MAC9B,WAAW;AAAA,QACT,EAAE,QAAQ,KAAK,aAAa,8CAA8C;AAAA,QAC1E,EAAE,QAAQ,KAAK,aAAa,qBAAqB;AAAA,QACjD,EAAE,QAAQ,KAAK,aAAa,eAAe;AAAA,QAC3C,EAAE,QAAQ,KAAK,aAAa,iDAAiD;AAAA,
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { CommandBus } from '@open-mercato/shared/lib/commands'\nimport {\n COMMUNICATION_CHANNELS_SET_PRIMARY_COMMAND_ID,\n type SetPrimaryChannelInput,\n type SetPrimaryChannelResult,\n} from '../../../../../commands/set-primary-channel'\nimport { validateRouteMutationGuard } from '../../../../../lib/route-mutation-guard'\n\nexport const metadata = {\n path: '/communication_channels/channels/[id]/set-primary',\n POST: {\n // Owner self-service: every email user controls their own channels. The\n // set-primary command enforces strict ownership (`not_owner` \u2192 404), and\n // \"primary\" only applies to per-user channels, so `connect_user_channel`\n // is the correct gate (not `manage`).\n requireAuth: true,\n requireFeatures: ['communication_channels.connect_user_channel'],\n },\n}\n\ntype RouteContext = {\n params: Promise<{ id: string }> | { id: string }\n}\n\nexport async function POST(req: Request, context: RouteContext): Promise<Response> {\n const { id } = await context.params\n if (!z.string().uuid().safeParse(id).success) {\n return NextResponse.json({ error: 'Invalid channel id' }, { status: 400 })\n }\n\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub || !auth?.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const container = await createRequestContainer()\n const guard = await validateRouteMutationGuard({\n container,\n req,\n auth,\n input: {\n resourceKind: 'communication_channels.channel',\n resourceId: id,\n operation: 'custom',\n mutationPayload: { isPrimary: true },\n },\n })\n if ('response' in guard) return guard.response\n\n const commandBus = container.resolve('commandBus') as CommandBus\n\n const input: SetPrimaryChannelInput = {\n channelId: id,\n userId: auth.sub as string,\n scope: {\n tenantId: auth.tenantId as string,\n organizationId: (auth as { orgId?: string | null }).orgId ?? null,\n },\n }\n const { result } = await commandBus.execute<\n SetPrimaryChannelInput,\n SetPrimaryChannelResult\n >(COMMUNICATION_CHANNELS_SET_PRIMARY_COMMAND_ID, {\n input,\n ctx: {\n container,\n auth: auth as never,\n organizationScope: null,\n selectedOrganizationId: (auth as { orgId?: string | null }).orgId ?? null,\n organizationIds: (auth as { orgId?: string | null }).orgId\n ? [(auth as { orgId?: string | null }).orgId!]\n : null,\n },\n })\n\n // Non-ownership maps to 404 (existence masking), consistent with the other\n // channel-scoped routes (GET [id], poll-now, import-history, test-send, DELETE).\n if (result.status === 'not_owner') {\n return NextResponse.json({ error: 'Channel not found' }, { status: 404 })\n }\n if (result.status === 'disconnected') {\n const { translate } = await resolveTranslations()\n return NextResponse.json(\n {\n error: translate(\n 'communication_channels.errors.setPrimaryDisconnected',\n 'This channel is disconnected. Reconnect it before setting it as primary.',\n ),\n code: 'channel_disconnected',\n },\n { status: 409 },\n )\n }\n if (result.status === 'noop') {\n return NextResponse.json({ channelId: id, isPrimary: true, unchanged: true }, { status: 200 })\n }\n await guard.afterSuccess()\n return NextResponse.json(\n {\n channelId: result.channelId,\n isPrimary: true,\n previousPrimaryChannelId: result.previousPrimaryChannelId,\n },\n { status: 200 },\n )\n}\n\nexport const openApi = {\n tags: ['CommunicationChannels'],\n methods: {\n POST: {\n summary: 'Mark a per-user channel as primary',\n tags: ['CommunicationChannels'],\n responses: [\n { status: 200, description: 'Channel set as primary (or already primary)' },\n { status: 400, description: 'Invalid channel id' },\n { status: 401, description: 'Unauthorized' },\n { status: 404, description: 'Channel not found or not owned by current user' },\n { status: 409, description: 'Channel is disconnected and cannot be set as primary' },\n ],\n },\n },\n}\nexport default POST\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -18,6 +18,9 @@ import { ChannelAccessDeniedError, assertCanManageChannel } from "../../../lib/a
|
|
|
18
18
|
import {
|
|
19
19
|
COMMUNICATION_CHANNELS_CONNECT_CREDENTIAL_CHANNEL_COMMAND_ID
|
|
20
20
|
} from "../../../commands/connect-credential-channel.js";
|
|
21
|
+
import {
|
|
22
|
+
COMMUNICATION_CHANNELS_DISCONNECT_CHANNEL_COMMAND_ID
|
|
23
|
+
} from "../../../commands/disconnect-channel.js";
|
|
21
24
|
import { emitCommunicationChannelsEvent } from "../../../events.js";
|
|
22
25
|
import {
|
|
23
26
|
TEST_SEED_CHAT_PROVIDER_KEY,
|
|
@@ -95,6 +98,10 @@ const seedSystemChannelSchema = z.object({
|
|
|
95
98
|
displayName: z.string().min(1).max(255).optional(),
|
|
96
99
|
externalIdentifier: z.string().min(1).max(255).optional()
|
|
97
100
|
});
|
|
101
|
+
const disconnectChannelSchema = z.object({
|
|
102
|
+
action: z.literal("disconnect-channel"),
|
|
103
|
+
channelId: z.string().uuid()
|
|
104
|
+
});
|
|
98
105
|
const clearCaptureSchema = z.object({
|
|
99
106
|
action: z.literal("clear-capture"),
|
|
100
107
|
systemRecipient: z.string().email().max(320).optional(),
|
|
@@ -155,6 +162,7 @@ const bodySchema = z.discriminatedUnion("action", [
|
|
|
155
162
|
emitInboundSchema,
|
|
156
163
|
purgeInboundSchema,
|
|
157
164
|
seedSystemChannelSchema,
|
|
165
|
+
disconnectChannelSchema,
|
|
158
166
|
clearCaptureSchema,
|
|
159
167
|
listCaptureSchema
|
|
160
168
|
]);
|
|
@@ -290,6 +298,29 @@ async function POST(req) {
|
|
|
290
298
|
}
|
|
291
299
|
return NextResponse.json({ channelId: channel.id }, { status: 201 });
|
|
292
300
|
}
|
|
301
|
+
if (body.action === "disconnect-channel") {
|
|
302
|
+
const commandBus = container.resolve("commandBus");
|
|
303
|
+
const { result } = await commandBus.execute(
|
|
304
|
+
COMMUNICATION_CHANNELS_DISCONNECT_CHANNEL_COMMAND_ID,
|
|
305
|
+
{
|
|
306
|
+
input: { channelId: body.channelId, userId, scope: { tenantId, organizationId } },
|
|
307
|
+
ctx: {
|
|
308
|
+
container,
|
|
309
|
+
auth,
|
|
310
|
+
organizationScope: null,
|
|
311
|
+
selectedOrganizationId: organizationId,
|
|
312
|
+
organizationIds: organizationId ? [organizationId] : null
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
);
|
|
316
|
+
if (result.status !== "disconnected") {
|
|
317
|
+
return NextResponse.json(
|
|
318
|
+
{ error: "[internal] test-seed disconnect failed", detail: { status: result.status } },
|
|
319
|
+
{ status: 500 }
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
return NextResponse.json({ channelId: result.channelId, status: result.status }, { status: 200 });
|
|
323
|
+
}
|
|
293
324
|
if (body.action === "connect-channel") {
|
|
294
325
|
const stamp = Date.now();
|
|
295
326
|
const commandBus = container.resolve("commandBus");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/communication_channels/api/post/test-seed/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { CommandBus } from '@open-mercato/shared/lib/commands'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { getCommandInterceptorHttpRejection } from '@open-mercato/shared/lib/commands/errors'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport {\n ChannelThreadMapping,\n CommunicationChannel,\n ExternalConversation,\n ExternalMessage,\n MessageChannelLink,\n} from '../../../data/entities'\nimport { ChannelAccessDeniedError, assertCanManageChannel } from '../../../lib/access-control'\nimport {\n COMMUNICATION_CHANNELS_CONNECT_CREDENTIAL_CHANNEL_COMMAND_ID,\n type ConnectCredentialChannelInput,\n type ConnectCredentialChannelResult,\n} from '../../../commands/connect-credential-channel'\nimport { emitCommunicationChannelsEvent } from '../../../events'\nimport {\n TEST_SEED_CHAT_PROVIDER_KEY,\n TEST_SEED_PROVIDER_KEY,\n clearTestSeedCapturedMessages,\n createTestSeedPlatformMessage,\n deleteTestSeedPlatformMessage,\n ensureTestSeedAdapterRegistered,\n isTestEmailCaptureAccessAuthorized,\n isTestChannelSeedingEnabled,\n listTestSeedCapturedMessages,\n} from '../../../lib/test-seed'\nimport {\n COMMUNICATION_CHANNELS_INGEST_INBOUND_COMMAND_ID,\n type IngestInboundMessageInput,\n type IngestInboundMessageResult,\n} from '../../../commands/ingest-inbound-message'\n\n/**\n * TEST-ONLY channel seeding endpoint.\n *\n * Gated by `OM_ENABLE_TEST_CHANNEL_SEEDING` \u2014 when the flag is unset (the\n * production default) every request returns 404, so this route is invisible and\n * inert in production. See `lib/test-seed.ts` for the full rationale.\n *\n * Actions, all scoped to the caller's tenant/org:\n * - `connect-channel`: connect a network-free stub channel owned by the caller\n * (delegates to the real connect-credential command so the channel persists\n * credentials + lands in `status='connected'`). Enables the outbound\n * compose \u2192 deliver \u2192 `.sent` chain to complete in CI. `providerFlavor: 'chat'`\n * connects the non-email stub instead, for tests about sender identity, and\n * `labelAsProviderKey` relabels the connected row so a provider-scoped\n * listing has something to list without a live credential probe.\n * - `ingest-inbound`: run the REAL `ingest_inbound_message` command over an\n * adapter-normalized chat frame, so the platform compose path \u2014 and every\n * validation rule on it \u2014 actually executes. Use this for anything that\n * claims inbound works.\n * - `emit-inbound`: insert an inbound `MessageChannelLink` (+ a `messages.message`\n * row for threading) and emit `communication_channels.message.received` so the\n * customers link-channel-message subscriber runs against real Postgres. Enables\n * the inbound auto-link tests (TC-CRM-EMAIL-002..005). NOTE: this action\n * deliberately bypasses `messages.messages.compose` \u2014 it can never prove that\n * the hub accepts a message, only that downstream subscribers fire.\n * - `purge-inbound`: hard-delete the rows one test-seeded or test-ingested\n * inbound message created, for integration teardown.\n */\ntype RbacServiceLike = {\n loadAcl: (\n userId: string,\n scope: { tenantId: string | null; organizationId: string | null },\n ) => Promise<{ isSuperAdmin: boolean; features: string[]; organizations: string[] | null }>\n}\n\nexport const metadata = {\n path: '/communication_channels/test-seed',\n POST: {\n requireAuth: true,\n requireFeatures: ['communication_channels.connect_user_channel'],\n },\n}\n\nconst addressObjectSchema = z.object({ address: z.string(), name: z.string().optional() })\nconst addressFieldSchema = z.union([\n z.string(),\n addressObjectSchema,\n z.array(z.union([z.string(), addressObjectSchema])),\n])\n\nconst connectChannelSchema = z.object({\n action: z.literal('connect-channel'),\n displayName: z.string().min(1).max(255).optional(),\n externalIdentifier: z.string().min(1).max(255).optional(),\n /**\n * Which stub provider to connect. `email` (default) keeps the historical\n * email-shaped channel; `chat` connects a channel whose senders have no\n * address, so a test can exercise the hub's non-email identity contract\n * without inventing one (#4975).\n */\n providerFlavor: z.enum(['email', 'chat']).optional(),\n /**\n * TEST-ONLY: rewrite the connected stub channel's `provider_key` to this value.\n *\n * Routes that a provider package owns filter on their own `provider_key`, and\n * a real channel for those providers cannot be connected in CI \u2014 the Discord\n * adapter's `validateCredentials` performs a live API call against a bot token\n * no test environment has. Without this, a provider-scoped listing has nothing\n * to list and can only be asserted against an empty result, which is exactly\n * the assertion that would have stayed green through #5602.\n *\n * It relabels a row; it registers nothing. The stub adapter is still the only\n * adapter present, so the channel remains network-free \u2014 but no test should\n * drive an outbound send through a relabelled channel, because delivery would\n * then resolve the real provider's adapter.\n */\n labelAsProviderKey: z\n .string()\n .min(1)\n .max(64)\n .regex(/^[a-z0-9_-]+$/)\n .optional(),\n})\n\n/**\n * Drive the REAL `ingest_inbound_message` command with a chat-shaped frame.\n *\n * `emit-inbound` below deliberately bypasses the platform compose path (it\n * inserts the `messages` row with raw SQL) because it only ever needed a\n * landing zone for the CRM-link subscribers. That bypass is why an inbound test\n * could pass while `composeMessageSchema` rejected every real message (#4975).\n * This action takes the opposite approach: it hands the frame to the adapter and\n * the ingest command and asserts nothing on the way, so whatever the hub's\n * contract really is, the test feels it.\n */\nconst ingestInboundSchema = z.object({\n action: z.literal('ingest-inbound'),\n channelId: z.string().uuid(),\n /**\n * Opaque sender handle \u2014 a Discord snowflake, a Slack member id, etc. For a\n * channel connected with the default `email` flavor this is the sender's\n * address, and the email stub normalizes it into an email-shaped frame.\n */\n senderIdentifier: z.string().min(1).max(255),\n senderDisplayName: z.string().max(255).optional(),\n /** Honored by the email stub only; chat frames carry no subject. */\n subject: z.string().max(500).optional(),\n body: z.string().max(50_000).optional(),\n externalMessageId: z.string().min(1).max(255),\n externalConversationId: z.string().min(1).max(255),\n})\n\nconst seedSystemChannelSchema = z.object({\n action: z.literal('seed-system-channel'),\n displayName: z.string().min(1).max(255).optional(),\n externalIdentifier: z.string().min(1).max(255).optional(),\n})\n\nconst clearCaptureSchema = z.object({\n action: z.literal('clear-capture'),\n systemRecipient: z.string().email().max(320).optional(),\n captureCorrelationToken: z.string().min(32).max(512).optional(),\n})\n\nconst listCaptureSchema = z.object({\n action: z.literal('list-capture'),\n systemRecipient: z.string().email().max(320).optional(),\n captureCorrelationToken: z.string().min(32).max(512).optional(),\n})\n\nconst emitInboundSchema = z.object({\n action: z.literal('emit-inbound'),\n /** Channel that owns the inbound message; controls authorUserId + default visibility. */\n channelId: z.string().uuid(),\n /** Provider key persisted on the link (defaults to the stub provider). */\n providerKey: z.string().min(1).max(64).optional(),\n /** Normalized inbound addresses (stored under channelPayload). */\n from: addressFieldSchema.optional(),\n to: addressFieldSchema.optional(),\n cc: addressFieldSchema.optional(),\n subject: z.string().max(500).optional(),\n bodyText: z.string().max(200_000).optional(),\n /** RFC2822 Message-ID of this inbound message (for In-Reply-To matching). */\n messageId: z.string().max(500).optional(),\n /** RFC2822 In-Reply-To header (threading-inheritance fallback). */\n inReplyTo: z.string().max(500).optional(),\n references: z.array(z.string().max(500)).max(50).optional(),\n /**\n * Open Mercato `messages.message` thread id this inbound message belongs to.\n * When set, a `messages.message` row is created with this `threadId` so the\n * hub-thread inheritance join can resolve a Person from a sibling message.\n */\n messageThreadId: z.string().uuid().optional(),\n /**\n * Test-only: also create a `ChannelThreadMapping` for the seeded thread. The\n * reaction (`/messages/[id]/reactions`) and thread-assign\n * (`/threads/[id]/assign`) routes resolve the owning channel through this\n * mapping and return 409/404 without it. Opt-in so the existing CRM-link\n * seeds (which don't need a mapping) keep their current mapping-free shape.\n */\n createThreadMapping: z.boolean().optional(),\n})\n\n/**\n * Teardown for test-seeded or test-ingested inbound records: hard-delete the\n * thread mapping, channel link, external message, synthetic conversation and\n * platform message, scoped to the caller's channel. Disconnecting a channel\n * deliberately retains these records, so without this every test leaks into the\n * shared integration database.\n */\nconst purgeInboundSchema = z.object({\n action: z.literal('purge-inbound'),\n channelId: z.string().uuid(),\n channelLinkId: z.string().uuid(),\n messageId: z.string().uuid(),\n conversationId: z.string().uuid(),\n})\n\n/**\n * Read-only: report the channel-side rows a platform message carries.\n *\n * The threading contract lives in rows no API exposes \u2014 an integration test\n * asserting that a composer send really was routed through the hub can only\n * observe them here.\n */\nconst inspectMessageLinksSchema = z.object({\n action: z.literal('inspect-message-links'),\n messageId: z.string().uuid(),\n})\n\nconst bodySchema = z.discriminatedUnion('action', [\n connectChannelSchema,\n inspectMessageLinksSchema,\n ingestInboundSchema,\n emitInboundSchema,\n purgeInboundSchema,\n seedSystemChannelSchema,\n clearCaptureSchema,\n listCaptureSchema,\n])\n\nconst logger = createLogger('communication_channels').child({ component: 'test-seed' })\n\nconst TEST_SEED_CONVERSATION_PREFIX = 'inbound-seed:'\n\nfunction ingestFailureResponse(err: unknown): Response {\n if (err instanceof z.ZodError) {\n return NextResponse.json({ error: err.message, issues: err.issues }, { status: 422 })\n }\n if (isCrudHttpError(err)) {\n return NextResponse.json(err.body, { status: err.status })\n }\n logger.error('ingest-inbound failed', { err })\n return NextResponse.json(\n { error: err instanceof Error ? err.message : '[internal] ingest-inbound failed' },\n { status: 500 },\n )\n}\n\nexport async function POST(req: Request): Promise<Response> {\n // Fail-closed: invisible in production. Mirrors an unknown route (404) rather\n // than 403 so the surface leaks nothing when the flag is off.\n if (!isTestChannelSeedingEnabled()) {\n return NextResponse.json({ error: 'Not found' }, { status: 404 })\n }\n\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub || !auth?.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n let body: z.infer<typeof bodySchema>\n try {\n body = bodySchema.parse(await readJsonSafe(req, null))\n } catch (err) {\n return NextResponse.json(\n { error: err instanceof Error ? err.message : 'Invalid request body' },\n { status: 422 },\n )\n }\n\n const tenantId = auth.tenantId as string\n const organizationId = (auth as { orgId?: string | null }).orgId ?? null\n const userId = auth.sub as string\n const captureScope = { tenantId, organizationId }\n\n if (\n (body.action === 'clear-capture' || body.action === 'list-capture')\n && body.systemRecipient\n && (\n !body.captureCorrelationToken\n || !isTestEmailCaptureAccessAuthorized(req.headers.get('x-om-test-email-capture-access-token'))\n )\n ) {\n return NextResponse.json({ error: 'Forbidden' }, { status: 403 })\n }\n\n if (body.action === 'clear-capture') {\n await clearTestSeedCapturedMessages(captureScope, {\n systemRecipient: body.systemRecipient,\n captureCorrelationToken: body.captureCorrelationToken,\n })\n return NextResponse.json({ ok: true })\n }\n\n if (body.action === 'list-capture') {\n return NextResponse.json({\n items: (await listTestSeedCapturedMessages(captureScope, {\n systemRecipient: body.systemRecipient,\n captureCorrelationToken: body.captureCorrelationToken,\n })).map(({ captureCorrelationToken: _captureCorrelationToken, ...message }) => message),\n })\n }\n\n const container = await createRequestContainer()\n // Defensive: make sure the stub adapter is registered for this process even if\n // a worker-only node skipped module di registration.\n ensureTestSeedAdapterRegistered()\n\n if (body.action === 'inspect-message-links') {\n const em = (container.resolve('em') as EntityManager).fork()\n const links = await em.find(MessageChannelLink, {\n messageId: body.messageId,\n tenantId,\n organizationId,\n })\n const conversationIds = links\n .map((link) => link.externalConversationId)\n .filter((id): id is string => Boolean(id))\n const mappings = conversationIds.length\n ? await em.find(ChannelThreadMapping, {\n externalConversationId: { $in: conversationIds },\n tenantId,\n organizationId,\n })\n : []\n return NextResponse.json({\n links: links.map((link) => ({\n id: link.id,\n direction: link.direction,\n providerKey: link.providerKey,\n channelType: link.channelType,\n deliveryStatus: link.deliveryStatus,\n externalConversationId: link.externalConversationId,\n })),\n threadMappings: mappings.map((mapping) => ({\n id: mapping.id,\n messageThreadId: mapping.messageThreadId,\n channelId: mapping.channelId,\n externalConversationId: mapping.externalConversationId,\n })),\n })\n }\n\n if (body.action === 'seed-system-channel') {\n const em = (container.resolve('em') as EntityManager).fork()\n const credentialsService = container.resolve('integrationCredentialsService') as {\n save: (\n integrationId: string,\n credentials: Record<string, unknown>,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<void>\n }\n await credentialsService.save(\n `channel_${TEST_SEED_PROVIDER_KEY}`,\n { testSeed: true },\n { tenantId, organizationId: organizationId ?? tenantId, userId: null },\n )\n let channel = await findOneWithDecryption(\n em,\n CommunicationChannel,\n {\n providerKey: TEST_SEED_PROVIDER_KEY,\n channelType: 'email',\n tenantId,\n organizationId,\n userId: null,\n deletedAt: null,\n },\n undefined,\n { tenantId, organizationId },\n )\n if (!channel) {\n const stamp = Date.now()\n channel = em.create(CommunicationChannel, {\n providerKey: TEST_SEED_PROVIDER_KEY,\n channelType: 'email',\n displayName: body.displayName ?? `Test Seed System Email ${stamp}`,\n externalIdentifier: body.externalIdentifier ?? `system-${stamp}@test-seed.local`,\n userId: null,\n isPrimary: false,\n isActive: true,\n status: 'connected',\n tenantId,\n organizationId,\n })\n em.persist(channel)\n await em.flush()\n } else if (!channel.isActive || channel.status !== 'connected') {\n channel.isActive = true\n channel.status = 'connected'\n channel.lastError = null\n await em.flush()\n }\n\n return NextResponse.json({ channelId: channel.id }, { status: 201 })\n }\n\n if (body.action === 'connect-channel') {\n const stamp = Date.now()\n const commandBus = container.resolve('commandBus') as CommandBus\n const isChatFlavor = body.providerFlavor === 'chat'\n // A chat channel is deliberately connected WITHOUT an email-ish credential\n // key, so `connect-credential-channel` derives no `externalIdentifier` and\n // the row is shaped exactly like a real Discord channel (identifier NULL \u2014\n // the condition #4977 describes). Inventing one here would recreate the\n // fixture dishonesty that hid #4975.\n const credentials = isChatFlavor\n ? { handle: body.externalIdentifier ?? `test-seed-chat-${stamp}` }\n : {\n username: body.externalIdentifier ?? `test-seed-${stamp}@test-seed.local`,\n fromAddress: body.externalIdentifier ?? `test-seed-${stamp}@test-seed.local`,\n }\n const input: ConnectCredentialChannelInput = {\n providerKey: isChatFlavor ? TEST_SEED_CHAT_PROVIDER_KEY : TEST_SEED_PROVIDER_KEY,\n displayName:\n body.displayName ?? `Test Seed ${isChatFlavor ? 'Chat ' : ''}Channel ${stamp}`,\n credentials,\n userId,\n scope: { tenantId, organizationId },\n }\n const { result } = await commandBus.execute<\n ConnectCredentialChannelInput,\n ConnectCredentialChannelResult\n >(COMMUNICATION_CHANNELS_CONNECT_CREDENTIAL_CHANNEL_COMMAND_ID, {\n input,\n ctx: {\n container,\n auth: auth as never,\n organizationScope: null,\n selectedOrganizationId: organizationId,\n organizationIds: organizationId ? [organizationId] : null,\n },\n })\n if (result.status !== 'connected') {\n return NextResponse.json(\n { error: '[internal] test-seed connect failed', detail: result },\n { status: 500 },\n )\n }\n if (body.labelAsProviderKey && body.labelAsProviderKey !== input.providerKey) {\n const seedEm = (container.resolve('em') as EntityManager).fork()\n const connected = await findOneWithDecryption(\n seedEm,\n CommunicationChannel,\n { id: result.channelId, tenantId },\n undefined,\n { tenantId, organizationId },\n )\n if (!connected) {\n return NextResponse.json(\n { error: '[internal] test-seed could not reload the channel it just connected' },\n { status: 500 },\n )\n }\n connected.providerKey = body.labelAsProviderKey\n await seedEm.flush()\n }\n return NextResponse.json(\n {\n channelId: result.channelId,\n externalIdentifier: result.externalIdentifier,\n providerKey: body.labelAsProviderKey ?? input.providerKey,\n },\n { status: 201 },\n )\n }\n\n // action === 'ingest-inbound' | 'emit-inbound' | 'purge-inbound' \u2014 all address an existing channel.\n const em = (container.resolve('em') as EntityManager).fork()\n // Only the `emit-inbound` branch stamps a caller-chosen provider key onto the\n // rows it seeds; `ingest-inbound` takes the channel's own (see below).\n const providerKey =\n body.action === 'emit-inbound' ? body.providerKey ?? TEST_SEED_PROVIDER_KEY : TEST_SEED_PROVIDER_KEY\n\n // `channelId` is caller-supplied, so confirm it names a channel this tenant/org\n // actually owns before seeding rows that reference it. Mirrors the ownership\n // lookup every other per-channel route performs (see channels/[id]/test-send).\n const ownedChannel = await findOneWithDecryption(\n em,\n CommunicationChannel,\n {\n id: body.channelId,\n tenantId,\n organizationId,\n deletedAt: null,\n },\n undefined,\n { tenantId, organizationId },\n )\n if (!ownedChannel) {\n return NextResponse.json({ error: 'Channel not found' }, { status: 404 })\n }\n\n // Tenant/org scope alone does not authorize: `connect_user_channel` is granted\n // broadly, so a same-tenant caller could otherwise seed against a colleague's\n // personal mailbox. Enforce the module's owner-only contract \u2014 personal\n // channels are owner-restricted, shared channels need `manage`.\n let userFeatures: string[] = []\n try {\n const rbac = container.resolve('rbacService') as RbacServiceLike\n const acl = await rbac.loadAcl(userId, { tenantId, organizationId })\n userFeatures = acl?.isSuperAdmin ? ['*'] : Array.isArray(acl?.features) ? acl.features : []\n } catch {\n userFeatures = []\n }\n try {\n assertCanManageChannel(\n { userId: ownedChannel.userId },\n userId,\n userFeatures,\n 'communication_channels.manage',\n )\n } catch (err) {\n if (err instanceof ChannelAccessDeniedError) {\n return NextResponse.json({ error: 'Channel not found' }, { status: 404 })\n }\n const status = (err as { statusCode?: number }).statusCode ?? 403\n return NextResponse.json(\n { error: err instanceof Error ? err.message : 'Access denied' },\n { status },\n )\n }\n\n if (body.action === 'purge-inbound') {\n const rowScope = { tenantId, organizationId }\n // Only rows hanging off a test-prefixed conversation are purgeable, so a\n // caller cannot use this action to hard-delete real channel traffic.\n const seededConversation = await findOneWithDecryption(\n em,\n ExternalConversation,\n {\n id: body.conversationId,\n channelId: body.channelId,\n externalConversationId: { $like: `${TEST_SEED_CONVERSATION_PREFIX}%` },\n ...rowScope,\n },\n undefined,\n rowScope,\n )\n if (!seededConversation) {\n return NextResponse.json({ error: 'seeded inbound conversation not found' }, { status: 404 })\n }\n await em.nativeDelete(ChannelThreadMapping, {\n externalConversationId: body.conversationId,\n channelId: body.channelId,\n ...rowScope,\n })\n await em.nativeDelete(MessageChannelLink, {\n id: body.channelLinkId,\n externalConversationId: body.conversationId,\n ...rowScope,\n })\n await em.nativeDelete(ExternalMessage, {\n conversationId: body.conversationId,\n channelId: body.channelId,\n ...rowScope,\n })\n await em.nativeDelete(ExternalConversation, {\n id: body.conversationId,\n channelId: body.channelId,\n ...rowScope,\n })\n await deleteTestSeedPlatformMessage(em, {\n messageId: body.messageId,\n channelId: body.channelId,\n conversationId: body.conversationId,\n ...rowScope,\n })\n return NextResponse.json({ ok: true })\n }\n\n if (body.action === 'ingest-inbound') {\n // Everything below this point is the real path: the adapter normalizes the\n // frame and `ingest_inbound_message` composes the platform message through\n // `messages.messages.compose`. Nothing is short-circuited, so a hub contract\n // the provider cannot satisfy surfaces here as a failure instead of hiding\n // behind seeded rows (#4975).\n // The channel's own provider key, never a hardcoded one: ingest does not\n // check that `providerKey` matches the channel it names, so passing a\n // different one would silently stamp the wrong provider onto the link.\n const channelProviderKey = ownedChannel.providerKey\n if (\n channelProviderKey !== TEST_SEED_CHAT_PROVIDER_KEY\n && channelProviderKey !== TEST_SEED_PROVIDER_KEY\n ) {\n return NextResponse.json(\n {\n error:\n 'ingest-inbound requires a channel connected through the test-seed stub; ' +\n `channel ${body.channelId} is '${channelProviderKey}'`,\n },\n { status: 422 },\n )\n }\n\n const commandBus = container.resolve('commandBus') as CommandBus\n const adapterRegistry = container.resolve('channelAdapterRegistry') as {\n get: (key: string) => { normalizeInbound: (raw: unknown) => Promise<unknown> } | undefined\n }\n const adapter = adapterRegistry.get(channelProviderKey)\n if (!adapter) {\n return NextResponse.json(\n { error: '[internal] test-seed chat adapter is not registered' },\n { status: 500 },\n )\n }\n\n let normalized: unknown\n try {\n normalized = await adapter.normalizeInbound({\n raw: {\n externalMessageId: body.externalMessageId,\n externalConversationId: body.externalConversationId,\n senderIdentifier: body.senderIdentifier,\n senderDisplayName: body.senderDisplayName,\n ...(body.subject !== undefined ? { subject: body.subject } : {}),\n body: body.body ?? '',\n },\n eventType: 'message',\n metadata: {},\n })\n } catch (err) {\n return NextResponse.json(\n { error: err instanceof Error ? err.message : '[internal] adapter rejected the inbound frame' },\n { status: 422 },\n )\n }\n\n const ingestInput = {\n channelId: body.channelId,\n providerKey: channelProviderKey,\n channelType: ownedChannel.channelType,\n scope: { tenantId, organizationId },\n message: normalized,\n } as IngestInboundMessageInput\n\n let result: IngestInboundMessageResult\n try {\n const execution = await commandBus.execute<\n IngestInboundMessageInput,\n IngestInboundMessageResult\n >(COMMUNICATION_CHANNELS_INGEST_INBOUND_COMMAND_ID, {\n input: ingestInput,\n ctx: {\n container,\n auth: auth as never,\n organizationScope: null,\n selectedOrganizationId: organizationId,\n organizationIds: organizationId ? [organizationId] : null,\n },\n })\n result = execution.result\n } catch (err) {\n const interceptorRejection = getCommandInterceptorHttpRejection(err)\n if (interceptorRejection) {\n return NextResponse.json(interceptorRejection.body, { status: interceptorRejection.status })\n }\n return ingestFailureResponse(err)\n }\n\n return NextResponse.json(\n {\n status: result.status,\n messageId: result.messageId ?? null,\n conversationId: result.externalConversationId ?? null,\n channelLinkId: result.channelLinkId ?? null,\n channelType: ownedChannel.channelType,\n },\n { status: 201 },\n )\n }\n\n // A MessageChannelLink requires a non-null external_conversation_id (FK) and\n // message_id. Create a synthetic conversation + (optionally threaded) message\n // so the link is shaped like a real inbound row the subscriber can consume.\n const conversation = em.create(ExternalConversation, {\n channelId: body.channelId,\n externalConversationId: `${TEST_SEED_CONVERSATION_PREFIX}${Date.now()}:${Math.random().toString(16).slice(2, 8)}`,\n subject: body.subject ?? null,\n tenantId,\n organizationId,\n lastMessageAt: new Date(),\n })\n em.persist(conversation)\n await em.flush()\n\n const messageId = await createTestSeedPlatformMessage(em, {\n providerKey,\n threadId: body.messageThreadId,\n senderUserId: userId,\n subject: body.subject,\n bodyText: body.bodyText,\n channelId: body.channelId,\n tenantId,\n organizationId,\n })\n if (!messageId) {\n return NextResponse.json({ error: '[internal] failed to seed message row' }, { status: 500 })\n }\n\n const link = em.create(MessageChannelLink, {\n messageId,\n externalConversationId: conversation.id,\n providerKey,\n channelType: 'email',\n direction: 'inbound',\n deliveryStatus: 'delivered',\n channelPayload: {\n ...(body.from !== undefined ? { from: body.from } : {}),\n ...(body.to !== undefined ? { to: body.to } : {}),\n ...(body.cc !== undefined ? { cc: body.cc } : {}),\n ...(body.subject !== undefined ? { subject: body.subject } : {}),\n ...(body.bodyText !== undefined ? { text: body.bodyText } : {}),\n ...(body.inReplyTo !== undefined ? { inReplyTo: body.inReplyTo } : {}),\n ...(body.references !== undefined ? { references: body.references } : {}),\n },\n channelContentType: 'text/plain',\n channelMetadata: {\n ...(body.messageId !== undefined ? { messageId: body.messageId } : {}),\n },\n tenantId,\n organizationId,\n })\n em.persist(link)\n await em.flush()\n\n // Optionally mirror `ingest-inbound-message`: a real inbound message always\n // lands a ChannelThreadMapping that the reaction + thread-assign routes use to\n // resolve the owning channel. Seeded inbound messages skip it by default; opt\n // in for tests that exercise those routes. Keyed by `messageThreadId ?? messageId`\n // to match how those commands resolve the mapping (`message.threadId ?? message.id`).\n if (body.createThreadMapping) {\n const mapping = em.create(ChannelThreadMapping, {\n externalConversationId: conversation.id,\n messageThreadId: body.messageThreadId ?? messageId,\n channelId: body.channelId,\n providerKey,\n externalThreadRef: conversation.externalConversationId,\n tenantId,\n organizationId,\n })\n em.persist(mapping)\n await em.flush()\n }\n\n // Emit the hub event through the real event bus so the persistent customers\n // link-channel-message-received subscriber is enqueued to the `events` queue.\n await emitCommunicationChannelsEvent(\n 'communication_channels.message.received',\n {\n channelLinkId: link.id,\n channelId: body.channelId,\n providerKey,\n direction: 'inbound',\n tenantId,\n organizationId,\n },\n { persistent: true },\n )\n\n return NextResponse.json(\n { channelLinkId: link.id, messageId, conversationId: conversation.id },\n { status: 201 },\n )\n}\n\nexport const openApi = {\n tags: ['CommunicationChannels'],\n methods: {\n POST: {\n summary:\n 'Test-only: seed a connected channel, ingest a real inbound message, emit a seeded inbound link, or purge one (env-gated)',\n tags: ['CommunicationChannels'],\n responses: [\n { status: 201, description: 'Channel seeded / inbound message emitted' },\n { status: 401, description: 'Unauthorized' },\n {\n status: 404,\n description:\n 'Test channel seeding disabled (production default), or the requested channel does not belong to the caller',\n },\n { status: 422, description: 'Invalid request body' },\n { status: 500, description: 'Seed failed' },\n ],\n },\n },\n}\n\nexport default POST\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { CommandBus } from '@open-mercato/shared/lib/commands'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { getCommandInterceptorHttpRejection } from '@open-mercato/shared/lib/commands/errors'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport {\n ChannelThreadMapping,\n CommunicationChannel,\n ExternalConversation,\n ExternalMessage,\n MessageChannelLink,\n} from '../../../data/entities'\nimport { ChannelAccessDeniedError, assertCanManageChannel } from '../../../lib/access-control'\nimport {\n COMMUNICATION_CHANNELS_CONNECT_CREDENTIAL_CHANNEL_COMMAND_ID,\n type ConnectCredentialChannelInput,\n type ConnectCredentialChannelResult,\n} from '../../../commands/connect-credential-channel'\nimport {\n COMMUNICATION_CHANNELS_DISCONNECT_CHANNEL_COMMAND_ID,\n type DisconnectChannelInput,\n type DisconnectChannelResult,\n} from '../../../commands/disconnect-channel'\nimport { emitCommunicationChannelsEvent } from '../../../events'\nimport {\n TEST_SEED_CHAT_PROVIDER_KEY,\n TEST_SEED_PROVIDER_KEY,\n clearTestSeedCapturedMessages,\n createTestSeedPlatformMessage,\n deleteTestSeedPlatformMessage,\n ensureTestSeedAdapterRegistered,\n isTestEmailCaptureAccessAuthorized,\n isTestChannelSeedingEnabled,\n listTestSeedCapturedMessages,\n} from '../../../lib/test-seed'\nimport {\n COMMUNICATION_CHANNELS_INGEST_INBOUND_COMMAND_ID,\n type IngestInboundMessageInput,\n type IngestInboundMessageResult,\n} from '../../../commands/ingest-inbound-message'\n\n/**\n * TEST-ONLY channel seeding endpoint.\n *\n * Gated by `OM_ENABLE_TEST_CHANNEL_SEEDING` \u2014 when the flag is unset (the\n * production default) every request returns 404, so this route is invisible and\n * inert in production. See `lib/test-seed.ts` for the full rationale.\n *\n * Actions, all scoped to the caller's tenant/org:\n * - `connect-channel`: connect a network-free stub channel owned by the caller\n * (delegates to the real connect-credential command so the channel persists\n * credentials + lands in `status='connected'`). Enables the outbound\n * compose \u2192 deliver \u2192 `.sent` chain to complete in CI. `providerFlavor: 'chat'`\n * connects the non-email stub instead, for tests about sender identity, and\n * `labelAsProviderKey` relabels the connected row so a provider-scoped\n * listing has something to list without a live credential probe.\n * - `ingest-inbound`: run the REAL `ingest_inbound_message` command over an\n * adapter-normalized chat frame, so the platform compose path \u2014 and every\n * validation rule on it \u2014 actually executes. Use this for anything that\n * claims inbound works.\n * - `emit-inbound`: insert an inbound `MessageChannelLink` (+ a `messages.message`\n * row for threading) and emit `communication_channels.message.received` so the\n * customers link-channel-message subscriber runs against real Postgres. Enables\n * the inbound auto-link tests (TC-CRM-EMAIL-002..005). NOTE: this action\n * deliberately bypasses `messages.messages.compose` \u2014 it can never prove that\n * the hub accepts a message, only that downstream subscribers fire.\n * - `purge-inbound`: hard-delete the rows one test-seeded or test-ingested\n * inbound message created, for integration teardown.\n * - `disconnect-channel`: run the REAL disconnect command on a channel the\n * caller owns. No HTTP route disconnects without deleting the row, so this is\n * the only way a test can reach a listed `disconnected` channel.\n */\ntype RbacServiceLike = {\n loadAcl: (\n userId: string,\n scope: { tenantId: string | null; organizationId: string | null },\n ) => Promise<{ isSuperAdmin: boolean; features: string[]; organizations: string[] | null }>\n}\n\nexport const metadata = {\n path: '/communication_channels/test-seed',\n POST: {\n requireAuth: true,\n requireFeatures: ['communication_channels.connect_user_channel'],\n },\n}\n\nconst addressObjectSchema = z.object({ address: z.string(), name: z.string().optional() })\nconst addressFieldSchema = z.union([\n z.string(),\n addressObjectSchema,\n z.array(z.union([z.string(), addressObjectSchema])),\n])\n\nconst connectChannelSchema = z.object({\n action: z.literal('connect-channel'),\n displayName: z.string().min(1).max(255).optional(),\n externalIdentifier: z.string().min(1).max(255).optional(),\n /**\n * Which stub provider to connect. `email` (default) keeps the historical\n * email-shaped channel; `chat` connects a channel whose senders have no\n * address, so a test can exercise the hub's non-email identity contract\n * without inventing one (#4975).\n */\n providerFlavor: z.enum(['email', 'chat']).optional(),\n /**\n * TEST-ONLY: rewrite the connected stub channel's `provider_key` to this value.\n *\n * Routes that a provider package owns filter on their own `provider_key`, and\n * a real channel for those providers cannot be connected in CI \u2014 the Discord\n * adapter's `validateCredentials` performs a live API call against a bot token\n * no test environment has. Without this, a provider-scoped listing has nothing\n * to list and can only be asserted against an empty result, which is exactly\n * the assertion that would have stayed green through #5602.\n *\n * It relabels a row; it registers nothing. The stub adapter is still the only\n * adapter present, so the channel remains network-free \u2014 but no test should\n * drive an outbound send through a relabelled channel, because delivery would\n * then resolve the real provider's adapter.\n */\n labelAsProviderKey: z\n .string()\n .min(1)\n .max(64)\n .regex(/^[a-z0-9_-]+$/)\n .optional(),\n})\n\n/**\n * Drive the REAL `ingest_inbound_message` command with a chat-shaped frame.\n *\n * `emit-inbound` below deliberately bypasses the platform compose path (it\n * inserts the `messages` row with raw SQL) because it only ever needed a\n * landing zone for the CRM-link subscribers. That bypass is why an inbound test\n * could pass while `composeMessageSchema` rejected every real message (#4975).\n * This action takes the opposite approach: it hands the frame to the adapter and\n * the ingest command and asserts nothing on the way, so whatever the hub's\n * contract really is, the test feels it.\n */\nconst ingestInboundSchema = z.object({\n action: z.literal('ingest-inbound'),\n channelId: z.string().uuid(),\n /**\n * Opaque sender handle \u2014 a Discord snowflake, a Slack member id, etc. For a\n * channel connected with the default `email` flavor this is the sender's\n * address, and the email stub normalizes it into an email-shaped frame.\n */\n senderIdentifier: z.string().min(1).max(255),\n senderDisplayName: z.string().max(255).optional(),\n /** Honored by the email stub only; chat frames carry no subject. */\n subject: z.string().max(500).optional(),\n body: z.string().max(50_000).optional(),\n externalMessageId: z.string().min(1).max(255),\n externalConversationId: z.string().min(1).max(255),\n})\n\nconst seedSystemChannelSchema = z.object({\n action: z.literal('seed-system-channel'),\n displayName: z.string().min(1).max(255).optional(),\n externalIdentifier: z.string().min(1).max(255).optional(),\n})\n\nconst disconnectChannelSchema = z.object({\n action: z.literal('disconnect-channel'),\n channelId: z.string().uuid(),\n})\n\nconst clearCaptureSchema = z.object({\n action: z.literal('clear-capture'),\n systemRecipient: z.string().email().max(320).optional(),\n captureCorrelationToken: z.string().min(32).max(512).optional(),\n})\n\nconst listCaptureSchema = z.object({\n action: z.literal('list-capture'),\n systemRecipient: z.string().email().max(320).optional(),\n captureCorrelationToken: z.string().min(32).max(512).optional(),\n})\n\nconst emitInboundSchema = z.object({\n action: z.literal('emit-inbound'),\n /** Channel that owns the inbound message; controls authorUserId + default visibility. */\n channelId: z.string().uuid(),\n /** Provider key persisted on the link (defaults to the stub provider). */\n providerKey: z.string().min(1).max(64).optional(),\n /** Normalized inbound addresses (stored under channelPayload). */\n from: addressFieldSchema.optional(),\n to: addressFieldSchema.optional(),\n cc: addressFieldSchema.optional(),\n subject: z.string().max(500).optional(),\n bodyText: z.string().max(200_000).optional(),\n /** RFC2822 Message-ID of this inbound message (for In-Reply-To matching). */\n messageId: z.string().max(500).optional(),\n /** RFC2822 In-Reply-To header (threading-inheritance fallback). */\n inReplyTo: z.string().max(500).optional(),\n references: z.array(z.string().max(500)).max(50).optional(),\n /**\n * Open Mercato `messages.message` thread id this inbound message belongs to.\n * When set, a `messages.message` row is created with this `threadId` so the\n * hub-thread inheritance join can resolve a Person from a sibling message.\n */\n messageThreadId: z.string().uuid().optional(),\n /**\n * Test-only: also create a `ChannelThreadMapping` for the seeded thread. The\n * reaction (`/messages/[id]/reactions`) and thread-assign\n * (`/threads/[id]/assign`) routes resolve the owning channel through this\n * mapping and return 409/404 without it. Opt-in so the existing CRM-link\n * seeds (which don't need a mapping) keep their current mapping-free shape.\n */\n createThreadMapping: z.boolean().optional(),\n})\n\n/**\n * Teardown for test-seeded or test-ingested inbound records: hard-delete the\n * thread mapping, channel link, external message, synthetic conversation and\n * platform message, scoped to the caller's channel. Disconnecting a channel\n * deliberately retains these records, so without this every test leaks into the\n * shared integration database.\n */\nconst purgeInboundSchema = z.object({\n action: z.literal('purge-inbound'),\n channelId: z.string().uuid(),\n channelLinkId: z.string().uuid(),\n messageId: z.string().uuid(),\n conversationId: z.string().uuid(),\n})\n\n/**\n * Read-only: report the channel-side rows a platform message carries.\n *\n * The threading contract lives in rows no API exposes \u2014 an integration test\n * asserting that a composer send really was routed through the hub can only\n * observe them here.\n */\nconst inspectMessageLinksSchema = z.object({\n action: z.literal('inspect-message-links'),\n messageId: z.string().uuid(),\n})\n\nconst bodySchema = z.discriminatedUnion('action', [\n connectChannelSchema,\n inspectMessageLinksSchema,\n ingestInboundSchema,\n emitInboundSchema,\n purgeInboundSchema,\n seedSystemChannelSchema,\n disconnectChannelSchema,\n clearCaptureSchema,\n listCaptureSchema,\n])\n\nconst logger = createLogger('communication_channels').child({ component: 'test-seed' })\n\nconst TEST_SEED_CONVERSATION_PREFIX = 'inbound-seed:'\n\nfunction ingestFailureResponse(err: unknown): Response {\n if (err instanceof z.ZodError) {\n return NextResponse.json({ error: err.message, issues: err.issues }, { status: 422 })\n }\n if (isCrudHttpError(err)) {\n return NextResponse.json(err.body, { status: err.status })\n }\n logger.error('ingest-inbound failed', { err })\n return NextResponse.json(\n { error: err instanceof Error ? err.message : '[internal] ingest-inbound failed' },\n { status: 500 },\n )\n}\n\nexport async function POST(req: Request): Promise<Response> {\n // Fail-closed: invisible in production. Mirrors an unknown route (404) rather\n // than 403 so the surface leaks nothing when the flag is off.\n if (!isTestChannelSeedingEnabled()) {\n return NextResponse.json({ error: 'Not found' }, { status: 404 })\n }\n\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub || !auth?.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n let body: z.infer<typeof bodySchema>\n try {\n body = bodySchema.parse(await readJsonSafe(req, null))\n } catch (err) {\n return NextResponse.json(\n { error: err instanceof Error ? err.message : 'Invalid request body' },\n { status: 422 },\n )\n }\n\n const tenantId = auth.tenantId as string\n const organizationId = (auth as { orgId?: string | null }).orgId ?? null\n const userId = auth.sub as string\n const captureScope = { tenantId, organizationId }\n\n if (\n (body.action === 'clear-capture' || body.action === 'list-capture')\n && body.systemRecipient\n && (\n !body.captureCorrelationToken\n || !isTestEmailCaptureAccessAuthorized(req.headers.get('x-om-test-email-capture-access-token'))\n )\n ) {\n return NextResponse.json({ error: 'Forbidden' }, { status: 403 })\n }\n\n if (body.action === 'clear-capture') {\n await clearTestSeedCapturedMessages(captureScope, {\n systemRecipient: body.systemRecipient,\n captureCorrelationToken: body.captureCorrelationToken,\n })\n return NextResponse.json({ ok: true })\n }\n\n if (body.action === 'list-capture') {\n return NextResponse.json({\n items: (await listTestSeedCapturedMessages(captureScope, {\n systemRecipient: body.systemRecipient,\n captureCorrelationToken: body.captureCorrelationToken,\n })).map(({ captureCorrelationToken: _captureCorrelationToken, ...message }) => message),\n })\n }\n\n const container = await createRequestContainer()\n // Defensive: make sure the stub adapter is registered for this process even if\n // a worker-only node skipped module di registration.\n ensureTestSeedAdapterRegistered()\n\n if (body.action === 'inspect-message-links') {\n const em = (container.resolve('em') as EntityManager).fork()\n const links = await em.find(MessageChannelLink, {\n messageId: body.messageId,\n tenantId,\n organizationId,\n })\n const conversationIds = links\n .map((link) => link.externalConversationId)\n .filter((id): id is string => Boolean(id))\n const mappings = conversationIds.length\n ? await em.find(ChannelThreadMapping, {\n externalConversationId: { $in: conversationIds },\n tenantId,\n organizationId,\n })\n : []\n return NextResponse.json({\n links: links.map((link) => ({\n id: link.id,\n direction: link.direction,\n providerKey: link.providerKey,\n channelType: link.channelType,\n deliveryStatus: link.deliveryStatus,\n externalConversationId: link.externalConversationId,\n })),\n threadMappings: mappings.map((mapping) => ({\n id: mapping.id,\n messageThreadId: mapping.messageThreadId,\n channelId: mapping.channelId,\n externalConversationId: mapping.externalConversationId,\n })),\n })\n }\n\n if (body.action === 'seed-system-channel') {\n const em = (container.resolve('em') as EntityManager).fork()\n const credentialsService = container.resolve('integrationCredentialsService') as {\n save: (\n integrationId: string,\n credentials: Record<string, unknown>,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<void>\n }\n await credentialsService.save(\n `channel_${TEST_SEED_PROVIDER_KEY}`,\n { testSeed: true },\n { tenantId, organizationId: organizationId ?? tenantId, userId: null },\n )\n let channel = await findOneWithDecryption(\n em,\n CommunicationChannel,\n {\n providerKey: TEST_SEED_PROVIDER_KEY,\n channelType: 'email',\n tenantId,\n organizationId,\n userId: null,\n deletedAt: null,\n },\n undefined,\n { tenantId, organizationId },\n )\n if (!channel) {\n const stamp = Date.now()\n channel = em.create(CommunicationChannel, {\n providerKey: TEST_SEED_PROVIDER_KEY,\n channelType: 'email',\n displayName: body.displayName ?? `Test Seed System Email ${stamp}`,\n externalIdentifier: body.externalIdentifier ?? `system-${stamp}@test-seed.local`,\n userId: null,\n isPrimary: false,\n isActive: true,\n status: 'connected',\n tenantId,\n organizationId,\n })\n em.persist(channel)\n await em.flush()\n } else if (!channel.isActive || channel.status !== 'connected') {\n channel.isActive = true\n channel.status = 'connected'\n channel.lastError = null\n await em.flush()\n }\n\n return NextResponse.json({ channelId: channel.id }, { status: 201 })\n }\n\n if (body.action === 'disconnect-channel') {\n const commandBus = container.resolve('commandBus') as CommandBus\n const { result } = await commandBus.execute<DisconnectChannelInput, DisconnectChannelResult>(\n COMMUNICATION_CHANNELS_DISCONNECT_CHANNEL_COMMAND_ID,\n {\n input: { channelId: body.channelId, userId, scope: { tenantId, organizationId } },\n ctx: {\n container,\n auth: auth as never,\n organizationScope: null,\n selectedOrganizationId: organizationId,\n organizationIds: organizationId ? [organizationId] : null,\n },\n },\n )\n if (result.status !== 'disconnected') {\n return NextResponse.json(\n { error: '[internal] test-seed disconnect failed', detail: { status: result.status } },\n { status: 500 },\n )\n }\n return NextResponse.json({ channelId: result.channelId, status: result.status }, { status: 200 })\n }\n\n if (body.action === 'connect-channel') {\n const stamp = Date.now()\n const commandBus = container.resolve('commandBus') as CommandBus\n const isChatFlavor = body.providerFlavor === 'chat'\n // A chat channel is deliberately connected WITHOUT an email-ish credential\n // key, so `connect-credential-channel` derives no `externalIdentifier` and\n // the row is shaped exactly like a real Discord channel (identifier NULL \u2014\n // the condition #4977 describes). Inventing one here would recreate the\n // fixture dishonesty that hid #4975.\n const credentials = isChatFlavor\n ? { handle: body.externalIdentifier ?? `test-seed-chat-${stamp}` }\n : {\n username: body.externalIdentifier ?? `test-seed-${stamp}@test-seed.local`,\n fromAddress: body.externalIdentifier ?? `test-seed-${stamp}@test-seed.local`,\n }\n const input: ConnectCredentialChannelInput = {\n providerKey: isChatFlavor ? TEST_SEED_CHAT_PROVIDER_KEY : TEST_SEED_PROVIDER_KEY,\n displayName:\n body.displayName ?? `Test Seed ${isChatFlavor ? 'Chat ' : ''}Channel ${stamp}`,\n credentials,\n userId,\n scope: { tenantId, organizationId },\n }\n const { result } = await commandBus.execute<\n ConnectCredentialChannelInput,\n ConnectCredentialChannelResult\n >(COMMUNICATION_CHANNELS_CONNECT_CREDENTIAL_CHANNEL_COMMAND_ID, {\n input,\n ctx: {\n container,\n auth: auth as never,\n organizationScope: null,\n selectedOrganizationId: organizationId,\n organizationIds: organizationId ? [organizationId] : null,\n },\n })\n if (result.status !== 'connected') {\n return NextResponse.json(\n { error: '[internal] test-seed connect failed', detail: result },\n { status: 500 },\n )\n }\n if (body.labelAsProviderKey && body.labelAsProviderKey !== input.providerKey) {\n const seedEm = (container.resolve('em') as EntityManager).fork()\n const connected = await findOneWithDecryption(\n seedEm,\n CommunicationChannel,\n { id: result.channelId, tenantId },\n undefined,\n { tenantId, organizationId },\n )\n if (!connected) {\n return NextResponse.json(\n { error: '[internal] test-seed could not reload the channel it just connected' },\n { status: 500 },\n )\n }\n connected.providerKey = body.labelAsProviderKey\n await seedEm.flush()\n }\n return NextResponse.json(\n {\n channelId: result.channelId,\n externalIdentifier: result.externalIdentifier,\n providerKey: body.labelAsProviderKey ?? input.providerKey,\n },\n { status: 201 },\n )\n }\n\n // action === 'ingest-inbound' | 'emit-inbound' | 'purge-inbound' \u2014 all address an existing channel.\n const em = (container.resolve('em') as EntityManager).fork()\n // Only the `emit-inbound` branch stamps a caller-chosen provider key onto the\n // rows it seeds; `ingest-inbound` takes the channel's own (see below).\n const providerKey =\n body.action === 'emit-inbound' ? body.providerKey ?? TEST_SEED_PROVIDER_KEY : TEST_SEED_PROVIDER_KEY\n\n // `channelId` is caller-supplied, so confirm it names a channel this tenant/org\n // actually owns before seeding rows that reference it. Mirrors the ownership\n // lookup every other per-channel route performs (see channels/[id]/test-send).\n const ownedChannel = await findOneWithDecryption(\n em,\n CommunicationChannel,\n {\n id: body.channelId,\n tenantId,\n organizationId,\n deletedAt: null,\n },\n undefined,\n { tenantId, organizationId },\n )\n if (!ownedChannel) {\n return NextResponse.json({ error: 'Channel not found' }, { status: 404 })\n }\n\n // Tenant/org scope alone does not authorize: `connect_user_channel` is granted\n // broadly, so a same-tenant caller could otherwise seed against a colleague's\n // personal mailbox. Enforce the module's owner-only contract \u2014 personal\n // channels are owner-restricted, shared channels need `manage`.\n let userFeatures: string[] = []\n try {\n const rbac = container.resolve('rbacService') as RbacServiceLike\n const acl = await rbac.loadAcl(userId, { tenantId, organizationId })\n userFeatures = acl?.isSuperAdmin ? ['*'] : Array.isArray(acl?.features) ? acl.features : []\n } catch {\n userFeatures = []\n }\n try {\n assertCanManageChannel(\n { userId: ownedChannel.userId },\n userId,\n userFeatures,\n 'communication_channels.manage',\n )\n } catch (err) {\n if (err instanceof ChannelAccessDeniedError) {\n return NextResponse.json({ error: 'Channel not found' }, { status: 404 })\n }\n const status = (err as { statusCode?: number }).statusCode ?? 403\n return NextResponse.json(\n { error: err instanceof Error ? err.message : 'Access denied' },\n { status },\n )\n }\n\n if (body.action === 'purge-inbound') {\n const rowScope = { tenantId, organizationId }\n // Only rows hanging off a test-prefixed conversation are purgeable, so a\n // caller cannot use this action to hard-delete real channel traffic.\n const seededConversation = await findOneWithDecryption(\n em,\n ExternalConversation,\n {\n id: body.conversationId,\n channelId: body.channelId,\n externalConversationId: { $like: `${TEST_SEED_CONVERSATION_PREFIX}%` },\n ...rowScope,\n },\n undefined,\n rowScope,\n )\n if (!seededConversation) {\n return NextResponse.json({ error: 'seeded inbound conversation not found' }, { status: 404 })\n }\n await em.nativeDelete(ChannelThreadMapping, {\n externalConversationId: body.conversationId,\n channelId: body.channelId,\n ...rowScope,\n })\n await em.nativeDelete(MessageChannelLink, {\n id: body.channelLinkId,\n externalConversationId: body.conversationId,\n ...rowScope,\n })\n await em.nativeDelete(ExternalMessage, {\n conversationId: body.conversationId,\n channelId: body.channelId,\n ...rowScope,\n })\n await em.nativeDelete(ExternalConversation, {\n id: body.conversationId,\n channelId: body.channelId,\n ...rowScope,\n })\n await deleteTestSeedPlatformMessage(em, {\n messageId: body.messageId,\n channelId: body.channelId,\n conversationId: body.conversationId,\n ...rowScope,\n })\n return NextResponse.json({ ok: true })\n }\n\n if (body.action === 'ingest-inbound') {\n // Everything below this point is the real path: the adapter normalizes the\n // frame and `ingest_inbound_message` composes the platform message through\n // `messages.messages.compose`. Nothing is short-circuited, so a hub contract\n // the provider cannot satisfy surfaces here as a failure instead of hiding\n // behind seeded rows (#4975).\n // The channel's own provider key, never a hardcoded one: ingest does not\n // check that `providerKey` matches the channel it names, so passing a\n // different one would silently stamp the wrong provider onto the link.\n const channelProviderKey = ownedChannel.providerKey\n if (\n channelProviderKey !== TEST_SEED_CHAT_PROVIDER_KEY\n && channelProviderKey !== TEST_SEED_PROVIDER_KEY\n ) {\n return NextResponse.json(\n {\n error:\n 'ingest-inbound requires a channel connected through the test-seed stub; ' +\n `channel ${body.channelId} is '${channelProviderKey}'`,\n },\n { status: 422 },\n )\n }\n\n const commandBus = container.resolve('commandBus') as CommandBus\n const adapterRegistry = container.resolve('channelAdapterRegistry') as {\n get: (key: string) => { normalizeInbound: (raw: unknown) => Promise<unknown> } | undefined\n }\n const adapter = adapterRegistry.get(channelProviderKey)\n if (!adapter) {\n return NextResponse.json(\n { error: '[internal] test-seed chat adapter is not registered' },\n { status: 500 },\n )\n }\n\n let normalized: unknown\n try {\n normalized = await adapter.normalizeInbound({\n raw: {\n externalMessageId: body.externalMessageId,\n externalConversationId: body.externalConversationId,\n senderIdentifier: body.senderIdentifier,\n senderDisplayName: body.senderDisplayName,\n ...(body.subject !== undefined ? { subject: body.subject } : {}),\n body: body.body ?? '',\n },\n eventType: 'message',\n metadata: {},\n })\n } catch (err) {\n return NextResponse.json(\n { error: err instanceof Error ? err.message : '[internal] adapter rejected the inbound frame' },\n { status: 422 },\n )\n }\n\n const ingestInput = {\n channelId: body.channelId,\n providerKey: channelProviderKey,\n channelType: ownedChannel.channelType,\n scope: { tenantId, organizationId },\n message: normalized,\n } as IngestInboundMessageInput\n\n let result: IngestInboundMessageResult\n try {\n const execution = await commandBus.execute<\n IngestInboundMessageInput,\n IngestInboundMessageResult\n >(COMMUNICATION_CHANNELS_INGEST_INBOUND_COMMAND_ID, {\n input: ingestInput,\n ctx: {\n container,\n auth: auth as never,\n organizationScope: null,\n selectedOrganizationId: organizationId,\n organizationIds: organizationId ? [organizationId] : null,\n },\n })\n result = execution.result\n } catch (err) {\n const interceptorRejection = getCommandInterceptorHttpRejection(err)\n if (interceptorRejection) {\n return NextResponse.json(interceptorRejection.body, { status: interceptorRejection.status })\n }\n return ingestFailureResponse(err)\n }\n\n return NextResponse.json(\n {\n status: result.status,\n messageId: result.messageId ?? null,\n conversationId: result.externalConversationId ?? null,\n channelLinkId: result.channelLinkId ?? null,\n channelType: ownedChannel.channelType,\n },\n { status: 201 },\n )\n }\n\n // A MessageChannelLink requires a non-null external_conversation_id (FK) and\n // message_id. Create a synthetic conversation + (optionally threaded) message\n // so the link is shaped like a real inbound row the subscriber can consume.\n const conversation = em.create(ExternalConversation, {\n channelId: body.channelId,\n externalConversationId: `${TEST_SEED_CONVERSATION_PREFIX}${Date.now()}:${Math.random().toString(16).slice(2, 8)}`,\n subject: body.subject ?? null,\n tenantId,\n organizationId,\n lastMessageAt: new Date(),\n })\n em.persist(conversation)\n await em.flush()\n\n const messageId = await createTestSeedPlatformMessage(em, {\n providerKey,\n threadId: body.messageThreadId,\n senderUserId: userId,\n subject: body.subject,\n bodyText: body.bodyText,\n channelId: body.channelId,\n tenantId,\n organizationId,\n })\n if (!messageId) {\n return NextResponse.json({ error: '[internal] failed to seed message row' }, { status: 500 })\n }\n\n const link = em.create(MessageChannelLink, {\n messageId,\n externalConversationId: conversation.id,\n providerKey,\n channelType: 'email',\n direction: 'inbound',\n deliveryStatus: 'delivered',\n channelPayload: {\n ...(body.from !== undefined ? { from: body.from } : {}),\n ...(body.to !== undefined ? { to: body.to } : {}),\n ...(body.cc !== undefined ? { cc: body.cc } : {}),\n ...(body.subject !== undefined ? { subject: body.subject } : {}),\n ...(body.bodyText !== undefined ? { text: body.bodyText } : {}),\n ...(body.inReplyTo !== undefined ? { inReplyTo: body.inReplyTo } : {}),\n ...(body.references !== undefined ? { references: body.references } : {}),\n },\n channelContentType: 'text/plain',\n channelMetadata: {\n ...(body.messageId !== undefined ? { messageId: body.messageId } : {}),\n },\n tenantId,\n organizationId,\n })\n em.persist(link)\n await em.flush()\n\n // Optionally mirror `ingest-inbound-message`: a real inbound message always\n // lands a ChannelThreadMapping that the reaction + thread-assign routes use to\n // resolve the owning channel. Seeded inbound messages skip it by default; opt\n // in for tests that exercise those routes. Keyed by `messageThreadId ?? messageId`\n // to match how those commands resolve the mapping (`message.threadId ?? message.id`).\n if (body.createThreadMapping) {\n const mapping = em.create(ChannelThreadMapping, {\n externalConversationId: conversation.id,\n messageThreadId: body.messageThreadId ?? messageId,\n channelId: body.channelId,\n providerKey,\n externalThreadRef: conversation.externalConversationId,\n tenantId,\n organizationId,\n })\n em.persist(mapping)\n await em.flush()\n }\n\n // Emit the hub event through the real event bus so the persistent customers\n // link-channel-message-received subscriber is enqueued to the `events` queue.\n await emitCommunicationChannelsEvent(\n 'communication_channels.message.received',\n {\n channelLinkId: link.id,\n channelId: body.channelId,\n providerKey,\n direction: 'inbound',\n tenantId,\n organizationId,\n },\n { persistent: true },\n )\n\n return NextResponse.json(\n { channelLinkId: link.id, messageId, conversationId: conversation.id },\n { status: 201 },\n )\n}\n\nexport const openApi = {\n tags: ['CommunicationChannels'],\n methods: {\n POST: {\n summary:\n 'Test-only: seed a connected channel, ingest a real inbound message, emit a seeded inbound link, or purge one (env-gated)',\n tags: ['CommunicationChannels'],\n responses: [\n { status: 201, description: 'Channel seeded / inbound message emitted' },\n { status: 401, description: 'Unauthorized' },\n {\n status: 404,\n description:\n 'Test channel seeding disabled (production default), or the requested channel does not belong to the caller',\n },\n { status: 422, description: 'Invalid request body' },\n { status: 500, description: 'Seed failed' },\n ],\n },\n },\n}\n\nexport default POST\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["em", "link"]
|
|
7
7
|
}
|
|
@@ -390,7 +390,7 @@ function ProfileCommunicationChannelsPage() {
|
|
|
390
390
|
const rowActions = React.useCallback(
|
|
391
391
|
(channel) => {
|
|
392
392
|
const items = [];
|
|
393
|
-
if (!channel.isPrimary) {
|
|
393
|
+
if (!channel.isPrimary && channel.status !== "disconnected") {
|
|
394
394
|
items.push({
|
|
395
395
|
id: "set-primary",
|
|
396
396
|
label: t("communication_channels.profile.actions.setPrimary", "Set as primary"),
|
package/dist/modules/communication_channels/backend/profile/communication-channels/page.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/communication_channels/backend/profile/communication-channels/page.tsx"],
|
|
4
|
-
"sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport { extensionPoints } from '@open-mercato/core/modules/communication_channels/extension-points'\nimport { getImportHistoryLimits } from '@open-mercato/core/modules/communication_channels/lib/import-history-limits'\nimport { OAUTH_RESULT_QUERY_PARAM, stripOAuthResultParams } from '@open-mercato/core/modules/communication_channels/lib/oauth-result'\nimport type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'\nimport { useRouter, useSearchParams } from 'next/navigation'\nimport { Page, PageBody } from '@open-mercato/ui/backend/Page'\nimport { DataTable } from '@open-mercato/ui/backend/DataTable'\nimport { Tag } from '@open-mercato/ui/primitives/tag'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { Alert, AlertDescription } from '@open-mercato/ui/primitives/alert'\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n} from '@open-mercato/ui/primitives/dialog'\nimport { Input } from '@open-mercato/ui/primitives/input'\nimport { Label } from '@open-mercato/ui/primitives/label'\nimport { Textarea } from '@open-mercato/ui/primitives/textarea'\nimport { KbdShortcut } from '@open-mercato/ui/primitives/kbd'\nimport { RowActions, type RowActionItem } from '@open-mercato/ui/backend/RowActions'\nimport { apiCall, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'\nimport { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'\nimport { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'\nimport { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { ConnectChannelMenu } from './ConnectChannelMenu'\n\ntype ChannelRow = {\n id: string\n providerKey: string\n channelType: string\n displayName: string\n externalIdentifier: string | null\n isPrimary: boolean\n /** `shared` = a team mailbox: teammates can read the CRM email it ingests. */\n visibility: 'private' | 'shared'\n /** Version token for the share toggle's optimistic-lock header. */\n updatedAt: string | null\n isActive: boolean\n status: 'connected' | 'requires_reauth' | 'error' | 'disconnected'\n lastError: string | null\n pollIntervalSeconds: number | null\n lastPolledAt: string | null\n /** Spec C \u2014 push delivery state (null when provider doesn't support push). */\n pushStatus: 'active' | 'inactive' | 'failed' | null\n lastPushError: { code: string | null; message: string | null; at: string | null } | null\n /**\n * `true` when the adapter declares real-time push, so the hub's poll worker\n * skips this channel entirely \u2014 inbound arrives over the provider connection.\n */\n supportsRealtimePush: boolean\n /** `true` when the adapter implements `registerPush` (Gmail-style subscriptions). */\n supportsPushRegistration: boolean\n createdAt: string | null\n}\n\nconst PROFILE_CHANNELS_MUTATION_CONTEXT_ID = 'communication-channels-profile'\nconst IMPORT_HISTORY_MUTATION_CONTEXT_ID = 'communication-channels-import-history'\nconst DISCONNECT_MUTATION_CONTEXT_ID = 'communication-channels-disconnect'\n\ntype ChannelMutationContext = {\n formId: string\n resourceKind: string\n resourceId: string\n retryLastMutation: () => Promise<boolean>\n}\n\nexport default function ProfileCommunicationChannelsPage() {\n const t = useT()\n const router = useRouter()\n const searchParams = useSearchParams()\n const flashType = searchParams?.get(OAUTH_RESULT_QUERY_PARAM)\n const flashCode = searchParams?.get('code')\n const flashProvider = searchParams?.get('provider')\n\n const [rows, setRows] = React.useState<ChannelRow[]>([])\n const [isLoading, setIsLoading] = React.useState(true)\n const [errorMessage, setErrorMessage] = React.useState<string | null>(null)\n const [reloadKey, setReloadKey] = React.useState(0)\n const [importChannel, setImportChannel] = React.useState<ChannelRow | null>(null)\n const [disconnectChannel, setDisconnectChannel] = React.useState<ChannelRow | null>(null)\n const { runMutation, retryLastMutation } = useGuardedMutation<ChannelMutationContext>({\n contextId: PROFILE_CHANNELS_MUTATION_CONTEXT_ID,\n blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),\n })\n // Sharing a mailbox is privacy-consequential, so the widening direction is\n // confirmed. Escape cancels and Cmd/Ctrl+Enter submits (ConfirmDialog owns both).\n const { confirm, ConfirmDialogElement } = useConfirmDialog()\n\n React.useEffect(() => {\n if (flashType === 'connected') {\n const providerLabel = flashProvider\n ? t(`communication_channels.channel.providers.${flashProvider}`, '')\n : ''\n flash(\n providerLabel\n ? t('communication_channels.profile.flash.connectedWithProvider', 'Channel connected ({provider}).', {\n provider: providerLabel,\n })\n : t('communication_channels.profile.flash.connected', 'Channel connected.'),\n 'success',\n )\n } else if (flashType === 'error') {\n flash(\n flashCode === 'oauth_client_not_configured'\n ? t(\n 'communication_channels.profile.connect.notConfigured',\n 'This provider is not configured yet. Ask an administrator to add the OAuth Client ID and Secret under Integrations before connecting a mailbox.',\n )\n : flashCode === 'mailbox_already_connected'\n ? t(\n 'communication_channels.profile.connect.mailboxAlreadyConnected',\n 'This mailbox is already connected through another provider. Disconnect it first to reconnect it with a different one.',\n )\n : flashCode === 'replay'\n ? t(\n 'communication_channels.profile.flash.replayedState',\n 'This connection link was already used \u2014 please start the connection again.',\n )\n : flashCode === 'state_store_unavailable'\n ? t(\n 'communication_channels.profile.flash.stateStoreUnavailable',\n 'A server error prevented completing the connection. Please try again in a moment.',\n )\n : t('communication_channels.profile.flash.error', 'Failed to connect channel.'),\n 'error',\n )\n } else {\n return\n }\n router.replace(stripOAuthResultParams(window.location.href), { scroll: false })\n }, [flashType, flashCode, flashProvider, router, t])\n\n React.useEffect(() => {\n let cancelled = false\n async function load() {\n setIsLoading(true)\n setErrorMessage(null)\n const response = await apiCall<{ items?: ChannelRow[] }>(\n '/api/communication_channels/me/channels',\n ).catch((err: unknown) => ({\n ok: false,\n result: { error: err instanceof Error ? err.message : 'Failed to load channels' },\n }))\n if (cancelled) return\n if (!response.ok) {\n const body = response.result as { error?: string } | undefined\n setErrorMessage(\n body?.error ?? t('communication_channels.errors.loadList', 'Failed to load channels'),\n )\n setRows([])\n } else {\n const data = (response.result ?? {}) as { items?: ChannelRow[] }\n setRows(Array.isArray(data.items) ? data.items : [])\n }\n setIsLoading(false)\n }\n void load()\n return () => {\n cancelled = true\n }\n }, [reloadKey, t])\n\n const reauthRows = rows.filter((r) => r.status === 'requires_reauth')\n\n const reloadChannels = React.useCallback(() => setReloadKey((k) => k + 1), [])\n\n const onSetVisibility = React.useCallback(\n async (channel: ChannelRow, nextShared: boolean) => {\n // Only the widening direction needs a confirmation; making a mailbox\n // private again is the safe direction and stays one click.\n if (nextShared) {\n const confirmed = await confirm({\n title: t(\n 'communication_channels.profile.share.confirm.title',\n 'Share this mailbox with your team?',\n ),\n text: t(\n 'communication_channels.profile.share.confirm.text',\n 'Colleagues will be able to read the CRM email this mailbox handles, including messages already received. You stay the only person who can manage the connection, and you can stop sharing at any time.',\n ),\n confirmText: t('communication_channels.profile.share.confirm.cta', 'Share with team'),\n })\n if (!confirmed) return\n }\n\n const nextVisibility = nextShared ? 'shared' : 'private'\n let response\n try {\n response = await runMutation({\n operation: () => withScopedApiRequestHeaders(\n buildOptimisticLockHeader(channel.updatedAt),\n () => apiCall(\n `/api/communication_channels/channels/${encodeURIComponent(channel.id)}/visibility`,\n {\n method: 'PUT',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ visibility: nextVisibility }),\n },\n ),\n ),\n context: {\n formId: PROFILE_CHANNELS_MUTATION_CONTEXT_ID,\n resourceKind: 'communication_channels.channel',\n resourceId: channel.id,\n retryLastMutation,\n },\n mutationPayload: { visibility: nextVisibility },\n })\n } catch (err) {\n if (surfaceRecordConflict(err, t)) return\n flash(\n err instanceof Error\n ? err.message\n : t('communication_channels.profile.share.failed', 'Failed to update sharing'),\n 'error',\n )\n return\n }\n if (!response.ok) {\n if (surfaceRecordConflict(\n { status: response.status, body: response.result },\n t,\n { onRefresh: () => setReloadKey((k) => k + 1) },\n )) return\n const body = response.result as { error?: string } | undefined\n flash(\n body?.error ?? t('communication_channels.profile.share.failed', 'Failed to update sharing'),\n 'error',\n )\n return\n }\n flash(\n nextShared\n ? t('communication_channels.profile.share.sharedSuccess', 'Mailbox shared with your team.')\n : t('communication_channels.profile.share.privateSuccess', 'Mailbox is private again.'),\n 'success',\n )\n setReloadKey((k) => k + 1)\n },\n [runMutation, retryLastMutation, confirm, t],\n )\n\n const onSetPrimary = React.useCallback(\n async (channelId: string) => {\n let response\n try {\n response = await runMutation({\n operation: () => apiCall(\n `/api/communication_channels/channels/${encodeURIComponent(channelId)}/set-primary`,\n { method: 'POST' },\n ),\n context: {\n formId: PROFILE_CHANNELS_MUTATION_CONTEXT_ID,\n resourceKind: 'communication_channels.channel',\n resourceId: channelId,\n retryLastMutation,\n },\n mutationPayload: { isPrimary: true },\n })\n } catch (err) {\n flash(err instanceof Error ? err.message : t('communication_channels.profile.actions.setPrimaryFailed', 'Failed to set as primary'), 'error')\n return\n }\n if (!response.ok) {\n const body = response.result as { error?: string } | undefined\n flash(\n body?.error ?? t('communication_channels.profile.actions.setPrimaryFailed', 'Failed to set as primary'),\n 'error',\n )\n return\n }\n flash(\n t('communication_channels.profile.actions.setPrimarySuccess', 'Marked as primary.'),\n 'success',\n )\n setReloadKey((k) => k + 1)\n },\n [retryLastMutation, runMutation, t],\n )\n\n const onRegisterPush = React.useCallback(\n async (channelId: string) => {\n let response\n try {\n response = await runMutation({\n operation: () => apiCall<{ pushStatus?: string; error?: { code: string; message: string } }>(\n `/api/communication_channels/channels/${encodeURIComponent(channelId)}/push/register`,\n { method: 'POST' },\n ),\n context: {\n formId: PROFILE_CHANNELS_MUTATION_CONTEXT_ID,\n resourceKind: 'communication_channels.channel',\n resourceId: channelId,\n retryLastMutation,\n },\n mutationPayload: { action: 'push-register' },\n })\n } catch (err) {\n flash(err instanceof Error ? err.message : t('communication_channels.push.button.reregister', 'Re-register push'), 'error')\n return\n }\n if (!response.ok) {\n const body = response.result as { error?: string } | undefined\n flash(body?.error ?? t('communication_channels.push.flash.registerFailed', 'Failed to register push'), 'error')\n return\n }\n const result = (response.result ?? {}) as { pushStatus?: string }\n if (result.pushStatus === 'active') {\n flash(t('communication_channels.push.status.active', 'Push active'), 'success')\n } else {\n flash(\n t(\n 'communication_channels.push.status.failed',\n 'Push registration returned a non-active status \u2014 falling back to polling.',\n ),\n 'error',\n )\n }\n setReloadKey((k) => k + 1)\n },\n [retryLastMutation, runMutation, t],\n )\n\n const onPollNow = React.useCallback(\n async (channelId: string) => {\n let response\n try {\n response = await runMutation({\n operation: () => apiCall(\n `/api/communication_channels/channels/${encodeURIComponent(channelId)}/poll-now`,\n { method: 'POST' },\n ),\n context: {\n formId: PROFILE_CHANNELS_MUTATION_CONTEXT_ID,\n resourceKind: 'communication_channels.channel',\n resourceId: channelId,\n retryLastMutation,\n },\n mutationPayload: { action: 'poll-now' },\n })\n } catch (err) {\n flash(err instanceof Error ? err.message : t('communication_channels.profile.actions.pollNowFailed', 'Failed to trigger poll'), 'error')\n return\n }\n if (!response.ok) {\n const body = response.result as { error?: string } | undefined\n flash(\n body?.error ?? t('communication_channels.profile.actions.pollNowFailed', 'Failed to trigger poll'),\n 'error',\n )\n return\n }\n flash(\n t(\n 'communication_channels.profile.actions.pollNowSuccess',\n 'Poll triggered \u2014 new messages will appear on linked Person timelines in a few seconds.',\n ),\n 'success',\n )\n // Give the worker a moment to fetch + ingest, then refetch our channel list\n // so `lastPolledAt` updates in the UI.\n setTimeout(() => setReloadKey((k) => k + 1), 1500)\n },\n [retryLastMutation, runMutation, t],\n )\n\n const columns = React.useMemo<ColumnDef<ChannelRow>[]>(\n () => [\n {\n header: t('communication_channels.columns.displayName', 'Channel'),\n accessorKey: 'displayName',\n },\n {\n header: t('communication_channels.columns.provider', 'Provider'),\n accessorKey: 'providerKey',\n cell: ({ row }) => (\n <Tag variant=\"info\">\n {t(\n `communication_channels.channel.providers.${row.original.providerKey}`,\n row.original.providerKey,\n )}\n </Tag>\n ),\n },\n {\n header: t('communication_channels.columns.identifier', 'Email / username'),\n accessorKey: 'externalIdentifier',\n cell: ({ row }) => row.original.externalIdentifier ?? '\u2014',\n meta: { truncate: true, maxWidth: 240 },\n },\n {\n header: t('communication_channels.profile.columns.primary', 'Primary'),\n accessorKey: 'isPrimary',\n // Status only \u2014 \"Set as primary\" lives in the row actions menu.\n cell: ({ row }) =>\n row.original.isPrimary ? (\n <Tag variant=\"success\" dot>\n {t('communication_channels.profile.primary', 'Primary')}\n </Tag>\n ) : (\n <span className=\"text-xs text-muted-foreground\">\u2014</span>\n ),\n },\n {\n header: t('communication_channels.profile.columns.sharing', 'Team access'),\n accessorKey: 'visibility',\n // Status only \u2014 the share/unshare flip lives in the row actions menu.\n //\n // Only email ingestion writes customer_interactions, so on an SMS or push\n // channel a share would flip the flag and flash success while changing\n // nothing anyone can observe. The menu applies the same email-only rule.\n cell: ({ row }) =>\n row.original.channelType !== 'email' ? (\n <span className=\"text-xs text-muted-foreground\">\u2014</span>\n ) : row.original.visibility === 'shared' ? (\n <Tag variant=\"info\" dot>\n {t('communication_channels.profile.share.shared', 'Shared')}\n </Tag>\n ) : (\n <span className=\"text-xs text-muted-foreground\">\n {t('communication_channels.profile.share.private', 'Only you')}\n </span>\n ),\n },\n {\n header: t('communication_channels.columns.status', 'Status'),\n accessorKey: 'status',\n cell: ({ row }) => statusTag(row.original.status, t),\n },\n {\n id: 'pushStatus',\n // Its own key: sharing `push.status.active` made the header render\n // \"Push active\" over a column whose rows say \"Polling only\" (#4980).\n header: t('communication_channels.profile.columns.push', 'Push'),\n cell: ({ row }) => {\n const ps = row.original.pushStatus\n const errorTitle = row.original.lastPushError?.message ?? undefined\n // Derived from the adapter's declared capabilities, never from the\n // provider name: `supportsPushRegistration` means push subscriptions\n // can be (re-)registered from here, `supportsRealtimePush` means the\n // hub's poll worker skips the channel because the provider connection\n // delivers inbound itself (#4980).\n if (!row.original.supportsPushRegistration) {\n if (!row.original.supportsRealtimePush) {\n return (\n <span className=\"text-xs text-muted-foreground\">\n {t('communication_channels.push.status.inactive', 'Polling only')}\n </span>\n )\n }\n if (ps === 'failed') {\n return (\n <Tag variant=\"error\" dot title={errorTitle}>\n {t('communication_channels.push.status.pushDrivenFailed', 'Push connection failed')}\n </Tag>\n )\n }\n // The \"why is there no Poll now action?\" explanation used to live on\n // the disabled Sync button's aria-label. That button is gone, so the\n // reason moves here rather than being lost.\n return (\n <Tag\n variant=\"success\"\n dot\n title={t(\n 'communication_channels.profile.actions.pollNowPushDriven',\n 'This channel is push-driven \u2014 inbound messages arrive over the provider connection, so polling does not apply.',\n )}\n >\n {t('communication_channels.push.status.pushDriven', 'Push-driven')}\n </Tag>\n )\n }\n if (ps === 'active') {\n return (\n <Tag variant=\"success\" dot>\n {t('communication_channels.push.status.active', 'Push active')}\n </Tag>\n )\n }\n if (ps === 'failed') {\n return (\n <Tag variant=\"error\" dot title={errorTitle}>\n {t('communication_channels.push.status.failed', 'Push failed \u2014 using polling')}\n </Tag>\n )\n }\n // null or 'inactive' \u2014 the provider can register push but has not yet.\n // Only a hub-polled channel falls back to polling meanwhile; for a\n // push-driven one nothing is delivering inbound at all, so claiming\n // \"Polling only\" would repeat the defect this issue is about (#4980).\n return (\n <span className=\"text-xs text-muted-foreground\">\n {row.original.supportsRealtimePush\n ? t('communication_channels.push.status.notRegistered', 'Push not registered')\n : t('communication_channels.push.status.inactive', 'Polling only')}\n </span>\n )\n },\n },\n {\n header: t('communication_channels.profile.columns.lastPolled', 'Last synced'),\n accessorKey: 'lastPolledAt',\n cell: ({ row }) =>\n row.original.lastPolledAt\n ? new Date(row.original.lastPolledAt).toLocaleString()\n : '\u2014',\n },\n ],\n [t],\n )\n\n /**\n * Every per-row action, in one menu. The table previously carried five action\n * columns, which pushed the row past the viewport and clipped the trailing\n * buttons.\n *\n * Passed as DataTable's `rowActions` rather than as a hand-rolled column so the\n * table can merge row actions injected by other modules into this same menu\n * (`extensionTableId` below) instead of rendering a second one, and so the\n * actions cell keeps the table's own header, alignment and sticky behaviour.\n *\n * `RowActionItem` has no disabled state, so an unavailable action is OMITTED\n * rather than greyed out. The reason stays visible in the row: the Status and\n * Push columns already say why a channel cannot be polled or needs reconnecting.\n */\n const rowActions = React.useCallback(\n (channel: ChannelRow) => {\n const items: RowActionItem[] = []\n\n if (!channel.isPrimary) {\n items.push({\n id: 'set-primary',\n label: t('communication_channels.profile.actions.setPrimary', 'Set as primary'),\n onSelect: () => { void onSetPrimary(channel.id) },\n })\n }\n\n // Email only, for the reason the Team access column documents: on a\n // non-email channel the flip would change nothing anyone can observe.\n if (channel.channelType === 'email') {\n items.push(\n channel.visibility === 'shared'\n ? {\n id: 'make-private',\n label: t('communication_channels.profile.share.makePrivate', 'Make private'),\n onSelect: () => { void onSetVisibility(channel, false) },\n }\n : {\n id: 'share-with-team',\n label: t('communication_channels.profile.share.shareCta', 'Share with team'),\n onSelect: () => { void onSetVisibility(channel, true) },\n },\n )\n }\n\n // Offered only when the adapter can register push AND it is not already\n // active \u2014 re-registering a healthy subscription is a no-op.\n if (channel.supportsPushRegistration && channel.pushStatus !== 'active') {\n items.push({\n id: 'register-push',\n label: t('communication_channels.push.button.reregister', 'Re-register push'),\n onSelect: () => { void onRegisterPush(channel.id) },\n })\n }\n\n // Same eligibility the History column enforced.\n if (channel.isActive && channel.status === 'connected' && channel.channelType === 'email') {\n items.push({\n id: 'import-history',\n label: t('communication_channels.profile.actions.importHistory', 'Import history'),\n onSelect: () => setImportChannel(channel),\n })\n }\n\n // Same eligibility the Sync column enforced: allowed from 'connected'\n // AND 'error' (so a stuck channel can be recovered without a\n // reconnect), never for a push-driven channel the worker skips (#4980).\n const pollable =\n channel.isActive &&\n !channel.supportsRealtimePush &&\n (channel.status === 'connected' || channel.status === 'error')\n if (pollable) {\n items.push({\n id: 'poll-now',\n label:\n channel.status === 'error'\n ? t('communication_channels.profile.actions.retryPoll', 'Retry')\n : t('communication_channels.profile.actions.pollNow', 'Poll now'),\n onSelect: () => { void onPollNow(channel.id) },\n })\n }\n\n items.push({\n id: 'disconnect',\n label: t('communication_channels.profile.actions.disconnect', 'Disconnect'),\n destructive: true,\n onSelect: () => setDisconnectChannel(channel),\n })\n\n return <RowActions items={items} />\n },\n [onSetPrimary, onSetVisibility, onPollNow, onRegisterPush, t],\n )\n\n return (\n <Page>\n <PageBody>\n <header className=\"mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4\">\n <div className=\"min-w-0\">\n <h1 className=\"text-2xl font-semibold\">\n {t('communication_channels.profile.title', 'My communication channels')}\n </h1>\n <p className=\"text-sm text-muted-foreground\">\n {t(\n 'communication_channels.profile.subtitle',\n 'Connect your communication channels so outbound messages come from your own account and inbound messages land in your unified inbox.',\n )}\n </p>\n </div>\n {/* Provider connect entry points injected by each channel-* package\n (channel-gmail, channel-imap) via UMES. They stack inside one\n dropdown so the header does not widen per installed provider. */}\n <ConnectChannelMenu onConnected={reloadChannels} />\n </header>\n\n {reauthRows.length > 0 ? (\n <Alert status=\"warning\" className=\"mb-4\">\n <AlertDescription>\n {t(\n 'communication_channels.profile.alerts.requiresReauth',\n '{count} channel(s) need reconnection. Click \"Reconnect\" on the affected channel below.',\n { count: reauthRows.length },\n )}\n </AlertDescription>\n </Alert>\n ) : null}\n\n <DataTable<ChannelRow>\n title={t('communication_channels.profile.tableTitle', 'Your channels')}\n titleHeadingLevel={2}\n extensionTableId={extensionPoints.hosts.profileChannelsTable.tableId}\n columns={columns}\n rowActions={rowActions}\n // Pinned because this table is wider than a laptop viewport: at 1280px\n // the row runs out well before the actions cell, and since Primary,\n // Team access and Push are status-only there is no other control in\n // view. Unpinned, reaching \u22EF meant scrolling to the far end, which\n // scrolls the Channel column away \u2014 so you could no longer see which\n // channel the open menu belonged to (#6717).\n stickyActionsColumn\n data={rows}\n isLoading={isLoading}\n error={errorMessage}\n emptyState={t(\n 'communication_channels.profile.empty',\n 'You have no connected channels yet. Add one using the menu at the top of this page.',\n )}\n />\n <ImportHistoryDialog\n channel={importChannel}\n onClose={() => setImportChannel(null)}\n onQueued={() => {\n setImportChannel(null)\n router.refresh()\n }}\n />\n <DisconnectChannelDialog\n channel={disconnectChannel}\n onClose={() => setDisconnectChannel(null)}\n onDisconnected={() => {\n setDisconnectChannel(null)\n setReloadKey((k) => k + 1)\n }}\n />\n {ConfirmDialogElement}\n </PageBody>\n </Page>\n )\n}\n\ntype ImportHistoryDialogProps = {\n channel: ChannelRow | null\n onClose: () => void\n onQueued: () => void\n}\n\nfunction ImportHistoryDialog({ channel, onClose, onQueued }: ImportHistoryDialogProps): React.JSX.Element {\n const t = useT()\n const importLimits = React.useMemo(() => getImportHistoryLimits(channel?.providerKey), [channel?.providerKey])\n const [sinceDays, setSinceDays] = React.useState('30')\n const [contactEmails, setContactEmails] = React.useState('')\n const [maxMessages, setMaxMessages] = React.useState('500')\n const [fieldErrors, setFieldErrors] = React.useState<Record<string, string>>({})\n const [submitting, setSubmitting] = React.useState(false)\n const { runMutation, retryLastMutation } = useGuardedMutation<ChannelMutationContext>({\n contextId: IMPORT_HISTORY_MUTATION_CONTEXT_ID,\n blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),\n })\n\n React.useEffect(() => {\n if (channel) {\n setSinceDays('30')\n setContactEmails('')\n setMaxMessages('500')\n setFieldErrors({})\n setSubmitting(false)\n }\n }, [channel?.id])\n\n const handleSubmit = React.useCallback(async () => {\n if (!channel || submitting) return\n const sinceNum = Number.parseInt(sinceDays, 10)\n const maxNum = Number.parseInt(maxMessages, 10)\n const errors: Record<string, string> = {}\n if (!Number.isFinite(sinceNum) || sinceNum < 1 || sinceNum > importLimits.maxSinceDays) {\n errors.sinceDays = t(\n 'communication_channels.profile.importHistory.errors.sinceDays',\n 'Choose a number between 1 and {max} days.',\n { max: importLimits.maxSinceDays },\n )\n }\n if (!Number.isFinite(maxNum) || maxNum < 1 || maxNum > importLimits.maxMessages) {\n errors.maxMessages = t(\n 'communication_channels.profile.importHistory.errors.maxMessages',\n 'Choose a number between 1 and {max} messages.',\n { max: importLimits.maxMessages },\n )\n }\n const parsedEmails = contactEmails\n .split(/[\\s,;]+/)\n .map((s) => s.trim())\n .filter(Boolean)\n if (parsedEmails.length > 0 && parsedEmails.some((s) => !/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(s))) {\n errors.contactEmails = t(\n 'communication_channels.profile.importHistory.errors.contactEmails',\n 'One or more entries is not a valid email address.',\n )\n }\n if (Object.keys(errors).length > 0) {\n setFieldErrors(errors)\n return\n }\n setFieldErrors({})\n setSubmitting(true)\n const mutationPayload = {\n sinceDays: sinceNum,\n maxMessages: maxNum,\n ...(parsedEmails.length > 0 ? { contactEmails: parsedEmails } : {}),\n }\n let response\n try {\n response = await runMutation({\n operation: () => apiCall<{ progressJobId?: string }>(\n `/api/communication_channels/channels/${encodeURIComponent(channel.id)}/import-history`,\n {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(mutationPayload),\n },\n ),\n context: {\n formId: IMPORT_HISTORY_MUTATION_CONTEXT_ID,\n resourceKind: 'communication_channels.channel',\n resourceId: channel.id,\n retryLastMutation,\n },\n mutationPayload,\n })\n } catch (err) {\n setSubmitting(false)\n flash(\n err instanceof Error\n ? err.message\n : t('communication_channels.profile.importHistory.flash.error', 'Failed to queue history import.'),\n 'error',\n )\n return\n }\n setSubmitting(false)\n if (!response.ok) {\n const body = response.result as { error?: string; fieldErrors?: Record<string, string> } | undefined\n if (body?.fieldErrors && Object.keys(body.fieldErrors).length > 0) {\n setFieldErrors(body.fieldErrors)\n return\n }\n flash(\n body?.error ??\n t(\n 'communication_channels.profile.importHistory.flash.error',\n 'Failed to queue history import.',\n ),\n 'error',\n )\n return\n }\n flash(\n t(\n 'communication_channels.profile.importHistory.flash.success',\n 'History import queued \u2014 track progress in the top bar.',\n ),\n 'success',\n )\n onQueued()\n }, [channel, sinceDays, maxMessages, contactEmails, submitting, t, onQueued, retryLastMutation, runMutation, importLimits])\n\n const handleKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {\n event.preventDefault()\n void handleSubmit()\n }\n },\n [handleSubmit],\n )\n\n return (\n <Dialog open={channel !== null} onOpenChange={(open) => { if (!open) onClose() }}>\n <DialogContent onKeyDown={handleKeyDown}>\n <DialogHeader>\n <DialogTitle>\n {t('communication_channels.profile.importHistory.title', 'Import channel history')}\n </DialogTitle>\n <DialogDescription>\n {t(\n 'communication_channels.profile.importHistory.description',\n 'Pull older messages this channel never observed at connect-time. Filters narrow the search server-side.',\n )}\n </DialogDescription>\n </DialogHeader>\n\n <div className=\"space-y-4\">\n <div className=\"space-y-1\">\n <Label htmlFor=\"import-history-since\">\n {t('communication_channels.profile.importHistory.fields.sinceDays', 'Look back (days)')}\n </Label>\n <Input\n id=\"import-history-since\"\n type=\"number\"\n min={1}\n max={importLimits.maxSinceDays}\n value={sinceDays}\n onChange={(e) => setSinceDays(e.target.value)}\n aria-invalid={Boolean(fieldErrors.sinceDays)}\n />\n {fieldErrors.sinceDays ? (\n <p className=\"text-xs text-status-error-text\">{fieldErrors.sinceDays}</p>\n ) : null}\n </div>\n\n <div className=\"space-y-1\">\n <Label htmlFor=\"import-history-emails\">\n {t(\n 'communication_channels.profile.importHistory.fields.contactEmails',\n 'Filter by sender (optional)',\n )}\n </Label>\n <Textarea\n id=\"import-history-emails\"\n rows={3}\n value={contactEmails}\n onChange={(e) => setContactEmails(e.target.value)}\n placeholder={t(\n 'communication_channels.profile.importHistory.fields.contactEmailsPlaceholder',\n 'alice@example.com, bob@example.com',\n )}\n aria-invalid={Boolean(fieldErrors.contactEmails)}\n />\n {fieldErrors.contactEmails ? (\n <p className=\"text-xs text-status-error-text\">{fieldErrors.contactEmails}</p>\n ) : (\n <p className=\"text-xs text-muted-foreground\">\n {t(\n 'communication_channels.profile.importHistory.fields.contactEmailsHint',\n 'Leave empty to scan all senders in the window.',\n )}\n </p>\n )}\n </div>\n\n <div className=\"space-y-1\">\n <Label htmlFor=\"import-history-max\">\n {t('communication_channels.profile.importHistory.fields.maxMessages', 'Maximum messages')}\n </Label>\n <Input\n id=\"import-history-max\"\n type=\"number\"\n min={1}\n max={importLimits.maxMessages}\n value={maxMessages}\n onChange={(e) => setMaxMessages(e.target.value)}\n aria-invalid={Boolean(fieldErrors.maxMessages)}\n />\n {fieldErrors.maxMessages ? (\n <p className=\"text-xs text-status-error-text\">{fieldErrors.maxMessages}</p>\n ) : null}\n </div>\n\n {fieldErrors.channelId ? (\n <Alert status=\"warning\">\n <AlertDescription>{fieldErrors.channelId}</AlertDescription>\n </Alert>\n ) : null}\n </div>\n\n <DialogFooter>\n <span className=\"mr-auto text-xs text-muted-foreground\">\n <KbdShortcut keys={['\u2318', 'Enter']} />\n </span>\n <Button type=\"button\" variant=\"outline\" onClick={onClose} disabled={submitting}>\n {t('communication_channels.profile.importHistory.cancel', 'Cancel')}\n </Button>\n <Button type=\"button\" onClick={() => void handleSubmit()} disabled={submitting}>\n {submitting\n ? t('communication_channels.profile.importHistory.submitting', 'Queueing\u2026')\n : t('communication_channels.profile.importHistory.submit', 'Start import')}\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n )\n}\n\ntype DisconnectChannelDialogProps = {\n channel: ChannelRow | null\n onClose: () => void\n onDisconnected: () => void\n}\n\nfunction DisconnectChannelDialog({\n channel,\n onClose,\n onDisconnected,\n}: DisconnectChannelDialogProps): React.JSX.Element {\n const t = useT()\n const [submitting, setSubmitting] = React.useState(false)\n const { runMutation, retryLastMutation } = useGuardedMutation<ChannelMutationContext>({\n contextId: DISCONNECT_MUTATION_CONTEXT_ID,\n blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),\n })\n\n React.useEffect(() => {\n if (channel) setSubmitting(false)\n }, [channel?.id])\n\n const handleConfirm = React.useCallback(async () => {\n if (!channel || submitting) return\n setSubmitting(true)\n let response\n try {\n response = await runMutation({\n // optimistic-lock-exempt: self-service connect/disconnect of the\n // signed-in operator's OWN communication channel (an integration link),\n // not a shared multi-editor record. Disconnect is a terminal action\n // keyed by channel id; there is no concurrent-edit lost-update window to\n // guard, and the row carries no client-surfaced `updatedAt` round-trip.\n operation: () => apiCall(\n `/api/communication_channels/channels/${encodeURIComponent(channel.id)}`,\n { method: 'DELETE' },\n ),\n context: {\n formId: DISCONNECT_MUTATION_CONTEXT_ID,\n resourceKind: 'communication_channels.channel',\n resourceId: channel.id,\n retryLastMutation,\n },\n mutationPayload: { action: 'disconnect' },\n })\n } catch (err) {\n setSubmitting(false)\n flash(\n err instanceof Error\n ? err.message\n : t('communication_channels.profile.actions.disconnectFailed', 'Failed to disconnect channel'),\n 'error',\n )\n return\n }\n setSubmitting(false)\n if (!response.ok) {\n const body = response.result as { error?: string } | undefined\n flash(\n body?.error ??\n t('communication_channels.profile.actions.disconnectFailed', 'Failed to disconnect channel'),\n 'error',\n )\n return\n }\n flash(\n t(\n 'communication_channels.profile.actions.disconnectSuccess',\n 'Channel disconnected. You can reconnect it anytime.',\n ),\n 'success',\n )\n onDisconnected()\n }, [channel, submitting, runMutation, retryLastMutation, t, onDisconnected])\n\n const handleKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {\n event.preventDefault()\n void handleConfirm()\n }\n },\n [handleConfirm],\n )\n\n return (\n <Dialog open={channel !== null} onOpenChange={(open) => { if (!open) onClose() }}>\n <DialogContent onKeyDown={handleKeyDown}>\n <DialogHeader>\n <DialogTitle>\n {t('communication_channels.profile.disconnect.title', 'Disconnect channel')}\n </DialogTitle>\n <DialogDescription>\n {t(\n 'communication_channels.profile.disconnect.description',\n 'This removes the connection and stops syncing. Emails already imported stay on your timelines. You can reconnect anytime.',\n )}\n </DialogDescription>\n </DialogHeader>\n\n {channel ? (\n <p className=\"text-sm font-medium\">{channel.externalIdentifier ?? channel.displayName}</p>\n ) : null}\n\n <DialogFooter>\n <span className=\"mr-auto text-xs text-muted-foreground\">\n <KbdShortcut keys={['\u2318', 'Enter']} />\n </span>\n <Button type=\"button\" variant=\"outline\" onClick={onClose} disabled={submitting}>\n {t('communication_channels.profile.disconnect.cancel', 'Cancel')}\n </Button>\n <Button type=\"button\" variant=\"destructive-solid\" onClick={() => void handleConfirm()} disabled={submitting}>\n {submitting\n ? t('communication_channels.profile.disconnect.submitting', 'Disconnecting\u2026')\n : t('communication_channels.profile.disconnect.confirm', 'Disconnect')}\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n )\n}\n\nfunction statusTag(\n status: ChannelRow['status'],\n t: (key: string, fallback?: string) => string,\n): React.ReactNode {\n switch (status) {\n case 'connected':\n return (\n <Tag variant=\"success\" dot>\n {t('communication_channels.status.connected', 'Connected')}\n </Tag>\n )\n case 'requires_reauth':\n return (\n <Tag variant=\"warning\" dot>\n {t('communication_channels.status.requiresReauth', 'Needs reconnection')}\n </Tag>\n )\n case 'error':\n return (\n <Tag variant=\"error\" dot>\n {t('communication_channels.status.error', 'Error')}\n </Tag>\n )\n case 'disconnected':\n return <Tag variant=\"neutral\">{t('communication_channels.status.disconnected', 'Disconnected')}</Tag>\n default:\n return <Tag variant=\"neutral\">{status}</Tag>\n }\n}\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport { extensionPoints } from '@open-mercato/core/modules/communication_channels/extension-points'\nimport { getImportHistoryLimits } from '@open-mercato/core/modules/communication_channels/lib/import-history-limits'\nimport { OAUTH_RESULT_QUERY_PARAM, stripOAuthResultParams } from '@open-mercato/core/modules/communication_channels/lib/oauth-result'\nimport type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'\nimport { useRouter, useSearchParams } from 'next/navigation'\nimport { Page, PageBody } from '@open-mercato/ui/backend/Page'\nimport { DataTable } from '@open-mercato/ui/backend/DataTable'\nimport { Tag } from '@open-mercato/ui/primitives/tag'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { Alert, AlertDescription } from '@open-mercato/ui/primitives/alert'\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n} from '@open-mercato/ui/primitives/dialog'\nimport { Input } from '@open-mercato/ui/primitives/input'\nimport { Label } from '@open-mercato/ui/primitives/label'\nimport { Textarea } from '@open-mercato/ui/primitives/textarea'\nimport { KbdShortcut } from '@open-mercato/ui/primitives/kbd'\nimport { RowActions, type RowActionItem } from '@open-mercato/ui/backend/RowActions'\nimport { apiCall, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'\nimport { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'\nimport { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'\nimport { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { ConnectChannelMenu } from './ConnectChannelMenu'\n\ntype ChannelRow = {\n id: string\n providerKey: string\n channelType: string\n displayName: string\n externalIdentifier: string | null\n isPrimary: boolean\n /** `shared` = a team mailbox: teammates can read the CRM email it ingests. */\n visibility: 'private' | 'shared'\n /** Version token for the share toggle's optimistic-lock header. */\n updatedAt: string | null\n isActive: boolean\n status: 'connected' | 'requires_reauth' | 'error' | 'disconnected'\n lastError: string | null\n pollIntervalSeconds: number | null\n lastPolledAt: string | null\n /** Spec C \u2014 push delivery state (null when provider doesn't support push). */\n pushStatus: 'active' | 'inactive' | 'failed' | null\n lastPushError: { code: string | null; message: string | null; at: string | null } | null\n /**\n * `true` when the adapter declares real-time push, so the hub's poll worker\n * skips this channel entirely \u2014 inbound arrives over the provider connection.\n */\n supportsRealtimePush: boolean\n /** `true` when the adapter implements `registerPush` (Gmail-style subscriptions). */\n supportsPushRegistration: boolean\n createdAt: string | null\n}\n\nconst PROFILE_CHANNELS_MUTATION_CONTEXT_ID = 'communication-channels-profile'\nconst IMPORT_HISTORY_MUTATION_CONTEXT_ID = 'communication-channels-import-history'\nconst DISCONNECT_MUTATION_CONTEXT_ID = 'communication-channels-disconnect'\n\ntype ChannelMutationContext = {\n formId: string\n resourceKind: string\n resourceId: string\n retryLastMutation: () => Promise<boolean>\n}\n\nexport default function ProfileCommunicationChannelsPage() {\n const t = useT()\n const router = useRouter()\n const searchParams = useSearchParams()\n const flashType = searchParams?.get(OAUTH_RESULT_QUERY_PARAM)\n const flashCode = searchParams?.get('code')\n const flashProvider = searchParams?.get('provider')\n\n const [rows, setRows] = React.useState<ChannelRow[]>([])\n const [isLoading, setIsLoading] = React.useState(true)\n const [errorMessage, setErrorMessage] = React.useState<string | null>(null)\n const [reloadKey, setReloadKey] = React.useState(0)\n const [importChannel, setImportChannel] = React.useState<ChannelRow | null>(null)\n const [disconnectChannel, setDisconnectChannel] = React.useState<ChannelRow | null>(null)\n const { runMutation, retryLastMutation } = useGuardedMutation<ChannelMutationContext>({\n contextId: PROFILE_CHANNELS_MUTATION_CONTEXT_ID,\n blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),\n })\n // Sharing a mailbox is privacy-consequential, so the widening direction is\n // confirmed. Escape cancels and Cmd/Ctrl+Enter submits (ConfirmDialog owns both).\n const { confirm, ConfirmDialogElement } = useConfirmDialog()\n\n React.useEffect(() => {\n if (flashType === 'connected') {\n const providerLabel = flashProvider\n ? t(`communication_channels.channel.providers.${flashProvider}`, '')\n : ''\n flash(\n providerLabel\n ? t('communication_channels.profile.flash.connectedWithProvider', 'Channel connected ({provider}).', {\n provider: providerLabel,\n })\n : t('communication_channels.profile.flash.connected', 'Channel connected.'),\n 'success',\n )\n } else if (flashType === 'error') {\n flash(\n flashCode === 'oauth_client_not_configured'\n ? t(\n 'communication_channels.profile.connect.notConfigured',\n 'This provider is not configured yet. Ask an administrator to add the OAuth Client ID and Secret under Integrations before connecting a mailbox.',\n )\n : flashCode === 'mailbox_already_connected'\n ? t(\n 'communication_channels.profile.connect.mailboxAlreadyConnected',\n 'This mailbox is already connected through another provider. Disconnect it first to reconnect it with a different one.',\n )\n : flashCode === 'replay'\n ? t(\n 'communication_channels.profile.flash.replayedState',\n 'This connection link was already used \u2014 please start the connection again.',\n )\n : flashCode === 'state_store_unavailable'\n ? t(\n 'communication_channels.profile.flash.stateStoreUnavailable',\n 'A server error prevented completing the connection. Please try again in a moment.',\n )\n : t('communication_channels.profile.flash.error', 'Failed to connect channel.'),\n 'error',\n )\n } else {\n return\n }\n router.replace(stripOAuthResultParams(window.location.href), { scroll: false })\n }, [flashType, flashCode, flashProvider, router, t])\n\n React.useEffect(() => {\n let cancelled = false\n async function load() {\n setIsLoading(true)\n setErrorMessage(null)\n const response = await apiCall<{ items?: ChannelRow[] }>(\n '/api/communication_channels/me/channels',\n ).catch((err: unknown) => ({\n ok: false,\n result: { error: err instanceof Error ? err.message : 'Failed to load channels' },\n }))\n if (cancelled) return\n if (!response.ok) {\n const body = response.result as { error?: string } | undefined\n setErrorMessage(\n body?.error ?? t('communication_channels.errors.loadList', 'Failed to load channels'),\n )\n setRows([])\n } else {\n const data = (response.result ?? {}) as { items?: ChannelRow[] }\n setRows(Array.isArray(data.items) ? data.items : [])\n }\n setIsLoading(false)\n }\n void load()\n return () => {\n cancelled = true\n }\n }, [reloadKey, t])\n\n const reauthRows = rows.filter((r) => r.status === 'requires_reauth')\n\n const reloadChannels = React.useCallback(() => setReloadKey((k) => k + 1), [])\n\n const onSetVisibility = React.useCallback(\n async (channel: ChannelRow, nextShared: boolean) => {\n // Only the widening direction needs a confirmation; making a mailbox\n // private again is the safe direction and stays one click.\n if (nextShared) {\n const confirmed = await confirm({\n title: t(\n 'communication_channels.profile.share.confirm.title',\n 'Share this mailbox with your team?',\n ),\n text: t(\n 'communication_channels.profile.share.confirm.text',\n 'Colleagues will be able to read the CRM email this mailbox handles, including messages already received. You stay the only person who can manage the connection, and you can stop sharing at any time.',\n ),\n confirmText: t('communication_channels.profile.share.confirm.cta', 'Share with team'),\n })\n if (!confirmed) return\n }\n\n const nextVisibility = nextShared ? 'shared' : 'private'\n let response\n try {\n response = await runMutation({\n operation: () => withScopedApiRequestHeaders(\n buildOptimisticLockHeader(channel.updatedAt),\n () => apiCall(\n `/api/communication_channels/channels/${encodeURIComponent(channel.id)}/visibility`,\n {\n method: 'PUT',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ visibility: nextVisibility }),\n },\n ),\n ),\n context: {\n formId: PROFILE_CHANNELS_MUTATION_CONTEXT_ID,\n resourceKind: 'communication_channels.channel',\n resourceId: channel.id,\n retryLastMutation,\n },\n mutationPayload: { visibility: nextVisibility },\n })\n } catch (err) {\n if (surfaceRecordConflict(err, t)) return\n flash(\n err instanceof Error\n ? err.message\n : t('communication_channels.profile.share.failed', 'Failed to update sharing'),\n 'error',\n )\n return\n }\n if (!response.ok) {\n if (surfaceRecordConflict(\n { status: response.status, body: response.result },\n t,\n { onRefresh: () => setReloadKey((k) => k + 1) },\n )) return\n const body = response.result as { error?: string } | undefined\n flash(\n body?.error ?? t('communication_channels.profile.share.failed', 'Failed to update sharing'),\n 'error',\n )\n return\n }\n flash(\n nextShared\n ? t('communication_channels.profile.share.sharedSuccess', 'Mailbox shared with your team.')\n : t('communication_channels.profile.share.privateSuccess', 'Mailbox is private again.'),\n 'success',\n )\n setReloadKey((k) => k + 1)\n },\n [runMutation, retryLastMutation, confirm, t],\n )\n\n const onSetPrimary = React.useCallback(\n async (channelId: string) => {\n let response\n try {\n response = await runMutation({\n operation: () => apiCall(\n `/api/communication_channels/channels/${encodeURIComponent(channelId)}/set-primary`,\n { method: 'POST' },\n ),\n context: {\n formId: PROFILE_CHANNELS_MUTATION_CONTEXT_ID,\n resourceKind: 'communication_channels.channel',\n resourceId: channelId,\n retryLastMutation,\n },\n mutationPayload: { isPrimary: true },\n })\n } catch (err) {\n flash(err instanceof Error ? err.message : t('communication_channels.profile.actions.setPrimaryFailed', 'Failed to set as primary'), 'error')\n return\n }\n if (!response.ok) {\n const body = response.result as { error?: string } | undefined\n flash(\n body?.error ?? t('communication_channels.profile.actions.setPrimaryFailed', 'Failed to set as primary'),\n 'error',\n )\n return\n }\n flash(\n t('communication_channels.profile.actions.setPrimarySuccess', 'Marked as primary.'),\n 'success',\n )\n setReloadKey((k) => k + 1)\n },\n [retryLastMutation, runMutation, t],\n )\n\n const onRegisterPush = React.useCallback(\n async (channelId: string) => {\n let response\n try {\n response = await runMutation({\n operation: () => apiCall<{ pushStatus?: string; error?: { code: string; message: string } }>(\n `/api/communication_channels/channels/${encodeURIComponent(channelId)}/push/register`,\n { method: 'POST' },\n ),\n context: {\n formId: PROFILE_CHANNELS_MUTATION_CONTEXT_ID,\n resourceKind: 'communication_channels.channel',\n resourceId: channelId,\n retryLastMutation,\n },\n mutationPayload: { action: 'push-register' },\n })\n } catch (err) {\n flash(err instanceof Error ? err.message : t('communication_channels.push.button.reregister', 'Re-register push'), 'error')\n return\n }\n if (!response.ok) {\n const body = response.result as { error?: string } | undefined\n flash(body?.error ?? t('communication_channels.push.flash.registerFailed', 'Failed to register push'), 'error')\n return\n }\n const result = (response.result ?? {}) as { pushStatus?: string }\n if (result.pushStatus === 'active') {\n flash(t('communication_channels.push.status.active', 'Push active'), 'success')\n } else {\n flash(\n t(\n 'communication_channels.push.status.failed',\n 'Push registration returned a non-active status \u2014 falling back to polling.',\n ),\n 'error',\n )\n }\n setReloadKey((k) => k + 1)\n },\n [retryLastMutation, runMutation, t],\n )\n\n const onPollNow = React.useCallback(\n async (channelId: string) => {\n let response\n try {\n response = await runMutation({\n operation: () => apiCall(\n `/api/communication_channels/channels/${encodeURIComponent(channelId)}/poll-now`,\n { method: 'POST' },\n ),\n context: {\n formId: PROFILE_CHANNELS_MUTATION_CONTEXT_ID,\n resourceKind: 'communication_channels.channel',\n resourceId: channelId,\n retryLastMutation,\n },\n mutationPayload: { action: 'poll-now' },\n })\n } catch (err) {\n flash(err instanceof Error ? err.message : t('communication_channels.profile.actions.pollNowFailed', 'Failed to trigger poll'), 'error')\n return\n }\n if (!response.ok) {\n const body = response.result as { error?: string } | undefined\n flash(\n body?.error ?? t('communication_channels.profile.actions.pollNowFailed', 'Failed to trigger poll'),\n 'error',\n )\n return\n }\n flash(\n t(\n 'communication_channels.profile.actions.pollNowSuccess',\n 'Poll triggered \u2014 new messages will appear on linked Person timelines in a few seconds.',\n ),\n 'success',\n )\n // Give the worker a moment to fetch + ingest, then refetch our channel list\n // so `lastPolledAt` updates in the UI.\n setTimeout(() => setReloadKey((k) => k + 1), 1500)\n },\n [retryLastMutation, runMutation, t],\n )\n\n const columns = React.useMemo<ColumnDef<ChannelRow>[]>(\n () => [\n {\n header: t('communication_channels.columns.displayName', 'Channel'),\n accessorKey: 'displayName',\n },\n {\n header: t('communication_channels.columns.provider', 'Provider'),\n accessorKey: 'providerKey',\n cell: ({ row }) => (\n <Tag variant=\"info\">\n {t(\n `communication_channels.channel.providers.${row.original.providerKey}`,\n row.original.providerKey,\n )}\n </Tag>\n ),\n },\n {\n header: t('communication_channels.columns.identifier', 'Email / username'),\n accessorKey: 'externalIdentifier',\n cell: ({ row }) => row.original.externalIdentifier ?? '\u2014',\n meta: { truncate: true, maxWidth: 240 },\n },\n {\n header: t('communication_channels.profile.columns.primary', 'Primary'),\n accessorKey: 'isPrimary',\n // Status only \u2014 \"Set as primary\" lives in the row actions menu.\n cell: ({ row }) =>\n row.original.isPrimary ? (\n <Tag variant=\"success\" dot>\n {t('communication_channels.profile.primary', 'Primary')}\n </Tag>\n ) : (\n <span className=\"text-xs text-muted-foreground\">\u2014</span>\n ),\n },\n {\n header: t('communication_channels.profile.columns.sharing', 'Team access'),\n accessorKey: 'visibility',\n // Status only \u2014 the share/unshare flip lives in the row actions menu.\n //\n // Only email ingestion writes customer_interactions, so on an SMS or push\n // channel a share would flip the flag and flash success while changing\n // nothing anyone can observe. The menu applies the same email-only rule.\n cell: ({ row }) =>\n row.original.channelType !== 'email' ? (\n <span className=\"text-xs text-muted-foreground\">\u2014</span>\n ) : row.original.visibility === 'shared' ? (\n <Tag variant=\"info\" dot>\n {t('communication_channels.profile.share.shared', 'Shared')}\n </Tag>\n ) : (\n <span className=\"text-xs text-muted-foreground\">\n {t('communication_channels.profile.share.private', 'Only you')}\n </span>\n ),\n },\n {\n header: t('communication_channels.columns.status', 'Status'),\n accessorKey: 'status',\n cell: ({ row }) => statusTag(row.original.status, t),\n },\n {\n id: 'pushStatus',\n // Its own key: sharing `push.status.active` made the header render\n // \"Push active\" over a column whose rows say \"Polling only\" (#4980).\n header: t('communication_channels.profile.columns.push', 'Push'),\n cell: ({ row }) => {\n const ps = row.original.pushStatus\n const errorTitle = row.original.lastPushError?.message ?? undefined\n // Derived from the adapter's declared capabilities, never from the\n // provider name: `supportsPushRegistration` means push subscriptions\n // can be (re-)registered from here, `supportsRealtimePush` means the\n // hub's poll worker skips the channel because the provider connection\n // delivers inbound itself (#4980).\n if (!row.original.supportsPushRegistration) {\n if (!row.original.supportsRealtimePush) {\n return (\n <span className=\"text-xs text-muted-foreground\">\n {t('communication_channels.push.status.inactive', 'Polling only')}\n </span>\n )\n }\n if (ps === 'failed') {\n return (\n <Tag variant=\"error\" dot title={errorTitle}>\n {t('communication_channels.push.status.pushDrivenFailed', 'Push connection failed')}\n </Tag>\n )\n }\n // The \"why is there no Poll now action?\" explanation used to live on\n // the disabled Sync button's aria-label. That button is gone, so the\n // reason moves here rather than being lost.\n return (\n <Tag\n variant=\"success\"\n dot\n title={t(\n 'communication_channels.profile.actions.pollNowPushDriven',\n 'This channel is push-driven \u2014 inbound messages arrive over the provider connection, so polling does not apply.',\n )}\n >\n {t('communication_channels.push.status.pushDriven', 'Push-driven')}\n </Tag>\n )\n }\n if (ps === 'active') {\n return (\n <Tag variant=\"success\" dot>\n {t('communication_channels.push.status.active', 'Push active')}\n </Tag>\n )\n }\n if (ps === 'failed') {\n return (\n <Tag variant=\"error\" dot title={errorTitle}>\n {t('communication_channels.push.status.failed', 'Push failed \u2014 using polling')}\n </Tag>\n )\n }\n // null or 'inactive' \u2014 the provider can register push but has not yet.\n // Only a hub-polled channel falls back to polling meanwhile; for a\n // push-driven one nothing is delivering inbound at all, so claiming\n // \"Polling only\" would repeat the defect this issue is about (#4980).\n return (\n <span className=\"text-xs text-muted-foreground\">\n {row.original.supportsRealtimePush\n ? t('communication_channels.push.status.notRegistered', 'Push not registered')\n : t('communication_channels.push.status.inactive', 'Polling only')}\n </span>\n )\n },\n },\n {\n header: t('communication_channels.profile.columns.lastPolled', 'Last synced'),\n accessorKey: 'lastPolledAt',\n cell: ({ row }) =>\n row.original.lastPolledAt\n ? new Date(row.original.lastPolledAt).toLocaleString()\n : '\u2014',\n },\n ],\n [t],\n )\n\n /**\n * Every per-row action, in one menu. The table previously carried five action\n * columns, which pushed the row past the viewport and clipped the trailing\n * buttons.\n *\n * Passed as DataTable's `rowActions` rather than as a hand-rolled column so the\n * table can merge row actions injected by other modules into this same menu\n * (`extensionTableId` below) instead of rendering a second one, and so the\n * actions cell keeps the table's own header, alignment and sticky behaviour.\n *\n * `RowActionItem` has no disabled state, so an unavailable action is OMITTED\n * rather than greyed out. The reason stays visible in the row: the Status and\n * Push columns already say why a channel cannot be polled or needs reconnecting.\n */\n const rowActions = React.useCallback(\n (channel: ChannelRow) => {\n const items: RowActionItem[] = []\n\n if (!channel.isPrimary && channel.status !== 'disconnected') {\n items.push({\n id: 'set-primary',\n label: t('communication_channels.profile.actions.setPrimary', 'Set as primary'),\n onSelect: () => { void onSetPrimary(channel.id) },\n })\n }\n\n // Email only, for the reason the Team access column documents: on a\n // non-email channel the flip would change nothing anyone can observe.\n if (channel.channelType === 'email') {\n items.push(\n channel.visibility === 'shared'\n ? {\n id: 'make-private',\n label: t('communication_channels.profile.share.makePrivate', 'Make private'),\n onSelect: () => { void onSetVisibility(channel, false) },\n }\n : {\n id: 'share-with-team',\n label: t('communication_channels.profile.share.shareCta', 'Share with team'),\n onSelect: () => { void onSetVisibility(channel, true) },\n },\n )\n }\n\n // Offered only when the adapter can register push AND it is not already\n // active \u2014 re-registering a healthy subscription is a no-op.\n if (channel.supportsPushRegistration && channel.pushStatus !== 'active') {\n items.push({\n id: 'register-push',\n label: t('communication_channels.push.button.reregister', 'Re-register push'),\n onSelect: () => { void onRegisterPush(channel.id) },\n })\n }\n\n // Same eligibility the History column enforced.\n if (channel.isActive && channel.status === 'connected' && channel.channelType === 'email') {\n items.push({\n id: 'import-history',\n label: t('communication_channels.profile.actions.importHistory', 'Import history'),\n onSelect: () => setImportChannel(channel),\n })\n }\n\n // Same eligibility the Sync column enforced: allowed from 'connected'\n // AND 'error' (so a stuck channel can be recovered without a\n // reconnect), never for a push-driven channel the worker skips (#4980).\n const pollable =\n channel.isActive &&\n !channel.supportsRealtimePush &&\n (channel.status === 'connected' || channel.status === 'error')\n if (pollable) {\n items.push({\n id: 'poll-now',\n label:\n channel.status === 'error'\n ? t('communication_channels.profile.actions.retryPoll', 'Retry')\n : t('communication_channels.profile.actions.pollNow', 'Poll now'),\n onSelect: () => { void onPollNow(channel.id) },\n })\n }\n\n items.push({\n id: 'disconnect',\n label: t('communication_channels.profile.actions.disconnect', 'Disconnect'),\n destructive: true,\n onSelect: () => setDisconnectChannel(channel),\n })\n\n return <RowActions items={items} />\n },\n [onSetPrimary, onSetVisibility, onPollNow, onRegisterPush, t],\n )\n\n return (\n <Page>\n <PageBody>\n <header className=\"mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4\">\n <div className=\"min-w-0\">\n <h1 className=\"text-2xl font-semibold\">\n {t('communication_channels.profile.title', 'My communication channels')}\n </h1>\n <p className=\"text-sm text-muted-foreground\">\n {t(\n 'communication_channels.profile.subtitle',\n 'Connect your communication channels so outbound messages come from your own account and inbound messages land in your unified inbox.',\n )}\n </p>\n </div>\n {/* Provider connect entry points injected by each channel-* package\n (channel-gmail, channel-imap) via UMES. They stack inside one\n dropdown so the header does not widen per installed provider. */}\n <ConnectChannelMenu onConnected={reloadChannels} />\n </header>\n\n {reauthRows.length > 0 ? (\n <Alert status=\"warning\" className=\"mb-4\">\n <AlertDescription>\n {t(\n 'communication_channels.profile.alerts.requiresReauth',\n '{count} channel(s) need reconnection. Click \"Reconnect\" on the affected channel below.',\n { count: reauthRows.length },\n )}\n </AlertDescription>\n </Alert>\n ) : null}\n\n <DataTable<ChannelRow>\n title={t('communication_channels.profile.tableTitle', 'Your channels')}\n titleHeadingLevel={2}\n extensionTableId={extensionPoints.hosts.profileChannelsTable.tableId}\n columns={columns}\n rowActions={rowActions}\n // Pinned because this table is wider than a laptop viewport: at 1280px\n // the row runs out well before the actions cell, and since Primary,\n // Team access and Push are status-only there is no other control in\n // view. Unpinned, reaching \u22EF meant scrolling to the far end, which\n // scrolls the Channel column away \u2014 so you could no longer see which\n // channel the open menu belonged to (#6717).\n stickyActionsColumn\n data={rows}\n isLoading={isLoading}\n error={errorMessage}\n emptyState={t(\n 'communication_channels.profile.empty',\n 'You have no connected channels yet. Add one using the menu at the top of this page.',\n )}\n />\n <ImportHistoryDialog\n channel={importChannel}\n onClose={() => setImportChannel(null)}\n onQueued={() => {\n setImportChannel(null)\n router.refresh()\n }}\n />\n <DisconnectChannelDialog\n channel={disconnectChannel}\n onClose={() => setDisconnectChannel(null)}\n onDisconnected={() => {\n setDisconnectChannel(null)\n setReloadKey((k) => k + 1)\n }}\n />\n {ConfirmDialogElement}\n </PageBody>\n </Page>\n )\n}\n\ntype ImportHistoryDialogProps = {\n channel: ChannelRow | null\n onClose: () => void\n onQueued: () => void\n}\n\nfunction ImportHistoryDialog({ channel, onClose, onQueued }: ImportHistoryDialogProps): React.JSX.Element {\n const t = useT()\n const importLimits = React.useMemo(() => getImportHistoryLimits(channel?.providerKey), [channel?.providerKey])\n const [sinceDays, setSinceDays] = React.useState('30')\n const [contactEmails, setContactEmails] = React.useState('')\n const [maxMessages, setMaxMessages] = React.useState('500')\n const [fieldErrors, setFieldErrors] = React.useState<Record<string, string>>({})\n const [submitting, setSubmitting] = React.useState(false)\n const { runMutation, retryLastMutation } = useGuardedMutation<ChannelMutationContext>({\n contextId: IMPORT_HISTORY_MUTATION_CONTEXT_ID,\n blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),\n })\n\n React.useEffect(() => {\n if (channel) {\n setSinceDays('30')\n setContactEmails('')\n setMaxMessages('500')\n setFieldErrors({})\n setSubmitting(false)\n }\n }, [channel?.id])\n\n const handleSubmit = React.useCallback(async () => {\n if (!channel || submitting) return\n const sinceNum = Number.parseInt(sinceDays, 10)\n const maxNum = Number.parseInt(maxMessages, 10)\n const errors: Record<string, string> = {}\n if (!Number.isFinite(sinceNum) || sinceNum < 1 || sinceNum > importLimits.maxSinceDays) {\n errors.sinceDays = t(\n 'communication_channels.profile.importHistory.errors.sinceDays',\n 'Choose a number between 1 and {max} days.',\n { max: importLimits.maxSinceDays },\n )\n }\n if (!Number.isFinite(maxNum) || maxNum < 1 || maxNum > importLimits.maxMessages) {\n errors.maxMessages = t(\n 'communication_channels.profile.importHistory.errors.maxMessages',\n 'Choose a number between 1 and {max} messages.',\n { max: importLimits.maxMessages },\n )\n }\n const parsedEmails = contactEmails\n .split(/[\\s,;]+/)\n .map((s) => s.trim())\n .filter(Boolean)\n if (parsedEmails.length > 0 && parsedEmails.some((s) => !/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(s))) {\n errors.contactEmails = t(\n 'communication_channels.profile.importHistory.errors.contactEmails',\n 'One or more entries is not a valid email address.',\n )\n }\n if (Object.keys(errors).length > 0) {\n setFieldErrors(errors)\n return\n }\n setFieldErrors({})\n setSubmitting(true)\n const mutationPayload = {\n sinceDays: sinceNum,\n maxMessages: maxNum,\n ...(parsedEmails.length > 0 ? { contactEmails: parsedEmails } : {}),\n }\n let response\n try {\n response = await runMutation({\n operation: () => apiCall<{ progressJobId?: string }>(\n `/api/communication_channels/channels/${encodeURIComponent(channel.id)}/import-history`,\n {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(mutationPayload),\n },\n ),\n context: {\n formId: IMPORT_HISTORY_MUTATION_CONTEXT_ID,\n resourceKind: 'communication_channels.channel',\n resourceId: channel.id,\n retryLastMutation,\n },\n mutationPayload,\n })\n } catch (err) {\n setSubmitting(false)\n flash(\n err instanceof Error\n ? err.message\n : t('communication_channels.profile.importHistory.flash.error', 'Failed to queue history import.'),\n 'error',\n )\n return\n }\n setSubmitting(false)\n if (!response.ok) {\n const body = response.result as { error?: string; fieldErrors?: Record<string, string> } | undefined\n if (body?.fieldErrors && Object.keys(body.fieldErrors).length > 0) {\n setFieldErrors(body.fieldErrors)\n return\n }\n flash(\n body?.error ??\n t(\n 'communication_channels.profile.importHistory.flash.error',\n 'Failed to queue history import.',\n ),\n 'error',\n )\n return\n }\n flash(\n t(\n 'communication_channels.profile.importHistory.flash.success',\n 'History import queued \u2014 track progress in the top bar.',\n ),\n 'success',\n )\n onQueued()\n }, [channel, sinceDays, maxMessages, contactEmails, submitting, t, onQueued, retryLastMutation, runMutation, importLimits])\n\n const handleKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {\n event.preventDefault()\n void handleSubmit()\n }\n },\n [handleSubmit],\n )\n\n return (\n <Dialog open={channel !== null} onOpenChange={(open) => { if (!open) onClose() }}>\n <DialogContent onKeyDown={handleKeyDown}>\n <DialogHeader>\n <DialogTitle>\n {t('communication_channels.profile.importHistory.title', 'Import channel history')}\n </DialogTitle>\n <DialogDescription>\n {t(\n 'communication_channels.profile.importHistory.description',\n 'Pull older messages this channel never observed at connect-time. Filters narrow the search server-side.',\n )}\n </DialogDescription>\n </DialogHeader>\n\n <div className=\"space-y-4\">\n <div className=\"space-y-1\">\n <Label htmlFor=\"import-history-since\">\n {t('communication_channels.profile.importHistory.fields.sinceDays', 'Look back (days)')}\n </Label>\n <Input\n id=\"import-history-since\"\n type=\"number\"\n min={1}\n max={importLimits.maxSinceDays}\n value={sinceDays}\n onChange={(e) => setSinceDays(e.target.value)}\n aria-invalid={Boolean(fieldErrors.sinceDays)}\n />\n {fieldErrors.sinceDays ? (\n <p className=\"text-xs text-status-error-text\">{fieldErrors.sinceDays}</p>\n ) : null}\n </div>\n\n <div className=\"space-y-1\">\n <Label htmlFor=\"import-history-emails\">\n {t(\n 'communication_channels.profile.importHistory.fields.contactEmails',\n 'Filter by sender (optional)',\n )}\n </Label>\n <Textarea\n id=\"import-history-emails\"\n rows={3}\n value={contactEmails}\n onChange={(e) => setContactEmails(e.target.value)}\n placeholder={t(\n 'communication_channels.profile.importHistory.fields.contactEmailsPlaceholder',\n 'alice@example.com, bob@example.com',\n )}\n aria-invalid={Boolean(fieldErrors.contactEmails)}\n />\n {fieldErrors.contactEmails ? (\n <p className=\"text-xs text-status-error-text\">{fieldErrors.contactEmails}</p>\n ) : (\n <p className=\"text-xs text-muted-foreground\">\n {t(\n 'communication_channels.profile.importHistory.fields.contactEmailsHint',\n 'Leave empty to scan all senders in the window.',\n )}\n </p>\n )}\n </div>\n\n <div className=\"space-y-1\">\n <Label htmlFor=\"import-history-max\">\n {t('communication_channels.profile.importHistory.fields.maxMessages', 'Maximum messages')}\n </Label>\n <Input\n id=\"import-history-max\"\n type=\"number\"\n min={1}\n max={importLimits.maxMessages}\n value={maxMessages}\n onChange={(e) => setMaxMessages(e.target.value)}\n aria-invalid={Boolean(fieldErrors.maxMessages)}\n />\n {fieldErrors.maxMessages ? (\n <p className=\"text-xs text-status-error-text\">{fieldErrors.maxMessages}</p>\n ) : null}\n </div>\n\n {fieldErrors.channelId ? (\n <Alert status=\"warning\">\n <AlertDescription>{fieldErrors.channelId}</AlertDescription>\n </Alert>\n ) : null}\n </div>\n\n <DialogFooter>\n <span className=\"mr-auto text-xs text-muted-foreground\">\n <KbdShortcut keys={['\u2318', 'Enter']} />\n </span>\n <Button type=\"button\" variant=\"outline\" onClick={onClose} disabled={submitting}>\n {t('communication_channels.profile.importHistory.cancel', 'Cancel')}\n </Button>\n <Button type=\"button\" onClick={() => void handleSubmit()} disabled={submitting}>\n {submitting\n ? t('communication_channels.profile.importHistory.submitting', 'Queueing\u2026')\n : t('communication_channels.profile.importHistory.submit', 'Start import')}\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n )\n}\n\ntype DisconnectChannelDialogProps = {\n channel: ChannelRow | null\n onClose: () => void\n onDisconnected: () => void\n}\n\nfunction DisconnectChannelDialog({\n channel,\n onClose,\n onDisconnected,\n}: DisconnectChannelDialogProps): React.JSX.Element {\n const t = useT()\n const [submitting, setSubmitting] = React.useState(false)\n const { runMutation, retryLastMutation } = useGuardedMutation<ChannelMutationContext>({\n contextId: DISCONNECT_MUTATION_CONTEXT_ID,\n blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),\n })\n\n React.useEffect(() => {\n if (channel) setSubmitting(false)\n }, [channel?.id])\n\n const handleConfirm = React.useCallback(async () => {\n if (!channel || submitting) return\n setSubmitting(true)\n let response\n try {\n response = await runMutation({\n // optimistic-lock-exempt: self-service connect/disconnect of the\n // signed-in operator's OWN communication channel (an integration link),\n // not a shared multi-editor record. Disconnect is a terminal action\n // keyed by channel id; there is no concurrent-edit lost-update window to\n // guard, and the row carries no client-surfaced `updatedAt` round-trip.\n operation: () => apiCall(\n `/api/communication_channels/channels/${encodeURIComponent(channel.id)}`,\n { method: 'DELETE' },\n ),\n context: {\n formId: DISCONNECT_MUTATION_CONTEXT_ID,\n resourceKind: 'communication_channels.channel',\n resourceId: channel.id,\n retryLastMutation,\n },\n mutationPayload: { action: 'disconnect' },\n })\n } catch (err) {\n setSubmitting(false)\n flash(\n err instanceof Error\n ? err.message\n : t('communication_channels.profile.actions.disconnectFailed', 'Failed to disconnect channel'),\n 'error',\n )\n return\n }\n setSubmitting(false)\n if (!response.ok) {\n const body = response.result as { error?: string } | undefined\n flash(\n body?.error ??\n t('communication_channels.profile.actions.disconnectFailed', 'Failed to disconnect channel'),\n 'error',\n )\n return\n }\n flash(\n t(\n 'communication_channels.profile.actions.disconnectSuccess',\n 'Channel disconnected. You can reconnect it anytime.',\n ),\n 'success',\n )\n onDisconnected()\n }, [channel, submitting, runMutation, retryLastMutation, t, onDisconnected])\n\n const handleKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {\n event.preventDefault()\n void handleConfirm()\n }\n },\n [handleConfirm],\n )\n\n return (\n <Dialog open={channel !== null} onOpenChange={(open) => { if (!open) onClose() }}>\n <DialogContent onKeyDown={handleKeyDown}>\n <DialogHeader>\n <DialogTitle>\n {t('communication_channels.profile.disconnect.title', 'Disconnect channel')}\n </DialogTitle>\n <DialogDescription>\n {t(\n 'communication_channels.profile.disconnect.description',\n 'This removes the connection and stops syncing. Emails already imported stay on your timelines. You can reconnect anytime.',\n )}\n </DialogDescription>\n </DialogHeader>\n\n {channel ? (\n <p className=\"text-sm font-medium\">{channel.externalIdentifier ?? channel.displayName}</p>\n ) : null}\n\n <DialogFooter>\n <span className=\"mr-auto text-xs text-muted-foreground\">\n <KbdShortcut keys={['\u2318', 'Enter']} />\n </span>\n <Button type=\"button\" variant=\"outline\" onClick={onClose} disabled={submitting}>\n {t('communication_channels.profile.disconnect.cancel', 'Cancel')}\n </Button>\n <Button type=\"button\" variant=\"destructive-solid\" onClick={() => void handleConfirm()} disabled={submitting}>\n {submitting\n ? t('communication_channels.profile.disconnect.submitting', 'Disconnecting\u2026')\n : t('communication_channels.profile.disconnect.confirm', 'Disconnect')}\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n )\n}\n\nfunction statusTag(\n status: ChannelRow['status'],\n t: (key: string, fallback?: string) => string,\n): React.ReactNode {\n switch (status) {\n case 'connected':\n return (\n <Tag variant=\"success\" dot>\n {t('communication_channels.status.connected', 'Connected')}\n </Tag>\n )\n case 'requires_reauth':\n return (\n <Tag variant=\"warning\" dot>\n {t('communication_channels.status.requiresReauth', 'Needs reconnection')}\n </Tag>\n )\n case 'error':\n return (\n <Tag variant=\"error\" dot>\n {t('communication_channels.status.error', 'Error')}\n </Tag>\n )\n case 'disconnected':\n return <Tag variant=\"neutral\">{t('communication_channels.status.disconnected', 'Disconnected')}</Tag>\n default:\n return <Tag variant=\"neutral\">{status}</Tag>\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -48,6 +48,9 @@ const setPrimaryChannelCommand = {
|
|
|
48
48
|
if (channel.userId !== input.userId) {
|
|
49
49
|
return { status: "not_owner", reason: "channel is not owned by the current user" };
|
|
50
50
|
}
|
|
51
|
+
if (channel.status === "disconnected") {
|
|
52
|
+
return { status: "disconnected", reason: "channel is disconnected" };
|
|
53
|
+
}
|
|
51
54
|
if (channel.isPrimary) {
|
|
52
55
|
return { status: "noop", reason: "channel is already primary" };
|
|
53
56
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/communication_channels/commands/set-primary-channel.ts"],
|
|
4
|
-
"sourcesContent": ["import { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandHandler } from '@open-mercato/shared/lib/commands'\nimport { registerCommand } from '@open-mercato/shared/lib/commands'\nimport { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'\nimport { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { CommunicationChannel } from '../data/entities'\nimport { emitCommunicationChannelsEvent } from '../events'\n\nconst setPrimaryChannelSchema = z.object({\n channelId: z.string().uuid(),\n userId: z.string().uuid(),\n scope: z.object({\n tenantId: z.string().uuid(),\n organizationId: z.string().uuid().nullable(),\n }),\n})\n\nexport type SetPrimaryChannelInput = z.infer<typeof setPrimaryChannelSchema>\n\nexport type SetPrimaryChannelResult =\n | { status: 'set'; channelId: string; previousPrimaryChannelId: string | null }\n | { status: 'noop'; reason: string }\n | { status: 'not_owner'; reason: string }\n\nexport const COMMUNICATION_CHANNELS_SET_PRIMARY_COMMAND_ID =\n 'communication_channels.channel.set_primary'\n\n/**\n * Mark a per-user channel as primary. Clears the primary flag on every other\n * channel owned by the same user. Enforced as one-primary-per-user by the\n * partial unique index `communication_channels_one_primary_per_user_uq`.\n *\n * Ownership-checked: refuses to set a primary on someone else's channel.\n */\nconst setPrimaryChannelCommand: CommandHandler<\n SetPrimaryChannelInput,\n SetPrimaryChannelResult\n> = {\n id: COMMUNICATION_CHANNELS_SET_PRIMARY_COMMAND_ID,\n // Intentionally NOT undoable. Restoring a prior primary is order-sensitive\n // against the partial unique index `communication_channels_one_primary_per_user_uq`\n // (it would need the same clear-then-set, cross-org, two-phase dance as\n // `execute`), and a stale snapshot could re-primary a channel the user has\n // since disconnected. Sibling lifecycle commands (disconnect/delete) likewise\n // do not re-claim primary on undo. Declared explicitly so the omission reads\n // as a decision, not an oversight.\n isUndoable: false,\n async execute(rawInput, ctx) {\n const input = setPrimaryChannelSchema.parse(rawInput) as SetPrimaryChannelInput\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n const dscope = {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n }\n\n const channel = await findOneWithDecryption(\n em,\n CommunicationChannel,\n {\n id: input.channelId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n deletedAt: null,\n },\n undefined,\n dscope,\n )\n if (!channel) {\n // Existence masking: a non-existent channel is indistinguishable from one\n // owned by another user \u2014 both surface as 404 (not_owner) at the route,\n // consistent with the other channel-scoped routes (import-history, etc.).\n return { status: 'not_owner', reason: 'channel not found' }\n }\n if (channel.userId !== input.userId) {\n return { status: 'not_owner', reason: 'channel is not owned by the current user' }\n }\n if (channel.isPrimary) {\n return { status: 'noop', reason: 'channel is already primary' }\n }\n\n // The partial unique index `communication_channels_one_primary_per_user_uq`\n // forbids two rows where `is_primary AND user_id = X`. PostgreSQL does NOT\n // defer partial unique checks \u2014 every UPDATE statement is checked against\n // the live partial index. So a SINGLE `em.flush()` containing both\n // `is_primary=false` and `is_primary=true` updates is unsafe: MikroORM\n // does not guarantee SET-false statements execute before SET-true.\n //\n // `withAtomicFlush` issues ONE flush at the END of all phases (it does NOT\n // flush between them \u2014 see packages/shared/src/lib/commands/flush.ts), so a\n // managed-entity two-phase still lands the SET-false and SET-true updates in\n // that single unordered flush and races (23505). Phase 1 therefore flushes\n // the clear EXPLICITLY so Postgres observes `is_primary=false` before Phase\n // 2's SET-true UPDATE runs; the transaction wraps both for all-or-nothing.\n const previousPrimaries = await findWithDecryption(\n em,\n CommunicationChannel,\n {\n // The one-primary-per-user partial unique index keys on `user_id` only,\n // so it spans ALL organizations for a user. The prior primary must be\n // cleared regardless of which org it lives in \u2014 otherwise a multi-org\n // user setting a new primary in org B would collide (23505) with their\n // existing primary in org A. `CommunicationChannel` has no encrypted\n // fields, so the cross-org read needs no per-org decryption scope.\n tenantId: input.scope.tenantId,\n userId: input.userId,\n isPrimary: true,\n deletedAt: null,\n },\n undefined,\n dscope,\n )\n let previousPrimaryChannelId: string | null = null\n await withAtomicFlush(\n em,\n [\n async () => {\n for (const prev of previousPrimaries as CommunicationChannel[]) {\n previousPrimaryChannelId = prev.id\n prev.isPrimary = false\n }\n // Flush the clear before Phase 2 sets the new primary, so Postgres\n // never sees two `is_primary` rows for this user at once (see above).\n await em.flush()\n },\n () => {\n channel.isPrimary = true\n },\n ],\n { transaction: true },\n )\n\n await emitCommunicationChannelsEvent(\n 'communication_channels.channel.primary_changed',\n {\n channelId: channel.id,\n userId: input.userId,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n previousPrimaryChannelId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n\n return {\n status: 'set',\n channelId: channel.id,\n previousPrimaryChannelId,\n }\n },\n}\n\nregisterCommand(setPrimaryChannelCommand)\n\nexport default setPrimaryChannelCommand\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,SAAS;AAGlB,SAAS,uBAAuB;AAChC,SAAS,uBAAuB;AAChC,SAAS,uBAAuB,0BAA0B;AAC1D,SAAS,4BAA4B;AACrC,SAAS,sCAAsC;AAE/C,MAAM,0BAA0B,EAAE,OAAO;AAAA,EACvC,WAAW,EAAE,OAAO,EAAE,KAAK;AAAA,EAC3B,QAAQ,EAAE,OAAO,EAAE,KAAK;AAAA,EACxB,OAAO,EAAE,OAAO;AAAA,IACd,UAAU,EAAE,OAAO,EAAE,KAAK;AAAA,IAC1B,gBAAgB,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS;AAAA,EAC7C,CAAC;AACH,CAAC;
|
|
4
|
+
"sourcesContent": ["import { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandHandler } from '@open-mercato/shared/lib/commands'\nimport { registerCommand } from '@open-mercato/shared/lib/commands'\nimport { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'\nimport { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { CommunicationChannel } from '../data/entities'\nimport { emitCommunicationChannelsEvent } from '../events'\n\nconst setPrimaryChannelSchema = z.object({\n channelId: z.string().uuid(),\n userId: z.string().uuid(),\n scope: z.object({\n tenantId: z.string().uuid(),\n organizationId: z.string().uuid().nullable(),\n }),\n})\n\nexport type SetPrimaryChannelInput = z.infer<typeof setPrimaryChannelSchema>\n\nexport type SetPrimaryChannelResult =\n | { status: 'set'; channelId: string; previousPrimaryChannelId: string | null }\n | { status: 'noop'; reason: string }\n | { status: 'not_owner'; reason: string }\n | { status: 'disconnected'; reason: string }\n\nexport const COMMUNICATION_CHANNELS_SET_PRIMARY_COMMAND_ID =\n 'communication_channels.channel.set_primary'\n\n/**\n * Mark a per-user channel as primary. Clears the primary flag on every other\n * channel owned by the same user. Enforced as one-primary-per-user by the\n * partial unique index `communication_channels_one_primary_per_user_uq`.\n *\n * Ownership-checked: refuses to set a primary on someone else's channel.\n * Refuses a disconnected channel: every path that disconnects a channel also\n * clears its primary flag, so promoting one would hand the user a primary that\n * cannot send.\n */\nconst setPrimaryChannelCommand: CommandHandler<\n SetPrimaryChannelInput,\n SetPrimaryChannelResult\n> = {\n id: COMMUNICATION_CHANNELS_SET_PRIMARY_COMMAND_ID,\n // Intentionally NOT undoable. Restoring a prior primary is order-sensitive\n // against the partial unique index `communication_channels_one_primary_per_user_uq`\n // (it would need the same clear-then-set, cross-org, two-phase dance as\n // `execute`), and a stale snapshot could re-primary a channel the user has\n // since disconnected. Sibling lifecycle commands (disconnect/delete) likewise\n // do not re-claim primary on undo. Declared explicitly so the omission reads\n // as a decision, not an oversight.\n isUndoable: false,\n async execute(rawInput, ctx) {\n const input = setPrimaryChannelSchema.parse(rawInput) as SetPrimaryChannelInput\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n const dscope = {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n }\n\n const channel = await findOneWithDecryption(\n em,\n CommunicationChannel,\n {\n id: input.channelId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n deletedAt: null,\n },\n undefined,\n dscope,\n )\n if (!channel) {\n // Existence masking: a non-existent channel is indistinguishable from one\n // owned by another user \u2014 both surface as 404 (not_owner) at the route,\n // consistent with the other channel-scoped routes (import-history, etc.).\n return { status: 'not_owner', reason: 'channel not found' }\n }\n if (channel.userId !== input.userId) {\n return { status: 'not_owner', reason: 'channel is not owned by the current user' }\n }\n if (channel.status === 'disconnected') {\n return { status: 'disconnected', reason: 'channel is disconnected' }\n }\n if (channel.isPrimary) {\n return { status: 'noop', reason: 'channel is already primary' }\n }\n\n // The partial unique index `communication_channels_one_primary_per_user_uq`\n // forbids two rows where `is_primary AND user_id = X`. PostgreSQL does NOT\n // defer partial unique checks \u2014 every UPDATE statement is checked against\n // the live partial index. So a SINGLE `em.flush()` containing both\n // `is_primary=false` and `is_primary=true` updates is unsafe: MikroORM\n // does not guarantee SET-false statements execute before SET-true.\n //\n // `withAtomicFlush` issues ONE flush at the END of all phases (it does NOT\n // flush between them \u2014 see packages/shared/src/lib/commands/flush.ts), so a\n // managed-entity two-phase still lands the SET-false and SET-true updates in\n // that single unordered flush and races (23505). Phase 1 therefore flushes\n // the clear EXPLICITLY so Postgres observes `is_primary=false` before Phase\n // 2's SET-true UPDATE runs; the transaction wraps both for all-or-nothing.\n const previousPrimaries = await findWithDecryption(\n em,\n CommunicationChannel,\n {\n // The one-primary-per-user partial unique index keys on `user_id` only,\n // so it spans ALL organizations for a user. The prior primary must be\n // cleared regardless of which org it lives in \u2014 otherwise a multi-org\n // user setting a new primary in org B would collide (23505) with their\n // existing primary in org A. `CommunicationChannel` has no encrypted\n // fields, so the cross-org read needs no per-org decryption scope.\n tenantId: input.scope.tenantId,\n userId: input.userId,\n isPrimary: true,\n deletedAt: null,\n },\n undefined,\n dscope,\n )\n let previousPrimaryChannelId: string | null = null\n await withAtomicFlush(\n em,\n [\n async () => {\n for (const prev of previousPrimaries as CommunicationChannel[]) {\n previousPrimaryChannelId = prev.id\n prev.isPrimary = false\n }\n // Flush the clear before Phase 2 sets the new primary, so Postgres\n // never sees two `is_primary` rows for this user at once (see above).\n await em.flush()\n },\n () => {\n channel.isPrimary = true\n },\n ],\n { transaction: true },\n )\n\n await emitCommunicationChannelsEvent(\n 'communication_channels.channel.primary_changed',\n {\n channelId: channel.id,\n userId: input.userId,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n previousPrimaryChannelId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n\n return {\n status: 'set',\n channelId: channel.id,\n previousPrimaryChannelId,\n }\n },\n}\n\nregisterCommand(setPrimaryChannelCommand)\n\nexport default setPrimaryChannelCommand\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,SAAS;AAGlB,SAAS,uBAAuB;AAChC,SAAS,uBAAuB;AAChC,SAAS,uBAAuB,0BAA0B;AAC1D,SAAS,4BAA4B;AACrC,SAAS,sCAAsC;AAE/C,MAAM,0BAA0B,EAAE,OAAO;AAAA,EACvC,WAAW,EAAE,OAAO,EAAE,KAAK;AAAA,EAC3B,QAAQ,EAAE,OAAO,EAAE,KAAK;AAAA,EACxB,OAAO,EAAE,OAAO;AAAA,IACd,UAAU,EAAE,OAAO,EAAE,KAAK;AAAA,IAC1B,gBAAgB,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS;AAAA,EAC7C,CAAC;AACH,CAAC;AAUM,MAAM,gDACX;AAYF,MAAM,2BAGF;AAAA,EACF,IAAI;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQJ,YAAY;AAAA,EACZ,MAAM,QAAQ,UAAU,KAAK;AAC3B,UAAM,QAAQ,wBAAwB,MAAM,QAAQ;AACpD,UAAM,KAAM,IAAI,UAAU,QAAQ,IAAI,EAAoB,KAAK;AAC/D,UAAM,SAAS;AAAA,MACb,UAAU,MAAM,MAAM;AAAA,MACtB,gBAAgB,MAAM,MAAM,kBAAkB;AAAA,IAChD;AAEA,UAAM,UAAU,MAAM;AAAA,MACpB;AAAA,MACA;AAAA,MACA;AAAA,QACE,IAAI,MAAM;AAAA,QACV,UAAU,MAAM,MAAM;AAAA,QACtB,gBAAgB,MAAM,MAAM,kBAAkB;AAAA,QAC9C,WAAW;AAAA,MACb;AAAA,MACA;AAAA,MACA;AAAA,IACF;AACA,QAAI,CAAC,SAAS;AAIZ,aAAO,EAAE,QAAQ,aAAa,QAAQ,oBAAoB;AAAA,IAC5D;AACA,QAAI,QAAQ,WAAW,MAAM,QAAQ;AACnC,aAAO,EAAE,QAAQ,aAAa,QAAQ,2CAA2C;AAAA,IACnF;AACA,QAAI,QAAQ,WAAW,gBAAgB;AACrC,aAAO,EAAE,QAAQ,gBAAgB,QAAQ,0BAA0B;AAAA,IACrE;AACA,QAAI,QAAQ,WAAW;AACrB,aAAO,EAAE,QAAQ,QAAQ,QAAQ,6BAA6B;AAAA,IAChE;AAeA,UAAM,oBAAoB,MAAM;AAAA,MAC9B;AAAA,MACA;AAAA,MACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,QAOE,UAAU,MAAM,MAAM;AAAA,QACtB,QAAQ,MAAM;AAAA,QACd,WAAW;AAAA,QACX,WAAW;AAAA,MACb;AAAA,MACA;AAAA,MACA;AAAA,IACF;AACA,QAAI,2BAA0C;AAC9C,UAAM;AAAA,MACJ;AAAA,MACA;AAAA,QACE,YAAY;AACV,qBAAW,QAAQ,mBAA6C;AAC9D,uCAA2B,KAAK;AAChC,iBAAK,YAAY;AAAA,UACnB;AAGA,gBAAM,GAAG,MAAM;AAAA,QACjB;AAAA,QACA,MAAM;AACJ,kBAAQ,YAAY;AAAA,QACtB;AAAA,MACF;AAAA,MACA,EAAE,aAAa,KAAK;AAAA,IACtB;AAEA,UAAM;AAAA,MACJ;AAAA,MACA;AAAA,QACE,WAAW,QAAQ;AAAA,QACnB,QAAQ,MAAM;AAAA,QACd,aAAa,QAAQ;AAAA,QACrB,aAAa,QAAQ;AAAA,QACrB;AAAA,QACA,UAAU,MAAM,MAAM;AAAA,QACtB,gBAAgB,MAAM,MAAM,kBAAkB;AAAA,MAChD;AAAA,MACA,EAAE,YAAY,KAAK;AAAA,IACrB;AAEA,WAAO;AAAA,MACL,QAAQ;AAAA,MACR,WAAW,QAAQ;AAAA,MACnB;AAAA,IACF;AAAA,EACF;AACF;AAEA,gBAAgB,wBAAwB;AAExC,IAAO,8BAAQ;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.10.1-develop.
|
|
3
|
+
"version": "0.10.1-develop.7973.1.2d07035b6f",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -266,16 +266,16 @@
|
|
|
266
266
|
"zod": "^4.6.6"
|
|
267
267
|
},
|
|
268
268
|
"peerDependencies": {
|
|
269
|
-
"@open-mercato/ai-assistant": "0.10.1-develop.
|
|
270
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
271
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
269
|
+
"@open-mercato/ai-assistant": "0.10.1-develop.7973.1.2d07035b6f",
|
|
270
|
+
"@open-mercato/shared": "0.10.1-develop.7973.1.2d07035b6f",
|
|
271
|
+
"@open-mercato/ui": "0.10.1-develop.7973.1.2d07035b6f",
|
|
272
272
|
"react": "^19.0.0",
|
|
273
273
|
"react-dom": "^19.0.0"
|
|
274
274
|
},
|
|
275
275
|
"devDependencies": {
|
|
276
|
-
"@open-mercato/ai-assistant": "0.10.1-develop.
|
|
277
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
278
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
276
|
+
"@open-mercato/ai-assistant": "0.10.1-develop.7973.1.2d07035b6f",
|
|
277
|
+
"@open-mercato/shared": "0.10.1-develop.7973.1.2d07035b6f",
|
|
278
|
+
"@open-mercato/ui": "0.10.1-develop.7973.1.2d07035b6f",
|
|
279
279
|
"@testing-library/dom": "^10.4.1",
|
|
280
280
|
"@testing-library/jest-dom": "^7.0.1",
|
|
281
281
|
"@testing-library/react": "^16.3.3",
|
|
@@ -111,6 +111,25 @@ export async function seedConnectedChannel(
|
|
|
111
111
|
return expectId(body?.channelId, 'connect-channel response should include channelId');
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Disconnect a channel the caller owns through the real disconnect command. The
|
|
116
|
+
* row stays listed with `status: 'disconnected'` (a UI disconnect deletes it).
|
|
117
|
+
*/
|
|
118
|
+
export async function disconnectSeededChannel(
|
|
119
|
+
request: APIRequestContext,
|
|
120
|
+
token: string,
|
|
121
|
+
channelId: string,
|
|
122
|
+
): Promise<void> {
|
|
123
|
+
const response = await apiRequest(request, 'POST', TEST_SEED_PATH, {
|
|
124
|
+
token,
|
|
125
|
+
data: { action: 'disconnect-channel', channelId },
|
|
126
|
+
});
|
|
127
|
+
expect(
|
|
128
|
+
response.status(),
|
|
129
|
+
'POST /api/communication_channels/test-seed (disconnect-channel) should return 200',
|
|
130
|
+
).toBe(200);
|
|
131
|
+
}
|
|
132
|
+
|
|
114
133
|
export async function seedSystemEmailChannel(
|
|
115
134
|
request: APIRequestContext,
|
|
116
135
|
token: string,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
2
|
import { z } from 'zod'
|
|
3
3
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
4
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
4
5
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
5
6
|
import type { CommandBus } from '@open-mercato/shared/lib/commands'
|
|
6
7
|
import {
|
|
@@ -82,6 +83,19 @@ export async function POST(req: Request, context: RouteContext): Promise<Respons
|
|
|
82
83
|
if (result.status === 'not_owner') {
|
|
83
84
|
return NextResponse.json({ error: 'Channel not found' }, { status: 404 })
|
|
84
85
|
}
|
|
86
|
+
if (result.status === 'disconnected') {
|
|
87
|
+
const { translate } = await resolveTranslations()
|
|
88
|
+
return NextResponse.json(
|
|
89
|
+
{
|
|
90
|
+
error: translate(
|
|
91
|
+
'communication_channels.errors.setPrimaryDisconnected',
|
|
92
|
+
'This channel is disconnected. Reconnect it before setting it as primary.',
|
|
93
|
+
),
|
|
94
|
+
code: 'channel_disconnected',
|
|
95
|
+
},
|
|
96
|
+
{ status: 409 },
|
|
97
|
+
)
|
|
98
|
+
}
|
|
85
99
|
if (result.status === 'noop') {
|
|
86
100
|
return NextResponse.json({ channelId: id, isPrimary: true, unchanged: true }, { status: 200 })
|
|
87
101
|
}
|
|
@@ -107,6 +121,7 @@ export const openApi = {
|
|
|
107
121
|
{ status: 400, description: 'Invalid channel id' },
|
|
108
122
|
{ status: 401, description: 'Unauthorized' },
|
|
109
123
|
{ status: 404, description: 'Channel not found or not owned by current user' },
|
|
124
|
+
{ status: 409, description: 'Channel is disconnected and cannot be set as primary' },
|
|
110
125
|
],
|
|
111
126
|
},
|
|
112
127
|
},
|
|
@@ -22,6 +22,11 @@ import {
|
|
|
22
22
|
type ConnectCredentialChannelInput,
|
|
23
23
|
type ConnectCredentialChannelResult,
|
|
24
24
|
} from '../../../commands/connect-credential-channel'
|
|
25
|
+
import {
|
|
26
|
+
COMMUNICATION_CHANNELS_DISCONNECT_CHANNEL_COMMAND_ID,
|
|
27
|
+
type DisconnectChannelInput,
|
|
28
|
+
type DisconnectChannelResult,
|
|
29
|
+
} from '../../../commands/disconnect-channel'
|
|
25
30
|
import { emitCommunicationChannelsEvent } from '../../../events'
|
|
26
31
|
import {
|
|
27
32
|
TEST_SEED_CHAT_PROVIDER_KEY,
|
|
@@ -67,6 +72,9 @@ import {
|
|
|
67
72
|
* the hub accepts a message, only that downstream subscribers fire.
|
|
68
73
|
* - `purge-inbound`: hard-delete the rows one test-seeded or test-ingested
|
|
69
74
|
* inbound message created, for integration teardown.
|
|
75
|
+
* - `disconnect-channel`: run the REAL disconnect command on a channel the
|
|
76
|
+
* caller owns. No HTTP route disconnects without deleting the row, so this is
|
|
77
|
+
* the only way a test can reach a listed `disconnected` channel.
|
|
70
78
|
*/
|
|
71
79
|
type RbacServiceLike = {
|
|
72
80
|
loadAcl: (
|
|
@@ -158,6 +166,11 @@ const seedSystemChannelSchema = z.object({
|
|
|
158
166
|
externalIdentifier: z.string().min(1).max(255).optional(),
|
|
159
167
|
})
|
|
160
168
|
|
|
169
|
+
const disconnectChannelSchema = z.object({
|
|
170
|
+
action: z.literal('disconnect-channel'),
|
|
171
|
+
channelId: z.string().uuid(),
|
|
172
|
+
})
|
|
173
|
+
|
|
161
174
|
const clearCaptureSchema = z.object({
|
|
162
175
|
action: z.literal('clear-capture'),
|
|
163
176
|
systemRecipient: z.string().email().max(320).optional(),
|
|
@@ -237,6 +250,7 @@ const bodySchema = z.discriminatedUnion('action', [
|
|
|
237
250
|
emitInboundSchema,
|
|
238
251
|
purgeInboundSchema,
|
|
239
252
|
seedSystemChannelSchema,
|
|
253
|
+
disconnectChannelSchema,
|
|
240
254
|
clearCaptureSchema,
|
|
241
255
|
listCaptureSchema,
|
|
242
256
|
])
|
|
@@ -408,6 +422,30 @@ export async function POST(req: Request): Promise<Response> {
|
|
|
408
422
|
return NextResponse.json({ channelId: channel.id }, { status: 201 })
|
|
409
423
|
}
|
|
410
424
|
|
|
425
|
+
if (body.action === 'disconnect-channel') {
|
|
426
|
+
const commandBus = container.resolve('commandBus') as CommandBus
|
|
427
|
+
const { result } = await commandBus.execute<DisconnectChannelInput, DisconnectChannelResult>(
|
|
428
|
+
COMMUNICATION_CHANNELS_DISCONNECT_CHANNEL_COMMAND_ID,
|
|
429
|
+
{
|
|
430
|
+
input: { channelId: body.channelId, userId, scope: { tenantId, organizationId } },
|
|
431
|
+
ctx: {
|
|
432
|
+
container,
|
|
433
|
+
auth: auth as never,
|
|
434
|
+
organizationScope: null,
|
|
435
|
+
selectedOrganizationId: organizationId,
|
|
436
|
+
organizationIds: organizationId ? [organizationId] : null,
|
|
437
|
+
},
|
|
438
|
+
},
|
|
439
|
+
)
|
|
440
|
+
if (result.status !== 'disconnected') {
|
|
441
|
+
return NextResponse.json(
|
|
442
|
+
{ error: '[internal] test-seed disconnect failed', detail: { status: result.status } },
|
|
443
|
+
{ status: 500 },
|
|
444
|
+
)
|
|
445
|
+
}
|
|
446
|
+
return NextResponse.json({ channelId: result.channelId, status: result.status }, { status: 200 })
|
|
447
|
+
}
|
|
448
|
+
|
|
411
449
|
if (body.action === 'connect-channel') {
|
|
412
450
|
const stamp = Date.now()
|
|
413
451
|
const commandBus = container.resolve('commandBus') as CommandBus
|
|
@@ -537,7 +537,7 @@ export default function ProfileCommunicationChannelsPage() {
|
|
|
537
537
|
(channel: ChannelRow) => {
|
|
538
538
|
const items: RowActionItem[] = []
|
|
539
539
|
|
|
540
|
-
if (!channel.isPrimary) {
|
|
540
|
+
if (!channel.isPrimary && channel.status !== 'disconnected') {
|
|
541
541
|
items.push({
|
|
542
542
|
id: 'set-primary',
|
|
543
543
|
label: t('communication_channels.profile.actions.setPrimary', 'Set as primary'),
|
|
@@ -22,6 +22,7 @@ export type SetPrimaryChannelResult =
|
|
|
22
22
|
| { status: 'set'; channelId: string; previousPrimaryChannelId: string | null }
|
|
23
23
|
| { status: 'noop'; reason: string }
|
|
24
24
|
| { status: 'not_owner'; reason: string }
|
|
25
|
+
| { status: 'disconnected'; reason: string }
|
|
25
26
|
|
|
26
27
|
export const COMMUNICATION_CHANNELS_SET_PRIMARY_COMMAND_ID =
|
|
27
28
|
'communication_channels.channel.set_primary'
|
|
@@ -32,6 +33,9 @@ export const COMMUNICATION_CHANNELS_SET_PRIMARY_COMMAND_ID =
|
|
|
32
33
|
* partial unique index `communication_channels_one_primary_per_user_uq`.
|
|
33
34
|
*
|
|
34
35
|
* Ownership-checked: refuses to set a primary on someone else's channel.
|
|
36
|
+
* Refuses a disconnected channel: every path that disconnects a channel also
|
|
37
|
+
* clears its primary flag, so promoting one would hand the user a primary that
|
|
38
|
+
* cannot send.
|
|
35
39
|
*/
|
|
36
40
|
const setPrimaryChannelCommand: CommandHandler<
|
|
37
41
|
SetPrimaryChannelInput,
|
|
@@ -75,6 +79,9 @@ const setPrimaryChannelCommand: CommandHandler<
|
|
|
75
79
|
if (channel.userId !== input.userId) {
|
|
76
80
|
return { status: 'not_owner', reason: 'channel is not owned by the current user' }
|
|
77
81
|
}
|
|
82
|
+
if (channel.status === 'disconnected') {
|
|
83
|
+
return { status: 'disconnected', reason: 'channel is disconnected' }
|
|
84
|
+
}
|
|
78
85
|
if (channel.isPrimary) {
|
|
79
86
|
return { status: 'noop', reason: 'channel is already primary' }
|
|
80
87
|
}
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"communication_channels.errors.pollNowPushDriven": "Der Kanal ist Push-gesteuert — Abrufen ist nicht anwendbar. Eingehende Nachrichten kommen über die Push-Verbindung des Anbieters.",
|
|
31
31
|
"communication_channels.errors.reactionFailed": "Reaktion fehlgeschlagen",
|
|
32
32
|
"communication_channels.errors.reactionNotFound": "Reaktion nicht gefunden",
|
|
33
|
+
"communication_channels.errors.setPrimaryDisconnected": "Dieser Kanal ist getrennt. Verbinde ihn erneut, bevor du ihn als primär festlegst.",
|
|
33
34
|
"communication_channels.errors.threadNotFound": "Thread nicht gefunden",
|
|
34
35
|
"communication_channels.errors.undoBlockedPrimaryConflict": "Ein anderer Kanal ist jetzt für diesen Benutzer als primär festgelegt. Setze ihn auf nicht-primär, bevor du den getrennten Kanal wieder als primär festlegst.",
|
|
35
36
|
"communication_channels.infoPanel.aria": "Kanalinfo",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"communication_channels.errors.pollNowPushDriven": "Channel is push-driven — polling does not apply. Inbound messages arrive through the provider push connection.",
|
|
31
31
|
"communication_channels.errors.reactionFailed": "Reaction failed",
|
|
32
32
|
"communication_channels.errors.reactionNotFound": "Reaction not found",
|
|
33
|
+
"communication_channels.errors.setPrimaryDisconnected": "This channel is disconnected. Reconnect it before setting it as primary.",
|
|
33
34
|
"communication_channels.errors.threadNotFound": "Thread not found",
|
|
34
35
|
"communication_channels.errors.undoBlockedPrimaryConflict": "Another channel is now primary for this user. Set it as non-primary before restoring the disconnected channel as primary.",
|
|
35
36
|
"communication_channels.infoPanel.aria": "Channel info",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"communication_channels.errors.pollNowPushDriven": "El canal funciona con push — el sondeo no aplica. Los mensajes entrantes llegan por la conexión push del proveedor.",
|
|
31
31
|
"communication_channels.errors.reactionFailed": "La reacción falló",
|
|
32
32
|
"communication_channels.errors.reactionNotFound": "Reacción no encontrada",
|
|
33
|
+
"communication_channels.errors.setPrimaryDisconnected": "Este canal está desconectado. Vuelve a conectarlo antes de establecerlo como principal.",
|
|
33
34
|
"communication_channels.errors.threadNotFound": "Hilo no encontrado",
|
|
34
35
|
"communication_channels.errors.undoBlockedPrimaryConflict": "Otro canal es ahora el principal para este usuario. Configúralo como no principal antes de restaurar el canal desconectado como principal.",
|
|
35
36
|
"communication_channels.infoPanel.aria": "Información del canal",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"communication_channels.errors.pollNowPushDriven": "이 채널은 푸시 기반입니다 — 폴링은 적용되지 않습니다. 수신 메시지는 공급자의 푸시 연결로 전달됩니다.",
|
|
31
31
|
"communication_channels.errors.reactionFailed": "반응 처리에 실패했습니다",
|
|
32
32
|
"communication_channels.errors.reactionNotFound": "반응을 찾을 수 없습니다",
|
|
33
|
+
"communication_channels.errors.setPrimaryDisconnected": "이 채널은 연결이 끊겼습니다. 기본으로 설정하기 전에 다시 연결하세요.",
|
|
33
34
|
"communication_channels.errors.threadNotFound": "스레드를 찾을 수 없습니다",
|
|
34
35
|
"communication_channels.errors.undoBlockedPrimaryConflict": "다른 채널이 현재 이 사용자의 기본 채널로 설정되어 있습니다. 연결이 끊어진 채널을 기본으로 복원하기 전에 해당 채널을 기본이 아닌 채널로 설정하세요.",
|
|
35
36
|
"communication_channels.infoPanel.aria": "채널 정보",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"communication_channels.errors.pollNowPushDriven": "Kanał działa w trybie push — odpytywanie go nie dotyczy. Wiadomości przychodzące docierają połączeniem push dostawcy.",
|
|
31
31
|
"communication_channels.errors.reactionFailed": "Reakcja nie powiodła się",
|
|
32
32
|
"communication_channels.errors.reactionNotFound": "Nie znaleziono reakcji",
|
|
33
|
+
"communication_channels.errors.setPrimaryDisconnected": "Ten kanał jest rozłączony. Połącz go ponownie, zanim ustawisz go jako główny.",
|
|
33
34
|
"communication_channels.errors.threadNotFound": "Nie znaleziono wątku",
|
|
34
35
|
"communication_channels.errors.undoBlockedPrimaryConflict": "Inny kanał jest teraz głównym dla tego użytkownika. Ustaw go jako niegłówny, zanim przywrócisz rozłączony kanał jako główny.",
|
|
35
36
|
"communication_channels.infoPanel.aria": "Informacje o kanale",
|