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, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
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 [];
}
}
}