File

apps/recallassess/recallassess-api/src/api/shared/services/system-log-http500-notification.service.ts

Index

Properties
Methods

Constructor

constructor(prisma: BNestPrismaService, emailSender: BNestEmailSenderService)
Parameters :
Name Type Optional
prisma BNestPrismaService No
emailSender BNestEmailSenderService No

Methods

Private Async loadRecipientsFromSettings
loadRecipientsFromSettings()
Returns : Promise<string[]>
Async notifyForLogIds
notifyForLogIds(logIds: number[])

Loads configured recipients and sends one alert email per recipient summarizing system_log rows (expected status_code 500).

Parameters :
Name Type Optional
logIds number[] No
Returns : Promise<void>

Properties

Private Readonly logger
Type : unknown
Default value : new Logger(SystemLogHttp500NotificationService.name)
import { BNestEmailSenderService } from "@bish-nest/core";
import { BNestPrismaService } from "@bish-nest/core/services";
import { Injectable, Logger } from "@nestjs/common";

/** System Setting key — comma/semicolon/whitespace-separated admin emails for HTTP 500 system_log alerts. */
export const EMAIL_ERROR_NOTIFICATION_RECIPIENTS_KEY = "email.errorNotificationRecipients";

const ERROR_MESSAGE_MAX_LEN = 4000;
const USER_AGENT_MAX_LEN = 200;

function escapeHtml(s: string | null | undefined): string {
  if (!s) return "";
  return s
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

function parseRecipientList(raw: string | null | undefined): string[] {
  if (!raw?.trim()) return [];
  return raw
    .split(/[,;\s\n]+/)
    .map((e) => e.trim())
    .filter((e) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e));
}

function formatTimestamp(d: Date | null | undefined): string {
  if (!d) return "—";
  try {
    return d.toISOString().replace("T", " ").replace(/\.\d{3}Z$/, " UTC");
  } catch {
    return String(d);
  }
}

function formatActor(
  user: { email: string; first_name: string | null; last_name: string | null } | null,
  participant: { email: string; first_name: string | null; last_name: string | null } | null,
): string {
  if (user?.email) {
    const name = [user.first_name, user.last_name].filter(Boolean).join(" ").trim();
    return name ? `${name} (${user.email})` : user.email;
  }
  if (participant?.email) {
    const name = [participant.first_name, participant.last_name].filter(Boolean).join(" ").trim();
    return name ? `${name} (${participant.email})` : participant.email;
  }
  return "Not recorded (anonymous or system request)";
}

function buildSubject(
  logs: Array<{ request_method: string | null; request_endpoint: string | null }>,
): string {
  if (logs.length === 1) {
    const l = logs[0];
    const method = (l.request_method ?? "REQUEST").toUpperCase();
    const path = l.request_endpoint ?? "unknown endpoint";
    const shortPath = path.length > 72 ? `${path.slice(0, 69)}…` : path;
    return `RecallAssess alert: HTTP 500 on ${method} ${shortPath}`;
  }
  return `RecallAssess alert: ${logs.length} HTTP 500 server errors`;
}

type Http500LogRow = {
  id: number;
  entity_type: string | null;
  operation_type: string;
  request_endpoint: string | null;
  request_method: string | null;
  error_message: string | null;
  ip_address: string | null;
  user_agent: string | null;
  response_time_ms: number | null;
  timestamp: Date | null;
  user: { email: string; first_name: string | null; last_name: string | null } | null;
  participant: { email: string; first_name: string | null; last_name: string | null } | null;
};

