Rocky_Mountain_Vending/convex/voiceSessions.ts

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,
})),
}
},
})