feat(chat): threads UI + graphql flow + qwen/gigachat integration

This commit is contained in:
Ruslan Bakiev
2026-02-18 19:41:34 +07:00
parent 676bb9e105
commit d7af2d0a46
21 changed files with 2432 additions and 437 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
query ChatConversationsQuery {
chatConversations {
id
title
createdAt
updatedAt
lastMessageAt
lastMessageText
}
}

View File

@@ -0,0 +1,18 @@
query ChatMessagesQuery {
chatMessages {
id
role
text
plan
thinking
tools
toolRuns {
name
status
input
output
at
}
createdAt
}
}

View File

@@ -0,0 +1,10 @@
mutation CreateCalendarEventMutation($input: CreateCalendarEventInput!) {
createCalendarEvent(input: $input) {
id
title
start
end
contact
note
}
}

View File

@@ -0,0 +1,10 @@
mutation CreateChatConversationMutation($title: String) {
createChatConversation(title: $title) {
id
title
createdAt
updatedAt
lastMessageAt
lastMessageText
}
}

View File

@@ -0,0 +1,6 @@
mutation CreateCommunicationMutation($input: CreateCommunicationInput!) {
createCommunication(input: $input) {
ok
id
}
}

View File

@@ -0,0 +1,73 @@
query DashboardQuery {
dashboard {
contacts {
id
name
avatar
company
country
location
channels
lastContactAt
description
}
communications {
id
at
contactId
contact
channel
kind
direction
text
duration
transcript
}
calendar {
id
title
start
end
contact
note
}
deals {
id
contact
title
company
stage
amount
nextStep
summary
}
feed {
id
at
contact
text
proposal {
title
details
key
}
decision
decisionNote
}
pins {
id
contact
text
}
documents {
id
title
type
owner
scope
updatedAt
summary
body
}
}
}

View File

@@ -0,0 +1,5 @@
mutation LogPilotNoteMutation($text: String!) {
logPilotNote(text: $text) {
ok
}
}

View File

@@ -0,0 +1,5 @@
mutation LoginMutation($phone: String!, $password: String!) {
login(phone: $phone, password: $password) {
ok
}
}

View File

@@ -0,0 +1,5 @@
mutation LogoutMutation {
logout {
ok
}
}

View File

@@ -0,0 +1,17 @@
query MeQuery {
me {
user {
id
phone
name
}
team {
id
name
}
conversation {
id
title
}
}
}

View File

@@ -0,0 +1,5 @@
mutation SelectChatConversationMutation($id: ID!) {
selectChatConversation(id: $id) {
ok
}
}

View File

@@ -0,0 +1,5 @@
mutation SendPilotMessageMutation($text: String!) {
sendPilotMessage(text: $text) {
ok
}
}

View File

@@ -0,0 +1,6 @@
mutation UpdateFeedDecisionMutation($id: ID!, $decision: String!, $decisionNote: String) {
updateFeedDecision(id: $id, decision: $decision, decisionNote: $decisionNote) {
ok
id
}
}

View File

