diff --git a/Front/app/admin/page.tsx b/Front/app/admin/page.tsx index acfca63..92fa108 100644 --- a/Front/app/admin/page.tsx +++ b/Front/app/admin/page.tsx @@ -26,7 +26,9 @@ export default function AdminPage() { const [authenticated, setAuthenticated] = useState(false) const [adminKeyInput, setAdminKeyInput] = useState("") const [loading, setLoading] = useState(false) + const [actionId, setActionId] = useState(null) const [error, setError] = useState(null) + const [notice, setNotice] = useState(null) const [platforms, setPlatforms] = useState([]) const [lenders, setLenders] = useState([]) const [events, setEvents] = useState([]) @@ -83,6 +85,7 @@ export default function AdminPage() { const handleRegisterPlatform = async (event: FormEvent) => { event.preventDefault() setError(null) + setNotice(null) setLastRegisteredKey(null) const result = await adminClient.registerPlatform({ @@ -101,6 +104,59 @@ export default function AdminPage() { loadDashboard() } + const handleDispute = async (event: AdminEvent) => { + const reason = window.prompt("Reason for disputing this credit event?")?.trim() + if (!reason) return + + setActionId(`dispute-${event.id}`) + setError(null) + setNotice(null) + const result = await adminClient.disputeEvent(event.id, reason) + setActionId(null) + + if (result.error) { + setError(result.error) + return + } + + setNotice("Event disputed. Recalculate the wallet to apply the score change.") + loadDashboard() + } + + const handleReinstate = async (event: AdminEvent) => { + setActionId(`reinstate-${event.id}`) + setError(null) + setNotice(null) + const result = await adminClient.reinstateEvent(event.id) + setActionId(null) + + if (result.error) { + setError(result.error) + return + } + + setNotice("Event reinstated. Recalculate the wallet to apply the score change.") + loadDashboard() + } + + const handleRecalculate = async (event: AdminEvent) => { + setActionId(`recalc-${event.id}`) + setError(null) + setNotice(null) + const result = await adminClient.recalculateUser(event.walletAddress) + setActionId(null) + + if (result.error) { + setError(result.error) + return + } + + setNotice( + `Recalculated ${event.walletAddress.slice(0, 8)}...: ${result.data?.previousScore} -> ${result.data?.score}` + ) + loadDashboard() + } + if (!authenticated) { return ( @@ -142,7 +198,7 @@ export default function AdminPage() {

Operator

Admin Dashboard

- Manage platforms, lenders, and monitor recent credit events. + Manage platforms, lenders, and disputed credit events.

@@ -152,6 +208,12 @@ export default function AdminPage() { {error} )} + {notice && ( + + + {notice} + + )} @@ -212,8 +274,12 @@ export default function AdminPage() { {lastRegisteredKey && (
-

New API key — save it now

-

{lastRegisteredKey}

+

+ New API key - save it now +

+

+ {lastRegisteredKey} +

)} @@ -284,6 +350,9 @@ export default function AdminPage() { Recent events ({events.length}) + + Dispute or reinstate an event, then recalculate that wallet. + @@ -293,6 +362,8 @@ export default function AdminPage() { PlatformWalletImpact + Status + Actions @@ -305,18 +376,47 @@ export default function AdminPage() { {event.walletAddress.slice(0, 8)}... - 0 - ? "text-white/70" - : event.scoreImpact < 0 - ? "text-white/35" - : "text-white/20" - }`} - > + {event.scoreImpact > 0 ? "+" : ""} {event.scoreImpact} + + {event.disputed ? ( + disputed + ) : ( + "active" + )} + + +
+ {event.disputed ? ( + + ) : ( + + )} + +
+
))}
diff --git a/Front/lib/admin-client.ts b/Front/lib/admin-client.ts index 1a2f37b..3a999ad 100644 --- a/Front/lib/admin-client.ts +++ b/Front/lib/admin-client.ts @@ -29,6 +29,20 @@ export interface AdminEvent { scoreImpact: number txHash: string createdAt: string + disputed: boolean + disputeReason: string | null + disputedAt: string | null + disputedBy: string | null +} + +export interface AdminRecalcResult { + walletAddress: string + previousScore: number + score: number + profileTier: string + stellarBase: number + eventsTotal: number + paymentsTotal: number } class AdminClient { @@ -101,6 +115,30 @@ class AdminClient { }>(`/api/admin/events/recent?limit=${limit}&offset=${offset}`) } + disputeEvent(id: string, reason: string) { + return this.request<{ event: AdminEvent }>( + `/api/admin/events/${encodeURIComponent(id)}/dispute`, + { + method: "POST", + body: JSON.stringify({ reason }), + } + ) + } + + reinstateEvent(id: string) { + return this.request<{ event: AdminEvent }>( + `/api/admin/events/${encodeURIComponent(id)}/reinstate`, + { method: "POST" } + ) + } + + recalculateUser(wallet: string) { + return this.request( + `/api/admin/users/${encodeURIComponent(wallet)}/recalculate`, + { method: "POST" } + ) + } + registerPlatform(payload: { platformId: string name: string diff --git a/Server/prisma/migrations/20260920230000_credit_event_disputes/migration.sql b/Server/prisma/migrations/20260920230000_credit_event_disputes/migration.sql new file mode 100644 index 0000000..c714a53 --- /dev/null +++ b/Server/prisma/migrations/20260920230000_credit_event_disputes/migration.sql @@ -0,0 +1,5 @@ +ALTER TABLE `CreditEvent` + ADD COLUMN `disputed` BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN `disputeReason` VARCHAR(191) NULL, + ADD COLUMN `disputedAt` DATETIME(3) NULL, + ADD COLUMN `disputedBy` VARCHAR(191) NULL; diff --git a/Server/prisma/schema.prisma b/Server/prisma/schema.prisma index 999d0e8..653060a 100644 --- a/Server/prisma/schema.prisma +++ b/Server/prisma/schema.prisma @@ -47,6 +47,10 @@ model CreditEvent { scoreImpact Int verifiedAt DateTime createdAt DateTime @default(now()) + disputed Boolean @default(false) + disputeReason String? + disputedAt DateTime? + disputedBy String? user User @relation(fields: [userId], references: [id]) platform Platform @relation(fields: [platformId], references: [id]) diff --git a/Server/scripts/recalculate-scores.ts b/Server/scripts/recalculate-scores.ts index 3cd89ee..bfb0a43 100644 --- a/Server/scripts/recalculate-scores.ts +++ b/Server/scripts/recalculate-scores.ts @@ -52,7 +52,7 @@ async function main() { const users = await prisma.user.findMany({ where: options.wallet ? { walletAddress: options.wallet } : undefined, include: { - creditEvents: { select: { scoreImpact: true } }, + creditEvents: { select: { scoreImpact: true, disputed: true } }, payments: { select: { status: true } }, }, }); @@ -65,7 +65,7 @@ async function main() { console.log( options.apply ? "Applying score recalculation..." - : "Dry run — no database writes" + : "Dry run - no database writes" ); for (const user of users) { diff --git a/Server/src/controllers/admin.controller.ts b/Server/src/controllers/admin.controller.ts index 9253ff2..0513dd7 100644 --- a/Server/src/controllers/admin.controller.ts +++ b/Server/src/controllers/admin.controller.ts @@ -1,5 +1,11 @@ import { NextFunction, Request, Response } from "express"; import { prisma } from "../config/database"; +import { calculateStellarBase } from "../services/scoring.service"; +import { + computeStellarBaseFromStoredData, + recalculateUserScore, +} from "../services/score-recalc.service"; +import type { StellarWalletData } from "../services/stellar.service"; /** * @swagger @@ -8,6 +14,10 @@ import { prisma } from "../config/database"; * description: Operator endpoints (X-Admin-Key required) */ +function getAdminActor(req: Request): string { + return req.header("X-Admin-Operator")?.trim() || "admin"; +} + /** * @swagger * /api/admin/platforms: @@ -122,6 +132,10 @@ export const listRecentEvents = async ( scoreImpact: event.scoreImpact, txHash: event.txHash, createdAt: event.createdAt.toISOString(), + disputed: event.disputed, + disputeReason: event.disputeReason, + disputedAt: event.disputedAt?.toISOString() ?? null, + disputedBy: event.disputedBy, })), pagination: { limit, offset, total }, }, @@ -130,3 +144,218 @@ export const listRecentEvents = async ( return next(error); } }; + +/** + * @swagger + * /api/admin/events/{id}/dispute: + * post: + * tags: [Admin] + * summary: Mark a credit event as disputed + * parameters: + * - in: header + * name: X-Admin-Key + * required: true + * schema: + * type: string + * - in: header + * name: X-Admin-Operator + * required: false + * schema: + * type: string + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [reason] + * properties: + * reason: + * type: string + * responses: + * 200: + * description: Event marked disputed + * 400: + * description: Missing dispute reason + * 404: + * description: Event not found + */ +export const disputeEvent = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + const reason = + typeof req.body?.reason === "string" ? req.body.reason.trim() : ""; + + if (!reason) { + return res + .status(400) + .json({ success: false, error: "Dispute reason is required" }); + } + + const existing = await prisma.creditEvent.findUnique({ + where: { id: req.params.id }, + select: { id: true }, + }); + if (!existing) { + return res + .status(404) + .json({ success: false, error: "Credit event not found" }); + } + + const event = await prisma.creditEvent.update({ + where: { id: req.params.id }, + data: { + disputed: true, + disputeReason: reason, + disputedAt: new Date(), + disputedBy: getAdminActor(req), + }, + }); + + return res.status(200).json({ success: true, data: { event } }); + } catch (error) { + return next(error); + } +}; + +/** + * @swagger + * /api/admin/events/{id}/reinstate: + * post: + * tags: [Admin] + * summary: Reinstate a disputed credit event + * parameters: + * - in: header + * name: X-Admin-Key + * required: true + * schema: + * type: string + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Event reinstated + * 404: + * description: Event not found + */ +export const reinstateEvent = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + const existing = await prisma.creditEvent.findUnique({ + where: { id: req.params.id }, + select: { id: true }, + }); + if (!existing) { + return res + .status(404) + .json({ success: false, error: "Credit event not found" }); + } + + const event = await prisma.creditEvent.update({ + where: { id: req.params.id }, + data: { + disputed: false, + disputeReason: null, + disputedAt: null, + disputedBy: null, + }, + }); + + return res.status(200).json({ success: true, data: { event } }); + } catch (error) { + return next(error); + } +}; + +/** + * @swagger + * /api/admin/users/{wallet}/recalculate: + * post: + * tags: [Admin] + * summary: Recalculate and persist one user's credit score + * parameters: + * - in: header + * name: X-Admin-Key + * required: true + * schema: + * type: string + * - in: path + * name: wallet + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Score recalculated + * 404: + * description: User not found + */ +export const recalculateAdminUserScore = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + const { wallet } = req.params; + const user = await prisma.user.findUnique({ + where: { walletAddress: wallet }, + include: { + creditEvents: { + select: { scoreImpact: true, disputed: true }, + }, + payments: { + select: { status: true }, + }, + }, + }); + + if (!user) { + return res.status(404).json({ success: false, error: "User not found" }); + } + + const cachedBase = computeStellarBaseFromStoredData( + user.stellarData as StellarWalletData | null + ); + const stellarBase = + cachedBase ?? (await calculateStellarBase(user.walletAddress)).score; + const recalculated = recalculateUserScore({ + stellarBase, + creditEvents: user.creditEvents, + payments: user.payments, + }); + + const previousScore = user.score; + await prisma.user.update({ + where: { id: user.id }, + data: { + score: recalculated.score, + profileTier: recalculated.profileTier, + }, + }); + + return res.status(200).json({ + success: true, + data: { + walletAddress: user.walletAddress, + previousScore, + ...recalculated, + }, + }); + } catch (error) { + return next(error); + } +}; diff --git a/Server/src/routes/admin.routes.ts b/Server/src/routes/admin.routes.ts index 3acb30b..aa7f312 100644 --- a/Server/src/routes/admin.routes.ts +++ b/Server/src/routes/admin.routes.ts @@ -1,8 +1,11 @@ import { Router } from "express"; import { + disputeEvent, listLenders, listPlatforms, listRecentEvents, + recalculateAdminUserScore, + reinstateEvent, } from "../controllers/admin.controller"; import { requireAdminKey } from "../middleware/admin-auth.middleware"; import { validateQuery } from "../middleware/validation.middleware"; @@ -19,5 +22,8 @@ router.get( validateQuery(PaginationQuerySchema), listRecentEvents ); +router.post("/events/:id/dispute", disputeEvent); +router.post("/events/:id/reinstate", reinstateEvent); +router.post("/users/:wallet/recalculate", recalculateAdminUserScore); export default router; diff --git a/Server/src/services/__tests__/score-recalc.service.test.ts b/Server/src/services/__tests__/score-recalc.service.test.ts index 5ffa83b..3ac6887 100644 --- a/Server/src/services/__tests__/score-recalc.service.test.ts +++ b/Server/src/services/__tests__/score-recalc.service.test.ts @@ -14,4 +14,18 @@ describe("score-recalc.service", () => { expect(result.eventsTotal).toBe(35); expect(result.paymentsTotal).toBe(-20); }); + + it("excludes disputed credit events from score impact", () => { + const result = recalculateUserScore({ + stellarBase: 120, + creditEvents: [ + { scoreImpact: 50, disputed: true }, + { scoreImpact: 25, disputed: false }, + ], + payments: [], + }); + + expect(result.score).toBe(145); + expect(result.eventsTotal).toBe(25); + }); }); diff --git a/Server/src/services/score-breakdown.service.ts b/Server/src/services/score-breakdown.service.ts index 6832d94..66d2568 100644 --- a/Server/src/services/score-breakdown.service.ts +++ b/Server/src/services/score-breakdown.service.ts @@ -20,6 +20,7 @@ export async function buildScoreBreakdown( where: { walletAddress }, include: { creditEvents: { + where: { disputed: false }, select: { platformId: true, scoreImpact: true }, }, }, diff --git a/Server/src/services/score-recalc.service.ts b/Server/src/services/score-recalc.service.ts index 9db02c2..b6a855e 100644 --- a/Server/src/services/score-recalc.service.ts +++ b/Server/src/services/score-recalc.service.ts @@ -7,7 +7,7 @@ import type { StellarWalletData } from "./stellar.service"; export interface RecalcInput { stellarBase: number; - creditEvents: { scoreImpact: number }[]; + creditEvents: { scoreImpact: number; disputed?: boolean }[]; payments: { status: string }[]; } @@ -41,7 +41,7 @@ export function computeStellarBaseFromStoredData( export function recalculateUserScore(input: RecalcInput): RecalcResult { const eventsTotal = input.creditEvents.reduce( - (sum, event) => sum + event.scoreImpact, + (sum, event) => (event.disputed ? sum : sum + event.scoreImpact), 0 ); const paymentsTotal = input.payments.reduce((sum, payment) => {