Add new conversation feature to SMS module

Enable starting ad-hoc SMS conversations from the conversations page
by searching contacts across SMS lists, CRM, and existing threads,
then composing and sending a first message.

Bunker Admin
This commit is contained in:
2026-02-28 16:55:24 -07:00
parent d835f0837b
commit aaba7df97d
4 changed files with 505 additions and 7 deletions

View File

@@ -24,6 +24,60 @@ router.get('/', async (req, res, next) => {
} catch (err) { next(err); }
});
// GET /api/sms/conversations/contact-search — search contacts for new conversation
router.get('/contact-search', async (req, res, next) => {
try {
const q = (req.query.q as string || '').trim();
if (q.length < 2) {
res.json({ results: [] });
return;
}
const results = await smsConversationsService.searchContacts(q);
res.json({ results });
} catch (err) { next(err); }
});
// POST /api/sms/conversations — start new conversation
router.post('/', async (req, res, next) => {
try {
const { phone, message, contactName, contactId } = req.body as {
phone?: string;
message?: string;
contactName?: string;
contactId?: string;
};
if (!phone || typeof phone !== 'string' || phone.replace(/\D/g, '').length < 7) {
res.status(400).json({ error: 'Valid phone number is required (min 7 digits)' });
return;
}
if (!message || typeof message !== 'string' || message.trim().length === 0) {
res.status(400).json({ error: 'Message is required' });
return;
}
if (message.length > 1600) {
res.status(400).json({ error: 'Message cannot exceed 1600 characters' });
return;
}
const conversation = await smsConversationsService.startConversation({
phone,
message: message.trim(),
contactName,
contactId,
});
res.status(201).json(conversation);
} catch (err: any) {
if (err.statusCode === 409) {
res.status(409).json({ error: err.message });
return;
}
if (err.statusCode === 400) {
res.status(400).json({ error: err.message });
return;
}
next(err);
}
});
// GET /api/sms/conversations/stats — conversation stats
router.get('/stats', async (_req, res, next) => {
try {

View File

@@ -2,6 +2,24 @@ import { prisma } from '../../../config/database';
import { Prisma } from '@prisma/client';
import { smsQueueService } from '../../../services/sms-queue.service';
/**
* Normalize a phone number: strip non-digit characters, validate 10-11 digits.
*/
function normalizePhone(raw: string): string | null {
const digits = raw.replace(/\D/g, '');
if (digits.length === 10) return digits;
if (digits.length === 11 && digits.startsWith('1')) return digits;
return null;
}
export interface ContactSearchResult {
phone: string;
name: string | null;
source: 'sms_contact' | 'crm_contact' | 'conversation';
sourceId: string;
contactId?: string;
}
export const smsConversationsService = {
async findAll(options: {
page?: number;
@@ -171,4 +189,181 @@ export const smsConversationsService = {
return { updated: result.count };
},
/**
* Search contacts across SMS lists, CRM contacts, and existing conversations.
* Deduplicates by phone number, prioritizing SMS contacts > CRM > conversations.
*/
async searchContacts(query: string, limit = 20): Promise<ContactSearchResult[]> {
const seen = new Map<string, ContactSearchResult>();
const [smsEntries, crmContacts, existingConvs] = await Promise.all([
// 1. SMS Contact List Entries
prisma.smsContactListEntry.findMany({
where: {
OR: [
{ phone: { contains: query } },
{ name: { contains: query, mode: 'insensitive' } },
],
},
take: limit,
select: { id: true, phone: true, name: true },
}),
// 2. CRM Contacts (with phones) — exclude opted out
prisma.contact.findMany({
where: {
doNotContact: false,
smsOptOut: false,
OR: [
{ displayName: { contains: query, mode: 'insensitive' } },
{ phone: { contains: query } },
{ phones: { some: { phone: { contains: query } } } },
],
},
take: limit,
select: {
id: true,
displayName: true,
phone: true,
phones: { select: { phone: true }, take: 5 },
},
}),
// 3. Existing conversations
prisma.smsConversation.findMany({
where: {
OR: [
{ phone: { contains: query } },
{ contactName: { contains: query, mode: 'insensitive' } },
],
},
take: limit,
select: { id: true, phone: true, contactName: true, contactId: true, status: true },
}),
]);
// Add SMS contacts first (highest priority)
for (const entry of smsEntries) {
if (!seen.has(entry.phone)) {
seen.set(entry.phone, {
phone: entry.phone,
name: entry.name,
source: 'sms_contact',
sourceId: entry.id,
});
}
}
// Add CRM contacts
for (const contact of crmContacts) {
const phones: string[] = [];
if (contact.phone) phones.push(contact.phone);
for (const cp of contact.phones) {
if (!phones.includes(cp.phone)) phones.push(cp.phone);
}
for (const phone of phones) {
if (!seen.has(phone)) {
seen.set(phone, {
phone,
name: contact.displayName,
source: 'crm_contact',
sourceId: contact.id,
contactId: contact.id,
});
}
}
}
// Add existing conversations (lowest priority)
for (const conv of existingConvs) {
if (!seen.has(conv.phone)) {
seen.set(conv.phone, {
phone: conv.phone,
name: conv.contactName,
source: 'conversation',
sourceId: conv.id,
contactId: conv.contactId || undefined,
});
}
}
return Array.from(seen.values()).slice(0, limit);
},
/**
* Start a new ad-hoc conversation or reuse an existing one, then send the first message.
*/
async startConversation(input: {
phone: string;
message: string;
contactName?: string;
contactId?: string;
}) {
const normalized = normalizePhone(input.phone);
if (!normalized) throw Object.assign(new Error('Invalid phone number'), { statusCode: 400 });
// Use a transaction to prevent race conditions on conversation lookup/create
const conversation = await prisma.$transaction(async (tx) => {
// Look for existing ad-hoc conversation (campaignId = null)
const existing = await tx.smsConversation.findFirst({
where: { phone: normalized, campaignId: null },
});
if (existing) {
if (existing.status === 'OPTED_OUT') {
throw Object.assign(new Error('Cannot message opted-out contact'), { statusCode: 409 });
}
// Reopen if closed, update stats
return tx.smsConversation.update({
where: { id: existing.id },
data: {
status: 'ACTIVE',
totalMessages: { increment: 1 },
lastMessageAt: new Date(),
// Update contact info if provided and not already set
contactName: existing.contactName || input.contactName || undefined,
contactId: existing.contactId || input.contactId || undefined,
},
});
}
// Create new conversation
return tx.smsConversation.create({
data: {
phone: normalized,
contactName: input.contactName || null,
contactId: input.contactId || null,
status: 'ACTIVE',
totalMessages: 1,
lastMessageAt: new Date(),
},
});
});
// Create outbound message
const smsMessage = await prisma.smsMessage.create({
data: {
phone: normalized,
message: input.message,
direction: 'OUTBOUND',
status: 'PENDING',
connectionType: 'termux',
conversationId: conversation.id,
},
});
// Queue the SMS send
await smsQueueService.addSmsJob({
recipientId: smsMessage.id,
campaignId: '',
phone: normalized,
message: input.message,
attemptNumber: 1,
});
// Return full conversation with messages
return this.findById(conversation.id);
},
};