feat: add unified client timeline query
This commit is contained in:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user