Add guided tour, media enhancements, error handling, and DevOps improvements

Major additions: onboarding tour system, correlation-id middleware, media
error handler, restore script, env validation script, Dockerignore files.
Updates across 70+ admin components for improved UX and error handling.

Bunker Admin
This commit is contained in:
2026-03-26 10:31:51 -06:00
parent 0c634e100f
commit 39d74e7b85
127 changed files with 3051 additions and 380 deletions

42
api/package-lock.json generated
View File

@@ -43,6 +43,7 @@
"sharp": "^0.34.5",
"stripe": "^20.3.1",
"winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0",
"ws": "^8.19.0",
"yaml": "^2.8.2",
"yjs": "^13.6.29",
@@ -3653,6 +3654,14 @@
"resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz",
"integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw=="
},
"node_modules/file-stream-rotator": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/file-stream-rotator/-/file-stream-rotator-0.6.1.tgz",
"integrity": "sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==",
"dependencies": {
"moment": "^2.29.1"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -4391,6 +4400,14 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"engines": {
"node": "*"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@ -4535,6 +4552,14 @@
"node": ">=0.10.0"
}
},
"node_modules/object-hash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"engines": {
"node": ">= 6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -5693,6 +5718,23 @@
"node": ">= 12.0.0"
}
},
"node_modules/winston-daily-rotate-file": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/winston-daily-rotate-file/-/winston-daily-rotate-file-5.0.0.tgz",
"integrity": "sha512-JDjiXXkM5qvwY06733vf09I2wnMXpZEhxEVOSPenZMii+g7pcDcTBt2MRugnoi8BwVSuCT2jfRXBUy+n1Zz/Yw==",
"dependencies": {
"file-stream-rotator": "^0.6.1",
"object-hash": "^3.0.0",
"triple-beam": "^1.4.1",
"winston-transport": "^4.7.0"
},
"engines": {
"node": ">=8"
},
"peerDependencies": {
"winston": "^3"
}
},
"node_modules/winston-transport": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz",

View File

@@ -51,6 +51,7 @@
"sharp": "^0.34.5",
"stripe": "^20.3.1",
"winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0",
"ws": "^8.19.0",
"yaml": "^2.8.2",
"yjs": "^13.6.29",

View File

@@ -58,6 +58,10 @@ async function main() {
});
if (admin) {
console.log(` Found existing admin user: ${admin.email}`);
} else {
console.error('❌ FATAL: No SUPER_ADMIN user exists and none could be created.');
console.error(' Fix INITIAL_ADMIN_PASSWORD in .env (12+ chars, uppercase, lowercase, digit)');
process.exit(2);
}
}

View File

