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
48 changes: 48 additions & 0 deletions .github/workflows/frontend-unit-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: CI – Frontend Unit Tests

on:
push:
paths:
- "frontend/lib/**"
- "frontend/hooks/**"
- ".github/workflows/frontend-unit-tests.yml"
pull_request:
paths:
- "frontend/lib/**"
- "frontend/hooks/**"
- ".github/workflows/frontend-unit-tests.yml"
workflow_dispatch:

concurrency:
group: frontend-unit-${{ github.ref }}
cancel-in-progress: true

jobs:
unit:
name: Node.js unit tests
runs-on: ubuntu-latest
timeout-minutes: 10
defaults:
run:
working-directory: frontend

steps:
- uses: actions/checkout@v4

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
package_json_file: frontend/package.json

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Run unit tests
run: pnpm test:unit
80 changes: 80 additions & 0 deletions frontend/app/api/admin/audit-log/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from "next/server"
import { supabase } from "@/lib/supabase"
import { readLimiter } from "@/lib/rate-limit"

export interface AuditRow {
id: string
pool_id: string
pool_name: string | null
activity_type: string
user_address: string | null
amount: number | null
tx_hash: string | null
description: string | null
created_at: string
/** True when the DB total_saved disagrees with the activity sum for this pool. */
inconsistent: boolean
}

/**
* GET /api/admin/audit-log?poolId=<id>
*
* Returns all pool_activity rows for the given pool together with a
* per-pool consistency flag: `inconsistent` is true when the pool's
* recorded `total_saved` diverges from the sum of deposit/withdrawal
* activity rows by more than 0.01 (floating-point tolerance).
*
* Only the pool creator should call this route; the component enforces
* that guard on the client side. A missing poolId returns 400.
*/
export async function GET(req: NextRequest) {
const limited = readLimiter(req)
if (limited) return limited

const poolId = req.nextUrl.searchParams.get("poolId")
if (!poolId) {
return NextResponse.json({ error: "poolId is required" }, { status: 400 })
}

const { data: pool, error: poolErr } = await supabase
.from("pools")
.select("id, name, total_saved")
.eq("id", poolId)
.single()

if (poolErr || !pool) {
return NextResponse.json({ error: "Pool not found" }, { status: 404 })
}

const { data: activity, error: actErr } = await supabase
.from("pool_activity")
.select("id, pool_id, activity_type, user_address, amount, tx_hash, description, created_at")
.eq("pool_id", poolId)
.order("created_at", { ascending: false })

if (actErr) {
return NextResponse.json({ error: "Failed to fetch activity" }, { status: 500 })
}

const rows = activity ?? []

// ── Consistency check ──────────────────────────────────────────────────────
const activityNet = rows.reduce((sum: number, r: { activity_type: string; amount: number | null }) => {
const amt = r.amount ?? 0
const t = r.activity_type.toLowerCase()
if (t === "deposit") return sum + amt
if (t === "withdraw" || t === "payout") return sum - amt
return sum
}, 0)

const recorded = pool.total_saved ?? 0
const inconsistent = Math.abs(activityNet - recorded) > 0.01

const auditRows: AuditRow[] = rows.map((r: { id: string; pool_id: string; activity_type: string; user_address: string | null; amount: number | null; tx_hash: string | null; description: string | null; created_at: string }) => ({
...r,
pool_name: pool.name,
inconsistent,
}))

return NextResponse.json({ rows: auditRows, inconsistent, activityNet, recorded })
}
15 changes: 7 additions & 8 deletions frontend/app/dashboard/group/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { GroupMembers } from "@/components/group/group-members";
import { GroupActivity } from "@/components/group/group-activity";
import { GroupActions } from "@/components/group/group-actions";
import { YieldDashboard } from "@/components/group/yield-dashboard";
import { AdminAuditLog } from "@/components/group/admin-audit-log";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
Expand All @@ -15,17 +16,12 @@ import { useStellar } from "@/components/web3-provider";
import { useRecentPools } from "@/hooks/useRecentPools";

interface Pool {
id: string
name: string
type: 'rotational' | 'target' | 'flexible'
contract_address: string
token_address: string
creator_address: string
id: string;
name: string;
type: "rotational" | "target" | "flexible";
contract_address: string;
token_address: string;
creator_address: string;
}

