@open-mercato/core 0.6.8-develop.7051.1.2e20648fd5 → 0.6.8-develop.7056.1.1a563a270d

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 (57) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/helpers/integration/communicationChannelsFixtures.js +19 -0
  3. package/dist/helpers/integration/communicationChannelsFixtures.js.map +2 -2
  4. package/dist/modules/communication_channels/api/post/channels/[id]/test-send/route.js +9 -1
  5. package/dist/modules/communication_channels/api/post/channels/[id]/test-send/route.js.map +2 -2
  6. package/dist/modules/communication_channels/api/post/test-seed/route.js +95 -10
  7. package/dist/modules/communication_channels/api/post/test-seed/route.js.map +2 -2
  8. package/dist/modules/communication_channels/commands/ingest-inbound-message.js +5 -0
  9. package/dist/modules/communication_channels/commands/ingest-inbound-message.js.map +2 -2
  10. package/dist/modules/communication_channels/di.js +7 -1
  11. package/dist/modules/communication_channels/di.js.map +2 -2
  12. package/dist/modules/communication_channels/lib/email-capabilities.js +4 -1
  13. package/dist/modules/communication_channels/lib/email-capabilities.js.map +2 -2
  14. package/dist/modules/communication_channels/lib/outbound-recipient.js +33 -0
  15. package/dist/modules/communication_channels/lib/outbound-recipient.js.map +7 -0
  16. package/dist/modules/communication_channels/lib/resolve-channel-type.js +51 -0
  17. package/dist/modules/communication_channels/lib/resolve-channel-type.js.map +7 -0
  18. package/dist/modules/communication_channels/lib/test-seed.js +41 -2
  19. package/dist/modules/communication_channels/lib/test-seed.js.map +2 -2
  20. package/dist/modules/customers/api/people/check-phone/route.js +51 -9
  21. package/dist/modules/customers/api/people/check-phone/route.js.map +2 -2
  22. package/dist/modules/customers/lib/findPeopleByAddresses.js +1 -0
  23. package/dist/modules/customers/lib/findPeopleByAddresses.js.map +2 -2
  24. package/dist/modules/messages/api/openapi.js +2 -0
  25. package/dist/modules/messages/api/openapi.js.map +2 -2
  26. package/dist/modules/messages/api/route.js +17 -2
  27. package/dist/modules/messages/api/route.js.map +2 -2
  28. package/dist/modules/messages/data/validators.js +18 -4
  29. package/dist/modules/messages/data/validators.js.map +2 -2
  30. package/dist/modules/messages/lib/channel-sender-identity.js +26 -0
  31. package/dist/modules/messages/lib/channel-sender-identity.js.map +7 -0
  32. package/dist/modules/messages/lib/composeSourceChannelType.js +41 -0
  33. package/dist/modules/messages/lib/composeSourceChannelType.js.map +7 -0
  34. package/dist/modules/warranty_claims/api/assignees/route.js +3 -3
  35. package/dist/modules/warranty_claims/api/assignees/route.js.map +2 -2
  36. package/dist/modules/warranty_claims/api/portal/claims/route.js +12 -9
  37. package/dist/modules/warranty_claims/api/portal/claims/route.js.map +2 -2
  38. package/package.json +7 -7
  39. package/src/helpers/integration/communicationChannelsFixtures.ts +69 -1
  40. package/src/modules/communication_channels/api/post/channels/[id]/test-send/route.ts +17 -1
  41. package/src/modules/communication_channels/api/post/test-seed/route.ts +155 -16
  42. package/src/modules/communication_channels/commands/ingest-inbound-message.ts +5 -0
  43. package/src/modules/communication_channels/di.ts +7 -0
  44. package/src/modules/communication_channels/lib/adapter.ts +14 -0
  45. package/src/modules/communication_channels/lib/email-capabilities.ts +4 -0
  46. package/src/modules/communication_channels/lib/outbound-recipient.ts +71 -0
  47. package/src/modules/communication_channels/lib/resolve-channel-type.ts +99 -0
  48. package/src/modules/communication_channels/lib/test-seed.ts +64 -4
  49. package/src/modules/customers/api/people/check-phone/route.ts +75 -10
  50. package/src/modules/customers/lib/findPeopleByAddresses.ts +8 -6
  51. package/src/modules/messages/api/openapi.ts +2 -0
  52. package/src/modules/messages/api/route.ts +31 -2
  53. package/src/modules/messages/data/validators.ts +43 -4
  54. package/src/modules/messages/lib/channel-sender-identity.ts +55 -0
  55. package/src/modules/messages/lib/composeSourceChannelType.ts +95 -0
  56. package/src/modules/warranty_claims/api/assignees/route.ts +3 -3
  57. package/src/modules/warranty_claims/api/portal/claims/route.ts +12 -9
@@ -1,4 +1,4 @@
1
- [build:core] found 4328 entry points
1
+ [build:core] found 4333 entry points
2
2
  [build:core] built successfully
3
3
  [build:core:generated] found 218 entry points
4
4
  [build:core:generated] built successfully
@@ -40,6 +40,24 @@ async function seedInboundMessage(request, token, input) {
40
40
  )
41
41
  };
42
42
  }