@@ -11,6 +11,12 @@ const envSchema = z.object({
ADMIN_URL: z.string().default('http://localhost:3000'),
DOMAIN: z.string().default('cmlite.org'),
// Logging
LOG_DIR: z.string().default('/app/logs'),
// Security
CSP_ENABLED: z.string().default('false'),
// Bunker Ops (Fleet Management)
INSTANCE_LABEL: z.string().default(''),
BUNKER_OPS_ENABLED: z.string().default('false'),

View File

@@ -30,6 +30,7 @@ import { photoUploadRoutes } from './modules/media/routes/photo-upload.routes';
import { photoAlbumsRoutes } from './modules/media/routes/photo-albums.routes';
import { photosPublicRoutes } from './modules/media/routes/photos-public.routes';
import { photoEngagementRoutes } from './modules/media/routes/photo-engagement.routes';
import { mediaErrorHandler } from './modules/media/middleware/error-handler';
// Add BigInt serialization support for Prisma BigInt fields
// This converts BigInt values to strings when JSON.stringify() is called
@@ -45,6 +46,8 @@ const fastify = Fastify({
trustProxy: true,
});
fastify.setErrorHandler(mediaErrorHandler);
// Graceful shutdown handler
process.on('SIGTERM', async () => {
logger.info('SIGTERM received, shutting down gracefully...');

View File

@@ -0,0 +1,16 @@
import { randomUUID } from 'crypto';
import { Request, Response, NextFunction } from 'express';
const CORRELATION_HEADER = 'x-request-id';
/**
* Middleware that assigns a unique correlation ID to each request.
* Uses the incoming x-request-id header if present, otherwise generates a new UUID.
* Sets the correlation ID on both the request object and response header.
*/
export function correlationId(req: Request, res: Response, next: NextFunction) {
const id = (req.headers[CORRELATION_HEADER] as string) || randomUUID();
req.correlationId = id;
res.setHeader(CORRELATION_HEADER, id);
next();
}

View File

@@ -16,7 +16,7 @@ export class AppError extends Error {
export function errorHandler(
err: Error,
_req: Request,
req: Request,
res: Response,
_next: NextFunction
) {
@@ -47,7 +47,13 @@ export function errorHandler(
return;
}
logger.error('Unhandled error:', err);
logger.error('Unhandled error', {
error: err.message,
stack: err.stack,
correlationId: req.correlationId,
path: req.path,
method: req.method,
});
res.status(500).json({
error: {

View File

@@ -0,0 +1,404 @@
import { cp, rm, readdir, mkdir, writeFile, stat } from 'fs/promises';
import path from 'path';
import { env } from '../../config/env';
import { logger } from '../../utils/logger';
import { docsFilesService } from './docs-files.service';
import { mkdocsConfigService } from './mkdocs-config.service';
const PRESERVED_DIRS = [
'hooks',
'assets',
'javascripts',
'overrides',
'stylesheets',
'blog',
'comments',
'partials',
'includes',
];
const BASELINE_INDEX_MD = `# Welcome to Your MkDocs Site
This site has been reset to baseline configuration.
## Getting Started
- Edit \`docs/index.md\` to change this page
- Add new pages in the \`docs/\` directory
- Configure navigation in \`mkdocs.yml\`
- Customize the theme in \`docs/overrides/\`
## Features Preserved
Your custom code has been preserved in:
- \`hooks/\` - Custom build hooks
- \`assets/\` - Images and static files
- \`javascripts/\` - Custom JavaScript
- \`overrides/\` - Theme overrides
- \`stylesheets/\` - Custom CSS
- \`blog/\` - Blog content
## Next Steps
1. Start adding your content
2. Configure the navigation
3. Customize the appearance
4. Deploy your site
---
*Built with MkDocs Material*
`;
const BASELINE_GETTING_STARTED_MD = `# Getting Started
Welcome to your fresh MkDocs Material site!
## Adding Content
Create new markdown files in the \`docs/\` directory:
\`\`\`bash
docs/
\u251c\u2500\u2500 index.md # Homepage
\u251c\u2500\u2500 getting-started.md # This page
\u251c\u2500\u2500 page1.md # Your content
\u2514\u2500\u2500 page2.md # More content
\`\`\`
## Configuring Navigation
Edit \`mkdocs.yml\` to add navigation:
\`\`\`yaml
nav:
- Home: index.md
- Getting Started: getting-started.md
- Your Section:
- Page 1: page1.md
- Page 2: page2.md
\`\`\`
## Using the Blog
The blog plugin is already configured. Add posts in \`docs/blog/posts/\`:
\`\`\`markdown
---
date: 2024-01-01
categories:
- News
---
# Your Blog Post Title
Post content here...
\`\`\`
## Customization
- Theme overrides: \`docs/overrides/\`
- Custom CSS: \`docs/stylesheets/\`
- Custom JS: \`docs/javascripts/\`
`;
const HOME_HTML = `{% extends "main.html" %}
{% block extrahead %}
{{ super() }}
<link rel="stylesheet" href="{{ 'stylesheets/home.css' | url }}">
{% endblock %}
{% block content %}
<div class="home-container">
<div class="home-hero">
{% if config.theme.logo %}
<img src="{{ config.theme.logo | url }}" alt="Logo" class="home-logo">
{% endif %}
<h1 class="home-title">Let's get started!</h1>
<p class="home-subtitle">
Your MkDocs Material site is ready for customization.
</p>
<div class="home-actions">
<a href="{{ 'getting-started/' | url }}" class="home-button home-button-primary">
Get Started
</a>
<a href="https://squidfunk.github.io/mkdocs-material/" class="home-button home-button-secondary">
Documentation
</a>
</div>
</div>
<div class="home-features">
<div class="feature-card">
<div class="feature-icon">📝</div>
<h3>Write Content</h3>
<p>Create pages with Markdown</p>
</div>
<div class="feature-card">
<div class="feature-icon">🎨</div>
<h3>Customize Theme</h3>
<p>Make it your own</p>
</div>
<div class="feature-card">
<div class="feature-icon">🚀</div>
<h3>Deploy</h3>
<p>Share with the world</p>
</div>
</div>
</div>
{% endblock %}
`;
const HOME_CSS = `/* Simple home page styles */
.home-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.home-hero {
text-align: center;
padding: 4rem 0;
}
.home-logo {
width: 120px;
height: 120px;
margin-bottom: 2rem;
}
.home-title {
font-size: 3rem;
font-weight: 700;
margin: 0 0 1rem 0;
color: var(--md-primary-fg-color);
}
.home-subtitle {
font-size: 1.25rem;
color: var(--md-default-fg-color--light);
margin: 0 0 2rem 0;
}
.home-actions {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
.home-button {
display: inline-block;
padding: 0.75rem 2rem;
border-radius: 0.25rem;
text-decoration: none;
font-weight: 500;
transition: all 0.2s;
}
.home-button-primary {
background: var(--md-primary-fg-color);
color: var(--md-primary-bg-color);
}
.home-button-primary:hover {
background: var(--md-primary-fg-color--dark);
transform: translateY(-2px);
}
.home-button-secondary {
border: 2px solid var(--md-primary-fg-color);
color: var(--md-primary-fg-color);
}
.home-button-secondary:hover {
background: var(--md-primary-fg-color);
color: var(--md-primary-bg-color);
}
.home-features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 2rem;
margin-top: 4rem;
}
.feature-card {
text-align: center;
padding: 2rem;
border-radius: 0.5rem;
background: var(--md-code-bg-color);
}
.feature-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.feature-card h3 {
margin: 0 0 0.5rem 0;
color: var(--md-default-fg-color);
}
.feature-card p {
margin: 0;
color: var(--md-default-fg-color--light);
}
/* Dark mode support */
[data-md-color-scheme="slate"] .home-logo {
filter: brightness(0.9);
}
[data-md-color-scheme="slate"] .feature-card {
background: var(--md-default-fg-color--lightest);
}
/* Mobile responsive */
@media (max-width: 768px) {
.home-title {
font-size: 2rem;
}
.home-subtitle {
font-size: 1rem;
}
.home-actions {
flex-direction: column;
align-items: center;
}
.home-button {
width: 100%;
max-width: 300px;
text-align: center;
}
}
`;
interface ResetResult {
success: boolean;
backupPath: string;
filesReset: number;
filesPreserved: number;
}
async function resetToBaseline(): Promise<ResetResult> {
const docsDir = path.resolve(env.MKDOCS_DOCS_PATH);
const mkdocsRoot = path.dirname(env.MKDOCS_CONFIG_PATH);
const timestamp = new Date().toISOString().replace(/[:.]/g, '').replace('T', '_').slice(0, 15);
const backupDir = path.join(mkdocsRoot, 'backups', `docs_backup_${timestamp}`);
const tempDir = path.join('/tmp', `mkdocs-reset-${process.pid}`);
logger.info('Starting docs reset to baseline', { docsDir, backupDir });
// Step a: Create timestamped backup
await mkdir(backupDir, { recursive: true });
await cp(docsDir, path.join(backupDir, 'docs'), { recursive: true });
await cp(env.MKDOCS_CONFIG_PATH, path.join(backupDir, 'mkdocs.yml'));
logger.info('Backup created', { backupDir });
// Step b: Save preserved directories to temp
await mkdir(tempDir, { recursive: true });
let filesPreserved = 0;
for (const dir of PRESERVED_DIRS) {
const srcDir = path.join(docsDir, dir);
try {
const info = await stat(srcDir);
if (info.isDirectory()) {
await cp(srcDir, path.join(tempDir, dir), { recursive: true });
filesPreserved++;
logger.info(`Preserved directory: ${dir}`);
}
} catch {
// Directory doesn't exist, skip
}
}
// Step c: Clear the docs directory
const entries = await readdir(docsDir);
for (const entry of entries) {
await rm(path.join(docsDir, entry), { recursive: true, force: true });
}
logger.info('Docs directory cleared');
// Step d: Write baseline content
let filesReset = 0;
await writeFile(path.join(docsDir, 'index.md'), BASELINE_INDEX_MD, 'utf-8');
filesReset++;
await writeFile(path.join(docsDir, 'getting-started.md'), BASELINE_GETTING_STARTED_MD, 'utf-8');
filesReset++;
// Step e: Restore preserved directories from temp
for (const dir of PRESERVED_DIRS) {
const tempSrcDir = path.join(tempDir, dir);
try {
const info = await stat(tempSrcDir);
if (info.isDirectory()) {
await cp(tempSrcDir, path.join(docsDir, dir), { recursive: true });
logger.info(`Restored directory: ${dir}`);
}
} catch {
// Wasn't preserved, create empty directory
await mkdir(path.join(docsDir, dir), { recursive: true });
}
}
// Step f: Create home.html and home.css templates if they don't exist in preserved overrides/stylesheets
const homeHtmlPath = path.join(docsDir, 'overrides', 'home.html');
try {
await stat(homeHtmlPath);
logger.info('Existing home.html preserved');
} catch {
await mkdir(path.join(docsDir, 'overrides'), { recursive: true });
await writeFile(homeHtmlPath, HOME_HTML, 'utf-8');
filesReset++;
logger.info('Created baseline home.html');
}
const homeCssPath = path.join(docsDir, 'stylesheets', 'home.css');
try {
await stat(homeCssPath);
logger.info('Existing home.css preserved');
} catch {
await mkdir(path.join(docsDir, 'stylesheets'), { recursive: true });
await writeFile(homeCssPath, HOME_CSS, 'utf-8');
filesReset++;
logger.info('Created baseline home.css');
}
// Clean up temp directory
await rm(tempDir, { recursive: true, force: true }).catch(() => {});
// Step g: Invalidate docs file tree cache
await docsFilesService.invalidateTreeCache();
// Step h: Trigger a build
const buildResult = await mkdocsConfigService.triggerBuild();
if (!buildResult.success) {
logger.warn('MkDocs build after reset did not succeed', { output: buildResult.output });
}
logger.info('Docs reset to baseline complete', { filesReset, filesPreserved, backupDir });
return {
success: true,
backupPath: backupDir,
filesReset,
filesPreserved,
};
}
export const docsResetService = {
resetToBaseline,
};

View File

@@ -14,6 +14,7 @@ import { docsCollabService } from './docs-collab.service';
import { mkdocsConfigService } from './mkdocs-config.service';
import { headerBuilderService } from './header-builder.service';
import { headerConfigSchema } from './header-builder.schemas';
import { docsResetService } from './docs-reset.service';
const router = Router();
router.use(authenticate);
@@ -114,6 +115,21 @@ router.post(
},
);
// POST /api/docs/reset — reset docs content to baseline
router.post(
'/reset',
requireRole('SUPER_ADMIN'),
async (_req: Request, res: Response, next: NextFunction) => {
try {
const result = await docsResetService.resetToBaseline();
res.json(result);
} catch (err) {
logger.error('Docs reset failed', err);
next(err);
}
},
);
// --- Header Builder ---
// GET /api/docs/header-config — read header nav bar config (content editors only)

View File

@@ -0,0 +1,20 @@
import { FastifyError, FastifyReply, FastifyRequest } from 'fastify';
import { logger } from '../../../utils/logger';
export function mediaErrorHandler(
error: FastifyError | Error,
request: FastifyRequest,
reply: FastifyReply
) {
logger.error('Media API error', {
error: error.message,
stack: error.stack,
url: request.url,
method: request.method,
});
const statusCode = 'statusCode' in error ? error.statusCode ?? 500 : 500;
reply.status(statusCode).send({
message: statusCode === 500 ? 'Internal server error' : error.message,
});
}

View File

@@ -1,6 +1,7 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import Redis from 'ioredis';
import { env } from '../../../config/env.js';
import { logger } from '../../../utils/logger';
/**
* SSE Chat Stream Routes
@@ -94,7 +95,7 @@ export function broadcastCommentToVideo(
});
redis.publish(channel, message);
} catch (error) {
console.error('Failed to broadcast comment:', error);
logger.error('Failed to broadcast comment:', error);
}
}
@@ -114,7 +115,7 @@ export function broadcastReactionToVideo(
});
redis.publish(channel, message);
} catch (error) {
console.error('Failed to broadcast reaction:', error);
logger.error('Failed to broadcast reaction:', error);
}
}

View File

@@ -1,6 +1,7 @@
import { FastifyInstance, FastifyRequest } from 'fastify';
import { prisma } from '../../../config/database';
import { authenticate } from '../middleware/auth';
import { logger } from '../../../utils/logger';
interface ThreadsQuery {
limit?: string;
@@ -111,7 +112,7 @@ export async function chatThreadsRoutes(fastify: FastifyInstance) {
return reply.send({ threads: paginated, total: threads.length });
} catch (error) {
console.error('Failed to fetch chat threads:', error);
logger.error('Failed to fetch chat threads:', error);
return reply.code(500).send({ message: 'Failed to fetch chat threads' });
}
}
@@ -153,7 +154,7 @@ export async function chatThreadsRoutes(fastify: FastifyInstance) {
return reply.send({ message: 'Thread marked as read' });
} catch (error) {
console.error('Failed to mark thread as read:', error);
logger.error('Failed to mark thread as read:', error);
return reply.code(500).send({ message: 'Failed to mark thread as read' });
}
}

View File

@@ -2,6 +2,7 @@ import { FastifyInstance, FastifyRequest } from 'fastify';
import { prisma } from '../../../config/database';
import { requireAdminRole } from '../middleware/auth';
import { invalidateWordListCache } from '../services/word-filter.service';
import { logger } from '../../../utils/logger';
interface ListCommentsQuery {
page?: string;
@@ -56,7 +57,7 @@ export async function commentAdminRoutes(fastify: FastifyInstance) {
return reply.send({ total, pending, flagged, hidden, safe });
} catch (error) {
console.error('Failed to fetch comment stats:', error);
logger.error('Failed to fetch comment stats:', error);
return reply.code(500).send({ message: 'Failed to fetch comment stats' });
}
}
@@ -163,7 +164,7 @@ export async function commentAdminRoutes(fastify: FastifyInstance) {
totalPages: Math.ceil(total / limit),
});
} catch (error) {
console.error('Failed to fetch admin comments:', error);
logger.error('Failed to fetch admin comments:', error);
return reply.code(500).send({ message: 'Failed to fetch comments' });
}
}
@@ -217,7 +218,7 @@ export async function commentAdminRoutes(fastify: FastifyInstance) {
return reply.send({ message: 'Comment approved' });
} catch (error) {
console.error('Failed to approve comment:', error);
logger.error('Failed to approve comment:', error);
return reply.code(500).send({ message: 'Failed to approve comment' });
}
}
@@ -275,7 +276,7 @@ export async function commentAdminRoutes(fastify: FastifyInstance) {
return reply.send({ message: 'Comment hidden' });
} catch (error) {
console.error('Failed to hide comment:', error);
logger.error('Failed to hide comment:', error);
return reply.code(500).send({ message: 'Failed to hide comment' });
}
}
@@ -325,7 +326,7 @@ export async function commentAdminRoutes(fastify: FastifyInstance) {
return reply.send({ message: 'Comment unhidden' });
} catch (error) {
console.error('Failed to unhide comment:', error);
logger.error('Failed to unhide comment:', error);
return reply.code(500).send({ message: 'Failed to unhide comment' });
}
}
@@ -359,7 +360,7 @@ export async function commentAdminRoutes(fastify: FastifyInstance) {
return reply.send({ message: 'Notes updated' });
} catch (error) {
console.error('Failed to update notes:', error);
logger.error('Failed to update notes:', error);
return reply.code(500).send({ message: 'Failed to update notes' });
}
}
@@ -394,7 +395,7 @@ export async function commentAdminRoutes(fastify: FastifyInstance) {
return reply.send({ message: 'Comment deleted' });
} catch (error) {
console.error('Failed to delete comment:', error);
logger.error('Failed to delete comment:', error);
return reply.code(500).send({ message: 'Failed to delete comment' });
}
}
@@ -421,7 +422,7 @@ export async function commentAdminRoutes(fastify: FastifyInstance) {
return reply.send({ words });
} catch (error) {
console.error('Failed to fetch word filters:', error);
logger.error('Failed to fetch word filters:', error);
return reply.code(500).send({ message: 'Failed to fetch word filters' });
}
}
@@ -469,7 +470,7 @@ export async function commentAdminRoutes(fastify: FastifyInstance) {
return reply.code(201).send(entry);
} catch (error) {
console.error('Failed to add word filter:', error);
logger.error('Failed to add word filter:', error);
return reply.code(500).send({ message: 'Failed to add word filter' });
}
}
@@ -497,7 +498,7 @@ export async function commentAdminRoutes(fastify: FastifyInstance) {
return reply.send({ message: 'Word filter removed' });
} catch (error) {
console.error('Failed to delete word filter:', error);
logger.error('Failed to delete word filter:', error);
return reply.code(500).send({ message: 'Failed to delete word filter' });
}
}

View File

@@ -5,6 +5,7 @@ import { broadcastCommentToVideo } from './chat-stream.routes.js';
import { optionalAuth } from '../middleware/auth';
import { checkContent } from '../services/word-filter.service';
import { notifyUser } from './chat-notifications.routes';
import { logger } from '../../../utils/logger';
// Rate limiting map: userId/sessionId -> array of timestamps
const commentRateLimitMap = new Map<string, number[]>();
@@ -87,7 +88,7 @@ export async function commentsRoutes(fastify: FastifyInstance) {
}),
});
} catch (error) {
console.error('Failed to fetch comments:', error);
logger.error('Failed to fetch comments:', error);
return reply.code(500).send({ message: 'Failed to fetch comments' });
}
}
@@ -272,13 +273,13 @@ export async function commentsRoutes(fastify: FastifyInstance) {
}
} catch (notifyErr) {
// Non-critical: don't fail the comment creation
console.error('Failed to send chat notifications:', notifyErr);
logger.error('Failed to send chat notifications:', notifyErr);
}
}
return reply.code(201).send(broadcastData);
} catch (error) {
console.error('Failed to create comment:', error);
logger.error('Failed to create comment:', error);
return reply.code(500).send({ message: 'Failed to create comment' });
}
}

View File

@@ -57,8 +57,7 @@ export async function publicRoutes(fastify: FastifyInstance) {
if (sort === 'oldest') {
orderBy = { publishedAt: 'asc' };
} else if (sort === 'popular') {
// TODO: Sort by view count when analytics are implemented
orderBy = { publishedAt: 'desc' };
orderBy = { viewCount: 'desc' };
}
const videos = await prisma.video.findMany({

View File

@@ -1,3 +1,5 @@
import { existsSync, unlinkSync } from 'fs';
import path from 'path';
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
@@ -122,6 +124,7 @@ import { agendaRouter } from './modules/meetings/agenda.routes';
import { actionItemsRouter } from './modules/meetings/action-items.routes';
import { WebSocketServer } from 'ws';
import { docsCollabService } from './modules/docs/docs-collab.service';
import { correlationId } from './middleware/correlation-id';
const app = express();
@@ -129,8 +132,26 @@ const app = express();
app.set('trust proxy', 1);
// --- Middleware Stack ---
app.use(correlationId);
app.use(helmet({
contentSecurityPolicy: env.NODE_ENV === 'production' ? undefined : false,
contentSecurityPolicy: env.CSP_ENABLED === 'true'
? {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
fontSrc: ["'self'", 'https://fonts.gstatic.com', 'data:'],
imgSrc: ["'self'", 'data:', 'blob:', 'https:'],
connectSrc: ["'self'", 'wss:', 'ws:'],
frameSrc: ["'self'", `*.${env.DOMAIN}`],
frameAncestors: ["'self'", `*.${env.DOMAIN}`],
objectSrc: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
},
}
: false,
}));
app.use(cors({
@@ -174,9 +195,11 @@ app.use((req, res, next) => {
});
// --- Health Check ---
app.get('/api/health', healthMetricsRateLimit, async (_req, res) => {
app.get('/api/health', healthMetricsRateLimit, async (req, res) => {
const checks: Record<string, string> = {};
const detailed = req.query.detailed === 'true';
// Core checks (always run — used by Docker healthcheck)
try {
await prisma.$queryRaw`SELECT 1`;
checks.database = 'ok';
@@ -191,8 +214,34 @@ app.get('/api/health', healthMetricsRateLimit, async (_req, res) => {
checks.redis = 'error';
}
const healthy = Object.values(checks).every(v => v === 'ok');
res.status(healthy ? 200 : 503).json({ status: healthy ? 'healthy' : 'degraded', checks });
// Extended checks (opt-in, for monitoring/debugging)
if (detailed) {
// MkDocs dev server
try {
const mkdocsRes = await fetch(`http://${env.MKDOCS_CONTAINER_NAME}:8000`, { signal: AbortSignal.timeout(3000) });
checks.mkdocs = mkdocsRes.ok ? 'ok' : 'error';
} catch {
checks.mkdocs = 'error';
}
// Disk space (logs directory)
try {
const { statfs } = await import('fs/promises');
const stats = await statfs(env.LOG_DIR);
const freeGB = Number(stats.bavail) * Number(stats.bsize) / (1024 ** 3);
checks.disk = freeGB > 1 ? 'ok' : 'warning';
checks.diskFreeGB = freeGB.toFixed(1);
} catch {
checks.disk = 'unknown';
}
}
const coreHealthy = checks.database === 'ok' && checks.redis === 'ok';
res.status(coreHealthy ? 200 : 503).json({
status: coreHealthy ? 'healthy' : 'degraded',
version: process.env.npm_package_version || 'unknown',
checks,
});
});
// --- Metrics Endpoint (authenticated - SUPER_ADMIN only) ---
@@ -417,6 +466,18 @@ async function start() {
logger.warn('Startup sync of MkDocs overrides failed:', err);
});
// Check for docs reset flag (set by config.sh during setup)
const docsResetFlagPath = path.resolve(path.dirname(env.MKDOCS_CONFIG_PATH), '.reset-docs-on-startup');
if (existsSync(docsResetFlagPath)) {
const { docsResetService } = await import('./modules/docs/docs-reset.service');
docsResetService.resetToBaseline()
.then((result) => {
logger.info(`Docs reset completed: ${result.filesReset} files reset, ${result.filesPreserved} preserved`);
unlinkSync(docsResetFlagPath);
})
.catch((err) => logger.warn('Docs reset from config flag failed:', err));
}
// Validate MkDocs exports on startup (recurring runs handled by scheduled-jobs queue)
pagesService.validateExports()
.then(({ validated, repaired, errors }) => {

View File

@@ -9,6 +9,7 @@ declare global {
role: UserRole;
roles: UserRole[];
};
correlationId?: string;
}
}
}

View File

@@ -1,6 +1,16 @@
import winston from 'winston';
import { env } from '../config/env';
const consoleFormat = winston.format.combine(
winston.format.colorize(),
winston.format.printf(({ timestamp, level, message, ...meta }) => {
const metaStr = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : '';
return `${timestamp} [${level}]: ${message}${metaStr}`;
})
);
const transports: winston.transport[] = [new winston.transports.Console()];
export const logger = winston.createLogger({
level: env.NODE_ENV === 'production' ? 'info' : 'debug',
format: winston.format.combine(
@@ -8,13 +18,31 @@ export const logger = winston.createLogger({
winston.format.errors({ stack: true }),
env.NODE_ENV === 'production'
? winston.format.json()
: winston.format.combine(
winston.format.colorize(),
winston.format.printf(({ timestamp, level, message, ...meta }) => {
const metaStr = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : '';
return `${timestamp} [${level}]: ${message}${metaStr}`;
})
)
: consoleFormat
),
transports: [new winston.transports.Console()],
transports,
});
// Add file transport in production (dynamic import to avoid breaking dev when not installed)
if (env.NODE_ENV === 'production') {
import('winston-daily-rotate-file').then((mod) => {
const DailyRotateFile = mod.default;
logger.add(
new DailyRotateFile({
dirname: env.LOG_DIR,
filename: 'api-%DATE%.log',
datePattern: 'YYYY-MM-DD',
maxSize: '20m',
maxFiles: '14d',
zippedArchive: true,
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json(),
),
})
);
}).catch(() => {
logger.warn('winston-daily-rotate-file not installed, file logging disabled');
});
}