feat: add unified client timeline query

This commit is contained in:
Ruslan Bakiev
2026-02-23 10:48:21 +07:00
parent c9e4c3172e
commit 4b9682e447
5 changed files with 660 additions and 54 deletions

View File

@@ -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,
const centeredRows: Array< item: entry.message,
| { };
id: string;
at: string;
kind: "eventLifecycle";
event: CalendarEvent;
phase: EventLifecyclePhase;
}
| {
id: string;
at: string;
kind: "recommendation";
card: FeedCard;
}
> = [];
for (const entry of selectedCommLifecycleEvents.value) {
centeredRows.push({
id: `event-${entry.event.id}`,
at: entry.timelineAt,
kind: "eventLifecycle",
event: entry.event,
phase: entry.phase,
});
} }
if (selectedThreadRecommendation.value) { if (entry.contentType === "calendar_event" && entry.calendarEvent) {
centeredRows.push({ const phase = eventLifecyclePhase(entry.calendarEvent, lifecycleNowMs.value);
id: `rec-${selectedThreadRecommendation.value.id}`, return {
at: selectedThreadRecommendation.value.at, id: entry.id,
kind: "recommendation", at: entry.datetime,
card: selectedThreadRecommendation.value, kind: "eventLifecycle" as const,
}); event: entry.calendarEvent,
phase,
};
} }
return [...messageRows, ...centeredRows].sort((a, b) => a.at.localeCompare(b.at)); if (entry.contentType === "recommendation" && entry.recommendation) {
return {
id: entry.id,
at: entry.datetime,
kind: "recommendation" as const,
card: entry.recommendation,
};
}
if (entry.contentType === "document" && entry.document) {
return {
id: entry.id,
at: entry.datetime,
kind: "document" as const,
document: entry.document,
};
}
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>

View 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
}
}
}

View File

@@ -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;

View File

@@ -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])
}

View File

@@ -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),