@open-mercato/core 0.10.1-develop.7972.1.03f813eaaf → 0.10.1-develop.7974.1.c98f9304d9

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.
Files changed (42) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/helpers/integration/communicationChannelsFixtures.js +11 -0
  3. package/dist/helpers/integration/communicationChannelsFixtures.js.map +2 -2
  4. package/dist/modules/communication_channels/api/post/channels/[id]/set-primary/route.js +16 -1
  5. package/dist/modules/communication_channels/api/post/channels/[id]/set-primary/route.js.map +2 -2
  6. package/dist/modules/communication_channels/api/post/test-seed/route.js +31 -0
  7. package/dist/modules/communication_channels/api/post/test-seed/route.js.map +2 -2
  8. package/dist/modules/communication_channels/backend/profile/communication-channels/page.js +1 -1
  9. package/dist/modules/communication_channels/backend/profile/communication-channels/page.js.map +2 -2
  10. package/dist/modules/communication_channels/commands/set-primary-channel.js +3 -0
  11. package/dist/modules/communication_channels/commands/set-primary-channel.js.map +2 -2
  12. package/dist/modules/dictionaries/api/[dictionaryId]/route.js +9 -2
  13. package/dist/modules/dictionaries/api/[dictionaryId]/route.js.map +2 -2
  14. package/dist/modules/dictionaries/api/route.js +9 -2
  15. package/dist/modules/dictionaries/api/route.js.map +2 -2
  16. package/dist/modules/dictionaries/components/AppearanceSelector.js +4 -1
  17. package/dist/modules/dictionaries/components/AppearanceSelector.js.map +2 -2
  18. package/dist/modules/dictionaries/components/DictionariesManager.js +2 -2
  19. package/dist/modules/dictionaries/components/DictionariesManager.js.map +2 -2
  20. package/dist/modules/dictionaries/components/DictionaryEntriesEditor.js +1 -1
  21. package/dist/modules/dictionaries/components/DictionaryEntriesEditor.js.map +1 -1
  22. package/package.json +7 -7
  23. package/src/helpers/integration/communicationChannelsFixtures.ts +19 -0
  24. package/src/modules/communication_channels/api/post/channels/[id]/set-primary/route.ts +15 -0
  25. package/src/modules/communication_channels/api/post/test-seed/route.ts +38 -0
  26. package/src/modules/communication_channels/backend/profile/communication-channels/page.tsx +1 -1
  27. package/src/modules/communication_channels/commands/set-primary-channel.ts +7 -0
  28. package/src/modules/communication_channels/i18n/de.json +1 -0
  29. package/src/modules/communication_channels/i18n/en.json +1 -0
  30. package/src/modules/communication_channels/i18n/es.json +1 -0
  31. package/src/modules/communication_channels/i18n/ko.json +1 -0
  32. package/src/modules/communication_channels/i18n/pl.json +1 -0
  33. package/src/modules/dictionaries/api/[dictionaryId]/route.ts +9 -2
  34. package/src/modules/dictionaries/api/route.ts +9 -2
  35. package/src/modules/dictionaries/components/AppearanceSelector.tsx +4 -1
  36. package/src/modules/dictionaries/components/DictionariesManager.tsx +2 -2
  37. package/src/modules/dictionaries/components/DictionaryEntriesEditor.tsx +1 -1
  38. package/src/modules/dictionaries/i18n/de.json +1 -0
  39. package/src/modules/dictionaries/i18n/en.json +1 -0
  40. package/src/modules/dictionaries/i18n/es.json +1 -0
  41. package/src/modules/dictionaries/i18n/ko.json +1 -0
  42. package/src/modules/dictionaries/i18n/pl.json +1 -0
@@ -1,4 +1,4 @@
1
- [build:core] found 6287 entry points
1
+ [build:core] found 6291 entry points
2
2
  [build:core] built successfully
3
3
  [build:core:generated] found 271 entry points
4
4
  [build:core:generated] built successfully
@@ -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,MAC/E;AAAA,IACF;AAAA,EACF;AACF;AACA,IAAO,gBAAQ;",
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"),