Tonne of things

This commit is contained in:
2026-03-08 18:11:26 -06:00
parent 3f35e4b18d
commit 76b87d9f3d
208 changed files with 4280 additions and 1416 deletions

View File

@@ -10,6 +10,12 @@ export function requireRole(...roles: UserRole[]) {
// Check multi-role array (falls back to single role via auth middleware)
const userRoles = req.user.roles || [req.user.role];
// SUPER_ADMIN bypasses all role checks
if (userRoles.includes(UserRole.SUPER_ADMIN)) {
return next();
}
const hasRole = userRoles.some(r => roles.includes(r));
if (!hasRole) {

View File

@@ -215,6 +215,18 @@ export const authService = {
throw new AppError(401, 'Refresh token not found', 'INVALID_REFRESH_TOKEN');
}
// Check user status — banned/inactive users must not get new tokens
if (stored.user.status !== UserStatus.ACTIVE) {
await prisma.refreshToken.delete({ where: { id: stored.id } });
throw new AppError(401, 'Account is not active', 'ACCOUNT_INACTIVE');
}
// Check account expiry
if (stored.user.expiresAt && stored.user.expiresAt < new Date()) {
await prisma.refreshToken.delete({ where: { id: stored.id } });
throw new AppError(401, 'Account has expired', 'ACCOUNT_EXPIRED');
}
if (stored.expiresAt < new Date()) {
await prisma.refreshToken.delete({ where: { id: stored.id } });
throw new AppError(401, 'Refresh token expired', 'REFRESH_TOKEN_EXPIRED');

View File

@@ -1,6 +1,7 @@
import { Router } from 'express';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { EVENTS_ROLES } from '../../utils/roles';
import { validate } from '../../middleware/validate';
import { adminCalendarService } from './admin-calendar.service';
import { createAdminViewSchema, updateAdminViewSchema } from './admin-calendar.schemas';
@@ -9,7 +10,7 @@ import { dateRangeQuerySchema } from './shared-calendar.schemas';
const router = Router();
router.use(authenticate);
router.use(requireRole('SUPER_ADMIN', 'MAP_ADMIN'));
router.use(requireRole(...EVENTS_ROLES));
// List admin calendar views
router.get('/', async (req, res, next) => {

View File

@@ -1,4 +1,6 @@
import crypto from 'crypto';
import dns from 'dns/promises';
import { URL } from 'url';
import {
CalendarLayerType,
CalendarVisibility,
@@ -20,6 +22,71 @@ const FETCH_TIMEOUT_MS = 30_000;
const FETCH_MAX_BYTES = 5 * 1024 * 1024; // 5MB
const MATERIALIZE_MONTHS = 3;
// SSRF protection: block requests to private/reserved IP ranges and internal hosts
const BLOCKED_HOSTNAMES = new Set([
'localhost', '0.0.0.0', '[::]', '[::1]',
// Common Docker internal hostnames
'changemaker-v2-postgres', 'redis-changemaker', 'changemaker-v2-api',
'changemaker-v2-admin', 'changemaker-v2-nginx', 'changemaker-v2-nocodb',
'listmonk-app', 'listmonk-db', 'mailhog-changemaker',
]);
function isPrivateIP(ip: string): boolean {
// IPv4 private/reserved ranges
if (ip.startsWith('10.')) return true;
if (ip.startsWith('127.')) return true;
if (ip.startsWith('169.254.')) return true; // Link-local / cloud metadata
if (ip.startsWith('172.')) {
const second = parseInt(ip.split('.')[1], 10);
if (second >= 16 && second <= 31) return true;
}
if (ip.startsWith('192.168.')) return true;
if (ip === '0.0.0.0') return true;
// IPv6 private/reserved
if (ip === '::1' || ip === '::') return true;
if (ip.startsWith('fc') || ip.startsWith('fd')) return true; // ULA
if (ip.startsWith('fe80')) return true; // Link-local
return false;
}
async function validateFeedUrl(rawUrl: string): Promise<void> {
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
throw new AppError(400, 'Invalid URL format', 'INVALID_FEED_URL');
}
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new AppError(400, 'Only http and https URLs are allowed', 'INVALID_FEED_URL');
}
const hostname = parsed.hostname.toLowerCase();
if (BLOCKED_HOSTNAMES.has(hostname)) {
throw new AppError(400, 'This URL is not allowed', 'BLOCKED_FEED_URL');
}
// Resolve DNS and check all resolved IPs
try {
const addrs4 = await dns.resolve4(hostname).catch(() => [] as string[]);
const addrs6 = await dns.resolve6(hostname).catch(() => [] as string[]);
const allAddrs = [...addrs4, ...addrs6];
if (allAddrs.length === 0) {
throw new AppError(400, 'Could not resolve feed URL hostname', 'FEED_URL_UNREACHABLE');
}
for (const addr of allAddrs) {
if (isPrivateIP(addr)) {
throw new AppError(400, 'This URL is not allowed', 'BLOCKED_FEED_URL');
}
}
} catch (err) {
if (err instanceof AppError) throw err;
throw new AppError(400, 'Could not resolve feed URL hostname', 'FEED_URL_UNREACHABLE');
}
}
// Map CalendarFeedInterval to milliseconds
const INTERVAL_MS: Record<CalendarFeedInterval, number> = {
FIFTEEN_MIN: 15 * 60 * 1000,
@@ -42,6 +109,9 @@ export const feedService = {
},
async createFeed(userId: string, data: CreateFeedInput) {
// SSRF protection: validate URL before making any request
await validateFeedUrl(data.url);
// Validate URL is reachable
try {
const controller = new AbortController();
@@ -49,10 +119,10 @@ export const feedService = {
const res = await fetch(data.url, {
method: 'HEAD',
signal: controller.signal,
redirect: 'follow',
redirect: 'manual', // Don't follow redirects (prevent SSRF via open redirects)
});
clearTimeout(timeout);
if (!res.ok && res.status !== 405) {
if (!res.ok && res.status !== 405 && !(res.status >= 300 && res.status < 400)) {
throw new AppError(400, `Feed URL returned status ${res.status}`, 'FEED_URL_UNREACHABLE');
}
} catch (err) {
@@ -114,7 +184,11 @@ export const feedService = {
data: { name: data.name },
});
}
if (data.url !== undefined) updateData.url = data.url;
if (data.url !== undefined) {
// SSRF protection: validate new URL before saving
await validateFeedUrl(data.url);
updateData.url = data.url;
}
if (data.refreshInterval !== undefined) {
updateData.refreshInterval = data.refreshInterval as CalendarFeedInterval;
}
@@ -148,9 +222,12 @@ export const feedService = {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
// Re-validate the stored URL in case it was changed outside the update flow
await validateFeedUrl(feed.url);
const response = await fetch(feed.url, {
signal: controller.signal,
redirect: 'follow',
redirect: 'manual', // Don't follow redirects (SSRF protection)
headers: { 'User-Agent': 'Changemaker-Calendar/1.0' },
});
clearTimeout(timeout);

View File

@@ -1,6 +1,7 @@
import { Router, Request, Response, NextFunction } from 'express';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { ADMIN_ROLES } from '../../utils/roles';
import {
getDashboardSummary,
getSystemInfo,
@@ -25,7 +26,7 @@ import {
const router = Router();
router.use(authenticate);
router.use(requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'));
router.use(requireRole(...ADMIN_ROLES));
// GET /api/dashboard/summary — platform counts
router.get('/summary', async (_req: Request, res: Response, next: NextFunction) => {

View File

@@ -1,13 +1,11 @@
import { Router } from 'express';
import { UserRole } from '@prisma/client';
import { validate } from '../../middleware/validate';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { docsAnalyticsRateLimit } from '../../middleware/rate-limit';
import { docsAnalyticsService } from './docs-analytics.service';
import { trackPageViewSchema, analyticsQuerySchema } from './docs-analytics.schemas';
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
import { CONTENT_ROLES } from '../../utils/roles';
// --- Public Router (no auth) ---
export const docsAnalyticsPublicRouter = Router();
@@ -47,7 +45,7 @@ docsAnalyticsPublicRouter.post(
// --- Admin Router (auth required) ---
export const docsAnalyticsAdminRouter = Router();
docsAnalyticsAdminRouter.use(authenticate);
docsAnalyticsAdminRouter.use(requireRole(...ADMIN_ROLES));
docsAnalyticsAdminRouter.use(requireRole(...CONTENT_ROLES));
// GET /api/docs-analytics/summary?days=30
docsAnalyticsAdminRouter.get(

View File

@@ -1,5 +1,4 @@
import { Router } from 'express';
import { UserRole } from '@prisma/client';
import { validate } from '../../middleware/validate';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
@@ -19,8 +18,7 @@ import {
moderationQuerySchema,
} from './docs-comments.schemas';
import { env } from '../../config/env';
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
import { CONTENT_ROLES } from '../../utils/roles';
// --- Public Router (CORS override for docs origin) ---
export const docsCommentsPublicRouter = Router();
@@ -195,7 +193,7 @@ docsCommentsPublicRouter.get('/oauth/config', async (_req, res) => {
// --- Admin Router (auth required) ---
export const docsCommentsAdminRouter = Router();
docsCommentsAdminRouter.use(authenticate);
docsCommentsAdminRouter.use(requireRole(...ADMIN_ROLES));
docsCommentsAdminRouter.use(requireRole(...CONTENT_ROLES));
// GET /api/docs-comments/moderation?status=PENDING&page=1
docsCommentsAdminRouter.get(

View File

@@ -0,0 +1,351 @@
import { IncomingMessage } from 'http';
import crypto from 'crypto';
import type WebSocket from 'ws';
import jwt from 'jsonwebtoken';
import { UserRole } from '@prisma/client';
import * as Y from 'yjs';
import { Hocuspocus } from '@hocuspocus/server';
import type { Extension } from '@hocuspocus/server';
import { env } from '../../config/env';
import { prisma } from '../../config/database';
import { redis } from '../../config/redis';
import { logger } from '../../utils/logger';
import { CONTENT_ROLES } from '../../utils/roles';
import { docsFilesService } from './docs-files.service';
// --- Metrics ---
import { Gauge } from 'prom-client';
const collabConnections = new Gauge({
name: 'cm_docs_collab_connections',
help: 'Number of active docs collaboration WebSocket connections',
});
const collabDocuments = new Gauge({
name: 'cm_docs_collab_documents',
help: 'Number of active collaboratively-edited documents',
});
// --- Connection tracking ---
const connectionsPerUser = new Map<string, number>();
const MAX_CONNECTIONS_PER_USER = 5;
const MAX_CONCURRENT_DOCUMENTS = 50;
const MAX_DOC_SIZE_BYTES = 5 * 1024 * 1024; // 5MB
// --- JWT token payload ---
interface TokenPayload {
id: string;
email: string;
role: UserRole;
roles?: UserRole[];
}
// --- Deterministic color from user ID ---
const COLLAB_COLORS = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7',
'#DDA0DD', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E9',
'#F0B27A', '#82E0AA', '#F1948A', '#AED6F1', '#D7BDE2',
];
function getUserColor(userId: string): string {
let hash = 0;
for (let i = 0; i < userId.length; i++) {
hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0;
}
return COLLAB_COLORS[Math.abs(hash) % COLLAB_COLORS.length];
}
// --- Redis cache key helper ---
function fileCacheKey(relativePath: string): string {
const hash = crypto.createHash('sha256').update(relativePath).digest('hex').substring(0, 16);
return `DOCS_CACHE:file:${hash}`;
}
// --- Hocuspocus extension with hooks ---
const docsExtension: Extension = {
priority: 1,
async onAuthenticate(data) {
const { token, documentName } = data;
if (!token) {
throw new Error('Authentication required');
}
// Verify JWT
let payload: TokenPayload;
try {
payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as TokenPayload;
} catch {
throw new Error('Invalid or expired token');
}
const roles = payload.roles || [payload.role];
// Check CONTENT_ROLES for write access
const hasWriteAccess = roles.some(r => (CONTENT_ROLES as string[]).includes(r));
if (!hasWriteAccess) {
// Allow read-only for any authenticated non-TEMP user
if (roles.includes(UserRole.TEMP)) {
throw new Error('TEMP users cannot access collaboration');
}
data.connectionConfig.readOnly = true;
}
// Validate document path (prevent path traversal)
try {
docsFilesService.safeResolve(documentName);
} catch {
throw new Error('Invalid document path');
}
// Rate limit: max connections per user
const currentCount = connectionsPerUser.get(payload.id) || 0;
if (currentCount >= MAX_CONNECTIONS_PER_USER) {
throw new Error('Too many concurrent connections');
}
// Rate limit: max concurrent documents
if (hocuspocus.getDocumentsCount() >= MAX_CONCURRENT_DOCUMENTS) {
// Only block if this is a NEW document (not joining existing)
if (!hocuspocus.documents.has(documentName)) {
throw new Error('Too many concurrent documents');
}
}
// Track connection
connectionsPerUser.set(payload.id, currentCount + 1);
// Look up user name from DB
let userName = payload.email.split('@')[0];
try {
const user = await prisma.user.findUnique({
where: { id: payload.id },
select: { name: true },
});
if (user?.name) userName = user.name;
} catch {
// Fall back to email prefix
}
// Set context for use in other hooks
data.context.user = {
id: payload.id,
email: payload.email,
name: userName,
color: getUserColor(payload.id),
roles,
};
logger.info(`Docs collab: ${userName} connected to ${documentName}`);
},
async onLoadDocument(data) {
const { document, documentName } = data;
// Try loading persisted Y.Doc state from DB
try {
const stored = await prisma.docCollabState.findUnique({
where: { documentId: documentName },
});
if (stored) {
const stateVector = new Uint8Array(stored.state);
Y.applyUpdate(document, stateVector);
logger.debug(`Docs collab: loaded persisted state for ${documentName} (${stateVector.length} bytes)`);
return;
}
} catch (err) {
logger.warn(`Docs collab: failed to load persisted state for ${documentName}`, err);
}
// No persisted state — seed from disk
try {
const content = await docsFilesService.readFileContent(documentName);
const yText = document.getText('content');
// Only seed if the Y.Text is empty (first load)
if (yText.length === 0) {
yText.insert(0, content);
}
logger.debug(`Docs collab: seeded from disk for ${documentName} (${content.length} chars)`);
} catch (err) {
logger.warn(`Docs collab: failed to read file from disk for ${documentName}`, err);
// File might not exist yet — that's OK, start with empty doc
}
},
async onChange(data) {
const { document, documentName } = data;
// Size guard
const yText = document.getText('content');
const textLength = yText.length;
if (textLength > MAX_DOC_SIZE_BYTES) {
logger.warn(`Docs collab: document ${documentName} exceeds size limit (${textLength} chars)`);
return;
}
// Write plaintext to disk (debounced by Hocuspocus's built-in debounce)
const content = yText.toString();
try {
await docsFilesService.writeFileContent(documentName, content);
// Invalidate Redis file cache
try {
await redis.del(fileCacheKey(documentName));
} catch { /* ignore */ }
logger.debug(`Docs collab: wrote ${documentName} to disk (${content.length} chars)`);
} catch (err) {
logger.error(`Docs collab: failed to write ${documentName} to disk`, err);
}
},
async onStoreDocument(data) {
const { document, documentName } = data;
// Persist Y.Doc binary state to PostgreSQL
try {
const state = Y.encodeStateAsUpdate(document);
await prisma.docCollabState.upsert({
where: { documentId: documentName },
update: {
state: Buffer.from(state),
},
create: {
documentId: documentName,
state: Buffer.from(state),
},
});
logger.debug(`Docs collab: persisted state for ${documentName}`);
} catch (err) {
logger.error(`Docs collab: failed to persist state for ${documentName}`, err);
}
},
async onDisconnect(data) {
const { documentName, context } = data;
const userId = context?.user?.id;
if (userId) {
const count = connectionsPerUser.get(userId) || 0;
if (count <= 1) {
connectionsPerUser.delete(userId);
} else {
connectionsPerUser.set(userId, count - 1);
}
}
logger.debug(`Docs collab: ${context?.user?.name || 'unknown'} disconnected from ${documentName}`);
// Update metrics
collabConnections.set(hocuspocus.getConnectionsCount());
collabDocuments.set(hocuspocus.getDocumentsCount());
},
async connected(data) {
const { documentName, context } = data;
// Awareness is set client-side; we just log here
logger.debug(`Docs collab: ${context?.user?.name || 'unknown'} fully synced on ${documentName}`);
// Update metrics
collabConnections.set(hocuspocus.getConnectionsCount());
collabDocuments.set(hocuspocus.getDocumentsCount());
},
};
// --- Create Hocuspocus instance ---
const hocuspocus = new Hocuspocus({
name: 'changemaker-docs',
quiet: true,
debounce: 1000, // Debounce disk writes by 1s
maxDebounce: 5000, // Force write every 5s at most
timeout: 30000, // 30s ping timeout
extensions: [docsExtension],
});
/**
* Handle an incoming WebSocket connection for docs collaboration.
* Called from server.ts after the HTTP → WS upgrade.
*/
function handleConnection(
ws: WebSocket,
request: IncomingMessage,
context: { documentName: string; token: string },
): void {
// Hocuspocus expects the token in the URL search params for onAuthenticate
// We manually set it via the context's requestParameters
const url = new URL(request.url || '', `http://${request.headers.host}`);
url.searchParams.set('token', context.token);
request.url = url.pathname + url.search;
hocuspocus.handleConnection(ws, request, {
// Pass initial context
documentName: context.documentName,
});
}
/**
* Invalidate a document's collaboration state.
* Called when files are modified externally (rename, delete, PUT endpoint).
*/
async function invalidateDocument(documentName: string): Promise<void> {
try {
// Delete persisted state
await prisma.docCollabState.deleteMany({
where: { documentId: documentName },
});
// Close active connections for this document so clients reload
hocuspocus.closeConnections(documentName);
logger.debug(`Docs collab: invalidated document ${documentName}`);
} catch (err) {
logger.warn(`Docs collab: failed to invalidate document ${documentName}`, err);
}
}
/**
* Clean up stale DocCollabState records (older than 7 days with no corresponding file).
*/
async function cleanupStaleStates(): Promise<void> {
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
try {
const staleRecords = await prisma.docCollabState.findMany({
where: { updatedAt: { lt: sevenDaysAgo } },
select: { id: true, documentId: true },
});
let cleaned = 0;
for (const record of staleRecords) {
try {
// Check if file still exists on disk
docsFilesService.safeResolve(record.documentId);
await docsFilesService.readFileContent(record.documentId);
} catch {
// File doesn't exist — delete the state
await prisma.docCollabState.delete({ where: { id: record.id } });
cleaned++;
}
}
if (cleaned > 0) {
logger.info(`Docs collab: cleaned up ${cleaned} stale collaboration states`);
}
} catch (err) {
logger.warn('Docs collab: cleanup failed', err);
}
}
/**
* Gracefully close all connections and stop Hocuspocus.
*/
async function shutdown(): Promise<void> {
hocuspocus.closeConnections();
collabConnections.set(0);
collabDocuments.set(0);
}
export const docsCollabService = {
handleConnection,
invalidateDocument,
cleanupStaleStates,
shutdown,
getConnectionsCount: () => hocuspocus.getConnectionsCount(),
getDocumentsCount: () => hocuspocus.getDocumentsCount(),
};

View File

@@ -5,10 +5,12 @@ import { extname, basename } from 'path';
import { authenticate } from '../../middleware/auth.middleware';
import { requireNonTemp, requireRole } from '../../middleware/rbac.middleware';
import { env } from '../../config/env';
import { CONTENT_ROLES } from '../../utils/roles';
import { logger } from '../../utils/logger';
import { isServiceOnline } from '../../utils/health-check';
import { cm_docs_operations } from '../../utils/metrics';
import { docsFilesService, PathTraversalError, FileNotFoundError } from './docs-files.service';
import { docsCollabService } from './docs-collab.service';
import { mkdocsConfigService } from './mkdocs-config.service';
import { headerBuilderService } from './header-builder.service';
import { headerConfigSchema } from './header-builder.schemas';
@@ -73,7 +75,7 @@ router.get(
// PUT /api/docs/mkdocs-config — validate + write mkdocs.yml (SUPER_ADMIN only)
router.put(
'/mkdocs-config',
requireRole('SUPER_ADMIN'),
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { content } = req.body as { content?: string };
@@ -94,10 +96,10 @@ router.put(
},
);
// POST /api/docs/build — trigger mkdocs build in container (SUPER_ADMIN only)
// POST /api/docs/build — trigger mkdocs build in container
router.post(
'/build',
requireRole('SUPER_ADMIN'),
requireRole(...CONTENT_ROLES),
async (_req: Request, res: Response, next: NextFunction) => {
try {
const result = await mkdocsConfigService.triggerBuild();
@@ -128,7 +130,7 @@ router.get(
// PUT /api/docs/header-config — save header nav bar config + regenerate template
router.put(
'/header-config',
requireRole('SUPER_ADMIN'),
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const parsed = headerConfigSchema.safeParse(req.body);
@@ -172,7 +174,7 @@ const upload = multer({
// POST /api/docs/upload — upload binary file (image, pdf, etc.)
router.post(
'/upload',
requireRole('SUPER_ADMIN'),
requireRole(...CONTENT_ROLES),
upload.single('file'),
async (req: Request, res: Response, next: NextFunction) => {
const tempPath = req.file?.path;
@@ -244,7 +246,7 @@ router.get(
// POST /api/docs/files/rename — rename/move file
router.post(
'/files/rename',
requireRole('SUPER_ADMIN'),
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
cm_docs_operations.inc({ operation: 'rename' });
@@ -254,6 +256,8 @@ router.post(
return;
}
await docsFilesService.renameFile(from, to);
// Invalidate old path's collaboration state
docsCollabService.invalidateDocument(from).catch(() => {});
res.json({ success: true });
} catch (err) {
handleFileError(err, res, next);
@@ -283,7 +287,7 @@ router.get(
// PUT /api/docs/files/* — write/update file content
router.put(
'/files/*',
requireRole('SUPER_ADMIN'),
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
cm_docs_operations.inc({ operation: 'write' });
@@ -298,6 +302,8 @@ router.put(
return;
}
await docsFilesService.writeFileContent(filePath, content);
// Invalidate collaboration state so next session starts fresh from disk
docsCollabService.invalidateDocument(filePath).catch(() => {});
res.json({ success: true, path: filePath });
} catch (err) {
handleFileError(err, res, next);
@@ -308,7 +314,7 @@ router.put(
// POST /api/docs/files/* — create new file or folder
router.post(
'/files/*',
requireRole('SUPER_ADMIN'),
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
cm_docs_operations.inc({ operation: 'create' });
@@ -329,7 +335,7 @@ router.post(
// DELETE /api/docs/files/* — delete file or empty folder
router.delete(
'/files/*',
requireRole('SUPER_ADMIN'),
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
cm_docs_operations.inc({ operation: 'delete' });
@@ -339,6 +345,8 @@ router.delete(
return;
}
await docsFilesService.deleteFile(filePath);
// Invalidate collaboration state for deleted file
docsCollabService.invalidateDocument(filePath).catch(() => {});
res.json({ success: true });
} catch (err) {
handleFileError(err, res, next);

View File

@@ -15,6 +15,7 @@ import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { UserRole } from '@prisma/client';
import { Request, Response } from 'express';
import { BROADCAST_ROLES } from '../../utils/roles';
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { redis } from '../../config/redis';
@@ -24,8 +25,8 @@ const router = Router();
// All email template routes require authentication
router.use(authenticate);
// All routes require admin role (SUPER_ADMIN, INFLUENCE_ADMIN, or MAP_ADMIN)
const requireAdminRole = requireRole(UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN);
// All routes require broadcast admin role
const requireBroadcastRole = requireRole(...BROADCAST_ROLES);
/**
* List email templates
@@ -33,7 +34,7 @@ const requireAdminRole = requireRole(UserRole.SUPER_ADMIN, UserRole.INFLUENCE_AD
*/
router.get(
'/',
requireAdminRole,
requireBroadcastRole,
validate(listEmailTemplatesSchema, 'query'),
async (req: Request, res: Response): Promise<void> => {
try {
@@ -52,7 +53,7 @@ router.get(
*/
router.get(
'/:id',
requireAdminRole,
requireBroadcastRole,
async (req: Request, res: Response): Promise<void> => {
try {
const template = await emailTemplatesService.getById(req.params.id as string);
@@ -74,7 +75,7 @@ router.get(
*/
router.post(
'/',
requireAdminRole,
requireBroadcastRole,
validate(createEmailTemplateSchema),
async (req: Request, res: Response): Promise<void> => {
try {
@@ -101,7 +102,7 @@ router.post(
*/
router.put(
'/:id',
requireAdminRole,
requireBroadcastRole,
validate(updateEmailTemplateSchema),
async (req: Request, res: Response): Promise<void> => {
try {
@@ -133,7 +134,7 @@ router.put(
*/
router.delete(
'/:id',
requireAdminRole,
requireBroadcastRole,
async (req: Request, res: Response): Promise<void> => {
try {
// Fetch template before deleting to get the key
@@ -167,7 +168,7 @@ router.delete(
*/
router.get(
'/:id/versions',
requireAdminRole,
requireBroadcastRole,
async (req: Request, res: Response): Promise<void> => {
try {
const versions = await emailTemplatesService.getVersions(req.params.id as string);
@@ -185,7 +186,7 @@ router.get(
*/
router.get(
'/:id/versions/:versionNumber',
requireAdminRole,
requireBroadcastRole,
async (req: Request, res: Response): Promise<void> => {
try {
const version = await emailTemplatesService.getVersion(
@@ -210,7 +211,7 @@ router.get(
*/
router.post(
'/:id/rollback',
requireAdminRole,
requireBroadcastRole,
validate(rollbackToVersionSchema),
async (req: Request, res: Response): Promise<void> => {
try {
@@ -237,7 +238,7 @@ router.post(
*/
router.post(
'/validate',
requireAdminRole,
requireBroadcastRole,
validate(validateTemplateSchema),
async (req: Request, res: Response): Promise<void> => {
try {
@@ -257,7 +258,7 @@ router.post(
*/
router.post(
'/:id/test',
requireAdminRole,
requireBroadcastRole,
rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10,
@@ -291,7 +292,7 @@ router.post(
*/
router.get(
'/:id/test-logs',
requireAdminRole,
requireBroadcastRole,
async (req: Request, res: Response): Promise<void> => {
try {
const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : 10;

View File

@@ -1,15 +1,15 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { galleryAdsService } from './gallery-ads.service';
import { createAdSchema, updateAdSchema, listAdsSchema, reorderAdsSchema, adAnalyticsQuerySchema } from './gallery-ads.schemas';
import { validate } from '../../middleware/validate';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { PAYMENTS_ROLES } from '../../utils/roles';
const router = Router();
router.use(authenticate);
router.use(requireRole(UserRole.SUPER_ADMIN));
router.use(requireRole(...PAYMENTS_ROLES));
// GET /api/gallery-ads/admin — list all ads (paginated)
router.get(

View File

@@ -1,5 +1,4 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { campaignEmailsService } from './campaign-emails.service';
import {
sendCampaignEmailSchema,
@@ -10,8 +9,7 @@ import { validate } from '../../../middleware/validate';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { emailRateLimit } from '../../../middleware/rate-limit';
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
import { INFLUENCE_ROLES } from '../../../utils/roles';
// --- Public Routes (no auth) ---
const publicRouter = Router();
@@ -53,7 +51,7 @@ publicRouter.post(
// --- Admin Routes (auth required) ---
const adminRouter = Router();
adminRouter.use(authenticate);
adminRouter.use(requireRole(...ADMIN_ROLES));
adminRouter.use(requireRole(...INFLUENCE_ROLES));
// GET /api/campaigns/:id/emails
adminRouter.get(

View File

@@ -1,17 +1,15 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { campaignsService } from './campaigns.service';
import { listModerationQueueSchema, moderateCampaignSchema } from './campaigns.schemas';
import { validate } from '../../../middleware/validate';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
import { INFLUENCE_ROLES } from '../../../utils/roles';
const router = Router();
router.use(authenticate);
router.use(requireRole(...ADMIN_ROLES));
router.use(requireRole(...INFLUENCE_ROLES));
// GET /api/campaigns/moderation/queue — list moderation queue
router.get(

View File

@@ -1,18 +1,16 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { campaignsService } from './campaigns.service';
import { createCampaignSchema, updateCampaignSchema, listCampaignsSchema } from './campaigns.schemas';
import { validate } from '../../../middleware/validate';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
import { INFLUENCE_ROLES } from '../../../utils/roles';
const router = Router();
// All campaign admin routes require authentication + admin role
router.use(authenticate);
router.use(requireRole(...ADMIN_ROLES));
router.use(requireRole(...INFLUENCE_ROLES));
// GET /api/campaigns — list campaigns with pagination/filters
router.get(

View File

@@ -1,5 +1,4 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { validate } from '../../../middleware/validate';
@@ -10,12 +9,11 @@ import {
geoQuerySchema,
repQuerySchema,
} from './effectiveness.schemas';
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN];
import { INFLUENCE_ROLES } from '../../../utils/roles';
const router = Router();
router.use(authenticate);
router.use(requireRole(...ADMIN_ROLES));
router.use(requireRole(...INFLUENCE_ROLES));
// GET /api/influence/effectiveness/overview
router.get(

View File

@@ -1,14 +1,12 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { emailQueueService } from '../../../services/email-queue.service';
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
import { INFLUENCE_ROLES } from '../../../utils/roles';
const router = Router();
router.use(authenticate);
router.use(requireRole(...ADMIN_ROLES));
router.use(requireRole(...INFLUENCE_ROLES));
// GET /api/email-queue/stats
router.get(

View File

@@ -1,13 +1,11 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { representativesService } from './representatives.service';
import { listRepresentativesSchema } from './representatives.schemas';
import { postalCodeParamSchema, postalCodeQuerySchema } from '../postal-codes/postal-codes.schemas';
import { validate } from '../../../middleware/validate';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
import { INFLUENCE_ROLES } from '../../../utils/roles';
const router = Router();
@@ -50,7 +48,7 @@ router.get(
// =============================================
router.use(authenticate);
router.use(requireRole(...ADMIN_ROLES));
router.use(requireRole(...INFLUENCE_ROLES));
// GET /api/representatives/cache-stats — cache statistics
router.get(

View File

@@ -1,5 +1,4 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { responsesService } from './responses.service';
import {
submitResponseSchema,
@@ -12,8 +11,7 @@ import { authenticate } from '../../../middleware/auth.middleware';
import { optionalAuth } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { responseRateLimit } from '../../../middleware/rate-limit';
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
import { INFLUENCE_ROLES } from '../../../utils/roles';
// --- Campaign-scoped public routes (mount at /api/campaigns) ---
const campaignPublicRouter = Router();
@@ -144,7 +142,7 @@ responsesPublicRouter.get(
// --- Admin routes (mount at /api/responses) ---
const responsesAdminRouter = Router();
responsesAdminRouter.use(authenticate);
responsesAdminRouter.use(requireRole(...ADMIN_ROLES));
responsesAdminRouter.use(requireRole(...INFLUENCE_ROLES));
// GET /api/responses
responsesAdminRouter.get(

View File

@@ -1,15 +1,15 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { listmonkClient } from '../../services/listmonk.client';
import { listmonkSyncService } from '../../services/listmonk-sync.service';
import { listmonkEventSyncService } from '../../services/listmonk-event-sync.service';
import { env } from '../../config/env';
import { BROADCAST_ROLES } from '../../utils/roles';
const router = Router();
router.use(authenticate);
router.use(requireRole(UserRole.SUPER_ADMIN));
router.use(requireRole(...BROADCAST_ROLES));
// GET /api/listmonk — sync status
router.get(

View File

@@ -8,10 +8,11 @@ import {
exportContactsToCampaign,
getCutCampaignAnalytics,
} from './canvass-export.service';
import { MAP_ROLES } from '../../../utils/roles';
const router = Router();
router.use(authenticate);
router.use(requireRole('SUPER_ADMIN', 'MAP_ADMIN', 'INFLUENCE_ADMIN'));
router.use(requireRole(...MAP_ROLES));
// POST /api/map/canvass/export-contacts/preview — preview matching contacts
router.post(

View File

@@ -20,8 +20,7 @@ import { validate } from '../../../middleware/validate';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { canvassVisitRateLimit, canvassBulkVisitRateLimit, canvassGeocodeRateLimit } from '../../../middleware/rate-limit';
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
import { MAP_ROLES } from '../../../utils/roles';
// ─── Volunteer Router ────────────────────────────────────────────────
const volunteerRouter = Router();
@@ -282,7 +281,7 @@ volunteerRouter.post(
// ─── Admin Router ────────────────────────────────────────────────────
const adminRouter = Router();
adminRouter.use(authenticate);
adminRouter.use(requireRole(...MAP_ADMIN_ROLES));
adminRouter.use(requireRole(...MAP_ROLES));
// GET /api/map/canvass/stats
adminRouter.get(

View File

@@ -1,11 +1,11 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import multer from 'multer';
import { cutsService } from './cuts.service';
import { createCutSchema, updateCutSchema, listCutsSchema } from './cuts.schemas';
import { validate } from '../../../middleware/validate';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { MAP_ROLES } from '../../../utils/roles';
const geojsonUpload = multer({
storage: multer.memoryStorage(),
@@ -19,12 +19,10 @@ const geojsonUpload = multer({
},
});
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
// --- Admin Router ---
const adminRouter = Router();
adminRouter.use(authenticate);
adminRouter.use(requireRole(...MAP_ADMIN_ROLES));
adminRouter.use(requireRole(...MAP_ROLES));
// GET /api/map/cuts — list paginated
adminRouter.get(

View File

@@ -1,12 +1,10 @@
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { UserRole } from '@prisma/client';
import { geocodingService } from './geocoding.service';
import { validate } from '../../../middleware/validate';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
import { MAP_ROLES } from '../../../utils/roles';
const searchSchema = z.object({
q: z.string().min(2, 'Query must be at least 2 characters'),
@@ -15,7 +13,7 @@ const searchSchema = z.object({
const router = Router();
router.use(authenticate);
router.use(requireRole(...MAP_ADMIN_ROLES));
router.use(requireRole(...MAP_ROLES));
// GET /api/map/geocoding/search?q=Ottawa&limit=5
router.get(

View File

@@ -1,5 +1,4 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { randomUUID } from 'crypto';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
@@ -8,12 +7,11 @@ import { areaImportPreviewSchema, areaImportStartSchema } from './area-import.sc
import { areaImportService, type AreaImportProgress } from './area-import.service';
import { redis } from '../../../config/redis';
import { logger } from '../../../utils/logger';
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
import { MAP_ROLES } from '../../../utils/roles';
const areaImportRouter = Router();
areaImportRouter.use(authenticate);
areaImportRouter.use(requireRole(...MAP_ADMIN_ROLES));
areaImportRouter.use(requireRole(...MAP_ROLES));
// POST /api/map/area-import/preview — get bounds, estimates, and existing count
areaImportRouter.post(

View File

@@ -1,16 +1,14 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { geocodeQueueService } from '../../../services/geocode-queue.service';
import { bulkGeocodeSchema } from './bulk-geocode.schemas';
import { validate } from '../../../middleware/validate';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
import { MAP_ROLES } from '../../../utils/roles';
const router = Router();
router.use(authenticate);
router.use(requireRole(...MAP_ADMIN_ROLES));
router.use(requireRole(...MAP_ROLES));
// POST /api/map/locations/bulk-geocode — start bulk geocoding job
router.post(

View File

@@ -1,5 +1,4 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import multer from 'multer';
import { locationsService } from './locations.service';
import {
@@ -17,8 +16,7 @@ import { prisma } from '../../../config/database';
import { validate } from '../../../middleware/validate';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
import { MAP_ROLES } from '../../../utils/roles';
// Multer config for CSV upload (memory storage, 10MB limit)
const upload = multer({
@@ -49,7 +47,7 @@ const bulkUpload = multer({
// --- Admin Router ---
const adminRouter = Router();
adminRouter.use(authenticate);
adminRouter.use(requireRole(...MAP_ADMIN_ROLES));
adminRouter.use(requireRole(...MAP_ROLES));
// GET /api/map/locations — list with pagination + filters
adminRouter.get(

View File

@@ -1,5 +1,4 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { z } from 'zod';
import { randomUUID } from 'crypto';
import { narImportService, writeProgress } from './nar-import.service';
@@ -8,8 +7,7 @@ import { logger } from '../../../utils/logger';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { validate } from '../../../middleware/validate';
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
import { MAP_ROLES } from '../../../utils/roles';
const serverImportSchema = z.object({
provinceCode: z.string().min(1).max(2),
@@ -24,7 +22,7 @@ const serverImportSchema = z.object({
const narImportRouter = Router();
narImportRouter.use(authenticate);
narImportRouter.use(requireRole(...MAP_ADMIN_ROLES));
narImportRouter.use(requireRole(...MAP_ROLES));
// GET /api/map/nar-import/datasets — list available NAR datasets by province
narImportRouter.get(

View File

@@ -1,12 +1,10 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { mapSettingsService } from './settings.service';
import { updateMapSettingsSchema } from './settings.schemas';
import { validate } from '../../../middleware/validate';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
import { MAP_ROLES } from '../../../utils/roles';
const router = Router();
@@ -27,7 +25,7 @@ router.get(
router.put(
'/',
authenticate,
requireRole(...MAP_ADMIN_ROLES),
requireRole(...MAP_ROLES),
validate(updateMapSettingsSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {

View File

@@ -2,14 +2,14 @@ import { Router } from 'express';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { validate } from '../../../middleware/validate';
import { UserRole } from '@prisma/client';
import { ShiftSeriesService } from './shift-series.service';
import { createShiftSeriesSchema, updateShiftSeriesSchema } from './shift-series.schemas';
import { SCHEDULING_ROLES } from '../../../utils/roles';
const router = Router();
// All routes require admin role
router.use(authenticate, requireRole(UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN));
router.use(authenticate, requireRole(...SCHEDULING_ROLES));
// Create series
router.post(

View File

@@ -1,5 +1,4 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { shiftsService } from './shifts.service';
import {
createShiftSchema,
@@ -14,13 +13,12 @@ import { requireRole } from '../../../middleware/rbac.middleware';
import { shiftSignupRateLimit } from '../../../middleware/rate-limit';
import { prisma } from '../../../config/database';
import { redis } from '../../../config/redis';
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
import { SCHEDULING_ROLES } from '../../../utils/roles';
// --- Admin Router ---
const adminRouter = Router();
adminRouter.use(authenticate);
adminRouter.use(requireRole(...MAP_ADMIN_ROLES));
adminRouter.use(requireRole(...SCHEDULING_ROLES));
// GET /api/map/shifts — list paginated
adminRouter.get(

View File

@@ -1,5 +1,4 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { trackingService } from './tracking.service';
import {
startTrackingSchema,
@@ -13,8 +12,7 @@ import { validate } from '../../../middleware/validate';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { gpsTrackingRateLimit } from '../../../middleware/rate-limit';
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
import { MAP_ROLES } from '../../../utils/roles';
// ─── Volunteer Router ────────────────────────────────────────────────
const volunteerRouter = Router();
@@ -135,7 +133,7 @@ volunteerRouter.get(
// ─── Admin Router ────────────────────────────────────────────────────
const adminRouter = Router();
adminRouter.use(authenticate);
adminRouter.use(requireRole(...MAP_ADMIN_ROLES));
adminRouter.use(requireRole(...MAP_ROLES));
// GET /api/map/tracking/live — active volunteers with positions + recent trails
adminRouter.get(

View File

@@ -3,7 +3,7 @@ import jwt from 'jsonwebtoken';
import { UserRole, UserStatus } from '@prisma/client';
import { prisma } from '../../../config/database';
import { env } from '../../../config/env';
import { hasAnyRole, ADMIN_ROLES as ADMIN_ROLE_LIST, getUserRoles } from '../../../utils/roles';
import { hasAnyRole, MEDIA_ROLES, getUserRoles } from '../../../utils/roles';
// Extend FastifyRequest to include user
declare module 'fastify' {
@@ -123,7 +123,7 @@ export async function requireAdminRole(
}
// Check admin role using multi-role utility
if (!request.user || !hasAnyRole(request.user, ADMIN_ROLE_LIST)) {
if (!request.user || !hasAnyRole(request.user, MEDIA_ROLES)) {
return reply.status(403).send({
error: 'Admin access required',
code: 'ADMIN_REQUIRED'

View File

@@ -5,7 +5,7 @@ import { prisma } from '../../../config/database';
import { env } from '../../../config/env';
import { requireAdminRole } from '../middleware/auth';
import { logger } from '../../../utils/logger';
import { hasAnyRole, ADMIN_ROLES } from '../../../utils/roles';
import { hasAnyRole, MEDIA_ROLES } from '../../../utils/roles';
import { unlink } from 'fs/promises';
/**
@@ -32,7 +32,7 @@ async function isAdminRequest(request: FastifyRequest): Promise<boolean> {
roles?: UserRole[];
};
if (!hasAnyRole(payload, ADMIN_ROLES)) return false;
if (!hasAnyRole(payload, MEDIA_ROLES)) return false;
const user = await prisma.user.findUnique({
where: { id: payload.id },

View File

@@ -8,7 +8,7 @@ import { UserRole, UserStatus } from '@prisma/client';
import { prisma } from '../../../config/database';
import { env } from '../../../config/env';
import { logger } from '../../../utils/logger';
import { hasAnyRole, ADMIN_ROLES } from '../../../utils/roles';
import { hasAnyRole, MEDIA_ROLES } from '../../../utils/roles';
/**
* Check if the request is from an authenticated admin user.
@@ -37,7 +37,7 @@ async function isAdminRequest(request: FastifyRequest): Promise<boolean> {
};
// Check admin role from token (multi-role aware)
if (!hasAnyRole(payload, ADMIN_ROLES)) return false;
if (!hasAnyRole(payload, MEDIA_ROLES)) return false;
// Verify user is still active in DB
const user = await prisma.user.findUnique({

View File

@@ -1,5 +1,4 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { meetingPlannerService } from './meeting-planner.service';
import {
createPollSchema,
@@ -13,17 +12,16 @@ import {
listPollsSchema,
} from './meeting-planner.schemas';
import { validate } from '../../middleware/validate';
import { authenticate } from '../../middleware/auth.middleware';
import { authenticate, optionalAuth } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { pollVoteRateLimit, pollCommentRateLimit } from './meeting-planner.rate-limits';
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
import { EVENTS_ROLES } from '../../utils/roles';
// --- Admin Router ---
const adminRouter = Router();
adminRouter.use(authenticate);
adminRouter.use(requireRole(...ADMIN_ROLES));
adminRouter.use(requireRole(...EVENTS_ROLES));
// List polls
adminRouter.get('/', validate(listPollsSchema, 'query'), async (req: Request, res: Response, next: NextFunction) => {
@@ -151,7 +149,7 @@ const publicRouter = Router();
// Public listing of open polls
publicRouter.get('/public', async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await meetingPlannerService.findAll({
const result = await meetingPlannerService.findAllPublic({
status: 'OPEN',
limit: 50,
page: 1,
@@ -161,51 +159,28 @@ publicRouter.get('/public', async (req: Request, res: Response, next: NextFuncti
});
// View poll by slug
publicRouter.get('/public/:slug', async (req: Request, res: Response, next: NextFunction) => {
publicRouter.get('/public/:slug', optionalAuth, async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
const poll = await meetingPlannerService.findBySlug(slug);
const poll = await meetingPlannerService.findBySlugPublic(slug, req.user?.id);
res.json(poll);
} catch (err) { next(err); }
});
// Submit votes
publicRouter.post('/public/:slug/vote', pollVoteRateLimit, validate(submitVotesSchema), async (req: Request, res: Response, next: NextFunction) => {
publicRouter.post('/public/:slug/vote', optionalAuth, pollVoteRateLimit, validate(submitVotesSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
// Try to get userId from optional auth header
let userId: string | undefined;
try {
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
const jwt = await import('jsonwebtoken');
const { env } = await import('../../config/env');
const decoded = jwt.default.verify(authHeader.slice(7), env.JWT_ACCESS_SECRET) as any;
userId = decoded.id;
}
} catch { /* not authenticated, that's fine */ }
const result = await meetingPlannerService.submitVotes(slug, req.body, userId);
const result = await meetingPlannerService.submitVotes(slug, req.body, req.user?.id);
res.json(result);
} catch (err) { next(err); }
});
// Add comment
publicRouter.post('/public/:slug/comment', pollCommentRateLimit, validate(submitCommentSchema), async (req: Request, res: Response, next: NextFunction) => {
publicRouter.post('/public/:slug/comment', optionalAuth, pollCommentRateLimit, validate(submitCommentSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
let userId: string | undefined;
try {
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
const jwt = await import('jsonwebtoken');
const { env } = await import('../../config/env');
const decoded = jwt.default.verify(authHeader.slice(7), env.JWT_ACCESS_SECRET) as any;
userId = decoded.id;
}
} catch { /* not authenticated */ }
const comment = await meetingPlannerService.addComment(slug, req.body, userId);
const comment = await meetingPlannerService.addComment(slug, req.body, req.user?.id);
res.status(201).json(comment);
} catch (err) { next(err); }
});

View File

@@ -7,6 +7,7 @@ export const createPollSchema = z.object({
location: z.string().max(500).optional(),
timezone: z.string().default('America/Edmonton'),
allowAnonymous: z.boolean().optional().default(true),
isPrivate: z.boolean().optional().default(false),
notifyOnVote: z.boolean().optional().default(true),
votingDeadline: z.string().datetime().optional(),
options: z.array(z.object({
@@ -22,6 +23,7 @@ export const updatePollSchema = z.object({
location: z.string().max(500).nullable().optional(),
timezone: z.string().optional(),
allowAnonymous: z.boolean().optional(),
isPrivate: z.boolean().optional(),
notifyOnVote: z.boolean().optional(),
votingDeadline: z.string().datetime().nullable().optional(),
status: z.nativeEnum(SchedulingPollStatus).optional(),

View File

@@ -1,3 +1,4 @@
import crypto from 'crypto';
import { Prisma, PollVoteValue } from '@prisma/client';
import { prisma } from '../../config/database';
import { AppError } from '../../middleware/error-handler';
@@ -22,6 +23,7 @@ const pollInclude = {
_count: { select: { options: true, votes: true, comments: true } },
} as const;
// Admin detail include — returns all vote fields (for admin endpoints)
const pollDetailInclude = {
options: {
orderBy: { sortOrder: 'asc' as const },
@@ -34,6 +36,31 @@ const pollDetailInclude = {
_count: { select: { options: true, votes: true, comments: true } },
} as const;
// Public detail include — strips voterEmail and voterToken from votes
const pollDetailPublicInclude = {
options: {
orderBy: { sortOrder: 'asc' as const },
include: {
votes: {
orderBy: { createdAt: 'asc' as const },
select: {
id: true,
pollId: true,
optionId: true,
voterName: true,
userId: true,
value: true,
createdAt: true,
// voterEmail and voterToken intentionally excluded
},
},
},
},
comments: { orderBy: { createdAt: 'asc' as const } },
createdBy: { select: { id: true, name: true } }, // exclude email from public
_count: { select: { options: true, votes: true, comments: true } },
} as const;
function aggregateVotes(options: Array<{ id: string; votes: Array<{ value: PollVoteValue }> }>) {
return options.map((opt) => {
let yesCount = 0;
@@ -56,7 +83,7 @@ function aggregateVotes(options: Array<{ id: string; votes: Array<{ value: PollV
function groupVotesByVoter(votes: Array<{
voterName: string;
voterToken: string | null;
voterToken?: string | null;
userId: string | null;
optionId: string;
value: PollVoteValue;
@@ -139,6 +166,68 @@ export const meetingPlannerService = {
return { ...poll, options: optionsWithCounts, voters };
},
async findBySlugPublic(slug: string, userId?: string) {
const poll = await prisma.schedulingPoll.findUnique({
where: { slug },
include: pollDetailPublicInclude,
});
if (!poll) throw new AppError(404, 'Poll not found');
// If private and not authenticated, return limited data
if (poll.isPrivate && !userId) {
return {
id: poll.id,
slug: poll.slug,
title: poll.title,
description: poll.description,
location: poll.location,
status: poll.status,
timezone: poll.timezone,
allowAnonymous: poll.allowAnonymous,
isPrivate: poll.isPrivate,
notifyOnVote: poll.notifyOnVote,
createdBy: poll.createdBy,
createdByUserId: poll.createdByUserId,
createdAt: poll.createdAt,
updatedAt: poll.updatedAt,
votingDeadline: poll.votingDeadline,
finalizedOptionId: null,
finalizedOption: null,
convertedShiftId: null,
convertedGancioEventId: null,
requiresAuth: true,
options: [],
voters: [],
comments: [],
_count: { options: 0, votes: 0, comments: 0 },
};
}
const optionsWithCounts = aggregateVotes(poll.options);
const allVotes = poll.options.flatMap((opt) =>
opt.votes.map((v) => ({ ...v, optionId: opt.id }))
);
const voters = groupVotesByVoter(allVotes);
return { ...poll, options: optionsWithCounts, voters, requiresAuth: false };
},
async findAllPublic(filters: ListPollsInput) {
const result = await this.findAll({ ...filters, status: 'OPEN' });
return {
...result,
// Filter out private polls entirely from the public listing
polls: result.polls
.filter((poll) => !poll.isPrivate)
.map((poll) => ({
...poll,
requiresAuth: false,
// Strip organizer email from public listing
createdBy: poll.createdBy ? { id: poll.createdBy.id, name: poll.createdBy.name } : null,
})),
};
},
async create(data: CreatePollInput, userId: string) {
const slug = generateSlug(data.title);
@@ -150,6 +239,7 @@ export const meetingPlannerService = {
location: data.location,
timezone: data.timezone,
allowAnonymous: data.allowAnonymous,
isPrivate: data.isPrivate,
notifyOnVote: data.notifyOnVote,
votingDeadline: data.votingDeadline ? new Date(data.votingDeadline) : null,
createdByUserId: userId,
@@ -178,6 +268,7 @@ export const meetingPlannerService = {
if (data.location !== undefined) updateData.location = data.location;
if (data.timezone !== undefined) updateData.timezone = data.timezone;
if (data.allowAnonymous !== undefined) updateData.allowAnonymous = data.allowAnonymous;
if (data.isPrivate !== undefined) updateData.isPrivate = data.isPrivate;
if (data.notifyOnVote !== undefined) updateData.notifyOnVote = data.notifyOnVote;
if (data.votingDeadline !== undefined) {
updateData.votingDeadline = data.votingDeadline ? new Date(data.votingDeadline) : null;
@@ -263,6 +354,9 @@ export const meetingPlannerService = {
if (poll.votingDeadline && new Date() > poll.votingDeadline) {
throw new AppError(400, 'The voting deadline has passed');
}
if (poll.isPrivate && !userId) {
throw new AppError(401, 'This poll requires authentication to vote');
}
if (!poll.allowAnonymous && !userId) {
throw new AppError(401, 'This poll requires authentication to vote');
}
@@ -332,6 +426,12 @@ export const meetingPlannerService = {
async addComment(slug: string, data: SubmitCommentInput, userId?: string) {
const poll = await prisma.schedulingPoll.findUnique({ where: { slug } });
if (!poll) throw new AppError(404, 'Poll not found');
if (poll.isPrivate && !userId) {
throw new AppError(401, 'This poll requires authentication to comment');
}
if (!poll.allowAnonymous && !userId) {
throw new AppError(401, 'This poll requires authentication to comment');
}
return prisma.schedulingPollComment.create({
data: {
@@ -517,12 +617,7 @@ export const meetingPlannerService = {
};
function generateVoterToken(): string {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
let token = '';
for (let i = 0; i < 24; i++) {
token += chars[Math.floor(Math.random() * chars.length)];
}
return token;
return crypto.randomBytes(18).toString('base64url').slice(0, 24);
}
function escapeHtml(str: string): string {

View File

@@ -1,17 +1,15 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { blocksService } from './blocks.service';
import { createPageBlockSchema, updatePageBlockSchema, listPageBlocksSchema } from './pages.schemas';
import { validate } from '../../middleware/validate';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
import { CONTENT_ROLES } from '../../utils/roles';
const router = Router();
router.use(authenticate);
router.use(requireRole(...ADMIN_ROLES));
router.use(requireRole(...CONTENT_ROLES));
// GET /api/page-blocks — list all blocks
router.get(

View File

@@ -1,18 +1,16 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { pagesService } from './pages.service';
import { createLandingPageSchema, updateLandingPageSchema, listLandingPagesSchema } from './pages.schemas';
import { validate } from '../../middleware/validate';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { prisma } from '../../config/database';
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
import { CONTENT_ROLES } from '../../utils/roles';
const router = Router();
router.use(authenticate);
router.use(requireRole(...ADMIN_ROLES));
router.use(requireRole(...CONTENT_ROLES));
// GET /api/pages/view-counts — landing page view counts (last 30d)
router.get(

View File

@@ -1,7 +1,7 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { PAYMENTS_ROLES } from '../../utils/roles';
import { validate } from '../../middleware/validate';
import { donationPagesService } from './donation-pages.service';
import {
@@ -12,8 +12,8 @@ import {
const router = Router();
// All routes require SUPER_ADMIN
router.use(authenticate, requireRole(UserRole.SUPER_ADMIN));
// All routes require PAYMENTS_ROLES
router.use(authenticate, requireRole(...PAYMENTS_ROLES));
// GET /api/payments/admin/donation-pages — list with pagination, search, status
router.get(

View File

@@ -1,7 +1,7 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { PAYMENTS_ROLES } from '../../utils/roles';
import { validate } from '../../middleware/validate';
import { paymentSettingsService } from './payment-settings.service';
import { subscriptionsService } from './subscriptions.service';
@@ -22,8 +22,8 @@ import {
const router = Router();
// All admin routes require SUPER_ADMIN
router.use(authenticate, requireRole(UserRole.SUPER_ADMIN));
// All admin routes require PAYMENTS_ROLES
router.use(authenticate, requireRole(...PAYMENTS_ROLES));
// =================== Settings ===================

View File

@@ -8,6 +8,7 @@ import { requireRole } from '../../middleware/rbac.middleware';
import { emailService } from '../../services/email.service';
import { giteaClient } from '../../services/gitea.client';
import { gancioSettingsSyncService } from '../../services/gancio-settings-sync.service';
import { autoUpgradeService } from '../../services/auto-upgrade.service';
import { headerBuilderService } from '../docs/header-builder.service';
import { mkdocsConfigService } from '../docs/mkdocs-config.service';
import { logger } from '../../utils/logger';
@@ -35,6 +36,7 @@ router.get(
async (_req: Request, res: Response, next: NextFunction) => {
try {
const settings = await siteSettingsService.getEffective();
res.set('Cache-Control', 'no-store');
res.json(settings);
} catch (err) {
next(err);
@@ -115,6 +117,12 @@ router.put(
gancioSettingsSyncService.syncChanged(req.body).catch(() => {});
}
// If auto-upgrade settings changed, restart the scheduler
const autoUpgradeFields = ['enableAutoUpgrade', 'autoUpgradeSchedule', 'autoUpgradePullServices'];
if (autoUpgradeFields.some((f) => f in req.body)) {
autoUpgradeService.start().catch(() => {});
}
// If navConfig or theme colors changed, trigger MkDocs header rebuild + docs build
const headerTriggerFields = [
'navConfig', 'publicHeaderGradient', 'publicColorBgBase', 'publicColorBgContainer',

View File

@@ -59,6 +59,7 @@ export const updateSiteSettingsSchema = z.object({
enableMeetingPlanner: z.boolean().optional(),
enableTicketedEvents: z.boolean().optional(),
enableSocialCalendar: z.boolean().optional(),
enableDocsCollaboration: z.boolean().optional(),
requireEventApproval: z.boolean().optional(),
autoSyncPeopleToMap: z.boolean().optional(),
@@ -86,6 +87,15 @@ export const updateSiteSettingsSchema = z.object({
provisionListmonk: z.boolean().optional(),
provisionListmonkTiming: z.enum(['lazy', 'eager']).optional(),
// Auto-upgrade settings
enableAutoUpgrade: z.boolean().optional(),
autoUpgradeSchedule: z.enum([
'daily-3am', 'daily-4am', 'daily-5am',
'weekly-sun-3am', 'weekly-mon-3am', '12h', '24h',
]).optional(),
autoUpgradePullServices: z.boolean().optional(),
notifyAdminAutoUpgrade: z.boolean().optional(),
// Navigation configuration (supports one level of nesting via groups)
navConfig: z.object({
items: z.array(z.object({

View File

@@ -5,11 +5,12 @@ import { validate } from '../../../middleware/validate';
import { smsCampaignsService } from './sms-campaigns.service';
import { createSmsCampaignSchema, updateSmsCampaignSchema } from './sms-campaigns.schemas';
import { smsQueueService } from '../../../services/sms-queue.service';
import { BROADCAST_ROLES } from '../../../utils/roles';
const router = Router();
// All routes require authentication + SUPER_ADMIN or INFLUENCE_ADMIN
router.use(authenticate, requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'));
// All routes require authentication + broadcast admin role
router.use(authenticate, requireRole(...BROADCAST_ROLES));
// GET /api/sms/campaigns — list all campaigns
router.get('/', async (req, res, next) => {

View File

@@ -4,11 +4,12 @@ import { requireRole } from '../../../middleware/rbac.middleware';
import { validate } from '../../../middleware/validate';
import { smsContactsService } from './sms-contacts.service';
import { createContactListSchema, updateContactListSchema, createContactEntrySchema, bulkAddEntriesSchema } from './sms-contacts.schemas';
import { BROADCAST_ROLES } from '../../../utils/roles';
const router = Router();
// All routes require authentication + SUPER_ADMIN or INFLUENCE_ADMIN
router.use(authenticate, requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'));
// All routes require authentication + broadcast admin role
router.use(authenticate, requireRole(...BROADCAST_ROLES));
// --- Contact Lists ---

View File

@@ -2,10 +2,11 @@ import { Router } from 'express';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { smsConversationsService } from './sms-conversations.service';
import { BROADCAST_ROLES } from '../../../utils/roles';
const router = Router();
router.use(authenticate, requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'));
router.use(authenticate, requireRole(...BROADCAST_ROLES));
// GET /api/sms/conversations — list conversations
router.get('/', async (req, res, next) => {

View File

@@ -3,10 +3,11 @@ import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { smsDeviceService } from './sms-device.service';
import { termuxClient } from '../../../services/termux.client';
import { BROADCAST_ROLES } from '../../../utils/roles';
const router = Router();
router.use(authenticate, requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'));
router.use(authenticate, requireRole(...BROADCAST_ROLES));
// GET /api/sms/device — latest device status
router.get('/', async (_req, res, next) => {

View File

@@ -2,10 +2,11 @@ import { Router } from 'express';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { smsMessagesService } from './sms-messages.service';
import { BROADCAST_ROLES } from '../../../utils/roles';
const router = Router();
router.use(authenticate, requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'));
router.use(authenticate, requireRole(...BROADCAST_ROLES));
// GET /api/sms/messages — list all messages
router.get('/', async (req, res, next) => {

View File

@@ -4,11 +4,12 @@ import { requireRole } from '../../../middleware/rbac.middleware';
import { validate } from '../../../middleware/validate';
import { smsTemplatesService } from './sms-templates.service';
import { createSmsTemplateSchema, updateSmsTemplateSchema } from './sms-templates.schemas';
import { BROADCAST_ROLES } from '../../../utils/roles';
const router = Router();
// All routes require authentication + SUPER_ADMIN or INFLUENCE_ADMIN
router.use(authenticate, requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'));
// All routes require authentication + broadcast admin role
router.use(authenticate, requireRole(...BROADCAST_ROLES));
// GET /api/sms/templates — list with search/filter/pagination
router.get('/', async (req, res, next) => {

View File

@@ -1,6 +1,7 @@
import { Router } from 'express';
import type { Request, Response } from 'express';
import { requireRole } from '../../middleware/rbac.middleware';
import { SOCIAL_ROLES } from '../../utils/roles';
import { challengeService } from './challenge.service';
import {
createChallengeSchema,
@@ -98,7 +99,7 @@ router.get('/:id/teams/:teamId', async (req: Request, res: Response) => {
// ── Admin ────────────────────────────────────────────────────────────
const adminRouter = Router();
adminRouter.use(requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'));
adminRouter.use(requireRole(...SOCIAL_ROLES));
/** POST /admin — create challenge */
adminRouter.post('/', async (req: Request, res: Response) => {

View File

@@ -1,5 +1,6 @@
import { Router } from 'express';
import { requireRole } from '../../middleware/rbac.middleware';
import { INFLUENCE_ROLES } from '../../utils/roles';
import { impactStoriesService } from './impact-stories.service';
import { createStorySchema, updateStorySchema, listStoriesSchema } from './impact-stories.schemas';
@@ -7,7 +8,7 @@ const router = Router();
// --- Admin routes (require admin role) ---
router.post('/', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), async (req, res, next) => {
router.post('/', requireRole(...INFLUENCE_ROLES), async (req, res, next) => {
try {
const data = createStorySchema.parse(req.body);
const story = await impactStoriesService.create(data, req.user!.id);
@@ -17,7 +18,7 @@ router.post('/', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), asy
}
});
router.put('/:id', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), async (req, res, next) => {
router.put('/:id', requireRole(...INFLUENCE_ROLES), async (req, res, next) => {
try {
const data = updateStorySchema.parse(req.body);
const story = await impactStoriesService.update(req.params.id as string, data);
@@ -27,7 +28,7 @@ router.put('/:id', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), a
}
});
router.delete('/:id', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), async (req, res, next) => {
router.delete('/:id', requireRole(...INFLUENCE_ROLES), async (req, res, next) => {
try {
const result = await impactStoriesService.delete(req.params.id as string);
res.json(result);
@@ -36,7 +37,7 @@ router.delete('/:id', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN')
}
});
router.post('/:id/publish', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), async (req, res, next) => {
router.post('/:id/publish', requireRole(...INFLUENCE_ROLES), async (req, res, next) => {
try {
const story = await impactStoriesService.publish(req.params.id as string);
// Fire-and-forget: notify participants
@@ -47,7 +48,7 @@ router.post('/:id/publish', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_A
}
});
router.post('/:id/archive', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), async (req, res, next) => {
router.post('/:id/archive', requireRole(...INFLUENCE_ROLES), async (req, res, next) => {
try {
const story = await impactStoriesService.archive(req.params.id as string);
res.json(story);
@@ -64,7 +65,7 @@ router.get('/', async (req, res, next) => {
// Admin users can filter by status; regular users see published only
const userRoles = req.user!.roles || [req.user!.role];
const isAdmin = userRoles.some((r: string) =>
['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'].includes(r),
(INFLUENCE_ROLES as string[]).includes(r),
);
if (isAdmin && (campaignId || status)) {

View File

@@ -1,6 +1,7 @@
import { Router } from 'express';
import type { Request, Response } from 'express';
import { requireRole } from '../../middleware/rbac.middleware';
import { SOCIAL_ROLES } from '../../utils/roles';
import { referralService } from './referral.service';
import { createInviteCodeSchema, validateCodeSchema, paginationSchema } from './referral.schemas';
@@ -72,7 +73,7 @@ router.get('/stats', async (req: Request, res: Response) => {
});
/** GET /api/social/referrals/admin/all — all referrals (admin only) */
router.get('/admin/all', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), async (req: Request, res: Response) => {
router.get('/admin/all', requireRole(...SOCIAL_ROLES), async (req: Request, res: Response) => {
try {
const { page, limit } = paginationSchema.parse(req.query);
const result = await referralService.listAllReferrals(page, limit);
@@ -83,7 +84,7 @@ router.get('/admin/all', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMI
});
/** GET /api/social/referrals/admin/leaderboard — top referrers (admin only) */
router.get('/admin/leaderboard', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), async (req: Request, res: Response) => {
router.get('/admin/leaderboard', requireRole(...SOCIAL_ROLES), async (req: Request, res: Response) => {
try {
const limit = parseInt((req.query.limit as string) || '10', 10);
const leaderboard = await referralService.getReferralLeaderboard(Math.min(limit, 50));

View File

@@ -1,6 +1,7 @@
import { Router } from 'express';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { SOCIAL_ROLES } from '../../utils/roles';
import { friendshipRouter } from './friendship.routes';
import { blockRouter } from './block.routes';
import { privacyRouter } from './privacy.routes';
@@ -35,7 +36,7 @@ router.use((req, _res, next) => {
router.use(authenticate);
// Admin sub-router (requires admin role)
router.use('/admin', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), socialAdminRouter);
router.use('/admin', requireRole(...SOCIAL_ROLES), socialAdminRouter);
// Sub-routers
router.use('/friends', friendshipRouter);

View File

@@ -1,6 +1,7 @@
import { Router } from 'express';
import type { Request, Response, NextFunction } from 'express';
import { requireRole } from '../../middleware/rbac.middleware';
import { SOCIAL_ROLES } from '../../utils/roles';
import { spotlightService } from './spotlight.service';
import {
nominateSchema,
@@ -88,7 +89,7 @@ router.post('/opt-out', async (req: Request, res: Response, next: NextFunction)
/** GET /api/social/spotlight/admin — list all spotlights */
router.get(
'/admin',
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
requireRole(...SOCIAL_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { page, limit, status } = listSpotlightsSchema.parse(req.query);
@@ -103,7 +104,7 @@ router.get(
/** POST /api/social/spotlight/admin/nominate — nominate a volunteer */
router.post(
'/admin/nominate',
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
requireRole(...SOCIAL_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const data = nominateSchema.parse(req.body);
@@ -118,7 +119,7 @@ router.post(
/** PUT /api/social/spotlight/admin/:id — update headline/story */
router.put(
'/admin/:id',
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
requireRole(...SOCIAL_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const data = updateSpotlightSchema.parse(req.body);
@@ -133,7 +134,7 @@ router.put(
/** POST /api/social/spotlight/admin/:id/approve — approve a nomination */
router.post(
'/admin/:id/approve',
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
requireRole(...SOCIAL_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const spotlight = await spotlightService.approve(req.params.id as string, req.user!.id);
@@ -147,7 +148,7 @@ router.post(
/** POST /api/social/spotlight/admin/:id/feature — feature for a month */
router.post(
'/admin/:id/feature',
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
requireRole(...SOCIAL_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { month } = featureSchema.parse(req.body);
@@ -162,7 +163,7 @@ router.post(
/** POST /api/social/spotlight/admin/:id/archive — archive a spotlight */
router.post(
'/admin/:id/archive',
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
requireRole(...SOCIAL_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const spotlight = await spotlightService.archive(req.params.id as string);
@@ -176,7 +177,7 @@ router.post(
/** DELETE /api/social/spotlight/admin/:id — delete a spotlight */
router.delete(
'/admin/:id',
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
requireRole(...SOCIAL_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await spotlightService.delete(req.params.id as string);

View File

@@ -13,16 +13,16 @@ import {
} from './ticketed-events.schemas';
import { prisma } from '../../config/database';
import { UserRole } from '@prisma/client';
import { EVENTS_ROLES } from '../../utils/roles';
const router = Router();
const ADMIN_ROLES: UserRole[] = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'];
/** Middleware: require admin role OR canCreateTicketedEvents permission */
async function requireEventPermission(req: Request, _res: Response, next: NextFunction) {
if (!req.user) return next(new Error('Auth required'));
const userRoles = req.user.roles || [req.user.role];
if (userRoles.some(r => ADMIN_ROLES.includes(r as UserRole))) {
if (userRoles.some(r => EVENTS_ROLES.includes(r as UserRole))) {
return next();
}
@@ -51,7 +51,7 @@ router.get('/', async (req: Request, res: Response, next: NextFunction) => {
const search = req.query.search as string | undefined;
const userRoles = req.user!.roles || [req.user!.role];
const isAdmin = userRoles.some(r => ADMIN_ROLES.includes(r as UserRole));
const isAdmin = userRoles.some(r => EVENTS_ROLES.includes(r as UserRole));
const result = await ticketedEventsService.list({
page,
@@ -110,7 +110,7 @@ router.post('/:id/publish', async (req: Request, res: Response, next: NextFuncti
});
// POST /:id/approve (admin only)
router.post('/:id/approve', requireRole(...ADMIN_ROLES), async (req: Request, res: Response, next: NextFunction) => {
router.post('/:id/approve', requireRole(...EVENTS_ROLES), async (req: Request, res: Response, next: NextFunction) => {
try {
const event = await ticketedEventsService.approve(req.params.id as string);
res.json(event);
@@ -118,7 +118,7 @@ router.post('/:id/approve', requireRole(...ADMIN_ROLES), async (req: Request, re
});
// POST /:id/reject (admin only)
router.post('/:id/reject', requireRole(...ADMIN_ROLES), async (req: Request, res: Response, next: NextFunction) => {
router.post('/:id/reject', requireRole(...EVENTS_ROLES), async (req: Request, res: Response, next: NextFunction) => {
try {
const event = await ticketedEventsService.reject(req.params.id as string);
res.json(event);
@@ -134,7 +134,7 @@ router.post('/:id/cancel', async (req: Request, res: Response, next: NextFunctio
});
// POST /:id/complete (admin only)
router.post('/:id/complete', requireRole(...ADMIN_ROLES), async (req: Request, res: Response, next: NextFunction) => {
router.post('/:id/complete', requireRole(...EVENTS_ROLES), async (req: Request, res: Response, next: NextFunction) => {
try {
const event = await ticketedEventsService.complete(req.params.id as string);
res.json(event);

View File

@@ -57,6 +57,15 @@ router.post('/start', (req, res) => {
}
});
/**
* GET /api/upgrade/history
* Returns the history of past upgrade results (newest first).
*/
router.get('/history', (_req, res) => {
const history = upgradeService.getHistory();
res.json({ history });
});
/**
* POST /api/upgrade/clear-result
* Removes the last upgrade result file.

View File

@@ -14,11 +14,14 @@ const STATUS_FILE = path.join(UPGRADE_DIR, 'status.json');
const PROGRESS_FILE = path.join(UPGRADE_DIR, 'progress.json');
const RESULT_FILE = path.join(UPGRADE_DIR, 'result.json');
const TRIGGER_FILE = path.join(UPGRADE_DIR, 'trigger.json');
const HISTORY_FILE = path.join(UPGRADE_DIR, 'history.json');
const TRIGGERED_BY_FILE = path.join(UPGRADE_DIR, 'triggered-by.txt');
// Stale threshold: if progress hasn't been updated in this many ms, assume crashed
const STALE_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes
const MAX_HISTORY_ENTRIES = 50;
interface UpgradeStatus {
export interface UpgradeStatus {
branch: string;
currentCommit: string;
currentCommitFull: string;
@@ -37,7 +40,7 @@ interface UpgradeStatus {
error: string | null;
}
interface UpgradeProgress {
export interface UpgradeProgress {
phase: number;
phaseName: string;
percentage: number;
@@ -45,7 +48,7 @@ interface UpgradeProgress {
lastUpdate: string;
}
interface UpgradeResult {
export interface UpgradeResult {
success: boolean;
message: string;
previousCommit: string;
@@ -54,6 +57,7 @@ interface UpgradeResult {
durationSeconds: number;
warnings: string[];
completedAt: string;
triggeredBy?: string;
}
interface TriggerPayload {
@@ -170,6 +174,48 @@ function clearStaleProgress(): void {
}
}
/** Archive a completed upgrade result to the persistent history file. */
function archiveResult(result: UpgradeResult): void {
try {
const history = readJsonFile<UpgradeResult[]>(HISTORY_FILE) || [];
history.unshift(result);
// Trim to max entries
if (history.length > MAX_HISTORY_ENTRIES) {
history.length = MAX_HISTORY_ENTRIES;
}
writeJsonFile(HISTORY_FILE, history);
logger.info(`Archived upgrade result to history (${history.length} entries)`);
} catch (err) {
logger.warn('Failed to archive upgrade result:', err);
}
}
/** Get the list of past upgrade results (newest first). */
function getHistory(): UpgradeResult[] {
return readJsonFile<UpgradeResult[]>(HISTORY_FILE) || [];
}
/** Read the triggered-by marker file (written by upgrade-watcher.sh). */
function getTriggeredBy(): string | null {
try {
if (!fs.existsSync(TRIGGERED_BY_FILE)) return null;
return fs.readFileSync(TRIGGERED_BY_FILE, 'utf-8').trim() || null;
} catch {
return null;
}
}
/** Remove the triggered-by marker file. */
function clearTriggeredBy(): void {
try {
if (fs.existsSync(TRIGGERED_BY_FILE)) {
fs.unlinkSync(TRIGGERED_BY_FILE);
}
} catch {
// Ignore
}
}
export const upgradeService = {
getStatus,
getProgress,
@@ -179,4 +225,8 @@ export const upgradeService = {
triggerUpgrade,
clearResult,
clearStaleProgress,
archiveResult,
getHistory,
getTriggeredBy,
clearTriggeredBy,
};

View File

@@ -6,19 +6,39 @@ import { createUserSchema, updateUserSchema, listUsersSchema } from './users.sch
import { validate } from '../../middleware/validate';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { hasAnyRole, ADMIN_ROLES } from '../../utils/roles';
import { hasAnyRole, ADMIN_ROLES, getUserRoles } from '../../utils/roles';
import { prisma } from '../../config/database';
import { emailService } from '../../services/email.service';
import { env } from '../../config/env';
import { logger } from '../../utils/logger';
import { userProvisioningService } from '../../services/user-provisioning/provisioning.service';
/** Check if user can manage other users (SUPER_ADMIN or canManageUsers permission) */
function canManageUsers(user: { roles?: unknown; role?: UserRole; permissions?: Record<string, unknown> | null }): boolean {
const roles = getUserRoles(user);
if (roles.includes(UserRole.SUPER_ADMIN)) return true;
return !!(user.permissions as Record<string, unknown> | null)?.canManageUsers;
}
/** Middleware: require user management permission */
function requireUserManagement(req: Request, res: Response, next: NextFunction) {
if (!req.user) {
res.status(401).json({ error: { message: 'Authentication required', code: 'AUTH_REQUIRED' } });
return;
}
if (!canManageUsers(req.user as any)) {
res.status(403).json({ error: { message: 'Insufficient permissions', code: 'FORBIDDEN' } });
return;
}
next();
}
const router = Router();
// All user routes require authentication
router.use(authenticate);
// GET /api/users — list users (admin only)
// GET /api/users — list users (any admin)
router.get(
'/',
requireRole(...ADMIN_ROLES),
@@ -55,10 +75,10 @@ router.get(
}
);
// POST /api/users — create user (admin only)
// POST /api/users — create user (SUPER_ADMIN or canManageUsers)
router.post(
'/',
requireRole(...ADMIN_ROLES),
requireUserManagement,
validate(createUserSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
@@ -70,7 +90,7 @@ router.post(
}
);
// PUT /api/users/:id — update user (admin or self, role changes admin-only)
// PUT /api/users/:id — update user (admin or self, role changes require user management perm)
router.put(
'/:id',
async (req: Request, res: Response, next: NextFunction) => {
@@ -78,17 +98,19 @@ router.put(
const id = req.params.id as string;
const isAdminUser = hasAnyRole(req.user!, ADMIN_ROLES);
const isSelf = req.user!.id === id;
const canManage = canManageUsers(req.user as any);
if (!isAdminUser && !isSelf) {
res.status(403).json({ error: { message: 'Insufficient permissions', code: 'FORBIDDEN' } });
return;
}
// Non-admins cannot change role, roles, or status
if (!isAdminUser) {
// Only users with management permission can change role, roles, or status
if (!canManage) {
delete req.body.role;
delete req.body.roles;
delete req.body.status;
delete req.body.permissions;
}
const parsed = updateUserSchema.parse(req.body);
@@ -100,12 +122,10 @@ router.put(
}
);
// POST /api/users/:id/approve — approve pending user (admin only)
const approveSchema = z.object({}).optional();
// POST /api/users/:id/approve — approve pending user (SUPER_ADMIN or canManageUsers)
router.post(
'/:id/approve',
requireRole(...ADMIN_ROLES),
requireUserManagement,
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
@@ -147,14 +167,14 @@ router.post(
}
);
// POST /api/users/:id/reject — reject pending user (admin only)
// POST /api/users/:id/reject — reject pending user (SUPER_ADMIN or canManageUsers)
const rejectSchema = z.object({
reason: z.string().max(500).optional(),
});
router.post(
'/:id/reject',
requireRole(...ADMIN_ROLES),
requireUserManagement,
validate(rejectSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
@@ -183,7 +203,7 @@ router.post(
}
);
// GET /api/users/:id/contact — get linked Contact for a user (admin only)
// GET /api/users/:id/contact — get linked Contact for a user (any admin)
router.get(
'/:id/contact',
requireRole(...ADMIN_ROLES),
@@ -205,10 +225,10 @@ router.get(
}
);
// DELETE /api/users/:id — delete user (admin only)
// DELETE /api/users/:id — delete user (SUPER_ADMIN or canManageUsers)
router.delete(
'/:id',
requireRole(...ADMIN_ROLES),
requireUserManagement,
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;

View File

@@ -165,12 +165,17 @@ export const usersService = {
select: userSelect,
});
// Invalidate sessions when user is deactivated
const deactivatedStatuses = ['INACTIVE', 'PENDING_APPROVAL', 'PENDING_VERIFICATION'];
// Invalidate sessions when user is deactivated or banned
const deactivatedStatuses = ['INACTIVE', 'BANNED', 'PENDING_APPROVAL', 'PENDING_VERIFICATION'];
if (data.status && deactivatedStatuses.includes(data.status)) {
await prisma.refreshToken.deleteMany({ where: { userId: id } });
}
// Invalidate sessions when password is changed by admin
if (data.password) {
await prisma.refreshToken.deleteMany({ where: { userId: id } });
}
// Fire-and-forget: sync changes to provisioned services
userProvisioningService.onUserUpdated(toCMUser(user), data).catch(err => {
logger.warn('User provisioning hook (update) failed:', err);

View File

@@ -113,7 +113,10 @@ import { checkinRouter } from './modules/ticketed-events/checkin.routes';
import { sseService } from './modules/social/sse.service';
import { presenceService } from './modules/social/presence.service';
import { upgradeService } from './modules/upgrade/upgrade.service';
import { autoUpgradeService } from './services/auto-upgrade.service';
import { calendarFeedQueueService } from './services/calendar-feed-queue.service';
import { WebSocketServer } from 'ws';
import { docsCollabService } from './modules/docs/docs-collab.service';
const app = express();
@@ -395,6 +398,11 @@ async function start() {
// Clean up stale upgrade progress on startup
upgradeService.clearStaleProgress();
// Archive any pending upgrade result to history + send notifications
autoUpgradeService.handlePostRestartResult().catch(() => {});
// Start auto-upgrade scheduler if enabled
autoUpgradeService.start().catch(() => {});
// Setup Rocket.Chat notification channels (non-blocking)
rocketchatWebhookService.setupChannels().catch(() => {});
@@ -446,9 +454,32 @@ async function start() {
});
}, 24 * 60 * 60 * 1000);
app.listen(env.PORT, () => {
const server = app.listen(env.PORT, () => {
logger.info(`API server running on port ${env.PORT} [${env.NODE_ENV}]`);
});
// --- WebSocket upgrade handler for docs collaboration ---
const wss = new WebSocketServer({ noServer: true });
server.on('upgrade', (request, socket, head) => {
const url = new URL(request.url || '', `http://${request.headers.host}`);
// HocuspocusProvider connects to the base URL; document name is sent via protocol
const collabPath = '/api/docs/collaborate';
if (url.pathname !== collabPath && !url.pathname.startsWith(collabPath + '/')) {
socket.destroy();
return;
}
wss.handleUpgrade(request, socket, head, (ws) => {
// Document name may be in the URL path (y-websocket) or sent via protocol (HocuspocusProvider)
const pathSuffix = url.pathname.slice(collabPath.length + 1); // strip /api/docs/collaborate/
const documentName = pathSuffix ? decodeURIComponent(pathSuffix) : '';
const token = url.searchParams.get('token') || '';
docsCollabService.handleConnection(ws, request, { documentName, token });
});
});
// Clean stale collab states on startup + daily
docsCollabService.cleanupStaleStates().catch(() => {});
setInterval(() => docsCollabService.cleanupStaleStates().catch(() => {}), 24 * 60 * 60 * 1000);
} catch (err) {
logger.error('Failed to start server:', err);
process.exit(1);
@@ -462,6 +493,7 @@ for (const signal of ['SIGTERM', 'SIGINT']) {
process.on(signal, async () => {
logger.info(`${signal} received, shutting down...`);
sseService.closeAll();
await docsCollabService.shutdown();
await stopProxy();
await emailQueueService.close();
await notificationQueueService.close();

View File

@@ -0,0 +1,257 @@
import { UserRole } from '@prisma/client';
import { upgradeService } from '../modules/upgrade/upgrade.service';
import { siteSettingsService } from '../modules/settings/settings.service';
import { emailService } from './email.service';
import { getAdminEmailsByRole } from './notification.helper';
import { logger } from '../utils/logger';
/**
* Auto-upgrade scheduler — checks for updates on a configurable schedule,
* and triggers upgrades automatically when the repo is behind.
*
* Pattern: same setInterval-based scheduling as Listmonk sync, canvass cleanup, etc.
*/
type SchedulePreset = 'daily-3am' | 'daily-4am' | 'daily-5am' | 'weekly-sun-3am' | 'weekly-mon-3am' | '12h' | '24h';
/** Map presets to their repeat interval in ms and initial delay calculation. */
const SCHEDULE_CONFIG: Record<SchedulePreset, { intervalMs: number; targetHour?: number; targetDay?: number }> = {
'daily-3am': { intervalMs: 24 * 60 * 60 * 1000, targetHour: 3 },
'daily-4am': { intervalMs: 24 * 60 * 60 * 1000, targetHour: 4 },
'daily-5am': { intervalMs: 24 * 60 * 60 * 1000, targetHour: 5 },
'weekly-sun-3am': { intervalMs: 7 * 24 * 60 * 60 * 1000, targetHour: 3, targetDay: 0 },
'weekly-mon-3am': { intervalMs: 7 * 24 * 60 * 60 * 1000, targetHour: 3, targetDay: 1 },
'12h': { intervalMs: 12 * 60 * 60 * 1000 },
'24h': { intervalMs: 24 * 60 * 60 * 1000 },
};
const STARTUP_GRACE_MS = 5 * 60 * 1000; // 5 minutes after startup for interval presets
const CHECK_POLL_INTERVAL_MS = 3000;
const CHECK_POLL_TIMEOUT_MS = 60000;
class AutoUpgradeService {
private initialTimer: ReturnType<typeof setTimeout> | null = null;
private repeatTimer: ReturnType<typeof setInterval> | null = null;
/** Read settings and start (or restart) the scheduler if enabled. */
async start(): Promise<void> {
this.stop();
try {
const settings = await siteSettingsService.get();
if (!settings.enableAutoUpgrade) {
logger.info('Auto-upgrade: disabled');
return;
}
const schedule = (settings.autoUpgradeSchedule || 'daily-3am') as SchedulePreset;
const config = SCHEDULE_CONFIG[schedule];
if (!config) {
logger.warn(`Auto-upgrade: unknown schedule "${schedule}", disabling`);
return;
}
const initialDelay = this.calculateInitialDelay(schedule, config);
const nextRunDate = new Date(Date.now() + initialDelay);
logger.info(
`Auto-upgrade: enabled (schedule=${schedule}, ` +
`interval=${Math.round(config.intervalMs / 3600000)}h, ` +
`next run=${nextRunDate.toLocaleString()})`,
);
this.initialTimer = setTimeout(() => {
this.runAutoUpgrade().catch(() => {});
// Then repeat on interval
this.repeatTimer = setInterval(
() => this.runAutoUpgrade().catch(() => {}),
config.intervalMs,
);
}, initialDelay);
} catch (err) {
logger.warn('Auto-upgrade: failed to start scheduler:', err);
}
}
/** Stop all timers. */
stop(): void {
if (this.initialTimer) {
clearTimeout(this.initialTimer);
this.initialTimer = null;
}
if (this.repeatTimer) {
clearInterval(this.repeatTimer);
this.repeatTimer = null;
}
}
/** The scheduled task: check for updates, trigger upgrade if behind. */
async runAutoUpgrade(): Promise<void> {
logger.info('Auto-upgrade: starting scheduled check');
if (upgradeService.isRunning()) {
logger.info('Auto-upgrade: upgrade already in progress, skipping');
return;
}
try {
// Write marker so post-restart knows this was an auto-upgrade
const fs = await import('fs');
const path = await import('path');
const markerPath = path.resolve('/app/upgrade/triggered-by.txt');
fs.mkdirSync(path.dirname(markerPath), { recursive: true });
fs.writeFileSync(markerPath, 'auto-upgrade', 'utf-8');
// Trigger update check
const statusBefore = upgradeService.getStatus();
const checkedAtBefore = statusBefore?.checkedAt || null;
upgradeService.triggerCheck('auto-upgrade');
// Poll for check completion
const checkComplete = await this.pollForCheckComplete(checkedAtBefore);
if (!checkComplete) {
logger.warn('Auto-upgrade: check timed out after 60s');
return;
}
const status = upgradeService.getStatus();
if (!status || status.commitsBehind === 0) {
logger.info('Auto-upgrade: already up to date');
// Clean up marker since no upgrade needed
upgradeService.clearTriggeredBy();
return;
}
logger.info(`Auto-upgrade: ${status.commitsBehind} commits behind, triggering upgrade`);
// Read settings for pullServices option
const settings = await siteSettingsService.get();
upgradeService.triggerUpgrade('auto-upgrade', {
skipBackup: false,
pullServices: settings.autoUpgradePullServices,
});
// API will restart during upgrade — post-restart handler takes over
} catch (err) {
logger.error('Auto-upgrade: failed:', err);
// Clean up marker on error
upgradeService.clearTriggeredBy();
}
}
/**
* Called on API startup — archives any pending result to history,
* sends notification emails if this was an auto-upgrade.
*/
async handlePostRestartResult(): Promise<void> {
const result = upgradeService.getResult();
if (!result) return;
const triggeredBy = upgradeService.getTriggeredBy();
// Archive result to history (add triggeredBy info)
const enrichedResult = { ...result, triggeredBy: triggeredBy || 'manual' };
upgradeService.archiveResult(enrichedResult);
logger.info(`Post-restart: archived upgrade result (triggeredBy=${triggeredBy || 'manual'}, success=${result.success})`);
// Send notification email if auto-upgrade and notifications enabled
if (triggeredBy === 'auto-upgrade') {
try {
const settings = await siteSettingsService.get();
if (settings.notifyAdminAutoUpgrade) {
const emails = await getAdminEmailsByRole([UserRole.SUPER_ADMIN]);
if (emails.length > 0) {
const orgName = settings.organizationName || 'Changemaker Lite';
const statusWord = result.success ? 'completed' : 'failed';
const subject = `[${orgName}] Auto-upgrade ${statusWord}`;
const warningsHtml = result.warnings.length > 0
? `<h3>Warnings</h3><ul>${result.warnings.map((w) => `<li>${w}</li>`).join('')}</ul>`
: '';
const html = [
`<h2>Auto-upgrade ${statusWord}</h2>`,
`<p>${result.message}</p>`,
`<table style="border-collapse:collapse">`,
`<tr><td style="padding:4px 12px 4px 0"><strong>Previous:</strong></td><td><code>${result.previousCommit}</code></td></tr>`,
`<tr><td style="padding:4px 12px 4px 0"><strong>Current:</strong></td><td><code>${result.newCommit}</code></td></tr>`,
`<tr><td style="padding:4px 12px 4px 0"><strong>Commits:</strong></td><td>${result.commitCount}</td></tr>`,
`<tr><td style="padding:4px 12px 4px 0"><strong>Duration:</strong></td><td>${result.durationSeconds}s</td></tr>`,
`<tr><td style="padding:4px 12px 4px 0"><strong>Completed:</strong></td><td>${result.completedAt}</td></tr>`,
`</table>`,
warningsHtml,
`<p style="color:#888;font-size:12px">You can configure auto-upgrade settings in Settings → System.</p>`,
].join('\n');
for (const email of emails) {
await emailService.sendEmail({
to: email,
subject,
html,
text: `Auto-upgrade ${statusWord}: ${result.message}\nPrevious: ${result.previousCommit}\nCurrent: ${result.newCommit}\nCommits: ${result.commitCount}\nDuration: ${result.durationSeconds}s`,
});
}
logger.info(`Auto-upgrade: notification sent to ${emails.length} admin(s)`);
}
}
} catch (err) {
logger.warn('Auto-upgrade: failed to send notification:', err);
}
}
// Clean up result and marker files
upgradeService.clearResult();
upgradeService.clearTriggeredBy();
}
/** Calculate ms until the next scheduled run. */
private calculateInitialDelay(
schedule: SchedulePreset,
config: { intervalMs: number; targetHour?: number; targetDay?: number },
): number {
if (config.targetHour === undefined) {
// Interval presets (12h, 24h) — start after grace period
return STARTUP_GRACE_MS;
}
const now = new Date();
const target = new Date(now);
target.setHours(config.targetHour, 0, 0, 0);
if (config.targetDay !== undefined) {
// Weekly preset — find the next matching day
const daysUntil = (config.targetDay - now.getDay() + 7) % 7;
if (daysUntil === 0 && now >= target) {
// Same day but already past — next week
target.setDate(target.getDate() + 7);
} else {
target.setDate(target.getDate() + daysUntil);
}
} else {
// Daily preset — if already past today's target, use tomorrow
if (now >= target) {
target.setDate(target.getDate() + 1);
}
}
return target.getTime() - now.getTime();
}
/** Poll status.json until checkedAt changes (indicating check completed). */
private pollForCheckComplete(previousCheckedAt: string | null): Promise<boolean> {
return new Promise((resolve) => {
const start = Date.now();
const interval = setInterval(() => {
if (Date.now() - start > CHECK_POLL_TIMEOUT_MS) {
clearInterval(interval);
resolve(false);
return;
}
const status = upgradeService.getStatus();
if (status && status.checkedAt !== previousCheckedAt) {
clearInterval(interval);
resolve(true);
}
}, CHECK_POLL_INTERVAL_MS);
});
}
}
export const autoUpgradeService = new AutoUpgradeService();

View File

@@ -3,12 +3,41 @@ import { UserRole } from '@prisma/client';
const ROLE_PRIORITY: Record<string, number> = {
SUPER_ADMIN: 5,
INFLUENCE_ADMIN: 4,
MAP_ADMIN: 3,
MAP_ADMIN: 4,
BROADCAST_ADMIN: 4,
CONTENT_ADMIN: 4,
MEDIA_ADMIN: 4,
PAYMENTS_ADMIN: 4,
EVENTS_ADMIN: 4,
SOCIAL_ADMIN: 4,
USER: 2,
TEMP: 1,
};
export const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
/** All admin roles (any user with one of these can access /app) */
export const ADMIN_ROLES: UserRole[] = [
UserRole.SUPER_ADMIN,
UserRole.INFLUENCE_ADMIN,
UserRole.MAP_ADMIN,
UserRole.BROADCAST_ADMIN,
UserRole.CONTENT_ADMIN,
UserRole.MEDIA_ADMIN,
UserRole.PAYMENTS_ADMIN,
UserRole.EVENTS_ADMIN,
UserRole.SOCIAL_ADMIN,
];
// Module-specific role groups
export const INFLUENCE_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN];
export const MAP_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
export const BROADCAST_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.BROADCAST_ADMIN];
export const CONTENT_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.CONTENT_ADMIN];
export const MEDIA_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MEDIA_ADMIN];
export const PAYMENTS_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.PAYMENTS_ADMIN];
export const EVENTS_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.EVENTS_ADMIN];
export const SOCIAL_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.SOCIAL_ADMIN];
export const SYSTEM_ROLES: UserRole[] = [UserRole.SUPER_ADMIN];
export const SCHEDULING_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN, UserRole.EVENTS_ADMIN];
/** Check if the user has any of the specified roles */
export function hasAnyRole(user: { roles?: unknown; role?: UserRole }, roles: UserRole[]): boolean {