diff --git a/backend/OUTBOX_IMPLEMENTATION.md b/backend/OUTBOX_IMPLEMENTATION.md index 07742e6c..4c5f2937 100644 --- a/backend/OUTBOX_IMPLEMENTATION.md +++ b/backend/OUTBOX_IMPLEMENTATION.md @@ -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 { diff --git a/backend/OUTBOX_IMPLEMENTATION_SUMMARY.md b/backend/OUTBOX_IMPLEMENTATION_SUMMARY.md index 960ef0f9..3d8f8920 100644 --- a/backend/OUTBOX_IMPLEMENTATION_SUMMARY.md +++ b/backend/OUTBOX_IMPLEMENTATION_SUMMARY.md @@ -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 diff --git a/backend/src/api/routes/health.ts b/backend/src/api/routes/health.ts index dff1df65..6d3543b1 100644 --- a/backend/src/api/routes/health.ts +++ b/backend/src/api/routes/health.ts @@ -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(); @@ -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", + }; + } + } + ); } diff --git a/backend/src/api/routes/outbox-admin.ts b/backend/src/api/routes/outbox-admin.ts index 1f1d93b2..b0e93155 100644 --- a/backend/src/api/routes/outbox-admin.ts +++ b/backend/src/api/routes/outbox-admin.ts @@ -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: { diff --git a/backend/src/services/email.service.ts b/backend/src/services/email.service.ts index bcf8062d..ddf41bab 100644 --- a/backend/src/services/email.service.ts +++ b/backend/src/services/email.service.ts @@ -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"; @@ -106,9 +107,7 @@ export class EmailNotificationService { ): Promise { // 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 = ` @@ -481,7 +480,7 @@ export class EmailNotificationService {
  • ${item.title}
    ${item.summary}
    - ${item.timestamp} + ${formatEmailDate(item.timestamp)}
  • ` ) .join(""); @@ -489,7 +488,7 @@ export class EmailNotificationService { 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"); @@ -498,7 +497,7 @@ export class EmailNotificationService {

    ${subject}

    Hello ${context.recipientName ?? "Subscriber"},

    -

    Digest generated at ${payload.generatedAt}.

    +

    Digest generated at ${formatEmailDate(payload.generatedAt)}.

    Unsubscribe

    @@ -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.", "", diff --git a/backend/src/services/reportScheduling.service.ts b/backend/src/services/reportScheduling.service.ts index b5e366b4..1885282d 100644 --- a/backend/src/services/reportScheduling.service.ts +++ b/backend/src/services/reportScheduling.service.ts @@ -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"; @@ -458,9 +459,7 @@ export class ReportSchedulingService { * degraded dependency never prevents the report from being sent. */ private async generateReportHtml(delivery: ReportDelivery): Promise { - 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(), diff --git a/backend/src/utils/email.ts b/backend/src/utils/email.ts index 3a45577c..0f69c499 100644 --- a/backend/src/utils/email.ts +++ b/backend/src/utils/email.ts @@ -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 @@ -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) @@ -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) diff --git a/e2e/tests/service-annotations.spec.ts b/e2e/tests/service-annotations.spec.ts new file mode 100644 index 00000000..cf6de1b9 --- /dev/null +++ b/e2e/tests/service-annotations.spec.ts @@ -0,0 +1,174 @@ +import { test, expect } from "@playwright/test"; +import { mockCoreApi } from "../utils/mockApi"; + +type ServiceAnnotationRecord = { + id: string; + serviceName: string; + entityType: string; + entityId: string | null; + content: string; + author: string; + startTime: string | null; + endTime: string | null; + active: boolean; + createdAt: string; + updatedAt: string; +}; + +let serviceAnnotations: ServiceAnnotationRecord[] = []; + +function createAnnotation(overrides: Partial = {}): ServiceAnnotationRecord { + return { + id: overrides.id ?? `ann-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + serviceName: overrides.serviceName ?? "price-service", + entityType: overrides.entityType ?? "source", + entityId: overrides.entityId ?? null, + content: overrides.content ?? "Initial annotation content", + author: overrides.author ?? "operator", + startTime: overrides.startTime ?? null, + endTime: overrides.endTime ?? null, + active: overrides.active ?? true, + createdAt: overrides.createdAt ?? new Date().toISOString(), + updatedAt: overrides.updatedAt ?? new Date().toISOString(), + }; +} + +test.beforeEach(async ({ page }) => { + serviceAnnotations = [createAnnotation({ id: "ann-seed", content: "Seed annotation" })]; + + await page.addInitScript(() => { + window.localStorage.setItem("bridge-watch:onboarding:v1", "true"); + window.localStorage.setItem( + "bridge-watch:dashboard-tour:v1", + JSON.stringify({ completed: true, lastStep: 0, seen: true }) + ); + }); + + await mockCoreApi(page); + + await page.route("**/api/v1/service-annotations**", async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const method = request.method(); + + if (method === "GET") { + if (url.pathname.startsWith("/api/v1/service-annotations/")) { + const id = url.pathname.split("/").pop(); + if (id === "audit") { + await route.fulfill({ + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify([]), + }); + return; + } + + const annotation = serviceAnnotations.find((item) => item.id === id); + if (!annotation) { + await route.fulfill({ status: 404, body: "Not found" }); + return; + } + + await route.fulfill({ + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify(annotation), + }); + return; + } + + await route.fulfill({ + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify(serviceAnnotations), + }); + return; + } + + if (method === "POST") { + const body = request.postDataJSON() as Record; + const annotation = createAnnotation({ + serviceName: String(body.serviceName ?? ""), + entityType: String(body.entityType ?? "source"), + entityId: typeof body.entityId === "string" ? body.entityId : null, + content: String(body.content ?? ""), + author: String(body.author ?? "operator"), + startTime: typeof body.startTime === "string" ? body.startTime : null, + endTime: typeof body.endTime === "string" ? body.endTime : null, + }); + serviceAnnotations = [annotation, ...serviceAnnotations]; + await route.fulfill({ + status: 201, + headers: { "content-type": "application/json" }, + body: JSON.stringify(annotation), + }); + return; + } + + if (method === "PATCH") { + const id = url.pathname.split("/").pop(); + const body = request.postDataJSON() as Record; + const index = serviceAnnotations.findIndex((item) => item.id === id); + if (index === -1) { + await route.fulfill({ status: 404, body: "Not found" }); + return; + } + + serviceAnnotations[index] = { + ...serviceAnnotations[index], + ...(typeof body.content === "string" ? { content: body.content } : {}), + ...(typeof body.startTime === "string" ? { startTime: body.startTime } : {}), + ...(typeof body.endTime === "string" ? { endTime: body.endTime } : {}), + updatedAt: new Date().toISOString(), + }; + + await route.fulfill({ + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify(serviceAnnotations[index]), + }); + return; + } + + if (method === "DELETE") { + const id = url.pathname.split("/").pop(); + serviceAnnotations = serviceAnnotations.filter((item) => item.id !== id); + await route.fulfill({ status: 204, body: "" }); + return; + } + + await route.continue(); + }); +}); + +test("creates, edits, and deletes service annotations", async ({ page }) => { + await page.goto("/service-annotations"); + await expect(page.getByRole("heading", { name: "Service Annotations" })).toBeVisible(); + + await page.getByRole("button", { name: "\+ New Annotation" }).click(); + await page.getByLabel("Service Name *").fill("price-service"); + await page.getByLabel("Content *").fill("Initial annotation for testing"); + await page.getByRole("button", { name: "Create Annotation" }).click(); + + await expect(page.getByText("Initial annotation for testing")).toBeVisible(); + + const createdRow = page.getByRole("row").filter({ hasText: "Initial annotation for testing" }); + await expect(createdRow).toBeVisible(); + await createdRow.getByRole("button", { name: /Edit annotation/i }).click(); + await page.getByLabel("Content *").fill("Updated annotation note"); + await page.getByRole("button", { name: "Update Annotation" }).click(); + + await expect(page.getByText("Updated annotation note")).toBeVisible(); + await expect(page.getByText("Initial annotation for testing")).not.toBeVisible(); + + const updatedRow = page.getByRole("row").filter({ hasText: "Updated annotation note" }); + await expect(updatedRow).toBeVisible(); + + page.once("dialog", async (dialog) => { + await dialog.accept(); + }); + await updatedRow.getByRole("button", { name: /Delete annotation/i }).click(); + + await expect(page.getByText("Updated annotation note")).not.toBeVisible(); + await expect(page.getByText("Seed annotation")).toBeVisible(); +}); diff --git a/frontend/src/components/AssetComparison/AssetSelector.test.tsx b/frontend/src/components/AssetComparison/AssetSelector.test.tsx new file mode 100644 index 00000000..771fe51a --- /dev/null +++ b/frontend/src/components/AssetComparison/AssetSelector.test.tsx @@ -0,0 +1,33 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import AssetSelector from "./AssetSelector"; + +describe("AssetSelector", () => { + it("filters the visible asset buttons by category", async () => { + const user = userEvent.setup(); + + render( + + ); + + expect(screen.getByRole("button", { name: "USDC" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "XLM" })).toBeInTheDocument(); + + await user.click(screen.getByRole("tab", { name: /native/i })); + + expect(screen.queryByRole("button", { name: "USDC" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "XLM" })).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/AssetComparison/AssetSelector.tsx b/frontend/src/components/AssetComparison/AssetSelector.tsx index cedda2e6..fc14c74f 100644 --- a/frontend/src/components/AssetComparison/AssetSelector.tsx +++ b/frontend/src/components/AssetComparison/AssetSelector.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import type { AssetWithHealth } from "../../types"; interface Props { @@ -6,9 +7,64 @@ interface Props { max: number; onToggle: (symbol: string) => void; isLoading: boolean; + activeCategory: string; + onCategoryChange: (category: string) => void; } -export default function AssetSelector({ assets, selected, max, onToggle, isLoading }: Props) { +function normalizeCategory(value?: string | null) { + const normalized = (value ?? "").trim().toLowerCase().replace(/[_\s]+/g, "-"); + + if (!normalized) return "other"; + if (["stablecoin", "stablecoins"].includes(normalized)) return "stablecoin"; + if (["real-world-asset", "real-world-assets", "realworldasset", "rwa"].includes(normalized)) { + return "real-world-asset"; + } + if (["native", "native-asset"].includes(normalized)) return "native"; + if (["bridged", "bridge", "bridged-asset"].includes(normalized)) return "bridged"; + if (["wrapped", "wrapped-asset"].includes(normalized)) return "wrapped"; + + return normalized; +} + +function getCategoryLabel(category: string) { + switch (category) { + case "stablecoin": + return "Stablecoins"; + case "real-world-asset": + return "RWA"; + case "native": + return "Native"; + case "bridged": + return "Bridged"; + case "wrapped": + return "Wrapped"; + case "other": + return "Other"; + default: + return category.charAt(0).toUpperCase() + category.slice(1); + } +} + +export default function AssetSelector({ + assets, + selected, + max, + onToggle, + isLoading, + activeCategory, + onCategoryChange, +}: Props) { + const categories = useMemo(() => { + const available = new Set(); + assets.forEach((asset) => available.add(normalizeCategory(asset.category))); + return ["all", ...Array.from(available)]; + }, [assets]); + + const visibleAssets = useMemo(() => { + if (activeCategory === "all") return assets; + return assets.filter((asset) => normalizeCategory(asset.category) === activeCategory); + }, [activeCategory, assets]); + if (isLoading) { return (
    @@ -24,30 +80,57 @@ export default function AssetSelector({ assets, selected, max, onToggle, isLoadi

    Select up to {max} assets to compare. {selected.length}/{max} selected.

    -
    - {assets.map((a) => { - const isSelected = selected.includes(a.symbol); - const isDisabled = !isSelected && selected.length >= max; + +
    + {categories.map((category) => { + const isActive = activeCategory === category; return ( ); })}
    + +
    + {visibleAssets.length === 0 ? ( +

    No assets available in this category.

    + ) : ( + visibleAssets.map((a) => { + const isSelected = selected.includes(a.symbol); + const isDisabled = !isSelected && selected.length >= max; + return ( + + ); + }) + )} +
    ); } diff --git a/frontend/src/pages/AssetComparison.tsx b/frontend/src/pages/AssetComparison.tsx index e4336e91..d5acf489 100644 --- a/frontend/src/pages/AssetComparison.tsx +++ b/frontend/src/pages/AssetComparison.tsx @@ -1,7 +1,9 @@ import { useState, useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useAssetsWithHealth } from "../hooks/useAssets"; import { useLocalStorageState } from "../hooks/useLocalStorageState"; import { AssetSelector, AssetComparisonMatrix } from "../components/AssetComparison"; +import { getAssetMetadataBySymbol } from "../services/api"; const MAX_COMPARE = 8; const STORAGE_KEY = "bridge-watch:asset-comparison:v1"; @@ -10,10 +12,38 @@ export default function AssetComparison() { const { data: allAssets = [], isLoading, error } = useAssetsWithHealth(); const [selected, setSelected] = useLocalStorageState(STORAGE_KEY, []); const [filter, setFilter] = useState(""); + const [activeCategory, setActiveCategory] = useState("all"); + + const metadataQuery = useQuery({ + queryKey: ["asset-comparison-metadata", allAssets.map((asset) => asset.symbol).join("|")], + queryFn: async () => { + const metadataEntries = await Promise.all( + allAssets.map(async (asset) => { + try { + const metadata = await getAssetMetadataBySymbol(asset.symbol); + return [asset.symbol, metadata.category ?? null] as const; + } catch { + return [asset.symbol, null] as const; + } + }) + ); + + return Object.fromEntries(metadataEntries) as Record; + }, + enabled: allAssets.length > 0, + }); + + const assetsWithCategories = useMemo(() => { + const metadataMap = metadataQuery.data ?? {}; + return allAssets.map((asset) => ({ + ...asset, + category: metadataMap[asset.symbol] ?? asset.category ?? null, + })); + }, [allAssets, metadataQuery.data]); const selectedAssets = useMemo( - () => allAssets.filter((a) => selected.includes(a.symbol)), - [allAssets, selected] + () => assetsWithCategories.filter((asset) => selected.includes(asset.symbol)), + [assetsWithCategories, selected] ); function toggleAsset(symbol: string) { @@ -27,6 +57,7 @@ export default function AssetComparison() { function clearAll() { setSelected([]); setFilter(""); + setActiveCategory("all"); } const avgHealth = @@ -82,11 +113,13 @@ export default function AssetComparison() {

    Failed to load assets. Please try again.

    ) : ( )} diff --git a/frontend/src/pages/ServiceAnnotations.tsx b/frontend/src/pages/ServiceAnnotations.tsx index 869ed166..030a1b5f 100644 --- a/frontend/src/pages/ServiceAnnotations.tsx +++ b/frontend/src/pages/ServiceAnnotations.tsx @@ -288,14 +288,14 @@ export default function ServiceAnnotations() { return (
    -
    -
    +
    +

    Service Annotations

    Create and manage annotations tied to services and time ranges.

    -
    +