Source: utils/notifications.js

/**
 * @module utils/notifications
 * @description Failure notification dispatcher (FEA-001).
 *
 * Dispatches notifications to configured channels when a test run completes
 * with failures.  Supports three channels:
 *
 * 1. **Microsoft Teams** — Adaptive Card via incoming webhook.
 * 2. **Email** — HTML summary via the existing `emailSender.js` transport.
 * 3. **Generic webhook** — POST JSON payload to a user-configured URL.
 *
 * All dispatches are best-effort: errors are logged but never propagate
 * to the caller, so a failing notification never affects the run outcome.
 *
 * ### Usage
 * ```js
 * import { fireNotifications } from "../utils/notifications.js";
 * await fireNotifications(run, project);
 * ```
 */

import * as notificationSettingsRepo from "../database/repositories/notificationSettingsRepo.js";
import { sendEmail, escapeHtml } from "./emailSender.js";
import { formatLogLine } from "./logFormatter.js";
import { safeFetch } from "./ssrfGuard.js";

// ─── Helpers ──────────────────────────────────────────────────────────────────

/**
 * Build the base URL for deep links into the Sentri UI.
 *
 * @returns {string}
 */
function getAppUrl() {
  if (process.env.APP_URL) return process.env.APP_URL;
  const corsOrigin = process.env.CORS_ORIGIN || "";
  return corsOrigin.split(",")[0].trim() || "http://localhost:3000";
}

/**
 * Build a deep link URL to a specific run detail page.
 *
 * @param {string} runId
 * @returns {string}
 */
function runDetailUrl(runId) {
  const base = getAppUrl().replace(/\/$/, "");
  const basePath = (process.env.APP_BASE_PATH || "/").replace(/\/$/, "");
  return `${base}${basePath}/runs/${runId}`;
}

/**
 * Extract failing test names from run results.
 *
 * @param {Object} run
 * @returns {string[]}
 */
function getFailingTestNames(run) {
  if (!Array.isArray(run.results)) return [];
  return run.results
    .filter(r => r.status === "failed")
    .map(r => r.testName || r.testId || "Unknown test")
    .slice(0, 10); // cap at 10 to avoid huge payloads
}

/**
 * Compute human-readable run duration.
 *
 * @param {Object} run
 * @returns {string}
 */
function formatDuration(run) {
  if (!run.duration) return "—";
  const secs = Math.round(run.duration / 1000);
  if (secs < 60) return `${secs}s`;
  const mins = Math.floor(secs / 60);
  const remSecs = secs % 60;
  return remSecs > 0 ? `${mins}m ${remSecs}s` : `${mins}m`;
}

// ─── Channel dispatchers ──────────────────────────────────────────────────────

/**
 * Send a Microsoft Teams Adaptive Card via incoming webhook.
 *
 * @param {string} webhookUrl - Teams incoming webhook URL.
 * @param {Object} run        - Completed run object.
 * @param {Object} project    - Project object.
 * @returns {Promise<void>}
 */
async function sendTeamsNotification(webhookUrl, run, project) {
  const failingTests = getFailingTestNames(run);
  const deepLink = runDetailUrl(run.id);

  const card = {
    type: "message",
    attachments: [{
      contentType: "application/vnd.microsoft.card.adaptive",
      contentUrl: null,
      content: {
        "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
        type: "AdaptiveCard",
        version: "1.4",
        body: [
          {
            type: "TextBlock",
            text: `🔴 Test Run Failed — ${project.name}`,
            weight: "Bolder",
            size: "Medium",
            wrap: true,
          },
          {
            type: "FactSet",
            facts: [
              { title: "Run", value: run.id },
              { title: "Passed", value: String(run.passed || 0) },
              { title: "Failed", value: String(run.failed || 0) },
              { title: "Total", value: String(run.total || 0) },
              { title: "Duration", value: formatDuration(run) },
            ],
          },
          ...(failingTests.length > 0 ? [{
            type: "TextBlock",
            text: `**Failing tests:**\n${failingTests.map(t => `- ${t}`).join("\n")}${failingTests.length >= 10 ? "\n- _(and more…)_" : ""}`,
            wrap: true,
            size: "Small",
          }] : []),
        ],
        actions: [
          {
            type: "Action.OpenUrl",
            title: "View Run Details",
            url: deepLink,
          },
        ],
      },
    }],
  };

  const res = await safeFetch(webhookUrl, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(card),
    signal: AbortSignal.timeout(10_000),
  });

  if (!res.ok) {
    const body = await res.text().catch(() => "");
    throw new Error(`Teams webhook returned ${res.status}: ${body.slice(0, 200)}`);
  }
}

/**
 * Send a failure notification email to all configured recipients.
 *
 * @param {string} recipients - Comma-separated email addresses.
 * @param {Object} run        - Completed run object.
 * @param {Object} project    - Project object.
 * @returns {Promise<void>}
 */
