Refine CRM chat UX and add DB-backed pin toggle

This commit is contained in:
Ruslan Bakiev
2026-02-19 13:51:18 +07:00
parent 626d4ddd76
commit 23a4deba37
10 changed files with 173 additions and 45 deletions

View File

@@ -7,6 +7,7 @@ import { ChatOpenAI } from "@langchain/openai";
import { tool } from "@langchain/core/tools";
import { z } from "zod";
import { getLangfuseClient } from "../utils/langfuse";
import { Prisma } from "@prisma/client";
function iso(d: Date) {
return d.toISOString();
@@ -570,7 +571,7 @@ export async function runLangGraphCrmAgentFor(input: {
channel: toChannel(change.channel),
content: change.text,
durationSec: change.durationSec,
transcriptJson: Array.isArray(change.transcript) ? change.transcript : null,
transcriptJson: Array.isArray(change.transcript) ? change.transcript : Prisma.JsonNull,
occurredAt: new Date(change.at),
},
});
@@ -598,7 +599,8 @@ export async function runLangGraphCrmAgentFor(input: {
};
const crmTool = tool(
async (raw: z.infer<typeof CrmToolSchema>) => {
async (rawInput: unknown) => {
const raw = CrmToolSchema.parse(rawInput);
const toolName = `crm:${raw.action}`;
const startedAt = new Date().toISOString();
toolsUsed.push(toolName);

View File

@@ -120,7 +120,7 @@ export async function exportDatasetFromPrismaFor(input: { teamId: string; userId
);
await fs.writeFile(evFile, evLines.join(""), "utf8");
const lastMessageAt = c.messages.length ? c.messages[c.messages.length - 1].occurredAt : null;
const lastMessageAt = c.messages.at(-1)?.occurredAt ?? null;
const nextEventAt = c.events.find((e) => new Date(e.startsAt).getTime() >= Date.now())?.startsAt ?? null;
contactIndex.push({

View File

@@ -388,6 +388,7 @@ async function getDashboard(auth: AuthContext | null) {
kind: m.kind === "CALL" ? "call" : "message",
direction: m.direction === "IN" ? "in" : "out",
text: m.content,
audioUrl: m.audioUrl ?? "",
duration: m.durationSec ? new Date(m.durationSec * 1000).toISOString().slice(14, 19) : "",
transcript: Array.isArray(m.transcriptJson) ? ((m.transcriptJson as any) as string[]) : [],
}));
@@ -515,6 +516,7 @@ async function createCommunication(auth: AuthContext | null, input: {
kind?: "message" | "call";
direction?: "in" | "out";
text?: string;
audioUrl?: string;
at?: string;
durationSec?: number;
transcript?: string[];
@@ -540,6 +542,7 @@ async function createCommunication(auth: AuthContext | null, input: {
direction: input?.direction === "in" ? "IN" : "OUT",
channel: toDbChannel(input?.channel ?? "Phone") as any,
content: (input?.text ?? "").trim(),
audioUrl: (input?.audioUrl ?? "").trim() || null,
durationSec: typeof input?.durationSec === "number" ? input.durationSec : null,
transcriptJson: Array.isArray(input?.transcript) ? input.transcript : undefined,
occurredAt,
@@ -710,6 +713,41 @@ async function logPilotNote(auth: AuthContext | null, textInput: string) {
return { ok: true };
}
async function toggleContactPin(auth: AuthContext | null, contactInput: string, textInput: string) {
const ctx = requireAuth(auth);
const contactName = (contactInput ?? "").trim();
const text = (textInput ?? "").replace(/\s+/g, " ").trim();
if (!contactName) throw new Error("contact is required");
if (!text) throw new Error("text is required");
const contact = await prisma.contact.findFirst({
where: { teamId: ctx.teamId, name: contactName },
select: { id: true },
});
if (!contact) throw new Error("contact not found");
const existing = await prisma.contactPin.findFirst({
where: { teamId: ctx.teamId, contactId: contact.id, text },
select: { id: true },
});
if (existing) {
await prisma.contactPin.deleteMany({
where: { teamId: ctx.teamId, contactId: contact.id, text },
});
return { ok: true, pinned: false };
}
await prisma.contactPin.create({
data: {
teamId: ctx.teamId,
contactId: contact.id,
text,
},
});
return { ok: true, pinned: true };
}
export const crmGraphqlSchema = buildSchema(`
type Query {
me: MePayload!
@@ -728,6 +766,7 @@ export const crmGraphqlSchema = buildSchema(`
confirmLatestChangeSet: MutationResult!
rollbackLatestChangeSet: MutationResult!
logPilotNote(text: String!): MutationResult!
toggleContactPin(contact: String!, text: String!): PinToggleResult!
createCalendarEvent(input: CreateCalendarEventInput!): CalendarEvent!
createCommunication(input: CreateCommunicationInput!): MutationWithIdResult!
updateFeedDecision(id: ID!, decision: String!, decisionNote: String): MutationWithIdResult!
@@ -742,6 +781,11 @@ export const crmGraphqlSchema = buildSchema(`
id: ID!
}
type PinToggleResult {
ok: Boolean!
pinned: Boolean!
}
input CreateCalendarEventInput {
title: String!
start: String!
@@ -757,6 +801,7 @@ export const crmGraphqlSchema = buildSchema(`
kind: String
direction: String
text: String
audioUrl: String
at: String
durationSec: Int
transcript: [String!]
@@ -853,6 +898,7 @@ export const crmGraphqlSchema = buildSchema(`
kind: String!
direction: String!
text: String!
audioUrl: String!
duration: String!
transcript: [String!]!
}
@@ -958,6 +1004,9 @@ export const crmGraphqlRoot = {
logPilotNote: async (args: { text: string }, context: GraphQLContext) =>
logPilotNote(context.auth, args.text),
toggleContactPin: async (args: { contact: string; text: string }, context: GraphQLContext) =>
toggleContactPin(context.auth, args.contact, args.text),
createCalendarEvent: async (args: { input: { title: string; start: string; end?: string; contact?: string; note?: string; status?: string } }, context: GraphQLContext) =>
createCalendarEvent(context.auth, args.input),
@@ -969,6 +1018,7 @@ export const crmGraphqlRoot = {
kind?: "message" | "call";
direction?: "in" | "out";
text?: string;
audioUrl?: string;
at?: string;
durationSec?: number;
transcript?: string[];

View File

@@ -1,6 +1,6 @@
import { Queue, Worker, type JobsOptions } from "bullmq";
import { Queue, Worker, type JobsOptions, type ConnectionOptions } from "bullmq";
import { Prisma } from "@prisma/client";
import { prisma } from "../utils/prisma";
import { getRedis } from "../utils/redis";
export const OUTBOUND_DELIVERY_QUEUE_NAME = "omni-outbound";
@@ -15,6 +15,19 @@ export type OutboundDeliveryJob = {
provider?: string;
};
function redisConnectionFromEnv(): ConnectionOptions {
const raw = (process.env.REDIS_URL || "redis://localhost:6379").trim();
const parsed = new URL(raw);
return {
host: parsed.hostname,
port: parsed.port ? Number(parsed.port) : 6379,
username: parsed.username ? decodeURIComponent(parsed.username) : undefined,
password: parsed.password ? decodeURIComponent(parsed.password) : undefined,
db: parsed.pathname && parsed.pathname !== "/" ? Number(parsed.pathname.slice(1)) : undefined,
maxRetriesPerRequest: null,
};
}
function ensureHttpUrl(value: string) {
const raw = (value ?? "").trim();
if (!raw) throw new Error("endpoint is required");
@@ -47,8 +60,8 @@ function extractProviderMessageId(body: unknown): string | null {
}
export function outboundDeliveryQueue() {
return new Queue<OutboundDeliveryJob>(OUTBOUND_DELIVERY_QUEUE_NAME, {
connection: getRedis(),
return new Queue<OutboundDeliveryJob, unknown, "deliver">(OUTBOUND_DELIVERY_QUEUE_NAME, {
connection: redisConnectionFromEnv(),
defaultJobOptions: {
removeOnComplete: { count: 1000 },
removeOnFail: { count: 5000 },
@@ -60,6 +73,7 @@ export async function enqueueOutboundDelivery(input: OutboundDeliveryJob, opts?:
const endpoint = ensureHttpUrl(input.endpoint);
const q = outboundDeliveryQueue();
const payload = (input.payload ?? null) as Prisma.InputJsonValue;
// Keep source message in pending before actual send starts.
await prisma.omniMessage.update({
where: { id: input.omniMessageId },
@@ -75,7 +89,7 @@ export async function enqueueOutboundDelivery(input: OutboundDeliveryJob, opts?:
method: input.method ?? "POST",
channel: input.channel ?? null,
provider: input.provider ?? null,
payload: input.payload,
payload,
},
},
},
@@ -90,7 +104,7 @@ export async function enqueueOutboundDelivery(input: OutboundDeliveryJob, opts?:
}
export function startOutboundDeliveryWorker() {
return new Worker<OutboundDeliveryJob>(
return new Worker<OutboundDeliveryJob, unknown, "deliver">(
OUTBOUND_DELIVERY_QUEUE_NAME,
async (job) => {
const msg = await prisma.omniMessage.findUnique({
@@ -112,12 +126,13 @@ export function startOutboundDeliveryWorker() {
...(job.data.headers ?? {}),
};
const requestPayload = (job.data.payload ?? null) as Prisma.InputJsonValue;
const requestStartedAt = new Date().toISOString();
try {
const response = await fetch(endpoint, {
method,
headers,
body: JSON.stringify(job.data.payload ?? {}),
body: JSON.stringify(requestPayload ?? {}),
signal: AbortSignal.timeout(timeoutMs),
});
@@ -152,7 +167,7 @@ export function startOutboundDeliveryWorker() {
channel: job.data.channel ?? null,
provider: job.data.provider ?? null,
startedAt: requestStartedAt,
payload: job.data.payload ?? null,
payload: requestPayload,
},
deliveryResponse: {
status: response.status,
@@ -182,7 +197,7 @@ export function startOutboundDeliveryWorker() {
channel: job.data.channel ?? null,
provider: job.data.provider ?? null,
startedAt: requestStartedAt,
payload: job.data.payload ?? null,
payload: requestPayload,
},
deliveryError: {
message: compactError(error),
@@ -195,6 +210,6 @@ export function startOutboundDeliveryWorker() {
throw error;
}
},
{ connection: getRedis() },
{ connection: redisConnectionFromEnv() },
);
}