← 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;