修复V4任务详情来源邮件展示

This commit is contained in:
andy
2026-07-21 10:29:03 +07:00
parent b44ab94113
commit c28130b3da
8 changed files with 488 additions and 35 deletions

View File

@@ -36,11 +36,11 @@
>
<h3>{{ t('taskV4.roomInformation.currentValues') }}</h3>
<dl
v-if="scalarEntries(currentValues).length"
v-if="scalarEntries(currentValues, { hideEditableBreakfast: false }).length"
class="room-information__grid"
>
<template
v-for="entry in scalarEntries(currentValues)"
v-for="entry in scalarEntries(currentValues, { hideEditableBreakfast: false })"
:key="entry.key"
>
<dt>{{ roomInformationFieldLabel(entry.key) }}</dt>
@@ -78,6 +78,54 @@
</div>
</section>
<section
v-if="showProposedValues"
class="room-information__panel room-information__panel--proposed"
>
<h3>{{ t('taskV4.roomInformation.proposedValues') }}</h3>
<dl
v-if="scalarEntries(proposedValues, { hideEditableBreakfast: false }).length"
class="room-information__grid"
>
<template
v-for="entry in scalarEntries(proposedValues, { hideEditableBreakfast: false })"
:key="entry.key"
>
<dt>{{ roomInformationFieldLabel(entry.key) }}</dt>
<dd>{{ entry.value }}</dd>
</template>
</dl>
<p
v-else-if="!roomItems(proposedValues).length"
class="room-information__empty"
>
{{ t('taskV4.roomInformation.emptyValues') }}
</p>
<div
v-if="roomItems(proposedValues).length"
class="room-information__room-items"
>
<h4>{{ t('taskV4.roomInformation.roomItems') }}</h4>
<table>
<thead>
<tr>
<th>{{ t('taskV4.roomInformation.roomTypeCode') }}</th>
<th>{{ t('taskV4.roomInformation.roomCount') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="(item, index) in roomItems(proposedValues)"
:key="index"
>
<td>{{ formatRoomInformationValue('room_type_code', item.room_type_code, item) }}</td>
<td>{{ formatRoomInformationValue('room_count', item.room_count, item) }}</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="room-information__panel">
<h3>{{ t('taskV4.roomInformation.finalValues') }}</h3>
<div class="room-information__derived">
@@ -210,6 +258,7 @@ const roomInformation = computed<ReservationV4RoomInformationDisplayModel | null
const eventType = computed(() => roomInformation.value?.event_type ?? props.card.event_type ?? '')
const bookingType = computed(() => roomInformation.value?.booking_type ?? '')
const currentValues = computed(() => normalizeValues(roomInformation.value?.current_values))
const proposedValues = computed(() => normalizeValues(roomInformation.value?.proposed_values))
const finalValues = computed(() => normalizeValues(roomInformation.value?.final_values))
const changeSummary = computed(() => roomInformation.value?.change_summary ?? [])
const visibleChangeSummary = computed(() => changeSummary.value.filter((change) =>
@@ -220,7 +269,12 @@ const visibleFields = computed(() => props.card.fields.filter((field) =>
))
const showChangeSummary = computed(() => eventType.value === 'UPDATE_BOOKING' && visibleChangeSummary.value.length > 0)
const showCurrentValues = computed(() =>
['CANCEL_BOOKING'].includes(eventType.value) && hasVisibleValues(currentValues.value),
['UPDATE_BOOKING', 'CANCEL_BOOKING'].includes(eventType.value) &&
hasVisibleValues(currentValues.value, { hideEditableBreakfast: false }),
)
const showProposedValues = computed(() =>
eventType.value === 'UPDATE_BOOKING' &&
hasVisibleValues(proposedValues.value, { hideEditableBreakfast: false }),
)
const showEditableFields = computed(() => eventType.value !== 'CANCEL_BOOKING' && visibleFields.value.length > 0)
const showReadonlyBreakfast = computed(() =>
@@ -241,11 +295,19 @@ const scalarFieldOrder = [
'confirmation_number',
]
function scalarEntries(values: ReservationV4RoomInformationValues): Array<{ key: string; value: string }> {
function scalarEntries(
values: ReservationV4RoomInformationValues,
options: { hideEditableBreakfast?: boolean } = {},
): Array<{ key: string; value: string }> {
const hideEditableBreakfast = options.hideEditableBreakfast ?? true
return scalarFieldOrder
.filter((key) => key in values && hasValue(values[key]))
.filter((key) => key !== 'group_booking_status_label')
.filter((key) => key !== 'breakfast_included' || !hasEditableField('/room_information/final_values/breakfast_included'))
.filter((key) =>
key !== 'breakfast_included' ||
!hideEditableBreakfast ||
!hasEditableField('/room_information/final_values/breakfast_included'),
)
.map((key) => ({
key,
value: formatRoomInformationValue(key, values[key], values),
@@ -279,8 +341,11 @@ function isVisibleRoomInformationChangeField(field: unknown): boolean {
!key.includes('locator_value')
}
function hasVisibleValues(values: ReservationV4RoomInformationValues): boolean {
return scalarEntries(values).length > 0 || roomItems(values).length > 0
function hasVisibleValues(
values: ReservationV4RoomInformationValues,
options: { hideEditableBreakfast?: boolean } = {},
): boolean {
return scalarEntries(values, options).length > 0 || roomItems(values).length > 0
}
function normalizeValues(value: unknown): ReservationV4RoomInformationValues {
@@ -391,6 +456,11 @@ const roomInformationLabelKeys: Record<string, string> = {
background: linear-gradient(180deg, var(--th-color-white), var(--th-color-info-bg));
}
.room-information__panel--proposed {
border-color: color-mix(in srgb, var(--th-color-warning) 24%, var(--th-color-slate-200));
background: color-mix(in srgb, var(--th-color-warning-bg) 42%, var(--th-color-white));
}
.room-information__panel--changes ul {
display: grid;
gap: 8px;

View File

@@ -42,6 +42,51 @@
<p>{{ sourceExcerpt }}</p>
</div>
<div class="source-preview source-body">
<h3>{{ t('taskV4.sourceMessage.body') }}</h3>
<p
v-if="bodyLoading"
class="source-body__muted"
>
{{ t('taskV4.sourceMessage.bodyLoading') }}
</p>
<p
v-else-if="bodyError"
class="source-body__muted"
>
{{ t('taskV4.sourceMessage.bodyLoadFailed') }}
</p>
<template v-else-if="messageBodyText">
<!-- eslint-disable vue/no-v-html -- only backend-sanitized html_body_sanitized is rendered; raw html_body is never used here. -->
<div
v-if="showExpandedHtmlBody"
class="source-body__html"
v-html="sanitizedHtmlBody"
/>
<!-- eslint-enable vue/no-v-html -->
<p
v-else
class="source-body__text"
>
{{ visibleMessageBodyText }}
</p>
<button
v-if="canToggleBody"
type="button"
class="source-body__toggle"
@click="bodyExpanded = !bodyExpanded"
>
{{ bodyExpanded ? t('taskV4.sourceMessage.collapseBody') : t('taskV4.sourceMessage.showFullBody') }}
</button>
</template>
<p
v-else
class="source-body__muted"
>
{{ t('taskV4.sourceMessage.noBody') }}
</p>
</div>
<div class="source-preview">
<h3>{{ t('taskV4.sourceMessage.attachments') }}</h3>
<ul
@@ -65,13 +110,6 @@
</p>
</div>
<ReservationV4TaskCardFieldRenderer
v-if="sourceCard?.fields.length"
:fields="sourceCard.fields"
:model-value="fieldValues"
:read-only="true"
/>
<RouterLink
v-if="showConversationLink && sourceMessageId"
class="source-link"
@@ -83,19 +121,19 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import ReservationStatusBadge from '@/components/reservation/ReservationStatusBadge.vue'
import ReservationV4TaskCardFieldRenderer from '@/components/reservation/ReservationV4TaskCardFieldRenderer.vue'
import { fetchSourceMessageConversation } from '@/services/reservationService'
import type {
ReservationRecord,
SourceMessageConversationItem,
ReservationV4SourceMessageSummary,
ReservationV4TaskCardResult,
} from '@/types/reservation'
import { formatReservationDateTime } from '@/utils/reservationFormat'
import { buildReservationV4InitialFieldValues } from '@/utils/reservationV4FieldRules'
const props = withDefaults(defineProps<{
title: string
@@ -107,9 +145,14 @@ const props = withDefaults(defineProps<{
})
const { t } = useI18n()
const bodyPreviewLength = 360
const currentMessage = ref<SourceMessageConversationItem | null>(null)
const bodyLoading = ref(false)
const bodyError = ref(false)
const bodyExpanded = ref(false)
let bodyRequestSequence = 0
const payload = computed(() => props.sourceCard?.display_payload ?? {})
const fieldValues = computed(() => buildReservationV4InitialFieldValues(props.sourceCard?.fields ?? []))
const sourceMessageId = computed(() =>
props.sourceMessageSummary?.source_message_id ?? readString(payload.value, 'source_message_id'),
)
@@ -141,11 +184,78 @@ const sourceExcerpt = computed(() =>
'summary',
]) ?? t('taskV4.sourceMessage.noExcerpt'),
)
const attachments = computed(() => normalizeAttachments(readFirstDefined(payload.value, [
const sourcePayloadAttachments = computed(() => normalizeAttachments(readFirstDefined(payload.value, [
'attachments',
'uploaded_media',
'file_references',
])))
const currentMessageAttachments = computed(() => normalizeAttachments([
...(currentMessage.value?.attachments ?? []),
...(currentMessage.value?.inline_images ?? []),
]))
const attachments = computed(() =>
sourcePayloadAttachments.value.length ? sourcePayloadAttachments.value : currentMessageAttachments.value,
)
const sanitizedHtmlBody = computed(() => {
const message = currentMessage.value
const htmlBody = message?.html_body_sanitized?.trim()
if (!htmlBody || (message?.html_render_mode && message.html_render_mode !== 'SANITIZED_HTML')) {
return ''
}
return htmlBody
})
const messageBodyText = computed(() => {
if (sanitizedHtmlBody.value) {
return htmlToText(sanitizedHtmlBody.value)
}
return compactBodyText(currentMessage.value?.text_body ?? '')
})
const canToggleBody = computed(() => messageBodyText.value.length > bodyPreviewLength)
const visibleMessageBodyText = computed(() => {
if (!canToggleBody.value || bodyExpanded.value) {
return messageBodyText.value
}
return `${messageBodyText.value.slice(0, bodyPreviewLength).trimEnd()}...`
})
const showExpandedHtmlBody = computed(() =>
bodyExpanded.value && Boolean(sanitizedHtmlBody.value),
)
watch(sourceMessageId, (nextSourceMessageId) => {
void loadCurrentSourceMessage(nextSourceMessageId)
}, { immediate: true })
async function loadCurrentSourceMessage(nextSourceMessageId: string | null) {
bodyRequestSequence += 1
const requestSequence = bodyRequestSequence
currentMessage.value = null
bodyError.value = false
bodyExpanded.value = false
if (!nextSourceMessageId) {
bodyLoading.value = false
return
}
bodyLoading.value = true
try {
const conversation = await fetchSourceMessageConversation(nextSourceMessageId)
if (requestSequence !== bodyRequestSequence) {
return
}
currentMessage.value = conversation.messages.find((message) =>
String(message.id) === String(nextSourceMessageId),
) ?? null
} catch {
if (requestSequence === bodyRequestSequence) {
bodyError.value = true
}
} finally {
if (requestSequence === bodyRequestSequence) {
bodyLoading.value = false
}
}
}
interface AttachmentSummary {
key: string
@@ -175,10 +285,10 @@ function normalizeAttachments(value: unknown): AttachmentSummary[] {
function toAttachmentSummary(value: unknown, index: number): AttachmentSummary {
const record = readRecord(value)
const name = record
? safeAttachmentName(readFirstString(record, ['name', 'file_name', 'filename', 'display_name', 'title']))
? safeAttachmentName(readFirstString(record, ['name', 'file_name', 'fileName', 'filename', 'display_name', 'title']))
: safeAttachmentName(String(value))
const type = record ? readFirstString(record, ['content_type', 'mime_type', 'media_type', 'file_type']) : null
const size = record ? readNumber(record, 'size_bytes') ?? readNumber(record, 'size') : null
const type = record ? readFirstString(record, ['content_type', 'contentType', 'mime_type', 'media_type', 'mediaType', 'file_type']) : null
const size = record ? readNumber(record, 'size_bytes') ?? readNumber(record, 'sizeBytes') ?? readNumber(record, 'size') : null
return {
key: `${name}-${index}`,
name,
@@ -261,6 +371,19 @@ function safeAttachmentName(value: string | null): string {
function isUnsafeAttachmentText(value: string): boolean {
return /^(https?:\/\/|oss:\/\/|s3:\/\/)/i.test(value.trim())
}
function htmlToText(value: string): string {
if (typeof document !== 'undefined') {
const element = document.createElement('div')
element.innerHTML = value
return compactBodyText(element.textContent ?? '')
}
return compactBodyText(value.replace(/<[^>]*>/g, ' '))
}
function compactBodyText(value: string): string {
return value.replace(/\s+/g, ' ').trim()
}
</script>
<style scoped>
@@ -307,6 +430,34 @@ function isUnsafeAttachmentText(value: string): boolean {
margin: 0;
}
.source-body__text,
.source-body__html {
max-width: 100%;
color: var(--th-color-slate-900);
font-size: 13px;
line-height: 1.65;
overflow-wrap: anywhere;
}
.source-body__html :deep(*) {
max-width: 100%;
}
.source-body__muted {
color: var(--th-color-slate-500);
}
.source-body__toggle {
justify-self: start;
border: 0;
background: transparent;
color: var(--th-color-blue-600);
cursor: pointer;
font-size: 13px;
font-weight: 900;
padding: 0;
}
.attachment-list {
display: grid;
gap: 8px;

View File

@@ -625,6 +625,12 @@ export default {
conversationCount: 'Conversation messages',
attachments: 'Attachments',
excerpt: 'Email excerpt',
body: 'Current email body',
bodyLoading: 'Loading current email body...',
bodyLoadFailed: 'Current email body cannot be loaded right now. Open the email conversation to view it.',
noBody: 'No current email body.',
showFullBody: 'Show full body',
collapseBody: 'Collapse body',
noExcerpt: 'No email excerpt',
noAttachments: 'No attachments',
},

View File

@@ -625,6 +625,12 @@ export default {
conversationCount: 'จำนวนอีเมลในเธรด',
attachments: 'ไฟล์แนบ',
excerpt: 'ข้อความบางส่วนของอีเมล',
body: 'เนื้อหาอีเมลปัจจุบัน',
bodyLoading: 'กำลังโหลดเนื้อหาอีเมลปัจจุบัน...',
bodyLoadFailed: 'ยังโหลดเนื้อหาอีเมลปัจจุบันไม่ได้ โปรดเปิดเธรดอีเมลเพื่อดู',
noBody: 'ไม่มีเนื้อหาอีเมลปัจจุบัน',
showFullBody: 'แสดงทั้งหมด',
collapseBody: 'ย่อเนื้อหา',
noExcerpt: 'ไม่มีข้อความบางส่วน',
noAttachments: 'ไม่มีไฟล์แนบ',
},

View File

@@ -625,6 +625,12 @@ export default {
conversationCount: '会话邮件数',
attachments: '附件',
excerpt: '邮件片段',
body: '当前邮件正文',
bodyLoading: '正在加载当前邮件正文...',
bodyLoadFailed: '当前邮件正文暂时无法加载,可进入邮件会话查看。',
noBody: '暂无当前邮件正文。',
showFullBody: '展开全文',
collapseBody: '收起正文',
noExcerpt: '暂无邮件片段',
noAttachments: '无附件',
},

View File

@@ -11,6 +11,7 @@ import type {
ReservationV4OrderTaskDetailResult,
ReservationV4SourceNotificationDetailResult,
ReservationV4TaskCardResult,
SourceMessageConversationResult,
} from '@/types/reservation'
import ReservationV4OrderTaskDetailView from '@/views/reservation/ReservationV4OrderTaskDetailView.vue'
import ReservationV4SourceNotificationDetailView from '@/views/reservation/ReservationV4SourceNotificationDetailView.vue'
@@ -27,6 +28,7 @@ vi.mock('@/services/reservationService', async (importOriginal) => {
fetchReservationV4RateCodeLookups: vi.fn(),
fetchReservationV4RoomTypeLookups: vi.fn(),
fetchReservationV4SourceNotificationDetail: vi.fn(),
fetchSourceMessageConversation: vi.fn(),
resolveReservationV4OrderTaskCardReview: vi.fn(),
}
})
@@ -43,7 +45,9 @@ describe('reservation V4 pages', () => {
vi.mocked(service.fetchReservationV4RateCodeLookups).mockReset()
vi.mocked(service.fetchReservationV4RoomTypeLookups).mockReset()
vi.mocked(service.fetchReservationV4SourceNotificationDetail).mockReset()
vi.mocked(service.fetchSourceMessageConversation).mockReset()
vi.mocked(service.resolveReservationV4OrderTaskCardReview).mockReset()
vi.mocked(service.fetchSourceMessageConversation).mockResolvedValue(createSourceMessageConversationResult())
mockCatalogLookups()
})
@@ -150,6 +154,139 @@ describe('reservation V4 pages', () => {
expect(wrapper.text()).not.toContain('oss://bucket')
})
it('renders V4 source message display at the bottom with only the current email body collapsed', async () => {
const detail = createOrderTaskDetail()
detail.source_message_card!.fields = [
createField('/source_message/ai_payload_json', {
display_name: 'AI payload',
value: 'AI_PAYLOAD_SHOULD_NOT_RENDER',
}),
createField('/source_message/raw_evidence', {
display_name: 'Raw evidence',
value: 'RAW_EVIDENCE_SHOULD_NOT_RENDER',
}),
createField('/source_message/attachment_url', {
display_name: 'Attachment URL',
value: 'https://oss.example/private/source-field.pdf',
}),
]
const currentBody = `Current trigger email body opening. ${'Current booking detail. '.repeat(24)}CURRENT_BODY_TAIL`
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail)
vi.mocked(service.fetchSourceMessageConversation).mockResolvedValue(createSourceMessageConversationResult({
sourceMessageId: '30001',
currentBody,
}))
const wrapper = await mountWithPlugins(
ReservationV4OrderTaskDetailView,
'/reservation/order-tasks/9001',
)
await flushPromises()
expect(service.fetchSourceMessageConversation).toHaveBeenCalledWith('30001')
const pageText = wrapper.text()
const basicIndex = pageText.indexOf(zhCN.taskV4.basicInformationCard)
const businessIndex = pageText.indexOf(zhCN.taskV4.businessCards)
const sourceIndex = pageText.indexOf(zhCN.taskV4.sourceMessageCard)
expect(basicIndex).toBeGreaterThanOrEqual(0)
expect(businessIndex).toBeGreaterThan(basicIndex)
expect(sourceIndex).toBeGreaterThan(businessIndex)
const sections = wrapper.findAll('.th-section')
expect(sections[sections.length - 1]?.text()).toContain(zhCN.taskV4.sourceMessageCard)
expect(pageText).toContain('Current trigger email body opening.')
expect(pageText).not.toContain('OLDER_MESSAGE_BODY')
expect(pageText).not.toContain('REPLY_MESSAGE_BODY')
expect(pageText).not.toContain('CURRENT_BODY_TAIL')
expect(pageText).not.toContain('AI_PAYLOAD_SHOULD_NOT_RENDER')
expect(pageText).not.toContain('RAW_EVIDENCE_SHOULD_NOT_RENDER')
expect(pageText).not.toContain('https://oss.example/private/source-field.pdf')
const expandButton = wrapper
.findAll('button')
.find((button) => button.text().includes(zhCN.taskV4.sourceMessage.showFullBody))
expect(expandButton).toBeTruthy()
await expandButton!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('CURRENT_BODY_TAIL')
expect(wrapper.text()).toContain(zhCN.taskV4.sourceMessage.collapseBody)
})
it('falls back to source summary when current source message body cannot be loaded', async () => {
const detail = createOrderTaskDetail()
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail)
vi.mocked(service.fetchSourceMessageConversation).mockRejectedValue(new ApiError('Forbidden', 403, {
code: 'SOURCE_MESSAGE_ORIGINAL_READ_FORBIDDEN',
message: 'Forbidden',
}))
const wrapper = await mountWithPlugins(
ReservationV4OrderTaskDetailView,
'/reservation/order-tasks/9001',
)
await flushPromises()
expect(wrapper.text()).toContain('Please book one twin room.')
expect(wrapper.text()).toContain(zhCN.taskV4.sourceMessage.bodyLoadFailed)
expect(wrapper.text()).toContain(zhCN.task.viewConversation)
})
it('renders only sanitized source message html and never raw html body', async () => {
const detail = createOrderTaskDetail()
const sanitizedHtml = `<p>Sanitized current email opening. ${'Sanitized detail. '.repeat(24)}SANITIZED_HTML_TAIL</p>`
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail)
vi.mocked(service.fetchSourceMessageConversation).mockResolvedValue(createSourceMessageConversationResult({
sourceMessageId: '30001',
currentBody: '',
sanitizedHtml,
rawHtml: '<script>RAW_HTML_SHOULD_NOT_RENDER</script>',
htmlRenderMode: 'SANITIZED_HTML',
}))
const wrapper = await mountWithPlugins(
ReservationV4OrderTaskDetailView,
'/reservation/order-tasks/9001',
)
await flushPromises()
expect(wrapper.text()).toContain('Sanitized current email opening.')
expect(wrapper.text()).not.toContain('SANITIZED_HTML_TAIL')
expect(wrapper.text()).not.toContain('RAW_HTML_SHOULD_NOT_RENDER')
const expandButton = wrapper
.findAll('button')
.find((button) => button.text().includes(zhCN.taskV4.sourceMessage.showFullBody))
expect(expandButton).toBeTruthy()
await expandButton!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('SANITIZED_HTML_TAIL')
expect(wrapper.html()).toContain('<p>Sanitized current email opening.')
expect(wrapper.html()).not.toContain('RAW_HTML_SHOULD_NOT_RENDER')
})
it('does not render sanitized html when backend marks the email body as text only', async () => {
const detail = createOrderTaskDetail()
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail)
vi.mocked(service.fetchSourceMessageConversation).mockResolvedValue(createSourceMessageConversationResult({
sourceMessageId: '30001',
currentBody: 'Plain text fallback body.',
sanitizedHtml: '<p>SANITIZED_HTML_SHOULD_NOT_RENDER</p>',
rawHtml: '<p>RAW_HTML_SHOULD_NOT_RENDER</p>',
htmlRenderMode: 'TEXT_ONLY',
}))
const wrapper = await mountWithPlugins(
ReservationV4OrderTaskDetailView,
'/reservation/order-tasks/9001',
)
await flushPromises()
expect(wrapper.text()).toContain('Plain text fallback body.')
expect(wrapper.text()).not.toContain('SANITIZED_HTML_SHOULD_NOT_RENDER')
expect(wrapper.text()).not.toContain('RAW_HTML_SHOULD_NOT_RENDER')
})
it('renders New Booking Room Information as a business form and confirms stable final values', async () => {
const detail = createOrderTaskDetail({
businessEventType: 'NEW_BOOKING',
@@ -393,6 +530,8 @@ describe('reservation V4 pages', () => {
const roomCard = wrapper.find('[data-testid="room-information-card"]')
expect(roomCard.text()).toContain(zhCN.taskV4.roomInformation.changeSummary)
expect(roomCard.text()).toContain(zhCN.taskV4.roomInformation.currentValues)
expect(roomCard.text()).toContain(zhCN.taskV4.roomInformation.proposedValues)
expect(roomCard.text()).toContain('2026-07-26')
expect(roomCard.text()).toContain('2026-07-27')
expect(roomCard.text()).toContain('3')
@@ -986,6 +1125,81 @@ function createOrderTaskDetail(options: {
}
}
function createSourceMessageConversationResult(options: {
sourceMessageId?: string
currentBody?: string
sanitizedHtml?: string | null
rawHtml?: string | null
htmlRenderMode?: SourceMessageConversationResult['messages'][number]['html_render_mode']
} = {}): SourceMessageConversationResult {
const sourceMessageId = options.sourceMessageId ?? '30001'
const currentBody = options.currentBody ?? 'Current trigger email body.'
return {
conversation: {
hotel_id: 'HOTEL-TEST',
channel: 'EMAIL',
external_conversation_id: 'thread-1',
subject: 'Booking Request',
message_count: 3,
first_received_at: '2026-07-08T02:00:00Z',
last_received_at: '2026-07-08T04:00:00Z',
},
messages: [
createConversationMessage({
id: '29999',
text_body: 'OLDER_MESSAGE_BODY',
received_at: '2026-07-08T02:00:00Z',
}),
createConversationMessage({
id: sourceMessageId,
text_body: currentBody,
html_body: options.rawHtml ?? null,
html_body_sanitized: options.sanitizedHtml ?? null,
html_render_mode: options.htmlRenderMode,
received_at: '2026-07-08T03:00:00Z',
attachments: [
{
mediaType: 'ATTACHMENT',
fileName: 'current-message.pdf',
contentType: 'application/pdf',
sizeBytes: 2048,
externalUrl: 'https://oss.example/private/current-message.pdf',
externalMediaId: 'media-current',
},
],
}),
createConversationMessage({
id: '30002',
text_body: 'REPLY_MESSAGE_BODY',
received_at: '2026-07-08T04:00:00Z',
}),
],
}
}
function createConversationMessage(
overrides: Partial<SourceMessageConversationResult['messages'][number]> & { id: string },
): SourceMessageConversationResult['messages'][number] {
return {
id: overrides.id,
external_message_id: overrides.external_message_id ?? `external-${overrides.id}`,
external_conversation_id: overrides.external_conversation_id ?? 'thread-1',
sender_summary: overrides.sender_summary ?? 'guest@example.test',
subject: overrides.subject ?? 'Booking Request',
received_at: overrides.received_at ?? '2026-07-08T03:00:00Z',
source_sent_at: overrides.source_sent_at ?? null,
text_body: overrides.text_body ?? null,
html_body: overrides.html_body ?? null,
html_body_sanitized: overrides.html_body_sanitized ?? null,
html_sanitize_required: overrides.html_sanitize_required ?? false,
html_render_mode: overrides.html_render_mode ?? (overrides.html_body_sanitized ? 'SANITIZED_HTML' : 'TEXT_ONLY'),
inline_images: overrides.inline_images ?? [],
attachments: overrides.attachments ?? [],
related_orders: overrides.related_orders ?? [],
related_tasks: overrides.related_tasks ?? [],
}
}
function createRoomInformationDisplayPayload(overrides: {
event_type?: string
booking_type?: string

View File

@@ -78,12 +78,6 @@
</div>
</section>
<ReservationV4SourceMessageCard
:title="t('taskV4.sourceMessageCard')"
:source-message-summary="detail.source_message_summary"
:source-card="detail.source_message_card"
/>
<TaskCardSection
v-if="detail.basic_information_card"
:card="detail.basic_information_card"
@@ -177,6 +171,12 @@
</article>
</div>
</section>
<ReservationV4SourceMessageCard
:title="t('taskV4.sourceMessageCard')"
:source-message-summary="detail.source_message_summary"
:source-card="detail.source_message_card"
/>
</template>
</div>
</template>