function buildIncidentCard(log: Http500LogRow): string {
  const method = escapeHtml((log.request_method ?? "—").toUpperCase());
  const endpoint = escapeHtml(log.request_endpoint ?? "—");
  const errorText = (log.error_message ?? "").trim();
  const whyBlock = errorText
    ? `<pre style="margin:0;white-space:pre-wrap;font-family:Consolas,Monaco,monospace;font-size:13px;line-height:1.45;color:#1e1b4b;">${escapeHtml(errorText.slice(0, ERROR_MESSAGE_MAX_LEN))}</pre>`
    : `<p style="margin:0;color:#64748b;font-size:14px;">No error message was stored for this event. Open the system log record in Admin for full request context.</p>`;

  const responseTime =
    typeof log.response_time_ms === "number"
      ? `${log.response_time_ms} ms`
      : "—";

  const userAgent = log.user_agent
    ? escapeHtml(log.user_agent.slice(0, USER_AGENT_MAX_LEN))
    : "—";

  return `
<section style="margin:0 0 24px;border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;">
  <div style="background:#7c3aed;color:#fff;padding:12px 16px;font-size:15px;font-weight:600;">
    Incident #${log.id} · HTTP 500
  </div>
  <div style="padding:16px;">
    <p style="margin:0 0 16px;font-size:14px;line-height:1.5;color:#334155;">
      The server failed while handling this request. Users may have seen a generic error or the action may not have completed.
    </p>

    <h3 style="margin:0 0 8px;font-size:13px;font-weight:700;color:#7c3aed;text-transform:uppercase;letter-spacing:0.04em;">Why it failed</h3>
    <div style="display:block;background:#f5f3ff;border-left:4px solid #7c3aed;padding:12px 14px;margin:0 0 16px;border-radius:0 6px 6px 0;">
      ${whyBlock}
    </div>

    <h3 style="margin:0 0 8px;font-size:13px;font-weight:700;color:#475569;text-transform:uppercase;letter-spacing:0.04em;">Request</h3>
    <table style="width:100%;border-collapse:collapse;font-size:14px;margin:0 0 16px;">
      <tr><td style="padding:4px 12px 4px 0;color:#64748b;width:120px;vertical-align:top;">Method</td><td style="padding:4px 0;color:#0f172a;"><strong>${method}</strong></td></tr>
      <tr><td style="padding:4px 12px 4px 0;color:#64748b;vertical-align:top;">Endpoint</td><td style="padding:4px 0;color:#0f172a;word-break:break-all;">${endpoint}</td></tr>
      <tr><td style="padding:4px 12px 4px 0;color:#64748b;vertical-align:top;">Time (UTC)</td><td style="padding:4px 0;color:#0f172a;">${escapeHtml(formatTimestamp(log.timestamp))}</td></tr>
      <tr><td style="padding:4px 12px 4px 0;color:#64748b;vertical-align:top;">Response time</td><td style="padding:4px 0;color:#0f172a;">${escapeHtml(responseTime)}</td></tr>
    </table>

    <h3 style="margin:0 0 8px;font-size:13px;font-weight:700;color:#475569;text-transform:uppercase;letter-spacing:0.04em;">Context</h3>
    <table style="width:100%;border-collapse:collapse;font-size:14px;margin:0;">
      <tr><td style="padding:4px 12px 4px 0;color:#64748b;width:120px;vertical-align:top;">System log</td><td style="padding:4px 0;color:#0f172a;">#${log.id}</td></tr>
      <tr><td style="padding:4px 12px 4px 0;color:#64748b;vertical-align:top;">Entity</td><td style="padding:4px 0;color:#0f172a;">${escapeHtml(String(log.entity_type ?? "—"))} · ${escapeHtml(String(log.operation_type ?? "—"))}</td></tr>
      <tr><td style="padding:4px 12px 4px 0;color:#64748b;vertical-align:top;">Actor</td><td style="padding:4px 0;color:#0f172a;">${escapeHtml(formatActor(log.user, log.participant))}</td></tr>
      <tr><td style="padding:4px 12px 4px 0;color:#64748b;vertical-align:top;">Client IP</td><td style="padding:4px 0;color:#0f172a;">${escapeHtml(log.ip_address ?? "—")}</td></tr>
      <tr><td style="padding:4px 12px 4px 0;color:#64748b;vertical-align:top;">User agent</td><td style="padding:4px 0;color:#0f172a;font-size:12px;word-break:break-all;">${userAgent}</td></tr>
    </table>
  </div>
</section>`;
}

