@open-mercato/core 0.9.0 → 0.9.1-develop.7462.1.94c09f158f
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/generated/entities/customer_dictionary_entry/index.js +2 -0
- package/dist/generated/entities/customer_dictionary_entry/index.js.map +2 -2
- package/dist/generated/entities/customer_interaction/index.js +2 -0
- package/dist/generated/entities/customer_interaction/index.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +2 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/attachments/api/route.js +29 -3
- package/dist/modules/attachments/api/route.js.map +2 -2
- package/dist/modules/attachments/lib/ocrLimits.js +49 -0
- package/dist/modules/attachments/lib/ocrLimits.js.map +7 -0
- package/dist/modules/attachments/lib/ocrQueue.js +76 -2
- package/dist/modules/attachments/lib/ocrQueue.js.map +2 -2
- package/dist/modules/attachments/lib/ocrService.js +4 -0
- package/dist/modules/attachments/lib/ocrService.js.map +2 -2
- package/dist/modules/attachments/lib/pdfProcessing.js +14 -2
- package/dist/modules/attachments/lib/pdfProcessing.js.map +2 -2
- package/dist/modules/attachments/lib/scoped-upload-service.js +4 -2
- package/dist/modules/attachments/lib/scoped-upload-service.js.map +2 -2
- package/dist/modules/attachments/lib/textExtraction.js +13 -1
- package/dist/modules/attachments/lib/textExtraction.js.map +2 -2
- package/dist/modules/catalog/commands/categories.js +1 -1
- package/dist/modules/catalog/commands/categories.js.map +2 -2
- package/dist/modules/catalog/commands/productDeleteChildren.js +389 -0
- package/dist/modules/catalog/commands/productDeleteChildren.js.map +7 -0
- package/dist/modules/catalog/commands/products.js +52 -17
- package/dist/modules/catalog/commands/products.js.map +2 -2
- package/dist/modules/catalog/search.js +3 -1
- package/dist/modules/catalog/search.js.map +2 -2
- package/dist/modules/communication_channels/api/get/oauth/[provider]/callback/route.js +2 -1
- package/dist/modules/communication_channels/api/get/oauth/[provider]/callback/route.js.map +2 -2
- package/dist/modules/communication_channels/api/post/channels/connect/credentials/route.js +4 -6
- package/dist/modules/communication_channels/api/post/channels/connect/credentials/route.js.map +2 -2
- package/dist/modules/communication_channels/api/post/channels/connect/tenant-credentials/route.js +4 -6
- package/dist/modules/communication_channels/api/post/channels/connect/tenant-credentials/route.js.map +2 -2
- package/dist/modules/communication_channels/api/post/oauth/[provider]/initiate/route.js +47 -11
- package/dist/modules/communication_channels/api/post/oauth/[provider]/initiate/route.js.map +3 -3
- package/dist/modules/communication_channels/backend/profile/communication-channels/page.js +10 -7
- package/dist/modules/communication_channels/backend/profile/communication-channels/page.js.map +2 -2
- package/dist/modules/communication_channels/commands/deliver-outbound-message.js +5 -1
- package/dist/modules/communication_channels/commands/deliver-outbound-message.js.map +2 -2
- package/dist/modules/communication_channels/lib/inbound-message-origin.js +1 -1
- package/dist/modules/communication_channels/lib/inbound-message-origin.js.map +2 -2
- package/dist/modules/communication_channels/lib/oauth-result.js +12 -0
- package/dist/modules/communication_channels/lib/oauth-result.js.map +7 -0
- package/dist/modules/communication_channels/lib/oauth-state.js +4 -1
- package/dist/modules/communication_channels/lib/oauth-state.js.map +2 -2
- package/dist/modules/communication_channels/lib/use-connect-channel.js +10 -0
- package/dist/modules/communication_channels/lib/use-connect-channel.js.map +2 -2
- package/dist/modules/customers/api/activity-types/route.js +151 -0
- package/dist/modules/customers/api/activity-types/route.js.map +7 -0
- package/dist/modules/customers/api/deals/[id]/people/route.js +58 -5
- package/dist/modules/customers/api/deals/[id]/people/route.js.map +2 -2
- package/dist/modules/customers/api/dictionaries/[kind]/[id]/route.js +13 -5
- package/dist/modules/customers/api/dictionaries/[kind]/[id]/route.js.map +2 -2
- package/dist/modules/customers/api/dictionaries/[kind]/route.js +20 -5
- package/dist/modules/customers/api/dictionaries/[kind]/route.js.map +2 -2
- package/dist/modules/customers/api/interactions/route.js +3 -0
- package/dist/modules/customers/api/interactions/route.js.map +2 -2
- package/dist/modules/customers/api/interactions/tasks/route.js +1 -0
- package/dist/modules/customers/api/interactions/tasks/route.js.map +2 -2
- package/dist/modules/customers/backend/config/customers/pipeline-stages/page.meta.js +18 -0
- package/dist/modules/customers/backend/config/customers/pipeline-stages/page.meta.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.js +0 -23
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/[id]/page.js +15 -17
- package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
- package/dist/modules/customers/calendar-event-types.js +548 -0
- package/dist/modules/customers/calendar-event-types.js.map +7 -0
- package/dist/modules/customers/commands/dictionaries.js +70 -10
- package/dist/modules/customers/commands/dictionaries.js.map +2 -2
- package/dist/modules/customers/commands/interactions.js +187 -22
- package/dist/modules/customers/commands/interactions.js.map +3 -3
- package/dist/modules/customers/components/ActivityTypeEditor.js +288 -0
- package/dist/modules/customers/components/ActivityTypeEditor.js.map +7 -0
- package/dist/modules/customers/components/DictionarySettings.js +3 -1
- package/dist/modules/customers/components/DictionarySettings.js.map +2 -2
- package/dist/modules/customers/components/calendar/CalendarEventEditor.js +391 -297
- package/dist/modules/customers/components/calendar/CalendarEventEditor.js.map +3 -3
- package/dist/modules/customers/components/calendar/CalendarSettingsModal.js +9 -74
- package/dist/modules/customers/components/calendar/CalendarSettingsModal.js.map +2 -2
- package/dist/modules/customers/components/calendar/TimeGrid.js +20 -16
- package/dist/modules/customers/components/calendar/TimeGrid.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/EventTypePanel.js +218 -0
- package/dist/modules/customers/components/calendar/editor/EventTypePanel.js.map +7 -0
- package/dist/modules/customers/components/calendar/editor/LocationField.js +7 -3
- package/dist/modules/customers/components/calendar/editor/LocationField.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/PeopleField.js +5 -2
- package/dist/modules/customers/components/calendar/editor/PeopleField.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/PriorityField.js +3 -1
- package/dist/modules/customers/components/calendar/editor/PriorityField.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/RepeatField.js +9 -1
- package/dist/modules/customers/components/calendar/editor/RepeatField.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/ResourcesField.js +4 -1
- package/dist/modules/customers/components/calendar/editor/ResourcesField.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/ScheduleSection.js +15 -7
- package/dist/modules/customers/components/calendar/editor/ScheduleSection.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/SegmentGroup.js +3 -1
- package/dist/modules/customers/components/calendar/editor/SegmentGroup.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/hooks.js +28 -13
- package/dist/modules/customers/components/calendar/editor/hooks.js.map +3 -3
- package/dist/modules/customers/components/calendar/editor/inputs.js +14 -6
- package/dist/modules/customers/components/calendar/editor/inputs.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/useCalendarCustomFields.js +54 -0
- package/dist/modules/customers/components/calendar/editor/useCalendarCustomFields.js.map +7 -0
- package/dist/modules/customers/components/calendar/editor/useEventTypeCatalog.js +82 -0
- package/dist/modules/customers/components/calendar/editor/useEventTypeCatalog.js.map +7 -0
- package/dist/modules/customers/components/calendar/types.js +1 -0
- package/dist/modules/customers/components/calendar/types.js.map +2 -2
- package/dist/modules/customers/components/detail/CompanyPeopleSection.js +78 -343
- package/dist/modules/customers/components/detail/CompanyPeopleSection.js.map +2 -2
- package/dist/modules/customers/components/detail/CreatePersonDialog.js +12 -11
- package/dist/modules/customers/components/detail/CreatePersonDialog.js.map +2 -2
- package/dist/modules/customers/components/detail/DealForm.js +7 -3
- package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
- package/dist/modules/customers/components/detail/DealPeopleSection.js +206 -0
- package/dist/modules/customers/components/detail/DealPeopleSection.js.map +7 -0
- package/dist/modules/customers/components/detail/LinkedPeopleSection.js +342 -0
- package/dist/modules/customers/components/detail/LinkedPeopleSection.js.map +7 -0
- package/dist/modules/customers/components/detail/PersonCard.js +5 -2
- package/dist/modules/customers/components/detail/PersonCard.js.map +2 -2
- package/dist/modules/customers/components/linking/adapters/personAdapter.js +3 -0
- package/dist/modules/customers/components/linking/adapters/personAdapter.js.map +2 -2
- package/dist/modules/customers/data/entities.js +6 -0
- package/dist/modules/customers/data/entities.js.map +2 -2
- package/dist/modules/customers/data/validators.js +39 -10
- package/dist/modules/customers/data/validators.js.map +2 -2
- package/dist/modules/customers/di.js +20 -1
- package/dist/modules/customers/di.js.map +2 -2
- package/dist/modules/customers/extension-points.js +13 -0
- package/dist/modules/customers/extension-points.js.map +2 -2
- package/dist/modules/customers/lib/calendar/conflicts.js +59 -16
- package/dist/modules/customers/lib/calendar/conflicts.js.map +2 -2
- package/dist/modules/customers/lib/calendar/editorPayload.js +34 -16
- package/dist/modules/customers/lib/calendar/editorPayload.js.map +2 -2
- package/dist/modules/customers/lib/calendar/eventTypeResolver.js +197 -0
- package/dist/modules/customers/lib/calendar/eventTypeResolver.js.map +7 -0
- package/dist/modules/customers/lib/calendar/interactionApplicability.js +67 -0
- package/dist/modules/customers/lib/calendar/interactionApplicability.js.map +7 -0
- package/dist/modules/customers/lib/calendar/mapItem.js +5 -2
- package/dist/modules/customers/lib/calendar/mapItem.js.map +2 -2
- package/dist/modules/customers/lib/calendar/recurrence.js +62 -1
- package/dist/modules/customers/lib/calendar/recurrence.js.map +2 -2
- package/dist/modules/customers/lib/calendar/timezone.js +111 -0
- package/dist/modules/customers/lib/calendar/timezone.js.map +7 -0
- package/dist/modules/customers/lib/interactionReadModel.js +1 -0
- package/dist/modules/customers/lib/interactionReadModel.js.map +2 -2
- package/dist/modules/customers/lib/todoCompatibility.js +1 -0
- package/dist/modules/customers/lib/todoCompatibility.js.map +2 -2
- package/dist/modules/customers/migrations/Migration20260928175058_customers.js +17 -0
- package/dist/modules/customers/migrations/Migration20260928175058_customers.js.map +7 -0
- package/dist/modules/customers/migrations/Migration20260930110235_customers.js +17 -0
- package/dist/modules/customers/migrations/Migration20260930110235_customers.js.map +7 -0
- package/dist/modules/dictionaries/components/DictionaryTable.js +60 -51
- package/dist/modules/dictionaries/components/DictionaryTable.js.map +2 -2
- package/dist/modules/messages/subscribers/message-notification.js +13 -1
- package/dist/modules/messages/subscribers/message-notification.js.map +2 -2
- package/dist/modules/payment_gateways/api/webhook/[provider]/route.js +201 -53
- package/dist/modules/payment_gateways/api/webhook/[provider]/route.js.map +2 -2
- package/dist/modules/payment_gateways/lib/gateway-service.js +37 -11
- package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
- package/dist/modules/planner/lib/availabilityMerge.js +101 -38
- package/dist/modules/planner/lib/availabilityMerge.js.map +3 -3
- package/dist/modules/planner/services/plannerAvailabilityService.js.map +2 -2
- package/dist/modules/sales/components/documents/LineItemDialog.js +159 -81
- package/dist/modules/sales/components/documents/LineItemDialog.js.map +2 -2
- package/dist/modules/sales/components/documents/lineItemUtils.js +7 -0
- package/dist/modules/sales/components/documents/lineItemUtils.js.map +2 -2
- package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js +12 -7
- package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js.map +2 -2
- package/dist/modules/sync_excel/lib/adapters/customers.js +28 -25
- package/dist/modules/sync_excel/lib/adapters/customers.js.map +2 -2
- package/generated/entities/customer_dictionary_entry/index.ts +1 -0
- package/generated/entities/customer_interaction/index.ts +1 -0
- package/generated/entity-fields-registry.ts +2 -0
- package/package.json +9 -8
- package/src/modules/attachments/api/route.ts +36 -3
- package/src/modules/attachments/lib/ocrLimits.ts +62 -0
- package/src/modules/attachments/lib/ocrQueue.ts +95 -2
- package/src/modules/attachments/lib/ocrService.ts +4 -0
- package/src/modules/attachments/lib/pdfProcessing.ts +14 -2
- package/src/modules/attachments/lib/scoped-upload-service.ts +4 -2
- package/src/modules/attachments/lib/textExtraction.ts +14 -1
- package/src/modules/auth/i18n/de.json +3 -0
- package/src/modules/auth/i18n/en.json +3 -0
- package/src/modules/auth/i18n/es.json +3 -0
- package/src/modules/auth/i18n/ko.json +3 -0
- package/src/modules/auth/i18n/pl.json +3 -0
- package/src/modules/catalog/commands/categories.ts +1 -1
- package/src/modules/catalog/commands/productDeleteChildren.ts +557 -0
- package/src/modules/catalog/commands/products.ts +58 -17
- package/src/modules/catalog/search.ts +5 -1
- package/src/modules/communication_channels/api/get/oauth/[provider]/callback/route.ts +2 -1
- package/src/modules/communication_channels/api/post/channels/connect/credentials/route.ts +4 -6
- package/src/modules/communication_channels/api/post/channels/connect/tenant-credentials/route.ts +4 -6
- package/src/modules/communication_channels/api/post/oauth/[provider]/initiate/route.ts +46 -10
- package/src/modules/communication_channels/backend/profile/communication-channels/page.tsx +12 -9
- package/src/modules/communication_channels/commands/deliver-outbound-message.ts +6 -1
- package/src/modules/communication_channels/i18n/de.json +4 -1
- package/src/modules/communication_channels/i18n/en.json +4 -1
- package/src/modules/communication_channels/i18n/es.json +4 -1
- package/src/modules/communication_channels/i18n/ko.json +4 -1
- package/src/modules/communication_channels/i18n/pl.json +4 -1
- package/src/modules/communication_channels/lib/inbound-message-origin.ts +2 -6
- package/src/modules/communication_channels/lib/oauth-result.ts +13 -0
- package/src/modules/communication_channels/lib/oauth-state.ts +4 -1
- package/src/modules/communication_channels/lib/use-connect-channel.ts +10 -0
- package/src/modules/customers/api/activity-types/route.ts +154 -0
- package/src/modules/customers/api/deals/[id]/people/route.ts +82 -3
- package/src/modules/customers/api/dictionaries/[kind]/[id]/route.ts +9 -1
- package/src/modules/customers/api/dictionaries/[kind]/route.ts +18 -2
- package/src/modules/customers/api/interactions/route.ts +4 -0
- package/src/modules/customers/api/interactions/tasks/route.ts +1 -0
- package/src/modules/customers/backend/config/customers/pipeline-stages/page.meta.ts +20 -0
- package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.ts +0 -29
- package/src/modules/customers/backend/customers/deals/[id]/page.tsx +15 -17
- package/src/modules/customers/calendar-event-types.ts +674 -0
- package/src/modules/customers/commands/dictionaries.ts +84 -8
- package/src/modules/customers/commands/interactions.ts +244 -21
- package/src/modules/customers/components/ActivityTypeEditor.tsx +239 -0
- package/src/modules/customers/components/DictionarySettings.tsx +5 -3
- package/src/modules/customers/components/calendar/CalendarEventEditor.tsx +347 -218
- package/src/modules/customers/components/calendar/CalendarSettingsModal.tsx +18 -96
- package/src/modules/customers/components/calendar/TimeGrid.tsx +20 -18
- package/src/modules/customers/components/calendar/editor/EventTypePanel.tsx +230 -0
- package/src/modules/customers/components/calendar/editor/LocationField.tsx +7 -1
- package/src/modules/customers/components/calendar/editor/PeopleField.tsx +4 -0
- package/src/modules/customers/components/calendar/editor/PriorityField.tsx +3 -0
- package/src/modules/customers/components/calendar/editor/RepeatField.tsx +10 -0
- package/src/modules/customers/components/calendar/editor/ResourcesField.tsx +4 -0
- package/src/modules/customers/components/calendar/editor/ScheduleSection.tsx +16 -4
- package/src/modules/customers/components/calendar/editor/SegmentGroup.tsx +3 -0
- package/src/modules/customers/components/calendar/editor/hooks.ts +29 -13
- package/src/modules/customers/components/calendar/editor/inputs.tsx +17 -5
- package/src/modules/customers/components/calendar/editor/useCalendarCustomFields.ts +49 -0
- package/src/modules/customers/components/calendar/editor/useEventTypeCatalog.ts +100 -0
- package/src/modules/customers/components/calendar/types.ts +1 -0
- package/src/modules/customers/components/detail/CompanyPeopleSection.tsx +94 -388
- package/src/modules/customers/components/detail/CreatePersonDialog.tsx +39 -19
- package/src/modules/customers/components/detail/DealForm.tsx +7 -3
- package/src/modules/customers/components/detail/DealPeopleSection.tsx +256 -0
- package/src/modules/customers/components/detail/LinkedPeopleSection.tsx +474 -0
- package/src/modules/customers/components/detail/PersonCard.tsx +12 -4
- package/src/modules/customers/components/detail/types.ts +1 -0
- package/src/modules/customers/components/linking/adapters/personAdapter.tsx +4 -0
- package/src/modules/customers/data/entities.ts +8 -1
- package/src/modules/customers/data/validators.ts +42 -7
- package/src/modules/customers/di.ts +21 -0
- package/src/modules/customers/extension-points.ts +13 -0
- package/src/modules/customers/i18n/de.json +104 -8
- package/src/modules/customers/i18n/en.json +104 -8
- package/src/modules/customers/i18n/es.json +104 -8
- package/src/modules/customers/i18n/ko.json +104 -8
- package/src/modules/customers/i18n/pl.json +104 -8
- package/src/modules/customers/lib/calendar/conflicts.ts +106 -27
- package/src/modules/customers/lib/calendar/editorPayload.ts +39 -19
- package/src/modules/customers/lib/calendar/eventTypeResolver.ts +258 -0
- package/src/modules/customers/lib/calendar/interactionApplicability.ts +99 -0
- package/src/modules/customers/lib/calendar/mapItem.ts +6 -2
- package/src/modules/customers/lib/calendar/recurrence.ts +65 -1
- package/src/modules/customers/lib/calendar/timezone.ts +117 -0
- package/src/modules/customers/lib/interactionReadModel.ts +1 -0
- package/src/modules/customers/lib/todoCompatibility.ts +2 -0
- package/src/modules/customers/migrations/.snapshot-open-mercato.json +34 -0
- package/src/modules/customers/migrations/Migration20260928175058_customers.ts +15 -0
- package/src/modules/customers/migrations/Migration20260930110235_customers.ts +15 -0
- package/src/modules/dictionaries/components/DictionaryTable.tsx +63 -56
- package/src/modules/messages/subscribers/message-notification.ts +13 -3
- package/src/modules/payment_gateways/api/webhook/[provider]/route.ts +243 -62
- package/src/modules/payment_gateways/lib/gateway-service.ts +39 -12
- package/src/modules/planner/lib/availabilityMerge.ts +106 -41
- package/src/modules/planner/services/plannerAvailabilityService.ts +4 -0
- package/src/modules/sales/components/documents/LineItemDialog.tsx +194 -111
- package/src/modules/sales/components/documents/lineItemUtils.ts +18 -0
- package/src/modules/staff/backend/staff/time-tracking/projects/[id]/page.tsx +25 -13
- package/src/modules/sync_excel/lib/adapters/customers.ts +33 -27
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/communication_channels/commands/deliver-outbound-message.ts"],
|
|
4
|
-
"sourcesContent": ["import { randomUUID } from 'node:crypto'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandHandler } from '@open-mercato/shared/lib/commands'\nimport { registerCommand } from '@open-mercato/shared/lib/commands'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { emitCommunicationChannelsEvent } from '../events'\nimport { refreshCredentialsIfNeeded } from '../lib/credential-refresh'\nimport { classifyOutboundError, isReauthError } from '../lib/error-classification'\nimport {\n buildBodyFooter,\n buildReferencesId,\n getOrCreateThreadToken,\n} from '../lib/thread-token'\nimport { stringOrUndefined, stripBrackets } from '../lib/email-mime'\nimport { resolveOutboundReplyExternalId, stripCallerReplyTargeting } from '../lib/outbound-reply-ref'\nimport type { ChannelAdapterRegistry } from '../lib/registry'\nimport { isUniqueViolation } from '../lib/pg-errors'\nimport { Message } from '../../messages/data/entities'\nimport {\n ChannelThreadMapping,\n CommunicationChannel,\n ExternalMessage,\n MessageChannelLink,\n} from '../data/entities'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst moduleLogger = createLogger('communication_channels').child({ component: 'deliver-outbound-message' })\n\n/**\n * Sentinel \u2014 `Message.threadId` of an internal-only (no channel link) message\n * has no matching `ChannelThreadMapping`. In that case outbound delivery is a no-op.\n */\nconst NO_THREAD_MAPPING_RESULT = { status: 'no_channel_link' as const }\n\nconst deliverInputSchema = z.object({\n messageId: z.string().uuid(),\n scope: z.object({\n tenantId: z.string().uuid(),\n organizationId: z.string().uuid().nullable(),\n }),\n /**\n * If true, force a credential refresh before sending \u2014 used by retry attempts\n * after a 401 from the provider.\n */\n forceCredentialRefresh: z.boolean().optional(),\n})\n\nexport type DeliverOutboundMessageInput = z.infer<typeof deliverInputSchema>\n\nexport type DeliverOutboundMessageResult =\n | { status: 'no_channel_link' }\n | { status: 'already_delivered'; messageId: string; channelLinkId: string }\n | {\n status: 'delivered'\n messageId: string\n channelLinkId: string\n externalMessageId: string\n providerKey: string\n }\n | {\n status: 'failed'\n messageId: string\n channelLinkId: string\n providerKey: string\n error: string\n transient: boolean\n /**\n * True when the failure was a 401 / invalid_grant \u2014 the channel was\n * flipped to `requires_reauth`. The worker uses this to attempt one\n * forced credential refresh before giving up.\n */\n requiresReauth: boolean\n }\n\nexport const COMMUNICATION_CHANNELS_DELIVER_OUTBOUND_COMMAND_ID =\n 'communication_channels.message.deliver_outbound'\n\ntype CredentialsServiceLike = {\n resolve: (\n integrationId: string,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<Record<string, unknown> | null>\n save?: (\n integrationId: string,\n credentials: Record<string, unknown>,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<void>\n}\n\ntype IntegrationLogLike = {\n log?: (entry: Record<string, unknown>) => Promise<void> | void\n warn?: (entry: Record<string, unknown>) => Promise<void> | void\n error?: (entry: Record<string, unknown>) => Promise<void> | void\n}\n\n/**\n * Outbound delivery command. Called from the outbound worker.\n *\n * Steps (SPEC-045d \u00A77):\n * 1. Re-fetch the Message by ID. Bail if internal-only (no ChannelThreadMapping).\n * 2. Resolve channel + adapter + credentials.\n * 3. Idempotently upsert a 'pending' MessageChannelLink (unique on `messageId`).\n * Skip if a 'sent'/'delivered' link already exists.\n * 4. Refresh credentials when OAuth + near expiry (or when caller forces it).\n * 5. Call `adapter.convertOutbound(...)` \u2192 channel-native content.\n * 6. Call `adapter.sendMessage(...)`.\n * 7. On success: persist ExternalMessage + flip link to 'sent', emit `.message.sent`.\n * 8. On failure: flip link to 'failed' + classify error, log to integrationLog,\n * emit `.delivery_failed`. The worker decides whether to retry based on\n * `result.transient`.\n *\n * Idempotency: the unique constraint on `message_channel_links.message_id`\n * prevents the same Message being sent twice through the channel even if the\n * subscriber fires repeatedly. Combined with the link's lifecycle state\n * (pending \u2192 sent | failed), we get safe retries.\n */\nconst deliverOutboundMessageCommand: CommandHandler<\n DeliverOutboundMessageInput,\n DeliverOutboundMessageResult\n> = {\n id: COMMUNICATION_CHANNELS_DELIVER_OUTBOUND_COMMAND_ID,\n async execute(rawInput, ctx) {\n const input = deliverInputSchema.parse(rawInput) as DeliverOutboundMessageInput\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n const dscope = {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n }\n\n // (1) Re-fetch Message by ID \u2014 never trust the event payload shape.\n const message = await findOneWithDecryption(\n em,\n Message,\n {\n id: input.messageId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n deletedAt: null,\n },\n undefined,\n dscope,\n )\n if (!message) {\n // Message was deleted before we got to deliver. Treat as no-op.\n return NO_THREAD_MAPPING_RESULT\n }\n if (!message.threadId) {\n // Message has no thread \u2192 no channel routing.\n return NO_THREAD_MAPPING_RESULT\n }\n\n // (1 cont.) Look up the channel link via ChannelThreadMapping.threadId.\n const mapping = await findOneWithDecryption(\n em,\n ChannelThreadMapping,\n {\n messageThreadId: message.threadId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n if (!mapping) {\n // Internal-only message \u2014 no channel delivery needed.\n return NO_THREAD_MAPPING_RESULT\n }\n\n // (2) Channel + adapter.\n const channel = await findOneWithDecryption(\n em,\n CommunicationChannel,\n {\n id: mapping.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 ${mapping.channelId} not found for tenant ${input.scope.tenantId} (or has been deleted)`,\n )\n }\n if (!channel.isActive) {\n throw new Error(`[internal] Channel ${mapping.channelId} is inactive; refusing to deliver outbound`)\n }\n\n const adapterRegistry = ctx.container.resolve('channelAdapterRegistry') as ChannelAdapterRegistry\n const adapter = adapterRegistry.get(channel.providerKey)\n if (!adapter) {\n throw new Error(\n `[internal] No ChannelAdapter registered for providerKey '${channel.providerKey}'. ` +\n 'Check that the provider package is enabled in modules.ts.',\n )\n }\n\n // (3) Idempotently upsert a 'pending' MessageChannelLink.\n let link = await findOneWithDecryption(\n em,\n MessageChannelLink,\n {\n messageId: message.id,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n if (\n link &&\n (link.deliveryStatus === 'queued' ||\n link.deliveryStatus === 'sent' ||\n link.deliveryStatus === 'delivered' ||\n link.deliveryStatus === 'read')\n ) {\n // Already sent (or accepted by the provider as 'queued') \u2014 short-circuit\n // so a retried job does not re-invoke the adapter and double-send.\n return {\n status: 'already_delivered',\n messageId: message.id,\n channelLinkId: link.id,\n }\n }\n if (!link) {\n link = em.create(MessageChannelLink, {\n messageId: message.id,\n externalConversationId: mapping.externalConversationId,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n direction: 'outbound',\n deliveryStatus: 'pending',\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n })\n em.persist(link)\n try {\n await em.flush()\n } catch (flushErr) {\n // Concurrency guard: the link lookup above is not atomic with this\n // insert, so two deliveries of the same message (a replayed\n // `messages.message.sent`, or an overlapping worker retry) can both\n // reach here. The `message_channel_links_message_uq` index rejects the\n // loser with a 23505. Defer to the winning job \u2014 re-read its link on a\n // fresh fork and report `already_delivered` \u2014 instead of re-invoking the\n // adapter (double send) or letting the raw error dead-letter the job.\n if (isUniqueViolation(flushErr)) {\n const winner = await findOneWithDecryption(\n em.fork(),\n MessageChannelLink,\n {\n messageId: message.id,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n if (winner) {\n return {\n status: 'already_delivered',\n messageId: message.id,\n channelLinkId: winner.id,\n }\n }\n }\n throw flushErr\n }\n }\n\n // (2 cont.) Decrypted credentials via the integrations module (if available).\n let credentialsService: CredentialsServiceLike | null = null\n try {\n credentialsService = ctx.container.resolve(\n 'integrationCredentialsService',\n ) as CredentialsServiceLike\n } catch {\n credentialsService = null\n }\n // Per-user credentials scope: pass `channel.userId` so the credentials\n // service returns this user's row, not whoever connected last. See\n // review R2-C1 / N1 (2026-05-26).\n //\n // Key the org on the CHANNEL's own org, not the message's org. Tenant-wide\n // channels store `organization_id = NULL` and their credentials live at\n // `organization_id = tenantId` (see connect-credential-channel.ts), so\n // `channel.organizationId ?? tenantId` matches the write key for both\n // org-scoped and tenant-wide channels. Keying on the message org would\n // resolve `{}` the moment a tenant-wide provider delivers from a message\n // that carries a non-null org.\n const credentialsScope = {\n tenantId: input.scope.tenantId,\n organizationId: channel.organizationId ?? input.scope.tenantId,\n userId: channel.userId ?? null,\n }\n let credentials: Record<string, unknown> = {}\n if (channel.credentialsRef && credentialsService) {\n try {\n credentials =\n (await credentialsService.resolve(`channel_${channel.providerKey}`, credentialsScope)) ?? {}\n } catch {\n credentials = {}\n }\n }\n\n // (4) Credential refresh if OAuth + near expiry, or forced by retry.\n let integrationLog: IntegrationLogLike | null = null\n try {\n integrationLog = ctx.container.resolve('integrationLogService') as IntegrationLogLike\n } catch {\n integrationLog = null\n }\n const refreshResult = await refreshCredentialsIfNeeded(\n {\n adapter,\n channelId: channel.id,\n credentials,\n scope: credentialsScope,\n force: Boolean(input.forceCredentialRefresh),\n },\n {\n credentialsService,\n logger: (...args) => moduleLogger.warn('credential refresh diagnostic', { details: args }),\n },\n )\n credentials = refreshResult.credentials\n\n // (4b) Spec B \u2014 get-or-create the per-thread crypto token and inject it\n // into the outbound payload BEFORE the adapter converts it. The token\n // travels as both a `References` header (via channelMetadata.references)\n // and a hidden body marker so inbound replies can be threaded back to\n // this conversation even when the recipient's MUA strips RFC 5322\n // headers. Idempotent on retry \u2014 same token reused per thread.\n let threadToken: string | null = null\n try {\n const { token } = await getOrCreateThreadToken(em, {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n messageThreadId: mapping.messageThreadId,\n })\n threadToken = token\n } catch (tokenErr) {\n // Token creation should never block a send \u2014 if it fails, the message\n // still goes out, just without our thread-token attachment point.\n // Threading falls back to the JWZ strategy via Message-Id headers.\n moduleLogger.warn('thread token unavailable, proceeding without it', { err: tokenErr })\n }\n\n // (4c) Chat-provider reply threading \u2014 resolve the provider-native id of the\n // message this one answers so a threading adapter can attach it (Discord's\n // `message_reference`). Email adapters ignore this and keep threading on the\n // `inReplyTo` / `references` headers built above. Best-effort like the thread\n // token: an unresolvable parent sends an unthreaded reply rather than failing\n // a delivery.\n let replyToExternalId: string | null = null\n try {\n replyToExternalId = await resolveOutboundReplyExternalId(em, {\n parentMessageId: message.parentMessageId ?? null,\n externalConversationId: mapping.externalConversationId,\n capabilities: adapter.capabilities,\n scope: {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n })\n } catch (replyRefErr) {\n moduleLogger.warn('outbound reply reference unavailable, sending unthreaded', {\n err: replyRefErr,\n })\n }\n if (!replyToExternalId && message.parentMessageId && adapter.capabilities?.threading === true) {\n // The provider threads and the caller asked for a reply, yet nothing\n // resolved \u2014 the parent never reached this channel, or it belongs to\n // another conversation. Delivery is unaffected, but say so: an unthreaded\n // reply is otherwise indistinguishable from a non-reply, which is exactly\n // the silent-unreachability shape #5541 was filed about.\n moduleLogger.debug('reply parent has no external id on this channel, sending unthreaded', {\n messageId: message.id,\n parentMessageId: message.parentMessageId,\n conversationId: mapping.externalConversationId,\n })\n }\n\n // (5) + (6) Convert + send.\n try {\n const outboundPayload = (link.channelPayload as Record<string, unknown> | null) ?? {}\n const outboundHtml = typeof outboundPayload.html === 'string' ? outboundPayload.html : null\n const outboundText = typeof outboundPayload.text === 'string' ? outboundPayload.text : null\n let outboundBody = outboundHtml ?? outboundText ?? message.body ?? ''\n const outboundBodyFormat = outboundHtml\n ? 'html'\n : ((message.bodyFormat as 'text' | 'markdown' | 'html') ?? 'text')\n\n // Pre-existing channelMetadata.references (string[]) so we can extend\n // it with the synthetic thread-token id without disturbing other refs\n // (e.g. the recipient's own reply chain).\n const baseMetadata = (link.channelMetadata as Record<string, unknown> | undefined) ?? {}\n const existingRefs = Array.isArray(baseMetadata.references)\n ? (baseMetadata.references as unknown[]).filter(\n (value): value is string => typeof value === 'string',\n )\n : []\n let mergedReferences = existingRefs\n if (threadToken && !outboundBody.includes(`[OM:${threadToken}]`)) {\n // Append the body footer for the corresponding format. The hidden\n // HTML span is `display:none`; the plain-text trailer is a small\n // bracketed marker on its own line. Both survive most reply clients.\n const footer = buildBodyFooter(threadToken)\n if (outboundBodyFormat === 'html') {\n const closingBody = outboundBody.lastIndexOf('</body>')\n outboundBody =\n closingBody >= 0\n ? `${outboundBody.slice(0, closingBody)}${footer.html}${outboundBody.slice(closingBody)}`\n : `${outboundBody}${footer.html}`\n } else {\n outboundBody = `${outboundBody}${footer.plain}`\n }\n const refId = buildReferencesId(threadToken)\n if (!mergedReferences.includes(refId)) {\n mergedReferences = [...mergedReferences, refId]\n }\n }\n const converted = await adapter.convertOutbound({\n body: outboundBody,\n bodyFormat: outboundBodyFormat,\n channelMetadata: {\n thread_id: mapping.externalThreadRef,\n // Reply targeting is hub-resolved only. `send-as-user` merges\n // caller-supplied metadata onto the link, so every reply-targeting key\n // is stripped off the stored metadata here rather than merely\n // out-ranked below: merge order settles the contest only when the hub\n // resolved a parent, and the uncontested case \u2014 no `parentMessageId`,\n // or a parent that does not resolve \u2014 is exactly the one a caller\n // controls.\n ...stripCallerReplyTargeting(baseMetadata),\n references: mergedReferences,\n ...(threadToken ? { omThreadToken: threadToken } : {}),\n ...(replyToExternalId ? { replyToExternalId } : {}),\n },\n })\n\n // NOTE \u2014 at-least-once delivery (accepted v1 semantics). This provider\n // send is a non-transactional external side effect. The terminal-status\n // short-circuit above and the `message_channel_links_message_uq` index\n // prevent duplicate link records and re-sends after a *completed*\n // delivery, but if the process crashes in the narrow window between this\n // call returning and the success flush below, the link stays `pending`\n // and a worker retry re-invokes the adapter \u2014 the recipient may receive a\n // duplicate. This is deliberate: email providers (Gmail/SMTP) expose no\n // idempotent-send key nor a reliable \"did message X send?\" query, so\n // re-sending is preferred over risking a dropped message.\n const sendResult = await adapter.sendMessage({\n conversationId: mapping.externalThreadRef,\n content: converted.content,\n credentials,\n scope: {\n tenantId: input.scope.tenantId,\n organizationId: channel.organizationId ?? input.scope.tenantId,\n },\n metadata: converted.metadata,\n })\n\n if (sendResult.status === 'failed') {\n throw new Error(sendResult.error ?? `Adapter '${adapter.providerKey}' reported send failure`)\n }\n\n // (7) Persist success records.\n //\n // Pre-generate the ExternalMessage PK client-side. The PK uses\n // `defaultRaw: 'gen_random_uuid()'`, so `externalMessage.id` is undefined\n // until after the INSERT returns \u2014 writing `link.externalMessageId =\n // externalMessage.id` before the flush would silently persist NULL on the\n // link's FK. Mirrors the inbound ingest path (ingest-inbound-message.ts).\n const externalMessageRowId = randomUUID()\n const externalMessage = em.create(ExternalMessage, {\n id: externalMessageRowId,\n channelId: channel.id,\n conversationId: mapping.externalConversationId,\n externalMessageId: sendResult.externalMessageId,\n direction: 'outbound',\n senderIdentifier: null,\n senderDisplayName: null,\n providerTimestamp: new Date(),\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n })\n em.persist(externalMessage)\n\n // A successful send proves the credentials are valid \u2014 clear any prior\n // `requires_reauth` / `error` state so a recovered channel doesn't keep\n // showing a stale reconnect banner (e.g. after a forced-refresh retry).\n if (channel.status === 'requires_reauth' || channel.status === 'error') {\n channel.status = 'connected'\n }\n\n link.deliveryStatus = sendResult.status === 'sent' ? 'sent' : 'queued'\n link.externalMessageId = externalMessageRowId\n link.channelMetadata = {\n ...((link.channelMetadata as Record<string, unknown> | undefined) ?? {}),\n ...(converted.metadata ?? {}),\n externalMessageId: sendResult.externalMessageId,\n // Always persist the RFC2822 Message-ID so inbound reply matching (JWZ\n // strategy in lib/thread-matcher) and sent-folder dedup can resolve this\n // outbound message. Adapters that build the message themselves (Gmail)\n // return it in `converted.metadata.messageId`; IMAP/SMTP lets\n // the transport mint it, surfacing it only as `sendResult.externalMessageId`\n // (the RFC2822 id the recipient replies to) \u2014 fall back to that.\n // Store it bracket-stripped to match the inbound convention\n // (`normalizeMimeInbound` strips), so the JWZ matcher and sent-folder dedup \u2014\n // which compare against stripped ids \u2014 resolve it. `assembleRfc2822`\n // re-applies brackets when this id is later used to build reply headers.\n messageId: stripBrackets(\n stringOrUndefined((converted.metadata as Record<string, unknown> | undefined)?.messageId) ??\n sendResult.externalMessageId,\n ),\n }\n await em.flush()\n\n await emitCommunicationChannelsEvent(\n 'communication_channels.message.sent',\n {\n messageId: message.id,\n externalMessageId: externalMessage.id,\n channelLinkId: link.id,\n conversationId: mapping.externalConversationId,\n channelId: channel.id,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n direction: 'outbound',\n // #6095: same contract as the inbound path \u2014 the send time travels on\n // the event so consumers (the customers timeline) date their rows\n // from it instead of reading the ExternalMessage row across the\n // module boundary. ISO string because this event is persistent.\n providerTimestamp:\n externalMessage.providerTimestamp instanceof Date\n ? externalMessage.providerTimestamp.toISOString()\n : null,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n\n return {\n status: 'delivered',\n messageId: message.id,\n channelLinkId: link.id,\n externalMessageId: externalMessage.id,\n providerKey: channel.providerKey,\n }\n } catch (sendErr) {\n // (8) Failure path \u2014 classify, persist, emit, return.\n const classification = classifyOutboundError(sendErr)\n const requiresReauth = isReauthError(classification)\n link.deliveryStatus = 'failed'\n link.channelMetadata = {\n ...((link.channelMetadata as Record<string, unknown> | undefined) ?? {}),\n lastError: classification.message,\n lastErrorAt: new Date().toISOString(),\n transient: classification.transient,\n requiresReauth,\n }\n // A 401 / invalid_grant means the stored credentials are dead and no\n // retry will help \u2014 flip the channel to `requires_reauth` (mirrors the\n // inbound poll path) so the operator gets a reconnect signal instead of\n // silently-failing sends. A later successful send self-heals back to\n // `connected` (see the success path above).\n if (requiresReauth) {\n channel.status = 'requires_reauth'\n }\n await em.flush()\n\n try {\n await integrationLog?.error?.({\n integrationId: `channel_${channel.providerKey}`,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n channelId: channel.id,\n messageId: message.id,\n status: classification.status ?? null,\n transient: classification.transient,\n message: classification.message,\n })\n } catch {\n // best-effort logging\n }\n\n if (requiresReauth) {\n await emitCommunicationChannelsEvent(\n 'communication_channels.channel.requires_reauth',\n {\n channelId: channel.id,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n reason: classification.message,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n }\n\n await emitCommunicationChannelsEvent(\n 'communication_channels.message.delivery_failed',\n {\n messageId: message.id,\n channelLinkId: link.id,\n conversationId: mapping.externalConversationId,\n channelId: channel.id,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n transient: classification.transient,\n error: classification.message,\n status: classification.status ?? null,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n\n return {\n status: 'failed',\n messageId: message.id,\n channelLinkId: link.id,\n providerKey: channel.providerKey,\n error: classification.message,\n transient: classification.transient,\n requiresReauth,\n }\n }\n },\n}\n\nregisterCommand(deliverOutboundMessageCommand)\n\nexport default deliverOutboundMessageCommand\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,kBAAkB;AAC3B,SAAS,SAAS;AAGlB,SAAS,uBAAuB;AAChC,SAAS,6BAA6B;AACtC,SAAS,sCAAsC;AAC/C,SAAS,kCAAkC;AAC3C,SAAS,uBAAuB,qBAAqB;AACrD;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,mBAAmB,qBAAqB;AACjD,SAAS,gCAAgC,iCAAiC;AAE1E,SAAS,yBAAyB;AAClC,SAAS,eAAe;AACxB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,oBAAoB;AAE7B,MAAM,eAAe,aAAa,wBAAwB,EAAE,MAAM,EAAE,WAAW,2BAA2B,CAAC;AAM3G,MAAM,2BAA2B,EAAE,QAAQ,kBAA2B;AAEtE,MAAM,qBAAqB,EAAE,OAAO;AAAA,EAClC,WAAW,EAAE,OAAO,EAAE,KAAK;AAAA,EAC3B,OAAO,EAAE,OAAO;AAAA,IACd,UAAU,EAAE,OAAO,EAAE,KAAK;AAAA,IAC1B,gBAAgB,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS;AAAA,EAC7C,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA,EAKD,wBAAwB,EAAE,QAAQ,EAAE,SAAS;AAC/C,CAAC;AA6BM,MAAM,qDACX;
|
|
4
|
+
"sourcesContent": ["import { randomUUID } from 'node:crypto'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandHandler } from '@open-mercato/shared/lib/commands'\nimport { registerCommand } from '@open-mercato/shared/lib/commands'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { emitCommunicationChannelsEvent } from '../events'\nimport { refreshCredentialsIfNeeded } from '../lib/credential-refresh'\nimport { classifyOutboundError, isReauthError } from '../lib/error-classification'\nimport {\n buildBodyFooter,\n buildReferencesId,\n getOrCreateThreadToken,\n} from '../lib/thread-token'\nimport { stringOrUndefined, stripBrackets } from '../lib/email-mime'\nimport { resolveOutboundReplyExternalId, stripCallerReplyTargeting } from '../lib/outbound-reply-ref'\nimport type { ChannelAdapterRegistry } from '../lib/registry'\nimport { isUniqueViolation } from '../lib/pg-errors'\nimport { Message } from '../../messages/data/entities'\nimport {\n ChannelThreadMapping,\n CommunicationChannel,\n ExternalMessage,\n MessageChannelLink,\n} from '../data/entities'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst moduleLogger = createLogger('communication_channels').child({ component: 'deliver-outbound-message' })\n\n/**\n * Sentinel \u2014 `Message.threadId` of an internal-only (no channel link) message\n * has no matching `ChannelThreadMapping`. In that case outbound delivery is a no-op.\n */\nconst NO_THREAD_MAPPING_RESULT = { status: 'no_channel_link' as const }\n\nconst deliverInputSchema = z.object({\n messageId: z.string().uuid(),\n scope: z.object({\n tenantId: z.string().uuid(),\n organizationId: z.string().uuid().nullable(),\n }),\n /**\n * If true, force a credential refresh before sending \u2014 used by retry attempts\n * after a 401 from the provider.\n */\n forceCredentialRefresh: z.boolean().optional(),\n})\n\nexport type DeliverOutboundMessageInput = z.infer<typeof deliverInputSchema>\n\nexport type DeliverOutboundMessageResult =\n | { status: 'no_channel_link' }\n | { status: 'already_delivered'; messageId: string; channelLinkId: string }\n | {\n status: 'delivered'\n messageId: string\n channelLinkId: string\n externalMessageId: string\n providerKey: string\n }\n | {\n status: 'failed'\n messageId: string\n channelLinkId: string\n providerKey: string\n error: string\n transient: boolean\n /**\n * True when the failure was a 401 / invalid_grant \u2014 the channel was\n * flipped to `requires_reauth`. The worker uses this to attempt one\n * forced credential refresh before giving up.\n */\n requiresReauth: boolean\n }\n\nexport const COMMUNICATION_CHANNELS_DELIVER_OUTBOUND_COMMAND_ID =\n 'communication_channels.message.deliver_outbound'\n\nfunction omitRawMessage(metadata: Record<string, unknown> | undefined): Record<string, unknown> {\n if (!metadata) return {}\n return Object.fromEntries(Object.entries(metadata).filter(([key]) => key !== 'rawMessage'))\n}\n\ntype CredentialsServiceLike = {\n resolve: (\n integrationId: string,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<Record<string, unknown> | null>\n save?: (\n integrationId: string,\n credentials: Record<string, unknown>,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<void>\n}\n\ntype IntegrationLogLike = {\n log?: (entry: Record<string, unknown>) => Promise<void> | void\n warn?: (entry: Record<string, unknown>) => Promise<void> | void\n error?: (entry: Record<string, unknown>) => Promise<void> | void\n}\n\n/**\n * Outbound delivery command. Called from the outbound worker.\n *\n * Steps (SPEC-045d \u00A77):\n * 1. Re-fetch the Message by ID. Bail if internal-only (no ChannelThreadMapping).\n * 2. Resolve channel + adapter + credentials.\n * 3. Idempotently upsert a 'pending' MessageChannelLink (unique on `messageId`).\n * Skip if a 'sent'/'delivered' link already exists.\n * 4. Refresh credentials when OAuth + near expiry (or when caller forces it).\n * 5. Call `adapter.convertOutbound(...)` \u2192 channel-native content.\n * 6. Call `adapter.sendMessage(...)`.\n * 7. On success: persist ExternalMessage + flip link to 'sent', emit `.message.sent`.\n * 8. On failure: flip link to 'failed' + classify error, log to integrationLog,\n * emit `.delivery_failed`. The worker decides whether to retry based on\n * `result.transient`.\n *\n * Idempotency: the unique constraint on `message_channel_links.message_id`\n * prevents the same Message being sent twice through the channel even if the\n * subscriber fires repeatedly. Combined with the link's lifecycle state\n * (pending \u2192 sent | failed), we get safe retries.\n */\nconst deliverOutboundMessageCommand: CommandHandler<\n DeliverOutboundMessageInput,\n DeliverOutboundMessageResult\n> = {\n id: COMMUNICATION_CHANNELS_DELIVER_OUTBOUND_COMMAND_ID,\n async execute(rawInput, ctx) {\n const input = deliverInputSchema.parse(rawInput) as DeliverOutboundMessageInput\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n const dscope = {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n }\n\n // (1) Re-fetch Message by ID \u2014 never trust the event payload shape.\n const message = await findOneWithDecryption(\n em,\n Message,\n {\n id: input.messageId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n deletedAt: null,\n },\n undefined,\n dscope,\n )\n if (!message) {\n // Message was deleted before we got to deliver. Treat as no-op.\n return NO_THREAD_MAPPING_RESULT\n }\n if (!message.threadId) {\n // Message has no thread \u2192 no channel routing.\n return NO_THREAD_MAPPING_RESULT\n }\n\n // (1 cont.) Look up the channel link via ChannelThreadMapping.threadId.\n const mapping = await findOneWithDecryption(\n em,\n ChannelThreadMapping,\n {\n messageThreadId: message.threadId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n if (!mapping) {\n // Internal-only message \u2014 no channel delivery needed.\n return NO_THREAD_MAPPING_RESULT\n }\n\n // (2) Channel + adapter.\n const channel = await findOneWithDecryption(\n em,\n CommunicationChannel,\n {\n id: mapping.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 ${mapping.channelId} not found for tenant ${input.scope.tenantId} (or has been deleted)`,\n )\n }\n if (!channel.isActive) {\n throw new Error(`[internal] Channel ${mapping.channelId} is inactive; refusing to deliver outbound`)\n }\n\n const adapterRegistry = ctx.container.resolve('channelAdapterRegistry') as ChannelAdapterRegistry\n const adapter = adapterRegistry.get(channel.providerKey)\n if (!adapter) {\n throw new Error(\n `[internal] No ChannelAdapter registered for providerKey '${channel.providerKey}'. ` +\n 'Check that the provider package is enabled in modules.ts.',\n )\n }\n\n // (3) Idempotently upsert a 'pending' MessageChannelLink.\n let link = await findOneWithDecryption(\n em,\n MessageChannelLink,\n {\n messageId: message.id,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n if (\n link &&\n (link.deliveryStatus === 'queued' ||\n link.deliveryStatus === 'sent' ||\n link.deliveryStatus === 'delivered' ||\n link.deliveryStatus === 'read')\n ) {\n // Already sent (or accepted by the provider as 'queued') \u2014 short-circuit\n // so a retried job does not re-invoke the adapter and double-send.\n return {\n status: 'already_delivered',\n messageId: message.id,\n channelLinkId: link.id,\n }\n }\n if (!link) {\n link = em.create(MessageChannelLink, {\n messageId: message.id,\n externalConversationId: mapping.externalConversationId,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n direction: 'outbound',\n deliveryStatus: 'pending',\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n })\n em.persist(link)\n try {\n await em.flush()\n } catch (flushErr) {\n // Concurrency guard: the link lookup above is not atomic with this\n // insert, so two deliveries of the same message (a replayed\n // `messages.message.sent`, or an overlapping worker retry) can both\n // reach here. The `message_channel_links_message_uq` index rejects the\n // loser with a 23505. Defer to the winning job \u2014 re-read its link on a\n // fresh fork and report `already_delivered` \u2014 instead of re-invoking the\n // adapter (double send) or letting the raw error dead-letter the job.\n if (isUniqueViolation(flushErr)) {\n const winner = await findOneWithDecryption(\n em.fork(),\n MessageChannelLink,\n {\n messageId: message.id,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n if (winner) {\n return {\n status: 'already_delivered',\n messageId: message.id,\n channelLinkId: winner.id,\n }\n }\n }\n throw flushErr\n }\n }\n\n // (2 cont.) Decrypted credentials via the integrations module (if available).\n let credentialsService: CredentialsServiceLike | null = null\n try {\n credentialsService = ctx.container.resolve(\n 'integrationCredentialsService',\n ) as CredentialsServiceLike\n } catch {\n credentialsService = null\n }\n // Per-user credentials scope: pass `channel.userId` so the credentials\n // service returns this user's row, not whoever connected last. See\n // review R2-C1 / N1 (2026-05-26).\n //\n // Key the org on the CHANNEL's own org, not the message's org. Tenant-wide\n // channels store `organization_id = NULL` and their credentials live at\n // `organization_id = tenantId` (see connect-credential-channel.ts), so\n // `channel.organizationId ?? tenantId` matches the write key for both\n // org-scoped and tenant-wide channels. Keying on the message org would\n // resolve `{}` the moment a tenant-wide provider delivers from a message\n // that carries a non-null org.\n const credentialsScope = {\n tenantId: input.scope.tenantId,\n organizationId: channel.organizationId ?? input.scope.tenantId,\n userId: channel.userId ?? null,\n }\n let credentials: Record<string, unknown> = {}\n if (channel.credentialsRef && credentialsService) {\n try {\n credentials =\n (await credentialsService.resolve(`channel_${channel.providerKey}`, credentialsScope)) ?? {}\n } catch {\n credentials = {}\n }\n }\n\n // (4) Credential refresh if OAuth + near expiry, or forced by retry.\n let integrationLog: IntegrationLogLike | null = null\n try {\n integrationLog = ctx.container.resolve('integrationLogService') as IntegrationLogLike\n } catch {\n integrationLog = null\n }\n const refreshResult = await refreshCredentialsIfNeeded(\n {\n adapter,\n channelId: channel.id,\n credentials,\n scope: credentialsScope,\n force: Boolean(input.forceCredentialRefresh),\n },\n {\n credentialsService,\n logger: (...args) => moduleLogger.warn('credential refresh diagnostic', { details: args }),\n },\n )\n credentials = refreshResult.credentials\n\n // (4b) Spec B \u2014 get-or-create the per-thread crypto token and inject it\n // into the outbound payload BEFORE the adapter converts it. The token\n // travels as both a `References` header (via channelMetadata.references)\n // and a hidden body marker so inbound replies can be threaded back to\n // this conversation even when the recipient's MUA strips RFC 5322\n // headers. Idempotent on retry \u2014 same token reused per thread.\n let threadToken: string | null = null\n try {\n const { token } = await getOrCreateThreadToken(em, {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n messageThreadId: mapping.messageThreadId,\n })\n threadToken = token\n } catch (tokenErr) {\n // Token creation should never block a send \u2014 if it fails, the message\n // still goes out, just without our thread-token attachment point.\n // Threading falls back to the JWZ strategy via Message-Id headers.\n moduleLogger.warn('thread token unavailable, proceeding without it', { err: tokenErr })\n }\n\n // (4c) Chat-provider reply threading \u2014 resolve the provider-native id of the\n // message this one answers so a threading adapter can attach it (Discord's\n // `message_reference`). Email adapters ignore this and keep threading on the\n // `inReplyTo` / `references` headers built above. Best-effort like the thread\n // token: an unresolvable parent sends an unthreaded reply rather than failing\n // a delivery.\n let replyToExternalId: string | null = null\n try {\n replyToExternalId = await resolveOutboundReplyExternalId(em, {\n parentMessageId: message.parentMessageId ?? null,\n externalConversationId: mapping.externalConversationId,\n capabilities: adapter.capabilities,\n scope: {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n })\n } catch (replyRefErr) {\n moduleLogger.warn('outbound reply reference unavailable, sending unthreaded', {\n err: replyRefErr,\n })\n }\n if (!replyToExternalId && message.parentMessageId && adapter.capabilities?.threading === true) {\n // The provider threads and the caller asked for a reply, yet nothing\n // resolved \u2014 the parent never reached this channel, or it belongs to\n // another conversation. Delivery is unaffected, but say so: an unthreaded\n // reply is otherwise indistinguishable from a non-reply, which is exactly\n // the silent-unreachability shape #5541 was filed about.\n moduleLogger.debug('reply parent has no external id on this channel, sending unthreaded', {\n messageId: message.id,\n parentMessageId: message.parentMessageId,\n conversationId: mapping.externalConversationId,\n })\n }\n\n // (5) + (6) Convert + send.\n try {\n const outboundPayload = (link.channelPayload as Record<string, unknown> | null) ?? {}\n const outboundHtml = typeof outboundPayload.html === 'string' ? outboundPayload.html : null\n const outboundText = typeof outboundPayload.text === 'string' ? outboundPayload.text : null\n let outboundBody = outboundHtml ?? outboundText ?? message.body ?? ''\n const outboundBodyFormat = outboundHtml\n ? 'html'\n : ((message.bodyFormat as 'text' | 'markdown' | 'html') ?? 'text')\n\n // Pre-existing channelMetadata.references (string[]) so we can extend\n // it with the synthetic thread-token id without disturbing other refs\n // (e.g. the recipient's own reply chain).\n const baseMetadata = (link.channelMetadata as Record<string, unknown> | undefined) ?? {}\n const existingRefs = Array.isArray(baseMetadata.references)\n ? (baseMetadata.references as unknown[]).filter(\n (value): value is string => typeof value === 'string',\n )\n : []\n let mergedReferences = existingRefs\n if (threadToken && !outboundBody.includes(`[OM:${threadToken}]`)) {\n // Append the body footer for the corresponding format. The hidden\n // HTML span is `display:none`; the plain-text trailer is a small\n // bracketed marker on its own line. Both survive most reply clients.\n const footer = buildBodyFooter(threadToken)\n if (outboundBodyFormat === 'html') {\n const closingBody = outboundBody.lastIndexOf('</body>')\n outboundBody =\n closingBody >= 0\n ? `${outboundBody.slice(0, closingBody)}${footer.html}${outboundBody.slice(closingBody)}`\n : `${outboundBody}${footer.html}`\n } else {\n outboundBody = `${outboundBody}${footer.plain}`\n }\n const refId = buildReferencesId(threadToken)\n if (!mergedReferences.includes(refId)) {\n mergedReferences = [...mergedReferences, refId]\n }\n }\n const converted = await adapter.convertOutbound({\n body: outboundBody,\n bodyFormat: outboundBodyFormat,\n channelMetadata: {\n thread_id: mapping.externalThreadRef,\n // Reply targeting is hub-resolved only. `send-as-user` merges\n // caller-supplied metadata onto the link, so every reply-targeting key\n // is stripped off the stored metadata here rather than merely\n // out-ranked below: merge order settles the contest only when the hub\n // resolved a parent, and the uncontested case \u2014 no `parentMessageId`,\n // or a parent that does not resolve \u2014 is exactly the one a caller\n // controls.\n ...stripCallerReplyTargeting(baseMetadata),\n references: mergedReferences,\n ...(threadToken ? { omThreadToken: threadToken } : {}),\n ...(replyToExternalId ? { replyToExternalId } : {}),\n },\n })\n\n // NOTE \u2014 at-least-once delivery (accepted v1 semantics). This provider\n // send is a non-transactional external side effect. The terminal-status\n // short-circuit above and the `message_channel_links_message_uq` index\n // prevent duplicate link records and re-sends after a *completed*\n // delivery, but if the process crashes in the narrow window between this\n // call returning and the success flush below, the link stays `pending`\n // and a worker retry re-invokes the adapter \u2014 the recipient may receive a\n // duplicate. This is deliberate: email providers (Gmail/SMTP) expose no\n // idempotent-send key nor a reliable \"did message X send?\" query, so\n // re-sending is preferred over risking a dropped message.\n const sendResult = await adapter.sendMessage({\n conversationId: mapping.externalThreadRef,\n content: converted.content,\n credentials,\n scope: {\n tenantId: input.scope.tenantId,\n organizationId: channel.organizationId ?? input.scope.tenantId,\n },\n metadata: converted.metadata,\n })\n\n if (sendResult.status === 'failed') {\n throw new Error(sendResult.error ?? `Adapter '${adapter.providerKey}' reported send failure`)\n }\n\n // (7) Persist success records.\n //\n // Pre-generate the ExternalMessage PK client-side. The PK uses\n // `defaultRaw: 'gen_random_uuid()'`, so `externalMessage.id` is undefined\n // until after the INSERT returns \u2014 writing `link.externalMessageId =\n // externalMessage.id` before the flush would silently persist NULL on the\n // link's FK. Mirrors the inbound ingest path (ingest-inbound-message.ts).\n const externalMessageRowId = randomUUID()\n const externalMessage = em.create(ExternalMessage, {\n id: externalMessageRowId,\n channelId: channel.id,\n conversationId: mapping.externalConversationId,\n externalMessageId: sendResult.externalMessageId,\n direction: 'outbound',\n senderIdentifier: null,\n senderDisplayName: null,\n providerTimestamp: new Date(),\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n })\n em.persist(externalMessage)\n\n // A successful send proves the credentials are valid \u2014 clear any prior\n // `requires_reauth` / `error` state so a recovered channel doesn't keep\n // showing a stale reconnect banner (e.g. after a forced-refresh retry).\n if (channel.status === 'requires_reauth' || channel.status === 'error') {\n channel.status = 'connected'\n }\n\n link.deliveryStatus = sendResult.status === 'sent' ? 'sent' : 'queued'\n link.externalMessageId = externalMessageRowId\n link.channelMetadata = {\n ...((link.channelMetadata as Record<string, unknown> | undefined) ?? {}),\n ...omitRawMessage(converted.metadata),\n externalMessageId: sendResult.externalMessageId,\n // Always persist the RFC2822 Message-ID so inbound reply matching (JWZ\n // strategy in lib/thread-matcher) and sent-folder dedup can resolve this\n // outbound message. Adapters that build the message themselves (Gmail)\n // return it in `converted.metadata.messageId`; IMAP/SMTP lets\n // the transport mint it, surfacing it only as `sendResult.externalMessageId`\n // (the RFC2822 id the recipient replies to) \u2014 fall back to that.\n // Store it bracket-stripped to match the inbound convention\n // (`normalizeMimeInbound` strips), so the JWZ matcher and sent-folder dedup \u2014\n // which compare against stripped ids \u2014 resolve it. `assembleRfc2822`\n // re-applies brackets when this id is later used to build reply headers.\n messageId: stripBrackets(\n stringOrUndefined((converted.metadata as Record<string, unknown> | undefined)?.messageId) ??\n sendResult.externalMessageId,\n ),\n }\n await em.flush()\n\n await emitCommunicationChannelsEvent(\n 'communication_channels.message.sent',\n {\n messageId: message.id,\n externalMessageId: externalMessage.id,\n channelLinkId: link.id,\n conversationId: mapping.externalConversationId,\n channelId: channel.id,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n direction: 'outbound',\n // #6095: same contract as the inbound path \u2014 the send time travels on\n // the event so consumers (the customers timeline) date their rows\n // from it instead of reading the ExternalMessage row across the\n // module boundary. ISO string because this event is persistent.\n providerTimestamp:\n externalMessage.providerTimestamp instanceof Date\n ? externalMessage.providerTimestamp.toISOString()\n : null,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n\n return {\n status: 'delivered',\n messageId: message.id,\n channelLinkId: link.id,\n externalMessageId: externalMessage.id,\n providerKey: channel.providerKey,\n }\n } catch (sendErr) {\n // (8) Failure path \u2014 classify, persist, emit, return.\n const classification = classifyOutboundError(sendErr)\n const requiresReauth = isReauthError(classification)\n link.deliveryStatus = 'failed'\n link.channelMetadata = {\n ...((link.channelMetadata as Record<string, unknown> | undefined) ?? {}),\n lastError: classification.message,\n lastErrorAt: new Date().toISOString(),\n transient: classification.transient,\n requiresReauth,\n }\n // A 401 / invalid_grant means the stored credentials are dead and no\n // retry will help \u2014 flip the channel to `requires_reauth` (mirrors the\n // inbound poll path) so the operator gets a reconnect signal instead of\n // silently-failing sends. A later successful send self-heals back to\n // `connected` (see the success path above).\n if (requiresReauth) {\n channel.status = 'requires_reauth'\n }\n await em.flush()\n\n try {\n await integrationLog?.error?.({\n integrationId: `channel_${channel.providerKey}`,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n channelId: channel.id,\n messageId: message.id,\n status: classification.status ?? null,\n transient: classification.transient,\n message: classification.message,\n })\n } catch {\n // best-effort logging\n }\n\n if (requiresReauth) {\n await emitCommunicationChannelsEvent(\n 'communication_channels.channel.requires_reauth',\n {\n channelId: channel.id,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n reason: classification.message,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n }\n\n await emitCommunicationChannelsEvent(\n 'communication_channels.message.delivery_failed',\n {\n messageId: message.id,\n channelLinkId: link.id,\n conversationId: mapping.externalConversationId,\n channelId: channel.id,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n transient: classification.transient,\n error: classification.message,\n status: classification.status ?? null,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n\n return {\n status: 'failed',\n messageId: message.id,\n channelLinkId: link.id,\n providerKey: channel.providerKey,\n error: classification.message,\n transient: classification.transient,\n requiresReauth,\n }\n }\n },\n}\n\nregisterCommand(deliverOutboundMessageCommand)\n\nexport default deliverOutboundMessageCommand\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { resolveCommunicationChannelsSystemUserId } from "./system-user.js";
|
|
2
|
-
|
|
2
|
+
import { EXTERNAL_CONVERSATION_SOURCE_ENTITY_TYPE } from "../../messages/lib/messageSourceEntityTypes.js";
|
|
3
3
|
function buildInboundIdempotencyKey(channelId, externalMessageId) {
|
|
4
4
|
return `cc:${channelId}:${externalMessageId}`;
|
|
5
5
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/communication_channels/lib/inbound-message-origin.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { resolveCommunicationChannelsSystemUserId } from './system-user'\
|
|
5
|
-
"mappings": "AACA,SAAS,gDAAgD;
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { resolveCommunicationChannelsSystemUserId } from './system-user'\nimport { EXTERNAL_CONVERSATION_SOURCE_ENTITY_TYPE } from '../../messages/lib/messageSourceEntityTypes'\n\nexport { EXTERNAL_CONVERSATION_SOURCE_ENTITY_TYPE }\n\n/**\n * Dedup key `ingest-inbound-message` stamps on the platform message it composes,\n * so a retried ingest reuses the first attempt's message instead of duplicating\n * it. The `cc:` prefix also identifies the message as ingest-authored \u2014 see\n * {@link isIngestedInboundMessage}.\n */\nexport function buildInboundIdempotencyKey(channelId: string, externalMessageId: string): string {\n return `cc:${channelId}:${externalMessageId}`\n}\n\ntype InboundMessageOriginInput = {\n message: {\n senderUserId: string\n sourceEntityType?: string | null\n idempotencyKey?: string | null\n }\n /** The `MessageChannelLink` already loaded for this message, if any. */\n existingLink: { direction: 'inbound' | 'outbound' } | null | undefined\n tenantId: string\n}\n\n/**\n * Whether a platform message in a channel-linked thread is the one\n * `ingest-inbound-message` composed for an incoming message \u2014 as opposed to a\n * message an operator wrote in the same conversation.\n *\n * The distinction matters because the messages module emits\n * `messages.message.sent` for both, and only the operator's message may be\n * delivered outbound. Before #5535 the discriminator was `sourceEntityType`\n * alone, which every operator reply inherits from the message it answers \u2014 so\n * operator replies were silently dropped along with the ingested messages.\n *\n * Three signals, checked cheapest-first, because the ingested message's inbound\n * `MessageChannelLink` is written *after* the compose command emits its event\n * and is therefore not guaranteed to be visible yet:\n *\n * 1. an inbound `MessageChannelLink` for this message \u2014 authoritative;\n * 2. the `cc:` ingest dedup key ({@link buildInboundIdempotencyKey}) \u2014 set\n * whenever the provider supplied an external message id;\n * 3. the tenant's channel system user as the sender \u2014 the ingest attribution\n * when the conversation has no assigned user.\n */\nexport async function isIngestedInboundMessage(\n em: EntityManager,\n { message, existingLink, tenantId }: InboundMessageOriginInput,\n): Promise<boolean> {\n if (message.sourceEntityType !== EXTERNAL_CONVERSATION_SOURCE_ENTITY_TYPE) return false\n if (existingLink?.direction === 'inbound') return true\n if (typeof message.idempotencyKey === 'string' && message.idempotencyKey.startsWith('cc:')) return true\n const systemUserId = await resolveCommunicationChannelsSystemUserId(em, tenantId)\n return message.senderUserId === systemUserId\n}\n"],
|
|
5
|
+
"mappings": "AACA,SAAS,gDAAgD;AACzD,SAAS,gDAAgD;AAUlD,SAAS,2BAA2B,WAAmB,mBAAmC;AAC/F,SAAO,MAAM,SAAS,IAAI,iBAAiB;AAC7C;AAkCA,eAAsB,yBACpB,IACA,EAAE,SAAS,cAAc,SAAS,GAChB;AAClB,MAAI,QAAQ,qBAAqB,yCAA0C,QAAO;AAClF,MAAI,cAAc,cAAc,UAAW,QAAO;AAClD,MAAI,OAAO,QAAQ,mBAAmB,YAAY,QAAQ,eAAe,WAAW,KAAK,EAAG,QAAO;AACnG,QAAM,eAAe,MAAM,yCAAyC,IAAI,QAAQ;AAChF,SAAO,QAAQ,iBAAiB;AAClC;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const OAUTH_RESULT_QUERY_PARAM = "oauth";
|
|
2
|
+
const OAUTH_RESULT_QUERY_PARAMS = [OAUTH_RESULT_QUERY_PARAM, "code", "provider", "channelId"];
|
|
3
|
+
function stripOAuthResultParams(href) {
|
|
4
|
+
const url = new URL(href);
|
|
5
|
+
for (const param of OAUTH_RESULT_QUERY_PARAMS) url.searchParams.delete(param);
|
|
6
|
+
return `${url.pathname}${url.search}${url.hash}`;
|
|
7
|
+
}
|
|
8
|
+
export {
|
|
9
|
+
OAUTH_RESULT_QUERY_PARAM,
|
|
10
|
+
stripOAuthResultParams
|
|
11
|
+
};
|
|
12
|
+
//# sourceMappingURL=oauth-result.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/communication_channels/lib/oauth-result.ts"],
|
|
4
|
+
"sourcesContent": ["// The OAuth callback reports its outcome to the return page through this query\n// param. It must not be `flash`: the global <FlashMessages> host reads `?flash=`\n// as the message text and would render the raw outcome (\"error\"/\"connected\") as\n// a success toast (#6402).\nexport const OAUTH_RESULT_QUERY_PARAM = 'oauth'\n\nconst OAUTH_RESULT_QUERY_PARAMS = [OAUTH_RESULT_QUERY_PARAM, 'code', 'provider', 'channelId']\n\nexport function stripOAuthResultParams(href: string): string {\n const url = new URL(href)\n for (const param of OAUTH_RESULT_QUERY_PARAMS) url.searchParams.delete(param)\n return `${url.pathname}${url.search}${url.hash}`\n}\n"],
|
|
5
|
+
"mappings": "AAIO,MAAM,2BAA2B;AAExC,MAAM,4BAA4B,CAAC,0BAA0B,QAAQ,YAAY,WAAW;AAErF,SAAS,uBAAuB,MAAsB;AAC3D,QAAM,MAAM,IAAI,IAAI,IAAI;AACxB,aAAW,SAAS,0BAA2B,KAAI,aAAa,OAAO,KAAK;AAC5E,SAAO,GAAG,IAAI,QAAQ,GAAG,IAAI,MAAM,GAAG,IAAI,IAAI;AAChD;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -40,7 +40,10 @@ function getSecret() {
|
|
|
40
40
|
const dedicated = process.env.OM_HUB_OAUTH_STATE_KEY ?? process.env.KMS_MASTER_KEY;
|
|
41
41
|
if (dedicated) return dedicated;
|
|
42
42
|
if (process.env.NODE_ENV === "production") {
|
|
43
|
-
throw new
|
|
43
|
+
throw new OAuthStateError(
|
|
44
|
+
"[internal] OM_HUB_OAUTH_STATE_KEY or KMS_MASTER_KEY required in production",
|
|
45
|
+
"missing_secret"
|
|
46
|
+
);
|
|
44
47
|
}
|
|
45
48
|
const fallback = process.env.JWT_SECRET;
|
|
46
49
|
if (!fallback) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/communication_channels/lib/oauth-state.ts"],
|
|
4
|
-
"sourcesContent": ["import crypto from 'node:crypto'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst oauthStateLogger = createLogger('communication_channels').child({ component: 'oauth-state' })\n\n/**\n * OAuth state-cookie helper for the communication_channels hub.\n *\n * **Ported (re-implemented locally), NOT imported, from `packages/enterprise/src/modules/sso/lib/state-cookie.ts`.**\n * Root `AGENTS.md` rule: `@open-mercato/core` MUST NOT import from `@open-mercato/enterprise`.\n *\n * Design (per email integration spec \u00A7 OSS Independence + \u00A7 Hub Deltas \u2192 Delta 7):\n * - AES-256-GCM payload encryption.\n * - HKDF (SHA-256) key derivation from `OM_HUB_OAUTH_STATE_KEY` (falling back to\n * `KMS_MASTER_KEY`). In production those dedicated keys are required; only in\n * dev/test do we fall back to `JWT_SECRET` so envs that configure one secret\n * still work. Production refuses the `JWT_SECRET` fallback so a session-secret\n * leak cannot also forge OAuth-state cookies.\n * - 5-minute TTL \u2014 short window to bound replay surface.\n * - Payload binds the initiating `userId` so the callback rejects state cookies\n * used by a different session.\n * - Single-use consume-on-read via a short-TTL cache marker keyed by\n * `tenantId:state` (see {@link consumeOAuthStateOnce}) so a captured cookie\n * cannot re-drive the callback within the TTL. **This guarantee only holds\n * across all replicas when `CACHE_STRATEGY=redis`.** With\n * `CACHE_STRATEGY=memory` (the default) the marker is process-local \u2014 a\n * replayed callback that reaches a different replica succeeds within the TTL.\n * Multi-replica deployments MUST use a shared cache (`CACHE_STRATEGY=redis`).\n *\n * The output is a base64url string that we set on an HttpOnly + SameSite=Lax cookie.\n * Forgery requires the encryption key (KMS-managed in production).\n */\n\nconst ALGORITHM = 'aes-256-gcm'\nconst IV_LENGTH = 12\nconst TAG_LENGTH = 16\nexport const COMMUNICATION_CHANNELS_OAUTH_STATE_TTL_MS = 5 * 60 * 1000\nconst HKDF_SALT = Buffer.from('open-mercato-channels-oauth-state-v1')\nconst HKDF_INFO = Buffer.from('communication_channels-oauth-state-cookie')\n\nexport const COMMUNICATION_CHANNELS_OAUTH_STATE_COOKIE_NAME =\n 'om_cc_oauth_state'\n\nexport const DEFAULT_OAUTH_RETURN_URL = '/backend/profile/communication-channels'\n\n/** Cache tag for OAuth-state consume markers (invalidation / diagnostics). */\nexport const COMMUNICATION_CHANNELS_OAUTH_STATE_CACHE_TAG =\n 'communication_channels:oauth-state'\n\n/** Errors thrown by the helpers. Stable for tests + route mapping. */\nexport class OAuthStateError extends Error {\n override name = 'OAuthStateError'\n constructor(\n message: string,\n readonly code:\n | 'missing_secret'\n | 'invalid_cookie'\n | 'expired'\n | 'user_mismatch'\n | 'decrypt_failed'\n | 'replay',\n ) {\n super(message)\n }\n}\n\n/**\n * Canonical state-cookie payload \u2014 provider-agnostic. Each OAuth provider\n * adapter (Gmail, \u2026) packs its own per-flow nonce / verifier into\n * the `extra` field rather than extending this shape.\n */\nexport interface OAuthStatePayload {\n /** Nonce-like opaque value mirrored into the OAuth `state` query parameter. */\n state: string\n /** Per-flow CSRF nonce, returned alongside the OAuth response. */\n nonce: string\n /** Tenant-scoped user that initiated the flow. Validated on callback. */\n userId: string\n /** Tenant scope so the callback can pin the channel to the same tenant. */\n tenantId: string\n /** Optional organization id for multi-org tenants. */\n organizationId?: string | null\n /** Provider key (e.g. `gmail`) \u2014 routes the callback. */\n providerKey: string\n /** Where to redirect on success. Defaults to the profile page in the route. */\n returnUrl?: string\n /** Wall-clock expiry (ms since epoch). */\n expiresAt: number\n /** Provider-specific extras (PKCE code_verifier, scopes, login_hint, \u2026). */\n extra?: Record<string, unknown>\n}\n\n/**\n * Minimal cache surface used by {@link consumeOAuthStateOnce}. Matches the\n * `has` / `set` subset of `@open-mercato/cache`'s `CacheStrategy` so callers\n * can pass `container.resolve('cache')` directly.\n */\nexport type OAuthStateConsumeStore = {\n has(key: string): Promise<boolean>\n set(\n key: string,\n value: unknown,\n options?: { ttl?: number; tags?: string[] },\n ): Promise<void>\n}\n\nexport function isSafeOAuthReturnUrl(value: string | null | undefined): value is string {\n if (typeof value !== 'string') return false\n if (value.length === 0 || value.length > 2048) return false\n if (!value.startsWith('/') || value.startsWith('//')) return false\n if (value.includes('\\\\')) return false\n try {\n const base = new URL('https://open-mercato.local')\n const parsed = new URL(value, base)\n return parsed.origin === base.origin && parsed.pathname.startsWith('/')\n } catch {\n return false\n }\n}\n\nexport function normalizeOAuthReturnUrl(\n value: string | null | undefined,\n fallback: string = DEFAULT_OAUTH_RETURN_URL,\n): string {\n return isSafeOAuthReturnUrl(value) ? value : fallback\n}\n\nfunction deriveKey(secret: string): Buffer {\n return Buffer.from(crypto.hkdfSync('sha256', secret, HKDF_SALT, HKDF_INFO, 32))\n}\n\nfunction getSecret(): string {\n const dedicated = process.env.OM_HUB_OAUTH_STATE_KEY ?? process.env.KMS_MASTER_KEY\n if (dedicated) return dedicated\n // No dedicated key configured. Fail closed in production rather than deriving\n // the state-cookie key from JWT_SECRET (the platform session-signing secret) \u2014\n // sharing that key means a JWT_SECRET leak also lets an attacker forge OAuth\n // state cookies, bypassing the userId/tenant binding. In non-production we fall\n // back to JWT_SECRET so dev/test envs that only configure one secret still work.\n if (process.env.NODE_ENV === 'production') {\n throw new Error('[internal] OM_HUB_OAUTH_STATE_KEY or KMS_MASTER_KEY required in production')\n }\n const fallback = process.env.JWT_SECRET\n if (!fallback) {\n throw new OAuthStateError(\n 'OM_HUB_OAUTH_STATE_KEY (or fallback KMS_MASTER_KEY / JWT_SECRET) must be set',\n 'missing_secret',\n )\n }\n return fallback\n}\n\n/** Cache key for a consumed OAuth `state` nonce. Exported for tests. */\nexport function oauthStateConsumedCacheKey(tenantId: string, state: string): string {\n return `communication_channels:oauth-state:used:${tenantId}:${state}`\n}\n\n/** Encrypt + sign a state payload. Output is a base64url string suitable for a cookie. */\nexport function encryptOAuthState(payload: OAuthStatePayload): string {\n const key = deriveKey(getSecret())\n const iv = crypto.randomBytes(IV_LENGTH)\n const json = JSON.stringify(payload)\n\n const cipher = crypto.createCipheriv(ALGORITHM, key, iv)\n const ciphertext = Buffer.concat([cipher.update(json, 'utf8'), cipher.final()])\n const tag = cipher.getAuthTag()\n\n return Buffer.concat([iv, tag, ciphertext]).toString('base64url')\n}\n\n/**\n * Decrypt + verify a state cookie. Returns the payload or `null` if the cookie\n * is malformed / tampered. Returns the payload (NOT null) when the cookie has\n * expired \u2014 callers should check `expiresAt` themselves with the verification\n * helper below for stable status codes.\n */\nexport function decryptOAuthState(cookie: string): OAuthStatePayload | null {\n try {\n const key = deriveKey(getSecret())\n const combined = Buffer.from(cookie, 'base64url')\n if (combined.length < IV_LENGTH + TAG_LENGTH) return null\n\n const iv = combined.subarray(0, IV_LENGTH)\n const tag = combined.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH)\n const ciphertext = combined.subarray(IV_LENGTH + TAG_LENGTH)\n\n const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)\n decipher.setAuthTag(tag)\n const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8')\n\n return JSON.parse(decrypted) as OAuthStatePayload\n } catch {\n return null\n }\n}\n\n/**\n * Verify a state cookie against the current session.\n *\n * Throws an {@link OAuthStateError} with a stable `code` field on any check\n * failure so route handlers can map to consistent HTTP responses + redirect\n * flash codes.\n *\n * Crypto + binding checks only \u2014 callers MUST also call\n * {@link consumeOAuthStateOnce} so the state cannot be replayed within the TTL.\n */\nexport function verifyOAuthState(input: {\n cookie: string | null | undefined\n expectedUserId: string\n expectedProviderKey?: string\n expectedState?: string\n now?: number\n}): OAuthStatePayload {\n if (!input.cookie) {\n throw new OAuthStateError('Missing state cookie', 'invalid_cookie')\n }\n const payload = decryptOAuthState(input.cookie)\n if (!payload) {\n throw new OAuthStateError('Invalid state cookie', 'decrypt_failed')\n }\n const now = input.now ?? Date.now()\n if (payload.expiresAt < now) {\n throw new OAuthStateError('State cookie expired', 'expired')\n }\n if (payload.userId !== input.expectedUserId) {\n throw new OAuthStateError('State cookie userId mismatch', 'user_mismatch')\n }\n if (input.expectedProviderKey && payload.providerKey !== input.expectedProviderKey) {\n throw new OAuthStateError('State cookie providerKey mismatch', 'invalid_cookie')\n }\n if (input.expectedState && payload.state !== input.expectedState) {\n throw new OAuthStateError('State cookie state nonce mismatch', 'invalid_cookie')\n }\n return payload\n}\n\n/**\n * Mark a verified OAuth `state` as consumed in a short-TTL store.\n *\n * First successful call records a used-marker until `expiresAt` (or 1s minimum).\n * A second call with the same `state` throws {@link OAuthStateError} `replay`.\n *\n * Call immediately after {@link verifyOAuthState} on the callback path so a\n * captured valid cookie cannot re-drive the flow within the cookie TTL.\n *\n * Note: the `has`\u2192`set` pattern is not atomic. Two concurrent callbacks racing\n * on the same `state` both pass the `has` check and both proceed \u2014 the\n * single-use guarantee requires an atomic `setNx` which `CacheStrategy` does\n * not currently expose. The race window is milliseconds; document it rather\n * than silently claiming it is closed.\n */\nexport async function consumeOAuthStateOnce(\n store: OAuthStateConsumeStore,\n payload: Pick<OAuthStatePayload, 'state' | 'expiresAt' | 'tenantId'>,\n now: number = Date.now(),\n): Promise<void> {\n const key = oauthStateConsumedCacheKey(payload.tenantId, payload.state)\n if (await store.has(key)) {\n throw new OAuthStateError('State cookie already used', 'replay')\n }\n const ttl = Math.max(payload.expiresAt - now, 1_000)\n await store.set(key, 1, {\n ttl,\n tags: [`${COMMUNICATION_CHANNELS_OAUTH_STATE_CACHE_TAG}:${payload.tenantId}`],\n })\n}\n\n/**\n * Create a fresh state payload + matching `state` query parameter. PKCE\n * verifiers are NOT generated here \u2014 the provider adapter decides whether it\n * needs PKCE and packs the verifier into `extra` itself.\n */\nexport function createOAuthState(params: {\n userId: string\n tenantId: string\n organizationId?: string | null\n providerKey: string\n returnUrl?: string\n extra?: Record<string, unknown>\n}): { payload: OAuthStatePayload; cookie: string; stateParam: string } {\n const state = crypto.randomBytes(32).toString('base64url')\n const nonce = crypto.randomBytes(16).toString('base64url')\n const payload: OAuthStatePayload = {\n state,\n nonce,\n userId: params.userId,\n tenantId: params.tenantId,\n organizationId: params.organizationId ?? null,\n providerKey: params.providerKey,\n returnUrl: params.returnUrl,\n extra: params.extra,\n expiresAt: Date.now() + COMMUNICATION_CHANNELS_OAUTH_STATE_TTL_MS,\n }\n const cookie = encryptOAuthState(payload)\n return { payload, cookie, stateParam: state }\n}\n\nlet memoryCacheStartupWarningEmitted = false\n\n/**\n * Emit a one-time startup warning unless the cache strategy is redis. Every other\n * strategy (memory, sqlite, jsonfile) keeps replay markers per process or per node,\n * so the single-use guarantee only holds across replicas on redis.\n */\nexport function emitOAuthStateMemoryCacheStartupWarningIfNeeded(\n env: NodeJS.ProcessEnv = process.env,\n): void {\n if (memoryCacheStartupWarningEmitted) return\n const strategy = (env.CACHE_STRATEGY ?? 'memory').trim().toLowerCase()\n if (strategy === 'redis') return\n memoryCacheStartupWarningEmitted = true\n oauthStateLogger.warn(\n `OAuth state single-use protection needs a store shared across replicas. CACHE_STRATEGY=${strategy} is per-process or per-node; multi-replica deployments MUST set CACHE_STRATEGY=redis.`,\n { context: 'startup', startup: true, cacheStrategy: strategy },\n )\n}\n\n/** Test helper \u2014 reset the one-time startup warning latch. */\nexport function resetOAuthStateMemoryCacheStartupWarningForTests(): void {\n memoryCacheStartupWarningEmitted = false\n}\n\nemitOAuthStateMemoryCacheStartupWarningIfNeeded()\n"],
|
|
5
|
-
"mappings": "AAAA,OAAO,YAAY;AACnB,SAAS,oBAAoB;AAE7B,MAAM,mBAAmB,aAAa,wBAAwB,EAAE,MAAM,EAAE,WAAW,cAAc,CAAC;AA8BlG,MAAM,YAAY;AAClB,MAAM,YAAY;AAClB,MAAM,aAAa;AACZ,MAAM,4CAA4C,IAAI,KAAK;AAClE,MAAM,YAAY,OAAO,KAAK,sCAAsC;AACpE,MAAM,YAAY,OAAO,KAAK,2CAA2C;AAElE,MAAM,iDACX;AAEK,MAAM,2BAA2B;AAGjC,MAAM,+CACX;AAGK,MAAM,wBAAwB,MAAM;AAAA,EAEzC,YACE,SACS,MAOT;AACA,UAAM,OAAO;AARJ;AAHX,SAAS,OAAO;AAAA,EAYhB;AACF;AA0CO,SAAS,qBAAqB,OAAmD;AACtF,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,MAAI,MAAM,WAAW,KAAK,MAAM,SAAS,KAAM,QAAO;AACtD,MAAI,CAAC,MAAM,WAAW,GAAG,KAAK,MAAM,WAAW,IAAI,EAAG,QAAO;AAC7D,MAAI,MAAM,SAAS,IAAI,EAAG,QAAO;AACjC,MAAI;AACF,UAAM,OAAO,IAAI,IAAI,4BAA4B;AACjD,UAAM,SAAS,IAAI,IAAI,OAAO,IAAI;AAClC,WAAO,OAAO,WAAW,KAAK,UAAU,OAAO,SAAS,WAAW,GAAG;AAAA,EACxE,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEO,SAAS,wBACd,OACA,WAAmB,0BACX;AACR,SAAO,qBAAqB,KAAK,IAAI,QAAQ;AAC/C;AAEA,SAAS,UAAU,QAAwB;AACzC,SAAO,OAAO,KAAK,OAAO,SAAS,UAAU,QAAQ,WAAW,WAAW,EAAE,CAAC;AAChF;AAEA,SAAS,YAAoB;AAC3B,QAAM,YAAY,QAAQ,IAAI,0BAA0B,QAAQ,IAAI;AACpE,MAAI,UAAW,QAAO;AAMtB,MAAI,QAAQ,IAAI,aAAa,cAAc;AACzC,UAAM,IAAI,
|
|
4
|
+
"sourcesContent": ["import crypto from 'node:crypto'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst oauthStateLogger = createLogger('communication_channels').child({ component: 'oauth-state' })\n\n/**\n * OAuth state-cookie helper for the communication_channels hub.\n *\n * **Ported (re-implemented locally), NOT imported, from `packages/enterprise/src/modules/sso/lib/state-cookie.ts`.**\n * Root `AGENTS.md` rule: `@open-mercato/core` MUST NOT import from `@open-mercato/enterprise`.\n *\n * Design (per email integration spec \u00A7 OSS Independence + \u00A7 Hub Deltas \u2192 Delta 7):\n * - AES-256-GCM payload encryption.\n * - HKDF (SHA-256) key derivation from `OM_HUB_OAUTH_STATE_KEY` (falling back to\n * `KMS_MASTER_KEY`). In production those dedicated keys are required; only in\n * dev/test do we fall back to `JWT_SECRET` so envs that configure one secret\n * still work. Production refuses the `JWT_SECRET` fallback so a session-secret\n * leak cannot also forge OAuth-state cookies.\n * - 5-minute TTL \u2014 short window to bound replay surface.\n * - Payload binds the initiating `userId` so the callback rejects state cookies\n * used by a different session.\n * - Single-use consume-on-read via a short-TTL cache marker keyed by\n * `tenantId:state` (see {@link consumeOAuthStateOnce}) so a captured cookie\n * cannot re-drive the callback within the TTL. **This guarantee only holds\n * across all replicas when `CACHE_STRATEGY=redis`.** With\n * `CACHE_STRATEGY=memory` (the default) the marker is process-local \u2014 a\n * replayed callback that reaches a different replica succeeds within the TTL.\n * Multi-replica deployments MUST use a shared cache (`CACHE_STRATEGY=redis`).\n *\n * The output is a base64url string that we set on an HttpOnly + SameSite=Lax cookie.\n * Forgery requires the encryption key (KMS-managed in production).\n */\n\nconst ALGORITHM = 'aes-256-gcm'\nconst IV_LENGTH = 12\nconst TAG_LENGTH = 16\nexport const COMMUNICATION_CHANNELS_OAUTH_STATE_TTL_MS = 5 * 60 * 1000\nconst HKDF_SALT = Buffer.from('open-mercato-channels-oauth-state-v1')\nconst HKDF_INFO = Buffer.from('communication_channels-oauth-state-cookie')\n\nexport const COMMUNICATION_CHANNELS_OAUTH_STATE_COOKIE_NAME =\n 'om_cc_oauth_state'\n\nexport const DEFAULT_OAUTH_RETURN_URL = '/backend/profile/communication-channels'\n\n/** Cache tag for OAuth-state consume markers (invalidation / diagnostics). */\nexport const COMMUNICATION_CHANNELS_OAUTH_STATE_CACHE_TAG =\n 'communication_channels:oauth-state'\n\n/** Errors thrown by the helpers. Stable for tests + route mapping. */\nexport class OAuthStateError extends Error {\n override name = 'OAuthStateError'\n constructor(\n message: string,\n readonly code:\n | 'missing_secret'\n | 'invalid_cookie'\n | 'expired'\n | 'user_mismatch'\n | 'decrypt_failed'\n | 'replay',\n ) {\n super(message)\n }\n}\n\n/**\n * Canonical state-cookie payload \u2014 provider-agnostic. Each OAuth provider\n * adapter (Gmail, \u2026) packs its own per-flow nonce / verifier into\n * the `extra` field rather than extending this shape.\n */\nexport interface OAuthStatePayload {\n /** Nonce-like opaque value mirrored into the OAuth `state` query parameter. */\n state: string\n /** Per-flow CSRF nonce, returned alongside the OAuth response. */\n nonce: string\n /** Tenant-scoped user that initiated the flow. Validated on callback. */\n userId: string\n /** Tenant scope so the callback can pin the channel to the same tenant. */\n tenantId: string\n /** Optional organization id for multi-org tenants. */\n organizationId?: string | null\n /** Provider key (e.g. `gmail`) \u2014 routes the callback. */\n providerKey: string\n /** Where to redirect on success. Defaults to the profile page in the route. */\n returnUrl?: string\n /** Wall-clock expiry (ms since epoch). */\n expiresAt: number\n /** Provider-specific extras (PKCE code_verifier, scopes, login_hint, \u2026). */\n extra?: Record<string, unknown>\n}\n\n/**\n * Minimal cache surface used by {@link consumeOAuthStateOnce}. Matches the\n * `has` / `set` subset of `@open-mercato/cache`'s `CacheStrategy` so callers\n * can pass `container.resolve('cache')` directly.\n */\nexport type OAuthStateConsumeStore = {\n has(key: string): Promise<boolean>\n set(\n key: string,\n value: unknown,\n options?: { ttl?: number; tags?: string[] },\n ): Promise<void>\n}\n\nexport function isSafeOAuthReturnUrl(value: string | null | undefined): value is string {\n if (typeof value !== 'string') return false\n if (value.length === 0 || value.length > 2048) return false\n if (!value.startsWith('/') || value.startsWith('//')) return false\n if (value.includes('\\\\')) return false\n try {\n const base = new URL('https://open-mercato.local')\n const parsed = new URL(value, base)\n return parsed.origin === base.origin && parsed.pathname.startsWith('/')\n } catch {\n return false\n }\n}\n\nexport function normalizeOAuthReturnUrl(\n value: string | null | undefined,\n fallback: string = DEFAULT_OAUTH_RETURN_URL,\n): string {\n return isSafeOAuthReturnUrl(value) ? value : fallback\n}\n\nfunction deriveKey(secret: string): Buffer {\n return Buffer.from(crypto.hkdfSync('sha256', secret, HKDF_SALT, HKDF_INFO, 32))\n}\n\nfunction getSecret(): string {\n const dedicated = process.env.OM_HUB_OAUTH_STATE_KEY ?? process.env.KMS_MASTER_KEY\n if (dedicated) return dedicated\n // No dedicated key configured. Fail closed in production rather than deriving\n // the state-cookie key from JWT_SECRET (the platform session-signing secret) \u2014\n // sharing that key means a JWT_SECRET leak also lets an attacker forge OAuth\n // state cookies, bypassing the userId/tenant binding. In non-production we fall\n // back to JWT_SECRET so dev/test envs that only configure one secret still work.\n if (process.env.NODE_ENV === 'production') {\n throw new OAuthStateError(\n '[internal] OM_HUB_OAUTH_STATE_KEY or KMS_MASTER_KEY required in production',\n 'missing_secret',\n )\n }\n const fallback = process.env.JWT_SECRET\n if (!fallback) {\n throw new OAuthStateError(\n 'OM_HUB_OAUTH_STATE_KEY (or fallback KMS_MASTER_KEY / JWT_SECRET) must be set',\n 'missing_secret',\n )\n }\n return fallback\n}\n\n/** Cache key for a consumed OAuth `state` nonce. Exported for tests. */\nexport function oauthStateConsumedCacheKey(tenantId: string, state: string): string {\n return `communication_channels:oauth-state:used:${tenantId}:${state}`\n}\n\n/** Encrypt + sign a state payload. Output is a base64url string suitable for a cookie. */\nexport function encryptOAuthState(payload: OAuthStatePayload): string {\n const key = deriveKey(getSecret())\n const iv = crypto.randomBytes(IV_LENGTH)\n const json = JSON.stringify(payload)\n\n const cipher = crypto.createCipheriv(ALGORITHM, key, iv)\n const ciphertext = Buffer.concat([cipher.update(json, 'utf8'), cipher.final()])\n const tag = cipher.getAuthTag()\n\n return Buffer.concat([iv, tag, ciphertext]).toString('base64url')\n}\n\n/**\n * Decrypt + verify a state cookie. Returns the payload or `null` if the cookie\n * is malformed / tampered. Returns the payload (NOT null) when the cookie has\n * expired \u2014 callers should check `expiresAt` themselves with the verification\n * helper below for stable status codes.\n */\nexport function decryptOAuthState(cookie: string): OAuthStatePayload | null {\n try {\n const key = deriveKey(getSecret())\n const combined = Buffer.from(cookie, 'base64url')\n if (combined.length < IV_LENGTH + TAG_LENGTH) return null\n\n const iv = combined.subarray(0, IV_LENGTH)\n const tag = combined.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH)\n const ciphertext = combined.subarray(IV_LENGTH + TAG_LENGTH)\n\n const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)\n decipher.setAuthTag(tag)\n const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8')\n\n return JSON.parse(decrypted) as OAuthStatePayload\n } catch {\n return null\n }\n}\n\n/**\n * Verify a state cookie against the current session.\n *\n * Throws an {@link OAuthStateError} with a stable `code` field on any check\n * failure so route handlers can map to consistent HTTP responses + redirect\n * flash codes.\n *\n * Crypto + binding checks only \u2014 callers MUST also call\n * {@link consumeOAuthStateOnce} so the state cannot be replayed within the TTL.\n */\nexport function verifyOAuthState(input: {\n cookie: string | null | undefined\n expectedUserId: string\n expectedProviderKey?: string\n expectedState?: string\n now?: number\n}): OAuthStatePayload {\n if (!input.cookie) {\n throw new OAuthStateError('Missing state cookie', 'invalid_cookie')\n }\n const payload = decryptOAuthState(input.cookie)\n if (!payload) {\n throw new OAuthStateError('Invalid state cookie', 'decrypt_failed')\n }\n const now = input.now ?? Date.now()\n if (payload.expiresAt < now) {\n throw new OAuthStateError('State cookie expired', 'expired')\n }\n if (payload.userId !== input.expectedUserId) {\n throw new OAuthStateError('State cookie userId mismatch', 'user_mismatch')\n }\n if (input.expectedProviderKey && payload.providerKey !== input.expectedProviderKey) {\n throw new OAuthStateError('State cookie providerKey mismatch', 'invalid_cookie')\n }\n if (input.expectedState && payload.state !== input.expectedState) {\n throw new OAuthStateError('State cookie state nonce mismatch', 'invalid_cookie')\n }\n return payload\n}\n\n/**\n * Mark a verified OAuth `state` as consumed in a short-TTL store.\n *\n * First successful call records a used-marker until `expiresAt` (or 1s minimum).\n * A second call with the same `state` throws {@link OAuthStateError} `replay`.\n *\n * Call immediately after {@link verifyOAuthState} on the callback path so a\n * captured valid cookie cannot re-drive the flow within the cookie TTL.\n *\n * Note: the `has`\u2192`set` pattern is not atomic. Two concurrent callbacks racing\n * on the same `state` both pass the `has` check and both proceed \u2014 the\n * single-use guarantee requires an atomic `setNx` which `CacheStrategy` does\n * not currently expose. The race window is milliseconds; document it rather\n * than silently claiming it is closed.\n */\nexport async function consumeOAuthStateOnce(\n store: OAuthStateConsumeStore,\n payload: Pick<OAuthStatePayload, 'state' | 'expiresAt' | 'tenantId'>,\n now: number = Date.now(),\n): Promise<void> {\n const key = oauthStateConsumedCacheKey(payload.tenantId, payload.state)\n if (await store.has(key)) {\n throw new OAuthStateError('State cookie already used', 'replay')\n }\n const ttl = Math.max(payload.expiresAt - now, 1_000)\n await store.set(key, 1, {\n ttl,\n tags: [`${COMMUNICATION_CHANNELS_OAUTH_STATE_CACHE_TAG}:${payload.tenantId}`],\n })\n}\n\n/**\n * Create a fresh state payload + matching `state` query parameter. PKCE\n * verifiers are NOT generated here \u2014 the provider adapter decides whether it\n * needs PKCE and packs the verifier into `extra` itself.\n */\nexport function createOAuthState(params: {\n userId: string\n tenantId: string\n organizationId?: string | null\n providerKey: string\n returnUrl?: string\n extra?: Record<string, unknown>\n}): { payload: OAuthStatePayload; cookie: string; stateParam: string } {\n const state = crypto.randomBytes(32).toString('base64url')\n const nonce = crypto.randomBytes(16).toString('base64url')\n const payload: OAuthStatePayload = {\n state,\n nonce,\n userId: params.userId,\n tenantId: params.tenantId,\n organizationId: params.organizationId ?? null,\n providerKey: params.providerKey,\n returnUrl: params.returnUrl,\n extra: params.extra,\n expiresAt: Date.now() + COMMUNICATION_CHANNELS_OAUTH_STATE_TTL_MS,\n }\n const cookie = encryptOAuthState(payload)\n return { payload, cookie, stateParam: state }\n}\n\nlet memoryCacheStartupWarningEmitted = false\n\n/**\n * Emit a one-time startup warning unless the cache strategy is redis. Every other\n * strategy (memory, sqlite, jsonfile) keeps replay markers per process or per node,\n * so the single-use guarantee only holds across replicas on redis.\n */\nexport function emitOAuthStateMemoryCacheStartupWarningIfNeeded(\n env: NodeJS.ProcessEnv = process.env,\n): void {\n if (memoryCacheStartupWarningEmitted) return\n const strategy = (env.CACHE_STRATEGY ?? 'memory').trim().toLowerCase()\n if (strategy === 'redis') return\n memoryCacheStartupWarningEmitted = true\n oauthStateLogger.warn(\n `OAuth state single-use protection needs a store shared across replicas. CACHE_STRATEGY=${strategy} is per-process or per-node; multi-replica deployments MUST set CACHE_STRATEGY=redis.`,\n { context: 'startup', startup: true, cacheStrategy: strategy },\n )\n}\n\n/** Test helper \u2014 reset the one-time startup warning latch. */\nexport function resetOAuthStateMemoryCacheStartupWarningForTests(): void {\n memoryCacheStartupWarningEmitted = false\n}\n\nemitOAuthStateMemoryCacheStartupWarningIfNeeded()\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -32,6 +32,16 @@ function useConnectChannel(options) {
|
|
|
32
32
|
});
|
|
33
33
|
const body = response.result;
|
|
34
34
|
if (!response.ok || !body?.authorizeUrl) {
|
|
35
|
+
if (body?.code === "missing_secret") {
|
|
36
|
+
flash(
|
|
37
|
+
t(
|
|
38
|
+
"communication_channels.profile.connect.notAvailable",
|
|
39
|
+
"Mailbox connection is not available. Ask an administrator to configure the OAuth state key."
|
|
40
|
+
),
|
|
41
|
+
"error"
|
|
42
|
+
);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
35
45
|
if (body?.code === "oauth_client_not_configured") {
|
|
36
46
|
flash(
|
|
37
47
|
t(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/communication_channels/lib/use-connect-channel.ts"],
|
|
4
|
-
"sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\n\ntype InitiateResponse = { authorizeUrl?: string; error?: string; code?: string }\n\n/**\n * Shared OAuth \"connect\" flow for email channel provider widgets (Gmail and\n * other OAuth providers). Wraps the guarded-mutation contract + `/oauth/<provider>/initiate`\n * call + redirect, so each provider widget only supplies its own button chrome.\n */\nexport function useConnectChannel(options: {\n providerKey: string\n returnUrl?: string\n}): { connect: () => Promise<void>; pending: boolean } {\n const { providerKey } = options\n const returnUrl = options.returnUrl ?? '/backend/profile/communication-channels'\n const t = useT()\n const [pending, setPending] = React.useState(false)\n const { runMutation, retryLastMutation } = useGuardedMutation({\n contextId: `channel-${providerKey}-connect`,\n blockedMessage: t('communication_channels.profile.connect.blocked', 'Connection blocked by validation'),\n })\n const mutationContext = React.useMemo(\n () => ({ providerKey, retryLastMutation }),\n [providerKey, retryLastMutation],\n )\n\n const connect = React.useCallback(async () => {\n if (pending) return\n setPending(true)\n try {\n const response = await runMutation({\n context: mutationContext,\n mutationPayload: { providerKey },\n operation: () =>\n apiCall<InitiateResponse>(`/api/communication_channels/oauth/${providerKey}/initiate`, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ returnUrl }),\n }),\n })\n const body = response.result as InitiateResponse | undefined\n if (!response.ok || !body?.authorizeUrl) {\n if (body?.code === 'oauth_client_not_configured') {\n flash(\n t(\n 'communication_channels.profile.connect.notConfigured',\n 'This provider is not configured yet. Ask an administrator to add the OAuth Client ID and Secret under Integrations before connecting a mailbox.',\n ),\n 'error',\n )\n return\n }\n flash(\n body?.error ??\n t('communication_channels.profile.connect.oauthFailed', 'Could not start OAuth connection.'),\n 'error',\n )\n return\n }\n window.location.assign(body.authorizeUrl)\n } finally {\n setPending(false)\n }\n }, [mutationContext, pending, providerKey, returnUrl, runMutation, t])\n\n return { connect, pending }\n}\n"],
|
|
5
|
-
"mappings": ";AAEA,YAAY,WAAW;AACvB,SAAS,YAAY;AACrB,SAAS,aAAa;AACtB,SAAS,0BAA0B;AACnC,SAAS,eAAe;AASjB,SAAS,kBAAkB,SAGqB;AACrD,QAAM,EAAE,YAAY,IAAI;AACxB,QAAM,YAAY,QAAQ,aAAa;AACvC,QAAM,IAAI,KAAK;AACf,QAAM,CAAC,SAAS,UAAU,IAAI,MAAM,SAAS,KAAK;AAClD,QAAM,EAAE,aAAa,kBAAkB,IAAI,mBAAmB;AAAA,IAC5D,WAAW,WAAW,WAAW;AAAA,IACjC,gBAAgB,EAAE,kDAAkD,kCAAkC;AAAA,EACxG,CAAC;AACD,QAAM,kBAAkB,MAAM;AAAA,IAC5B,OAAO,EAAE,aAAa,kBAAkB;AAAA,IACxC,CAAC,aAAa,iBAAiB;AAAA,EACjC;AAEA,QAAM,UAAU,MAAM,YAAY,YAAY;AAC5C,QAAI,QAAS;AACb,eAAW,IAAI;AACf,QAAI;AACF,YAAM,WAAW,MAAM,YAAY;AAAA,QACjC,SAAS;AAAA,QACT,iBAAiB,EAAE,YAAY;AAAA,QAC/B,WAAW,MACT,QAA0B,qCAAqC,WAAW,aAAa;AAAA,UACrF,QAAQ;AAAA,UACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,UAC9C,MAAM,KAAK,UAAU,EAAE,UAAU,CAAC;AAAA,QACpC,CAAC;AAAA,MACL,CAAC;AACD,YAAM,OAAO,SAAS;AACtB,UAAI,CAAC,SAAS,MAAM,CAAC,MAAM,cAAc;AACvC,YAAI,MAAM,SAAS,+BAA+B;AAChD;AAAA,YACE;AAAA,cACE;AAAA,cACA;AAAA,YACF;AAAA,YACA;AAAA,UACF;AACA;AAAA,QACF;AACA;AAAA,UACE,MAAM,SACJ,EAAE,sDAAsD,mCAAmC;AAAA,UAC7F;AAAA,QACF;AACA;AAAA,MACF;AACA,aAAO,SAAS,OAAO,KAAK,YAAY;AAAA,IAC1C,UAAE;AACA,iBAAW,KAAK;AAAA,IAClB;AAAA,EACF,GAAG,CAAC,iBAAiB,SAAS,aAAa,WAAW,aAAa,CAAC,CAAC;AAErE,SAAO,EAAE,SAAS,QAAQ;AAC5B;",
|
|
4
|
+
"sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\n\ntype InitiateResponse = { authorizeUrl?: string; error?: string; code?: string }\n\n/**\n * Shared OAuth \"connect\" flow for email channel provider widgets (Gmail and\n * other OAuth providers). Wraps the guarded-mutation contract + `/oauth/<provider>/initiate`\n * call + redirect, so each provider widget only supplies its own button chrome.\n */\nexport function useConnectChannel(options: {\n providerKey: string\n returnUrl?: string\n}): { connect: () => Promise<void>; pending: boolean } {\n const { providerKey } = options\n const returnUrl = options.returnUrl ?? '/backend/profile/communication-channels'\n const t = useT()\n const [pending, setPending] = React.useState(false)\n const { runMutation, retryLastMutation } = useGuardedMutation({\n contextId: `channel-${providerKey}-connect`,\n blockedMessage: t('communication_channels.profile.connect.blocked', 'Connection blocked by validation'),\n })\n const mutationContext = React.useMemo(\n () => ({ providerKey, retryLastMutation }),\n [providerKey, retryLastMutation],\n )\n\n const connect = React.useCallback(async () => {\n if (pending) return\n setPending(true)\n try {\n const response = await runMutation({\n context: mutationContext,\n mutationPayload: { providerKey },\n operation: () =>\n apiCall<InitiateResponse>(`/api/communication_channels/oauth/${providerKey}/initiate`, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ returnUrl }),\n }),\n })\n const body = response.result as InitiateResponse | undefined\n if (!response.ok || !body?.authorizeUrl) {\n if (body?.code === 'missing_secret') {\n flash(\n t(\n 'communication_channels.profile.connect.notAvailable',\n 'Mailbox connection is not available. Ask an administrator to configure the OAuth state key.',\n ),\n 'error',\n )\n return\n }\n if (body?.code === 'oauth_client_not_configured') {\n flash(\n t(\n 'communication_channels.profile.connect.notConfigured',\n 'This provider is not configured yet. Ask an administrator to add the OAuth Client ID and Secret under Integrations before connecting a mailbox.',\n ),\n 'error',\n )\n return\n }\n flash(\n body?.error ??\n t('communication_channels.profile.connect.oauthFailed', 'Could not start OAuth connection.'),\n 'error',\n )\n return\n }\n window.location.assign(body.authorizeUrl)\n } finally {\n setPending(false)\n }\n }, [mutationContext, pending, providerKey, returnUrl, runMutation, t])\n\n return { connect, pending }\n}\n"],
|
|
5
|
+
"mappings": ";AAEA,YAAY,WAAW;AACvB,SAAS,YAAY;AACrB,SAAS,aAAa;AACtB,SAAS,0BAA0B;AACnC,SAAS,eAAe;AASjB,SAAS,kBAAkB,SAGqB;AACrD,QAAM,EAAE,YAAY,IAAI;AACxB,QAAM,YAAY,QAAQ,aAAa;AACvC,QAAM,IAAI,KAAK;AACf,QAAM,CAAC,SAAS,UAAU,IAAI,MAAM,SAAS,KAAK;AAClD,QAAM,EAAE,aAAa,kBAAkB,IAAI,mBAAmB;AAAA,IAC5D,WAAW,WAAW,WAAW;AAAA,IACjC,gBAAgB,EAAE,kDAAkD,kCAAkC;AAAA,EACxG,CAAC;AACD,QAAM,kBAAkB,MAAM;AAAA,IAC5B,OAAO,EAAE,aAAa,kBAAkB;AAAA,IACxC,CAAC,aAAa,iBAAiB;AAAA,EACjC;AAEA,QAAM,UAAU,MAAM,YAAY,YAAY;AAC5C,QAAI,QAAS;AACb,eAAW,IAAI;AACf,QAAI;AACF,YAAM,WAAW,MAAM,YAAY;AAAA,QACjC,SAAS;AAAA,QACT,iBAAiB,EAAE,YAAY;AAAA,QAC/B,WAAW,MACT,QAA0B,qCAAqC,WAAW,aAAa;AAAA,UACrF,QAAQ;AAAA,UACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,UAC9C,MAAM,KAAK,UAAU,EAAE,UAAU,CAAC;AAAA,QACpC,CAAC;AAAA,MACL,CAAC;AACD,YAAM,OAAO,SAAS;AACtB,UAAI,CAAC,SAAS,MAAM,CAAC,MAAM,cAAc;AACvC,YAAI,MAAM,SAAS,kBAAkB;AACnC;AAAA,YACE;AAAA,cACE;AAAA,cACA;AAAA,YACF;AAAA,YACA;AAAA,UACF;AACA;AAAA,QACF;AACA,YAAI,MAAM,SAAS,+BAA+B;AAChD;AAAA,YACE;AAAA,cACE;AAAA,cACA;AAAA,YACF;AAAA,YACA;AAAA,UACF;AACA;AAAA,QACF;AACA;AAAA,UACE,MAAM,SACJ,EAAE,sDAAsD,mCAAmC;AAAA,UAC7F;AAAA,QACF;AACA;AAAA,MACF;AACA,aAAO,SAAS,OAAO,KAAK,YAAY;AAAA,IAC1C,UAAE;AACA,iBAAW,KAAK;AAAA,IAClB;AAAA,EACF,GAAG,CAAC,iBAAiB,SAAS,aAAa,WAAW,aAAa,CAAC,CAAC;AAErE,SAAO,EAAE,SAAS,QAAQ;AAC5B;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { isCrudHttpError } from "@open-mercato/shared/lib/crud/errors";
|
|
4
|
+
import { CrudHttpError } from "@open-mercato/shared/lib/crud/errors";
|
|
5
|
+
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
6
|
+
import { getTelemetryRuntime } from "@open-mercato/shared/lib/telemetry/runtime";
|
|
7
|
+
import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
|
|
8
|
+
import { resolveDictionaryRouteContext } from "../dictionaries/context.js";
|
|
9
|
+
import { createDictionaryCacheTags, DICTIONARY_CACHE_TTL_MS } from "../dictionaries/cache.js";
|
|
10
|
+
import { getInjectionRegistryVersion } from "@open-mercato/shared/modules/widgets/injection-loader";
|
|
11
|
+
import {
|
|
12
|
+
resolveScopedCalendarEventTypes
|
|
13
|
+
} from "../../lib/calendar/eventTypeResolver.js";
|
|
14
|
+
import { calendarEventTypeBehaviorSchema } from "../../calendar-event-types.js";
|
|
15
|
+
import { getCalendarEventTypeRegistryVersion } from "../../calendar-event-types.js";
|
|
16
|
+
const logger = createLogger("customers");
|
|
17
|
+
function reportActivityTypeError(error, code, attributes) {
|
|
18
|
+
try {
|
|
19
|
+
getTelemetryRuntime()?.reportError(error, { module: "customers", code, attributes });
|
|
20
|
+
} catch {
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const querySchema = z.object({ organizationId: z.string().uuid().optional() });
|
|
24
|
+
const responseSchema = z.object({
|
|
25
|
+
fallbackKey: z.literal("meeting"),
|
|
26
|
+
items: z.array(z.object({
|
|
27
|
+
key: z.string(),
|
|
28
|
+
label: z.string(),
|
|
29
|
+
labelKey: z.string().optional(),
|
|
30
|
+
icon: z.string().nullable().optional(),
|
|
31
|
+
color: z.string().nullable().optional(),
|
|
32
|
+
behavior: calendarEventTypeBehaviorSchema,
|
|
33
|
+
selectable: z.boolean(),
|
|
34
|
+
panelKey: z.string().optional(),
|
|
35
|
+
source: z.string(),
|
|
36
|
+
provenance: z.record(z.string(), z.unknown()),
|
|
37
|
+
historical: z.boolean(),
|
|
38
|
+
fallbackReason: z.enum(["tombstoned", "module-unavailable", "unknown"]).optional(),
|
|
39
|
+
adminConfigurable: z.boolean(),
|
|
40
|
+
isInherited: z.boolean(),
|
|
41
|
+
isLocalOverride: z.boolean(),
|
|
42
|
+
updatedAt: z.string().nullable(),
|
|
43
|
+
missingCustomFieldsetIds: z.array(z.string()),
|
|
44
|
+
inactiveDictionaryOverride: z.boolean().optional()
|
|
45
|
+
}))
|
|
46
|
+
});
|
|
47
|
+
const metadata = {
|
|
48
|
+
GET: { requireAuth: true }
|
|
49
|
+
};
|
|
50
|
+
async function GET(req) {
|
|
51
|
+
try {
|
|
52
|
+
const url = new URL(req.url);
|
|
53
|
+
const query = querySchema.parse({ organizationId: url.searchParams.get("organizationId") ?? void 0 });
|
|
54
|
+
const context = await resolveDictionaryRouteContext(req, { selectedId: query.organizationId });
|
|
55
|
+
if (!context.organizationId) {
|
|
56
|
+
return NextResponse.json({ error: context.translate("customers.errors.organization_required", "Organization context is required") }, { status: 400 });
|
|
57
|
+
}
|
|
58
|
+
const organizationId = context.organizationId;
|
|
59
|
+
const rbac = context.container.resolve("rbacService");
|
|
60
|
+
const actorId = context.auth?.sub;
|
|
61
|
+
if (!rbac || !actorId) {
|
|
62
|
+
throw new CrudHttpError(403, { error: context.translate("customers.calendar.activityTypes.errors.forbidden", "You do not have permission to view activity types.") });
|
|
63
|
+
}
|
|
64
|
+
const permissionScope = { tenantId: context.tenantId, organizationId };
|
|
65
|
+
const canViewInteractions = await rbac.userHasAllFeatures(actorId, ["customers.interactions.view"], permissionScope);
|
|
66
|
+
const canManageSettings = canViewInteractions || await rbac.userHasAllFeatures(actorId, ["customers.settings.manage"], permissionScope);
|
|
67
|
+
if (!canManageSettings) {
|
|
68
|
+
throw new CrudHttpError(403, { error: context.translate("customers.calendar.activityTypes.errors.forbidden", "You do not have permission to view activity types.") });
|
|
69
|
+
}
|
|
70
|
+
const hierarchy = context.container.resolve("organizationHierarchyService");
|
|
71
|
+
const ancestors = await hierarchy.resolveAncestorIds({ tenantId: context.tenantId, organizationId });
|
|
72
|
+
if (ancestors === null) {
|
|
73
|
+
throw new CrudHttpError(403, { error: context.translate("customers.errors.organization_forbidden", "Organization not accessible") });
|
|
74
|
+
}
|
|
75
|
+
const scopeIds = Array.from(/* @__PURE__ */ new Set([organizationId, ...ancestors.slice().reverse()]));
|
|
76
|
+
const cacheKey = `customers:activity-types:${context.tenantId}:org=${organizationId}:scope=${scopeIds.join("|")}:widgets=${getInjectionRegistryVersion()}:registry=${getCalendarEventTypeRegistryVersion()}`;
|
|
77
|
+
const cached = await context.cache?.get(cacheKey);
|
|
78
|
+
if (cached) return NextResponse.json(cached);
|
|
79
|
+
let catalog;
|
|
80
|
+
try {
|
|
81
|
+
catalog = await resolveScopedCalendarEventTypes({
|
|
82
|
+
em: context.em,
|
|
83
|
+
tenantId: context.tenantId,
|
|
84
|
+
organizationId,
|
|
85
|
+
readableOrganizationIds: scopeIds
|
|
86
|
+
});
|
|
87
|
+
} catch (err) {
|
|
88
|
+
logger.error("customers.activity_types.resolve failed", {
|
|
89
|
+
err,
|
|
90
|
+
tenantId: context.tenantId,
|
|
91
|
+
organizationId
|
|
92
|
+
});
|
|
93
|
+
reportActivityTypeError(err, "customers.activity_type_catalog_resolution_failed", {
|
|
94
|
+
tenantId: context.tenantId,
|
|
95
|
+
organizationId
|
|
96
|
+
});
|
|
97
|
+
return NextResponse.json({
|
|
98
|
+
error: context.translate("customers.calendar.activityTypes.errors.loadFailed", "Failed to load activity types"),
|
|
99
|
+
code: "activity_type_catalog_unavailable"
|
|
100
|
+
}, { status: 503 });
|
|
101
|
+
}
|
|
102
|
+
if (context.cache) {
|
|
103
|
+
await context.cache.set(cacheKey, catalog, {
|
|
104
|
+
ttl: DICTIONARY_CACHE_TTL_MS,
|
|
105
|
+
tags: createDictionaryCacheTags({
|
|
106
|
+
tenantId: context.tenantId,
|
|
107
|
+
mappedKind: "activity_type",
|
|
108
|
+
organizationIds: scopeIds
|
|
109
|
+
})
|
|
110
|
+
}).catch((err) => {
|
|
111
|
+
logger.warn("customers.activity_types cache write failed", { err });
|
|
112
|
+
reportActivityTypeError(err, "customers.activity_type_catalog_cache_write_failed", {
|
|
113
|
+
tenantId: context.tenantId,
|
|
114
|
+
organizationId
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return NextResponse.json(catalog);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status });
|
|
121
|
+
logger.error("customers.activity_types request failed", { err });
|
|
122
|
+
reportActivityTypeError(err, "customers.activity_type_catalog_request_failed");
|
|
123
|
+
const { translate } = await resolveTranslations();
|
|
124
|
+
return NextResponse.json({
|
|
125
|
+
error: translate("customers.calendar.activityTypes.errors.loadFailed", "Failed to load activity types")
|
|
126
|
+
}, { status: err instanceof z.ZodError ? 400 : 500 });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const openApi = {
|
|
130
|
+
tag: "Customers",
|
|
131
|
+
summary: "Effective calendar activity types",
|
|
132
|
+
methods: {
|
|
133
|
+
GET: {
|
|
134
|
+
summary: "List effective calendar activity types",
|
|
135
|
+
description: "Returns the tenant and organization scoped calendar activity-type catalog. Requires customers.interactions.view or customers.settings.manage.",
|
|
136
|
+
responses: [{ status: 200, description: "Effective activity-type catalog", schema: responseSchema }],
|
|
137
|
+
errors: [
|
|
138
|
+
{ status: 400, description: "Organization scope is required", schema: z.object({ error: z.string() }) },
|
|
139
|
+
{ status: 401, description: "Unauthorized", schema: z.object({ error: z.string() }) },
|
|
140
|
+
{ status: 403, description: "Forbidden", schema: z.object({ error: z.string() }) },
|
|
141
|
+
{ status: 500, description: "Unexpected server error", schema: z.object({ error: z.string() }) }
|
|
142
|
+
]
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
export {
|
|
147
|
+
GET,
|
|
148
|
+
metadata,
|
|
149
|
+
openApi
|
|
150
|
+
};
|
|
151
|
+
//# sourceMappingURL=route.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../../src/modules/customers/api/activity-types/route.ts"],
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport type { OrganizationHierarchyService } from '@open-mercato/shared/lib/auth/principal-service'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { resolveDictionaryRouteContext } from '../dictionaries/context'\nimport { createDictionaryCacheTags, DICTIONARY_CACHE_TTL_MS } from '../dictionaries/cache'\nimport { getInjectionRegistryVersion } from '@open-mercato/shared/modules/widgets/injection-loader'\nimport {\n resolveScopedCalendarEventTypes,\n} from '../../lib/calendar/eventTypeResolver'\nimport { calendarEventTypeBehaviorSchema } from '../../calendar-event-types'\nimport { getCalendarEventTypeRegistryVersion } from '../../calendar-event-types'\n\nconst logger = createLogger('customers')\n\nfunction reportActivityTypeError(error: unknown, code: string, attributes?: Record<string, string>): void {\n try {\n getTelemetryRuntime()?.reportError(error, { module: 'customers', code, attributes })\n } catch {}\n}\n\nconst querySchema = z.object({ organizationId: z.string().uuid().optional() })\nconst responseSchema = z.object({\n fallbackKey: z.literal('meeting'),\n items: z.array(z.object({\n key: z.string(),\n label: z.string(),\n labelKey: z.string().optional(),\n icon: z.string().nullable().optional(),\n color: z.string().nullable().optional(),\n behavior: calendarEventTypeBehaviorSchema,\n selectable: z.boolean(),\n panelKey: z.string().optional(),\n source: z.string(),\n provenance: z.record(z.string(), z.unknown()),\n historical: z.boolean(),\n fallbackReason: z.enum(['tombstoned', 'module-unavailable', 'unknown']).optional(),\n adminConfigurable: z.boolean(),\n isInherited: z.boolean(),\n isLocalOverride: z.boolean(),\n updatedAt: z.string().nullable(),\n missingCustomFieldsetIds: z.array(z.string()),\n inactiveDictionaryOverride: z.boolean().optional(),\n })),\n})\n\nexport const metadata = {\n GET: { requireAuth: true },\n}\n\nexport async function GET(req: Request) {\n try {\n const url = new URL(req.url)\n const query = querySchema.parse({ organizationId: url.searchParams.get('organizationId') ?? undefined })\n const context = await resolveDictionaryRouteContext(req, { selectedId: query.organizationId })\n if (!context.organizationId) {\n return NextResponse.json({ error: context.translate('customers.errors.organization_required', 'Organization context is required') }, { status: 400 })\n }\n const organizationId = context.organizationId\n const rbac = context.container.resolve('rbacService') as RbacService | undefined\n const actorId = context.auth?.sub\n if (!rbac || !actorId) {\n throw new CrudHttpError(403, { error: context.translate('customers.calendar.activityTypes.errors.forbidden', 'You do not have permission to view activity types.') })\n }\n const permissionScope = { tenantId: context.tenantId, organizationId }\n const canViewInteractions = await rbac.userHasAllFeatures(actorId, ['customers.interactions.view'], permissionScope)\n const canManageSettings = canViewInteractions || await rbac.userHasAllFeatures(actorId, ['customers.settings.manage'], permissionScope)\n if (!canManageSettings) {\n throw new CrudHttpError(403, { error: context.translate('customers.calendar.activityTypes.errors.forbidden', 'You do not have permission to view activity types.') })\n }\n const hierarchy = context.container.resolve('organizationHierarchyService') as OrganizationHierarchyService\n const ancestors = await hierarchy.resolveAncestorIds({ tenantId: context.tenantId, organizationId })\n if (ancestors === null) {\n throw new CrudHttpError(403, { error: context.translate('customers.errors.organization_forbidden', 'Organization not accessible') })\n }\n const scopeIds = Array.from(new Set([organizationId, ...ancestors.slice().reverse()]))\n const cacheKey = `customers:activity-types:${context.tenantId}:org=${organizationId}:scope=${scopeIds.join('|')}:widgets=${getInjectionRegistryVersion()}:registry=${getCalendarEventTypeRegistryVersion()}`\n const cached = await context.cache?.get(cacheKey)\n if (cached) return NextResponse.json(cached)\n\n let catalog\n try {\n catalog = await resolveScopedCalendarEventTypes({\n em: context.em,\n tenantId: context.tenantId,\n organizationId,\n readableOrganizationIds: scopeIds,\n })\n } catch (err) {\n logger.error('customers.activity_types.resolve failed', {\n err,\n tenantId: context.tenantId,\n organizationId,\n })\n reportActivityTypeError(err, 'customers.activity_type_catalog_resolution_failed', {\n tenantId: context.tenantId,\n organizationId,\n })\n return NextResponse.json({\n error: context.translate('customers.calendar.activityTypes.errors.loadFailed', 'Failed to load activity types'),\n code: 'activity_type_catalog_unavailable',\n }, { status: 503 })\n }\n if (context.cache) {\n await context.cache.set(cacheKey, catalog, {\n ttl: DICTIONARY_CACHE_TTL_MS,\n tags: createDictionaryCacheTags({\n tenantId: context.tenantId,\n mappedKind: 'activity_type',\n organizationIds: scopeIds,\n }),\n }).catch((err) => {\n logger.warn('customers.activity_types cache write failed', { err })\n reportActivityTypeError(err, 'customers.activity_type_catalog_cache_write_failed', {\n tenantId: context.tenantId,\n organizationId,\n })\n })\n }\n return NextResponse.json(catalog)\n } catch (err) {\n if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })\n logger.error('customers.activity_types request failed', { err })\n reportActivityTypeError(err, 'customers.activity_type_catalog_request_failed')\n const { translate } = await resolveTranslations()\n return NextResponse.json({\n error: translate('customers.calendar.activityTypes.errors.loadFailed', 'Failed to load activity types'),\n }, { status: err instanceof z.ZodError ? 400 : 500 })\n }\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Customers',\n summary: 'Effective calendar activity types',\n methods: {\n GET: {\n summary: 'List effective calendar activity types',\n description: 'Returns the tenant and organization scoped calendar activity-type catalog. Requires customers.interactions.view or customers.settings.manage.',\n responses: [{ status: 200, description: 'Effective activity-type catalog', schema: responseSchema }],\n errors: [\n { status: 400, description: 'Organization scope is required', schema: z.object({ error: z.string() }) },\n { status: 401, description: 'Unauthorized', schema: z.object({ error: z.string() }) },\n { status: 403, description: 'Forbidden', schema: z.object({ error: z.string() }) },\n { status: 500, description: 'Unexpected server error', schema: z.object({ error: z.string() }) },\n ],\n },\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|