@@ -19,6 +19,14 @@ export type AgentReply = {
text: string;
plan: string[];
tools: string[];
thinking?: string[];
toolRuns?: Array<{
name: string;
status: "ok" | "error";
input: string;
output: string;
at: string;
}>;
dbWrites?: Array<{ kind: string; detail: string }>;
};
@@ -76,7 +84,14 @@ export async function runCrmAgentFor(
input: { teamId: string; userId: string; userText: string },
): Promise<AgentReply> {
const mode = (process.env.CF_AGENT_MODE ?? "langgraph").toLowerCase();
if (mode !== "rule" && process.env.OPENAI_API_KEY) {
const llmApiKey =
process.env.LLM_API_KEY ||
process.env.OPENAI_API_KEY ||
process.env.DASHSCOPE_API_KEY ||
process.env.QWEN_API_KEY;
const hasGigachat = Boolean((process.env.GIGACHAT_AUTH_KEY ?? "").trim() && (process.env.GIGACHAT_SCOPE ?? "").trim());
if (mode !== "rule" && (llmApiKey || hasGigachat)) {
return runLangGraphCrmAgentFor(input);
}
@@ -109,6 +124,15 @@ export async function runCrmAgentFor(
"Отсортировать и показать топ",
],
tools: ["read index/contacts.json", "read messages/{contactId}.jsonl", "read events/{contactId}.jsonl"],
toolRuns: [
{
name: "dataset:index_contacts",
status: "ok",
input: "index/contacts.json",
output: "Loaded contacts index",
at: new Date().toISOString(),
},
],
text:
`Топ-10 по активности (сообщения + события):\n` +
top.map(formatContactLine).join("\n") +
@@ -163,6 +187,15 @@ export async function runCrmAgentFor(
"Сформировать короткий список действий",
],
tools: ["read index/contacts.json", "read events/{contactId}.jsonl"],
toolRuns: [
{
name: "dataset:query_events",
status: "ok",
input: "events/*.jsonl (today)",
output: `Found ${todayEvents.length} events`,
at: new Date().toISOString(),
},
],
text: lines.join("\n"),
};
}
@@ -171,6 +204,7 @@ export async function runCrmAgentFor(
return {
plan: ["Уточнить цель", "Выбрать данные для анализа", "Предложить план действий и, если нужно, изменения в CRM"],
tools: ["read index/contacts.json (по необходимости)", "search messages/events (по необходимости)"],
toolRuns: [],
text:
"Ок. Скажи, что нужно сделать.\n" +
"Примеры:\n" +
@@ -185,17 +219,38 @@ export async function persistChatMessage(input: {
text: string;
plan?: string[];
tools?: string[];
thinking?: string[];
toolRuns?: Array<{
name: string;
status: "ok" | "error";
input: string;
output: string;
at: string;
}>;
teamId: string;
conversationId: string;
authorUserId?: string | null;
}) {
const hasDebugPayload = Boolean(
(input.plan && input.plan.length) ||
(input.tools && input.tools.length) ||
(input.thinking && input.thinking.length) ||
(input.toolRuns && input.toolRuns.length),
);
const data: Prisma.ChatMessageCreateInput = {
team: { connect: { id: input.teamId } },
conversation: { connect: { id: input.conversationId } },
authorUser: input.authorUserId ? { connect: { id: input.authorUserId } } : undefined,
role: input.role,
text: input.text,
planJson: input.plan || input.tools ? ({ steps: input.plan ?? [], tools: input.tools ?? [] } as any) : undefined,
planJson: hasDebugPayload
? ({
steps: input.plan ?? [],
tools: input.tools ?? [],
thinking: input.thinking ?? input.plan ?? [],
toolRuns: input.toolRuns ?? [],
} as any)
: undefined,
};
return prisma.chatMessage.create({ data });
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
import { readBody } from "h3";
import { graphql } from "graphql";
import { getAuthContext } from "../utils/auth";
import { crmGraphqlRoot, crmGraphqlSchema } from "../graphql/schema";
type GraphqlBody = {
query?: string;
operationName?: string;
variables?: Record<string, unknown>;
};
export default defineEventHandler(async (event) => {
const body = await readBody<GraphqlBody>(event);
if (!body?.query || !body.query.trim()) {
throw createError({ statusCode: 400, statusMessage: "GraphQL query is required" });
}
let auth = null;
try {
auth = await getAuthContext(event);
} catch {
auth = null;
}
const result = await graphql({
schema: crmGraphqlSchema,
source: body.query,
rootValue: crmGraphqlRoot,
contextValue: { auth, event },
variableValues: body.variables,
operationName: body.operationName,
});
return {
data: result.data ?? null,
errors: result.errors?.map((error) => ({ message: error.message })) ?? undefined,
};
});

View File

@@ -0,0 +1,781 @@
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";
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) ?? {};
return {
id: m.id,
role: m.role === "USER" ? "user" : m.role === "ASSISTANT" ? "assistant" : "system",
text: m.text,
plan: Array.isArray(debug.steps) ? (debug.steps as string[]) : [],
thinking: Array.isArray(debug.thinking) ? (debug.thinking as string[]) : Array.isArray(debug.steps) ? (debug.steps 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(),
}))
: [],
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<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,
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 };
}
async function sendPilotMessage(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: ctx.userId,
role: "USER",
text,
});
const reply = await runCrmAgentFor({ teamId: ctx.teamId, userId: ctx.userId, userText: text });
await persistChatMessage({
teamId: ctx.teamId,
conversationId: ctx.conversationId,
authorUserId: null,
role: "ASSISTANT",
text: reply.text,
plan: reply.plan,
thinking: reply.thinking ?? reply.plan,
tools: reply.tools,
toolRuns: reply.toolRuns ?? [],
});
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,
plan: [],
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!
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!
plan: [String!]!
thinking: [String!]!
tools: [String!]!
toolRuns: [PilotToolRun!]!
createdAt: 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),
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),
};

