diff --git a/frontend/app/pages/index.vue b/frontend/app/pages/index.vue index 1bf839e..841ed7e 100644 --- a/frontend/app/pages/index.vue +++ b/frontend/app/pages/index.vue @@ -3,6 +3,7 @@ import { nextTick, onBeforeUnmount, onMounted } from "vue"; import meQuery from "~~/graphql/operations/me.graphql?raw"; import chatMessagesQuery from "~~/graphql/operations/chat-messages.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 logoutMutation from "~~/graphql/operations/logout.graphql?raw"; import logPilotNoteMutation from "~~/graphql/operations/log-pilot-note.graphql?raw"; @@ -145,6 +146,18 @@ type WorkspaceDocument = { 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("communications"); const peopleLeftMode = ref("contacts"); @@ -319,6 +332,7 @@ const calendarEvents = ref([]); const commItems = ref([]); const contactInboxes = ref([]); +const clientTimelineItems = ref([]); const commPins = ref([]); @@ -516,6 +530,7 @@ let crmRealtimeReconnectTimer: ReturnType | null = null; let crmRealtimeRefreshTimer: ReturnType | null = null; let crmRealtimeRefreshInFlight = false; let crmRealtimeReconnectAttempt = 0; +let clientTimelineRequestToken = 0; watch( () => pilotLiveLogs.value.length, @@ -873,6 +888,7 @@ async function bootstrapSession() { stopCrmRealtime(); pilotMessages.value = []; chatConversations.value = []; + clientTimelineItems.value = []; telegramConnectStatus.value = "not_connected"; telegramConnections.value = []; telegramConnectUrl.value = ""; @@ -887,6 +903,7 @@ async function bootstrapSession() { authMe.value = null; pilotMessages.value = []; chatConversations.value = []; + clientTimelineItems.value = []; telegramConnectStatus.value = "not_connected"; telegramConnections.value = []; telegramConnectUrl.value = ""; @@ -959,6 +976,7 @@ async function logout() { livePilotAssistantText.value = ""; pilotChat.messages = []; chatConversations.value = []; + clientTimelineItems.value = []; telegramConnectStatus.value = "not_connected"; telegramConnections.value = []; telegramConnectUrl.value = ""; @@ -997,6 +1015,33 @@ async function refreshCrmData() { ...c, 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() { @@ -3487,8 +3532,14 @@ watch(selectedCommThreadId, () => { eventArchiveRecordingById.value = {}; eventArchiveTranscribingById.value = {}; eventArchiveMicErrorById.value = {}; + clientTimelineRequestToken += 1; const preferred = selectedCommThread.value?.channels.find((channel) => channel !== "Phone") ?? ""; commSendChannel.value = preferred; + if (!selectedCommThread.value) { + clientTimelineItems.value = []; + return; + } + void refreshSelectedClientTimeline().catch(() => undefined); }); const commSendChannelOptions = computed(() => { @@ -3502,14 +3553,6 @@ const visibleThreadItems = computed(() => { 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(() => { if (!selectedCommThread.value) return []; return commPins.value.filter((item) => item.contact === selectedCommThread.value?.contact); @@ -3536,67 +3579,68 @@ const hiddenContactInboxes = computed(() => ); const selectedCommLifecycleEvents = computed(() => { - if (!selectedCommThread.value) return []; const nowMs = lifecycleNowMs.value; - return sortedEvents.value - .filter((event) => event.contact === selectedCommThread.value?.contact) - .map((event) => { + return clientTimelineItems.value + .filter((entry) => entry.contentType === "calendar_event" && entry.calendarEvent) + .map((entry) => { + const event = entry.calendarEvent as CalendarEvent; const phase = eventLifecyclePhase(event, nowMs); return { event, phase, - timelineAt: eventTimelineAt(event, phase), + timelineAt: entry.datetime, }; }) - .sort((a, b) => a.timelineAt.localeCompare(b.timelineAt)) - .slice(-12); + .sort((a, b) => a.timelineAt.localeCompare(b.timelineAt)); }); const threadStreamItems = computed(() => { - const messageRows = visibleThreadItems.value.map((item) => ({ - id: `comm-${item.id}`, - at: item.at, - kind: item.kind, - item, - })).sort((a, b) => a.at.localeCompare(b.at)); + const rows = clientTimelineItems.value + .map((entry) => { + if (entry.contentType === "message" && entry.message) { + return { + id: entry.id, + at: entry.datetime, + kind: entry.message.kind, + item: entry.message, + }; + } - const centeredRows: Array< - | { - id: string; - at: string; - kind: "eventLifecycle"; - event: CalendarEvent; - phase: EventLifecyclePhase; - } - | { - id: string; - at: string; - kind: "recommendation"; - card: FeedCard; - } - > = []; + if (entry.contentType === "calendar_event" && entry.calendarEvent) { + const phase = eventLifecyclePhase(entry.calendarEvent, lifecycleNowMs.value); + return { + id: entry.id, + at: entry.datetime, + kind: "eventLifecycle" as const, + event: entry.calendarEvent, + phase, + }; + } - 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 (entry.contentType === "recommendation" && entry.recommendation) { + return { + id: entry.id, + at: entry.datetime, + kind: "recommendation" as const, + card: entry.recommendation, + }; + } - if (selectedThreadRecommendation.value) { - centeredRows.push({ - id: `rec-${selectedThreadRecommendation.value.id}`, - at: selectedThreadRecommendation.value.at, - kind: "recommendation", - card: selectedThreadRecommendation.value, - }); - } + if (entry.contentType === "document" && entry.document) { + return { + id: entry.id, + at: entry.datetime, + kind: "document" as const, + document: entry.document, + }; + } - return [...messageRows, ...centeredRows].sort((a, b) => a.at.localeCompare(b.at)); + return null; + }) + .filter((entry) => entry !== null) as Array; + + return rows.sort((a, b) => a.at.localeCompare(b.at)); }); watch( @@ -5979,6 +6023,17 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") +
+
+

