@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/helpers/integration/communicationChannelsFixtures.js +19 -0
- package/dist/helpers/integration/communicationChannelsFixtures.js.map +2 -2
- package/dist/modules/communication_channels/api/post/channels/[id]/test-send/route.js +9 -1
- package/dist/modules/communication_channels/api/post/channels/[id]/test-send/route.js.map +2 -2
- package/dist/modules/communication_channels/api/post/test-seed/route.js +95 -10
- package/dist/modules/communication_channels/api/post/test-seed/route.js.map +2 -2
- package/dist/modules/communication_channels/commands/ingest-inbound-message.js +5 -0
- package/dist/modules/communication_channels/commands/ingest-inbound-message.js.map +2 -2
- package/dist/modules/communication_channels/di.js +7 -1
- package/dist/modules/communication_channels/di.js.map +2 -2
- package/dist/modules/communication_channels/lib/email-capabilities.js +4 -1
- package/dist/modules/communication_channels/lib/email-capabilities.js.map +2 -2
- package/dist/modules/communication_channels/lib/outbound-recipient.js +33 -0
- package/dist/modules/communication_channels/lib/outbound-recipient.js.map +7 -0
- package/dist/modules/communication_channels/lib/resolve-channel-type.js +51 -0
- package/dist/modules/communication_channels/lib/resolve-channel-type.js.map +7 -0
- package/dist/modules/communication_channels/lib/test-seed.js +41 -2
- package/dist/modules/communication_channels/lib/test-seed.js.map +2 -2
- package/dist/modules/customers/api/people/check-phone/route.js +51 -9
- package/dist/modules/customers/api/people/check-phone/route.js.map +2 -2
- package/dist/modules/customers/lib/findPeopleByAddresses.js +1 -0
- package/dist/modules/customers/lib/findPeopleByAddresses.js.map +2 -2
- package/dist/modules/messages/api/openapi.js +2 -0
- package/dist/modules/messages/api/openapi.js.map +2 -2
- package/dist/modules/messages/api/route.js +17 -2
- package/dist/modules/messages/api/route.js.map +2 -2
- package/dist/modules/messages/data/validators.js +18 -4
- package/dist/modules/messages/data/validators.js.map +2 -2
- package/dist/modules/messages/lib/channel-sender-identity.js +26 -0
- package/dist/modules/messages/lib/channel-sender-identity.js.map +7 -0
- package/dist/modules/messages/lib/composeSourceChannelType.js +41 -0
- package/dist/modules/messages/lib/composeSourceChannelType.js.map +7 -0
- package/dist/modules/warranty_claims/api/assignees/route.js +3 -3
- package/dist/modules/warranty_claims/api/assignees/route.js.map +2 -2
- package/dist/modules/warranty_claims/api/portal/claims/route.js +12 -9
- package/dist/modules/warranty_claims/api/portal/claims/route.js.map +2 -2
- package/package.json +7 -7
- package/src/helpers/integration/communicationChannelsFixtures.ts +69 -1
- package/src/modules/communication_channels/api/post/channels/[id]/test-send/route.ts +17 -1
- package/src/modules/communication_channels/api/post/test-seed/route.ts +155 -16
- package/src/modules/communication_channels/commands/ingest-inbound-message.ts +5 -0
- package/src/modules/communication_channels/di.ts +7 -0
- package/src/modules/communication_channels/lib/adapter.ts +14 -0
- package/src/modules/communication_channels/lib/email-capabilities.ts +4 -0
- package/src/modules/communication_channels/lib/outbound-recipient.ts +71 -0
- package/src/modules/communication_channels/lib/resolve-channel-type.ts +99 -0
- package/src/modules/communication_channels/lib/test-seed.ts +64 -4
- package/src/modules/customers/api/people/check-phone/route.ts +75 -10
- package/src/modules/customers/lib/findPeopleByAddresses.ts +8 -6
- package/src/modules/messages/api/openapi.ts +2 -0
- package/src/modules/messages/api/route.ts +31 -2
- package/src/modules/messages/data/validators.ts +43 -4
- package/src/modules/messages/lib/channel-sender-identity.ts +55 -0
- package/src/modules/messages/lib/composeSourceChannelType.ts +95 -0
- package/src/modules/warranty_claims/api/assignees/route.ts +3 -3
- 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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
104
|
+
where.tenantId = auth.tenantId
|
|
55
105
|
}
|
|
56
|
-
qb.andWhere({ organizationId: { $in: Array.from(allowedOrgIds) } })
|
|
57
|
-
qb.limit(1)
|
|
58
106
|
|
|
59
|
-
const
|
|
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
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
-
|
|
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 }>(
|