diff --git a/components/dashboard/AuditTrailList.tsx b/components/dashboard/AuditTrailList.tsx new file mode 100644 index 0000000..869e176 --- /dev/null +++ b/components/dashboard/AuditTrailList.tsx @@ -0,0 +1,326 @@ +'use client'; + +import { useRef, type KeyboardEvent } from 'react'; +import { AlertCircle, ArrowDownUp, Check, Copy, Inbox, RefreshCw } from 'lucide-react'; +import { useAuditTrail } from '@/hooks/useAuditTrail'; +import type { AuditTrailEvent, AuditTrailMetadataValue } from '@/types/auditTrail'; + +const SKELETON_COUNT = 3; + +interface AuditTrailListProps { + /** Delivery whose on-chain events are listed. */ + deliveryId: string | null; + className?: string; +} + +/** "escrow_funded" / "escrow-funded" → "Escrow Funded". */ +function formatEventType(eventType: string): string { + return eventType + .split(/[_\-\s]+/) + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' '); +} + +function shorten(value: string, edge = 6): string { + return value.length > edge * 2 + 1 ? `${value.slice(0, edge)}…${value.slice(-edge)}` : value; +} + +function formatTimestamp(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return 'Unknown time'; + return date.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +} + +function formatMetadataKey(key: string): string { + return formatEventType(key.replace(/([a-z])([A-Z])/g, '$1 $2')); +} + +function formatMetadataValue(value: AuditTrailMetadataValue): string { + if (value === null) return '—'; + if (typeof value === 'boolean') return value ? 'Yes' : 'No'; + return String(value); +} + +function SkeletonCard() { + return ( +
  • +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
  • + ); +} + +interface EventCardProps { + event: AuditTrailEvent; + isCopied: boolean; + onCopy: (_eventId: string) => void; + cardRef: (_node: HTMLElement | null) => void; +} + +function EventCard({ event, isCopied, onCopy, cardRef }: EventCardProps) { + const titleId = `audit-event-${event.eventId}`; + const metadata = Object.entries(event.metadata ?? {}); + + return ( +
  • +
    +
    +

    + {formatEventType(event.eventType)} +

    + +
    + +
    +
    +
    Actor
    +
    + {event.actor.displayName && {event.actor.displayName}} + + {shorten(event.actor.address)} + + {event.actor.role && ( + + {event.actor.role} + + )} +
    +
    + {event.txHash && ( +
    +
    Transaction
    +
    + {shorten(event.txHash, 8)} +
    +
    + )} + {event.ledger !== undefined && ( +
    +
    Ledger
    +
    + {event.ledger.toLocaleString('en-US')} +
    +
    + )} +
    + + {metadata.length > 0 && ( +
    + {metadata.map(([key, value]) => ( +
    +
    {formatMetadataKey(key)}
    +
    + {formatMetadataValue(value)} +
    +
    + ))} +
    + )} + + +
    +
  • + ); +} + +/** + * AuditTrailList — read-only list of immutable blockchain events for a delivery. + * + * Layered Architecture: + * AuditTrailList (Component) → useAuditTrail (Hook) → auditTrailService (Service) + * + * Cards are keyboard navigable: Tab moves through them, and Arrow Up/Down, + * Home and End move focus between cards. + */ +export function AuditTrailList({ deliveryId, className = '' }: AuditTrailListProps) { + const { + events, + sortOrder, + toggleSortOrder, + isLoading, + isRefetching, + error, + retry, + copiedEventId, + copyEventId, + } = useAuditTrail(deliveryId); + const cardRefs = useRef<(HTMLElement | null)[]>([]); + + const handleListKeyDown = (e: KeyboardEvent) => { + const cards = cardRefs.current.slice(0, events.length); + const current = cards.findIndex((card) => card === e.target); + if (current === -1) return; + + const last = cards.length - 1; + const next: Record = { + ArrowDown: Math.min(current + 1, last), + ArrowUp: Math.max(current - 1, 0), + Home: 0, + End: last, + }; + if (!(e.key in next)) return; + e.preventDefault(); + cards[next[e.key]]?.focus(); + }; + + const sortLabel = sortOrder === 'newest' ? 'Newest first' : 'Oldest first'; + + return ( +
    +
    +
    +

    + Audit Trail +

    +

    + Immutable smart contract events for this delivery +

    +
    + {deliveryId && ( +
    + + +
    + )} +
    + + {!deliveryId && ( +

    + Select a delivery to view its audit trail. +

    + )} + + {deliveryId && isLoading && ( + <> +

    + Loading audit events +

    +
      + {Array.from({ length: SKELETON_COUNT }, (_, i) => ( + + ))} +
    + + )} + + {deliveryId && !isLoading && error && ( +
    + +

    {error}

    + +
    + )} + + {deliveryId && !isLoading && !error && events.length === 0 && ( +
    + +
    +

    No events recorded yet

    +

    + Contract events appear here once they are confirmed on-chain. +

    +
    + +
    + )} + + {deliveryId && !isLoading && !error && events.length > 0 && ( +
      + {events.map((event, index) => ( + void copyEventId(id)} + cardRef={(node) => { + cardRefs.current[index] = node; + }} + /> + ))} +
    + )} +
    + ); +} diff --git a/components/dashboard/__tests__/AuditTrailList.test.tsx b/components/dashboard/__tests__/AuditTrailList.test.tsx new file mode 100644 index 0000000..4e8beb2 --- /dev/null +++ b/components/dashboard/__tests__/AuditTrailList.test.tsx @@ -0,0 +1,200 @@ +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactElement } from 'react'; +import { AuditTrailList } from '@/components/dashboard/AuditTrailList'; +import { auditTrailService } from '@/services/auditTrailService'; +import { auditTrailEventsFixture } from '@/hooks/__tests__/fixtures/auditTrailApiResponses'; + +// Only the network boundary is mocked; useAuditTrail runs for real so these +// tests exercise the Component -> Hook -> Service path. +jest.mock('@/services/auditTrailService', () => ({ + auditTrailService: { getDeliveryEvents: jest.fn() }, +})); + +jest.mock('sonner', () => ({ + toast: { error: jest.fn(), success: jest.fn() }, +})); + +const mockGetEvents = auditTrailService.getDeliveryEvents as jest.Mock; +const writeText = jest.fn(); + +function renderWithClient(ui: ReactElement) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }); + return render({ui}); +} + +async function renderLoaded() { + const utils = renderWithClient(); + const list = await screen.findByRole('list', { name: /audit trail events/i }); + return { ...utils, list }; +} + +const cardTitles = (list: HTMLElement) => + within(list) + .getAllByRole('article') + .map((card) => within(card).getByRole('heading').textContent); + +describe('AuditTrailList', () => { + beforeAll(() => { + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }); + }); + + beforeEach(() => { + jest.clearAllMocks(); + writeText.mockResolvedValue(undefined); + mockGetEvents.mockResolvedValue(auditTrailEventsFixture); + }); + + describe('chronological ordering', () => { + it('renders events newest first', async () => { + const { list } = await renderLoaded(); + + expect(cardTitles(list)).toEqual(['Escrow Funded', 'Driver Assigned', 'Contract Created']); + }); + + it('reverses to oldest first from the sort toggle', async () => { + const { list } = await renderLoaded(); + + fireEvent.click(screen.getByRole('button', { name: /sort order: newest first/i })); + + expect(cardTitles(list)).toEqual(['Contract Created', 'Driver Assigned', 'Escrow Funded']); + expect(screen.getByRole('button', { name: /sort order: oldest first/i })).toBeInTheDocument(); + }); + }); + + describe('event cards', () => { + it('shows event type, timestamp, actor and metadata', async () => { + const { list } = await renderLoaded(); + const card = within(list).getAllByRole('article')[0]; + + expect(within(card).getByRole('heading')).toHaveTextContent('Escrow Funded'); + expect(card.querySelector('time')).toHaveAttribute('dateTime', '2026-09-20T11:30:00.000Z'); + expect(within(card).getByText('Acme Imports')).toBeInTheDocument(); + expect(within(card).getByTitle(auditTrailEventsFixture[1].actor.address)).toBeInTheDocument(); + expect(within(card).getByText('customer')).toBeInTheDocument(); + expect(within(card).getByText('51,235,002')).toBeInTheDocument(); + + const metadata = within(card).getByLabelText('Event metadata'); + expect(within(metadata).getByText('Amount')).toBeInTheDocument(); + expect(within(metadata).getByText('1500')).toBeInTheDocument(); + expect(within(metadata).getByText('Insured')).toBeInTheDocument(); + expect(within(metadata).getByText('Yes')).toBeInTheDocument(); + }); + + it('omits the metadata block when an event has none', async () => { + const { list } = await renderLoaded(); + const contractCard = within(list).getAllByRole('article')[2]; + + expect(within(contractCard).queryByLabelText('Event metadata')).not.toBeInTheDocument(); + }); + + it('copies the event id to the clipboard', async () => { + await renderLoaded(); + + const button = screen.getByRole('button', { name: 'Copy event ID evt-0003' }); + await act(async () => { + fireEvent.click(button); + }); + + expect(writeText).toHaveBeenCalledWith('evt-0003'); + expect(button).toHaveTextContent('Copied'); + }); + }); + + describe('loading, empty and error states', () => { + it('shows a skeleton card per placeholder while loading', async () => { + let resolve: (_value: unknown) => void = () => undefined; + mockGetEvents.mockReturnValue(new Promise((r) => (resolve = r))); + renderWithClient(); + + expect(screen.getByRole('status')).toHaveTextContent('Loading audit events'); + const skeleton = screen.getByTestId('audit-trail-skeleton'); + expect(skeleton.querySelectorAll('li')).toHaveLength(3); + + await act(async () => { + resolve(auditTrailEventsFixture); + }); + await waitFor(() => + expect(screen.queryByTestId('audit-trail-skeleton')).not.toBeInTheDocument(), + ); + expect(screen.getAllByRole('article')).toHaveLength(3); + }); + + it('shows an empty state with a retry that refetches', async () => { + mockGetEvents.mockResolvedValueOnce([]); + renderWithClient(); + + expect(await screen.findByText('No events recorded yet')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + + expect(await screen.findByRole('list', { name: /audit trail events/i })).toBeInTheDocument(); + expect(mockGetEvents).toHaveBeenCalledTimes(2); + }); + + it('shows the error with a retry option', async () => { + mockGetEvents.mockRejectedValueOnce(new Error('Indexer unavailable')); + renderWithClient(); + + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent('Indexer unavailable'); + + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })); + await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()); + expect(screen.getAllByRole('article')).toHaveLength(3); + }); + + it('prompts for a delivery when none is selected', () => { + renderWithClient(); + + expect(screen.getByText('Select a delivery to view its audit trail.')).toBeInTheDocument(); + expect(mockGetEvents).not.toHaveBeenCalled(); + }); + }); + + describe('keyboard navigation', () => { + it('makes every card focusable', async () => { + const { list } = await renderLoaded(); + + within(list) + .getAllByRole('article') + .forEach((card) => expect(card).toHaveAttribute('tabIndex', '0')); + }); + + it('moves focus with Arrow keys, Home and End', async () => { + const { list } = await renderLoaded(); + const cards = within(list).getAllByRole('article'); + + cards[0].focus(); + fireEvent.keyDown(cards[0], { key: 'ArrowDown' }); + expect(cards[1]).toHaveFocus(); + + fireEvent.keyDown(cards[1], { key: 'End' }); + expect(cards[2]).toHaveFocus(); + + fireEvent.keyDown(cards[2], { key: 'ArrowDown' }); + expect(cards[2]).toHaveFocus(); + + fireEvent.keyDown(cards[2], { key: 'ArrowUp' }); + expect(cards[1]).toHaveFocus(); + + fireEvent.keyDown(cards[1], { key: 'Home' }); + expect(cards[0]).toHaveFocus(); + }); + + it('leaves arrow keys alone when focus is on a button inside a card', async () => { + const { list } = await renderLoaded(); + const copy = screen.getByRole('button', { name: 'Copy event ID evt-0003' }); + + copy.focus(); + fireEvent.keyDown(copy, { key: 'ArrowDown' }); + + expect(copy).toHaveFocus(); + expect(within(list).getAllByRole('article')[1]).not.toHaveFocus(); + }); + }); +}); diff --git a/hooks/__tests__/fixtures/auditTrailApiResponses.ts b/hooks/__tests__/fixtures/auditTrailApiResponses.ts new file mode 100644 index 0000000..08a2900 --- /dev/null +++ b/hooks/__tests__/fixtures/auditTrailApiResponses.ts @@ -0,0 +1,40 @@ +import type { AuditTrailEvent } from '@/types/auditTrail'; + +/** Deliberately returned out of order to exercise client-side sorting. */ +export const auditTrailEventsFixture: AuditTrailEvent[] = [ + { + eventId: 'evt-0002', + eventType: 'driver_assigned', + timestamp: '2026-09-20T10:15:00.000Z', + actor: { + address: 'GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H', + role: 'driver', + }, + txHash: '7d2c1f0e9b8a7968574635241302f1e0d9c8b7a6958473625140f3e2d1c0b9a8', + ledger: 51234567, + metadata: { vehicleId: 'TRK-204', estimatedPickup: '2026-09-20T12:00:00Z' }, + }, + { + eventId: 'evt-0003', + eventType: 'escrow_funded', + timestamp: '2026-09-20T11:30:00.000Z', + actor: { + address: 'GACV3JHPXU7CKKYIRSTNLPFVFWGCAYDGBVNFNFJDG5BFCPJV7ZRKJSGN', + role: 'customer', + displayName: 'Acme Imports', + }, + txHash: 'a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90', + ledger: 51235002, + metadata: { amount: 1500, asset: 'XLM', insured: true }, + }, + { + eventId: 'evt-0001', + eventType: 'contract_created', + timestamp: '2026-09-20T09:00:00.000Z', + actor: { + address: 'CCJZ5DGASBWQXR5MPFCJXMBI333XE5U3FSJTNQU7RIKE3P5GN2K2WYD5', + role: 'contract', + }, + ledger: 51233900, + }, +]; diff --git a/hooks/__tests__/useAuditTrail.test.tsx b/hooks/__tests__/useAuditTrail.test.tsx new file mode 100644 index 0000000..60fb51c --- /dev/null +++ b/hooks/__tests__/useAuditTrail.test.tsx @@ -0,0 +1,150 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { createElement, type ReactNode } from 'react'; +import { toast } from 'sonner'; +import { sortAuditEvents, useAuditTrail } from '@/hooks/useAuditTrail'; +import { auditTrailService } from '@/services/auditTrailService'; +import { auditTrailEventsFixture } from './fixtures/auditTrailApiResponses'; + +jest.mock('@/services/auditTrailService', () => ({ + auditTrailService: { getDeliveryEvents: jest.fn() }, +})); + +jest.mock('sonner', () => ({ + toast: { error: jest.fn(), success: jest.fn() }, +})); + +const mockGetEvents = auditTrailService.getDeliveryEvents as jest.Mock; +const writeText = jest.fn(); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }); + return function Wrapper({ children }: { children: ReactNode }) { + return createElement(QueryClientProvider, { client: queryClient }, children); + }; +} + +const ids = (events: { eventId: string }[]) => events.map((e) => e.eventId); + +describe('useAuditTrail', () => { + beforeAll(() => { + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }); + }); + + beforeEach(() => { + jest.clearAllMocks(); + writeText.mockResolvedValue(undefined); + mockGetEvents.mockResolvedValue(auditTrailEventsFixture); + }); + + it('fetches events for the delivery and tracks loading', async () => { + const { result } = renderHook(() => useAuditTrail('del-42'), { wrapper: createWrapper() }); + + expect(result.current.isLoading).toBe(true); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(mockGetEvents).toHaveBeenCalledWith('del-42', expect.any(AbortSignal)); + }); + + it('orders events newest first by default', async () => { + const { result } = renderHook(() => useAuditTrail('del-42'), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.events).toHaveLength(3)); + expect(result.current.sortOrder).toBe('newest'); + expect(ids(result.current.events)).toEqual(['evt-0003', 'evt-0002', 'evt-0001']); + }); + + it('toggles to oldest first', async () => { + const { result } = renderHook(() => useAuditTrail('del-42'), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.events).toHaveLength(3)); + + act(() => result.current.toggleSortOrder()); + + expect(result.current.sortOrder).toBe('oldest'); + expect(ids(result.current.events)).toEqual(['evt-0001', 'evt-0002', 'evt-0003']); + }); + + it('does not fetch without a delivery id', () => { + const { result } = renderHook(() => useAuditTrail(null), { wrapper: createWrapper() }); + + expect(mockGetEvents).not.toHaveBeenCalled(); + expect(result.current.isLoading).toBe(false); + expect(result.current.events).toEqual([]); + }); + + it('exposes errors and recovers on retry', async () => { + mockGetEvents.mockRejectedValueOnce(new Error('Indexer unavailable')); + const { result } = renderHook(() => useAuditTrail('del-42'), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.error).toBe('Indexer unavailable')); + + act(() => result.current.retry()); + + await waitFor(() => expect(result.current.events).toHaveLength(3)); + expect(result.current.error).toBeNull(); + }); + + describe('copyEventId', () => { + it('writes the event id to the clipboard and marks it copied', async () => { + const { result } = renderHook(() => useAuditTrail('del-42'), { wrapper: createWrapper() }); + + await act(async () => { + await result.current.copyEventId('evt-0002'); + }); + + expect(writeText).toHaveBeenCalledWith('evt-0002'); + expect(result.current.copiedEventId).toBe('evt-0002'); + }); + + it('clears the copied marker after a delay', async () => { + jest.useFakeTimers(); + try { + const { result } = renderHook(() => useAuditTrail(null), { wrapper: createWrapper() }); + + await act(async () => { + await result.current.copyEventId('evt-0002'); + }); + expect(result.current.copiedEventId).toBe('evt-0002'); + + act(() => { + jest.advanceTimersByTime(2000); + }); + expect(result.current.copiedEventId).toBeNull(); + } finally { + jest.useRealTimers(); + } + }); + + it('shows an error toast when the clipboard is unavailable', async () => { + writeText.mockRejectedValue(new Error('Permission denied')); + const { result } = renderHook(() => useAuditTrail('del-42'), { wrapper: createWrapper() }); + + await act(async () => { + await result.current.copyEventId('evt-0002'); + }); + + expect(toast.error).toHaveBeenCalledWith('Could not copy the event ID'); + expect(result.current.copiedEventId).toBeNull(); + }); + }); +}); + +describe('sortAuditEvents', () => { + it('keeps events with invalid timestamps last in both orders', () => { + const broken = { ...auditTrailEventsFixture[0], eventId: 'evt-bad', timestamp: 'not-a-date' }; + const events = [broken, ...auditTrailEventsFixture]; + + expect(ids(sortAuditEvents(events, 'newest')).at(-1)).toBe('evt-bad'); + expect(ids(sortAuditEvents(events, 'oldest')).at(-1)).toBe('evt-bad'); + }); + + it('does not mutate the input', () => { + const input = [...auditTrailEventsFixture]; + sortAuditEvents(input, 'newest'); + expect(input).toEqual(auditTrailEventsFixture); + }); +}); diff --git a/services/__tests__/auditTrailService.test.ts b/services/__tests__/auditTrailService.test.ts new file mode 100644 index 0000000..60f82cd --- /dev/null +++ b/services/__tests__/auditTrailService.test.ts @@ -0,0 +1,70 @@ +import { AxiosError, AxiosHeaders } from 'axios'; +import api from '@/lib/api'; +import { auditTrailService } from '@/services/auditTrailService'; +import { auditTrailEventsFixture } from '@/hooks/__tests__/fixtures/auditTrailApiResponses'; + +jest.mock('@/lib/api', () => ({ + __esModule: true, + default: { get: jest.fn() }, +})); + +const mockGet = api.get as jest.Mock; + +describe('auditTrailService.getDeliveryEvents', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('reads the delivery events endpoint and returns the events', async () => { + mockGet.mockResolvedValue({ + data: { success: true, data: { deliveryId: 'del-42', events: auditTrailEventsFixture } }, + }); + + await expect(auditTrailService.getDeliveryEvents('del-42')).resolves.toEqual( + auditTrailEventsFixture, + ); + expect(mockGet).toHaveBeenCalledWith('/audit/delivery/del-42/events', { signal: undefined }); + }); + + it('encodes the delivery id', async () => { + mockGet.mockResolvedValue({ data: { success: true, data: { deliveryId: 'x', events: [] } } }); + + await auditTrailService.getDeliveryEvents('a/b'); + + expect(mockGet).toHaveBeenCalledWith('/audit/delivery/a%2Fb/events', { signal: undefined }); + }); + + it('throws the API message for an unsuccessful response', async () => { + mockGet.mockResolvedValue({ data: { success: false, message: 'Delivery not found' } }); + + await expect(auditTrailService.getDeliveryEvents('missing')).rejects.toThrow( + 'Delivery not found', + ); + }); + + it('uses the server error message from an HTTP failure', async () => { + const headers = new AxiosHeaders(); + mockGet.mockRejectedValue( + new AxiosError('Request failed', 'ERR_BAD_RESPONSE', { headers }, null, { + status: 502, + statusText: 'Bad Gateway', + headers, + config: { headers }, + data: { message: 'Indexer unavailable' }, + }), + ); + + await expect(auditTrailService.getDeliveryEvents('del-42')).rejects.toThrow( + 'Indexer unavailable', + ); + }); + + it('falls back to a readable message when the error has no detail', async () => { + const headers = new AxiosHeaders(); + mockGet.mockRejectedValue(new AxiosError('timeout', 'ECONNABORTED', { headers })); + + await expect(auditTrailService.getDeliveryEvents('del-42')).rejects.toThrow( + 'Unable to load the audit trail. Please try again.', + ); + }); +}); diff --git a/services/auditTrailService.ts b/services/auditTrailService.ts new file mode 100644 index 0000000..1e232e1 --- /dev/null +++ b/services/auditTrailService.ts @@ -0,0 +1,34 @@ +import { isAxiosError } from 'axios'; +import api from '@/lib/api'; +import type { AuditTrailEvent, AuditTrailResponse } from '@/types/auditTrail'; + +/** + * auditTrailService — reads the immutable on-chain event log of a delivery. + * Hooks call this; components never call this directly. + * + * Errors are thrown (not folded into the response) so query hooks can track + * them as error state. + */ +export const auditTrailService = { + async getDeliveryEvents(deliveryId: string, signal?: AbortSignal): Promise { + const fallback = 'Unable to load the audit trail. Please try again.'; + let response: AuditTrailResponse; + try { + const { data } = await api.get( + `/audit/delivery/${encodeURIComponent(deliveryId)}/events`, + { signal }, + ); + response = data; + } catch (error) { + if (isAxiosError<{ message?: string }>(error)) { + throw new Error(error.response?.data?.message ?? fallback); + } + throw error instanceof Error ? error : new Error(fallback); + } + + if (!response.success || !response.data) { + throw new Error(response.message ?? fallback); + } + return response.data.events ?? []; + }, +}; diff --git a/types/auditTrail.ts b/types/auditTrail.ts new file mode 100644 index 0000000..39fd84e --- /dev/null +++ b/types/auditTrail.ts @@ -0,0 +1,40 @@ +/** + * Immutable blockchain events recorded against a delivery's escrow contract, + * returned by GET /api/audit/delivery/{deliveryId}/events. + */ + +export type AuditTrailMetadataValue = string | number | boolean | null; + +export interface AuditTrailActor { + /** Stellar public key (G...) or contract address (C...) that emitted the event. */ + address: string; + /** Role on the delivery, e.g. "customer", "driver", "admin" or "contract". */ + role?: string; + displayName?: string; +} + +export interface AuditTrailEvent { + /** Stable event identifier, unique per contract event. */ + eventId: string; + /** Event type as emitted by the contract, e.g. "escrow_funded". */ + eventType: string; + /** ISO 8601 ledger close time of the event. */ + timestamp: string; + actor: AuditTrailActor; + /** Hash of the transaction that emitted the event. */ + txHash?: string; + /** Ledger sequence the event was included in. */ + ledger?: number; + metadata?: Record; +} + +export type AuditTrailSortOrder = 'newest' | 'oldest'; + +export interface AuditTrailResponse { + success: boolean; + message?: string; + data?: { + deliveryId: string; + events: AuditTrailEvent[]; + }; +}