Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/OUTBOX_IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,14 @@ await dispatcher.start();

### Health Check

The official outbox health endpoint is:

```bash
GET /api/v1/health/outbox
```

The deprecated admin-side health route has been removed; this endpoint is the supported health check for the outbox system.

Response:
```json
{
Expand Down
2 changes: 1 addition & 1 deletion backend/OUTBOX_IMPLEMENTATION_SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ This document summarizes the complete implementation of the Transactional Outbox
├── Monitor Statistics (/api/v1/admin/outbox/stats)
├── Retry Failed Events (/api/v1/admin/outbox/retry/:id)
├── Inspect DLQ (/api/v1/admin/outbox/dlq)
└── Health Checks (/api/v1/health/outbox)
└── Official health endpoint (/api/v1/health/outbox)
```

## 🔧 Configuration
Expand Down
34 changes: 34 additions & 0 deletions backend/src/api/routes/health.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { getOutboxSystem } from "../../outbox/index.js";
import { HealthCheckService } from "../../services/healthCheck.service.js";

const healthService = new HealthCheckService();
Expand Down Expand Up @@ -205,4 +206,37 @@ export async function healthRoutes(server: FastifyInstance) {
}
}
);

// Outbox-specific health check
server.get(
"/outbox",
async (_request: FastifyRequest, reply: FastifyReply) => {
try {
const outboxSystem = getOutboxSystem();
const health = await outboxSystem.healthCheck();

reply.code(health.status === "unhealthy" ? 503 : 200);
return {
status: health.status,
details: health.details,
timestamp: new Date().toISOString(),
};
} catch (error) {
server.log.error({ error }, "Outbox health check failed");
reply.code(503);
return {
status: "unhealthy",
details: {
initialized: false,
dispatcherRunning: false,
pendingEvents: 0,
failedEvents: 0,
deadLetterEvents: 0,
},
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
);
}
50 changes: 0 additions & 50 deletions backend/src/api/routes/outbox-admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,56 +223,6 @@ export async function outboxAdminRoutes(fastify: FastifyInstance) {
}
});

// GET /admin/outbox/health - Health check endpoint
fastify.get("/health", {
schema: {
description: "Health check for outbox system",
tags: ["outbox-admin"],
response: {
200: {
type: "object",
properties: {
status: { type: "string" },
pending: { type: "number" },
processing: { type: "number" },
failed: { type: "number" },
deadLetter: { type: "number" },
timestamp: { type: "string" },
},
},
},
},
}, async (request, reply) => {
try {
const stats = await adminApi.getStats();

// Determine health status based on metrics
let status = "healthy";
if (stats.outbox.failed > 100) {
status = "degraded";
}
if (stats.deadLetter.total > 50) {
status = "unhealthy";
}

return reply.send({
status,
pending: stats.outbox.pending,
processing: stats.outbox.processing,
failed: stats.outbox.failed,
deadLetter: stats.deadLetter.total,
timestamp: new Date().toISOString(),
});
} catch (error) {
logger.error({ error }, "Health check failed");
return reply.code(200 as any).send({
status: "error",
error: "Health check failed",
timestamp: new Date().toISOString(),
});
}
});

