@open-mercato/core 0.8.1-develop.7221.1.ece7ca874e → 0.8.1-develop.7227.1.9248c14599

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 (102) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.js +166 -0
  3. package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.js.map +7 -0
  4. package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.meta.js +16 -0
  5. package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.meta.js.map +7 -0
  6. package/dist/modules/catalog/backend/catalog/prices/create/page.js +68 -0
  7. package/dist/modules/catalog/backend/catalog/prices/create/page.js.map +7 -0
  8. package/dist/modules/catalog/backend/catalog/prices/create/page.meta.js +25 -0
  9. package/dist/modules/catalog/backend/catalog/prices/create/page.meta.js.map +7 -0
  10. package/dist/modules/catalog/backend/catalog/prices/page.js +11 -0
  11. package/dist/modules/catalog/backend/catalog/prices/page.js.map +7 -0
  12. package/dist/modules/catalog/backend/catalog/prices/page.meta.js +16 -0
  13. package/dist/modules/catalog/backend/catalog/prices/page.meta.js.map +7 -0
  14. package/dist/modules/catalog/components/prices/PriceScopeSelectors.js +201 -0
  15. package/dist/modules/catalog/components/prices/PriceScopeSelectors.js.map +7 -0
  16. package/dist/modules/catalog/components/prices/PricesDataTable.js +273 -0
  17. package/dist/modules/catalog/components/prices/PricesDataTable.js.map +7 -0
  18. package/dist/modules/catalog/components/prices/normalizePriceRecord.js +43 -0
  19. package/dist/modules/catalog/components/prices/normalizePriceRecord.js.map +7 -0
  20. package/dist/modules/catalog/components/prices/priceFormFields.js +195 -0
  21. package/dist/modules/catalog/components/prices/priceFormFields.js.map +7 -0
  22. package/dist/modules/catalog/components/prices/useBatchLabels.js +42 -0
  23. package/dist/modules/catalog/components/prices/useBatchLabels.js.map +7 -0
  24. package/dist/modules/catalog/data/validators.js +21 -2
  25. package/dist/modules/catalog/data/validators.js.map +2 -2
  26. package/dist/modules/catalog/extension-points.js +2 -1
  27. package/dist/modules/catalog/extension-points.js.map +2 -2
  28. package/dist/modules/catalog/lib/pricing.js +37 -8
  29. package/dist/modules/catalog/lib/pricing.js.map +2 -2
  30. package/dist/modules/communication_channels/api/post/channels/[id]/import-history/route.js +5 -1
  31. package/dist/modules/communication_channels/api/post/channels/[id]/import-history/route.js.map +2 -2
  32. package/dist/modules/communication_channels/backend/profile/communication-channels/ConnectChannelMenu.js +88 -0
  33. package/dist/modules/communication_channels/backend/profile/communication-channels/ConnectChannelMenu.js.map +7 -0
  34. package/dist/modules/communication_channels/backend/profile/communication-channels/page.js +17 -19
  35. package/dist/modules/communication_channels/backend/profile/communication-channels/page.js.map +2 -2
  36. package/dist/modules/communication_channels/commands/connect-credential-channel.js +7 -2
  37. package/dist/modules/communication_channels/commands/connect-credential-channel.js.map +2 -2
  38. package/dist/modules/communication_channels/commands/ingest-inbound-message.js +11 -2
  39. package/dist/modules/communication_channels/commands/ingest-inbound-message.js.map +2 -2
  40. package/dist/modules/communication_channels/commands/queue-import-history.js +40 -7
  41. package/dist/modules/communication_channels/commands/queue-import-history.js.map +2 -2
  42. package/dist/modules/communication_channels/extension-points.js +1 -1
  43. package/dist/modules/communication_channels/extension-points.js.map +1 -1
  44. package/dist/modules/communication_channels/lib/connect-channel.js +33 -0
  45. package/dist/modules/communication_channels/lib/connect-channel.js.map +2 -2
  46. package/dist/modules/communication_channels/lib/import-history-limits.js +69 -0
  47. package/dist/modules/communication_channels/lib/import-history-limits.js.map +7 -0
  48. package/dist/modules/communication_channels/workers/channel-import-history.js +36 -2
  49. package/dist/modules/communication_channels/workers/channel-import-history.js.map +2 -2
  50. package/dist/modules/messages/api/route.js +5 -1
  51. package/dist/modules/messages/api/route.js.map +2 -2
  52. package/dist/modules/messages/commands/messages.js +3 -2
  53. package/dist/modules/messages/commands/messages.js.map +2 -2
  54. package/dist/modules/messages/data/validators.js +12 -2
  55. package/dist/modules/messages/data/validators.js.map +2 -2
  56. package/dist/modules/messages/subscribers/message-notification.js +4 -1
  57. package/dist/modules/messages/subscribers/message-notification.js.map +2 -2
  58. package/dist/modules/query_index/lib/engine.js +19 -1
  59. package/dist/modules/query_index/lib/engine.js.map +2 -2
  60. package/package.json +7 -7
  61. package/src/modules/catalog/AGENTS.md +6 -2
  62. package/src/modules/catalog/backend/catalog/prices/[id]/edit/page.meta.ts +12 -0
  63. package/src/modules/catalog/backend/catalog/prices/[id]/edit/page.tsx +198 -0
  64. package/src/modules/catalog/backend/catalog/prices/create/page.meta.ts +23 -0
  65. package/src/modules/catalog/backend/catalog/prices/create/page.tsx +70 -0
  66. package/src/modules/catalog/backend/catalog/prices/page.meta.ts +12 -0
  67. package/src/modules/catalog/backend/catalog/prices/page.tsx +14 -0
  68. package/src/modules/catalog/components/prices/PriceScopeSelectors.tsx +243 -0
  69. package/src/modules/catalog/components/prices/PricesDataTable.tsx +320 -0
  70. package/src/modules/catalog/components/prices/normalizePriceRecord.ts +71 -0
  71. package/src/modules/catalog/components/prices/priceFormFields.tsx +228 -0
  72. package/src/modules/catalog/components/prices/useBatchLabels.ts +57 -0
  73. package/src/modules/catalog/data/validators.ts +43 -2
  74. package/src/modules/catalog/extension-points.ts +1 -0
  75. package/src/modules/catalog/i18n/de.json +72 -0
  76. package/src/modules/catalog/i18n/en.json +72 -0
  77. package/src/modules/catalog/i18n/es.json +72 -0
  78. package/src/modules/catalog/i18n/ko.json +72 -0
  79. package/src/modules/catalog/i18n/pl.json +72 -0
  80. package/src/modules/catalog/lib/pricing.ts +103 -9
  81. package/src/modules/communication_channels/api/post/channels/[id]/import-history/route.ts +6 -1
  82. package/src/modules/communication_channels/backend/profile/communication-channels/ConnectChannelMenu.tsx +120 -0
  83. package/src/modules/communication_channels/backend/profile/communication-channels/page.tsx +20 -17
  84. package/src/modules/communication_channels/commands/connect-credential-channel.ts +9 -0
  85. package/src/modules/communication_channels/commands/ingest-inbound-message.ts +17 -2
  86. package/src/modules/communication_channels/commands/queue-import-history.ts +59 -11
  87. package/src/modules/communication_channels/extension-points.ts +1 -1
  88. package/src/modules/communication_channels/i18n/de.json +4 -3
  89. package/src/modules/communication_channels/i18n/en.json +4 -3
  90. package/src/modules/communication_channels/i18n/es.json +4 -3
  91. package/src/modules/communication_channels/i18n/ko.json +4 -3
  92. package/src/modules/communication_channels/i18n/pl.json +4 -3
  93. package/src/modules/communication_channels/lib/adapter.ts +16 -2
  94. package/src/modules/communication_channels/lib/connect-channel.ts +49 -0
  95. package/src/modules/communication_channels/lib/import-history-limits.ts +109 -0
  96. package/src/modules/communication_channels/workers/channel-import-history.ts +46 -5
  97. package/src/modules/messages/api/route.ts +8 -2
  98. package/src/modules/messages/commands/messages.ts +17 -2
  99. package/src/modules/messages/data/validators.ts +26 -11
  100. package/src/modules/messages/subscribers/message-notification.ts +17 -5
  101. package/src/modules/query_index/lib/engine.ts +23 -1
  102. package/src/modules/sales/AGENTS.md +2 -3
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/communication_channels/commands/ingest-inbound-message.ts"],
4
- "sourcesContent": ["import { randomUUID } from 'node:crypto'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandBus, CommandHandler, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { registerCommand } from '@open-mercato/shared/lib/commands'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { htmlToPlainText } from '@open-mercato/shared/lib/html/htmlToPlainText'\nimport { emitCommunicationChannelsEvent } from '../events'\nimport { resolveContact } from '../lib/contact-resolver'\nimport type { ChannelAdapterRegistry } from '../lib/registry'\nimport type { NormalizedInboundMessage } from '../lib/adapter'\nimport { matchThread, type ThreadMatch } from '../lib/thread-matcher'\nimport {\n ChannelThreadMapping,\n CommunicationChannel,\n ExternalConversation,\n ExternalMessage,\n MessageChannelLink,\n} from '../data/entities'\nimport { normalizedInboundMessageSchema } from '../data/validators'\nimport { resolveCommunicationChannelsSystemUserId } from '../lib/system-user'\nimport { isUniqueViolation } from '../lib/pg-errors'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('communication_channels').child({ component: 'ingest-inbound-message' })\n\nconst ingestInputSchema = z.object({\n channelId: z.string().uuid(),\n providerKey: z.string().min(1),\n channelType: z.string().min(1),\n scope: z.object({\n tenantId: z.string().uuid(),\n organizationId: z.string().uuid().nullable(),\n }),\n message: normalizedInboundMessageSchema,\n})\n\nexport type IngestInboundMessageInput = z.infer<typeof ingestInputSchema>\n\nexport type IngestInboundMessageResult = {\n status: 'created' | 'duplicate'\n messageId?: string\n externalConversationId?: string\n externalMessageId?: string\n channelLinkId?: string\n threadMappingId?: string\n contactPersonId?: string | null\n}\n\nexport const COMMUNICATION_CHANNELS_INGEST_INBOUND_COMMAND_ID = 'communication_channels.message.ingest_inbound'\n\n/**\n * Idempotently ingest a normalized inbound channel message.\n *\n * Steps (per SPEC-045d \u00A76):\n * 1. Dedup by `(channel_id, external_message_id)` \u2014 if a MessageChannelLink already\n * exists for that pair, return `{ status: 'duplicate' }` without side effects.\n * 2. Create or load `ExternalConversation` by `(channel_id, external_conversation_id)`.\n * 3. Create or load `ChannelThreadMapping` (1:1 with ExternalConversation).\n * 4. Resolve CRM contact via adapter + QueryEngine (best-effort).\n * 5. Compose the platform `Message` via `messages.messages.compose` (separate transaction).\n * 6. Create `ExternalMessage` + `MessageChannelLink`.\n * 7. Emit `communication_channels.message.received` (and `.conversation.created` / `.contact.resolved` when applicable).\n *\n * The two-transaction model (compose-message-then-record-link) is acceptable for v1;\n * the link's unique-on-message-id constraint is the safety net against orphans. See\n * the pre-implementation analysis for a discussion of single-transaction alternatives.\n */\nconst ingestInboundMessageCommand: CommandHandler<IngestInboundMessageInput, IngestInboundMessageResult> = {\n id: COMMUNICATION_CHANNELS_INGEST_INBOUND_COMMAND_ID,\n async execute(rawInput, ctx) {\n const input = ingestInputSchema.parse(rawInput) as IngestInboundMessageInput\n\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n const adapterRegistry = ctx.container.resolve('channelAdapterRegistry') as ChannelAdapterRegistry\n const dscope = {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n }\n\n // (1) Dedup: short-circuit if we've already processed this provider message.\n // The unique constraint is on `messageId`, not (channel, externalMessageId).\n // We must dedup by joining against ExternalMessage which IS uniquely indexed by\n // (channel_id, external_message_id). Hub-side dedup is the authoritative gate.\n const existingExternal = await findOneWithDecryption(\n em,\n ExternalMessage,\n {\n channelId: input.channelId,\n externalMessageId: input.message.externalMessageId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n if (existingExternal) {\n return {\n status: 'duplicate',\n externalConversationId: existingExternal.conversationId,\n externalMessageId: existingExternal.id,\n }\n }\n\n // (1b) Spec B \u00A7 Sent-folder dedup.\n //\n // When an outbound message lands in the user's IMAP Sent folder (or\n // when Gmail's \"send and archive\" deposits it in All Mail), the next\n // poll will re-fetch it from INBOX as if it were inbound. Skip it\n // here using the RFC 5322 `Message-ID` header \u2014 we recorded it on the\n // outbound `MessageChannelLink.channelMetadata.messageId` at send time.\n //\n // We dedup ONLY on outbound links (direction='outbound') for the same\n // channel \u2014 that way an inbound copy of someone ELSE's email that\n // happens to share a Message-ID is still ingested normally.\n const incomingMessageId = (() => {\n const fromMeta = (input.message.channelMetadata as Record<string, unknown> | undefined)?.messageId\n if (typeof fromMeta === 'string' && fromMeta.length > 0) return fromMeta\n return null\n })()\n if (incomingMessageId) {\n // MikroORM v7 dropped the Knex builder in favour of Kysely/raw SQL.\n // We use a positional-placeholder raw query for the JSONB\n // `channel_metadata->>messageId` comparison.\n try {\n const sentFolderHit = await em.getConnection().execute<Array<{ id: string }>>(\n `SELECT link.id FROM message_channel_links AS link\n INNER JOIN external_conversations AS conv\n ON conv.id = link.external_conversation_id\n WHERE link.tenant_id = ?\n AND ((?::uuid IS NULL AND link.organization_id IS NULL) OR link.organization_id = ?::uuid)\n AND conv.tenant_id = ?\n AND ((?::uuid IS NULL AND conv.organization_id IS NULL) OR conv.organization_id = ?::uuid)\n AND conv.channel_id = ?\n AND link.direction = 'outbound'\n AND link.channel_metadata->>'messageId' = ?\n LIMIT 1`,\n [\n input.scope.tenantId,\n input.scope.organizationId ?? null,\n input.scope.organizationId ?? null,\n input.scope.tenantId,\n input.scope.organizationId ?? null,\n input.scope.organizationId ?? null,\n input.channelId,\n incomingMessageId,\n ],\n )\n if (Array.isArray(sentFolderHit) && sentFolderHit.length > 0) {\n return {\n status: 'duplicate',\n externalConversationId: input.message.externalConversationId,\n externalMessageId: input.message.externalMessageId,\n }\n }\n } catch (dedupErr) {\n // Sent-folder dedup is best-effort \u2014 a failure here must not abort\n // ingest (better a possible duplicate than a lost inbound message).\n logger.warn('sent-folder dedup query failed, continuing', { err: dedupErr })\n }\n }\n\n // Channel + adapter lookup (the channel must exist + be active).\n const channel = await findOneWithDecryption(\n em,\n CommunicationChannel,\n {\n id: input.channelId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n deletedAt: null,\n },\n undefined,\n dscope,\n )\n if (!channel) {\n throw new Error(\n `[internal] Channel ${input.channelId} not found for tenant ${input.scope.tenantId} (or has been deleted)`,\n )\n }\n if (!channel.isActive) {\n throw new Error(`[internal] Channel ${input.channelId} is inactive; refusing to ingest`)\n }\n\n const adapter = adapterRegistry.get(input.providerKey)\n if (!adapter) {\n throw new Error(\n `[internal] No ChannelAdapter registered for providerKey '${input.providerKey}'. ` +\n 'Check that the provider package is enabled in modules.ts.',\n )\n }\n\n // (2) ExternalConversation upsert by (channel_id, externalConversationId).\n const m = input.message\n let conversation = await findOneWithDecryption(\n em,\n ExternalConversation,\n {\n channelId: input.channelId,\n externalConversationId: m.externalConversationId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n let conversationCreated = false\n if (!conversation) {\n conversation = em.create(ExternalConversation, {\n channelId: input.channelId,\n externalConversationId: m.externalConversationId,\n subject: m.subject ?? null,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n lastMessageAt: m.timestamp ?? new Date(),\n })\n em.persist(conversation)\n conversationCreated = true\n }\n\n // (3) ChannelThreadMapping upsert (1:1 with ExternalConversation per tenant).\n let mapping = await findOneWithDecryption(\n em,\n ChannelThreadMapping,\n {\n externalConversationId: conversation.id,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n\n // Last-activity bump on an existing conversation. Applied AFTER the mapping\n // lookup, immediately before the flush, so the scalar mutation and its flush\n // stay adjacent with no query in between (core flush-ordering rule \u2014 a query\n // between a scalar mutation and `em.flush()` can drop the change under some\n // flush modes / subscriber configurations).\n if (\n !conversationCreated &&\n m.timestamp &&\n (!conversation.lastMessageAt || m.timestamp > conversation.lastMessageAt)\n ) {\n conversation.lastMessageAt = m.timestamp\n }\n // We'll fill `messageThreadId` after composing the platform Message (since the\n // first inbound message becomes the thread root in the messages module).\n await em.flush()\n\n // (3b) Spec B \u2014 layered thread match.\n //\n // Resolve the inbound message to an existing platform thread using\n // (in priority order):\n // 1. Crypto token in References / In-Reply-To header (high confidence)\n // 2. Crypto token in body hidden span or plain-text marker (high)\n // 3. JWZ on Message-Id \u2194 stored `MessageChannelLink.channelMetadata.messageId` (medium)\n // 4. Subject + participants in last 30 days, same channel (low)\n //\n // The matcher returns `null` when nothing hits \u2014 in that case we fall\n // back to the existing `ChannelThreadMapping`-by-conversation-id lookup\n // (which also returns null on first-ever inbound, in which case the\n // compose command opens a new thread).\n const metaForMatcher = (m.channelMetadata ?? {}) as Record<string, unknown>\n let threadMatch: ThreadMatch | null = null\n try {\n threadMatch = await matchThread(\n {\n channelId: input.channelId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n messageId: extractStringFromMeta(metaForMatcher, 'messageId') ?? m.externalMessageId,\n inReplyTo:\n m.replyToExternalId ?? extractStringFromMeta(metaForMatcher, 'inReplyTo'),\n references: extractStringArrayFromMeta(metaForMatcher, 'references'),\n subject: m.subject ?? '',\n fromAddress:\n extractStringFromMeta(metaForMatcher, 'from') ?? m.senderIdentifier,\n toAddresses: extractStringArrayFromMeta(metaForMatcher, 'to'),\n ccAddresses: extractStringArrayFromMeta(metaForMatcher, 'cc'),\n bodyPlain: m.bodyFormat === 'html' ? null : m.body ?? null,\n bodyHtml: m.bodyFormat === 'html' ? m.body ?? null : null,\n receivedAt: m.timestamp ?? new Date(),\n },\n { em },\n )\n } catch (matcherErr) {\n // Matcher failure must not block ingest \u2014 fall back to the existing\n // conversation-based thread mapping so the message still lands.\n logger.warn('thread matcher failed, falling back to conversation mapping', { err: matcherErr })\n }\n\n // (4) Contact resolution (best-effort, advisory).\n let contactHint: {\n matchedPersonId?: string | null\n email?: string\n displayName?: string\n } | null = null\n try {\n contactHint = await resolveContact(\n {\n adapter,\n senderIdentifier: m.senderIdentifier,\n senderDisplayName: m.senderDisplayName,\n channelMetadata: m.channelMetadata,\n credentials: {}, // credentials decrypted at the webhook route; resolver doesn't re-fetch\n scope: {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? input.scope.tenantId,\n },\n },\n { container: ctx.container },\n )\n } catch (contactErr) {\n // Best-effort: contact resolution is advisory and must not abort ingest.\n // Log like the sibling dedup/matcher catches so a misbehaving resolver is\n // visible in operator logs instead of failing silently.\n logger.warn('contact resolution failed, continuing without a CRM match', { err: contactErr })\n contactHint = null\n }\n const matchedPersonId = contactHint?.matchedPersonId ?? null\n if (matchedPersonId && conversation.contactPersonId !== matchedPersonId) {\n conversation.contactPersonId = matchedPersonId\n // Flush this scalar mutation before the system-user lookup below queries the\n // same EntityManager. SPEC-018: a query between a scalar mutation and its\n // flush can silently discard the pending UPDATE (mirrors the lastMessageAt\n // bump above).\n await em.flush()\n }\n\n // (5) Compose the platform Message via the messages module command.\n //\n // Sanitize against the `messages` module's validators (max 50_000 char body\n // + non-empty subject) so real-world emails don't get rejected mid-ingest:\n // - HTML emails routinely exceed 50k (Gmail signatures, marketing\n // templates, RFC 5322 multipart). Truncate with a marker rather than\n // drop the whole message \u2014 the full raw body is still in\n // ExternalMessage.rawPayload if needed for forensic / forward use.\n // - Some legitimate messages have no subject (notifications, bounce\n // digests). Substitute a placeholder instead of failing ingest.\n // - HTML bodies land in the platform body as plain text rather than being\n // merely relabelled as `text`: `MarkdownContent` renders a `text` body\n // verbatim, so unconverted markup shows up as literal doctype, <style>\n // blocks and tags in the inbox preview and the detail body. The source\n // markup is untouched in `channelPayload.html`, which `data/enrichers.ts`\n // sanitizes into `_channelPayload.sanitizedHtml` for the channel-payload\n // renderer widget mounted at `detail:messages:message:body:after`.\n // - The plain-text alternative wins over conversion when the sender's\n // client supplied one (`multipart/alternative`), because that part is\n // what a human actually wrote; `html-to-text` output \u2014 inlined link\n // URLs, flattened tables, synthesized list markers \u2014 is the fallback for\n // HTML-only senders. `lib/email-mime.ts` preferred `bodyHtml` when\n // picking `body`, but preserved the text part at `channelPayload.text`.\n const MAX_COMPOSE_BODY = 50_000\n // Bound the synchronous parse: inbound mail is untrusted and the 5MB body\n // ceiling from `lib/email-capabilities.ts` would otherwise block the ingest\n // worker on a single oversized document. This cap counts markup bytes while\n // MAX_COMPOSE_BODY counts text characters, so it can bite first \u2014 an\n // ordinary marketing template or a long quoted thread is easily over 512KB\n // of markup and still well under 50k characters of text. It therefore\n // carries the same truncation marker, rather than ending mid-sentence with\n // nothing to tell the reader the rest exists.\n const MAX_HTML_PARSE_INPUT = 512 * 1024\n const TRUNCATE_MARKER =\n '\\n\\n[\u2026message truncated by Open Mercato \u2014 full body preserved in ExternalMessage.rawPayload]'\n const sourceBody = m.body ?? ''\n const plainAlternative =\n typeof m.channelPayload?.text === 'string' ? m.channelPayload.text.trim() : ''\n const convertHtmlBody = () => {\n const converted = htmlToPlainText(sourceBody.slice(0, MAX_HTML_PARSE_INPUT))\n return sourceBody.length > MAX_HTML_PARSE_INPUT ? converted + TRUNCATE_MARKER : converted\n }\n const rawBody = m.bodyFormat === 'html'\n ? (plainAlternative || convertHtmlBody())\n : sourceBody\n const truncatedBody =\n rawBody.length > MAX_COMPOSE_BODY\n ? rawBody.slice(0, MAX_COMPOSE_BODY - TRUNCATE_MARKER.length) + TRUNCATE_MARKER\n : rawBody\n const safeSubject = (m.subject ?? '').trim() || '(no subject)'\n\n const composeInput = {\n type: `channel.${input.providerKey}`,\n visibility: 'public' as const,\n sourceEntityType: 'communication_channels.external_conversation',\n sourceEntityId: conversation.id,\n externalEmail: contactHint?.email ?? undefined,\n externalName: contactHint?.displayName ?? m.senderDisplayName,\n // #4975: tells the hub which identity contract applies to this sender.\n // Email-typed channels keep the mandatory `externalEmail`; providers whose\n // senders have no address (Discord, Slack, SMS\u2026) are validated without it.\n // The messages validator fails closed on any type it does not recognize.\n sourceChannelType: input.channelType,\n recipients: mapping?.assignedUserId\n ? [{ userId: mapping.assignedUserId, type: 'to' as const }]\n : [],\n subject: safeSubject,\n body: truncatedBody,\n bodyFormat: (m.bodyFormat === 'html' ? 'text' : m.bodyFormat) as 'text' | 'markdown',\n priority: 'normal' as const,\n sendViaEmail: false,\n // Spec B: matcher-resolved thread id takes priority over the existing\n // conversation-based mapping. Falls through to `mapping?.messageThreadId`\n // when the matcher returned null (no token / JWZ / subject hit).\n parentMessageId: threadMatch?.messageThreadId ?? mapping?.messageThreadId,\n isDraft: false,\n // Stable dedup key so a retried ingest (after a transient failure between\n // compose and the ExternalMessage anchor insert) reuses the message\n // composed by the first attempt instead of duplicating it. Mirrors the\n // (channel, externalMessageId) ExternalMessage anchor's natural key.\n idempotencyKey: m.externalMessageId\n ? `cc:${input.channelId}:${m.externalMessageId}`\n : undefined,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId,\n userId: await resolveCommunicationChannelsSystemUserId(\n em,\n input.scope.tenantId,\n mapping?.assignedUserId ?? null,\n ),\n }\n\n const commandBus = ctx.container.resolve('commandBus') as CommandBus\n const composeResult = await commandBus.execute<typeof composeInput, { id: string; threadId: string | null }>(\n 'messages.messages.compose',\n {\n input: composeInput,\n ctx: passthroughCommandCtx(ctx, input.scope),\n },\n )\n const message = composeResult.result\n if (!message?.id) {\n throw new Error('messages.messages.compose did not return a message id')\n }\n\n // (3 continued) Create or update ChannelThreadMapping now that we have a threadId.\n if (!mapping) {\n mapping = em.create(ChannelThreadMapping, {\n externalConversationId: conversation.id,\n messageThreadId: message.threadId ?? message.id,\n channelId: input.channelId,\n providerKey: input.providerKey,\n externalThreadRef: m.externalConversationId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n })\n em.persist(mapping)\n }\n\n // (6) Create ExternalMessage + MessageChannelLink (hub-side records).\n //\n // The PrimaryKey for both uses `defaultRaw: 'gen_random_uuid()'` \u2014 a\n // Postgres-side default that doesn't populate the entity's `id` field\n // until after the INSERT returns. If we let MikroORM generate both, then\n // `em.create(MessageChannelLink, { externalMessageId: externalMessage.id })`\n // reads `undefined` for `externalMessage.id` (it hasn't been flushed yet)\n // and writes NULL to `message_channel_links.external_message_id`,\n // breaking the FK and causing downstream joins to silently return 0 rows.\n //\n // Pre-generating both UUIDs client-side fixes the cross-row reference\n // problem and keeps the single-transaction flush semantics intact.\n const externalMessageRowId = randomUUID()\n const channelLinkRowId = randomUUID()\n const externalMessage = em.create(ExternalMessage, {\n id: externalMessageRowId,\n channelId: input.channelId,\n conversationId: conversation.id,\n externalMessageId: m.externalMessageId,\n direction: 'inbound',\n senderIdentifier: m.senderIdentifier,\n senderDisplayName: m.senderDisplayName ?? null,\n providerTimestamp: m.timestamp,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n })\n em.persist(externalMessage)\n\n // Spec B: annotate the link with which thread-matcher strategy resolved\n // this message (or `'new-thread'` when matcher returned null and we\n // opened a fresh thread). Surfaced to observability + future UI (\"this\n // thread match is low-confidence \u2014 confirm or move\").\n const matcherAnnotatedMetadata: Record<string, unknown> = {\n ...((m.channelMetadata as Record<string, unknown> | undefined) ?? {}),\n threadMatchStrategy: threadMatch?.matchedBy ?? 'new-thread',\n threadMatchConfidence: threadMatch?.confidence ?? 'low',\n }\n\n const channelLink = em.create(MessageChannelLink, {\n id: channelLinkRowId,\n messageId: message.id,\n externalConversationId: conversation.id,\n externalMessageId: externalMessageRowId,\n providerKey: input.providerKey,\n channelType: input.channelType,\n direction: 'inbound',\n deliveryStatus: 'received',\n channelPayload: m.channelPayload,\n channelContentType: m.channelContentType,\n channelMetadata: matcherAnnotatedMetadata,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n })\n em.persist(channelLink)\n\n try {\n await em.flush()\n } catch (flushErr) {\n // Concurrency guard: the pre-check at (1) is not atomic with this insert,\n // so a poll re-fetch racing a push notification (or two push deliveries)\n // can both pass the check and reach here. The `(channel_id,\n // external_message_id)` unique index rejects the loser with a 23505. Treat\n // that as a duplicate \u2014 returning here (instead of throwing) prevents the\n // message from being dead-lettered and retried forever. The winning job\n // already recorded the message + link.\n if (isUniqueViolation(flushErr)) {\n return {\n status: 'duplicate',\n externalConversationId: conversation.id,\n externalMessageId: m.externalMessageId,\n }\n }\n throw flushErr\n }\n\n // (7) Emit events \u2014 order matters for downstream subscribers.\n if (conversationCreated) {\n await emitCommunicationChannelsEvent(\n 'communication_channels.conversation.created',\n {\n conversationId: conversation.id,\n channelId: input.channelId,\n providerKey: input.providerKey,\n channelType: input.channelType,\n externalConversationId: m.externalConversationId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n }\n if (matchedPersonId) {\n await emitCommunicationChannelsEvent(\n 'communication_channels.contact.resolved',\n {\n conversationId: conversation.id,\n contactPersonId: matchedPersonId,\n providerKey: input.providerKey,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n }\n await emitCommunicationChannelsEvent(\n 'communication_channels.message.received',\n {\n messageId: message.id,\n externalMessageId: externalMessage.id,\n channelLinkId: channelLink.id,\n conversationId: conversation.id,\n channelId: input.channelId,\n providerKey: input.providerKey,\n channelType: input.channelType,\n direction: 'inbound',\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n\n return {\n status: 'created',\n messageId: message.id,\n externalConversationId: conversation.id,\n externalMessageId: externalMessage.id,\n channelLinkId: channelLink.id,\n threadMappingId: mapping.id,\n contactPersonId: matchedPersonId,\n }\n },\n}\n\n\n/**\n * Build a runtime context for the nested `messages.messages.compose` call.\n *\n * The compose command expects a `CommandRuntimeContext`. For inbound webhook\n * processing there is no platform user; we pass `auth: null` and use the tenant\n * scope from our input.\n */\nfunction passthroughCommandCtx(\n parent: CommandRuntimeContext,\n scope: IngestInboundMessageInput['scope'],\n): CommandRuntimeContext {\n return {\n container: parent.container,\n auth: null,\n organizationScope: null,\n selectedOrganizationId: scope.organizationId ?? null,\n organizationIds: scope.organizationId ? [scope.organizationId] : null,\n }\n}\n\n/**\n * Pull a string value from the provider's `channelMetadata` map. Returns\n * `null` (not `undefined`) when the key is absent or the value isn't a\n * string \u2014 keeps the matcher's input shape predictable.\n */\nfunction extractStringFromMeta(\n meta: Record<string, unknown>,\n key: string,\n): string | null {\n const value = meta[key]\n if (typeof value === 'string' && value.length > 0) return value\n return null\n}\n\n/**\n * Pull a string[] value from the provider's `channelMetadata` map.\n * Filters out non-string entries defensively. Returns an empty array\n * when the key is absent or the value isn't an array.\n */\nfunction extractStringArrayFromMeta(\n meta: Record<string, unknown>,\n key: string,\n): string[] {\n const value = meta[key]\n if (!Array.isArray(value)) return []\n return value.filter((item): item is string => typeof item === 'string')\n}\n\nregisterCommand(ingestInboundMessageCommand)\n\nexport default ingestInboundMessageCommand\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { randomUUID } from 'node:crypto'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandBus, CommandHandler, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { registerCommand } from '@open-mercato/shared/lib/commands'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { htmlToPlainText } from '@open-mercato/shared/lib/html/htmlToPlainText'\nimport { emitCommunicationChannelsEvent } from '../events'\nimport { resolveContact } from '../lib/contact-resolver'\nimport type { ChannelAdapterRegistry } from '../lib/registry'\nimport type { NormalizedInboundMessage } from '../lib/adapter'\nimport { matchThread, type ThreadMatch } from '../lib/thread-matcher'\nimport {\n ChannelThreadMapping,\n CommunicationChannel,\n ExternalConversation,\n ExternalMessage,\n MessageChannelLink,\n} from '../data/entities'\nimport { normalizedInboundMessageSchema } from '../data/validators'\nimport { resolveCommunicationChannelsSystemUserId } from '../lib/system-user'\nimport { isUniqueViolation } from '../lib/pg-errors'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('communication_channels').child({ component: 'ingest-inbound-message' })\n\nconst ingestInputSchema = z.object({\n channelId: z.string().uuid(),\n providerKey: z.string().min(1),\n channelType: z.string().min(1),\n scope: z.object({\n tenantId: z.string().uuid(),\n organizationId: z.string().uuid().nullable(),\n }),\n message: normalizedInboundMessageSchema,\n})\n\nexport type IngestInboundMessageInput = z.infer<typeof ingestInputSchema>\n\nexport type IngestInboundMessageResult = {\n status: 'created' | 'duplicate'\n messageId?: string\n externalConversationId?: string\n externalMessageId?: string\n channelLinkId?: string\n threadMappingId?: string\n contactPersonId?: string | null\n}\n\nexport const COMMUNICATION_CHANNELS_INGEST_INBOUND_COMMAND_ID = 'communication_channels.message.ingest_inbound'\n\n/**\n * Idempotently ingest a normalized inbound channel message.\n *\n * Steps (per SPEC-045d \u00A76):\n * 1. Dedup by `(channel_id, external_message_id)` \u2014 if a MessageChannelLink already\n * exists for that pair, return `{ status: 'duplicate' }` without side effects.\n * 2. Create or load `ExternalConversation` by `(channel_id, external_conversation_id)`.\n * 3. Create or load `ChannelThreadMapping` (1:1 with ExternalConversation).\n * 4. Resolve CRM contact via adapter + QueryEngine (best-effort).\n * 5. Compose the platform `Message` via `messages.messages.compose` (separate transaction).\n * 6. Create `ExternalMessage` + `MessageChannelLink`.\n * 7. Emit `communication_channels.message.received` (and `.conversation.created` / `.contact.resolved` when applicable).\n *\n * The two-transaction model (compose-message-then-record-link) is acceptable for v1;\n * the link's unique-on-message-id constraint is the safety net against orphans. See\n * the pre-implementation analysis for a discussion of single-transaction alternatives.\n */\nconst ingestInboundMessageCommand: CommandHandler<IngestInboundMessageInput, IngestInboundMessageResult> = {\n id: COMMUNICATION_CHANNELS_INGEST_INBOUND_COMMAND_ID,\n async execute(rawInput, ctx) {\n const input = ingestInputSchema.parse(rawInput) as IngestInboundMessageInput\n\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n const adapterRegistry = ctx.container.resolve('channelAdapterRegistry') as ChannelAdapterRegistry\n const dscope = {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n }\n\n // (1) Dedup: short-circuit if we've already processed this provider message.\n // The unique constraint is on `messageId`, not (channel, externalMessageId).\n // We must dedup by joining against ExternalMessage which IS uniquely indexed by\n // (channel_id, external_message_id). Hub-side dedup is the authoritative gate.\n const existingExternal = await findOneWithDecryption(\n em,\n ExternalMessage,\n {\n channelId: input.channelId,\n externalMessageId: input.message.externalMessageId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n if (existingExternal) {\n return {\n status: 'duplicate',\n externalConversationId: existingExternal.conversationId,\n externalMessageId: existingExternal.id,\n }\n }\n\n // (1b) Spec B \u00A7 Sent-folder dedup.\n //\n // When an outbound message lands in the user's IMAP Sent folder (or\n // when Gmail's \"send and archive\" deposits it in All Mail), the next\n // poll will re-fetch it from INBOX as if it were inbound. Skip it\n // here using the RFC 5322 `Message-ID` header \u2014 we recorded it on the\n // outbound `MessageChannelLink.channelMetadata.messageId` at send time.\n //\n // We dedup ONLY on outbound links (direction='outbound') for the same\n // channel \u2014 that way an inbound copy of someone ELSE's email that\n // happens to share a Message-ID is still ingested normally.\n const incomingMessageId = (() => {\n const fromMeta = (input.message.channelMetadata as Record<string, unknown> | undefined)?.messageId\n if (typeof fromMeta === 'string' && fromMeta.length > 0) return fromMeta\n return null\n })()\n if (incomingMessageId) {\n // MikroORM v7 dropped the Knex builder in favour of Kysely/raw SQL.\n // We use a positional-placeholder raw query for the JSONB\n // `channel_metadata->>messageId` comparison.\n try {\n const sentFolderHit = await em.getConnection().execute<Array<{ id: string }>>(\n `SELECT link.id FROM message_channel_links AS link\n INNER JOIN external_conversations AS conv\n ON conv.id = link.external_conversation_id\n WHERE link.tenant_id = ?\n AND ((?::uuid IS NULL AND link.organization_id IS NULL) OR link.organization_id = ?::uuid)\n AND conv.tenant_id = ?\n AND ((?::uuid IS NULL AND conv.organization_id IS NULL) OR conv.organization_id = ?::uuid)\n AND conv.channel_id = ?\n AND link.direction = 'outbound'\n AND link.channel_metadata->>'messageId' = ?\n LIMIT 1`,\n [\n input.scope.tenantId,\n input.scope.organizationId ?? null,\n input.scope.organizationId ?? null,\n input.scope.tenantId,\n input.scope.organizationId ?? null,\n input.scope.organizationId ?? null,\n input.channelId,\n incomingMessageId,\n ],\n )\n if (Array.isArray(sentFolderHit) && sentFolderHit.length > 0) {\n return {\n status: 'duplicate',\n externalConversationId: input.message.externalConversationId,\n externalMessageId: input.message.externalMessageId,\n }\n }\n } catch (dedupErr) {\n // Sent-folder dedup is best-effort \u2014 a failure here must not abort\n // ingest (better a possible duplicate than a lost inbound message).\n logger.warn('sent-folder dedup query failed, continuing', { err: dedupErr })\n }\n }\n\n // Channel + adapter lookup (the channel must exist + be active).\n const channel = await findOneWithDecryption(\n em,\n CommunicationChannel,\n {\n id: input.channelId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n deletedAt: null,\n },\n undefined,\n dscope,\n )\n if (!channel) {\n throw new Error(\n `[internal] Channel ${input.channelId} not found for tenant ${input.scope.tenantId} (or has been deleted)`,\n )\n }\n if (!channel.isActive) {\n throw new Error(`[internal] Channel ${input.channelId} is inactive; refusing to ingest`)\n }\n\n const adapter = adapterRegistry.get(input.providerKey)\n if (!adapter) {\n throw new Error(\n `[internal] No ChannelAdapter registered for providerKey '${input.providerKey}'. ` +\n 'Check that the provider package is enabled in modules.ts.',\n )\n }\n\n // (2) ExternalConversation upsert by (channel_id, externalConversationId).\n const m = input.message\n let conversation = await findOneWithDecryption(\n em,\n ExternalConversation,\n {\n channelId: input.channelId,\n externalConversationId: m.externalConversationId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n let conversationCreated = false\n if (!conversation) {\n conversation = em.create(ExternalConversation, {\n channelId: input.channelId,\n externalConversationId: m.externalConversationId,\n subject: m.subject ?? null,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n lastMessageAt: m.timestamp ?? new Date(),\n assignedUserId: channel.userId ?? null,\n })\n em.persist(conversation)\n conversationCreated = true\n }\n\n // (3) ChannelThreadMapping upsert (1:1 with ExternalConversation per tenant).\n let mapping = await findOneWithDecryption(\n em,\n ChannelThreadMapping,\n {\n externalConversationId: conversation.id,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n\n // Last-activity bump on an existing conversation. Applied AFTER the mapping\n // lookup, immediately before the flush, so the scalar mutation and its flush\n // stay adjacent with no query in between (core flush-ordering rule \u2014 a query\n // between a scalar mutation and `em.flush()` can drop the change under some\n // flush modes / subscriber configurations).\n if (\n !conversationCreated &&\n m.timestamp &&\n (!conversation.lastMessageAt || m.timestamp > conversation.lastMessageAt)\n ) {\n conversation.lastMessageAt = m.timestamp\n }\n // We'll fill `messageThreadId` after composing the platform Message (since the\n // first inbound message becomes the thread root in the messages module).\n await em.flush()\n\n // (3b) Spec B \u2014 layered thread match.\n //\n // Resolve the inbound message to an existing platform thread using\n // (in priority order):\n // 1. Crypto token in References / In-Reply-To header (high confidence)\n // 2. Crypto token in body hidden span or plain-text marker (high)\n // 3. JWZ on Message-Id \u2194 stored `MessageChannelLink.channelMetadata.messageId` (medium)\n // 4. Subject + participants in last 30 days, same channel (low)\n //\n // The matcher returns `null` when nothing hits \u2014 in that case we fall\n // back to the existing `ChannelThreadMapping`-by-conversation-id lookup\n // (which also returns null on first-ever inbound, in which case the\n // compose command opens a new thread).\n const metaForMatcher = (m.channelMetadata ?? {}) as Record<string, unknown>\n let threadMatch: ThreadMatch | null = null\n try {\n threadMatch = await matchThread(\n {\n channelId: input.channelId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n messageId: extractStringFromMeta(metaForMatcher, 'messageId') ?? m.externalMessageId,\n inReplyTo:\n m.replyToExternalId ?? extractStringFromMeta(metaForMatcher, 'inReplyTo'),\n references: extractStringArrayFromMeta(metaForMatcher, 'references'),\n subject: m.subject ?? '',\n fromAddress:\n extractStringFromMeta(metaForMatcher, 'from') ?? m.senderIdentifier,\n toAddresses: extractStringArrayFromMeta(metaForMatcher, 'to'),\n ccAddresses: extractStringArrayFromMeta(metaForMatcher, 'cc'),\n bodyPlain: m.bodyFormat === 'html' ? null : m.body ?? null,\n bodyHtml: m.bodyFormat === 'html' ? m.body ?? null : null,\n receivedAt: m.timestamp ?? new Date(),\n },\n { em },\n )\n } catch (matcherErr) {\n // Matcher failure must not block ingest \u2014 fall back to the existing\n // conversation-based thread mapping so the message still lands.\n logger.warn('thread matcher failed, falling back to conversation mapping', { err: matcherErr })\n }\n\n // (4) Contact resolution (best-effort, advisory).\n let contactHint: {\n matchedPersonId?: string | null\n email?: string\n displayName?: string\n } | null = null\n try {\n contactHint = await resolveContact(\n {\n adapter,\n senderIdentifier: m.senderIdentifier,\n senderDisplayName: m.senderDisplayName,\n channelMetadata: m.channelMetadata,\n credentials: {}, // credentials decrypted at the webhook route; resolver doesn't re-fetch\n scope: {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? input.scope.tenantId,\n },\n },\n { container: ctx.container },\n )\n } catch (contactErr) {\n // Best-effort: contact resolution is advisory and must not abort ingest.\n // Log like the sibling dedup/matcher catches so a misbehaving resolver is\n // visible in operator logs instead of failing silently.\n logger.warn('contact resolution failed, continuing without a CRM match', { err: contactErr })\n contactHint = null\n }\n const matchedPersonId = contactHint?.matchedPersonId ?? null\n if (matchedPersonId && conversation.contactPersonId !== matchedPersonId) {\n conversation.contactPersonId = matchedPersonId\n // Flush this scalar mutation before the system-user lookup below queries the\n // same EntityManager. SPEC-018: a query between a scalar mutation and its\n // flush can silently discard the pending UPDATE (mirrors the lastMessageAt\n // bump above).\n await em.flush()\n }\n\n // (5) Compose the platform Message via the messages module command.\n //\n // Sanitize against the `messages` module's validators (max 50_000 char body\n // + non-empty subject) so real-world emails don't get rejected mid-ingest:\n // - HTML emails routinely exceed 50k (Gmail signatures, marketing\n // templates, RFC 5322 multipart). Truncate with a marker rather than\n // drop the whole message \u2014 the full raw body is still in\n // ExternalMessage.rawPayload if needed for forensic / forward use.\n // - Some legitimate messages have no subject (notifications, bounce\n // digests). Substitute a placeholder instead of failing ingest.\n // - HTML bodies land in the platform body as plain text rather than being\n // merely relabelled as `text`: `MarkdownContent` renders a `text` body\n // verbatim, so unconverted markup shows up as literal doctype, <style>\n // blocks and tags in the inbox preview and the detail body. The source\n // markup is untouched in `channelPayload.html`, which `data/enrichers.ts`\n // sanitizes into `_channelPayload.sanitizedHtml` for the channel-payload\n // renderer widget mounted at `detail:messages:message:body:after`.\n // - The plain-text alternative wins over conversion when the sender's\n // client supplied one (`multipart/alternative`), because that part is\n // what a human actually wrote; `html-to-text` output \u2014 inlined link\n // URLs, flattened tables, synthesized list markers \u2014 is the fallback for\n // HTML-only senders. `lib/email-mime.ts` preferred `bodyHtml` when\n // picking `body`, but preserved the text part at `channelPayload.text`.\n const MAX_COMPOSE_BODY = 50_000\n // Bound the synchronous parse: inbound mail is untrusted and the 5MB body\n // ceiling from `lib/email-capabilities.ts` would otherwise block the ingest\n // worker on a single oversized document. This cap counts markup bytes while\n // MAX_COMPOSE_BODY counts text characters, so it can bite first \u2014 an\n // ordinary marketing template or a long quoted thread is easily over 512KB\n // of markup and still well under 50k characters of text. It therefore\n // carries the same truncation marker, rather than ending mid-sentence with\n // nothing to tell the reader the rest exists.\n const MAX_HTML_PARSE_INPUT = 512 * 1024\n const TRUNCATE_MARKER =\n '\\n\\n[\u2026message truncated by Open Mercato \u2014 full body preserved in ExternalMessage.rawPayload]'\n const sourceBody = m.body ?? ''\n const plainAlternative =\n typeof m.channelPayload?.text === 'string' ? m.channelPayload.text.trim() : ''\n const convertHtmlBody = () => {\n const converted = htmlToPlainText(sourceBody.slice(0, MAX_HTML_PARSE_INPUT))\n return sourceBody.length > MAX_HTML_PARSE_INPUT ? converted + TRUNCATE_MARKER : converted\n }\n const rawBody = m.bodyFormat === 'html'\n ? (plainAlternative || convertHtmlBody())\n : sourceBody\n const truncatedBody =\n rawBody.length > MAX_COMPOSE_BODY\n ? rawBody.slice(0, MAX_COMPOSE_BODY - TRUNCATE_MARKER.length) + TRUNCATE_MARKER\n : rawBody\n const safeSubject = (m.subject ?? '').trim() || '(no subject)'\n\n // #6106: a per-user channel is its owner's inbox, so the owner is the\n // default assignee. Without this fallback the first message of a thread\n // (no mapping yet) and every message on an unassigned mapping had no\n // recipient, and the participant-scoped inbox showed it to nobody. A\n // manual assignment stays authoritative; tenant-wide channels have no owner.\n const routedAssigneeId = mapping?.assignedUserId ?? channel.userId ?? null\n\n const composeInput = {\n type: `channel.${input.providerKey}`,\n visibility: 'public' as const,\n sourceEntityType: 'communication_channels.external_conversation',\n sourceEntityId: conversation.id,\n externalEmail: contactHint?.email ?? undefined,\n externalName: contactHint?.displayName ?? m.senderDisplayName,\n // #4975: tells the hub which identity contract applies to this sender.\n // Email-typed channels keep the mandatory `externalEmail`; providers whose\n // senders have no address (Discord, Slack, SMS\u2026) are validated without it.\n // The messages validator fails closed on any type it does not recognize.\n sourceChannelType: input.channelType,\n // #6093: this message came in from the channel, so the recipient below\n // is internal routing (the conversation's assignee), not an external\n // addressee. Without the flag the hub's \"no recipients on a public\n // message\" rule rejected every message in an assigned conversation and\n // the worker dropped it as a permanent failure.\n inboundFromChannel: true,\n recipients: routedAssigneeId\n ? [{ userId: routedAssigneeId, type: 'to' as const }]\n : [],\n subject: safeSubject,\n body: truncatedBody,\n bodyFormat: (m.bodyFormat === 'html' ? 'text' : m.bodyFormat) as 'text' | 'markdown',\n priority: 'normal' as const,\n sendViaEmail: false,\n // Spec B: matcher-resolved thread id takes priority over the existing\n // conversation-based mapping. Falls through to `mapping?.messageThreadId`\n // when the matcher returned null (no token / JWZ / subject hit).\n parentMessageId: threadMatch?.messageThreadId ?? mapping?.messageThreadId,\n isDraft: false,\n // Stable dedup key so a retried ingest (after a transient failure between\n // compose and the ExternalMessage anchor insert) reuses the message\n // composed by the first attempt instead of duplicating it. Mirrors the\n // (channel, externalMessageId) ExternalMessage anchor's natural key.\n idempotencyKey: m.externalMessageId\n ? `cc:${input.channelId}:${m.externalMessageId}`\n : undefined,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId,\n userId: await resolveCommunicationChannelsSystemUserId(\n em,\n input.scope.tenantId,\n mapping?.assignedUserId ?? null,\n ),\n }\n\n const commandBus = ctx.container.resolve('commandBus') as CommandBus\n const composeResult = await commandBus.execute<typeof composeInput, { id: string; threadId: string | null }>(\n 'messages.messages.compose',\n {\n input: composeInput,\n ctx: passthroughCommandCtx(ctx, input.scope),\n },\n )\n const message = composeResult.result\n if (!message?.id) {\n throw new Error('messages.messages.compose did not return a message id')\n }\n\n // (3 continued) Create or update ChannelThreadMapping now that we have a threadId.\n if (!mapping) {\n mapping = em.create(ChannelThreadMapping, {\n externalConversationId: conversation.id,\n messageThreadId: message.threadId ?? message.id,\n channelId: input.channelId,\n providerKey: input.providerKey,\n externalThreadRef: m.externalConversationId,\n assignedUserId: channel.userId ?? null,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n })\n em.persist(mapping)\n }\n\n // (6) Create ExternalMessage + MessageChannelLink (hub-side records).\n //\n // The PrimaryKey for both uses `defaultRaw: 'gen_random_uuid()'` \u2014 a\n // Postgres-side default that doesn't populate the entity's `id` field\n // until after the INSERT returns. If we let MikroORM generate both, then\n // `em.create(MessageChannelLink, { externalMessageId: externalMessage.id })`\n // reads `undefined` for `externalMessage.id` (it hasn't been flushed yet)\n // and writes NULL to `message_channel_links.external_message_id`,\n // breaking the FK and causing downstream joins to silently return 0 rows.\n //\n // Pre-generating both UUIDs client-side fixes the cross-row reference\n // problem and keeps the single-transaction flush semantics intact.\n const externalMessageRowId = randomUUID()\n const channelLinkRowId = randomUUID()\n const externalMessage = em.create(ExternalMessage, {\n id: externalMessageRowId,\n channelId: input.channelId,\n conversationId: conversation.id,\n externalMessageId: m.externalMessageId,\n direction: 'inbound',\n senderIdentifier: m.senderIdentifier,\n senderDisplayName: m.senderDisplayName ?? null,\n providerTimestamp: m.timestamp,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n })\n em.persist(externalMessage)\n\n // Spec B: annotate the link with which thread-matcher strategy resolved\n // this message (or `'new-thread'` when matcher returned null and we\n // opened a fresh thread). Surfaced to observability + future UI (\"this\n // thread match is low-confidence \u2014 confirm or move\").\n const matcherAnnotatedMetadata: Record<string, unknown> = {\n ...((m.channelMetadata as Record<string, unknown> | undefined) ?? {}),\n threadMatchStrategy: threadMatch?.matchedBy ?? 'new-thread',\n threadMatchConfidence: threadMatch?.confidence ?? 'low',\n }\n\n const channelLink = em.create(MessageChannelLink, {\n id: channelLinkRowId,\n messageId: message.id,\n externalConversationId: conversation.id,\n externalMessageId: externalMessageRowId,\n providerKey: input.providerKey,\n channelType: input.channelType,\n direction: 'inbound',\n deliveryStatus: 'received',\n channelPayload: m.channelPayload,\n channelContentType: m.channelContentType,\n channelMetadata: matcherAnnotatedMetadata,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n })\n em.persist(channelLink)\n\n try {\n await em.flush()\n } catch (flushErr) {\n // Concurrency guard: the pre-check at (1) is not atomic with this insert,\n // so a poll re-fetch racing a push notification (or two push deliveries)\n // can both pass the check and reach here. The `(channel_id,\n // external_message_id)` unique index rejects the loser with a 23505. Treat\n // that as a duplicate \u2014 returning here (instead of throwing) prevents the\n // message from being dead-lettered and retried forever. The winning job\n // already recorded the message + link.\n if (isUniqueViolation(flushErr)) {\n return {\n status: 'duplicate',\n externalConversationId: conversation.id,\n externalMessageId: m.externalMessageId,\n }\n }\n throw flushErr\n }\n\n // (7) Emit events \u2014 order matters for downstream subscribers.\n if (conversationCreated) {\n await emitCommunicationChannelsEvent(\n 'communication_channels.conversation.created',\n {\n conversationId: conversation.id,\n channelId: input.channelId,\n providerKey: input.providerKey,\n channelType: input.channelType,\n externalConversationId: m.externalConversationId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n }\n if (matchedPersonId) {\n await emitCommunicationChannelsEvent(\n 'communication_channels.contact.resolved',\n {\n conversationId: conversation.id,\n contactPersonId: matchedPersonId,\n providerKey: input.providerKey,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n }\n await emitCommunicationChannelsEvent(\n 'communication_channels.message.received',\n {\n messageId: message.id,\n externalMessageId: externalMessage.id,\n channelLinkId: channelLink.id,\n conversationId: conversation.id,\n channelId: input.channelId,\n providerKey: input.providerKey,\n channelType: input.channelType,\n direction: 'inbound',\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n\n return {\n status: 'created',\n messageId: message.id,\n externalConversationId: conversation.id,\n externalMessageId: externalMessage.id,\n channelLinkId: channelLink.id,\n threadMappingId: mapping.id,\n contactPersonId: matchedPersonId,\n }\n },\n}\n\n\n/**\n * Build a runtime context for the nested `messages.messages.compose` call.\n *\n * The compose command expects a `CommandRuntimeContext`. For inbound webhook\n * processing there is no platform user; we pass `auth: null` and use the tenant\n * scope from our input.\n */\nfunction passthroughCommandCtx(\n parent: CommandRuntimeContext,\n scope: IngestInboundMessageInput['scope'],\n): CommandRuntimeContext {\n return {\n container: parent.container,\n auth: null,\n organizationScope: null,\n selectedOrganizationId: scope.organizationId ?? null,\n organizationIds: scope.organizationId ? [scope.organizationId] : null,\n }\n}\n\n/**\n * Pull a string value from the provider's `channelMetadata` map. Returns\n * `null` (not `undefined`) when the key is absent or the value isn't a\n * string \u2014 keeps the matcher's input shape predictable.\n */\nfunction extractStringFromMeta(\n meta: Record<string, unknown>,\n key: string,\n): string | null {\n const value = meta[key]\n if (typeof value === 'string' && value.length > 0) return value\n return null\n}\n\n/**\n * Pull a string[] value from the provider's `channelMetadata` map.\n * Filters out non-string entries defensively. Returns an empty array\n * when the key is absent or the value isn't an array.\n */\nfunction extractStringArrayFromMeta(\n meta: Record<string, unknown>,\n key: string,\n): string[] {\n const value = meta[key]\n if (!Array.isArray(value)) return []\n return value.filter((item): item is string => typeof item === 'string')\n}\n\nregisterCommand(ingestInboundMessageCommand)\n\nexport default ingestInboundMessageCommand\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -4,15 +4,31 @@ import { findOneWithDecryption } from "@open-mercato/shared/lib/encryption/find"
4
4
  import { CommunicationChannel } from "../data/entities.js";
5
5
  import { getChannelAdapterRegistry } from "../lib/adapter-registry-singleton.js";
6
6
  import { COMMUNICATION_CHANNELS_QUEUES, getCommunicationChannelsQueue } from "../lib/queue.js";
7
- const queueImportHistorySchema = z.object({
8
- channelId: z.string().uuid(),
9
- sinceDays: z.number().int().min(1).max(365).default(30),
10
- contactEmails: z.array(z.string().email().max(255)).max(200).optional(),
11
- maxMessages: z.number().int().min(1).max(5e3).default(1e3)
12
- });
7
+ import {
8
+ IMPORT_HISTORY_MAX_CONTACT_EMAILS,
9
+ getImportHistoryLimits
10
+ } from "../lib/import-history-limits.js";
11
+ function createQueueImportHistorySchema(limits = getImportHistoryLimits()) {
12
+ return z.object({
13
+ channelId: z.string().uuid(),
14
+ sinceDays: z.number().int().min(1).max(limits.maxSinceDays).default(limits.defaultSinceDays),
15
+ contactEmails: z.array(z.string().email().max(255)).max(IMPORT_HISTORY_MAX_CONTACT_EMAILS).optional(),
16
+ maxMessages: z.number().int().min(1).max(limits.maxMessages).default(limits.defaultMaxMessages)
17
+ });
18
+ }
19
+ let cachedSchema = null;
20
+ function getQueueImportHistorySchema() {
21
+ const limits = getImportHistoryLimits();
22
+ const key = `${limits.maxSinceDays}:${limits.maxMessages}`;
23
+ if (!cachedSchema || cachedSchema.key !== key) {
24
+ cachedSchema = { key, schema: createQueueImportHistorySchema(limits) };
25
+ }
26
+ return cachedSchema.schema;
27
+ }
28
+ const queueImportHistorySchema = getQueueImportHistorySchema();
13
29
  const CHANNEL_IMPORT_HISTORY_JOB_TYPE = "communication_channels.channel.import_history";
14
30
  async function queueImportHistory(params) {
15
- const input = queueImportHistorySchema.parse(params.input);
31
+ const input = getQueueImportHistorySchema().parse(params.input);
16
32
  const { container, scope } = params;
17
33
  const em = container.resolve("em").fork();
18
34
  const dscope = { tenantId: scope.tenantId, organizationId: scope.organizationId };
@@ -57,6 +73,21 @@ async function queueImportHistory(params) {
57
73
  { status: 400 }
58
74
  );
59
75
  }
76
+ const providerLimits = getImportHistoryLimits(channel.providerKey);
77
+ if (input.sinceDays > providerLimits.maxSinceDays) {
78
+ throw createCrudFormError(
79
+ "sinceDays exceeds the limit for this provider",
80
+ { sinceDays: `Provider "${channel.providerKey}" supports at most ${providerLimits.maxSinceDays} days` },
81
+ { status: 400 }
82
+ );
83
+ }
84
+ if (input.maxMessages > providerLimits.maxMessages) {
85
+ throw createCrudFormError(
86
+ "maxMessages exceeds the limit for this provider",
87
+ { maxMessages: `Provider "${channel.providerKey}" supports at most ${providerLimits.maxMessages} messages` },
88
+ { status: 400 }
89
+ );
90
+ }
60
91
  const progressService = container.resolve("progressService");
61
92
  const progressContext = {
62
93
  tenantId: scope.tenantId,
@@ -107,6 +138,8 @@ async function queueImportHistory(params) {
107
138
  }
108
139
  export {
109
140
  CHANNEL_IMPORT_HISTORY_JOB_TYPE,
141
+ createQueueImportHistorySchema,
142
+ getQueueImportHistorySchema,
110
143
  queueImportHistory,
111
144
  queueImportHistorySchema
112
145
  };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/communication_channels/commands/queue-import-history.ts"],
4
- "sourcesContent": ["import { z } from 'zod'\nimport type { AwilixContainer } from 'awilix'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { createCrudFormError } from '@open-mercato/ui/backend/utils/serverErrors'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport type {\n ProgressService,\n ProgressServiceContext,\n} from '../../progress/lib/progressService'\nimport { CommunicationChannel } from '../data/entities'\nimport { getChannelAdapterRegistry } from '../lib/adapter-registry-singleton'\nimport { COMMUNICATION_CHANNELS_QUEUES, getCommunicationChannelsQueue } from '../lib/queue'\n\n/**\n * Spec B \u00A7 Phase B6 \u2014 operator-triggered backlog import.\n *\n * Validates the channel + adapter capability, creates a `ProgressJob`, then\n * enqueues a `channel-import-history` job. Returns the job id so the UI can\n * track progress via the existing ProgressTopBar.\n *\n * Concurrency guard: at most one in-flight import per channel \u2014 a second call\n * while the first is still running returns a 429 envelope via\n * `createCrudFormError`. The worker itself runs with `concurrency: 1` so a\n * dropped guard is still safe.\n */\n\nexport const queueImportHistorySchema = z.object({\n channelId: z.string().uuid(),\n sinceDays: z.number().int().min(1).max(365).default(30),\n contactEmails: z\n .array(z.string().email().max(255))\n .max(200)\n .optional(),\n maxMessages: z.number().int().min(1).max(5000).default(1000),\n})\n\nexport type QueueImportHistoryInput = z.infer<typeof queueImportHistorySchema>\n\nexport interface QueueImportHistoryScope {\n tenantId: string\n organizationId: string\n userId?: string | null\n}\n\nexport interface QueueImportHistoryResult {\n progressJobId: string\n totalCountHint: number\n}\n\nexport const CHANNEL_IMPORT_HISTORY_JOB_TYPE = 'communication_channels.channel.import_history'\n\nexport interface ChannelImportHistoryJobPayload {\n progressJobId: string\n channelId: string\n sinceDays: number\n contactEmails?: string[]\n maxMessages: number\n scope: { tenantId: string; organizationId: string }\n}\n\nexport async function queueImportHistory(params: {\n container: AwilixContainer\n scope: QueueImportHistoryScope\n input: QueueImportHistoryInput\n}): Promise<QueueImportHistoryResult> {\n const input = queueImportHistorySchema.parse(params.input)\n const { container, scope } = params\n\n const em = (container.resolve('em') as EntityManager).fork()\n const dscope = { tenantId: scope.tenantId, organizationId: scope.organizationId }\n\n const channel = await findOneWithDecryption(\n em,\n CommunicationChannel,\n {\n id: input.channelId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n },\n undefined,\n dscope,\n )\n if (!channel) {\n throw createCrudFormError(\n 'Channel not found',\n { channelId: 'Channel not found' },\n { status: 404 },\n )\n }\n if (!channel.isActive || channel.status !== 'connected') {\n // 409 (not 400): the request is well-formed but the channel is in a\n // conflicting state (requires_reauth / error / disconnected). The UI maps\n // this to the localized \"reconnect first\" flash; see spec \u00A7 API Contracts.\n throw createCrudFormError(\n 'Channel is not connected',\n { channelId: 'Channel must be connected to import history' },\n { status: 409 },\n )\n }\n\n // Adapter must declare `importHistory` \u2014 Gmail / IMAP adapters gain it\n // in Spec C. Other providers (chat, SMS) cannot do historical inbox sweeps.\n const adapter = getChannelAdapterRegistry().get(channel.providerKey)\n if (!adapter) {\n throw createCrudFormError(\n 'Channel provider is not available',\n { channelId: `No adapter registered for provider \"${channel.providerKey}\"` },\n { status: 400 },\n )\n }\n if (typeof adapter.importHistory !== 'function') {\n throw createCrudFormError(\n 'History import is not supported on this provider',\n { channelId: `Provider \"${channel.providerKey}\" does not support history import yet` },\n { status: 400 },\n )\n }\n\n const progressService = container.resolve('progressService') as ProgressService\n const progressContext: ProgressServiceContext = {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n userId: scope.userId ?? null,\n }\n\n // Concurrency guard (best-effort): refuse if another import is already\n // in-flight for the same channel. We can't query by meta in a portable way\n // without DB-specific JSON operators, so we scan the small active-jobs window.\n // This check and `createJob` below are NOT atomic \u2014 two near-simultaneous\n // requests for the same channel can both pass and enqueue. That is acceptable,\n // not corrupting: the import worker runs at concurrency 1 (duplicate jobs run\n // sequentially, never in parallel) and inbound ingest dedups by the\n // `(channel_id, external_message_id)` unique index, so the worst case is\n // wasted re-fetch work, never duplicate rows.\n const active = await progressService.getActiveJobs(progressContext)\n const conflict = active.find((job) => {\n if (job.jobType !== CHANNEL_IMPORT_HISTORY_JOB_TYPE) return false\n const meta = (job.meta ?? {}) as Record<string, unknown>\n return meta.channelId === channel.id\n })\n if (conflict) {\n throw createCrudFormError(\n 'An import is already in progress for this channel',\n { channelId: 'Another history import is already running' },\n { status: 429 },\n )\n }\n\n const progressJob = await progressService.createJob(\n {\n jobType: CHANNEL_IMPORT_HISTORY_JOB_TYPE,\n name: `Import history: ${channel.displayName ?? channel.externalIdentifier ?? channel.providerKey}`,\n description: `Import up to ${input.maxMessages} messages from the last ${input.sinceDays} days`,\n totalCount: input.maxMessages,\n cancellable: true,\n meta: {\n channelId: channel.id,\n providerKey: channel.providerKey,\n sinceDays: input.sinceDays,\n contactEmailsCount: input.contactEmails?.length ?? 0,\n maxMessages: input.maxMessages,\n },\n },\n progressContext,\n )\n\n const payload: ChannelImportHistoryJobPayload = {\n progressJobId: progressJob.id,\n channelId: channel.id,\n sinceDays: input.sinceDays,\n contactEmails: input.contactEmails,\n maxMessages: input.maxMessages,\n scope: { tenantId: scope.tenantId, organizationId: scope.organizationId },\n }\n const queue = getCommunicationChannelsQueue(COMMUNICATION_CHANNELS_QUEUES.importHistory)\n await queue.enqueue(payload as unknown as Record<string, unknown>)\n\n return { progressJobId: progressJob.id, totalCountHint: input.maxMessages }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { z } from 'zod'\nimport type { AwilixContainer } from 'awilix'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { createCrudFormError } from '@open-mercato/ui/backend/utils/serverErrors'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport type {\n ProgressService,\n ProgressServiceContext,\n} from '../../progress/lib/progressService'\nimport { CommunicationChannel } from '../data/entities'\nimport { getChannelAdapterRegistry } from '../lib/adapter-registry-singleton'\nimport { COMMUNICATION_CHANNELS_QUEUES, getCommunicationChannelsQueue } from '../lib/queue'\nimport {\n IMPORT_HISTORY_MAX_CONTACT_EMAILS,\n getImportHistoryLimits,\n type ImportHistoryLimits,\n} from '../lib/import-history-limits'\n\n/**\n * Spec B \u00A7 Phase B6 \u2014 operator-triggered backlog import.\n *\n * Validates the channel + adapter capability, creates a `ProgressJob`, then\n * enqueues a `channel-import-history` job. Returns the job id so the UI can\n * track progress via the existing ProgressTopBar.\n *\n * Concurrency guard: at most one in-flight import per channel \u2014 a second call\n * while the first is still running returns a 429 envelope via\n * `createCrudFormError`. The worker itself runs with `concurrency: 1` so a\n * dropped guard is still safe.\n */\n\nexport function createQueueImportHistorySchema(limits: ImportHistoryLimits = getImportHistoryLimits()) {\n return z.object({\n channelId: z.string().uuid(),\n sinceDays: z.number().int().min(1).max(limits.maxSinceDays).default(limits.defaultSinceDays),\n contactEmails: z\n .array(z.string().email().max(255))\n .max(IMPORT_HISTORY_MAX_CONTACT_EMAILS)\n .optional(),\n maxMessages: z.number().int().min(1).max(limits.maxMessages).default(limits.defaultMaxMessages),\n })\n}\n\nexport type QueueImportHistorySchema = ReturnType<typeof createQueueImportHistorySchema>\n\nlet cachedSchema: { key: string; schema: QueueImportHistorySchema } | null = null\n\n/**\n * The ceilings come from env, which a test may change after this module was\n * imported, so the schema is rebuilt whenever the effective limits differ from\n * the ones the cached instance was built with.\n */\nexport function getQueueImportHistorySchema(): QueueImportHistorySchema {\n const limits = getImportHistoryLimits()\n const key = `${limits.maxSinceDays}:${limits.maxMessages}`\n if (!cachedSchema || cachedSchema.key !== key) {\n cachedSchema = { key, schema: createQueueImportHistorySchema(limits) }\n }\n return cachedSchema.schema\n}\n\n/** Retained for callers that need a statically typed schema instance. */\nexport const queueImportHistorySchema = getQueueImportHistorySchema()\n\nexport type QueueImportHistoryInput = z.infer<QueueImportHistorySchema>\n\nexport interface QueueImportHistoryScope {\n tenantId: string\n organizationId: string\n userId?: string | null\n}\n\nexport interface QueueImportHistoryResult {\n progressJobId: string\n totalCountHint: number\n}\n\nexport const CHANNEL_IMPORT_HISTORY_JOB_TYPE = 'communication_channels.channel.import_history'\n\nexport interface ChannelImportHistoryJobPayload {\n progressJobId: string\n channelId: string\n sinceDays: number\n contactEmails?: string[]\n maxMessages: number\n scope: { tenantId: string; organizationId: string }\n}\n\nexport async function queueImportHistory(params: {\n container: AwilixContainer\n scope: QueueImportHistoryScope\n input: QueueImportHistoryInput\n}): Promise<QueueImportHistoryResult> {\n const input = getQueueImportHistorySchema().parse(params.input)\n const { container, scope } = params\n\n const em = (container.resolve('em') as EntityManager).fork()\n const dscope = { tenantId: scope.tenantId, organizationId: scope.organizationId }\n\n const channel = await findOneWithDecryption(\n em,\n CommunicationChannel,\n {\n id: input.channelId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n },\n undefined,\n dscope,\n )\n if (!channel) {\n throw createCrudFormError(\n 'Channel not found',\n { channelId: 'Channel not found' },\n { status: 404 },\n )\n }\n if (!channel.isActive || channel.status !== 'connected') {\n // 409 (not 400): the request is well-formed but the channel is in a\n // conflicting state (requires_reauth / error / disconnected). The UI maps\n // this to the localized \"reconnect first\" flash; see spec \u00A7 API Contracts.\n throw createCrudFormError(\n 'Channel is not connected',\n { channelId: 'Channel must be connected to import history' },\n { status: 409 },\n )\n }\n\n // Adapter must declare `importHistory` \u2014 Gmail / IMAP adapters gain it\n // in Spec C. Other providers (chat, SMS) cannot do historical inbox sweeps.\n const adapter = getChannelAdapterRegistry().get(channel.providerKey)\n if (!adapter) {\n throw createCrudFormError(\n 'Channel provider is not available',\n { channelId: `No adapter registered for provider \"${channel.providerKey}\"` },\n { status: 400 },\n )\n }\n if (typeof adapter.importHistory !== 'function') {\n throw createCrudFormError(\n 'History import is not supported on this provider',\n { channelId: `Provider \"${channel.providerKey}\" does not support history import yet` },\n { status: 400 },\n )\n }\n\n // The schema above validated against the deployment-wide ceiling; some\n // providers (IMAP) have a lower ceiling of their own (see\n // `import-history-limits.ts`). Reject rather than silently truncate \u2014 the\n // adapter itself has no way to report back \"I clamped your request\".\n const providerLimits = getImportHistoryLimits(channel.providerKey)\n if (input.sinceDays > providerLimits.maxSinceDays) {\n throw createCrudFormError(\n 'sinceDays exceeds the limit for this provider',\n { sinceDays: `Provider \"${channel.providerKey}\" supports at most ${providerLimits.maxSinceDays} days` },\n { status: 400 },\n )\n }\n if (input.maxMessages > providerLimits.maxMessages) {\n throw createCrudFormError(\n 'maxMessages exceeds the limit for this provider',\n { maxMessages: `Provider \"${channel.providerKey}\" supports at most ${providerLimits.maxMessages} messages` },\n { status: 400 },\n )\n }\n\n const progressService = container.resolve('progressService') as ProgressService\n const progressContext: ProgressServiceContext = {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n userId: scope.userId ?? null,\n }\n\n // Concurrency guard (best-effort): refuse if another import is already\n // in-flight for the same channel. We can't query by meta in a portable way\n // without DB-specific JSON operators, so we scan the small active-jobs window.\n // This check and `createJob` below are NOT atomic \u2014 two near-simultaneous\n // requests for the same channel can both pass and enqueue. That is acceptable,\n // not corrupting: the import worker runs at concurrency 1 (duplicate jobs run\n // sequentially, never in parallel) and inbound ingest dedups by the\n // `(channel_id, external_message_id)` unique index, so the worst case is\n // wasted re-fetch work, never duplicate rows.\n const active = await progressService.getActiveJobs(progressContext)\n const conflict = active.find((job) => {\n if (job.jobType !== CHANNEL_IMPORT_HISTORY_JOB_TYPE) return false\n const meta = (job.meta ?? {}) as Record<string, unknown>\n return meta.channelId === channel.id\n })\n if (conflict) {\n throw createCrudFormError(\n 'An import is already in progress for this channel',\n { channelId: 'Another history import is already running' },\n { status: 429 },\n )\n }\n\n const progressJob = await progressService.createJob(\n {\n jobType: CHANNEL_IMPORT_HISTORY_JOB_TYPE,\n name: `Import history: ${channel.displayName ?? channel.externalIdentifier ?? channel.providerKey}`,\n description: `Import up to ${input.maxMessages} messages from the last ${input.sinceDays} days`,\n totalCount: input.maxMessages,\n cancellable: true,\n meta: {\n channelId: channel.id,\n providerKey: channel.providerKey,\n sinceDays: input.sinceDays,\n contactEmailsCount: input.contactEmails?.length ?? 0,\n maxMessages: input.maxMessages,\n },\n },\n progressContext,\n )\n\n const payload: ChannelImportHistoryJobPayload = {\n progressJobId: progressJob.id,\n channelId: channel.id,\n sinceDays: input.sinceDays,\n contactEmails: input.contactEmails,\n maxMessages: input.maxMessages,\n scope: { tenantId: scope.tenantId, organizationId: scope.organizationId },\n }\n const queue = getCommunicationChannelsQueue(COMMUNICATION_CHANNELS_QUEUES.importHistory)\n await queue.enqueue(payload as unknown as Record<string, unknown>)\n\n return { progressJobId: progressJob.id, totalCountHint: input.maxMessages }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -7,7 +7,7 @@ const extensionPoints = defineModuleExtensionPoints({
7
7
  family: "generic",
8
8
  spotId: "profile:communication-channels:connect",
9
9
  supported: ["render-widget"],
10
- source: "backend/profile/communication-channels/page.tsx"
10
+ source: "backend/profile/communication-channels/ConnectChannelMenu.tsx"
11
11
  }),
12
12
  profileChannelsTable: dataTableExtensionHost({ tableId: "communication_channels.profile.channels", source: "backend/profile/communication-channels/page.tsx" })
13
13
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/modules/communication_channels/extension-points.ts"],
4
- "sourcesContent": ["import { dataTableExtensionHost, defineModuleExtensionPoints, injectionExtensionHost } from '@open-mercato/shared/modules/widgets/extension-points'\n\nexport const extensionPoints = defineModuleExtensionPoints({\n moduleId: 'communication_channels',\n hosts: {\n channelsTable: dataTableExtensionHost({ tableId: 'communication_channels.channels', source: 'backend/communication_channels/channels/page.tsx' }),\n profileConnect: injectionExtensionHost({\n family: 'generic',\n spotId: 'profile:communication-channels:connect',\n supported: ['render-widget'],\n source: 'backend/profile/communication-channels/page.tsx',\n }),\n profileChannelsTable: dataTableExtensionHost({ tableId: 'communication_channels.profile.channels', source: 'backend/profile/communication-channels/page.tsx' }),\n },\n})\n\nexport default extensionPoints\n"],
4
+ "sourcesContent": ["import { dataTableExtensionHost, defineModuleExtensionPoints, injectionExtensionHost } from '@open-mercato/shared/modules/widgets/extension-points'\n\nexport const extensionPoints = defineModuleExtensionPoints({\n moduleId: 'communication_channels',\n hosts: {\n channelsTable: dataTableExtensionHost({ tableId: 'communication_channels.channels', source: 'backend/communication_channels/channels/page.tsx' }),\n profileConnect: injectionExtensionHost({\n family: 'generic',\n spotId: 'profile:communication-channels:connect',\n supported: ['render-widget'],\n source: 'backend/profile/communication-channels/ConnectChannelMenu.tsx',\n }),\n profileChannelsTable: dataTableExtensionHost({ tableId: 'communication_channels.profile.channels', source: 'backend/profile/communication-channels/page.tsx' }),\n },\n})\n\nexport default extensionPoints\n"],
5
5
  "mappings": "AAAA,SAAS,wBAAwB,6BAA6B,8BAA8B;AAErF,MAAM,kBAAkB,4BAA4B;AAAA,EACzD,UAAU;AAAA,EACV,OAAO;AAAA,IACL,eAAe,uBAAuB,EAAE,SAAS,mCAAmC,QAAQ,mDAAmD,CAAC;AAAA,IAChJ,gBAAgB,uBAAuB;AAAA,MACrC,QAAQ;AAAA,MACR,QAAQ;AAAA,MACR,WAAW,CAAC,eAAe;AAAA,MAC3B,QAAQ;AAAA,IACV,CAAC;AAAA,IACD,sBAAsB,uBAAuB,EAAE,SAAS,2CAA2C,QAAQ,kDAAkD,CAAC;AAAA,EAChK;AACF,CAAC;AAED,IAAO,2BAAQ;",
6
6
  "names": []
7
7
  }
