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:
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user