// POST /admin/outbox/purge/delivered - Purge old delivered events
fastify.post("/purge/delivered", {
schema: {
Expand Down
13 changes: 6 additions & 7 deletions backend/src/services/email.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import nodemailer from "nodemailer";
import type { Transporter } from "nodemailer";
import { config } from "../config/index.js";
import { formatEmailDate } from "../utils/email.js";
import { logger } from "../utils/logger.js";

type EmailTemplateType = "alert" | "digest";
Expand Down Expand Up @@ -106,9 +107,7 @@ export class EmailNotificationService {
): Promise<string> {
// Use a simple renderer that wraps htmlContent into a basic template
const renderer = (p: EmailReportPayload, ctx: EmailTemplateContext) => {
const subject = `Bridge Watch Report (${p.periodStart.toISOString().slice(0, 10)} - ${p.periodEnd
.toISOString()
.slice(0, 10)})`;
const subject = `Bridge Watch Report (${formatEmailDate(p.periodStart)} - ${formatEmailDate(p.periodEnd)})`;
const html = `
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.5;">
Expand Down Expand Up @@ -481,15 +480,15 @@ export class EmailNotificationService {
<li>
<strong>${item.title}</strong><br />
${item.summary}<br />
<small>${item.timestamp}</small>
<small>${formatEmailDate(item.timestamp)}</small>
</li>`
)
.join("");

const itemsText = payload.items
.map(
(item) =>
`- ${item.title}\n ${item.summary}\n ${item.timestamp}`
`- ${item.title}\n ${item.summary}\n ${formatEmailDate(item.timestamp)}`
)
.join("\n");

Expand All @@ -498,7 +497,7 @@ export class EmailNotificationService {
<body style="font-family: Arial, sans-serif; line-height: 1.5;">
<h2>${subject}</h2>
<p>Hello ${context.recipientName ?? "Subscriber"},</p>
<p>Digest generated at ${payload.generatedAt}.</p>
<p>Digest generated at ${formatEmailDate(payload.generatedAt)}.</p>
<ul>${itemsHtml}</ul>
<p><a href="${context.unsubscribeUrl ?? "#"}">Unsubscribe</a></p>
</body>
Expand All @@ -508,7 +507,7 @@ export class EmailNotificationService {
subject,
"",
`Hello ${context.recipientName ?? "Subscriber"},`,
`Digest generated at ${payload.generatedAt}.`,
`Digest generated at ${formatEmailDate(payload.generatedAt)}.`,
"",
itemsText || "No digest items.",
"",
Expand Down
5 changes: 2 additions & 3 deletions backend/src/services/reportScheduling.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { getDatabase } from "../database/connection.js";
import { logger } from "../utils/logger.js";
import { EmailNotificationService, EmailRecipient, EmailReportPayload } from "./email.service.js";
import { AnalyticsService } from "./analytics.service.js";
import { formatEmailDate } from "../utils/email.js";
import { AlertService } from "./alert.service.js";
import { ReconciliationService } from "./reconciliation.service.js";

Expand Down Expand Up @@ -458,9 +459,7 @@ export class ReportSchedulingService {
* degraded dependency never prevents the report from being sent.
*/
private async generateReportHtml(delivery: ReportDelivery): Promise<string> {
const periodLabel = `${delivery.periodStart.toISOString().slice(0, 10)} – ${delivery.periodEnd
.toISOString()
.slice(0, 10)}`;
const periodLabel = `${formatEmailDate(delivery.periodStart)} – ${formatEmailDate(delivery.periodEnd)}`;

const [protocolStats, assetRankings, alertSummary, reconciliation] = await Promise.all([
this.buildProtocolStatsSection(),
Expand Down
29 changes: 23 additions & 6 deletions backend/src/utils/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,27 @@ import type { ExportRecord } from "../types/export.types.js";

let transporter: Transporter | null = null;

export function formatEmailDate(value: Date | string | number | null | undefined): string {
if (value == null || value === "") {
return "N/A";
}

const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return "N/A";
}

return `${new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: "UTC",
}).format(date)} UTC`;
}

/**
* Get or create email transporter
* Returns null if SMTP is not configured
Expand Down Expand Up @@ -81,9 +102,7 @@ export async function sendExportEmail(
* Generate HTML email content
*/
function generateEmailHTML(exportRecord: ExportRecord): string {
const expiryDate = exportRecord.download_url_expires_at
? new Date(exportRecord.download_url_expires_at).toLocaleString()
: "N/A";
const expiryDate = formatEmailDate(exportRecord.download_url_expires_at);

const fileSizeMB = exportRecord.file_size_bytes
? (exportRecord.file_size_bytes / (1024 * 1024)).toFixed(2)
Expand Down Expand Up @@ -207,9 +226,7 @@ function generateEmailHTML(exportRecord: ExportRecord): string {
* Generate plain text email content
*/
function generateEmailText(exportRecord: ExportRecord): string {
const expiryDate = exportRecord.download_url_expires_at
? new Date(exportRecord.download_url_expires_at).toLocaleString()
: "N/A";
const expiryDate = formatEmailDate(exportRecord.download_url_expires_at);

const fileSizeMB = exportRecord.file_size_bytes
? (exportRecord.file_size_bytes / (1024 * 1024)).toFixed(2)
Expand Down
Loading
Loading