Source: routes/projects.js

/**
 * @module routes/projects
 * @description Project CRUD routes. Mounted at `/api/v1/projects` (INF-005).
 *
 * ### Endpoints
 * | Method   | Path                              | Description                                            |
 * |----------|-----------------------------------|--------------------------------------------------------|
 * | `POST`   | `/api/v1/projects`                | Create a project                                       |
 * | `GET`    | `/api/v1/projects`                | List all non-deleted projects                          |
 * | `GET`    | `/api/v1/projects/:id`            | Get a single project                                   |
 * | `DELETE` | `/api/v1/projects/:id`            | Soft-delete project + cascade soft-delete its data     |
 * | `GET`    | `/api/v1/projects/:id/schedule`   | Get the cron schedule for a project                    |
 * | `PATCH`  | `/api/v1/projects/:id/schedule`   | Create or update the cron schedule for a project       |
 * | `DELETE` | `/api/v1/projects/:id/schedule`   | Remove the cron schedule for a project                 |
 * | `GET`    | `/api/v1/projects/:id/notifications` | Get notification settings for a project             |
 * | `PATCH`  | `/api/v1/projects/:id/notifications` | Create or update notification settings              |
 * | `DELETE` | `/api/v1/projects/:id/notifications` | Remove notification settings for a project          |
 */

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 { getDatabase } from "../database/sqlite.js";
import { generateProjectId, generateScheduleId } from "../utils/idGenerator.js";
import { logActivity } from "../utils/activityLogger.js";
import { encryptCredentials } from "../utils/credentialEncryption.js";
import { validateProjectPayload, sanitise } from "../utils/validate.js";
import { actor } from "../utils/actor.js";
import { sanitiseProjectForClient } from "../utils/projectSanitiser.js";
import { reloadSchedule, stopSchedule, getNextRunAt } from "../scheduler.js";
import { requireRole } from "../middleware/requireRole.js";
import * as notificationSettingsRepo from "../database/repositories/notificationSettingsRepo.js";
import { generateNotificationSettingId } from "../utils/idGenerator.js";
import { validateUrl } from "../utils/ssrfGuard.js";
import cron from "node-cron";

const router = Router();

// ─── Project CRUD ─────────────────────────────────────────────────────────────

router.post("/", requireRole("qa_lead"), (req, res) => {
  const validationErr = validateProjectPayload(req.body);
  if (validationErr) return res.status(400).json({ error: validationErr });

  const name = sanitise(req.body.name, 200);
  const url = req.body.url?.trim() || "";
  const credentials = req.body.credentials;

  const id = generateProjectId();
  const project = {
    id,
    name,
    url,
    credentials: encryptCredentials(credentials) || null,
    createdAt: new Date().toISOString(),
    status: "idle",
    workspaceId: req.workspaceId || null,
  };
  projectRepo.create(project);

  logActivity({ ...actor(req),
    type: "project.create", projectId: id, projectName: name,
    detail: `Project created — "${name}" (${url})`,
  });

  res.status(201).json(sanitiseProjectForClient(project));
});

router.get("/", (req, res) => {
  res.json(projectRepo.getAll(req.workspaceId).map(sanitiseProjectForClient));
});

router.get("/:id", (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "not found" });
  res.json(sanitiseProjectForClient(project));
});

