require('dotenv').config({ quiet: true }) require('./otel') // must load before express/pg are used, for auto-instrumentation const path = require('path') const express = require('express') const cors = require('cors') const { pool } = require('./db') const { log } = require('./otel') const { FEEDS, syncAll, fetchCommentsTree } = require('./hn') const PORT = Number(process.env.PORT || 3012) const SYNC_INTERVAL_MS = 5 * 60 * 1000 const app = express() app.use(cors()) app.use(express.json()) app.get('/api/health', async (_req, res) => { try { await pool.query('SELECT 1') res.json({ ok: true }) } catch (err) { res.status(503).json({ ok: false, error: String(err) }) } }) app.get('/api/feed/:feed', async (req, res) => { const { feed } = req.params if (!FEEDS[feed]) return res.status(404).json({ error: 'unknown feed' }) const limit = Math.min(Number(req.query.limit) || 100, 100) const includeArchived = req.query.includeArchived === 'true' try { const { rows } = await pool.query( `SELECT s.id, s.type, s.title, s.url, s.domain, s.by_user, s.score, s.descendants, s.story_text, s.hn_created_at, fi.rank, COALESCE(st.read, false) AS read, COALESCE(st.starred, false) AS starred, COALESCE(st.archived, false) AS archived FROM feed_items fi JOIN stories s ON s.id = fi.story_id LEFT JOIN story_state st ON st.story_id = s.id WHERE fi.feed = $1 ${includeArchived ? '' : 'AND COALESCE(st.archived, false) = false'} ORDER BY fi.rank ASC LIMIT $2`, [feed, limit] ) res.json({ feed, items: rows }) } catch (err) { log('ERROR', 'feed query failed', { feed, error: String(err) }) res.status(500).json({ error: 'internal error' }) } }) app.get('/api/story/:id', async (req, res) => { const id = Number(req.params.id) try { const { rows } = await pool.query( `SELECT s.*, COALESCE(st.read, false) AS read, COALESCE(st.starred, false) AS starred, COALESCE(st.archived, false) AS archived FROM stories s LEFT JOIN story_state st ON st.story_id = s.id WHERE s.id = $1`, [id] ) if (!rows.length) return res.status(404).json({ error: 'not found' }) res.json(rows[0]) } catch (err) { log('ERROR', 'story query failed', { id, error: String(err) }) res.status(500).json({ error: 'internal error' }) } }) app.get('/api/story/:id/comments', async (req, res) => { const id = Number(req.params.id) try { const comments = await fetchCommentsTree(id) res.json({ id, comments }) } catch (err) { log('ERROR', 'comments fetch failed', { id, error: String(err) }) res.status(502).json({ error: 'failed to fetch comments' }) } }) app.post('/api/story/:id/state', async (req, res) => { const id = Number(req.params.id) const { read, starred, archived } = req.body || {} try { const { rows } = await pool.query( `INSERT INTO story_state (story_id, read, starred, archived, read_at, updated_at) VALUES ($1, COALESCE($2, false), COALESCE($3, false), COALESCE($4, false), CASE WHEN $2 THEN now() ELSE NULL END, now()) ON CONFLICT (story_id) DO UPDATE SET read = COALESCE($2, story_state.read), starred = COALESCE($3, story_state.starred), archived = COALESCE($4, story_state.archived), read_at = CASE WHEN COALESCE($2, story_state.read) AND NOT story_state.read THEN now() ELSE story_state.read_at END, updated_at = now() RETURNING *`, [id, read, starred, archived] ) res.json(rows[0]) } catch (err) { log('ERROR', 'state update failed', { id, error: String(err) }) res.status(500).json({ error: 'internal error' }) } }) app.post('/api/sync', async (_req, res) => { syncAll().catch((err) => log('ERROR', 'manual sync failed', { error: String(err) })) res.json({ ok: true, started: true }) }) // Serve the built frontend in production. const distDir = path.join(__dirname, '..', 'dist') app.use(express.static(distDir)) app.get(/^(?!\/api).*/, (_req, res) => { res.sendFile(path.join(distDir, 'index.html')) }) app.listen(PORT, () => { log('INFO', 'hn-reader-api listening', { port: PORT }) }) syncAll() .then(() => log('INFO', 'initial sync complete')) .catch((err) => log('ERROR', 'initial sync failed', { error: String(err) })) setInterval(() => { syncAll().catch((err) => log('ERROR', 'periodic sync failed', { error: String(err) })) }, SYNC_INTERVAL_MS)