1503 lines
42 KiB
TypeScript
1503 lines
42 KiB
TypeScript
import { buildSchema } from "graphql";
|
|
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";
|
|
|
|
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()
|
|
);
|
|
}
|
|
|
|
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 getDashboard(auth: AuthContext | null) {
|
|
const ctx = requireAuth(auth);
|
|
const from = new Date(Date.now() - 1000 * 60 * 60 * 24 * 30);
|
|
const to = new Date(Date.now() + 1000 * 60 * 60 * 24 * 60);
|
|
|
|
const [
|
|
contactsRaw,
|
|
communicationsRaw,
|
|
calendarRaw,
|
|
dealsRaw,
|
|
feedRaw,
|
|
pinsRaw,
|
|
documentsRaw,
|
|
] = await Promise.all([
|
|
prisma.contact.findMany({
|
|
where: { teamId: ctx.teamId },
|
|
include: {
|
|
note: { select: { content: true } },
|
|
messages: { select: { occurredAt: true }, orderBy: { occurredAt: "desc" }, take: 1 },
|
|
},
|
|
orderBy: { updatedAt: "desc" },
|
|
take: 500,
|
|
}),
|
|
prisma.contactMessage.findMany({
|
|
where: { contact: { teamId: ctx.teamId } },
|
|
orderBy: { occurredAt: "asc" },
|
|
take: 2000,
|
|
include: { contact: { select: { id: true, name: true } } },
|
|
}),
|
|
prisma.calendarEvent.findMany({
|
|
where: { teamId: ctx.teamId, startsAt: { gte: from, lte: to } },
|
|
include: { contact: { select: { name: true } } },
|
|
orderBy: { startsAt: "asc" },
|
|
take: 500,
|
|
}),
|
|
prisma.deal.findMany({
|
|
where: { teamId: ctx.teamId },
|
|
include: {
|
|
contact: { select: { name: true, company: true } },
|
|
steps: { orderBy: [{ order: "asc" }, { createdAt: "asc" }] },
|
|
},
|
|
orderBy: { updatedAt: "desc" },
|
|
take: 500,
|
|
}),
|
|
prisma.feedCard.findMany({
|
|
where: { teamId: ctx.teamId },
|
|
include: { contact: { select: { name: true } } },
|
|
orderBy: { happenedAt: "desc" },
|
|
take: 200,
|
|
}),
|
|
prisma.contactPin.findMany({
|
|
where: { teamId: ctx.teamId },
|
|
include: { contact: { select: { name: true } } },
|
|
orderBy: { updatedAt: "desc" },
|
|
take: 500,
|
|
}),
|
|
prisma.workspaceDocument.findMany({
|
|
where: { teamId: ctx.teamId },
|
|
orderBy: { updatedAt: "desc" },
|
|
take: 200,
|
|
}),
|
|
]);
|
|
|
|
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 channelsByContactId = new Map<string, Set<string>>();
|
|
for (const item of communicationsRaw) {
|
|
if (!channelsByContactId.has(item.contactId)) {
|
|
channelsByContactId.set(item.contactId, new Set());
|
|
}
|
|
channelsByContactId.get(item.contactId)?.add(mapChannel(item.channel));
|
|
}
|
|
|
|
const contacts = contactsRaw.map((c) => ({
|
|
id: c.id,
|
|
name: c.name,
|
|
avatar: c.avatarUrl ?? "",
|
|
company: c.company ?? "",
|
|
country: c.country ?? "",
|
|
location: c.location ?? "",
|
|
channels: Array.from(channelsByContactId.get(c.id) ?? []),
|
|
lastContactAt: c.messages[0]?.occurredAt?.toISOString?.() ?? c.updatedAt.toISOString(),
|
|
description: c.note?.content ?? "",
|
|
}));
|
|
|
|
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;
|
|
};
|
|
|
|
const communications = communicationsRaw.map((m) => ({
|
|
id: m.id,
|
|
at: m.occurredAt.toISOString(),
|
|
contactId: m.contactId,
|
|
contact: m.contact.name,
|
|
channel: mapChannel(m.channel),
|
|
kind: m.kind === "CALL" ? "call" : "message",
|
|
direction: m.direction === "IN" ? "in" : "out",
|
|
text: m.content,
|
|
audioUrl: "",
|
|
duration: m.durationSec ? new Date(m.durationSec * 1000).toISOString().slice(14, 19) : "",
|
|
transcript: Array.isArray(m.transcriptJson) ? ((m.transcriptJson as any) as string[]) : [],
|
|
deliveryStatus: resolveDeliveryStatus(m),
|
|
}));
|
|
|
|
const calendar = 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() ?? "",
|
|
}));
|
|
|
|
const deals = dealsRaw.map((d) => ({
|
|
id: d.id,
|
|
contact: d.contact.name,
|
|
title: d.title,
|
|
company: d.contact.company ?? "",
|
|
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() ?? "",
|
|
})),
|
|
}));
|
|
|
|
const feed = 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 ?? "",
|
|
}));
|
|
|
|
const pins = pinsRaw.map((p) => ({
|
|
id: p.id,
|
|
contact: p.contact.name,
|
|
text: p.text,
|
|
}));
|
|
|
|
const documents = 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,
|
|
}));
|
|
|
|
return {
|
|
contacts,
|
|
communications,
|
|
calendar,
|
|
deals,
|
|
feed,
|
|
pins,
|
|
documents,
|
|
};
|
|
}
|
|
|
|
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();
|
|
|
|
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 },
|
|
});
|
|
if (!thread) {
|
|
throw new Error("telegram thread not found for contact");
|
|
}
|
|
|
|
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}`);
|
|
}
|
|
}
|
|
|
|
const created = await prisma.contactMessage.create({
|
|
data: {
|
|
contactId: contact.id,
|
|
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,
|
|
},
|
|
});
|
|
|
|
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 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!]!
|
|
dashboard: DashboardPayload!
|
|
}
|
|
|
|
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!
|
|
updateCommunicationTranscript(id: ID!, transcript: [String!]!): MutationWithIdResult!
|
|
updateFeedDecision(id: ID!, decision: String!, decisionNote: String): MutationWithIdResult!
|
|
}
|
|
|
|
type MutationResult {
|
|
ok: Boolean!
|
|
}
|
|
|
|
type MutationWithIdResult {
|
|
ok: Boolean!
|
|
id: ID!
|
|
}
|
|
|
|
type PinToggleResult {
|
|
ok: Boolean!
|
|
pinned: Boolean!
|
|
}
|
|
|
|
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 DashboardPayload {
|
|
contacts: [Contact!]!
|
|
communications: [CommItem!]!
|
|
calendar: [CalendarEvent!]!
|
|
deals: [Deal!]!
|
|
feed: [FeedCard!]!
|
|
pins: [CommPin!]!
|
|
documents: [WorkspaceDocument!]!
|
|
}
|
|
|
|
type Contact {
|
|
id: ID!
|
|
name: String!
|
|
avatar: String!
|
|
company: String!
|
|
country: String!
|
|
location: String!
|
|
channels: [String!]!
|
|
lastContactAt: String!
|
|
description: String!
|
|
}
|
|
|
|
type CommItem {
|
|
id: ID!
|
|
at: String!
|
|
contactId: String!
|
|
contact: String!
|
|
channel: String!
|
|
kind: String!
|
|
direction: String!
|
|
text: String!
|
|
audioUrl: String!
|
|
duration: String!
|
|
transcript: [String!]!
|
|
deliveryStatus: 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!
|
|
company: 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),
|
|
dashboard: async (_args: unknown, context: GraphQLContext) => getDashboard(context.auth),
|
|
|
|
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),
|
|
|
|
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),
|
|
};
|