sms updates

This commit is contained in:
bunker-admin 2026-02-27 15:02:28 -07:00
parent 9f9244df32
commit 06ce9dac1b
22 changed files with 1983 additions and 211 deletions

View File

@ -38,6 +38,7 @@ import {
AlertOutlined, AlertOutlined,
MailOutlined, MailOutlined,
RetweetOutlined, RetweetOutlined,
PhoneOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
@ -614,6 +615,36 @@ export default function SettingsPage() {
</Form.Item> </Form.Item>
</Card> </Card>
</Col> </Col>
{/* SMS Notifications — only show when enableSms is on */}
<Form.Item noStyle shouldUpdate={(prev: Record<string, unknown>, cur: Record<string, unknown>) => prev.enableSms !== cur.enableSms}>
{({ getFieldValue }: { getFieldValue: (name: string) => unknown }) =>
getFieldValue('enableSms') ? (
<Col xs={24} lg={12}>
<Card
size="small"
title={<Space><PhoneOutlined /> SMS Notifications</Space>}
>
<Form.Item label="Shift Reminders" name="smsShiftReminders" valuePropName="checked" extra="SMS reminder before a volunteer's shift. Variables: {name}, {shiftTitle}, {shiftTime}, {shiftLocation}" style={{ marginBottom: 12 }}>
<Switch />
</Form.Item>
<Form.Item label="Reminder Hours Before" name="smsShiftReminderHours" extra="Hours before shift to send the SMS reminder (1-72)" style={{ marginBottom: 12 }}>
<InputNumber min={1} max={72} />
</Form.Item>
<Form.Item label="Signup Confirmations" name="smsShiftSignupConfirm" valuePropName="checked" extra="SMS confirmation when a volunteer signs up. Variables: {name}, {shiftTitle}, {shiftDate}, {shiftTime}" style={{ marginBottom: 12 }}>
<Switch />
</Form.Item>
<Form.Item label="Volunteer Welcome" name="smsVolunteerWelcome" valuePropName="checked" extra="Welcome SMS when a new volunteer account is created. Variables: {name}" style={{ marginBottom: 12 }}>
<Switch />
</Form.Item>
<Text type="secondary" style={{ fontSize: 12, display: 'block' }}>
All SMS notifications respect opt-outs. Requires an active phone connection.
</Text>
</Card>
</Col>
) : null
}
</Form.Item>
</Row> </Row>
</div> </div>
), ),

View File

