Files
clientsflow/frontend/server/graphql/schema.ts
2026-02-20 12:10:25 +07:00

1158 lines
32 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 { persistChatMessage, runCrmAgentFor } from "../agent/crmAgent";
import { buildChangeSet, captureSnapshot, rollbackChangeSet } from "../utils/changeSet";
import type { ChangeSet } from "../utils/changeSet";
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";
}
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.chatConversation.findFirst({
where: { teamId: membership.teamId, createdByUserId: user.id },
orderBy: { createdAt: "desc" },
})) ||
(await prisma.chatConversation.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.chatConversation.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.chatConversation.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.chatConversation.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.chatConversation.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.chatConversation.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.chatConversation.delete({ where: { id: conversation.id } });
if (ctx.conversationId !== conversation.id) {
return ctx.conversationId;
}
const created = await tx.chatConversation.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.chatMessage.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) => ({
entity: String(item.entity ?? ""),
action: String(item.action ?? ""),
title: String(item.title ?? ""),
before: String(item.before ?? ""),
after: String(item.after ?? ""),
}))
: [],
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,
}),
]);
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 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[]) : [],
}));
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 created = await prisma.contactMessage.create({
data: {
contactId: contact.id,
kind: input?.kind === "call" ? "CALL" : "MESSAGE",
direction: input?.direction === "in" ? "IN" : "OUT",
channel: toDbChannel(input?.channel ?? "Phone") as any,
content: (input?.text ?? "").trim(),
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 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.chatMessage.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 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.chatMessage.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(),
},
};
await prisma.chatMessage.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 persistChatMessage({
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 persistChatMessage({
teamId: ctx.teamId,
conversationId: ctx.conversationId,
authorUserId: null,
role: "ASSISTANT",
text: reply.text,
requestId,
eventType: "assistant",
phase: "final",
transient: false,
});
if (changeSet) {
await persistChatMessage({
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 persistChatMessage({
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!
logPilotNote(text: String!): MutationResult!
toggleContactPin(contact: String!, text: String!): PinToggleResult!
createCalendarEvent(input: CreateCalendarEventInput!): CalendarEvent!
archiveCalendarEvent(input: ArchiveCalendarEventInput!): CalendarEvent!
createCommunication(input: CreateCommunicationInput!): MutationWithIdResult!
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!]
}
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 {
entity: String!
action: String!
title: String!
before: String!
after: String!
}
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!]!
}
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),
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),
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),
};