From f1fb2fbfa63af5a8f611ad80bd99f11ad4834030 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Mon, 23 Feb 2026 09:04:49 +0700 Subject: [PATCH] feat(chat): add outbound delivery statuses to omnichat thread UI --- frontend/app.vue | 47 ++++++++++ frontend/graphql/operations/dashboard.graphql | 1 + frontend/server/graphql/schema.ts | 88 +++++++++++++++++++ 3 files changed, 136 insertions(+) diff --git a/frontend/app.vue b/frontend/app.vue index 85725e7..7f32b32 100644 --- a/frontend/app.vue +++ b/frontend/app.vue @@ -86,6 +86,7 @@ type CommItem = { audioUrl?: string; duration?: string; transcript?: string[]; + deliveryStatus?: "PENDING" | "SENT" | "DELIVERED" | "READ" | "FAILED" | string | null; }; type CommPin = { @@ -3941,6 +3942,24 @@ function channelIcon(channel: "All" | CommItem["channel"]) { return "phone"; } +function messageDeliveryUiState(item: CommItem): "none" | "sending" | "sent" | "delivered" | "failed" { + if (item.kind !== "message" || item.direction !== "out") return "none"; + const rawStatus = String(item.deliveryStatus ?? "").toUpperCase(); + if (rawStatus === "FAILED") return "failed"; + if (rawStatus === "READ" || rawStatus === "DELIVERED") return "delivered"; + if (rawStatus === "SENT") return "sent"; + return "sending"; +} + +function messageDeliveryLabel(item: CommItem) { + const state = messageDeliveryUiState(item); + if (state === "failed") return "Delivery failed"; + if (state === "delivered") return "Delivered"; + if (state === "sent") return "Sent"; + if (state === "sending") return "Sending"; + return ""; +} + function makeId(prefix: string) { return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1000)}`; } @@ -5599,6 +5618,34 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") {{ formatStamp(entry.item.at) }} + + + + ✓ + + + ✓✓ + + + ! + +

diff --git a/frontend/graphql/operations/dashboard.graphql b/frontend/graphql/operations/dashboard.graphql index 1c3b7b5..0823882 100644 --- a/frontend/graphql/operations/dashboard.graphql +++ b/frontend/graphql/operations/dashboard.graphql @@ -23,6 +23,7 @@ query DashboardQuery { audioUrl duration transcript + deliveryStatus } calendar { id diff --git a/frontend/server/graphql/schema.ts b/frontend/server/graphql/schema.ts index f56b9c2..1d896ce 100644 --- a/frontend/server/graphql/schema.ts +++ b/frontend/server/graphql/schema.ts @@ -357,6 +357,48 @@ async function getDashboard(auth: AuthContext | null) { }), ]); + let omniMessagesRaw: Array<{ + id: string; + contactId: string; + channel: string; + direction: string; + text: string; + status: string; + occurredAt: Date; + updatedAt: Date; + }> = []; + + if (communicationsRaw.length) { + const contactIds = [...new Set(communicationsRaw.map((row) => row.contactId))]; + const minOccurredAt = communicationsRaw[0]?.occurredAt ?? new Date(); + const maxOccurredAt = communicationsRaw[communicationsRaw.length - 1]?.occurredAt ?? new Date(); + const fromOccurredAt = new Date(minOccurredAt.getTime() - 5 * 60 * 1000); + const toOccurredAt = new Date(maxOccurredAt.getTime() + 5 * 60 * 1000); + + omniMessagesRaw = await prisma.omniMessage.findMany({ + where: { + teamId: ctx.teamId, + contactId: { in: contactIds }, + occurredAt: { + gte: fromOccurredAt, + lte: toOccurredAt, + }, + }, + select: { + id: true, + contactId: true, + channel: true, + direction: true, + text: true, + status: true, + occurredAt: true, + updatedAt: true, + }, + orderBy: [{ occurredAt: "asc" }, { updatedAt: "asc" }], + take: 5000, + }); + } + const channelsByContactId = new Map>(); for (const item of communicationsRaw) { if (!channelsByContactId.has(item.contactId)) { @@ -377,6 +419,50 @@ async function getDashboard(auth: AuthContext | null) { description: c.note?.content ?? "", })); + const omniByKey = new Map(); + for (const row of omniMessagesRaw) { + const key = [row.contactId, row.channel, row.direction, row.text.trim()].join("|"); + if (!omniByKey.has(key)) omniByKey.set(key, []); + omniByKey.get(key)?.push(row); + } + const consumedOmniMessageIds = new Set(); + + const resolveDeliveryStatus = (m: (typeof communicationsRaw)[number]) => { + if (m.kind !== "MESSAGE") return null; + const key = [m.contactId, m.channel, m.direction, m.content.trim()].join("|"); + const candidates = omniByKey.get(key) ?? []; + if (!candidates.length) { + if (m.direction === "OUT" && m.channel === "TELEGRAM") return "PENDING"; + return null; + } + + const targetMs = m.occurredAt.getTime(); + let best: (typeof candidates)[number] | null = null; + let bestDiff = Number.POSITIVE_INFINITY; + + for (const candidate of candidates) { + if (consumedOmniMessageIds.has(candidate.id)) continue; + const diff = Math.abs(candidate.occurredAt.getTime() - targetMs); + if (diff > 5 * 60 * 1000) continue; + if (diff < bestDiff) { + best = candidate; + bestDiff = diff; + continue; + } + if (diff === bestDiff && best && candidate.updatedAt.getTime() > best.updatedAt.getTime()) { + best = candidate; + } + } + + if (!best) { + if (m.direction === "OUT" && m.channel === "TELEGRAM") return "PENDING"; + return null; + } + + consumedOmniMessageIds.add(best.id); + return best.status; + }; + const communications = communicationsRaw.map((m) => ({ id: m.id, at: m.occurredAt.toISOString(), @@ -389,6 +475,7 @@ async function getDashboard(auth: AuthContext | null) { audioUrl: "", duration: m.durationSec ? new Date(m.durationSec * 1000).toISOString().slice(14, 19) : "", transcript: Array.isArray(m.transcriptJson) ? ((m.transcriptJson as any) as string[]) : [], + deliveryStatus: resolveDeliveryStatus(m), })); const calendar = calendarRaw.map((e) => ({ @@ -1204,6 +1291,7 @@ export const crmGraphqlSchema = buildSchema(` audioUrl: String! duration: String! transcript: [String!]! + deliveryStatus: String } type CalendarEvent {