- 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>
71 lines
5.4 KiB
JavaScript
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);
|
|
}
|