Files
clientsflow/frontend/server/graphql/schema.ts
Ruslan Bakiev 5492e0d05c feat: unread message tracking with blue dot indicator
Add ContactThreadRead model to track when users last viewed each contact thread.
Contacts with messages newer than the last read time show a blue dot in the sidebar.
Opening a thread automatically marks it as read via markThreadRead mutation.

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

2266 lines
66 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, threadReadsRaw] = 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,
}),
prisma.contactThreadRead.findMany({
where: { teamId: ctx.teamId, userId: ctx.userId },
select: { contactId: true, readAt: true },
}),
]);
const readAtByContactId = new Map(threadReadsRaw.map((r) => [r.contactId, r.readAt]));
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) : "",
hasUnread: c.messages[0]?.occurredAt
? (!readAtByContactId.has(c.id) || c.messages[0].occurredAt > readAtByContactId.get(c.id)!)
: false,
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 markThreadRead(
auth: AuthContext | null,
input: { contactId: string },
) {
const ctx = requireAuth(auth);
const contactId = String(input?.contactId ?? "").trim();
if (!contactId) throw new Error("contactId is required");
await prisma.contactThreadRead.upsert({
where: { userId_contactId: { userId: ctx.userId, contactId } },
create: { teamId: ctx.teamId, userId: ctx.userId, contactId, readAt: new Date() },
update: { readAt: new Date() },
});
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!
markThreadRead(contactId: ID!): 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!
hasUnread: Boolean!
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),
markThreadRead: async (
args: { contactId: string },
context: GraphQLContext,
) => markThreadRead(context.auth, args),
};