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:
42
api/package-lock.json
generated
42
api/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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...');
|
||||
|
||||
16
api/src/middleware/correlation-id.ts
Normal file
16
api/src/middleware/correlation-id.ts
Normal 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();
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
404
api/src/modules/docs/docs-reset.service.ts
Normal file
404
api/src/modules/docs/docs-reset.service.ts
Normal 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,
|
||||
};
|
||||
@@ -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)
|
||||
|
||||
20
api/src/modules/media/middleware/error-handler.ts
Normal file
20
api/src/modules/media/middleware/error-handler.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
1
api/src/types/express.d.ts
vendored
1
api/src/types/express.d.ts
vendored
@@ -9,6 +9,7 @@ declare global {
|
||||
role: UserRole;
|
||||
roles: UserRole[];
|
||||
};
|
||||
correlationId?: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user