110 lines
4.5 KiB
TypeScript
110 lines
4.5 KiB
TypeScript
import express from "express";
|
|
import { migrate, pool, seedSources } from "./db.js";
|
|
import { protectedResourceMetadata, requireMcpAuth } from "./auth.js";
|
|
import { handleMcp } from "./mcp.js";
|
|
import { SOURCES } from "./sources.js";
|
|
import { syncAll } from "./sync.js";
|
|
|
|
const app = express();
|
|
app.set("trust proxy", true);
|
|
app.use(express.json({ limit: "2mb" }));
|
|
|
|
app.get("/health", async (_req, res) => {
|
|
try {
|
|
await pool.query("SELECT 1");
|
|
res.json({ ok: true, service: "media-intelligence", version: "0.1.0" });
|
|
} catch (error) {
|
|
res.status(503).json({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
}
|
|
});
|
|
|
|
app.get("/ready", async (_req, res) => {
|
|
try {
|
|
const result = await pool.query(
|
|
`SELECT s.id, s.name, s.last_synced_at, s.last_error,
|
|
COUNT(sig.id)::int AS signal_count
|
|
FROM sources s
|
|
LEFT JOIN signals sig ON sig.source_id=s.id
|
|
GROUP BY s.id,s.name,s.last_synced_at,s.last_error
|
|
ORDER BY s.id`
|
|
);
|
|
const missing = result.rows
|
|
.filter((row: any) => Number(row.signal_count) < 1)
|
|
.map((row: any) => row.id);
|
|
const errored = result.rows
|
|
.filter((row: any) => row.last_error)
|
|
.map((row: any) => row.id);
|
|
|
|
if (missing.length || errored.length) {
|
|
return res.status(503).json({
|
|
ok: false,
|
|
missing_sources: missing,
|
|
errored_sources: errored,
|
|
sources: result.rows
|
|
});
|
|
}
|
|
return res.json({ ok: true, sources: result.rows });
|
|
} catch (error) {
|
|
return res.status(503).json({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
}
|
|
});
|
|
|
|
app.get("/.well-known/oauth-protected-resource", (req, res) => res.json(protectedResourceMetadata(req)));
|
|
app.get("/.well-known/oauth-protected-resource/api/mcp", (req, res) => res.json(protectedResourceMetadata(req)));
|
|
|
|
app.post("/api/mcp", requireMcpAuth, handleMcp);
|
|
app.get("/api/mcp", requireMcpAuth, (_req, res) => {
|
|
res.status(405).setHeader("Allow", "POST").json({ error: "Use POST for MCP JSON-RPC requests" });
|
|
});
|
|
|
|
app.get("/", async (_req, res) => {
|
|
const sourceRows = await pool.query("SELECT id,name,last_synced_at,last_error FROM sources ORDER BY id");
|
|
const counts = await pool.query(`
|
|
SELECT
|
|
(SELECT COUNT(*) FROM signals) AS signals,
|
|
(SELECT COUNT(*) FROM opportunities) AS opportunities,
|
|
(SELECT COUNT(*) FROM opportunities WHERE status='new') AS new_opportunities
|
|
`);
|
|
const c = counts.rows[0] ?? {};
|
|
res.type("html").send(`<!doctype html>
|
|
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Media Intelligence</title>
|
|
<style>
|
|
body{font-family:system-ui,-apple-system,sans-serif;max-width:920px;margin:40px auto;padding:0 20px;color:#1f2937}
|
|
.card{border:1px solid #d1d5db;border-radius:12px;padding:18px;margin:12px 0}
|
|
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px}
|
|
strong.big{font-size:2rem;display:block}
|
|
.ok{color:#166534}.bad{color:#991b1b}code{background:#f3f4f6;padding:2px 6px;border-radius:5px}
|
|
</style></head><body>
|
|
<h1>Media Intelligence MVP</h1>
|
|
<p>Human-led NZ public-source monitoring behind a dedicated MCP.</p>
|
|
<div class="grid">
|
|
<div class="card"><strong class="big">${c.signals ?? 0}</strong>signals</div>
|
|
<div class="card"><strong class="big">${c.opportunities ?? 0}</strong>opportunities</div>
|
|
<div class="card"><strong class="big">${c.new_opportunities ?? 0}</strong>new</div>
|
|
</div>
|
|
<h2>Sources</h2>
|
|
${sourceRows.rows.map((s:any)=>`<div class="card"><strong>${s.name}</strong><br><code>${s.id}</code><br><span class="${s.last_error?'bad':'ok'}">${s.last_error ? 'Error: '+s.last_error : (s.last_synced_at ? 'Last sync '+new Date(s.last_synced_at).toLocaleString() : 'Waiting for first sync')}</span></div>`).join("")}
|
|
<p>MCP endpoint: <code>/api/mcp</code></p>
|
|
</body></html>`);
|
|
});
|
|
|
|
async function start() {
|
|
await migrate();
|
|
await seedSources(SOURCES);
|
|
|
|
const port = Number(process.env.PORT ?? 3000);
|
|
app.listen(port, "0.0.0.0", () => {
|
|
console.log(JSON.stringify({ event: "server_started", port, version: "0.1.0" }));
|
|
});
|
|
|
|
const intervalMinutes = Math.max(Number(process.env.SYNC_INTERVAL_MINUTES ?? 15), 5);
|
|
setTimeout(() => syncAll().then((r) => console.log(JSON.stringify({ event: "initial_sync", result: r }))).catch((e) => console.error(e)), 5000);
|
|
setInterval(() => syncAll().then((r) => console.log(JSON.stringify({ event: "scheduled_sync", result: r }))).catch((e) => console.error(e)), intervalMinutes * 60_000);
|
|
}
|
|
|
|
start().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|