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:
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)
|
||||
|
||||
Reference in New Issue
Block a user