// 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); }