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:
parent
d835f0837b
commit
aaba7df97d
231
admin/src/pages/sms/NewConversationModal.tsx
Normal file
231
admin/src/pages/sms/NewConversationModal.tsx
Normal file
@ -0,0 +1,231 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Modal, Input, List, Tag, Typography, Space, Button, App } from 'antd';
|
||||
import { SendOutlined, PhoneOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { api } from '@/lib/api';
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
import type { SmsContactSearchResult, SmsConversation } from '@/types/sms';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
const SOURCE_LABELS: Record<string, { label: string; color: string }> = {
|
||||
sms_contact: { label: 'SMS List', color: 'purple' },
|
||||
crm_contact: { label: 'CRM', color: 'blue' },
|
||||
conversation: { label: 'Previous', color: 'default' },
|
||||
};
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: (conv: SmsConversation) => void;
|
||||
}
|
||||
|
||||
export default function NewConversationModal({ open, onClose, onCreated }: Props) {
|
||||
const { message } = App.useApp();
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<SmsContactSearchResult[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [selectedContact, setSelectedContact] = useState<SmsContactSearchResult | null>(null);
|
||||
const [msgText, setMsgText] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const debouncedQuery = useDebounce(query, 300);
|
||||
|
||||
// Search contacts when debounced query changes
|
||||
useEffect(() => {
|
||||
if (!debouncedQuery || debouncedQuery.length < 2) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setSearching(true);
|
||||
api.get<{ results: SmsContactSearchResult[] }>('/sms/conversations/contact-search', {
|
||||
params: { q: debouncedQuery },
|
||||
}).then(({ data }) => {
|
||||
if (!cancelled) setResults(data.results);
|
||||
}).catch(() => {
|
||||
if (!cancelled) setResults([]);
|
||||
}).finally(() => {
|
||||
if (!cancelled) setSearching(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [debouncedQuery]);
|
||||
|
||||
// Check if query looks like a phone number (mostly digits, 7+)
|
||||
const digitsOnly = query.replace(/\D/g, '');
|
||||
const isPhoneQuery = digitsOnly.length >= 7;
|
||||
const phoneAlreadyInResults = isPhoneQuery && results.some((r) => r.phone.includes(digitsOnly));
|
||||
|
||||
const handleSelect = (contact: SmsContactSearchResult) => {
|
||||
setSelectedContact(contact);
|
||||
};
|
||||
|
||||
const handleManualPhone = () => {
|
||||
setSelectedContact({
|
||||
phone: digitsOnly,
|
||||
name: null,
|
||||
source: 'sms_contact',
|
||||
sourceId: '',
|
||||
});
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!selectedContact || !msgText.trim()) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const { data } = await api.post<SmsConversation>('/sms/conversations', {
|
||||
phone: selectedContact.phone,
|
||||
message: msgText.trim(),
|
||||
contactName: selectedContact.name || undefined,
|
||||
contactId: selectedContact.contactId || undefined,
|
||||
});
|
||||
message.success('Message queued');
|
||||
onCreated(data);
|
||||
handleReset();
|
||||
} catch (err: any) {
|
||||
const errMsg = err.response?.data?.error || 'Failed to send message';
|
||||
message.error(errMsg);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
setSelectedContact(null);
|
||||
setMsgText('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setSelectedContact(null);
|
||||
setMsgText('');
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="New Conversation"
|
||||
open={open}
|
||||
onCancel={handleReset}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
width={480}
|
||||
>
|
||||
{!selectedContact ? (
|
||||
// Step 1: Contact Search
|
||||
<div>
|
||||
<Input
|
||||
placeholder="Search by name or phone number..."
|
||||
prefix={<PhoneOutlined />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
allowClear
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: 12, maxHeight: 320, overflowY: 'auto' }}>
|
||||
{/* Manual phone entry option */}
|
||||
{isPhoneQuery && !phoneAlreadyInResults && (
|
||||
<div
|
||||
onClick={handleManualPhone}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
borderRadius: 6,
|
||||
marginBottom: 4,
|
||||
border: '1px dashed rgba(255,255,255,0.15)',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,0.06)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<Space>
|
||||
<PhoneOutlined />
|
||||
<Text>Use number: {digitsOnly}</Text>
|
||||
<Tag color="green">Manual</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<List
|
||||
loading={searching}
|
||||
dataSource={results}
|
||||
locale={{ emptyText: query.length >= 2 ? 'No contacts found' : 'Type to search...' }}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
onClick={() => handleSelect(item)}
|
||||
style={{ cursor: 'pointer', padding: '8px 12px', borderRadius: 6 }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,0.06)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%', alignItems: 'center' }}>
|
||||
<Space>
|
||||
<UserOutlined />
|
||||
<div>
|
||||
<Text strong>{item.name || item.phone}</Text>
|
||||
{item.name && <Text type="secondary" style={{ marginLeft: 8, fontSize: 12 }}>{item.phone}</Text>}
|
||||
</div>
|
||||
</Space>
|
||||
<Tag color={SOURCE_LABELS[item.source]?.color}>
|
||||
{SOURCE_LABELS[item.source]?.label}
|
||||
</Tag>
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Step 2: Compose Message
|
||||
<div>
|
||||
<Button type="link" onClick={handleBack} style={{ padding: 0, marginBottom: 8 }}>
|
||||
← Change contact
|
||||
</Button>
|
||||
|
||||
<div style={{
|
||||
padding: '10px 14px',
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
borderRadius: 8,
|
||||
marginBottom: 12,
|
||||
}}>
|
||||
<Space>
|
||||
<UserOutlined />
|
||||
<div>
|
||||
{selectedContact.name && <Text strong>{selectedContact.name}</Text>}
|
||||
<Text type="secondary" style={{ marginLeft: selectedContact.name ? 8 : 0 }}>
|
||||
{selectedContact.phone}
|
||||
</Text>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<TextArea
|
||||
rows={4}
|
||||
value={msgText}
|
||||
onChange={(e) => setMsgText(e.target.value)}
|
||||
placeholder="Type your message..."
|
||||
maxLength={1600}
|
||||
showCount
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 12 }}>
|
||||
<Space>
|
||||
<Button onClick={handleReset}>Cancel</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
onClick={handleSend}
|
||||
loading={sending}
|
||||
disabled={!msgText.trim()}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@ -1,10 +1,11 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Row, Col, List, Card, Input, Button, Tag, Badge, Space, Typography, Empty, Spin, App, Checkbox, Select, Collapse } from 'antd';
|
||||
import { SendOutlined, CheckOutlined, ReadOutlined, CloseCircleOutlined, LinkOutlined } from '@ant-design/icons';
|
||||
import { SendOutlined, CheckOutlined, ReadOutlined, CloseCircleOutlined, LinkOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { api } from '@/lib/api';
|
||||
import type { SmsConversation, SmsPaginatedResponse } from '@/types/sms';
|
||||
import type { AppOutletContext } from '@/types/api';
|
||||
import { useOutletContext, Link } from 'react-router-dom';
|
||||
import NewConversationModal from './NewConversationModal';
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
const { Search, TextArea } = Input;
|
||||
@ -37,6 +38,9 @@ export default function SmsConversationsPage() {
|
||||
const [notesSaving, setNotesSaving] = useState(false);
|
||||
const [tagsSaving, setTagsSaving] = useState(false);
|
||||
|
||||
// New conversation modal
|
||||
const [newConvOpen, setNewConvOpen] = useState(false);
|
||||
|
||||
// Bulk selection state
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [bulkLoading, setBulkLoading] = useState(false);
|
||||
@ -179,12 +183,17 @@ export default function SmsConversationsPage() {
|
||||
<Row gutter={16} style={{ height: 'calc(100vh - 200px)', minHeight: 400 }}>
|
||||
{/* Conversation List (left panel) */}
|
||||
<Col xs={24} md={8} style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||||
<Search
|
||||
placeholder="Search by phone or name..."
|
||||
onSearch={(v) => { setSearch(v); fetchConversations(v); }}
|
||||
allowClear
|
||||
style={{ marginBottom: 8 }}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setNewConvOpen(true)}>
|
||||
New
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Bulk action bar */}
|
||||
{selectedIds.size > 0 && (
|
||||
@ -396,6 +405,15 @@ export default function SmsConversationsPage() {
|
||||
</Card>
|
||||
)}
|
||||
</Col>
|
||||
|
||||
<NewConversationModal
|
||||
open={newConvOpen}
|
||||
onClose={() => setNewConvOpen(false)}
|
||||
onCreated={(conv) => {
|
||||
fetchConversations();
|
||||
if (conv) setSelected(conv);
|
||||
}}
|
||||
/>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user