Files
apparel-designer/server.js
khalid@traclabs.com 5c21188df1 server: thread auth into mountEditorApi + apply ALL goods-editor migrations
Closes the coverage-gap audit's sharpest item (H8/D-auth): mountAuth's
return is captured (authGuards) and design-route ownership is passed
explicitly to mountEditorApi (customerId/isAdmin off req.customer/
req.admin — matches the module defaults but survives default drift);
/api/fonts/registered is admin-gated by a host-level requireAdmin
registered ahead of the module route (auth.middleware can't do this:
the module applies it to the whole /designs group, which would lock
guests out of design persistence). mockupsDir/storageDir now threaded
so design-route thumbnails get the mockup backdrop. Dev harness
unchanged: without DATABASE_URL the roster stays open and ownership
resolves to the guest model.

Also fixes a real bug the boot test surfaced: the host hardcoded
001_designs.sql, silently skipping 002_design_family.sql — the store
SELECTs design_family_id unconditionally, so every GET /api/designs
500'd ('column does not exist'). Now applies every *.sql in the
module's migrations dir in filename order (all idempotent).

Verified by booting both modes: auth ON → roster 403 admin_required,
designs unknown-id 404 (was 500), both migrations applied on a fresh
db; auth OFF → roster 200 with the font list, warning logged. NOTE:
goods-editor's ./server export resolves to dist/, which was stale
(built Jul 7 — predating the module's auth option, roster route, and
Aug security fixes); rebuilt from the branch source. Any deploy must
run the module's build, not just npm install the file: dep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 00:20:15 -05:00

