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>
592 lines
24 KiB
JavaScript
592 lines
24 KiB
JavaScript
// 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');
|
||
});
|