feat(chat): threads UI + graphql flow + qwen/gigachat integration
This commit is contained in:
802
Frontend/app.vue
802
Frontend/app.vue
File diff suppressed because it is too large
Load Diff
10
Frontend/graphql/operations/chat-conversations.graphql
Normal file
10
Frontend/graphql/operations/chat-conversations.graphql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
query ChatConversationsQuery {
|
||||||
|
chatConversations {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
lastMessageAt
|
||||||
|
lastMessageText
|
||||||
|
}
|
||||||
|
}
|
||||||
18
Frontend/graphql/operations/chat-messages.graphql
Normal file
18
Frontend/graphql/operations/chat-messages.graphql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
query ChatMessagesQuery {
|
||||||
|
chatMessages {
|
||||||
|
id
|
||||||
|
role
|
||||||
|
text
|
||||||
|
plan
|
||||||
|
thinking
|
||||||
|
tools
|
||||||
|
toolRuns {
|
||||||
|
name
|
||||||
|
status
|
||||||
|
input
|
||||||
|
output
|
||||||
|
at
|
||||||
|
}
|
||||||
|
createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
10
Frontend/graphql/operations/create-calendar-event.graphql
Normal file
10
Frontend/graphql/operations/create-calendar-event.graphql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
mutation CreateCalendarEventMutation($input: CreateCalendarEventInput!) {
|
||||||
|
createCalendarEvent(input: $input) {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
start
|
||||||
|
end
|
||||||
|
contact
|
||||||
|
note
|
||||||
|
}
|
||||||
|
}
|
||||||
10
Frontend/graphql/operations/create-chat-conversation.graphql
Normal file
10
Frontend/graphql/operations/create-chat-conversation.graphql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
mutation CreateChatConversationMutation($title: String) {
|
||||||
|
createChatConversation(title: $title) {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
lastMessageAt
|
||||||
|
lastMessageText
|
||||||
|
}
|
||||||
|
}
|
||||||
6
Frontend/graphql/operations/create-communication.graphql
Normal file
6
Frontend/graphql/operations/create-communication.graphql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
mutation CreateCommunicationMutation($input: CreateCommunicationInput!) {
|
||||||
|
createCommunication(input: $input) {
|
||||||
|
ok
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
73
Frontend/graphql/operations/dashboard.graphql
Normal file
73
Frontend/graphql/operations/dashboard.graphql
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
Frontend/graphql/operations/log-pilot-note.graphql
Normal file
5
Frontend/graphql/operations/log-pilot-note.graphql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
mutation LogPilotNoteMutation($text: String!) {
|
||||||
|
logPilotNote(text: $text) {
|
||||||
|
ok
|
||||||
|
}
|
||||||
|
}
|
||||||
5
Frontend/graphql/operations/login.graphql
Normal file
5
Frontend/graphql/operations/login.graphql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
mutation LoginMutation($phone: String!, $password: String!) {
|
||||||
|
login(phone: $phone, password: $password) {
|
||||||
|
ok
|
||||||
|
}
|
||||||
|
}
|
||||||
5
Frontend/graphql/operations/logout.graphql
Normal file
5
Frontend/graphql/operations/logout.graphql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
mutation LogoutMutation {
|
||||||
|
logout {
|
||||||
|
ok
|
||||||
|
}
|
||||||
|
}
|
||||||
17
Frontend/graphql/operations/me.graphql
Normal file
17
Frontend/graphql/operations/me.graphql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
query MeQuery {
|
||||||
|
me {
|
||||||
|
user {
|
||||||
|
id
|
||||||
|
phone
|
||||||
|
name
|
||||||
|
}
|
||||||
|
team {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
conversation {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
mutation SelectChatConversationMutation($id: ID!) {
|
||||||
|
selectChatConversation(id: $id) {
|
||||||
|
ok
|
||||||
|
}
|
||||||
|
}
|
||||||
5
Frontend/graphql/operations/send-pilot-message.graphql
Normal file
5
Frontend/graphql/operations/send-pilot-message.graphql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
mutation SendPilotMessageMutation($text: String!) {
|
||||||
|
sendPilotMessage(text: $text) {
|
||||||
|
ok
|
||||||
|
}
|
||||||
|
}
|
||||||
6
Frontend/graphql/operations/update-feed-decision.graphql
Normal file
6
Frontend/graphql/operations/update-feed-decision.graphql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
mutation UpdateFeedDecisionMutation($id: ID!, $decision: String!, $decisionNote: String) {
|
||||||
|
updateFeedDecision(id: $id, decision: $decision, decisionNote: $decisionNote) {
|
||||||
|
ok
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,14 @@ export type AgentReply = {
|
|||||||
text: string;
|
text: string;
|
||||||
plan: string[];
|
plan: string[];
|
||||||
tools: string[];
|
tools: string[];
|
||||||
|
thinking?: string[];
|
||||||
|
toolRuns?: Array<{
|
||||||
|
name: string;
|
||||||
|
status: "ok" | "error";
|
||||||
|
input: string;
|
||||||
|
output: string;
|
||||||
|
at: string;
|
||||||
|
}>;
|
||||||
dbWrites?: Array<{ kind: string; detail: string }>;
|
dbWrites?: Array<{ kind: string; detail: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -76,7 +84,14 @@ export async function runCrmAgentFor(
|
|||||||
input: { teamId: string; userId: string; userText: string },
|
input: { teamId: string; userId: string; userText: string },
|
||||||
): Promise<AgentReply> {
|
): Promise<AgentReply> {
|
||||||
const mode = (process.env.CF_AGENT_MODE ?? "langgraph").toLowerCase();
|
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);
|
return runLangGraphCrmAgentFor(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +124,15 @@ export async function runCrmAgentFor(
|
|||||||
"Отсортировать и показать топ",
|
"Отсортировать и показать топ",
|
||||||
],
|
],
|
||||||
tools: ["read index/contacts.json", "read messages/{contactId}.jsonl", "read events/{contactId}.jsonl"],
|
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:
|
text:
|
||||||
`Топ-10 по активности (сообщения + события):\n` +
|
`Топ-10 по активности (сообщения + события):\n` +
|
||||||
top.map(formatContactLine).join("\n") +
|
top.map(formatContactLine).join("\n") +
|
||||||
@@ -163,6 +187,15 @@ export async function runCrmAgentFor(
|
|||||||
"Сформировать короткий список действий",
|
"Сформировать короткий список действий",
|
||||||
],
|
],
|
||||||
tools: ["read index/contacts.json", "read events/{contactId}.jsonl"],
|
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"),
|
text: lines.join("\n"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -171,6 +204,7 @@ export async function runCrmAgentFor(
|
|||||||
return {
|
return {
|
||||||
plan: ["Уточнить цель", "Выбрать данные для анализа", "Предложить план действий и, если нужно, изменения в CRM"],
|
plan: ["Уточнить цель", "Выбрать данные для анализа", "Предложить план действий и, если нужно, изменения в CRM"],
|
||||||
tools: ["read index/contacts.json (по необходимости)", "search messages/events (по необходимости)"],
|
tools: ["read index/contacts.json (по необходимости)", "search messages/events (по необходимости)"],
|
||||||
|
toolRuns: [],
|
||||||
text:
|
text:
|
||||||
"Ок. Скажи, что нужно сделать.\n" +
|
"Ок. Скажи, что нужно сделать.\n" +
|
||||||
"Примеры:\n" +
|
"Примеры:\n" +
|
||||||
@@ -185,17 +219,38 @@ export async function persistChatMessage(input: {
|
|||||||
text: string;
|
text: string;
|
||||||
plan?: string[];
|
plan?: string[];
|
||||||
tools?: string[];
|
tools?: string[];
|
||||||
|
thinking?: string[];
|
||||||
|
toolRuns?: Array<{
|
||||||
|
name: string;
|
||||||
|
status: "ok" | "error";
|
||||||
|
input: string;
|
||||||
|
output: string;
|
||||||
|
at: string;
|
||||||
|
}>;
|
||||||
teamId: string;
|
teamId: string;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
authorUserId?: string | null;
|
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 = {
|
const data: Prisma.ChatMessageCreateInput = {
|
||||||
team: { connect: { id: input.teamId } },
|
team: { connect: { id: input.teamId } },
|
||||||
conversation: { connect: { id: input.conversationId } },
|
conversation: { connect: { id: input.conversationId } },
|
||||||
authorUser: input.authorUserId ? { connect: { id: input.authorUserId } } : undefined,
|
authorUser: input.authorUserId ? { connect: { id: input.authorUserId } } : undefined,
|
||||||
role: input.role,
|
role: input.role,
|
||||||
text: input.text,
|
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 });
|
return prisma.chatMessage.create({ data });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import type { AgentReply } from "./crmAgent";
|
import type { AgentReply } from "./crmAgent";
|
||||||
import { prisma } from "../utils/prisma";
|
import { prisma } from "../utils/prisma";
|
||||||
import { ensureDataset } from "../dataset/exporter";
|
import { ensureDataset } from "../dataset/exporter";
|
||||||
@@ -10,18 +11,192 @@ function iso(d: Date) {
|
|||||||
return d.toISOString();
|
return d.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildCrmSnapshot(input: { teamId: string }) {
|
type GigachatTokenCache = {
|
||||||
|
token: string;
|
||||||
|
expiresAtSec: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
let gigachatTokenCache: GigachatTokenCache | null = null;
|
||||||
|
|
||||||
|
function normalizeAuthHeader(authKey: string, scheme: "Basic" | "Bearer") {
|
||||||
|
const key = authKey.trim();
|
||||||
|
if (!key) return "";
|
||||||
|
if (key.startsWith("Basic ") || key.startsWith("Bearer ")) return key;
|
||||||
|
return `${scheme} ${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestGigachatToken(input: {
|
||||||
|
authKey: string;
|
||||||
|
scope: string;
|
||||||
|
oauthUrl: string;
|
||||||
|
authScheme: "Basic" | "Bearer";
|
||||||
|
}) {
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
body.set("scope", input.scope);
|
||||||
|
|
||||||
|
const res = await fetch(input.oauthUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
Accept: "application/json",
|
||||||
|
RqUID: randomUUID(),
|
||||||
|
Authorization: normalizeAuthHeader(input.authKey, input.authScheme),
|
||||||
|
},
|
||||||
|
body: body.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(`GigaChat oauth failed: ${res.status} ${text.slice(0, 240)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await res.json()) as { access_token?: string; expires_at?: number };
|
||||||
|
if (!payload?.access_token) {
|
||||||
|
throw new Error("GigaChat oauth failed: access_token is missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: payload.access_token,
|
||||||
|
expiresAtSec: typeof payload.expires_at === "number" ? payload.expires_at : Math.floor(Date.now() / 1000) + 25 * 60,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getGigachatAccessToken(input: {
|
||||||
|
authKey: string;
|
||||||
|
scope: string;
|
||||||
|
oauthUrl: string;
|
||||||
|
}) {
|
||||||
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
|
if (gigachatTokenCache && gigachatTokenCache.expiresAtSec - nowSec > 60) {
|
||||||
|
return gigachatTokenCache.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = await requestGigachatToken({ ...input, authScheme: "Basic" });
|
||||||
|
gigachatTokenCache = token;
|
||||||
|
return token.token;
|
||||||
|
} catch {
|
||||||
|
const token = await requestGigachatToken({ ...input, authScheme: "Bearer" });
|
||||||
|
gigachatTokenCache = token;
|
||||||
|
return token.token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SnapshotOptions = {
|
||||||
|
teamId: string;
|
||||||
|
contact?: string;
|
||||||
|
contactsLimit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PendingChange =
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
type: "update_contact_note";
|
||||||
|
createdAt: string;
|
||||||
|
contactId: string;
|
||||||
|
contactName: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
type: "create_event";
|
||||||
|
createdAt: string;
|
||||||
|
contactId: string | null;
|
||||||
|
contactName: string | null;
|
||||||
|
title: string;
|
||||||
|
start: string;
|
||||||
|
end: string | null;
|
||||||
|
note: string | null;
|
||||||
|
status: string | null;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
type: "create_message";
|
||||||
|
createdAt: string;
|
||||||
|
contactId: string;
|
||||||
|
contactName: string;
|
||||||
|
text: string;
|
||||||
|
kind: "message" | "call";
|
||||||
|
direction: "in" | "out";
|
||||||
|
channel: "Telegram" | "WhatsApp" | "Instagram" | "Phone" | "Email";
|
||||||
|
at: string;
|
||||||
|
durationSec: number | null;
|
||||||
|
transcript: string[] | null;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
type: "update_deal_stage";
|
||||||
|
createdAt: string;
|
||||||
|
dealId: string;
|
||||||
|
stage: string;
|
||||||
|
dealTitle: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeId(prefix: string) {
|
||||||
|
return `${prefix}_${Date.now()}_${Math.random().toString(16).slice(2, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toChannel(channel: "Telegram" | "WhatsApp" | "Instagram" | "Phone" | "Email") {
|
||||||
|
if (channel === "Telegram") return "TELEGRAM" as const;
|
||||||
|
if (channel === "WhatsApp") return "WHATSAPP" as const;
|
||||||
|
if (channel === "Instagram") return "INSTAGRAM" as const;
|
||||||
|
if (channel === "Email") return "EMAIL" as const;
|
||||||
|
return "PHONE" as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveContact(teamId: string, contactRef: string) {
|
||||||
|
const contact = contactRef.trim();
|
||||||
|
if (!contact) return null;
|
||||||
|
|
||||||
|
const exact = await prisma.contact.findFirst({
|
||||||
|
where: { teamId, OR: [{ id: contact }, { name: contact }] },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
if (exact) return exact;
|
||||||
|
|
||||||
|
return prisma.contact.findFirst({
|
||||||
|
where: { teamId, name: { contains: contact } },
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildCrmSnapshot(input: SnapshotOptions) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const in7 = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
const in7 = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||||
|
const contactLimit = Math.max(1, Math.min(input.contactsLimit ?? 25, 80));
|
||||||
|
|
||||||
const [contacts, upcoming, deals] = await Promise.all([
|
const selectedContact = input.contact ? await resolveContact(input.teamId, input.contact) : null;
|
||||||
|
const contactWhere = selectedContact ? { teamId: input.teamId, id: selectedContact.id } : { teamId: input.teamId };
|
||||||
|
|
||||||
|
const [contacts, upcoming, deals, docs, totalContacts, totalDeals, totalEvents] = await Promise.all([
|
||||||
prisma.contact.findMany({
|
prisma.contact.findMany({
|
||||||
where: { teamId: input.teamId },
|
where: contactWhere,
|
||||||
orderBy: { updatedAt: "desc" },
|
orderBy: { updatedAt: "desc" },
|
||||||
take: 25,
|
take: selectedContact ? 1 : contactLimit,
|
||||||
include: {
|
include: {
|
||||||
messages: { select: { occurredAt: true, channel: true, direction: true }, orderBy: { occurredAt: "desc" }, take: 1 },
|
note: { select: { content: true, updatedAt: true } },
|
||||||
deals: { select: { stage: true, amount: true, updatedAt: true }, orderBy: { updatedAt: "desc" }, take: 1 },
|
messages: {
|
||||||
|
select: { id: true, occurredAt: true, channel: true, direction: true, kind: true, content: true },
|
||||||
|
orderBy: { occurredAt: "desc" },
|
||||||
|
take: 4,
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
select: { id: true, title: true, startsAt: true, endsAt: true, status: true },
|
||||||
|
orderBy: { startsAt: "asc" },
|
||||||
|
where: { startsAt: { gte: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000) } },
|
||||||
|
take: 4,
|
||||||
|
},
|
||||||
|
deals: {
|
||||||
|
select: { id: true, title: true, stage: true, amount: true, updatedAt: true, nextStep: true, summary: true },
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
take: 3,
|
||||||
|
},
|
||||||
|
pins: {
|
||||||
|
select: { id: true, text: true, updatedAt: true },
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
take: 3,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.calendarEvent.findMany({
|
prisma.calendarEvent.findMany({
|
||||||
@@ -36,44 +211,111 @@ async function buildCrmSnapshot(input: { teamId: string }) {
|
|||||||
take: 20,
|
take: 20,
|
||||||
include: { contact: { select: { name: true, company: true } } },
|
include: { contact: { select: { name: true, company: true } } },
|
||||||
}),
|
}),
|
||||||
|
prisma.workspaceDocument.findMany({
|
||||||
|
where: { teamId: input.teamId },
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
take: 12,
|
||||||
|
select: { id: true, type: true, title: true, summary: true, updatedAt: true, owner: true, scope: true },
|
||||||
|
}),
|
||||||
|
prisma.contact.count({ where: { teamId: input.teamId } }),
|
||||||
|
prisma.deal.count({ where: { teamId: input.teamId } }),
|
||||||
|
prisma.calendarEvent.count({ where: { teamId: input.teamId, startsAt: { gte: now } } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const byStage = new Map<string, number>();
|
const byStage = new Map<string, number>();
|
||||||
for (const d of deals) byStage.set(d.stage, (byStage.get(d.stage) ?? 0) + 1);
|
for (const d of deals) byStage.set(d.stage, (byStage.get(d.stage) ?? 0) + 1);
|
||||||
|
|
||||||
const lines: string[] = [];
|
return {
|
||||||
lines.push(`Snapshot time: ${iso(now)}`);
|
meta: {
|
||||||
lines.push(`Contacts: ${await prisma.contact.count({ where: { teamId: input.teamId } })}`);
|
generatedAt: iso(now),
|
||||||
lines.push(`Deals: ${await prisma.deal.count({ where: { teamId: input.teamId } })}`);
|
focusContactId: selectedContact?.id ?? null,
|
||||||
lines.push(`Upcoming events (7d): ${upcoming.length}`);
|
contactsIncluded: contacts.length,
|
||||||
lines.push("");
|
mode: selectedContact ? "focused" : "team",
|
||||||
|
},
|
||||||
if (upcoming.length) {
|
totals: {
|
||||||
lines.push("Upcoming events:");
|
contacts: totalContacts,
|
||||||
for (const e of upcoming) {
|
deals: totalDeals,
|
||||||
lines.push(`- ${e.startsAt.toISOString()} · ${e.title} · ${e.contact?.name ?? "No contact"}`);
|
upcomingEvents: totalEvents,
|
||||||
}
|
},
|
||||||
lines.push("");
|
stats: {
|
||||||
}
|
dealsByStage: [...byStage.entries()]
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
if (byStage.size) {
|
.map(([stage, count]) => ({ stage, count })),
|
||||||
lines.push("Deals by stage:");
|
},
|
||||||
for (const [stage, n] of [...byStage.entries()].sort((a, b) => b[1] - a[1])) {
|
upcomingEvents: upcoming.map((e) => ({
|
||||||
lines.push(`- ${stage}: ${n}`);
|
id: e.id,
|
||||||
}
|
title: e.title,
|
||||||
lines.push("");
|
startsAt: iso(e.startsAt),
|
||||||
}
|
endsAt: iso(e.endsAt ?? e.startsAt),
|
||||||
|
status: e.status,
|
||||||
if (contacts.length) {
|
note: e.note,
|
||||||
lines.push("Recently updated contacts:");
|
contact: e.contact?.name ?? null,
|
||||||
for (const c of contacts.slice(0, 12)) {
|
})),
|
||||||
const last = c.messages[0]?.occurredAt ? c.messages[0].occurredAt.toISOString() : c.updatedAt.toISOString();
|
deals: deals.map((d) => ({
|
||||||
const deal = c.deals[0] ? `${c.deals[0].stage}${c.deals[0].amount ? ` $${c.deals[0].amount}` : ""}` : "no deal";
|
id: d.id,
|
||||||
lines.push(`- ${c.name}${c.company ? ` (${c.company})` : ""} · last touch ${last} · ${deal}`);
|
title: d.title,
|
||||||
}
|
stage: d.stage,
|
||||||
}
|
amount: d.amount,
|
||||||
|
nextStep: d.nextStep,
|
||||||
return lines.join("\n");
|
summary: d.summary,
|
||||||
|
updatedAt: iso(d.updatedAt),
|
||||||
|
contact: {
|
||||||
|
name: d.contact.name,
|
||||||
|
company: d.contact.company,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
contacts: contacts.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
company: c.company,
|
||||||
|
country: c.country,
|
||||||
|
location: c.location,
|
||||||
|
email: c.email,
|
||||||
|
phone: c.phone,
|
||||||
|
avatarUrl: c.avatarUrl,
|
||||||
|
updatedAt: iso(c.updatedAt),
|
||||||
|
summary: c.note?.content ?? null,
|
||||||
|
summaryUpdatedAt: c.note?.updatedAt ? iso(c.note.updatedAt) : null,
|
||||||
|
latestMessages: c.messages.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
occurredAt: iso(m.occurredAt),
|
||||||
|
channel: m.channel,
|
||||||
|
direction: m.direction,
|
||||||
|
kind: m.kind,
|
||||||
|
content: m.content,
|
||||||
|
})),
|
||||||
|
latestEvents: c.events.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
title: e.title,
|
||||||
|
startsAt: iso(e.startsAt),
|
||||||
|
endsAt: iso(e.endsAt ?? e.startsAt),
|
||||||
|
status: e.status,
|
||||||
|
})),
|
||||||
|
deals: c.deals.map((d) => ({
|
||||||
|
id: d.id,
|
||||||
|
title: d.title,
|
||||||
|
stage: d.stage,
|
||||||
|
amount: d.amount,
|
||||||
|
nextStep: d.nextStep,
|
||||||
|
summary: d.summary,
|
||||||
|
updatedAt: iso(d.updatedAt),
|
||||||
|
})),
|
||||||
|
pins: c.pins.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
text: p.text,
|
||||||
|
updatedAt: iso(p.updatedAt),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
documents: docs.map((d) => ({
|
||||||
|
id: d.id,
|
||||||
|
type: d.type,
|
||||||
|
title: d.title,
|
||||||
|
summary: d.summary,
|
||||||
|
owner: d.owner,
|
||||||
|
scope: d.scope,
|
||||||
|
updatedAt: iso(d.updatedAt),
|
||||||
|
})),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runLangGraphCrmAgentFor(input: {
|
export async function runLangGraphCrmAgentFor(input: {
|
||||||
@@ -81,11 +323,60 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
userId: string;
|
userId: string;
|
||||||
userText: string;
|
userText: string;
|
||||||
}): Promise<AgentReply> {
|
}): Promise<AgentReply> {
|
||||||
if (!process.env.OPENAI_API_KEY) {
|
const genericApiKey =
|
||||||
|
process.env.LLM_API_KEY ||
|
||||||
|
process.env.OPENAI_API_KEY ||
|
||||||
|
process.env.DASHSCOPE_API_KEY ||
|
||||||
|
process.env.QWEN_API_KEY;
|
||||||
|
const genericBaseURL =
|
||||||
|
process.env.LLM_BASE_URL ||
|
||||||
|
process.env.OPENAI_BASE_URL ||
|
||||||
|
process.env.DASHSCOPE_BASE_URL ||
|
||||||
|
process.env.QWEN_BASE_URL;
|
||||||
|
const genericModel =
|
||||||
|
process.env.LLM_MODEL ||
|
||||||
|
process.env.OPENAI_MODEL ||
|
||||||
|
process.env.DASHSCOPE_MODEL ||
|
||||||
|
process.env.QWEN_MODEL ||
|
||||||
|
"gpt-4o-mini";
|
||||||
|
const gigachatAuthKey = (process.env.GIGACHAT_AUTH_KEY ?? "").trim();
|
||||||
|
const gigachatModel = (process.env.GIGACHAT_MODEL ?? "").trim();
|
||||||
|
const gigachatScope = (process.env.GIGACHAT_SCOPE ?? "").trim();
|
||||||
|
const gigachatOauthUrl = (process.env.GIGACHAT_OAUTH_URL ?? "https://ngw.devices.sberbank.ru:9443/api/v2/oauth").trim();
|
||||||
|
const gigachatBaseUrl = (process.env.GIGACHAT_BASE_URL ?? "https://gigachat.devices.sberbank.ru/api/v1").trim();
|
||||||
|
const useGigachat = Boolean(gigachatAuthKey && gigachatScope);
|
||||||
|
|
||||||
|
let llmApiKey = genericApiKey;
|
||||||
|
let llmBaseURL = genericBaseURL;
|
||||||
|
let llmModel = genericModel;
|
||||||
|
|
||||||
|
if (useGigachat) {
|
||||||
|
try {
|
||||||
|
llmApiKey = await getGigachatAccessToken({
|
||||||
|
authKey: gigachatAuthKey,
|
||||||
|
scope: gigachatScope,
|
||||||
|
oauthUrl: gigachatOauthUrl,
|
||||||
|
});
|
||||||
|
llmBaseURL = gigachatBaseUrl;
|
||||||
|
llmModel = gigachatModel || "GigaChat-2-Max";
|
||||||
|
} catch (e: any) {
|
||||||
return {
|
return {
|
||||||
text: "OPENAI_API_KEY не задан. Сейчас включен fallback-агент без LLM.",
|
text: `Не удалось получить токен GigaChat: ${String(e?.message || e)}`,
|
||||||
plan: ["Проверить .env", "Добавить OPENAI_API_KEY", "Перезапустить dev-сервер"],
|
plan: ["Проверить GIGACHAT_AUTH_KEY", "Проверить GIGACHAT_SCOPE", "Проверить сетевой доступ до OAuth endpoint и перезапустить dev-сервер"],
|
||||||
tools: [],
|
tools: [],
|
||||||
|
thinking: ["Провайдер GigaChat настроен, но OAuth не прошел."],
|
||||||
|
toolRuns: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!llmApiKey) {
|
||||||
|
return {
|
||||||
|
text: "LLM API key не задан. Сейчас включен fallback-агент без LLM.",
|
||||||
|
plan: ["Проверить .env", "Добавить LLM_API_KEY (или OPENAI_API_KEY / DASHSCOPE_API_KEY / QWEN_API_KEY / GIGACHAT_AUTH_KEY+GIGACHAT_SCOPE)", "Перезапустить dev-сервер"],
|
||||||
|
tools: [],
|
||||||
|
thinking: ["LLM недоступна, возвращен fallback-ответ."],
|
||||||
|
toolRuns: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,12 +385,24 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
|
|
||||||
const toolsUsed: string[] = [];
|
const toolsUsed: string[] = [];
|
||||||
const dbWrites: Array<{ kind: string; detail: string }> = [];
|
const dbWrites: Array<{ kind: string; detail: string }> = [];
|
||||||
|
const toolRuns: NonNullable<AgentReply["toolRuns"]> = [];
|
||||||
|
const pendingChanges: PendingChange[] = [];
|
||||||
|
|
||||||
|
function compact(value: unknown, max = 240) {
|
||||||
|
const text = typeof value === "string" ? value : JSON.stringify(value);
|
||||||
|
if (!text) return "";
|
||||||
|
return text.length > max ? `${text.slice(0, max)}...` : text;
|
||||||
|
}
|
||||||
|
|
||||||
const CrmToolSchema = z.object({
|
const CrmToolSchema = z.object({
|
||||||
action: z.enum([
|
action: z.enum([
|
||||||
|
"get_snapshot",
|
||||||
"query_contacts",
|
"query_contacts",
|
||||||
"query_deals",
|
"query_deals",
|
||||||
"query_events",
|
"query_events",
|
||||||
|
"pending_changes",
|
||||||
|
"discard_changes",
|
||||||
|
"commit_changes",
|
||||||
"update_contact_note",
|
"update_contact_note",
|
||||||
"create_event",
|
"create_event",
|
||||||
"create_message",
|
"create_message",
|
||||||
@@ -111,6 +414,7 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
from: z.string().optional(),
|
from: z.string().optional(),
|
||||||
to: z.string().optional(),
|
to: z.string().optional(),
|
||||||
limit: z.number().int().optional(),
|
limit: z.number().int().optional(),
|
||||||
|
mode: z.enum(["stage", "apply"]).optional(),
|
||||||
// writes
|
// writes
|
||||||
contact: z.string().optional(),
|
contact: z.string().optional(),
|
||||||
content: z.string().optional(),
|
content: z.string().optional(),
|
||||||
@@ -129,9 +433,93 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
dealId: z.string().optional(),
|
dealId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const applyPendingChanges = async () => {
|
||||||
|
const queue = pendingChanges.splice(0, pendingChanges.length);
|
||||||
|
if (!queue.length) {
|
||||||
|
return { ok: true, applied: 0, changes: [] as Array<{ id: string; type: string; detail: string }> };
|
||||||
|
}
|
||||||
|
|
||||||
|
const applied: Array<{ id: string; type: string; detail: string }> = [];
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
for (const change of queue) {
|
||||||
|
if (change.type === "update_contact_note") {
|
||||||
|
await tx.contactNote.upsert({
|
||||||
|
where: { contactId: change.contactId },
|
||||||
|
update: { content: change.content },
|
||||||
|
create: { contactId: change.contactId, content: change.content },
|
||||||
|
});
|
||||||
|
applied.push({ id: change.id, type: change.type, detail: `${change.contactName}: summary updated` });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (change.type === "create_event") {
|
||||||
|
const created = await tx.calendarEvent.create({
|
||||||
|
data: {
|
||||||
|
teamId: input.teamId,
|
||||||
|
contactId: change.contactId,
|
||||||
|
title: change.title,
|
||||||
|
startsAt: new Date(change.start),
|
||||||
|
endsAt: change.end ? new Date(change.end) : null,
|
||||||
|
note: change.note,
|
||||||
|
status: change.status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
applied.push({ id: change.id, type: change.type, detail: `created event ${created.id}` });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (change.type === "create_message") {
|
||||||
|
const created = await tx.contactMessage.create({
|
||||||
|
data: {
|
||||||
|
contactId: change.contactId,
|
||||||
|
kind: change.kind === "call" ? "CALL" : "MESSAGE",
|
||||||
|
direction: change.direction === "in" ? "IN" : "OUT",
|
||||||
|
channel: toChannel(change.channel),
|
||||||
|
content: change.text,
|
||||||
|
durationSec: change.durationSec,
|
||||||
|
transcriptJson: Array.isArray(change.transcript) ? change.transcript : null,
|
||||||
|
occurredAt: new Date(change.at),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
applied.push({ id: change.id, type: change.type, detail: `created message ${created.id}` });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (change.type === "update_deal_stage") {
|
||||||
|
const updated = await tx.deal.updateMany({
|
||||||
|
where: { id: change.dealId, teamId: input.teamId },
|
||||||
|
data: { stage: change.stage },
|
||||||
|
});
|
||||||
|
if (updated.count === 0) throw new Error(`deal not found: ${change.dealId}`);
|
||||||
|
applied.push({ id: change.id, type: change.type, detail: `${change.dealTitle}: stage -> ${change.stage}` });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const item of applied) {
|
||||||
|
dbWrites.push({ kind: item.type, detail: item.detail });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, applied: applied.length, changes: applied };
|
||||||
|
};
|
||||||
|
|
||||||
const crmTool = tool(
|
const crmTool = tool(
|
||||||
async (raw: z.infer<typeof CrmToolSchema>) => {
|
async (raw: z.infer<typeof CrmToolSchema>) => {
|
||||||
toolsUsed.push(`crm:${raw.action}`);
|
const toolName = `crm:${raw.action}`;
|
||||||
|
const startedAt = new Date().toISOString();
|
||||||
|
toolsUsed.push(toolName);
|
||||||
|
|
||||||
|
const executeAction = async () => {
|
||||||
|
if (raw.action === "get_snapshot") {
|
||||||
|
const snapshot = await buildCrmSnapshot({
|
||||||
|
teamId: input.teamId,
|
||||||
|
contact: raw.contact,
|
||||||
|
contactsLimit: raw.limit,
|
||||||
|
});
|
||||||
|
return JSON.stringify(snapshot, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
if (raw.action === "query_contacts") {
|
if (raw.action === "query_contacts") {
|
||||||
const q = (raw.query ?? "").trim();
|
const q = (raw.query ?? "").trim();
|
||||||
@@ -215,25 +603,55 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (raw.action === "pending_changes") {
|
||||||
|
return JSON.stringify(
|
||||||
|
{
|
||||||
|
count: pendingChanges.length,
|
||||||
|
items: pendingChanges.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
type: item.type,
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.action === "discard_changes") {
|
||||||
|
const discarded = pendingChanges.length;
|
||||||
|
pendingChanges.splice(0, pendingChanges.length);
|
||||||
|
return JSON.stringify({ ok: true, discarded }, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.action === "commit_changes") {
|
||||||
|
const committed = await applyPendingChanges();
|
||||||
|
return JSON.stringify(committed, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
if (raw.action === "update_contact_note") {
|
if (raw.action === "update_contact_note") {
|
||||||
const contactName = (raw.contact ?? "").trim();
|
const contactName = (raw.contact ?? "").trim();
|
||||||
const content = (raw.content ?? "").trim();
|
const content = (raw.content ?? "").trim();
|
||||||
if (!contactName) throw new Error("contact is required");
|
if (!contactName) throw new Error("contact is required");
|
||||||
if (!content) throw new Error("content is required");
|
if (!content) throw new Error("content is required");
|
||||||
|
|
||||||
const contact = await prisma.contact.findFirst({
|
const contact = await resolveContact(input.teamId, contactName);
|
||||||
where: { teamId: input.teamId, name: contactName },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
if (!contact) throw new Error("contact not found");
|
if (!contact) throw new Error("contact not found");
|
||||||
|
|
||||||
await prisma.contactNote.upsert({
|
pendingChanges.push({
|
||||||
where: { contactId: contact.id },
|
id: makeId("chg"),
|
||||||
update: { content },
|
type: "update_contact_note",
|
||||||
create: { contactId: contact.id, content },
|
createdAt: iso(new Date()),
|
||||||
|
contactId: contact.id,
|
||||||
|
contactName: contact.name,
|
||||||
|
content,
|
||||||
});
|
});
|
||||||
dbWrites.push({ kind: "contact_note", detail: `${contactName}: updated` });
|
|
||||||
return JSON.stringify({ ok: true });
|
if (raw.mode === "apply") {
|
||||||
|
const committed = await applyPendingChanges();
|
||||||
|
return JSON.stringify(committed, null, 2);
|
||||||
|
}
|
||||||
|
return JSON.stringify({ ok: true, staged: true, pending: pendingChanges.length }, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (raw.action === "create_event") {
|
if (raw.action === "create_event") {
|
||||||
@@ -245,22 +663,27 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
const end = raw.end ? new Date(raw.end) : null;
|
const end = raw.end ? new Date(raw.end) : null;
|
||||||
const contactName = (raw.contact ?? "").trim();
|
const contactName = (raw.contact ?? "").trim();
|
||||||
const contact = contactName
|
const contact = contactName
|
||||||
? await prisma.contact.findFirst({ where: { teamId: input.teamId, name: contactName }, select: { id: true } })
|
? await resolveContact(input.teamId, contactName)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const created = await prisma.calendarEvent.create({
|
pendingChanges.push({
|
||||||
data: {
|
id: makeId("chg"),
|
||||||
teamId: input.teamId,
|
type: "create_event",
|
||||||
|
createdAt: iso(new Date()),
|
||||||
contactId: contact?.id ?? null,
|
contactId: contact?.id ?? null,
|
||||||
|
contactName: contact?.name ?? null,
|
||||||
title,
|
title,
|
||||||
startsAt: start,
|
start: iso(start),
|
||||||
endsAt: end && !Number.isNaN(end.getTime()) ? end : null,
|
end: end && !Number.isNaN(end.getTime()) ? iso(end) : null,
|
||||||
note: (raw.note ?? "").trim() || null,
|
note: (raw.note ?? "").trim() || null,
|
||||||
status: (raw.status ?? "").trim() || null,
|
status: (raw.status ?? "").trim() || null,
|
||||||
},
|
|
||||||
});
|
});
|
||||||
dbWrites.push({ kind: "calendar_event", detail: `created ${created.id}` });
|
|
||||||
return JSON.stringify({ ok: true, id: created.id });
|
if (raw.mode === "apply") {
|
||||||
|
const committed = await applyPendingChanges();
|
||||||
|
return JSON.stringify(committed, null, 2);
|
||||||
|
}
|
||||||
|
return JSON.stringify({ ok: true, staged: true, pending: pendingChanges.length }, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (raw.action === "create_message") {
|
if (raw.action === "create_message") {
|
||||||
@@ -269,38 +692,32 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
if (!contactName) throw new Error("contact is required");
|
if (!contactName) throw new Error("contact is required");
|
||||||
if (!text) throw new Error("text is required");
|
if (!text) throw new Error("text is required");
|
||||||
|
|
||||||
const contact = await prisma.contact.findFirst({
|
const contact = await resolveContact(input.teamId, contactName);
|
||||||
where: { teamId: input.teamId, name: contactName },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
if (!contact) throw new Error("contact not found");
|
if (!contact) throw new Error("contact not found");
|
||||||
|
|
||||||
const occurredAt = raw.at ? new Date(raw.at) : new Date();
|
const occurredAt = raw.at ? new Date(raw.at) : new Date();
|
||||||
if (Number.isNaN(occurredAt.getTime())) throw new Error("at is invalid");
|
if (Number.isNaN(occurredAt.getTime())) throw new Error("at is invalid");
|
||||||
|
|
||||||
const created = await prisma.contactMessage.create({
|
pendingChanges.push({
|
||||||
data: {
|
id: makeId("chg"),
|
||||||
|
type: "create_message",
|
||||||
|
createdAt: iso(new Date()),
|
||||||
contactId: contact.id,
|
contactId: contact.id,
|
||||||
kind: raw.kind === "call" ? "CALL" : "MESSAGE",
|
contactName: contact.name,
|
||||||
direction: raw.direction === "in" ? "IN" : "OUT",
|
kind: raw.kind === "call" ? "call" : "message",
|
||||||
channel:
|
direction: raw.direction === "in" ? "in" : "out",
|
||||||
raw.channel === "Telegram"
|
channel: raw.channel ?? "Phone",
|
||||||
? "TELEGRAM"
|
text,
|
||||||
: raw.channel === "WhatsApp"
|
at: iso(occurredAt),
|
||||||
? "WHATSAPP"
|
|
||||||
: raw.channel === "Instagram"
|
|
||||||
? "INSTAGRAM"
|
|
||||||
: raw.channel === "Email"
|
|
||||||
? "EMAIL"
|
|
||||||
: "PHONE",
|
|
||||||
content: text,
|
|
||||||
durationSec: typeof raw.durationSec === "number" ? raw.durationSec : null,
|
durationSec: typeof raw.durationSec === "number" ? raw.durationSec : null,
|
||||||
transcriptJson: Array.isArray(raw.transcript) ? raw.transcript : null,
|
transcript: Array.isArray(raw.transcript) ? raw.transcript : null,
|
||||||
occurredAt,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
dbWrites.push({ kind: "contact_message", detail: `created ${created.id}` });
|
|
||||||
return JSON.stringify({ ok: true, id: created.id });
|
if (raw.mode === "apply") {
|
||||||
|
const committed = await applyPendingChanges();
|
||||||
|
return JSON.stringify(committed, null, 2);
|
||||||
|
}
|
||||||
|
return JSON.stringify({ ok: true, staged: true, pending: pendingChanges.length }, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (raw.action === "update_deal_stage") {
|
if (raw.action === "update_deal_stage") {
|
||||||
@@ -309,16 +726,51 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
if (!dealId) throw new Error("dealId is required");
|
if (!dealId) throw new Error("dealId is required");
|
||||||
if (!stage) throw new Error("stage is required");
|
if (!stage) throw new Error("stage is required");
|
||||||
|
|
||||||
const updated = await prisma.deal.updateMany({
|
const deal = await prisma.deal.findFirst({
|
||||||
where: { id: dealId, teamId: input.teamId },
|
where: { id: dealId, teamId: input.teamId },
|
||||||
data: { stage },
|
select: { id: true, title: true },
|
||||||
});
|
});
|
||||||
if (updated.count === 0) throw new Error("deal not found");
|
if (!deal) throw new Error("deal not found");
|
||||||
dbWrites.push({ kind: "deal", detail: `updated stage for ${dealId}` });
|
|
||||||
return JSON.stringify({ ok: true });
|
pendingChanges.push({
|
||||||
|
id: makeId("chg"),
|
||||||
|
type: "update_deal_stage",
|
||||||
|
createdAt: iso(new Date()),
|
||||||
|
dealId: deal.id,
|
||||||
|
dealTitle: deal.title,
|
||||||
|
stage,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (raw.mode === "apply") {
|
||||||
|
const committed = await applyPendingChanges();
|
||||||
|
return JSON.stringify(committed, null, 2);
|
||||||
|
}
|
||||||
|
return JSON.stringify({ ok: true, staged: true, pending: pendingChanges.length }, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
return JSON.stringify({ ok: false, error: "unknown action" });
|
return JSON.stringify({ ok: false, error: "unknown action" });
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await executeAction();
|
||||||
|
toolRuns.push({
|
||||||
|
name: toolName,
|
||||||
|
status: "ok",
|
||||||
|
input: compact(raw),
|
||||||
|
output: compact(result),
|
||||||
|
at: startedAt,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (error: any) {
|
||||||
|
toolRuns.push({
|
||||||
|
name: toolName,
|
||||||
|
status: "error",
|
||||||
|
input: compact(raw),
|
||||||
|
output: compact(error?.message || String(error)),
|
||||||
|
at: startedAt,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "crm",
|
name: "crm",
|
||||||
@@ -329,11 +781,19 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const snapshot = await buildCrmSnapshot({ teamId: input.teamId });
|
const snapshot = await buildCrmSnapshot({ teamId: input.teamId });
|
||||||
|
const snapshotJson = JSON.stringify(snapshot, null, 2);
|
||||||
|
|
||||||
const model = new ChatOpenAI({
|
const model = new ChatOpenAI({
|
||||||
apiKey: process.env.OPENAI_API_KEY,
|
apiKey: llmApiKey,
|
||||||
model: process.env.OPENAI_MODEL || "gpt-4o-mini",
|
model: llmModel,
|
||||||
temperature: 0.2,
|
temperature: 0.2,
|
||||||
|
...(llmBaseURL
|
||||||
|
? {
|
||||||
|
configuration: {
|
||||||
|
baseURL: llmBaseURL,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const agent = createReactAgent({
|
const agent = createReactAgent({
|
||||||
@@ -349,12 +809,15 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
"You are Pilot, a CRM assistant.",
|
"You are Pilot, a CRM assistant.",
|
||||||
"Rules:",
|
"Rules:",
|
||||||
"- Be concrete and concise.",
|
"- Be concrete and concise.",
|
||||||
"- If you need data beyond the snapshot, call the crm tool.",
|
"- You are given a structured CRM JSON snapshot as baseline context.",
|
||||||
"- If user asks to change CRM, you may do it via the crm tool and then report what changed.",
|
"- If you need fresher or narrower data, call crm.get_snapshot/query_* tools.",
|
||||||
"- Do not claim you sent an external message; you can only create draft messages/events/notes in CRM.",
|
"- For changes, stage first with mode=stage. Commit only when user asks to execute.",
|
||||||
|
"- You can apply immediately with mode=apply only if user explicitly asked to do it now.",
|
||||||
|
"- Use pending_changes and commit_changes to control staged updates.",
|
||||||
|
"- Do not claim you sent an external message; you can only create CRM records.",
|
||||||
"",
|
"",
|
||||||
"CRM Snapshot:",
|
"CRM Snapshot JSON:",
|
||||||
snapshot,
|
snapshotJson,
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
const res: any = await agent.invoke(
|
const res: any = await agent.invoke(
|
||||||
@@ -371,5 +834,12 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
const text = structured?.answer?.trim() || "Готово.";
|
const text = structured?.answer?.trim() || "Готово.";
|
||||||
const plan = Array.isArray(structured?.plan) ? structured!.plan : ["Собрать данные", "Сформировать ответ"];
|
const plan = Array.isArray(structured?.plan) ? structured!.plan : ["Собрать данные", "Сформировать ответ"];
|
||||||
|
|
||||||
return { text, plan, tools: toolsUsed, dbWrites: dbWrites.length ? dbWrites : undefined };
|
return {
|
||||||
|
text,
|
||||||
|
plan,
|
||||||
|
thinking: plan,
|
||||||
|
tools: toolsUsed,
|
||||||
|
toolRuns,
|
||||||
|
dbWrites: dbWrites.length ? dbWrites : undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
39
Frontend/server/api/graphql.post.ts
Normal file
39
Frontend/server/api/graphql.post.ts
Normal 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,
|
||||||
|
};
|
||||||
|
});
|
||||||
781
Frontend/server/graphql/schema.ts
Normal file
781
Frontend/server/graphql/schema.ts
Normal 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),
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { H3Event } from "h3";
|
import type { H3Event } from "h3";
|
||||||
import { getCookie, setCookie, deleteCookie, getHeader } from "h3";
|
import { getCookie, setCookie, deleteCookie, getHeader } from "h3";
|
||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
|
import { hashPassword } from "./password";
|
||||||
|
|
||||||
export type AuthContext = {
|
export type AuthContext = {
|
||||||
teamId: string;
|
teamId: string;
|
||||||
@@ -58,9 +59,16 @@ export async function getAuthContext(event: H3Event): Promise<AuthContext> {
|
|||||||
|
|
||||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||||
const team = await prisma.team.findUnique({ where: { id: teamId } });
|
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" });
|
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,10 +76,11 @@ export async function getAuthContext(event: H3Event): Promise<AuthContext> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureDemoAuth() {
|
export async function ensureDemoAuth() {
|
||||||
|
const demoPasswordHash = hashPassword("DemoPass123!");
|
||||||
const user = await prisma.user.upsert({
|
const user = await prisma.user.upsert({
|
||||||
where: { id: "demo-user" },
|
where: { id: "demo-user" },
|
||||||
update: { email: "demo@clientsflow.local", name: "Demo User" },
|
update: { phone: "+15550000099", email: "demo@clientsflow.local", passwordHash: demoPasswordHash, name: "Demo User" },
|
||||||
create: { id: "demo-user", email: "demo@clientsflow.local", name: "Demo User" },
|
create: { id: "demo-user", phone: "+15550000099", email: "demo@clientsflow.local", passwordHash: demoPasswordHash, name: "Demo User" },
|
||||||
});
|
});
|
||||||
const team = await prisma.team.upsert({
|
const team = await prisma.team.upsert({
|
||||||
where: { id: "demo-team" },
|
where: { id: "demo-team" },
|
||||||
|
|||||||
29
Frontend/server/utils/password.ts
Normal file
29
Frontend/server/utils/password.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -12,6 +12,9 @@ services:
|
|||||||
REDIS_URL: "redis://redis:6379"
|
REDIS_URL: "redis://redis:6379"
|
||||||
CF_AGENT_MODE: "langgraph"
|
CF_AGENT_MODE: "langgraph"
|
||||||
OPENAI_MODEL: "gpt-4o-mini"
|
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:
|
# Set this in your shell or a compose override:
|
||||||
# OPENAI_API_KEY: "..."
|
# OPENAI_API_KEY: "..."
|
||||||
command: >
|
command: >
|
||||||
|
|||||||
Reference in New Issue
Block a user