View File

@@ -1,6 +1,7 @@
import type { H3Event } from "h3";
import { getCookie, setCookie, deleteCookie, getHeader } from "h3";
import { prisma } from "./prisma";
import { hashPassword } from "./password";
export type AuthContext = {
teamId: string;
@@ -58,9 +59,16 @@ export async function getAuthContext(event: H3Event): Promise<AuthContext> {
const user = await prisma.user.findUnique({ where: { id: userId } });
const team = await prisma.team.findUnique({ where: { id: teamId } });
const conv = await prisma.chatConversation.findUnique({ where: { id: conversationId } });
if (!user || !team || !conv) {
if (!user || !team) {
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
}
const conv = await prisma.chatConversation.findFirst({
where: { id: conversationId, teamId: team.id, createdByUserId: user.id },
});
if (!conv) {
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
}
@@ -68,10 +76,11 @@ export async function getAuthContext(event: H3Event): Promise<AuthContext> {
}
export async function ensureDemoAuth() {
const demoPasswordHash = hashPassword("DemoPass123!");
const user = await prisma.user.upsert({
where: { id: "demo-user" },
update: { email: "demo@clientsflow.local", name: "Demo User" },
create: { id: "demo-user", email: "demo@clientsflow.local", name: "Demo User" },
update: { phone: "+15550000099", email: "demo@clientsflow.local", passwordHash: demoPasswordHash, name: "Demo User" },
create: { id: "demo-user", phone: "+15550000099", email: "demo@clientsflow.local", passwordHash: demoPasswordHash, name: "Demo User" },
});
const team = await prisma.team.upsert({
where: { id: "demo-team" },

View File

@@ -0,0 +1,29 @@
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
const SCRYPT_KEY_LENGTH = 64;
export function normalizePhone(raw: string) {
const trimmed = (raw ?? "").trim();
if (!trimmed) return "";
const hasPlus = trimmed.startsWith("+");
const digits = trimmed.replace(/\D/g, "");
if (!digits) return "";
return `${hasPlus ? "+" : ""}${digits}`;
}
export function hashPassword(password: string) {
const salt = randomBytes(16).toString("base64url");
const digest = scryptSync(password, salt, SCRYPT_KEY_LENGTH).toString("base64url");
return `scrypt$${salt}$${digest}`;
}
export function verifyPassword(password: string, encodedHash: string) {
const [algo, salt, digest] = (encodedHash ?? "").split("$");
if (algo !== "scrypt" || !salt || !digest) return false;
const actual = scryptSync(password, salt, SCRYPT_KEY_LENGTH);
const expected = Buffer.from(digest, "base64url");
if (actual.byteLength !== expected.byteLength) return false;
return timingSafeEqual(actual, expected);
}

View File

@@ -12,6 +12,9 @@ services:
REDIS_URL: "redis://redis:6379"
CF_AGENT_MODE: "langgraph"
OPENAI_MODEL: "gpt-4o-mini"
GIGACHAT_AUTH_KEY: "${GIGACHAT_AUTH_KEY:-}"
GIGACHAT_MODEL: "${GIGACHAT_MODEL:-GigaChat-2-Max}"
GIGACHAT_SCOPE: "${GIGACHAT_SCOPE:-GIGACHAT_API_PERS}"
# Set this in your shell or a compose override:
# OPENAI_API_KEY: "..."
command: >