43
+ async function ingestInboundChatMessage(request, token, input) {
44
+ const response = await apiRequest(request, "POST", TEST_SEED_PATH, {
45
+ token,
46
+ data: { action: "ingest-inbound", ...input }
47
+ });
48
+ expect(
49
+ response.status(),
50
+ "POST /api/communication_channels/test-seed (ingest-inbound) should return 201 \u2014 a non-201 here means the hub rejected a sender with no email address"
51
+ ).toBe(201);
52
+ const body = await readJsonSafe(response);
53
+ return {
54
+ status: body?.status ?? "unknown",
55
+ messageId: body?.messageId ?? null,
56
+ conversationId: body?.conversationId ?? null,
57
+ channelLinkId: body?.channelLinkId ?? null,
58
+ channelType: body?.channelType ?? null
59
+ };
60
+ }
43
61
  async function deleteChannelIfExists(request, token, channelId) {
44
62
  if (!token || !channelId) return;
45
63
  await apiRequest(
@@ -51,6 +69,7 @@ async function deleteChannelIfExists(request, token, channelId) {
51
69
  }
52
70
  export {
53
71
  deleteChannelIfExists,
72
+ ingestInboundChatMessage,
54
73
  isChannelSeedingAvailable,
55
74
  seedConnectedChannel,
56
75
  seedInboundMessage
@@ -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\nconst TEST_SEED_PATH = '/api/communication_channels/test-seed';\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: { displayName?: string; externalIdentifier?: string } = {},\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 * 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 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": "AAAA,SAAS,cAAsC;AAC/C,SAAS,kBAAkB;AAC3B,SAAS,UAAU,oBAAoB;AAgBvC,MAAM,iBAAiB;AAOvB,eAAsB,0BACpB,SACA,OACkB;AAElB,QAAM,WAAW,MAAM,WAAW,SAAS,QAAQ,gBAAgB;AAAA,IACjE;AAAA,IACA,MAAM,EAAE,QAAQ,YAAY;AAAA,EAC9B,CAAC;AACD,SAAO,SAAS,OAAO,MAAM;AAC/B;AAMA,eAAsB,qBACpB,SACA,OACA,QAA+D,CAAC,GAC/C;AACjB,QAAM,WAAW,MAAM,WAAW,SAAS,QAAQ,gBAAgB;AAAA,IACjE;AAAA,IACA,MAAM,EAAE,QAAQ,mBAAmB,GAAG,MAAM;AAAA,EAC9C,CAAC;AACD;AAAA,IACE,SAAS,OAAO;AAAA,IAChB;AAAA,EACF,EAAE,KAAK,GAAG;AACV,QAAM,OAAO,MAAM,aAAqC,QAAQ;AAChE,SAAO,SAAS,MAAM,WAAW,mDAAmD;AACtF;AAgBA,eAAsB,mBACpB,SACA,OACA,OAc+E;AAC/E,QAAM,WAAW,MAAM,WAAW,SAAS,QAAQ,gBAAgB;AAAA,IACjE;AAAA,IACA,MAAM,EAAE,QAAQ,gBAAgB,GAAG,MAAM;AAAA,EAC3C,CAAC;AACD;AAAA,IACE,SAAS,OAAO;AAAA,IAChB;AAAA,EACF,EAAE,KAAK,GAAG;AACV,QAAM,OAAO,MAAM,aAIhB,QAAQ;AACX,SAAO;AAAA,IACL,eAAe,SAAS,MAAM,eAAe,oDAAoD;AAAA,IACjG,WAAW,SAAS,MAAM,WAAW,gDAAgD;AAAA,IACrF,gBAAgB;AAAA,MACd,MAAM;AAAA,MACN;AAAA,IACF;AAAA,EACF;AACF;AAMA,eAAsB,sBACpB,SACA,OACA,WACe;AACf,MAAI,CAAC,SAAS,CAAC,UAAW;AAC1B,QAAM;AAAA,IACJ;AAAA,IACA;AAAA,IACA,wCAAwC,mBAAmB,SAAS,CAAC;AAAA,IACrE,EAAE,MAAM;AAAA,EACV,EAAE,MAAM,MAAM,MAAS;AACzB;",
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\nconst TEST_SEED_PATH = '/api/communication_channels/test-seed';\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): 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 * 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 delete of a seeded channel via the owner-scoped DELETE route.\n * Safe to call with a null id in `finally`.\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\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": "AAAA,SAAS,cAAsC;AAC/C,SAAS,kBAAkB;AAC3B,SAAS,UAAU,oBAAoB;AAgBvC,MAAM,iBAAiB;AAOvB,eAAsB,0BACpB,SACA,OACkB;AAElB,QAAM,WAAW,MAAM,WAAW,SAAS,QAAQ,gBAAgB;AAAA,IACjE;AAAA,IACA,MAAM,EAAE,QAAQ,YAAY;AAAA,EAC9B,CAAC;AACD,SAAO,SAAS,OAAO,MAAM;AAC/B;AAMA,eAAsB,qBACpB,SACA,OACA,QAWI,CAAC,GACY;AACjB,QAAM,WAAW,MAAM,WAAW,SAAS,QAAQ,gBAAgB;AAAA,IACjE;AAAA,IACA,MAAM,EAAE,QAAQ,mBAAmB,GAAG,MAAM;AAAA,EAC9C,CAAC;AACD;AAAA,IACE,SAAS,OAAO;AAAA,IAChB;AAAA,EACF,EAAE,KAAK,GAAG;AACV,QAAM,OAAO,MAAM,aAAqC,QAAQ;AAChE,SAAO,SAAS,MAAM,WAAW,mDAAmD;AACtF;AAgBA,eAAsB,mBACpB,SACA,OACA,OAc+E;AAC/E,QAAM,WAAW,MAAM,WAAW,SAAS,QAAQ,gBAAgB;AAAA,IACjE;AAAA,IACA,MAAM,EAAE,QAAQ,gBAAgB,GAAG,MAAM;AAAA,EAC3C,CAAC;AACD;AAAA,IACE,SAAS,OAAO;AAAA,IAChB;AAAA,EACF,EAAE,KAAK,GAAG;AACV,QAAM,OAAO,MAAM,aAIhB,QAAQ;AACX,SAAO;AAAA,IACL,eAAe,SAAS,MAAM,eAAe,oDAAoD;AAAA,IACjG,WAAW,SAAS,MAAM,WAAW,gDAAgD;AAAA,IACrF,gBAAgB;AAAA,MACd,MAAM;AAAA,MACN;AAAA,IACF;AAAA,EACF;AACF;AAoBA,eAAsB,yBACpB,SACA,OACA,OAcC;AACD,QAAM,WAAW,MAAM,WAAW,SAAS,QAAQ,gBAAgB;AAAA,IACjE;AAAA,IACA,MAAM,EAAE,QAAQ,kBAAkB,GAAG,MAAM;AAAA,EAC7C,CAAC;AACD;AAAA,IACE,SAAS,OAAO;AAAA,IAChB;AAAA,EAEF,EAAE,KAAK,GAAG;AACV,QAAM,OAAO,MAAM,aAMhB,QAAQ;AACX,SAAO;AAAA,IACL,QAAQ,MAAM,UAAU;AAAA,IACxB,WAAW,MAAM,aAAa;AAAA,IAC9B,gBAAgB,MAAM,kBAAkB;AAAA,IACxC,eAAe,MAAM,iBAAiB;AAAA,IACtC,aAAa,MAAM,eAAe;AAAA,EACpC;AACF;AAEA,eAAsB,sBACpB,SACA,OACA,WACe;AACf,MAAI,CAAC,SAAS,CAAC,UAAW;AAC1B,QAAM;AAAA,IACJ;AAAA,IACA;AAAA,IACA,wCAAwC,mBAAmB,SAAS,CAAC;AAAA,IACrE,EAAE,MAAM;AAAA,EACV,EAAE,MAAM,MAAM,MAAS;AACzB;",
6
6
  "names": []
7
7
  }
@@ -12,6 +12,10 @@ import {
12
12
  channelOrgScopeWhere
13
13
  } from "../../../../../lib/access-control.js";
14
14
  import { refreshCredentialsIfNeeded } from "../../../../../lib/credential-refresh.js";
15
+ import {
16
+ MAX_OUTBOUND_RECIPIENT_LENGTH,
17
+ validateOutboundRecipient
18
+ } from "../../../../../lib/outbound-recipient.js";
15
19
  import { validateRouteMutationGuard } from "../../../../../lib/route-mutation-guard.js";
16
20
  const metadata = {
17
21
  path: "/communication_channels/channels/[id]/test-send",
@@ -24,7 +28,7 @@ const metadata = {
24
28
  }
25
29
  };
26
30
  const bodySchema = z.object({
27
- to: z.string().email(),
31
+ to: z.string().min(1).max(MAX_OUTBOUND_RECIPIENT_LENGTH).regex(/^[^\r\n]*$/),
28
32
  subject: z.string().min(1).max(500).optional(),
29
33
  body: z.string().max(5e4).optional()
30
34
  });
@@ -119,6 +123,10 @@ async function POST(req, context) {
119
123
  { status: 404 }
120
124
  );
121
125
  }
