From 206f47f114cd367d92a5a90b5dfc8d274ed428b0 Mon Sep 17 00:00:00 2001 From: benzeneCLI Date: Sat, 27 Jun 2026 15:06:25 +0100 Subject: [PATCH] feat: add dispute timeout service, core e2e tests, memoized escrow list item, and pagination Closes #136 Closes #271 Closes #368 Closes #392 --- .../modules/escrow/escrow-timeout.service.ts | 44 +++++++++++++++ .../frontend/components/common/Pagination.tsx | 56 +++++++++++++++++++ .../components/dashboard/EscrowListItem.tsx | 53 ++++++++++++++++++ apps/frontend/e2e/core-flows.spec.ts | 36 ++++++++++++ 4 files changed, 189 insertions(+) create mode 100644 apps/backend/src/modules/escrow/escrow-timeout.service.ts create mode 100644 apps/frontend/components/common/Pagination.tsx create mode 100644 apps/frontend/components/dashboard/EscrowListItem.tsx create mode 100644 apps/frontend/e2e/core-flows.spec.ts diff --git a/apps/backend/src/modules/escrow/escrow-timeout.service.ts b/apps/backend/src/modules/escrow/escrow-timeout.service.ts new file mode 100644 index 00000000..6d83076c --- /dev/null +++ b/apps/backend/src/modules/escrow/escrow-timeout.service.ts @@ -0,0 +1,44 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { + Dispute, + DisputeOutcome, + DisputeStatus, +} from './entities/dispute.entity'; + +@Injectable() +export class EscrowTimeoutService { + private readonly logger = new Logger(EscrowTimeoutService.name); + + constructor( + @InjectRepository(Dispute) + private readonly disputeRepo: Repository, + ) {} + + /** + * Auto-resolves disputes that have been OPEN longer than `timeoutHours`. + * Defaults the outcome to REFUNDED_TO_BUYER when no admin action is taken. + */ + async resolveExpiredDisputes(timeoutHours = 72): Promise { + const cutoff = new Date(Date.now() - timeoutHours * 60 * 60 * 1000); + const openDisputes = await this.disputeRepo.find({ + where: { status: DisputeStatus.OPEN }, + }); + + const expired = openDisputes.filter((d) => d.createdAt < cutoff); + + for (const dispute of expired) { + dispute.status = DisputeStatus.RESOLVED; + dispute.outcome = DisputeOutcome.REFUNDED_TO_BUYER; + dispute.resolvedAt = new Date(); + dispute.resolutionNotes = `Auto-resolved: no admin action within ${timeoutHours}h`; + await this.disputeRepo.save(dispute); + this.logger.log( + `Dispute ${dispute.id} auto-resolved after ${timeoutHours}h timeout`, + ); + } + + return expired.length; + } +} diff --git a/apps/frontend/components/common/Pagination.tsx b/apps/frontend/components/common/Pagination.tsx new file mode 100644 index 00000000..e3d4ea54 --- /dev/null +++ b/apps/frontend/components/common/Pagination.tsx @@ -0,0 +1,56 @@ +"use client"; +import React from "react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; + +interface PaginationProps { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; +} + +export default function Pagination({ + currentPage, + totalPages, + onPageChange, +}: PaginationProps) { + if (totalPages <= 1) return null; + + const windowSize = Math.min(5, totalPages); + const start = Math.max(1, Math.min(currentPage - 2, totalPages - windowSize + 1)); + const pages = Array.from({ length: windowSize }, (_, i) => start + i); + + return ( + + ); +} diff --git a/apps/frontend/components/dashboard/EscrowListItem.tsx b/apps/frontend/components/dashboard/EscrowListItem.tsx new file mode 100644 index 00000000..e70ba1b6 --- /dev/null +++ b/apps/frontend/components/dashboard/EscrowListItem.tsx @@ -0,0 +1,53 @@ +import React, { memo } from "react"; +import Link from "next/link"; + +interface IEscrow { + id: string; + title: string; + amount: string; + asset: string; + status: string; + deadline: string; +} + +const STATUS_COLORS: Record = { + created: "bg-blue-100 text-blue-800", + funded: "bg-indigo-100 text-indigo-800", + confirmed: "bg-yellow-100 text-yellow-800", + completed: "bg-green-100 text-green-800", + cancelled: "bg-gray-100 text-gray-800", + disputed: "bg-red-100 text-red-800", + expired: "bg-orange-100 text-orange-800", +}; + +const EscrowListItem = memo(function EscrowListItem({ + escrow, +}: { + escrow: IEscrow; +}) { + const colorClass = + STATUS_COLORS[escrow.status] ?? "bg-gray-100 text-gray-800"; + return ( + +
+
+

{escrow.title}

+

+ {escrow.amount} {escrow.asset} +

+
+ + {escrow.status} + +
+

+ Due: {new Date(escrow.deadline).toLocaleDateString()} +

+ + ); +}); + +export default EscrowListItem; diff --git a/apps/frontend/e2e/core-flows.spec.ts b/apps/frontend/e2e/core-flows.spec.ts new file mode 100644 index 00000000..17e17747 --- /dev/null +++ b/apps/frontend/e2e/core-flows.spec.ts @@ -0,0 +1,36 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Core User Flows', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + }); + + test('landing page renders a top-level heading', async ({ page }) => { + await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); + }); + + test('connect wallet call-to-action is present', async ({ page }) => { + const connectBtn = page.getByRole('button', { name: /connect wallet/i }); + const getStarted = page.getByRole('link', { name: /get started/i }); + await expect(connectBtn.or(getStarted)).toBeVisible(); + }); + + test('dashboard route is reachable', async ({ page }) => { + await page.goto('/dashboard'); + await expect(page).toHaveURL(/\/(dashboard|login|\?)/); + }); + + test('create-escrow page is reachable', async ({ page }) => { + await page.goto('/escrow/create'); + await expect(page).toHaveURL(/\/escrow\/create/); + }); + + test('transactions page is reachable', async ({ page }) => { + await page.goto('/transactions'); + await expect(page).toHaveURL(/\/transactions/); + }); + + test('page title contains Vaultix', async ({ page }) => { + await expect(page).toHaveTitle(/vaultix/i); + }); +});