@open-mercato/core 0.9.1-develop.7515.1.e2712c8b00 → 0.9.1-develop.7516.1.176184247d
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/modules/communication_channels/lib/send-as-user.js.map +2 -2
- package/dist/modules/customers/api/people/[id]/emails/route.js +44 -1
- package/dist/modules/customers/api/people/[id]/emails/route.js.map +2 -2
- package/dist/modules/customers/lib/personEmailThreads.js +36 -1
- package/dist/modules/customers/lib/personEmailThreads.js.map +2 -2
- package/dist/modules/messages/api/[id]/route.js +3 -9
- package/dist/modules/messages/api/[id]/route.js.map +2 -2
- package/dist/modules/messages/api/route.js +33 -16
- package/dist/modules/messages/api/route.js.map +2 -2
- package/dist/modules/messages/commands/messages.js.map +2 -2
- package/dist/modules/messages/lib/routeHelpers.js +40 -1
- package/dist/modules/messages/lib/routeHelpers.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/communication_channels/lib/send-as-user.ts +4 -0
- package/src/modules/customers/api/people/[id]/emails/route.ts +67 -1
- package/src/modules/customers/i18n/de.json +1 -0
- package/src/modules/customers/i18n/en.json +1 -0
- package/src/modules/customers/i18n/es.json +1 -0
- package/src/modules/customers/i18n/ko.json +1 -0
- package/src/modules/customers/i18n/pl.json +1 -0
- package/src/modules/customers/lib/personEmailThreads.ts +61 -0
- package/src/modules/messages/api/[id]/route.ts +2 -9
- package/src/modules/messages/api/route.ts +42 -20
- package/src/modules/messages/commands/messages.ts +7 -0
- package/src/modules/messages/i18n/de.json +1 -0
- package/src/modules/messages/i18n/en.json +1 -0
- package/src/modules/messages/i18n/es.json +1 -0
- package/src/modules/messages/i18n/ko.json +1 -0
- package/src/modules/messages/i18n/pl.json +1 -0
- package/src/modules/messages/lib/routeHelpers.ts +84 -1
|
@@ -1691,6 +1691,7 @@
|
|
|
1691
1691
|
"customers.email.conversationShare.sharedByTeammate": "Shared by a teammate",
|
|
1692
1692
|
"customers.email.errors.deliveryFailed": "Delivery failed — not sent",
|
|
1693
1693
|
"customers.email.errors.flipFailed": "Visibility update failed",
|
|
1694
|
+
"customers.email.errors.parentMessageNotFound": "The email you are replying to was not found",
|
|
1694
1695
|
"customers.email.errors.sendFailed": "Send failed",
|
|
1695
1696
|
"customers.email.sync.button": "Sync",
|
|
1696
1697
|
"customers.email.sync.failed": "Failed to sync mailbox",
|
|
@@ -1691,6 +1691,7 @@
|
|
|
1691
1691
|
"customers.email.conversationShare.sharedByTeammate": "Compartido por un compañero",
|
|
1692
1692
|
"customers.email.errors.deliveryFailed": "Error de entrega: no enviado",
|
|
1693
1693
|
"customers.email.errors.flipFailed": "Error al actualizar la visibilidad",
|
|
1694
|
+
"customers.email.errors.parentMessageNotFound": "No se encontró el correo al que está respondiendo",
|
|
1694
1695
|
"customers.email.errors.sendFailed": "Error al enviar",
|
|
1695
1696
|
"customers.email.sync.button": "Sincronizar",
|
|
1696
1697
|
"customers.email.sync.failed": "No se pudo sincronizar el buzón",
|
|
@@ -1691,6 +1691,7 @@
|
|
|
1691
1691
|
"customers.email.conversationShare.sharedByTeammate": "동료가 공유",
|
|
1692
1692
|
"customers.email.errors.deliveryFailed": "전송 실패 — 발송되지 않음",
|
|
1693
1693
|
"customers.email.errors.flipFailed": "공개 범위 업데이트 실패",
|
|
1694
|
+
"customers.email.errors.parentMessageNotFound": "답장하려는 이메일을 찾을 수 없습니다",
|
|
1694
1695
|
"customers.email.errors.sendFailed": "전송 실패",
|
|
1695
1696
|
"customers.email.sync.button": "동기화",
|
|
1696
1697
|
"customers.email.sync.failed": "메일함 동기화에 실패했습니다",
|
|
@@ -1691,6 +1691,7 @@
|
|
|
1691
1691
|
"customers.email.conversationShare.sharedByTeammate": "Udostępnione przez współpracownika",
|
|
1692
1692
|
"customers.email.errors.deliveryFailed": "Dostarczanie nie powiodło się – nie wysłano",
|
|
1693
1693
|
"customers.email.errors.flipFailed": "Aktualizacja widoczności nieudana",
|
|
1694
|
+
"customers.email.errors.parentMessageNotFound": "Nie znaleziono e-maila, na który odpowiadasz",
|
|
1694
1695
|
"customers.email.errors.sendFailed": "Wysyłanie nieudane",
|
|
1695
1696
|
"customers.email.sync.button": "Synchronizuj",
|
|
1696
1697
|
"customers.email.sync.failed": "Nie udało się zsynchronizować skrzynki",
|
|
@@ -378,3 +378,64 @@ export async function buildPersonEmailThreads(
|
|
|
378
378
|
threads.sort((a, b) => b.lastMessageAt.localeCompare(a.lastMessageAt))
|
|
379
379
|
return threads.slice(0, maxThreads)
|
|
380
380
|
}
|
|
381
|
+
|
|
382
|
+
export type PersonEmailMessageAccessOptions = Pick<
|
|
383
|
+
BuildPersonEmailThreadsOptions,
|
|
384
|
+
'personId' | 'tenantId' | 'organizationId' | 'viewerUserId' | 'userFeatures' | 'sharedConversations' | 'sharedChannelIds'
|
|
385
|
+
> & {
|
|
386
|
+
/** Open Mercato `messages.message` id the caller wants to reply to. */
|
|
387
|
+
messageId: string
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Whether `messageId` is on this Person's email history as the viewer sees it —
|
|
392
|
+
* i.e. whether {@link buildPersonEmailThreads} would show it to them.
|
|
393
|
+
*
|
|
394
|
+
* Walks the same anchor (email `CustomerInteraction` → `MessageChannelLink` →
|
|
395
|
+
* message) with the same scope and the same visibility predicate, so a reply
|
|
396
|
+
* from the Person page can only name a parent the Emails tab could have shown:
|
|
397
|
+
* the viewer's own mail, shared or legacy mail, a conversation shared for THIS
|
|
398
|
+
* Person, or a shared team mailbox. Callers remain responsible for resolving the
|
|
399
|
+
* Person in scope first.
|
|
400
|
+
*/
|
|
401
|
+
export async function isMessageOnPersonEmailHistory(
|
|
402
|
+
em: EntityManager,
|
|
403
|
+
opts: PersonEmailMessageAccessOptions,
|
|
404
|
+
): Promise<boolean> {
|
|
405
|
+
const { personId, tenantId, organizationId, messageId } = opts
|
|
406
|
+
const dscope = { tenantId, organizationId: organizationId ?? null }
|
|
407
|
+
|
|
408
|
+
const linkWhere: JsonRecord = { messageId, tenantId }
|
|
409
|
+
if (organizationId) linkWhere.organizationId = organizationId
|
|
410
|
+
const links = (await findWithDecryption(
|
|
411
|
+
em,
|
|
412
|
+
'MessageChannelLink' as never,
|
|
413
|
+
linkWhere as never,
|
|
414
|
+
undefined,
|
|
415
|
+
dscope,
|
|
416
|
+
)) as JsonRecord[]
|
|
417
|
+
const linkIds = links
|
|
418
|
+
.map((link) => (typeof link.id === 'string' ? (link.id as string) : null))
|
|
419
|
+
.filter((value): value is string => !!value)
|
|
420
|
+
if (linkIds.length === 0) return false
|
|
421
|
+
|
|
422
|
+
const interactionWhere: JsonRecord = {
|
|
423
|
+
entity: personId,
|
|
424
|
+
interactionType: 'email',
|
|
425
|
+
deletedAt: null,
|
|
426
|
+
tenantId,
|
|
427
|
+
externalMessageId: { $in: linkIds },
|
|
428
|
+
}
|
|
429
|
+
if (organizationId) interactionWhere.organizationId = organizationId
|
|
430
|
+
Object.assign(
|
|
431
|
+
interactionWhere,
|
|
432
|
+
buildEmailVisibilityMikroFilter({
|
|
433
|
+
currentUserId: opts.viewerUserId,
|
|
434
|
+
userFeatures: opts.userFeatures,
|
|
435
|
+
sharedConversations: opts.sharedConversations,
|
|
436
|
+
sharedChannelIds: opts.sharedChannelIds,
|
|
437
|
+
}),
|
|
438
|
+
)
|
|
439
|
+
const visible = await em.count(CustomerInteraction, interactionWhere as never)
|
|
440
|
+
return visible > 0
|
|
441
|
+
}
|
|
@@ -16,9 +16,9 @@ import { getMessageTypeOrDefault } from '../../lib/message-types-registry'
|
|
|
16
16
|
import { attachOperationMetadataHeader } from '../../lib/operationMetadata'
|
|
17
17
|
import {
|
|
18
18
|
canPostToChannelThread,
|
|
19
|
-
hasChannelThreadReadAccess,
|
|
20
19
|
hasOrganizationAccess,
|
|
21
20
|
resolveMessageContext,
|
|
21
|
+
resolveMessageReadAccess,
|
|
22
22
|
} from '../../lib/routeHelpers'
|
|
23
23
|
import { resolveUserFeatures, runMessageMutationGuardAfterSuccess, runMessageMutationGuards } from '../guards'
|
|
24
24
|
import {
|
|
@@ -103,13 +103,7 @@ export async function GET(req: Request, { params }: { params: { id: string } })
|
|
|
103
103
|
return Response.json({ error: 'Access denied' }, { status: 403 })
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
const recipient = await
|
|
107
|
-
messageId: params.id,
|
|
108
|
-
recipientUserId: scope.userId,
|
|
109
|
-
deletedAt: null,
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
const isSender = message.senderUserId === scope.userId
|
|
106
|
+
const { recipient, isSender, hasChannelThreadAccess } = await resolveMessageReadAccess(ctx, scope, message)
|
|
113
107
|
const isRecipient = Boolean(recipient)
|
|
114
108
|
|
|
115
109
|
// #5535: a message that arrived over a communication channel has the channel
|
|
@@ -126,7 +120,6 @@ export async function GET(req: Request, { params }: { params: { id: string } })
|
|
|
126
120
|
// `requireAuth` only so that a participant can always read their own message.
|
|
127
121
|
// The gate goes through RBAC, not through `ctx.auth.features` — the session JWT
|
|
128
122
|
// carries no `features` claim at all, so reading it would deny everyone.
|
|
129
|
-
const hasChannelThreadAccess = await hasChannelThreadReadAccess(ctx, scope, message)
|
|
130
123
|
|
|
131
124
|
if (!isSender && !isRecipient && !hasChannelThreadAccess) {
|
|
132
125
|
return Response.json({ error: 'Access denied' }, { status: 403 })
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
resolveCrudCache,
|
|
13
13
|
} from '@open-mercato/shared/lib/crud/cache'
|
|
14
14
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi/types'
|
|
15
|
-
import {
|
|
15
|
+
import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
16
16
|
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'
|
|
@@ -36,6 +36,7 @@ import { attachOperationMetadataHeader } from '../lib/operationMetadata'
|
|
|
36
36
|
import {
|
|
37
37
|
canPostToChannelThread,
|
|
38
38
|
canUseChannelThreadFallback,
|
|
39
|
+
resolveReplyParentMessage,
|
|
39
40
|
canUseMessageEmailFeature,
|
|
40
41
|
resolveMessageContext,
|
|
41
42
|
} from '../lib/routeHelpers'
|
|
@@ -46,6 +47,7 @@ import { MessageCommandExecuteResult } from '../commands/shared'
|
|
|
46
47
|
import {
|
|
47
48
|
composeMessageRequestSchema as composeSchema,
|
|
48
49
|
composeResponseSchema,
|
|
50
|
+
errorResponseSchema,
|
|
49
51
|
listMessagesSchema as listSchema,
|
|
50
52
|
messageListItemSchema,
|
|
51
53
|
} from './openapi'
|
|
@@ -510,6 +512,35 @@ export async function POST(req: Request) {
|
|
|
510
512
|
}
|
|
511
513
|
}
|
|
512
514
|
|
|
515
|
+
// A parent decides which conversation the new message joins, so it is held to
|
|
516
|
+
// the same rule as reading it: knowing a message id is not enough to thread
|
|
517
|
+
// onto someone else's conversation. Checked on the parent the client sent, for
|
|
518
|
+
// drafts and every visibility, before any guard, delegation or write runs.
|
|
519
|
+
let replyParent: Message | null = null
|
|
520
|
+
if (input.parentMessageId) {
|
|
521
|
+
const resolution = await resolveReplyParentMessage(
|
|
522
|
+
ctx,
|
|
523
|
+
{ tenantId: scope.tenantId, organizationId: scope.organizationId ?? null, userId: scope.userId },
|
|
524
|
+
input.parentMessageId,
|
|
525
|
+
)
|
|
526
|
+
if (resolution.status === 'not_found') {
|
|
527
|
+
const { translate } = await resolveTranslations()
|
|
528
|
+
return Response.json(
|
|
529
|
+
{
|
|
530
|
+
error: translate(
|
|
531
|
+
'messages.errors.parentMessageNotFound',
|
|
532
|
+
'The message you are replying to was not found',
|
|
533
|
+
),
|
|
534
|
+
},
|
|
535
|
+
{ status: 404 },
|
|
536
|
+
)
|
|
537
|
+
}
|
|
538
|
+
if (resolution.status === 'forbidden') {
|
|
539
|
+
return Response.json({ error: 'Access denied' }, { status: 403 })
|
|
540
|
+
}
|
|
541
|
+
replyParent = resolution.message
|
|
542
|
+
}
|
|
543
|
+
|
|
513
544
|
// #5535: composing on a channel conversation without naming a parent message
|
|
514
545
|
// used to open a brand-new thread. A fresh thread has no `ChannelThreadMapping`,
|
|
515
546
|
// so the outbound bridge had nothing to route on and the operator got a 201 for
|
|
@@ -555,27 +586,14 @@ export async function POST(req: Request) {
|
|
|
555
586
|
// derives the thread from that parent alone — so a public, non-draft message
|
|
556
587
|
// could be threaded onto a channel-linked conversation and delivered to the
|
|
557
588
|
// external correspondent without any channel-access check. A tenant-wide
|
|
558
|
-
// channel accepts delivery from any sender, so
|
|
559
|
-
//
|
|
560
|
-
// actually derive. An internal thread
|
|
561
|
-
//
|
|
589
|
+
// channel accepts delivery from any sender, so reading a message is not
|
|
590
|
+
// enough to post onto its thread. Apply the same check to the thread the
|
|
591
|
+
// command will actually derive. An internal thread and an absent hub resolve
|
|
592
|
+
// to `null` and keep the pre-existing rule.
|
|
562
593
|
// Both channel branches also require `messages.view`, like reply and forward:
|
|
563
594
|
// the hub grants every shared channel regardless of features (#6432).
|
|
564
|
-
if (isPublicVisibility && !input.isDraft &&
|
|
565
|
-
const
|
|
566
|
-
const parentMessage = await findOneWithDecryption(
|
|
567
|
-
em,
|
|
568
|
-
Message,
|
|
569
|
-
{
|
|
570
|
-
id: input.parentMessageId,
|
|
571
|
-
tenantId: scope.tenantId,
|
|
572
|
-
organizationId: scope.organizationId,
|
|
573
|
-
deletedAt: null,
|
|
574
|
-
},
|
|
575
|
-
undefined,
|
|
576
|
-
{ tenantId: scope.tenantId, organizationId: scope.organizationId },
|
|
577
|
-
)
|
|
578
|
-
const parentThreadId = parentMessage?.threadId ?? input.parentMessageId
|
|
595
|
+
if (isPublicVisibility && !input.isDraft && replyParent) {
|
|
596
|
+
const parentThreadId = replyParent.threadId ?? replyParent.id
|
|
579
597
|
if (!(await canPostToChannelThread(ctx, scope, parentThreadId))) {
|
|
580
598
|
return Response.json({ error: 'Access denied' }, { status: 403 })
|
|
581
599
|
}
|
|
@@ -711,6 +729,10 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
711
729
|
schema: composeResponseSchema,
|
|
712
730
|
},
|
|
713
731
|
],
|
|
732
|
+
errors: [
|
|
733
|
+
{ status: 403, description: 'Access denied, including a parentMessageId the caller cannot read', schema: errorResponseSchema },
|
|
734
|
+
{ status: 404, description: 'parentMessageId is unknown, deleted, or outside the caller organization', schema: errorResponseSchema },
|
|
735
|
+
],
|
|
714
736
|
},
|
|
715
737
|
},
|
|
716
738
|
}
|
|
@@ -266,6 +266,13 @@ async function buildComposeResultFromExisting(
|
|
|
266
266
|
}
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
+
/**
|
|
270
|
+
* `parentMessageId` is trusted: the thread is derived from it without a read
|
|
271
|
+
* check, because the acting `userId` is itself an input and server-side callers
|
|
272
|
+
* (channel ingest) pass parents the actor is not a participant of. Every surface
|
|
273
|
+
* that accepts a parent from a caller MUST resolve it against its own read rule
|
|
274
|
+
* first (`resolveReplyParentMessage` for the messages API).
|
|
275
|
+
*/
|
|
269
276
|
const composeMessageCommand: CommandHandler<unknown, { id: string; threadId: string | null; externalEmail: string | null; isDraft: boolean; recipientUserIds: string[]; deduplicated?: boolean }> = {
|
|
270
277
|
id: 'messages.messages.compose',
|
|
271
278
|
async execute(rawInput, ctx) {
|
|
@@ -130,6 +130,7 @@
|
|
|
130
130
|
"messages.errors.noExternalEmail": "Bitte gib eine gültige externe E-Mail ein.",
|
|
131
131
|
"messages.errors.noRecipients": "Füge mindestens einen Empfänger hinzu.",
|
|
132
132
|
"messages.errors.noSubject": "Bitte Betreff eingeben.",
|
|
133
|
+
"messages.errors.parentMessageNotFound": "Die Nachricht, auf die Sie antworten, wurde nicht gefunden",
|
|
133
134
|
"messages.errors.sendFailed": "Nachricht konnte nicht gesendet werden.",
|
|
134
135
|
"messages.errors.senderDraftSendUnsupported": "Das Senden eines gespeicherten Entwurfs über ein verbundenes Postfach wird noch nicht unterstützt.",
|
|
135
136
|
"messages.errors.senderPayloadUnsupported": "Der Versand aus dem eigenen Postfach unterstützt noch keine Anhänge oder verknüpften Datensätze.",
|
|
@@ -130,6 +130,7 @@
|
|
|
130
130
|
"messages.errors.noExternalEmail": "Please enter a valid external email.",
|
|
131
131
|
"messages.errors.noRecipients": "Please add at least one recipient.",
|
|
132
132
|
"messages.errors.noSubject": "Please enter a subject.",
|
|
133
|
+
"messages.errors.parentMessageNotFound": "The message you are replying to was not found",
|
|
133
134
|
"messages.errors.sendFailed": "Failed to send message.",
|
|
134
135
|
"messages.errors.senderDraftSendUnsupported": "Sending a saved draft through a connected mailbox is not supported yet.",
|
|
135
136
|
"messages.errors.senderPayloadUnsupported": "Sending from your own mailbox does not support attachments or linked records yet.",
|
|
@@ -130,6 +130,7 @@
|
|
|
130
130
|
"messages.errors.noExternalEmail": "Introduce un correo externo válido.",
|
|
131
131
|
"messages.errors.noRecipients": "Añade al menos un destinatario.",
|
|
132
132
|
"messages.errors.noSubject": "Introduce un asunto.",
|
|
133
|
+
"messages.errors.parentMessageNotFound": "No se encontró el mensaje al que está respondiendo",
|
|
133
134
|
"messages.errors.sendFailed": "No se pudo enviar el mensaje.",
|
|
134
135
|
"messages.errors.senderDraftSendUnsupported": "Enviar un borrador guardado desde un buzón conectado todavía no es compatible.",
|
|
135
136
|
"messages.errors.senderPayloadUnsupported": "El envío desde tu propio buzón todavía no admite adjuntos ni registros vinculados.",
|
|
@@ -130,6 +130,7 @@
|
|
|
130
130
|
"messages.errors.noExternalEmail": "유효한 외부 이메일을 입력하세요.",
|
|
131
131
|
"messages.errors.noRecipients": "수신자를 하나 이상 추가하세요.",
|
|
132
132
|
"messages.errors.noSubject": "제목을 입력하세요.",
|
|
133
|
+
"messages.errors.parentMessageNotFound": "답장하려는 메시지를 찾을 수 없습니다",
|
|
133
134
|
"messages.errors.sendFailed": "메시지 전송에 실패했습니다.",
|
|
134
135
|
"messages.errors.senderDraftSendUnsupported": "저장된 초안을 연결된 메일함으로 보내는 기능은 아직 지원되지 않습니다.",
|
|
135
136
|
"messages.errors.senderPayloadUnsupported": "본인 메일함에서 보내기는 아직 첨부 파일이나 연결된 레코드를 지원하지 않습니다.",
|
|
@@ -130,6 +130,7 @@
|
|
|
130
130
|
"messages.errors.noExternalEmail": "Wpisz poprawny zewnętrzny adres e-mail.",
|
|
131
131
|
"messages.errors.noRecipients": "Dodaj przynajmniej jednego odbiorcę.",
|
|
132
132
|
"messages.errors.noSubject": "Wpisz temat.",
|
|
133
|
+
"messages.errors.parentMessageNotFound": "Nie znaleziono wiadomości, na którą odpowiadasz",
|
|
133
134
|
"messages.errors.sendFailed": "Nie udało się wysłać wiadomości.",
|
|
134
135
|
"messages.errors.senderDraftSendUnsupported": "Wysyłka zapisanego szkicu przez połączoną skrzynkę nie jest jeszcze obsługiwana.",
|
|
135
136
|
"messages.errors.senderPayloadUnsupported": "Wysyłka z własnej skrzynki nie obsługuje jeszcze załączników ani powiązanych rekordów.",
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
1
2
|
import { resolveRequestContext } from '@open-mercato/shared/lib/api/context'
|
|
3
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
4
|
+
import { Message, MessageRecipient } from '../data/entities'
|
|
2
5
|
import {
|
|
3
6
|
CHANNEL_THREAD_FALLBACK_FEATURE,
|
|
4
7
|
EXTERNAL_CONVERSATION_SOURCE_ENTITY_TYPE,
|
|
@@ -16,6 +19,11 @@ export function hasOrganizationAccess(
|
|
|
16
19
|
return messageOrganizationId == null
|
|
17
20
|
}
|
|
18
21
|
|
|
22
|
+
export type ChannelThreadCallerContext = {
|
|
23
|
+
container: { resolve: <T = unknown>(name: string) => T }
|
|
24
|
+
auth?: unknown
|
|
25
|
+
}
|
|
26
|
+
|
|
19
27
|
export type MessageScope = {
|
|
20
28
|
tenantId: string
|
|
21
29
|
organizationId: string | null
|
|
@@ -101,7 +109,7 @@ export async function canUseChannelThreadFallback(
|
|
|
101
109
|
* message that is not explicitly public.
|
|
102
110
|
*/
|
|
103
111
|
export async function hasChannelThreadReadAccess(
|
|
104
|
-
ctx:
|
|
112
|
+
ctx: ChannelThreadCallerContext,
|
|
105
113
|
scope: MessageScope,
|
|
106
114
|
message: { id: string; threadId?: string | null; sourceEntityType?: string | null },
|
|
107
115
|
): Promise<boolean> {
|
|
@@ -116,6 +124,81 @@ export async function hasChannelThreadReadAccess(
|
|
|
116
124
|
return channelThread?.canAccess === true
|
|
117
125
|
}
|
|
118
126
|
|
|
127
|
+
export type MessageReadAccess = {
|
|
128
|
+
recipient: MessageRecipient | null
|
|
129
|
+
isSender: boolean
|
|
130
|
+
hasChannelThreadAccess: boolean
|
|
131
|
+
canRead: boolean
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The read rule of `GET /api/messages/[id]`, for a message already loaded in the
|
|
136
|
+
* caller's scope: the sender, a recipient whose row is not deleted, or — for an
|
|
137
|
+
* explicitly public message only — a caller the channels hub lets work the
|
|
138
|
+
* channel thread. Channel access never opens an internal note.
|
|
139
|
+
*
|
|
140
|
+
* Shared by the detail read and by every write that names another message as its
|
|
141
|
+
* parent, so "may reference" can never be wider than "may read".
|
|
142
|
+
*/
|
|
143
|
+
export async function resolveMessageReadAccess(
|
|
144
|
+
ctx: ChannelThreadCallerContext,
|
|
145
|
+
scope: MessageScope,
|
|
146
|
+
message: Pick<Message, 'id' | 'senderUserId' | 'visibility' | 'threadId' | 'sourceEntityType'>,
|
|
147
|
+
): Promise<MessageReadAccess> {
|
|
148
|
+
const em = ctx.container.resolve('em') as EntityManager
|
|
149
|
+
const recipient = await em.findOne(MessageRecipient, {
|
|
150
|
+
messageId: message.id,
|
|
151
|
+
recipientUserId: scope.userId,
|
|
152
|
+
deletedAt: null,
|
|
153
|
+
})
|
|
154
|
+
const isSender = message.senderUserId === scope.userId
|
|
155
|
+
const hasChannelThreadAccess = await hasChannelThreadReadAccess(ctx, scope, message)
|
|
156
|
+
const isParticipant = isSender || Boolean(recipient)
|
|
157
|
+
return {
|
|
158
|
+
recipient,
|
|
159
|
+
isSender,
|
|
160
|
+
hasChannelThreadAccess,
|
|
161
|
+
canRead: isParticipant || (hasChannelThreadAccess && message.visibility === 'public'),
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export type ReplyParentResolution =
|
|
166
|
+
| { status: 'readable'; message: Message }
|
|
167
|
+
| { status: 'not_found' }
|
|
168
|
+
| { status: 'forbidden' }
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Resolve a caller-supplied parent message before a write threads onto it.
|
|
172
|
+
*
|
|
173
|
+
* The parent is looked up in exactly the scope the new message is written to —
|
|
174
|
+
* tenant, organization (null only matches null) and not deleted — so a parent
|
|
175
|
+
* that is unknown, deleted, or in another organization or tenant all answer
|
|
176
|
+
* `not_found`. A parent in scope that the caller may not read answers
|
|
177
|
+
* `forbidden`, the same distinction the detail read already makes.
|
|
178
|
+
*/
|
|
179
|
+
export async function resolveReplyParentMessage(
|
|
180
|
+
ctx: ChannelThreadCallerContext,
|
|
181
|
+
scope: MessageScope,
|
|
182
|
+
parentMessageId: string,
|
|
183
|
+
): Promise<ReplyParentResolution> {
|
|
184
|
+
const em = ctx.container.resolve('em') as EntityManager
|
|
185
|
+
const parent = await findOneWithDecryption(
|
|
186
|
+
em,
|
|
187
|
+
Message,
|
|
188
|
+
{
|
|
189
|
+
id: parentMessageId,
|
|
190
|
+
tenantId: scope.tenantId,
|
|
191
|
+
organizationId: scope.organizationId,
|
|
192
|
+
deletedAt: null,
|
|
193
|
+
},
|
|
194
|
+
undefined,
|
|
195
|
+
{ tenantId: scope.tenantId, organizationId: scope.organizationId },
|
|
196
|
+
)
|
|
197
|
+
if (!parent) return { status: 'not_found' }
|
|
198
|
+
const access = await resolveMessageReadAccess(ctx, scope, parent)
|
|
199
|
+
return access.canRead ? { status: 'readable', message: parent } : { status: 'forbidden' }
|
|
200
|
+
}
|
|
201
|
+
|
|
119
202
|
/**
|
|
120
203
|
* Whether the caller may post a message meant to leave the platform onto
|
|
121
204
|
* `messageThreadId` (#5645 review, #6432).
|