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 type { AgentTraceEvent } 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 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 debug = (m.planJson as any) ?? {}; const cs = getChangeSetFromPlanJson(m.planJson); return { id: m.id, role: m.role === "USER" ? "user" : m.role === "ASSISTANT" ? "assistant" : "system", text: m.text, thinking: Array.isArray(debug.thinking) ? (debug.thinking as string[]) : [], tools: Array.isArray(debug.tools) ? (debug.tools as string[]) : [], toolRuns: Array.isArray(debug.toolRuns) ? (debug.toolRuns as any[]) .filter((t) => t && typeof t === "object") .map((t: any) => ({ name: String(t.name ?? "crm:unknown"), status: t.status === "error" ? "error" : "ok", input: String(t.input ?? ""), output: String(t.output ?? ""), at: t.at ? String(t.at) : m.createdAt.toISOString(), })) : [], 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 } } }, 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>(); 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, 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 ?? "", })); 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 ?? "", })); 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; status?: 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: { teamId: ctx.teamId, contactId: contact?.id ?? null, title, startsAt: start, endsAt: end && !Number.isNaN(end.getTime()) ? end : null, note: (input?.note ?? "").trim() || null, status: (input?.status ?? "").trim() || 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 ?? "", }; } async function createCommunication(auth: AuthContext | null, input: { contact: string; channel?: string; kind?: "message" | "call"; direction?: "in" | "out"; text?: 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 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; } 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 snapshotBefore = await captureSnapshot(prisma, ctx.teamId); await persistChatMessage({ teamId: ctx.teamId, conversationId: ctx.conversationId, authorUserId: ctx.userId, role: "USER", text, }); const reply = await runCrmAgentFor({ teamId: ctx.teamId, userId: ctx.userId, userText: text, onTrace: async (event: AgentTraceEvent) => { await persistChatMessage({ teamId: ctx.teamId, conversationId: ctx.conversationId, authorUserId: null, role: "SYSTEM", text: event.text, thinking: [], tools: event.toolRun ? [event.toolRun.name] : [], toolRuns: event.toolRun ? [event.toolRun] : [], }); }, }); 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, thinking: reply.thinking ?? [], tools: reply.tools, toolRuns: reply.toolRuns ?? [], 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, thinking: [], tools: [], toolRuns: [], }); return { ok: 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! sendPilotMessage(text: String!): MutationResult! confirmLatestChangeSet: MutationResult! rollbackLatestChangeSet: MutationResult! logPilotNote(text: String!): MutationResult! createCalendarEvent(input: CreateCalendarEventInput!): CalendarEvent! createCommunication(input: CreateCommunicationInput!): MutationWithIdResult! updateFeedDecision(id: ID!, decision: String!, decisionNote: String): MutationWithIdResult! } type MutationResult { ok: Boolean! } type MutationWithIdResult { ok: Boolean! id: ID! } input CreateCalendarEventInput { title: String! start: String! end: String contact: String note: String status: String } input CreateCommunicationInput { contact: String! channel: String kind: String direction: String text: 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! 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! duration: String! transcript: [String!]! } type CalendarEvent { id: ID! title: String! start: String! end: String! contact: String! note: String! } type Deal { id: ID! contact: String! title: String! company: String! stage: String! amount: String! nextStep: String! summary: 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), 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), createCalendarEvent: async (args: { input: { title: string; start: string; end?: string; contact?: string; note?: string; status?: string } }, context: GraphQLContext) => createCalendarEvent(context.auth, args.input), createCommunication: async ( args: { input: { contact: string; channel?: string; kind?: "message" | "call"; direction?: "in" | "out"; text?: string; at?: string; durationSec?: number; transcript?: string[]; }; }, context: GraphQLContext, ) => createCommunication(context.auth, args.input), updateFeedDecision: async ( args: { id: string; decision: "accepted" | "rejected" | "pending"; decisionNote?: string }, context: GraphQLContext, ) => updateFeedDecision(context.auth, args.id, args.decision, args.decisionNote), };