@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Table, Button, Modal, Form, Input, Select, InputNumber, Space, Tag, Progress, App, Typography, Popconfirm } from 'antd'; import { Table, Button, Modal, Form, Input, Select, InputNumber, Space, Tag, Progress, App, Typography, Popconfirm, Divider, Alert } from 'antd';
import { PlusOutlined, PlayCircleOutlined, PauseCircleOutlined, CaretRightOutlined, DeleteOutlined } from '@ant-design/icons'; import { PlusOutlined, PlayCircleOutlined, PauseCircleOutlined, CaretRightOutlined, DeleteOutlined, EyeOutlined, SendOutlined, PhoneOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import type { SmsCampaign, SmsContactList, SmsPaginatedResponse } from '@/types/sms'; import type { SmsCampaign, SmsContactList, SmsPaginatedResponse } from '@/types/sms';
@ -30,6 +30,11 @@ export default function SmsCampaignsPage() {
const [contactLists, setContactLists] = useState<SmsContactList[]>([]); const [contactLists, setContactLists] = useState<SmsContactList[]>([]);
const [createForm] = Form.useForm(); const [createForm] = Form.useForm();
// Preview & Test
const [testPreview, setTestPreview] = useState('');
const [testPhone, setTestPhone] = useState('');
const [testSending, setTestSending] = useState(false);
useEffect(() => { useEffect(() => {
setPageHeader({ title: 'SMS Campaigns', subtitle: 'Create and manage SMS campaigns' }); setPageHeader({ title: 'SMS Campaigns', subtitle: 'Create and manage SMS campaigns' });
}, [setPageHeader]); }, [setPageHeader]);
@ -110,6 +115,31 @@ export default function SmsCampaignsPage() {
} catch { message.error('Delete failed — only DRAFT campaigns can be deleted'); } } catch { message.error('Delete failed — only DRAFT campaigns can be deleted'); }
}; };
const handlePreviewTest = () => {
const template = createForm.getFieldValue('messageTemplate') || '';
// Substitute placeholders with sample values
const preview = template
.replace(/\{name\}/g, 'Jane Doe')
.replace(/\{phone\}/g, '+1 555 000 0000');
setTestPreview(preview);
};
const handleSendTestCampaign = async () => {
if (!testPhone.trim() || !testPreview.trim()) {
message.warning('Enter a test phone number and ensure the message preview is visible');
return;
}
setTestSending(true);
try {
await api.post('/sms/messages/send', { phone: testPhone.trim(), message: testPreview });
message.success('Test message sent');
} catch {
message.error('Failed to send test message');
} finally {
setTestSending(false);
}
};
const columns: ColumnsType<SmsCampaign> = [ const columns: ColumnsType<SmsCampaign> = [
{ title: 'Name', dataIndex: 'name', ellipsis: true }, { title: 'Name', dataIndex: 'name', ellipsis: true },
{ {
@ -204,7 +234,7 @@ export default function SmsCampaignsPage() {
<Modal <Modal
title="New SMS Campaign" title="New SMS Campaign"
open={createOpen} open={createOpen}
onCancel={() => setCreateOpen(false)} onCancel={() => { setCreateOpen(false); setTestPreview(''); }}
onOk={() => createForm.submit()} onOk={() => createForm.submit()}
width={600} width={600}
> >
@ -224,6 +254,54 @@ export default function SmsCampaignsPage() {
<Form.Item name="messageTemplate" label="Message Template" rules={[{ required: true }]} extra="Use {name}, {phone} for substitution"> <Form.Item name="messageTemplate" label="Message Template" rules={[{ required: true }]} extra="Use {name}, {phone} for substitution">
<TextArea rows={4} maxLength={1600} showCount placeholder="Hi {name}, ..." /> <TextArea rows={4} maxLength={1600} showCount placeholder="Hi {name}, ..." />
</Form.Item> </Form.Item>
<Divider dashed style={{ margin: '12px 0' }} />
<Button icon={<EyeOutlined />} onClick={handlePreviewTest} style={{ marginBottom: 12 }}>
Preview & Test
</Button>
{testPreview && (
<Alert
type="info"
message="Message Preview"
description={
<div>
<div style={{
background: 'rgba(0,0,0,0.05)',
padding: 12,
borderRadius: 6,
marginBottom: 12,
whiteSpace: 'pre-wrap',
fontFamily: 'monospace',
fontSize: 13,
}}>
{testPreview}
</div>
<Space>
<Input
value={testPhone}
onChange={(e) => setTestPhone(e.target.value)}
placeholder="+1 555 123 4567"
prefix={<PhoneOutlined />}
style={{ width: 200 }}
/>
<Button
type="primary"
size="small"
icon={<SendOutlined />}
loading={testSending}
onClick={handleSendTestCampaign}
disabled={!testPhone.trim()}
>
Send Test
</Button>
</Space>
</div>
}
style={{ marginBottom: 16 }}
/>
)}
<Form.Item name="delayBetweenMs" label="Delay Between Messages (ms)"> <Form.Item name="delayBetweenMs" label="Delay Between Messages (ms)">
<InputNumber min={1000} max={60000} step={500} style={{ width: '100%' }} /> <InputNumber min={1000} max={60000} step={500} style={{ width: '100%' }} />
</Form.Item> </Form.Item>

View File

@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Table, Button, Modal, Form, Input, Space, Drawer, Upload, App, Typography, Popconfirm } from 'antd'; import { Table, Button, Modal, Form, Input, Space, Drawer, Upload, App, Typography, Popconfirm, Tabs, Select } from 'antd';
import { PlusOutlined, UploadOutlined, PhoneOutlined, DeleteOutlined, ImportOutlined } from '@ant-design/icons'; import { PlusOutlined, UploadOutlined, PhoneOutlined, DeleteOutlined, ImportOutlined, DatabaseOutlined, UserOutlined, EnvironmentOutlined, CalendarOutlined, MessageOutlined, ContactsOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import type { SmsContactList, SmsContactListEntry, SmsPaginatedResponse } from '@/types/sms'; import type { SmsContactList, SmsContactListEntry, SmsPaginatedResponse } from '@/types/sms';
@ -10,6 +10,11 @@ import { useOutletContext } from 'react-router-dom';
const { Text } = Typography; const { Text } = Typography;
const { TextArea } = Input; const { TextArea } = Input;
interface PreviewResult {
total: number;
sample: { phone: string; name?: string }[];
}
export default function SmsContactsPage() { export default function SmsContactsPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>(); const { setPageHeader } = useOutletContext<AppOutletContext>();
const { message } = App.useApp(); const { message } = App.useApp();
@ -30,6 +35,25 @@ export default function SmsContactsPage() {
const [entriesLoading, setEntriesLoading] = useState(false); const [entriesLoading, setEntriesLoading] = useState(false);
const [createForm] = Form.useForm(); const [createForm] = Form.useForm();
// Import modal state
const [preview, setPreview] = useState<PreviewResult | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [importLoading, setImportLoading] = useState(false);
// Filter state for each tab
const [userRole, setUserRole] = useState<string | undefined>();
const [addrProvince, setAddrProvince] = useState<string | undefined>();
const [addrSupportLevel, setAddrSupportLevel] = useState<string | undefined>();
const [signupShiftId, setSignupShiftId] = useState<string | undefined>();
const [convCampaignId, setConvCampaignId] = useState<string | undefined>();
const [convResponseType, setConvResponseType] = useState<string | undefined>();
const [contactTag, setContactTag] = useState<string | undefined>();
const [contactSupportLevel, setContactSupportLevel] = useState<string | undefined>();
// Dropdown options loaded from API
const [shifts, setShifts] = useState<{ id: string; title: string }[]>([]);
const [smsCampaigns, setSmsCampaigns] = useState<{ id: string; name: string }[]>([]);
useEffect(() => { useEffect(() => {
setPageHeader({ title: 'SMS Contacts', subtitle: 'Manage contact lists for SMS campaigns' }); setPageHeader({ title: 'SMS Contacts', subtitle: 'Manage contact lists for SMS campaigns' });
}, [setPageHeader]); }, [setPageHeader]);
@ -59,6 +83,20 @@ export default function SmsContactsPage() {
} }
}, []); }, []);
// Load shifts and SMS campaigns for filter dropdowns when import modal opens
const loadFilterOptions = useCallback(async () => {
try {
const [shiftsRes, campaignsRes] = await Promise.all([
api.get('/map/shifts', { params: { limit: 100 } }),
api.get('/sms/campaigns', { params: { limit: 100 } }),
]);
setShifts((shiftsRes.data.items || []).map((s: { id: string; title: string }) => ({ id: s.id, title: s.title })));
setSmsCampaigns((campaignsRes.data.items || []).map((c: { id: string; name: string }) => ({ id: c.id, name: c.name })));
} catch {
// Silently fail — dropdowns will be empty
}
}, []);
const handleCreate = async (values: { name: string }) => { const handleCreate = async (values: { name: string }) => {
try { try {
await api.post('/sms/contacts', values); await api.post('/sms/contacts', values);
@ -83,27 +121,36 @@ export default function SmsContactsPage() {
const handleImportCsv = async () => { const handleImportCsv = async () => {
if (!importListId || !csvText.trim()) return; if (!importListId || !csvText.trim()) return;
setImportLoading(true);
try { try {
const { data } = await api.post(`/sms/contacts/${importListId}/import-csv`, { csv: csvText }); const { data } = await api.post(`/sms/contacts/${importListId}/import-csv`, { csv: csvText });
message.success(`Imported ${data.imported} contacts (${data.skipped} skipped)`); message.success(`Imported ${data.imported} contacts (${data.skipped} skipped)`);
setImportOpen(false); setImportOpen(false);
setCsvText(''); resetImportState();
fetchLists(); fetchLists();
if (selectedList?.id === importListId) fetchEntries(importListId); if (selectedList?.id === importListId) fetchEntries(importListId);
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Import failed'; const msg = err instanceof Error ? err.message : 'Import failed';
message.error(msg); message.error(msg);
} finally {
setImportLoading(false);
} }
}; };
const handleImportFromPhone = async (listId: string) => { const handleImportFromPhone = async () => {
if (!importListId) return;
setImportLoading(true);
try { try {
const { data } = await api.post(`/sms/contacts/${listId}/import-phone`); const { data } = await api.post(`/sms/contacts/${importListId}/import-phone`);
message.success(`Imported ${data.imported} contacts from phone`); message.success(`Imported ${data.imported} contacts from phone`);
setImportOpen(false);
resetImportState();
fetchLists(); fetchLists();
if (selectedList?.id === listId) fetchEntries(listId); if (selectedList?.id === importListId) fetchEntries(importListId);
} catch { } catch {
message.error('Failed to import from phone'); message.error('Failed to import from phone');
} finally {
setImportLoading(false);
} }
}; };
@ -125,6 +172,132 @@ export default function SmsContactsPage() {
fetchEntries(list.id); fetchEntries(list.id);
}; };
const openImportModal = (listId: string) => {
setImportListId(listId);
setImportOpen(true);
loadFilterOptions();
};
const resetImportState = () => {
setCsvText('');
setPreview(null);
setUserRole(undefined);
setAddrProvince(undefined);
setAddrSupportLevel(undefined);
setSignupShiftId(undefined);
setConvCampaignId(undefined);
setConvResponseType(undefined);
setContactTag(undefined);
setContactSupportLevel(undefined);
};
// Preview handlers for each source
const handlePreview = async (source: string) => {
setPreviewLoading(true);
setPreview(null);
try {
let params: Record<string, string> = {};
switch (source) {
case 'users':
if (userRole) params.role = userRole;
break;
case 'addresses':
if (addrProvince) params.province = addrProvince;
if (addrSupportLevel) params.supportLevel = addrSupportLevel;
break;
case 'signups':
if (signupShiftId) params.shiftId = signupShiftId;
break;
case 'conversations':
if (convCampaignId) params.campaignId = convCampaignId;
if (convResponseType) params.responseType = convResponseType;
break;
case 'contacts':
if (contactTag) params.tag = contactTag;
if (contactSupportLevel) params.supportLevel = contactSupportLevel;
break;
}
const { data } = await api.get<PreviewResult>(`/sms/contacts/preview-${source}`, { params });
setPreview(data);
} catch {
message.error('Failed to load preview');
} finally {
setPreviewLoading(false);
}
};
// Import handlers for each source
const handleDatabaseImport = async (source: string) => {
if (!importListId) return;
setImportLoading(true);
try {
let body: Record<string, string | undefined> = {};
switch (source) {
case 'users':
body = { role: userRole };
break;
case 'addresses':
body = { province: addrProvince, supportLevel: addrSupportLevel };
break;
case 'signups':
body = { shiftId: signupShiftId };
break;
case 'conversations':
body = { campaignId: convCampaignId, responseType: convResponseType };
break;
case 'contacts':
body = { tag: contactTag, supportLevel: contactSupportLevel };
break;
}
const { data } = await api.post(`/sms/contacts/${importListId}/import-${source}`, body);
message.success(`Imported ${data.imported} contacts (${data.skipped} skipped)`);
setImportOpen(false);
resetImportState();
fetchLists();
if (selectedList?.id === importListId) fetchEntries(importListId);
} catch {
message.error('Import failed');
} finally {
setImportLoading(false);
}
};
// Render a preview table + import button for a database source tab
const renderPreviewSection = (source: string) => (
<div style={{ marginTop: 12 }}>
<Space>
<Button onClick={() => handlePreview(source)} loading={previewLoading}>Preview</Button>
</Space>
{preview && (
<div style={{ marginTop: 12 }}>
<Text strong>{preview.total} contact{preview.total !== 1 ? 's' : ''} found</Text>
{preview.sample.length > 0 && (
<Table
rowKey="phone"
size="small"
pagination={false}
style={{ marginTop: 8 }}
dataSource={preview.sample}
columns={[
{ title: 'Phone', dataIndex: 'phone', width: 140 },
{ title: 'Name', dataIndex: 'name', render: (v: string) => v || <Text type="secondary">-</Text> },
]}
/>
)}
<Button
type="primary"
style={{ marginTop: 12 }}
onClick={() => handleDatabaseImport(source)}
loading={importLoading}
disabled={preview.total === 0}
>
Import {preview.total} contact{preview.total !== 1 ? 's' : ''}
</Button>
</div>
)}
</div>
);
const columns: ColumnsType<SmsContactList> = [ const columns: ColumnsType<SmsContactList> = [
{ {
title: 'Name', title: 'Name',
@ -145,11 +318,10 @@ export default function SmsContactsPage() {
}, },
{ {
title: 'Actions', title: 'Actions',
width: 200, width: 160,
render: (_, record) => ( render: (_, record) => (
<Space> <Space>
<Button size="small" icon={<UploadOutlined />} onClick={() => { setImportListId(record.id); setImportOpen(true); }}>Import CSV</Button> <Button size="small" icon={<DatabaseOutlined />} onClick={() => openImportModal(record.id)}>Import</Button>
<Button size="small" icon={<PhoneOutlined />} onClick={() => handleImportFromPhone(record.id)}>From Phone</Button>
<Popconfirm title="Archive this list?" onConfirm={() => handleArchive(record.id)}> <Popconfirm title="Archive this list?" onConfirm={() => handleArchive(record.id)}>
<Button size="small" danger icon={<DeleteOutlined />} /> <Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm> </Popconfirm>
@ -173,6 +345,28 @@ export default function SmsContactsPage() {
}, },
]; ];
const supportLevelOptions = [
{ value: 'LEVEL_1', label: 'Level 1 (Strong Support)' },
{ value: 'LEVEL_2', label: 'Level 2 (Leaning Support)' },
{ value: 'LEVEL_3', label: 'Level 3 (Undecided)' },
{ value: 'LEVEL_4', label: 'Level 4 (Opposition)' },
];
const responseTypeOptions = [
{ value: 'POSITIVE', label: 'Positive' },
{ value: 'NEGATIVE', label: 'Negative' },
{ value: 'QUESTION', label: 'Question' },
{ value: 'NEUTRAL', label: 'Neutral' },
];
const roleOptions = [
{ value: 'USER', label: 'User' },
{ value: 'TEMP', label: 'Temp' },
{ value: 'SUPER_ADMIN', label: 'Super Admin' },
{ value: 'INFLUENCE_ADMIN', label: 'Influence Admin' },
{ value: 'MAP_ADMIN', label: 'Map Admin' },
];
return ( return (
<> <>
<Space style={{ marginBottom: 16 }}> <Space style={{ marginBottom: 16 }}>
@ -202,37 +396,208 @@ export default function SmsContactsPage() {
</Form> </Form>
</Modal> </Modal>
{/* CSV Import Modal */} {/* Unified Import Modal */}
<Modal <Modal
title="Import CSV" title="Import Contacts"
open={importOpen} open={importOpen}
onCancel={() => { setImportOpen(false); setCsvText(''); }} onCancel={() => { setImportOpen(false); resetImportState(); }}
onOk={handleImportCsv} footer={null}
okText="Import" width={640}
width={600} destroyOnClose
> >
<Text type="secondary" style={{ display: 'block', marginBottom: 12 }}> <Tabs
Paste CSV text below. First row should be headers. Expected columns: phone (required), name, email. Other columns become custom fields. onChange={() => setPreview(null)}
</Text> items={[
<Upload {
accept=".csv,.tsv,.txt" key: 'csv',
maxCount={1} label: <span><UploadOutlined /> CSV</span>,
beforeUpload={(file) => { children: (
const reader = new FileReader(); <>
reader.onload = (e) => setCsvText(e.target?.result as string || ''); <Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
reader.readAsText(file); Paste CSV text or upload a file. First row = headers. Expected: phone (required), name, email.
return false; </Text>
}} <Upload
showUploadList={false} accept=".csv,.tsv,.txt"
> maxCount={1}
<Button icon={<ImportOutlined />}>Load from file</Button> beforeUpload={(file) => {
</Upload> const reader = new FileReader();
<TextArea reader.onload = (e) => setCsvText(e.target?.result as string || '');
rows={10} reader.readAsText(file);
value={csvText} return false;
onChange={(e) => setCsvText(e.target.value)} }}
placeholder="phone,name,email&#10;5551234567,John Doe,john@example.com" showUploadList={false}
style={{ marginTop: 12, fontFamily: 'monospace' }} >
<Button icon={<ImportOutlined />}>Load from file</Button>
</Upload>
<TextArea
rows={8}
value={csvText}
onChange={(e) => setCsvText(e.target.value)}
placeholder="phone,name,email&#10;5551234567,John Doe,john@example.com"
style={{ marginTop: 12, fontFamily: 'monospace' }}
/>
<Button
type="primary"
style={{ marginTop: 12 }}
onClick={handleImportCsv}
loading={importLoading}
disabled={!csvText.trim()}
>
Import CSV
</Button>
</>
),
},
{
key: 'phone',
label: <span><PhoneOutlined /> Phone Book</span>,
children: (
<>
<Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
Import contacts from the connected Android phone address book via Termux API.
</Text>
<Button
type="primary"
icon={<PhoneOutlined />}
onClick={handleImportFromPhone}
loading={importLoading}
>
Import from Phone
</Button>
</>
),
},
{
key: 'users',
label: <span><UserOutlined /> Users</span>,
children: (
<>
<Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
Import platform users who have phone numbers on file.
</Text>
<Select
placeholder="Filter by role (optional)"
allowClear
style={{ width: '100%' }}
value={userRole}
onChange={setUserRole}
options={roleOptions}
/>
{renderPreviewSection('users')}
</>
),
},
{
key: 'addresses',
label: <span><EnvironmentOutlined /> Addresses</span>,
children: (
<>
<Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
Import from map addresses that have phone numbers.
</Text>
<Space direction="vertical" style={{ width: '100%' }}>
<Input
placeholder="Province code (e.g. AB, ON)"
allowClear
value={addrProvince}
onChange={(e) => setAddrProvince(e.target.value || undefined)}
/>
<Select
placeholder="Support level (optional)"
allowClear
style={{ width: '100%' }}
value={addrSupportLevel}
onChange={setAddrSupportLevel}
options={supportLevelOptions}
/>
</Space>
{renderPreviewSection('addresses')}
</>
),
},
{
key: 'signups',
label: <span><CalendarOutlined /> Shift Signups</span>,
children: (
<>
<Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
Import from volunteer shift signups with phone numbers.
</Text>
<Select
placeholder="Filter by shift (optional)"
allowClear
showSearch
optionFilterProp="label"
style={{ width: '100%' }}
value={signupShiftId}
onChange={setSignupShiftId}
options={shifts.map((s) => ({ value: s.id, label: s.title }))}
/>
{renderPreviewSection('signups')}
</>
),
},
{
key: 'conversations',
label: <span><MessageOutlined /> Conversations</span>,
children: (
<>
<Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
Import from SMS conversations (excludes opted-out contacts).
</Text>
<Space direction="vertical" style={{ width: '100%' }}>
<Select
placeholder="Filter by SMS campaign (optional)"
allowClear
showSearch
optionFilterProp="label"
style={{ width: '100%' }}
value={convCampaignId}
onChange={setConvCampaignId}
options={smsCampaigns.map((c) => ({ value: c.id, label: c.name }))}
/>
<Select
placeholder="Filter by response type (optional)"
allowClear
style={{ width: '100%' }}
value={convResponseType}
onChange={setConvResponseType}
options={responseTypeOptions}
/>
</Space>
{renderPreviewSection('conversations')}
</>
),
},
{
key: 'contacts',
label: <span><ContactsOutlined /> CRM Contacts</span>,
children: (
<>
<Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
Import from CRM contacts with phone numbers (respects opt-out / do-not-contact flags).
</Text>
<Space direction="vertical" style={{ width: '100%' }}>
<Input
placeholder="Tag name (optional)"
allowClear
value={contactTag}
onChange={(e) => setContactTag(e.target.value || undefined)}
/>
<Select
placeholder="Support level (optional)"
allowClear
style={{ width: '100%' }}
value={contactSupportLevel}
onChange={setContactSupportLevel}
options={supportLevelOptions}
/>
</Space>
{renderPreviewSection('contacts')}
</>
),
},
]}
/> />
</Modal> </Modal>

View File

@ -1,13 +1,13 @@
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 } from 'antd'; import { Row, Col, List, Card, Input, Button, Tag, Badge, Space, Typography, Empty, Spin, App, Checkbox, Select, Collapse } from 'antd';
import { SendOutlined, CheckOutlined } from '@ant-design/icons'; import { SendOutlined, CheckOutlined, ReadOutlined, CloseCircleOutlined, LinkOutlined } 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 } from 'react-router-dom'; import { useOutletContext, Link } from 'react-router-dom';
const { Text, Paragraph } = Typography; const { Text, Paragraph } = Typography;
const { Search } = Input; const { Search, TextArea } = Input;
const RESPONSE_TYPE_COLORS: Record<string, string> = { const RESPONSE_TYPE_COLORS: Record<string, string> = {
POSITIVE: 'success', POSITIVE: 'success',
@ -31,6 +31,16 @@ export default function SmsConversationsPage() {
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
// Notes & tags state
const [notes, setNotes] = useState('');
const [tags, setTags] = useState<string[]>([]);
const [notesSaving, setNotesSaving] = useState(false);
const [tagsSaving, setTagsSaving] = useState(false);
// Bulk selection state
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [bulkLoading, setBulkLoading] = useState(false);
useEffect(() => { useEffect(() => {
setPageHeader({ title: 'SMS Conversations', subtitle: 'View and reply to SMS threads' }); setPageHeader({ title: 'SMS Conversations', subtitle: 'View and reply to SMS threads' });
}, [setPageHeader]); }, [setPageHeader]);
@ -61,6 +71,8 @@ export default function SmsConversationsPage() {
try { try {
const { data } = await api.get<SmsConversation>(`/sms/conversations/${conv.id}`); const { data } = await api.get<SmsConversation>(`/sms/conversations/${conv.id}`);
setSelected(data); setSelected(data);
setNotes(data.notes || '');
setTags(data.tags || []);
// Mark as read // Mark as read
if (conv.unreadCount > 0) { if (conv.unreadCount > 0) {
api.post(`/sms/conversations/${conv.id}/read`).catch(() => {}); api.post(`/sms/conversations/${conv.id}/read`).catch(() => {});
@ -80,7 +92,6 @@ export default function SmsConversationsPage() {
await api.post(`/sms/conversations/${selected.id}/reply`, { message: replyText.trim() }); await api.post(`/sms/conversations/${selected.id}/reply`, { message: replyText.trim() });
message.success('Reply queued'); message.success('Reply queued');
setReplyText(''); setReplyText('');
// Refresh conversation
selectConversation(selected); selectConversation(selected);
} catch { } catch {
message.error('Failed to send reply'); message.error('Failed to send reply');
@ -89,57 +100,160 @@ export default function SmsConversationsPage() {
} }
}; };
const handleNotesSave = async () => {
if (!selected) return;
setNotesSaving(true);
try {
await api.put(`/sms/conversations/${selected.id}/notes`, { notes });
} catch {
message.error('Failed to save notes');
} finally {
setNotesSaving(false);
}
};
const handleTagsChange = async (newTags: string[]) => {
setTags(newTags);
if (!selected) return;
setTagsSaving(true);
try {
await api.put(`/sms/conversations/${selected.id}/tags`, { tags: newTags });
} catch {
message.error('Failed to save tags');
} finally {
setTagsSaving(false);
}
};
// Bulk actions
const toggleSelection = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const toggleSelectAll = () => {
if (selectedIds.size === conversations.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(conversations.map((c) => c.id)));
}
};
const handleBulkRead = async () => {
const ids = Array.from(selectedIds);
if (ids.length === 0) return;
setBulkLoading(true);
try {
const { data } = await api.post('/sms/conversations/bulk-read', { ids });
message.success(`Marked ${data.updated} conversation${data.updated !== 1 ? 's' : ''} as read`);
setSelectedIds(new Set());
fetchConversations();
} catch {
message.error('Failed to mark as read');
} finally {
setBulkLoading(false);
}
};
const handleBulkClose = async () => {
const ids = Array.from(selectedIds);
if (ids.length === 0) return;
setBulkLoading(true);
try {
const { data } = await api.post('/sms/conversations/bulk-close', { ids });
message.success(`Closed ${data.updated} conversation${data.updated !== 1 ? 's' : ''}`);
setSelectedIds(new Set());
fetchConversations();
} catch {
message.error('Failed to close conversations');
} finally {
setBulkLoading(false);
}
};
return ( return (
<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%', overflowY: 'auto' }}> <Col xs={24} md={8} style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Search <Search
placeholder="Search by phone or name..." placeholder="Search by phone or name..."
onSearch={(v) => { setSearch(v); fetchConversations(v); }} onSearch={(v) => { setSearch(v); fetchConversations(v); }}
allowClear allowClear
style={{ marginBottom: 12 }} style={{ marginBottom: 8 }}
/> />
<List
loading={loading} {/* Bulk action bar */}
dataSource={conversations} {selectedIds.size > 0 && (
locale={{ emptyText: <Empty description="No conversations yet" /> }} <Space style={{ marginBottom: 8, padding: '4px 8px', background: 'rgba(255,255,255,0.04)', borderRadius: 6 }}>
renderItem={(conv) => ( <Text type="secondary" style={{ fontSize: 12 }}>{selectedIds.size} selected</Text>
<List.Item <Button size="small" icon={<ReadOutlined />} onClick={handleBulkRead} loading={bulkLoading}>Mark Read</Button>
onClick={() => selectConversation(conv)} <Button size="small" icon={<CloseCircleOutlined />} onClick={handleBulkClose} loading={bulkLoading}>Close</Button>
style={{ <Button size="small" type="text" onClick={() => setSelectedIds(new Set())}>Clear</Button>
cursor: 'pointer', </Space>
background: selected?.id === conv.id ? 'rgba(255,255,255,0.06)' : undefined, )}
padding: '8px 12px',
borderRadius: 6, <div style={{ flex: 1, overflowY: 'auto' }}>
}} {conversations.length > 0 && (
> <div style={{ padding: '4px 12px', marginBottom: 4 }}>
<List.Item.Meta <Checkbox
title={ checked={selectedIds.size === conversations.length && conversations.length > 0}
<Space> indeterminate={selectedIds.size > 0 && selectedIds.size < conversations.length}
<Badge count={conv.unreadCount} size="small"> onChange={toggleSelectAll}
<Text strong>{conv.contactName || conv.phone}</Text> >
</Badge> <Text type="secondary" style={{ fontSize: 12 }}>Select all</Text>
{conv.status === 'OPTED_OUT' && <Tag color="volcano" style={{ fontSize: 10 }}>OPT OUT</Tag>} </Checkbox>
</Space> </div>
}
description={
<Space direction="vertical" size={0}>
{conv.contactName && <Text type="secondary" style={{ fontSize: 12 }}>{conv.phone}</Text>}
{conv.campaign && <Text type="secondary" style={{ fontSize: 11 }}>{conv.campaign.name}</Text>}
{conv.lastMessageAt && (
<Text type="secondary" style={{ fontSize: 11 }}>
{new Date(conv.lastMessageAt).toLocaleString()}
</Text>
)}
</Space>
}
/>
</List.Item>
)} )}
/> <List
<Text type="secondary" style={{ display: 'block', textAlign: 'center', marginTop: 8 }}> loading={loading}
{total} conversation{total !== 1 ? 's' : ''} dataSource={conversations}
</Text> locale={{ emptyText: <Empty description="No conversations yet" /> }}
renderItem={(conv) => (
<List.Item
style={{
cursor: 'pointer',
background: selected?.id === conv.id ? 'rgba(255,255,255,0.06)' : undefined,
padding: '8px 12px',
borderRadius: 6,
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', width: '100%', gap: 8 }}>
<Checkbox
checked={selectedIds.has(conv.id)}
onClick={(e) => e.stopPropagation()}
onChange={() => toggleSelection(conv.id)}
style={{ marginTop: 4 }}
/>
<div style={{ flex: 1 }} onClick={() => selectConversation(conv)}>
<Space>
<Badge count={conv.unreadCount} size="small">
<Text strong>{conv.contactName || conv.phone}</Text>
</Badge>
{conv.status === 'OPTED_OUT' && <Tag color="volcano" style={{ fontSize: 10 }}>OPT OUT</Tag>}
{conv.status === 'CLOSED' && <Tag color="default" style={{ fontSize: 10 }}>CLOSED</Tag>}
</Space>
<Space direction="vertical" size={0}>
{conv.contactName && <Text type="secondary" style={{ fontSize: 12 }}>{conv.phone}</Text>}
{conv.campaign && <Text type="secondary" style={{ fontSize: 11 }}>{conv.campaign.name}</Text>}
{conv.lastMessageAt && (
<Text type="secondary" style={{ fontSize: 11 }}>
{new Date(conv.lastMessageAt).toLocaleString()}
</Text>
)}
</Space>
</div>
</div>
</List.Item>
)}
/>
<Text type="secondary" style={{ display: 'block', textAlign: 'center', marginTop: 8 }}>
{total} conversation{total !== 1 ? 's' : ''}
</Text>
</div>
</Col> </Col>
{/* Message Thread (right panel) */} {/* Message Thread (right panel) */}
@ -157,16 +271,61 @@ export default function SmsConversationsPage() {
<Tag color={selected.status === 'ACTIVE' ? 'success' : selected.status === 'OPTED_OUT' ? 'volcano' : 'default'}> <Tag color={selected.status === 'ACTIVE' ? 'success' : selected.status === 'OPTED_OUT' ? 'volcano' : 'default'}>
{selected.status} {selected.status}
</Tag> </Tag>
{selected.contact && (
<Link to={`/app/crm/contacts`} style={{ fontSize: 12 }}>
<LinkOutlined /> {selected.contact.displayName}
</Link>
)}
</Space> </Space>
} }
style={{ flex: 1, display: 'flex', flexDirection: 'column' }} style={{ flex: 1, display: 'flex', flexDirection: 'column' }}
styles={{ body: { flex: 1, overflowY: 'auto', padding: '12px 16px' } }} styles={{ body: { flex: 1, overflowY: 'auto', padding: '0' } }}
> >
{detailLoading ? ( {detailLoading ? (
<Spin style={{ display: 'block', margin: '40px auto' }} /> <Spin style={{ display: 'block', margin: '40px auto' }} />
) : ( ) : (
<> <div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ flex: 1, overflowY: 'auto', paddingBottom: 12 }}> {/* Notes & Tags collapsible section */}
<Collapse
ghost
size="small"
style={{ borderBottom: '1px solid rgba(255,255,255,0.08)' }}
items={[{
key: 'meta',
label: <Text type="secondary" style={{ fontSize: 12 }}>Notes & Tags</Text>,
children: (
<Space direction="vertical" style={{ width: '100%' }} size={8}>
<div>
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 2 }}>Notes</Text>
<TextArea
rows={2}
value={notes}
onChange={(e) => setNotes(e.target.value)}
onBlur={handleNotesSave}
placeholder="Add notes about this conversation..."
style={{ fontSize: 12 }}
/>
{notesSaving && <Text type="secondary" style={{ fontSize: 10 }}>Saving...</Text>}
</div>
<div>
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 2 }}>Tags</Text>
<Select
mode="tags"
style={{ width: '100%' }}
value={tags}
onChange={handleTagsChange}
placeholder="Add tags..."
tokenSeparators={[',']}
loading={tagsSaving}
/>
</div>
</Space>
),
}]}
/>
{/* Messages */}
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px' }}>
{(selected.messages || []).map((msg) => ( {(selected.messages || []).map((msg) => (
<div <div
key={msg.id} key={msg.id}
@ -210,7 +369,7 @@ export default function SmsConversationsPage() {
{/* Reply composer */} {/* Reply composer */}
{selected.status !== 'OPTED_OUT' && ( {selected.status !== 'OPTED_OUT' && (
<div style={{ display: 'flex', gap: 8, paddingTop: 12, borderTop: '1px solid rgba(255,255,255,0.08)' }}> <div style={{ display: 'flex', gap: 8, padding: '12px 16px', borderTop: '1px solid rgba(255,255,255,0.08)' }}>
<Input <Input
value={replyText} value={replyText}
onChange={(e) => setReplyText(e.target.value)} onChange={(e) => setReplyText(e.target.value)}
@ -232,7 +391,7 @@ export default function SmsConversationsPage() {
This contact has opted out. Replies are disabled. This contact has opted out. Replies are disabled.
</Text> </Text>
)} )}
</> </div>
)} )}
</Card> </Card>
)} )}

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { Card, Row, Col, Statistic, Tag, Typography, Space, Button, Descriptions, Spin, App } from 'antd'; import { Card, Row, Col, Statistic, Tag, Typography, Space, Button, Descriptions, Spin, App, Input, Drawer, Switch } from 'antd';
import { import {
PhoneOutlined, PhoneOutlined,
CheckCircleOutlined, CheckCircleOutlined,
@ -8,6 +8,8 @@ import {
ThunderboltOutlined, ThunderboltOutlined,
SyncOutlined, SyncOutlined,
MobileOutlined, MobileOutlined,
SendOutlined,
FileTextOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import type { SmsDeviceStatus, SmsCampaign, SmsPaginatedResponse } from '@/types/sms'; import type { SmsDeviceStatus, SmsCampaign, SmsPaginatedResponse } from '@/types/sms';
@ -15,6 +17,7 @@ import type { AppOutletContext } from '@/types/api';
import { useOutletContext } from 'react-router-dom'; import { useOutletContext } from 'react-router-dom';
const { Title, Text } = Typography; const { Title, Text } = Typography;
const { TextArea } = Input;
export default function SmsDashboardPage() { export default function SmsDashboardPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>(); const { setPageHeader } = useOutletContext<AppOutletContext>();
@ -60,16 +63,123 @@ export default function SmsDashboardPage() {
} }
}; };
// --- Quick Send ---
const [quickPhone, setQuickPhone] = useState('');
const [quickMessage, setQuickMessage] = useState('');
const [quickSending, setQuickSending] = useState(false);
const handleQuickSend = async () => {
if (!quickPhone.trim() || !quickMessage.trim()) {
message.warning('Enter both a phone number and a message');
return;
}
setQuickSending(true);
try {
await api.post('/sms/messages/send', { phone: quickPhone.trim(), message: quickMessage.trim() });
message.success('Message sent');
setQuickMessage('');
} catch {
message.error('Failed to send message');
} finally {
setQuickSending(false);
}
};
// --- Logs Drawer ---
const [logsOpen, setLogsOpen] = useState(false);
const [logLines, setLogLines] = useState<string[]>([]);
const [logInfo, setLogInfo] = useState<{ totalLines: number; fileSize: number } | null>(null);
const [logsLoading, setLogsLoading] = useState(false);
const [logsAutoRefresh, setLogsAutoRefresh] = useState(false);
const logsEndRef = useRef<HTMLDivElement>(null);
const fetchLogs = useCallback(async () => {
setLogsLoading(true);
try {
const { data } = await api.get<{ lines: string[]; totalLines: number; fileSize: number }>(
'/sms/device/logs', { params: { lines: 200 } }
);
setLogLines(data.lines);
setLogInfo({ totalLines: data.totalLines, fileSize: data.fileSize });
setTimeout(() => logsEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 100);
} catch {
// Endpoint may not exist yet — silently handle
setLogLines(['Failed to fetch logs. The endpoint may not be available yet.']);
} finally {
setLogsLoading(false);
}
}, []);
useEffect(() => {
if (logsOpen) fetchLogs();
}, [logsOpen, fetchLogs]);
useEffect(() => {
if (!logsOpen || !logsAutoRefresh) return;
const interval = setInterval(fetchLogs, 5000);
return () => clearInterval(interval);
}, [logsOpen, logsAutoRefresh, fetchLogs]);
const colorLogLine = (line: string) => {
if (/\bERROR\b/i.test(line)) return '#ff4d4f';
if (/\bWARN(ING)?\b/i.test(line)) return '#faad14';
return '#d9d9d9';
};
if (loading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />; if (loading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
const activeCampaigns = campaigns.filter((c) => c.status === 'RUNNING'); const activeCampaigns = campaigns.filter((c) => c.status === 'RUNNING');
return ( return (
<Space direction="vertical" size="large" style={{ width: '100%' }}> <Space direction="vertical" size="large" style={{ width: '100%' }}>
{/* Quick Send */}
<Card title={<><SendOutlined /> Quick Send</>} size="small">
<Row gutter={16} align="bottom">
<Col xs={24} sm={8} md={6}>
<Text type="secondary" style={{ fontSize: 12 }}>Phone Number</Text>
<Input
value={quickPhone}
onChange={(e) => setQuickPhone(e.target.value)}
placeholder="+1 555 123 4567"
prefix={<PhoneOutlined />}
style={{ marginTop: 4 }}
/>
</Col>
<Col xs={24} sm={12} md={14}>
<Text type="secondary" style={{ fontSize: 12 }}>Message</Text>
<TextArea
value={quickMessage}
onChange={(e) => setQuickMessage(e.target.value)}
placeholder="Type your message..."
rows={1}
maxLength={1600}
showCount
style={{ marginTop: 4 }}
/>
</Col>
<Col xs={24} sm={4} md={4}>
<Button
type="primary"
icon={<SendOutlined />}
loading={quickSending}
onClick={handleQuickSend}
block
>
Send
</Button>
</Col>
</Row>
</Card>
{/* Device Status */} {/* Device Status */}
<Card <Card
title={<><MobileOutlined /> Device Status</>} title={<><MobileOutlined /> Device Status</>}
extra={<Button icon={<SyncOutlined />} onClick={handleSync} size="small">Sync Now</Button>} extra={
<Space>
<Button icon={<FileTextOutlined />} onClick={() => setLogsOpen(true)} size="small">View Phone Logs</Button>
<Button icon={<SyncOutlined />} onClick={handleSync} size="small">Sync Now</Button>
</Space>
}
> >
{deviceStatus ? ( {deviceStatus ? (
<Descriptions column={{ xs: 1, sm: 2, md: 4 }}> <Descriptions column={{ xs: 1, sm: 2, md: 4 }}>
@ -174,6 +284,51 @@ export default function SmsDashboardPage() {
</Space> </Space>
)} )}
</Card> </Card>
{/* Logs Drawer */}
<Drawer
title="Phone Logs"
open={logsOpen}
onClose={() => { setLogsOpen(false); setLogsAutoRefresh(false); }}
width={720}
extra={
<Space>
<Text type="secondary" style={{ fontSize: 12 }}>
{logInfo ? `${logInfo.totalLines} lines (${(logInfo.fileSize / 1024).toFixed(1)} KB)` : ''}
</Text>
<Switch
checkedChildren="Auto"
unCheckedChildren="Auto"
checked={logsAutoRefresh}
onChange={setLogsAutoRefresh}
size="small"
/>
<Button size="small" icon={<SyncOutlined spin={logsLoading} />} onClick={fetchLogs}>
Refresh
</Button>
</Space>
}
>
<div
style={{
background: '#1a1a2e',
borderRadius: 6,
padding: 12,
height: 'calc(100vh - 160px)',
overflow: 'auto',
fontFamily: "'JetBrains Mono', 'Fira Code', 'Consolas', monospace",
fontSize: 12,
lineHeight: 1.6,
}}
>
{logLines.map((line, i) => (
<div key={i} style={{ color: colorLogLine(line), whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
{line}
</div>
))}
<div ref={logsEndRef} />
</div>
</Drawer>
</Space> </Space>
); );
} }

View File

@ -9,7 +9,7 @@ import {
CopyOutlined, EyeOutlined, EyeInvisibleOutlined, ApiOutlined, CopyOutlined, EyeOutlined, EyeInvisibleOutlined, ApiOutlined,
WifiOutlined, LinkOutlined, ThunderboltOutlined, ReloadOutlined, WifiOutlined, LinkOutlined, ThunderboltOutlined, ReloadOutlined,
AndroidOutlined, CloudServerOutlined, RocketOutlined, SendOutlined, AndroidOutlined, CloudServerOutlined, RocketOutlined, SendOutlined,
MessageOutlined, MessageOutlined, WarningOutlined, KeyOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import type { AppOutletContext } from '@/components/AppLayout'; import type { AppOutletContext } from '@/components/AppLayout';
@ -71,6 +71,10 @@ export default function SmsSetupPage() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
// Send Test SMS
const [testPhone, setTestPhone] = useState('');
const [testSending, setTestSending] = useState(false);
useEffect(() => { useEffect(() => {
setPageHeader({ title: 'SMS Setup' }); setPageHeader({ title: 'SMS Setup' });
return () => setPageHeader(null); return () => setPageHeader(null);
@ -176,7 +180,7 @@ export default function SmsSetupPage() {
}); });
setTestResult(res.data); setTestResult(res.data);
if (res.data.success) { if (res.data.success) {
message.success('Connection successful!'); message.success('Connection successful — phone is reachable and key is valid!');
} else { } else {
message.error(res.data.error || 'Connection failed'); message.error(res.data.error || 'Connection failed');
} }
@ -222,6 +226,34 @@ export default function SmsSetupPage() {
} }
}; };
const handleSendTest = async () => {
if (!testPhone.trim()) {
message.warning('Enter a phone number to send a test SMS');
return;
}
setTestSending(true);
try {
const res = await api.post<{ status: string; termuxResult?: { success: boolean; error?: string } }>('/sms/messages/send', {
phone: testPhone.trim(),
message: 'Test SMS from Changemaker',
});
if (res.data.termuxResult?.success === false) {
const err = res.data.termuxResult.error || 'Unknown error';
if (err.includes('401') || err.includes('Authentication')) {
message.error('API key mismatch — the key saved here does not match the phone. Re-run setup or update the key on the phone.');
} else {
message.error(`SMS failed: ${err}`);
}
} else {
message.success('Test SMS sent!');
}
} catch {
message.error('Failed to send test SMS');
} finally {
setTestSending(false);
}
};
// --- Render --- // --- Render ---
if (loading) { if (loading) {
@ -300,6 +332,24 @@ export default function SmsSetupPage() {
{/* Step 0: Phone Preparation */} {/* Step 0: Phone Preparation */}
{currentStep === 0 && ( {currentStep === 0 && (
<Space direction="vertical" size="middle" style={{ width: '100%' }}> <Space direction="vertical" size="middle" style={{ width: '100%' }}>
{/* RCS Warning */}
<Alert
type="warning"
showIcon
message="Disable RCS / Chat Features"
description={
<div>
<Paragraph style={{ marginBottom: 4 }}>
On the SMS phone, open <Text strong>Google Messages</Text> <Text strong>Settings</Text> <Text strong>Chat features</Text> <Text strong>Turn off</Text>.
</Paragraph>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
This ensures all messages use plain SMS. If RCS (Rich Communication Services) is enabled, some replies may be sent via
data/Wi-Fi instead of the carrier SMS channel, which means the Termux server will never see them.
</Paragraph>
</div>
}
/>
{/* Part 1: Install F-Droid Apps */} {/* Part 1: Install F-Droid Apps */}
<Alert <Alert
type="error" type="error"
@ -328,12 +378,12 @@ export default function SmsSetupPage() {
<br /><Text type="secondary">Also from <Text strong>F-Droid</Text>. Must be same source as Termux.</Text> <br /><Text type="secondary">Also from <Text strong>F-Droid</Text>. Must be same source as Termux.</Text>
</li> </li>
<li> <li>
<Text strong>Termux:Boot</Text> (optional) auto-start server on phone reboot <Text strong>Termux:Boot</Text> (recommended) auto-start server on phone reboot
<br /><Text type="secondary">Also from <Text strong>F-Droid</Text>. Open once after install to register.</Text> <br /><Text type="secondary">Also from <Text strong>F-Droid</Text>. Open once after install to register.</Text>
</li> </li>
<li> <li>
<Text strong>Tailscale</Text> (recommended) VPN mesh for stable IP addressing <Text strong>Tailscale</Text> (recommended) VPN mesh for stable IP addressing
<br /><Text type="secondary">Install from Play Store. Creates a persistent 100.x.x.x IP.</Text> <br /><Text type="secondary">Install from Play Store. Creates a persistent 100.x.x.x IP that works across networks.</Text>
</li> </li>
</ol> </ol>
</div> </div>
@ -343,7 +393,8 @@ export default function SmsSetupPage() {
{/* Part 2: Generate API Key */} {/* Part 2: Generate API Key */}
<Divider>Step 2 Generate API Key</Divider> <Divider>Step 2 Generate API Key</Divider>
<Paragraph> <Paragraph>
Generate a shared secret key. The setup script on the phone will use this to authenticate with the server. Generate a shared secret key. This key authenticates all communication between the server and the phone.
<Text strong> All phone API endpoints require this key</Text> including health checks.
</Paragraph> </Paragraph>
<Space> <Space>
@ -417,7 +468,7 @@ export default function SmsSetupPage() {
</Paragraph> </Paragraph>
<ul style={{ marginLeft: 20, lineHeight: 2 }}> <ul style={{ marginLeft: 20, lineHeight: 2 }}>
<li>Install Python, Flask, and termux-api</li> <li>Install Python, Flask, and termux-api</li>
<li>Save the API key to ~/.bashrc</li> <li>Save the API key to ~/.bashrc and ~/.sms-api-key</li>
<li>Request SMS and Contacts permissions (tap <Text strong>Allow</Text>)</li> <li>Request SMS and Contacts permissions (tap <Text strong>Allow</Text>)</li>
<li>Set up Termux:Boot auto-start (if installed)</li> <li>Set up Termux:Boot auto-start (if installed)</li>
<li>Start the server with the watchdog</li> <li>Start the server with the watchdog</li>
@ -425,10 +476,28 @@ export default function SmsSetupPage() {
<Paragraph style={{ marginTop: 8 }}> <Paragraph style={{ marginTop: 8 }}>
When done, note the <Text strong>Phone URL</Text> displayed (e.g. <Text code>http://100.x.x.x:5001</Text>) — you'll need it in the next step. When done, note the <Text strong>Phone URL</Text> displayed (e.g. <Text code>http://100.x.x.x:5001</Text>) — you'll need it in the next step.
</Paragraph> </Paragraph>
<Divider style={{ margin: '12px 0' }} /> </div>
<Paragraph type="secondary" style={{ marginBottom: 0 }}> }
<Text strong>Also recommended:</Text> Disable battery optimization for Termux Android Settings Apps Termux Battery Unrestricted. />
)}
{/* Android hardening */}
{generatedKey && (
<Alert
type="warning"
showIcon
icon={<WarningOutlined />}
message="Prevent Android from Killing the Server"
description={
<div>
<Paragraph style={{ marginBottom: 4 }}>
Android aggressively kills background processes. To keep the SMS server running:
</Paragraph> </Paragraph>
<ol style={{ marginLeft: 20, lineHeight: 2 }}>
<li><Text strong>Disable battery optimization</Text> for Termux, Termux:API, and Tailscale Android Settings Apps [App] Battery Unrestricted</li>
<li><Text strong>Run wake lock</Text> in Termux: <Text code copyable>termux-wake-lock</Text></li>
<li><Text strong>Lock Termux in recents</Text> long-press the Termux card in the app switcher and tap the lock icon</li>
</ol>
</div> </div>
} }
/> />
@ -437,15 +506,19 @@ export default function SmsSetupPage() {
{/* Already set up? */} {/* Already set up? */}
{generatedKey && ( {generatedKey && (
<Alert <Alert
type="warning" type="info"
showIcon showIcon
icon={<KeyOutlined />}
message="Already have the server running?" message="Already have the server running?"
description={ description={
<div> <div>
<Paragraph style={{ marginBottom: 4 }}>If you've already set up the phone, you can update the server with:</Paragraph> <Paragraph style={{ marginBottom: 4 }}>If the server is already running but the API key needs updating, run this on the phone:</Paragraph>
<div style={{ background: 'rgba(0,0,0,0.1)', padding: 12, borderRadius: 6 }}> <div style={{ background: 'rgba(0,0,0,0.1)', padding: 12, borderRadius: 6 }}>
<CmdLine comment="Pull latest code and re-run setup" cmd={`cd ~/sms-server && git pull && bash android/setup.sh ${generatedKey}`} /> <CmdLine comment="Update key, restart server" cmd={`export SMS_API_SECRET="${generatedKey}" && sed -i '/SMS_API_SECRET/d' ~/.bashrc && echo 'export SMS_API_SECRET="${generatedKey}"' >> ~/.bashrc && pkill -f termux-sms-api-server.py && sleep 2 && cd ~/sms-server/android && bash sms-watchdog.sh`} />
</div> </div>
<Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
Or pull latest code and re-run full setup: <Text code copyable={{ text: `cd ~/sms-server && git pull && bash android/setup.sh ${generatedKey}` }}>cd ~/sms-server && git pull && bash android/setup.sh {generatedKey.substring(0, 8)}...</Text>
</Paragraph>
</div> </div>
} }
/> />
@ -576,7 +649,11 @@ export default function SmsSetupPage() {
prefix={<LinkOutlined />} prefix={<LinkOutlined />}
/> />
</Form.Item> </Form.Item>
<Form.Item label="Termux API Key" required> <Form.Item
label="Termux API Key"
required
help="Must match the SMS_API_SECRET on the phone. If you generated a key in Step 1, it's pre-filled."
>
<Input.Password <Input.Password
value={termuxApiKey} value={termuxApiKey}
onChange={e => setTermuxApiKey(e.target.value)} onChange={e => setTermuxApiKey(e.target.value)}
@ -622,6 +699,13 @@ export default function SmsSetupPage() {
)} )}
</Descriptions> </Descriptions>
<Alert
type="info"
showIcon
message="Connection test verifies both reachability and API key authentication"
description="The test calls the phone's health endpoint with your API key. If the key doesn't match what the phone has, the test will fail with an authentication error."
/>
<Space> <Space>
<Button <Button
type="primary" type="primary"
@ -640,22 +724,50 @@ export default function SmsSetupPage() {
{testResult && ( {testResult && (
testResult.success ? ( testResult.success ? (
<Alert <>
type="success" <Alert
showIcon type="success"
message="Connection Successful!" showIcon
description={ message="Connection Successful — Phone Authenticated!"
<div> description={
{testResult.health && ( <div>
<Descriptions column={{ xs: 1, sm: 2 }} size="small" bordered style={{ marginTop: 8 }}> <Paragraph type="secondary" style={{ marginBottom: 8 }}>
<Descriptions.Item label="Status">{testResult.health.status}</Descriptions.Item> The phone is reachable and the API key is valid.
<Descriptions.Item label="Uptime">{Math.round(testResult.health.uptime / 60)}m</Descriptions.Item> </Paragraph>
<Descriptions.Item label="Messages Sent">{testResult.health.messages_sent}</Descriptions.Item> {testResult.health && (
</Descriptions> <Descriptions column={{ xs: 1, sm: 2 }} size="small" bordered>
)} <Descriptions.Item label="Status">{testResult.health.status}</Descriptions.Item>
<Descriptions.Item label="Uptime">{Math.round(testResult.health.uptime / 60)}m</Descriptions.Item>
<Descriptions.Item label="Messages Sent">{testResult.health.messages_sent}</Descriptions.Item>
</Descriptions>
)}
</div>
}
/>
<Card size="small" title={<><SendOutlined /> Send Test SMS</>}>
<Space>
<Input
value={testPhone}
onChange={(e) => setTestPhone(e.target.value)}
placeholder="+1 555 123 4567"
prefix={<PhoneOutlined />}
style={{ width: 220 }}
/>
<Button
type="primary"
icon={<SendOutlined />}
loading={testSending}
onClick={handleSendTest}
disabled={!testPhone.trim()}
>
Send Test SMS
</Button>
</Space>
<div style={{ marginTop: 8 }}>
<Text type="secondary">Sends "Test SMS from Changemaker" to the number above.</Text>
</div> </div>
} </Card>
/> </>
) : ( ) : (
<Alert <Alert
type="error" type="error"
@ -664,9 +776,17 @@ export default function SmsSetupPage() {
description={ description={
<div> <div>
<Paragraph>{testResult.error}</Paragraph> <Paragraph>{testResult.error}</Paragraph>
<Paragraph type="secondary"> {testResult.error?.includes('401') || testResult.error?.includes('Authentication') ? (
Make sure the Termux SMS server is running on the phone and the URL/key are correct. <Paragraph>
</Paragraph> <Text strong type="danger">API key mismatch.</Text>{' '}
The key entered here does not match the phone's <Text code>SMS_API_SECRET</Text>.
Go back to Step 1 and re-run the setup script with the generated key, or update the key on the phone:
</Paragraph>
) : (
<Paragraph type="secondary">
Make sure the Termux SMS server is running on the phone, the URL is correct, and the phone is accessible on the network.
</Paragraph>
)}
</div> </div>
} }
/> />
@ -730,7 +850,7 @@ export default function SmsSetupPage() {
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
/> />
<Space wrap> <Space wrap style={{ marginBottom: 16 }}>
<Button type="primary" icon={<PhoneOutlined />} onClick={() => navigate('/app/sms')}> <Button type="primary" icon={<PhoneOutlined />} onClick={() => navigate('/app/sms')}>
SMS Dashboard SMS Dashboard
</Button> </Button>
@ -741,6 +861,28 @@ export default function SmsSetupPage() {
Conversations Conversations
</Button> </Button>
</Space> </Space>
<Divider style={{ margin: '12px 0' }} />
<Text strong>Send Test SMS</Text>
<div style={{ marginTop: 8 }}>
<Space>
<Input
value={testPhone}
onChange={(e) => setTestPhone(e.target.value)}
placeholder="+1 555 123 4567"
prefix={<PhoneOutlined />}
style={{ width: 220 }}
/>
<Button
icon={<SendOutlined />}
loading={testSending}
onClick={handleSendTest}
disabled={!testPhone.trim()}
>
Send Test SMS
</Button>
</Space>
</div>
</Card> </Card>
)} )}
@ -750,15 +892,27 @@ export default function SmsSetupPage() {
type="warning" type="warning"
showIcon showIcon
message="Phone Disconnected" message="Phone Disconnected"
description="The Termux API server is not responding. Make sure the SMS server is running on the phone and the phone is accessible." description={
<div>
<Paragraph style={{ marginBottom: 8 }}>
The Termux API server is not responding. This can mean:
</Paragraph>
<ul style={{ marginLeft: 20, marginBottom: 8 }}>
<li><Text strong>Server not running</Text> Android may have killed the Termux process. Restart it on the phone: <Text code>cd ~/sms-server/android && bash sms-watchdog.sh</Text></li>
<li><Text strong>API key mismatch</Text> the key saved here doesn't match the phone's <Text code>SMS_API_SECRET</Text>. Click Reconfigure to generate a new key and update both sides.</li>
<li><Text strong>Network issue</Text> the phone may not be reachable. Check Tailscale is connected on the phone.</li>
</ul>
</div>
}
action={ action={
<Button size="small" icon={<ReloadOutlined />} onClick={fetchStatus}> <Space direction="vertical" size="small">
Retry <Button size="small" icon={<ReloadOutlined />} onClick={fetchStatus}>
</Button> Retry
</Button>
</Space>
} }
/> />
)} )}
</Space> </Space>
); );
} }

View File

@ -1167,6 +1167,11 @@ export interface SiteSettings {
provisionVaultwardenTiming: 'lazy' | 'eager'; provisionVaultwardenTiming: 'lazy' | 'eager';
provisionListmonk: boolean; provisionListmonk: boolean;
provisionListmonkTiming: 'lazy' | 'eager'; provisionListmonkTiming: 'lazy' | 'eager';
// SMS notification settings
smsShiftReminders: boolean;
smsShiftReminderHours: number;
smsShiftSignupConfirm: boolean;
smsVolunteerWelcome: boolean;
// Notification settings // Notification settings
notifyAdminShiftSignup: boolean; notifyAdminShiftSignup: boolean;
notifyAdminResponseSubmitted: boolean; notifyAdminResponseSubmitted: boolean;

View File

@ -91,6 +91,8 @@ export interface SmsConversation {
contactName: string | null; contactName: string | null;
campaignId: string | null; campaignId: string | null;
campaign?: { id: string; name: string } | null; campaign?: { id: string; name: string } | null;
contactId: string | null;
contact?: { id: string; displayName: string } | null;
status: SmsConversationStatus; status: SmsConversationStatus;
totalMessages: number; totalMessages: number;
totalResponses: number; totalResponses: number;

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "site_settings" ADD COLUMN "sms_shift_reminder_hours" INTEGER NOT NULL DEFAULT 24,
ADD COLUMN "sms_shift_reminders" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "sms_shift_signup_confirm" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "sms_volunteer_welcome" BOOLEAN NOT NULL DEFAULT false;

View File

@ -916,6 +916,12 @@ model SiteSettings {
notifyVolunteerShiftReminder Boolean @default(true) notifyVolunteerShiftReminder Boolean @default(true)
notifyVolunteerShiftThankYou Boolean @default(true) notifyVolunteerShiftThankYou Boolean @default(true)
// SMS notification settings
smsShiftReminders Boolean @default(false) @map("sms_shift_reminders")
smsShiftReminderHours Int @default(24) @map("sms_shift_reminder_hours")
smsShiftSignupConfirm Boolean @default(false) @map("sms_shift_signup_confirm")
smsVolunteerWelcome Boolean @default(false) @map("sms_volunteer_welcome")
// Re-engagement settings // Re-engagement settings
notifyVolunteerReengagement Boolean @default(false) @map("notify_volunteer_reengagement") notifyVolunteerReengagement Boolean @default(false) @map("notify_volunteer_reengagement")
reengagementInactiveDays Int @default(30) @map("reengagement_inactive_days") reengagementInactiveDays Int @default(30) @map("reengagement_inactive_days")

View File

@ -464,6 +464,9 @@ async function main() {
console.warn('⚠️ No admin user found - skipping email template seeding'); console.warn('⚠️ No admin user found - skipping email template seeding');
} }
// Seed SMS notification templates
await seedSmsNotificationTemplates();
// Seed pre-made gallery ads (all inactive by default — admin enables manually) // Seed pre-made gallery ads (all inactive by default — admin enables manually)
await seedGalleryAds(); await seedGalleryAds();
@ -852,6 +855,56 @@ async function seedEmailTemplates(admin: { id: string; email: string }) {
console.log(`Email templates seeded: ${seededCount} created, ${skippedCount} skipped`); console.log(`Email templates seeded: ${seededCount} created, ${skippedCount} skipped`);
} }
/**
* Seed default SMS notification templates
*/
async function seedSmsNotificationTemplates() {
console.log('Seeding SMS notification templates...');
const templates = [
{
name: 'shift-reminder',
template: 'Hi {name}, reminder: {shiftTitle} is tomorrow at {shiftTime}. Location: {shiftLocation}',
description: 'Sent before a volunteer shift as a reminder',
category: 'notification',
},
{
name: 'shift-signup-confirm',
template: "Hi {name}, you're signed up for {shiftTitle} on {shiftDate} at {shiftTime}. See you there!",
description: 'Sent when a volunteer signs up for a shift',
category: 'notification',
},
{
name: 'volunteer-welcome',
template: 'Welcome to the team, {name}! Thanks for signing up as a volunteer.',
description: 'Sent when a new volunteer account is created',
category: 'notification',
},
];
let seeded = 0;
let skipped = 0;
for (const t of templates) {
const existing = await prisma.smsMessageTemplate.findFirst({ where: { name: t.name } });
if (existing) {
skipped++;
continue;
}
await prisma.smsMessageTemplate.create({
data: {
name: t.name,
template: t.template,
description: t.description,
category: t.category,
},
});
seeded++;
}
console.log(`SMS notification templates seeded: ${seeded} created, ${skipped} skipped`);
}
/** /**
* Seed pre-made gallery ads * Seed pre-made gallery ads
*/ */

View File

@ -16,6 +16,7 @@ import { groupService } from '../../social/group.service';
import { achievementsService } from '../../social/achievements.service'; import { achievementsService } from '../../social/achievements.service';
import { generateSlug } from '../../../utils/slug'; import { generateSlug } from '../../../utils/slug';
import { siteSettingsService } from '../../settings/settings.service'; import { siteSettingsService } from '../../settings/settings.service';
import { smsNotificationService } from '../../../services/sms-notification.service';
import crypto from 'crypto'; import crypto from 'crypto';
import type { import type {
CreateShiftInput, CreateShiftInput,
@ -537,6 +538,23 @@ export const shiftsService = {
logger.error('Failed to send shift signup confirmation email:', err); logger.error('Failed to send shift signup confirmation email:', err);
} }
// SMS signup confirmation (fire-and-forget)
if (data.phone) {
const shiftDate = new Date(shift.date);
const smsDateStr = shiftDate.toLocaleDateString('en-CA', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
smsNotificationService.sendShiftSignupConfirmation(data.phone, {
name: data.name,
shiftTitle: shift.title,
shiftDate: smsDateStr,
shiftTime: `${shift.startTime}${shift.endTime}`,
}).catch(err => logger.error('SMS signup confirmation failed:', err));
}
// Notify Rocket.Chat // Notify Rocket.Chat
const shiftDateStr = new Date(shift.date).toLocaleDateString('en-CA', { month: 'short', day: 'numeric' }); const shiftDateStr = new Date(shift.date).toLocaleDateString('en-CA', { month: 'short', day: 'numeric' });
rocketchatWebhookService.onShiftSignup({ rocketchatWebhookService.onShiftSignup({
@ -595,6 +613,20 @@ export const shiftsService = {
logger.error('Failed to schedule shift reminder:', err); logger.error('Failed to schedule shift reminder:', err);
} }
// SMS shift reminder (fire-and-forget, delay calculated by notification service)
if (data.phone) {
const smsShiftDatetime = new Date(shift.date);
const [smsH, smsM] = shift.startTime.split(':').map(Number);
smsShiftDatetime.setHours(smsH || 0, smsM || 0, 0, 0);
smsNotificationService.sendShiftReminder(data.phone, {
name: data.name,
shiftTitle: shift.title,
shiftTime: shift.startTime,
shiftLocation: shift.location || 'TBD',
}, smsShiftDatetime).catch(err => logger.error('SMS shift reminder failed:', err));
}
// Notification: schedule post-shift thank-you (2h after end) // Notification: schedule post-shift thank-you (2h after end)
try { try {
if (await isNotificationEnabled('notifyVolunteerShiftThankYou')) { if (await isNotificationEnabled('notifyVolunteerShiftThankYou')) {

View File

@ -30,6 +30,63 @@ router.post('/', validate(createContactListSchema), async (req, res, next) => {
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
// --- Database Import Previews (must be BEFORE /:id routes) ---
// GET /api/sms/contacts/preview-users — preview users with phone numbers
router.get('/preview-users', async (req, res, next) => {
try {
const result = await smsContactsService.previewUsers({
role: req.query.role as string | undefined,
});
res.json(result);
} catch (err) { next(err); }
});
// GET /api/sms/contacts/preview-addresses — preview addresses with phone numbers
router.get('/preview-addresses', async (req, res, next) => {
try {
const result = await smsContactsService.previewAddresses({
province: req.query.province as string | undefined,
supportLevel: req.query.supportLevel as string | undefined,
});
res.json(result);
} catch (err) { next(err); }
});
// GET /api/sms/contacts/preview-signups — preview shift signups with phone numbers
router.get('/preview-signups', async (req, res, next) => {
try {
const result = await smsContactsService.previewSignups({
shiftId: req.query.shiftId as string | undefined,
});
res.json(result);
} catch (err) { next(err); }
});
// GET /api/sms/contacts/preview-conversations — preview conversations
router.get('/preview-conversations', async (req, res, next) => {
try {
const result = await smsContactsService.previewConversations({
campaignId: req.query.campaignId as string | undefined,
responseType: req.query.responseType as string | undefined,
});
res.json(result);
} catch (err) { next(err); }
});
// GET /api/sms/contacts/preview-contacts — preview CRM contacts with phones
router.get('/preview-contacts', async (req, res, next) => {
try {
const result = await smsContactsService.previewContacts({
tag: req.query.tag as string | undefined,
supportLevel: req.query.supportLevel as string | undefined,
});
res.json(result);
} catch (err) { next(err); }
});
// --- Single List Routes (/:id) ---
// GET /api/sms/contacts/:id — get a single contact list // GET /api/sms/contacts/:id — get a single contact list
router.get('/:id', async (req, res, next) => { router.get('/:id', async (req, res, next) => {
try { try {
@ -106,4 +163,51 @@ router.post('/:id/import-phone', async (req, res, next) => {
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
// --- Database Import Routes ---
// POST /api/sms/contacts/:id/import-users — import users with phone numbers
router.post('/:id/import-users', async (req, res, next) => {
try {
const { role } = req.body as { role?: string };
const result = await smsContactsService.importFromUsers(req.params.id as string, { role });
res.json(result);
} catch (err) { next(err); }
});
// POST /api/sms/contacts/:id/import-addresses — import addresses with phone numbers
router.post('/:id/import-addresses', async (req, res, next) => {
try {
const { province, supportLevel } = req.body as { province?: string; supportLevel?: string };
const result = await smsContactsService.importFromAddresses(req.params.id as string, { province, supportLevel });
res.json(result);
} catch (err) { next(err); }
});
// POST /api/sms/contacts/:id/import-signups — import shift signups with phone numbers
router.post('/:id/import-signups', async (req, res, next) => {
try {
const { shiftId } = req.body as { shiftId?: string };
const result = await smsContactsService.importFromSignups(req.params.id as string, { shiftId });
res.json(result);
} catch (err) { next(err); }
});
// POST /api/sms/contacts/:id/import-conversations — import from conversations
router.post('/:id/import-conversations', async (req, res, next) => {
try {
const { campaignId, responseType } = req.body as { campaignId?: string; responseType?: string };
const result = await smsContactsService.importFromConversations(req.params.id as string, { campaignId, responseType });
res.json(result);
} catch (err) { next(err); }
});
// POST /api/sms/contacts/:id/import-contacts — import CRM contacts with phones
router.post('/:id/import-contacts', async (req, res, next) => {
try {
const { tag, supportLevel } = req.body as { tag?: string; supportLevel?: string };
const result = await smsContactsService.importFromContacts(req.params.id as string, { tag, supportLevel });
res.json(result);
} catch (err) { next(err); }
});
export const smsContactsRouter = router; export const smsContactsRouter = router;

View File

@ -1,5 +1,5 @@
import { prisma } from '../../../config/database'; import { prisma } from '../../../config/database';
import { Prisma } from '@prisma/client'; import { Prisma, type UserRole, type SupportLevel, type SmsResponseType } from '@prisma/client';
import { termuxClient } from '../../../services/termux.client'; import { termuxClient } from '../../../services/termux.client';
import type { CreateContactListInput, UpdateContactListInput, CreateContactEntryInput } from './sms-contacts.schemas'; import type { CreateContactListInput, UpdateContactListInput, CreateContactEntryInput } from './sms-contacts.schemas';
@ -291,4 +291,332 @@ export const smsContactsService = {
const count = await prisma.smsContactListEntry.count({ where: { listId } }); const count = await prisma.smsContactListEntry.count({ where: { listId } });
return { totalEntries: count, duplicatesRemoved: 0 }; return { totalEntries: count, duplicatesRemoved: 0 };
}, },
// =========================================================================
// Database Import Sources — preview + import methods
// =========================================================================
/**
* Preview users with phone numbers matching filters
*/
async previewUsers(filters: { role?: string }) {
const where: Prisma.UserWhereInput = { phone: { not: null } };
if (filters.role) where.role = filters.role as UserRole;
const [total, sample] = await Promise.all([
prisma.user.count({ where }),
prisma.user.findMany({
where,
select: { phone: true, name: true },
take: 10,
}),
]);
return {
total,
sample: sample.map((u) => ({ phone: u.phone!, name: u.name || undefined })),
};
},
/**
* Import users with phone numbers into a contact list
*/
async importFromUsers(listId: string, filters: { role?: string }) {
const where: Prisma.UserWhereInput = { phone: { not: null } };
if (filters.role) where.role = filters.role as UserRole;
const users = await prisma.user.findMany({
where,
select: { phone: true, name: true, email: true },
});
let imported = 0;
let skipped = 0;
for (const user of users) {
const phone = normalizePhone(user.phone!);
if (!phone) { skipped++; continue; }
try {
await prisma.smsContactListEntry.upsert({
where: { listId_phone: { listId, phone } },
create: { listId, phone, name: user.name || undefined, email: user.email || undefined },
update: { name: user.name || undefined, email: user.email || undefined },
});
imported++;
} catch {
skipped++;
}
}
const total = await prisma.smsContactListEntry.count({ where: { listId } });
await prisma.smsContactList.update({ where: { id: listId }, data: { totalContacts: total } });
return { imported, skipped, total };
},
/**
* Preview addresses with phone numbers matching filters
*/
async previewAddresses(filters: { province?: string; supportLevel?: string }) {
const where: Prisma.AddressWhereInput = { phone: { not: null } };
if (filters.supportLevel) where.supportLevel = filters.supportLevel as SupportLevel;
if (filters.province) where.location = { province: filters.province };
const [total, sample] = await Promise.all([
prisma.address.count({ where }),
prisma.address.findMany({
where,
select: { phone: true, firstName: true, lastName: true },
take: 10,
}),
]);
return {
total,
sample: sample.map((a) => ({
phone: a.phone!,
name: [a.firstName, a.lastName].filter(Boolean).join(' ') || undefined,
})),
};
},
/**
* Import addresses with phone numbers into a contact list
*/
async importFromAddresses(listId: string, filters: { province?: string; supportLevel?: string }) {
const where: Prisma.AddressWhereInput = { phone: { not: null } };
if (filters.supportLevel) where.supportLevel = filters.supportLevel as SupportLevel;
if (filters.province) where.location = { province: filters.province };
const addresses = await prisma.address.findMany({
where,
select: { phone: true, firstName: true, lastName: true, email: true },
});
let imported = 0;
let skipped = 0;
for (const addr of addresses) {
const phone = normalizePhone(addr.phone!);
if (!phone) { skipped++; continue; }
const name = [addr.firstName, addr.lastName].filter(Boolean).join(' ') || undefined;
try {
await prisma.smsContactListEntry.upsert({
where: { listId_phone: { listId, phone } },
create: { listId, phone, name, email: addr.email || undefined },
update: { name, email: addr.email || undefined },
});
imported++;
} catch {
skipped++;
}
}
const total = await prisma.smsContactListEntry.count({ where: { listId } });
await prisma.smsContactList.update({ where: { id: listId }, data: { totalContacts: total } });
return { imported, skipped, total };
},
/**
* Preview shift signups with phone numbers matching filters
*/
async previewSignups(filters: { shiftId?: string }) {
const where: Prisma.ShiftSignupWhereInput = { userPhone: { not: null } };
if (filters.shiftId) where.shiftId = filters.shiftId;
const [total, sample] = await Promise.all([
prisma.shiftSignup.count({ where }),
prisma.shiftSignup.findMany({
where,
select: { userPhone: true, userName: true },
take: 10,
}),
]);
return {
total,
sample: sample.map((s) => ({ phone: s.userPhone!, name: s.userName || undefined })),
};
},
/**
* Import shift signups with phone numbers into a contact list
*/
async importFromSignups(listId: string, filters: { shiftId?: string }) {
const where: Prisma.ShiftSignupWhereInput = { userPhone: { not: null } };
if (filters.shiftId) where.shiftId = filters.shiftId;
const signups = await prisma.shiftSignup.findMany({
where,
select: { userPhone: true, userName: true, userEmail: true },
});
let imported = 0;
let skipped = 0;
for (const signup of signups) {
const phone = normalizePhone(signup.userPhone!);
if (!phone) { skipped++; continue; }
try {
await prisma.smsContactListEntry.upsert({
where: { listId_phone: { listId, phone } },
create: { listId, phone, name: signup.userName || undefined, email: signup.userEmail || undefined },
update: { name: signup.userName || undefined, email: signup.userEmail || undefined },
});
imported++;
} catch {
skipped++;
}
}
const total = await prisma.smsContactListEntry.count({ where: { listId } });
await prisma.smsContactList.update({ where: { id: listId }, data: { totalContacts: total } });
return { imported, skipped, total };
},
/**
* Preview conversations matching filters (excluding opted-out)
*/
async previewConversations(filters: { campaignId?: string; responseType?: string }) {
const where: Prisma.SmsConversationWhereInput = { status: { not: 'OPTED_OUT' } };
if (filters.campaignId) where.campaignId = filters.campaignId;
if (filters.responseType) {
where.messages = { some: { responseType: filters.responseType as SmsResponseType } };
}
const [total, sample] = await Promise.all([
prisma.smsConversation.count({ where }),
prisma.smsConversation.findMany({
where,
select: { phone: true, contactName: true },
take: 10,
}),
]);
return {
total,
sample: sample.map((c) => ({ phone: c.phone, name: c.contactName || undefined })),
};
},
/**
* Import conversation contacts into a contact list (excluding opted-out)
*/
async importFromConversations(listId: string, filters: { campaignId?: string; responseType?: string }) {
const where: Prisma.SmsConversationWhereInput = { status: { not: 'OPTED_OUT' } };
if (filters.campaignId) where.campaignId = filters.campaignId;
if (filters.responseType) {
where.messages = { some: { responseType: filters.responseType as SmsResponseType } };
}
const conversations = await prisma.smsConversation.findMany({
where,
select: { phone: true, contactName: true },
});
let imported = 0;
let skipped = 0;
for (const conv of conversations) {
const phone = normalizePhone(conv.phone);
if (!phone) { skipped++; continue; }
try {
await prisma.smsContactListEntry.upsert({
where: { listId_phone: { listId, phone } },
create: { listId, phone, name: conv.contactName || undefined },
update: { name: conv.contactName || undefined },
});
imported++;
} catch {
skipped++;
}
}
const total = await prisma.smsContactListEntry.count({ where: { listId } });
await prisma.smsContactList.update({ where: { id: listId }, data: { totalContacts: total } });
return { imported, skipped, total };
},
/**
* Preview CRM contacts with phones matching filters
*/
async previewContacts(filters: { tag?: string; supportLevel?: string }) {
const where: Prisma.ContactWhereInput = {
phones: { some: {} },
mergedIntoId: null,
doNotContact: false,
smsOptOut: false,
};
if (filters.supportLevel) where.supportLevel = filters.supportLevel as SupportLevel;
if (filters.tag) where.tags = { array_contains: [filters.tag] };
const [total, sample] = await Promise.all([
prisma.contact.count({ where }),
prisma.contact.findMany({
where,
select: { displayName: true, phones: { select: { phone: true }, take: 1 } },
take: 10,
}),
]);
return {
total,
sample: sample.map((c) => ({
phone: c.phones[0]?.phone || '',
name: c.displayName || undefined,
})),
};
},
/**
* Import CRM contacts with phones into a contact list
*/
async importFromContacts(listId: string, filters: { tag?: string; supportLevel?: string }) {
const where: Prisma.ContactWhereInput = {
phones: { some: {} },
mergedIntoId: null,
doNotContact: false,
smsOptOut: false,
};
if (filters.supportLevel) where.supportLevel = filters.supportLevel as SupportLevel;
if (filters.tag) where.tags = { array_contains: [filters.tag] };
const contacts = await prisma.contact.findMany({
where,
select: { displayName: true, email: true, phones: { select: { phone: true } } },
});
let imported = 0;
let skipped = 0;
for (const contact of contacts) {
for (const cp of contact.phones) {
const phone = normalizePhone(cp.phone);
if (!phone) { skipped++; continue; }
try {
await prisma.smsContactListEntry.upsert({
where: { listId_phone: { listId, phone } },
create: { listId, phone, name: contact.displayName || undefined, email: contact.email || undefined },
update: { name: contact.displayName || undefined, email: contact.email || undefined },
});
imported++;
} catch {
skipped++;
}
}
}
const total = await prisma.smsContactListEntry.count({ where: { listId } });
await prisma.smsContactList.update({ where: { id: listId }, data: { totalContacts: total } });
return { imported, skipped, total };
},
}; };

View File

@ -80,4 +80,32 @@ router.post('/:id/reply', async (req, res, next) => {
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
// --- Bulk Actions ---
// POST /api/sms/conversations/bulk-read — mark multiple conversations as read
router.post('/bulk-read', async (req, res, next) => {
try {
const { ids } = req.body as { ids: string[] };
if (!Array.isArray(ids) || ids.length === 0) {
res.status(400).json({ error: 'ids array is required' });
return;
}
const result = await smsConversationsService.bulkMarkRead(ids);
res.json(result);
} catch (err) { next(err); }
});
// POST /api/sms/conversations/bulk-close — close multiple conversations
router.post('/bulk-close', async (req, res, next) => {
try {
const { ids } = req.body as { ids: string[] };
if (!Array.isArray(ids) || ids.length === 0) {
res.status(400).json({ error: 'ids array is required' });
return;
}
const result = await smsConversationsService.bulkClose(ids);
res.json(result);
} catch (err) { next(err); }
});
export const smsConversationsRouter = router; export const smsConversationsRouter = router;

View File

@ -46,6 +46,7 @@ export const smsConversationsService = {
where: { id }, where: { id },
include: { include: {
campaign: { select: { id: true, name: true } }, campaign: { select: { id: true, name: true } },
contact: { select: { id: true, displayName: true } },
messages: { messages: {
orderBy: { sentAt: 'asc' }, orderBy: { sentAt: 'asc' },
take: 200, take: 200,
@ -136,4 +137,38 @@ export const smsConversationsService = {
]); ]);
return { total, active, optedOut, unread }; return { total, active, optedOut, unread };
}, },
/**
* Bulk mark conversations as read
*/
async bulkMarkRead(ids: string[]) {
if (ids.length === 0) return { updated: 0 };
const result = await prisma.smsConversation.updateMany({
where: { id: { in: ids } },
data: { unreadCount: 0 },
});
// Also mark all messages in these conversations as read
await prisma.smsMessage.updateMany({
where: { conversationId: { in: ids }, isRead: false },
data: { isRead: true },
});
return { updated: result.count };
},
/**
* Bulk close conversations
*/
async bulkClose(ids: string[]) {
if (ids.length === 0) return { updated: 0 };
const result = await prisma.smsConversation.updateMany({
where: { id: { in: ids }, status: 'ACTIVE' },
data: { status: 'CLOSED' },
});
return { updated: result.count };
},
}; };

View File

@ -2,6 +2,7 @@ import { Router } from 'express';
import { authenticate } from '../../../middleware/auth.middleware'; import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware'; import { requireRole } from '../../../middleware/rbac.middleware';
import { smsDeviceService } from './sms-device.service'; import { smsDeviceService } from './sms-device.service';
import { termuxClient } from '../../../services/termux.client';
const router = Router(); const router = Router();
@ -40,4 +41,17 @@ router.post('/sync', async (_req, res, next) => {
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
// GET /api/sms/device/logs — tail phone API server logs
router.get('/logs', async (req, res, next) => {
try {
const lines = Math.min(500, Math.max(1, Number(req.query.lines) || 100));
const logs = await termuxClient.getLogs(lines);
if (!logs) {
res.status(503).json({ error: 'Phone not connected or SMS not enabled' });
return;
}
res.json(logs);
} catch (err) { next(err); }
});
export const smsDeviceRouter = router; export const smsDeviceRouter = router;

View File

@ -0,0 +1,157 @@
import { prisma } from '../config/database';
import { logger } from '../utils/logger';
import { smsQueueService } from './sms-queue.service';
class SmsNotificationService {
/**
* Send a shift reminder SMS.
* Respects smsShiftReminders setting and calculates appropriate delay.
*/
async sendShiftReminder(
phone: string,
data: { name: string; shiftTitle: string; shiftTime: string; shiftLocation: string },
shiftDatetime: Date,
): Promise<void> {
if (!(await this.shouldSend(phone))) return;
const settings = await this.getSettings();
if (!settings.smsShiftReminders) return;
const template = await this.getTemplate('shift-reminder');
if (!template) return;
const message = this.substituteTemplate(template, {
name: data.name,
shiftTitle: data.shiftTitle,
shiftTime: data.shiftTime,
shiftLocation: data.shiftLocation,
});
// Calculate delay: send N hours before the shift
const reminderHours = settings.smsShiftReminderHours || 24;
const sendAt = new Date(shiftDatetime.getTime() - reminderHours * 60 * 60 * 1000);
const delayMs = Math.max(0, sendAt.getTime() - Date.now());
await smsQueueService.addSmsJob(
{ recipientId: '', campaignId: '', phone, message, attemptNumber: 1 },
delayMs,
);
}
/**
* Send a shift signup confirmation SMS. Sent immediately.
*/
async sendShiftSignupConfirmation(
phone: string,
data: { name: string; shiftTitle: string; shiftDate: string; shiftTime: string },
): Promise<void> {
if (!(await this.shouldSend(phone))) return;
const settings = await this.getSettings();
if (!settings.smsShiftSignupConfirm) return;
const template = await this.getTemplate('shift-signup-confirm');
if (!template) return;
const message = this.substituteTemplate(template, {
name: data.name,
shiftTitle: data.shiftTitle,
shiftDate: data.shiftDate,
shiftTime: data.shiftTime,
});
await smsQueueService.addSmsJob(
{ recipientId: '', campaignId: '', phone, message, attemptNumber: 1 },
);
}
/**
* Send a welcome SMS to a new volunteer. Sent immediately.
*/
async sendVolunteerWelcome(
phone: string,
data: { name: string },
): Promise<void> {
if (!(await this.shouldSend(phone))) return;
const settings = await this.getSettings();
if (!settings.smsVolunteerWelcome) return;
const template = await this.getTemplate('volunteer-welcome');
if (!template) return;
const message = this.substituteTemplate(template, { name: data.name });
await smsQueueService.addSmsJob(
{ recipientId: '', campaignId: '', phone, message, attemptNumber: 1 },
);
}
/**
* Send a custom one-off SMS notification. Sent immediately.
*/
async sendCustom(phone: string, message: string): Promise<void> {
if (!(await this.shouldSend(phone))) return;
await smsQueueService.addSmsJob(
{ recipientId: '', campaignId: '', phone, message, attemptNumber: 1 },
);
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
/**
* Check whether we should actually send an SMS to this phone.
* Verifies: enableSms is on, phone is non-empty, phone is not opted out.
*/
private async shouldSend(phone: string): Promise<boolean> {
if (!phone || !phone.trim()) return false;
const settings = await this.getSettings();
if (!settings.enableSms) return false;
// Check for opt-out: if a conversation exists with status OPTED_OUT, skip
const optedOut = await prisma.smsConversation.findFirst({
where: { phone, status: 'OPTED_OUT' },
select: { id: true },
});
if (optedOut) {
logger.debug(`SMS notification skipped for ${phone}: opted out`);
return false;
}
return true;
}
/**
* Look up an SmsMessageTemplate by name.
*/
private async getTemplate(name: string): Promise<string | null> {
const tmpl = await prisma.smsMessageTemplate.findFirst({
where: { name },
select: { template: true },
});
return tmpl?.template || null;
}
/**
* Replace {var} placeholders in a template string.
*/
private substituteTemplate(template: string, vars: Record<string, string>): string {
return template.replace(/\{(\w+)\}/g, (match, key) => {
return vars[key] !== undefined ? vars[key] : match;
});
}
/**
* Fetch site settings (cached per-request is fine for notification context).
*/
private async getSettings() {
const { siteSettingsService } = await import('../modules/settings/settings.service');
return siteSettingsService.get();
}
}
export const smsNotificationService = new SmsNotificationService();

View File

@ -5,9 +5,9 @@ import { prisma } from '../config/database';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { termuxClient } from './termux.client'; import { termuxClient } from './termux.client';
interface SmsJobData { export interface SmsJobData {
recipientId: string; recipientId: string; // empty string for notification jobs
campaignId: string; campaignId: string; // empty string for notification jobs
phone: string; phone: string;
message: string; message: string;
attemptNumber: number; attemptNumber: number;
@ -34,17 +34,21 @@ class SmsQueueService {
'sms-campaigns', 'sms-campaigns',
async (job: Job<SmsJobData>) => { async (job: Job<SmsJobData>) => {
const { recipientId, campaignId, phone, message } = job.data; const { recipientId, campaignId, phone, message } = job.data;
logger.info(`Processing SMS job ${job.id} for campaign ${campaignId}, phone ${phone}`); const isNotification = !campaignId;
// Check if campaign is still RUNNING (support pause) logger.info(`Processing SMS job ${job.id}${isNotification ? ' (notification)' : ` for campaign ${campaignId}`}, phone ${phone}`);
const campaign = await prisma.smsCampaign.findUnique({
where: { id: campaignId },
select: { status: true },
});
if (!campaign || campaign.status !== 'RUNNING') { // For campaign jobs, check if campaign is still RUNNING (support pause)
logger.info(`Campaign ${campaignId} is ${campaign?.status || 'deleted'}, skipping SMS to ${phone}`); if (!isNotification) {
return { skipped: true, reason: 'campaign_not_running' }; const campaign = await prisma.smsCampaign.findUnique({
where: { id: campaignId },
select: { status: true },
});
if (!campaign || campaign.status !== 'RUNNING') {
logger.info(`Campaign ${campaignId} is ${campaign?.status || 'deleted'}, skipping SMS to ${phone}`);
return { skipped: true, reason: 'campaign_not_running' };
}
} }
// Send SMS via Termux // Send SMS via Termux
@ -52,15 +56,17 @@ class SmsQueueService {
const status: SmsMessageStatus = result.success ? 'SENT' : 'FAILED'; const status: SmsMessageStatus = result.success ? 'SENT' : 'FAILED';
// Update recipient status // Update recipient status (campaign jobs only)
await prisma.smsCampaignRecipient.update({ if (!isNotification && recipientId) {
where: { id: recipientId }, await prisma.smsCampaignRecipient.update({
data: { where: { id: recipientId },
status, data: {
sentAt: result.success ? new Date() : undefined, status,
errorMessage: result.error || undefined, sentAt: result.success ? new Date() : undefined,
}, errorMessage: result.error || undefined,
}); },
});
}
// Create SmsMessage record // Create SmsMessage record
const smsMessage = await prisma.smsMessage.create({ const smsMessage = await prisma.smsMessage.create({
@ -70,64 +76,71 @@ class SmsQueueService {
direction: 'OUTBOUND', direction: 'OUTBOUND',
status, status,
connectionType: 'termux', connectionType: 'termux',
campaignId, campaignId: campaignId || null,
}, },
}); });
// Create or update conversation // Create or update conversation (campaign jobs use compound unique, notifications use phone-only)
const conversation = await prisma.smsConversation.upsert({ if (!isNotification) {
where: { phone_campaignId: { phone, campaignId } }, const conversation = await prisma.smsConversation.upsert({
create: { where: { phone_campaignId: { phone, campaignId } },
phone, create: {
campaignId, phone,
totalMessages: 1, campaignId,
lastMessageAt: new Date(), totalMessages: 1,
}, lastMessageAt: new Date(),
update: { },
totalMessages: { increment: 1 }, update: {
lastMessageAt: new Date(), totalMessages: { increment: 1 },
}, lastMessageAt: new Date(),
}); },
});
// Link message to conversation // Link message to conversation
await prisma.smsMessage.update({ await prisma.smsMessage.update({
where: { id: smsMessage.id }, where: { id: smsMessage.id },
data: { conversationId: conversation.id }, data: { conversationId: conversation.id },
}); });
// Record outbound SMS as ContactActivity if conversation has a contactId // Record outbound SMS as ContactActivity if conversation has a contactId
if (conversation.contactId) { if (conversation.contactId) {
try { try {
await prisma.contactActivity.create({ await prisma.contactActivity.create({
data: { data: {
contactId: conversation.contactId, contactId: conversation.contactId,
type: 'SMS_SENT', type: 'SMS_SENT',
title: 'SMS sent', title: 'SMS sent',
description: message.length > 200 ? message.slice(0, 200) + '...' : message, description: message.length > 200 ? message.slice(0, 200) + '...' : message,
metadata: { metadata: {
phone, phone,
conversationId: conversation.id, conversationId: conversation.id,
campaignId, campaignId,
},
}, },
}, });
}); } catch (err) {
} catch (err) { logger.debug('Failed to record outbound SMS ContactActivity:', err);
logger.debug('Failed to record outbound SMS ContactActivity:', err); }
} }
}
// Update campaign counters // Update campaign counters
if (result.success) { if (result.success) {
await prisma.smsCampaign.update({ await prisma.smsCampaign.update({
where: { id: campaignId }, where: { id: campaignId },
data: { totalSent: { increment: 1 } }, data: { totalSent: { increment: 1 } },
}); });
} else {
await prisma.smsCampaign.update({
where: { id: campaignId },
data: { totalFailed: { increment: 1 } },
});
throw new Error(`Failed to send SMS to ${phone}: ${result.error}`);
}
} else { } else {
await prisma.smsCampaign.update({ // Notification job: just throw on failure for BullMQ retry
where: { id: campaignId }, if (!result.success) {
data: { totalFailed: { increment: 1 } }, throw new Error(`Failed to send notification SMS to ${phone}: ${result.error}`);
}); }
throw new Error(`Failed to send SMS to ${phone}: ${result.error}`);
} }
return { success: true, phone }; return { success: true, phone };

View File

@ -76,7 +76,7 @@ class TermuxClient {
this.dbKey = settings.smsTermuxApiKey || ''; this.dbKey = settings.smsTermuxApiKey || '';
this.dbEnabled = settings.enableSms; this.dbEnabled = settings.enableSms;
} catch (err) { } catch (err) {
logger.warn('Failed to load SMS config from DB:', err instanceof Error ? err.message : err); logger.warn(`Failed to load SMS config from DB: ${err instanceof Error ? err.message : err}`);
} }
} }
@ -152,7 +152,7 @@ class TermuxClient {
try { try {
return await this.request<TermuxHealthResponse>('GET', '/health'); return await this.request<TermuxHealthResponse>('GET', '/health');
} catch (err) { } catch (err) {
logger.warn('Termux getHealth failed:', err instanceof Error ? err.message : err); logger.warn(`Termux getHealth failed: ${err instanceof Error ? err.message : err}`);
return null; return null;
} }
} }
@ -171,7 +171,7 @@ class TermuxClient {
}, 30000); // 30s timeout for SMS send }, 30000); // 30s timeout for SMS send
} catch (err) { } catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Unknown error'; const errorMsg = err instanceof Error ? err.message : 'Unknown error';
logger.warn(`Termux sendSms to ${phone} failed:`, errorMsg); logger.warn(`Termux sendSms to ${phone} failed: ${errorMsg}`);
return { success: false, error: errorMsg }; return { success: false, error: errorMsg };
} }
} }
@ -193,7 +193,7 @@ class TermuxClient {
); );
return data.messages || []; return data.messages || [];
} catch (err) { } catch (err) {
logger.warn('Termux getInbox failed:', err instanceof Error ? err.message : err); logger.warn(`Termux getInbox failed: ${err instanceof Error ? err.message : err}`);
return []; return [];
} }
} }
@ -211,7 +211,7 @@ class TermuxClient {
); );
return data.contacts || []; return data.contacts || [];
} catch (err) { } catch (err) {
logger.warn('Termux getContacts failed:', err instanceof Error ? err.message : err); logger.warn(`Termux getContacts failed: ${err instanceof Error ? err.message : err}`);
return []; return [];
} }
} }
@ -228,7 +228,7 @@ class TermuxClient {
); );
return data.battery || data as unknown as TermuxBatteryStatus; return data.battery || data as unknown as TermuxBatteryStatus;
} catch (err) { } catch (err) {
logger.warn('Termux getBattery failed:', err instanceof Error ? err.message : err); logger.warn(`Termux getBattery failed: ${err instanceof Error ? err.message : err}`);
return null; return null;
} }
} }
@ -239,7 +239,33 @@ class TermuxClient {
try { try {
return await this.request<Record<string, unknown>>('GET', '/api/device/info'); return await this.request<Record<string, unknown>>('GET', '/api/device/info');
} catch (err) { } catch (err) {
logger.warn('Termux getDeviceInfo failed:', err instanceof Error ? err.message : err); logger.warn(`Termux getDeviceInfo failed: ${err instanceof Error ? err.message : err}`);
return null;
}
}
// --- Logs ---
/**
* Get last N lines from the phone's SMS API log file.
*/
async getLogs(lines = 100): Promise<{ lines: string[]; totalLines: number; fileSize: number } | null> {
if (!this.enabled) return null;
try {
const data = await this.request<{ lines?: string[]; total_lines?: number; file_size?: number; success?: boolean }>(
'GET',
`/api/logs/tail?lines=${Math.min(500, Math.max(1, lines))}`,
undefined,
15000,
);
return {
lines: data.lines || [],
totalLines: data.total_lines || 0,
fileSize: data.file_size || 0,
};
} catch (err) {
logger.warn(`Termux getLogs failed: ${err instanceof Error ? err.message : err}`);
return null; return null;
} }
} }

@ -1 +1 @@
Subproject commit 81fa6c983d49d9fffa7bfa5383897015ef91d9f6 Subproject commit 2457662e12b5fd4c2e62a22503f3ffd93dc5e303

View File

@ -394,6 +394,28 @@ export SMS_API_SECRET='your-generated-key'
echo 'export SMS_API_SECRET="your-key"' >> ~/.bashrc echo 'export SMS_API_SECRET="your-key"' >> ~/.bashrc
``` ```
### RCS / Chat Features interfering with replies
**Symptoms:** You send SMS messages successfully but some or all replies never appear in the system. Recipients say they replied, but the conversation shows no inbound messages.
**Cause:** Google Messages enables **RCS (Rich Communication Services)** by default. When RCS is active, replies from recipients who also have RCS may be sent over data/Wi-Fi instead of the carrier SMS channel. The Termux server only reads the SMS inbox via `termux-sms-list`, so RCS messages are invisible to it.
**Fix:** Disable RCS on the SMS phone:
1. Open **Google Messages** on the phone
2. Tap the profile icon (top right) → **Messages settings**
3. Tap **RCS chats** (or "Chat features")
4. **Turn off** "Turn on RCS chats"
!!! warning "This must be done on the phone running the SMS server"
Disabling RCS on the server phone forces all outgoing messages to use plain SMS, and ensures replies also come back as SMS. You do not need recipients to change anything on their end — when the server phone sends a plain SMS, the reply will be plain SMS as well (unless the recipient's carrier forces RCS-only, which is rare).
**Additional checks:**
- Some carriers (e.g. Google Fi, Jio) enable RCS at the carrier level. If disabling in the app doesn't help, contact the carrier to disable RCS on the SIM.
- If the phone has **Samsung Messages** instead of Google Messages, go to Samsung Messages → Settings → Chat settings → turn off.
- After disabling RCS, restart the phone and verify by sending a test message — the send button should show an **SMS** label, not "Chat".
### Updating the SMS server ### Updating the SMS server
To pull the latest version of the server code: To pull the latest version of the server code: