Rocky_Mountain_Vending/convex/crmModel.ts

497 lines
14 KiB
TypeScript

// @ts-nocheck
export function normalizeEmail(value?: string) {
const normalized = String(value || "")
.trim()
.toLowerCase()
return normalized || undefined
}
export function normalizePhone(value?: string) {
const digits = String(value || "").replace(/\D/g, "")
if (!digits) {
return undefined
}
if (digits.length === 10) {
return `+1${digits}`
}
if (digits.length === 11 && digits.startsWith("1")) {
return `+${digits}`
}
return `+${digits}`
}
function trimOptional(value?: string) {
const trimmed = String(value || "").trim()
return trimmed || undefined
}
function isPlaceholderFirstName(value?: string) {
const normalized = String(value || "")
.trim()
.toLowerCase()
return normalized === "unknown" || normalized === "phone"
}
function isPlaceholderLastName(value?: string) {
const normalized = String(value || "")
.trim()
.toLowerCase()
return (
normalized === "contact" ||
normalized === "lead" ||
normalized === "caller"
)
}
function looksLikePhoneLabel(value?: string) {
const normalized = trimOptional(value)
if (!normalized) {
return false
}
const digits = normalized.replace(/\D/g, "")
return digits.length >= 7 && digits.length <= 15
}
export function sanitizeContactNameParts(args: {
firstName?: string
lastName?: string
fullName?: string
}) {
let firstName = trimOptional(args.firstName)
let lastName = trimOptional(args.lastName)
if (!firstName && !lastName) {
const fullName = trimOptional(args.fullName)
if (fullName && !looksLikePhoneLabel(fullName)) {
const parts = fullName.split(/\s+/).filter(Boolean)
if (parts.length === 1) {
firstName = parts[0]
} else if (parts.length > 1) {
firstName = parts.shift()
lastName = parts.join(" ")
}
}
}
if (isPlaceholderFirstName(firstName)) {
firstName = undefined
}
if (isPlaceholderLastName(lastName)) {
lastName = undefined
}
return {
firstName,
lastName,
}
}
export function dedupeStrings(values?: string[]) {
return Array.from(
new Set(
(values || [])
.map((value) => String(value || "").trim())
.filter(Boolean)
)
)
}
export async function findContactByIdentity(ctx, args) {
if (args.ghlContactId) {
const byGhl = await ctx.db
.query("contacts")
.withIndex("by_ghlContactId", (q) => q.eq("ghlContactId", args.ghlContactId))
.unique()
if (byGhl) {
return byGhl
}
}
const normalizedEmail = normalizeEmail(args.email)
if (normalizedEmail) {
const byEmail = await ctx.db
.query("contacts")
.withIndex("by_normalizedEmail", (q) =>
q.eq("normalizedEmail", normalizedEmail)
)
.unique()
if (byEmail) {
return byEmail
}
}
const normalizedPhone = normalizePhone(args.phone)
if (normalizedPhone) {
const byPhone = await ctx.db
.query("contacts")
.withIndex("by_normalizedPhone", (q) =>
q.eq("normalizedPhone", normalizedPhone)
)
.unique()
if (byPhone) {
return byPhone
}
}
return null
}
export async function upsertContactRecord(ctx, input) {
const now = input.updatedAt ?? Date.now()
const normalizedEmail = normalizeEmail(input.email)
const normalizedPhone = normalizePhone(input.phone)
const existing = await findContactByIdentity(ctx, {
ghlContactId: input.ghlContactId,
email: normalizedEmail,
phone: normalizedPhone,
})
const existingName = sanitizeContactNameParts({
firstName: existing?.firstName,
lastName: existing?.lastName,
})
const incomingName = sanitizeContactNameParts({
firstName: input.firstName,
lastName: input.lastName,
fullName: input.fullName,
})
const patch = {
firstName: incomingName.firstName ?? existingName.firstName ?? "",
lastName: incomingName.lastName ?? existingName.lastName ?? "",
email: input.email || existing?.email,
normalizedEmail: normalizedEmail || existing?.normalizedEmail,
phone: input.phone || existing?.phone,
normalizedPhone: normalizedPhone || existing?.normalizedPhone,
company: input.company ?? existing?.company,
tags: dedupeStrings([...(existing?.tags || []), ...(input.tags || [])]),
status: input.status || existing?.status || "lead",
source: input.source || existing?.source,
notes: input.notes ?? existing?.notes,
ghlContactId: input.ghlContactId || existing?.ghlContactId,
livekitIdentity: input.livekitIdentity || existing?.livekitIdentity,
lastActivityAt:
input.lastActivityAt ?? existing?.lastActivityAt ?? input.createdAt ?? now,
updatedAt: now,
}
if (existing) {
await ctx.db.patch(existing._id, patch)
return await ctx.db.get(existing._id)
}
const id = await ctx.db.insert("contacts", {
...patch,
createdAt: input.createdAt ?? now,
})
return await ctx.db.get(id)
}
export async function upsertConversationRecord(ctx, input) {
const now = input.updatedAt ?? Date.now()
let existing = null
if (input.ghlConversationId) {
existing = await ctx.db
.query("conversations")
.withIndex("by_ghlConversationId", (q) =>
q.eq("ghlConversationId", input.ghlConversationId)
)
.unique()
}
if (!existing && input.livekitRoomName) {
existing = await ctx.db
.query("conversations")
.withIndex("by_livekitRoomName", (q) =>
q.eq("livekitRoomName", input.livekitRoomName)
)
.unique()
}
if (!existing && input.voiceSessionId) {
existing = await ctx.db
.query("conversations")
.withIndex("by_voiceSessionId", (q) =>
q.eq("voiceSessionId", input.voiceSessionId)
)
.unique()
}
if (!existing && input.contactId) {
const candidates = await ctx.db
.query("conversations")
.withIndex("by_contactId", (q) => q.eq("contactId", input.contactId))
.collect()
const targetMoment =
input.lastMessageAt ?? input.startedAt ?? input.updatedAt ?? now
existing =
candidates
.filter((candidate) => {
if (input.channel && candidate.channel !== input.channel) {
return false
}
const candidateMoment =
candidate.lastMessageAt ??
candidate.startedAt ??
candidate.updatedAt ??
0
return Math.abs(candidateMoment - targetMoment) <= 5 * 60 * 1000
})
.sort((a, b) => {
const aMoment = a.lastMessageAt ?? a.startedAt ?? a.updatedAt ?? 0
const bMoment = b.lastMessageAt ?? b.startedAt ?? b.updatedAt ?? 0
return Math.abs(aMoment - targetMoment) - Math.abs(bMoment - targetMoment)
})[0] || null
}
const patch = {
contactId: input.contactId ?? existing?.contactId,
title: input.title || existing?.title,
channel: input.channel || existing?.channel || "unknown",
source: input.source || existing?.source,
status: input.status || existing?.status || "open",
direction: input.direction || existing?.direction || "mixed",
startedAt: input.startedAt ?? existing?.startedAt ?? now,
endedAt: input.endedAt ?? existing?.endedAt,
lastMessageAt: input.lastMessageAt ?? existing?.lastMessageAt,
lastMessagePreview: input.lastMessagePreview ?? existing?.lastMessagePreview,
unreadCount: input.unreadCount ?? existing?.unreadCount ?? 0,
summaryText: input.summaryText ?? existing?.summaryText,
ghlConversationId: input.ghlConversationId || existing?.ghlConversationId,
livekitRoomName: input.livekitRoomName || existing?.livekitRoomName,
voiceSessionId: input.voiceSessionId ?? existing?.voiceSessionId,
updatedAt: now,
}
if (existing) {
await ctx.db.patch(existing._id, patch)
return await ctx.db.get(existing._id)
}
const id = await ctx.db.insert("conversations", {
...patch,
createdAt: input.createdAt ?? now,
})
return await ctx.db.get(id)
}
export async function ensureConversationParticipant(ctx, input) {
const participants = await ctx.db
.query("conversationParticipants")
.withIndex("by_conversationId", (q) =>
q.eq("conversationId", input.conversationId)
)
.collect()
const normalizedEmail = normalizeEmail(input.email)
const normalizedPhone = normalizePhone(input.phone)
const existing = participants.find((participant) => {
if (input.contactId && participant.contactId === input.contactId) {
return true
}
if (
input.externalContactId &&
participant.externalContactId === input.externalContactId
) {
return true
}
if (normalizedEmail && participant.normalizedEmail === normalizedEmail) {
return true
}
if (normalizedPhone && participant.normalizedPhone === normalizedPhone) {
return true
}
return false
})
const patch = {
contactId: input.contactId ?? existing?.contactId,
role: input.role || existing?.role || "unknown",
displayName: input.displayName || existing?.displayName,
phone: input.phone || existing?.phone,
normalizedPhone: normalizedPhone || existing?.normalizedPhone,
email: input.email || existing?.email,
normalizedEmail: normalizedEmail || existing?.normalizedEmail,
externalContactId: input.externalContactId || existing?.externalContactId,
updatedAt: Date.now(),
}
if (existing) {
await ctx.db.patch(existing._id, patch)
return await ctx.db.get(existing._id)
}
const id = await ctx.db.insert("conversationParticipants", {
conversationId: input.conversationId,
...patch,
createdAt: Date.now(),
})
return await ctx.db.get(id)
}
export async function upsertMessageRecord(ctx, input) {
let existing = null
if (input.ghlMessageId) {
existing = await ctx.db
.query("messages")
.withIndex("by_ghlMessageId", (q) =>
q.eq("ghlMessageId", input.ghlMessageId)
)
.unique()
}
if (!existing && input.voiceTranscriptTurnId) {
existing = await ctx.db
.query("messages")
.withIndex("by_voiceTranscriptTurnId", (q) =>
q.eq("voiceTranscriptTurnId", input.voiceTranscriptTurnId)
)
.unique()
}
const now = input.updatedAt ?? Date.now()
const patch = {
conversationId: input.conversationId,
contactId: input.contactId,
direction: input.direction || existing?.direction || "system",
channel: input.channel || existing?.channel || "unknown",
source: input.source || existing?.source,
messageType: input.messageType || existing?.messageType,
body: String(input.body || existing?.body || "").trim(),
status: input.status || existing?.status,
sentAt: input.sentAt ?? existing?.sentAt ?? now,
ghlMessageId: input.ghlMessageId || existing?.ghlMessageId,
voiceTranscriptTurnId:
input.voiceTranscriptTurnId ?? existing?.voiceTranscriptTurnId,
voiceSessionId: input.voiceSessionId ?? existing?.voiceSessionId,
livekitRoomName: input.livekitRoomName || existing?.livekitRoomName,
metadata: input.metadata || existing?.metadata,
updatedAt: now,
}
let message
if (existing) {
await ctx.db.patch(existing._id, patch)
message = await ctx.db.get(existing._id)
} else {
const id = await ctx.db.insert("messages", {
...patch,
createdAt: input.createdAt ?? now,
})
message = await ctx.db.get(id)
}
await ctx.db.patch(input.conversationId, {
lastMessageAt: patch.sentAt,
lastMessagePreview: patch.body.slice(0, 240),
updatedAt: now,
})
return message
}
export async function upsertCallArtifactRecord(ctx, input) {
let existing = null
if (input.recordingId) {
existing = await ctx.db
.query("callArtifacts")
.withIndex("by_recordingId", (q) => q.eq("recordingId", input.recordingId))
.unique()
}
if (!existing && input.voiceSessionId) {
existing = await ctx.db
.query("callArtifacts")
.withIndex("by_voiceSessionId", (q) =>
q.eq("voiceSessionId", input.voiceSessionId)
)
.unique()
}
if (!existing && input.ghlMessageId) {
existing = await ctx.db
.query("callArtifacts")
.withIndex("by_ghlMessageId", (q) =>
q.eq("ghlMessageId", input.ghlMessageId)
)
.unique()
}
const now = input.updatedAt ?? Date.now()
const patch = {
conversationId: input.conversationId,
contactId: input.contactId ?? existing?.contactId,
source: input.source || existing?.source,
recordingId: input.recordingId || existing?.recordingId,
recordingUrl: input.recordingUrl || existing?.recordingUrl,
recordingStatus: input.recordingStatus || existing?.recordingStatus,
transcriptionText: input.transcriptionText ?? existing?.transcriptionText,
durationMs: input.durationMs ?? existing?.durationMs,
startedAt: input.startedAt ?? existing?.startedAt,
endedAt: input.endedAt ?? existing?.endedAt,
ghlMessageId: input.ghlMessageId || existing?.ghlMessageId,
voiceSessionId: input.voiceSessionId ?? existing?.voiceSessionId,
livekitRoomName: input.livekitRoomName || existing?.livekitRoomName,
metadata: input.metadata || existing?.metadata,
updatedAt: now,
}
if (existing) {
await ctx.db.patch(existing._id, patch)
return await ctx.db.get(existing._id)
}
const id = await ctx.db.insert("callArtifacts", {
...patch,
createdAt: input.createdAt ?? now,
})
return await ctx.db.get(id)
}
export async function upsertExternalSyncState(ctx, input) {
const existing = await ctx.db
.query("externalSyncState")
.withIndex("by_provider_entityType_entityId", (q) =>
q
.eq("provider", input.provider)
.eq("entityType", input.entityType)
.eq("entityId", input.entityId)
)
.unique()
const patch = {
cursor: input.cursor ?? existing?.cursor,
checksum: input.checksum ?? existing?.checksum,
status: input.status || existing?.status || "pending",
lastAttemptAt: input.lastAttemptAt ?? existing?.lastAttemptAt ?? Date.now(),
lastSyncedAt: input.lastSyncedAt ?? existing?.lastSyncedAt,
error: input.error ?? existing?.error,
metadata: input.metadata ?? existing?.metadata,
updatedAt: Date.now(),
}
if (existing) {
await ctx.db.patch(existing._id, patch)
return await ctx.db.get(existing._id)
}
const id = await ctx.db.insert("externalSyncState", {
provider: input.provider,
entityType: input.entityType,
entityId: input.entityId,
...patch,
})
return await ctx.db.get(id)
}