feat(chat): add outbound delivery statuses to omnichat thread UI
This commit is contained in:
@@ -86,6 +86,7 @@ type CommItem = {
|
|||||||
audioUrl?: string;
|
audioUrl?: string;
|
||||||
duration?: string;
|
duration?: string;
|
||||||
transcript?: string[];
|
transcript?: string[];
|
||||||
|
deliveryStatus?: "PENDING" | "SENT" | "DELIVERED" | "READ" | "FAILED" | string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CommPin = {
|
type CommPin = {
|
||||||
@@ -3941,6 +3942,24 @@ function channelIcon(channel: "All" | CommItem["channel"]) {
|
|||||||
return "phone";
|
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) {
|
function makeId(prefix: string) {
|
||||||
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
|
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
|
||||||
}
|
}
|
||||||
@@ -5599,6 +5618,34 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
|||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<span>{{ formatStamp(entry.item.at) }}</span>
|
<span>{{ formatStamp(entry.item.at) }}</span>
|
||||||
|
<span
|
||||||
|
v-if="messageDeliveryUiState(entry.item) !== 'none'"
|
||||||
|
class="ml-1 inline-flex items-center align-middle text-base-content/70"
|
||||||
|
:title="messageDeliveryLabel(entry.item)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="messageDeliveryUiState(entry.item) === 'sending'"
|
||||||
|
class="inline-block h-2.5 w-2.5 animate-spin rounded-full border border-current border-t-transparent"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-else-if="messageDeliveryUiState(entry.item) === 'sent'"
|
||||||
|
class="text-[10px] leading-none"
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else-if="messageDeliveryUiState(entry.item) === 'delivered'"
|
||||||
|
class="text-[10px] leading-none tracking-[-0.12em]"
|
||||||
|
>
|
||||||
|
✓✓
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else-if="messageDeliveryUiState(entry.item) === 'failed'"
|
||||||
|
class="text-[10px] font-semibold leading-none text-error"
|
||||||
|
>
|
||||||
|
!
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ query DashboardQuery {
|
|||||||
audioUrl
|
audioUrl
|
||||||
duration
|
duration
|
||||||
transcript
|
transcript
|
||||||
|
deliveryStatus
|
||||||
}
|
}
|
||||||
calendar {
|
calendar {
|
||||||
id
|
id
|
||||||
|
|||||||
@@ -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<string, Set<string>>();
|
const channelsByContactId = new Map<string, Set<string>>();
|
||||||
for (const item of communicationsRaw) {
|
for (const item of communicationsRaw) {
|
||||||
if (!channelsByContactId.has(item.contactId)) {
|
if (!channelsByContactId.has(item.contactId)) {
|
||||||
@@ -377,6 +419,50 @@ async function getDashboard(auth: AuthContext | null) {
|
|||||||
description: c.note?.content ?? "",
|
description: c.note?.content ?? "",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const omniByKey = new Map<string, typeof omniMessagesRaw>();
|
||||||
|
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<string>();
|
||||||
|
|
||||||
|
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) => ({
|
const communications = communicationsRaw.map((m) => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
at: m.occurredAt.toISOString(),
|
at: m.occurredAt.toISOString(),
|
||||||
@@ -389,6 +475,7 @@ async function getDashboard(auth: AuthContext | null) {
|
|||||||
audioUrl: "",
|
audioUrl: "",
|
||||||
duration: m.durationSec ? new Date(m.durationSec * 1000).toISOString().slice(14, 19) : "",
|
duration: m.durationSec ? new Date(m.durationSec * 1000).toISOString().slice(14, 19) : "",
|
||||||
transcript: Array.isArray(m.transcriptJson) ? ((m.transcriptJson as any) as string[]) : [],
|
transcript: Array.isArray(m.transcriptJson) ? ((m.transcriptJson as any) as string[]) : [],
|
||||||
|
deliveryStatus: resolveDeliveryStatus(m),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const calendar = calendarRaw.map((e) => ({
|
const calendar = calendarRaw.map((e) => ({
|
||||||
@@ -1204,6 +1291,7 @@ export const crmGraphqlSchema = buildSchema(`
|
|||||||
audioUrl: String!
|
audioUrl: String!
|
||||||
duration: String!
|
duration: String!
|
||||||
transcript: [String!]!
|
transcript: [String!]!
|
||||||
|
deliveryStatus: String
|
||||||
}
|
}
|
||||||
|
|
||||||
type CalendarEvent {
|
type CalendarEvent {
|
||||||
|
|||||||
Reference in New Issue
Block a user