async function sendEmailNotification(recipients, run, project) {
  const failingTests = getFailingTestNames(run);
  const deepLink = runDetailUrl(run.id);
  const duration = formatDuration(run);

  const subject = `[Sentri] ❌ ${run.failed} test${run.failed !== 1 ? "s" : ""} failed — ${project.name}`;

  const failList = failingTests.length > 0
    ? failingTests.map(t => `<li style="color:#dc2626;">${escapeHtml(t)}</li>`).join("")
    : "<li>No details available</li>";

  const html = `
    <div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 520px; margin: 0 auto; padding: 32px 24px;">
      <h2 style="margin: 0 0 16px; font-size: 20px; color: #0f172a;">Test Run Failed — ${escapeHtml(project.name)}</h2>
      <table style="width: 100%; border-collapse: collapse; margin: 0 0 16px; font-size: 14px; color: #475569;">
        <tr><td style="padding: 4px 8px 4px 0; font-weight: 600;">Run</td><td>${escapeHtml(run.id)}</td></tr>
        <tr><td style="padding: 4px 8px 4px 0; font-weight: 600;">Passed</td><td style="color: #16a34a;">${run.passed || 0}</td></tr>
        <tr><td style="padding: 4px 8px 4px 0; font-weight: 600;">Failed</td><td style="color: #dc2626;">${run.failed || 0}</td></tr>
        <tr><td style="padding: 4px 8px 4px 0; font-weight: 600;">Total</td><td>${run.total || 0}</td></tr>
        <tr><td style="padding: 4px 8px 4px 0; font-weight: 600;">Duration</td><td>${escapeHtml(duration)}</td></tr>
      </table>
      <p style="margin: 0 0 8px; font-size: 14px; font-weight: 600; color: #0f172a;">Failing tests:</p>
      <ul style="margin: 0 0 20px; padding-left: 20px; font-size: 13px; line-height: 1.6;">${failList}</ul>
      <a href="${escapeHtml(deepLink)}" style="display: inline-block; padding: 10px 24px; background: #6366f1; color: #fff; text-decoration: none; border-radius: 8px; font-weight: 600; font-size: 14px;">
        View Run Details
      </a>
    </div>
  `;

  const text = [
    `Test Run Failed — ${project.name}`,
    `Run: ${run.id} | Passed: ${run.passed || 0} | Failed: ${run.failed || 0} | Total: ${run.total || 0} | Duration: ${duration}`,
    `Failing tests: ${failingTests.join(", ")}`,
    `Details: ${deepLink}`,
  ].join("\n\n");

  const emails = recipients.split(",").map(e => e.trim()).filter(Boolean);
  for (const to of emails) {
    await sendEmail({ to, subject, html, text });
  }
}

/**
 * Send a generic webhook notification (POST JSON).
 *
 * @param {string} url     - Webhook URL.
 * @param {Object} run     - Completed run object.
 * @param {Object} project - Project object.
 * @returns {Promise<void>}
 */
async function sendWebhookNotification(url, run, project) {
  const payload = {
    event: "run.failed",
    runId: run.id,
    projectId: project.id,
    projectName: project.name,
    status: run.status,
    passed: run.passed || 0,
    failed: run.failed || 0,
    total: run.total || 0,
    duration: run.duration || null,
    failingTests: getFailingTestNames(run),
    detailUrl: runDetailUrl(run.id),
    timestamp: new Date().toISOString(),
  };

  const res = await safeFetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(payload),
    signal: AbortSignal.timeout(10_000),
  });

  if (!res.ok) {
    const body = await res.text().catch(() => "");
    throw new Error(`Webhook returned ${res.status}: ${body.slice(0, 200)}`);
  }
}

// ─── Public API ───────────────────────────────────────────────────────────────

/**
 * Fire all configured notification channels for a completed run.
 *
 * Only dispatches when:
 * 1. The run has failures (`run.failed > 0`).
 * 2. The project has notification settings configured and enabled.
 *
 * All dispatches are best-effort — errors are logged but never thrown.
 *
 * @param {Object} run     - The completed run object.
 * @param {Object} project - The project `{ id, name, url }`.
 * @returns {Promise<void>}
 */
export async function fireNotifications(run, project) {
  // Only notify on failures
  if (!run.failed || run.failed <= 0) return;

  let settings;
  try {
    settings = notificationSettingsRepo.getByProjectId(project.id);
  } catch (err) {
    console.warn(formatLogLine("warn", null,
      `[notifications] Failed to read settings for project ${project.id}: ${err.message}`));
    return;
  }

  if (!settings || !settings.enabled) return;

  const dispatches = [];

  // Microsoft Teams
  if (settings.teamsWebhookUrl) {
    dispatches.push(
      sendTeamsNotification(settings.teamsWebhookUrl, run, project)
        .then(() => console.log(formatLogLine("info", null,
          `[notifications] Teams notification sent for ${run.id}`)))
        .catch(err => console.warn(formatLogLine("warn", null,
          `[notifications] Teams notification failed for ${run.id}: ${err.message}`)))
    );
  }

  // Email
  if (settings.emailRecipients) {
    dispatches.push(
      sendEmailNotification(settings.emailRecipients, run, project)
        .then(() => console.log(formatLogLine("info", null,
          `[notifications] Email notification sent for ${run.id}`)))
        .catch(err => console.warn(formatLogLine("warn", null,
          `[notifications] Email notification failed for ${run.id}: ${err.message}`)))
    );
  }

  // Generic webhook
  if (settings.webhookUrl) {
    dispatches.push(
      sendWebhookNotification(settings.webhookUrl, run, project)
        .then(() => console.log(formatLogLine("info", null,
          `[notifications] Webhook notification sent for ${run.id}`)))
        .catch(err => console.warn(formatLogLine("warn", null,
          `[notifications] Webhook notification failed for ${run.id}: ${err.message}`)))
    );
  }

  await Promise.allSettled(dispatches);
}