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:
bunker-admin 2026-02-27 20:15:13 -07:00
parent ce590ccae8
commit 18997da3eb
9 changed files with 746 additions and 0 deletions

View File

@ -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={

View File

@ -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({

View 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>
</>
);
}

View File

@ -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 {

View 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;

View 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>;

View 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,
};

View File

@ -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)

View File

@ -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}}