Source: utils/activityLogger.js

/**
 * @module utils/activityLogger
 * @description Shared activity logging helper. Records user/system actions
 * so the Activity page shows a complete timeline.
 *
 * ### Type convention (dot-separated: `<resource>.<action>`, imperative form)
 * `project.create` · `crawl.start` · `crawl.complete` · `crawl.fail` ·
 * `test_run.start` · `test_run.complete` · `test_run.fail` ·
 * `test.create` · `test.generate` · `test.edit` · `test.delete` ·
 * `test.approve` · `test.reject` · `test.restore` ·
 * `test.auto_approve` · `test.revoke` · (AUTO-003b)
 * `test.bulk_approve` · `test.bulk_reject` · `test.bulk_restore` ·
 * `settings.update`
 *
 * Test-review event literals are exported as `ACTIVITY_TYPES` from
 * `backend/src/constants/activityTypes.js` — prefer the constant over the
 * literal string at every callsite (the `"test.approve"` vs `"test.approved"`
 * mismatch with the frontend was the bug that motivated extracting them).
 */

import { generateActivityId } from "./idGenerator.js";
import * as activityRepo from "../database/repositories/activityRepo.js";
import { formatLogLine } from "./logFormatter.js";

/**
 * Log an activity entry to the database.
 *
 * @param {Object}      opts
 * @param {string}      opts.type        - Activity type (e.g. `"test.approve"`).
 * @param {string}      [opts.projectId] - Associated project ID.
 * @param {string}      [opts.projectName] - Project name for display.
 * @param {string}      [opts.testId]    - Associated test ID.
 * @param {string}      [opts.testName]  - Test name for display.
 * @param {string}      [opts.detail]    - Human-readable description.
 * @param {string}      [opts.status="completed"] - Activity status.
 * @param {string}      [opts.userId]    - ID of the user who triggered the action (from req.authUser.sub).
 * @param {string}      [opts.userName]  - Display name of the user (from req.authUser.name or email).
 * @param {string}      [opts.workspaceId] - Workspace ID for multi-tenancy scoping (ACL-001).
 * @param {Object}      [opts.meta]      - Structured metadata persisted as JSON (migration 018). E.g. `{ score, threshold }` on auto-approval, `{ wasAutoApproved }` on revoke.
 * @returns {Object}    The created activity record.
 */
export function logActivity({ type, req, projectId, projectName, testId, testName, detail, status, userId, userName, workspaceId, meta }) {
  // Warn when a project-scoped activity is logged without a workspaceId.
  // Activities with workspaceId=NULL become orphaned — invisible to
  // workspace-scoped queries (/api/activities, /api/data/activities).
  if (projectId && !workspaceId) {
    console.warn(formatLogLine("warn", null,
      `[activity] Activity "${type}" for project ${projectId} logged without workspaceId — row will be orphaned`));
  }

  const id = generateActivityId();
  const activity = {
    id,
    type,
    projectId: projectId || null,
    projectName: projectName || null,
    testId: testId || null,
    testName: testName || null,
    detail: detail || null,
    status: status || "completed",
    createdAt: new Date().toISOString(),
    userId: userId || null,
    userName: userName || null,
    workspaceId: workspaceId || null,
    meta: meta || null,
    ipAddress: req?.ip || null,
    userAgent: req?.get?.("user-agent") || null,
    prevHash: null,
  };
  activityRepo.create(activity);

  // SEC-007 Part C: fire-and-forget SIEM forwarding. Every persisted
  // audit row is pushed to the workspace's configured SIEM target on
  // a deferred microtask so the originating request is never blocked
  // by a SIEM outage. The forwarder itself NEVER throws — failures
  // land in the `audit_dlq` table for admin replay.
  //
  // Lazy-imported to break the import cycle: notifications.js imports
  // auditDlqRepo, which imports counterRepo, which… etc. Using a
  // dynamic import here keeps activityLogger's top-level dependency
  // graph minimal (the original notification dispatcher uses the same
  // dynamic-import pattern in routes/system.js for the replay path).
  // SEC-007 SIEM dispatch contract:
  //   - First write of an event → forwarded once with `count: 1`, `deduped: false`.
  //   - Dedup hit (auth.login.failed burst, audit.read poll) → forwarded
  //     again with the SAME `id`, incremented `count`, and `deduped: true`.
  //     SIEM-side integrity verifiers that expect unique row ids must
  //     dedupe on `(id, count)` or treat repeated ids as UPDATE events.
  //     The alternative (suppress dispatch on dedup hits) would hide
  //     credential-stuffing bursts — the live `count++` stream is the
  //     attack signal a SOC analyst wants.
  if (activity.workspaceId) {
    setImmediate(() => {
      import("./notifications.js")
        .then((mod) => mod.dispatchSiemEvent?.(activity.workspaceId, activity))
        .catch((err) => {
          // SEC-007: best-effort SIEM forwarding — the row is already
          // persisted, so a dispatch failure does not block the originating
          // request. BUT a dynamic-import failure (module not found, syntax
          // error in notifications.js, etc.) never reaches the DLQ —
          // dispatchSiemEvent was never invoked. Surface to stderr so
          // operators see persistent forwarding outages even when the DLQ
          // counter stays at zero. Per-row failures (HTTP errors) are
          // handled inside dispatchSiemEvent and land in audit_dlq.
          console.error(formatLogLine("error", null,
            `[activity/siem] SIEM dispatch failed for activity ${activity.id}: ${err?.message || err}`));
        });
    });
  }

  return activity;
}