497 lines
14 KiB
TypeScript
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)
|
|
}
|