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 SmsContactsPage from '@/pages/sms/SmsContactsPage';
|
||||||
import SmsCampaignsPage from '@/pages/sms/SmsCampaignsPage';
|
import SmsCampaignsPage from '@/pages/sms/SmsCampaignsPage';
|
||||||
import SmsConversationsPage from '@/pages/sms/SmsConversationsPage';
|
import SmsConversationsPage from '@/pages/sms/SmsConversationsPage';
|
||||||
|
import SmsTemplatesPage from '@/pages/sms/SmsTemplatesPage';
|
||||||
import SmsSetupPage from '@/pages/sms/SmsSetupPage';
|
import SmsSetupPage from '@/pages/sms/SmsSetupPage';
|
||||||
import PeoplePage from '@/pages/PeoplePage';
|
import PeoplePage from '@/pages/PeoplePage';
|
||||||
import ContactProfilePage from '@/pages/public/ContactProfilePage';
|
import ContactProfilePage from '@/pages/public/ContactProfilePage';
|
||||||
@ -615,6 +616,14 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="sms/templates"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
||||||
|
<SmsTemplatesPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="settings"
|
path="settings"
|
||||||
element={
|
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/contacts', icon: <TeamOutlined />, label: 'SMS Contacts' },
|
||||||
{ key: '/app/sms/campaigns', icon: <SendOutlined />, label: 'SMS Campaigns' },
|
{ key: '/app/sms/campaigns', icon: <SendOutlined />, label: 'SMS Campaigns' },
|
||||||
{ key: '/app/sms/conversations', icon: <MessageOutlined />, label: 'SMS Threads' },
|
{ key: '/app/sms/conversations', icon: <MessageOutlined />, label: 'SMS Threads' },
|
||||||
|
{ key: '/app/sms/templates', icon: <FileTextOutlined />, label: 'SMS Templates' },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
items.push({
|
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;
|
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 ---
|
// --- Setup ---
|
||||||
|
|
||||||
export interface SmsSetupStatus {
|
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 { smsMessagesRouter } from './modules/sms/messages/sms-messages.routes';
|
||||||
import { smsDeviceRouter } from './modules/sms/device/sms-device.routes';
|
import { smsDeviceRouter } from './modules/sms/device/sms-device.routes';
|
||||||
import { smsSetupRouter } from './modules/sms/setup/sms-setup.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 { smsQueueService } from './services/sms-queue.service';
|
||||||
import { smsResponseSyncService } from './services/sms-response-sync.service';
|
import { smsResponseSyncService } from './services/sms-response-sync.service';
|
||||||
import { smsDeviceMonitorService } from './services/sms-device-monitor.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/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/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/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/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/profile', profilePublicRouter); // Self-service contact profile (no auth, token-based)
|
||||||
app.use('/api/people', peopleRouter); // People CRM aggregation (ADMIN roles)
|
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_USER=v2-api
|
||||||
LISTMONK_ADMIN_PASSWORD={{secrets.listmonkApiToken}}
|
LISTMONK_ADMIN_PASSWORD={{secrets.listmonkApiToken}}
|
||||||
LISTMONK_PROXY_PORT=9002
|
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
|
# Media
|
||||||
{{#if enableMedia}}
|
{{#if enableMedia}}
|
||||||
@ -102,6 +110,13 @@ MEDIA_API_PORT=4100
|
|||||||
MEDIA_ROOT=/media/local
|
MEDIA_ROOT=/media/local
|
||||||
MEDIA_UPLOADS=/media/uploads
|
MEDIA_UPLOADS=/media/uploads
|
||||||
MAX_UPLOAD_SIZE_GB=10
|
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
|
||||||
NAR_DATA_DIR=/data
|
NAR_DATA_DIR=/data
|
||||||
@ -109,21 +124,35 @@ NAR_DATA_DIR=/data
|
|||||||
# Platform Service URLs (used for health checks)
|
# Platform Service URLs (used for health checks)
|
||||||
MINI_QR_URL=http://{{containerPrefix}}-mini-qr:8080
|
MINI_QR_URL=http://{{containerPrefix}}-mini-qr:8080
|
||||||
EXCALIDRAW_URL=http://{{containerPrefix}}-excalidraw:80
|
EXCALIDRAW_URL=http://{{containerPrefix}}-excalidraw:80
|
||||||
|
EXCALIDRAW_WS_URL=wss://draw.{{domain}}
|
||||||
HOMEPAGE_URL=http://{{containerPrefix}}-homepage:3000
|
HOMEPAGE_URL=http://{{containerPrefix}}-homepage:3000
|
||||||
VAULTWARDEN_URL=http://{{containerPrefix}}-vaultwarden:80
|
VAULTWARDEN_URL=http://{{containerPrefix}}-vaultwarden:80
|
||||||
VAULTWARDEN_ADMIN_TOKEN={{secrets.vaultwardenAdminToken}}
|
VAULTWARDEN_ADMIN_TOKEN={{secrets.vaultwardenAdminToken}}
|
||||||
VAULTWARDEN_DOMAIN=https://vault.{{domain}}
|
VAULTWARDEN_DOMAIN=https://vault.{{domain}}
|
||||||
VAULTWARDEN_SIGNUPS_ALLOWED=false
|
VAULTWARDEN_SIGNUPS_ALLOWED=false
|
||||||
VAULTWARDEN_WEBSOCKET_ENABLED=true
|
VAULTWARDEN_WEBSOCKET_ENABLED=true
|
||||||
|
VAULTWARDEN_SMTP_SECURITY=off
|
||||||
|
|
||||||
# Geocoding
|
# Geocoding
|
||||||
MAPBOX_API_KEY=
|
MAPBOX_API_KEY=
|
||||||
GOOGLE_MAPS_API_KEY=
|
GOOGLE_MAPS_API_KEY=
|
||||||
GOOGLE_MAPS_ENABLED=false
|
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
|
||||||
REPRESENT_API_URL=https://represent.opennorth.ca
|
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 Tunnel
|
||||||
PANGOLIN_API_URL=
|
PANGOLIN_API_URL=
|
||||||
PANGOLIN_API_KEY=
|
PANGOLIN_API_KEY=
|
||||||
@ -205,18 +234,42 @@ GRAFANA_ADMIN_PASSWORD={{secrets.grafanaAdminPassword}}
|
|||||||
GRAFANA_ROOT_URL=https://grafana.{{domain}}
|
GRAFANA_ROOT_URL=https://grafana.{{domain}}
|
||||||
PROMETHEUS_PORT=9090
|
PROMETHEUS_PORT=9090
|
||||||
GRAFANA_PORT=3000
|
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
|
||||||
MKDOCS_PORT={{math ports.embed "+" 8}}
|
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_PORT={{math ports.embed "+" 7}}
|
||||||
|
CODE_SERVER_URL=http://{{containerPrefix}}-code-server:8080
|
||||||
|
USER_NAME=coder
|
||||||
BASE_DOMAIN=https://{{domain}}
|
BASE_DOMAIN=https://{{domain}}
|
||||||
|
|
||||||
# Gitea
|
# Gitea
|
||||||
GITEA_URL=http://{{containerPrefix}}-gitea:3000
|
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_PASSWD={{secrets.giteaAdminPassword}}
|
||||||
GITEA_DB_ROOT_PASSWORD={{secrets.giteaAdminPassword}}
|
GITEA_DB_ROOT_PASSWORD={{secrets.giteaAdminPassword}}
|
||||||
GITEA_ROOT_URL=https://git.{{domain}}
|
GITEA_ROOT_URL=https://git.{{domain}}
|
||||||
GITEA_DOMAIN=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
|
||||||
N8N_HOST=n8n.{{domain}}
|
N8N_HOST=n8n.{{domain}}
|
||||||
@ -224,12 +277,17 @@ N8N_URL=http://{{containerPrefix}}-n8n:5678
|
|||||||
N8N_ENCRYPTION_KEY={{secrets.n8nEncryptionKey}}
|
N8N_ENCRYPTION_KEY={{secrets.n8nEncryptionKey}}
|
||||||
N8N_USER_EMAIL={{secrets.adminEmail}}
|
N8N_USER_EMAIL={{secrets.adminEmail}}
|
||||||
N8N_USER_PASSWORD={{secrets.nocodbAdminPassword}}
|
N8N_USER_PASSWORD={{secrets.nocodbAdminPassword}}
|
||||||
|
GENERIC_TIMEZONE=UTC
|
||||||
|
|
||||||
# MailHog
|
# MailHog
|
||||||
MAILHOG_URL=http://{{containerPrefix}}-mailhog:8025
|
MAILHOG_URL=http://{{containerPrefix}}-mailhog:8025
|
||||||
MAILHOG_SMTP_PORT=1025
|
MAILHOG_SMTP_PORT=1025
|
||||||
MAILHOG_WEB_PORT=8025
|
MAILHOG_WEB_PORT=8025
|
||||||
|
|
||||||
|
# Homepage
|
||||||
|
HOMEPAGE_PORT=3010
|
||||||
|
HOMEPAGE_VAR_BASE_URL=http://localhost
|
||||||
|
|
||||||
# Dev Tools
|
# Dev Tools
|
||||||
{{#if enableDevTools}}
|
{{#if enableDevTools}}
|
||||||
ENABLE_DEV_TOOLS=true
|
ENABLE_DEV_TOOLS=true
|
||||||
@ -251,6 +309,11 @@ VITE_MKDOCS_URL=http://{{containerPrefix}}-mkdocs:8000
|
|||||||
VITE_MEDIA_API_URL=http://{{containerPrefix}}-media-api:4100
|
VITE_MEDIA_API_URL=http://{{containerPrefix}}-media-api:4100
|
||||||
{{/if}}
|
{{/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)
|
# Embed proxy ports (nginx proxy for iframe embedding in admin GUI)
|
||||||
NOCODB_EMBED_PORT={{math ports.embed "+" 0}}
|
NOCODB_EMBED_PORT={{math ports.embed "+" 0}}
|
||||||
N8N_EMBED_PORT={{math ports.embed "+" 1}}
|
N8N_EMBED_PORT={{math ports.embed "+" 1}}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user