router.delete("/:id", requireRole("admin"), (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "not found" });

  // Refuse soft-deletion while async operations are in progress
  const activeRun = runRepo.findActiveByProjectId(req.params.id);
  if (activeRun) {
    return res.status(409).json({
      error: "Cannot delete project while operations are running. Wait for active crawls or test runs to complete.",
    });
  }

  // Check for automation config that will be permanently destroyed (not recoverable
  // via restore) so the response can inform the frontend for a user-facing warning.
  const existingTokens  = webhookTokenRepo.getByProjectId(req.params.id);
  const existingSchedule = scheduleRepo.getByProjectId(req.params.id);

  // Wrap the cascade soft-delete in a transaction so all three tables get the
  // same `datetime('now')` value.  This guarantees the cascade-restore in
  // recycleBin.js (which uses `deletedAt >= project.deletedAt`) never misses
  // children due to a second-boundary crossing between separate statements.
  const db = getDatabase();
  let testIds, runIds;
  db.transaction(() => {
    projectRepo.deleteById(req.params.id);
    testIds = testRepo.deleteByProjectId(req.params.id);
    runIds  = runRepo.deleteByProjectId(req.params.id);
    // Trigger tokens are not soft-deleted — they are always hard-deleted
    // immediately since they are security credentials, not recoverable data.
    // Restoring the project will NOT restore these — CI pipelines will need
    // new tokens.
    webhookTokenRepo.deleteByProjectId(req.params.id);
    // Stop any armed cron task so the scheduler doesn't keep firing for a
    // soft-deleted project (which would log repeated warnings every interval).
    // Restoring the project will NOT restore the schedule — it must be
    // reconfigured manually.
    scheduleRepo.deleteByProjectId(req.params.id);
  })();
  stopSchedule(req.params.id);

  logActivity({ ...actor(req),
    type: "project.delete", projectId: req.params.id, projectName: project.name,
    detail: `Project soft-deleted — "${project.name}" (${testIds.length} tests, ${runIds.length} runs moved to recycle bin)`,
  });

  res.json({
    ok: true,
    deletedTests: testIds.length,
    deletedRuns: runIds.length,
    // Inform the client about permanently destroyed automation config so the
    // frontend can display a warning (these are NOT restored on project restore).
    destroyedTokens: existingTokens.length,
    destroyedSchedule: !!existingSchedule,
  });
});

// ─── Schedule endpoints ───────────────────────────────────────────────────────

/**
 * GET /api/projects/:id/schedule
 * Return the current schedule for a project, or null if none exists.
 */
router.get("/:id/schedule", (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "Project not found" });
  const schedule = scheduleRepo.getByProjectId(req.params.id);
  res.json({ schedule: schedule || null });
});

/**
 * PATCH /api/projects/:id/schedule
 * Create or update the cron schedule for a project.
 *
 * Body:
 *   cronExpr {string}  - 5-field cron expression (required)
 *   timezone {string}  - IANA timezone name (default "UTC")
 *   enabled  {boolean} - Whether the schedule is active (default true)
 */
router.patch("/:id/schedule", requireRole("qa_lead"), (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "Project not found" });

  const { cronExpr, timezone = "UTC", enabled = true } = req.body || {};

  if (!cronExpr || typeof cronExpr !== "string") {
    return res.status(400).json({ error: "cronExpr is required" });
  }
  if (!cron.validate(cronExpr)) {
    return res.status(400).json({ error: `Invalid cron expression: "${cronExpr}"` });
  }
  // Reject expressions with seconds field (6-part) — node-cron supports it
  // but we only expose the standard 5-field format to users.
  if (cronExpr.trim().split(/\s+/).length !== 5) {
    return res.status(400).json({ error: "cronExpr must be a standard 5-field expression (minute hour dom month dow)" });
  }

  // Validate timezone — an invalid IANA name would throw a RangeError in
  // toLocaleString (used by getNextRunAt) and crash with a 500.
  try {
    Intl.DateTimeFormat(undefined, { timeZone: timezone });
  } catch {
    return res.status(400).json({ error: `Invalid timezone: "${timezone}"` });
  }

  const existing = scheduleRepo.getByProjectId(req.params.id);
  const now = new Date().toISOString();
  const nextRunAt = getNextRunAt(cronExpr, timezone);

  const schedule = scheduleRepo.upsert({
    id: existing?.id || generateScheduleId(),
    projectId: req.params.id,
    cronExpr,
    timezone,
    enabled: Boolean(enabled),
    lastRunAt: existing?.lastRunAt || null,
    nextRunAt,
    createdAt: existing?.createdAt || now,
    updatedAt: now,
  });

  // Hot-reload the cron task without a restart
  reloadSchedule(req.params.id);

  logActivity({
    ...actor(req),
    type: "schedule.update",
    projectId: project.id,
    projectName: project.name,
    detail: `Schedule ${existing ? "updated" : "created"} — ${cronExpr} (${timezone})`,
  });

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

/**
 * DELETE /api/projects/:id/schedule
 * Remove the cron schedule for a project entirely.
 */
