635 lines
19 KiB
TypeScript
635 lines
19 KiB
TypeScript
// @ts-nocheck
|
|
import { mutation, query } from "./_generated/server"
|
|
import { v } from "convex/values"
|
|
|
|
export const getByRoom = query({
|
|
args: {
|
|
roomName: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
return await ctx.db
|
|
.query("voiceSessions")
|
|
.withIndex("by_roomName", (q) => q.eq("roomName", args.roomName))
|
|
.unique()
|
|
},
|
|
})
|
|
|
|
export const getSessionWithTurnsBySessionId = query({
|
|
args: {
|
|
sessionId: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const session = await ctx.db.get(args.sessionId as any)
|
|
if (!session) {
|
|
return null
|
|
}
|
|
|
|
const turns = await ctx.db
|
|
.query("voiceTranscriptTurns")
|
|
.withIndex("by_sessionId", (q) => q.eq("sessionId", session._id))
|
|
.collect()
|
|
turns.sort((a, b) => a.createdAt - b.createdAt)
|
|
|
|
return { session, turns }
|
|
},
|
|
})
|
|
|
|
export const getSessionWithTurnsByRoom = query({
|
|
args: {
|
|
roomName: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const session = await ctx.db
|
|
.query("voiceSessions")
|
|
.withIndex("by_roomName", (q) => q.eq("roomName", args.roomName))
|
|
.unique()
|
|
if (!session) {
|
|
return null
|
|
}
|
|
|
|
const turns = await ctx.db
|
|
.query("voiceTranscriptTurns")
|
|
.withIndex("by_sessionId", (q) => q.eq("sessionId", session._id))
|
|
.collect()
|
|
turns.sort((a, b) => a.createdAt - b.createdAt)
|
|
|
|
return { session, turns }
|
|
},
|
|
})
|
|
|
|
export const createSession = mutation({
|
|
args: {
|
|
roomName: v.string(),
|
|
participantIdentity: v.string(),
|
|
callerPhone: v.optional(v.string()),
|
|
siteUrl: v.optional(v.string()),
|
|
pathname: v.optional(v.string()),
|
|
pageUrl: v.optional(v.string()),
|
|
source: v.optional(v.string()),
|
|
metadata: v.optional(v.string()),
|
|
contactProfileId: v.optional(v.id("contactProfiles")),
|
|
contactDisplayName: v.optional(v.string()),
|
|
contactCompany: v.optional(v.string()),
|
|
startedAt: v.optional(v.number()),
|
|
recordingDisclosureAt: v.optional(v.number()),
|
|
callStatus: v.optional(
|
|
v.union(v.literal("started"), v.literal("completed"), v.literal("failed"))
|
|
),
|
|
recordingStatus: v.optional(
|
|
v.union(
|
|
v.literal("pending"),
|
|
v.literal("starting"),
|
|
v.literal("recording"),
|
|
v.literal("completed"),
|
|
v.literal("failed")
|
|
)
|
|
),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const now = args.startedAt ?? Date.now()
|
|
return await ctx.db.insert("voiceSessions", {
|
|
...args,
|
|
startedAt: now,
|
|
callStatus: args.callStatus,
|
|
transcriptTurnCount: 0,
|
|
leadOutcome: "none",
|
|
handoffRequested: false,
|
|
notificationStatus: "pending",
|
|
reminderStatus: "none",
|
|
warmTransferStatus: "none",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
})
|
|
},
|
|
})
|
|
|
|
export const upsertPhoneCallSession = mutation({
|
|
args: {
|
|
roomName: v.string(),
|
|
participantIdentity: v.string(),
|
|
callerPhone: v.optional(v.string()),
|
|
siteUrl: v.optional(v.string()),
|
|
pathname: v.optional(v.string()),
|
|
pageUrl: v.optional(v.string()),
|
|
source: v.optional(v.string()),
|
|
metadata: v.optional(v.string()),
|
|
contactProfileId: v.optional(v.id("contactProfiles")),
|
|
contactDisplayName: v.optional(v.string()),
|
|
contactCompany: v.optional(v.string()),
|
|
startedAt: v.optional(v.number()),
|
|
recordingDisclosureAt: v.optional(v.number()),
|
|
recordingStatus: v.optional(
|
|
v.union(
|
|
v.literal("pending"),
|
|
v.literal("starting"),
|
|
v.literal("recording"),
|
|
v.literal("completed"),
|
|
v.literal("failed")
|
|
)
|
|
),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const now = args.startedAt ?? Date.now()
|
|
const existing = await ctx.db
|
|
.query("voiceSessions")
|
|
.withIndex("by_roomName", (q) => q.eq("roomName", args.roomName))
|
|
.unique()
|
|
|
|
if (existing) {
|
|
await ctx.db.patch(existing._id, {
|
|
participantIdentity: args.participantIdentity,
|
|
callerPhone: args.callerPhone || existing.callerPhone,
|
|
siteUrl: args.siteUrl,
|
|
pathname: args.pathname,
|
|
pageUrl: args.pageUrl,
|
|
source: args.source,
|
|
metadata: args.metadata,
|
|
contactProfileId: args.contactProfileId || existing.contactProfileId,
|
|
contactDisplayName:
|
|
args.contactDisplayName || existing.contactDisplayName,
|
|
contactCompany: args.contactCompany || existing.contactCompany,
|
|
startedAt: existing.startedAt || now,
|
|
recordingDisclosureAt:
|
|
args.recordingDisclosureAt ?? existing.recordingDisclosureAt,
|
|
recordingStatus: args.recordingStatus ?? existing.recordingStatus,
|
|
callStatus: existing.callStatus || "started",
|
|
notificationStatus: existing.notificationStatus || "pending",
|
|
reminderStatus: existing.reminderStatus || "none",
|
|
warmTransferStatus: existing.warmTransferStatus || "none",
|
|
updatedAt: Date.now(),
|
|
})
|
|
return await ctx.db.get(existing._id)
|
|
}
|
|
|
|
const id = await ctx.db.insert("voiceSessions", {
|
|
roomName: args.roomName,
|
|
participantIdentity: args.participantIdentity,
|
|
callerPhone: args.callerPhone,
|
|
siteUrl: args.siteUrl,
|
|
pathname: args.pathname,
|
|
pageUrl: args.pageUrl,
|
|
source: args.source,
|
|
metadata: args.metadata,
|
|
contactProfileId: args.contactProfileId,
|
|
contactDisplayName: args.contactDisplayName,
|
|
contactCompany: args.contactCompany,
|
|
startedAt: now,
|
|
recordingDisclosureAt: args.recordingDisclosureAt,
|
|
recordingStatus: args.recordingStatus,
|
|
callStatus: "started",
|
|
transcriptTurnCount: 0,
|
|
leadOutcome: "none",
|
|
handoffRequested: false,
|
|
notificationStatus: "pending",
|
|
reminderStatus: "none",
|
|
warmTransferStatus: "none",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
})
|
|
|
|
return await ctx.db.get(id)
|
|
},
|
|
})
|
|
|
|
export const addTranscriptTurn = mutation({
|
|
args: {
|
|
sessionId: v.id("voiceSessions"),
|
|
roomName: v.string(),
|
|
participantIdentity: v.string(),
|
|
role: v.union(
|
|
v.literal("user"),
|
|
v.literal("assistant"),
|
|
v.literal("system")
|
|
),
|
|
text: v.string(),
|
|
kind: v.optional(v.string()),
|
|
isFinal: v.optional(v.boolean()),
|
|
language: v.optional(v.string()),
|
|
source: v.optional(v.string()),
|
|
createdAt: v.optional(v.number()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const createdAt = args.createdAt ?? Date.now()
|
|
const turnId = await ctx.db.insert("voiceTranscriptTurns", {
|
|
...args,
|
|
text: args.text.trim(),
|
|
createdAt,
|
|
})
|
|
|
|
const session = await ctx.db.get(args.sessionId)
|
|
if (session) {
|
|
await ctx.db.patch(args.sessionId, {
|
|
transcriptTurnCount: (session.transcriptTurnCount ?? 0) + 1,
|
|
agentAnsweredAt:
|
|
args.role === "assistant" && !session.agentAnsweredAt
|
|
? createdAt
|
|
: session.agentAnsweredAt,
|
|
updatedAt: Date.now(),
|
|
})
|
|
}
|
|
|
|
return turnId
|
|
},
|
|
})
|
|
|
|
export const linkPhoneCallLead = mutation({
|
|
args: {
|
|
sessionId: v.id("voiceSessions"),
|
|
linkedLeadId: v.optional(v.string()),
|
|
contactProfileId: v.optional(v.id("contactProfiles")),
|
|
contactDisplayName: v.optional(v.string()),
|
|
contactCompany: v.optional(v.string()),
|
|
leadOutcome: v.optional(
|
|
v.union(
|
|
v.literal("none"),
|
|
v.literal("contact"),
|
|
v.literal("requestMachine")
|
|
)
|
|
),
|
|
handoffRequested: v.optional(v.boolean()),
|
|
handoffReason: v.optional(v.string()),
|
|
reminderStatus: v.optional(
|
|
v.union(v.literal("none"), v.literal("scheduled"), v.literal("sameDay"))
|
|
),
|
|
reminderRequestedAt: v.optional(v.number()),
|
|
reminderStartAt: v.optional(v.number()),
|
|
reminderEndAt: v.optional(v.number()),
|
|
reminderCalendarEventId: v.optional(v.string()),
|
|
reminderCalendarHtmlLink: v.optional(v.string()),
|
|
reminderNote: v.optional(v.string()),
|
|
warmTransferStatus: v.optional(
|
|
v.union(
|
|
v.literal("none"),
|
|
v.literal("attempted"),
|
|
v.literal("connected"),
|
|
v.literal("failed"),
|
|
v.literal("fallback")
|
|
)
|
|
),
|
|
warmTransferTarget: v.optional(v.string()),
|
|
warmTransferAttemptedAt: v.optional(v.number()),
|
|
warmTransferConnectedAt: v.optional(v.number()),
|
|
warmTransferFailureReason: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const patch: Record<string, unknown> = {
|
|
updatedAt: Date.now(),
|
|
}
|
|
|
|
const optionalEntries = {
|
|
linkedLeadId: args.linkedLeadId,
|
|
contactProfileId: args.contactProfileId,
|
|
contactDisplayName: args.contactDisplayName,
|
|
contactCompany: args.contactCompany,
|
|
leadOutcome: args.leadOutcome,
|
|
handoffRequested: args.handoffRequested,
|
|
handoffReason: args.handoffReason,
|
|
reminderStatus: args.reminderStatus,
|
|
reminderRequestedAt: args.reminderRequestedAt,
|
|
reminderStartAt: args.reminderStartAt,
|
|
reminderEndAt: args.reminderEndAt,
|
|
reminderCalendarEventId: args.reminderCalendarEventId,
|
|
reminderCalendarHtmlLink: args.reminderCalendarHtmlLink,
|
|
reminderNote: args.reminderNote,
|
|
warmTransferStatus: args.warmTransferStatus,
|
|
warmTransferTarget: args.warmTransferTarget,
|
|
warmTransferAttemptedAt: args.warmTransferAttemptedAt,
|
|
warmTransferConnectedAt: args.warmTransferConnectedAt,
|
|
warmTransferFailureReason: args.warmTransferFailureReason,
|
|
}
|
|
|
|
for (const [key, value] of Object.entries(optionalEntries)) {
|
|
if (value !== undefined) {
|
|
patch[key] = value
|
|
}
|
|
}
|
|
|
|
await ctx.db.patch(args.sessionId, patch)
|
|
|
|
return await ctx.db.get(args.sessionId)
|
|
},
|
|
})
|
|
|
|
export const updateRecording = mutation({
|
|
args: {
|
|
sessionId: v.id("voiceSessions"),
|
|
recordingStatus: v.optional(
|
|
v.union(
|
|
v.literal("pending"),
|
|
v.literal("starting"),
|
|
v.literal("recording"),
|
|
v.literal("completed"),
|
|
v.literal("failed")
|
|
)
|
|
),
|
|
recordingId: v.optional(v.string()),
|
|
recordingUrl: v.optional(v.string()),
|
|
recordingError: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
await ctx.db.patch(args.sessionId, {
|
|
recordingStatus: args.recordingStatus,
|
|
recordingId: args.recordingId,
|
|
recordingUrl: args.recordingUrl,
|
|
recordingError: args.recordingError,
|
|
updatedAt: Date.now(),
|
|
})
|
|
return await ctx.db.get(args.sessionId)
|
|
},
|
|
})
|
|
|
|
export const completeSession = mutation({
|
|
args: {
|
|
sessionId: v.id("voiceSessions"),
|
|
endedAt: v.optional(v.number()),
|
|
callStatus: v.optional(
|
|
v.union(v.literal("started"), v.literal("completed"), v.literal("failed"))
|
|
),
|
|
recordingStatus: v.optional(
|
|
v.union(
|
|
v.literal("pending"),
|
|
v.literal("starting"),
|
|
v.literal("recording"),
|
|
v.literal("completed"),
|
|
v.literal("failed")
|
|
)
|
|
),
|
|
recordingId: v.optional(v.string()),
|
|
recordingUrl: v.optional(v.string()),
|
|
recordingError: v.optional(v.string()),
|
|
summaryText: v.optional(v.string()),
|
|
notificationStatus: v.optional(
|
|
v.union(
|
|
v.literal("pending"),
|
|
v.literal("sent"),
|
|
v.literal("failed"),
|
|
v.literal("disabled")
|
|
)
|
|
),
|
|
notificationSentAt: v.optional(v.number()),
|
|
notificationError: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const endedAt = args.endedAt ?? Date.now()
|
|
await ctx.db.patch(args.sessionId, {
|
|
endedAt,
|
|
callStatus: args.callStatus,
|
|
recordingStatus: args.recordingStatus,
|
|
recordingId: args.recordingId,
|
|
recordingUrl: args.recordingUrl,
|
|
recordingError: args.recordingError,
|
|
summaryText: args.summaryText,
|
|
notificationStatus: args.notificationStatus,
|
|
notificationSentAt: args.notificationSentAt,
|
|
notificationError: args.notificationError,
|
|
updatedAt: endedAt,
|
|
})
|
|
return await ctx.db.get(args.sessionId)
|
|
},
|
|
})
|
|
|
|
function normalizePhoneCallForAdmin(session: any) {
|
|
const durationMs =
|
|
typeof session.endedAt === "number" && typeof session.startedAt === "number"
|
|
? Math.max(0, session.endedAt - session.startedAt)
|
|
: null
|
|
|
|
return {
|
|
id: session._id,
|
|
roomName: session.roomName,
|
|
participantIdentity: session.participantIdentity,
|
|
callerPhone: session.callerPhone,
|
|
pathname: session.pathname,
|
|
pageUrl: session.pageUrl,
|
|
source: session.source,
|
|
contactProfileId: session.contactProfileId,
|
|
contactDisplayName: session.contactDisplayName,
|
|
contactCompany: session.contactCompany,
|
|
startedAt: session.startedAt,
|
|
endedAt: session.endedAt,
|
|
durationMs,
|
|
callStatus: session.callStatus || "started",
|
|
transcriptTurnCount: session.transcriptTurnCount ?? 0,
|
|
answered: Boolean(session.agentAnsweredAt),
|
|
agentAnsweredAt: session.agentAnsweredAt,
|
|
linkedLeadId: session.linkedLeadId,
|
|
leadOutcome: session.leadOutcome || "none",
|
|
handoffRequested: Boolean(session.handoffRequested),
|
|
handoffReason: session.handoffReason,
|
|
summaryText: session.summaryText,
|
|
notificationStatus: session.notificationStatus || "pending",
|
|
notificationSentAt: session.notificationSentAt,
|
|
notificationError: session.notificationError,
|
|
reminderStatus: session.reminderStatus || "none",
|
|
reminderRequestedAt: session.reminderRequestedAt,
|
|
reminderStartAt: session.reminderStartAt,
|
|
reminderEndAt: session.reminderEndAt,
|
|
reminderCalendarEventId: session.reminderCalendarEventId,
|
|
reminderCalendarHtmlLink: session.reminderCalendarHtmlLink,
|
|
reminderNote: session.reminderNote,
|
|
warmTransferStatus: session.warmTransferStatus || "none",
|
|
warmTransferTarget: session.warmTransferTarget,
|
|
warmTransferAttemptedAt: session.warmTransferAttemptedAt,
|
|
warmTransferConnectedAt: session.warmTransferConnectedAt,
|
|
warmTransferFailureReason: session.warmTransferFailureReason,
|
|
recordingStatus: session.recordingStatus,
|
|
recordingUrl: session.recordingUrl,
|
|
recordingError: session.recordingError,
|
|
}
|
|
}
|
|
|
|
export const getPhoneAgentContextByPhone = query({
|
|
args: {
|
|
normalizedPhone: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const contactProfile = await ctx.db
|
|
.query("contactProfiles")
|
|
.withIndex("by_normalizedPhone", (q) =>
|
|
q.eq("normalizedPhone", args.normalizedPhone)
|
|
)
|
|
.unique()
|
|
|
|
const recentSessions = await ctx.db
|
|
.query("voiceSessions")
|
|
.withIndex("by_callerPhone", (q) => q.eq("callerPhone", args.normalizedPhone))
|
|
.collect()
|
|
|
|
recentSessions.sort((a, b) => (b.startedAt || 0) - (a.startedAt || 0))
|
|
const recentSession = recentSessions[0] || null
|
|
|
|
const recentLead = await ctx.db
|
|
.query("leadSubmissions")
|
|
.withIndex("by_normalizedPhone", (q) =>
|
|
q.eq("normalizedPhone", args.normalizedPhone)
|
|
)
|
|
.collect()
|
|
|
|
recentLead.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0))
|
|
const latestLead = recentLead[0] || null
|
|
|
|
return {
|
|
contactProfile: contactProfile
|
|
? {
|
|
id: contactProfile._id,
|
|
normalizedPhone: contactProfile.normalizedPhone,
|
|
displayName: contactProfile.displayName,
|
|
firstName: contactProfile.firstName,
|
|
lastName: contactProfile.lastName,
|
|
email: contactProfile.email,
|
|
company: contactProfile.company,
|
|
lastIntent: contactProfile.lastIntent,
|
|
lastLeadOutcome: contactProfile.lastLeadOutcome,
|
|
lastSummaryText: contactProfile.lastSummaryText,
|
|
lastCallAt: contactProfile.lastCallAt,
|
|
lastReminderAt: contactProfile.lastReminderAt,
|
|
reminderNotes: contactProfile.reminderNotes,
|
|
}
|
|
: null,
|
|
recentSession: recentSession ? normalizePhoneCallForAdmin(recentSession) : null,
|
|
recentLead: latestLead
|
|
? {
|
|
id: latestLead._id,
|
|
type: latestLead.type,
|
|
firstName: latestLead.firstName,
|
|
lastName: latestLead.lastName,
|
|
email: latestLead.email,
|
|
phone: latestLead.phone,
|
|
company: latestLead.company,
|
|
intent: latestLead.intent,
|
|
message: latestLead.message,
|
|
createdAt: latestLead.createdAt,
|
|
}
|
|
: null,
|
|
}
|
|
},
|
|
})
|
|
|
|
export const listAdminPhoneCalls = query({
|
|
args: {
|
|
search: v.optional(v.string()),
|
|
status: v.optional(
|
|
v.union(v.literal("started"), v.literal("completed"), v.literal("failed"))
|
|
),
|
|
page: v.optional(v.number()),
|
|
limit: v.optional(v.number()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const page = Math.max(1, args.page ?? 1)
|
|
const limit = Math.min(100, Math.max(1, args.limit ?? 25))
|
|
const search = String(args.search || "")
|
|
.trim()
|
|
.toLowerCase()
|
|
|
|
const sessions = await ctx.db
|
|
.query("voiceSessions")
|
|
.withIndex("by_source_startedAt", (q) => q.eq("source", "phone-agent"))
|
|
.collect()
|
|
|
|
const filtered = sessions.filter((session) => {
|
|
if (args.status && (session.callStatus || "started") !== args.status) {
|
|
return false
|
|
}
|
|
|
|
if (!search) {
|
|
return true
|
|
}
|
|
|
|
const haystack = [
|
|
session.roomName,
|
|
session.participantIdentity,
|
|
session.callerPhone,
|
|
session.contactDisplayName,
|
|
session.contactCompany,
|
|
session.pathname,
|
|
session.linkedLeadId,
|
|
session.summaryText,
|
|
session.handoffReason,
|
|
session.reminderNote,
|
|
session.warmTransferFailureReason,
|
|
]
|
|
.map((value) => String(value || "").toLowerCase())
|
|
.join("\n")
|
|
|
|
return haystack.includes(search)
|
|
})
|
|
|
|
filtered.sort((a, b) => (b.startedAt || 0) - (a.startedAt || 0))
|
|
|
|
const total = filtered.length
|
|
const items = filtered
|
|
.slice((page - 1) * limit, page * limit)
|
|
.map(normalizePhoneCallForAdmin)
|
|
|
|
return {
|
|
items,
|
|
pagination: {
|
|
page,
|
|
limit,
|
|
total,
|
|
totalPages: Math.max(1, Math.ceil(total / limit)),
|
|
},
|
|
}
|
|
},
|
|
})
|
|
|
|
export const getAdminPhoneCallDetail = query({
|
|
args: {
|
|
callId: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
let session = await ctx.db.get(args.callId as any)
|
|
|
|
if (!session) {
|
|
session = await ctx.db
|
|
.query("voiceSessions")
|
|
.withIndex("by_roomName", (q) => q.eq("roomName", args.callId))
|
|
.unique()
|
|
}
|
|
|
|
if (!session || session.source !== "phone-agent") {
|
|
return null
|
|
}
|
|
|
|
const turns = await ctx.db
|
|
.query("voiceTranscriptTurns")
|
|
.withIndex("by_sessionId", (q) => q.eq("sessionId", session._id))
|
|
.collect()
|
|
|
|
turns.sort((a, b) => a.createdAt - b.createdAt)
|
|
|
|
const linkedLead = session.linkedLeadId
|
|
? await ctx.db.get(session.linkedLeadId as any)
|
|
: null
|
|
|
|
return {
|
|
call: normalizePhoneCallForAdmin(session),
|
|
linkedLead: linkedLead
|
|
? {
|
|
id: linkedLead._id,
|
|
type: linkedLead.type,
|
|
status: linkedLead.status,
|
|
firstName: linkedLead.firstName,
|
|
lastName: linkedLead.lastName,
|
|
email: linkedLead.email,
|
|
phone: linkedLead.phone,
|
|
company: linkedLead.company,
|
|
intent: linkedLead.intent,
|
|
message: linkedLead.message,
|
|
createdAt: linkedLead.createdAt,
|
|
}
|
|
: null,
|
|
contactProfile: session.contactProfileId
|
|
? await ctx.db.get(session.contactProfileId)
|
|
: null,
|
|
turns: turns.map((turn) => ({
|
|
id: turn._id,
|
|
role: turn.role,
|
|
text: turn.text,
|
|
source: turn.source,
|
|
kind: turn.kind,
|
|
createdAt: turn.createdAt,
|
|
})),
|
|
}
|
|
},
|
|
})
|