Document · {{ formatStamp(entry.at) }}

+

{{ entry.document.title }}

+

+ {{ formatDocumentScope(entry.document.scope) }} · {{ entry.document.owner }} +

+

{{ entry.document.summary }}

+
+
+

{{ entry.card.text }}

diff --git a/frontend/graphql/operations/get-client-timeline.graphql b/frontend/graphql/operations/get-client-timeline.graphql new file mode 100644 index 0000000..359b306 --- /dev/null +++ b/frontend/graphql/operations/get-client-timeline.graphql @@ -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 + } + } +} diff --git a/frontend/prisma/migrations/2_client_timeline_entries/migration.sql b/frontend/prisma/migrations/2_client_timeline_entries/migration.sql new file mode 100644 index 0000000..919a4a2 --- /dev/null +++ b/frontend/prisma/migrations/2_client_timeline_entries/migration.sql @@ -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; + diff --git a/frontend/prisma/schema.prisma b/frontend/prisma/schema.prisma index daab2a8..fcb993c 100644 --- a/frontend/prisma/schema.prisma +++ b/frontend/prisma/schema.prisma @@ -58,6 +58,12 @@ enum WorkspaceDocumentType { Template } +enum ClientTimelineContentType { + CALENDAR_EVENT + DOCUMENT + RECOMMENDATION +} + model Team { id String @id @default(cuid()) name String @@ -79,6 +85,7 @@ model Team { feedCards FeedCard[] contactPins ContactPin[] documents WorkspaceDocument[] + clientTimelineEntries ClientTimelineEntry[] contactInboxes ContactInbox[] contactInboxPreferences ContactInboxPreference[] } @@ -137,6 +144,7 @@ model Contact { omniMessages OmniMessage[] omniIdentities OmniContactIdentity[] contactInboxes ContactInbox[] + clientTimelineEntries ClientTimelineEntry[] @@index([teamId, updatedAt]) } @@ -436,3 +444,21 @@ model WorkspaceDocument { @@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]) +} diff --git a/frontend/server/graphql/schema.ts b/frontend/server/graphql/schema.ts index f58564a..6f291f8 100644 --- a/frontend/server/graphql/schema.ts +++ b/frontend/server/graphql/schema.ts @@ -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) { const raw = String(sourceExternalId ?? "").trim(); 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(); + 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(); + + 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 & { datetime: string }>; + + return [...messageItems, ...timelineItems] + .sort((a, b) => a.datetime.localeCompare(b.datetime)) + .slice(-limit); +} + async function createCalendarEvent(auth: AuthContext | null, input: { title: string; start: string; @@ -740,6 +1114,16 @@ async function createCalendarEvent(auth: AuthContext | null, input: { 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 { id: created.id, title: created.title, @@ -776,6 +1160,16 @@ async function archiveCalendarEvent(auth: AuthContext | null, input: { id: strin 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 { id: updated.id, 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 { id: created.id, title: created.title, @@ -1351,6 +1766,7 @@ export const crmGraphqlSchema = buildSchema(` chatMessages: [PilotMessage!]! chatConversations: [Conversation!]! dashboard: DashboardPayload! + getClientTimeline(contactId: ID!, limit: Int): [ClientTimelineItem!]! } type Mutation { @@ -1498,6 +1914,18 @@ export const crmGraphqlSchema = buildSchema(` documents: [WorkspaceDocument!]! } + type ClientTimelineItem { + id: ID! + contactId: String! + contentType: String! + contentId: String! + datetime: String! + message: CommItem + calendarEvent: CalendarEvent + recommendation: FeedCard + document: WorkspaceDocument + } + type Contact { id: ID! name: String! @@ -1615,6 +2043,10 @@ export const crmGraphqlRoot = { chatMessages: async (_args: unknown, context: GraphQLContext) => getChatMessages(context.auth), chatConversations: async (_args: unknown, context: GraphQLContext) => getChatConversations(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) => loginWithPassword(context.event, args.phone, args.password),