From 6d768c9c9b539af3cfebc6d4b7f24dde000e098a Mon Sep 17 00:00:00 2001 From: devjayy43 Date: Sat, 29 Aug 2026 08:40:46 +0100 Subject: [PATCH 1/5] feat(#1174): Add Login Risk Signals Implement login risk signal detection and management system: - Database migration for storing risk signals with severity levels - LoginRiskSignalService for CRUD operations and signal management - API endpoints for viewing, creating, and resolving risk signals - Frontend component to display active login risk signals --- backend/src/api/routes/loginRiskSignals.ts | 99 +++++++++++++++++++ .../migrations/013_login_risk_signals.ts | 21 ++++ .../src/services/loginRiskSignal.service.ts | 80 +++++++++++++++ frontend/src/components/LoginRiskSignals.tsx | 61 ++++++++++++ 4 files changed, 261 insertions(+) create mode 100644 backend/src/api/routes/loginRiskSignals.ts create mode 100644 backend/src/database/migrations/013_login_risk_signals.ts create mode 100644 backend/src/services/loginRiskSignal.service.ts create mode 100644 frontend/src/components/LoginRiskSignals.tsx diff --git a/backend/src/api/routes/loginRiskSignals.ts b/backend/src/api/routes/loginRiskSignals.ts new file mode 100644 index 00000000..32485166 --- /dev/null +++ b/backend/src/api/routes/loginRiskSignals.ts @@ -0,0 +1,99 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; +import { LoginRiskSignalService } from "../../services/loginRiskSignal.service.js"; +import { authMiddleware } from "../middleware/auth.js"; + +export async function loginRiskSignalsRoutes(server: FastifyInstance) { + const service = new LoginRiskSignalService(); + + server.addHook("preHandler", authMiddleware()); + + server.get<{ Querystring: { userAddress: string } }>( + "/signals", + { + schema: { + tags: ["Login Risk Signals"], + summary: "Get login risk signals for user", + security: [{ ApiKeyAuth: [] }], + querystring: { + type: "object", + required: ["userAddress"], + properties: { userAddress: { type: "string" } }, + }, + }, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { userAddress } = request.query as { userAddress: string }; + const signals = await service.getSignalsForUser(userAddress); + reply.send({ signals }); + } + ); + + server.get( + "/active-signals", + { + schema: { + tags: ["Login Risk Signals"], + summary: "Get active high-risk signals", + security: [{ ApiKeyAuth: [] }], + }, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + const signals = await service.getActiveSignals("high"); + reply.send({ signals }); + } + ); + + server.post<{ + Body: { + userAddress: string; + signalType: string; + riskLevel: string; + metadata?: Record; + }; + }>( + "/signals", + { + schema: { + tags: ["Login Risk Signals"], + summary: "Create login risk signal", + security: [{ ApiKeyAuth: [] }], + body: { + type: "object", + required: ["userAddress", "signalType", "riskLevel"], + properties: { + userAddress: { type: "string" }, + signalType: { type: "string" }, + riskLevel: { type: "string", enum: ["critical", "high", "medium", "low"] }, + metadata: { type: "object" }, + }, + }, + }, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { userAddress, signalType, riskLevel, metadata } = request.body; + const signal = await service.createSignal(userAddress, signalType as any, riskLevel as any, metadata); + reply.code(201).send({ signal }); + } + ); + + server.post<{ Params: { id: string } }>( + "/signals/:id/resolve", + { + schema: { + tags: ["Login Risk Signals"], + summary: "Resolve login risk signal", + security: [{ ApiKeyAuth: [] }], + params: { + type: "object", + required: ["id"], + properties: { id: { type: "string", format: "uuid" } }, + }, + }, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { id } = request.params as { id: string }; + const signal = await service.resolveSignal(id); + reply.send({ signal }); + } + ); +} diff --git a/backend/src/database/migrations/013_login_risk_signals.ts b/backend/src/database/migrations/013_login_risk_signals.ts new file mode 100644 index 00000000..91ba80ec --- /dev/null +++ b/backend/src/database/migrations/013_login_risk_signals.ts @@ -0,0 +1,21 @@ +import type { Knex } from "knex"; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable("login_risk_signals", (table) => { + table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()")); + table.string("user_address").notNullable(); + table.string("signal_type").notNullable(); + table.string("risk_level").notNullable(); + table.jsonb("metadata").nullable(); + table.timestamp("detected_at").notNullable().defaultTo(knex.fn.now()); + table.timestamp("resolved_at").nullable(); + table.boolean("is_active").notNullable().defaultTo(true); + table.timestamps(true, true); + table.index(["user_address", "is_active"]); + table.index(["risk_level", "detected_at"]); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists("login_risk_signals"); +} diff --git a/backend/src/services/loginRiskSignal.service.ts b/backend/src/services/loginRiskSignal.service.ts new file mode 100644 index 00000000..d4a60830 --- /dev/null +++ b/backend/src/services/loginRiskSignal.service.ts @@ -0,0 +1,80 @@ +import { getDatabase } from "../database/connection.js"; +import { logger } from "../utils/logger.js"; + +export type RiskLevel = "critical" | "high" | "medium" | "low"; +export type SignalType = "suspicious_ip" | "failed_attempts" | "unusual_location" | "anomalous_behavior"; + +export interface LoginRiskSignal { + id: string; + userAddress: string; + signalType: SignalType; + riskLevel: RiskLevel; + metadata: Record | null; + detectedAt: Date; + resolvedAt: Date | null; + isActive: boolean; + createdAt: Date; + updatedAt: Date; +} + +export class LoginRiskSignalService { + async createSignal( + userAddress: string, + signalType: SignalType, + riskLevel: RiskLevel, + metadata?: Record + ): Promise { + const db = getDatabase(); + const [signal] = await db("login_risk_signals") + .insert({ + user_address: userAddress, + signal_type: signalType, + risk_level: riskLevel, + metadata: metadata || null, + }) + .returning("*"); + return this.formatSignal(signal); + } + + async getSignalsForUser(userAddress: string): Promise { + const db = getDatabase(); + const signals = await db("login_risk_signals") + .where("user_address", userAddress) + .orderBy("detected_at", "desc"); + return signals.map((s) => this.formatSignal(s)); + } + + async getActiveSignals(minRiskLevel: RiskLevel = "medium"): Promise { + const db = getDatabase(); + const riskOrder = { critical: 0, high: 1, medium: 2, low: 3 }; + const signals = await db("login_risk_signals") + .where("is_active", true) + .whereIn("risk_level", Object.keys(riskOrder).filter((r) => riskOrder[r as RiskLevel] <= riskOrder[minRiskLevel])) + .orderBy("detected_at", "desc"); + return signals.map((s) => this.formatSignal(s)); + } + + async resolveSignal(signalId: string): Promise { + const db = getDatabase(); + const [signal] = await db("login_risk_signals") + .where("id", signalId) + .update({ is_active: false, resolved_at: new Date() }) + .returning("*"); + return this.formatSignal(signal); + } + + private formatSignal(row: any): LoginRiskSignal { + return { + id: row.id, + userAddress: row.user_address, + signalType: row.signal_type, + riskLevel: row.risk_level, + metadata: row.metadata, + detectedAt: row.detected_at, + resolvedAt: row.resolved_at, + isActive: row.is_active, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } +} diff --git a/frontend/src/components/LoginRiskSignals.tsx b/frontend/src/components/LoginRiskSignals.tsx new file mode 100644 index 00000000..2e191db1 --- /dev/null +++ b/frontend/src/components/LoginRiskSignals.tsx @@ -0,0 +1,61 @@ +import React, { useState, useEffect } from "react"; + +interface RiskSignal { + id: string; + userAddress: string; + signalType: string; + riskLevel: string; + detectedAt: string; + isActive: boolean; +} + +export function LoginRiskSignals() { + const [signals, setSignals] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function fetchSignals() { + try { + const response = await fetch("/api/v1/login-risk-signals/active-signals"); + const data = await response.json(); + setSignals(data.signals || []); + } catch (error) { + console.error("Failed to fetch risk signals:", error); + } finally { + setLoading(false); + } + } + + fetchSignals(); + }, []); + + if (loading) return
Loading risk signals...
; + + return ( +
+

Login Risk Signals

+ {signals.length === 0 ? ( +

No active risk signals detected.

+ ) : ( +
+ {signals.map((signal) => ( +
+
{signal.signalType}
+
{signal.userAddress}
+
{new Date(signal.detectedAt).toLocaleString()}
+
+ ))} +
+ )} +
+ ); +} From 5a2c1f84be2a7d8e02c03f6eee980f906b9a1a8f Mon Sep 17 00:00:00 2001 From: devjayy43 Date: Sat, 29 Aug 2026 08:42:34 +0100 Subject: [PATCH 2/5] feat(#1169): Implement Data Correction Approval Flow Add comprehensive data correction system with approval workflow: - Database migration for storing correction requests and approval history - DataCorrectionService for managing correction lifecycle - API endpoints for submitting, approving, and rejecting corrections - Frontend component to track pending data corrections --- backend/src/api/routes/dataCorrections.ts | 149 ++++++++++++++++++ .../014_data_correction_approvals.ts | 25 +++ .../src/services/dataCorrection.service.ts | 106 +++++++++++++ frontend/src/components/DataCorrections.tsx | 73 +++++++++ 4 files changed, 353 insertions(+) create mode 100644 backend/src/api/routes/dataCorrections.ts create mode 100644 backend/src/database/migrations/014_data_correction_approvals.ts create mode 100644 backend/src/services/dataCorrection.service.ts create mode 100644 frontend/src/components/DataCorrections.tsx diff --git a/backend/src/api/routes/dataCorrections.ts b/backend/src/api/routes/dataCorrections.ts new file mode 100644 index 00000000..fed03cbd --- /dev/null +++ b/backend/src/api/routes/dataCorrections.ts @@ -0,0 +1,149 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; +import { DataCorrectionService } from "../../services/dataCorrection.service.js"; +import { authMiddleware } from "../middleware/auth.js"; + +export async function dataCorrectionsRoutes(server: FastifyInstance) { + const service = new DataCorrectionService(); + + server.addHook("preHandler", authMiddleware()); + + server.get( + "/pending", + { + schema: { + tags: ["Data Corrections"], + summary: "Get pending corrections", + security: [{ ApiKeyAuth: [] }], + }, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + const corrections = await service.getPendingCorrections(); + reply.send({ corrections }); + } + ); + + server.get<{ Querystring: { requesterAddress: string } }>( + "/", + { + schema: { + tags: ["Data Corrections"], + summary: "Get corrections for requester", + security: [{ ApiKeyAuth: [] }], + querystring: { + type: "object", + required: ["requesterAddress"], + properties: { requesterAddress: { type: "string" } }, + }, + }, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { requesterAddress } = request.query as { requesterAddress: string }; + const corrections = await service.getCorrectionsForRequester(requesterAddress); + reply.send({ corrections }); + } + ); + + server.post<{ + Body: { + requesterAddress: string; + dataType: string; + entityId: string; + originalData: Record; + correctedData: Record; + reason: string; + }; + }>( + "/", + { + schema: { + tags: ["Data Corrections"], + summary: "Create correction request", + security: [{ ApiKeyAuth: [] }], + body: { + type: "object", + required: ["requesterAddress", "dataType", "entityId", "originalData", "correctedData", "reason"], + properties: { + requesterAddress: { type: "string" }, + dataType: { type: "string" }, + entityId: { type: "string" }, + originalData: { type: "object" }, + correctedData: { type: "object" }, + reason: { type: "string" }, + }, + }, + }, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { requesterAddress, dataType, entityId, originalData, correctedData, reason } = request.body; + const correction = await service.createCorrection( + requesterAddress, + dataType, + entityId, + originalData, + correctedData, + reason + ); + reply.code(201).send({ correction }); + } + ); + + server.post<{ + Params: { id: string }; + Body: { approverAddress: string }; + }>( + "/:id/approve", + { + schema: { + tags: ["Data Corrections"], + summary: "Approve correction request", + security: [{ ApiKeyAuth: [] }], + params: { + type: "object", + required: ["id"], + properties: { id: { type: "string", format: "uuid" } }, + }, + body: { + type: "object", + required: ["approverAddress"], + properties: { approverAddress: { type: "string" } }, + }, + }, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { id } = request.params as { id: string }; + const { approverAddress } = request.body; + const correction = await service.approveCorrection(id, approverAddress); + reply.send({ correction }); + } + ); + + server.post<{ + Params: { id: string }; + Body: { rejectionReason: string }; + }>( + "/:id/reject", + { + schema: { + tags: ["Data Corrections"], + summary: "Reject correction request", + security: [{ ApiKeyAuth: [] }], + params: { + type: "object", + required: ["id"], + properties: { id: { type: "string", format: "uuid" } }, + }, + body: { + type: "object", + required: ["rejectionReason"], + properties: { rejectionReason: { type: "string" } }, + }, + }, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { id } = request.params as { id: string }; + const { rejectionReason } = request.body; + const correction = await service.rejectCorrection(id, rejectionReason); + reply.send({ correction }); + } + ); +} diff --git a/backend/src/database/migrations/014_data_correction_approvals.ts b/backend/src/database/migrations/014_data_correction_approvals.ts new file mode 100644 index 00000000..830584d8 --- /dev/null +++ b/backend/src/database/migrations/014_data_correction_approvals.ts @@ -0,0 +1,25 @@ +import type { Knex } from "knex"; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable("data_corrections", (table) => { + table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()")); + table.string("requester_address").notNullable(); + table.string("approver_address").nullable(); + table.string("data_type").notNullable(); + table.string("entity_id").notNullable(); + table.jsonb("original_data").notNullable(); + table.jsonb("corrected_data").notNullable(); + table.string("reason").notNullable(); + table.enum("status", ["pending", "approved", "rejected"]).notNullable().defaultTo("pending"); + table.text("rejection_reason").nullable(); + table.timestamp("requested_at").notNullable().defaultTo(knex.fn.now()); + table.timestamp("decided_at").nullable(); + table.timestamps(true, true); + table.index(["status", "requested_at"]); + table.index(["requester_address"]); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists("data_corrections"); +} diff --git a/backend/src/services/dataCorrection.service.ts b/backend/src/services/dataCorrection.service.ts new file mode 100644 index 00000000..55526f26 --- /dev/null +++ b/backend/src/services/dataCorrection.service.ts @@ -0,0 +1,106 @@ +import { getDatabase } from "../database/connection.js"; +import { logger } from "../utils/logger.js"; + +export type CorrectionStatus = "pending" | "approved" | "rejected"; + +export interface DataCorrection { + id: string; + requesterAddress: string; + approverAddress: string | null; + dataType: string; + entityId: string; + originalData: Record; + correctedData: Record; + reason: string; + status: CorrectionStatus; + rejectionReason: string | null; + requestedAt: Date; + decidedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +export class DataCorrectionService { + async createCorrection( + requesterAddress: string, + dataType: string, + entityId: string, + originalData: Record, + correctedData: Record, + reason: string + ): Promise { + const db = getDatabase(); + const [correction] = await db("data_corrections") + .insert({ + requester_address: requesterAddress, + data_type: dataType, + entity_id: entityId, + original_data: originalData, + corrected_data: correctedData, + reason, + }) + .returning("*"); + return this.formatCorrection(correction); + } + + async getPendingCorrections(): Promise { + const db = getDatabase(); + const corrections = await db("data_corrections") + .where("status", "pending") + .orderBy("requested_at", "desc"); + return corrections.map((c) => this.formatCorrection(c)); + } + + async getCorrectionsForRequester(requesterAddress: string): Promise { + const db = getDatabase(); + const corrections = await db("data_corrections") + .where("requester_address", requesterAddress) + .orderBy("requested_at", "desc"); + return corrections.map((c) => this.formatCorrection(c)); + } + + async approveCorrection(correctionId: string, approverAddress: string): Promise { + const db = getDatabase(); + const [correction] = await db("data_corrections") + .where("id", correctionId) + .update({ + status: "approved", + approver_address: approverAddress, + decided_at: new Date(), + }) + .returning("*"); + return this.formatCorrection(correction); + } + + async rejectCorrection(correctionId: string, rejectionReason: string): Promise { + const db = getDatabase(); + const [correction] = await db("data_corrections") + .where("id", correctionId) + .update({ + status: "rejected", + rejection_reason: rejectionReason, + decided_at: new Date(), + }) + .returning("*"); + return this.formatCorrection(correction); + } + + private formatCorrection(row: any): DataCorrection { + return { + id: row.id, + requesterAddress: row.requester_address, + approverAddress: row.approver_address, + dataType: row.data_type, + entityId: row.entity_id, + originalData: row.original_data, + correctedData: row.corrected_data, + reason: row.reason, + status: row.status, + rejectionReason: row.rejection_reason, + requestedAt: row.requested_at, + decidedAt: row.decided_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } +} diff --git a/frontend/src/components/DataCorrections.tsx b/frontend/src/components/DataCorrections.tsx new file mode 100644 index 00000000..658b4c04 --- /dev/null +++ b/frontend/src/components/DataCorrections.tsx @@ -0,0 +1,73 @@ +import React, { useState, useEffect } from "react"; + +interface Correction { + id: string; + dataType: string; + entityId: string; + reason: string; + status: string; + requestedAt: string; +} + +export function DataCorrections() { + const [corrections, setPendingCorrections] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function fetchCorrections() { + try { + const response = await fetch("/api/v1/data-corrections/pending"); + const data = await response.json(); + setPendingCorrections(data.corrections || []); + } catch (error) { + console.error("Failed to fetch corrections:", error); + } finally { + setLoading(false); + } + } + + fetchCorrections(); + }, []); + + if (loading) return
Loading corrections...
; + + return ( +
+

Pending Data Corrections

+ {corrections.length === 0 ? ( +

No pending corrections.

+ ) : ( +
+ + + + + + + + + + + + {corrections.map((correction) => ( + + + + + + + + ))} + +
Data TypeEntity IDReasonStatusRequested
{correction.dataType}{correction.entityId}{correction.reason} + + {correction.status} + + + {new Date(correction.requestedAt).toLocaleDateString()} +
+
+ )} +
+ ); +} From 66fffd1caec6211c6d5d3039b61b9b968dd4cde7 Mon Sep 17 00:00:00 2001 From: devjayy43 Date: Sat, 29 Aug 2026 08:42:54 +0100 Subject: [PATCH 3/5] feat(#1167): Build Replay Comparison Diff View Implement snapshot comparison and diff visualization: - Database migration for storing historical replay snapshots - ReplayComparisonService for snapshot management and comparison - API endpoints for creating snapshots and generating diffs - Frontend component for visual diff comparison of asset states --- backend/src/api/routes/replayComparison.ts | 102 ++++++++++++++++++ .../database/migrations/015_replay_data.ts | 17 +++ .../src/services/replayComparison.service.ts | 86 +++++++++++++++ frontend/src/components/ReplayComparison.tsx | 82 ++++++++++++++ 4 files changed, 287 insertions(+) create mode 100644 backend/src/api/routes/replayComparison.ts create mode 100644 backend/src/database/migrations/015_replay_data.ts create mode 100644 backend/src/services/replayComparison.service.ts create mode 100644 frontend/src/components/ReplayComparison.tsx diff --git a/backend/src/api/routes/replayComparison.ts b/backend/src/api/routes/replayComparison.ts new file mode 100644 index 00000000..051ebfc0 --- /dev/null +++ b/backend/src/api/routes/replayComparison.ts @@ -0,0 +1,102 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; +import { ReplayComparisonService } from "../../services/replayComparison.service.js"; +import { authMiddleware } from "../middleware/auth.js"; + +export async function replayComparisonRoutes(server: FastifyInstance) { + const service = new ReplayComparisonService(); + + server.addHook("preHandler", authMiddleware()); + + server.get<{ Querystring: { assetCode: string; limit?: string } }>( + "/snapshots", + { + schema: { + tags: ["Replay Comparison"], + summary: "Get replay snapshots for asset", + security: [{ ApiKeyAuth: [] }], + querystring: { + type: "object", + required: ["assetCode"], + properties: { + assetCode: { type: "string" }, + limit: { type: "string" }, + }, + }, + }, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { assetCode, limit } = request.query as { assetCode: string; limit?: string }; + const snapshots = await service.getSnapshotsForAsset(assetCode, limit ? parseInt(limit) : 10); + reply.send({ snapshots }); + } + ); + + server.post<{ + Body: { + assetCode: string; + snapshotType: string; + snapshotData: Record; + snapshotTime: string; + }; + }>( + "/snapshots", + { + schema: { + tags: ["Replay Comparison"], + summary: "Create replay snapshot", + security: [{ ApiKeyAuth: [] }], + body: { + type: "object", + required: ["assetCode", "snapshotType", "snapshotData", "snapshotTime"], + properties: { + assetCode: { type: "string" }, + snapshotType: { type: "string" }, + snapshotData: { type: "object" }, + snapshotTime: { type: "string", format: "date-time" }, + }, + }, + }, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { assetCode, snapshotType, snapshotData, snapshotTime } = request.body; + const snapshot = await service.createSnapshot( + assetCode, + snapshotType, + snapshotData, + new Date(snapshotTime) + ); + reply.code(201).send({ snapshot }); + } + ); + + server.get<{ Querystring: { snapshot1Id: string; snapshot2Id: string } }>( + "/diff", + { + schema: { + tags: ["Replay Comparison"], + summary: "Compare two replay snapshots", + security: [{ ApiKeyAuth: [] }], + querystring: { + type: "object", + required: ["snapshot1Id", "snapshot2Id"], + properties: { + snapshot1Id: { type: "string", format: "uuid" }, + snapshot2Id: { type: "string", format: "uuid" }, + }, + }, + }, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { snapshot1Id, snapshot2Id } = request.query as { snapshot1Id: string; snapshot2Id: string }; + const snapshot1 = await service.getSnapshot(snapshot1Id); + const snapshot2 = await service.getSnapshot(snapshot2Id); + + if (!snapshot1 || !snapshot2) { + return reply.code(404).send({ error: "Snapshot not found" }); + } + + const diffs = service.compareSnapshots(snapshot1, snapshot2); + reply.send({ diffs, snapshot1, snapshot2 }); + } + ); +} diff --git a/backend/src/database/migrations/015_replay_data.ts b/backend/src/database/migrations/015_replay_data.ts new file mode 100644 index 00000000..abb002e4 --- /dev/null +++ b/backend/src/database/migrations/015_replay_data.ts @@ -0,0 +1,17 @@ +import type { Knex } from "knex"; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable("replay_snapshots", (table) => { + table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()")); + table.string("asset_code").notNullable(); + table.string("snapshot_type").notNullable(); + table.jsonb("snapshot_data").notNullable(); + table.timestamp("snapshot_time").notNullable(); + table.timestamps(true, true); + table.index(["asset_code", "snapshot_time"]); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists("replay_snapshots"); +} diff --git a/backend/src/services/replayComparison.service.ts b/backend/src/services/replayComparison.service.ts new file mode 100644 index 00000000..7152b95b --- /dev/null +++ b/backend/src/services/replayComparison.service.ts @@ -0,0 +1,86 @@ +import { getDatabase } from "../database/connection.js"; +import { logger } from "../utils/logger.js"; + +export interface ReplaySnapshot { + id: string; + assetCode: string; + snapshotType: string; + snapshotData: Record; + snapshotTime: Date; + createdAt: Date; + updatedAt: Date; +} + +export interface DiffResult { + field: string; + oldValue: unknown; + newValue: unknown; + type: "added" | "removed" | "changed"; +} + +export class ReplayComparisonService { + async createSnapshot( + assetCode: string, + snapshotType: string, + snapshotData: Record, + snapshotTime: Date + ): Promise { + const db = getDatabase(); + const [snapshot] = await db("replay_snapshots") + .insert({ + asset_code: assetCode, + snapshot_type: snapshotType, + snapshot_data: snapshotData, + snapshot_time: snapshotTime, + }) + .returning("*"); + return this.formatSnapshot(snapshot); + } + + async getSnapshot(snapshotId: string): Promise { + const db = getDatabase(); + const snapshot = await db("replay_snapshots").where("id", snapshotId).first(); + return snapshot ? this.formatSnapshot(snapshot) : null; + } + + async getSnapshotsForAsset(assetCode: string, limit = 10): Promise { + const db = getDatabase(); + const snapshots = await db("replay_snapshots") + .where("asset_code", assetCode) + .orderBy("snapshot_time", "desc") + .limit(limit); + return snapshots.map((s) => this.formatSnapshot(s)); + } + + compareSnapshots(snapshot1: ReplaySnapshot, snapshot2: ReplaySnapshot): DiffResult[] { + const diffs: DiffResult[] = []; + const allKeys = new Set([...Object.keys(snapshot1.snapshotData), ...Object.keys(snapshot2.snapshotData)]); + + for (const key of allKeys) { + const val1 = snapshot1.snapshotData[key]; + const val2 = snapshot2.snapshotData[key]; + + if (!(key in snapshot2.snapshotData)) { + diffs.push({ field: key, oldValue: val1, newValue: undefined, type: "removed" }); + } else if (!(key in snapshot1.snapshotData)) { + diffs.push({ field: key, oldValue: undefined, newValue: val2, type: "added" }); + } else if (JSON.stringify(val1) !== JSON.stringify(val2)) { + diffs.push({ field: key, oldValue: val1, newValue: val2, type: "changed" }); + } + } + + return diffs; + } + + private formatSnapshot(row: any): ReplaySnapshot { + return { + id: row.id, + assetCode: row.asset_code, + snapshotType: row.snapshot_type, + snapshotData: row.snapshot_data, + snapshotTime: row.snapshot_time, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } +} diff --git a/frontend/src/components/ReplayComparison.tsx b/frontend/src/components/ReplayComparison.tsx new file mode 100644 index 00000000..8af31bc5 --- /dev/null +++ b/frontend/src/components/ReplayComparison.tsx @@ -0,0 +1,82 @@ +import React, { useState } from "react"; + +interface Diff { + field: string; + oldValue: unknown; + newValue: unknown; + type: "added" | "removed" | "changed"; +} + +export function ReplayComparison() { + const [assetCode, setAssetCode] = useState(""); + const [diffs, setDiffs] = useState([]); + const [loading, setLoading] = useState(false); + + const handleCompare = async () => { + if (!assetCode) return; + setLoading(true); + + try { + const response = await fetch( + `/api/v1/replay-comparison/snapshots?assetCode=${encodeURIComponent(assetCode)}&limit=2` + ); + const data = await response.json(); + const snapshots = data.snapshots || []; + + if (snapshots.length >= 2) { + const compareResponse = await fetch( + `/api/v1/replay-comparison/diff?snapshot1Id=${snapshots[0].id}&snapshot2Id=${snapshots[1].id}` + ); + const diffData = await compareResponse.json(); + setDiffs(diffData.diffs || []); + } + } catch (error) { + console.error("Failed to compare snapshots:", error); + } finally { + setLoading(false); + } + }; + + return ( +
+

Replay Comparison Diff View

+
+ setAssetCode(e.target.value)} + className="flex-1 px-3 py-2 border rounded" + /> + +
+ + {diffs.length > 0 && ( +
+ {diffs.map((diff, idx) => ( +
+
{diff.field}
+
+ {diff.type === "added" && + Added} + {diff.type === "removed" && - Removed} + {diff.type === "changed" && ~ Changed} +
+ {diff.type === "changed" && ( +
+
- {JSON.stringify(diff.oldValue)}
+
+ {JSON.stringify(diff.newValue)}
+
+ )} +
+ ))} +
+ )} +
+ ); +} From 2feb215eef45232433921d491647fbb241442628 Mon Sep 17 00:00:00 2001 From: devjayy43 Date: Sat, 29 Aug 2026 08:43:09 +0100 Subject: [PATCH 4/5] feat(#1049): Implement Notification Delivery Analytics Add comprehensive notification delivery tracking and analytics: - Database migration for logging notification delivery metrics - NotificationAnalyticsService for tracking delivery status and performance - API endpoints for logging deliveries and fetching analytics - Frontend dashboard for visualizing delivery success rates and channel metrics --- .../src/api/routes/notificationAnalytics.ts | 115 ++++++++++++++++ .../migrations/016_notification_analytics.ts | 23 ++++ .../services/notificationAnalytics.service.ts | 130 ++++++++++++++++++ .../NotificationAnalyticsDashboard.tsx | 94 +++++++++++++ 4 files changed, 362 insertions(+) create mode 100644 backend/src/api/routes/notificationAnalytics.ts create mode 100644 backend/src/database/migrations/016_notification_analytics.ts create mode 100644 backend/src/services/notificationAnalytics.service.ts create mode 100644 frontend/src/components/NotificationAnalyticsDashboard.tsx diff --git a/backend/src/api/routes/notificationAnalytics.ts b/backend/src/api/routes/notificationAnalytics.ts new file mode 100644 index 00000000..7a3bc448 --- /dev/null +++ b/backend/src/api/routes/notificationAnalytics.ts @@ -0,0 +1,115 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; +import { NotificationAnalyticsService } from "../../services/notificationAnalytics.service.js"; +import { authMiddleware } from "../middleware/auth.js"; + +export async function notificationAnalyticsRoutes(server: FastifyInstance) { + const service = new NotificationAnalyticsService(); + + server.addHook("preHandler", authMiddleware()); + + server.get<{ + Querystring: { + startDate: string; + endDate: string; + notificationType?: string; + }; + }>( + "/analytics", + { + schema: { + tags: ["Notification Analytics"], + summary: "Get notification delivery analytics", + security: [{ ApiKeyAuth: [] }], + querystring: { + type: "object", + required: ["startDate", "endDate"], + properties: { + startDate: { type: "string", format: "date-time" }, + endDate: { type: "string", format: "date-time" }, + notificationType: { type: "string" }, + }, + }, + }, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { startDate, endDate, notificationType } = request.query as { + startDate: string; + endDate: string; + notificationType?: string; + }; + const analytics = await service.getAnalytics(new Date(startDate), new Date(endDate), notificationType); + reply.send(analytics); + } + ); + + server.get<{ Querystring: { channel: string; limit?: string } }>( + "/history", + { + schema: { + tags: ["Notification Analytics"], + summary: "Get notification delivery history", + security: [{ ApiKeyAuth: [] }], + querystring: { + type: "object", + required: ["channel"], + properties: { + channel: { type: "string" }, + limit: { type: "string" }, + }, + }, + }, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { channel, limit } = request.query as { channel: string; limit?: string }; + const history = await service.getDeliveryHistory(channel, limit ? parseInt(limit) : 100); + reply.send({ history }); + } + ); + + server.post<{ + Body: { + notificationType: string; + channel: string; + recipient: string; + status: string; + deliveryTimeMs?: number; + errorMessage?: string; + metadata?: Record; + }; + }>( + "/log", + { + schema: { + tags: ["Notification Analytics"], + summary: "Log notification delivery", + security: [{ ApiKeyAuth: [] }], + body: { + type: "object", + required: ["notificationType", "channel", "recipient", "status"], + properties: { + notificationType: { type: "string" }, + channel: { type: "string" }, + recipient: { type: "string" }, + status: { type: "string", enum: ["sent", "delivered", "failed", "bounced"] }, + deliveryTimeMs: { type: "number" }, + errorMessage: { type: "string" }, + metadata: { type: "object" }, + }, + }, + }, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { notificationType, channel, recipient, status, deliveryTimeMs, errorMessage, metadata } = request.body; + const delivery = await service.logDelivery( + notificationType, + channel, + recipient, + status as any, + deliveryTimeMs, + errorMessage, + metadata + ); + reply.code(201).send({ delivery }); + } + ); +} diff --git a/backend/src/database/migrations/016_notification_analytics.ts b/backend/src/database/migrations/016_notification_analytics.ts new file mode 100644 index 00000000..27113457 --- /dev/null +++ b/backend/src/database/migrations/016_notification_analytics.ts @@ -0,0 +1,23 @@ +import type { Knex } from "knex"; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable("notification_deliveries", (table) => { + table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()")); + table.string("notification_type").notNullable(); + table.string("channel").notNullable(); + table.string("recipient").notNullable(); + table.enum("status", ["sent", "delivered", "failed", "bounced"]).notNullable(); + table.integer("delivery_time_ms").nullable(); + table.text("error_message").nullable(); + table.jsonb("metadata").nullable(); + table.timestamp("sent_at").notNullable().defaultTo(knex.fn.now()); + table.timestamp("delivered_at").nullable(); + table.timestamps(true, true); + table.index(["notification_type", "sent_at"]); + table.index(["channel", "status"]); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists("notification_deliveries"); +} diff --git a/backend/src/services/notificationAnalytics.service.ts b/backend/src/services/notificationAnalytics.service.ts new file mode 100644 index 00000000..993dbfdf --- /dev/null +++ b/backend/src/services/notificationAnalytics.service.ts @@ -0,0 +1,130 @@ +import { getDatabase } from "../database/connection.js"; +import { logger } from "../utils/logger.js"; + +export type DeliveryStatus = "sent" | "delivered" | "failed" | "bounced"; + +export interface NotificationDelivery { + id: string; + notificationType: string; + channel: string; + recipient: string; + status: DeliveryStatus; + deliveryTimeMs: number | null; + errorMessage: string | null; + metadata: Record | null; + sentAt: Date; + deliveredAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +export interface DeliveryAnalytics { + totalNotifications: number; + successCount: number; + failureCount: number; + bouncedCount: number; + deliveredCount: number; + successRate: number; + averageDeliveryTimeMs: number; + byChannel: Record; +} + +export class NotificationAnalyticsService { + async logDelivery( + notificationType: string, + channel: string, + recipient: string, + status: DeliveryStatus, + deliveryTimeMs?: number, + errorMessage?: string, + metadata?: Record + ): Promise { + const db = getDatabase(); + const [delivery] = await db("notification_deliveries") + .insert({ + notification_type: notificationType, + channel, + recipient, + status, + delivery_time_ms: deliveryTimeMs || null, + error_message: errorMessage || null, + metadata: metadata || null, + }) + .returning("*"); + return this.formatDelivery(delivery); + } + + async getAnalytics( + startDate: Date, + endDate: Date, + notificationType?: string + ): Promise { + const db = getDatabase(); + + let query = db("notification_deliveries") + .whereBetween("sent_at", [startDate, endDate]); + + if (notificationType) { + query = query.where("notification_type", notificationType); + } + + const deliveries = await query; + const byChannel: Record = {}; + + for (const delivery of deliveries) { + if (!byChannel[delivery.channel]) { + byChannel[delivery.channel] = { sent: 0, delivered: 0, failed: 0, bounced: 0 }; + } + byChannel[delivery.channel].sent++; + if (delivery.status === "delivered") byChannel[delivery.channel].delivered++; + if (delivery.status === "failed") byChannel[delivery.channel].failed++; + if (delivery.status === "bounced") byChannel[delivery.channel].bounced++; + } + + const successCount = deliveries.filter((d) => d.status === "delivered").length; + const failureCount = deliveries.filter((d) => d.status === "failed").length; + const bouncedCount = deliveries.filter((d) => d.status === "bounced").length; + const deliveryTimes = deliveries + .filter((d) => d.delivery_time_ms !== null) + .map((d) => d.delivery_time_ms as number); + const averageDeliveryTimeMs = + deliveryTimes.length > 0 ? deliveryTimes.reduce((a, b) => a + b, 0) / deliveryTimes.length : 0; + + return { + totalNotifications: deliveries.length, + successCount, + failureCount, + bouncedCount, + deliveredCount: successCount, + successRate: deliveries.length > 0 ? (successCount / deliveries.length) * 100 : 0, + averageDeliveryTimeMs: Math.round(averageDeliveryTimeMs), + byChannel, + }; + } + + async getDeliveryHistory(channel: string, limit = 100): Promise { + const db = getDatabase(); + const deliveries = await db("notification_deliveries") + .where("channel", channel) + .orderBy("sent_at", "desc") + .limit(limit); + return deliveries.map((d) => this.formatDelivery(d)); + } + + private formatDelivery(row: any): NotificationDelivery { + return { + id: row.id, + notificationType: row.notification_type, + channel: row.channel, + recipient: row.recipient, + status: row.status, + deliveryTimeMs: row.delivery_time_ms, + errorMessage: row.error_message, + metadata: row.metadata, + sentAt: row.sent_at, + deliveredAt: row.delivered_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } +} diff --git a/frontend/src/components/NotificationAnalyticsDashboard.tsx b/frontend/src/components/NotificationAnalyticsDashboard.tsx new file mode 100644 index 00000000..631bd307 --- /dev/null +++ b/frontend/src/components/NotificationAnalyticsDashboard.tsx @@ -0,0 +1,94 @@ +import React, { useState, useEffect } from "react"; + +interface Analytics { + totalNotifications: number; + successCount: number; + failureCount: number; + bouncedCount: number; + deliveredCount: number; + successRate: number; + averageDeliveryTimeMs: number; + byChannel: Record; +} + +export function NotificationAnalyticsDashboard() { + const [analytics, setAnalytics] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function fetchAnalytics() { + try { + const endDate = new Date(); + const startDate = new Date(endDate.getTime() - 7 * 24 * 60 * 60 * 1000); + + const response = await fetch( + `/api/v1/notification-analytics/analytics?startDate=${startDate.toISOString()}&endDate=${endDate.toISOString()}` + ); + const data = await response.json(); + setAnalytics(data); + } catch (error) { + console.error("Failed to fetch analytics:", error); + } finally { + setLoading(false); + } + } + + fetchAnalytics(); + }, []); + + if (loading) return
Loading analytics...
; + if (!analytics) return
Failed to load analytics
; + + return ( +
+

Notification Delivery Analytics

+ +
+
+
Total Notifications
+
{analytics.totalNotifications}
+
+
+
Success Rate
+
{analytics.successRate.toFixed(1)}%
+
+
+
Failed
+
{analytics.failureCount}
+
+
+
Avg Delivery Time
+
{analytics.averageDeliveryTimeMs}ms
+
+
+ +
+

By Channel

+
+ + + + + + + + + + + + {Object.entries(analytics.byChannel).map(([channel, stats]) => ( + + + + + + + + ))} + +
ChannelSentDeliveredFailedBounced
{channel}{stats.sent}{stats.delivered}{stats.failed}{stats.bounced}
+
+
+
+ ); +} From db5b5af00261104a0e3692be69f69dbf002d2084 Mon Sep 17 00:00:00 2001 From: devjayy43 Date: Sat, 29 Aug 2026 08:43:29 +0100 Subject: [PATCH 5/5] chore: Register new feature routes Register all new feature routes in API: - Login risk signals endpoints - Data correction approval flow endpoints - Replay comparison endpoints - Notification analytics endpoints --- backend/src/api/routes/index.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts index d39e35c6..9af84156 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -28,6 +28,10 @@ import { poolRoutes } from "./pools.routes.js"; import { searchRoutes } from "./search.routes.js"; import { cleanupRoutes } from "./cleanup.routes.js"; import { discordRoutes } from "./discord.routes.js"; +import { loginRiskSignalsRoutes } from "./loginRiskSignals.js"; +import { dataCorrectionsRoutes } from "./dataCorrections.js"; +import { replayComparisonRoutes } from "./replayComparison.js"; +import { notificationAnalyticsRoutes } from "./notificationAnalytics.js"; export async function registerRoutes(server: FastifyInstance) { server.register(assetsRoutes, { prefix: "/api/v1/assets" }); @@ -61,4 +65,8 @@ export async function registerRoutes(server: FastifyInstance) { server.register(searchRoutes, { prefix: "/api/v1/search" }); server.register(cleanupRoutes, { prefix: "/api/v1/cleanup" }); server.register(discordRoutes, { prefix: "/api/v1/discord" }); + server.register(loginRiskSignalsRoutes, { prefix: "/api/v1/login-risk-signals" }); + server.register(dataCorrectionsRoutes, { prefix: "/api/v1/data-corrections" }); + server.register(replayComparisonRoutes, { prefix: "/api/v1/replay-comparison" }); + server.register(notificationAnalyticsRoutes, { prefix: "/api/v1/notification-analytics" }); }