126
+ const recipientCheck = validateOutboundRecipient(body.to, adapter.capabilities);
127
+ if (!recipientCheck.ok) {
128
+ return NextResponse.json({ error: recipientCheck.error }, { status: 422 });
129
+ }
122
130
  let credentials = {};
123
131
  let credentialsService = null;
124
132
  try {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../../../src/modules/communication_channels/api/post/channels/%5Bid%5D/test-send/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 { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { CommunicationChannel } from '../../../../../data/entities'\nimport { getChannelAdapter } from '../../../../../lib/adapter-registry-singleton'\nimport {\n ChannelAccessDeniedError,\n assertCanManageChannel,\n channelOrgScopeWhere,\n} from '../../../../../lib/access-control'\nimport { refreshCredentialsIfNeeded } from '../../../../../lib/credential-refresh'\nimport { validateRouteMutationGuard } from '../../../../../lib/route-mutation-guard'\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/channels/[id]/test-send',\n POST: {\n // Owner self-service: a user may send a test from their OWN mailbox (gated\n // by `connect_user_channel`). Test-sending from a shared/tenant-wide channel\n // still requires `manage` \u2014 enforced per channel type by `assertCanManageChannel`.\n requireAuth: true,\n requireFeatures: ['communication_channels.connect_user_channel'],\n },\n}\n\nconst bodySchema = z.object({\n to: z.string().email(),\n subject: z.string().min(1).max(500).optional(),\n body: z.string().max(50_000).optional(),\n})\n\ntype RouteContext = {\n params: Promise<{ id: string }> | { id: string }\n}\n\ntype CredentialsServiceLike = {\n resolve: (\n integrationId: string,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<Record<string, unknown> | null>\n save?: (\n integrationId: string,\n credentials: Record<string, unknown>,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<void>\n}\n\n/**\n * Admin diagnostic \u2014 send a test message via the adapter without creating a\n * platform `Message`. Useful for verifying credentials + outbound connectivity\n * after a channel is configured.\n *\n * The result is NOT persisted to `ExternalMessage` / `MessageChannelLink` \u2014 it\n * is a one-shot probe.\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 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 container = await createRequestContainer()\n const em = (container.resolve('em') as EntityManager).fork()\n const organizationId = (auth as { orgId?: string | null }).orgId ?? null\n const dscope = { tenantId: auth.tenantId as string, organizationId }\n const channel = await findOneWithDecryption(\n em,\n CommunicationChannel,\n {\n id,\n tenantId: auth.tenantId as string,\n ...channelOrgScopeWhere(organizationId),\n deletedAt: null,\n },\n undefined,\n dscope,\n )\n if (!channel) {\n return NextResponse.json({ error: 'Channel not found' }, { status: 404 })\n }\n // Resolve credentials at the channel's OWN org, not the caller's selected org.\n // Tenant-wide channels store `organization_id = NULL` and their credentials\n // live at `organization_id = tenantId` (see connect-credential-channel.ts), so\n // the read key must be `channel.organizationId ?? tenantId` \u2014 keying on the\n // session org would resolve `{}` for a tenant-wide channel viewed from a\n // non-null org.\n const channelCredentialsOrg = channel.organizationId ?? (auth.tenantId as string)\n // Load features via RBAC service so admin bypass (`communication_channels.admin`,\n // wildcards, super-admin) is honoured. The `auth` object from\n // `getAuthFromRequest` carries identity only \u2014 feature ACLs live in the RBAC\n // service and must be loaded per request.\n let userFeatures: string[] = []\n try {\n const rbac = container.resolve('rbacService') as RbacServiceLike\n const acl = await rbac.loadAcl(auth.sub as string, {\n tenantId: auth.tenantId as string,\n organizationId,\n })\n userFeatures = acl?.isSuperAdmin ? ['*'] : Array.isArray(acl?.features) ? acl.features : []\n } catch {\n userFeatures = []\n }\n try {\n assertCanManageChannel(\n { userId: channel.userId },\n auth.sub as string,\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 if (!channel.isActive || channel.status !== 'connected') {\n return NextResponse.json(\n { error: `Channel is in status '${channel.status}' (not connected)` },\n { status: 409 },\n )\n }\n\n const guard = await validateRouteMutationGuard({\n container,\n req,\n auth,\n input: {\n resourceKind: 'communication_channels.channel',\n resourceId: channel.id,\n operation: 'custom',\n mutationPayload: body as unknown as Record<string, unknown>,\n },\n })\n if ('response' in guard) return guard.response\n\n const adapter = getChannelAdapter(channel.providerKey)\n if (!adapter) {\n return NextResponse.json(\n { error: `No adapter registered for provider '${channel.providerKey}'` },\n { status: 404 },\n )\n }\n\n // Resolve credentials + optionally refresh.\n let credentials: Record<string, unknown> = {}\n let credentialsService: CredentialsServiceLike | null = null\n try {\n credentialsService = container.resolve('integrationCredentialsService') as CredentialsServiceLike\n } catch {\n credentialsService = null\n }\n if (channel.credentialsRef && credentialsService) {\n credentials =\n (await credentialsService\n .resolve(`channel_${channel.providerKey}`, {\n tenantId: auth.tenantId as string,\n organizationId: channelCredentialsOrg,\n userId: channel.userId ?? null,\n })\n .catch(() => null)) ?? {}\n }\n const credentialScope = {\n tenantId: auth.tenantId as string,\n organizationId: channelCredentialsOrg,\n userId: channel.userId ?? null,\n }\n const refreshed = await refreshCredentialsIfNeeded(\n {\n adapter,\n channelId: channel.id,\n credentials,\n scope: credentialScope,\n },\n { credentialsService },\n )\n credentials = refreshed.credentials\n\n try {\n const converted = await adapter.convertOutbound({\n body: body.body ?? 'Test message from Open Mercato',\n bodyFormat: 'text',\n })\n const result = await adapter.sendMessage({\n content: converted.content,\n credentials,\n scope: {\n tenantId: auth.tenantId as string,\n organizationId: channelCredentialsOrg,\n },\n metadata: { to: body.to, subject: body.subject ?? 'Test send', testSend: true },\n })\n await guard.afterSuccess()\n return NextResponse.json({\n status: result.status,\n externalMessageId: result.externalMessageId,\n providerError: result.error ?? null,\n })\n } catch (err) {\n const message = err instanceof Error ? err.message : 'send failed'\n return NextResponse.json({ status: 'failed', error: message }, { status: 502 })\n }\n}\n\nexport const openApi = {\n tags: ['CommunicationChannels'],\n methods: {\n POST: {\n summary: 'Diagnostic \u2014 send a test message through the channel without persisting',\n tags: ['CommunicationChannels'],\n responses: [\n { status: 200, description: 'Test send result' },\n { status: 400, description: 'Invalid channel id' },\n { status: 401, description: 'Unauthorized' },\n { status: 403, description: 'Not allowed to manage this channel' },\n { status: 404, description: 'Channel or adapter not found' },\n { status: 409, description: 'Channel not connected' },\n { status: 422, description: 'Invalid request body' },\n { status: 502, description: 'Provider error' },\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 { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { CommunicationChannel } from '../../../../../data/entities'\nimport { getChannelAdapter } from '../../../../../lib/adapter-registry-singleton'\nimport {\n ChannelAccessDeniedError,\n assertCanManageChannel,\n channelOrgScopeWhere,\n} from '../../../../../lib/access-control'\nimport { refreshCredentialsIfNeeded } from '../../../../../lib/credential-refresh'\nimport {\n MAX_OUTBOUND_RECIPIENT_LENGTH,\n validateOutboundRecipient,\n} from '../../../../../lib/outbound-recipient'\nimport { validateRouteMutationGuard } from '../../../../../lib/route-mutation-guard'\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/channels/[id]/test-send',\n POST: {\n // Owner self-service: a user may send a test from their OWN mailbox (gated\n // by `connect_user_channel`). Test-sending from a shared/tenant-wide channel\n // still requires `manage` \u2014 enforced per channel type by `assertCanManageChannel`.\n requireAuth: true,\n requireFeatures: ['communication_channels.connect_user_channel'],\n },\n}\n\n// `to` is deliberately NOT typed as an email here. The recipient shape depends\n// on the provider (an email address for Gmail/IMAP, a channel snowflake for\n// Discord), and the adapter is only known once the channel is loaded \u2014 so the\n// schema accepts any non-empty single-line string and `validateOutboundRecipient`\n// applies the provider-appropriate rules below, defaulting to email. The CR/LF\n// rejection stays at the schema level so a header-injection attempt is refused\n// before the request reaches the channel lookup or the mutation guard.\nconst bodySchema = z.object({\n to: z.string().min(1).max(MAX_OUTBOUND_RECIPIENT_LENGTH).regex(/^[^\\r\\n]*$/),\n subject: z.string().min(1).max(500).optional(),\n body: z.string().max(50_000).optional(),\n})\n\ntype RouteContext = {\n params: Promise<{ id: string }> | { id: string }\n}\n\ntype CredentialsServiceLike = {\n resolve: (\n integrationId: string,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<Record<string, unknown> | null>\n save?: (\n integrationId: string,\n credentials: Record<string, unknown>,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<void>\n}\n\n/**\n * Admin diagnostic \u2014 send a test message via the adapter without creating a\n * platform `Message`. Useful for verifying credentials + outbound connectivity\n * after a channel is configured.\n *\n * The result is NOT persisted to `ExternalMessage` / `MessageChannelLink` \u2014 it\n * is a one-shot probe.\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 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 container = await createRequestContainer()\n const em = (container.resolve('em') as EntityManager).fork()\n const organizationId = (auth as { orgId?: string | null }).orgId ?? null\n const dscope = { tenantId: auth.tenantId as string, organizationId }\n const channel = await findOneWithDecryption(\n em,\n CommunicationChannel,\n {\n id,\n tenantId: auth.tenantId as string,\n ...channelOrgScopeWhere(organizationId),\n deletedAt: null,\n },\n undefined,\n dscope,\n )\n if (!channel) {\n return NextResponse.json({ error: 'Channel not found' }, { status: 404 })\n }\n // Resolve credentials at the channel's OWN org, not the caller's selected org.\n // Tenant-wide channels store `organization_id = NULL` and their credentials\n // live at `organization_id = tenantId` (see connect-credential-channel.ts), so\n // the read key must be `channel.organizationId ?? tenantId` \u2014 keying on the\n // session org would resolve `{}` for a tenant-wide channel viewed from a\n // non-null org.\n const channelCredentialsOrg = channel.organizationId ?? (auth.tenantId as string)\n // Load features via RBAC service so admin bypass (`communication_channels.admin`,\n // wildcards, super-admin) is honoured. The `auth` object from\n // `getAuthFromRequest` carries identity only \u2014 feature ACLs live in the RBAC\n // service and must be loaded per request.\n let userFeatures: string[] = []\n try {\n const rbac = container.resolve('rbacService') as RbacServiceLike\n const acl = await rbac.loadAcl(auth.sub as string, {\n tenantId: auth.tenantId as string,\n organizationId,\n })\n userFeatures = acl?.isSuperAdmin ? ['*'] : Array.isArray(acl?.features) ? acl.features : []\n } catch {\n userFeatures = []\n }\n try {\n assertCanManageChannel(\n { userId: channel.userId },\n auth.sub as string,\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 if (!channel.isActive || channel.status !== 'connected') {\n return NextResponse.json(\n { error: `Channel is in status '${channel.status}' (not connected)` },\n { status: 409 },\n )\n }\n\n const guard = await validateRouteMutationGuard({\n container,\n req,\n auth,\n input: {\n resourceKind: 'communication_channels.channel',\n resourceId: channel.id,\n operation: 'custom',\n mutationPayload: body as unknown as Record<string, unknown>,\n },\n })\n if ('response' in guard) return guard.response\n\n const adapter = getChannelAdapter(channel.providerKey)\n if (!adapter) {\n return NextResponse.json(\n { error: `No adapter registered for provider '${channel.providerKey}'` },\n { status: 404 },\n )\n }\n\n const recipientCheck = validateOutboundRecipient(body.to, adapter.capabilities)\n if (!recipientCheck.ok) {\n return NextResponse.json({ error: recipientCheck.error }, { status: 422 })\n }\n\n // Resolve credentials + optionally refresh.\n let credentials: Record<string, unknown> = {}\n let credentialsService: CredentialsServiceLike | null = null\n try {\n credentialsService = container.resolve('integrationCredentialsService') as CredentialsServiceLike\n } catch {\n credentialsService = null\n }\n if (channel.credentialsRef && credentialsService) {\n credentials =\n (await credentialsService\n .resolve(`channel_${channel.providerKey}`, {\n tenantId: auth.tenantId as string,\n organizationId: channelCredentialsOrg,\n userId: channel.userId ?? null,\n })\n .catch(() => null)) ?? {}\n }\n const credentialScope = {\n tenantId: auth.tenantId as string,\n organizationId: channelCredentialsOrg,\n userId: channel.userId ?? null,\n }\n const refreshed = await refreshCredentialsIfNeeded(\n {\n adapter,\n channelId: channel.id,\n credentials,\n scope: credentialScope,\n },\n { credentialsService },\n )\n credentials = refreshed.credentials\n\n try {\n const converted = await adapter.convertOutbound({\n body: body.body ?? 'Test message from Open Mercato',\n bodyFormat: 'text',\n })\n const result = await adapter.sendMessage({\n content: converted.content,\n credentials,\n scope: {\n tenantId: auth.tenantId as string,\n organizationId: channelCredentialsOrg,\n },\n metadata: { to: body.to, subject: body.subject ?? 'Test send', testSend: true },\n })\n await guard.afterSuccess()\n return NextResponse.json({\n status: result.status,\n externalMessageId: result.externalMessageId,\n providerError: result.error ?? null,\n })\n } catch (err) {\n const message = err instanceof Error ? err.message : 'send failed'\n return NextResponse.json({ status: 'failed', error: message }, { status: 502 })\n }\n}\n\nexport const openApi = {\n tags: ['CommunicationChannels'],\n methods: {\n POST: {\n summary: 'Diagnostic \u2014 send a test message through the channel without persisting',\n tags: ['CommunicationChannels'],\n responses: [\n { status: 200, description: 'Test send result' },\n { status: 400, description: 'Invalid channel id' },\n { status: 401, description: 'Unauthorized' },\n { status: 403, description: 'Not allowed to manage this channel' },\n { status: 404, description: 'Channel or adapter not found' },\n { status: 409, description: 'Channel not connected' },\n { status: 422, description: 'Invalid request body' },\n { status: 502, description: 'Provider error' },\n ],\n },\n },\n}\nexport default POST\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -16,10 +16,14 @@ import {
16
16
  } from "../../../commands/connect-credential-channel.js";
17
17
  import { emitCommunicationChannelsEvent } from "../../../events.js";
18
18
  import {
19
+ TEST_SEED_CHAT_PROVIDER_KEY,
19
20
  TEST_SEED_PROVIDER_KEY,
20
21
  ensureTestSeedAdapterRegistered,
21
22
  isTestChannelSeedingEnabled
22
23
  } from "../../../lib/test-seed.js";
24
+ import {
25
+ COMMUNICATION_CHANNELS_INGEST_INBOUND_COMMAND_ID
26
+ } from "../../../commands/ingest-inbound-message.js";
23
27
  const metadata = {
24
28
  path: "/communication_channels/test-seed",
25
29
  POST: {
@@ -36,7 +40,24 @@ const addressFieldSchema = z.union([
36
40
  const connectChannelSchema = z.object({
37
41
  action: z.literal("connect-channel"),
38
42
  displayName: z.string().min(1).max(255).optional(),
39
- externalIdentifier: z.string().min(1).max(255).optional()
43
+ externalIdentifier: z.string().min(1).max(255).optional(),
44
+ /**
45
+ * Which stub provider to connect. `email` (default) keeps the historical
46
+ * email-shaped channel; `chat` connects a channel whose senders have no
47
+ * address, so a test can exercise the hub's non-email identity contract
48
+ * without inventing one (#4975).
49
+ */
50
+ providerFlavor: z.enum(["email", "chat"]).optional()
51
+ });
52
+ const ingestInboundSchema = z.object({
53
+ action: z.literal("ingest-inbound"),
54
+ channelId: z.string().uuid(),
55
+ /** Opaque sender handle — a Discord snowflake, a Slack member id, etc. */
56
+ senderIdentifier: z.string().min(1).max(255),
57
+ senderDisplayName: z.string().max(255).optional(),
58
+ body: z.string().max(5e4).optional(),
59
+ externalMessageId: z.string().min(1).max(255),
60
+ externalConversationId: z.string().min(1).max(255)
40
61
  });
41
62
  const emitInboundSchema = z.object({
42
63
  action: z.literal("emit-inbound"),
@@ -70,7 +91,11 @@ const emitInboundSchema = z.object({
70
91
  */
71
92
  createThreadMapping: z.boolean().optional()
72
93
  });
73
- const bodySchema = z.discriminatedUnion("action", [connectChannelSchema, emitInboundSchema]);
94
+ const bodySchema = z.discriminatedUnion("action", [
95
+ connectChannelSchema,
96
+ ingestInboundSchema,
97
+ emitInboundSchema
98
+ ]);
74
99
  async function POST(req) {
75
100
  if (!isTestChannelSeedingEnabled()) {
76
101
  return NextResponse.json({ error: "Not found" }, { status: 404 });
@@ -96,13 +121,15 @@ async function POST(req) {
96
121
  if (body.action === "connect-channel") {
97
122
  const stamp = Date.now();
98
123
  const commandBus = container.resolve("commandBus");
124
+ const isChatFlavor = body.providerFlavor === "chat";
125
+ const credentials = isChatFlavor ? { handle: body.externalIdentifier ?? `test-seed-chat-${stamp}` } : {
126
+ username: body.externalIdentifier ?? `test-seed-${stamp}@test-seed.local`,
127
+ fromAddress: body.externalIdentifier ?? `test-seed-${stamp}@test-seed.local`
128
+ };
99
129
  const input = {
100
- providerKey: TEST_SEED_PROVIDER_KEY,
101
- displayName: body.displayName ?? `Test Seed Channel ${stamp}`,
102
- credentials: {
103
- username: body.externalIdentifier ?? `test-seed-${stamp}@test-seed.local`,
104
- fromAddress: body.externalIdentifier ?? `test-seed-${stamp}@test-seed.local`
105
- },
130
+ providerKey: isChatFlavor ? TEST_SEED_CHAT_PROVIDER_KEY : TEST_SEED_PROVIDER_KEY,
131
+ displayName: body.displayName ?? `Test Seed ${isChatFlavor ? "Chat " : ""}Channel ${stamp}`,
132
+ credentials,
106
133
  userId,
107
134
  scope: { tenantId, organizationId }
108
135
  };
@@ -128,7 +155,7 @@ async function POST(req) {
128
155
  );
129
156
  }
130
157
  const em = container.resolve("em").fork();
131
- const providerKey = body.providerKey ?? TEST_SEED_PROVIDER_KEY;
158
+ const providerKey = body.action === "emit-inbound" ? body.providerKey ?? TEST_SEED_PROVIDER_KEY : TEST_SEED_PROVIDER_KEY;
132
159
  const ownedChannel = await findOneWithDecryption(
133
160
  em,
134
161
  CommunicationChannel,
@@ -169,6 +196,64 @@ async function POST(req) {
169
196
  { status }
170
197
  );
171
198
  }
199
+ if (body.action === "ingest-inbound") {
200
+ const channelProviderKey = ownedChannel.providerKey;
201
+ if (channelProviderKey !== TEST_SEED_CHAT_PROVIDER_KEY) {
202
+ return NextResponse.json(
203
+ {
204
+ error: `ingest-inbound requires a channel connected with providerFlavor: "chat"; channel ${body.channelId} is '${channelProviderKey}'`
205
+ },
206
+ { status: 422 }
207
+ );
208
+ }
209
+ const commandBus = container.resolve("commandBus");
210
+ const adapterRegistry = container.resolve("channelAdapterRegistry");
211
+ const adapter = adapterRegistry.get(channelProviderKey);
212
+ if (!adapter) {
213
+ return NextResponse.json(
214
+ { error: "[internal] test-seed chat adapter is not registered" },
215
+ { status: 500 }
216
+ );
217
+ }
218
+ const normalized = await adapter.normalizeInbound({
219
+ raw: {
220
+ externalMessageId: body.externalMessageId,
221
+ externalConversationId: body.externalConversationId,
222
+ senderIdentifier: body.senderIdentifier,
223
+ senderDisplayName: body.senderDisplayName,
224
+ body: body.body ?? ""
225
+ },
226
+ eventType: "message",
227
+ metadata: {}
228
+ });
229
+ const ingestInput = {
230
+ channelId: body.channelId,
231
+ providerKey: channelProviderKey,
232
+ channelType: ownedChannel.channelType,
233
+ scope: { tenantId, organizationId },
234
+ message: normalized
235
+ };
236
+ const { result } = await commandBus.execute(COMMUNICATION_CHANNELS_INGEST_INBOUND_COMMAND_ID, {
237
+ input: ingestInput,
238
+ ctx: {
239
+ container,
240
+ auth,
241
+ organizationScope: null,
242
+ selectedOrganizationId: organizationId,
243
+ organizationIds: organizationId ? [organizationId] : null
244
+ }
245
+ });
246
+ return NextResponse.json(
247
+ {
248
+ status: result.status,
249
+ messageId: result.messageId ?? null,
250
+ conversationId: result.externalConversationId ?? null,
251
+ channelLinkId: result.channelLinkId ?? null,
252
+ channelType: ownedChannel.channelType
253
+ },
254
+ { status: 201 }
255
+ );
256
+ }
172
257
  const conversation = em.create(ExternalConversation, {
173
258
  channelId: body.channelId,
174
259
  externalConversationId: `inbound-seed:${Date.now()}:${Math.random().toString(16).slice(2, 8)}`,
@@ -263,7 +348,7 @@ const openApi = {
263
348
  tags: ["CommunicationChannels"],
264
349
  methods: {
265
350
  POST: {
266
- summary: "Test-only: seed a connected channel or emit an inbound message (env-gated)",
351
+ summary: "Test-only: seed a connected channel, ingest a real inbound message, or emit a seeded inbound link (env-gated)",
267
352
  tags: ["CommunicationChannels"],
268
353
  responses: [
269
354
  { status: 201, description: "Channel seeded / inbound message emitted" },
@@ -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 {\n ChannelThreadMapping,\n CommunicationChannel,\n ExternalConversation,\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_PROVIDER_KEY,\n ensureTestSeedAdapterRegistered,\n isTestChannelSeedingEnabled,\n} from '../../../lib/test-seed'\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 * Two actions, both scoped to the caller's tenant/org:\n * - `connect-channel`: connect a network-free `__test_seed__` channel owned by\n * the caller (delegates to the real connect-credential command so the channel\n * persists credentials + lands in `status='connected'`). Enables the outbound\n * compose \u2192 deliver \u2192 `.sent` chain to complete in CI.\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).\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\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\nconst bodySchema = z.discriminatedUnion('action', [connectChannelSchema, emitInboundSchema])\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 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 const tenantId = auth.tenantId as string\n const organizationId = (auth as { orgId?: string | null }).orgId ?? null\n const userId = auth.sub as string\n\n if (body.action === 'connect-channel') {\n const stamp = Date.now()\n const commandBus = container.resolve('commandBus') as CommandBus\n const input: ConnectCredentialChannelInput = {\n providerKey: TEST_SEED_PROVIDER_KEY,\n displayName: body.displayName ?? `Test Seed Channel ${stamp}`,\n credentials: {\n username: body.externalIdentifier ?? `test-seed-${stamp}@test-seed.local`,\n fromAddress: body.externalIdentifier ?? `test-seed-${stamp}@test-seed.local`,\n },\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 return NextResponse.json(\n { channelId: result.channelId, externalIdentifier: result.externalIdentifier },\n { status: 201 },\n )\n }\n\n // action === 'emit-inbound'\n const em = (container.resolve('em') as EntityManager).fork()\n const providerKey = body.providerKey ?? 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 // 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: `inbound-seed:${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 // Insert the platform `messages.message` row via raw SQL rather than importing\n // the messages module's entity class (cross-module ORM coupling rule). Only\n // `thread_id` matters for the hub-thread inheritance join (TC-CRM-EMAIL-005);\n // the rest satisfy NOT NULL constraints.\n const messageRows = (await em.getConnection().execute(\n `INSERT INTO messages\n (type, thread_id, sender_user_id, subject, body, body_format, priority, status,\n is_draft, sent_at, visibility, source_entity_type, source_entity_id,\n tenant_id, organization_id, created_at, updated_at)\n VALUES\n (?, ?, ?, ?, ?, 'text', 'normal', 'sent',\n false, now(), 'public', 'communication_channels.test_seed_inbound', ?,\n ?, ?, now(), now())\n RETURNING id`,\n [\n `channel.${providerKey}`,\n body.messageThreadId ?? null,\n userId,\n body.subject ?? '(no subject)',\n body.bodyText ?? '',\n body.channelId,\n tenantId,\n organizationId,\n ],\n )) as Array<{ id: string }>\n const messageId = messageRows[0]?.id\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: 'Test-only: seed a connected channel or emit an inbound message (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 {\n ChannelThreadMapping,\n CommunicationChannel,\n ExternalConversation,\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 ensureTestSeedAdapterRegistered,\n isTestChannelSeedingEnabled,\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 * Three 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.\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 */\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\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 /** Opaque sender handle \u2014 a Discord snowflake, a Slack member id, etc. */\n senderIdentifier: z.string().min(1).max(255),\n senderDisplayName: z.string().max(255).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 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\nconst bodySchema = z.discriminatedUnion('action', [\n connectChannelSchema,\n ingestInboundSchema,\n emitInboundSchema,\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 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 const tenantId = auth.tenantId as string\n const organizationId = (auth as { orgId?: string | null }).orgId ?? null\n const userId = auth.sub as string\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 return NextResponse.json(\n { channelId: result.channelId, externalIdentifier: result.externalIdentifier },\n { status: 201 },\n )\n }\n\n // action === 'ingest-inbound' | 'emit-inbound' \u2014 both 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 === '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 (channelProviderKey !== TEST_SEED_CHAT_PROVIDER_KEY) {\n return NextResponse.json(\n {\n error:\n 'ingest-inbound requires a channel connected with providerFlavor: \"chat\"; ' +\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 const normalized = await adapter.normalizeInbound({\n raw: {\n externalMessageId: body.externalMessageId,\n externalConversationId: body.externalConversationId,\n senderIdentifier: body.senderIdentifier,\n senderDisplayName: body.senderDisplayName,\n body: body.body ?? '',\n },\n eventType: 'message',\n metadata: {},\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 const { result } = 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\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: `inbound-seed:${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 // Insert the platform `messages.message` row via raw SQL rather than importing\n // the messages module's entity class (cross-module ORM coupling rule). Only\n // `thread_id` matters for the hub-thread inheritance join (TC-CRM-EMAIL-005);\n // the rest satisfy NOT NULL constraints.\n const messageRows = (await em.getConnection().execute(\n `INSERT INTO messages\n (type, thread_id, sender_user_id, subject, body, body_format, priority, status,\n is_draft, sent_at, visibility, source_entity_type, source_entity_id,\n tenant_id, organization_id, created_at, updated_at)\n VALUES\n (?, ?, ?, ?, ?, 'text', 'normal', 'sent',\n false, now(), 'public', 'communication_channels.test_seed_inbound', ?,\n ?, ?, now(), now())\n RETURNING id`,\n [\n `channel.${providerKey}`,\n body.messageThreadId ?? null,\n userId,\n body.subject ?? '(no subject)',\n body.bodyText ?? '',\n body.channelId,\n tenantId,\n organizationId,\n ],\n )) as Array<{ id: string }>\n const messageId = messageRows[0]?.id\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, or emit a seeded inbound link (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": []
7
7
  }
@@ -235,6 +235,11 @@ const ingestInboundMessageCommand = {
235
235
  sourceEntityId: conversation.id,
236
236
  externalEmail: contactHint?.email ?? void 0,
237
237
  externalName: contactHint?.displayName ?? m.senderDisplayName,
238
+ // #4975: tells the hub which identity contract applies to this sender.
239
+ // Email-typed channels keep the mandatory `externalEmail`; providers whose
240
+ // senders have no address (Discord, Slack, SMS…) are validated without it.
241
+ // The messages validator fails closed on any type it does not recognize.
242
+ sourceChannelType: input.channelType,
238
243
  recipients: mapping?.assignedUserId ? [{ userId: mapping.assignedUserId, type: "to" }] : [],
239
244
  subject: safeSubject,
240
245
  body: truncatedBody,