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

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