@@ -1,3 +1,4 @@
1
+ import { withAtomicFlush } from "@open-mercato/shared/lib/commands/flush";
1
2
  import { findOneWithDecryption, findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
2
3
  import { CommunicationChannel } from "../data/entities.js";
3
4
  import { isUniqueViolation } from "./pg-errors.js";
@@ -65,6 +66,38 @@ async function createConnectedChannelRow(args) {
65
66
  return existing;
66
67
  }
67
68
  }
69
+ if (args.adoptUnidentifiedChannel && effectiveExternalIdentifier && !isTenantWidePush) {
70
+ const unidentified = await findWithDecryption(
71
+ em,
72
+ CommunicationChannel,
73
+ { tenantId: scope.tenantId, userId, providerKey, externalIdentifier: null, deletedAt: null },
74
+ { orderBy: { createdAt: "desc" } },
75
+ dscope
76
+ );
77
+ const [adopted, ...superseded] = unidentified;
78
+ if (adopted) {
79
+ const inheritsPrimary = superseded.some((duplicate) => duplicate.isPrimary);
80
+ await withAtomicFlush(
81
+ em,
82
+ [
83
+ () => {
84
+ for (const duplicate of superseded) {
85
+ duplicate.isActive = false;
86
+ duplicate.isPrimary = false;
87
+ duplicate.status = "disconnected";
88
+ duplicate.lastError = "superseded_by_reconnect";
89
+ }
90
+ },
91
+ () => {
92
+ applyConnectionState(adopted);
93
+ if (inheritsPrimary) adopted.isPrimary = true;
94
+ }
95
+ ],
96
+ { transaction: true }
97
+ );
98
+ return adopted;
99
+ }
100
+ }
68
101
  const channel = em.create(CommunicationChannel, {
69
102
  providerKey,
70
103
  channelType: adapter.channelType,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/communication_channels/lib/connect-channel.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { CommunicationChannel } from '../data/entities'\nimport type { ChannelAdapter } from './adapter'\nimport { isUniqueViolation } from './pg-errors'\n\n/**\n * Default poll cadence (seconds) for a polling-only channel \u2014 one whose adapter\n * declares `realtimePush: false`. Push-capable channels use `null` (push-driven,\n * no fixed poll). Shared so push teardown restores the same cadence connect uses.\n */\nexport const POLLING_ONLY_DEFAULT_INTERVAL_SECONDS = 300\n\n/**\n * Thrown by {@link createConnectedChannelRow} when the same mailbox\n * (`externalIdentifier`) is already connected for this user via a DIFFERENT\n * provider. Both channels would poll the same inbox, and the per-channel\n * `(channel_id, external_message_id)` dedup cannot dedupe the same email across\n * channels \u2014 so every message would be ingested (and threaded) twice.\n */\nexport class MailboxAlreadyConnectedError extends Error {\n readonly externalIdentifier: string\n readonly existingProviderKey: string\n constructor(externalIdentifier: string, existingProviderKey: string) {\n super(`Mailbox ${externalIdentifier} is already connected via ${existingProviderKey}`)\n this.name = 'MailboxAlreadyConnectedError'\n this.externalIdentifier = externalIdentifier\n this.existingProviderKey = existingProviderKey\n }\n}\n\nexport interface CreateConnectedChannelRowArgs {\n em: EntityManager\n adapter: Pick<ChannelAdapter, 'channelType' | 'capabilities'>\n providerKey: string\n displayName: string\n externalIdentifier: string | null\n credentialsRefId: string | null\n /** Connecting user, or `null` for a tenant-wide channel (push: FCM/APNs/Expo). */\n userId: string | null\n scope: { tenantId: string; organizationId: string | null }\n /**\n * Explicit poll-interval override (seconds). When omitted, it is derived from\n * the adapter's push capability (push-capable \u2192 null, polling-only \u2192 300).\n */\n pollIntervalSeconds?: number | null\n}\n\n/**\n * Create + persist a `CommunicationChannel` row for a connect flow. `userId` is\n * the connecting user for per-user channels (Gmail/IMAP) or `null` for tenant-wide\n * channels (push: FCM/APNs/Expo). Shared by the credential-connect command and the\n * OAuth callback so both entry points use one channel-shape implementation instead\n * of duplicating `em.create`.\n *\n * When credentials could not be persisted (`credentialsRefId === null`) the row\n * is created in `requires_reauth` + `isActive=false` so workers don't poll a\n * credential-less channel; the user reconnects to recover.\n */\nexport async function createConnectedChannelRow(\n args: CreateConnectedChannelRowArgs,\n): Promise<CommunicationChannel> {\n const { em, adapter, providerKey, displayName, externalIdentifier, credentialsRefId, userId, scope } = args\n const credentialsAvailable = credentialsRefId !== null\n\n // A tenant-wide push channel (FCM/APNs/Expo, `user_id = NULL`) is a mailbox-less channel keyed on\n // (tenant, provider, channel_type='push', user_id NULL), enforced by\n // `communication_channels_tenant_push_provider_uq`. Push credential schemas are `.passthrough()`, so a\n // stray `email`/`username`/`fromAddress` key can leak through the connect command as an\n // `externalIdentifier`. Left in place it would send a push reconnect down the mailbox dedup branch\n // below \u2014 which never matches the existing push row \u2014 so the INSERT hits the tenant-push unique index,\n // and the mailbox recovery filter can't find the winner \u21D2 an unrecoverable 500. Drop the stray\n // identifier for tenant-wide push so the guard, the dedup key, and the stored row all stay consistent\n // with that index.\n const isTenantWidePush = adapter.channelType === 'push' && userId === null\n const effectiveExternalIdentifier = isTenantWidePush ? null : externalIdentifier\n const pollIntervalSeconds =\n args.pollIntervalSeconds !== undefined\n ? args.pollIntervalSeconds\n : adapter.capabilities?.realtimePush === false\n ? POLLING_ONLY_DEFAULT_INTERVAL_SECONDS\n : null\n const dscope = { tenantId: scope.tenantId, organizationId: scope.organizationId ?? null }\n\n // Cross-provider duplicate guard: the same mailbox must not be connected via\n // two providers for one user. Both channels would poll the same inbox, and the\n // per-channel `(channel_id, external_message_id)` dedup cannot dedupe the same\n // email across channels \u2014 so every message would be ingested (and threaded)\n // twice. Reconnecting the SAME provider/mailbox is fine (healed below); this\n // only blocks a DIFFERENT provider for an already-connected address.\n if (effectiveExternalIdentifier) {\n const normalized = effectiveExternalIdentifier.toLowerCase()\n const userChannels = (await findWithDecryption(\n em,\n CommunicationChannel,\n { tenantId: scope.tenantId, userId, deletedAt: null },\n undefined,\n dscope,\n )) as CommunicationChannel[]\n const conflict = userChannels.find(\n (existing) =>\n existing.providerKey !== providerKey &&\n typeof existing.externalIdentifier === 'string' &&\n existing.externalIdentifier.toLowerCase() === normalized,\n )\n if (conflict) {\n throw new MailboxAlreadyConnectedError(effectiveExternalIdentifier, conflict.providerKey)\n }\n }\n\n // Heal-on-reconnect dedup key. Two shapes:\n // - mailbox channels (Gmail/IMAP): keyed on (tenant, user, provider, mailbox);\n // only rows with a known `externalIdentifier` participate.\n // - tenant-wide push channels (FCM/APNs/Expo): no mailbox, `user_id = NULL`, so\n // keyed on (tenant, provider, channel_type='push', user_id NULL). Without this\n // every admin reconnect would insert a duplicate (fan-out takes the oldest per\n // provider, so duplicates are silent noise + a concurrent-connect race).\n // `null` \u21D2 no dedup key (identifier-less non-push channel): always insert.\n const dedupeFilter = isTenantWidePush\n ? {\n tenantId: scope.tenantId,\n userId: null,\n providerKey,\n channelType: 'push',\n deletedAt: null,\n }\n : effectiveExternalIdentifier\n ? {\n tenantId: scope.tenantId,\n userId,\n providerKey,\n externalIdentifier: effectiveExternalIdentifier,\n deletedAt: null,\n }\n : null\n\n // Heal-on-reconnect: a channel for the same dedup key already exists when the\n // user re-runs OAuth / reconnects after a `requires_reauth`, or an admin\n // re-submits a tenant push provider's credentials. Update it in place rather\n // than inserting a duplicate row \u2014 a duplicate would stay `isActive` and keep\n // polling + re-emitting reauth banners, and register a second competing push\n // subscription. Backed by the partial unique indexes\n // `communication_channels_user_provider_external_uq` (mailbox) and\n // `communication_channels_tenant_push_provider_uq` (tenant push).\n const applyConnectionState = (target: CommunicationChannel): void => {\n target.channelType = adapter.channelType\n target.displayName = displayName\n target.externalIdentifier = effectiveExternalIdentifier ?? null\n target.credentialsRef = credentialsRefId\n target.capabilities = adapter.capabilities as unknown as Record<string, unknown>\n target.isActive = credentialsAvailable\n target.pollIntervalSeconds = pollIntervalSeconds\n target.status = credentialsAvailable ? 'connected' : 'requires_reauth'\n target.lastError = credentialsAvailable ? null : 'credentials_persist_failed'\n }\n\n if (dedupeFilter) {\n const existing = await findOneWithDecryption(em, CommunicationChannel, dedupeFilter, undefined, dscope)\n if (existing) {\n applyConnectionState(existing)\n await em.flush()\n return existing\n }\n }\n\n const channel = em.create(CommunicationChannel, {\n providerKey,\n channelType: adapter.channelType,\n displayName,\n externalIdentifier: effectiveExternalIdentifier ?? null,\n credentialsRef: credentialsRefId,\n capabilities: adapter.capabilities as unknown as Record<string, unknown>,\n isActive: credentialsAvailable,\n userId,\n isPrimary: false,\n pollIntervalSeconds,\n status: credentialsAvailable ? 'connected' : 'requires_reauth',\n lastError: credentialsAvailable ? null : 'credentials_persist_failed',\n tenantId: scope.tenantId,\n organizationId: scope.organizationId ?? null,\n })\n em.persist(channel)\n try {\n await em.flush()\n return channel\n } catch (err) {\n // Concurrent connect for the same mailbox won the race (partial unique index\n // rejected ours). Re-select the winner on a clean fork and heal it so the\n // caller still gets a single, connected channel.\n if (!isUniqueViolation(err) || !dedupeFilter) throw err\n const reEm = em.fork()\n const winner = await findOneWithDecryption(reEm, CommunicationChannel, dedupeFilter, undefined, dscope)\n if (!winner) throw err\n applyConnectionState(winner)\n await reEm.flush()\n return winner\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'\nimport { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { CommunicationChannel } from '../data/entities'\nimport type { ChannelAdapter } from './adapter'\nimport { isUniqueViolation } from './pg-errors'\n\n/**\n * Default poll cadence (seconds) for a polling-only channel \u2014 one whose adapter\n * declares `realtimePush: false`. Push-capable channels use `null` (push-driven,\n * no fixed poll). Shared so push teardown restores the same cadence connect uses.\n */\nexport const POLLING_ONLY_DEFAULT_INTERVAL_SECONDS = 300\n\n/**\n * Thrown by {@link createConnectedChannelRow} when the same mailbox\n * (`externalIdentifier`) is already connected for this user via a DIFFERENT\n * provider. Both channels would poll the same inbox, and the per-channel\n * `(channel_id, external_message_id)` dedup cannot dedupe the same email across\n * channels \u2014 so every message would be ingested (and threaded) twice.\n */\nexport class MailboxAlreadyConnectedError extends Error {\n readonly externalIdentifier: string\n readonly existingProviderKey: string\n constructor(externalIdentifier: string, existingProviderKey: string) {\n super(`Mailbox ${externalIdentifier} is already connected via ${existingProviderKey}`)\n this.name = 'MailboxAlreadyConnectedError'\n this.externalIdentifier = externalIdentifier\n this.existingProviderKey = existingProviderKey\n }\n}\n\nexport interface CreateConnectedChannelRowArgs {\n em: EntityManager\n adapter: Pick<ChannelAdapter, 'channelType' | 'capabilities'>\n providerKey: string\n displayName: string\n externalIdentifier: string | null\n credentialsRefId: string | null\n /** Connecting user, or `null` for a tenant-wide channel (push: FCM/APNs/Expo). */\n userId: string | null\n scope: { tenantId: string; organizationId: string | null }\n /**\n * Explicit poll-interval override (seconds). When omitted, it is derived from\n * the adapter's push capability (push-capable \u2192 null, polling-only \u2192 300).\n */\n pollIntervalSeconds?: number | null\n /**\n * The identifier was reported by the adapter itself (not sniffed from the\n * credential bag). Rows this user created for the same provider before the\n * adapter reported one carry `external_identifier = NULL` and would never match\n * the heal key, so the newest such row is adopted instead of inserting another\n * one next to it.\n */\n adoptUnidentifiedChannel?: boolean\n}\n\n/**\n * Create + persist a `CommunicationChannel` row for a connect flow. `userId` is\n * the connecting user for per-user channels (Gmail/IMAP) or `null` for tenant-wide\n * channels (push: FCM/APNs/Expo). Shared by the credential-connect command and the\n * OAuth callback so both entry points use one channel-shape implementation instead\n * of duplicating `em.create`.\n *\n * When credentials could not be persisted (`credentialsRefId === null`) the row\n * is created in `requires_reauth` + `isActive=false` so workers don't poll a\n * credential-less channel; the user reconnects to recover.\n */\nexport async function createConnectedChannelRow(\n args: CreateConnectedChannelRowArgs,\n): Promise<CommunicationChannel> {\n const { em, adapter, providerKey, displayName, externalIdentifier, credentialsRefId, userId, scope } = args\n const credentialsAvailable = credentialsRefId !== null\n\n // A tenant-wide push channel (FCM/APNs/Expo, `user_id = NULL`) is a mailbox-less channel keyed on\n // (tenant, provider, channel_type='push', user_id NULL), enforced by\n // `communication_channels_tenant_push_provider_uq`. Push credential schemas are `.passthrough()`, so a\n // stray `email`/`username`/`fromAddress` key can leak through the connect command as an\n // `externalIdentifier`. Left in place it would send a push reconnect down the mailbox dedup branch\n // below \u2014 which never matches the existing push row \u2014 so the INSERT hits the tenant-push unique index,\n // and the mailbox recovery filter can't find the winner \u21D2 an unrecoverable 500. Drop the stray\n // identifier for tenant-wide push so the guard, the dedup key, and the stored row all stay consistent\n // with that index.\n const isTenantWidePush = adapter.channelType === 'push' && userId === null\n const effectiveExternalIdentifier = isTenantWidePush ? null : externalIdentifier\n const pollIntervalSeconds =\n args.pollIntervalSeconds !== undefined\n ? args.pollIntervalSeconds\n : adapter.capabilities?.realtimePush === false\n ? POLLING_ONLY_DEFAULT_INTERVAL_SECONDS\n : null\n const dscope = { tenantId: scope.tenantId, organizationId: scope.organizationId ?? null }\n\n // Cross-provider duplicate guard: the same mailbox must not be connected via\n // two providers for one user. Both channels would poll the same inbox, and the\n // per-channel `(channel_id, external_message_id)` dedup cannot dedupe the same\n // email across channels \u2014 so every message would be ingested (and threaded)\n // twice. Reconnecting the SAME provider/mailbox is fine (healed below); this\n // only blocks a DIFFERENT provider for an already-connected address.\n if (effectiveExternalIdentifier) {\n const normalized = effectiveExternalIdentifier.toLowerCase()\n const userChannels = (await findWithDecryption(\n em,\n CommunicationChannel,\n { tenantId: scope.tenantId, userId, deletedAt: null },\n undefined,\n dscope,\n )) as CommunicationChannel[]\n const conflict = userChannels.find(\n (existing) =>\n existing.providerKey !== providerKey &&\n typeof existing.externalIdentifier === 'string' &&\n existing.externalIdentifier.toLowerCase() === normalized,\n )\n if (conflict) {\n throw new MailboxAlreadyConnectedError(effectiveExternalIdentifier, conflict.providerKey)\n }\n }\n\n // Heal-on-reconnect dedup key. Two shapes:\n // - mailbox channels (Gmail/IMAP): keyed on (tenant, user, provider, mailbox);\n // only rows with a known `externalIdentifier` participate.\n // - tenant-wide push channels (FCM/APNs/Expo): no mailbox, `user_id = NULL`, so\n // keyed on (tenant, provider, channel_type='push', user_id NULL). Without this\n // every admin reconnect would insert a duplicate (fan-out takes the oldest per\n // provider, so duplicates are silent noise + a concurrent-connect race).\n // `null` \u21D2 no dedup key (identifier-less non-push channel): always insert.\n const dedupeFilter = isTenantWidePush\n ? {\n tenantId: scope.tenantId,\n userId: null,\n providerKey,\n channelType: 'push',\n deletedAt: null,\n }\n : effectiveExternalIdentifier\n ? {\n tenantId: scope.tenantId,\n userId,\n providerKey,\n externalIdentifier: effectiveExternalIdentifier,\n deletedAt: null,\n }\n : null\n\n // Heal-on-reconnect: a channel for the same dedup key already exists when the\n // user re-runs OAuth / reconnects after a `requires_reauth`, or an admin\n // re-submits a tenant push provider's credentials. Update it in place rather\n // than inserting a duplicate row \u2014 a duplicate would stay `isActive` and keep\n // polling + re-emitting reauth banners, and register a second competing push\n // subscription. Backed by the partial unique indexes\n // `communication_channels_user_provider_external_uq` (mailbox) and\n // `communication_channels_tenant_push_provider_uq` (tenant push).\n const applyConnectionState = (target: CommunicationChannel): void => {\n target.channelType = adapter.channelType\n target.displayName = displayName\n target.externalIdentifier = effectiveExternalIdentifier ?? null\n target.credentialsRef = credentialsRefId\n target.capabilities = adapter.capabilities as unknown as Record<string, unknown>\n target.isActive = credentialsAvailable\n target.pollIntervalSeconds = pollIntervalSeconds\n target.status = credentialsAvailable ? 'connected' : 'requires_reauth'\n target.lastError = credentialsAvailable ? null : 'credentials_persist_failed'\n }\n\n if (dedupeFilter) {\n const existing = await findOneWithDecryption(em, CommunicationChannel, dedupeFilter, undefined, dscope)\n if (existing) {\n applyConnectionState(existing)\n await em.flush()\n return existing\n }\n }\n\n if (args.adoptUnidentifiedChannel && effectiveExternalIdentifier && !isTenantWidePush) {\n const unidentified = (await findWithDecryption(\n em,\n CommunicationChannel,\n { tenantId: scope.tenantId, userId, providerKey, externalIdentifier: null, deletedAt: null },\n { orderBy: { createdAt: 'desc' } },\n dscope,\n )) as CommunicationChannel[]\n const [adopted, ...superseded] = unidentified\n if (adopted) {\n // Per-user credentials are stored once per provider, so every older\n // identifier-less row is a duplicate of the adopted one. Left active, a\n // stuck `requires_reauth` duplicate would keep its reauth banner forever.\n const inheritsPrimary = superseded.some((duplicate) => duplicate.isPrimary)\n // Postgres checks the partial `communication_channels_one_primary_per_user_uq`\n // index per statement and one flush does not order the SET-false before\n // the SET-true, so the old primary is cleared in its own phase (each phase\n // flushes) before the adopted row takes the flag, inside one transaction.\n await withAtomicFlush(\n em,\n [\n () => {\n for (const duplicate of superseded) {\n duplicate.isActive = false\n duplicate.isPrimary = false\n duplicate.status = 'disconnected'\n duplicate.lastError = 'superseded_by_reconnect'\n }\n },\n () => {\n applyConnectionState(adopted)\n if (inheritsPrimary) adopted.isPrimary = true\n },\n ],\n { transaction: true },\n )\n return adopted\n }\n }\n\n const channel = em.create(CommunicationChannel, {\n providerKey,\n channelType: adapter.channelType,\n displayName,\n externalIdentifier: effectiveExternalIdentifier ?? null,\n credentialsRef: credentialsRefId,\n capabilities: adapter.capabilities as unknown as Record<string, unknown>,\n isActive: credentialsAvailable,\n userId,\n isPrimary: false,\n pollIntervalSeconds,\n status: credentialsAvailable ? 'connected' : 'requires_reauth',\n lastError: credentialsAvailable ? null : 'credentials_persist_failed',\n tenantId: scope.tenantId,\n organizationId: scope.organizationId ?? null,\n })\n em.persist(channel)\n try {\n await em.flush()\n return channel\n } catch (err) {\n // Concurrent connect for the same mailbox won the race (partial unique index\n // rejected ours). Re-select the winner on a clean fork and heal it so the\n // caller still gets a single, connected channel.\n if (!isUniqueViolation(err) || !dedupeFilter) throw err\n const reEm = em.fork()\n const winner = await findOneWithDecryption(reEm, CommunicationChannel, dedupeFilter, undefined, dscope)\n if (!winner) throw err\n applyConnectionState(winner)\n await reEm.flush()\n return winner\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,69 @@
1
+ const IMPORT_HISTORY_DEFAULT_SINCE_DAYS = 30;
2
+ const IMPORT_HISTORY_DEFAULT_MAX_MESSAGES = 1e3;
3
+ const IMPORT_HISTORY_MAX_SINCE_DAYS_FALLBACK = 3650;
4
+ const IMPORT_HISTORY_MAX_MESSAGES_FALLBACK = 5e4;
5
+ const IMPORT_HISTORY_MAX_CONTACT_EMAILS = 200;
6
+ const IMPORT_HISTORY_MIN_PAGE_BUDGET = 100;
7
+ const IMPORT_HISTORY_ABSOLUTE_MAX_PAGES = 1e4;
8
+ const IMPORT_HISTORY_MAX_EMPTY_PAGES = 10;
9
+ const IMPORT_HISTORY_MAX_SINCE_DAYS_ENV = "OM_IMPORT_HISTORY_MAX_SINCE_DAYS";
10
+ const IMPORT_HISTORY_MAX_MESSAGES_ENV = "OM_IMPORT_HISTORY_MAX_MESSAGES";
11
+ function readPositiveIntEnv(name, fallback) {
12
+ const raw = typeof process !== "undefined" ? process.env?.[name] : void 0;
13
+ const parsed = Number.parseInt(raw ?? "", 10);
14
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
15
+ }
16
+ function getImportHistoryMaxSinceDays() {
17
+ return Math.max(
18
+ IMPORT_HISTORY_DEFAULT_SINCE_DAYS,
19
+ readPositiveIntEnv(IMPORT_HISTORY_MAX_SINCE_DAYS_ENV, IMPORT_HISTORY_MAX_SINCE_DAYS_FALLBACK)
20
+ );
21
+ }
22
+ function getImportHistoryMaxMessages() {
23
+ return Math.max(
24
+ IMPORT_HISTORY_DEFAULT_MAX_MESSAGES,
25
+ readPositiveIntEnv(IMPORT_HISTORY_MAX_MESSAGES_ENV, IMPORT_HISTORY_MAX_MESSAGES_FALLBACK)
26
+ );
27
+ }
28
+ const PROVIDER_MAX_SINCE_DAYS = {
29
+ imap: 365
30
+ };
31
+ const PROVIDER_MAX_MESSAGES = {
32
+ imap: 5e3
33
+ };
34
+ function getImportHistoryLimits(providerKey) {
35
+ const maxSinceDays = getImportHistoryMaxSinceDays();
36
+ const maxMessages = getImportHistoryMaxMessages();
37
+ const providerMaxSinceDays = providerKey ? PROVIDER_MAX_SINCE_DAYS[providerKey] : void 0;
38
+ const providerMaxMessages = providerKey ? PROVIDER_MAX_MESSAGES[providerKey] : void 0;
39
+ return {
40
+ defaultSinceDays: IMPORT_HISTORY_DEFAULT_SINCE_DAYS,
41
+ defaultMaxMessages: IMPORT_HISTORY_DEFAULT_MAX_MESSAGES,
42
+ maxSinceDays: providerMaxSinceDays !== void 0 ? Math.min(maxSinceDays, providerMaxSinceDays) : maxSinceDays,
43
+ maxMessages: providerMaxMessages !== void 0 ? Math.min(maxMessages, providerMaxMessages) : maxMessages
44
+ };
45
+ }
46
+ function resolveImportHistoryPageBudget(maxMessages) {
47
+ if (!Number.isFinite(maxMessages) || maxMessages <= 0) return IMPORT_HISTORY_MIN_PAGE_BUDGET;
48
+ return Math.min(
49
+ IMPORT_HISTORY_ABSOLUTE_MAX_PAGES,
50
+ Math.max(IMPORT_HISTORY_MIN_PAGE_BUDGET, Math.ceil(maxMessages))
51
+ );
52
+ }
53
+ export {
54
+ IMPORT_HISTORY_ABSOLUTE_MAX_PAGES,
55
+ IMPORT_HISTORY_DEFAULT_MAX_MESSAGES,
56
+ IMPORT_HISTORY_DEFAULT_SINCE_DAYS,
57
+ IMPORT_HISTORY_MAX_CONTACT_EMAILS,
58
+ IMPORT_HISTORY_MAX_EMPTY_PAGES,
59
+ IMPORT_HISTORY_MAX_MESSAGES_ENV,
60
+ IMPORT_HISTORY_MAX_MESSAGES_FALLBACK,
61
+ IMPORT_HISTORY_MAX_SINCE_DAYS_ENV,
62
+ IMPORT_HISTORY_MAX_SINCE_DAYS_FALLBACK,
63
+ IMPORT_HISTORY_MIN_PAGE_BUDGET,
64
+ getImportHistoryLimits,
65
+ getImportHistoryMaxMessages,
66
+ getImportHistoryMaxSinceDays,
67
+ resolveImportHistoryPageBudget
68
+ };
69
+ //# sourceMappingURL=import-history-limits.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/communication_channels/lib/import-history-limits.ts"],
4
+ "sourcesContent": ["/**\n * Single source of truth for the operator-triggered history-import bounds.\n *\n * Defaults are the values a request gets when it omits the field; ceilings are\n * the largest value the hub accepts. Ceilings are deliberately wide enough for\n * a multi-year backfill and can be raised or lowered per deployment through\n * `OM_IMPORT_HISTORY_MAX_SINCE_DAYS` / `OM_IMPORT_HISTORY_MAX_MESSAGES`.\n *\n * Env is read at call time (not module load) so a deployment can change the\n * ceiling without a rebuild, and so tests can exercise the override. In the\n * browser bundle the variables are absent and the built-in ceilings apply \u2014\n * client-side validation is a UX affordance, the API remains the authority.\n */\n\nexport const IMPORT_HISTORY_DEFAULT_SINCE_DAYS = 30\nexport const IMPORT_HISTORY_DEFAULT_MAX_MESSAGES = 1000\n\nexport const IMPORT_HISTORY_MAX_SINCE_DAYS_FALLBACK = 3650\nexport const IMPORT_HISTORY_MAX_MESSAGES_FALLBACK = 50000\n\nexport const IMPORT_HISTORY_MAX_CONTACT_EMAILS = 200\n\nexport const IMPORT_HISTORY_MIN_PAGE_BUDGET = 100\nexport const IMPORT_HISTORY_ABSOLUTE_MAX_PAGES = 10000\n\n/**\n * Consecutive pages that may return no messages while the adapter still claims\n * `hasMore` and advances its cursor. Empty pages are legitimate: the Gmail\n * adapter drops messages that returned 404/410 between listing and fetching,\n * and server-side filters can eliminate a whole page. A stuck adapter is caught\n * by the repeated-cursor check, so this counter only has to bound the run when\n * the cursor keeps moving but nothing is ever returned.\n */\nexport const IMPORT_HISTORY_MAX_EMPTY_PAGES = 10\n\nexport const IMPORT_HISTORY_MAX_SINCE_DAYS_ENV = 'OM_IMPORT_HISTORY_MAX_SINCE_DAYS'\nexport const IMPORT_HISTORY_MAX_MESSAGES_ENV = 'OM_IMPORT_HISTORY_MAX_MESSAGES'\n\nexport type ImportHistoryLimits = {\n defaultSinceDays: number\n defaultMaxMessages: number\n maxSinceDays: number\n maxMessages: number\n}\n\nfunction readPositiveIntEnv(name: string, fallback: number): number {\n const raw = typeof process !== 'undefined' ? process.env?.[name] : undefined\n const parsed = Number.parseInt(raw ?? '', 10)\n return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback\n}\n\nexport function getImportHistoryMaxSinceDays(): number {\n return Math.max(\n IMPORT_HISTORY_DEFAULT_SINCE_DAYS,\n readPositiveIntEnv(IMPORT_HISTORY_MAX_SINCE_DAYS_ENV, IMPORT_HISTORY_MAX_SINCE_DAYS_FALLBACK),\n )\n}\n\nexport function getImportHistoryMaxMessages(): number {\n return Math.max(\n IMPORT_HISTORY_DEFAULT_MAX_MESSAGES,\n readPositiveIntEnv(IMPORT_HISTORY_MAX_MESSAGES_ENV, IMPORT_HISTORY_MAX_MESSAGES_FALLBACK),\n )\n}\n\n/**\n * Per-provider ceilings, lower than the deployment-wide ceiling above.\n *\n * The IMAP adapter's cursor embeds the *entire* remaining UID list discovered\n * by `SEARCH` (unlike Gmail's opaque `nextPageToken` cursor), so a wide ceiling\n * there means a multi-megabyte cursor round-tripping through the worker on\n * every page. Until that adapter gets a paging cursor of its own, its ceiling\n * stays at the pre-widening values so the dialog, the hub's validation, and the\n * adapter's own clamp (`packages/channel-imap/.../adapter.ts`) agree \u2014 a\n * request above this is rejected rather than silently truncated.\n */\nconst PROVIDER_MAX_SINCE_DAYS: Record<string, number> = {\n imap: 365,\n}\nconst PROVIDER_MAX_MESSAGES: Record<string, number> = {\n imap: 5000,\n}\n\nexport function getImportHistoryLimits(providerKey?: string): ImportHistoryLimits {\n const maxSinceDays = getImportHistoryMaxSinceDays()\n const maxMessages = getImportHistoryMaxMessages()\n const providerMaxSinceDays = providerKey ? PROVIDER_MAX_SINCE_DAYS[providerKey] : undefined\n const providerMaxMessages = providerKey ? PROVIDER_MAX_MESSAGES[providerKey] : undefined\n return {\n defaultSinceDays: IMPORT_HISTORY_DEFAULT_SINCE_DAYS,\n defaultMaxMessages: IMPORT_HISTORY_DEFAULT_MAX_MESSAGES,\n maxSinceDays: providerMaxSinceDays !== undefined ? Math.min(maxSinceDays, providerMaxSinceDays) : maxSinceDays,\n maxMessages: providerMaxMessages !== undefined ? Math.min(maxMessages, providerMaxMessages) : maxMessages,\n }\n}\n\n/**\n * Page budget for the import drain loop, derived from the effective message cap\n * so a large import is never truncated before it reaches that cap. It is only a\n * backstop: an adapter that never makes progress is stopped much earlier by the\n * consecutive-empty-page guard.\n */\nexport function resolveImportHistoryPageBudget(maxMessages: number): number {\n if (!Number.isFinite(maxMessages) || maxMessages <= 0) return IMPORT_HISTORY_MIN_PAGE_BUDGET\n return Math.min(\n IMPORT_HISTORY_ABSOLUTE_MAX_PAGES,\n Math.max(IMPORT_HISTORY_MIN_PAGE_BUDGET, Math.ceil(maxMessages)),\n )\n}\n"],
5
+ "mappings": "AAcO,MAAM,oCAAoC;AAC1C,MAAM,sCAAsC;AAE5C,MAAM,yCAAyC;AAC/C,MAAM,uCAAuC;AAE7C,MAAM,oCAAoC;AAE1C,MAAM,iCAAiC;AACvC,MAAM,oCAAoC;AAU1C,MAAM,iCAAiC;AAEvC,MAAM,oCAAoC;AAC1C,MAAM,kCAAkC;AAS/C,SAAS,mBAAmB,MAAc,UAA0B;AAClE,QAAM,MAAM,OAAO,YAAY,cAAc,QAAQ,MAAM,IAAI,IAAI;AACnE,QAAM,SAAS,OAAO,SAAS,OAAO,IAAI,EAAE;AAC5C,SAAO,OAAO,SAAS,MAAM,KAAK,SAAS,IAAI,SAAS;AAC1D;AAEO,SAAS,+BAAuC;AACrD,SAAO,KAAK;AAAA,IACV;AAAA,IACA,mBAAmB,mCAAmC,sCAAsC;AAAA,EAC9F;AACF;AAEO,SAAS,8BAAsC;AACpD,SAAO,KAAK;AAAA,IACV;AAAA,IACA,mBAAmB,iCAAiC,oCAAoC;AAAA,EAC1F;AACF;AAaA,MAAM,0BAAkD;AAAA,EACtD,MAAM;AACR;AACA,MAAM,wBAAgD;AAAA,EACpD,MAAM;AACR;AAEO,SAAS,uBAAuB,aAA2C;AAChF,QAAM,eAAe,6BAA6B;AAClD,QAAM,cAAc,4BAA4B;AAChD,QAAM,uBAAuB,cAAc,wBAAwB,WAAW,IAAI;AAClF,QAAM,sBAAsB,cAAc,sBAAsB,WAAW,IAAI;AAC/E,SAAO;AAAA,IACL,kBAAkB;AAAA,IAClB,oBAAoB;AAAA,IACpB,cAAc,yBAAyB,SAAY,KAAK,IAAI,cAAc,oBAAoB,IAAI;AAAA,IAClG,aAAa,wBAAwB,SAAY,KAAK,IAAI,aAAa,mBAAmB,IAAI;AAAA,EAChG;AACF;AAQO,SAAS,+BAA+B,aAA6B;AAC1E,MAAI,CAAC,OAAO,SAAS,WAAW,KAAK,eAAe,EAAG,QAAO;AAC9D,SAAO,KAAK;AAAA,IACV;AAAA,IACA,KAAK,IAAI,gCAAgC,KAAK,KAAK,WAAW,CAAC;AAAA,EACjE;AACF;",
6
+ "names": []
7
+ }