@open-mercato/core 0.10.1-develop.7771.1.615a6c9ba9 → 0.10.1-develop.7773.1.a41b0f6c03
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/commands/ingest-inbound-message.js +6 -7
- package/dist/modules/communication_channels/commands/ingest-inbound-message.js.map +2 -2
- package/dist/modules/communication_channels/data/validators.js +2 -1
- package/dist/modules/communication_channels/data/validators.js.map +2 -2
- package/dist/modules/communication_channels/lib/email-mime.js +13 -3
- package/dist/modules/communication_channels/lib/email-mime.js.map +2 -2
- package/dist/modules/communication_channels/lib/thread-matcher.js.map +2 -2
- package/dist/modules/customers/api/activities/route.js +16 -6
- package/dist/modules/customers/api/activities/route.js.map +2 -2
- package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js +1 -1
- package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/[id]/page.js +1 -1
- package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/people-v2/[id]/page.js +1 -1
- package/dist/modules/customers/backend/customers/people-v2/[id]/page.js.map +2 -2
- package/dist/modules/customers/components/detail/CompanyDetailTabs.js +3 -2
- package/dist/modules/customers/components/detail/CompanyDetailTabs.js.map +2 -2
- package/dist/modules/customers/components/detail/DealDetailTabs.js +3 -2
- package/dist/modules/customers/components/detail/DealDetailTabs.js.map +2 -2
- package/dist/modules/customers/components/detail/PersonDetailTabs.js +3 -2
- package/dist/modules/customers/components/detail/PersonDetailTabs.js.map +2 -2
- package/dist/modules/customers/lib/interactionOrderBy.js +12 -0
- package/dist/modules/customers/lib/interactionOrderBy.js.map +7 -0
- package/dist/modules/customers/lib/link-channel-message-handler.js +12 -11
- package/dist/modules/customers/lib/link-channel-message-handler.js.map +2 -2
- package/dist/modules/customers/lib/personEmailThreads.js +43 -12
- package/dist/modules/customers/lib/personEmailThreads.js.map +3 -3
- package/dist/modules/messages/api/route.js +2 -1
- package/dist/modules/messages/api/route.js.map +2 -2
- package/dist/modules/messages/commands/messages.js +2 -1
- package/dist/modules/messages/commands/messages.js.map +2 -2
- package/dist/modules/messages/components/MessagesInboxPageClient.js +1 -0
- package/dist/modules/messages/components/MessagesInboxPageClient.js.map +2 -2
- package/dist/modules/messages/components/defaults/DefaultMessageListItem.js +10 -8
- package/dist/modules/messages/components/defaults/DefaultMessageListItem.js.map +2 -2
- package/dist/modules/messages/components/message-detail/panels/MessageHeader.js +1 -1
- package/dist/modules/messages/components/message-detail/panels/MessageHeader.js.map +2 -2
- package/dist/modules/messages/components/message-detail/panels/MessageListComponent.js +3 -3
- package/dist/modules/messages/components/message-detail/panels/MessageListComponent.js.map +2 -2
- package/dist/modules/messages/components/message-detail/panels/thread-panel.js +1 -1
- package/dist/modules/messages/components/message-detail/panels/thread-panel.js.map +2 -2
- package/dist/modules/messages/components/message-detail/utils.js +3 -3
- package/dist/modules/messages/components/message-detail/utils.js.map +2 -2
- package/dist/modules/messages/data/validators.js +3 -2
- package/dist/modules/messages/data/validators.js.map +2 -2
- package/dist/modules/messages/frontend/messages/view/[token]/page.js +6 -5
- package/dist/modules/messages/frontend/messages/view/[token]/page.js.map +2 -2
- package/dist/modules/messages/lib/forwarding.js +3 -1
- package/dist/modules/messages/lib/forwarding.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/communication_channels/commands/ingest-inbound-message.ts +5 -7
- package/src/modules/communication_channels/data/validators.ts +2 -1
- package/src/modules/communication_channels/lib/adapter.ts +4 -1
- package/src/modules/communication_channels/lib/email-mime.ts +24 -7
- package/src/modules/communication_channels/lib/thread-matcher.ts +2 -1
- package/src/modules/customers/api/activities/route.ts +17 -7
- package/src/modules/customers/backend/customers/companies-v2/[id]/page.tsx +1 -1
- package/src/modules/customers/backend/customers/deals/[id]/page.tsx +1 -1
- package/src/modules/customers/backend/customers/people-v2/[id]/page.tsx +1 -1
- package/src/modules/customers/components/detail/CompanyDetailTabs.tsx +5 -1
- package/src/modules/customers/components/detail/DealDetailTabs.tsx +6 -2
- package/src/modules/customers/components/detail/PersonDetailTabs.tsx +6 -2
- package/src/modules/customers/lib/interactionOrderBy.ts +9 -0
- package/src/modules/customers/lib/link-channel-message-handler.ts +27 -38
- package/src/modules/customers/lib/personEmailThreads.ts +63 -21
- package/src/modules/messages/api/route.ts +3 -1
- package/src/modules/messages/commands/messages.ts +2 -1
- package/src/modules/messages/components/MessagesInboxPageClient.tsx +2 -0
- package/src/modules/messages/components/defaults/DefaultMessageListItem.tsx +10 -8
- package/src/modules/messages/components/message-detail/panels/MessageHeader.tsx +3 -1
- package/src/modules/messages/components/message-detail/panels/MessageListComponent.tsx +5 -3
- package/src/modules/messages/components/message-detail/panels/thread-panel.tsx +1 -1
- package/src/modules/messages/components/message-detail/utils.ts +3 -3
- package/src/modules/messages/data/validators.ts +4 -3
- package/src/modules/messages/frontend/messages/view/[token]/page.tsx +5 -4
- 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 +2 -1
- package/src/modules/messages/lib/forwarding.ts +3 -1
|
@@ -95,17 +95,6 @@ export default async function handler(
|
|
|
95
95
|
const metaJson = (link.channelMetadata ?? null) as Record<string, unknown> | null
|
|
96
96
|
const payloadJson = (link.channelPayload ?? null) as Record<string, unknown> | null
|
|
97
97
|
|
|
98
|
-
// ── (1b) Resolve when the email actually happened ─────────────────────
|
|
99
|
-
//
|
|
100
|
-
// `link.createdAt` is when the hub ingested the message, which for a history
|
|
101
|
-
// import is the import minute, not the day the mail arrived (#6095). The hub
|
|
102
|
-
// carries the provider's own timestamp on the event payload, so take it from
|
|
103
|
-
// there: reading the communication_channels ExternalMessage row here would
|
|
104
|
-
// cross the storage boundary (Cross-Module Coupling in AGENTS.md) and add a
|
|
105
|
-
// peer-table query per event. Resolved once and threaded through every branch
|
|
106
|
-
// below so the address-match and threading-inheritance paths agree.
|
|
107
|
-
const occurredAt = resolveOccurredAt(payload, link)
|
|
108
|
-
|
|
109
98
|
// ── (2) Resolve the channel to get its owner userId ───────────────────
|
|
110
99
|
//
|
|
111
100
|
// The channel.userId is needed for two purposes:
|
|
@@ -130,6 +119,8 @@ export default async function handler(
|
|
|
130
119
|
channelUserId = channel?.userId ?? null
|
|
131
120
|
}
|
|
132
121
|
|
|
122
|
+
const occurredAt = await resolveEmailOccurredAt(em, link, tenantId, organizationId, payload)
|
|
123
|
+
|
|
133
124
|
// ── (3) Collect recipient addresses ───────────────────────────────────
|
|
134
125
|
//
|
|
135
126
|
// The channel metadata shape differs by provider and direction:
|
|
@@ -281,7 +272,7 @@ async function handleThreadingInheritance(
|
|
|
281
272
|
channelId: string | null,
|
|
282
273
|
metaJson: Record<string, unknown> | null,
|
|
283
274
|
payloadJson: Record<string, unknown> | null,
|
|
284
|
-
occurredAt: Date,
|
|
275
|
+
occurredAt: Date | null,
|
|
285
276
|
): Promise<void> {
|
|
286
277
|
// ── Primary: inherit Person(s) from the hub's authoritative thread ──────
|
|
287
278
|
//
|
|
@@ -474,35 +465,33 @@ async function handleThreadingInheritance(
|
|
|
474
465
|
)
|
|
475
466
|
}
|
|
476
467
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
/**
|
|
480
|
-
* When the linked email happened, for `CustomerInteraction.occurredAt` (#6095).
|
|
481
|
-
*
|
|
482
|
-
* Preference order:
|
|
483
|
-
* 1. `payload.providerTimestamp` — the provider's own receive/send time, put
|
|
484
|
-
* on the hub event by ingest / delivery;
|
|
485
|
-
* 2. `MessageChannelLink.createdAt` — the ingest time, the only date this
|
|
486
|
-
* handler knew before #6095, and the compatibility path for events that
|
|
487
|
-
* were enqueued before the field existed;
|
|
488
|
-
* 3. now, for a link row with no usable `createdAt` (test stubs).
|
|
489
|
-
*/
|
|
490
|
-
function resolveOccurredAt(
|
|
491
|
-
payload: LinkChannelMessagePayload,
|
|
468
|
+
async function resolveEmailOccurredAt(
|
|
469
|
+
em: EntityManager,
|
|
492
470
|
link: Record<string, unknown>,
|
|
493
|
-
|
|
494
|
-
|
|
471
|
+
tenantId: string,
|
|
472
|
+
organizationId: string | null,
|
|
473
|
+
payload: LinkChannelMessagePayload,
|
|
474
|
+
): Promise<Date | null> {
|
|
475
|
+
const message = typeof link.messageId === 'string'
|
|
476
|
+
? await findOneWithDecryption(
|
|
477
|
+
em,
|
|
478
|
+
'Message' as never,
|
|
479
|
+
{ id: link.messageId, tenantId, organizationId, deletedAt: null } as never,
|
|
480
|
+
undefined,
|
|
481
|
+
{ tenantId, organizationId },
|
|
482
|
+
) as { sentAt?: Date | null } | null
|
|
483
|
+
: null
|
|
484
|
+
|
|
485
|
+
if (message) return message.sentAt ?? null
|
|
495
486
|
const carried = payload.providerTimestamp
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
const parsed = new Date(carried)
|
|
501
|
-
if (!Number.isNaN(parsed.getTime())) return parsed
|
|
502
|
-
}
|
|
503
|
-
return ingestedAt
|
|
487
|
+
const providerTimestamp = carried instanceof Date
|
|
488
|
+
? carried
|
|
489
|
+
: typeof carried === 'string' && carried ? new Date(carried) : null
|
|
490
|
+
return providerTimestamp && !Number.isNaN(providerTimestamp.getTime()) ? providerTimestamp : null
|
|
504
491
|
}
|
|
505
492
|
|
|
493
|
+
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
494
|
+
|
|
506
495
|
interface InteractionData {
|
|
507
496
|
linkId: string
|
|
508
497
|
tenantId: string
|
|
@@ -511,7 +500,7 @@ interface InteractionData {
|
|
|
511
500
|
title: string | null
|
|
512
501
|
body: string | null
|
|
513
502
|
authorUserId: string | null
|
|
514
|
-
occurredAt: Date
|
|
503
|
+
occurredAt: Date | null
|
|
515
504
|
visibility: 'private' | 'shared'
|
|
516
505
|
channelProviderKey: string | null
|
|
517
506
|
/**
|
|
@@ -3,6 +3,7 @@ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
|
3
3
|
import { CustomerInteraction } from '../data/entities'
|
|
4
4
|
import { resolveUserNames } from './userNames'
|
|
5
5
|
import { buildEmailVisibilityMikroFilter } from './visibilityFilter'
|
|
6
|
+
import { buildInteractionOccurredAtOrderBy } from './interactionOrderBy'
|
|
6
7
|
import type { ConversationShareGrant } from './conversationShares'
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -45,7 +46,7 @@ export type PersonEmailMessage = {
|
|
|
45
46
|
cc: string[]
|
|
46
47
|
subject: string | null
|
|
47
48
|
bodyText: string | null
|
|
48
|
-
sentAt: string
|
|
49
|
+
sentAt: string | null
|
|
49
50
|
providerKey: string | null
|
|
50
51
|
}
|
|
51
52
|
|
|
@@ -54,7 +55,7 @@ export type PersonEmailThread = {
|
|
|
54
55
|
subject: string | null
|
|
55
56
|
preview: string | null
|
|
56
57
|
participants: string[]
|
|
57
|
-
lastMessageAt: string
|
|
58
|
+
lastMessageAt: string | null
|
|
58
59
|
messageCount: number
|
|
59
60
|
providerKey: string | null
|
|
60
61
|
lastDirection: EmailThreadDirection
|
|
@@ -195,17 +196,20 @@ export async function buildPersonEmailThreads(
|
|
|
195
196
|
em,
|
|
196
197
|
CustomerInteraction,
|
|
197
198
|
interactionWhere as never,
|
|
198
|
-
{
|
|
199
|
+
{
|
|
200
|
+
orderBy: buildInteractionOccurredAtOrderBy('desc'),
|
|
201
|
+
limit: maxThreads * 20,
|
|
202
|
+
},
|
|
199
203
|
dscope,
|
|
200
204
|
)) as CustomerInteraction[]
|
|
201
205
|
|
|
202
|
-
const linkIdByInteraction = new Map<string, { occurredAt: Date; authorUserId: string | null }>()
|
|
206
|
+
const linkIdByInteraction = new Map<string, { occurredAt: Date | null; authorUserId: string | null }>()
|
|
203
207
|
const linkIds: string[] = []
|
|
204
208
|
for (const interaction of interactions) {
|
|
205
209
|
const linkId = interaction.externalMessageId
|
|
206
210
|
if (!linkId || linkIdByInteraction.has(linkId)) continue
|
|
207
211
|
linkIdByInteraction.set(linkId, {
|
|
208
|
-
occurredAt: interaction.occurredAt ??
|
|
212
|
+
occurredAt: interaction.occurredAt ?? null,
|
|
209
213
|
authorUserId: interaction.authorUserId ?? null,
|
|
210
214
|
})
|
|
211
215
|
linkIds.push(linkId)
|
|
@@ -301,14 +305,12 @@ export async function buildPersonEmailThreads(
|
|
|
301
305
|
typeof message?.body === 'string' ? message.body : null,
|
|
302
306
|
)
|
|
303
307
|
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
new Date()
|
|
311
|
-
const sentAt = (sentAtRaw instanceof Date ? sentAtRaw : new Date(sentAtRaw)).toISOString()
|
|
308
|
+
const messageSentAt = message?.sentAt instanceof Date || typeof message?.sentAt === 'string'
|
|
309
|
+
? new Date(message.sentAt)
|
|
310
|
+
: null
|
|
311
|
+
const sentAt = messageSentAt && !Number.isNaN(messageSentAt.getTime())
|
|
312
|
+
? messageSentAt.toISOString()
|
|
313
|
+
: null
|
|
312
314
|
|
|
313
315
|
const threadKey =
|
|
314
316
|
(typeof message?.threadId === 'string' && message.threadId ? message.threadId : null) ??
|
|
@@ -353,11 +355,27 @@ export async function buildPersonEmailThreads(
|
|
|
353
355
|
// ── (5) Finalize thread summaries ───────────────────────────────────────
|
|
354
356
|
const threads: PersonEmailThread[] = []
|
|
355
357
|
for (const thread of threadsByKey.values()) {
|
|
356
|
-
thread.messages.sort((
|
|
358
|
+
thread.messages.sort((left, right) => compareNullableIsochronological(
|
|
359
|
+
left.sentAt,
|
|
360
|
+
right.sentAt,
|
|
361
|
+
left.id,
|
|
362
|
+
right.id,
|
|
363
|
+
))
|
|
364
|
+
const datedMessages = thread.messages.filter((message) => message.sentAt !== null)
|
|
365
|
+
const undatedMessages = thread.messages.filter((message) => message.sentAt === null)
|
|
357
366
|
if (thread.messages.length > maxMessagesPerThread) {
|
|
358
|
-
|
|
367
|
+
const retainedDatedMessages = maxMessagesPerThread > 0
|
|
368
|
+
? datedMessages.slice(-maxMessagesPerThread)
|
|
369
|
+
: []
|
|
370
|
+
const remainingSlots = Math.max(0, maxMessagesPerThread - retainedDatedMessages.length)
|
|
371
|
+
const retainedUndatedMessages = remainingSlots > 0
|
|
372
|
+
? undatedMessages.slice(-remainingSlots)
|
|
373
|
+
: []
|
|
374
|
+
thread.messages = [...retainedDatedMessages, ...retainedUndatedMessages]
|
|
359
375
|
}
|
|
360
|
-
const
|
|
376
|
+
const retainedDatedMessages = thread.messages.filter((message) => message.sentAt !== null)
|
|
377
|
+
const latestDatedMessage = retainedDatedMessages[retainedDatedMessages.length - 1]
|
|
378
|
+
const summaryMessage = latestDatedMessage ?? thread.messages[thread.messages.length - 1]
|
|
361
379
|
const firstWithSubject = thread.messages.find((m) => m.subject)
|
|
362
380
|
const participantSet = new Set<string>()
|
|
363
381
|
for (const message of thread.messages) {
|
|
@@ -366,19 +384,43 @@ export async function buildPersonEmailThreads(
|
|
|
366
384
|
if (message.direction === 'outbound') message.to.forEach((email) => participantSet.add(email))
|
|
367
385
|
}
|
|
368
386
|
thread.subject = firstWithSubject?.subject ?? thread.subject
|
|
369
|
-
thread.preview = truncate(
|
|
387
|
+
thread.preview = truncate(summaryMessage?.bodyText ?? null)
|
|
370
388
|
thread.participants = Array.from(participantSet)
|
|
371
|
-
thread.lastMessageAt =
|
|
372
|
-
thread.lastDirection =
|
|
373
|
-
thread.providerKey =
|
|
389
|
+
thread.lastMessageAt = latestDatedMessage?.sentAt ?? null
|
|
390
|
+
thread.lastDirection = summaryMessage?.direction ?? thread.lastDirection
|
|
391
|
+
thread.providerKey = summaryMessage?.providerKey ?? thread.providerKey
|
|
374
392
|
thread.messageCount = thread.messages.length
|
|
375
393
|
threads.push(thread)
|
|
376
394
|
}
|
|
377
395
|
|
|
378
|
-
threads.sort((
|
|
396
|
+
threads.sort((left, right) => compareNullableIsochronological(
|
|
397
|
+
left.lastMessageAt,
|
|
398
|
+
right.lastMessageAt,
|
|
399
|
+
left.threadKey,
|
|
400
|
+
right.threadKey,
|
|
401
|
+
'desc',
|
|
402
|
+
))
|
|
379
403
|
return threads.slice(0, maxThreads)
|
|
380
404
|
}
|
|
381
405
|
|
|
406
|
+
function compareNullableIsochronological(
|
|
407
|
+
leftValue: string | null,
|
|
408
|
+
rightValue: string | null,
|
|
409
|
+
leftTieBreaker: string,
|
|
410
|
+
rightTieBreaker: string,
|
|
411
|
+
direction: 'asc' | 'desc' = 'asc',
|
|
412
|
+
): number {
|
|
413
|
+
if (leftValue === rightValue) {
|
|
414
|
+
const tie = leftTieBreaker.localeCompare(rightTieBreaker)
|
|
415
|
+
return direction === 'asc' ? tie : -tie
|
|
416
|
+
}
|
|
417
|
+
if (leftValue === null) return 1
|
|
418
|
+
if (rightValue === null) return -1
|
|
419
|
+
return direction === 'asc'
|
|
420
|
+
? leftValue.localeCompare(rightValue)
|
|
421
|
+
: rightValue.localeCompare(leftValue)
|
|
422
|
+
}
|
|
423
|
+
|
|
382
424
|
export type PersonEmailMessageAccessOptions = Pick<
|
|
383
425
|
BuildPersonEmailThreadsOptions,
|
|
384
426
|
'personId' | 'tenantId' | 'organizationId' | 'viewerUserId' | 'userFeatures' | 'sharedConversations' | 'sharedChannelIds'
|
|
@@ -321,7 +321,8 @@ export async function GET(req: Request) {
|
|
|
321
321
|
'r.status as recipient_status',
|
|
322
322
|
'r.read_at',
|
|
323
323
|
])
|
|
324
|
-
.orderBy('m.sent_at'
|
|
324
|
+
.orderBy(sql`${sql.ref('m.sent_at')} desc nulls last`)
|
|
325
|
+
.orderBy('m.id', 'desc')
|
|
325
326
|
.offset(offset)
|
|
326
327
|
.limit(input.pageSize)
|
|
327
328
|
.execute()
|
|
@@ -428,6 +429,7 @@ export async function GET(req: Request) {
|
|
|
428
429
|
senderUserId: message.senderUserId,
|
|
429
430
|
priority: message.priority,
|
|
430
431
|
status: row.recipient_status ?? (row.is_draft ? 'draft' : 'sent'),
|
|
432
|
+
isDraft: row.is_draft,
|
|
431
433
|
hasObjects: (objectsByMessage[message.id] || []).length > 0,
|
|
432
434
|
objectCount: (objectsByMessage[message.id] || []).length,
|
|
433
435
|
hasAttachments: (attachmentCountByMessage[message.id] || 0) > 0,
|
|
@@ -329,6 +329,7 @@ const composeMessageCommand: CommandHandler<unknown, { id: string; threadId: str
|
|
|
329
329
|
// here echoed every inbound email back to its sender through the tenant
|
|
330
330
|
// system email channel and made send-as-user deliver twice.
|
|
331
331
|
const sendViaEmail = input.sendViaEmail
|
|
332
|
+
const sentAt = input.sentAt === undefined ? new Date() : input.sentAt
|
|
332
333
|
const message = trx.create(Message, {
|
|
333
334
|
type: input.type,
|
|
334
335
|
visibility: input.visibility ?? null,
|
|
@@ -347,7 +348,7 @@ const composeMessageCommand: CommandHandler<unknown, { id: string; threadId: str
|
|
|
347
348
|
isDraft: input.isDraft ?? false,
|
|
348
349
|
// #6095: channel ingest passes the provider's timestamp so an imported
|
|
349
350
|
// mailbox sorts by when the mail was received, not when it was imported.
|
|
350
|
-
sentAt: input.isDraft ? null :
|
|
351
|
+
sentAt: input.isDraft ? null : sentAt,
|
|
351
352
|
actionData: input.actionData as MessageActionData | undefined,
|
|
352
353
|
sendViaEmail,
|
|
353
354
|
idempotencyKey: input.idempotencyKey ?? null,
|
|
@@ -45,6 +45,7 @@ type MessageListItem = {
|
|
|
45
45
|
sourceEntityType?: string | null
|
|
46
46
|
priority: string
|
|
47
47
|
status: string
|
|
48
|
+
isDraft: boolean
|
|
48
49
|
hasObjects: boolean
|
|
49
50
|
objectCount: number
|
|
50
51
|
hasAttachments: boolean
|
|
@@ -304,6 +305,7 @@ export function MessagesInboxPageClient() {
|
|
|
304
305
|
bodyFormat: 'text' as const,
|
|
305
306
|
priority: (item.priority as 'low' | 'normal' | 'high' | 'urgent') ?? 'normal',
|
|
306
307
|
sentAt: item.sentAt ? new Date(item.sentAt) : null,
|
|
308
|
+
isDraft: item.isDraft,
|
|
307
309
|
senderName: getMessageListParticipantLabel(item, folder, t),
|
|
308
310
|
hasObjects: item.hasObjects,
|
|
309
311
|
objectCount: item.objectCount,
|
|
@@ -5,15 +5,15 @@ import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
|
5
5
|
import { cn } from '@open-mercato/shared/lib/utils'
|
|
6
6
|
import { CheckCircle2, FileText, Paperclip, Zap } from 'lucide-react'
|
|
7
7
|
|
|
8
|
-
function formatDateTime(value: Date | null): string {
|
|
9
|
-
if (!value) return
|
|
10
|
-
if (Number.isNaN(value.getTime())) return
|
|
8
|
+
function formatDateTime(value: Date | null, unknownDate: string): string {
|
|
9
|
+
if (!value) return unknownDate
|
|
10
|
+
if (Number.isNaN(value.getTime())) return unknownDate
|
|
11
11
|
return value.toLocaleString()
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
function formatSentTime(value: Date | null): string {
|
|
15
|
-
if (!value) return
|
|
16
|
-
if (Number.isNaN(value.getTime())) return
|
|
14
|
+
function formatSentTime(value: Date | null, unknownDate: string): string {
|
|
15
|
+
if (!value) return unknownDate
|
|
16
|
+
if (Number.isNaN(value.getTime())) return unknownDate
|
|
17
17
|
|
|
18
18
|
const now = new Date()
|
|
19
19
|
const isSameDay = now.toDateString() === value.toDateString()
|
|
@@ -41,8 +41,10 @@ export function DefaultMessageListItem({ message, onClick }: MessageListItemProp
|
|
|
41
41
|
const t = useT()
|
|
42
42
|
const senderLabel = message.senderName?.trim() || t('messages.list.noRecipient', '(No recipient)')
|
|
43
43
|
const subject = message.subject.trim() || t('messages.list.noSubject', '(No subject)')
|
|
44
|
-
const
|
|
45
|
-
const
|
|
44
|
+
const unknownDate = t('messages.unknownDate', 'Unknown date')
|
|
45
|
+
const notSentYet = '—'
|
|
46
|
+
const absoluteSentAt = message.isDraft ? notSentYet : formatDateTime(message.sentAt, unknownDate)
|
|
47
|
+
const sentAtLabel = message.isDraft ? notSentYet : formatSentTime(message.sentAt, unknownDate)
|
|
46
48
|
const bodyPreview = truncateWords(message.body || '', 16)
|
|
47
49
|
|
|
48
50
|
return (
|
|
@@ -124,7 +124,9 @@ export function MessageHeader(props: MessageHeaderProps) {
|
|
|
124
124
|
mode="detail"
|
|
125
125
|
title={showSubject ? props.detail.subject : undefined}
|
|
126
126
|
subtitle={`${t('messages.detail.from', 'From')}: ${senderLabel}`}
|
|
127
|
-
statusBadge={<p className="text-xs text-muted-foreground">{
|
|
127
|
+
statusBadge={<p className="text-xs text-muted-foreground">{props.detail.isDraft
|
|
128
|
+
? '—'
|
|
129
|
+
: formatDateTime(props.detail.sentAt, t('messages.unknownDate', 'Unknown date'))}</p>}
|
|
128
130
|
utilityActions={canReply ? (
|
|
129
131
|
<IconButton
|
|
130
132
|
type="button"
|
|
@@ -4,8 +4,8 @@ import type { MessageListItemProps } from '@open-mercato/shared/modules/messages
|
|
|
4
4
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
5
5
|
import { cn } from '@open-mercato/shared/lib/utils'
|
|
6
6
|
|
|
7
|
-
function formatSentTime(value: Date | null): string {
|
|
8
|
-
if (!value || Number.isNaN(value.getTime())) return
|
|
7
|
+
function formatSentTime(value: Date | null, unknownDate: string): string {
|
|
8
|
+
if (!value || Number.isNaN(value.getTime())) return unknownDate
|
|
9
9
|
|
|
10
10
|
const now = new Date()
|
|
11
11
|
if (now.toDateString() === value.toDateString()) {
|
|
@@ -24,7 +24,9 @@ export function MessageListComponent({ message, onClick }: MessageListItemProps)
|
|
|
24
24
|
const t = useT()
|
|
25
25
|
const senderLabel = message.senderName || '—'
|
|
26
26
|
const body = normalizeBody(message.body)
|
|
27
|
-
const sentAtLabel =
|
|
27
|
+
const sentAtLabel = message.isDraft
|
|
28
|
+
? '—'
|
|
29
|
+
: formatSentTime(message.sentAt, t('messages.unknownDate', 'Unknown date'))
|
|
28
30
|
|
|
29
31
|
return (
|
|
30
32
|
<div
|
|
@@ -21,7 +21,7 @@ export function MessageDetailThreadSection({ detail }: { detail: MessageDetail }
|
|
|
21
21
|
{(detail.thread ?? []).map((threadItem) => (
|
|
22
22
|
<article key={threadItem.id} className="rounded border p-3">
|
|
23
23
|
<p className="text-xs text-muted-foreground">
|
|
24
|
-
{getMessageParticipantLabel(threadItem)} • {formatDateTime(threadItem.sentAt)}
|
|
24
|
+
{getMessageParticipantLabel(threadItem)} • {formatDateTime(threadItem.sentAt, t('messages.unknownDate', 'Unknown date'))}
|
|
25
25
|
</p>
|
|
26
26
|
<div className="mt-2 max-h-[60vh] overflow-y-auto pr-1">
|
|
27
27
|
<MarkdownContent
|
|
@@ -24,10 +24,10 @@ export function toErrorMessage(payload: unknown): string | null {
|
|
|
24
24
|
return null
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
export function formatDateTime(value: string | null | undefined): string {
|
|
28
|
-
if (!value) return
|
|
27
|
+
export function formatDateTime(value: string | null | undefined, unknownDate = '—'): string {
|
|
28
|
+
if (!value) return unknownDate
|
|
29
29
|
const date = new Date(value)
|
|
30
|
-
if (Number.isNaN(date.getTime())) return
|
|
30
|
+
if (Number.isNaN(date.getTime())) return unknownDate
|
|
31
31
|
return date.toLocaleString()
|
|
32
32
|
}
|
|
33
33
|
|
|
@@ -151,15 +151,16 @@ const composeMessageBaseSchema = z.object({
|
|
|
151
151
|
* (#6095). Channel ingest sets it to the provider's timestamp so an imported
|
|
152
152
|
* mailbox keeps its real chronology instead of collapsing onto the import
|
|
153
153
|
* minute. Server-only: the HTTP route strips any client-sent value, so a
|
|
154
|
-
* browser caller cannot backdate a message.
|
|
154
|
+
* browser caller cannot backdate a message. Explicit null preserves unknown
|
|
155
|
+
* external occurrence time; omission uses the send time. Ignored for drafts.
|
|
155
156
|
*/
|
|
156
|
-
sentAt: z.coerce.date().optional(),
|
|
157
|
+
sentAt: z.coerce.date().nullable().optional(),
|
|
157
158
|
})
|
|
158
159
|
|
|
159
160
|
type ComposeMessageRefinementValue = Omit<
|
|
160
161
|
z.infer<typeof composeMessageBaseSchema>,
|
|
161
162
|
'sourceChannelType' | 'sentAt'
|
|
162
|
-
> & { sourceChannelType?: string; sentAt?: Date }
|
|
163
|
+
> & { sourceChannelType?: string; sentAt?: Date | null }
|
|
163
164
|
|
|
164
165
|
function refineComposeMessage(value: ComposeMessageRefinementValue, ctx: z.RefinementCtx): void {
|
|
165
166
|
const isDraft = value.isDraft ?? false
|
|
@@ -94,10 +94,10 @@ function toErrorMessage(payload: unknown): string | null {
|
|
|
94
94
|
return null
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
function formatDateTime(value: string | null | undefined): string {
|
|
98
|
-
if (!value) return
|
|
97
|
+
function formatDateTime(value: string | null | undefined, unknownDate: string): string {
|
|
98
|
+
if (!value) return unknownDate
|
|
99
99
|
const date = new Date(value)
|
|
100
|
-
if (Number.isNaN(date.getTime())) return
|
|
100
|
+
if (Number.isNaN(date.getTime())) return unknownDate
|
|
101
101
|
return date.toLocaleString()
|
|
102
102
|
}
|
|
103
103
|
|
|
@@ -308,7 +308,8 @@ export default function MessageTokenPage({ params }: { params: { token: string }
|
|
|
308
308
|
<header className="space-y-1">
|
|
309
309
|
<h1 className="text-2xl font-semibold">{data.subject}</h1>
|
|
310
310
|
<p className="text-sm text-muted-foreground">
|
|
311
|
-
{t('messages.token.sentAt', 'Sent')}:
|
|
311
|
+
{t('messages.token.sentAt', 'Sent')}:{' '}
|
|
312
|
+
{formatDateTime(data.sentAt, t('messages.unknownDate', 'Unknown date'))}
|
|
312
313
|
</p>
|
|
313
314
|
</header>
|
|
314
315
|
|
|
@@ -266,6 +266,7 @@
|
|
|
266
266
|
"messages.types.default": "Nachricht",
|
|
267
267
|
"messages.types.defaultWithObjects": "Objektnachricht",
|
|
268
268
|
"messages.types.system": "Systembenachrichtigung",
|
|
269
|
+
"messages.unknownDate": "Datum unbekannt",
|
|
269
270
|
"messages.visibility": "Sichtbarkeit",
|
|
270
271
|
"messages.visibilityInternal": "Intern",
|
|
271
272
|
"messages.visibilityInternalHint": "Interne Nachrichten werden an ausgewählte Systemnutzer gesendet.",
|
|
@@ -266,6 +266,7 @@
|
|
|
266
266
|
"messages.types.default": "Message",
|
|
267
267
|
"messages.types.defaultWithObjects": "Object message",
|
|
268
268
|
"messages.types.system": "System notification",
|
|
269
|
+
"messages.unknownDate": "Unknown date",
|
|
269
270
|
"messages.visibility": "Visibility",
|
|
270
271
|
"messages.visibilityInternal": "Internal",
|
|
271
272
|
"messages.visibilityInternalHint": "Internal messages are sent to selected system users.",
|
|
@@ -266,6 +266,7 @@
|
|
|
266
266
|
"messages.types.default": "Mensaje",
|
|
267
267
|
"messages.types.defaultWithObjects": "Mensaje con objeto",
|
|
268
268
|
"messages.types.system": "Notificación del sistema",
|
|
269
|
+
"messages.unknownDate": "Fecha desconocida",
|
|
269
270
|
"messages.visibility": "Visibilidad",
|
|
270
271
|
"messages.visibilityInternal": "Interna",
|
|
271
272
|
"messages.visibilityInternalHint": "Los mensajes internos se envían a los usuarios del sistema seleccionados.",
|
|
@@ -266,6 +266,7 @@
|
|
|
266
266
|
"messages.types.default": "메시지",
|
|
267
267
|
"messages.types.defaultWithObjects": "객체 메시지",
|
|
268
268
|
"messages.types.system": "시스템 알림",
|
|
269
|
+
"messages.unknownDate": "날짜를 알 수 없음",
|
|
269
270
|
"messages.visibility": "공개 범위",
|
|
270
271
|
"messages.visibilityInternal": "내부",
|
|
271
272
|
"messages.visibilityInternalHint": "내부 메시지는 선택한 시스템 사용자에게 전송됩니다.",
|
|
@@ -266,10 +266,11 @@
|
|
|
266
266
|
"messages.types.default": "Wiadomość",
|
|
267
267
|
"messages.types.defaultWithObjects": "Wiadomość z obiektem",
|
|
268
268
|
"messages.types.system": "Powiadomienie systemowe",
|
|
269
|
+
"messages.unknownDate": "Data nieznana",
|
|
269
270
|
"messages.visibility": "Widoczność",
|
|
270
271
|
"messages.visibilityInternal": "Wewnętrzna",
|
|
271
272
|
"messages.visibilityInternalHint": "Wiadomości wewnętrzne są wysyłane do wybranych użytkowników systemu.",
|
|
272
273
|
"messages.visibilityPublic": "Publiczna",
|
|
273
274
|
"messages.visibilityPublicHint": "Wiadomości publiczne są wysyłane wyłącznie na zewnętrzny e-mail.",
|
|
274
|
-
"notifications.categories.messages": "
|
|
275
|
+
"notifications.categories.messages": "Wiadomości"
|
|
275
276
|
}
|
|
@@ -133,6 +133,8 @@ export async function buildForwardThreadSlice(
|
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
const orderedThreadMessages = Array.from(messagesById.values()).sort((a, b) => {
|
|
136
|
+
if (a.sentAt && !b.sentAt) return -1
|
|
137
|
+
if (!a.sentAt && b.sentAt) return 1
|
|
136
138
|
const aTime = a.sentAt?.getTime() ?? 0
|
|
137
139
|
const bTime = b.sentAt?.getTime() ?? 0
|
|
138
140
|
if (aTime !== bTime) return aTime - bTime
|
|
@@ -230,7 +232,7 @@ export async function buildForwardPreviewFromThreadSlice(
|
|
|
230
232
|
return [
|
|
231
233
|
FORWARD_MARKER,
|
|
232
234
|
`From: ${formatUserLabel(sender, item.senderUserId)}`,
|
|
233
|
-
`Date: ${formatDateLabel(item.sentAt
|
|
235
|
+
`Date: ${formatDateLabel(item.sentAt)}`,
|
|
234
236
|
`Subject: ${normalizeSubject(item.subject)}`,
|
|
235
237
|
`To: ${toLabel}`,
|
|
236
238
|
'',
|