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
5 changes: 3 additions & 2 deletions backend/src/api/routes/duplicateAlertCheck.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export async function duplicateAlertCheckRoutes(server: FastifyInstance) {
);

// POST /dedup-rules — add a new dedup rule
server.post(
server.post<{ Body: any }>(
"/dedup-rules",
{
schema: {
Expand Down Expand Up @@ -83,8 +83,9 @@ export async function duplicateAlertCheckRoutes(server: FastifyInstance) {
},
},
async (request: FastifyRequest<{ Body: any }>, reply: FastifyReply) => {
const body = request.body as any;
const { name, alertType, assetCode, windowMs, matchFields, severityBehavior, isActive } =
request.body;
body;
const rule = duplicateAlertCheckService.addDedupRule({
name,
alertType: alertType ?? "*",
Expand Down
16 changes: 10 additions & 6 deletions backend/src/api/routes/queryPresets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ interface ListPresetsQuery {
search?: string;
}

function getRequestUserId(request: FastifyRequest): string {
return request.apiKeyAuth?.id ?? "00000000-0000-0000-0000-000000000000";
}

export async function queryPresetsRoutes(server: FastifyInstance) {
// Create preset
server.post<{ Body: CreatePresetBody }>(
Expand All @@ -58,7 +62,7 @@ export async function queryPresetsRoutes(server: FastifyInstance) {
},
async (request, reply) => {
try {
const userId = request.user?.id || "anonymous";
const userId = getRequestUserId(request);

// Validate query definition
const isValid = await queryPresetService.validateQueryDefinition(
Expand Down Expand Up @@ -103,7 +107,7 @@ export async function queryPresetsRoutes(server: FastifyInstance) {
},
async (request, reply) => {
try {
const userId = request.user?.id || "anonymous";
const userId = getRequestUserId(request);

const filters = {
category: request.query.category,
Expand Down Expand Up @@ -139,7 +143,7 @@ export async function queryPresetsRoutes(server: FastifyInstance) {
},
async (request, reply) => {
try {
const userId = request.user?.id || "anonymous";
const userId = getRequestUserId(request);

const preset = await queryPresetService.getPresetById(
request.params.id,
Expand Down Expand Up @@ -195,7 +199,7 @@ export async function queryPresetsRoutes(server: FastifyInstance) {
},
async (request, reply) => {
try {
const userId = request.user?.id || "anonymous";
const userId = getRequestUserId(request);

// Validate query definition if provided
if (request.body.query_definition) {
Expand Down Expand Up @@ -254,7 +258,7 @@ export async function queryPresetsRoutes(server: FastifyInstance) {
},
async (request, reply) => {
try {
const userId = request.user?.id || "anonymous";
const userId = getRequestUserId(request);

const success = await queryPresetService.deletePreset(
request.params.id,
Expand Down Expand Up @@ -296,7 +300,7 @@ export async function queryPresetsRoutes(server: FastifyInstance) {
},
async (request, reply) => {
try {
const userId = request.user?.id || "anonymous";
const userId = getRequestUserId(request);

const versions = await queryPresetService.getPresetVersions(
request.params.id,
Expand Down
105 changes: 103 additions & 2 deletions backend/src/api/routes/reconciliation.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,68 @@
import type { FastifyInstance, FastifyPluginOptions, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { ReconciliationService } from "../../services/reconciliation.service.js";
import {
ReconciliationService,
type ReconciliationRange,
type ReconciliationTriageStatus,
} from "../../services/reconciliation.service.js";
import { logger } from "../../utils/logger.js";

const listQuerySchema = z.object({
assetCode: z.string().min(1).optional(),
limit: z.coerce.number().int().positive().max(500).optional(),
});

const driftSummaryQuerySchema = z.object({
assetCode: z.string().trim().min(1).optional(),
bridge: z.string().trim().min(1).optional(),
range: z.enum(["24h", "7d", "30d", "90d"]).optional(),
startDate: z.string().trim().min(1).optional(),
endDate: z.string().trim().min(1).optional(),
});

const mismatchDetailQuerySchema = z.object({
range: z.enum(["24h", "7d", "30d", "90d"]).optional(),
});

const idParamsSchema = z.object({
id: z.string().uuid(),
});

const triageBodySchema = z.object({
status: z.enum(["open", "investigating", "acknowledged", "resolved", "false_positive"]),
owner: z.string().trim().max(120).nullable().optional(),
note: z.string().trim().max(2000).nullable().optional(),
});

export async function reconciliationRoutes(
fastify: FastifyInstance,
_options: FastifyPluginOptions
) {
const svc = new ReconciliationService();

fastify.get(
"/drift-summaries",
async (request: FastifyRequest, reply: FastifyReply) => {
const parsed = driftSummaryQuerySchema.safeParse(request.query ?? {});
if (!parsed.success) {
return reply.code(400).send({ error: "Invalid query", details: parsed.error.flatten() });
}

try {
return await svc.getDriftSummaries({
assetCode: parsed.data.assetCode,
bridge: parsed.data.bridge,
range: parsed.data.range as ReconciliationRange | undefined,
startDate: parsed.data.startDate,
endDate: parsed.data.endDate,
});
} catch (error) {
logger.error({ error }, "Failed to fetch reconciliation drift summaries");
return reply.code(500).send({ error: "Failed to fetch drift summaries" });
}
}
);

fastify.get(
"/runs",
async (request: FastifyRequest, reply: FastifyReply) => {
Expand All @@ -35,6 +84,59 @@ export async function reconciliationRoutes(
}
);

fastify.get(
"/mismatches/:id",
async (request: FastifyRequest, reply: FastifyReply) => {
const params = idParamsSchema.safeParse(request.params ?? {});
const query = mismatchDetailQuerySchema.safeParse(request.query ?? {});
if (!params.success) {
return reply.code(400).send({ error: "Invalid mismatch id", details: params.error.flatten() });
}
if (!query.success) {
return reply.code(400).send({ error: "Invalid query", details: query.error.flatten() });
}

try {
const detail = await svc.getMismatchDetail(params.data.id, {
range: query.data.range as ReconciliationRange | undefined,
});
if (!detail) return reply.code(404).send({ error: "Mismatch not found" });
return detail;
} catch (error) {
logger.error({ error, id: params.data.id }, "Failed to fetch mismatch detail");
return reply.code(500).send({ error: "Failed to fetch mismatch detail" });
}
}
);

fastify.patch(
"/runs/:id/triage",
async (request: FastifyRequest, reply: FastifyReply) => {
const params = idParamsSchema.safeParse(request.params ?? {});
const body = triageBodySchema.safeParse(request.body ?? {});
if (!params.success) {
return reply.code(400).send({ error: "Invalid run id", details: params.error.flatten() });
}
if (!body.success) {
return reply.code(400).send({ error: "Invalid triage update", details: body.error.flatten() });
}

try {
const triageUpdate = body.data as {
status: ReconciliationTriageStatus;
owner?: string | null;
note?: string | null;
};
const run = await svc.updateTriageStatus(params.data.id, triageUpdate);
if (!run) return reply.code(404).send({ error: "Run not found" });
return { run };
} catch (error) {
logger.error({ error, id: params.data.id }, "Failed to update reconciliation triage");
return reply.code(500).send({ error: "Failed to update triage status" });
}
}
);

fastify.get(
"/latest/:assetCode",
async (request: FastifyRequest, reply: FastifyReply) => {
Expand All @@ -52,4 +154,3 @@ export async function reconciliationRoutes(
}
);
}

5 changes: 2 additions & 3 deletions backend/src/api/routes/usageMetrics.routes.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { FastifyInstance } from "fastify";
import { stringify } from "csv-stringify/sync";
import { getUsageMetricsService } from "../../services/usageMetrics.service.js";
import { authMiddleware } from "../middleware/auth.js";
import { Parser as CsvParser } from "json2csv";

export async function usageMetricsRoutes(server: FastifyInstance) {
const svc = getUsageMetricsService();
Expand Down Expand Up @@ -29,8 +29,7 @@ export async function usageMetricsRoutes(server: FastifyInstance) {
const { start, end, groupBy = "endpoint", rollup = "hour", format = "json" } = request.query as any;
const rows = await svc.queryAggregates({ start, end, groupBy, rollup });
if (format === "csv") {
const parser = new CsvParser({ flatten: true });
const csv = parser.parse(rows);
const csv = stringify(rows, { header: true });
reply.type("text/csv");
return csv;
}
Expand Down
4 changes: 4 additions & 0 deletions backend/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,10 @@ const envSchema = z.object({
HEALTH_CHECK_MEMORY_THRESHOLD: z.coerce.number().default(90),
HEALTH_CHECK_DISK_THRESHOLD: z.coerce.number().default(80),
HEALTH_CHECK_EXTERNAL_APIS: z.string().default("true"),
MAINTENANCE_MODE: z.coerce.boolean().default(false),
MAINTENANCE_MESSAGE: z.string().default(""),
MAINTENANCE_SEVERITY: z.enum(["info", "warning", "critical"]).default("info"),
STATUS_PAGE_URL: z.string().url().optional(),

// Data Validation Configuration
VALIDATION_STRICT_MODE: z.coerce.boolean().default(false),
Expand Down
75 changes: 75 additions & 0 deletions backend/src/database/migrations/032_reconciliation_dashboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { Knex } from "knex";

async function addColumnIfMissing(
knex: Knex,
tableName: string,
columnName: string,
addColumn: (table: Knex.AlterTableBuilder) => void
) {
const exists = await knex.schema.hasColumn(tableName, columnName);
if (!exists) {
await knex.schema.alterTable(tableName, addColumn);
}
}

export async function up(knex: Knex): Promise<void> {
await addColumnIfMissing(knex, "reconciliation_runs", "bridge_name", (table) => {
table.string("bridge_name").nullable();
});

await addColumnIfMissing(knex, "reconciliation_runs", "source_chain", (table) => {
table.string("source_chain").nullable();
});

await addColumnIfMissing(knex, "reconciliation_runs", "on_chain_source", (table) => {
table.jsonb("on_chain_source").nullable();
});

await addColumnIfMissing(knex, "reconciliation_runs", "reserve_attestation", (table) => {
table.jsonb("reserve_attestation").nullable();
});

await addColumnIfMissing(knex, "reconciliation_runs", "reported_backing", (table) => {
table.jsonb("reported_backing").nullable();
});

await addColumnIfMissing(knex, "reconciliation_runs", "triage_status", (table) => {
table.string("triage_status").nullable();
});

await addColumnIfMissing(knex, "reconciliation_runs", "triage_owner", (table) => {
table.string("triage_owner").nullable();
});

await addColumnIfMissing(knex, "reconciliation_runs", "triage_note", (table) => {
table.text("triage_note").nullable();
});

await addColumnIfMissing(knex, "reconciliation_runs", "triaged_at", (table) => {
table.timestamp("triaged_at").nullable();
});

await knex.raw(
"CREATE INDEX IF NOT EXISTS reconciliation_runs_asset_bridge_started_idx ON reconciliation_runs (asset_code, bridge_name, started_at DESC)"
);
await knex.raw(
"CREATE INDEX IF NOT EXISTS reconciliation_runs_triage_status_idx ON reconciliation_runs (triage_status)"
);
}

export async function down(knex: Knex): Promise<void> {
await knex.raw("DROP INDEX IF EXISTS reconciliation_runs_triage_status_idx");
await knex.raw("DROP INDEX IF EXISTS reconciliation_runs_asset_bridge_started_idx");

await knex.schema.alterTable("reconciliation_runs", (table) => {
table.dropColumn("triaged_at");
table.dropColumn("triage_note");
table.dropColumn("triage_owner");
table.dropColumn("triage_status");
table.dropColumn("reported_backing");
table.dropColumn("reserve_attestation");
table.dropColumn("on_chain_source");
table.dropColumn("source_chain");
table.dropColumn("bridge_name");
});
}
15 changes: 15 additions & 0 deletions backend/src/database/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ export interface BridgeTransactionSummary {
failedTransactions: number;
}
export type ReconciliationStatus = "running" | "success" | "mismatch" | "failed";
export type ReconciliationTriageStatus =
| "open"
| "investigating"
| "acknowledged"
| "resolved"
| "false_positive";

// ─── assets ──────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -209,10 +215,19 @@ export interface ReconciliationRun {
id: string;
asset_code: string;
job_id: string | null;
bridge_name: string | null;
source_chain: string | null;
status: ReconciliationStatus;
stellar_supply: string | null;
reported_supply: string | null;
mismatch_percentage: string | null;
on_chain_source: unknown | null;
reserve_attestation: unknown | null;
reported_backing: unknown | null;
triage_status: ReconciliationTriageStatus | null;
triage_owner: string | null;
triage_note: string | null;
triaged_at: Date | null;
attempt: number;
error: string | null;
finished_at: Date | null;
Expand Down
1 change: 1 addition & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { registerCorrelationMiddleware } from "./api/middleware/correlation.midd
import { registerRequestLoggingMiddleware } from "./api/middleware/logging.middleware.js";
import { registerTracing } from "./api/middleware/tracing.js";
import { getTelegramBotService } from "./services/telegram.bot.service.js";
import { startOutboxSystem, stopOutboxSystem } from "./outbox/index.js";

export async function buildServer() {
const server = Fastify({
Expand Down
2 changes: 1 addition & 1 deletion backend/src/services/alertWindowing.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export class AlertWindowingService {
const windowKey = this.determineWindowKey(alert);
const windowStart = this.getWindowStart(alert.occurredAt);

let window = await db("alert_windows")
const window = await db("alert_windows")
.where("asset_code", alert.assetCode)
.where("alert_type", alert.alertType)
.where("window_start", windowStart)
Expand Down
7 changes: 5 additions & 2 deletions backend/src/services/correlation.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,11 @@ export class CorrelationService {
await trx("incident_correlation_audit").insert({ action: "unlinked", group_id: groupId, incident_id: incidentId, target_incident_id: targetIncidentId, actor, metadata: JSON.stringify({}) });

// if group has fewer than 2 members, delete group
const [{ count }] = await trx("incident_correlation_members").where({ group_id: groupId }).count<{ count: string }>("id as count");
if (Number(count) < 2) {
const countRow = await trx("incident_correlation_members")
.where({ group_id: groupId })
.count<{ count: string }>("id as count")
.first();
if (Number(countRow?.count ?? 0) < 2) {
await trx("incident_correlation_members").where({ group_id: groupId }).del();
await trx("incident_correlation_groups").where({ id: groupId }).del();
}
Expand Down
2 changes: 1 addition & 1 deletion backend/src/services/ownershipMatrix.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,7 +517,7 @@ export class OwnershipMatrixService {
"alert_ownership.created_at"
);

let countQuery = db("alert_ownership")
const countQuery = db("alert_ownership")
.join("alert_rules", "alert_ownership.alert_id", "alert_rules.id")
.where((builder) => {
builder
Expand Down
Loading
Loading