Add ~50 missing env vars to CCP env.hbs template for full feature coverage
New instances provisioned via CCP were missing env vars for video analytics, geocoding config, Listmonk SMTP, Gitea comments, Overpass/area import, monitoring ports, Bunker Ops, and other features added since the template was last updated. Bunker Admin
This commit is contained in:
parent
ce590ccae8
commit
18997da3eb
@ -107,6 +107,7 @@ import SmsDashboardPage from '@/pages/sms/SmsDashboardPage';
|
||||
import SmsContactsPage from '@/pages/sms/SmsContactsPage';
|
||||
import SmsCampaignsPage from '@/pages/sms/SmsCampaignsPage';
|
||||
import SmsConversationsPage from '@/pages/sms/SmsConversationsPage';
|
||||
import SmsTemplatesPage from '@/pages/sms/SmsTemplatesPage';
|
||||
import SmsSetupPage from '@/pages/sms/SmsSetupPage';
|
||||
import PeoplePage from '@/pages/PeoplePage';
|
||||
import ContactProfilePage from '@/pages/public/ContactProfilePage';
|
||||
@ -615,6 +616,14 @@ export default function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="sms/templates"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
||||
<SmsTemplatesPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings"
|
||||
element={
|
||||
|
||||
@ -212,6 +212,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
|
||||
{ key: '/app/sms/contacts', icon: <TeamOutlined />, label: 'SMS Contacts' },
|
||||
{ key: '/app/sms/campaigns', icon: <SendOutlined />, label: 'SMS Campaigns' },
|
||||
{ key: '/app/sms/conversations', icon: <MessageOutlined />, label: 'SMS Threads' },
|
||||
{ key: '/app/sms/templates', icon: <FileTextOutlined />, label: 'SMS Templates' },
|
||||
);
|
||||
}
|
||||
items.push({
|
||||
|
||||
406
admin/src/pages/sms/SmsTemplatesPage.tsx
Normal file
406
admin/src/pages/sms/SmsTemplatesPage.tsx
Normal file
@ -0,0 +1,406 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Table, Button, Modal, Form, Input, Select, Space, Tag, App, Typography, Switch, Tooltip } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, CopyOutlined, DeleteOutlined, StarFilled, StarOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { api } from '@/lib/api';
|
||||
import type { SmsMessageTemplate, SmsPaginatedResponse } from '@/types/sms';
|
||||
import type { AppOutletContext } from '@/types/api';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Text } = Typography;
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
notification: 'blue',
|
||||
campaign: 'purple',
|
||||
custom: 'green',
|
||||
};
|
||||
|
||||
/** Known placeholder sample values for live preview */
|
||||
const SAMPLE_VALUES: Record<string, string> = {
|
||||
name: 'Jane Doe',
|
||||
phone: '+1 555 000 0000',
|
||||
shiftTitle: 'Ward 6 Canvass',
|
||||
shiftTime: '2:00 PM',
|
||||
shiftDate: 'Mar 15',
|
||||
shiftLocation: 'Community Centre',
|
||||
organizationName: 'Changemaker',
|
||||
};
|
||||
|
||||
/** Extract {var} names from a template string */
|
||||
function extractVars(template: string): string[] {
|
||||
const vars: string[] = [];
|
||||
const regex = /\{(\w+)\}/g;
|
||||
let m;
|
||||
while ((m = regex.exec(template)) !== null) {
|
||||
const v = m[1] as string;
|
||||
if (!vars.includes(v)) vars.push(v);
|
||||
}
|
||||
return vars;
|
||||
}
|
||||
|
||||
/** Calculate SMS segment count */
|
||||
function segmentCount(length: number): number {
|
||||
if (length === 0) return 0;
|
||||
if (length <= 160) return 1;
|
||||
return Math.ceil(length / 153);
|
||||
}
|
||||
|
||||
/** Render a live preview with sample substitutions */
|
||||
function renderPreview(template: string): string {
|
||||
return template.replace(/\{(\w+)\}/g, (match, key) => SAMPLE_VALUES[key] ?? match);
|
||||
}
|
||||
|
||||
export default function SmsTemplatesPage() {
|
||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||
const { message } = App.useApp();
|
||||
|
||||
const [templates, setTemplates] = useState<SmsMessageTemplate[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Filters
|
||||
const [search, setSearch] = useState('');
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
const [categoryFilter, setCategoryFilter] = useState<string | undefined>();
|
||||
const [favoritesOnly, setFavoritesOnly] = useState(false);
|
||||
|
||||
// Modal
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Live template text for character counter + variable extraction
|
||||
const [liveTemplate, setLiveTemplate] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader({ title: 'SMS Templates', subtitle: 'Manage reusable SMS message templates' });
|
||||
}, [setPageHeader]);
|
||||
|
||||
const fetchTemplates = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, string | number> = { page, limit: 50 };
|
||||
if (debouncedSearch) params.search = debouncedSearch;
|
||||
if (categoryFilter) params.category = categoryFilter;
|
||||
if (favoritesOnly) params.isFavorite = 'true';
|
||||
const { data } = await api.get<SmsPaginatedResponse<SmsMessageTemplate>>('/sms/templates', { params });
|
||||
setTemplates(data.items);
|
||||
setTotal(data.total);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, debouncedSearch, categoryFilter, favoritesOnly]);
|
||||
|
||||
useEffect(() => { fetchTemplates(); }, [fetchTemplates]);
|
||||
|
||||
// Reset to page 1 when filters change
|
||||
useEffect(() => { setPage(1); }, [debouncedSearch, categoryFilter, favoritesOnly]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingId(null);
|
||||
form.resetFields();
|
||||
setLiveTemplate('');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (record: SmsMessageTemplate) => {
|
||||
setEditingId(record.id);
|
||||
form.setFieldsValue({
|
||||
name: record.name,
|
||||
template: record.template,
|
||||
description: record.description || '',
|
||||
category: record.category || undefined,
|
||||
});
|
||||
setLiveTemplate(record.template);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openDuplicate = (record: SmsMessageTemplate) => {
|
||||
setEditingId(null);
|
||||
form.setFieldsValue({
|
||||
name: `${record.name} (copy)`,
|
||||
template: record.template,
|
||||
description: record.description || '',
|
||||
category: record.category || undefined,
|
||||
});
|
||||
setLiveTemplate(record.template);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async (values: { name: string; template: string; description?: string; category?: string }) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editingId) {
|
||||
await api.put(`/sms/templates/${editingId}`, values);
|
||||
message.success('Template updated');
|
||||
} else {
|
||||
await api.post('/sms/templates', values);
|
||||
message.success('Template created');
|
||||
}
|
||||
setModalOpen(false);
|
||||
form.resetFields();
|
||||
fetchTemplates();
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Save failed';
|
||||
message.error(msg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await api.delete(`/sms/templates/${id}`);
|
||||
message.success('Template deleted');
|
||||
fetchTemplates();
|
||||
} catch {
|
||||
message.error('Delete failed — system templates cannot be deleted');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleFavorite = async (id: string) => {
|
||||
try {
|
||||
await api.post(`/sms/templates/${id}/favorite`);
|
||||
fetchTemplates();
|
||||
} catch {
|
||||
message.error('Failed to toggle favorite');
|
||||
}
|
||||
};
|
||||
|
||||
// Computed values for modal
|
||||
const liveVars = useMemo(() => extractVars(liveTemplate), [liveTemplate]);
|
||||
const livePreview = useMemo(() => renderPreview(liveTemplate), [liveTemplate]);
|
||||
const charCount = liveTemplate.length;
|
||||
const segments = segmentCount(charCount);
|
||||
|
||||
const columns: ColumnsType<SmsMessageTemplate> = [
|
||||
{
|
||||
title: 'Name',
|
||||
dataIndex: 'name',
|
||||
ellipsis: true,
|
||||
render: (name, record) => (
|
||||
<Space>
|
||||
<Tooltip title={record.isFavorite ? 'Remove from favorites' : 'Add to favorites'}>
|
||||
<span style={{ cursor: 'pointer' }} onClick={() => handleToggleFavorite(record.id)}>
|
||||
{record.isFavorite ? <StarFilled style={{ color: '#faad14' }} /> : <StarOutlined style={{ color: 'rgba(255,255,255,0.3)' }} />}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span>{name}</span>
|
||||
{record.isSystem && <Tag color="geekblue">SYSTEM</Tag>}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Category',
|
||||
dataIndex: 'category',
|
||||
width: 120,
|
||||
render: (cat) => cat ? <Tag color={CATEGORY_COLORS[cat] || 'default'}>{cat}</Tag> : <Text type="secondary">-</Text>,
|
||||
},
|
||||
{
|
||||
title: 'Template',
|
||||
dataIndex: 'template',
|
||||
ellipsis: true,
|
||||
width: 300,
|
||||
render: (tmpl) => (
|
||||
<Text type="secondary" style={{ fontSize: 12, fontFamily: 'monospace' }}>
|
||||
{tmpl.length > 80 ? `${tmpl.slice(0, 80)}...` : tmpl}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Variables',
|
||||
width: 200,
|
||||
render: (_, record) => (
|
||||
<Space wrap size={2}>
|
||||
{(record.variables || []).map((v) => (
|
||||
<Tag key={v} style={{ fontSize: 11 }}>{`{${v}}`}</Tag>
|
||||
))}
|
||||
{(!record.variables || record.variables.length === 0) && <Text type="secondary">-</Text>}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Uses',
|
||||
dataIndex: 'usageCount',
|
||||
width: 70,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: 'Updated',
|
||||
dataIndex: 'updatedAt',
|
||||
width: 100,
|
||||
render: (d) => {
|
||||
const diff = Date.now() - new Date(d).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
width: 140,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Tooltip title="Edit">
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(record)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="Duplicate">
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => openDuplicate(record)} />
|
||||
</Tooltip>
|
||||
{!record.isSystem && (
|
||||
<Tooltip title="Delete">
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: 'Delete template?',
|
||||
content: `This will permanently delete "${record.name}".`,
|
||||
okText: 'Delete',
|
||||
okType: 'danger',
|
||||
onOk: () => handleDelete(record.id),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
New Template
|
||||
</Button>
|
||||
<Input.Search
|
||||
placeholder="Search templates..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Category"
|
||||
value={categoryFilter}
|
||||
onChange={setCategoryFilter}
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ value: 'notification', label: 'Notification' },
|
||||
{ value: 'campaign', label: 'Campaign' },
|
||||
{ value: 'custom', label: 'Custom' },
|
||||
]}
|
||||
/>
|
||||
<Space>
|
||||
<Switch size="small" checked={favoritesOnly} onChange={setFavoritesOnly} />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>Favorites only</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={templates}
|
||||
loading={loading}
|
||||
pagination={{ current: page, total, pageSize: 50, onChange: setPage, showSizeChanger: false }}
|
||||
size="middle"
|
||||
/>
|
||||
|
||||
{/* Create / Edit Modal */}
|
||||
<Modal
|
||||
title={editingId ? 'Edit Template' : 'New Template'}
|
||||
open={modalOpen}
|
||||
onCancel={() => { setModalOpen(false); setLiveTemplate(''); }}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={saving}
|
||||
width={640}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSave}>
|
||||
<Form.Item name="name" label="Name" rules={[{ required: true, message: 'Template name is required' }]}>
|
||||
<Input placeholder="e.g. shift-reminder-custom" maxLength={200} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="template"
|
||||
label="Message Template"
|
||||
rules={[{ required: true, message: 'Template body is required' }]}
|
||||
extra={
|
||||
<Space style={{ marginTop: 4 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{charCount} / 1,600 chars
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{segments} SMS segment{segments !== 1 ? 's' : ''}
|
||||
</Text>
|
||||
{charCount > 160 && (
|
||||
<Text type="warning" style={{ fontSize: 12 }}>
|
||||
Multi-part message (153 chars/segment)
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<TextArea
|
||||
rows={5}
|
||||
maxLength={1600}
|
||||
placeholder="Hi {name}, your shift {shiftTitle} is coming up on {shiftDate} at {shiftTime}."
|
||||
onChange={(e) => setLiveTemplate(e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* Live variables */}
|
||||
{liveVars.length > 0 && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>Detected variables: </Text>
|
||||
{liveVars.map((v) => (
|
||||
<Tag key={v} color="blue" style={{ fontSize: 11 }}>{`{${v}}`}</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live preview */}
|
||||
{liveTemplate && (
|
||||
<div style={{
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
}}>
|
||||
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 6 }}>Preview:</Text>
|
||||
<Text style={{ fontSize: 13, fontFamily: 'monospace', whiteSpace: 'pre-wrap' }}>
|
||||
{livePreview}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Form.Item name="description" label="Description">
|
||||
<TextArea rows={2} maxLength={500} placeholder="Internal notes about when to use this template" />
|
||||
</Form.Item>
|
||||
<Form.Item name="category" label="Category">
|
||||
<Select
|
||||
placeholder="Select category"
|
||||
allowClear
|
||||
options={[
|
||||
{ value: 'notification', label: 'Notification' },
|
||||
{ value: 'campaign', label: 'Campaign' },
|
||||
{ value: 'custom', label: 'Custom' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -106,6 +106,24 @@ export interface SmsConversation {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// --- Templates ---
|
||||
|
||||
export interface SmsMessageTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
template: string;
|
||||
description: string | null;
|
||||
category: string | null;
|
||||
isFavorite: boolean;
|
||||
usageCount: number;
|
||||
createdByUserId: string | null;
|
||||
createdByUser?: { id: string; name: string | null; email: string };
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
variables?: string[];
|
||||
isSystem?: boolean;
|
||||
}
|
||||
|
||||
// --- Setup ---
|
||||
|
||||
export interface SmsSetupStatus {
|
||||
|
||||
67
api/src/modules/sms/templates/sms-templates.routes.ts
Normal file
67
api/src/modules/sms/templates/sms-templates.routes.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate } from '../../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||
import { validate } from '../../../middleware/validate';
|
||||
import { smsTemplatesService } from './sms-templates.service';
|
||||
import { createSmsTemplateSchema, updateSmsTemplateSchema } from './sms-templates.schemas';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require authentication + SUPER_ADMIN or INFLUENCE_ADMIN
|
||||
router.use(authenticate, requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'));
|
||||
|
||||
// GET /api/sms/templates — list with search/filter/pagination
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const page = Math.max(1, Number(req.query.page) || 1);
|
||||
const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 50));
|
||||
const search = req.query.search as string | undefined;
|
||||
const category = req.query.category as string | undefined;
|
||||
const isFavorite = req.query.isFavorite as string | undefined;
|
||||
const result = await smsTemplatesService.findAll({ page, limit, search, category, isFavorite });
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /api/sms/templates/:id — single template with computed fields
|
||||
router.get('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const template = await smsTemplatesService.findById(req.params.id as string);
|
||||
if (!template) { res.status(404).json({ error: 'Template not found' }); return; }
|
||||
res.json(template);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /api/sms/templates — create template
|
||||
router.post('/', validate(createSmsTemplateSchema), async (req, res, next) => {
|
||||
try {
|
||||
const template = await smsTemplatesService.create(req.body, req.user!.id);
|
||||
res.status(201).json(template);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// PUT /api/sms/templates/:id — update template
|
||||
router.put('/:id', validate(updateSmsTemplateSchema), async (req, res, next) => {
|
||||
try {
|
||||
const template = await smsTemplatesService.update(req.params.id as string, req.body);
|
||||
res.json(template);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// DELETE /api/sms/templates/:id — delete (system-protected)
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
try {
|
||||
await smsTemplatesService.delete(req.params.id as string);
|
||||
res.json({ success: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /api/sms/templates/:id/favorite — toggle favorite
|
||||
router.post('/:id/favorite', async (req, res, next) => {
|
||||
try {
|
||||
const template = await smsTemplatesService.toggleFavorite(req.params.id as string);
|
||||
res.json(template);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
export const smsTemplatesRouter = router;
|
||||
28
api/src/modules/sms/templates/sms-templates.schemas.ts
Normal file
28
api/src/modules/sms/templates/sms-templates.schemas.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const listSmsTemplatesSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||
search: z.string().optional(),
|
||||
category: z.string().optional(),
|
||||
isFavorite: z.enum(['true', 'false']).optional(),
|
||||
});
|
||||
|
||||
export const createSmsTemplateSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
template: z.string().min(1).max(1600),
|
||||
description: z.string().max(500).nullable().optional(),
|
||||
category: z.string().max(50).nullable().optional(),
|
||||
isFavorite: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const updateSmsTemplateSchema = z.object({
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
template: z.string().min(1).max(1600).optional(),
|
||||
description: z.string().max(500).nullable().optional(),
|
||||
category: z.string().max(50).nullable().optional(),
|
||||
isFavorite: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type CreateSmsTemplateInput = z.infer<typeof createSmsTemplateSchema>;
|
||||
export type UpdateSmsTemplateInput = z.infer<typeof updateSmsTemplateSchema>;
|
||||
152
api/src/modules/sms/templates/sms-templates.service.ts
Normal file
152
api/src/modules/sms/templates/sms-templates.service.ts
Normal file
@ -0,0 +1,152 @@
|
||||
import { prisma } from '../../../config/database';
|
||||
import type { CreateSmsTemplateInput, UpdateSmsTemplateInput } from './sms-templates.schemas';
|
||||
|
||||
/** Names of templates seeded by the system — cannot be deleted */
|
||||
const SYSTEM_TEMPLATE_NAMES = ['shift-reminder', 'shift-signup-confirm', 'volunteer-welcome'];
|
||||
|
||||
/** Extract {var} placeholder names from a template string */
|
||||
function extractVariables(template: string): string[] {
|
||||
const vars: string[] = [];
|
||||
const regex = /\{(\w+)\}/g;
|
||||
let match;
|
||||
while ((match = regex.exec(template)) !== null) {
|
||||
if (!vars.includes(match[1])) vars.push(match[1]);
|
||||
}
|
||||
return vars;
|
||||
}
|
||||
|
||||
export const smsTemplatesService = {
|
||||
async findAll(params: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
category?: string;
|
||||
isFavorite?: string;
|
||||
}) {
|
||||
const page = params.page || 1;
|
||||
const limit = params.limit || 50;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
|
||||
if (params.search) {
|
||||
where.OR = [
|
||||
{ name: { contains: params.search, mode: 'insensitive' } },
|
||||
{ description: { contains: params.search, mode: 'insensitive' } },
|
||||
{ template: { contains: params.search, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
if (params.category) {
|
||||
where.category = params.category;
|
||||
}
|
||||
|
||||
if (params.isFavorite === 'true') {
|
||||
where.isFavorite = true;
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.smsMessageTemplate.findMany({
|
||||
where,
|
||||
orderBy: [{ isFavorite: 'desc' }, { name: 'asc' }],
|
||||
skip,
|
||||
take: limit,
|
||||
include: {
|
||||
createdByUser: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
}),
|
||||
prisma.smsMessageTemplate.count({ where }),
|
||||
]);
|
||||
|
||||
// Enrich with computed fields
|
||||
const enriched = items.map((t) => ({
|
||||
...t,
|
||||
variables: extractVariables(t.template),
|
||||
isSystem: SYSTEM_TEMPLATE_NAMES.includes(t.name) && t.createdByUserId === null,
|
||||
}));
|
||||
|
||||
return { items: enriched, total, page, limit };
|
||||
},
|
||||
|
||||
async findById(id: string) {
|
||||
const t = await prisma.smsMessageTemplate.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
createdByUser: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
});
|
||||
if (!t) return null;
|
||||
return {
|
||||
...t,
|
||||
variables: extractVariables(t.template),
|
||||
isSystem: SYSTEM_TEMPLATE_NAMES.includes(t.name) && t.createdByUserId === null,
|
||||
};
|
||||
},
|
||||
|
||||
async create(data: CreateSmsTemplateInput, userId: string) {
|
||||
// Check for duplicate name (SmsNotificationService looks up by name)
|
||||
const existing = await prisma.smsMessageTemplate.findFirst({
|
||||
where: { name: data.name },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) throw new Error('A template with this name already exists');
|
||||
|
||||
return prisma.smsMessageTemplate.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
template: data.template,
|
||||
description: data.description ?? null,
|
||||
category: data.category ?? null,
|
||||
isFavorite: data.isFavorite ?? false,
|
||||
createdByUserId: userId,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async update(id: string, data: UpdateSmsTemplateInput) {
|
||||
const existing = await prisma.smsMessageTemplate.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
if (!existing) throw new Error('Template not found');
|
||||
|
||||
// If renaming, check for duplicate
|
||||
if (data.name && data.name !== existing.name) {
|
||||
const dup = await prisma.smsMessageTemplate.findFirst({
|
||||
where: { name: data.name, NOT: { id } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (dup) throw new Error('A template with this name already exists');
|
||||
}
|
||||
|
||||
return prisma.smsMessageTemplate.update({ where: { id }, data });
|
||||
},
|
||||
|
||||
async delete(id: string) {
|
||||
const t = await prisma.smsMessageTemplate.findUnique({
|
||||
where: { id },
|
||||
select: { name: true, createdByUserId: true },
|
||||
});
|
||||
if (!t) throw new Error('Template not found');
|
||||
if (SYSTEM_TEMPLATE_NAMES.includes(t.name) && t.createdByUserId === null) {
|
||||
throw new Error('System templates cannot be deleted');
|
||||
}
|
||||
|
||||
await prisma.smsMessageTemplate.delete({ where: { id } });
|
||||
},
|
||||
|
||||
async toggleFavorite(id: string) {
|
||||
const t = await prisma.smsMessageTemplate.findUnique({
|
||||
where: { id },
|
||||
select: { isFavorite: true },
|
||||
});
|
||||
if (!t) throw new Error('Template not found');
|
||||
|
||||
return prisma.smsMessageTemplate.update({
|
||||
where: { id },
|
||||
data: { isFavorite: !t.isFavorite },
|
||||
});
|
||||
},
|
||||
|
||||
extractVariables,
|
||||
};
|
||||
@ -83,6 +83,7 @@ import { smsConversationsRouter } from './modules/sms/conversations/sms-conversa
|
||||
import { smsMessagesRouter } from './modules/sms/messages/sms-messages.routes';
|
||||
import { smsDeviceRouter } from './modules/sms/device/sms-device.routes';
|
||||
import { smsSetupRouter } from './modules/sms/setup/sms-setup.routes';
|
||||
import { smsTemplatesRouter } from './modules/sms/templates/sms-templates.routes';
|
||||
import { smsQueueService } from './services/sms-queue.service';
|
||||
import { smsResponseSyncService } from './services/sms-response-sync.service';
|
||||
import { smsDeviceMonitorService } from './services/sms-device-monitor.service';
|
||||
@ -246,6 +247,7 @@ app.use('/api/sms/campaigns', smsCampaignsRouter); // SMS campaign C
|
||||
app.use('/api/sms/conversations', smsConversationsRouter); // SMS conversation threads (ADMIN roles)
|
||||
app.use('/api/sms/messages', smsMessagesRouter); // SMS message history + ad-hoc send (ADMIN roles)
|
||||
app.use('/api/sms/device', smsDeviceRouter); // SMS device status + sync trigger (ADMIN roles)
|
||||
app.use('/api/sms/templates', smsTemplatesRouter); // SMS template CRUD (ADMIN roles)
|
||||
app.use('/api/sms/setup', smsSetupRouter); // SMS setup wizard (SUPER_ADMIN only)
|
||||
app.use('/api/profile', profilePublicRouter); // Self-service contact profile (no auth, token-based)
|
||||
app.use('/api/people', peopleRouter); // People CRM aggregation (ADMIN roles)
|
||||
|
||||
@ -91,6 +91,14 @@ LISTMONK_API_TOKEN={{secrets.listmonkApiToken}}
|
||||
LISTMONK_ADMIN_USER=v2-api
|
||||
LISTMONK_ADMIN_PASSWORD={{secrets.listmonkApiToken}}
|
||||
LISTMONK_PROXY_PORT=9002
|
||||
LISTMONK_WEBHOOK_SECRET=
|
||||
LISTMONK_DB_PORT=5434
|
||||
LISTMONK_SMTP_HOST={{containerPrefix}}-mailhog
|
||||
LISTMONK_SMTP_PORT=1025
|
||||
LISTMONK_SMTP_USER=
|
||||
LISTMONK_SMTP_PASSWORD=
|
||||
LISTMONK_SMTP_TLS_TYPE=none
|
||||
LISTMONK_SMTP_FROM={{name}} <noreply@{{domain}}>
|
||||
|
||||
# Media
|
||||
{{#if enableMedia}}
|
||||
@ -102,6 +110,13 @@ MEDIA_API_PORT=4100
|
||||
MEDIA_ROOT=/media/local
|
||||
MEDIA_UPLOADS=/media/uploads
|
||||
MAX_UPLOAD_SIZE_GB=10
|
||||
PUBLIC_MEDIA_PORT=3100
|
||||
VIDEO_PLAYER_DEBUG=false
|
||||
VIDEO_ANALYTICS_RETENTION_DAYS=90
|
||||
VIDEO_ANALYTICS_IP_HASHING_ENABLED=true
|
||||
VIDEO_SCHEDULE_DEFAULT_TIMEZONE=UTC
|
||||
VIDEO_SCHEDULE_NOTIFICATION_ENABLED=true
|
||||
VIDEO_PREVIEW_LINK_EXPIRY_HOURS=24
|
||||
|
||||
# NAR Data
|
||||
NAR_DATA_DIR=/data
|
||||
@ -109,21 +124,35 @@ NAR_DATA_DIR=/data
|
||||
# Platform Service URLs (used for health checks)
|
||||
MINI_QR_URL=http://{{containerPrefix}}-mini-qr:8080
|
||||
EXCALIDRAW_URL=http://{{containerPrefix}}-excalidraw:80
|
||||
EXCALIDRAW_WS_URL=wss://draw.{{domain}}
|
||||
HOMEPAGE_URL=http://{{containerPrefix}}-homepage:3000
|
||||
VAULTWARDEN_URL=http://{{containerPrefix}}-vaultwarden:80
|
||||
VAULTWARDEN_ADMIN_TOKEN={{secrets.vaultwardenAdminToken}}
|
||||
VAULTWARDEN_DOMAIN=https://vault.{{domain}}
|
||||
VAULTWARDEN_SIGNUPS_ALLOWED=false
|
||||
VAULTWARDEN_WEBSOCKET_ENABLED=true
|
||||
VAULTWARDEN_SMTP_SECURITY=off
|
||||
|
||||
# Geocoding
|
||||
MAPBOX_API_KEY=
|
||||
GOOGLE_MAPS_API_KEY=
|
||||
GOOGLE_MAPS_ENABLED=false
|
||||
GEOCODING_RATE_LIMIT_MS=1100
|
||||
GEOCODING_CACHE_ENABLED=true
|
||||
GEOCODING_CACHE_TTL_HOURS=24
|
||||
GEOCODING_PARALLEL_ENABLED=true
|
||||
GEOCODING_BATCH_SIZE=10
|
||||
BULK_GEOCODE_ENABLED=true
|
||||
BULK_GEOCODE_MAX_BATCH=5000
|
||||
|
||||
# Represent API
|
||||
REPRESENT_API_URL=https://represent.opennorth.ca
|
||||
|
||||
# Overpass / Area Import
|
||||
OVERPASS_API_URL=https://overpass-api.de/api/interpreter
|
||||
OVERPASS_MIN_DELAY_MS=30000
|
||||
AREA_IMPORT_MAX_GRID_POINTS=500
|
||||
|
||||
# Pangolin Tunnel
|
||||
PANGOLIN_API_URL=
|
||||
PANGOLIN_API_KEY=
|
||||
@ -205,18 +234,42 @@ GRAFANA_ADMIN_PASSWORD={{secrets.grafanaAdminPassword}}
|
||||
GRAFANA_ROOT_URL=https://grafana.{{domain}}
|
||||
PROMETHEUS_PORT=9090
|
||||
GRAFANA_PORT=3000
|
||||
CADVISOR_PORT=8086
|
||||
NODE_EXPORTER_PORT=9100
|
||||
REDIS_EXPORTER_PORT=9121
|
||||
ALERTMANAGER_PORT=9093
|
||||
ALERTMANAGER_EMBED_PORT={{math ports.embed "+" 16}}
|
||||
GOTIFY_PORT=8889
|
||||
GOTIFY_ADMIN_USER=admin
|
||||
GOTIFY_ADMIN_PASSWORD=admin
|
||||
|
||||
# MkDocs
|
||||
MKDOCS_PORT={{math ports.embed "+" 8}}
|
||||
MKDOCS_SITE_SERVER_PORT={{math ports.embed "+" 14}}
|
||||
MKDOCS_PREVIEW_URL=http://{{containerPrefix}}-mkdocs:8000
|
||||
MKDOCS_DOCS_PATH=/mkdocs/docs
|
||||
CODE_SERVER_PORT={{math ports.embed "+" 7}}
|
||||
CODE_SERVER_URL=http://{{containerPrefix}}-code-server:8080
|
||||
USER_NAME=coder
|
||||
BASE_DOMAIN=https://{{domain}}
|
||||
|
||||
# Gitea
|
||||
GITEA_URL=http://{{containerPrefix}}-gitea:3000
|
||||
GITEA_SSH_PORT=2222
|
||||
GITEA_DB_TYPE=mysql
|
||||
GITEA_DB_HOST={{containerPrefix}}-gitea-db:3306
|
||||
GITEA_DB_NAME=gitea
|
||||
GITEA_DB_USER=gitea
|
||||
GITEA_DB_PASSWD={{secrets.giteaAdminPassword}}
|
||||
GITEA_DB_ROOT_PASSWORD={{secrets.giteaAdminPassword}}
|
||||
GITEA_ROOT_URL=https://git.{{domain}}
|
||||
GITEA_DOMAIN=git.{{domain}}
|
||||
GITEA_COMMENTS_ENABLED=false
|
||||
GITEA_API_TOKEN=
|
||||
GITEA_COMMENTS_REPO_OWNER=
|
||||
GITEA_COMMENTS_REPO_NAME=docs-comments
|
||||
GITEA_OAUTH_CLIENT_ID=
|
||||
GITEA_OAUTH_CLIENT_SECRET=
|
||||
|
||||
# n8n
|
||||
N8N_HOST=n8n.{{domain}}
|
||||
@ -224,12 +277,17 @@ N8N_URL=http://{{containerPrefix}}-n8n:5678
|
||||
N8N_ENCRYPTION_KEY={{secrets.n8nEncryptionKey}}
|
||||
N8N_USER_EMAIL={{secrets.adminEmail}}
|
||||
N8N_USER_PASSWORD={{secrets.nocodbAdminPassword}}
|
||||
GENERIC_TIMEZONE=UTC
|
||||
|
||||
# MailHog
|
||||
MAILHOG_URL=http://{{containerPrefix}}-mailhog:8025
|
||||
MAILHOG_SMTP_PORT=1025
|
||||
MAILHOG_WEB_PORT=8025
|
||||
|
||||
# Homepage
|
||||
HOMEPAGE_PORT=3010
|
||||
HOMEPAGE_VAR_BASE_URL=http://localhost
|
||||
|
||||
# Dev Tools
|
||||
{{#if enableDevTools}}
|
||||
ENABLE_DEV_TOOLS=true
|
||||
@ -251,6 +309,11 @@ VITE_MKDOCS_URL=http://{{containerPrefix}}-mkdocs:8000
|
||||
VITE_MEDIA_API_URL=http://{{containerPrefix}}-media-api:4100
|
||||
{{/if}}
|
||||
|
||||
# Bunker Ops (Fleet Management)
|
||||
INSTANCE_LABEL={{slug}}
|
||||
BUNKER_OPS_ENABLED=false
|
||||
BUNKER_OPS_REMOTE_WRITE_URL=
|
||||
|
||||
# Embed proxy ports (nginx proxy for iframe embedding in admin GUI)
|
||||
NOCODB_EMBED_PORT={{math ports.embed "+" 0}}
|
||||
N8N_EMBED_PORT={{math ports.embed "+" 1}}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user