← back to Lifestyle Asset Intel
routes/api.js
355 lines
const express = require('express');
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const { pool } = require('../lib/db');
const {
valueAsset, listAssets, getIndex, listIndices, getFamily, listFamilies, METHODOLOGY_VERSION
} = require('../lib/valuation');
const {
isValidEmail,
listHoldings,
insertHolding,
patchHolding,
deleteHolding
} = require('../lib/portfolio');
const { auditMiddleware, recentCalls } = require('../lib/audit');
const { buildSpec } = require('../lib/openapi');
const router = express.Router();
// Mount the immutable audit log on every /api/* call. /api/health is
// skipped inside the middleware to keep monitoring traffic out of the
// table. See lib/audit.js for the full schema + redaction rules.
router.use(auditMiddleware);
// Per-route Cache-Control discipline. Why each rule:
//
// no-store : valuations + portfolio + audit (auditable + per-user;
// must hit live so audit row writes; never sit in CDN)
// short cache : list endpoints (sources/assets/indices/openapi) —
// minutes-fresh is fine for the catalog
// no-cache : health/version (allow conditional GETs but never
// serve stale to monitoring)
const NO_STORE = 'no-store, must-revalidate';
const NO_CACHE = 'no-cache, must-revalidate';
const PUB_60 = 'public, max-age=60, stale-while-revalidate=300';
const PUB_300 = 'public, max-age=300, stale-while-revalidate=86400';
const PUB_3600 = 'public, max-age=3600, stale-while-revalidate=86400';
function setCache(value) {
return (req, res, next) => { res.set('Cache-Control', value); next(); };
}
// Resolve build metadata at boot (cheap; doesn't touch the DB).
const PKG_VERSION = (() => {
try { return require('../package.json').version; } catch { return '0.0.0'; }
})();
const GIT_SHA = (() => {
try {
return execSync('git rev-parse --short=12 HEAD', {
cwd: path.join(__dirname, '..'),
stdio: ['ignore', 'pipe', 'ignore']
}).toString().trim();
} catch { return null; }
})();
const GIT_DIRTY = (() => {
try {
const out = execSync('git status --porcelain', {
cwd: path.join(__dirname, '..'),
stdio: ['ignore', 'pipe', 'ignore']
}).toString().trim();
return out.length > 0;
} catch { return null; }
})();
const BUILD_TIME = new Date().toISOString();
// Cache the OpenAPI spec at boot — content is build-deterministic, so
// regenerating per-request would just burn CPU.
const OPENAPI_SPEC = buildSpec({ version: PKG_VERSION, gitSha: GIT_SHA });
router.get('/health', setCache(NO_CACHE), async (req, res) => {
let dbOk = false;
try {
await pool.query('SELECT 1');
dbOk = true;
} catch (err) {
console.error('[health] db check failed:', err.message);
}
res.json({ ok: true, version: PKG_VERSION, db_ok: dbOk });
});
// /api/version — full build + data introspection. Used by deploy
// scripts, monitoring dashboards, and "is the prod the same as my
// local?" pre-flight checks.
router.get('/version', setCache(NO_CACHE), async (req, res, next) => {
try {
const [migs, counts] = await Promise.all([
pool.query(`SELECT filename, applied_at FROM schema_migrations ORDER BY filename`),
pool.query(`SELECT
(SELECT COUNT(*) FROM canonical_assets)::int AS assets,
(SELECT COUNT(*) FROM transactions)::int AS transactions,
(SELECT COUNT(*) FROM valuation_snapshots)::int AS snapshots,
(SELECT COUNT(*) FROM indices)::int AS indices,
(SELECT COUNT(*) FROM index_points)::int AS index_points,
(SELECT COUNT(*) FROM portfolio_holdings)::int AS holdings,
(SELECT COUNT(*) FROM image_assets)::int AS images,
(SELECT MAX(as_of) FROM valuation_snapshots) AS latest_snapshot_as_of`)
]);
res.json({
service: 'lifestyle-asset-intel',
package_version: PKG_VERSION,
methodology_version: METHODOLOGY_VERSION,
git: GIT_SHA ? { sha: GIT_SHA, dirty: GIT_DIRTY } : null,
runtime: {
node: process.version,
platform: process.platform,
arch: process.arch,
pid: process.pid,
boot_time: BUILD_TIME,
uptime_seconds: Math.round(process.uptime())
},
schema: {
migrations_applied: migs.rows.length,
migrations: migs.rows.map((r) => ({
filename: r.filename,
applied_at: r.applied_at
}))
},
data: counts.rows[0]
});
} catch (e) { next(e); }
});
// Whitelist of supported /api/sources ?sort= values mapped to safe SQL
// ORDER BY fragments. All sort keys are DB-backed columns (no computed
// values), so SQL-side sort is cleaner than the JS post-sort the other
// list endpoints use. Default mirrors the historical tier+name order.
const SOURCE_SORTS = {
default: 'tier ASC, name ASC',
name: 'name ASC',
slug: 'slug ASC',
tier: 'tier ASC, name ASC',
'tier-desc': 'tier DESC, name ASC',
'weight-desc':'weight DESC, name ASC',
'weight-asc': 'weight ASC, name ASC',
kind: 'kind ASC, name ASC',
enabled: 'enabled DESC, tier ASC, name ASC'
};
router.get('/sources', setCache(PUB_300), async (req, res, next) => {
try {
const sort = typeof req.query.sort === 'string' ? req.query.sort.trim() : '';
const orderBy = SOURCE_SORTS[sort] || SOURCE_SORTS.default;
const qRaw = typeof req.query.q === 'string' ? req.query.q.trim() : '';
// Case-insensitive substring filter across the human-meaningful
// fields. Notes is included because seeded source rows carry the
// "auction"/"marketplace"/"index" colour in plain English there.
const params = [];
let whereSql = '';
if (qRaw) {
params.push(`%${qRaw.toLowerCase()}%`);
whereSql =
' WHERE LOWER(slug) LIKE $1 ' +
' OR LOWER(name) LIKE $1 ' +
' OR LOWER(kind) LIKE $1 ' +
' OR LOWER(COALESCE(notes, \'\')) LIKE $1';
}
const r = await pool.query(
`SELECT slug, name, tier, kind, weight, enabled, url, notes
FROM sources${whereSql}
ORDER BY ${orderBy}`,
params
);
res.json({
count: r.rows.length,
q: qRaw || null,
sort: sort || null,
sources: r.rows
});
} catch (e) { next(e); }
});
router.get('/assets', setCache(PUB_60), async (req, res, next) => {
try {
const q = typeof req.query.q === 'string' ? req.query.q : '';
const sort = typeof req.query.sort === 'string' ? req.query.sort : '';
const rows = await listAssets({ q, sort });
res.json({ count: rows.length, q: q || null, sort: sort || null, assets: rows });
} catch (e) { next(e); }
});
// Valuations are auditable + must always hit live (audit middleware
// must record every request). NEVER cache.
router.get('/valuation/:slug', setCache(NO_STORE), async (req, res, next) => {
try {
const out = await valueAsset(req.params.slug);
if (!out) return res.status(404).json({ error: 'asset_not_found' });
res.json(out);
} catch (e) { next(e); }
});
router.get('/family', setCache(PUB_300), async (req, res, next) => {
try {
const q = typeof req.query.q === 'string' ? req.query.q : '';
const sort = typeof req.query.sort === 'string' ? req.query.sort : '';
const families = await listFamilies({ q, sort });
res.json({
count: families.length,
q: q || null,
sort: sort || null,
families
});
} catch (e) { next(e); }
});
router.get('/family/:slug', setCache(PUB_300), async (req, res, next) => {
try {
const out = await getFamily(req.params.slug);
if (!out) return res.status(404).json({ error: 'family_not_found' });
res.json(out);
} catch (e) { next(e); }
});
router.get('/index', setCache(PUB_300), async (req, res, next) => {
try {
const q = typeof req.query.q === 'string' ? req.query.q : '';
const sort = typeof req.query.sort === 'string' ? req.query.sort : '';
const indices = await listIndices({ q, sort });
res.json({
count: indices.length,
q: q || null,
sort: sort || null,
indices
});
} catch (e) { next(e); }
});
router.get('/index/:slug', setCache(PUB_300), async (req, res, next) => {
try {
const out = await getIndex(req.params.slug);
if (!out) return res.status(404).json({ error: 'index_not_found' });
res.json(out);
} catch (e) { next(e); }
});
router.get('/openapi.json', setCache(PUB_3600), (req, res) => {
res.type('application/json').send(JSON.stringify(OPENAPI_SPEC));
});
// Audit must reflect the live table.
router.get('/audit/recent', setCache(NO_STORE), async (req, res, next) => {
try {
const limit = req.query.limit;
const asset_slug = req.query.asset_slug || null;
const route = typeof req.query.route === 'string' ? req.query.route.trim() : '';
const method = typeof req.query.method === 'string' ? req.query.method.trim() : '';
const statusRaw = typeof req.query.status === 'string' ? req.query.status.trim() : '';
// Echo back the normalized status: full 3-digit codes as integers,
// 1-digit class prefixes as the string "2xx"/"4xx"/etc. Anything
// that doesn't validate echoes back as null (server ignored it).
let statusEcho = null;
if (/^[1-5][0-9]{2}$/.test(statusRaw)) statusEcho = parseInt(statusRaw, 10);
else if (/^[1-5]$/.test(statusRaw)) statusEcho = `${statusRaw}xx`;
const sinceRaw = typeof req.query.since === 'string' ? req.query.since.trim() : '';
// Echo back the normalized ISO string if parseable, else null.
let sinceEcho = null;
if (sinceRaw) {
const d = new Date(sinceRaw);
if (!Number.isNaN(d.getTime())) sinceEcho = d.toISOString();
}
const untilRaw = typeof req.query.until === 'string' ? req.query.until.trim() : '';
let untilEcho = null;
if (untilRaw) {
const d = new Date(untilRaw);
if (!Number.isNaN(d.getTime())) untilEcho = d.toISOString();
}
const rows = await recentCalls({
limit,
asset_slug,
route: route || null,
method: method || null,
status: statusRaw || null,
since: sinceRaw || null,
until: untilRaw || null
});
res.json({
count: rows.length,
asset_slug: asset_slug || null,
route: route || null,
method: method ? method.toUpperCase() : null,
status: statusEcho,
since: sinceEcho,
until: untilEcho,
calls: rows
});
} catch (e) { next(e); }
});
// Portfolio is per-user state — never cache, never put behind a CDN.
router.use('/portfolio', setCache(NO_STORE));
router.get('/portfolio', async (req, res, next) => {
try {
const owner = req.query.owner;
if (!owner) return res.status(400).json({ error: 'owner_query_required' });
const holdings = await listHoldings(owner);
res.json({ owner, holdings });
} catch (e) { next(e); }
});
router.post('/portfolio', async (req, res, next) => {
try {
const { owner_email, asset_slug, acquired_at, acquisition_basis, notes } = req.body || {};
if (!isValidEmail(owner_email)) {
return res.status(400).json({ error: 'invalid_email' });
}
if (!asset_slug || typeof asset_slug !== 'string' || !asset_slug.trim()) {
return res.status(400).json({ error: 'invalid_asset_slug' });
}
try {
const row = await insertHolding({ owner_email, asset_slug, acquired_at, acquisition_basis, notes });
return res.status(201).json(row);
} catch (err) {
if (err.code === 'invalid_asset_slug') {
return res.status(400).json({ error: 'invalid_asset_slug' });
}
throw err;
}
} catch (e) { next(e); }
});
router.patch('/portfolio/:id', async (req, res, next) => {
try {
const body = req.body || {};
const patchable = ['acquired_at', 'acquisition_basis', 'notes'];
const fields = {};
for (const k of patchable) {
if (Object.prototype.hasOwnProperty.call(body, k)) fields[k] = body[k];
}
if (!Object.keys(fields).length) {
return res.status(400).json({ error: 'no_fields_to_update' });
}
const out = await patchHolding(req.params.id, fields);
if (out.error === 'not_found') return res.status(404).json({ error: 'not_found' });
if (out.error) return res.status(400).json({ error: out.error });
res.json(out.row);
} catch (e) {
// invalid uuid → pg throws 22P02; surface as 404 for clean DELETE-then-404 contract too
if (e && e.code === '22P02') return res.status(404).json({ error: 'not_found' });
next(e);
}
});
router.delete('/portfolio/:id', async (req, res, next) => {
try {
const ok = await deleteHolding(req.params.id);
if (!ok) return res.status(404).json({ error: 'not_found' });
res.status(204).end();
} catch (e) {
if (e && e.code === '22P02') return res.status(404).json({ error: 'not_found' });
next(e);
}
});
module.exports = router;