Refine CRM chat UX and add DB-backed pin toggle
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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() },
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user