router.delete("/:id/schedule", requireRole("qa_lead"), (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "Project not found" });

  const existing = scheduleRepo.getByProjectId(req.params.id);
  if (!existing) return res.status(404).json({ error: "No schedule found for this project" });

  scheduleRepo.deleteByProjectId(req.params.id);
  stopSchedule(req.params.id);

  logActivity({
    ...actor(req),
    type: "schedule.delete",
    projectId: project.id,
    projectName: project.name,
    detail: `Schedule removed`,
  });

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

// ─── Notification settings endpoints (FEA-001) ───────────────────────────────

/**
 * GET /api/projects/:id/notifications
 * Return the notification settings for a project, or null if none exist.
 */
router.get("/:id/notifications", (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "Project not found" });
  const settings = notificationSettingsRepo.getByProjectId(req.params.id);
  res.json({ notifications: settings || null });
});

/**
 * PATCH /api/projects/:id/notifications
 * Create or update notification settings for a project.
 *
 * Body:
 *   teamsWebhookUrl  {string}  - Microsoft Teams incoming webhook URL (optional)
 *   emailRecipients  {string}  - Comma-separated email addresses (optional)
 *   webhookUrl       {string}  - Generic webhook URL (optional)
 *   enabled          {boolean} - Whether notifications are active (default true)
 */
router.patch("/:id/notifications", requireRole("qa_lead"), async (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "Project not found" });

  const { teamsWebhookUrl, emailRecipients, webhookUrl, enabled = true } = req.body || {};

  // Validate at least one channel is configured
  const hasTeams = typeof teamsWebhookUrl === "string" && teamsWebhookUrl.trim();
  const hasEmail = typeof emailRecipients === "string" && emailRecipients.trim();
  const hasWebhook = typeof webhookUrl === "string" && webhookUrl.trim();

  if (!hasTeams && !hasEmail && !hasWebhook) {
    return res.status(400).json({ error: "At least one notification channel must be configured (teamsWebhookUrl, emailRecipients, or webhookUrl)" });
  }

  // SSRF-safe URL validation for webhook URLs (protocol, private hosts, DNS resolution)
  if (hasTeams) {
    const teamsErr = await validateUrl(teamsWebhookUrl);
    if (teamsErr) return res.status(400).json({ error: `teamsWebhookUrl: ${teamsErr}` });
  }
  if (hasWebhook) {
    const webhookErr = await validateUrl(webhookUrl);
    if (webhookErr) return res.status(400).json({ error: `webhookUrl: ${webhookErr}` });
  }

  // Validate email format (basic check)
  if (hasEmail) {
    const emails = emailRecipients.split(",").map(e => e.trim()).filter(Boolean);
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    for (const email of emails) {
      if (!emailRegex.test(email)) {
        return res.status(400).json({ error: `Invalid email address: "${email}"` });
      }
    }
  }

  const existing = notificationSettingsRepo.getByProjectId(req.params.id);
  const now = new Date().toISOString();

  const settings = notificationSettingsRepo.upsert({
    id: existing?.id || generateNotificationSettingId(),
    projectId: req.params.id,
    teamsWebhookUrl: hasTeams ? teamsWebhookUrl.trim() : null,
    emailRecipients: hasEmail ? emailRecipients.trim() : null,
    webhookUrl: hasWebhook ? webhookUrl.trim() : null,
    enabled: Boolean(enabled),
    createdAt: existing?.createdAt || now,
    updatedAt: now,
  });

  logActivity({
    ...actor(req),
    type: "notifications.update",
    projectId: project.id,
    projectName: project.name,
    detail: `Notification settings ${existing ? "updated" : "created"}`,
  });

  res.json({ ok: true, notifications: settings });
});

/**
 * DELETE /api/projects/:id/notifications
 * Remove notification settings for a project.
 */
router.delete("/:id/notifications", requireRole("qa_lead"), (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "Project not found" });

  const existing = notificationSettingsRepo.getByProjectId(req.params.id);
  if (!existing) return res.status(404).json({ error: "No notification settings found for this project" });

  notificationSettingsRepo.deleteByProjectId(req.params.id);

  logActivity({
    ...actor(req),
    type: "notifications.delete",
    projectId: project.id,
    projectName: project.name,
    detail: "Notification settings removed",
  });

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

export default router;