import { buildSchema } from "graphql"; import fs from "node:fs/promises"; import type { H3Event } from "h3"; import type { AuthContext } from "../utils/auth"; import { clearAuthSession, setSession } from "../utils/auth"; import { prisma } from "../utils/prisma"; import { normalizePhone, verifyPassword } from "../utils/password"; import { persistAiMessage, runCrmAgentFor } from "../agent/crmAgent"; import { buildChangeSet, captureSnapshot, rollbackChangeSet, rollbackChangeSetItems } from "../utils/changeSet"; import type { ChangeSet } from "../utils/changeSet"; import { enqueueTelegramSend } from "../queues/telegramSend"; import { datasetRoot } from "../dataset/paths"; type GraphQLContext = { auth: AuthContext | null; event: H3Event; }; function requireAuth(auth: AuthContext | null) { if (!auth) { throw new Error("Unauthorized"); } return auth; } function mapChannel(channel: string) { if (channel === "TELEGRAM") return "Telegram"; if (channel === "WHATSAPP") return "WhatsApp"; if (channel === "INSTAGRAM") return "Instagram"; if (channel === "EMAIL") return "Email"; return "Phone"; } function toDbChannel(channel: string) { const c = channel.toLowerCase(); if (c === "telegram") return "TELEGRAM"; if (c === "whatsapp") return "WHATSAPP"; if (c === "instagram") return "INSTAGRAM"; if (c === "email") return "EMAIL"; return "PHONE"; } function asObject(value: unknown): Record { if (!value || typeof value !== "object" || Array.isArray(value)) return {}; return value as Record; } function readNestedString(obj: Record, 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)[segment]; } return typeof current === "string" ? current.trim() : ""; } function extractOmniNormalizedText(rawJson: unknown, fallbackText = "") { const raw = asObject(rawJson); return ( readNestedString(raw, ["normalized", "text"]) || readNestedString(raw, ["payloadNormalized", "text"]) || readNestedString(raw, ["deliveryRequest", "payload", "text"]) || String(fallbackText ?? "").trim() ); } type ClientTimelineContentType = "CALENDAR_EVENT" | "DOCUMENT" | "RECOMMENDATION"; const CONTACT_DOCUMENT_SCOPE_PREFIX = "contact:"; const TELEGRAM_AUDIO_FILE_MARKER = "tg-file:"; function mapTimelineContentType(value: ClientTimelineContentType) { if (value === "CALENDAR_EVENT") return "calendar_event"; if (value === "DOCUMENT") return "document"; return "recommendation"; } function parseContactDocumentScope(scopeInput: string) { const raw = String(scopeInput ?? "").trim(); if (!raw.startsWith(CONTACT_DOCUMENT_SCOPE_PREFIX)) return null; const payload = raw.slice(CONTACT_DOCUMENT_SCOPE_PREFIX.length); const [idRaw, ...nameParts] = payload.split(":"); const contactId = decodeURIComponent(idRaw ?? "").trim(); const contactName = decodeURIComponent(nameParts.join(":") ?? "").trim(); if (!contactId) return null; return { contactId, contactName, }; } function normalizeSourceExternalId(channel: string, sourceExternalId: string | null | undefined) { const raw = String(sourceExternalId ?? "").trim(); if (raw) return raw; return `${channel.toLowerCase()}:unknown`; } function visibleMessageWhere(hiddenInboxIds: string[]) { if (!hiddenInboxIds.length) return undefined; return { OR: [ { contactInboxId: null }, { contactInboxId: { notIn: hiddenInboxIds } }, ], }; } function resolveContactMessageAudioUrl(message: { id: string; channel: string; audioUrl: string | null; }) { const raw = String(message.audioUrl ?? "").trim(); if (!raw) return ""; if (message.channel === "TELEGRAM" && raw.startsWith(TELEGRAM_AUDIO_FILE_MARKER)) { return `/api/omni/telegram/media?messageId=${encodeURIComponent(message.id)}`; } return raw; } function resolveContactAvatarUrl(contact: { id: string; avatarUrl: string | null; }, hasTelegramChannel: boolean) { const raw = String(contact.avatarUrl ?? "").trim(); if (!raw) { return hasTelegramChannel ? `/api/omni/telegram/avatar?contactId=${encodeURIComponent(contact.id)}` : ""; } if (raw.startsWith(TELEGRAM_AUDIO_FILE_MARKER)) { return `/api/omni/telegram/avatar?contactId=${encodeURIComponent(contact.id)}`; } return raw; } async function upsertContactInbox(input: { teamId: string; contactId: string; channel: "TELEGRAM" | "WHATSAPP" | "INSTAGRAM" | "PHONE" | "EMAIL" | "INTERNAL"; sourceExternalId: string; title?: string | null; }) { return prisma.contactInbox.upsert({ where: { teamId_channel_sourceExternalId: { teamId: input.teamId, channel: input.channel, sourceExternalId: normalizeSourceExternalId(input.channel, input.sourceExternalId), }, }, create: { teamId: input.teamId, contactId: input.contactId, channel: input.channel, sourceExternalId: normalizeSourceExternalId(input.channel, input.sourceExternalId), title: (input.title ?? "").trim() || null, }, update: { contactId: input.contactId, title: (input.title ?? "").trim() || undefined, }, select: { id: true }, }); } async function loginWithPassword(event: H3Event, phoneInput: string, passwordInput: string) { const phone = normalizePhone(phoneInput); const password = (passwordInput ?? "").trim(); if (!phone) { throw new Error("phone is required"); } if (!password) { throw new Error("password is required"); } const user = await prisma.user.findUnique({ where: { phone }, include: { memberships: { orderBy: { createdAt: "asc" }, take: 1, }, }, }); if (!user || !verifyPassword(password, user.passwordHash)) { throw new Error("invalid credentials"); } const membership = user.memberships[0]; if (!membership) { throw new Error("user has no team access"); } const conversation = (await prisma.aiConversation.findFirst({ where: { teamId: membership.teamId, createdByUserId: user.id }, orderBy: { createdAt: "desc" }, })) || (await prisma.aiConversation.create({ data: { teamId: membership.teamId, createdByUserId: user.id, title: "Pilot" }, })); setSession(event, { teamId: membership.teamId, userId: user.id, conversationId: conversation.id, }); return { ok: true }; } async function getAuthPayload(auth: AuthContext | null) { const ctx = requireAuth(auth); const [user, team, conv] = await Promise.all([ prisma.user.findUnique({ where: { id: ctx.userId } }), prisma.team.findUnique({ where: { id: ctx.teamId } }), prisma.aiConversation.findUnique({ where: { id: ctx.conversationId }, include: { messages: { orderBy: { createdAt: "desc" }, take: 1, select: { text: true, createdAt: true } } }, }), ]); if (!user || !team || !conv) { throw new Error("Unauthorized"); } return { user: { id: user.id, phone: user.phone, name: user.name }, team: { id: team.id, name: team.name }, conversation: { id: conv.id, title: conv.title ?? "New chat", createdAt: conv.createdAt.toISOString(), updatedAt: conv.updatedAt.toISOString(), lastMessageAt: conv.messages[0]?.createdAt?.toISOString?.() ?? null, lastMessageText: conv.messages[0]?.text ?? null, }, }; } function defaultConversationTitle(input?: string | null) { const value = (input ?? "").trim(); if (value) return value; const stamp = new Intl.DateTimeFormat("en-GB", { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit", }).format(new Date()); return `Chat ${stamp}`; } async function getChatConversations(auth: AuthContext | null) { const ctx = requireAuth(auth); const items = await prisma.aiConversation.findMany({ where: { teamId: ctx.teamId, createdByUserId: ctx.userId }, include: { messages: { orderBy: { createdAt: "desc" }, take: 1, select: { text: true, createdAt: true }, }, }, take: 100, }); return items .map((c) => ({ id: c.id, title: defaultConversationTitle(c.title), createdAt: c.createdAt.toISOString(), updatedAt: c.updatedAt.toISOString(), lastMessageAt: c.messages[0]?.createdAt?.toISOString?.() ?? null, lastMessageText: c.messages[0]?.text ?? null, })) .sort((a, b) => { const aTime = a.lastMessageAt ?? a.updatedAt; const bTime = b.lastMessageAt ?? b.updatedAt; return bTime.localeCompare(aTime); }); } async function createChatConversation(auth: AuthContext | null, event: H3Event, titleInput?: string | null) { const ctx = requireAuth(auth); const conversation = await prisma.aiConversation.create({ data: { teamId: ctx.teamId, createdByUserId: ctx.userId, title: defaultConversationTitle(titleInput), }, }); setSession(event, { teamId: ctx.teamId, userId: ctx.userId, conversationId: conversation.id, }); return { id: conversation.id, title: defaultConversationTitle(conversation.title), createdAt: conversation.createdAt.toISOString(), updatedAt: conversation.updatedAt.toISOString(), lastMessageAt: null, lastMessageText: null, }; } async function selectChatConversation(auth: AuthContext | null, event: H3Event, id: string) { const ctx = requireAuth(auth); const convId = (id ?? "").trim(); if (!convId) throw new Error("id is required"); const conversation = await prisma.aiConversation.findFirst({ where: { id: convId, teamId: ctx.teamId, createdByUserId: ctx.userId, }, select: { id: true }, }); if (!conversation) throw new Error("conversation not found"); setSession(event, { teamId: ctx.teamId, userId: ctx.userId, conversationId: conversation.id, }); return { ok: true }; } async function archiveChatConversation(auth: AuthContext | null, event: H3Event, id: string) { const ctx = requireAuth(auth); const convId = (id ?? "").trim(); if (!convId) throw new Error("id is required"); const conversation = await prisma.aiConversation.findFirst({ where: { id: convId, teamId: ctx.teamId, createdByUserId: ctx.userId, }, select: { id: true }, }); if (!conversation) throw new Error("conversation not found"); const nextConversationId = await prisma.$transaction(async (tx) => { await tx.aiConversation.delete({ where: { id: conversation.id } }); if (ctx.conversationId !== conversation.id) { return ctx.conversationId; } const created = await tx.aiConversation.create({ data: { teamId: ctx.teamId, createdByUserId: ctx.userId, title: "Pilot" }, select: { id: true }, }); return created.id; }); setSession(event, { teamId: ctx.teamId, userId: ctx.userId, conversationId: nextConversationId, }); return { ok: true }; } async function getChatMessages(auth: AuthContext | null) { const ctx = requireAuth(auth); const items = await prisma.aiMessage.findMany({ where: { teamId: ctx.teamId, conversationId: ctx.conversationId }, orderBy: { createdAt: "asc" }, take: 200, }); return items.map((m) => { const cs = getChangeSetFromPlanJson(m.planJson); const messageKind = getMessageKindFromPlanJson(m.planJson) ?? (cs ? "change_set_summary" : null); return { id: m.id, role: m.role === "USER" ? "user" : m.role === "ASSISTANT" ? "assistant" : "system", text: m.text, messageKind, requestId: null, eventType: null, phase: null, transient: null, thinking: [], tools: [], toolRuns: [], changeSetId: cs?.id ?? null, changeStatus: cs?.status ?? null, changeSummary: cs?.summary ?? null, changeItems: Array.isArray(cs?.items) ? cs.items.map((item, idx) => ({ id: String((item as any)?.id ?? `legacy-${idx}`), entity: String(item.entity ?? ""), entityId: (item as any)?.entityId ? String((item as any).entityId) : null, action: String(item.action ?? ""), title: String(item.title ?? ""), before: String(item.before ?? ""), after: String(item.after ?? ""), rolledBack: Array.isArray((cs as any)?.rolledBackItemIds) ? (cs as any).rolledBackItemIds.includes((item as any)?.id) : false, })) : [], createdAt: m.createdAt.toISOString(), }; }); } async function getHiddenInboxIds(teamId: string, userId: string) { const hiddenPrefRows = await prisma.contactInboxPreference.findMany({ where: { teamId, userId, isHidden: true }, select: { contactInboxId: true }, }); return hiddenPrefRows.map((row) => row.contactInboxId); } async function getContacts(auth: AuthContext | null) { const ctx = requireAuth(auth); const hiddenInboxIds = await getHiddenInboxIds(ctx.teamId, ctx.userId); const messageWhere = visibleMessageWhere(hiddenInboxIds); const hiddenInboxIdSet = new Set(hiddenInboxIds); const [contactsRaw, contactInboxesRaw, communicationsRaw, threadReadsRaw, latestInboundAtByContactIdRaw] = await Promise.all([ prisma.contact.findMany({ where: { teamId: ctx.teamId }, include: { note: { select: { content: true } }, messages: { where: messageWhere, select: { content: true, channel: true, occurredAt: true }, orderBy: { occurredAt: "desc" as const }, take: 1, }, }, orderBy: { updatedAt: "desc" }, take: 500, }), prisma.contactInbox.findMany({ where: { teamId: ctx.teamId }, select: { id: true, contactId: true, channel: true }, orderBy: { updatedAt: "desc" }, take: 5000, }), prisma.contactMessage.findMany({ where: { contact: { teamId: ctx.teamId }, ...(messageWhere ?? {}), }, select: { contactId: true, channel: true }, orderBy: { occurredAt: "asc" }, take: 2000, }), prisma.contactThreadRead.findMany({ where: { teamId: ctx.teamId, userId: ctx.userId }, select: { contactId: true, readAt: true }, }), prisma.contactMessage.groupBy({ by: ["contactId"], where: { contact: { teamId: ctx.teamId }, direction: "IN", ...(messageWhere ?? {}), }, _max: { occurredAt: true }, }), ]); const readAtByContactId = new Map(threadReadsRaw.map((r) => [r.contactId, r.readAt])); const latestInboundAtByContactId = new Map( latestInboundAtByContactIdRaw .map((row) => [row.contactId, row._max.occurredAt] as const) .filter((entry): entry is readonly [string, Date] => entry[1] instanceof Date), ); const channelsByContactId = new Map>(); const totalInboxesByContactId = new Map(); const visibleInboxesByContactId = new Map(); for (const inbox of contactInboxesRaw) { totalInboxesByContactId.set(inbox.contactId, (totalInboxesByContactId.get(inbox.contactId) ?? 0) + 1); if (hiddenInboxIdSet.has(inbox.id)) continue; visibleInboxesByContactId.set(inbox.contactId, (visibleInboxesByContactId.get(inbox.contactId) ?? 0) + 1); if (!channelsByContactId.has(inbox.contactId)) channelsByContactId.set(inbox.contactId, new Set()); channelsByContactId.get(inbox.contactId)?.add(mapChannel(inbox.channel)); } for (const item of communicationsRaw) { if (!channelsByContactId.has(item.contactId)) channelsByContactId.set(item.contactId, new Set()); channelsByContactId.get(item.contactId)?.add(mapChannel(item.channel)); } return contactsRaw .filter((c) => { const total = totalInboxesByContactId.get(c.id) ?? 0; if (total === 0) return true; return (visibleInboxesByContactId.get(c.id) ?? 0) > 0; }) .map((c) => { const channels = Array.from(channelsByContactId.get(c.id) ?? []); const lastInboundAt = latestInboundAtByContactId.get(c.id); return { id: c.id, name: c.name, avatar: resolveContactAvatarUrl(c, channels.includes("Telegram")), channels, lastContactAt: c.messages[0]?.occurredAt?.toISOString?.() ?? c.updatedAt.toISOString(), lastMessageText: c.messages[0]?.content ?? "", lastMessageChannel: c.messages[0]?.channel ? mapChannel(c.messages[0].channel) : "", hasUnread: lastInboundAt ? (!readAtByContactId.has(c.id) || lastInboundAt > readAtByContactId.get(c.id)!) : false, description: c.note?.content ?? "", }; }); } async function getCommunications(auth: AuthContext | null) { const ctx = requireAuth(auth); const hiddenInboxIds = await getHiddenInboxIds(ctx.teamId, ctx.userId); const messageWhere = visibleMessageWhere(hiddenInboxIds); const communicationsRaw = await prisma.contactMessage.findMany({ where: { contact: { teamId: ctx.teamId }, ...(messageWhere ?? {}), }, orderBy: { occurredAt: "asc" }, take: 2000, include: { contact: { select: { id: true, name: true } }, contactInbox: { select: { id: true, sourceExternalId: true, title: true } }, }, }); let omniMessagesRaw: Array<{ id: string; contactId: string; channel: string; direction: string; text: string; rawJson: unknown; status: string; occurredAt: Date; updatedAt: Date; }> = []; if (communicationsRaw.length) { const contactIds = [...new Set(communicationsRaw.map((row) => row.contactId))]; const minOccurredAt = communicationsRaw[0]?.occurredAt ?? new Date(); const maxOccurredAt = communicationsRaw[communicationsRaw.length - 1]?.occurredAt ?? new Date(); const fromOccurredAt = new Date(minOccurredAt.getTime() - 5 * 60 * 1000); const toOccurredAt = new Date(maxOccurredAt.getTime() + 5 * 60 * 1000); omniMessagesRaw = await prisma.omniMessage.findMany({ where: { teamId: ctx.teamId, contactId: { in: contactIds }, occurredAt: { gte: fromOccurredAt, lte: toOccurredAt }, }, select: { id: true, contactId: true, channel: true, direction: true, text: true, rawJson: true, status: true, occurredAt: true, updatedAt: true, }, orderBy: [{ occurredAt: "asc" }, { updatedAt: "asc" }], take: 5000, }); } const omniByKey = new Map(); 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(); const resolveDeliveryStatus = (m: (typeof communicationsRaw)[number]) => { if (m.kind !== "MESSAGE") return null; const key = [m.contactId, m.channel, m.direction, m.content.trim()].join("|"); const candidates = omniByKey.get(key) ?? []; if (!candidates.length) { if (m.direction === "OUT" && m.channel === "TELEGRAM") return "PENDING"; return null; } const targetMs = m.occurredAt.getTime(); let best: (typeof candidates)[number] | null = null; let bestDiff = Number.POSITIVE_INFINITY; for (const candidate of candidates) { if (consumedOmniMessageIds.has(candidate.id)) continue; const diff = Math.abs(candidate.occurredAt.getTime() - targetMs); if (diff > 5 * 60 * 1000) continue; if (diff < bestDiff) { best = candidate; bestDiff = diff; continue; } if (diff === bestDiff && best && candidate.updatedAt.getTime() > best.updatedAt.getTime()) { best = candidate; } } if (!best) { if (m.direction === "OUT" && m.channel === "TELEGRAM") return "PENDING"; return null; } consumedOmniMessageIds.add(best.id); return best.status; }; return communicationsRaw.map((m) => ({ id: m.id, at: m.occurredAt.toISOString(), contactId: m.contactId, contact: m.contact.name, contactInboxId: m.contactInboxId ?? "", sourceExternalId: m.contactInbox?.sourceExternalId ?? "", sourceTitle: m.contactInbox?.title ?? "", channel: mapChannel(m.channel), kind: m.kind === "CALL" ? "call" : "message", direction: m.direction === "IN" ? "in" : "out", text: m.content, audioUrl: resolveContactMessageAudioUrl(m), duration: m.durationSec ? new Date(m.durationSec * 1000).toISOString().slice(14, 19) : "", waveform: Array.isArray(m.waveformJson) ? m.waveformJson.map((value) => Number(value)).filter((value) => Number.isFinite(value)) : [], transcript: Array.isArray(m.transcriptJson) ? ((m.transcriptJson as any) as string[]) : [], deliveryStatus: resolveDeliveryStatus(m), })); } async function getContactInboxes(auth: AuthContext | null) { const ctx = requireAuth(auth); const hiddenInboxIds = await getHiddenInboxIds(ctx.teamId, ctx.userId); const messageWhere = visibleMessageWhere(hiddenInboxIds); const hiddenInboxIdSet = new Set(hiddenInboxIds); const contactInboxesRaw = await prisma.contactInbox.findMany({ where: { teamId: ctx.teamId }, orderBy: { updatedAt: "desc" }, include: { contact: { select: { name: true } }, messages: { where: messageWhere, select: { occurredAt: true }, orderBy: { occurredAt: "desc" }, take: 1, }, }, take: 5000, }); return contactInboxesRaw.map((inbox) => ({ id: inbox.id, contactId: inbox.contactId, contactName: inbox.contact.name, channel: mapChannel(inbox.channel), sourceExternalId: inbox.sourceExternalId, title: inbox.title ?? "", isHidden: hiddenInboxIdSet.has(inbox.id), lastMessageAt: inbox.messages[0]?.occurredAt?.toISOString?.() ?? "", updatedAt: inbox.updatedAt.toISOString(), })); } async function getCalendar(auth: AuthContext | null, dateRange?: { from?: string; to?: string }) { const ctx = requireAuth(auth); const from = dateRange?.from ? new Date(dateRange.from) : new Date(Date.now() - 1000 * 60 * 60 * 24 * 30); const to = dateRange?.to ? new Date(dateRange.to) : new Date(Date.now() + 1000 * 60 * 60 * 24 * 60); const calendarRaw = await prisma.calendarEvent.findMany({ where: { teamId: ctx.teamId, startsAt: { gte: from, lte: to } }, include: { contact: { select: { name: true } } }, orderBy: { startsAt: "asc" }, take: 500, }); return calendarRaw.map((e) => ({ id: e.id, title: e.title, start: e.startsAt.toISOString(), end: (e.endsAt ?? e.startsAt).toISOString(), contact: e.contact?.name ?? "", note: e.note ?? "", isArchived: Boolean(e.isArchived), createdAt: e.createdAt.toISOString(), archiveNote: e.archiveNote ?? "", archivedAt: e.archivedAt?.toISOString() ?? "", })); } async function getDeals(auth: AuthContext | null) { const ctx = requireAuth(auth); const dealsRaw = await prisma.deal.findMany({ where: { teamId: ctx.teamId }, include: { contact: { select: { name: true } }, steps: { orderBy: [{ order: "asc" }, { createdAt: "asc" }] }, }, orderBy: { updatedAt: "desc" }, take: 500, }); return dealsRaw.map((d) => mapDealRecord(d)); } function mapDealRecord(d: { id: string; title: string; stage: string; amount: number | null; paidAmount: number | null; nextStep: string | null; summary: string | null; currentStepId: string | null; contact: { name: string }; steps: Array<{ id: string; title: string; description: string | null; status: string; dueAt: Date | null; order: number; completedAt: Date | null; }>; }) { return { id: d.id, contact: d.contact.name, title: d.title, stage: d.stage, amount: d.amount !== null ? String(d.amount) : "", paidAmount: d.paidAmount !== null ? String(d.paidAmount) : "", nextStep: d.nextStep ?? "", summary: d.summary ?? "", currentStepId: d.currentStepId ?? "", steps: d.steps.map((step) => ({ id: step.id, title: step.title, description: step.description ?? "", status: step.status, dueAt: step.dueAt?.toISOString() ?? "", order: step.order, completedAt: step.completedAt?.toISOString() ?? "", })), }; } async function getFeed(auth: AuthContext | null) { const ctx = requireAuth(auth); const feedRaw = await prisma.feedCard.findMany({ where: { teamId: ctx.teamId }, include: { contact: { select: { name: true } } }, orderBy: { happenedAt: "desc" }, take: 200, }); return feedRaw.map((c) => ({ id: c.id, at: c.happenedAt.toISOString(), contact: c.contact?.name ?? "", text: c.text, proposal: { title: ((c.proposalJson as any)?.title ?? "") as string, details: (Array.isArray((c.proposalJson as any)?.details) ? (c.proposalJson as any).details : []) as string[], key: ((c.proposalJson as any)?.key ?? "") as string, }, decision: c.decision === "ACCEPTED" ? "accepted" : c.decision === "REJECTED" ? "rejected" : "pending", decisionNote: c.decisionNote ?? "", })); } async function getPins(auth: AuthContext | null) { const ctx = requireAuth(auth); const pinsRaw = await prisma.contactPin.findMany({ where: { teamId: ctx.teamId }, include: { contact: { select: { name: true } } }, orderBy: { updatedAt: "desc" }, take: 500, }); return pinsRaw.map((p) => ({ id: p.id, contact: p.contact.name, text: p.text, })); } async function getDocuments(auth: AuthContext | null) { const ctx = requireAuth(auth); const documentsRaw = await prisma.workspaceDocument.findMany({ where: { teamId: ctx.teamId }, orderBy: { updatedAt: "desc" }, take: 200, }); return documentsRaw.map((d) => ({ id: d.id, title: d.title, type: d.type, owner: d.owner, scope: d.scope, updatedAt: d.updatedAt.toISOString(), summary: d.summary, body: d.body, })); } async function getClientTimeline(auth: AuthContext | null, contactIdInput: string, limitInput?: number) { const ctx = requireAuth(auth); const contactId = String(contactIdInput ?? "").trim(); if (!contactId) throw new Error("contactId is required"); const contact = await prisma.contact.findFirst({ where: { id: contactId, teamId: ctx.teamId, }, select: { id: true, name: true }, }); if (!contact) throw new Error("contact not found"); const limitRaw = Number(limitInput ?? 400); const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(2000, Math.trunc(limitRaw))) : 400; const hiddenPrefRows = await prisma.contactInboxPreference.findMany({ where: { teamId: ctx.teamId, userId: ctx.userId, isHidden: true, }, select: { contactInboxId: true }, }); const hiddenInboxIds = hiddenPrefRows.map((row) => row.contactInboxId); const messageWhere = visibleMessageWhere(hiddenInboxIds); const [messagesRawDesc, timelineRowsDesc] = await Promise.all([ prisma.contactMessage.findMany({ where: { contactId: contact.id, contact: { teamId: ctx.teamId }, ...(messageWhere ?? {}), }, orderBy: [{ occurredAt: "desc" }, { createdAt: "desc" }], take: limit, include: { contactInbox: { select: { id: true, sourceExternalId: true, title: true } }, }, }), prisma.clientTimelineEntry.findMany({ where: { teamId: ctx.teamId, contactId: contact.id, }, orderBy: [{ datetime: "desc" }, { createdAt: "desc" }], take: limit, }), ]); const messagesRaw = [...messagesRawDesc].reverse(); const timelineRows = [...timelineRowsDesc].reverse(); let omniMessagesRaw: Array<{ id: string; contactId: string; channel: string; direction: string; text: string; rawJson: unknown; status: string; occurredAt: Date; updatedAt: Date; }> = []; if (messagesRaw.length) { const minOccurredAt = messagesRaw[0]?.occurredAt ?? new Date(); const maxOccurredAt = messagesRaw[messagesRaw.length - 1]?.occurredAt ?? new Date(); const fromOccurredAt = new Date(minOccurredAt.getTime() - 5 * 60 * 1000); const toOccurredAt = new Date(maxOccurredAt.getTime() + 5 * 60 * 1000); omniMessagesRaw = await prisma.omniMessage.findMany({ where: { teamId: ctx.teamId, contactId: contact.id, occurredAt: { gte: fromOccurredAt, lte: toOccurredAt, }, }, select: { id: true, contactId: true, channel: true, direction: true, text: true, rawJson: true, status: true, occurredAt: true, updatedAt: true, }, orderBy: [{ occurredAt: "asc" }, { updatedAt: "asc" }], take: Math.max(limit * 2, 300), }); } const omniByKey = new Map(); 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(); const resolveDeliveryStatus = (m: (typeof messagesRaw)[number]) => { if (m.kind !== "MESSAGE") return null; const key = [m.contactId, m.channel, m.direction, m.content.trim()].join("|"); const candidates = omniByKey.get(key) ?? []; if (!candidates.length) { if (m.direction === "OUT" && m.channel === "TELEGRAM") return "PENDING"; return null; } const targetMs = m.occurredAt.getTime(); let best: (typeof candidates)[number] | null = null; let bestDiff = Number.POSITIVE_INFINITY; for (const candidate of candidates) { if (consumedOmniMessageIds.has(candidate.id)) continue; const diff = Math.abs(candidate.occurredAt.getTime() - targetMs); if (diff > 5 * 60 * 1000) continue; if (diff < bestDiff) { best = candidate; bestDiff = diff; continue; } if (diff === bestDiff && best && candidate.updatedAt.getTime() > best.updatedAt.getTime()) { best = candidate; } } if (!best) { if (m.direction === "OUT" && m.channel === "TELEGRAM") return "PENDING"; return null; } consumedOmniMessageIds.add(best.id); return best.status; }; const messageItems = messagesRaw.map((m) => ({ id: `message-${m.id}`, contactId: contact.id, contentType: "message", contentId: m.id, datetime: m.occurredAt.toISOString(), message: { id: m.id, at: m.occurredAt.toISOString(), contactId: contact.id, contact: contact.name, contactInboxId: m.contactInboxId ?? "", sourceExternalId: m.contactInbox?.sourceExternalId ?? "", sourceTitle: m.contactInbox?.title ?? "", channel: mapChannel(m.channel), kind: m.kind === "CALL" ? "call" : "message", direction: m.direction === "IN" ? "in" : "out", text: m.content, audioUrl: resolveContactMessageAudioUrl(m), duration: m.durationSec ? new Date(m.durationSec * 1000).toISOString().slice(14, 19) : "", waveform: Array.isArray(m.waveformJson) ? m.waveformJson.map((value) => Number(value)).filter((value) => Number.isFinite(value)) : [], transcript: Array.isArray(m.transcriptJson) ? ((m.transcriptJson as any) as string[]) : [], deliveryStatus: resolveDeliveryStatus(m), }, })); const calendarIds: string[] = []; const documentIds: string[] = []; const recommendationIds: string[] = []; for (const row of timelineRows) { if (row.contentType === "CALENDAR_EVENT") { calendarIds.push(row.contentId); continue; } if (row.contentType === "DOCUMENT") { documentIds.push(row.contentId); continue; } if (row.contentType === "RECOMMENDATION") { recommendationIds.push(row.contentId); } } const [calendarRows, documentRows, recommendationRows] = await Promise.all([ calendarIds.length ? prisma.calendarEvent.findMany({ where: { id: { in: calendarIds }, teamId: ctx.teamId, contactId: contact.id, }, }) : Promise.resolve([]), documentIds.length ? prisma.workspaceDocument.findMany({ where: { id: { in: documentIds }, teamId: ctx.teamId, }, }) : Promise.resolve([]), recommendationIds.length ? prisma.feedCard.findMany({ where: { id: { in: recommendationIds }, teamId: ctx.teamId, contactId: contact.id, }, }) : Promise.resolve([]), ]); const calendarById = new Map( calendarRows.map((row) => [ row.id, { id: row.id, title: row.title, start: row.startsAt.toISOString(), end: (row.endsAt ?? row.startsAt).toISOString(), contact: contact.name, note: row.note ?? "", isArchived: Boolean(row.isArchived), createdAt: row.createdAt.toISOString(), archiveNote: row.archiveNote ?? "", archivedAt: row.archivedAt?.toISOString() ?? "", }, ]), ); const documentById = new Map( documentRows.map((row) => [ row.id, { id: row.id, title: row.title, type: row.type, owner: row.owner, scope: row.scope, updatedAt: row.updatedAt.toISOString(), summary: row.summary, body: row.body, }, ]), ); const recommendationById = new Map( recommendationRows.map((row) => [ row.id, { id: row.id, at: row.happenedAt.toISOString(), contact: contact.name, text: row.text, proposal: { title: ((row.proposalJson as any)?.title ?? "") as string, details: (Array.isArray((row.proposalJson as any)?.details) ? (row.proposalJson as any).details : []) as string[], key: ((row.proposalJson as any)?.key ?? "") as string, }, decision: row.decision === "ACCEPTED" ? "accepted" : row.decision === "REJECTED" ? "rejected" : "pending", decisionNote: row.decisionNote ?? "", }, ]), ); const timelineItems = timelineRows .map((row) => { const base = { id: row.id, contactId: row.contactId, contentType: mapTimelineContentType(row.contentType as ClientTimelineContentType), contentId: row.contentId, datetime: row.datetime.toISOString(), }; if (row.contentType === "CALENDAR_EVENT") { const event = calendarById.get(row.contentId); if (!event) return null; return { ...base, calendarEvent: event, }; } if (row.contentType === "DOCUMENT") { const document = documentById.get(row.contentId); if (!document) return null; return { ...base, document, }; } if (row.contentType === "RECOMMENDATION") { const recommendation = recommendationById.get(row.contentId); if (!recommendation) return null; return { ...base, recommendation, }; } return null; }) .filter((item) => item !== null) as Array & { datetime: string }>; return [...messageItems, ...timelineItems] .sort((a, b) => a.datetime.localeCompare(b.datetime)) .slice(-limit); } async function createCalendarEvent(auth: AuthContext | null, input: { title: string; start: string; end?: string; contact?: string; note?: string; archived?: boolean; archiveNote?: string; }) { const ctx = requireAuth(auth); const title = (input?.title ?? "").trim(); const start = input?.start ? new Date(input.start) : null; const end = input?.end ? new Date(input.end) : null; if (!title) throw new Error("title is required"); if (!start || Number.isNaN(start.getTime())) throw new Error("start is invalid"); const contactName = (input?.contact ?? "").trim(); const contact = contactName ? await prisma.contact.findFirst({ where: { teamId: ctx.teamId, name: contactName }, select: { id: true, name: true } }) : null; const created = await prisma.calendarEvent.create({ data: (() => { const archived = Boolean(input?.archived); const note = (input?.note ?? "").trim() || null; const archiveNote = (input?.archiveNote ?? "").trim() || note; return { teamId: ctx.teamId, contactId: contact?.id ?? null, title, startsAt: start, endsAt: end && !Number.isNaN(end.getTime()) ? end : null, note, isArchived: archived, archiveNote: archived ? archiveNote : null, archivedAt: archived ? new Date() : null, }; })(), include: { contact: { select: { name: true } } }, }); return { id: created.id, title: created.title, start: created.startsAt.toISOString(), end: (created.endsAt ?? created.startsAt).toISOString(), contact: created.contact?.name ?? "", note: created.note ?? "", isArchived: Boolean(created.isArchived), createdAt: created.createdAt.toISOString(), archiveNote: created.archiveNote ?? "", archivedAt: created.archivedAt?.toISOString() ?? "", }; } async function archiveCalendarEvent(auth: AuthContext | null, input: { id: string; archiveNote?: string }) { const ctx = requireAuth(auth); const id = String(input?.id ?? "").trim(); const archiveNote = String(input?.archiveNote ?? "").trim(); if (!id) throw new Error("id is required"); const existing = await prisma.calendarEvent.findFirst({ where: { id, teamId: ctx.teamId }, select: { id: true }, }); if (!existing) throw new Error("event not found"); const updated = await prisma.calendarEvent.update({ where: { id }, data: { isArchived: true, archiveNote: archiveNote || null, archivedAt: new Date(), }, include: { contact: { select: { name: true } } }, }); return { id: updated.id, title: updated.title, start: updated.startsAt.toISOString(), end: (updated.endsAt ?? updated.startsAt).toISOString(), contact: updated.contact?.name ?? "", note: updated.note ?? "", isArchived: Boolean(updated.isArchived), createdAt: updated.createdAt.toISOString(), archiveNote: updated.archiveNote ?? "", archivedAt: updated.archivedAt?.toISOString() ?? "", }; } function parseOptionalDealMoney(value: unknown, field: "amount" | "paidAmount") { if (value === null) return null; const num = Number(value); if (!Number.isFinite(num)) throw new Error(`${field} is invalid`); if (!Number.isInteger(num)) throw new Error(`${field} must be an integer`); if (num < 0) throw new Error(`${field} must be greater than or equal to 0`); return num; } async function updateDeal(auth: AuthContext | null, input: { id: string; stage?: string | null; amount?: number | null; paidAmount?: number | null; }) { const ctx = requireAuth(auth); const id = String(input?.id ?? "").trim(); if (!id) throw new Error("id is required"); const existing = await prisma.deal.findFirst({ where: { id, teamId: ctx.teamId }, select: { id: true, amount: true, paidAmount: true }, }); if (!existing) throw new Error("deal not found"); const data: { stage?: string; amount?: number | null; paidAmount?: number | null; } = {}; let nextAmount = existing.amount; let nextPaidAmount = existing.paidAmount; if ("stage" in input) { const stage = String(input.stage ?? "").trim(); if (!stage) throw new Error("stage is required"); data.stage = stage; } if ("amount" in input) { const amount = parseOptionalDealMoney(input.amount, "amount"); data.amount = amount; nextAmount = amount; } if ("paidAmount" in input) { const paidAmount = parseOptionalDealMoney(input.paidAmount, "paidAmount"); data.paidAmount = paidAmount; nextPaidAmount = paidAmount; } if (nextAmount === null && nextPaidAmount !== null) { throw new Error("paidAmount requires amount"); } if (nextAmount !== null && nextPaidAmount !== null && nextPaidAmount > nextAmount) { throw new Error("paidAmount cannot exceed amount"); } if (!Object.keys(data).length) { const current = await prisma.deal.findFirst({ where: { id: existing.id, teamId: ctx.teamId }, include: { contact: { select: { name: true } }, steps: { orderBy: [{ order: "asc" }, { createdAt: "asc" }] }, }, }); if (!current) throw new Error("deal not found"); return mapDealRecord(current); } const updated = await prisma.deal.update({ where: { id: existing.id }, data, include: { contact: { select: { name: true } }, steps: { orderBy: [{ order: "asc" }, { createdAt: "asc" }] }, }, }); return mapDealRecord(updated); } async function createDeal(auth: AuthContext | null, input: { contactId: string; title: string; stage?: string | null; amount?: number | null; paidAmount?: number | null; nextStep?: string | null; summary?: string | null; }) { const ctx = requireAuth(auth); const contactId = String(input?.contactId ?? "").trim(); const title = String(input?.title ?? "").trim(); const stage = String(input?.stage ?? "").trim() || "Новый"; const nextStep = String(input?.nextStep ?? "").trim() || null; const summary = String(input?.summary ?? "").trim() || null; if (!contactId) throw new Error("contactId is required"); if (!title) throw new Error("title is required"); if (!stage) throw new Error("stage is required"); const contact = await prisma.contact.findFirst({ where: { id: contactId, teamId: ctx.teamId }, select: { id: true }, }); if (!contact) throw new Error("contact not found"); const amount = "amount" in input ? parseOptionalDealMoney(input.amount ?? null, "amount") : null; const paidAmount = "paidAmount" in input ? parseOptionalDealMoney(input.paidAmount ?? null, "paidAmount") : null; if (amount === null && paidAmount !== null) { throw new Error("paidAmount requires amount"); } if (amount !== null && paidAmount !== null && paidAmount > amount) { throw new Error("paidAmount cannot exceed amount"); } const created = await prisma.deal.create({ data: { teamId: ctx.teamId, contactId: contact.id, title, stage, amount, paidAmount, nextStep, summary, }, include: { contact: { select: { name: true } }, steps: { orderBy: [{ order: "asc" }, { createdAt: "asc" }] }, }, }); return mapDealRecord(created); } async function createCommunication(auth: AuthContext | null, input: { contact: string; channel?: string; kind?: "message" | "call"; direction?: "in" | "out"; text?: string; audioUrl?: string; at?: string; durationSec?: number; transcript?: string[]; }) { const ctx = requireAuth(auth); const contactName = (input?.contact ?? "").trim(); if (!contactName) throw new Error("contact is required"); const contact = await prisma.contact.findFirst({ where: { teamId: ctx.teamId, name: contactName }, select: { id: true }, }); if (!contact) throw new Error("contact not found"); const occurredAt = input?.at ? new Date(input.at) : new Date(); if (Number.isNaN(occurredAt.getTime())) throw new Error("at is invalid"); const kind = input?.kind === "call" ? "CALL" : "MESSAGE"; const direction = input?.direction === "in" ? "IN" : "OUT"; const channel = toDbChannel(input?.channel ?? "Phone") as any; const content = (input?.text ?? "").trim(); let contactInboxId: string | null = null; if (kind === "MESSAGE" && channel === "TELEGRAM" && direction === "OUT") { const thread = await prisma.omniThread.findFirst({ where: { teamId: ctx.teamId, contactId: contact.id, channel: "TELEGRAM", }, orderBy: { updatedAt: "desc" }, select: { id: true, externalChatId: true, title: true }, }); if (!thread) { throw new Error("telegram thread not found for contact"); } const inbox = await upsertContactInbox({ teamId: ctx.teamId, contactId: contact.id, channel: "TELEGRAM", sourceExternalId: thread.externalChatId, title: thread.title ?? null, }); contactInboxId = inbox.id; const omniMessage = await prisma.omniMessage.create({ data: { teamId: ctx.teamId, contactId: contact.id, threadId: thread.id, direction: "OUT", channel: "TELEGRAM", status: "PENDING", text: content, providerMessageId: null, providerUpdateId: null, rawJson: { version: 1, source: "graphql.createCommunication", provider: "telegram_business", normalized: { channel: "TELEGRAM", direction: "OUT", text: content, }, payloadNormalized: { contactId: contact.id, threadId: thread.id, text: content, }, enqueuedAt: new Date().toISOString(), }, occurredAt, }, select: { id: true }, }); try { await enqueueTelegramSend({ omniMessageId: omniMessage.id }); } catch (error) { const message = error instanceof Error ? error.message : String(error); const existingOmni = await prisma.omniMessage.findUnique({ where: { id: omniMessage.id }, select: { rawJson: true }, }); await prisma.omniMessage.update({ where: { id: omniMessage.id }, data: { status: "FAILED", rawJson: { ...asObject(existingOmni?.rawJson), source: "graphql.createCommunication", delivery: { status: "FAILED", error: { message }, failedAt: new Date().toISOString(), }, }, }, }).catch(() => undefined); throw new Error(`telegram enqueue failed: ${message}`); } } else { const existingInbox = await prisma.contactInbox.findFirst({ where: { teamId: ctx.teamId, contactId: contact.id, channel, }, orderBy: { updatedAt: "desc" }, select: { id: true }, }); contactInboxId = existingInbox?.id ?? null; } const created = await prisma.contactMessage.create({ data: { contactId: contact.id, contactInboxId, kind, direction, channel, content, durationSec: typeof input?.durationSec === "number" ? input.durationSec : null, transcriptJson: Array.isArray(input?.transcript) ? input.transcript : undefined, occurredAt, }, }); return { ok: true, id: created.id }; } async function createWorkspaceDocument(auth: AuthContext | null, input: { title: string; owner?: string; scope: string; summary: string; body?: string; }) { const ctx = requireAuth(auth); const title = String(input?.title ?? "").trim(); const scope = String(input?.scope ?? "").trim(); const summary = String(input?.summary ?? "").trim(); const body = String(input?.body ?? "").trim(); const owner = String(input?.owner ?? "").trim() || "Workspace"; if (!title) throw new Error("title is required"); if (!scope) throw new Error("scope is required"); if (!summary) throw new Error("summary is required"); const created = await prisma.workspaceDocument.create({ data: { teamId: ctx.teamId, title, type: "Template", owner, scope, summary, body: body || summary, }, }); const linkedScope = parseContactDocumentScope(created.scope); if (linkedScope?.contactId) { const linkedContact = await prisma.contact.findFirst({ where: { id: linkedScope.contactId, teamId: ctx.teamId, }, select: { id: true }, }); if (linkedContact) { await prisma.clientTimelineEntry.upsert({ where: { teamId_contentType_contentId: { teamId: ctx.teamId, contentType: "DOCUMENT", contentId: created.id, }, }, create: { teamId: ctx.teamId, contactId: linkedContact.id, contentType: "DOCUMENT", contentId: created.id, datetime: new Date(), }, update: { contactId: linkedContact.id, datetime: new Date(), }, select: { id: true }, }); } } return { id: created.id, title: created.title, type: created.type, owner: created.owner, scope: created.scope, updatedAt: created.updatedAt.toISOString(), summary: created.summary, body: created.body, }; } async function deleteWorkspaceDocument(auth: AuthContext | null, documentIdInput: string) { const ctx = requireAuth(auth); const documentId = String(documentIdInput ?? "").trim(); if (!documentId) throw new Error("id is required"); const existing = await prisma.workspaceDocument.findFirst({ where: { id: documentId, teamId: ctx.teamId, }, select: { id: true }, }); if (!existing) throw new Error("document not found"); await prisma.$transaction([ prisma.workspaceDocument.delete({ where: { id: existing.id }, }), prisma.clientTimelineEntry.deleteMany({ where: { teamId: ctx.teamId, contentType: "DOCUMENT", contentId: existing.id, }, }), ]); await fs.rm(datasetRoot({ teamId: ctx.teamId, userId: ctx.userId }), { recursive: true, force: true, }).catch(() => undefined); return { ok: true, id: existing.id }; } async function setContactInboxHidden( auth: AuthContext | null, input: { inboxId: string; hidden: boolean }, ) { const ctx = requireAuth(auth); const inboxId = String(input?.inboxId ?? "").trim(); if (!inboxId) throw new Error("inboxId is required"); const inbox = await prisma.contactInbox.findFirst({ where: { id: inboxId, teamId: ctx.teamId, }, select: { id: true }, }); if (!inbox) throw new Error("inbox not found"); const hidden = Boolean(input?.hidden); await prisma.contactInboxPreference.upsert({ where: { userId_contactInboxId: { userId: ctx.userId, contactInboxId: inbox.id, }, }, create: { teamId: ctx.teamId, userId: ctx.userId, contactInboxId: inbox.id, isHidden: hidden, }, update: { isHidden: hidden, }, }); await fs.rm(datasetRoot({ teamId: ctx.teamId, userId: ctx.userId }), { recursive: true, force: true, }).catch(() => undefined); return { ok: true }; } async function markThreadRead( auth: AuthContext | null, input: { contactId: string }, ) { const ctx = requireAuth(auth); const contactId = String(input?.contactId ?? "").trim(); if (!contactId) throw new Error("contactId is required"); await prisma.contactThreadRead.upsert({ where: { userId_contactId: { userId: ctx.userId, contactId } }, create: { teamId: ctx.teamId, userId: ctx.userId, contactId, readAt: new Date() }, update: { readAt: new Date() }, }); return { ok: true }; } async function updateCommunicationTranscript(auth: AuthContext | null, id: string, transcript: string[]) { const ctx = requireAuth(auth); const messageId = String(id ?? "").trim(); if (!messageId) throw new Error("id is required"); const lines = Array.isArray(transcript) ? transcript.map((line) => String(line ?? "").trim()).filter(Boolean) : []; const updated = await prisma.contactMessage.updateMany({ where: { id: messageId, contact: { teamId: ctx.teamId }, }, data: { transcriptJson: lines, }, }); if (!updated.count) throw new Error("communication not found"); return { ok: true, id: messageId }; } async function updateFeedDecision(auth: AuthContext | null, id: string, decision: "accepted" | "rejected" | "pending", decisionNote?: string) { const ctx = requireAuth(auth); if (!id) throw new Error("id is required"); if (!decision) throw new Error("decision is required"); const nextDecision = decision === "accepted" ? "ACCEPTED" : decision === "rejected" ? "REJECTED" : "PENDING"; const res = await prisma.feedCard.updateMany({ where: { id, teamId: ctx.teamId }, data: { decision: nextDecision, decisionNote: decisionNote ?? null }, }); if (res.count === 0) throw new Error("feed card not found"); return { ok: true, id }; } function getChangeSetFromPlanJson(planJson: unknown): ChangeSet | null { const debug = (planJson as any) ?? {}; const cs = debug?.changeSet; if (!cs || typeof cs !== "object") return null; if (!cs.id || !Array.isArray(cs.items) || !Array.isArray(cs.undo)) return null; return cs as ChangeSet; } function getMessageKindFromPlanJson(planJson: unknown): string | null { const debug = (planJson as any) ?? {}; const kind = debug?.messageKind; if (!kind || typeof kind !== "string") return null; return kind; } function renderChangeSetSummary(changeSet: ChangeSet): string { const totals = { created: 0, updated: 0, deleted: 0 }; for (const item of changeSet.items) { if (item.action === "created") totals.created += 1; else if (item.action === "updated") totals.updated += 1; else if (item.action === "deleted") totals.deleted += 1; } return [ "Technical change summary", `Total: ${changeSet.items.length} · Created: ${totals.created} · Updated: ${totals.updated} · Archived: ${totals.deleted}`, ].join("\n"); } async function findLatestChangeCarrierMessage(auth: AuthContext | null) { const ctx = requireAuth(auth); const items = await prisma.aiMessage.findMany({ where: { teamId: ctx.teamId, conversationId: ctx.conversationId, role: "ASSISTANT", }, orderBy: { createdAt: "desc" }, take: 30, }); for (const item of items) { const changeSet = getChangeSetFromPlanJson(item.planJson); if (!changeSet) continue; if (changeSet.status === "rolled_back") continue; return { item, changeSet }; } return null; } async function findChangeCarrierMessageByChangeSetId(auth: AuthContext | null, changeSetId: string) { const ctx = requireAuth(auth); const targetId = String(changeSetId ?? "").trim(); if (!targetId) return null; const items = await prisma.aiMessage.findMany({ where: { teamId: ctx.teamId, conversationId: ctx.conversationId, role: "ASSISTANT", }, orderBy: { createdAt: "desc" }, take: 200, }); for (const item of items) { const changeSet = getChangeSetFromPlanJson(item.planJson); if (!changeSet) continue; if (changeSet.id !== targetId) continue; return { item, changeSet }; } return null; } async function confirmLatestChangeSet(auth: AuthContext | null) { const found = await findLatestChangeCarrierMessage(auth); if (!found) return { ok: true }; const { item, changeSet } = found; if (changeSet.status === "confirmed") return { ok: true }; const next = { ...((item.planJson as any) ?? {}), changeSet: { ...changeSet, status: "confirmed", confirmedAt: new Date().toISOString(), }, }; await prisma.aiMessage.update({ where: { id: item.id }, data: { planJson: next as any }, }); return { ok: true }; } async function rollbackLatestChangeSet(auth: AuthContext | null) { const ctx = requireAuth(auth); const found = await findLatestChangeCarrierMessage(ctx); if (!found) return { ok: true }; const { item, changeSet } = found; if (changeSet.status === "rolled_back") return { ok: true }; await rollbackChangeSet(prisma, ctx.teamId, changeSet); const next = { ...((item.planJson as any) ?? {}), changeSet: { ...changeSet, status: "rolled_back", rolledBackAt: new Date().toISOString(), rolledBackItemIds: Array.isArray(changeSet.items) ? changeSet.items .map((changeItem: any, idx: number) => String(changeItem?.id ?? `legacy-${idx}`)) .filter(Boolean) : [], }, }; await prisma.aiMessage.update({ where: { id: item.id }, data: { planJson: next as any }, }); return { ok: true }; } async function rollbackChangeSetItemsMutation(auth: AuthContext | null, changeSetId: string, itemIds: string[]) { const ctx = requireAuth(auth); const found = await findChangeCarrierMessageByChangeSetId(ctx, changeSetId); if (!found) return { ok: true }; const { item, changeSet } = found; if (changeSet.status === "rolled_back") return { ok: true }; const selectedIds = [...new Set((itemIds ?? []).map((id) => String(id ?? "").trim()).filter(Boolean))]; if (!selectedIds.length) return { ok: true }; await rollbackChangeSetItems(prisma, ctx.teamId, changeSet, selectedIds); const allIds = Array.isArray(changeSet.items) ? changeSet.items .map((changeItem: any, idx: number) => String(changeItem?.id ?? `legacy-${idx}`)) .filter(Boolean) : []; const prevRolledBack = Array.isArray((changeSet as any)?.rolledBackItemIds) ? ((changeSet as any).rolledBackItemIds as string[]).map((id) => String(id)) : []; const nextRolledBackSet = new Set([...prevRolledBack, ...selectedIds]); const nextRolledBack = [...nextRolledBackSet]; const allRolledBack = allIds.length > 0 && allIds.every((id) => nextRolledBackSet.has(id)); const next = { ...((item.planJson as any) ?? {}), changeSet: { ...changeSet, status: allRolledBack ? "rolled_back" : "pending", rolledBackAt: allRolledBack ? new Date().toISOString() : null, rolledBackItemIds: nextRolledBack, }, }; await prisma.aiMessage.update({ where: { id: item.id }, data: { planJson: next as any }, }); return { ok: true }; } async function sendPilotMessage(auth: AuthContext | null, textInput: string) { const ctx = requireAuth(auth); const text = (textInput ?? "").trim(); if (!text) throw new Error("text is required"); const requestId = `req_${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`; const snapshotBefore = await captureSnapshot(prisma, ctx.teamId); await persistAiMessage({ teamId: ctx.teamId, conversationId: ctx.conversationId, authorUserId: ctx.userId, role: "USER", text, requestId, eventType: "user", phase: "final", transient: false, }); const reply = await runCrmAgentFor({ teamId: ctx.teamId, userId: ctx.userId, userText: text, requestId, conversationId: ctx.conversationId, onTrace: async () => {}, }); const snapshotAfter = await captureSnapshot(prisma, ctx.teamId); const changeSet = buildChangeSet(snapshotBefore, snapshotAfter); await persistAiMessage({ teamId: ctx.teamId, conversationId: ctx.conversationId, authorUserId: null, role: "ASSISTANT", text: reply.text, requestId, eventType: "assistant", phase: "final", transient: false, }); if (changeSet) { await persistAiMessage({ teamId: ctx.teamId, conversationId: ctx.conversationId, authorUserId: null, role: "ASSISTANT", text: renderChangeSetSummary(changeSet), requestId, eventType: "note", phase: "final", transient: false, messageKind: "change_set_summary", changeSet, }); } return { ok: true }; } async function logPilotNote(auth: AuthContext | null, textInput: string) { const ctx = requireAuth(auth); const text = (textInput ?? "").trim(); if (!text) throw new Error("text is required"); await persistAiMessage({ teamId: ctx.teamId, conversationId: ctx.conversationId, authorUserId: null, role: "ASSISTANT", text, }); return { ok: true }; } async function toggleContactPin(auth: AuthContext | null, contactInput: string, textInput: string) { const ctx = requireAuth(auth); const contactName = (contactInput ?? "").trim(); const text = (textInput ?? "").replace(/\s+/g, " ").trim(); if (!contactName) throw new Error("contact is required"); if (!text) throw new Error("text is required"); const contact = await prisma.contact.findFirst({ where: { teamId: ctx.teamId, name: contactName }, select: { id: true }, }); if (!contact) throw new Error("contact not found"); const existing = await prisma.contactPin.findFirst({ where: { teamId: ctx.teamId, contactId: contact.id, text }, select: { id: true }, }); if (existing) { await prisma.contactPin.deleteMany({ where: { teamId: ctx.teamId, contactId: contact.id, text }, }); return { ok: true, pinned: false }; } await prisma.contactPin.create({ data: { teamId: ctx.teamId, contactId: contact.id, text, }, }); return { ok: true, pinned: true }; } export const crmGraphqlSchema = buildSchema(` type Query { me: MePayload! chatMessages: [PilotMessage!]! chatConversations: [Conversation!]! contacts: [Contact!]! communications: [CommItem!]! contactInboxes: [ContactInbox!]! calendar(dateRange: CalendarDateRange): [CalendarEvent!]! deals: [Deal!]! feed: [FeedCard!]! pins: [CommPin!]! documents: [WorkspaceDocument!]! getClientTimeline(contactId: ID!, limit: Int): [ClientTimelineItem!]! } type Mutation { login(phone: String!, password: String!): MutationResult! logout: MutationResult! createChatConversation(title: String): Conversation! selectChatConversation(id: ID!): MutationResult! archiveChatConversation(id: ID!): MutationResult! sendPilotMessage(text: String!): MutationResult! confirmLatestChangeSet: MutationResult! rollbackLatestChangeSet: MutationResult! rollbackChangeSetItems(changeSetId: ID!, itemIds: [ID!]!): MutationResult! logPilotNote(text: String!): MutationResult! toggleContactPin(contact: String!, text: String!): PinToggleResult! createCalendarEvent(input: CreateCalendarEventInput!): CalendarEvent! archiveCalendarEvent(input: ArchiveCalendarEventInput!): CalendarEvent! createCommunication(input: CreateCommunicationInput!): MutationWithIdResult! createDeal(input: CreateDealInput!): Deal! updateDeal(input: UpdateDealInput!): Deal! createWorkspaceDocument(input: CreateWorkspaceDocumentInput!): WorkspaceDocument! deleteWorkspaceDocument(id: ID!): MutationWithIdResult! updateCommunicationTranscript(id: ID!, transcript: [String!]!): MutationWithIdResult! updateFeedDecision(id: ID!, decision: String!, decisionNote: String): MutationWithIdResult! setContactInboxHidden(inboxId: ID!, hidden: Boolean!): MutationResult! markThreadRead(contactId: ID!): MutationResult! } type MutationResult { ok: Boolean! } type MutationWithIdResult { ok: Boolean! id: ID! } type PinToggleResult { ok: Boolean! pinned: Boolean! } input CalendarDateRange { from: String to: String } input CreateCalendarEventInput { title: String! start: String! end: String contact: String note: String archived: Boolean archiveNote: String } input ArchiveCalendarEventInput { id: ID! archiveNote: String } input CreateCommunicationInput { contact: String! channel: String kind: String direction: String text: String audioUrl: String at: String durationSec: Int transcript: [String!] } input CreateWorkspaceDocumentInput { title: String! owner: String scope: String! summary: String! body: String } input CreateDealInput { contactId: ID! title: String! stage: String amount: Int paidAmount: Int nextStep: String summary: String } input UpdateDealInput { id: ID! stage: String amount: Int paidAmount: Int } type MePayload { user: MeUser! team: MeTeam! conversation: Conversation! } type MeUser { id: ID! phone: String! name: String! } type MeTeam { id: ID! name: String! } type Conversation { id: ID! title: String! createdAt: String! updatedAt: String! lastMessageAt: String lastMessageText: String } type PilotMessage { id: ID! role: String! text: String! messageKind: String requestId: String eventType: String phase: String transient: Boolean thinking: [String!]! tools: [String!]! toolRuns: [PilotToolRun!]! changeSetId: String changeStatus: String changeSummary: String changeItems: [PilotChangeItem!]! createdAt: String! } type PilotChangeItem { id: ID! entity: String! entityId: String action: String! title: String! before: String! after: String! rolledBack: Boolean! } type PilotToolRun { name: String! status: String! input: String! output: String! at: String! } type ClientTimelineItem { id: ID! contactId: String! contentType: String! contentId: String! datetime: String! message: CommItem calendarEvent: CalendarEvent recommendation: FeedCard document: WorkspaceDocument } type Contact { id: ID! name: String! avatar: String! channels: [String!]! lastContactAt: String! lastMessageText: String! lastMessageChannel: String! hasUnread: Boolean! description: String! } type CommItem { id: ID! at: String! contactId: String! contact: String! contactInboxId: String! sourceExternalId: String! sourceTitle: String! channel: String! kind: String! direction: String! text: String! audioUrl: String! duration: String! waveform: [Float!]! transcript: [String!]! deliveryStatus: String } type ContactInbox { id: ID! contactId: String! contactName: String! channel: String! sourceExternalId: String! title: String! isHidden: Boolean! lastMessageAt: String! updatedAt: String! } type CalendarEvent { id: ID! title: String! start: String! end: String! contact: String! note: String! isArchived: Boolean! createdAt: String! archiveNote: String! archivedAt: String! } type Deal { id: ID! contact: String! title: String! stage: String! amount: String! paidAmount: String! nextStep: String! summary: String! currentStepId: String! steps: [DealStep!]! } type DealStep { id: ID! title: String! description: String! status: String! dueAt: String! order: Int! completedAt: String! } type FeedCard { id: ID! at: String! contact: String! text: String! proposal: FeedProposal! decision: String! decisionNote: String! } type FeedProposal { title: String! details: [String!]! key: String! } type CommPin { id: ID! contact: String! text: String! } type WorkspaceDocument { id: ID! title: String! type: String! owner: String! scope: String! updatedAt: String! summary: String! body: String! } `); export const crmGraphqlRoot = { me: async (_args: unknown, context: GraphQLContext) => getAuthPayload(context.auth), chatMessages: async (_args: unknown, context: GraphQLContext) => getChatMessages(context.auth), chatConversations: async (_args: unknown, context: GraphQLContext) => getChatConversations(context.auth), contacts: async (_args: unknown, context: GraphQLContext) => getContacts(context.auth), communications: async (_args: unknown, context: GraphQLContext) => getCommunications(context.auth), contactInboxes: async (_args: unknown, context: GraphQLContext) => getContactInboxes(context.auth), calendar: async (args: { dateRange?: { from?: string; to?: string } }, context: GraphQLContext) => getCalendar(context.auth, args.dateRange ?? undefined), deals: async (_args: unknown, context: GraphQLContext) => getDeals(context.auth), feed: async (_args: unknown, context: GraphQLContext) => getFeed(context.auth), pins: async (_args: unknown, context: GraphQLContext) => getPins(context.auth), documents: async (_args: unknown, context: GraphQLContext) => getDocuments(context.auth), getClientTimeline: async ( args: { contactId: string; limit?: number }, context: GraphQLContext, ) => getClientTimeline(context.auth, args.contactId, args.limit), login: async (args: { phone: string; password: string }, context: GraphQLContext) => loginWithPassword(context.event, args.phone, args.password), logout: async (_args: unknown, context: GraphQLContext) => { clearAuthSession(context.event); return { ok: true }; }, createChatConversation: async (args: { title?: string }, context: GraphQLContext) => createChatConversation(context.auth, context.event, args.title), selectChatConversation: async (args: { id: string }, context: GraphQLContext) => selectChatConversation(context.auth, context.event, args.id), archiveChatConversation: async (args: { id: string }, context: GraphQLContext) => archiveChatConversation(context.auth, context.event, args.id), sendPilotMessage: async (args: { text: string }, context: GraphQLContext) => sendPilotMessage(context.auth, args.text), confirmLatestChangeSet: async (_args: unknown, context: GraphQLContext) => confirmLatestChangeSet(context.auth), rollbackLatestChangeSet: async (_args: unknown, context: GraphQLContext) => rollbackLatestChangeSet(context.auth), rollbackChangeSetItems: async ( args: { changeSetId: string; itemIds: string[] }, context: GraphQLContext, ) => rollbackChangeSetItemsMutation(context.auth, args.changeSetId, args.itemIds), logPilotNote: async (args: { text: string }, context: GraphQLContext) => logPilotNote(context.auth, args.text), toggleContactPin: async (args: { contact: string; text: string }, context: GraphQLContext) => toggleContactPin(context.auth, args.contact, args.text), createCalendarEvent: async (args: { input: { title: string; start: string; end?: string; contact?: string; note?: string; archived?: boolean; archiveNote?: string } }, context: GraphQLContext) => createCalendarEvent(context.auth, args.input), archiveCalendarEvent: async (args: { input: { id: string; archiveNote?: string } }, context: GraphQLContext) => archiveCalendarEvent(context.auth, args.input), createCommunication: async ( args: { input: { contact: string; channel?: string; kind?: "message" | "call"; direction?: "in" | "out"; text?: string; audioUrl?: string; at?: string; durationSec?: number; transcript?: string[]; }; }, context: GraphQLContext, ) => createCommunication(context.auth, args.input), createDeal: async ( args: { input: { contactId: string; title: string; stage?: string; amount?: number | null; paidAmount?: number | null; nextStep?: string; summary?: string; }; }, context: GraphQLContext, ) => createDeal(context.auth, args.input), updateDeal: async ( args: { input: { id: string; stage?: string; amount?: number | null; paidAmount?: number | null } }, context: GraphQLContext, ) => updateDeal(context.auth, args.input), createWorkspaceDocument: async ( args: { input: { title: string; owner?: string; scope: string; summary: string; body?: string; }; }, context: GraphQLContext, ) => createWorkspaceDocument(context.auth, args.input), deleteWorkspaceDocument: async ( args: { id: string }, context: GraphQLContext, ) => deleteWorkspaceDocument(context.auth, args.id), updateCommunicationTranscript: async ( args: { id: string; transcript: string[] }, context: GraphQLContext, ) => updateCommunicationTranscript(context.auth, args.id, args.transcript), updateFeedDecision: async ( args: { id: string; decision: "accepted" | "rejected" | "pending"; decisionNote?: string }, context: GraphQLContext, ) => updateFeedDecision(context.auth, args.id, args.decision, args.decisionNote), setContactInboxHidden: async ( args: { inboxId: string; hidden: boolean }, context: GraphQLContext, ) => setContactInboxHidden(context.auth, args), markThreadRead: async ( args: { contactId: string }, context: GraphQLContext, ) => markThreadRead(context.auth, args), };