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,
MailOutlined,
RetweetOutlined,
PhoneOutlined,
} from '@ant-design/icons';
import { useSettingsStore } from '@/stores/settings.store';
import { api } from '@/lib/api';
@ -614,6 +615,36 @@ export default function SettingsPage() {
</Form.Item>
</Card>
</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>
</div>
),

View File

@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import { Table, Button, Modal, Form, Input, Select, InputNumber, Space, Tag, Progress, App, Typography, Popconfirm } from 'antd';
import { PlusOutlined, PlayCircleOutlined, PauseCircleOutlined, CaretRightOutlined, DeleteOutlined } from '@ant-design/icons';
import { Table, Button, Modal, Form, Input, Select, InputNumber, Space, Tag, Progress, App, Typography, Popconfirm, Divider, Alert } from 'antd';
import { PlusOutlined, PlayCircleOutlined, PauseCircleOutlined, CaretRightOutlined, DeleteOutlined, EyeOutlined, SendOutlined, PhoneOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { api } from '@/lib/api';
import type { SmsCampaign, SmsContactList, SmsPaginatedResponse } from '@/types/sms';
@ -30,6 +30,11 @@ export default function SmsCampaignsPage() {
const [contactLists, setContactLists] = useState<SmsContactList[]>([]);
const [createForm] = Form.useForm();
// Preview & Test
const [testPreview, setTestPreview] = useState('');
const [testPhone, setTestPhone] = useState('');
const [testSending, setTestSending] = useState(false);
useEffect(() => {
setPageHeader({ title: 'SMS Campaigns', subtitle: 'Create and manage SMS campaigns' });
}, [setPageHeader]);
@ -110,6 +115,31 @@ export default function SmsCampaignsPage() {
} 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> = [
{ title: 'Name', dataIndex: 'name', ellipsis: true },
{
@ -204,7 +234,7 @@ export default function SmsCampaignsPage() {
<Modal
title="New SMS Campaign"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onCancel={() => { setCreateOpen(false); setTestPreview(''); }}
onOk={() => createForm.submit()}
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">
<TextArea rows={4} maxLength={1600} showCount placeholder="Hi {name}, ..." />
</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)">
<InputNumber min={1000} max={60000} step={500} style={{ width: '100%' }} />
</Form.Item>

View File

@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import { Table, Button, Modal, Form, Input, Space, Drawer, Upload, App, Typography, Popconfirm } from 'antd';
import { PlusOutlined, UploadOutlined, PhoneOutlined, DeleteOutlined, ImportOutlined } from '@ant-design/icons';
import { Table, Button, Modal, Form, Input, Space, Drawer, Upload, App, Typography, Popconfirm, Tabs, Select } from 'antd';
import { PlusOutlined, UploadOutlined, PhoneOutlined, DeleteOutlined, ImportOutlined, DatabaseOutlined, UserOutlined, EnvironmentOutlined, CalendarOutlined, MessageOutlined, ContactsOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { api } from '@/lib/api';
import type { SmsContactList, SmsContactListEntry, SmsPaginatedResponse } from '@/types/sms';
@ -10,6 +10,11 @@ import { useOutletContext } from 'react-router-dom';
const { Text } = Typography;
const { TextArea } = Input;
interface PreviewResult {
total: number;
sample: { phone: string; name?: string }[];
}
export default function SmsContactsPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const { message } = App.useApp();
@ -30,6 +35,25 @@ export default function SmsContactsPage() {
const [entriesLoading, setEntriesLoading] = useState(false);
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(() => {
setPageHeader({ title: 'SMS Contacts', subtitle: 'Manage contact lists for SMS campaigns' });
}, [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 }) => {
try {
await api.post('/sms/contacts', values);
@ -83,27 +121,36 @@ export default function SmsContactsPage() {
const handleImportCsv = async () => {
if (!importListId || !csvText.trim()) return;
setImportLoading(true);
try {
const { data } = await api.post(`/sms/contacts/${importListId}/import-csv`, { csv: csvText });
message.success(`Imported ${data.imported} contacts (${data.skipped} skipped)`);
setImportOpen(false);
setCsvText('');
resetImportState();
fetchLists();
if (selectedList?.id === importListId) fetchEntries(importListId);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Import failed';
message.error(msg);
} finally {
setImportLoading(false);
}
};
const handleImportFromPhone = async (listId: string) => {
const handleImportFromPhone = async () => {
if (!importListId) return;
setImportLoading(true);
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`);
setImportOpen(false);
resetImportState();
fetchLists();
if (selectedList?.id === listId) fetchEntries(listId);
if (selectedList?.id === importListId) fetchEntries(importListId);
} catch {
message.error('Failed to import from phone');
} finally {
setImportLoading(false);
}
};
@ -125,6 +172,132 @@ export default function SmsContactsPage() {
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> = [
{
title: 'Name',
@ -145,11 +318,10 @@ export default function SmsContactsPage() {
},
{
title: 'Actions',
width: 200,
width: 160,
render: (_, record) => (
<Space>
<Button size="small" icon={<UploadOutlined />} onClick={() => { setImportListId(record.id); setImportOpen(true); }}>Import CSV</Button>
<Button size="small" icon={<PhoneOutlined />} onClick={() => handleImportFromPhone(record.id)}>From Phone</Button>
<Button size="small" icon={<DatabaseOutlined />} onClick={() => openImportModal(record.id)}>Import</Button>
<Popconfirm title="Archive this list?" onConfirm={() => handleArchive(record.id)}>
<Button size="small" danger icon={<DeleteOutlined />} />
</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 (
<>
<Space style={{ marginBottom: 16 }}>
@ -202,37 +396,208 @@ export default function SmsContactsPage() {
</Form>
</Modal>
{/* CSV Import Modal */}
{/* Unified Import Modal */}
<Modal
title="Import CSV"
title="Import Contacts"
open={importOpen}
onCancel={() => { setImportOpen(false); setCsvText(''); }}
onOk={handleImportCsv}
okText="Import"
width={600}
onCancel={() => { setImportOpen(false); resetImportState(); }}
footer={null}
width={640}
destroyOnClose
>
<Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
Paste CSV text below. First row should be headers. Expected columns: phone (required), name, email. Other columns become custom fields.
</Text>
<Upload
accept=".csv,.tsv,.txt"
maxCount={1}
beforeUpload={(file) => {
const reader = new FileReader();
reader.onload = (e) => setCsvText(e.target?.result as string || '');
reader.readAsText(file);
return false;
}}
showUploadList={false}
>
<Button icon={<ImportOutlined />}>Load from file</Button>
</Upload>
<TextArea
rows={10}
value={csvText}
onChange={(e) => setCsvText(e.target.value)}
placeholder="phone,name,email&#10;5551234567,John Doe,john@example.com"
style={{ marginTop: 12, fontFamily: 'monospace' }}
<Tabs
onChange={() => setPreview(null)}
items={[
{
key: 'csv',
label: <span><UploadOutlined /> CSV</span>,
children: (
<>
<Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
Paste CSV text or upload a file. First row = headers. Expected: phone (required), name, email.
</Text>
<Upload
accept=".csv,.tsv,.txt"
maxCount={1}
beforeUpload={(file) => {
const reader = new FileReader();
reader.onload = (e) => setCsvText(e.target?.result as string || '');
reader.readAsText(file);
return false;
}}
showUploadList={false}
>
<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>

View File

@ -1,13 +1,13 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { Row, Col, List, Card, Input, Button, Tag, Badge, Space, Typography, Empty, Spin, App } from 'antd';
import { SendOutlined, CheckOutlined } from '@ant-design/icons';
import { Row, Col, List, Card, Input, Button, Tag, Badge, Space, Typography, Empty, Spin, App, Checkbox, Select, Collapse } from 'antd';
import { SendOutlined, CheckOutlined, ReadOutlined, CloseCircleOutlined, LinkOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import type { SmsConversation, SmsPaginatedResponse } from '@/types/sms';
import type { AppOutletContext } from '@/types/api';
import { useOutletContext } from 'react-router-dom';
import { useOutletContext, Link } from 'react-router-dom';
const { Text, Paragraph } = Typography;
const { Search } = Input;
const { Search, TextArea } = Input;
const RESPONSE_TYPE_COLORS: Record<string, string> = {
POSITIVE: 'success',
@ -31,6 +31,16 @@ export default function SmsConversationsPage() {
const [sending, setSending] = useState(false);
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(() => {
setPageHeader({ title: 'SMS Conversations', subtitle: 'View and reply to SMS threads' });
}, [setPageHeader]);
@ -61,6 +71,8 @@ export default function SmsConversationsPage() {
try {
const { data } = await api.get<SmsConversation>(`/sms/conversations/${conv.id}`);
setSelected(data);
setNotes(data.notes || '');
setTags(data.tags || []);
// Mark as read
if (conv.unreadCount > 0) {
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() });
message.success('Reply queued');
setReplyText('');
// Refresh conversation
selectConversation(selected);
} catch {
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 (
<Row gutter={16} style={{ height: 'calc(100vh - 200px)', minHeight: 400 }}>
{/* 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
placeholder="Search by phone or name..."
onSearch={(v) => { setSearch(v); fetchConversations(v); }}
allowClear
style={{ marginBottom: 12 }}
style={{ marginBottom: 8 }}
/>
<List
loading={loading}
dataSource={conversations}
locale={{ emptyText: <Empty description="No conversations yet" /> }}
renderItem={(conv) => (
<List.Item
onClick={() => selectConversation(conv)}
style={{
cursor: 'pointer',
background: selected?.id === conv.id ? 'rgba(255,255,255,0.06)' : undefined,
padding: '8px 12px',
borderRadius: 6,
}}
>
<List.Item.Meta
title={
<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>}
</Space>
}
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>
{/* Bulk action bar */}
{selectedIds.size > 0 && (
<Space style={{ marginBottom: 8, padding: '4px 8px', background: 'rgba(255,255,255,0.04)', borderRadius: 6 }}>
<Text type="secondary" style={{ fontSize: 12 }}>{selectedIds.size} selected</Text>
<Button size="small" icon={<ReadOutlined />} onClick={handleBulkRead} loading={bulkLoading}>Mark Read</Button>
<Button size="small" icon={<CloseCircleOutlined />} onClick={handleBulkClose} loading={bulkLoading}>Close</Button>
<Button size="small" type="text" onClick={() => setSelectedIds(new Set())}>Clear</Button>
</Space>
)}
<div style={{ flex: 1, overflowY: 'auto' }}>
{conversations.length > 0 && (
<div style={{ padding: '4px 12px', marginBottom: 4 }}>
<Checkbox
checked={selectedIds.size === conversations.length && conversations.length > 0}
indeterminate={selectedIds.size > 0 && selectedIds.size < conversations.length}
onChange={toggleSelectAll}
>
<Text type="secondary" style={{ fontSize: 12 }}>Select all</Text>
</Checkbox>
</div>
)}
/>
<Text type="secondary" style={{ display: 'block', textAlign: 'center', marginTop: 8 }}>
{total} conversation{total !== 1 ? 's' : ''}
</Text>
<List
loading={loading}
dataSource={conversations}
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>
{/* Message Thread (right panel) */}
@ -157,16 +271,61 @@ export default function SmsConversationsPage() {
<Tag color={selected.status === 'ACTIVE' ? 'success' : selected.status === 'OPTED_OUT' ? 'volcano' : 'default'}>
{selected.status}
</Tag>
{selected.contact && (
<Link to={`/app/crm/contacts`} style={{ fontSize: 12 }}>
<LinkOutlined /> {selected.contact.displayName}
</Link>
)}
</Space>
}
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 ? (
<Spin style={{ display: 'block', margin: '40px auto' }} />
) : (
<>
<div style={{ flex: 1, overflowY: 'auto', paddingBottom: 12 }}>
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* 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) => (
<div
key={msg.id}
@ -210,7 +369,7 @@ export default function SmsConversationsPage() {
{/* Reply composer */}
{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
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
@ -232,7 +391,7 @@ export default function SmsConversationsPage() {
This contact has opted out. Replies are disabled.
</Text>
)}
</>
</div>
)}
</Card>
)}

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { Card, Row, Col, Statistic, Tag, Typography, Space, Button, Descriptions, Spin, App } from 'antd';
import { useState, useEffect, useCallback, useRef } from 'react';
import { Card, Row, Col, Statistic, Tag, Typography, Space, Button, Descriptions, Spin, App, Input, Drawer, Switch } from 'antd';
import {
PhoneOutlined,
CheckCircleOutlined,
@ -8,6 +8,8 @@ import {
ThunderboltOutlined,
SyncOutlined,
MobileOutlined,
SendOutlined,
FileTextOutlined,
} from '@ant-design/icons';
import { api } from '@/lib/api';
import type { SmsDeviceStatus, SmsCampaign, SmsPaginatedResponse } from '@/types/sms';
@ -15,6 +17,7 @@ import type { AppOutletContext } from '@/types/api';
import { useOutletContext } from 'react-router-dom';
const { Title, Text } = Typography;
const { TextArea } = Input;
export default function SmsDashboardPage() {
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' }} />;
const activeCampaigns = campaigns.filter((c) => c.status === 'RUNNING');
return (
<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 */}
<Card
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 ? (
<Descriptions column={{ xs: 1, sm: 2, md: 4 }}>
@ -174,6 +284,51 @@ export default function SmsDashboardPage() {
</Space>
)}
</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>
);
}

View File

@ -9,7 +9,7 @@ import {
CopyOutlined, EyeOutlined, EyeInvisibleOutlined, ApiOutlined,
WifiOutlined, LinkOutlined, ThunderboltOutlined, ReloadOutlined,
AndroidOutlined, CloudServerOutlined, RocketOutlined, SendOutlined,
MessageOutlined,
MessageOutlined, WarningOutlined, KeyOutlined,
} from '@ant-design/icons';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/components/AppLayout';
@ -71,6 +71,10 @@ export default function SmsSetupPage() {
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
// Send Test SMS
const [testPhone, setTestPhone] = useState('');
const [testSending, setTestSending] = useState(false);
useEffect(() => {
setPageHeader({ title: 'SMS Setup' });
return () => setPageHeader(null);
@ -176,7 +180,7 @@ export default function SmsSetupPage() {
});
setTestResult(res.data);
if (res.data.success) {
message.success('Connection successful!');
message.success('Connection successful — phone is reachable and key is valid!');
} else {
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 ---
if (loading) {
@ -300,6 +332,24 @@ export default function SmsSetupPage() {
{/* Step 0: Phone Preparation */}
{currentStep === 0 && (
<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 */}
<Alert
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>
</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>
</li>
<li>
<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>
</ol>
</div>
@ -343,7 +393,8 @@ export default function SmsSetupPage() {
{/* Part 2: Generate API Key */}
<Divider>Step 2 Generate API Key</Divider>
<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>
<Space>
@ -417,7 +468,7 @@ export default function SmsSetupPage() {
</Paragraph>
<ul style={{ marginLeft: 20, lineHeight: 2 }}>
<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>Set up Termux:Boot auto-start (if installed)</li>
<li>Start the server with the watchdog</li>
@ -425,10 +476,28 @@ export default function SmsSetupPage() {
<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.
</Paragraph>
<Divider style={{ margin: '12px 0' }} />
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
<Text strong>Also recommended:</Text> Disable battery optimization for Termux Android Settings Apps Termux Battery Unrestricted.
</div>
}
/>
)}
{/* 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>
<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>
}
/>
@ -437,15 +506,19 @@ export default function SmsSetupPage() {
{/* Already set up? */}
{generatedKey && (
<Alert
type="warning"
type="info"
showIcon
icon={<KeyOutlined />}
message="Already have the server running?"
description={
<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 }}>
<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>
<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>
}
/>
@ -576,7 +649,11 @@ export default function SmsSetupPage() {
prefix={<LinkOutlined />}
/>
</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
value={termuxApiKey}
onChange={e => setTermuxApiKey(e.target.value)}
@ -622,6 +699,13 @@ export default function SmsSetupPage() {
)}
</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>
<Button
type="primary"
@ -640,22 +724,50 @@ export default function SmsSetupPage() {
{testResult && (
testResult.success ? (
<Alert
type="success"
showIcon
message="Connection Successful!"
description={
<div>
{testResult.health && (
<Descriptions column={{ xs: 1, sm: 2 }} size="small" bordered style={{ marginTop: 8 }}>
<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>
)}
<>
<Alert
type="success"
showIcon
message="Connection Successful — Phone Authenticated!"
description={
<div>
<Paragraph type="secondary" style={{ marginBottom: 8 }}>
The phone is reachable and the API key is valid.
</Paragraph>
{testResult.health && (
<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>
}
/>
</Card>
</>
) : (
<Alert
type="error"
@ -664,9 +776,17 @@ export default function SmsSetupPage() {
description={
<div>
<Paragraph>{testResult.error}</Paragraph>
<Paragraph type="secondary">
Make sure the Termux SMS server is running on the phone and the URL/key are correct.
</Paragraph>
{testResult.error?.includes('401') || testResult.error?.includes('Authentication') ? (
<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>
}
/>
@ -730,7 +850,7 @@ export default function SmsSetupPage() {
style={{ marginBottom: 16 }}
/>
<Space wrap>
<Space wrap style={{ marginBottom: 16 }}>
<Button type="primary" icon={<PhoneOutlined />} onClick={() => navigate('/app/sms')}>
SMS Dashboard
</Button>
@ -741,6 +861,28 @@ export default function SmsSetupPage() {
Conversations
</Button>
</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>
)}
@ -750,15 +892,27 @@ export default function SmsSetupPage() {
type="warning"
showIcon
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={
<Button size="small" icon={<ReloadOutlined />} onClick={fetchStatus}>
Retry
</Button>
<Space direction="vertical" size="small">
<Button size="small" icon={<ReloadOutlined />} onClick={fetchStatus}>
Retry
</Button>
</Space>
}
/>
)}
</Space>
);
}

View File

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

View File

@ -91,6 +91,8 @@ export interface SmsConversation {
contactName: string | null;
campaignId: string | null;
campaign?: { id: string; name: string } | null;
contactId: string | null;
contact?: { id: string; displayName: string } | null;
status: SmsConversationStatus;
totalMessages: 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)
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
notifyVolunteerReengagement Boolean @default(false) @map("notify_volunteer_reengagement")
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');
}
// Seed SMS notification templates
await seedSmsNotificationTemplates();
// Seed pre-made gallery ads (all inactive by default — admin enables manually)
await seedGalleryAds();
@ -852,6 +855,56 @@ async function seedEmailTemplates(admin: { id: string; email: string }) {
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
*/

View File

@ -16,6 +16,7 @@ import { groupService } from '../../social/group.service';
import { achievementsService } from '../../social/achievements.service';
import { generateSlug } from '../../../utils/slug';
import { siteSettingsService } from '../../settings/settings.service';
import { smsNotificationService } from '../../../services/sms-notification.service';
import crypto from 'crypto';
import type {
CreateShiftInput,
@ -537,6 +538,23 @@ export const shiftsService = {
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
const shiftDateStr = new Date(shift.date).toLocaleDateString('en-CA', { month: 'short', day: 'numeric' });
rocketchatWebhookService.onShiftSignup({
@ -595,6 +613,20 @@ export const shiftsService = {
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)
try {
if (await isNotificationEnabled('notifyVolunteerShiftThankYou')) {

View File

@ -30,6 +30,63 @@ router.post('/', validate(createContactListSchema), async (req, res, next) => {
} 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
router.get('/:id', async (req, res, next) => {
try {
@ -106,4 +163,51 @@ router.post('/:id/import-phone', async (req, res, next) => {
} 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;

View File

@ -1,5 +1,5 @@
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 type { CreateContactListInput, UpdateContactListInput, CreateContactEntryInput } from './sms-contacts.schemas';
@ -291,4 +291,332 @@ export const smsContactsService = {
const count = await prisma.smsContactListEntry.count({ where: { listId } });
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); }
});
// --- 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;

View File

@ -46,6 +46,7 @@ export const smsConversationsService = {
where: { id },
include: {
campaign: { select: { id: true, name: true } },
contact: { select: { id: true, displayName: true } },
messages: {
orderBy: { sentAt: 'asc' },
take: 200,
@ -136,4 +137,38 @@ export const smsConversationsService = {
]);
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 { requireRole } from '../../../middleware/rbac.middleware';
import { smsDeviceService } from './sms-device.service';
import { termuxClient } from '../../../services/termux.client';
const router = Router();
@ -40,4 +41,17 @@ router.post('/sync', async (_req, res, next) => {
} 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;

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

View File

@ -76,7 +76,7 @@ class TermuxClient {
this.dbKey = settings.smsTermuxApiKey || '';
this.dbEnabled = settings.enableSms;
} 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 {
return await this.request<TermuxHealthResponse>('GET', '/health');
} 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;
}
}
@ -171,7 +171,7 @@ class TermuxClient {
}, 30000); // 30s timeout for SMS send
} catch (err) {
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 };
}
}
@ -193,7 +193,7 @@ class TermuxClient {
);
return data.messages || [];
} 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 [];
}
}
@ -211,7 +211,7 @@ class TermuxClient {
);
return data.contacts || [];
} 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 [];
}
}
@ -228,7 +228,7 @@ class TermuxClient {
);
return data.battery || data as unknown as TermuxBatteryStatus;
} 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;
}
}
@ -239,7 +239,33 @@ class TermuxClient {
try {
return await this.request<Record<string, unknown>>('GET', '/api/device/info');
} 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;
}
}

@ -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
```
### 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
To pull the latest version of the server code: