Source: routes/dashboard.js

/**
 * @module routes/dashboard
 * @description Dashboard analytics endpoint. Mounted at `/api/v1` (INF-005).
 *
 * ### Endpoints
 * | Method | Path                 | Description                                                |
 * |--------|----------------------|------------------------------------------------------------|
 * | `GET`  | `/api/v1/dashboard`  | Pass rate, defects, flaky tests, MTTR, growth, and more    |
 */

import { Router } from "express";
import * as projectRepo from "../database/repositories/projectRepo.js";
import * as testRepo from "../database/repositories/testRepo.js";
import * as runRepo from "../database/repositories/runRepo.js";
import * as activityRepo from "../database/repositories/activityRepo.js";
import * as healingRepo from "../database/repositories/healingRepo.js";
import * as accessibilityViolationRepo from "../database/repositories/accessibilityViolationRepo.js";
import * as environmentRepo from "../database/repositories/environmentRepo.js";
import { classifyFailure } from "../pipeline/feedbackLoop.js";
import { getTopFlakyTests } from "../utils/flakyDetector.js";
import { getQueueStats, isQueueAvailable, runQueue } from "../queue.js";
import { formatLogLine } from "../utils/logFormatter.js";

const router = Router();

router.get("/dashboard", async (req, res) => {
  // Express 4 does NOT auto-catch rejected promises from async handlers, so
  // any synchronous throw (e.g. a repo call) would otherwise hang the request
  // until the client times out. Wrap the entire body so failures surface as
  // a 500 instead. Mirrors the pattern used by other async routes (e.g.
  // routes/auth.js).
  try {
  // ACL-001: Scope dashboard data to the user's workspace.
  // Projects are filtered by workspaceId; runs and tests are filtered by
  // the set of project IDs that belong to this workspace.
  const projects = projectRepo.getAll(req.workspaceId);
  const projectIds = projects.map(p => p.id);

  // Scope queries to the user's workspace project IDs at the SQL layer
  // so multi-tenant deployments don't load data from other workspaces.
  const runs = runRepo.getWithResultsByProjectIds(projectIds);
  const tests = testRepo.getAllByProjectIds(projectIds);
  // Only fetch activity types needed for dashboard counters (not all activities)
  const generationActivities = activityRepo.getByTypes(["test.create", "test.generate"], {
    workspaceId: req.workspaceId,
  });
  const projectsById = {};
  for (const p of projects) projectsById[p.id] = p;

  // ── Pass rate (last 10 completed test runs) ─────────────────────────────
  const completedTestRuns = runs
    .filter((r) => (r.type === "test_run" || r.type === "run") && r.status === "completed")
    .sort((a, b) => new Date(b.startedAt) - new Date(a.startedAt))
    .slice(0, 10);

  const passRate =
    completedTestRuns.length
      ? Math.round(
          (completedTestRuns.reduce((s, r) => s + (r.passed || 0), 0) /
            completedTestRuns.reduce((s, r) => s + (r.total || 1), 0)) *
            100
        )
      : null;

  // ── Chart history — last 20 test runs with results (chronological) ──────
  const history = runs
    .filter((r) => (r.type === "test_run" || r.type === "run") && r.passed != null)
    .sort((a, b) => new Date(a.startedAt) - new Date(b.startedAt))
    .slice(-20)
    .map((r) => ({ passed: r.passed || 0, failed: r.failed || 0, total: r.total || 0, date: r.startedAt }));

  // ── Recent runs — ALL statuses so failures/aborts are visible ───────────
  const recentRuns = runs
    .sort((a, b) => new Date(b.startedAt) - new Date(a.startedAt))
    .slice(0, 8)
    .map((r) => {
      const p = projectsById[r.projectId];
      return { id: r.id, projectId: r.projectId, projectName: p?.name || null, type: r.type, status: r.status, startedAt: r.startedAt, passed: r.passed, failed: r.failed, total: r.total };
    });

  // ── Run status distribution ─────────────────────────────────────────────
  const runsByStatus = { completed: 0, completed_empty: 0, failed: 0, aborted: 0, running: 0 };
  for (const r of runs) { if (r.status in runsByStatus) runsByStatus[r.status]++; }

  // ── Test review pipeline ────────────────────────────────────────────────
  const testsByReview = { draft: 0, approved: 0, rejected: 0 };
  for (const t of tests) { const s = t.reviewStatus || "draft"; if (s in testsByReview) testsByReview[s]++; }

  // ── Tests created / generated (today & this week) ───────────────────────
  // Each AI generation logs TWO test.generate activities: one at start
  // (status "running") and one on completion (status "completed" / default).
  // Only count completed activities to avoid double-counting.
  const now = new Date();
  const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).toISOString();
  const weekStart = new Date(now.getFullYear(), now.getMonth(), now.getDate() - now.getDay()).toISOString();
  let testsCreatedToday = 0, testsCreatedThisWeek = 0, testsGeneratedTotal = 0;
  for (const a of generationActivities) {
    // Skip "running" status entries to avoid double-counting start + completion
    if (a.status === "running") continue;
    testsGeneratedTotal++;
    if (a.createdAt >= todayStart) testsCreatedToday++;
    if (a.createdAt >= weekStart) testsCreatedThisWeek++;
  }

  // ── Tests auto-fixed (feedback loop + self-healing) ─────────────────────
  let testsAutoFixed = 0;
  for (const r of runs) { if (r.feedbackLoop?.improved) testsAutoFixed += r.feedbackLoop.improved; }
  const testIds = tests.map((t) => t.id);
  const healingEntries = healingRepo.countByTestIds(testIds);
  const healingSuccesses = healingRepo.countSuccessesByTestIds(testIds);

  // ── Average run duration (completed test runs) ──────────────────────────
  const durations = completedTestRuns.filter((r) => r.duration > 0).map((r) => r.duration);
  const avgRunDurationMs = durations.length ? Math.round(durations.reduce((s, d) => s + d, 0) / durations.length) : null;

  // ── Defect / failure category breakdown (across all test run results) ───
  const defectBreakdown = { SELECTOR_ISSUE: 0, NAVIGATION_FAIL: 0, TIMEOUT: 0, ASSERTION_FAIL: 0, UNKNOWN: 0 };
  const testResultStatuses = {};   // testId → Set<"passed"|"failed">
  const testRunResults = runs.filter((r) => (r.type === "test_run" || r.type === "run") && r.results?.length);
  for (const r of testRunResults) {
    for (const result of r.results) {
      if (!testResultStatuses[result.testId]) testResultStatuses[result.testId] = new Set();
      if (result.status) testResultStatuses[result.testId].add(result.status);
      if (result.status === "failed" && result.error) {
        const cat = classifyFailure(result.error);
        if (cat in defectBreakdown) defectBreakdown[cat]++;
        else defectBreakdown.UNKNOWN++;
      }
    }
  }

  // ── Flaky test count (tests with both "passed" and "failed" across runs) ─
  let flakyTestCount = 0;
  for (const statuses of Object.values(testResultStatuses)) {
    if (statuses.has("passed") && statuses.has("failed")) flakyTestCount++;
  }

  // ── Test growth — cumulative test count per week (last 8 weeks) ─────────
  const GROWTH_WEEKS = 8;
  const weekMs = 7 * 24 * 60 * 60 * 1000;
  const growthStart = new Date(now.getTime() - GROWTH_WEEKS * weekMs);
  const weekBuckets = {};
  for (let i = 0; i < GROWTH_WEEKS; i++) {
    const d = new Date(growthStart.getTime() + i * weekMs);
    const key = d.toISOString().slice(0, 10);
    weekBuckets[key] = 0;
  }
  for (const a of generationActivities) {
    if (a.status === "running") continue; // skip start entries (same as above)
    if (a.createdAt < growthStart.toISOString()) continue;
    const aTime = new Date(a.createdAt).getTime();
    for (let i = GROWTH_WEEKS - 1; i >= 0; i--) {
      const bucketStart = growthStart.getTime() + i * weekMs;
      if (aTime >= bucketStart) {
        const key = new Date(bucketStart).toISOString().slice(0, 10);
        weekBuckets[key] = (weekBuckets[key] || 0) + 1;
        break;
      }
    }
  }
  const testGrowth = [];
  let cumulative = tests.length;
  const sortedKeys = Object.keys(weekBuckets).sort();
  const totalRecent = sortedKeys.reduce((s, k) => s + weekBuckets[k], 0);
  cumulative = Math.max(0, tests.length - totalRecent);
  for (const key of sortedKeys) {
    cumulative += weekBuckets[key];
    testGrowth.push({ week: key, count: cumulative });
  }

  // ── MTTR — mean time to recovery (failed → passed) ─────────────────────
  const chronologicalRuns = runs
    .filter((r) => (r.type === "test_run" || r.type === "run") && r.results?.length && r.startedAt)
    .sort((a, b) => new Date(a.startedAt) - new Date(b.startedAt));
  const lastFailTime = {};
  const recoveryDeltas = [];
  for (const r of chronologicalRuns) {
    for (const result of r.results) {
      if (result.status === "failed") {
        lastFailTime[result.testId] = r.startedAt;
      } else if (result.status === "passed" && lastFailTime[result.testId]) {
        const delta = new Date(r.startedAt) - new Date(lastFailTime[result.testId]);
        if (delta > 0) recoveryDeltas.push(delta);
        delete lastFailTime[result.testId];
      }
    }
  }
  const mttrMs = recoveryDeltas.length
    ? Math.round(recoveryDeltas.reduce((s, d) => s + d, 0) / recoveryDeltas.length)
    : null;

  // ── DIF-011: Test density per URL for coverage heatmap ────────────────────
  // Counts approved tests per sourceUrl so the SiteGraph can colour nodes
  // by coverage density: 0 = red, 1–2 = amber, 3+ = green.
  const testsByUrl = {};
  for (const t of tests) {
    if (t.reviewStatus !== "approved" || !t.sourceUrl) continue;
    testsByUrl[t.sourceUrl] = (testsByUrl[t.sourceUrl] || 0) + 1;
  }

  // DIF-004: Top flaky tests — persisted flakyScore from the flaky detector
  const topFlakyTests = getTopFlakyTests(projectIds, 10);
  // ── Top accessibility offenders (AUTO-016b) ─────────────────────────────
  // Group crawl/generate runs by project once, then issue a single
  // `countByRunIds` query for all the recent run IDs across all projects.
  // Avoids the per-project filter+sort+N+1 SELECT pattern.
  const crawlGenRunsByProject = {};
  for (const r of runs) {
    if (r.type !== "crawl" && r.type !== "generate") continue;
    (crawlGenRunsByProject[r.projectId] ??= []).push(r);
  }
  const projectRunIds = {};
  const allRecentRunIds = [];
  for (const projectId of projectIds) {
    const projectRuns = crawlGenRunsByProject[projectId];
    if (!projectRuns?.length) continue;
    const runIds = projectRuns
      .sort((a, b) => new Date(b.startedAt || 0) - new Date(a.startedAt || 0))
      .slice(0, 5)
      .map((run) => run.id);
    projectRunIds[projectId] = runIds;
    allRecentRunIds.push(...runIds);
  }
  const violationCountsByRunId = accessibilityViolationRepo.countByRunIds(allRecentRunIds);
  // Build minimal {projectId, violations} tuples, sort+slice, *then* attach
  // project names so we don't retain strings for projects that won't make the cut.
  const offenderCounts = [];
  for (const projectId of projectIds) {
    const runIds = projectRunIds[projectId];
    if (!runIds) continue;
    let count = 0;
    for (const runId of runIds) count += violationCountsByRunId[runId] || 0;
    if (count > 0) offenderCounts.push({ projectId, violations: count });
  }
  offenderCounts.sort((a, b) => b.violations - a.violations);
  const topAccessibilityOffenders = offenderCounts.slice(0, 5).map((o) => ({
    projectId: o.projectId,
    projectName: projectsById[o.projectId]?.name || "Unknown project",
    violations: o.violations,
  }));

  // ── DIF-012: per-environment pass rate + last green run ──────────────────
  // Aggregate completed test runs bucketed by `runs.environmentId`. Runs
  // without an environmentId fall into a synthetic "default" bucket so
  // projects that have never picked a non-default target still surface
  // alongside multi-env ones. We only compute this when at least one
  // environment exists in the workspace — keeps the payload identical for
  // users who haven't adopted the feature.
  //
  // Batched in one SQL round-trip via `listByProjectIds` (replaces the prior
  // per-project N+1 loop) and bucketed locally below.
  const allEnvironments = environmentRepo.listByProjectIds(projectIds);
  const environmentsByProject = {};
  for (const env of allEnvironments) {
    (environmentsByProject[env.projectId] ??= []).push(env);
  }
  const workspaceHasEnvironments = allEnvironments.length > 0;

  // 90-day window for the per-env pass-rate aggregation. Other dashboard
  // metrics use a last-N-runs window (passRate at line 42-55: 10 runs),
  // but per-env buckets are sparser — a quiet env might only see a handful
  // of runs a month, so a time window matches user intuition better than
  // a run-count window. 90 days balances "recent enough to be relevant"
  // against "enough data points for the green-rate signal to be stable".
  const ENV_AGGREGATION_WINDOW_DAYS = 90;
  const envWindowCutoff = Date.now() - ENV_AGGREGATION_WINDOW_DAYS * 24 * 60 * 60 * 1000;

  let environmentPassRates = null;
  if (workspaceHasEnvironments) {
    // bucketKey = `${projectId}::${environmentId || "default"}`. Pre-build a
    // map of bucket → { name, baseUrl } so we can resolve names without a
    // second pass over the env list. Synthetic "default" buckets carry the
    // project's own URL so the UI can still show "Last green vs prod.example.com".
    const buckets = new Map();
    for (const p of projects) {
      const defaultKey = `${p.id}::default`;
      buckets.set(defaultKey, {
        projectId: p.id,
        projectName: p.name,
        environmentId: null,
        environmentName: "default",
        baseUrl: p.url || null,
        total: 0,
        passed: 0,
        failed: 0,
        lastGreenRunAt: null,
        lastGreenRunId: null,
      });
      for (const env of environmentsByProject[p.id] || []) {
        buckets.set(`${p.id}::${env.id}`, {
          projectId: p.id,
          projectName: p.name,
          environmentId: env.id,
          environmentName: env.name,
          baseUrl: env.baseUrl,
          total: 0,
          passed: 0,
          failed: 0,
          lastGreenRunAt: null,
          lastGreenRunId: null,
        });
      }
    }
    for (const r of runs) {
      if (r.type !== "test_run" && r.type !== "run") continue;
      if (r.status !== "completed") continue;
      if (!r.startedAt) continue;
      const key = `${r.projectId}::${r.environmentId || "default"}`;
      const b = buckets.get(key);
      if (!b) continue; // run targeted a now-deleted env — skip silently
      const inWindow = new Date(r.startedAt).getTime() >= envWindowCutoff;
      // Time-window the pass-rate denominator so a project's noisy first
      // year doesn't permanently anchor the displayed rate.
      if (inWindow) {
        b.total += r.total || 0;
        b.passed += r.passed || 0;
        b.failed += r.failed || 0;
      }
      // "Last green run" = most recent completed test run where every test
      // passed (failed === 0) and at least one test ran. Mirrors how
      // `passRate === 100%` is conventionally interpreted in the UI.
      // Unbounded by the window: a "most recent green" lookup is meaningful
      // even when the last green run is older than 90 days (low-cadence
      // envs like DR / quarterly-release projects). The UI shows the
      // timestamp so users can judge staleness themselves.
      const isGreen = (r.failed || 0) === 0 && (r.passed || 0) > 0;
      if (isGreen) {
        const ts = r.startedAt;
        if (!b.lastGreenRunAt || new Date(ts) > new Date(b.lastGreenRunAt)) {
          b.lastGreenRunAt = ts;
          b.lastGreenRunId = r.id;
        }
      }
    }
    environmentPassRates = Array.from(buckets.values())
      // Hide buckets that never received a run so the table doesn't get noisy
      // with synthetic defaults on freshly-created multi-env projects.
      .filter((b) => b.total > 0 || b.environmentId)
      .map((b) => ({
        ...b,
        passRate: b.total > 0 ? Math.round((b.passed / b.total) * 100) : null,
        windowDays: ENV_AGGREGATION_WINDOW_DAYS,
      }));
  }

  // ── AUTO-008: Distributed worker pool visibility ───────────────────────
  // When BullMQ/Redis is available, surface live queue depth + per-worker
  // health so operators can see whether the pool is keeping up. Falls back
  // to a `single-process` stub (zeroed counters) when Redis is absent so the
  // frontend never has to special-case missing fields.
  let workerPool = {
    mode: "single-process",
    queue: { waiting: 0, active: 0, completed: 0, delayed: 0, failed: 0 },
    activeWorkers: 0,
    idleWorkers: 0,
    totalWorkers: 0,
  };
  if (isQueueAvailable()) {
    try {
      const queue = await getQueueStats();
      // BullMQ Queue#getWorkers() returns one entry per live Worker process
      // connected to the queue (across every container/replica). It does NOT
      // report per-slot concurrency, so "active" here is the number of
      // worker processes currently leasing a job, derived from queue.active
      // capped by the worker count. Idle = remaining workers.
      let totalWorkers = 0;
      if (typeof runQueue?.getWorkers === "function") {
        const workers = await runQueue.getWorkers();
        totalWorkers = Array.isArray(workers) ? workers.length : 0;
      }
      const activeWorkers = Math.min(queue.active || 0, totalWorkers);
      const idleWorkers = Math.max(0, totalWorkers - activeWorkers);
      workerPool = {
        mode: "distributed",
        queue,
        activeWorkers,
        idleWorkers,
        totalWorkers,
      };
    } catch {
      // Best-effort — never let queue introspection fail the dashboard.
    }
  }

  res.json({
    totalProjects: projects.length,
    totalTests: tests.length,
    totalRuns: runs.length,
    totalActivities: activityRepo.countFiltered({ workspaceId: req.workspaceId }),
    passRate,
    history,
    recentRuns,
    runsByStatus,
    testsByReview,
    testsCreatedToday,
    testsCreatedThisWeek,
    testsGeneratedTotal,
    testsAutoFixed,
    healingEntries,
    healingSuccesses,
    avgRunDurationMs,
    defectBreakdown,
    flakyTestCount,
    topFlakyTests,
    workerPool,
    testGrowth,
    mttrMs,
    testsByUrl,
    topAccessibilityOffenders,
    environmentPassRates, // DIF-012 — null when no envs configured
  });
  } catch (err) {
    console.error(formatLogLine("error", null, `[dashboard] ${err?.stack || err?.message || err}`));
    return res.status(500).json({ error: "Dashboard data unavailable." });
  }
});

export default router;