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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/helpers/integration/communicationChannelsFixtures.js +19 -0
  3. package/dist/helpers/integration/communicationChannelsFixtures.js.map +2 -2
  4. package/dist/modules/communication_channels/api/post/channels/[id]/test-send/route.js +9 -1
  5. package/dist/modules/communication_channels/api/post/channels/[id]/test-send/route.js.map +2 -2
  6. package/dist/modules/communication_channels/api/post/test-seed/route.js +95 -10
  7. package/dist/modules/communication_channels/api/post/test-seed/route.js.map +2 -2
  8. package/dist/modules/communication_channels/commands/ingest-inbound-message.js +5 -0
  9. package/dist/modules/communication_channels/commands/ingest-inbound-message.js.map +2 -2
  10. package/dist/modules/communication_channels/di.js +7 -1
  11. package/dist/modules/communication_channels/di.js.map +2 -2
  12. package/dist/modules/communication_channels/lib/email-capabilities.js +4 -1
  13. package/dist/modules/communication_channels/lib/email-capabilities.js.map +2 -2
  14. package/dist/modules/communication_channels/lib/outbound-recipient.js +33 -0
  15. package/dist/modules/communication_channels/lib/outbound-recipient.js.map +7 -0
  16. package/dist/modules/communication_channels/lib/resolve-channel-type.js +51 -0
  17. package/dist/modules/communication_channels/lib/resolve-channel-type.js.map +7 -0
  18. package/dist/modules/communication_channels/lib/test-seed.js +41 -2
  19. package/dist/modules/communication_channels/lib/test-seed.js.map +2 -2
  20. package/dist/modules/customers/api/people/check-phone/route.js +51 -9
  21. package/dist/modules/customers/api/people/check-phone/route.js.map +2 -2
  22. package/dist/modules/customers/lib/findPeopleByAddresses.js +1 -0
  23. package/dist/modules/customers/lib/findPeopleByAddresses.js.map +2 -2
  24. package/dist/modules/messages/api/openapi.js +2 -0
  25. package/dist/modules/messages/api/openapi.js.map +2 -2
  26. package/dist/modules/messages/api/route.js +17 -2
  27. package/dist/modules/messages/api/route.js.map +2 -2
  28. package/dist/modules/messages/data/validators.js +18 -4
  29. package/dist/modules/messages/data/validators.js.map +2 -2
  30. package/dist/modules/messages/lib/channel-sender-identity.js +26 -0
  31. package/dist/modules/messages/lib/channel-sender-identity.js.map +7 -0
  32. package/dist/modules/messages/lib/composeSourceChannelType.js +41 -0
  33. package/dist/modules/messages/lib/composeSourceChannelType.js.map +7 -0
  34. package/dist/modules/warranty_claims/api/assignees/route.js +3 -3
  35. package/dist/modules/warranty_claims/api/assignees/route.js.map +2 -2
  36. package/dist/modules/warranty_claims/api/portal/claims/route.js +12 -9
  37. package/dist/modules/warranty_claims/api/portal/claims/route.js.map +2 -2
  38. package/package.json +7 -7
  39. package/src/helpers/integration/communicationChannelsFixtures.ts +69 -1
  40. package/src/modules/communication_channels/api/post/channels/[id]/test-send/route.ts +17 -1
  41. package/src/modules/communication_channels/api/post/test-seed/route.ts +155 -16
  42. package/src/modules/communication_channels/commands/ingest-inbound-message.ts +5 -0
  43. package/src/modules/communication_channels/di.ts +7 -0
  44. package/src/modules/communication_channels/lib/adapter.ts +14 -0
  45. package/src/modules/communication_channels/lib/email-capabilities.ts +4 -0
  46. package/src/modules/communication_channels/lib/outbound-recipient.ts +71 -0
  47. package/src/modules/communication_channels/lib/resolve-channel-type.ts +99 -0
  48. package/src/modules/communication_channels/lib/test-seed.ts +64 -4
  49. package/src/modules/customers/api/people/check-phone/route.ts +75 -10
  50. package/src/modules/customers/lib/findPeopleByAddresses.ts +8 -6
  51. package/src/modules/messages/api/openapi.ts +2 -0
  52. package/src/modules/messages/api/route.ts +31 -2
  53. package/src/modules/messages/data/validators.ts +43 -4
  54. package/src/modules/messages/lib/channel-sender-identity.ts +55 -0
  55. package/src/modules/messages/lib/composeSourceChannelType.ts +95 -0
  56. package/src/modules/warranty_claims/api/assignees/route.ts +3 -3
  57. package/src/modules/warranty_claims/api/portal/claims/route.ts +12 -9