const isPendingAddress = (addr: string) =>
Expand Down Expand Up @@ -66,7 +62,6 @@ export default function GroupPage({
contract_address: pool.contract_address,
});
}
// Reset tracker when navigating to a different pool
if (!pool || loading) {
trackedRef.current = false;
}
Expand Down Expand Up @@ -115,6 +110,11 @@ export default function GroupPage({
contractAddress={cacheKey}
startLedger={0}
/>
{/* Admin audit log with CSV export — only shown to the pool creator */}
<AdminAuditLog
groupId={id}
creatorAddress={pool.creator_address}
/>
</div>

<div className="space-y-6">
Expand Down Expand Up @@ -142,4 +142,3 @@ export default function GroupPage({
</div>
);
}

167 changes: 167 additions & 0 deletions frontend/components/group/admin-audit-log.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
"use client"

/**
* AdminAuditLog
*
* Shows the full pool activity log with a consistency-check banner and
* an Export CSV button. Only rendered for the pool creator.
*/

import { useState, useEffect } from "react"
import { Card } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { AlertTriangle, CheckCircle2, Download, Loader2 } from "lucide-react"
import { useStellar } from "@/components/web3-provider"
import { buildCsv, downloadCsv } from "@/lib/csv-export"
import type { AuditRow } from "@/app/api/admin/audit-log/route"

interface AdminAuditLogProps {
groupId: string
creatorAddress: string
}

export function AdminAuditLog({ groupId, creatorAddress }: AdminAuditLogProps) {
const { address } = useStellar()

const isCreator =
address &&
creatorAddress &&
address.toLowerCase() === creatorAddress.toLowerCase()

const [rows, setRows] = useState<AuditRow[]>([])
const [inconsistent, setInconsistent] = useState(false)
const [activityNet, setActivityNet] = useState(0)
const [recorded, setRecorded] = useState(0)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)

useEffect(() => {
if (!isCreator) return
setLoading(true)
fetch(`/api/admin/audit-log?poolId=${groupId}`)
.then((r) => r.json())
.then((data) => {
if (data.error) throw new Error(data.error)
setRows(data.rows)
setInconsistent(data.inconsistent)
setActivityNet(data.activityNet)
setRecorded(data.recorded)
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false))
}, [isCreator, groupId])

if (!isCreator) return null

const handleExport = () => {
const headers = [
"Date",
"Activity Type",
"User Address",
"Amount (XLM)",
"Tx Hash",
"Description",
]
const data = rows.map((r) => [
new Date(r.created_at).toISOString().slice(0, 19).replace("T", " "),
r.activity_type,
r.user_address ?? "",
r.amount != null ? r.amount.toFixed(2) : "",
r.tx_hash ?? "",
r.description ?? "",
])
const csv = buildCsv(headers, data)
downloadCsv(csv, `audit-log-${groupId}-${new Date().toISOString().slice(0, 10)}.csv`)
}

return (
<Card className="p-6 space-y-4">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h3 className="text-lg font-semibold">Admin Audit Log</h3>
<p className="text-xs text-muted-foreground">Visible to pool creator only</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleExport}
disabled={loading || rows.length === 0}
className="gap-2"
>
<Download className="h-4 w-4" />
Export CSV
</Button>
</div>

{/* Consistency banner */}
{!loading && !error && (
<div
className={`flex items-start gap-3 rounded-lg px-4 py-3 text-sm ${
inconsistent
? "bg-destructive/10 text-destructive"
: "bg-primary/10 text-primary"
}`}
>
{inconsistent ? (
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
) : (
<CheckCircle2 className="h-4 w-4 mt-0.5 shrink-0" />
)}
<span>
{inconsistent
? `Balance inconsistency: activity net ${activityNet.toFixed(2)} XLM ≠ recorded ${recorded.toFixed(2)} XLM`
: `Balance consistent: ${recorded.toFixed(2)} XLM`}
</span>
</div>
)}

{loading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
)}

{error && (
<p className="text-sm text-destructive">{error}</p>
)}

{!loading && !error && rows.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">No activity recorded.</p>
)}

{!loading && !error && rows.length > 0 && (
<div className="divide-y divide-border text-sm">
{rows.map((r) => (
<div key={r.id} className="py-3 flex items-start justify-between gap-4 flex-wrap">
<div className="space-y-0.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium capitalize">{r.activity_type}</span>
{r.amount != null && (
<Badge variant="secondary">{r.amount.toFixed(2)} XLM</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
{r.user_address ? `${r.user_address.slice(0, 8)}…${r.user_address.slice(-6)}` : "System"}
</p>
{r.tx_hash && (
<a
href={`https://stellar.expert/explorer/testnet/tx/${r.tx_hash}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline"
>
{r.tx_hash.slice(0, 8)}…
</a>
)}
</div>
<time className="text-xs text-muted-foreground whitespace-nowrap">
{new Date(r.created_at).toLocaleString()}
</time>
</div>
))}
</div>
)}
</Card>
)
}
Loading
Loading