function buildAlertEmailHtml(logs: Http500LogRow[]): string {
  const intro =
    logs.length === 1
      ? "One internal server error (HTTP 500) was recorded."
      : `${logs.length} internal server errors (HTTP 500) were recorded.`;

  const cards = logs.map((l) => buildIncidentCard(l)).join("");

  return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:24px 16px;background:#f8fafc;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;font-size:15px;color:#0f172a;">
  <div style="max-width:640px;margin:0 auto;background:#fff;border-radius:10px;border:1px solid #e2e8f0;overflow:hidden;box-shadow:0 1px 3px rgba(15,23,42,0.08);">
    <div style="background:linear-gradient(135deg,#6d28d9 0%,#7c3aed 100%);padding:20px 24px;color:#fff;">
      <p style="margin:0 0 4px;font-size:12px;font-weight:600;opacity:0.9;letter-spacing:0.06em;text-transform:uppercase;">Server error alert</p>
      <h1 style="margin:0;font-size:20px;font-weight:700;line-height:1.3;">RecallAssess — HTTP 500</h1>
    </div>
    <div style="display:block;padding:20px 24px 8px;">
      <p style="margin:0 0 8px;font-size:15px;line-height:1.55;color:#334155;">${escapeHtml(intro)}</p>
      <p style="margin:0 0 20px;font-size:14px;line-height:1.5;color:#64748b;">
        This email is sent automatically when the application logs a failed request with status code 500.
        Use the details below to investigate; open <strong>Admin → System Log</strong> for the full record.
      </p>
      ${cards}
    </div>
    <div style="padding:12px 24px 20px;border-top:1px solid #f1f5f9;font-size:12px;color:#94a3b8;">
      RecallAssess operations notification
    </div>
  </div>
</body>
</html>`;
}

@Injectable()
export class SystemLogHttp500NotificationService {
  private readonly logger = new Logger(SystemLogHttp500NotificationService.name);

  constructor(
    private readonly prisma: BNestPrismaService,
    private readonly emailSender: BNestEmailSenderService,
  ) {}

  /**
   * Loads configured recipients and sends one alert email per recipient
   * summarizing system_log rows (expected status_code 500).
   */
  async notifyForLogIds(logIds: number[]): Promise<void> {
    if (!logIds.length) return;

    const recipients = await this.loadRecipientsFromSettings();
    if (!recipients.length) return;

    const logs = await this.prisma.client.systemLog.findMany({
      where: { id: { in: logIds }, status_code: 500 },
      select: {
        id: true,
        entity_type: true,
        operation_type: true,
        request_endpoint: true,
        request_method: true,
        error_message: true,
        ip_address: true,
        user_agent: true,
        response_time_ms: true,
        timestamp: true,
        user: {
          select: { email: true, first_name: true, last_name: true },
        },
        participant: {
          select: { email: true, first_name: true, last_name: true },
        },
      },
    });

    if (!logs.length) return;

    const content = buildAlertEmailHtml(logs as Http500LogRow[]);
    const subject = buildSubject(logs);

    for (const to of recipients) {
      try {
        await this.emailSender.sendEmail({
          to,
          subject,
          content,
          skipEnvironmentSubjectPrefix: true,
          metadata: {
            kind: "system_log_http500_alert",
            system_log_ids: logs.map((l) => l.id),
          },
        });
      } catch (err) {
        this.logger.error(
          `Failed to send HTTP 500 system log alert to ${to}: ${err instanceof Error ? err.message : String(err)}`,
        );
      }
    }
  }

  private async loadRecipientsFromSettings(): Promise<string[]> {
    try {
      const row = await this.prisma.client.systemSetting.findUnique({
        where: { key: EMAIL_ERROR_NOTIFICATION_RECIPIENTS_KEY },
      });
      return parseRecipientList(row?.value);
    } catch (err) {
      this.logger.warn(
        `Could not load ${EMAIL_ERROR_NOTIFICATION_RECIPIENTS_KEY}: ${err instanceof Error ? err.message : String(err)}`,
      );
      return [];
    }
  }
}

results matching ""

    No results matching ""