Source: routes/recycleBin.js

/**
 * @module routes/recycleBin
 * @description Recycle-bin endpoints for soft-deleted entities. Mounted at `/api/v1` (INF-005).
 *
 * ### Endpoints
 * | Method   | Path                           | Description                                        |
 * |----------|--------------------------------|-----------------------------------------------------|
 * | `GET`    | `/api/v1/recycle-bin`          | List all soft-deleted entities grouped by type     |
 * | `POST`   | `/api/v1/restore/:type/:id`    | Restore a soft-deleted entity                      |
 * | `DELETE` | `/api/v1/purge/:type/:id`      | Permanently delete a soft-deleted entity (purge)   |
 */

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 webhookTokenRepo from "../database/repositories/webhookTokenRepo.js";
import * as scheduleRepo from "../database/repositories/scheduleRepo.js";
import { stopSchedule } from "../scheduler.js";
import { logActivity } from "../utils/activityLogger.js";
import { actor } from "../utils/actor.js";
import { sanitiseProjectForClient } from "../utils/projectSanitiser.js";
import { requireRole } from "../middleware/requireRole.js";

const router = Router();

// ─── Recycle bin ─────────────────────────────────────────────────────────────

/**
 * GET /api/recycle-bin
 * Returns all soft-deleted entities grouped by type, newest first.
 * Capped at 200 items per type to prevent unbounded responses.
 */
router.get("/recycle-bin", (req, res) => {
  const LIMIT = 200;
  // ACL-001: Scope recycle bin to the user's workspace.
  const projects = projectRepo.getDeletedAll(req.workspaceId).slice(0, LIMIT).map(sanitiseProjectForClient);
  const deletedProjectIds = new Set(projects.map(p => p.id));
  // Also include project IDs from live projects in this workspace so we can
  // show deleted tests/runs that belong to non-deleted workspace projects.
  const liveProjects = projectRepo.getAll(req.workspaceId);
  const allProjectIds = new Set([...deletedProjectIds, ...liveProjects.map(p => p.id)]);
  const tests    = testRepo.getDeletedAll().filter(t => allProjectIds.has(t.projectId)).slice(0, LIMIT);
  const runs     = runRepo.getDeletedAll().filter(r => allProjectIds.has(r.projectId)).slice(0, LIMIT);
  res.json({ projects, tests, runs });
});

/**
 * POST /api/restore/:type/:id
 * Restore a soft-deleted entity. type must be "project", "test", or "run".
 */
router.post("/restore/:type/:id", requireRole("qa_lead"), (req, res) => {
  const { type, id } = req.params;
  let restored = false;

  if (type === "project") {
    // ACL-001: Verify the project belongs to the user's workspace.
    const projectBefore = projectRepo.getByIdIncludeDeleted(id);
    if (!projectBefore || projectBefore.workspaceId !== req.workspaceId) {
      return res.status(404).json({ error: "not found or not in recycle bin" });
    }
    // Capture the project's deletedAt before restoring so we can scope the
    // cascade to only children deleted at the same time (or later).  Items
    // individually deleted *before* the project are left in the recycle bin.
    restored = projectRepo.restore(id);
    if (restored) {
      const deletedAt = projectBefore?.deletedAt;
      if (deletedAt) {
        testRepo.restoreByProjectIdAfter(id, deletedAt);
        runRepo.restoreByProjectIdAfter(id, deletedAt);
      }
      const proj = projectRepo.getById(id);
      logActivity({ ...actor(req),
        type: "project.restore", projectId: id, projectName: proj?.name,
        detail: `Project "${proj?.name}" restored from recycle bin`,
      });
    }
  } else if (type === "test") {
    const test = testRepo.getByIdIncludeDeleted(id);
    if (test) {
      // ACL-001: Verify the test's project belongs to the user's workspace.
      const parentProjectFull = projectRepo.getByIdIncludeDeleted(test.projectId);
      if (!parentProjectFull || parentProjectFull.workspaceId !== req.workspaceId) {
        return res.status(404).json({ error: "not found or not in recycle bin" });
      }
      const parentProject = projectRepo.getById(test.projectId);
      if (!parentProject) {
        return res.status(409).json({ error: "Parent project is deleted — restore the project first" });
      }
    }
    restored = testRepo.restore(id);
    if (restored) {
      logActivity({ ...actor(req),
        type: "test.restore", testId: id, testName: test?.name,
        detail: `Test "${test?.name}" restored from recycle bin`,
      });
    }
  } else if (type === "run") {
    const run = runRepo.getByIdIncludeDeleted(id);
    if (run) {
      // ACL-001: Verify the run's project belongs to the user's workspace.
      const parentProjectFull = projectRepo.getByIdIncludeDeleted(run.projectId);
      if (!parentProjectFull || parentProjectFull.workspaceId !== req.workspaceId) {
        return res.status(404).json({ error: "not found or not in recycle bin" });
      }
      const parentProject = projectRepo.getById(run.projectId);
      if (!parentProject) {
        return res.status(409).json({ error: "Parent project is deleted — restore the project first" });
      }
    }
    restored = runRepo.restore(id);
    if (restored) {
      logActivity({ ...actor(req),
        type: "run.restore", detail: `Run ${id} restored from recycle bin`,
      });
    }
  } else {
    return res.status(400).json({ error: "type must be project, test, or run" });
  }

  if (!restored) return res.status(404).json({ error: "not found or not in recycle bin" });
  res.json({ ok: true });
});