@@ -3,9 +3,12 @@ import { z } from 'zod'
3
3
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
4
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
5
5
  import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
6
- import type { EntityManager } from '@mikro-orm/postgresql'
6
+ import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'
7
7
  import { CustomerEntity } from '../../../data/entities'
8
8
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
9
+ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
10
+ import { isTenantDataEncryptionEnabled } from '@open-mercato/shared/lib/encryption/toggles'
11
+ import { MATCH_CANDIDATE_LIMIT } from '../../../lib/findPeopleByAddresses'
9
12
 
10
13
  const querySchema = z.object({
11
14
  digits: z
@@ -18,6 +21,10 @@ export const metadata = {
18
21
  GET: { requireAuth: true, requireFeatures: ['customers.people.view'] },
19
22
  }
20
23
 
24
+ function normalizePhoneDigits(value: string | null | undefined): string {
25
+ return typeof value === 'string' ? value.replace(/\D/g, '') : ''
26
+ }
27
+
21
28
  export async function GET(req: Request) {
22
29
  const url = new URL(req.url)
23
30
  const rawQuery: Record<string, string | null> = { digits: url.searchParams.get('digits') }
@@ -45,18 +52,76 @@ export async function GET(req: Request) {
45
52
  return NextResponse.json({ match: null })
46
53
  }
47
54
 
48
- const qb = em.createQueryBuilder(CustomerEntity, 'person')
49
- qb.select(['person.id', 'person.displayName'])
50
- qb.where({ kind: 'person', deletedAt: null })
51
- qb.andWhere('person.primary_phone is not null')
52
- qb.andWhere("regexp_replace(person.primary_phone, '\\D', '', 'g') = ?", [parse.data.digits])
55
+ // SQL fast path, gated on the encryption toggle: it can only match stored
56
+ // plaintext digits, so running it while tenant data encryption is enabled
57
+ // (the default) would pay a per-row scan that provably returns nothing.
58
+ // When encryption is off it serves the pre-encryption single-query behavior,
59
+ // including legacy plaintext rows. Encrypted rows written before the toggle
60
+ // was turned off are not resolvable by either path by design; plaintext
61
+ // legacy rows under an enabled toggle still resolve through the decrypted
62
+ // scan below, where decryption no-ops on non-ciphertext values.
63
+ if (!isTenantDataEncryptionEnabled()) {
64
+ const qb = em.createQueryBuilder(CustomerEntity, 'person')
65
+ qb.select(['person.id', 'person.displayName'])
66
+ qb.where({ kind: 'person', deletedAt: null })
67
+ qb.andWhere('person.primary_phone is not null')
68
+ qb.andWhere("regexp_replace(person.primary_phone, '\\D', '', 'g') = ?", [parse.data.digits])
69
+ if (auth.tenantId) {
70
+ qb.andWhere({ tenantId: auth.tenantId })
71
+ }
72
+ qb.andWhere({ organizationId: { $in: Array.from(allowedOrgIds) } })
73
+ qb.limit(1)
74
+
75
+ const fastMatch = await qb.getSingleResult()
76
+ if (fastMatch) {
77
+ return NextResponse.json({
78
+ match: {
79
+ id: fastMatch.id,
80
+ displayName: fastMatch.displayName,
81
+ },
82
+ })
83
+ }
84
+ }
85
+
86
+ // Encryption-on path: primary_phone holds random-IV ciphertext, so digit
87
+ // normalization must run in application code over decrypted rows instead of
88
+ // SQL against the stored column (issue #3840).
89
+ // Encryption-on path: primary_phone holds random-IV ciphertext, so digit
90
+ // normalization must run in application code over decrypted rows instead of
91
+ // SQL against the stored column (issue #3840). The scan is bounded newest-
92
+ // first and projected to the columns the route uses plus tenantId/
93
+ // organizationId, which the decryption path reads per row to resolve each
94
+ // row's own key (a request-level fallback cannot cover multi-org scans or
95
+ // null-tenant sessions). A primary_phone blind index is the exact O(1)
96
+ // follow-up (#5515).
97
+ const where: FilterQuery<CustomerEntity> = {
98
+ kind: 'person',
99
+ deletedAt: null,
100
+ primaryPhone: { $ne: null },
101
+ organizationId: { $in: Array.from(allowedOrgIds) },
102
+ }
53
103
  if (auth.tenantId) {
54
- qb.andWhere({ tenantId: auth.tenantId })
104
+ where.tenantId = auth.tenantId
55
105
  }
56
- qb.andWhere({ organizationId: { $in: Array.from(allowedOrgIds) } })
57
- qb.limit(1)
58
106
 
59
- const match = await qb.getSingleResult()
107
+ const candidates = await findWithDecryption(
108
+ em,
109
+ CustomerEntity,
110
+ where,
111
+ {
112
+ limit: MATCH_CANDIDATE_LIMIT,
113
+ orderBy: { createdAt: 'DESC' },
114
+ fields: ['id', 'displayName', 'primaryPhone', 'tenantId', 'organizationId'],
115
+ },
116
+ {
117
+ tenantId: auth.tenantId ?? null,
118
+ organizationId: scope?.selectedId ?? auth.orgId ?? null,
119
+ },
120
+ )
121
+
122
+ const match = candidates.find(
123
+ (candidate) => normalizePhoneDigits(candidate.primaryPhone) === parse.data.digits,
124
+ )
60
125
  if (!match) {
61
126
  return NextResponse.json({ match: null })
62
127
  }
@@ -32,13 +32,15 @@ export interface MatchedPerson {
32
32
  }
33
33
 
34
34
  /**
35
- * Max person rows scanned by the in-memory fallback match. `primary_email` is
36
- * GDPR-encrypted with a random IV (see `customers/encryption.ts`), so it cannot
37
- * be filtered by value in SQL when tenant data encryption is on — recent rows are
38
- * decrypted and compared in memory instead. Bounded to keep the inbound path cheap;
39
- * a `primary_email` blind-index (hash) column is the follow-up if tenants outgrow it.
35
+ * Max rows scanned by in-memory fallback matches over encrypted contact
36
+ * columns (`primary_email` here, `primary_phone` in the check-phone route).
37
+ * These columns are GDPR-encrypted with a random IV (see
38
+ * `customers/encryption.ts`), so they cannot be filtered by value in SQL when
39
+ * tenant data encryption is on — fallbacks decrypt recent rows and compare in
40
+ * memory instead. Bounded to keep interactive lookups cheap; a blind-index
41
+ * (hash) column per field is the follow-up if tenants outgrow it (#5515).
40
42
  */
41
- const MATCH_CANDIDATE_LIMIT = 500
43
+ export const MATCH_CANDIDATE_LIMIT = 500
42
44
 
43
45
  /**
44
46
  * Batch lookup of CustomerEntity rows (kind='person') whose `primaryEmail`
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod'
2
2
  import {
3
3
  attachmentIdsPayloadSchema,
4
+ composeMessageRequestSchema,
4
5
  composeMessageSchema,
5
6
  executeActionSchema,
6
7
  forwardMessageSchema,
@@ -283,6 +284,7 @@ export {
283
284
  messageObjectSchema,
284
285
  messageRecipientSchema,
285
286
  composeMessageSchema,
287
+ composeMessageRequestSchema,
286
288
  listMessagesSchema,
287
289
  forwardMessageSchema,
288
290
  replyMessageSchema,
@@ -17,6 +17,11 @@ import { lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes'
17
17
  import { User } from '../../auth/data/entities'
18
18
  import { Message, MessageObject } from '../data/entities'
19
19
  import { composeMessageSchema, listMessagesSchema, type ListMessagesInput } from '../data/validators'
20
+ import {
21
+ composeRequiresChannelTypeResolution,
22
+ composeSourceHintSchema,
23
+ resolveComposeSourceChannelType,
24
+ } from '../lib/composeSourceChannelType'
20
25
  import { MESSAGE_ATTACHMENT_ENTITY_ID } from '../lib/constants'
21
26
  import { getMessageType } from '../lib/message-types-registry'
22
27
  import { validateMessageObjectsForType } from '../lib/object-validation'
@@ -27,7 +32,7 @@ import { resolveUserFeatures, runMessageMutationGuardAfterSuccess, runMessageMut
27
32
  import { findMessageIdsBySearchTokens } from '../lib/searchLookup'
28
33
  import { MessageCommandExecuteResult } from '../commands/shared'
29
34
  import {
30
- composeMessageSchema as composeSchema,
35
+ composeMessageRequestSchema as composeSchema,
31
36
  composeResponseSchema,
32
37
  listMessagesSchema as listSchema,
33
38
  messageListItemSchema,
@@ -445,7 +450,31 @@ export async function POST(req: Request) {
445
450
  const { ctx, scope } = await resolveMessageContext(req)
446
451
  const commandBus = ctx.container.resolve('commandBus') as CommandBus
447
452
  const body = await req.json().catch(() => ({}))
448
- const input = composeMessageSchema.parse(body)
453
+
454
+ // #4975: whether `externalEmail` is required depends on the channel this
455
+ // message originates from, so the channel type has to be established BEFORE
456
+ // validation. It is resolved server-side from the referenced conversation or
457
+ // parent message and any client-sent `sourceChannelType` is dropped —
458
+ // otherwise a caller could waive the requirement by asserting its own type.
459
+ // An unresolvable source stays `undefined`, which the validator fails closed on.
460
+ // Only the "public, non-draft, no address" branch of the validator reads the
461
+ // answer, so every other compose skips the lookup instead of discarding it.
462
+ const sourceHint = composeRequiresChannelTypeResolution(body)
463
+ ? composeSourceHintSchema.safeParse(body)
464
+ : null
465
+ const sourceChannelType = sourceHint?.success
466
+ ? await resolveComposeSourceChannelType(
467
+ ctx.container,
468
+ { tenantId: scope.tenantId, organizationId: scope.organizationId ?? null },
469
+ sourceHint.data,
470
+ )
471
+ : undefined
472
+ const { sourceChannelType: _clientSuppliedChannelType, ...clientBody } =
473
+ (body ?? {}) as Record<string, unknown>
474
+ const input = composeMessageSchema.parse({
475
+ ...clientBody,
476
+ ...(sourceChannelType ? { sourceChannelType } : {}),
477
+ })
449
478
 
450
479
  const isPublicVisibility = input.visibility === 'public'
451
480
  const sendViaEmail = isPublicVisibility ? true : input.sendViaEmail
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod'
2
2
  import { parseBooleanFlag } from '@open-mercato/shared/lib/boolean'
3
3
  import { sanitizeRichTextHref } from '@open-mercato/shared/lib/html/sanitizeRichText'
4
+ import { channelTypeRequiresExternalEmail } from '../lib/channel-sender-identity'
4
5
 
5
6
  function collectDuplicateRecipientIds(
6
7
  recipients: Array<{ userId: string }>,
@@ -99,13 +100,22 @@ export const messageActionDataSchema = z.object({
99
100
  expiresAt: z.string().datetime().optional(),
100
101
  })
101
102
 
102
- export const composeMessageSchema = z.object({
103
+ const composeMessageBaseSchema = z.object({
103
104
  type: z.string().optional().default('default'),
104
105
  visibility: z.enum(['public', 'internal']).nullable().optional(),
105
106
  sourceEntityType: z.string().min(1).optional(),
106
107
  sourceEntityId: z.string().uuid().optional(),
107
108
  externalEmail: z.string().email().optional(),
108
109
  externalName: z.string().min(1).max(255).optional(),
110
+ /**
111
+ * Channel type the message originates from, when the caller knows it (#4975).
112
+ * Non-email channels (Discord, Slack, SMS…) have senders with no address, so
113
+ * `externalEmail` is not required for them. Resolved server-side — the HTTP
114
+ * route strips any client-sent value and derives it from the referenced
115
+ * conversation or parent message, so a caller cannot waive the requirement by
116
+ * asserting its own channel type.
117
+ */
118
+ sourceChannelType: z.string().min(1).max(64).optional(),
109
119
  recipients: z.array(messageRecipientSchema).max(100).optional().default([]),
110
120
  subject: z.string().max(500).optional().default(''),
111
121
  body: z.string().max(50000).optional().default(''),
@@ -118,7 +128,14 @@ export const composeMessageSchema = z.object({
118
128
  sendViaEmail: z.boolean().optional().default(false),
119
129
  parentMessageId: z.string().uuid().optional(),
120
130
  isDraft: z.boolean().optional().default(false),
121
- }).superRefine((value, ctx) => {
131
+ })
132
+
133
+ type ComposeMessageRefinementValue = Omit<
134
+ z.infer<typeof composeMessageBaseSchema>,
135
+ 'sourceChannelType'
136
+ > & { sourceChannelType?: string }
137
+
138
+ function refineComposeMessage(value: ComposeMessageRefinementValue, ctx: z.RefinementCtx): void {
122
139
  const isDraft = value.isDraft ?? false
123
140
  const visibility = value.visibility ?? 'internal'
124
141
  const recipientCount = value.recipients.length
@@ -128,7 +145,10 @@ export const composeMessageSchema = z.object({
128
145
 
129
146
  if (!isDraft) {
130
147
  if (visibility === 'public') {
131
- if (!hasExternalEmail) {
148
+ // #4975: an external correspondent is only guaranteed to have an address
149
+ // on an email-typed channel. `channelTypeRequiresExternalEmail` fails
150
+ // closed, so an unknown or absent channel type keeps the original rule.
151
+ if (!hasExternalEmail && channelTypeRequiresExternalEmail(value.sourceChannelType)) {
132
152
  ctx.addIssue({
133
153
  code: z.ZodIssueCode.custom,
134
154
  path: ['externalEmail'],
@@ -176,7 +196,26 @@ export const composeMessageSchema = z.object({
176
196
  }
177
197
 
178
198
  validateDefaultWithObjectsPayload(value, ctx)
179
- })
199
+ }
200
+
201
+ /**
202
+ * Full compose contract, including the server-resolved `sourceChannelType`.
203
+ * Used by the `messages.messages.compose` command and by the HTTP route AFTER
204
+ * it has resolved the channel type itself.
205
+ */
206
+ export const composeMessageSchema = composeMessageBaseSchema.superRefine(refineComposeMessage)
207
+
208
+ /**
209
+ * Client-facing compose contract — the same rules minus `sourceChannelType`,
210
+ * which is never accepted from a request body (#4975). Published in OpenAPI so
211
+ * the documented request shape matches what `POST /api/messages` actually reads:
212
+ * the route discards any client-sent channel type, resolves the real one from
213
+ * the referenced conversation or parent message, and only then validates against
214
+ * {@link composeMessageSchema}.
215
+ */
216
+ export const composeMessageRequestSchema = composeMessageBaseSchema
217
+ .omit({ sourceChannelType: true })
218
+ .superRefine(refineComposeMessage)
180
219
 
181
220
  export const updateDraftSchema = z.object({
182
221
  type: z.string().optional(),
@@ -0,0 +1,55 @@
1
+ /**
2
+ * How the hub decides whether an external correspondent must be identified by an
3
+ * email address.
4
+ *
5
+ * Background (#4975): `composeMessageSchema` used to require `externalEmail` for
6
+ * every non-draft message with `visibility: 'public'` — which is every message
7
+ * from an external sender. That assumption holds only for email-typed channels.
8
+ * A Discord sender is a snowflake and has no address at all, so inbound Discord
9
+ * failed validation deterministically and the ingest job died in the queue while
10
+ * the channel still reported `Connected`.
11
+ *
12
+ * Variant A of the spec's § Open decision — hub sender-identity contract makes
13
+ * the requirement conditional on the originating channel type, and **fails
14
+ * closed**: only a channel type this list positively recognizes as non-email
15
+ * waives the requirement. An unrecognized, empty or absent channel type keeps
16
+ * the pre-existing behaviour, so no current caller changes and a typo'd or
17
+ * caller-asserted type cannot quietly switch a validation rule off.
18
+ *
19
+ * For these channels the sender identity is not lost — it is carried by
20
+ * `ExternalMessage.sender_identifier` and joined 1:1 to the platform message
21
+ * through `MessageChannelLink`.
22
+ *
23
+ * Adding a channel type here is additive and non-breaking: it only widens the
24
+ * set of inputs the hub accepts.
25
+ */
26
+ const NON_EMAIL_SENDER_CHANNEL_TYPES = new Set([
27
+ 'discord',
28
+ 'slack',
29
+ 'teams',
30
+ 'telegram',
31
+ 'signal',
32
+ 'whatsapp',
33
+ 'messenger',
34
+ 'instagram',
35
+ 'sms',
36
+ 'push',
37
+ ])
38
+
39
+ /**
40
+ * True when a message originating from `channelType` must carry an
41
+ * `externalEmail` to be composed with `visibility: 'public'`.
42
+ *
43
+ * Fail-closed by design — see the note above.
44
+ */
45
+ export function channelTypeRequiresExternalEmail(channelType?: string | null): boolean {
46
+ if (typeof channelType !== 'string') return true
47
+ const normalized = channelType.trim().toLowerCase()
48
+ if (!normalized) return true
49
+ return !NON_EMAIL_SENDER_CHANNEL_TYPES.has(normalized)
50
+ }
51
+
52
+ /** The recognized non-email channel types, for tests and diagnostics. */
53
+ export function listNonEmailSenderChannelTypes(): string[] {
54
+ return Array.from(NON_EMAIL_SENDER_CHANNEL_TYPES).sort((left, right) => left.localeCompare(right))
55
+ }
@@ -0,0 +1,95 @@
1
+ import { z } from 'zod'
2
+
3
+ /**
4
+ * Entity type a channel-backed conversation is exposed under on a message's
5
+ * `sourceEntityType`. Written by `communication_channels`' inbound ingest.
6
+ */
7
+ const EXTERNAL_CONVERSATION_SOURCE_ENTITY_TYPE = 'communication_channels.external_conversation'
8
+
9
+ /**
10
+ * The only parts of a compose body this resolution needs. Read defensively from
11
+ * the raw request body, before the body is validated, because the validation
12
+ * outcome itself depends on the answer (#4975).
13
+ */
14
+ export const composeSourceHintSchema = z.object({
15
+ sourceEntityType: z.string().optional(),
16
+ sourceEntityId: z.string().uuid().optional(),
17
+ parentMessageId: z.string().uuid().optional(),
18
+ })
19
+
20
+ export type ComposeSourceHint = z.infer<typeof composeSourceHintSchema>
21
+
22
+ /**
23
+ * Whether the compose validator can consult a resolved channel type at all.
24
+ *
25
+ * `sourceChannelType` is read in exactly one branch — a non-draft, public
26
+ * message that carries no address. Every internal reply, every draft and every
27
+ * public message that already supplies an address ignores it, so resolving it
28
+ * for them is a DI resolve plus a database round-trip whose answer is
29
+ * discarded. Mirrors `refineComposeMessage` and reads the raw body defensively,
30
+ * because this runs before validation. Skipping resolution yields `undefined`,
31
+ * which is already the fail-closed value.
32
+ */
33
+ export function composeRequiresChannelTypeResolution(body: unknown): boolean {
34
+ if (!body || typeof body !== 'object') return false
35
+ const raw = body as Record<string, unknown>
36
+ if (raw.visibility !== 'public') return false
37
+ if (raw.isDraft === true) return false
38
+ const externalEmail = raw.externalEmail
39
+ return !(typeof externalEmail === 'string' && externalEmail.trim().length > 0)
40
+ }
41
+
42
+ type ResolveChannelTypeService = (
43
+ container: ContainerLike,
44
+ scope: { tenantId: string; organizationId: string | null },
45
+ reference: { externalConversationId?: string | null; messageId?: string | null },
46
+ ) => Promise<string | null>
47
+
48
+ type ContainerLike = { resolve: <T = unknown>(name: string) => T }
49
+
50
+ function tryResolveChannelTypeService(
51
+ container: ContainerLike,
52
+ ): ResolveChannelTypeService | undefined {
53
+ try {
54
+ return container.resolve<ResolveChannelTypeService>('communicationChannelsResolveChannelType')
55
+ } catch {
56
+ // `communication_channels` is optional: without it no message can originate
57
+ // from a channel, so "unknown" is both correct and fail-closed.
58
+ return undefined
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Resolve the channel type a compose request originates from, server-side.
64
+ *
65
+ * Never derived from the request body's own `sourceChannelType` — that would let
66
+ * any caller waive the `externalEmail` requirement by asserting a channel type.
67
+ * It is looked up from the conversation the message is being composed on, or
68
+ * from the parent message it replies to.
69
+ *
70
+ * Returns `undefined` when nothing can be established, which the compose
71
+ * validator treats as "unknown" and therefore keeps the pre-#4975 rule.
72
+ */
73
+ export async function resolveComposeSourceChannelType(
74
+ container: ContainerLike,
75
+ scope: { tenantId: string; organizationId: string | null },
76
+ hint: ComposeSourceHint,
77
+ ): Promise<string | undefined> {
78
+ const externalConversationId =
79
+ hint.sourceEntityType === EXTERNAL_CONVERSATION_SOURCE_ENTITY_TYPE
80
+ ? hint.sourceEntityId ?? null
81
+ : null
82
+ const messageId = hint.parentMessageId ?? null
83
+ if (!externalConversationId && !messageId) return undefined
84
+
85
+ const resolveChannelType = tryResolveChannelTypeService(container)
86
+ if (!resolveChannelType) return undefined
87
+
88
+ const channelType = await resolveChannelType(container, scope, {
89
+ externalConversationId,
90
+ messageId,
91
+ })
92
+ return channelType ?? undefined
93
+ }
94
+
95
+ export { EXTERNAL_CONVERSATION_SOURCE_ENTITY_TYPE }
@@ -40,12 +40,12 @@ export function parseRequestedIds(raw: string): string[] {
40
40
  }
41
41
 
42
42
  export async function GET(req: Request) {
43
+ const { translate } = await resolveTranslations()
43
44
  try {
44
45
  const url = new URL(req.url)
45
46
  const parsedQuery = querySchema.parse(Object.fromEntries(url.searchParams))
46
47
  const container = await createRequestContainer()
47
48
  const auth = await getAuthFromRequest(req)
48
- const { translate } = await resolveTranslations()
49
49
  if (!auth || !auth.tenantId) {
50
50
  throw new CrudHttpError(401, { error: translate('warranty_claims.errors.unauthorized', 'Unauthorized') })
51
51
  }
@@ -75,10 +75,10 @@ export async function GET(req: Request) {
75
75
  return NextResponse.json(error.body, { status: error.status })
76
76
  }
77
77
  if (error instanceof z.ZodError) {
78
- return NextResponse.json({ error: 'warranty_claims.errors.invalidInput' }, { status: 400 })
78
+ return NextResponse.json({ error: translate('warranty_claims.errors.invalidInput', 'Invalid input') }, { status: 400 })
79
79
  }
80
80
  logger.error('[warranty_claims] Failed to resolve assignee display names', { error })
81
- return NextResponse.json({ error: 'warranty_claims.errors.load_failed' }, { status: 500 })
81
+ return NextResponse.json({ error: translate('warranty_claims.errors.load_failed', 'Failed to load warranty claim data.') }, { status: 500 })
82
82
  }
83
83
  }
84
84
 
@@ -153,12 +153,13 @@ function serializePortalClaim(claim: WarrantyClaim, lines: WarrantyClaimLine[])
153
153
  }
154
154
 
155
155
  async function resolvePortalContext(req: Request): Promise<PortalContext | Response> {
156
+ const { translate } = await resolveTranslations()
156
157
  const auth = await getCustomerAuthFromRequest(req)
157
158
  if (!auth) {
158
- return NextResponse.json({ ok: false, error: 'warranty_claims.errors.unauthorized' }, { status: 401 })
159
+ return NextResponse.json({ ok: false, error: translate('warranty_claims.errors.unauthorized', 'Unauthorized') }, { status: 401 })
159
160
  }
160
161
  if (!auth.customerEntityId) {
161
- return NextResponse.json({ ok: false, error: 'warranty_claims.errors.customerAccountNotLinked' }, { status: 403 })
162
+ return NextResponse.json({ ok: false, error: translate('warranty_claims.errors.customerAccountNotLinked', 'Customer account is not linked to a customer record') }, { status: 403 })
162
163
  }
163
164
  const container = await createRequestContainer()
164
165
  const em = container.resolve('em') as EntityManager
@@ -264,6 +265,7 @@ async function validatePortalOrderOwnership(
264
265
  context: PortalContext,
265
266
  input: PortalIntakeInput,
266
267
  ): Promise<OrderValidationResult | Response> {
268
+ const { translate } = await resolveTranslations()
267
269
  const orderLineIds = Array.from(new Set(input.lines.flatMap((line) => line.orderLineId ? [line.orderLineId] : [])))
268
270
  const orderLinesById = await loadOwnedOrderLines(context, orderLineIds)
269
271
  const orderIds = new Set<string>()
@@ -273,7 +275,7 @@ async function validatePortalOrderOwnership(
273
275
  let resolvedOrder = input.orderId ? (ordersById.get(input.orderId) ?? null) : null
274
276
  if (input.orderId) {
275
277
  if (!resolvedOrder) {
276
- return NextResponse.json({ ok: false, error: 'warranty_claims.errors.orderNotOwned' }, { status: 404 })
278
+ return NextResponse.json({ ok: false, error: translate('warranty_claims.errors.orderNotOwned', 'Order not found.') }, { status: 404 })
277
279
  }
278
280
  }
279
281
 
@@ -282,10 +284,9 @@ async function validatePortalOrderOwnership(
282
284
  const resolvedLine = orderLinesById.get(line.orderLineId)
283
285
  const lineOrder = resolvedLine ? ordersById.get(resolvedLine.orderId) : null
284
286
  if (!resolvedLine || !lineOrder) {
285
- return NextResponse.json({ ok: false, error: 'warranty_claims.errors.orderLineMismatch' }, { status: 404 })
287
+ return NextResponse.json({ ok: false, error: translate('warranty_claims.errors.orderLineMismatch', 'Order line does not belong to the selected order') }, { status: 404 })
286
288
  }
287
289
  if (resolvedOrder && lineOrder.id !== resolvedOrder.id) {
288
- const { translate } = await resolveTranslations()
289
290
  return NextResponse.json(
290
291
  { ok: false, error: translate('warranty_claims.errors.orderLineMismatch', 'Order line does not belong to the selected order') },
291
292
  { status: 400 },
@@ -337,7 +338,8 @@ export async function GET(req: Request) {
337
338
  serialNumber: url.searchParams.get('serialNumber') ?? undefined,
338
339
  })
339
340
  if (!query.success) {
340
- return NextResponse.json({ ok: false, error: 'warranty_claims.errors.invalidInput' }, { status: 400 })
341
+ const { translate } = await resolveTranslations()
342
+ return NextResponse.json({ ok: false, error: translate('warranty_claims.errors.invalidInput', 'Invalid input') }, { status: 400 })
341
343
  }
342
344
  const { page, pageSize, search, status, stateGroup, serialNumber } = query.data
343
345
  const where = buildPortalOwnedClaimWhere(context)
@@ -415,15 +417,16 @@ export async function POST(req: Request) {
415
417
  const contextOrResponse = await resolvePortalContext(req)
416
418
  if (contextOrResponse instanceof Response) return contextOrResponse
417
419
  const context = contextOrResponse
420
+ const { translate } = await resolveTranslations()
418
421
  let body: unknown
419
422
  try {
420
423
  body = await req.json()
421
424
  } catch {
422
- return NextResponse.json({ ok: false, error: 'warranty_claims.errors.invalidInput' }, { status: 400 })
425
+ return NextResponse.json({ ok: false, error: translate('warranty_claims.errors.invalidInput', 'Invalid input') }, { status: 400 })
423
426
  }
424
427
  const parsed = portalIntakeInputSchema.safeParse(body)
425
428
  if (!parsed.success) {
426
- return NextResponse.json({ ok: false, error: 'warranty_claims.errors.invalidInput' }, { status: 400 })
429
+ return NextResponse.json({ ok: false, error: translate('warranty_claims.errors.invalidInput', 'Invalid input') }, { status: 400 })
427
430
  }
428
431
  const ownership = await validatePortalOrderOwnership(context, parsed.data)
429
432
  if (ownership instanceof Response) return ownership
@@ -487,7 +490,7 @@ export async function POST(req: Request) {
487
490
  )
488
491
  const claimId = createResult.result?.claimId
489
492
  if (typeof claimId !== 'string') {
490
- return NextResponse.json({ ok: false, error: 'warranty_claims.errors.save_failed' }, { status: 500 })
493
+ return NextResponse.json({ ok: false, error: translate('warranty_claims.errors.save_failed', 'Failed to save warranty claim.') }, { status: 500 })
491
494
  }
492
495
  try {
493
496
  await commandBus.execute<{ id: string; actorCustomerId: string }, { claimId: string }>(