Files
clientsflow/frontend/server/graphql/schema.ts
Ruslan Bakiev 601de37ab0 feat: add lastMessageText and lastMessageChannel to contacts query
Enriches the contacts resolver to include the last message preview
and channel, so the sidebar can show thread previews without loading
all communications. No frontend changes yet — fields returned but unused.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 16:02:02 +07:00

2233 lines
65 KiB
TypeScript

import { buildSchema } from "graphql";
import fs from "node:fs/promises";
import type { H3Event } from "h3";
import type { AuthContext } from "../utils/auth";
import { clearAuthSession, setSession } from "../utils/auth";
import { prisma } from "../utils/prisma";
import { normalizePhone, verifyPassword } from "../utils/password";
import { persistAiMessage, runCrmAgentFor } from "../agent/crmAgent";
import { buildChangeSet, captureSnapshot, rollbackChangeSet, rollbackChangeSetItems } from "../utils/changeSet";
import type { ChangeSet } from "../utils/changeSet";
import { enqueueTelegramSend } from "../queues/telegramSend";
import { datasetRoot } from "../dataset/paths";
type GraphQLContext = {
auth: AuthContext | null;
event: H3Event;
};
function requireAuth(auth: AuthContext | null) {
if (!auth) {
throw new Error("Unauthorized");
}
return auth;
}
function mapChannel(channel: string) {
if (channel === "TELEGRAM") return "Telegram";
if (channel === "WHATSAPP") return "WhatsApp";
if (channel === "INSTAGRAM") return "Instagram";
if (channel === "EMAIL") return "Email";
return "Phone";
}
function toDbChannel(channel: string) {
const c = channel.toLowerCase();
if (c === "telegram") return "TELEGRAM";
if (c === "whatsapp") return "WHATSAPP";
if (c === "instagram") return "INSTAGRAM";
if (c === "email") return "EMAIL";
return "PHONE";
}
function asObject(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
return value as Record<string, unknown>;
}
function readNestedString(obj: Record<string, unknown>, path: string[]): string {
let current: unknown = obj;
for (const segment of path) {
if (!current || typeof current !== "object" || Array.isArray(current)) return "";
current = (current as Record<string, unknown>)[segment];
}
return typeof current === "string" ? current.trim() : "";
}
function extractOmniNormalizedText(rawJson: unknown, fallbackText = "") {
const raw = asObject(rawJson);
return (
readNestedString(raw, ["normalized", "text"]) ||
readNestedString(raw, ["payloadNormalized", "text"]) ||
readNestedString(raw, ["deliveryRequest", "payload", "text"]) ||
String(fallbackText ?? "").trim()
);
}
type ClientTimelineContentType = "CALENDAR_EVENT" | "DOCUMENT" | "RECOMMENDATION";
const CONTACT_DOCUMENT_SCOPE_PREFIX = "contact:";
const TELEGRAM_AUDIO_FILE_MARKER = "tg-file:";
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;
return `${channel.toLowerCase()}:unknown`;
}
function visibleMessageWhere(hiddenInboxIds: string[]) {
if (!hiddenInboxIds.length) return undefined;
return {
OR: [
{ contactInboxId: null },
{ contactInboxId: { notIn: hiddenInboxIds } },
],
};
}
function resolveContactMessageAudioUrl(message: {
id: string;
channel: string;
audioUrl: string | null;
}) {
const raw = String(message.audioUrl ?? "").trim();
if (!raw) return "";
if (message.channel === "TELEGRAM" && raw.startsWith(TELEGRAM_AUDIO_FILE_MARKER)) {
return `/api/omni/telegram/media?messageId=${encodeURIComponent(message.id)}`;
}
return raw;
}
async function upsertContactInbox(input: {
teamId: string;
contactId: string;
channel: "TELEGRAM" | "WHATSAPP" | "INSTAGRAM" | "PHONE" | "EMAIL" | "INTERNAL";
sourceExternalId: string;
title?: string | null;
}) {
return prisma.contactInbox.upsert({
where: {
teamId_channel_sourceExternalId: {
teamId: input.teamId,
channel: input.channel,
sourceExternalId: normalizeSourceExternalId(input.channel, input.sourceExternalId),
},
},
create: {
teamId: input.teamId,
contactId: input.contactId,
channel: input.channel,
sourceExternalId: normalizeSourceExternalId(input.channel, input.sourceExternalId),
title: (input.title ?? "").trim() || null,
},
update: {
contactId: input.contactId,
title: (input.title ?? "").trim() || undefined,
},
select: { id: true },
});
}
async function loginWithPassword(event: H3Event, phoneInput: string, passwordInput: string) {
const phone = normalizePhone(phoneInput);
const password = (passwordInput ?? "").trim();
if (!phone) {
throw new Error("phone is required");
}
if (!password) {
throw new Error("password is required");
}
const user = await prisma.user.findUnique({
where: { phone },
include: {
memberships: {
orderBy: { createdAt: "asc" },
take: 1,
},
},
});
if (!user || !verifyPassword(password, user.passwordHash)) {
throw new Error("invalid credentials");
}
const membership = user.memberships[0];
if (!membership) {
throw new Error("user has no team access");
}
const conversation =
(await prisma.aiConversation.findFirst({
where: { teamId: membership.teamId, createdByUserId: user.id },
orderBy: { createdAt: "desc" },
})) ||
(await prisma.aiConversation.create({
data: { teamId: membership.teamId, createdByUserId: user.id, title: "Pilot" },
}));
setSession(event, {
teamId: membership.teamId,
userId: user.id,
conversationId: conversation.id,
});
return { ok: true };
}
async function getAuthPayload(auth: AuthContext | null) {
const ctx = requireAuth(auth);
const [user, team, conv] = await Promise.all([
prisma.user.findUnique({ where: { id: ctx.userId } }),
prisma.team.findUnique({ where: { id: ctx.teamId } }),
prisma.aiConversation.findUnique({
where: { id: ctx.conversationId },
include: { messages: { orderBy: { createdAt: "desc" }, take: 1, select: { text: true, createdAt: true } } },
}),
]);
if (!user || !team || !conv) {
throw new Error("Unauthorized");
}
return {
user: { id: user.id, phone: user.phone, name: user.name },
team: { id: team.id, name: team.name },
conversation: {
id: conv.id,
title: conv.title ?? "New chat",
createdAt: conv.createdAt.toISOString(),
updatedAt: conv.updatedAt.toISOString(),
lastMessageAt: conv.messages[0]?.createdAt?.toISOString?.() ?? null,
lastMessageText: conv.messages[0]?.text ?? null,
},
};
}
function defaultConversationTitle(input?: string | null) {
const value = (input ?? "").trim();
if (value) return value;
const stamp = new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
}).format(new Date());
return `Chat ${stamp}`;
}
async function getChatConversations(auth: AuthContext | null) {
const ctx = requireAuth(auth);
const items = await prisma.aiConversation.findMany({
where: { teamId: ctx.teamId, createdByUserId: ctx.userId },
include: {
messages: {
orderBy: { createdAt: "desc" },
take: 1,
select: { text: true, createdAt: true },
},
},
take: 100,
});
return items
.map((c) => ({
id: c.id,
title: defaultConversationTitle(c.title),
createdAt: c.createdAt.toISOString(),
updatedAt: c.updatedAt.toISOString(),
lastMessageAt: c.messages[0]?.createdAt?.toISOString?.() ?? null,
lastMessageText: c.messages[0]?.text ?? null,
}))
.sort((a, b) => {
const aTime = a.lastMessageAt ?? a.updatedAt;
const bTime = b.lastMessageAt ?? b.updatedAt;
return bTime.localeCompare(aTime);
});
}
async function createChatConversation(auth: AuthContext | null, event: H3Event, titleInput?: string | null) {
const ctx = requireAuth(auth);
const conversation = await prisma.aiConversation.create({
data: {
teamId: ctx.teamId,
createdByUserId: ctx.userId,
title: defaultConversationTitle(titleInput),
},
});
setSession(event, {
teamId: ctx.teamId,
userId: ctx.userId,
conversationId: conversation.id,
});
return {
id: conversation.id,
title: defaultConversationTitle(conversation.title),
createdAt: conversation.createdAt.toISOString(),
updatedAt: conversation.updatedAt.toISOString(),
lastMessageAt: null,
lastMessageText: null,
};
}
async function selectChatConversation(auth: AuthContext | null, event: H3Event, id: string) {
const ctx = requireAuth(auth);
const convId = (id ?? "").trim();
if (!convId) throw new Error("id is required");
const conversation = await prisma.aiConversation.findFirst({
where: {
id: convId,
teamId: ctx.teamId,
createdByUserId: ctx.userId,
},
select: { id: true },
});
if (!conversation) throw new Error("conversation not found");
setSession(event, {
teamId: ctx.teamId,
userId: ctx.userId,
conversationId: conversation.id,
});
return { ok: true };
}
async function archiveChatConversation(auth: AuthContext | null, event: H3Event, id: string) {
const ctx = requireAuth(auth);
const convId = (id ?? "").trim();
if (!convId) throw new Error("id is required");
const conversation = await prisma.aiConversation.findFirst({
where: {
id: convId,
teamId: ctx.teamId,
createdByUserId: ctx.userId,
},
select: { id: true },
});
if (!conversation) throw new Error("conversation not found");
const nextConversationId = await prisma.$transaction(async (tx) => {
await tx.aiConversation.delete({ where: { id: conversation.id } });
if (ctx.conversationId !== conversation.id) {
return ctx.conversationId;
}
const created = await tx.aiConversation.create({
data: { teamId: ctx.teamId, createdByUserId: ctx.userId, title: "Pilot" },
select: { id: true },
});
return created.id;
});
setSession(event, {
teamId: ctx.teamId,
userId: ctx.userId,
conversationId: nextConversationId,
});
return { ok: true };
}
async function getChatMessages(auth: AuthContext | null) {
const ctx = requireAuth(auth);
const items = await prisma.aiMessage.findMany({
where: { teamId: ctx.teamId, conversationId: ctx.conversationId },
orderBy: { createdAt: "asc" },
take: 200,
});
return items.map((m) => {
const cs = getChangeSetFromPlanJson(m.planJson);
const messageKind = getMessageKindFromPlanJson(m.planJson) ?? (cs ? "change_set_summary" : null);
return {
id: m.id,
role: m.role === "USER" ? "user" : m.role === "ASSISTANT" ? "assistant" : "system",
text: m.text,
messageKind,
requestId: null,
eventType: null,
phase: null,
transient: null,
thinking: [],
tools: [],
toolRuns: [],
changeSetId: cs?.id ?? null,
changeStatus: cs?.status ?? null,
changeSummary: cs?.summary ?? null,
changeItems: Array.isArray(cs?.items)
? cs.items.map((item, idx) => ({
id: String((item as any)?.id ?? `legacy-${idx}`),
entity: String(item.entity ?? ""),
entityId: (item as any)?.entityId ? String((item as any).entityId) : null,
action: String(item.action ?? ""),
title: String(item.title ?? ""),
before: String(item.before ?? ""),
after: String(item.after ?? ""),
rolledBack: Array.isArray((cs as any)?.rolledBackItemIds)
? (cs as any).rolledBackItemIds.includes((item as any)?.id)
: false,
}))
: [],
createdAt: m.createdAt.toISOString(),
};
});
}
async function getHiddenInboxIds(teamId: string, userId: string) {
const hiddenPrefRows = await prisma.contactInboxPreference.findMany({
where: { teamId, userId, isHidden: true },
select: { contactInboxId: true },
});
return hiddenPrefRows.map((row) => row.contactInboxId);
}
async function getContacts(auth: AuthContext | null) {
const ctx = requireAuth(auth);
const hiddenInboxIds = await getHiddenInboxIds(ctx.teamId, ctx.userId);
const messageWhere = visibleMessageWhere(hiddenInboxIds);
const hiddenInboxIdSet = new Set(hiddenInboxIds);
const [contactsRaw, contactInboxesRaw, communicationsRaw] = await Promise.all([
prisma.contact.findMany({
where: { teamId: ctx.teamId },
include: {
note: { select: { content: true } },
messages: {
...(messageWhere ? { where: messageWhere } : {}),
select: { content: true, channel: true, occurredAt: true },
orderBy: { occurredAt: "desc" as const },
take: 1,
},
},
orderBy: { updatedAt: "desc" },
take: 500,
}),
prisma.contactInbox.findMany({
where: { teamId: ctx.teamId },
select: { id: true, contactId: true, channel: true },
orderBy: { updatedAt: "desc" },
take: 5000,
}),
prisma.contactMessage.findMany({
where: {
contact: { teamId: ctx.teamId },
...(messageWhere ?? {}),
},
select: { contactId: true, channel: true },
orderBy: { occurredAt: "asc" },
take: 2000,
}),
]);
const channelsByContactId = new Map<string, Set<string>>();
const totalInboxesByContactId = new Map<string, number>();
const visibleInboxesByContactId = new Map<string, number>();
for (const inbox of contactInboxesRaw) {
totalInboxesByContactId.set(inbox.contactId, (totalInboxesByContactId.get(inbox.contactId) ?? 0) + 1);
if (hiddenInboxIdSet.has(inbox.id)) continue;
visibleInboxesByContactId.set(inbox.contactId, (visibleInboxesByContactId.get(inbox.contactId) ?? 0) + 1);
if (!channelsByContactId.has(inbox.contactId)) channelsByContactId.set(inbox.contactId, new Set());
channelsByContactId.get(inbox.contactId)?.add(mapChannel(inbox.channel));
}
for (const item of communicationsRaw) {
if (!channelsByContactId.has(item.contactId)) channelsByContactId.set(item.contactId, new Set());
channelsByContactId.get(item.contactId)?.add(mapChannel(item.channel));
}
return contactsRaw
.filter((c) => {
const total = totalInboxesByContactId.get(c.id) ?? 0;
if (total === 0) return true;
return (visibleInboxesByContactId.get(c.id) ?? 0) > 0;
})
.map((c) => ({
id: c.id,
name: c.name,
avatar: c.avatarUrl ?? "",
channels: Array.from(channelsByContactId.get(c.id) ?? []),
lastContactAt: c.messages[0]?.occurredAt?.toISOString?.() ?? c.updatedAt.toISOString(),
lastMessageText: c.messages[0]?.content ?? "",
lastMessageChannel: c.messages[0]?.channel ? mapChannel(c.messages[0].channel) : "",
description: c.note?.content ?? "",
}));
}
async function getCommunications(auth: AuthContext | null) {
const ctx = requireAuth(auth);
const hiddenInboxIds = await getHiddenInboxIds(ctx.teamId, ctx.userId);
const messageWhere = visibleMessageWhere(hiddenInboxIds);
const communicationsRaw = await prisma.contactMessage.findMany({
where: {
contact: { teamId: ctx.teamId },
...(messageWhere ?? {}),
},
orderBy: { occurredAt: "asc" },
take: 2000,
include: {
contact: { select: { id: true, name: true } },
contactInbox: { select: { id: true, sourceExternalId: true, title: true } },
},
});
let omniMessagesRaw: Array<{
id: string;
contactId: string;
channel: string;
direction: string;
text: string;
rawJson: unknown;
status: string;
occurredAt: Date;
updatedAt: Date;
}> = [];
if (communicationsRaw.length) {
const contactIds = [...new Set(communicationsRaw.map((row) => row.contactId))];
const minOccurredAt = communicationsRaw[0]?.occurredAt ?? new Date();
const maxOccurredAt = communicationsRaw[communicationsRaw.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: { in: contactIds },
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: 5000,
});
}
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 communicationsRaw)[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;
};
return communicationsRaw.map((m) => ({
id: m.id,
at: m.occurredAt.toISOString(),
contactId: m.contactId,
contact: m.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: resolveContactMessageAudioUrl(m),
duration: m.durationSec ? new Date(m.durationSec * 1000).toISOString().slice(14, 19) : "",
waveform: Array.isArray(m.waveformJson)
? m.waveformJson.map((value) => Number(value)).filter((value) => Number.isFinite(value))
: [],
transcript: Array.isArray(m.transcriptJson) ? ((m.transcriptJson as any) as string[]) : [],
deliveryStatus: resolveDeliveryStatus(m),
}));
}
async function getContactInboxes(auth: AuthContext | null) {
const ctx = requireAuth(auth);
const hiddenInboxIds = await getHiddenInboxIds(ctx.teamId, ctx.userId);
const messageWhere = visibleMessageWhere(hiddenInboxIds);
const hiddenInboxIdSet = new Set(hiddenInboxIds);
const contactInboxesRaw = await prisma.contactInbox.findMany({
where: { teamId: ctx.teamId },
orderBy: { updatedAt: "desc" },
include: {
contact: { select: { name: true } },
messages: {
where: messageWhere,
select: { occurredAt: true },
orderBy: { occurredAt: "desc" },
take: 1,
},
},
take: 5000,
});
return contactInboxesRaw.map((inbox) => ({
id: inbox.id,
contactId: inbox.contactId,
contactName: inbox.contact.name,
channel: mapChannel(inbox.channel),
sourceExternalId: inbox.sourceExternalId,
title: inbox.title ?? "",
isHidden: hiddenInboxIdSet.has(inbox.id),
lastMessageAt: inbox.messages[0]?.occurredAt?.toISOString?.() ?? "",
updatedAt: inbox.updatedAt.toISOString(),
}));
}
async function getCalendar(auth: AuthContext | null, dateRange?: { from?: string; to?: string }) {
const ctx = requireAuth(auth);
const from = dateRange?.from ? new Date(dateRange.from) : new Date(Date.now() - 1000 * 60 * 60 * 24 * 30);
const to = dateRange?.to ? new Date(dateRange.to) : new Date(Date.now() + 1000 * 60 * 60 * 24 * 60);
const calendarRaw = await prisma.calendarEvent.findMany({
where: { teamId: ctx.teamId, startsAt: { gte: from, lte: to } },
include: { contact: { select: { name: true } } },
orderBy: { startsAt: "asc" },
take: 500,
});
return calendarRaw.map((e) => ({
id: e.id,
title: e.title,
start: e.startsAt.toISOString(),
end: (e.endsAt ?? e.startsAt).toISOString(),
contact: e.contact?.name ?? "",
note: e.note ?? "",
isArchived: Boolean(e.isArchived),
createdAt: e.createdAt.toISOString(),
archiveNote: e.archiveNote ?? "",
archivedAt: e.archivedAt?.toISOString() ?? "",
}));
}
async function getDeals(auth: AuthContext | null) {
const ctx = requireAuth(auth);
const dealsRaw = await prisma.deal.findMany({
where: { teamId: ctx.teamId },
include: {
contact: { select: { name: true } },
steps: { orderBy: [{ order: "asc" }, { createdAt: "asc" }] },
},
orderBy: { updatedAt: "desc" },
take: 500,
});
return dealsRaw.map((d) => ({
id: d.id,
contact: d.contact.name,
title: d.title,
stage: d.stage,
amount: d.amount ? String(d.amount) : "",
nextStep: d.nextStep ?? "",
summary: d.summary ?? "",
currentStepId: d.currentStepId ?? "",
steps: d.steps.map((step) => ({
id: step.id,
title: step.title,
description: step.description ?? "",
status: step.status,
dueAt: step.dueAt?.toISOString() ?? "",
order: step.order,
completedAt: step.completedAt?.toISOString() ?? "",
})),
}));
}
async function getFeed(auth: AuthContext | null) {
const ctx = requireAuth(auth);
const feedRaw = await prisma.feedCard.findMany({
where: { teamId: ctx.teamId },
include: { contact: { select: { name: true } } },
orderBy: { happenedAt: "desc" },
take: 200,
});
return feedRaw.map((c) => ({
id: c.id,
at: c.happenedAt.toISOString(),
contact: c.contact?.name ?? "",
text: c.text,
proposal: {
title: ((c.proposalJson as any)?.title ?? "") as string,
details: (Array.isArray((c.proposalJson as any)?.details) ? (c.proposalJson as any).details : []) as string[],
key: ((c.proposalJson as any)?.key ?? "") as string,
},
decision: c.decision === "ACCEPTED" ? "accepted" : c.decision === "REJECTED" ? "rejected" : "pending",
decisionNote: c.decisionNote ?? "",
}));
}
async function getPins(auth: AuthContext | null) {
const ctx = requireAuth(auth);
const pinsRaw = await prisma.contactPin.findMany({
where: { teamId: ctx.teamId },
include: { contact: { select: { name: true } } },
orderBy: { updatedAt: "desc" },
take: 500,
});
return pinsRaw.map((p) => ({
id: p.id,
contact: p.contact.name,
text: p.text,
}));
}
async function getDocuments(auth: AuthContext | null) {
const ctx = requireAuth(auth);
const documentsRaw = await prisma.workspaceDocument.findMany({
where: { teamId: ctx.teamId },
orderBy: { updatedAt: "desc" },
take: 200,
});
return documentsRaw.map((d) => ({
id: d.id,
title: d.title,
type: d.type,
owner: d.owner,
scope: d.scope,
updatedAt: d.updatedAt.toISOString(),
summary: d.summary,
body: d.body,
}));
}
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: resolveContactMessageAudioUrl(m),
duration: m.durationSec ? new Date(m.durationSec * 1000).toISOString().slice(14, 19) : "",
waveform: Array.isArray(m.waveformJson)
? m.waveformJson.map((value) => Number(value)).filter((value) => Number.isFinite(value))
: [],
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;
end?: string;
contact?: string;
note?: string;
archived?: boolean;
archiveNote?: string;
}) {
const ctx = requireAuth(auth);
const title = (input?.title ?? "").trim();
const start = input?.start ? new Date(input.start) : null;
const end = input?.end ? new Date(input.end) : null;
if (!title) throw new Error("title is required");
if (!start || Number.isNaN(start.getTime())) throw new Error("start is invalid");
const contactName = (input?.contact ?? "").trim();
const contact = contactName
? await prisma.contact.findFirst({ where: { teamId: ctx.teamId, name: contactName }, select: { id: true, name: true } })
: null;
const created = await prisma.calendarEvent.create({
data: (() => {
const archived = Boolean(input?.archived);
const note = (input?.note ?? "").trim() || null;
const archiveNote = (input?.archiveNote ?? "").trim() || note;
return {
teamId: ctx.teamId,
contactId: contact?.id ?? null,
title,
startsAt: start,
endsAt: end && !Number.isNaN(end.getTime()) ? end : null,
note,
isArchived: archived,
archiveNote: archived ? archiveNote : null,
archivedAt: archived ? new Date() : null,
};
})(),
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,
start: created.startsAt.toISOString(),
end: (created.endsAt ?? created.startsAt).toISOString(),
contact: created.contact?.name ?? "",
note: created.note ?? "",
isArchived: Boolean(created.isArchived),
createdAt: created.createdAt.toISOString(),
archiveNote: created.archiveNote ?? "",
archivedAt: created.archivedAt?.toISOString() ?? "",
};
}
async function archiveCalendarEvent(auth: AuthContext | null, input: { id: string; archiveNote?: string }) {
const ctx = requireAuth(auth);
const id = String(input?.id ?? "").trim();
const archiveNote = String(input?.archiveNote ?? "").trim();
if (!id) throw new Error("id is required");
const existing = await prisma.calendarEvent.findFirst({
where: { id, teamId: ctx.teamId },
select: { id: true },
});
if (!existing) throw new Error("event not found");
const updated = await prisma.calendarEvent.update({
where: { id },
data: {
isArchived: true,
archiveNote: archiveNote || null,
archivedAt: new Date(),
},
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,
start: updated.startsAt.toISOString(),
end: (updated.endsAt ?? updated.startsAt).toISOString(),
contact: updated.contact?.name ?? "",
note: updated.note ?? "",
isArchived: Boolean(updated.isArchived),
createdAt: updated.createdAt.toISOString(),
archiveNote: updated.archiveNote ?? "",
archivedAt: updated.archivedAt?.toISOString() ?? "",
};
}
async function createCommunication(auth: AuthContext | null, input: {
contact: string;
channel?: string;
kind?: "message" | "call";
direction?: "in" | "out";
text?: string;
audioUrl?: string;
at?: string;
durationSec?: number;
transcript?: string[];
}) {
const ctx = requireAuth(auth);
const contactName = (input?.contact ?? "").trim();
if (!contactName) throw new Error("contact is required");
const contact = await prisma.contact.findFirst({
where: { teamId: ctx.teamId, name: contactName },
select: { id: true },
});
if (!contact) throw new Error("contact not found");
const occurredAt = input?.at ? new Date(input.at) : new Date();
if (Number.isNaN(occurredAt.getTime())) throw new Error("at is invalid");
const kind = input?.kind === "call" ? "CALL" : "MESSAGE";
const direction = input?.direction === "in" ? "IN" : "OUT";
const channel = toDbChannel(input?.channel ?? "Phone") as any;
const content = (input?.text ?? "").trim();
let contactInboxId: string | null = null;
if (kind === "MESSAGE" && channel === "TELEGRAM" && direction === "OUT") {
const thread = await prisma.omniThread.findFirst({
where: {
teamId: ctx.teamId,
contactId: contact.id,
channel: "TELEGRAM",
},
orderBy: { updatedAt: "desc" },
select: { id: true, externalChatId: true, title: true },
});
if (!thread) {
throw new Error("telegram thread not found for contact");
}
const inbox = await upsertContactInbox({
teamId: ctx.teamId,
contactId: contact.id,
channel: "TELEGRAM",
sourceExternalId: thread.externalChatId,
title: thread.title ?? null,
});
contactInboxId = inbox.id;
const omniMessage = await prisma.omniMessage.create({
data: {
teamId: ctx.teamId,
contactId: contact.id,
threadId: thread.id,
direction: "OUT",
channel: "TELEGRAM",
status: "PENDING",
text: content,
providerMessageId: null,
providerUpdateId: null,
rawJson: {
version: 1,
source: "graphql.createCommunication",
provider: "telegram_business",
normalized: {
channel: "TELEGRAM",
direction: "OUT",
text: content,
},
payloadNormalized: {
contactId: contact.id,
threadId: thread.id,
text: content,
},
enqueuedAt: new Date().toISOString(),
},
occurredAt,
},
select: { id: true },
});
try {
await enqueueTelegramSend({ omniMessageId: omniMessage.id });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const existingOmni = await prisma.omniMessage.findUnique({
where: { id: omniMessage.id },
select: { rawJson: true },
});
await prisma.omniMessage.update({
where: { id: omniMessage.id },
data: {
status: "FAILED",
rawJson: {
...asObject(existingOmni?.rawJson),
source: "graphql.createCommunication",
delivery: {
status: "FAILED",
error: { message },
failedAt: new Date().toISOString(),
},
},
},
}).catch(() => undefined);
throw new Error(`telegram enqueue failed: ${message}`);
}
} else {
const existingInbox = await prisma.contactInbox.findFirst({
where: {
teamId: ctx.teamId,
contactId: contact.id,
channel,
},
orderBy: { updatedAt: "desc" },
select: { id: true },
});
contactInboxId = existingInbox?.id ?? null;
}
const created = await prisma.contactMessage.create({
data: {
contactId: contact.id,
contactInboxId,
kind,
direction,
channel,
content,
durationSec: typeof input?.durationSec === "number" ? input.durationSec : null,
transcriptJson: Array.isArray(input?.transcript) ? input.transcript : undefined,
occurredAt,
},
});
return { ok: true, id: created.id };
}
async function createWorkspaceDocument(auth: AuthContext | null, input: {
title: string;
owner?: string;
scope: string;
summary: string;
body?: string;
}) {
const ctx = requireAuth(auth);
const title = String(input?.title ?? "").trim();
const scope = String(input?.scope ?? "").trim();
const summary = String(input?.summary ?? "").trim();
const body = String(input?.body ?? "").trim();
const owner = String(input?.owner ?? "").trim() || "Workspace";
if (!title) throw new Error("title is required");
if (!scope) throw new Error("scope is required");
if (!summary) throw new Error("summary is required");
const created = await prisma.workspaceDocument.create({
data: {
teamId: ctx.teamId,
title,
type: "Template",
owner,
scope,
summary,
body: body || summary,
},
});
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,
type: created.type,
owner: created.owner,
scope: created.scope,
updatedAt: created.updatedAt.toISOString(),
summary: created.summary,
body: created.body,
};
}
async function deleteWorkspaceDocument(auth: AuthContext | null, documentIdInput: string) {
const ctx = requireAuth(auth);
const documentId = String(documentIdInput ?? "").trim();
if (!documentId) throw new Error("id is required");
const existing = await prisma.workspaceDocument.findFirst({
where: {
id: documentId,
teamId: ctx.teamId,
},
select: { id: true },
});
if (!existing) throw new Error("document not found");
await prisma.$transaction([
prisma.workspaceDocument.delete({
where: { id: existing.id },
}),
prisma.clientTimelineEntry.deleteMany({
where: {
teamId: ctx.teamId,
contentType: "DOCUMENT",
contentId: existing.id,
},
}),
]);
await fs.rm(datasetRoot({ teamId: ctx.teamId, userId: ctx.userId }), {
recursive: true,
force: true,
}).catch(() => undefined);
return { ok: true, id: existing.id };
}
async function setContactInboxHidden(
auth: AuthContext | null,
input: { inboxId: string; hidden: boolean },
) {
const ctx = requireAuth(auth);
const inboxId = String(input?.inboxId ?? "").trim();
if (!inboxId) throw new Error("inboxId is required");
const inbox = await prisma.contactInbox.findFirst({
where: {
id: inboxId,
teamId: ctx.teamId,
},
select: { id: true },
});
if (!inbox) throw new Error("inbox not found");
const hidden = Boolean(input?.hidden);
await prisma.contactInboxPreference.upsert({
where: {
userId_contactInboxId: {
userId: ctx.userId,
contactInboxId: inbox.id,
},
},
create: {
teamId: ctx.teamId,
userId: ctx.userId,
contactInboxId: inbox.id,
isHidden: hidden,
},
update: {
isHidden: hidden,
},
});
await fs.rm(datasetRoot({ teamId: ctx.teamId, userId: ctx.userId }), {
recursive: true,
force: true,
}).catch(() => undefined);
return { ok: true };
}
async function updateCommunicationTranscript(auth: AuthContext | null, id: string, transcript: string[]) {
const ctx = requireAuth(auth);
const messageId = String(id ?? "").trim();
if (!messageId) throw new Error("id is required");
const lines = Array.isArray(transcript)
? transcript.map((line) => String(line ?? "").trim()).filter(Boolean)
: [];
const updated = await prisma.contactMessage.updateMany({
where: {
id: messageId,
contact: { teamId: ctx.teamId },
},
data: {
transcriptJson: lines,
},
});
if (!updated.count) throw new Error("communication not found");
return { ok: true, id: messageId };
}
async function updateFeedDecision(auth: AuthContext | null, id: string, decision: "accepted" | "rejected" | "pending", decisionNote?: string) {
const ctx = requireAuth(auth);
if (!id) throw new Error("id is required");
if (!decision) throw new Error("decision is required");
const nextDecision = decision === "accepted" ? "ACCEPTED" : decision === "rejected" ? "REJECTED" : "PENDING";
const res = await prisma.feedCard.updateMany({
where: { id, teamId: ctx.teamId },
data: { decision: nextDecision, decisionNote: decisionNote ?? null },
});
if (res.count === 0) throw new Error("feed card not found");
return { ok: true, id };
}
function getChangeSetFromPlanJson(planJson: unknown): ChangeSet | null {
const debug = (planJson as any) ?? {};
const cs = debug?.changeSet;
if (!cs || typeof cs !== "object") return null;
if (!cs.id || !Array.isArray(cs.items) || !Array.isArray(cs.undo)) return null;
return cs as ChangeSet;
}
function getMessageKindFromPlanJson(planJson: unknown): string | null {
const debug = (planJson as any) ?? {};
const kind = debug?.messageKind;
if (!kind || typeof kind !== "string") return null;
return kind;
}
function renderChangeSetSummary(changeSet: ChangeSet): string {
const totals = { created: 0, updated: 0, deleted: 0 };
for (const item of changeSet.items) {
if (item.action === "created") totals.created += 1;
else if (item.action === "updated") totals.updated += 1;
else if (item.action === "deleted") totals.deleted += 1;
}
return [
"Technical change summary",
`Total: ${changeSet.items.length} · Created: ${totals.created} · Updated: ${totals.updated} · Archived: ${totals.deleted}`,
].join("\n");
}
async function findLatestChangeCarrierMessage(auth: AuthContext | null) {
const ctx = requireAuth(auth);
const items = await prisma.aiMessage.findMany({
where: {
teamId: ctx.teamId,
conversationId: ctx.conversationId,
role: "ASSISTANT",
},
orderBy: { createdAt: "desc" },
take: 30,
});
for (const item of items) {
const changeSet = getChangeSetFromPlanJson(item.planJson);
if (!changeSet) continue;
if (changeSet.status === "rolled_back") continue;
return { item, changeSet };
}
return null;
}
async function findChangeCarrierMessageByChangeSetId(auth: AuthContext | null, changeSetId: string) {
const ctx = requireAuth(auth);
const targetId = String(changeSetId ?? "").trim();
if (!targetId) return null;
const items = await prisma.aiMessage.findMany({
where: {
teamId: ctx.teamId,
conversationId: ctx.conversationId,
role: "ASSISTANT",
},
orderBy: { createdAt: "desc" },
take: 200,
});
for (const item of items) {
const changeSet = getChangeSetFromPlanJson(item.planJson);
if (!changeSet) continue;
if (changeSet.id !== targetId) continue;
return { item, changeSet };
}
return null;
}
async function confirmLatestChangeSet(auth: AuthContext | null) {
const found = await findLatestChangeCarrierMessage(auth);
if (!found) return { ok: true };
const { item, changeSet } = found;
if (changeSet.status === "confirmed") return { ok: true };
const next = {
...((item.planJson as any) ?? {}),
changeSet: {
...changeSet,
status: "confirmed",
confirmedAt: new Date().toISOString(),
},
};
await prisma.aiMessage.update({
where: { id: item.id },
data: { planJson: next as any },
});
return { ok: true };
}
async function rollbackLatestChangeSet(auth: AuthContext | null) {
const ctx = requireAuth(auth);
const found = await findLatestChangeCarrierMessage(ctx);
if (!found) return { ok: true };
const { item, changeSet } = found;
if (changeSet.status === "rolled_back") return { ok: true };
await rollbackChangeSet(prisma, ctx.teamId, changeSet);
const next = {
...((item.planJson as any) ?? {}),
changeSet: {
...changeSet,
status: "rolled_back",
rolledBackAt: new Date().toISOString(),
rolledBackItemIds: Array.isArray(changeSet.items)
? changeSet.items
.map((changeItem: any, idx: number) => String(changeItem?.id ?? `legacy-${idx}`))
.filter(Boolean)
: [],
},
};
await prisma.aiMessage.update({
where: { id: item.id },
data: { planJson: next as any },
});
return { ok: true };
}
async function rollbackChangeSetItemsMutation(auth: AuthContext | null, changeSetId: string, itemIds: string[]) {
const ctx = requireAuth(auth);
const found = await findChangeCarrierMessageByChangeSetId(ctx, changeSetId);
if (!found) return { ok: true };
const { item, changeSet } = found;
if (changeSet.status === "rolled_back") return { ok: true };
const selectedIds = [...new Set((itemIds ?? []).map((id) => String(id ?? "").trim()).filter(Boolean))];
if (!selectedIds.length) return { ok: true };
await rollbackChangeSetItems(prisma, ctx.teamId, changeSet, selectedIds);
const allIds = Array.isArray(changeSet.items)
? changeSet.items
.map((changeItem: any, idx: number) => String(changeItem?.id ?? `legacy-${idx}`))
.filter(Boolean)
: [];
const prevRolledBack = Array.isArray((changeSet as any)?.rolledBackItemIds)
? ((changeSet as any).rolledBackItemIds as string[]).map((id) => String(id))
: [];
const nextRolledBackSet = new Set([...prevRolledBack, ...selectedIds]);
const nextRolledBack = [...nextRolledBackSet];
const allRolledBack = allIds.length > 0 && allIds.every((id) => nextRolledBackSet.has(id));
const next = {
...((item.planJson as any) ?? {}),
changeSet: {
...changeSet,
status: allRolledBack ? "rolled_back" : "pending",
rolledBackAt: allRolledBack ? new Date().toISOString() : null,
rolledBackItemIds: nextRolledBack,
},
};
await prisma.aiMessage.update({
where: { id: item.id },
data: { planJson: next as any },
});
return { ok: true };
}
async function sendPilotMessage(auth: AuthContext | null, textInput: string) {
const ctx = requireAuth(auth);
const text = (textInput ?? "").trim();
if (!text) throw new Error("text is required");
const requestId = `req_${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`;
const snapshotBefore = await captureSnapshot(prisma, ctx.teamId);
await persistAiMessage({
teamId: ctx.teamId,
conversationId: ctx.conversationId,
authorUserId: ctx.userId,
role: "USER",
text,
requestId,
eventType: "user",
phase: "final",
transient: false,
});
const reply = await runCrmAgentFor({
teamId: ctx.teamId,
userId: ctx.userId,
userText: text,
requestId,
conversationId: ctx.conversationId,
onTrace: async () => {},
});
const snapshotAfter = await captureSnapshot(prisma, ctx.teamId);
const changeSet = buildChangeSet(snapshotBefore, snapshotAfter);
await persistAiMessage({
teamId: ctx.teamId,
conversationId: ctx.conversationId,
authorUserId: null,
role: "ASSISTANT",
text: reply.text,
requestId,
eventType: "assistant",
phase: "final",
transient: false,
});
if (changeSet) {
await persistAiMessage({
teamId: ctx.teamId,
conversationId: ctx.conversationId,
authorUserId: null,
role: "ASSISTANT",
text: renderChangeSetSummary(changeSet),
requestId,
eventType: "note",
phase: "final",
transient: false,
messageKind: "change_set_summary",
changeSet,
});
}
return { ok: true };
}
async function logPilotNote(auth: AuthContext | null, textInput: string) {
const ctx = requireAuth(auth);
const text = (textInput ?? "").trim();
if (!text) throw new Error("text is required");
await persistAiMessage({
teamId: ctx.teamId,
conversationId: ctx.conversationId,
authorUserId: null,
role: "ASSISTANT",
text,
});
return { ok: true };
}
async function toggleContactPin(auth: AuthContext | null, contactInput: string, textInput: string) {
const ctx = requireAuth(auth);
const contactName = (contactInput ?? "").trim();
const text = (textInput ?? "").replace(/\s+/g, " ").trim();
if (!contactName) throw new Error("contact is required");
if (!text) throw new Error("text is required");
const contact = await prisma.contact.findFirst({
where: { teamId: ctx.teamId, name: contactName },
select: { id: true },
});
if (!contact) throw new Error("contact not found");
const existing = await prisma.contactPin.findFirst({
where: { teamId: ctx.teamId, contactId: contact.id, text },
select: { id: true },
});
if (existing) {
await prisma.contactPin.deleteMany({
where: { teamId: ctx.teamId, contactId: contact.id, text },
});
return { ok: true, pinned: false };
}
await prisma.contactPin.create({
data: {
teamId: ctx.teamId,
contactId: contact.id,
text,
},
});
return { ok: true, pinned: true };
}
export const crmGraphqlSchema = buildSchema(`
type Query {
me: MePayload!
chatMessages: [PilotMessage!]!
chatConversations: [Conversation!]!
contacts: [Contact!]!
communications: [CommItem!]!
contactInboxes: [ContactInbox!]!
calendar(dateRange: CalendarDateRange): [CalendarEvent!]!
deals: [Deal!]!
feed: [FeedCard!]!
pins: [CommPin!]!
documents: [WorkspaceDocument!]!
getClientTimeline(contactId: ID!, limit: Int): [ClientTimelineItem!]!
}
type Mutation {
login(phone: String!, password: String!): MutationResult!
logout: MutationResult!
createChatConversation(title: String): Conversation!
selectChatConversation(id: ID!): MutationResult!
archiveChatConversation(id: ID!): MutationResult!
sendPilotMessage(text: String!): MutationResult!
confirmLatestChangeSet: MutationResult!
rollbackLatestChangeSet: MutationResult!
rollbackChangeSetItems(changeSetId: ID!, itemIds: [ID!]!): MutationResult!
logPilotNote(text: String!): MutationResult!
toggleContactPin(contact: String!, text: String!): PinToggleResult!
createCalendarEvent(input: CreateCalendarEventInput!): CalendarEvent!
archiveCalendarEvent(input: ArchiveCalendarEventInput!): CalendarEvent!
createCommunication(input: CreateCommunicationInput!): MutationWithIdResult!
createWorkspaceDocument(input: CreateWorkspaceDocumentInput!): WorkspaceDocument!
deleteWorkspaceDocument(id: ID!): MutationWithIdResult!
updateCommunicationTranscript(id: ID!, transcript: [String!]!): MutationWithIdResult!
updateFeedDecision(id: ID!, decision: String!, decisionNote: String): MutationWithIdResult!
setContactInboxHidden(inboxId: ID!, hidden: Boolean!): MutationResult!
}
type MutationResult {
ok: Boolean!
}
type MutationWithIdResult {
ok: Boolean!
id: ID!
}
type PinToggleResult {
ok: Boolean!
pinned: Boolean!
}
input CalendarDateRange {
from: String
to: String
}
input CreateCalendarEventInput {
title: String!
start: String!
end: String
contact: String
note: String
archived: Boolean
archiveNote: String
}
input ArchiveCalendarEventInput {
id: ID!
archiveNote: String
}
input CreateCommunicationInput {
contact: String!
channel: String
kind: String
direction: String
text: String
audioUrl: String
at: String
durationSec: Int
transcript: [String!]
}
input CreateWorkspaceDocumentInput {
title: String!
owner: String
scope: String!
summary: String!
body: String
}
type MePayload {
user: MeUser!
team: MeTeam!
conversation: Conversation!
}
type MeUser {
id: ID!
phone: String!
name: String!
}
type MeTeam {
id: ID!
name: String!
}
type Conversation {
id: ID!
title: String!
createdAt: String!
updatedAt: String!
lastMessageAt: String
lastMessageText: String
}
type PilotMessage {
id: ID!
role: String!
text: String!
messageKind: String
requestId: String
eventType: String
phase: String
transient: Boolean
thinking: [String!]!
tools: [String!]!
toolRuns: [PilotToolRun!]!
changeSetId: String
changeStatus: String
changeSummary: String
changeItems: [PilotChangeItem!]!
createdAt: String!
}
type PilotChangeItem {
id: ID!
entity: String!
entityId: String
action: String!
title: String!
before: String!
after: String!
rolledBack: Boolean!
}
type PilotToolRun {
name: String!
status: String!
input: String!
output: String!
at: String!
}
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!
avatar: String!
channels: [String!]!
lastContactAt: String!
lastMessageText: String!
lastMessageChannel: String!
description: String!
}
type CommItem {
id: ID!
at: String!
contactId: String!
contact: String!
contactInboxId: String!
sourceExternalId: String!
sourceTitle: String!
channel: String!
kind: String!
direction: String!
text: String!
audioUrl: String!
duration: String!
waveform: [Float!]!
transcript: [String!]!
deliveryStatus: String
}
type ContactInbox {
id: ID!
contactId: String!
contactName: String!
channel: String!
sourceExternalId: String!
title: String!
isHidden: Boolean!
lastMessageAt: String!
updatedAt: String!
}
type CalendarEvent {
id: ID!
title: String!
start: String!
end: String!
contact: String!
note: String!
isArchived: Boolean!
createdAt: String!
archiveNote: String!
archivedAt: String!
}
type Deal {
id: ID!
contact: String!
title: String!
stage: String!
amount: String!
nextStep: String!
summary: String!
currentStepId: String!
steps: [DealStep!]!
}
type DealStep {
id: ID!
title: String!
description: String!
status: String!
dueAt: String!
order: Int!
completedAt: String!
}
type FeedCard {
id: ID!
at: String!
contact: String!
text: String!
proposal: FeedProposal!
decision: String!
decisionNote: String!
}
type FeedProposal {
title: String!
details: [String!]!
key: String!
}
type CommPin {
id: ID!
contact: String!
text: String!
}
type WorkspaceDocument {
id: ID!
title: String!
type: String!
owner: String!
scope: String!
updatedAt: String!
summary: String!
body: String!
}
`);
export const crmGraphqlRoot = {
me: async (_args: unknown, context: GraphQLContext) => getAuthPayload(context.auth),
chatMessages: async (_args: unknown, context: GraphQLContext) => getChatMessages(context.auth),
chatConversations: async (_args: unknown, context: GraphQLContext) => getChatConversations(context.auth),
contacts: async (_args: unknown, context: GraphQLContext) => getContacts(context.auth),
communications: async (_args: unknown, context: GraphQLContext) => getCommunications(context.auth),
contactInboxes: async (_args: unknown, context: GraphQLContext) => getContactInboxes(context.auth),
calendar: async (args: { dateRange?: { from?: string; to?: string } }, context: GraphQLContext) => getCalendar(context.auth, args.dateRange ?? undefined),
deals: async (_args: unknown, context: GraphQLContext) => getDeals(context.auth),
feed: async (_args: unknown, context: GraphQLContext) => getFeed(context.auth),
pins: async (_args: unknown, context: GraphQLContext) => getPins(context.auth),
documents: async (_args: unknown, context: GraphQLContext) => getDocuments(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),
logout: async (_args: unknown, context: GraphQLContext) => {
clearAuthSession(context.event);
return { ok: true };
},
createChatConversation: async (args: { title?: string }, context: GraphQLContext) =>
createChatConversation(context.auth, context.event, args.title),
selectChatConversation: async (args: { id: string }, context: GraphQLContext) =>
selectChatConversation(context.auth, context.event, args.id),
archiveChatConversation: async (args: { id: string }, context: GraphQLContext) =>
archiveChatConversation(context.auth, context.event, args.id),
sendPilotMessage: async (args: { text: string }, context: GraphQLContext) =>
sendPilotMessage(context.auth, args.text),
confirmLatestChangeSet: async (_args: unknown, context: GraphQLContext) =>
confirmLatestChangeSet(context.auth),
rollbackLatestChangeSet: async (_args: unknown, context: GraphQLContext) =>
rollbackLatestChangeSet(context.auth),
rollbackChangeSetItems: async (
args: { changeSetId: string; itemIds: string[] },
context: GraphQLContext,
) => rollbackChangeSetItemsMutation(context.auth, args.changeSetId, args.itemIds),
logPilotNote: async (args: { text: string }, context: GraphQLContext) =>
logPilotNote(context.auth, args.text),
toggleContactPin: async (args: { contact: string; text: string }, context: GraphQLContext) =>
toggleContactPin(context.auth, args.contact, args.text),
createCalendarEvent: async (args: { input: { title: string; start: string; end?: string; contact?: string; note?: string; archived?: boolean; archiveNote?: string } }, context: GraphQLContext) =>
createCalendarEvent(context.auth, args.input),
archiveCalendarEvent: async (args: { input: { id: string; archiveNote?: string } }, context: GraphQLContext) =>
archiveCalendarEvent(context.auth, args.input),
createCommunication: async (
args: {
input: {
contact: string;
channel?: string;
kind?: "message" | "call";
direction?: "in" | "out";
text?: string;
audioUrl?: string;
at?: string;
durationSec?: number;
transcript?: string[];
};
},
context: GraphQLContext,
) => createCommunication(context.auth, args.input),
createWorkspaceDocument: async (
args: {
input: {
title: string;
owner?: string;
scope: string;
summary: string;
body?: string;
};
},
context: GraphQLContext,
) => createWorkspaceDocument(context.auth, args.input),
deleteWorkspaceDocument: async (
args: { id: string },
context: GraphQLContext,
) => deleteWorkspaceDocument(context.auth, args.id),
updateCommunicationTranscript: async (
args: { id: string; transcript: string[] },
context: GraphQLContext,
) => updateCommunicationTranscript(context.auth, args.id, args.transcript),
updateFeedDecision: async (
args: { id: string; decision: "accepted" | "rejected" | "pending"; decisionNote?: string },
context: GraphQLContext,
) => updateFeedDecision(context.auth, args.id, args.decision, args.decisionNote),
setContactInboxHidden: async (
args: { inboxId: string; hidden: boolean },
context: GraphQLContext,
) => setContactInboxHidden(context.auth, args),
};