/**
 * DELETE /api/purge/:type/:id
 * Permanently and irreversibly delete a soft-deleted entity.
 * type must be "project", "test", or "run".
 */
router.delete("/purge/:type/:id", requireRole("admin"), (req, res) => {
  const { type, id } = req.params;

  if (type === "project") {
    const project = projectRepo.getByIdIncludeDeleted(id);
    if (!project || !project.deletedAt) {
      return res.status(404).json({ error: "not found in recycle bin" });
    }
    // ACL-001: Verify the project belongs to the user's workspace.
    if (project.workspaceId !== req.workspaceId) {
      return res.status(404).json({ error: "not found in recycle bin" });
    }
    const testIds = testRepo.hardDeleteByProjectId(id);
    if (testIds.length > 0) healingRepo.deleteByTestIds(testIds);
    runRepo.hardDeleteByProjectId(id);
    activityRepo.deleteByProjectId(id);
    webhookTokenRepo.deleteByProjectId(id);
    scheduleRepo.deleteByProjectId(id);
    stopSchedule(id);
    projectRepo.hardDeleteById(id);
    logActivity({ ...actor(req),
      type: "project.purge", projectId: id, projectName: project.name,
      detail: `Project "${project.name}" permanently purged`,
    });
  } else if (type === "test") {
    const test = testRepo.getByIdIncludeDeleted(id);
    if (!test || !test.deletedAt) {
      return res.status(404).json({ error: "not found in recycle bin" });
    }
    // ACL-001: Verify the test's project belongs to the user's workspace.
    const testProject = projectRepo.getByIdIncludeDeleted(test.projectId);
    if (!testProject || testProject.workspaceId !== req.workspaceId) {
      return res.status(404).json({ error: "not found in recycle bin" });
    }
    healingRepo.deleteByTestIds([id]);
    testRepo.hardDeleteById(id);
    logActivity({ ...actor(req),
      type: "test.purge", testId: id,
      detail: `Test "${test.name}" permanently purged`,
    });
  } else if (type === "run") {
    const run = runRepo.getByIdIncludeDeleted(id);
    if (!run || !run.deletedAt) {
      return res.status(404).json({ error: "not found in recycle bin" });
    }
    // ACL-001: Verify the run's project belongs to the user's workspace.
    const runProject = projectRepo.getByIdIncludeDeleted(run.projectId);
    if (!runProject || runProject.workspaceId !== req.workspaceId) {
      return res.status(404).json({ error: "not found in recycle bin" });
    }
    runRepo.hardDeleteById(id);
    logActivity({ ...actor(req),
      type: "run.purge", detail: `Run ${id} permanently purged`,
    });
  } else {
    return res.status(400).json({ error: "type must be project, test, or run" });
  }

  res.json({ ok: true });
});

export default router;