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 { 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 { 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 { api } from '@/lib/api';
|
||||||
import type { SmsConversation, SmsPaginatedResponse } from '@/types/sms';
|
import type { SmsConversation, SmsPaginatedResponse } from '@/types/sms';
|
||||||
import type { AppOutletContext } from '@/types/api';
|
import type { AppOutletContext } from '@/types/api';
|
||||||
import { useOutletContext, Link } from 'react-router-dom';
|
import { useOutletContext, Link } from 'react-router-dom';
|
||||||
|
import NewConversationModal from './NewConversationModal';
|
||||||
|
|
||||||
const { Text, Paragraph } = Typography;
|
const { Text, Paragraph } = Typography;
|
||||||
const { Search, TextArea } = Input;
|
const { Search, TextArea } = Input;
|
||||||
@ -37,6 +38,9 @@ export default function SmsConversationsPage() {
|
|||||||
const [notesSaving, setNotesSaving] = useState(false);
|
const [notesSaving, setNotesSaving] = useState(false);
|
||||||
const [tagsSaving, setTagsSaving] = useState(false);
|
const [tagsSaving, setTagsSaving] = useState(false);
|
||||||
|
|
||||||
|
// New conversation modal
|
||||||
|
const [newConvOpen, setNewConvOpen] = useState(false);
|
||||||
|
|
||||||
// Bulk selection state
|
// Bulk selection state
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const [bulkLoading, setBulkLoading] = useState(false);
|
const [bulkLoading, setBulkLoading] = useState(false);
|
||||||
@ -179,12 +183,17 @@ export default function SmsConversationsPage() {
|
|||||||
<Row gutter={16} style={{ height: 'calc(100vh - 200px)', minHeight: 400 }}>
|
<Row gutter={16} style={{ height: 'calc(100vh - 200px)', minHeight: 400 }}>
|
||||||
{/* Conversation List (left panel) */}
|
{/* Conversation List (left panel) */}
|
||||||
<Col xs={24} md={8} style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
<Col xs={24} md={8} style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
<Search
|
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||||||
placeholder="Search by phone or name..."
|
<Search
|
||||||
onSearch={(v) => { setSearch(v); fetchConversations(v); }}
|
placeholder="Search by phone or name..."
|
||||||
allowClear
|
onSearch={(v) => { setSearch(v); fetchConversations(v); }}
|
||||||
style={{ marginBottom: 8 }}
|
allowClear
|
||||||
/>
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => setNewConvOpen(true)}>
|
||||||
|
New
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Bulk action bar */}
|
{/* Bulk action bar */}
|
||||||
{selectedIds.size > 0 && (
|
{selectedIds.size > 0 && (
|
||||||
@ -396,6 +405,15 @@ export default function SmsConversationsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
|
<NewConversationModal
|
||||||
|
open={newConvOpen}
|
||||||
|
onClose={() => setNewConvOpen(false)}
|
||||||
|
onCreated={(conv) => {
|
||||||
|
fetchConversations();
|
||||||
|
if (conv) setSelected(conv);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Row>
|
</Row>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,6 +24,60 @@ router.get('/', async (req, res, next) => {
|
|||||||
} catch (err) { next(err); }
|
} 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
|
// GET /api/sms/conversations/stats — conversation stats
|
||||||
router.get('/stats', async (_req, res, next) => {
|
router.get('/stats', async (_req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -2,6 +2,24 @@ import { prisma } from '../../../config/database';
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { smsQueueService } from '../../../services/sms-queue.service';
|
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 = {
|
export const smsConversationsService = {
|
||||||
async findAll(options: {
|
async findAll(options: {
|
||||||
page?: number;
|
page?: number;
|
||||||
@ -171,4 +189,181 @@ export const smsConversationsService = {
|
|||||||
|
|
||||||
return { updated: result.count };
|
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