← back to Lifestyle Asset Intel
routes/console.js
206 lines
const express = require('express');
const fs = require('fs');
const path = require('path');
const { marked } = require('marked');
const { listAssets, valueAsset, getIndex, getFamily, listFamilies } = require('../lib/valuation');
const {
isValidEmail,
listHoldings,
insertHolding,
deleteHolding
} = require('../lib/portfolio');
const { recentCalls } = require('../lib/audit');
const router = express.Router();
// Cache the rendered methodology HTML at boot — the file is static at
// runtime; if it changes, pm2 restart is the right way to reload.
const METHODOLOGY_HTML = (() => {
try {
const md = fs.readFileSync(path.join(__dirname, '..', 'METHODOLOGY.md'), 'utf8');
return marked.parse(md, { gfm: true, breaks: false });
} catch (e) {
console.error('[console] failed to render METHODOLOGY.md:', e.message);
return '<p>Methodology document is unavailable. See <code>METHODOLOGY.md</code> in the project root.</p>';
}
})();
router.get('/', async (req, res, next) => {
try {
const q = typeof req.query.q === 'string' ? req.query.q.trim() : '';
const assets = await listAssets(q ? { q } : {});
res.render('index', {
title: 'Lifestyle Asset Intelligence — Market-Ops Console',
assets,
q,
path: '/'
});
} catch (e) { next(e); }
});
router.get('/asset/:slug', async (req, res, next) => {
try {
const data = await valueAsset(req.params.slug);
if (!data) return res.status(404).render('error', {
title: 'Asset not found', message: `No canonical asset with slug "${req.params.slug}"`, path: req.path
});
res.render('asset', {
title: `${data.asset.brand} ${data.asset.family} ${data.asset.size || ''} — ${data.asset.material || ''}`.trim(),
data,
path: req.path
});
} catch (e) { next(e); }
});
router.get('/family', async (req, res, next) => {
try {
const sort = typeof req.query.sort === 'string' ? req.query.sort.trim() : '';
const families = await listFamilies({ sort });
res.render('families', {
title: 'Model families — lifestyle-asset-intel',
families,
sort,
path: req.path
});
} catch (e) { next(e); }
});
router.get('/family/:slug', async (req, res, next) => {
try {
const out = await getFamily(req.params.slug);
if (!out) return res.status(404).render('error', {
title: 'Family not found',
message: `No model family with slug "${req.params.slug}"`,
path: req.path
});
res.render('family', {
title: `${out.brand.name} ${out.family.name}`,
out,
path: req.path
});
} catch (e) { next(e); }
});
router.get('/indices/:slug', async (req, res, next) => {
try {
const out = await getIndex(req.params.slug);
if (!out) return res.status(404).render('error', {
title: 'Index not found', message: `No index with slug "${req.params.slug}"`, path: req.path
});
res.render('index_detail', {
title: out.index.name,
out,
path: req.path
});
} catch (e) { next(e); }
});
router.get('/portfolio', async (req, res, next) => {
try {
const owner = (req.query.owner || '').trim();
const assets = await listAssets();
if (!owner) {
return res.render('portfolio', {
title: 'Portfolio',
owner: '',
holdings: [],
assets,
flash: req.query.flash || null,
path: req.path
});
}
if (!isValidEmail(owner)) {
return res.status(400).render('portfolio', {
title: 'Portfolio',
owner,
holdings: [],
assets,
flash: 'invalid_email',
path: req.path
});
}
const holdings = await listHoldings(owner);
res.render('portfolio', {
title: `Portfolio — ${owner}`,
owner,
holdings,
assets,
flash: req.query.flash || null,
path: req.path
});
} catch (e) { next(e); }
});
router.post('/portfolio/add', async (req, res, next) => {
try {
const { owner_email, asset_slug, acquired_at, acquisition_basis, notes } = req.body || {};
const ownerOut = (owner_email || '').trim();
if (!isValidEmail(ownerOut)) {
return res.redirect(302, `/portfolio?owner=${encodeURIComponent(ownerOut)}&flash=invalid_email`);
}
if (!asset_slug || typeof asset_slug !== 'string' || !asset_slug.trim()) {
return res.redirect(302, `/portfolio?owner=${encodeURIComponent(ownerOut)}&flash=invalid_asset_slug`);
}
try {
await insertHolding({
owner_email: ownerOut,
asset_slug,
acquired_at: acquired_at || null,
acquisition_basis: acquisition_basis === '' ? null : acquisition_basis,
notes: notes || null
});
} catch (err) {
if (err.code === 'invalid_asset_slug') {
return res.redirect(302, `/portfolio?owner=${encodeURIComponent(ownerOut)}&flash=invalid_asset_slug`);
}
throw err;
}
res.redirect(302, `/portfolio?owner=${encodeURIComponent(ownerOut)}&flash=added`);
} catch (e) { next(e); }
});
// /admin/audit — HTML view over /api/audit/recent. Read-only inspector of
// the immutable valuation_calls log. No auth gate in v0 — runs on
// localhost-only pm2; once the brand domain goes live, gate this behind
// the next-auth-gate skill before exposing externally.
router.get('/admin/audit', async (req, res, next) => {
try {
const limitRaw = parseInt(req.query.limit, 10);
const limit = Number.isFinite(limitRaw) && limitRaw > 0
? Math.min(limitRaw, 1000)
: 100;
const slug = typeof req.query.slug === 'string' ? req.query.slug.trim() : '';
const calls = await recentCalls({ limit, asset_slug: slug || null });
res.set('Cache-Control', 'no-store, must-revalidate');
res.render('admin_audit', {
title: 'Audit — recent API calls',
calls,
filterSlug: slug,
limit,
path: req.path
});
} catch (e) { next(e); }
});
router.get('/methodology', (req, res) => {
res.render('methodology', {
title: 'Methodology — lifestyle-asset-intel',
body_html: METHODOLOGY_HTML,
path: req.path
});
});
router.post('/portfolio/remove/:id', async (req, res, next) => {
try {
const ownerOut = (req.body && req.body.owner_email) ? req.body.owner_email.trim() : '';
try {
await deleteHolding(req.params.id);
} catch (err) {
if (!(err && err.code === '22P02')) throw err;
}
res.redirect(302, `/portfolio?owner=${encodeURIComponent(ownerOut)}&flash=removed`);
} catch (e) { next(e); }
});
module.exports = router;