CalendarEvent and FeedCard timeline entries are now handled by Prisma middleware automatically. Document timeline entry is inlined since WorkspaceDocument stores contactId in scope field, not on the model. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2225 lines
65 KiB
TypeScript
2225 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,
|
|
};
|
|
}
|
|
|
|
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 } } },
|
|
});
|
|
|
|
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 } } },
|
|
});
|
|
|
|
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 prisma.clientTimelineEntry.upsert({
|
|
where: {
|
|
teamId_contentType_contentId: {
|
|
teamId: ctx.teamId,
|
|
contentType: "DOCUMENT",
|
|
contentId: created.id,
|
|
},
|
|
},
|
|
create: {
|
|
teamId: ctx.teamId,
|
|
contactId: linkedContact.id,
|
|
contentType: "DOCUMENT",
|
|
contentId: created.id,
|
|
datetime: new Date(),
|
|
},
|
|
update: {
|
|
contactId: linkedContact.id,
|
|
datetime: new Date(),
|
|
},
|
|
select: { id: true },
|
|
});
|
|
}
|
|
}
|
|
|
|
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),
|
|
};
|