metatroncubeswdev ca42c781fa Admin: audit log and system health
- Every successful change in the dashboard is logged (who, what, when) with passwords, secrets and tokens masked;
  shown with the publish log in plain English, filter by period, client, person, search
- System health: publishing/scheduler switches, overdue or failed posts, stale numbers refreshes,
  connections needing reconnect or expiring, email, AI runs and API key, last backup, data size
- docker-compose mounts ./backups read-only so health can see the last backup

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:24:33 -04:00

71 lines
5.4 KiB
JavaScript

// Activity log: every change someone makes in the dashboard (POST/PUT/PATCH/DELETE that succeeded),
// with secrets masked. Read together with the queue's publish log on the Audit page.
import fs from "node:fs";
import path from "node:path";
import { DATA_DIR } from "./config.js";
import { PROJECT_ROOT } from "../../connector/src/env.js";
const FILE = path.join(DATA_DIR, "activity.jsonl");
const PUBLISH_LOG = path.join(PROJECT_ROOT, "data", "publish-log.jsonl");
const SECRET = /pass(word)?|secret|token|api[_-]?key|authorization|cookie/i;
const MAX_BYTES = 20 * 1024 * 1024;
function mask(v, depth = 0) {
if (depth > 4 || v == null) return v;
if (typeof v === "string") return v.length > 300 ? `${v.slice(0, 300)}…` : v;
if (Array.isArray(v)) return v.slice(0, 20).map((x) => mask(x, depth + 1));
if (typeof v === "object") return Object.fromEntries(Object.entries(v).slice(0, 40).map(([k, x]) => [k, SECRET.test(k) ? "•••" : mask(x, depth + 1)]));
return v;
}
/** Express middleware: log successful writes once the response is sent. */
export function middleware(req, res, next) {
if (req.method === "GET" || req.method === "HEAD" || req.method === "OPTIONS") return next();
res.on("finish", () => {
if (res.statusCode >= 400 || !req.user) return;
// Skip noise: marking notifications read, uploads (body is the file).
if (/\/api\/notifications\/read$|\/upload$/.test(req.path)) return;
const entry = { at: new Date().toISOString(), by: req.user.name || req.user.email, userId: req.user.id, method: req.method, path: req.originalUrl.split("?")[0], body: mask(req.body && typeof req.body === "object" && !Buffer.isBuffer(req.body) ? req.body : undefined), status: res.statusCode };
try {
if (fs.existsSync(FILE) && fs.statSync(FILE).size > MAX_BYTES) fs.renameSync(FILE, FILE.replace(/\.jsonl$/, `-${Date.now()}.jsonl`));
fs.appendFileSync(FILE, JSON.stringify(entry) + "\n");
} catch (e) { console.error("Activity log failed", e.message); }
});
next();
}
const readLines = (f) => { try { return fs.readFileSync(f, "utf8").split("\n").filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); } catch { return []; } };
// Plain-English description of an API call.
const WHAT = [
[/^POST \/api\/schedule\/[^/]+\/approve$/, "approved a post"], [/^POST \/api\/schedule\/[^/]+\/cancel$/, "cancelled a post"],
[/^POST \/api\/schedule\/[^/]+\/retry$/, "retried a post"], [/^POST \/api\/schedule\/[^/]+\/send-to-client$/, "sent a post to the client"],
[/^POST \/api\/schedule$/, "created a draft post"], [/^PATCH \/api\/schedule\//, "edited a post"],
[/^POST \/api\/library\/bulk-schedule$/, "bulk-scheduled library items"], [/^POST \/api\/library\/[^/]+\/review$/, "reviewed library work"],
[/^POST \/api\/library\/[^/]+\/schedule$/, "scheduled library work"], [/^POST \/api\/library$/, "uploaded work to the library"], [/^POST \/api\/library\/[^/]+\/archive$/, "archived library work"],
[/^(POST|PATCH|DELETE) \/api\/plan/, "changed the content plan"], [/^POST \/api\/calendar\/move$/, "moved a calendar item"],
[/^(POST|PATCH|DELETE) \/api\/users/, "changed a team member"], [/^POST \/api\/me\/password$/, "changed their password"],
[/^(PUT|DELETE) \/api\/platforms\//, "changed a platform app"], [/^(PATCH|DELETE) \/api\/connections\//, "changed a connection"], [/^POST \/api\/connections\/[^/]+\/check$/, "checked a connection"],
[/^(POST|PATCH|DELETE) \/api\/portal\//, "changed a client approval link"], [/^POST \/api\/inbox\/(reviews\/)?reply$/, "replied to a comment or review"], [/^POST \/api\/inbox\/hide$/, "hid or unhid a comment"],
[/^(POST|DELETE) \/api\/competitors/, "changed tracked competitors"], [/^PUT \/api\/brandkit\//, "edited a brand kit"], [/^PUT \/api\/bio\//, "edited a link-in-bio page"],
[/^POST \/api\/clients\/[^/]+\/monthly\/email$/, "emailed a monthly report"], [/^POST \/api\/clients\/[^/]+\/monthly\/build$/, "created a monthly report"],
[/^POST \/api\/jobs/, "started an AI run"], [/^POST \/api\/team\/tasks/, "changed an Odoo task"],
];
const describe = (e) => WHAT.find(([re]) => re.test(`${e.method} ${e.path}`))?.[1] || `${e.method} ${e.path.replace(/^\/api\//, "")}`;
const clientOf = (e) => e.body?.client || e.path.match(/\/(?:clients|portal|brandkit|bio|competitors)\/([a-z0-9-]+)/)?.[1] || null;
/** Activity + publishing events, newest first. */
export function list({ days = 30, user, client, q, limit = 300 } = {}) {
const since = new Date(Date.now() - days * 86400000).toISOString();
const acts = readLines(FILE).filter((e) => e.at >= since).map((e) => ({ at: e.at, by: e.by, kind: "action", text: describe(e), client: clientOf(e), detail: e.body ? JSON.stringify(e.body).slice(0, 400) : "" }));
const pubs = readLines(PUBLISH_LOG).filter((e) => e.at >= since).map((e) => ({
at: e.at, by: e.by || "Quorla", kind: e.action === "failed" ? "error" : "publish", client: e.client || null,
text: e.action === "published" ? `published to ${e.platform}` : e.action === "failed" ? `couldn't publish to ${e.platform}` : e.action === "waiting_for_quota" ? `waiting for ${e.platform}'s daily limit` : e.action.replace(/_/g, " "),
detail: e.error || e.result?.permalink || "",
}));
const needle = String(q || "").toLowerCase();
return [...acts, ...pubs]
.filter((e) => (!user || e.by === user) && (!client || e.client === client) && (!needle || `${e.text} ${e.detail} ${e.by}`.toLowerCase().includes(needle)))
.sort((a, b) => b.at.localeCompare(a.at)).slice(0, limit);
}