592 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Host-side server. After the module extraction this shrunk from
// ~700 lines (the original editor's server.js) to a thin wrapper
// that mounts the module's middleware + provides the host's own
// upload endpoint.
//
// What stayed in the host
// ───────────────────────
// • The HTTP server itself (express, port binding, listen)
// • CORS, rate limiting, logger — host-level concerns
// • The /api/upload endpoint (host owns upload storage)
// • SPA static serving for the built frontend
// • Cleanup of old uploaded files (TTL sweep)
//
// What moved to the module (goods-editor/server)
// ──────────────────────────────────────────────
// • POST /api/export — the canvas+sharp render pipeline
// • GET /api/download/:filename — serve exported PNGs
// • GET /api/health — health probe
// • Font registration with node-canvas, plus GET
// /api/fonts/fonts.css and GET /api/fonts/<filename>
// for browser-side delivery. index.html loads the .css
// directly; the Google Fonts CDN link the host used to
// ship is gone with v0.8.
// • POST/GET/DELETE /api/designs/* — design persistence,
// mounted only when a Postgres `db` handle is passed to
// mountEditorApi. Wired (#23): we pass pgPool when
// DATABASE_URL is set; without it the routes don't mount
// and the client falls back to cart-without-design_id.
import express from 'express';
import cors from 'cors';
import multer from 'multer';
import { v4 as uuidv4 } from 'uuid';
import sharp from 'sharp';
import canvas from 'canvas';
import rateLimit from 'express-rate-limit';
import pino from 'pino';
import pinoHttp from 'pino-http';
import { fileURLToPath } from 'url';
import { basename, dirname, join, resolve } from 'path';
import { mkdirSync, existsSync, readdirSync, readFileSync, statSync, rmSync } from 'fs';
import { mountEditorApi } from 'goods-editor/server';
import { renderDesignComposite } from 'goods-editor/server';
import { mountTemplateStore } from 'template-manager/server';
import { marketingPreviewRenderer } from 'template-manager/server';
import { mountAuth, runMigrations } from '@khalidadil/auth-manager/server';
import pkg from 'pg';
const { Pool } = pkg;
import { SEED_PRODUCTS } from './src/constants/seedProducts.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const app = express();
const PORT = process.env.PORT || 3001;
const IS_PRODUCTION = process.env.NODE_ENV === 'production';
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
base: { service: 'apparel-designer' },
});
app.use(pinoHttp({
logger,
autoLogging: { ignore: (req) => req.url === '/api/health' },
}));
// ── Filesystem layout ──────────────────────────────────────────────
const uploadsDir = join(__dirname, 'uploads');
const exportsDir = join(__dirname, 'exports');
const templatesStorageDir = join(__dirname, 'templates');
const fontsDir = join(__dirname, 'node_modules', 'goods-editor', 'fonts');
const stickersDir = IS_PRODUCTION
? join(__dirname, 'dist', 'stickers')
: join(__dirname, 'public', 'stickers');
// (uploadsRoot + `sep` removed — dead since the path-guarded routes
// moved into goods-editor/template-manager; eslint now parses this
// file (ecmaVersion 2022) and flagged them.)
// v0.6 — mockupsDir is gone (variant mockups removed). Product
// mockups live under templatesStorageDir/products/<id>/sizes/<sid>/
// and are managed entirely by template-manager.
[uploadsDir, exportsDir, templatesStorageDir].forEach((dir) => {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
});
// ── TTL cleanup ─────────────────────────────────────────────────────
const FILE_TTL_MS = parseInt(process.env.FILE_TTL_MS || '', 10) || 24 * 60 * 60 * 1000;
const CLEANUP_INTERVAL_MS = parseInt(process.env.CLEANUP_INTERVAL_MS || '', 10) || 60 * 60 * 1000;
// Referenced-file exemption (#24) — template JSONs (element srcs,
// slot previewSrc, entry-config imagery) and persisted designs
// (Postgres designs.design_json) reference files as `/uploads/<name>`
// (or `/uploads/preview/<name>`). Those references are long-lived;
// sweeping the files at 24h breaks template renders and design
// re-renders. Before each sweep pass we collect every referenced
// basename (uploads filenames are UUID-based, so basename matching
// is collision-free) and skip deleting those files. A simple regex
// over raw JSON text keeps this robust to schema drift.
const UPLOADS_REF_RE = /\/uploads\/([A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*)/g;
function collectUploadRefs(text, out) {
UPLOADS_REF_RE.lastIndex = 0;
let m;
while ((m = UPLOADS_REF_RE.exec(text)) !== null) {
out.add(basename(m[1]));
}
}
// Recursively scan a directory's *.json files for /uploads/ references.
// Covers template-manager's whole storage tree: templates/<id>/
// metadata.json + versions/*.json, products/<id>/product.json, and
// the _*.json index docs.
function scanDirForUploadRefs(dir, out) {
if (!existsSync(dir)) return;
let entries = [];
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
for (const entry of entries) {
const full = join(dir, entry.name);
try {
if (entry.isDirectory()) {
scanDirForUploadRefs(full, out);
} else if (entry.isFile() && entry.name.endsWith('.json')) {
collectUploadRefs(readFileSync(full, 'utf8'), out);
}
} catch { /* unreadable entry — skip */ }
}
}
// Built fresh once per sweep pass (templates/designs change between
// passes; a single Set per pass is cache enough).
async function collectReferencedUploads() {
const referenced = new Set();
try { scanDirForUploadRefs(templatesStorageDir, referenced); } catch { /* never block the sweep */ }
// Persisted designs — only when Postgres is wired. Any db error
// (table not migrated yet, connection down) skips silently: the
// sweep must never crash the host.
if (pgPool) {
try {
const { rows } = await pgPool.query('SELECT design_json FROM designs WHERE deleted_at IS NULL');
for (const row of rows) {
collectUploadRefs(JSON.stringify(row.design_json), referenced);
}
} catch { /* db unavailable — protect what templates reference */ }
}
return referenced;
}
function cleanupOldFiles(dir, ttlMs, protectedNames = null, skipDirs = null) {
if (!existsSync(dir)) return { removed: 0, protectedCount: 0 };
const now = Date.now();
let removed = 0;
let protectedCount = 0;
let entries = [];
try { entries = readdirSync(dir); } catch { return { removed, protectedCount }; }
for (const entry of entries) {
const full = join(dir, entry);
try {
const stat = statSync(full);
if (stat.isFile() && now - stat.mtimeMs > ttlMs) {
if (protectedNames && protectedNames.has(entry)) {
protectedCount++;
} else {
rmSync(full);
removed++;
}
} else if (stat.isDirectory()) {
if (skipDirs && skipDirs.has(resolve(full))) continue;
const sub = cleanupOldFiles(full, ttlMs, protectedNames, skipDirs);
removed += sub.removed;
protectedCount += sub.protectedCount;
}
} catch { /* skip */ }
}
return { removed, protectedCount };
}
// exports/designs is goods-editor's designsDir (mountEditorApi default:
// <exportsDir>/designs). It holds persisted design artifacts — source
// images, thumbnails, print renders — whose lifecycle is db-driven
// (designs.deleted_at soft-delete, 30-day guest TTL), not this sweep's
// 24h TTL. Source images in particular are unrecoverable, so the sweep
// skips the whole subtree.
const designsArtifactsDir = resolve(join(exportsDir, 'designs'));
async function runCleanup() {
try {
const referenced = await collectReferencedUploads();
const u = cleanupOldFiles(uploadsDir, FILE_TTL_MS, referenced);
const e = cleanupOldFiles(exportsDir, FILE_TTL_MS, null, new Set([designsArtifactsDir]));
if (u.removed > 0 || e.removed > 0 || u.protectedCount > 0) {
logger.info({
removedUploads: u.removed,
removedExports: e.removed,
protectedUploads: u.protectedCount,
ttlMs: FILE_TTL_MS,
}, 'Cleanup pass complete');
}
} catch (err) {
logger.warn({ err: err.message }, 'Cleanup pass failed; will retry next interval');
}
}
// NOTE: the initial run + interval start AFTER the auth-manager
// bootstrap below — collectReferencedUploads reads `pgPool`, which is
// declared there (TDZ until then).
// ── App-level middleware (CORS, JSON, rate limits) ─────────────────
const corsOptions = IS_PRODUCTION
? { origin: process.env.CORS_ORIGIN || false }
: { origin: true };
app.use(cors(corsOptions));
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
// ── Facet personalization config (Project doc §R10) ──────────────
// Env-driven, host-injected into both module mounts. FACET_KEY is the
// master switch — unset ⇒ the feature is inert (facet-config reports
// disabled, the account routes no-op, templates are unfiltered, and the
// editor shows all stickers). The facet *values* (dog/cat/…) are
// admin-managed inside template-manager; only the definition is here.
const envBool = (v, dflt = false) => (
v === undefined || v === null || v === ''
? dflt
: /^(1|true|yes|on)$/i.test(String(v).trim())
);
const facetConfig = {
key: (process.env.FACET_KEY || '').trim() || null,
label: (process.env.FACET_LABEL || '').trim() || null,
selectionMode: process.env.FACET_SELECTION_MODE === 'single' ? 'single' : 'multi',
requireSelection: envBool(process.env.REQUIRE_FACET_SELECTION, false),
};
const requireTemplateCategory = envBool(process.env.REQUIRE_TEMPLATE_CATEGORY, false);
if (facetConfig.key) {
logger.info({
key: facetConfig.key,
label: facetConfig.label,
selectionMode: facetConfig.selectionMode,
requireSelection: facetConfig.requireSelection,
requireTemplateCategory,
}, 'Personalization facet enabled');
}
// ── auth-manager bootstrap ─────────────────────────────────────────
// Runs migrations + mounts auth routes if DATABASE_URL is set. If
// it isn't, we log a warning and continue without auth so a developer
// can still iterate on goods-editor / template-manager pieces without
// provisioning Postgres locally.
let pgPool = null;
// mountAuth's returned guards (requireAdmin etc.) — captured so they can
// be threaded into other module mounts below. Null when auth is disabled.
let authGuards = null;
if (process.env.DATABASE_URL) {
pgPool = new Pool({ connectionString: process.env.DATABASE_URL });
try {
await runMigrations(pgPool, { logger });
authGuards = mountAuth(app, {
db: pgPool,
apiBase: '/api',
callbackBaseUrl: process.env.PUBLIC_BASE_URL || `http://localhost:${PORT}`,
smtp: process.env.SMTP_HOST ? {
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT,
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
from: process.env.SMTP_FROM,
} : undefined,
fromAddress: process.env.SMTP_FROM || 'no-reply@example.com',
appName: 'Pawfectly Yours',
googleClientId: process.env.GOOGLE_CLIENT_ID,
googleClientSecret: process.env.GOOGLE_CLIENT_SECRET,
adminEmails: (process.env.ADMIN_EMAILS || '').split(',').map((s) => s.trim()).filter(Boolean),
cookieDomain: process.env.COOKIE_DOMAIN || undefined,
isProduction: IS_PRODUCTION,
// Facet personalization (Project doc §R10) — scopes the
// customer_facet_values rows + adds facetValues to /me. Inert
// when facetConfig.key is null.
facet: facetConfig,
logger,
});
} catch (err) {
logger.error({ err: err.message }, 'auth-manager bootstrap failed; auth disabled');
try { await pgPool.end(); } catch { /* ignore */ }
pgPool = null;
authGuards = null;
}
} else {
logger.warn('DATABASE_URL not set — auth-manager disabled. Set DATABASE_URL in .env to enable customer accounts and admin SSO.');
}
// ── goods-editor designs migrations (#23) ──────────────────────────
// The module documents that the HOST applies its designs schema before
// passing `db` to mountEditorApi. Every file in its migrations/ dir is
// idempotent (CREATE TABLE/ADD COLUMN IF NOT EXISTS), so we apply ALL
// of them in filename order — hardcoding 001 silently skipped
// 002_design_family.sql when the module grew it (the store SELECTs
// design_family_id unconditionally → every GET /api/designs 500'd).
// With the file: dep the SQL is reachable through node_modules; a
// packed install ships dist/ only, so a missing dir logs a warning
// instead of crashing — the /designs routes still mount and fail
// loudly until the schema is applied by hand.
if (pgPool) {
const designsMigrationsDir = join(
__dirname, 'node_modules', 'goods-editor', 'src', 'server', 'migrations',
);
try {
if (existsSync(designsMigrationsDir)) {
const files = readdirSync(designsMigrationsDir).filter((f) => f.endsWith('.sql')).sort();
for (const file of files) {
await pgPool.query(readFileSync(join(designsMigrationsDir, file), 'utf8'));
}
logger.info({ files }, 'goods-editor designs migrations applied');
} else {
logger.warn({ path: designsMigrationsDir }, 'goods-editor migrations dir not found — apply its *.sql manually before using design persistence');
}
} catch (err) {
logger.error({ err: err.message }, 'goods-editor designs migrations failed — POST /api/designs will error until the schema is applied');
}
}
// TTL sweep kickoff (#24) — here rather than at the definition site
// because collectReferencedUploads reads pgPool.
runCleanup();
setInterval(runCleanup, CLEANUP_INTERVAL_MS).unref();
const uploadLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many upload requests. Please try again later.' },
});
// ── Static serving ─────────────────────────────────────────────────
app.use('/uploads', express.static(uploadsDir));
app.use('/exports', express.static(exportsDir));
if (IS_PRODUCTION) {
app.use(express.static(join(__dirname, 'dist')));
}
// ── Host-owned: upload endpoint ────────────────────────────────────
const MIME_TO_EXT = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
};
const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, uploadsDir),
filename: (_req, file, cb) => {
const ext = MIME_TO_EXT[file.mimetype] || 'bin';
cb(null, `${uuidv4()}.${ext}`);
},
});
const fileFilter = (_req, file, cb) => {
if (MIME_TO_EXT[file.mimetype]) {
cb(null, true);
} else {
cb(new Error('Invalid file type. Only JPEG, PNG, and WebP are allowed.'), false);
}
};
const upload = multer({
storage, fileFilter,
limits: { fileSize: 20 * 1024 * 1024 },
});
// Separate multer instance for variant mockup uploads. The template-
// manager's variants route calls `.single('file')` on this and then
// renames `req.file.path` to <mockupsPath>/<productType>/<variant>.png
// inside the route handler. We use disk storage with a temp filename
// in the host's uploadsDir; the rename moves it to its final mockups
// location. Restricted to PNG since that's the file extension the
// route writes — a JPEG/WebP source would end up with a misleading
// .png extension on disk.
const mockupUpload = multer({
storage: multer.diskStorage({
destination: (_req, _file, cb) => cb(null, uploadsDir),
filename: (_req, _file, cb) => cb(null, `mockup-${uuidv4()}.tmp`),
}),
fileFilter: (_req, file, cb) => {
if (file.mimetype === 'image/png') cb(null, true);
else cb(new Error('Only PNG mockups are accepted.'));
},
limits: { fileSize: 10 * 1024 * 1024 },
});
app.post('/api/upload', uploadLimiter, upload.single('image'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
const originalUrl = `/uploads/${req.file.filename}`;
const previewFilename = `${uuidv4()}.png`;
const previewDir = join(uploadsDir, 'preview');
if (!existsSync(previewDir)) mkdirSync(previewDir, { recursive: true });
await sharp(req.file.path)
.resize({ width: 1000, height: 1000, fit: 'inside' })
.png()
.toFile(join(previewDir, previewFilename));
res.json({
success: true,
original: {
url: originalUrl,
filename: req.file.filename,
size: req.file.size,
mimetype: req.file.mimetype,
},
preview: {
url: `/uploads/preview/${previewFilename}`,
filename: previewFilename,
},
});
} catch (err) {
req.log?.error({ err: err.message }, 'Upload pipeline failure');
res.status(500).json({ error: 'Failed to process upload', details: err.message });
}
});
// ── Module-owned: editor API (export, download, health) ────────────
// v0.8 (#23) — `db` gates the design-persistence routes (POST/GET/
// DELETE /api/designs…). pgPool is null without DATABASE_URL (or when
// the auth bootstrap failed), in which case the routes simply don't
// mount and the client's persistCurrentDesign 404s into its graceful
// cart-without-design_id fallback.
//
// Font-registration roster: ops-diagnosis data, admin-only when auth is
// live. Registered BEFORE mountEditorApi so this guard runs ahead of the
// module's own GET /api/fonts/registered handler (non-admins 403, admins
// fall through). We deliberately do NOT pass auth.middleware into
// mountEditorApi for this — the module applies that same middleware to
// the whole /designs group, and a requireAdmin there would lock guests
// out of design persistence (guest rows are a designed-in feature).
if (authGuards) {
app.use('/api/fonts/registered', authGuards.requireAdmin);
}
mountEditorApi(app, {
canvas,
sharp,
exportsDir,
fontsDir,
stickersDir,
uploadsDir,
// Coverage-gap audit items: the design routes' thumbnail generator
// wants the mockup/template roots (previously only passed to
// renderDesignComposite for marketing previews, not here).
mockupsDir: templatesStorageDir,
storageDir: templatesStorageDir,
basePath: '/api',
logger,
db: pgPool,
// Design-route ownership (coverage-gap audit, sharpest item): owned
// rows 404 for non-owners, admins bypass. These match the module's
// documented defaults but are passed explicitly so the enforcement is
// visible here and survives any future change to those defaults.
// req.customer / req.admin are populated by auth-manager's
// authMiddleware, which mountAuth app.use()s ABOVE this mount; when
// auth is disabled both resolve null/false = the open dev harness.
auth: {
customerId: (req) => req.customer?.id ?? null,
isAdmin: (req) => !!req.admin,
},
});
// Template store (admin + public). adminAuth pass-through for v1
// per the plan; v1.5 swaps in a real middleware via server/lib/auth.js.
//
// v0.6 — the host no longer passes a variantCatalog or phoneModels.
// template-manager owns the catalog (productsStore) and accepts a
// seedProducts array used on first boot only. After the first boot,
// the catalog lives in `<templatesStorageDir>/_products_index.json`
// + `<templatesStorageDir>/products/<id>/product.json` and the seed
// is ignored even if it changes (intentional: prevents unintended
// catalog rewrites on server restart).
const templateStore = mountTemplateStore(app, {
storagePath: templatesStorageDir,
uploadsPath: uploadsDir,
adminAuth: (req, res, next) => next(),
seedProducts: SEED_PRODUCTS,
multer: mockupUpload,
logger,
basePath: '/api',
// Facet personalization (Project doc §R10) — server-enforced template
// categorization gate + the facet definition exposed via
// GET /api/public/facet-config. Both inert when facetConfig.key null.
requireTemplateCategory,
facet: facetConfig,
onTemplateChange: (templateId, kind) => {
logger.info({ templateId, kind }, 'Template changed');
// TODO (v0.4): trigger SSG rebuild for the affected template's
// shop page. v1 no-op; client re-fetches anyway.
},
});
// ── Marketing preview drainer ──────────────────────────────
//
// Periodically pulls templateIds off the marketing-preview queue and
// renders preview.png for each. The queue is in-memory and populated
// by (a) version creates, (b) variant mockup uploads, and (c) explicit
// regenerate clicks from the admin UI.
//
// Without this drainer, those enqueues accumulate silently — the
// server sets `autoStaleSince` on the metadata but nothing ever
// clears it, so the UI's "regenerating…" badge would stick forever.
//
// Implementation notes
// ────────────────────
// • The renderer factory takes goods-editor's `renderDesignComposite`
// as `editorRenderer`. We wrap it here to close over the host's
// `canvas` module + the mockups/uploads/storage dirs, since
// marketingPreviewRenderer calls editorRenderer with only the
// design data (no native deps).
// • Interval is 2s for snappy admin feedback. Render time on a
// typical 800×800 composite is well under a second on a modern
// laptop, so even a quick reload after "Save Version" should
// find a fresh preview waiting.
// • We drain ONE item per tick so a burst of enqueues doesn't pin
// the event loop. Multiple items just get processed over multiple
// ticks.
// • .unref() the timer so it doesn't keep the process alive at
// shutdown.
const renderOne = marketingPreviewRenderer({
editorRenderer: (designArgs) => renderDesignComposite({
...designArgs,
canvas,
sharp,
// v0.6 — mockups now live under templatesStorageDir/products/<id>/
// sizes/<sid>/mockup.png. renderDesignComposite picks the right
// path from the resolved variant's `mockupPath` field. We pass
// templatesStorageDir as a parent for path resolution.
mockupsDir: templatesStorageDir,
uploadsDir,
stickersDir,
storageDir: templatesStorageDir,
}),
});
const PREVIEW_DRAIN_INTERVAL_MS = parseInt(
process.env.PREVIEW_DRAIN_INTERVAL_MS || '',
10,
) || 2000;
async function drainPreviewQueue() {
const id = templateStore.marketingPreviewQueue.dequeue();
if (!id) return;
try {
await renderOne({
templateId: id,
storagePath: templatesStorageDir,
// v0.6 — productsStore replaces variantCatalog for variant lookup.
productsStore: templateStore.productsStore,
auditLog: templateStore.auditLog,
});
logger.info({ templateId: id }, 'Marketing preview rendered');
} catch (err) {
logger.error({ templateId: id, err: err.message }, 'Marketing preview render failed');
}
}
setInterval(drainPreviewQueue, PREVIEW_DRAIN_INTERVAL_MS).unref();
// ── 404 + SPA catch-alls ───────────────────────────────────────────
app.all('/api/*', (_req, res) => {
res.status(404).json({ error: 'API route not found' });
});
if (IS_PRODUCTION) {
app.get('*', (_req, res) => {
res.sendFile(join(__dirname, 'dist', 'index.html'));
});
}
// ── Error handler ──────────────────────────────────────────────────
app.use((err, req, res, _next) => {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'File too large. Maximum size is 20MB.' });
}
return res.status(400).json({ error: err.message });
}
req.log?.error({ err: err.message, stack: err.stack }, 'Unhandled error');
res.status(500).json({ error: 'Internal server error', details: err.message });
});
app.listen(PORT, () => {
logger.info({ port: PORT, mode: IS_PRODUCTION ? 'production' : 'development' }, 'Server listening');
});