feat: add unified client timeline query
This commit is contained in:
@@ -3,6 +3,7 @@ import { nextTick, onBeforeUnmount, onMounted } from "vue";
|
|||||||
import meQuery from "~~/graphql/operations/me.graphql?raw";
|
import meQuery from "~~/graphql/operations/me.graphql?raw";
|
||||||
import chatMessagesQuery from "~~/graphql/operations/chat-messages.graphql?raw";
|
import chatMessagesQuery from "~~/graphql/operations/chat-messages.graphql?raw";
|
||||||
import dashboardQuery from "~~/graphql/operations/dashboard.graphql?raw";
|
import dashboardQuery from "~~/graphql/operations/dashboard.graphql?raw";
|
||||||
|
import getClientTimelineQuery from "~~/graphql/operations/get-client-timeline.graphql?raw";
|
||||||
import loginMutation from "~~/graphql/operations/login.graphql?raw";
|
import loginMutation from "~~/graphql/operations/login.graphql?raw";
|
||||||
import logoutMutation from "~~/graphql/operations/logout.graphql?raw";
|
import logoutMutation from "~~/graphql/operations/logout.graphql?raw";
|
||||||
import logPilotNoteMutation from "~~/graphql/operations/log-pilot-note.graphql?raw";
|
import logPilotNoteMutation from "~~/graphql/operations/log-pilot-note.graphql?raw";
|
||||||
@@ -145,6 +146,18 @@ type WorkspaceDocument = {
|
|||||||
body: string;
|
body: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ClientTimelineItem = {
|
||||||
|
id: string;
|
||||||
|
contactId: string;
|
||||||
|
contentType: "message" | "calendar_event" | "document" | "recommendation" | string;
|
||||||
|
contentId: string;
|
||||||
|
datetime: string;
|
||||||
|
message?: CommItem | null;
|
||||||
|
calendarEvent?: CalendarEvent | null;
|
||||||
|
recommendation?: FeedCard | null;
|
||||||
|
document?: WorkspaceDocument | null;
|
||||||
|
};
|
||||||
|
|
||||||
const selectedTab = ref<TabId>("communications");
|
const selectedTab = ref<TabId>("communications");
|
||||||
const peopleLeftMode = ref<PeopleLeftMode>("contacts");
|
const peopleLeftMode = ref<PeopleLeftMode>("contacts");
|
||||||
|
|
||||||
@@ -319,6 +332,7 @@ const calendarEvents = ref<CalendarEvent[]>([]);
|
|||||||
|
|
||||||
const commItems = ref<CommItem[]>([]);
|
const commItems = ref<CommItem[]>([]);
|
||||||
const contactInboxes = ref<ContactInbox[]>([]);
|
const contactInboxes = ref<ContactInbox[]>([]);
|
||||||
|
const clientTimelineItems = ref<ClientTimelineItem[]>([]);
|
||||||
|
|
||||||
const commPins = ref<CommPin[]>([]);
|
const commPins = ref<CommPin[]>([]);
|
||||||
|
|
||||||
@@ -516,6 +530,7 @@ let crmRealtimeReconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|||||||
let crmRealtimeRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
let crmRealtimeRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let crmRealtimeRefreshInFlight = false;
|
let crmRealtimeRefreshInFlight = false;
|
||||||
let crmRealtimeReconnectAttempt = 0;
|
let crmRealtimeReconnectAttempt = 0;
|
||||||
|
let clientTimelineRequestToken = 0;
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => pilotLiveLogs.value.length,
|
() => pilotLiveLogs.value.length,
|
||||||
@@ -873,6 +888,7 @@ async function bootstrapSession() {
|
|||||||
stopCrmRealtime();
|
stopCrmRealtime();
|
||||||
pilotMessages.value = [];
|
pilotMessages.value = [];
|
||||||
chatConversations.value = [];
|
chatConversations.value = [];
|
||||||
|
clientTimelineItems.value = [];
|
||||||
telegramConnectStatus.value = "not_connected";
|
telegramConnectStatus.value = "not_connected";
|
||||||
telegramConnections.value = [];
|
telegramConnections.value = [];
|
||||||
telegramConnectUrl.value = "";
|
telegramConnectUrl.value = "";
|
||||||
@@ -887,6 +903,7 @@ async function bootstrapSession() {
|
|||||||
authMe.value = null;
|
authMe.value = null;
|
||||||
pilotMessages.value = [];
|
pilotMessages.value = [];
|
||||||
chatConversations.value = [];
|
chatConversations.value = [];
|
||||||
|
clientTimelineItems.value = [];
|
||||||
telegramConnectStatus.value = "not_connected";
|
telegramConnectStatus.value = "not_connected";
|
||||||
telegramConnections.value = [];
|
telegramConnections.value = [];
|
||||||
telegramConnectUrl.value = "";
|
telegramConnectUrl.value = "";
|
||||||
@@ -959,6 +976,7 @@ async function logout() {
|
|||||||
livePilotAssistantText.value = "";
|
livePilotAssistantText.value = "";
|
||||||
pilotChat.messages = [];
|
pilotChat.messages = [];
|
||||||
chatConversations.value = [];
|
chatConversations.value = [];
|
||||||
|
clientTimelineItems.value = [];
|
||||||
telegramConnectStatus.value = "not_connected";
|
telegramConnectStatus.value = "not_connected";
|
||||||
telegramConnections.value = [];
|
telegramConnections.value = [];
|
||||||
telegramConnectUrl.value = "";
|
telegramConnectUrl.value = "";
|
||||||
@@ -997,6 +1015,33 @@ async function refreshCrmData() {
|
|||||||
...c,
|
...c,
|
||||||
channels: Array.from(byName.get(c.name) ?? []),
|
channels: Array.from(byName.get(c.name) ?? []),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
await refreshSelectedClientTimeline();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadClientTimeline(contactId: string, limit = 500) {
|
||||||
|
const normalizedContactId = String(contactId ?? "").trim();
|
||||||
|
if (!normalizedContactId) {
|
||||||
|
clientTimelineItems.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestToken = ++clientTimelineRequestToken;
|
||||||
|
const data = await gqlFetch<{ getClientTimeline: ClientTimelineItem[] }>(getClientTimelineQuery, {
|
||||||
|
contactId: normalizedContactId,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
if (requestToken !== clientTimelineRequestToken) return;
|
||||||
|
clientTimelineItems.value = data.getClientTimeline ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSelectedClientTimeline() {
|
||||||
|
const contactId = String(selectedCommThreadId.value ?? "").trim();
|
||||||
|
if (!contactId) {
|
||||||
|
clientTimelineItems.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadClientTimeline(contactId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearCrmRealtimeReconnectTimer() {
|
function clearCrmRealtimeReconnectTimer() {
|
||||||
@@ -3487,8 +3532,14 @@ watch(selectedCommThreadId, () => {
|
|||||||
eventArchiveRecordingById.value = {};
|
eventArchiveRecordingById.value = {};
|
||||||
eventArchiveTranscribingById.value = {};
|
eventArchiveTranscribingById.value = {};
|
||||||
eventArchiveMicErrorById.value = {};
|
eventArchiveMicErrorById.value = {};
|
||||||
|
clientTimelineRequestToken += 1;
|
||||||
const preferred = selectedCommThread.value?.channels.find((channel) => channel !== "Phone") ?? "";
|
const preferred = selectedCommThread.value?.channels.find((channel) => channel !== "Phone") ?? "";
|
||||||
commSendChannel.value = preferred;
|
commSendChannel.value = preferred;
|
||||||
|
if (!selectedCommThread.value) {
|
||||||
|
clientTimelineItems.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void refreshSelectedClientTimeline().catch(() => undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
const commSendChannelOptions = computed<CommItem["channel"][]>(() => {
|
const commSendChannelOptions = computed<CommItem["channel"][]>(() => {
|
||||||
@@ -3502,14 +3553,6 @@ const visibleThreadItems = computed(() => {
|
|||||||
return selectedCommThread.value.items;
|
return selectedCommThread.value.items;
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedThreadRecommendation = computed(() => {
|
|
||||||
if (!selectedCommThread.value) return null;
|
|
||||||
const cards = feedCards.value
|
|
||||||
.filter((card) => card.contact === selectedCommThread.value?.contact)
|
|
||||||
.sort((a, b) => a.at.localeCompare(b.at));
|
|
||||||
return cards[cards.length - 1] ?? null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectedCommPins = computed(() => {
|
const selectedCommPins = computed(() => {
|
||||||
if (!selectedCommThread.value) return [];
|
if (!selectedCommThread.value) return [];
|
||||||
return commPins.value.filter((item) => item.contact === selectedCommThread.value?.contact);
|
return commPins.value.filter((item) => item.contact === selectedCommThread.value?.contact);
|
||||||
@@ -3536,67 +3579,68 @@ const hiddenContactInboxes = computed(() =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const selectedCommLifecycleEvents = computed(() => {
|
const selectedCommLifecycleEvents = computed(() => {
|
||||||
if (!selectedCommThread.value) return [];
|
|
||||||
const nowMs = lifecycleNowMs.value;
|
const nowMs = lifecycleNowMs.value;
|
||||||
|
|
||||||
return sortedEvents.value
|
return clientTimelineItems.value
|
||||||
.filter((event) => event.contact === selectedCommThread.value?.contact)
|
.filter((entry) => entry.contentType === "calendar_event" && entry.calendarEvent)
|
||||||
.map((event) => {
|
.map((entry) => {
|
||||||
|
const event = entry.calendarEvent as CalendarEvent;
|
||||||
const phase = eventLifecyclePhase(event, nowMs);
|
const phase = eventLifecyclePhase(event, nowMs);
|
||||||
return {
|
return {
|
||||||
event,
|
event,
|
||||||
phase,
|
phase,
|
||||||
timelineAt: eventTimelineAt(event, phase),
|
timelineAt: entry.datetime,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((a, b) => a.timelineAt.localeCompare(b.timelineAt))
|
.sort((a, b) => a.timelineAt.localeCompare(b.timelineAt));
|
||||||
.slice(-12);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const threadStreamItems = computed(() => {
|
const threadStreamItems = computed(() => {
|
||||||
const messageRows = visibleThreadItems.value.map((item) => ({
|
const rows = clientTimelineItems.value
|
||||||
id: `comm-${item.id}`,
|
.map((entry) => {
|
||||||
at: item.at,
|
if (entry.contentType === "message" && entry.message) {
|
||||||
kind: item.kind,
|
return {
|
||||||
item,
|
id: entry.id,
|
||||||
})).sort((a, b) => a.at.localeCompare(b.at));
|
at: entry.datetime,
|
||||||
|
kind: entry.message.kind,
|
||||||
|
item: entry.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const centeredRows: Array<
|
if (entry.contentType === "calendar_event" && entry.calendarEvent) {
|
||||||
| {
|
const phase = eventLifecyclePhase(entry.calendarEvent, lifecycleNowMs.value);
|
||||||
id: string;
|
return {
|
||||||
at: string;
|
id: entry.id,
|
||||||
kind: "eventLifecycle";
|
at: entry.datetime,
|
||||||
event: CalendarEvent;
|
kind: "eventLifecycle" as const,
|
||||||
phase: EventLifecyclePhase;
|
event: entry.calendarEvent,
|
||||||
}
|
phase,
|
||||||
| {
|
};
|
||||||
id: string;
|
}
|
||||||
at: string;
|
|
||||||
kind: "recommendation";
|
|
||||||
card: FeedCard;
|
|
||||||
}
|
|
||||||
> = [];
|
|
||||||
|
|
||||||
for (const entry of selectedCommLifecycleEvents.value) {
|
if (entry.contentType === "recommendation" && entry.recommendation) {
|
||||||
centeredRows.push({
|
return {
|
||||||
id: `event-${entry.event.id}`,
|
id: entry.id,
|
||||||
at: entry.timelineAt,
|
at: entry.datetime,
|
||||||
kind: "eventLifecycle",
|
kind: "recommendation" as const,
|
||||||
event: entry.event,
|
card: entry.recommendation,
|
||||||
phase: entry.phase,
|
};
|
||||||
});
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedThreadRecommendation.value) {
|
if (entry.contentType === "document" && entry.document) {
|
||||||
centeredRows.push({
|
return {
|
||||||
id: `rec-${selectedThreadRecommendation.value.id}`,
|
id: entry.id,
|
||||||
at: selectedThreadRecommendation.value.at,
|
at: entry.datetime,
|
||||||
kind: "recommendation",
|
kind: "document" as const,
|
||||||
card: selectedThreadRecommendation.value,
|
document: entry.document,
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...messageRows, ...centeredRows].sort((a, b) => a.at.localeCompare(b.at));
|
return null;
|
||||||
|
})
|
||||||
|
.filter((entry) => entry !== null) as Array<any>;
|
||||||
|
|
||||||
|
return rows.sort((a, b) => a.at.localeCompare(b.at));
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -5979,6 +6023,17 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="entry.kind === 'document'" class="flex justify-center">
|
||||||
|
<article class="w-full max-w-[460px] rounded-xl border border-base-300 bg-base-100 p-3 text-left">
|
||||||
|
<p class="text-xs text-base-content/65">Document · {{ formatStamp(entry.at) }}</p>
|
||||||
|
<p class="mt-1 text-sm font-semibold text-base-content">{{ entry.document.title }}</p>
|
||||||
|
<p class="mt-1 text-xs text-base-content/70">
|
||||||
|
{{ formatDocumentScope(entry.document.scope) }} · {{ entry.document.owner }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-sm text-base-content/85">{{ entry.document.summary }}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-else-if="entry.kind === 'recommendation'" class="flex justify-center">
|
<div v-else-if="entry.kind === 'recommendation'" class="flex justify-center">
|
||||||
<article class="w-full max-w-[460px] rounded-xl border border-base-300 bg-base-100 p-3">
|
<article class="w-full max-w-[460px] rounded-xl border border-base-300 bg-base-100 p-3">
|
||||||
<p class="text-sm">{{ entry.card.text }}</p>
|
<p class="text-sm">{{ entry.card.text }}</p>
|
||||||
|
|||||||
61
frontend/graphql/operations/get-client-timeline.graphql
Normal file
61
frontend/graphql/operations/get-client-timeline.graphql
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
query GetClientTimelineQuery($contactId: ID!, $limit: Int) {
|
||||||
|
getClientTimeline(contactId: $contactId, limit: $limit) {
|
||||||
|
id
|
||||||
|
contactId
|
||||||
|
contentType
|
||||||
|
contentId
|
||||||
|
datetime
|
||||||
|
message {
|
||||||
|
id
|
||||||
|
at
|
||||||
|
contactId
|
||||||
|
contact
|
||||||
|
contactInboxId
|
||||||
|
sourceExternalId
|
||||||
|
sourceTitle
|
||||||
|
channel
|
||||||
|
kind
|
||||||
|
direction
|
||||||
|
text
|
||||||
|
audioUrl
|
||||||
|
duration
|
||||||
|
transcript
|
||||||
|
deliveryStatus
|
||||||
|
}
|
||||||
|
calendarEvent {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
start
|
||||||
|
end
|
||||||
|
contact
|
||||||
|
note
|
||||||
|
isArchived
|
||||||
|
createdAt
|
||||||
|
archiveNote
|
||||||
|
archivedAt
|
||||||
|
}
|
||||||
|
recommendation {
|
||||||
|
id
|
||||||
|
at
|
||||||
|
contact
|
||||||
|
text
|
||||||
|
proposal {
|
||||||
|
title
|
||||||
|
details
|
||||||
|
key
|
||||||
|
}
|
||||||
|
decision
|
||||||
|
decisionNote
|
||||||
|
}
|
||||||
|
document {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
type
|
||||||
|
owner
|
||||||
|
scope
|
||||||
|
updatedAt
|
||||||
|
summary
|
||||||
|
body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ClientTimelineContentType" AS ENUM ('CALENDAR_EVENT', 'DOCUMENT', 'RECOMMENDATION');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ClientTimelineEntry" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"teamId" TEXT NOT NULL,
|
||||||
|
"contactId" TEXT NOT NULL,
|
||||||
|
"contentType" "ClientTimelineContentType" NOT NULL,
|
||||||
|
"contentId" TEXT NOT NULL,
|
||||||
|
"datetime" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ClientTimelineEntry_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ClientTimelineEntry_teamId_contactId_datetime_idx" ON "ClientTimelineEntry"("teamId", "contactId", "datetime");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ClientTimelineEntry_contactId_datetime_idx" ON "ClientTimelineEntry"("contactId", "datetime");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ClientTimelineEntry_teamId_contentType_contentId_key" ON "ClientTimelineEntry"("teamId", "contentType", "contentId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ClientTimelineEntry" ADD CONSTRAINT "ClientTimelineEntry_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ClientTimelineEntry" ADD CONSTRAINT "ClientTimelineEntry_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "Contact"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -58,6 +58,12 @@ enum WorkspaceDocumentType {
|
|||||||
Template
|
Template
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ClientTimelineContentType {
|
||||||
|
CALENDAR_EVENT
|
||||||
|
DOCUMENT
|
||||||
|
RECOMMENDATION
|
||||||
|
}
|
||||||
|
|
||||||
model Team {
|
model Team {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
@@ -79,6 +85,7 @@ model Team {
|
|||||||
feedCards FeedCard[]
|
feedCards FeedCard[]
|
||||||
contactPins ContactPin[]
|
contactPins ContactPin[]
|
||||||
documents WorkspaceDocument[]
|
documents WorkspaceDocument[]
|
||||||
|
clientTimelineEntries ClientTimelineEntry[]
|
||||||
contactInboxes ContactInbox[]
|
contactInboxes ContactInbox[]
|
||||||
contactInboxPreferences ContactInboxPreference[]
|
contactInboxPreferences ContactInboxPreference[]
|
||||||
}
|
}
|
||||||
@@ -137,6 +144,7 @@ model Contact {
|
|||||||
omniMessages OmniMessage[]
|
omniMessages OmniMessage[]
|
||||||
omniIdentities OmniContactIdentity[]
|
omniIdentities OmniContactIdentity[]
|
||||||
contactInboxes ContactInbox[]
|
contactInboxes ContactInbox[]
|
||||||
|
clientTimelineEntries ClientTimelineEntry[]
|
||||||
|
|
||||||
@@index([teamId, updatedAt])
|
@@index([teamId, updatedAt])
|
||||||
}
|
}
|
||||||
@@ -436,3 +444,21 @@ model WorkspaceDocument {
|
|||||||
|
|
||||||
@@index([teamId, updatedAt])
|
@@index([teamId, updatedAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model ClientTimelineEntry {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
teamId String
|
||||||
|
contactId String
|
||||||
|
contentType ClientTimelineContentType
|
||||||
|
contentId String
|
||||||
|
datetime DateTime @default(now())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||||
|
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([teamId, contentType, contentId])
|
||||||
|
@@index([teamId, contactId, datetime])
|
||||||
|
@@index([contactId, datetime])
|
||||||
|
}
|
||||||
|
|||||||
@@ -64,6 +64,67 @@ function extractOmniNormalizedText(rawJson: unknown, fallbackText = "") {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ClientTimelineContentType = "CALENDAR_EVENT" | "DOCUMENT" | "RECOMMENDATION";
|
||||||
|
|
||||||
|
const CONTACT_DOCUMENT_SCOPE_PREFIX = "contact:";
|
||||||
|
|
||||||
|
function mapTimelineContentType(value: ClientTimelineContentType) {
|
||||||
|
if (value === "CALENDAR_EVENT") return "calendar_event";
|
||||||
|
if (value === "DOCUMENT") return "document";
|
||||||
|
return "recommendation";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseContactDocumentScope(scopeInput: string) {
|
||||||
|
const raw = String(scopeInput ?? "").trim();
|
||||||
|
if (!raw.startsWith(CONTACT_DOCUMENT_SCOPE_PREFIX)) return null;
|
||||||
|
|
||||||
|
const payload = raw.slice(CONTACT_DOCUMENT_SCOPE_PREFIX.length);
|
||||||
|
const [idRaw, ...nameParts] = payload.split(":");
|
||||||
|
const contactId = decodeURIComponent(idRaw ?? "").trim();
|
||||||
|
const contactName = decodeURIComponent(nameParts.join(":") ?? "").trim();
|
||||||
|
if (!contactId) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
contactId,
|
||||||
|
contactName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertClientTimelineEntry(input: {
|
||||||
|
teamId: string;
|
||||||
|
contactId: string;
|
||||||
|
contentType: ClientTimelineContentType;
|
||||||
|
contentId: string;
|
||||||
|
datetime?: Date;
|
||||||
|
}) {
|
||||||
|
const datetime =
|
||||||
|
input.datetime && !Number.isNaN(input.datetime.getTime())
|
||||||
|
? input.datetime
|
||||||
|
: new Date();
|
||||||
|
|
||||||
|
return prisma.clientTimelineEntry.upsert({
|
||||||
|
where: {
|
||||||
|
teamId_contentType_contentId: {
|
||||||
|
teamId: input.teamId,
|
||||||
|
contentType: input.contentType,
|
||||||
|
contentId: input.contentId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
teamId: input.teamId,
|
||||||
|
contactId: input.contactId,
|
||||||
|
contentType: input.contentType,
|
||||||
|
contentId: input.contentId,
|
||||||
|
datetime,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
contactId: input.contactId,
|
||||||
|
datetime,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeSourceExternalId(channel: string, sourceExternalId: string | null | undefined) {
|
function normalizeSourceExternalId(channel: string, sourceExternalId: string | null | undefined) {
|
||||||
const raw = String(sourceExternalId ?? "").trim();
|
const raw = String(sourceExternalId ?? "").trim();
|
||||||
if (raw) return raw;
|
if (raw) return raw;
|
||||||
@@ -697,6 +758,319 @@ async function getDashboard(auth: AuthContext | null) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getClientTimeline(auth: AuthContext | null, contactIdInput: string, limitInput?: number) {
|
||||||
|
const ctx = requireAuth(auth);
|
||||||
|
const contactId = String(contactIdInput ?? "").trim();
|
||||||
|
if (!contactId) throw new Error("contactId is required");
|
||||||
|
|
||||||
|
const contact = await prisma.contact.findFirst({
|
||||||
|
where: {
|
||||||
|
id: contactId,
|
||||||
|
teamId: ctx.teamId,
|
||||||
|
},
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
if (!contact) throw new Error("contact not found");
|
||||||
|
|
||||||
|
const limitRaw = Number(limitInput ?? 400);
|
||||||
|
const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(2000, Math.trunc(limitRaw))) : 400;
|
||||||
|
|
||||||
|
const hiddenPrefRows = await prisma.contactInboxPreference.findMany({
|
||||||
|
where: {
|
||||||
|
teamId: ctx.teamId,
|
||||||
|
userId: ctx.userId,
|
||||||
|
isHidden: true,
|
||||||
|
},
|
||||||
|
select: { contactInboxId: true },
|
||||||
|
});
|
||||||
|
const hiddenInboxIds = hiddenPrefRows.map((row) => row.contactInboxId);
|
||||||
|
const messageWhere = visibleMessageWhere(hiddenInboxIds);
|
||||||
|
|
||||||
|
const [messagesRawDesc, timelineRowsDesc] = await Promise.all([
|
||||||
|
prisma.contactMessage.findMany({
|
||||||
|
where: {
|
||||||
|
contactId: contact.id,
|
||||||
|
contact: { teamId: ctx.teamId },
|
||||||
|
...(messageWhere ?? {}),
|
||||||
|
},
|
||||||
|
orderBy: [{ occurredAt: "desc" }, { createdAt: "desc" }],
|
||||||
|
take: limit,
|
||||||
|
include: {
|
||||||
|
contactInbox: { select: { id: true, sourceExternalId: true, title: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.clientTimelineEntry.findMany({
|
||||||
|
where: {
|
||||||
|
teamId: ctx.teamId,
|
||||||
|
contactId: contact.id,
|
||||||
|
},
|
||||||
|
orderBy: [{ datetime: "desc" }, { createdAt: "desc" }],
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const messagesRaw = [...messagesRawDesc].reverse();
|
||||||
|
const timelineRows = [...timelineRowsDesc].reverse();
|
||||||
|
|
||||||
|
let omniMessagesRaw: Array<{
|
||||||
|
id: string;
|
||||||
|
contactId: string;
|
||||||
|
channel: string;
|
||||||
|
direction: string;
|
||||||
|
text: string;
|
||||||
|
rawJson: unknown;
|
||||||
|
status: string;
|
||||||
|
occurredAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
if (messagesRaw.length) {
|
||||||
|
const minOccurredAt = messagesRaw[0]?.occurredAt ?? new Date();
|
||||||
|
const maxOccurredAt = messagesRaw[messagesRaw.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: contact.id,
|
||||||
|
occurredAt: {
|
||||||
|
gte: fromOccurredAt,
|
||||||
|
lte: toOccurredAt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
contactId: true,
|
||||||
|
channel: true,
|
||||||
|
direction: true,
|
||||||
|
text: true,
|
||||||
|
rawJson: true,
|
||||||
|
status: true,
|
||||||
|
occurredAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
orderBy: [{ occurredAt: "asc" }, { updatedAt: "asc" }],
|
||||||
|
take: Math.max(limit * 2, 300),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const omniByKey = new Map<string, typeof omniMessagesRaw>();
|
||||||
|
for (const row of omniMessagesRaw) {
|
||||||
|
const normalizedText = extractOmniNormalizedText(row.rawJson, row.text);
|
||||||
|
const key = [row.contactId, row.channel, row.direction, normalizedText].join("|");
|
||||||
|
if (!omniByKey.has(key)) omniByKey.set(key, []);
|
||||||
|
omniByKey.get(key)?.push(row);
|
||||||
|
}
|
||||||
|
const consumedOmniMessageIds = new Set<string>();
|
||||||
|
|
||||||
|
const resolveDeliveryStatus = (m: (typeof messagesRaw)[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 messageItems = messagesRaw.map((m) => ({
|
||||||
|
id: `message-${m.id}`,
|
||||||
|
contactId: contact.id,
|
||||||
|
contentType: "message",
|
||||||
|
contentId: m.id,
|
||||||
|
datetime: m.occurredAt.toISOString(),
|
||||||
|
message: {
|
||||||
|
id: m.id,
|
||||||
|
at: m.occurredAt.toISOString(),
|
||||||
|
contactId: contact.id,
|
||||||
|
contact: contact.name,
|
||||||
|
contactInboxId: m.contactInboxId ?? "",
|
||||||
|
sourceExternalId: m.contactInbox?.sourceExternalId ?? "",
|
||||||
|
sourceTitle: m.contactInbox?.title ?? "",
|
||||||
|
channel: mapChannel(m.channel),
|
||||||
|
kind: m.kind === "CALL" ? "call" : "message",
|
||||||
|
direction: m.direction === "IN" ? "in" : "out",
|
||||||
|
text: m.content,
|
||||||
|
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 calendarIds: string[] = [];
|
||||||
|
const documentIds: string[] = [];
|
||||||
|
const recommendationIds: string[] = [];
|
||||||
|
|
||||||
|
for (const row of timelineRows) {
|
||||||
|
if (row.contentType === "CALENDAR_EVENT") {
|
||||||
|
calendarIds.push(row.contentId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (row.contentType === "DOCUMENT") {
|
||||||
|
documentIds.push(row.contentId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (row.contentType === "RECOMMENDATION") {
|
||||||
|
recommendationIds.push(row.contentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [calendarRows, documentRows, recommendationRows] = await Promise.all([
|
||||||
|
calendarIds.length
|
||||||
|
? prisma.calendarEvent.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: calendarIds },
|
||||||
|
teamId: ctx.teamId,
|
||||||
|
contactId: contact.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: Promise.resolve([]),
|
||||||
|
documentIds.length
|
||||||
|
? prisma.workspaceDocument.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: documentIds },
|
||||||
|
teamId: ctx.teamId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: Promise.resolve([]),
|
||||||
|
recommendationIds.length
|
||||||
|
? prisma.feedCard.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: recommendationIds },
|
||||||
|
teamId: ctx.teamId,
|
||||||
|
contactId: contact.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const calendarById = new Map(
|
||||||
|
calendarRows.map((row) => [
|
||||||
|
row.id,
|
||||||
|
{
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
start: row.startsAt.toISOString(),
|
||||||
|
end: (row.endsAt ?? row.startsAt).toISOString(),
|
||||||
|
contact: contact.name,
|
||||||
|
note: row.note ?? "",
|
||||||
|
isArchived: Boolean(row.isArchived),
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
archiveNote: row.archiveNote ?? "",
|
||||||
|
archivedAt: row.archivedAt?.toISOString() ?? "",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const documentById = new Map(
|
||||||
|
documentRows.map((row) => [
|
||||||
|
row.id,
|
||||||
|
{
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
type: row.type,
|
||||||
|
owner: row.owner,
|
||||||
|
scope: row.scope,
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
summary: row.summary,
|
||||||
|
body: row.body,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const recommendationById = new Map(
|
||||||
|
recommendationRows.map((row) => [
|
||||||
|
row.id,
|
||||||
|
{
|
||||||
|
id: row.id,
|
||||||
|
at: row.happenedAt.toISOString(),
|
||||||
|
contact: contact.name,
|
||||||
|
text: row.text,
|
||||||
|
proposal: {
|
||||||
|
title: ((row.proposalJson as any)?.title ?? "") as string,
|
||||||
|
details: (Array.isArray((row.proposalJson as any)?.details) ? (row.proposalJson as any).details : []) as string[],
|
||||||
|
key: ((row.proposalJson as any)?.key ?? "") as string,
|
||||||
|
},
|
||||||
|
decision: row.decision === "ACCEPTED" ? "accepted" : row.decision === "REJECTED" ? "rejected" : "pending",
|
||||||
|
decisionNote: row.decisionNote ?? "",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const timelineItems = timelineRows
|
||||||
|
.map((row) => {
|
||||||
|
const base = {
|
||||||
|
id: row.id,
|
||||||
|
contactId: row.contactId,
|
||||||
|
contentType: mapTimelineContentType(row.contentType as ClientTimelineContentType),
|
||||||
|
contentId: row.contentId,
|
||||||
|
datetime: row.datetime.toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (row.contentType === "CALENDAR_EVENT") {
|
||||||
|
const event = calendarById.get(row.contentId);
|
||||||
|
if (!event) return null;
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
calendarEvent: event,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.contentType === "DOCUMENT") {
|
||||||
|
const document = documentById.get(row.contentId);
|
||||||
|
if (!document) return null;
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
document,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.contentType === "RECOMMENDATION") {
|
||||||
|
const recommendation = recommendationById.get(row.contentId);
|
||||||
|
if (!recommendation) return null;
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
recommendation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter((item) => item !== null) as Array<Record<string, unknown> & { datetime: string }>;
|
||||||
|
|
||||||
|
return [...messageItems, ...timelineItems]
|
||||||
|
.sort((a, b) => a.datetime.localeCompare(b.datetime))
|
||||||
|
.slice(-limit);
|
||||||
|
}
|
||||||
|
|
||||||
async function createCalendarEvent(auth: AuthContext | null, input: {
|
async function createCalendarEvent(auth: AuthContext | null, input: {
|
||||||
title: string;
|
title: string;
|
||||||
start: string;
|
start: string;
|
||||||
@@ -740,6 +1114,16 @@ async function createCalendarEvent(auth: AuthContext | null, input: {
|
|||||||
include: { contact: { select: { name: true } } },
|
include: { contact: { select: { name: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (created.contactId) {
|
||||||
|
await upsertClientTimelineEntry({
|
||||||
|
teamId: ctx.teamId,
|
||||||
|
contactId: created.contactId,
|
||||||
|
contentType: "CALENDAR_EVENT",
|
||||||
|
contentId: created.id,
|
||||||
|
datetime: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: created.id,
|
id: created.id,
|
||||||
title: created.title,
|
title: created.title,
|
||||||
@@ -776,6 +1160,16 @@ async function archiveCalendarEvent(auth: AuthContext | null, input: { id: strin
|
|||||||
include: { contact: { select: { name: true } } },
|
include: { contact: { select: { name: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (updated.contactId) {
|
||||||
|
await upsertClientTimelineEntry({
|
||||||
|
teamId: ctx.teamId,
|
||||||
|
contactId: updated.contactId,
|
||||||
|
contentType: "CALENDAR_EVENT",
|
||||||
|
contentId: updated.id,
|
||||||
|
datetime: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: updated.id,
|
id: updated.id,
|
||||||
title: updated.title,
|
title: updated.title,
|
||||||
@@ -961,6 +1355,27 @@ async function createWorkspaceDocument(auth: AuthContext | null, input: {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const linkedScope = parseContactDocumentScope(created.scope);
|
||||||
|
if (linkedScope?.contactId) {
|
||||||
|
const linkedContact = await prisma.contact.findFirst({
|
||||||
|
where: {
|
||||||
|
id: linkedScope.contactId,
|
||||||
|
teamId: ctx.teamId,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (linkedContact) {
|
||||||
|
await upsertClientTimelineEntry({
|
||||||
|
teamId: ctx.teamId,
|
||||||
|
contactId: linkedContact.id,
|
||||||
|
contentType: "DOCUMENT",
|
||||||
|
contentId: created.id,
|
||||||
|
datetime: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: created.id,
|
id: created.id,
|
||||||
title: created.title,
|
title: created.title,
|
||||||
@@ -1351,6 +1766,7 @@ export const crmGraphqlSchema = buildSchema(`
|
|||||||
chatMessages: [PilotMessage!]!
|
chatMessages: [PilotMessage!]!
|
||||||
chatConversations: [Conversation!]!
|
chatConversations: [Conversation!]!
|
||||||
dashboard: DashboardPayload!
|
dashboard: DashboardPayload!
|
||||||
|
getClientTimeline(contactId: ID!, limit: Int): [ClientTimelineItem!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mutation {
|
type Mutation {
|
||||||
@@ -1498,6 +1914,18 @@ export const crmGraphqlSchema = buildSchema(`
|
|||||||
documents: [WorkspaceDocument!]!
|
documents: [WorkspaceDocument!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ClientTimelineItem {
|
||||||
|
id: ID!
|
||||||
|
contactId: String!
|
||||||
|
contentType: String!
|
||||||
|
contentId: String!
|
||||||
|
datetime: String!
|
||||||
|
message: CommItem
|
||||||
|
calendarEvent: CalendarEvent
|
||||||
|
recommendation: FeedCard
|
||||||
|
document: WorkspaceDocument
|
||||||
|
}
|
||||||
|
|
||||||
type Contact {
|
type Contact {
|
||||||
id: ID!
|
id: ID!
|
||||||
name: String!
|
name: String!
|
||||||
@@ -1615,6 +2043,10 @@ export const crmGraphqlRoot = {
|
|||||||
chatMessages: async (_args: unknown, context: GraphQLContext) => getChatMessages(context.auth),
|
chatMessages: async (_args: unknown, context: GraphQLContext) => getChatMessages(context.auth),
|
||||||
chatConversations: async (_args: unknown, context: GraphQLContext) => getChatConversations(context.auth),
|
chatConversations: async (_args: unknown, context: GraphQLContext) => getChatConversations(context.auth),
|
||||||
dashboard: async (_args: unknown, context: GraphQLContext) => getDashboard(context.auth),
|
dashboard: async (_args: unknown, context: GraphQLContext) => getDashboard(context.auth),
|
||||||
|
getClientTimeline: async (
|
||||||
|
args: { contactId: string; limit?: number },
|
||||||
|
context: GraphQLContext,
|
||||||
|
) => getClientTimeline(context.auth, args.contactId, args.limit),
|
||||||
|
|
||||||
login: async (args: { phone: string; password: string }, context: GraphQLContext) =>
|
login: async (args: { phone: string; password: string }, context: GraphQLContext) =>
|
||||||
loginWithPassword(context.event, args.phone, args.password),
|
loginWithPassword(context.event, args.phone, args.password),
|
||||||
|
|||||||
Reference in New Issue
Block a user