diff --git a/CHANGELOG.md b/CHANGELOG.md index 910472b..652220a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ All notable changes to **JointSave** will be documented in this file. - **Pool notifications** for user-facing updates related to pool activity. - **Explore page** so users can discover available pools. - **User-facing transparency features** including transaction/pool visibility aligned with on-chain state. +- **Automated pool archival** — a daily job that moves completed, emergency-withdrawn, and genuinely dead pools out of Explore and My Groups into a read-only Archived view, with admin archive/unarchive controls and a full audit log. Nothing is deleted: archived pools keep all history and remain viewable and exportable, and on-chain data is untouched. See [docs/pool-archival.md](docs/pool-archival.md). ### Changed diff --git a/docs/pool-archival.md b/docs/pool-archival.md new file mode 100644 index 0000000..f930012 --- /dev/null +++ b/docs/pool-archival.md @@ -0,0 +1,214 @@ +# Pool Archival and Cleanup + +_Issue [#212](https://github.com/JointSave-org/Joint_Save/issues/212)_ + +As the platform grows, finished and abandoned pools accumulate in Explore and +My Groups. Archival moves those pools out of the default discovery views and +makes them read-only, without losing anything. + +**Archival never deletes data.** An archived pool keeps every row it owned — +metadata, members, activity, daily metrics, messages, disputes. Its detail +page still renders, its activity feed is still readable, and CSV/PDF export +still works. The on-chain contract is immutable and completely unaffected; +archival is a Supabase-side visibility flag and nothing more. Every archival +is reversible by the pool admin in one click. + +--- + +## Data model + +`supabase/migrations/20260828000000_pool_archival.sql` + +### `pools` — new columns + +| Column | Type | Notes | +| ------------------------ | ------------- | --------------------------------------------------------- | +| `archived_at` | `timestamptz` | Null for active pools. The single source of truth. | +| `archive_reason` | `text` | One of the four reasons below. Moves with `archived_at`. | +| `completed_at` | `timestamptz` | Anchors the 7-day completion grace period. | +| `emergency_withdrawn_at` | `timestamptz` | Anchors the 30-day emergency grace period. | + +`completed_at` and `emergency_withdrawn_at` did not exist before this feature. +Without them the grace periods could not be applied — a pool would be archived +the moment its status flipped. Existing completed pools are backfilled from +`updated_at` so the first sweep gives them a grace period rather than +archiving them all at once. `pools.status` also gained +`emergency_withdrawn` alongside `active`, `completed`, and `paused`. + +A `CHECK` constraint keeps `archived_at` and `archive_reason` in lockstep, so +no row can claim to be archived without saying why. + +### `archive_log` — audit trail + +One row per archive **and** per unarchive, automated or manual: + +| Column | Notes | +| -------------- | -------------------------------------------------------- | +| `pool_id` | FK to `pools`, cascades on delete | +| `action` | `archived` or `unarchived` | +| `reason` | Same vocabulary as `pools.archive_reason` | +| `triggered_by` | `cron`, or the wallet address that triggered it | +| `automated` | `true` for the daily sweep | +| `note` | Free text — the sweep records the age that qualified it | + +Public `SELECT` (why a pool was archived is part of its visible history), +service-role writes only, matching `pool_activity` and `disputes`. + +### Indexes + +Explore and My Groups both filter on `archived_at IS NULL`, so the default +queries are backed by **partial** indexes covering only the active set: + +- `idx_pools_active_created` — Explore feed +- `idx_pools_active_creator` — My Groups +- `idx_pools_archival_sweep` — the cron's own candidate scan +- `idx_pools_archived_at` — the Archived tab + +Because the partial indexes only ever hold unarchived rows, the default views +get *faster* as pools are archived, not slower. + +--- + +## Archival criteria + +Implemented as pure functions in `frontend/lib/archival.ts`, so they are +testable without a database (`frontend/lib/archival.test.ts`, 30 tests). + +| Reason | Condition | +| --------------------- | ---------------------------------------------------------------------------------- | +| `completed` | `status = 'completed'` and `completed_at` older than **7 days** | +| `emergency_withdrawn` | `status = 'emergency_withdrawn'` and `emergency_withdrawn_at` older than **30 days** | +| `inactive_90d` | `status = 'active'`, no `pool_activity` for **90 days**, **and** the pool holds no funds | +| `admin_archived` | Never automated — only ever set by the manual endpoint | + +### Why the inactivity rule checks the balance + +"No activity for 90 days" on its own is not enough. A pool sitting quietly on +real member deposits is not dead, it is waiting — and hiding it from the +people whose money is in it would be a trust problem, not a cleanup. So +`inactive_90d` additionally requires the pool to hold nothing: never funded, +or fully withdrawn. The balance is derived from the activity feed (deposits +minus withdrawals and payouts), the same aggregation `/api/analytics` and the +metrics cron use, so all three agree on what "empty" means. + +Two more guards in the same spirit: + +- **Paused pools are exempt.** Pausing is a deliberate admin decision, and the + sweep must not quietly undo it. +- **Unparseable or future timestamps fail closed** — a bad date keeps a pool + visible rather than hiding it. + +--- + +## The daily sweep + +`POST` / `GET /api/cron/archive-pools`, scheduled in `vercel.json` at +**02:00 UTC**. Vercel Cron issues a `GET`, so `GET` is the real handler and +`POST` delegates to it for manual runs. + +Each run: + +1. Selects unarchived pools and their activity in two queries (not one query + per pool). +2. Applies `evaluateArchival` to each. +3. Sets `archived_at` / `archive_reason`, re-asserting `archived_at IS NULL` + in the `WHERE` so a manual archive landing mid-run is not overwritten. +4. Writes an `archive_log` row. +5. Notifies the pool admin with the reason in plain language and how to + restore the pool. +6. Heartbeats into `cron_job_logs`, so it appears in `/api/cron/health`. + +**Idempotent** — archived pools are excluded from the query *and* re-checked +by `evaluateArchival`, so a double run is a no-op. + +**Safety valve** — a run that wants to archive more than 200 pools stops and +reports instead. At that volume a bad backfill or a clock problem is likelier +than a genuine cliff of dead pools, and the failure mode would be an emptied +Explore page. + +`archive_log` and notification failures are collected and returned, not +thrown: neither should roll back an archival that already succeeded. + +Auth is the same `Bearer ${CRON_SECRET}` header the other crons use. + +### Running it by hand + +```bash +curl -X POST https:///api/cron/archive-pools \ + -H "Authorization: Bearer $CRON_SECRET" +``` + +Response: + +```json +{ "scanned": 412, "archived": 7, "byReason": { "completed": 5, "inactive_90d": 2 }, "errors": [] } +``` + +--- + +## API + +| Endpoint | Notes | +| --------------------------------- | ----------------------------------------------------------------- | +| `GET /api/pools?archived=` | Omitted → active only (default). `true` → both. `only` → archived only. | +| `PUT /api/pools/[id]/archive` | `{ admin_address, reason?, note? }` — pool creator only | +| `PUT /api/pools/[id]/unarchive` | `{ admin_address, note? }` — pool creator only | + +The `archived` param applies to all four list branches of `GET /api/pools` +(explore, creator, member, fallback). The creator and explore branches filter +in the query so the partial indexes are used; the member branch filters after +the join, because `pools` is an embedded relation PostgREST cannot filter +without dropping the membership rows being paginated. + +### Read-only enforcement + +Hiding buttons is presentation, not enforcement. `lib/server/archival-guard.ts` +blocks writes aimed at an archived pool at the API boundary, returning `409` +with the archival reason: + +- `PATCH /api/pools` (field updates and activity logging) +- `POST /api/pools/deposit` +- `POST /api/pools/messages` + +A stale tab, a bookmarked request, or a direct `curl` therefore cannot mutate +an archived pool. `GET` paths are deliberately untouched — archived pools stay +fully readable and exportable. + +--- + +## UI + +- **Explore** — a "Show archived" switch, off by default. Archived pools + render as grayed-out compact cards with an "Archived" badge. +- **My Groups** — "Active" and "Archived" tabs. The archived tab fetches + lazily, only once opened, and pages independently of the active tab. +- **`components/shared/archived-pool-card.tsx`** — the compact row: name, + type, badge, reason, completion date, member count, final TVL, and + "View History". It skips the on-chain read, health badge, and sparkline that + `PoolCard` performs — an archived pool's numbers are final, so per-card RPC + calls would buy nothing and would make a long archived list expensive. +- **Pool detail page** — an archived pool leads with a banner stating that it + is archived, why, and that nothing was lost. The actions column, lending + tab, and yield dashboard do not mount at all; details, members, activity, + audit logs, and export are unchanged, with the activity feed labelled + historical. Admins see "Restore pool" in the banner, and a confirmed + "Archive pool" control on active pools. + +Tab and toggle state live in the URL, so an archived view survives a refresh +and a back navigation from a pool's history page. + +All strings are translated in `messages/en.json` and `messages/es.json`. + +--- + +## Rollback + +The feature is a visibility layer, so backing it out is low-risk: + +1. Remove the `/api/cron/archive-pools` entry from `vercel.json` to stop new + archivals. +2. `UPDATE public.pools SET archived_at = NULL, archive_reason = NULL;` + restores every pool to discovery. `archive_log` retains the history of what + had been archived and why. + +The columns and table can be left in place — they are additive and nullable. diff --git a/frontend/__tests__/archived-pool.test.tsx b/frontend/__tests__/archived-pool.test.tsx new file mode 100644 index 0000000..a86d8f0 --- /dev/null +++ b/frontend/__tests__/archived-pool.test.tsx @@ -0,0 +1,145 @@ +import React from "react" +import { render, screen, waitFor } from "@/test-utils" +import GroupPage from "@/app/[locale]/dashboard/group/[id]/page" +import { ArchivedPoolCard } from "@/components/shared/archived-pool-card" +import { vi, describe, it, expect, beforeEach } from "vitest" + +vi.mock("@/hooks/useJointSaveContracts") + +const BASE_POOL = { + id: "pool-123", + name: "Community Savings", + type: "flexible" as const, + contract_address: "CBZNGP52FLFZ4BOGC265FUAMP5KFMAYPQK3KTI5UHMYVMM3QCST3IMRI", + token_address: "native", + creator_address: "GBX1234567890TESTADDRESS", +} + +function mockPoolFetch(pool: Record) { + global.fetch = vi.fn().mockImplementation(async (url: string) => { + if (typeof url === "string" && (url.includes("/admin/") || url.includes("activity"))) { + return { ok: true, json: async () => [] } + } + return { ok: true, json: async () => pool } + }) +} + +// ── Archived pool detail page ───────────────────────────────────────────────── + +describe("Archived pool detail page", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("shows the archived banner with the archival reason", async () => { + mockPoolFetch({ + ...BASE_POOL, + archived_at: "2026-08-01T00:00:00.000Z", + archive_reason: "inactive_90d", + }) + + render() + + await waitFor(() => { + expect(screen.getByText("This pool has been archived")).toBeInTheDocument() + }) + expect( + screen.getByText("Archived after 90 days with no activity and no member funds held.") + ).toBeInTheDocument() + }) + + it("hides the actions panel so no deposit, withdraw, or pause control renders", async () => { + mockPoolFetch({ + ...BASE_POOL, + archived_at: "2026-08-01T00:00:00.000Z", + archive_reason: "completed", + }) + + render() + + await waitFor(() => { + expect(screen.getByText("This pool has been archived")).toBeInTheDocument() + }) + + // The whole actions column is gone rather than each button being disabled. + expect(screen.queryByText("Quick Actions")).not.toBeInTheDocument() + expect(screen.queryByRole("button", { name: /deposit/i })).not.toBeInTheDocument() + expect(screen.queryByRole("button", { name: /withdraw/i })).not.toBeInTheDocument() + expect(screen.queryByRole("button", { name: /pause/i })).not.toBeInTheDocument() + }) + + it("marks the activity feed as historical", async () => { + mockPoolFetch({ + ...BASE_POOL, + archived_at: "2026-08-01T00:00:00.000Z", + archive_reason: "completed", + }) + + render() + + await waitFor(() => { + expect( + screen.getByText("Historical activity — this pool is archived and receives no new events.") + ).toBeInTheDocument() + }) + }) + + it("keeps the actions panel on a pool that is not archived", async () => { + mockPoolFetch({ ...BASE_POOL, archived_at: null, archive_reason: null }) + + render() + + // The counterpart to the test above: proves the actions panel really does + // render here, so its absence on an archived pool is the archival gate + // rather than a mock that never mounts it. + await waitFor(() => { + expect(screen.getByText(/Quick Actions/i)).toBeInTheDocument() + }) + expect(screen.queryByText("This pool has been archived")).not.toBeInTheDocument() + }) +}) + +// ── Archived pool card ──────────────────────────────────────────────────────── + +describe("ArchivedPoolCard", () => { + const pool = { + id: "pool-9", + name: "Lagos Circle", + type: "rotational" as const, + archived_at: "2026-08-20T00:00:00.000Z", + archive_reason: "completed" as const, + completed_at: "2026-08-10T00:00:00.000Z", + total_saved: 1250.5, + members_count: 8, + token_symbol: "USDC", + } + + it("renders the name, archived badge, reason, final TVL, and member count", () => { + render() + + expect(screen.getByText("Lagos Circle")).toBeInTheDocument() + expect(screen.getByText("Archived")).toBeInTheDocument() + expect( + screen.getByText("This pool finished and was archived after its review window.") + ).toBeInTheDocument() + expect(screen.getByText("1250.50 USDC")).toBeInTheDocument() + expect(screen.getByText("8 members")).toBeInTheDocument() + }) + + it("links View History to the pool detail page", () => { + render() + + const link = screen.getByRole("link", { name: /view history/i }) + expect(link).toHaveAttribute("href", expect.stringContaining("/dashboard/group/pool-9")) + }) + + it("falls back to the archived date when the pool never completed", () => { + render( + + ) + + expect( + screen.getByText("Archived after 90 days with no activity and no member funds held.") + ).toBeInTheDocument() + }) +}) diff --git a/frontend/app/[locale]/dashboard/group/[id]/page.tsx b/frontend/app/[locale]/dashboard/group/[id]/page.tsx index dc0e7a4..a752026 100644 --- a/frontend/app/[locale]/dashboard/group/[id]/page.tsx +++ b/frontend/app/[locale]/dashboard/group/[id]/page.tsx @@ -7,6 +7,7 @@ import { DashboardHeader } from "@/components/dashboard/dashboard-header" import { GroupDetails } from "@/components/group/group-details" import { GroupMembers } from "@/components/group/group-members" import { GroupActions } from "@/components/group/group-actions" +import { ArchivedPoolBanner, ArchivePoolButton } from "@/components/group/archived-pool-banner" import { Button } from "@/components/ui/button" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { ArrowLeft, LayoutDashboard, HandCoins } from "lucide-react" @@ -15,6 +16,7 @@ import { fetchIsPaused, fetchPoolAdmin } from "@/hooks/useJointSaveContracts" import { useStellar } from "@/components/web3-provider" import { useRecentPools } from "@/hooks/useRecentPools" import { ErrorBoundary, SectionErrorBoundary } from "@/components/error-boundary" +import type { ArchiveReason } from "@/lib/archival" import { GroupActivitySkeleton, YieldDashboardSkeleton, @@ -73,6 +75,8 @@ interface Pool { creator_address: string /** Pool member list returned by /api/pools — used for lending eligibility checks */ pool_members?: { member_address: string }[] + archived_at?: string | null + archive_reason?: ArchiveReason | null } const isPendingAddress = (addr: string) => !addr || addr === "pending_deployment" @@ -83,6 +87,7 @@ export default function GroupPage({ params: Promise<{ id: string }> | { id: string } }) { const t = useTranslations("group.page") + const tArchived = useTranslations("group.archived") // Support both async params (Next 15+) and plain object params const resolvedParams = params instanceof Promise ? use(params) : (params as { id: string }) const id = resolvedParams.id @@ -94,7 +99,7 @@ export default function GroupPage({ const [poolAdmin, setPoolAdmin] = useState(null) const trackedRef = useRef(false) - useEffect(() => { + const loadPool = useCallback(() => { fetch(`/api/pools?id=${id}`) .then((res) => res.json()) .then((data) => { @@ -104,6 +109,10 @@ export default function GroupPage({ .catch(() => setLoading(false)) }, [id]) + useEffect(() => { + loadPool() + }, [loadPool]) + // Track visit when pool data loads useEffect(() => { if (pool && !loading && !trackedRef.current) { @@ -156,6 +165,12 @@ export default function GroupPage({ (poolAdmin?.toLowerCase() === address.toLowerCase() || pool.creator_address?.toLowerCase() === address.toLowerCase()) + // An archived pool is read-only: the whole actions column and the lending + // marketplace come off, rather than each button being disabled individually. + // The API refuses these writes too (lib/server/archival-guard.ts) — hiding + // the controls alone would not be enforcement. + const archived = !!pool.archived_at + return (
@@ -168,6 +183,17 @@ export default function GroupPage({ + {archived && ( + + )} + {/* Top-level Overview / Lending tabs */} @@ -175,10 +201,12 @@ export default function GroupPage({ {t("overview")} - - - {t("lending")} - + {!archived && ( + + + {t("lending")} + + )} {/* ── Overview tab — existing pool content ────────────────── */} @@ -195,6 +223,11 @@ export default function GroupPage({ sectionName={t("sectionActivityFeed")} walletAddress={address} > + {archived && ( +

+ {tArchived("historicalActivity")} +

+ )} {/* Admin audit log with CSV export — only shown to the pool creator */} @@ -211,22 +244,33 @@ export default function GroupPage({
- - - - {pool.type === "flexible" && ( + {!archived && ( + + + {isAdmin && ( +
+ +
+ )} +
+ )} + {pool.type === "flexible" && !archived && ( {/* ── Lending tab — P2P microloan marketplace ──────────────── */} - - - - - + {/* Archived pools take no new loans, so the tab comes off entirely + rather than rendering a marketplace whose actions would 409. */} + {!archived && ( + + + + + + )}
diff --git a/frontend/app/api/cron/archive-pools/route.ts b/frontend/app/api/cron/archive-pools/route.ts new file mode 100644 index 0000000..d924a22 --- /dev/null +++ b/frontend/app/api/cron/archive-pools/route.ts @@ -0,0 +1,214 @@ +/** + * /api/cron/archive-pools — daily pool archival sweep (issue #212) + * + * Scheduled by Vercel Cron at 02:00 UTC (see vercel.json). Vercel Cron issues + * a GET, so GET is the real handler and POST delegates to it for manual runs + * and for the endpoint shape the issue specifies. + * + * The sweep applies the criteria in lib/archival.ts, sets archived_at and + * archive_reason on matching pools, writes an archive_log row for each, and + * notifies the pool admin. It deletes nothing: archived pools keep every + * member, activity, and metric row, stay readable and exportable, and their + * on-chain contracts are untouched. + * + * Idempotent — already-archived pools are excluded from the query and + * re-checked by evaluateArchival, so a double run is a no-op. + */ + +import { NextRequest, NextResponse } from "next/server" +import { getAdminClient } from "@/lib/supabase-admin" +import { + evaluateArchival, + latestActivityAt, + netBalanceFromActivity, + type ArchivalCandidate, + type ArchiveReason, +} from "@/lib/archival" + +/** + * Safety valve. A sweep that wanted to archive more than this in one run is + * more likely a data problem — a bad backfill, a clock skew — than a real + * cliff of dead pools, so it stops and reports instead of emptying Explore. + */ +const MAX_ARCHIVALS_PER_RUN = 200 + +interface PoolRow { + id: string + name: string + status: string + creator_address: string + archived_at: string | null + completed_at: string | null + emergency_withdrawn_at: string | null + created_at: string +} + +interface ActivityRow { + pool_id: string + activity_type: string | null + amount: number | null + created_at: string +} + +const REASON_MESSAGES: Record = { + completed: "it finished and the 7-day review window has passed", + inactive_90d: "it had no activity for 90 days and holds no member funds", + emergency_withdrawn: "funds were withdrawn in an emergency over 30 days ago", + admin_archived: "an admin archived it", +} + +export async function GET(req: NextRequest) { + const authHeader = req.headers.get("authorization") + if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const admin = getAdminClient() + const now = Date.now() + const nowIso = new Date(now).toISOString() + + // Only unarchived pools are candidates — this is the query the + // idx_pools_archival_sweep partial index exists for. + const { data: pools, error: poolsError } = await admin + .from("pools") + .select( + "id, name, status, creator_address, archived_at, completed_at, emergency_withdrawn_at, created_at" + ) + .is("archived_at", null) + .returns() + + if (poolsError || !pools) { + await logRun(admin, "failed", poolsError?.message ?? "Failed to fetch pools") + return NextResponse.json({ error: "Failed to fetch pools" }, { status: 500 }) + } + + if (pools.length === 0) { + await logRun(admin, "success", null) + return NextResponse.json( + { scanned: 0, archived: 0, byReason: {}, errors: [] }, + { headers: { "Cache-Control": "private, no-cache" } } + ) + } + + // One activity read for the whole sweep rather than a query per pool: the + // per-pool loop in snapshot-pool-metrics is already the slowest cron here, + // and this one runs over the same table. + const { data: activities, error: activityError } = await admin + .from("pool_activity") + .select("pool_id, activity_type, amount, created_at") + .in( + "pool_id", + pools.map((p) => p.id) + ) + .returns() + + if (activityError) { + await logRun(admin, "failed", activityError.message) + return NextResponse.json({ error: "Failed to fetch pool activity" }, { status: 500 }) + } + + const activityByPool = new Map() + for (const activity of activities ?? []) { + const bucket = activityByPool.get(activity.pool_id) + if (bucket) bucket.push(activity) + else activityByPool.set(activity.pool_id, [activity]) + } + + const candidates: { pool: PoolRow; reason: ArchiveReason; note: string }[] = [] + + for (const pool of pools) { + const poolActivity = activityByPool.get(pool.id) ?? [] + const candidate: ArchivalCandidate = { + id: pool.id, + status: pool.status, + archived_at: pool.archived_at, + completed_at: pool.completed_at, + emergency_withdrawn_at: pool.emergency_withdrawn_at, + last_activity_at: latestActivityAt(poolActivity), + created_at: pool.created_at, + net_balance: netBalanceFromActivity(poolActivity), + } + + const decision = evaluateArchival(candidate, now) + if (decision.archive && decision.reason) { + candidates.push({ pool, reason: decision.reason, note: decision.note ?? "" }) + } + } + + if (candidates.length > MAX_ARCHIVALS_PER_RUN) { + const message = `Refusing to archive ${candidates.length} pools in one run (limit ${MAX_ARCHIVALS_PER_RUN})` + await logRun(admin, "warning", message) + return NextResponse.json( + { scanned: pools.length, archived: 0, byReason: {}, errors: [message] }, + { status: 409 } + ) + } + + const errors: string[] = [] + const byReason: Partial> = {} + let archived = 0 + + for (const { pool, reason, note } of candidates) { + const { error: updateError } = await admin + .from("pools") + .update({ archived_at: nowIso, archive_reason: reason }) + // Re-assert the unarchived precondition so a manual archive landing + // between the scan and this write cannot be overwritten. + .eq("id", pool.id) + .is("archived_at", null) + + if (updateError) { + errors.push(`${pool.id}: ${updateError.message}`) + continue + } + + archived++ + byReason[reason] = (byReason[reason] ?? 0) + 1 + + const { error: logError } = await admin.from("archive_log").insert({ + pool_id: pool.id, + action: "archived", + reason, + triggered_by: "cron", + automated: true, + note, + }) + if (logError) errors.push(`${pool.id} (log): ${logError.message}`) + + // Summary notification to the pool admin. A failure here must not undo a + // successful archival, so it is recorded and the sweep continues. + const { error: notifyError } = await admin.from("notifications").insert({ + wallet_address: pool.creator_address?.toLowerCase() ?? "", + pool_id: pool.id, + activity_type: "pool_archived", + message: `"${pool.name}" was archived because ${REASON_MESSAGES[reason]}. Its history is still available under the Archived tab, and you can restore it from the pool page.`, + read: false, + }) + if (notifyError) errors.push(`${pool.id} (notify): ${notifyError.message}`) + } + + await logRun(admin, errors.length > 0 ? "warning" : "success", errors[0] ?? null) + + return NextResponse.json( + { scanned: pools.length, archived, byReason, errors }, + { headers: { "Cache-Control": "private, no-cache" } } + ) +} + +/** Manual/scripted trigger — the issue specifies POST for this endpoint. */ +export async function POST(req: NextRequest) { + return GET(req) +} + +async function logRun( + admin: ReturnType, + status: "success" | "warning" | "failed", + errorMessage: string | null +) { + const { error } = await admin.from("cron_job_logs").insert({ + job_name: "archive-pools", + status, + error_message: errorMessage, + }) + if (error) console.error("Failed to log archive-pools run:", error.message) +} diff --git a/frontend/app/api/pools/[id]/archive/route.ts b/frontend/app/api/pools/[id]/archive/route.ts new file mode 100644 index 0000000..1cd7cf2 --- /dev/null +++ b/frontend/app/api/pools/[id]/archive/route.ts @@ -0,0 +1,103 @@ +/** + * /api/pools/[id]/archive — manual pool archival (issue #212) + * + * PUT { admin_address, reason?, note? } + * + * Lets the pool creator archive a pool ahead of the daily sweep — a circle + * that wound down early, a duplicate, a test pool. Nothing is deleted: the + * pool keeps every member, activity, and metric row it owned, and its + * on-chain contract is unaffected. The pool simply leaves Explore and the + * active My Groups tab and becomes read-only. + */ + +import { NextRequest, NextResponse } from "next/server" +import { getAdminClient } from "@/lib/supabase-admin" +import { writeLimiter } from "@/lib/rate-limit" +import { isArchiveReason, type ArchiveReason } from "@/lib/archival" + +export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + const limited = writeLimiter(req) + if (limited) return limited + + const { id } = await ctx.params + + let body: { admin_address?: string; reason?: string; note?: string } + try { + body = await req.json() + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }) + } + + const adminAddress = + typeof body.admin_address === "string" ? body.admin_address.toLowerCase() : "" + const note = typeof body.note === "string" ? body.note.trim() || null : null + + if (!id || !adminAddress) { + return NextResponse.json({ error: "pool id and admin_address are required" }, { status: 400 }) + } + + // A manual archival is `admin_archived` unless the admin names a specific + // reason — the same vocabulary the cron writes, so the log stays uniform. + const reason: ArchiveReason = isArchiveReason(body.reason) ? body.reason : "admin_archived" + if (body.reason !== undefined && !isArchiveReason(body.reason)) { + return NextResponse.json( + { + error: + "reason must be one of: completed, inactive_90d, admin_archived, emergency_withdrawn", + }, + { status: 422 } + ) + } + + const admin = getAdminClient() + + const { data: pool } = await admin + .from("pools") + .select("id, name, creator_address, archived_at") + .eq("id", id) + .maybeSingle() + + if (!pool) return NextResponse.json({ error: "Pool not found" }, { status: 404 }) + + if (pool.creator_address?.toLowerCase() !== adminAddress) { + return NextResponse.json( + { error: "Only the pool admin can archive this pool" }, + { status: 403 } + ) + } + + if (pool.archived_at) { + return NextResponse.json({ error: "Pool is already archived" }, { status: 409 }) + } + + const archivedAt = new Date().toISOString() + + const { data: updated, error: updateError } = await admin + .from("pools") + .update({ archived_at: archivedAt, archive_reason: reason }) + .eq("id", id) + .select("*") + .single() + + if (updateError) return NextResponse.json({ error: updateError.message }, { status: 500 }) + + await admin.from("archive_log").insert({ + pool_id: id, + action: "archived", + reason, + triggered_by: adminAddress, + automated: false, + note, + }) + + // Mirrored into the activity feed so members see why the pool went quiet + // rather than finding it silently gone from their list. + await admin.from("pool_activity").insert({ + pool_id: id, + activity_type: "pool_archived", + user_address: adminAddress, + description: note ? `Pool archived by admin: ${note}` : "Pool archived by admin", + }) + + return NextResponse.json({ success: true, pool: updated }) +} diff --git a/frontend/app/api/pools/[id]/unarchive/route.ts b/frontend/app/api/pools/[id]/unarchive/route.ts new file mode 100644 index 0000000..0838f3c --- /dev/null +++ b/frontend/app/api/pools/[id]/unarchive/route.ts @@ -0,0 +1,89 @@ +/** + * /api/pools/[id]/unarchive — reverse a pool archival (issue #212) + * + * PUT { admin_address, note? } + * + * The escape hatch for a false positive: an admin returning to a pool the + * daily sweep hid, or undoing their own manual archival. Clears archived_at + * and archive_reason so the pool returns to Explore and the active My Groups + * tab, and logs the reversal to archive_log alongside the archival it undoes. + */ + +import { NextRequest, NextResponse } from "next/server" +import { getAdminClient } from "@/lib/supabase-admin" +import { writeLimiter } from "@/lib/rate-limit" +import type { ArchiveReason } from "@/lib/archival" + +export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + const limited = writeLimiter(req) + if (limited) return limited + + const { id } = await ctx.params + + let body: { admin_address?: string; note?: string } + try { + body = await req.json() + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }) + } + + const adminAddress = + typeof body.admin_address === "string" ? body.admin_address.toLowerCase() : "" + const note = typeof body.note === "string" ? body.note.trim() || null : null + + if (!id || !adminAddress) { + return NextResponse.json({ error: "pool id and admin_address are required" }, { status: 400 }) + } + + const admin = getAdminClient() + + const { data: pool } = await admin + .from("pools") + .select("id, name, creator_address, archived_at, archive_reason") + .eq("id", id) + .maybeSingle() + + if (!pool) return NextResponse.json({ error: "Pool not found" }, { status: 404 }) + + if (pool.creator_address?.toLowerCase() !== adminAddress) { + return NextResponse.json( + { error: "Only the pool admin can unarchive this pool" }, + { status: 403 } + ) + } + + if (!pool.archived_at) { + return NextResponse.json({ error: "Pool is not archived" }, { status: 409 }) + } + + // Carried onto the log row so the reversal records what it undid — an + // unarchive with no reason of its own would lose that context. + const previousReason = (pool.archive_reason as ArchiveReason | null) ?? "admin_archived" + + const { data: updated, error: updateError } = await admin + .from("pools") + .update({ archived_at: null, archive_reason: null }) + .eq("id", id) + .select("*") + .single() + + if (updateError) return NextResponse.json({ error: updateError.message }, { status: 500 }) + + await admin.from("archive_log").insert({ + pool_id: id, + action: "unarchived", + reason: previousReason, + triggered_by: adminAddress, + automated: false, + note, + }) + + await admin.from("pool_activity").insert({ + pool_id: id, + activity_type: "pool_unarchived", + user_address: adminAddress, + description: note ? `Pool restored from archive: ${note}` : "Pool restored from archive", + }) + + return NextResponse.json({ success: true, pool: updated }) +} diff --git a/frontend/app/api/pools/deposit/route.ts b/frontend/app/api/pools/deposit/route.ts index 4fe425d..3b85dc5 100644 --- a/frontend/app/api/pools/deposit/route.ts +++ b/frontend/app/api/pools/deposit/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server" import { supabase } from "@/lib/supabase" import { writeLimiter } from "@/lib/rate-limit" +import { blockIfArchived } from "@/lib/server/archival-guard" const HORIZON_URL = process.env.NEXT_PUBLIC_STELLAR_HORIZON_URL || "https://horizon-testnet.stellar.org" @@ -32,6 +33,11 @@ export async function POST(req: NextRequest) { ) } + // An archived pool takes no new deposits — refuse before spending a + // Horizon round trip on a transaction that must not be recorded anyway. + const archived = await blockIfArchived(poolId) + if (archived) return archived + // 1. Verify the transaction exists and succeeded on Horizon. let tx: { successful?: boolean } try { diff --git a/frontend/app/api/pools/messages/route.ts b/frontend/app/api/pools/messages/route.ts index 4196c7e..e59e67c 100644 --- a/frontend/app/api/pools/messages/route.ts +++ b/frontend/app/api/pools/messages/route.ts @@ -16,6 +16,7 @@ import { NextRequest, NextResponse } from "next/server" import { readLimiter } from "@/lib/rate-limit" import { CHAT_MESSAGE_MAX_LENGTH, CHAT_RATE_LIMIT_MS } from "@/lib/constants" import { jsonPublic } from "@/lib/cache-headers" +import { blockIfArchived } from "@/lib/server/archival-guard" const PAGE_SIZE = 50 @@ -123,6 +124,11 @@ export async function POST(req: NextRequest) { ) } + // The chat closes with the pool: an archived pool's history stays readable + // (GET is untouched) but takes no new messages. + const archived = await blockIfArchived(pool_id) + if (archived) return archived + // DB-backed per-sender rate limit — safe across all serverless instances const waitMs = await getRateLimitWaitMs(pool_id, wallet) if (waitMs > 0) { diff --git a/frontend/app/api/pools/route.ts b/frontend/app/api/pools/route.ts index fdcac74..027deb5 100644 --- a/frontend/app/api/pools/route.ts +++ b/frontend/app/api/pools/route.ts @@ -2,6 +2,7 @@ import { supabase, savePoolToDatabase } from "@/lib/supabase" import { NextRequest, NextResponse } from "next/server" import { readLimiter, writeLimiter } from "@/lib/rate-limit" import { jsonPublic, jsonPrivate } from "@/lib/cache-headers" +import { blockIfArchived } from "@/lib/server/archival-guard" export async function POST(req: NextRequest) { try { @@ -104,6 +105,13 @@ export async function GET(req: NextRequest) { const creatorAddress = req.nextUrl.searchParams.get("creator") const contractAddress = req.nextUrl.searchParams.get("contract") const memberAddress = req.nextUrl.searchParams.get("member") + // Archived pools are excluded from every list view unless asked for. + // `archived=true` includes them alongside active ones; `archived=only` + // returns just the archived set, which is what the My Groups "Archived" + // tab and the Explore toggle use. + const archivedParam = req.nextUrl.searchParams.get("archived") + const includeArchived = archivedParam === "true" || archivedParam === "only" + const archivedOnly = archivedParam === "only" if (poolId) { // Fetch single pool by ID @@ -188,6 +196,14 @@ export async function GET(req: NextRequest) { const pools = (data || []) .map((row: { pools: unknown }) => row.pools) .filter((pool): pool is Record => !!pool) + // Filtered here rather than in the query: `pools` is an embedded + // relation, so PostgREST cannot filter it without dropping the + // membership rows the caller is paginating over. + .filter((pool) => { + const isArchived = !!pool.archived_at + if (archivedOnly) return isArchived + return includeArchived || !isArchived + }) // Wallet-scoped, like the `creator=` branch — never shared-cached. return jsonPrivate({ data: pools, total: pools.length }) @@ -197,10 +213,15 @@ export async function GET(req: NextRequest) { const from = page * PAGE_SIZE const to = from + PAGE_SIZE - 1 - const { data, error, count } = await supabase + let query = supabase .from("pools") .select("*", { count: "exact" }) .eq("creator_address", creatorAddress.toLowerCase()) + + if (archivedOnly) query = query.not("archived_at", "is", null) + else if (!includeArchived) query = query.is("archived_at", null) + + const { data, error, count } = await query .order("created_at", { ascending: false }) .range(from, to) @@ -216,9 +237,12 @@ export async function GET(req: NextRequest) { const from = page * PAGE_SIZE const to = from + PAGE_SIZE - 1 - const { data, error, count } = await supabase - .from("pools") - .select("*", { count: "exact" }) + let query = supabase.from("pools").select("*", { count: "exact" }) + + if (archivedOnly) query = query.not("archived_at", "is", null) + else if (!includeArchived) query = query.is("archived_at", null) + + const { data, error, count } = await query .order("created_at", { ascending: false }) .range(from, to) @@ -229,11 +253,12 @@ export async function GET(req: NextRequest) { return jsonPublic({ data: data || [], total: count ?? 0, page, pageSize: PAGE_SIZE }) } else { // Fetch all pools - const { data, error } = await supabase - .from("pools") - .select("*") - .order("created_at", { ascending: false }) - .limit(50) + let query = supabase.from("pools").select("*") + + if (archivedOnly) query = query.not("archived_at", "is", null) + else if (!includeArchived) query = query.is("archived_at", null) + + const { data, error } = await query.order("created_at", { ascending: false }).limit(50) if (error) { throw error @@ -261,6 +286,12 @@ export async function PATCH(req: NextRequest) { return NextResponse.json({ error: "Pool ID required" }, { status: 400 }) } + // Archived pools are read-only. Enforced here and not only in the UI, so a + // stale tab or a direct request cannot write to a pool that has left + // discovery. Un-archiving goes through PUT /api/pools/[id]/unarchive. + const archived = await blockIfArchived(poolId) + if (archived) return archived + // If body contains an `activity` object, log it to pool_activity if (body.activity) { const { activity_type, user_address, amount, token_amount, tx_hash } = body.activity diff --git a/frontend/components/dashboard/explore.tsx b/frontend/components/dashboard/explore.tsx index 86f9a0d..0e2d641 100644 --- a/frontend/components/dashboard/explore.tsx +++ b/frontend/components/dashboard/explore.tsx @@ -10,13 +10,19 @@ import { PaginationPrevious, } from "@/components/ui/pagination" import { Compass } from "lucide-react" +import { Label } from "@/components/ui/label" +import { Switch } from "@/components/ui/switch" import { motion } from "framer-motion" import { useState, useEffect, useCallback } from "react" import { useRouter, useSearchParams } from "next/navigation" import { PoolCard, PoolCardSkeleton, type Pool } from "@/components/dashboard/pool-card" +import { ArchivedPoolCard, type ArchivedPool } from "@/components/shared/archived-pool-card" const PAGE_SIZE = 6 +/** Explore rows carry the archival columns so a card can render either way. */ +type ExplorePool = Pool & Partial> + const container = { hidden: { opacity: 0 }, show: { opacity: 1, transition: { staggerChildren: 0.1 } }, @@ -27,13 +33,15 @@ export function Explore() { const router = useRouter() const searchParams = useSearchParams() - const [pools, setPools] = useState([]) + const [pools, setPools] = useState([]) const [total, setTotal] = useState(0) const [loading, setLoading] = useState(true) const [error, setError] = useState("") // Use a dedicated query param so it doesn't collide with My Groups pagination. const page = Math.max(0, parseInt(searchParams.get("explorePage") || "0", 10)) + // Off by default — archived pools are excluded from discovery unless asked for. + const showArchived = searchParams.get("showArchived") === "true" const totalPages = Math.ceil(total / PAGE_SIZE) const setPage = useCallback( @@ -45,18 +53,32 @@ export function Explore() { [router, searchParams] ) + const toggleArchived = useCallback( + (next: boolean) => { + const params = new URLSearchParams(searchParams.toString()) + if (next) params.set("showArchived", "true") + else params.delete("showArchived") + // The archived set changes the result count, so paging restarts. + params.set("explorePage", "0") + router.push(`?${params.toString()}`, { scroll: false }) + }, + [router, searchParams] + ) + useEffect(() => { loadPools(page) - }, [page]) + }, [page, showArchived]) const loadPools = async (currentPage: number) => { try { setLoading(true) setError("") - const res = await fetch(`/api/pools?explore=true&page=${currentPage}`) + const res = await fetch( + `/api/pools?explore=true&page=${currentPage}${showArchived ? "&archived=true" : ""}` + ) if (!res.ok) throw new Error(t("fetchError")) const json = await res.json() - const data: Pool[] = Array.isArray(json) ? json : (json.data ?? []) + const data: ExplorePool[] = Array.isArray(json) ? json : (json.data ?? []) setPools(data) setTotal(json.total ?? data.length) } catch (err) { @@ -105,9 +127,27 @@ export function Explore() { initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5 }} + className="flex flex-wrap items-start justify-between gap-3" > -

{t("title")}

-

{t("poolCount", { count: total })}

+
+

{t("title")}

+

{t("poolCount", { count: total })}

+
+ +
+ + +
{pools.length === 0 ? ( @@ -126,9 +166,21 @@ export function Explore() { animate="show" className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6" > - {pools.map((pool) => ( - - ))} + {pools.map((pool) => + pool.archived_at ? ( + + ) : ( + + ) + )} {totalPages > 1 && ( diff --git a/frontend/components/dashboard/my-groups.tsx b/frontend/components/dashboard/my-groups.tsx index f841454..7b2244b 100644 --- a/frontend/components/dashboard/my-groups.tsx +++ b/frontend/components/dashboard/my-groups.tsx @@ -11,7 +11,8 @@ import { PaginationNext, PaginationPrevious, } from "@/components/ui/pagination" -import { LayoutGrid, Search, CalendarDays } from "lucide-react" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { LayoutGrid, Search, CalendarDays, Archive } from "lucide-react" import { motion } from "framer-motion" import { useTranslations } from "next-intl" import { useState, useEffect, useCallback } from "react" @@ -24,6 +25,7 @@ import { PoolCard, PoolCardSkeleton, type Pool } from "@/components/dashboard/po import { BatchDepositPanel } from "@/components/dashboard/batch-deposit-panel" import { DepositCalendar } from "@/components/dashboard/deposit-calendar/DepositCalendar" import { useDebouncedValue } from "@/hooks/use-debounced-value" +import { ArchivedPoolCard, type ArchivedPool } from "@/components/shared/archived-pool-card" const PAGE_SIZE = 6 @@ -39,6 +41,7 @@ const container = { // ── Main MyGroups component ─────────────────────────────────────────────────── export function MyGroups({ onCreateClick }: MyGroupsProps) { const t = useTranslations("dashboard.myGroups") + const tArchived = useTranslations("dashboard.archived") const { address } = useStellar() const router = useRouter() const searchParams = useSearchParams() @@ -55,6 +58,10 @@ export function MyGroups({ onCreateClick }: MyGroupsProps) { const debouncedSearchInput = useDebouncedValue(searchInput, 300) const totalPages = Math.ceil(total / PAGE_SIZE) + // Tab lives in the URL so an archived view survives a refresh or a back + // navigation from a pool's history page. + const tab = searchParams.get("groupsTab") === "archived" ? "archived" : "active" + const setPage = useCallback( (p: number) => { const params = new URLSearchParams(searchParams.toString()) @@ -64,6 +71,16 @@ export function MyGroups({ onCreateClick }: MyGroupsProps) { [router, searchParams] ) + const setTab = useCallback( + (next: string) => { + const params = new URLSearchParams(searchParams.toString()) + if (next === "archived") params.set("groupsTab", "archived") + else params.delete("groupsTab") + router.push(`?${params.toString()}`, { scroll: false }) + }, + [router, searchParams] + ) + const setSearchTerm = useCallback( (term: string) => { const params = new URLSearchParams(searchParams.toString()) @@ -101,6 +118,8 @@ export function MyGroups({ onCreateClick }: MyGroupsProps) { try { setLoading(true) setError("") + // No `archived` param — archived pools are excluded by default and live + // in their own tab below. const res = await fetch(`/api/pools?creator=${address?.toLowerCase()}&page=${currentPage}`) if (!res.ok) throw new Error(t("fetchError")) const json = await res.json() @@ -122,73 +141,21 @@ export function MyGroups({ onCreateClick }: MyGroupsProps) { ? pools.filter((pool) => pool.name.toLowerCase().includes(searchTerm.toLowerCase())) : pools - if (loading) { - return ( -
-
-

{t("title")}

- -
-
- {Array.from({ length: PAGE_SIZE }).map((_, i) => ( - - ))} -
-
- ) - } - - if (error) { - return ( -
-
-

{t("title")}

-
- -

{error}

-
-
- ) - } - - return ( + const activeContent = loading ? ( +
+ {Array.from({ length: PAGE_SIZE }).map((_, i) => ( + + ))} +
+ ) : error ? ( + +

{error}

+
+ ) : (
- -
-

{t("title")}

-

{t("activeGroupsCount", { count: total })}

-
- - next && setView(next as "grid" | "calendar")} - aria-label={t("viewToggle.label")} - > - - - - - -
- {/* Deposits owed across every pool the wallet belongs to. Renders nothing when there is nothing outstanding. */} loadPools(page)} /> @@ -220,7 +187,10 @@ export function MyGroups({ onCreateClick }: MyGroupsProps) {

{t.rich("noSearchResultsHint", { clear: (chunks) => ( - ), @@ -257,7 +227,9 @@ export function MyGroups({ onCreateClick }: MyGroupsProps) { setPage(page - 1)} aria-disabled={page === 0} - className={page === 0 ? "pointer-events-none opacity-50" : "cursor-pointer"} + className={ + page === 0 ? "pointer-events-none opacity-50" : "cursor-pointer" + } /> @@ -265,7 +237,9 @@ export function MyGroups({ onCreateClick }: MyGroupsProps) { onClick={() => setPage(page + 1)} aria-disabled={page >= totalPages - 1} className={ - page >= totalPages - 1 ? "pointer-events-none opacity-50" : "cursor-pointer" + page >= totalPages - 1 + ? "pointer-events-none opacity-50" + : "cursor-pointer" } /> @@ -279,4 +253,213 @@ export function MyGroups({ onCreateClick }: MyGroupsProps) { )}

) + + return ( +
+ +
+

{t("title")}

+ {loading ? ( + + ) : ( +

{t("activeGroupsCount", { count: total })}

+ )} +
+ + {/* Grid/calendar only applies to active groups — archived pools have no + upcoming deposits to put on a calendar. */} + {tab === "active" && ( + next && setView(next as "grid" | "calendar")} + aria-label={t("viewToggle.label")} + > + + + + + + )} +
+ + + + + {tArchived("tabActive")} + + + + + + {activeContent} + + + + + +
+ ) +} + +// ── Archived tab ────────────────────────────────────────────────────────────── + +/** + * Compact list of the wallet's archived pools (issue #212). + * + * Fetched lazily — the request only fires once the tab is opened, so the + * common case of never looking at archived pools costs nothing. Uses its own + * `archivedPage` param so paging here does not disturb the active tab. + */ +function ArchivedGroups({ address, enabled }: { address: string | null; enabled: boolean }) { + const t = useTranslations("dashboard.archived") + const router = useRouter() + const searchParams = useSearchParams() + + const [pools, setPools] = useState([]) + const [total, setTotal] = useState(0) + const [loading, setLoading] = useState(true) + const [error, setError] = useState("") + + const page = Math.max(0, parseInt(searchParams.get("archivedPage") || "0", 10)) + const totalPages = Math.ceil(total / PAGE_SIZE) + + const setPage = useCallback( + (p: number) => { + const params = new URLSearchParams(searchParams.toString()) + params.set("archivedPage", String(p)) + router.push(`?${params.toString()}`, { scroll: false }) + }, + [router, searchParams] + ) + + useEffect(() => { + if (!enabled) return + if (!address) { + setLoading(false) + return + } + + let cancelled = false + const load = async () => { + try { + setLoading(true) + setError("") + const res = await fetch( + `/api/pools?creator=${address.toLowerCase()}&page=${page}&archived=only` + ) + if (!res.ok) throw new Error(t("fetchError")) + const json = await res.json() + if (cancelled) return + const data: ArchivedPool[] = Array.isArray(json) ? json : (json.data ?? []) + setPools(data) + setTotal(json.total ?? data.length) + } catch (err) { + if (cancelled) return + setError(err instanceof Error ? err.message : t("fetchError")) + setPools([]) + } finally { + if (!cancelled) setLoading(false) + } + } + + load() + return () => { + cancelled = true + } + }, [address, page, enabled, t]) + + if (loading) { + return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) + } + + if (error) { + return ( + +

{error}

+
+ ) + } + + if (pools.length === 0) { + return ( + +
+ +
+

{t("empty")}

+

{t("emptyHint")}

+
+ ) + } + + return ( +
+

{t("count", { count: total })}

+ +
+ {pools.map((pool) => ( + + ))} +
+ + {totalPages > 1 && ( +
+

+ {t("showingRange", { + from: page * PAGE_SIZE + 1, + to: Math.min((page + 1) * PAGE_SIZE, total), + total, + })} +

+ + + + setPage(page - 1)} + aria-disabled={page === 0} + className={page === 0 ? "pointer-events-none opacity-50" : "cursor-pointer"} + /> + + + setPage(page + 1)} + aria-disabled={page >= totalPages - 1} + className={ + page >= totalPages - 1 ? "pointer-events-none opacity-50" : "cursor-pointer" + } + /> + + + +
+ )} +
+ ) } diff --git a/frontend/components/group/archived-pool-banner.tsx b/frontend/components/group/archived-pool-banner.tsx new file mode 100644 index 0000000..628a177 --- /dev/null +++ b/frontend/components/group/archived-pool-banner.tsx @@ -0,0 +1,183 @@ +"use client" + +import { useState } from "react" +import { useTranslations } from "next-intl" +import { Card } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Archive, ArchiveRestore, Loader2 } from "lucide-react" +import { toastManager } from "@/lib/toast" +import type { ArchiveReason } from "@/lib/archival" + +interface ArchivedPoolBannerProps { + groupId: string + archivedAt: string + archiveReason: ArchiveReason | null + /** Only the pool creator sees the restore control. */ + isAdmin: boolean + /** Connected wallet — the endpoint authorises against the pool creator. */ + adminAddress: string | null + onRestored?: () => void +} + +/** + * Banner shown at the top of an archived pool's detail page (issue #212). + * + * States plainly that the pool is archived, why, and that nothing was lost — + * a member arriving from a bookmark should not have to guess why the deposit + * button is gone. The admin gets the restore control here, which is the + * escape hatch for a false-positive automated archival. + */ +export function ArchivedPoolBanner({ + groupId, + archivedAt, + archiveReason, + isAdmin, + adminAddress, + onRestored, +}: ArchivedPoolBannerProps) { + const t = useTranslations("group.archived") + const [restoring, setRestoring] = useState(false) + + const reason: ArchiveReason = archiveReason ?? "admin_archived" + + const handleRestore = async () => { + if (!adminAddress) return toastManager.error(t("unarchiveError")) + setRestoring(true) + try { + const res = await fetch(`/api/pools/${groupId}/unarchive`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ admin_address: adminAddress }), + }) + if (!res.ok) throw new Error(await res.text()) + toastManager.success(t("unarchiveSuccess")) + onRestored?.() + } catch { + toastManager.error(t("unarchiveError")) + } finally { + setRestoring(false) + } + } + + return ( + +
+
+
+ +
+

{t("title")}

+

{t(`reason.${reason}`)}

+

{t("body")}

+

+ {t("archivedOn", { + date: new Date(archivedAt).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }), + })} +

+
+ + {isAdmin && ( + + )} +
+
+ ) +} + +interface ArchivePoolButtonProps { + groupId: string + /** Connected wallet — the endpoint authorises against the pool creator. */ + adminAddress: string | null + onArchived?: () => void +} + +/** + * Manual archive control for the pool admin, shown on an active pool. Behind a + * confirmation because archival removes the pool from everyone's active list — + * reversible, but not something to trigger with a stray click. + */ +export function ArchivePoolButton({ groupId, adminAddress, onArchived }: ArchivePoolButtonProps) { + const t = useTranslations("group.archived") + const [open, setOpen] = useState(false) + const [archiving, setArchiving] = useState(false) + + const handleArchive = async () => { + if (!adminAddress) return toastManager.error(t("archiveError")) + setArchiving(true) + try { + const res = await fetch(`/api/pools/${groupId}/archive`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ admin_address: adminAddress }), + }) + if (!res.ok) throw new Error(await res.text()) + toastManager.success(t("archiveSuccess")) + setOpen(false) + onArchived?.() + } catch { + toastManager.error(t("archiveError")) + } finally { + setArchiving(false) + } + } + + return ( + <> + + + + + + {t("archiveConfirmTitle")} + {t("archiveConfirmBody")} + + + + + + + + + ) +} diff --git a/frontend/components/shared/archived-pool-card.tsx b/frontend/components/shared/archived-pool-card.tsx new file mode 100644 index 0000000..593905c --- /dev/null +++ b/frontend/components/shared/archived-pool-card.tsx @@ -0,0 +1,93 @@ +"use client" + +import { Card } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Archive, ArrowRight, Users } from "lucide-react" +import { Link } from "@/i18n/navigation" +import { useTranslations } from "next-intl" +import type { ArchiveReason } from "@/lib/archival" + +export interface ArchivedPool { + id: string + name: string + type: "rotational" | "target" | "flexible" + archived_at: string | null + archive_reason: ArchiveReason | null + completed_at: string | null + total_saved: number + members_count: number + token_symbol?: string | null +} + +/** + * Compact card for a pool that has left discovery (issue #212). + * + * Deliberately quieter and cheaper than PoolCard: no on-chain reads, no + * health badge, no sparkline. An archived pool's numbers are final, so there + * is nothing live to fetch, and the archived list is exactly where a page of + * per-card RPC calls would be pure waste. + */ +export function ArchivedPoolCard({ pool }: { pool: ArchivedPool }) { + const t = useTranslations("dashboard.archived") + const tPool = useTranslations("pool") + + const tokenSymbol = pool.token_symbol || "XLM" + // The completion date is what a member looks for first; pools archived for + // inactivity never completed, so the archival date stands in. + const dateLabel = pool.completed_at ?? pool.archived_at + const reason: ArchiveReason = pool.archive_reason ?? "admin_archived" + + return ( + +
+
+

{pool.name}

+ {tPool(`type.${pool.type}`)} + + +
+ +

{t(`reason.${reason}`)}

+ +
+ {dateLabel && ( + + {pool.completed_at ? t("completedOn") : t("archivedOn")}{" "} + + + )} + + + + {t("finalTvl")}{" "} + + {pool.total_saved.toFixed(2)} {tokenSymbol} + + +
+
+ + +
+ ) +} diff --git a/frontend/lib/archival.test.ts b/frontend/lib/archival.test.ts new file mode 100644 index 0000000..2b6629f --- /dev/null +++ b/frontend/lib/archival.test.ts @@ -0,0 +1,299 @@ +import { test } from "node:test" +import assert from "node:assert" +import { + ARCHIVE_REASONS, + COMPLETED_GRACE_DAYS, + EMERGENCY_WITHDRAWN_GRACE_DAYS, + INACTIVE_THRESHOLD_DAYS, + evaluateArchival, + holdsNoFunds, + isArchiveReason, + isArchived, + latestActivityAt, + netBalanceFromActivity, + type ArchivalCandidate, +} from "@/lib/archival" + +const NOW = new Date("2026-08-28T02:00:00.000Z").getTime() +const DAY_MS = 24 * 60 * 60 * 1000 + +const daysAgo = (days: number) => new Date(NOW - days * DAY_MS).toISOString() + +function candidate(overrides: Partial = {}): ArchivalCandidate { + return { + id: "pool-1", + status: "active", + archived_at: null, + completed_at: null, + emergency_withdrawn_at: null, + last_activity_at: daysAgo(1), + created_at: daysAgo(120), + net_balance: 0, + ...overrides, + } +} + +// ── isArchiveReason ────────────────────────────────────────────────────────── + +test("archival — every declared reason passes the guard", () => { + for (const reason of ARCHIVE_REASONS) { + assert.strictEqual(isArchiveReason(reason), true) + } +}) + +test("archival — unknown reasons are rejected", () => { + assert.strictEqual(isArchiveReason("deleted"), false) + assert.strictEqual(isArchiveReason(""), false) + assert.strictEqual(isArchiveReason(null), false) + assert.strictEqual(isArchiveReason(7), false) +}) + +// ── Completed pools ────────────────────────────────────────────────────────── + +test("archival — completed pool past the grace period is archived", () => { + const decision = evaluateArchival( + candidate({ status: "completed", completed_at: daysAgo(COMPLETED_GRACE_DAYS + 1) }), + NOW + ) + assert.strictEqual(decision.archive, true) + assert.strictEqual(decision.reason, "completed") +}) + +test("archival — completed pool inside the grace period is kept visible", () => { + const decision = evaluateArchival( + candidate({ status: "completed", completed_at: daysAgo(COMPLETED_GRACE_DAYS - 1) }), + NOW + ) + assert.strictEqual(decision.archive, false) +}) + +test("archival — completed pool exactly at the grace boundary is archived", () => { + const decision = evaluateArchival( + candidate({ status: "completed", completed_at: daysAgo(COMPLETED_GRACE_DAYS) }), + NOW + ) + assert.strictEqual(decision.archive, true) +}) + +test("archival — completed pool with no completed_at is left alone", () => { + const decision = evaluateArchival(candidate({ status: "completed", completed_at: null }), NOW) + assert.strictEqual(decision.archive, false) +}) + +test("archival — completed pool holding funds is still archived", () => { + // Completion is an explicit end state; an unclaimed final payout must not + // keep a finished pool in discovery forever. + const decision = evaluateArchival( + candidate({ + status: "completed", + completed_at: daysAgo(COMPLETED_GRACE_DAYS + 5), + net_balance: 500, + }), + NOW + ) + assert.strictEqual(decision.archive, true) + assert.strictEqual(decision.reason, "completed") +}) + +// ── Emergency-withdrawn pools ──────────────────────────────────────────────── + +test("archival — emergency-withdrawn pool past 30 days is archived", () => { + const decision = evaluateArchival( + candidate({ + status: "emergency_withdrawn", + emergency_withdrawn_at: daysAgo(EMERGENCY_WITHDRAWN_GRACE_DAYS + 1), + }), + NOW + ) + assert.strictEqual(decision.archive, true) + assert.strictEqual(decision.reason, "emergency_withdrawn") +}) + +test("archival — emergency-withdrawn pool inside 30 days is kept visible", () => { + const decision = evaluateArchival( + candidate({ + status: "emergency_withdrawn", + emergency_withdrawn_at: daysAgo(EMERGENCY_WITHDRAWN_GRACE_DAYS - 2), + }), + NOW + ) + assert.strictEqual(decision.archive, false) +}) + +// ── Inactive pools ─────────────────────────────────────────────────────────── + +test("archival — silent and empty pool past 90 days is archived", () => { + const decision = evaluateArchival( + candidate({ last_activity_at: daysAgo(INACTIVE_THRESHOLD_DAYS + 5), net_balance: 0 }), + NOW + ) + assert.strictEqual(decision.archive, true) + assert.strictEqual(decision.reason, "inactive_90d") +}) + +test("archival — silent pool still holding member funds is NOT archived", () => { + // The false positive that matters: quiet is not the same as dead, and + // hiding a pool with real money in it would be a trust problem. + const decision = evaluateArchival( + candidate({ last_activity_at: daysAgo(INACTIVE_THRESHOLD_DAYS + 200), net_balance: 25 }), + NOW + ) + assert.strictEqual(decision.archive, false) +}) + +test("archival — empty pool that is still active recently is NOT archived", () => { + const decision = evaluateArchival( + candidate({ last_activity_at: daysAgo(INACTIVE_THRESHOLD_DAYS - 1), net_balance: 0 }), + NOW + ) + assert.strictEqual(decision.archive, false) +}) + +test("archival — never-active pool falls back to created_at for silence", () => { + const decision = evaluateArchival( + candidate({ last_activity_at: null, created_at: daysAgo(INACTIVE_THRESHOLD_DAYS + 1) }), + NOW + ) + assert.strictEqual(decision.archive, true) + assert.strictEqual(decision.reason, "inactive_90d") +}) + +test("archival — freshly created empty pool is NOT archived", () => { + const decision = evaluateArchival( + candidate({ last_activity_at: null, created_at: daysAgo(2) }), + NOW + ) + assert.strictEqual(decision.archive, false) +}) + +test("archival — paused pool is never swept for inactivity", () => { + // Pausing is a deliberate admin decision; the sweep must not undo it. + const decision = evaluateArchival( + candidate({ + status: "paused", + last_activity_at: daysAgo(INACTIVE_THRESHOLD_DAYS + 100), + net_balance: 0, + }), + NOW + ) + assert.strictEqual(decision.archive, false) +}) + +// ── Idempotency and bad data ───────────────────────────────────────────────── + +test("archival — an already-archived pool is never re-archived", () => { + const decision = evaluateArchival( + candidate({ + status: "completed", + completed_at: daysAgo(365), + archived_at: daysAgo(300), + }), + NOW + ) + assert.strictEqual(decision.archive, false) +}) + +test("archival — unparseable timestamps do not trigger archival", () => { + const decision = evaluateArchival( + candidate({ status: "completed", completed_at: "not-a-date" }), + NOW + ) + assert.strictEqual(decision.archive, false) +}) + +test("archival — a future completed_at does not trigger archival", () => { + const decision = evaluateArchival( + candidate({ status: "completed", completed_at: daysAgo(-5) }), + NOW + ) + assert.strictEqual(decision.archive, false) +}) + +// ── holdsNoFunds ───────────────────────────────────────────────────────────── + +test("archival — zero and dust balances count as empty", () => { + assert.strictEqual(holdsNoFunds(0), true) + assert.strictEqual(holdsNoFunds(1e-12), true) +}) + +test("archival — a negative net balance counts as empty", () => { + assert.strictEqual(holdsNoFunds(-3), true) +}) + +test("archival — any real balance is not empty", () => { + assert.strictEqual(holdsNoFunds(0.5), false) + assert.strictEqual(holdsNoFunds(1000), false) +}) + +test("archival — NaN balance is treated as holding funds", () => { + // Unknown balance must fail closed: keep the pool rather than hide it. + assert.strictEqual(holdsNoFunds(Number.NaN), false) +}) + +// ── netBalanceFromActivity ─────────────────────────────────────────────────── + +test("archival — deposits add and withdrawals/payouts subtract", () => { + const net = netBalanceFromActivity([ + { activity_type: "deposit", amount: 100 }, + { activity_type: "deposit", amount: 50 }, + { activity_type: "withdraw", amount: 30 }, + { activity_type: "payout", amount: 20 }, + ]) + assert.strictEqual(net, 100) +}) + +test("archival — activity types are matched case-insensitively", () => { + const net = netBalanceFromActivity([ + { activity_type: "DEPOSIT", amount: 10 }, + { activity_type: "Withdraw", amount: 4 }, + ]) + assert.strictEqual(net, 6) +}) + +test("archival — non-balance activity and null amounts are ignored", () => { + const net = netBalanceFromActivity([ + { activity_type: "pool_created", amount: null }, + { activity_type: "member_added", amount: null }, + { activity_type: "deposit", amount: null }, + { activity_type: null, amount: 99 }, + ]) + assert.strictEqual(net, 0) +}) + +test("archival — a fully withdrawn pool nets to zero", () => { + const net = netBalanceFromActivity([ + { activity_type: "deposit", amount: 250 }, + { activity_type: "withdraw", amount: 250 }, + ]) + assert.strictEqual(holdsNoFunds(net), true) +}) + +// ── latestActivityAt ───────────────────────────────────────────────────────── + +test("archival — newest activity timestamp wins regardless of order", () => { + const newest = latestActivityAt([ + { created_at: "2026-01-05T00:00:00.000Z" }, + { created_at: "2026-06-01T00:00:00.000Z" }, + { created_at: "2026-03-11T00:00:00.000Z" }, + ]) + assert.strictEqual(newest, "2026-06-01T00:00:00.000Z") +}) + +test("archival — an empty activity list has no latest timestamp", () => { + assert.strictEqual(latestActivityAt([]), null) +}) + +test("archival — unparseable activity timestamps are skipped", () => { + const newest = latestActivityAt([ + { created_at: "garbage" }, + { created_at: "2026-02-02T00:00:00.000Z" }, + ]) + assert.strictEqual(newest, "2026-02-02T00:00:00.000Z") +}) + +// ── isArchived ─────────────────────────────────────────────────────────────── + +test("archival — isArchived keys off archived_at", () => { + assert.strictEqual(isArchived({ archived_at: null, archive_reason: null }), false) + assert.strictEqual(isArchived({ archived_at: daysAgo(1), archive_reason: "completed" }), true) +}) diff --git a/frontend/lib/archival.ts b/frontend/lib/archival.ts new file mode 100644 index 0000000..0838f3b --- /dev/null +++ b/frontend/lib/archival.ts @@ -0,0 +1,193 @@ +// Pure pool-archival domain helpers (issue #212). Framework-free so it runs +// under the node test runner and can be shared by the cron job, the admin +// endpoints, and the UI. +// +// Archival is an off-chain visibility layer only. Nothing here deletes data: +// an archived pool keeps every member, activity, and metric row it owned, and +// its on-chain contract is untouched and immutable. Archival decides what the +// discovery surfaces show by default, and nothing more. + +/** Grace period after completion before a finished pool leaves discovery. */ +export const COMPLETED_GRACE_DAYS = 7 +/** How long a pool must be both silent and empty before it counts as dead. */ +export const INACTIVE_THRESHOLD_DAYS = 90 +/** Emergency-withdrawn pools stay visible a full month for member follow-up. */ +export const EMERGENCY_WITHDRAWN_GRACE_DAYS = 30 + +const DAY_MS = 24 * 60 * 60 * 1000 + +export const ARCHIVE_REASONS = [ + "completed", + "inactive_90d", + "admin_archived", + "emergency_withdrawn", +] as const + +export type ArchiveReason = (typeof ARCHIVE_REASONS)[number] + +export type ArchiveAction = "archived" | "unarchived" + +export function isArchiveReason(value: unknown): value is ArchiveReason { + return typeof value === "string" && (ARCHIVE_REASONS as readonly string[]).includes(value) +} + +/** Row shape of `archive_log` as returned by the archival endpoints. */ +export interface ArchiveLogRecord { + id: string + pool_id: string + action: ArchiveAction + reason: ArchiveReason + triggered_by: string + automated: boolean + note: string | null + created_at: string +} + +/** + * Everything the archival rules need about one pool. Deliberately narrower + * than the `pools` row so the cron can select only these columns, and so the + * rules stay testable without a database. + */ +export interface ArchivalCandidate { + id: string + status: string + archived_at: string | null + completed_at: string | null + emergency_withdrawn_at: string | null + /** Newest `pool_activity.created_at`, or null when the pool never had any. */ + last_activity_at: string | null + /** Fallback anchor for silence when there is no activity at all. */ + created_at: string + /** + * Deposits minus withdrawals/payouts, in token units. A pool still holding + * funds is never treated as inactive no matter how quiet it has been. + */ + net_balance: number +} + +export interface ArchivalDecision { + archive: boolean + reason?: ArchiveReason + /** Human-readable justification, stored as the archive_log note. */ + note?: string +} + +function daysBetween(fromIso: string, nowMs: number): number { + const from = new Date(fromIso).getTime() + if (!Number.isFinite(from)) return Number.NaN + return (nowMs - from) / DAY_MS +} + +/** + * Decide whether a single pool should be archived by the daily sweep. + * + * The three automated criteria, in priority order: + * + * 1. `completed` — finished, and `completed_at` is more than 7 days old, so + * members have had a week to review the final state. + * 2. `emergency_withdrawn` — funds pulled, and more than 30 days have passed. + * 3. `inactive_90d` — no activity for 90 days *and* the pool holds nothing. + * The balance check is the important half: a pool sitting quietly on real + * member funds is not dead, it is waiting, and hiding it would be a + * false positive with money attached. + * + * `admin_archived` is never produced here — it only ever comes from the manual + * endpoint, which is why an admin can archive a pool this function would keep. + */ +export function evaluateArchival(pool: ArchivalCandidate, nowMs: number): ArchivalDecision { + // Already archived — the sweep must be idempotent across daily runs. + if (pool.archived_at) return { archive: false } + + if (pool.status === "completed" && pool.completed_at) { + const age = daysBetween(pool.completed_at, nowMs) + if (Number.isFinite(age) && age >= COMPLETED_GRACE_DAYS) { + return { + archive: true, + reason: "completed", + note: `Completed ${Math.floor(age)} days ago (grace period ${COMPLETED_GRACE_DAYS} days)`, + } + } + } + + if (pool.status === "emergency_withdrawn" && pool.emergency_withdrawn_at) { + const age = daysBetween(pool.emergency_withdrawn_at, nowMs) + if (Number.isFinite(age) && age >= EMERGENCY_WITHDRAWN_GRACE_DAYS) { + return { + archive: true, + reason: "emergency_withdrawn", + note: `Emergency withdrawn ${Math.floor(age)} days ago (grace period ${EMERGENCY_WITHDRAWN_GRACE_DAYS} days)`, + } + } + } + + // A paused pool is paused on purpose — an admin is expected to come back to + // it — so silence alone must not sweep it out of discovery. + if (pool.status === "active") { + const silenceAnchor = pool.last_activity_at ?? pool.created_at + const silentDays = daysBetween(silenceAnchor, nowMs) + const isEmpty = holdsNoFunds(pool.net_balance) + if (Number.isFinite(silentDays) && silentDays >= INACTIVE_THRESHOLD_DAYS && isEmpty) { + return { + archive: true, + reason: "inactive_90d", + note: `No activity for ${Math.floor(silentDays)} days and no member funds held`, + } + } + } + + return { archive: false } +} + +/** + * Whether a pool holds nothing on behalf of its members — never deposited, or + * fully withdrawn. Tolerates the sub-unit dust that rounding in the activity + * amounts can leave behind, and treats a negative net (over-counted payouts) + * as empty rather than as a reason to keep a dead pool alive. + */ +export function holdsNoFunds(netBalance: number): boolean { + if (!Number.isFinite(netBalance)) return false + return netBalance <= 1e-9 +} + +/** + * Net funds a pool still holds, derived from its activity feed. Mirrors the + * deposit/withdrawal aggregation in /api/analytics and the metrics cron so all + * three agree on what "empty" means. + */ +export function netBalanceFromActivity( + activities: { activity_type: string | null; amount: number | null }[] +): number { + let net = 0 + for (const activity of activities) { + const type = activity.activity_type?.toLowerCase() + const amount = activity.amount ?? 0 + if (!Number.isFinite(amount)) continue + if (type === "deposit") net += amount + else if (type === "withdraw" || type === "payout") net -= amount + } + return net +} + +/** Newest `created_at` in an activity list, or null when the list is empty. */ +export function latestActivityAt(activities: { created_at: string }[]): string | null { + let newest: string | null = null + let newestMs = -Infinity + for (const activity of activities) { + const ms = new Date(activity.created_at).getTime() + if (Number.isFinite(ms) && ms > newestMs) { + newestMs = ms + newest = activity.created_at + } + } + return newest +} + +/** Minimal archived-pool shape the UI needs to explain an archival. */ +export interface ArchivedPoolSummary { + archived_at: string | null + archive_reason: ArchiveReason | null +} + +export function isArchived(pool: ArchivedPoolSummary): boolean { + return !!pool.archived_at +} diff --git a/frontend/lib/server/archival-guard.ts b/frontend/lib/server/archival-guard.ts new file mode 100644 index 0000000..b225ee7 --- /dev/null +++ b/frontend/lib/server/archival-guard.ts @@ -0,0 +1,59 @@ +/** + * Server-side read-only enforcement for archived pools (issue #212). + * + * Hiding the deposit/withdraw/pause buttons is presentation. This is the part + * that actually holds: any write aimed at an archived pool is refused at the + * API boundary, so a stale tab, a bookmarked request, or a direct curl cannot + * mutate a pool that has left discovery. + * + * Server-only — it uses the service-role client and must never be imported + * from a client component. + */ + +import { NextResponse } from "next/server" +import { getAdminClient } from "@/lib/supabase-admin" +import type { ArchiveReason } from "@/lib/archival" + +export interface ArchivedPoolState { + archived_at: string | null + archive_reason: ArchiveReason | null +} + +/** + * Look up a pool's archival state. Returns null when the pool does not exist + * or the lookup fails — callers decide whether a missing pool is fatal, since + * some of them already 404 on their own. + */ +export async function getArchivalState(poolId: string): Promise { + if (!poolId) return null + const admin = getAdminClient() + const { data, error } = await admin + .from("pools") + .select("archived_at, archive_reason") + .eq("id", poolId) + .maybeSingle() + + if (error || !data) return null + return data as ArchivedPoolState +} + +/** + * Returns a 409 response when the pool is archived, or null when the write may + * proceed. Fails open on a lookup miss so an unrelated database hiccup does not + * block writes to healthy pools — archival is a visibility concern, not a + * security boundary, and the pool's own handler still validates the rest. + */ +export async function blockIfArchived(poolId: string): Promise { + const state = await getArchivalState(poolId) + if (!state?.archived_at) return null + + return NextResponse.json( + { + error: "This pool has been archived and is no longer active", + archived: true, + archived_at: state.archived_at, + archive_reason: state.archive_reason, + }, + { status: 409 } + ) +} diff --git a/frontend/lib/supabase.ts b/frontend/lib/supabase.ts index 587e088..fcc75aa 100644 --- a/frontend/lib/supabase.ts +++ b/frontend/lib/supabase.ts @@ -1,4 +1,5 @@ import { createClient } from "@supabase/supabase-js" +import type { ArchiveAction, ArchiveReason } from "@/lib/archival" const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? "" const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "" @@ -25,7 +26,7 @@ export type Database = { name: string description: string | null type: "rotational" | "target" | "flexible" - status: "active" | "completed" | "paused" + status: "active" | "completed" | "paused" | "emergency_withdrawn" creator_address: string contract_address: string token_address: string @@ -46,12 +47,16 @@ export type Database = { minimum_deposit: number | null withdrawal_fee: number | null yield_enabled: boolean + archived_at: string | null + archive_reason: ArchiveReason | null + completed_at: string | null + emergency_withdrawn_at: string | null } Insert: { name: string description?: string | null type: "rotational" | "target" | "flexible" - status?: "active" | "completed" | "paused" + status?: "active" | "completed" | "paused" | "emergency_withdrawn" creator_address: string contract_address: string token_address: string @@ -70,12 +75,16 @@ export type Database = { minimum_deposit?: number | null withdrawal_fee?: number | null yield_enabled?: boolean + archived_at?: string | null + archive_reason?: ArchiveReason | null + completed_at?: string | null + emergency_withdrawn_at?: string | null } Update: { name?: string description?: string | null type?: "rotational" | "target" | "flexible" - status?: "active" | "completed" | "paused" + status?: "active" | "completed" | "paused" | "emergency_withdrawn" creator_address?: string contract_address?: string token_address?: string @@ -94,6 +103,10 @@ export type Database = { minimum_deposit?: number | null withdrawal_fee?: number | null yield_enabled?: boolean + archived_at?: string | null + archive_reason?: ArchiveReason | null + completed_at?: string | null + emergency_withdrawn_at?: string | null } Relationships: [] } @@ -436,6 +449,34 @@ export type Database = { } Relationships: [] } + archive_log: { + Row: { + id: string + pool_id: string + action: ArchiveAction + reason: ArchiveReason + triggered_by: string + automated: boolean + note: string | null + created_at: string + } + Insert: { + pool_id: string + action: ArchiveAction + reason: ArchiveReason + triggered_by: string + automated?: boolean + note?: string | null + } + Update: { + action?: ArchiveAction + reason?: ArchiveReason + triggered_by?: string + automated?: boolean + note?: string | null + } + Relationships: [] + } disputes: { Row: { id: string diff --git a/frontend/messages/en.json b/frontend/messages/en.json index cda2c05..faf75c8 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -372,6 +372,30 @@ "payout": "Payout", "refund": "Refund" } + }, + "archived": { + "title": "Archived Groups", + "badge": "Archived", + "tabActive": "Active", + "tabArchived": "Archived", + "count": "{count, plural, =0 {No archived groups yet} one {# archived group} other {# archived groups}}", + "empty": "Nothing archived yet", + "emptyHint": "Pools show up here once they finish, or after 90 days with no activity and no member funds. Nothing is deleted — the full history stays available.", + "fetchError": "Failed to fetch archived pools", + "loadingLabel": "Loading archived groups", + "showingRange": "Showing {from}–{to} of {total} archived pools", + "completedOn": "Completed", + "archivedOn": "Archived", + "finalTvl": "Final TVL", + "memberCount": "{count, plural, one {# member} other {# members}}", + "viewHistory": "View History", + "cardAria": "Archived pool {name}. {reason}", + "reason": { + "completed": "This pool finished and was archived after its review window.", + "inactive_90d": "Archived after 90 days with no activity and no member funds held.", + "admin_archived": "Archived by the pool admin.", + "emergency_withdrawn": "Archived after an emergency withdrawal." + } } }, "pool": { @@ -383,7 +407,8 @@ "status": { "active": "Active", "completed": "Completed", - "paused": "Paused" + "paused": "Paused", + "emergency_withdrawn": "Emergency Withdrawn" }, "create": { "intro": { @@ -1005,6 +1030,29 @@ "resolved_dismissed": "Dismissed", "expired": "Expired" } + }, + "archived": { + "title": "This pool has been archived", + "body": "It is no longer active and is shown here read-only. Deposits, withdrawals, and admin actions are disabled. All history below is preserved.", + "historicalActivity": "Historical activity — this pool is archived and receives no new events.", + "archivedOn": "Archived on {date}", + "unarchive": "Restore pool", + "unarchiving": "Restoring…", + "unarchiveSuccess": "Pool restored — it is active and visible again.", + "unarchiveError": "Could not restore this pool. Please try again.", + "archive": "Archive pool", + "archiving": "Archiving…", + "archiveSuccess": "Pool archived. Its history stays available under the Archived tab.", + "archiveError": "Could not archive this pool. Please try again.", + "archiveConfirmTitle": "Archive this pool?", + "archiveConfirmBody": "It will be hidden from Explore and the active groups tab, and become read-only. Nothing is deleted — all members, activity, and history are preserved, and you can restore it at any time.", + "cancel": "Cancel", + "reason": { + "completed": "This pool finished and was archived after its review window.", + "inactive_90d": "Archived after 90 days with no activity and no member funds held.", + "admin_archived": "Archived by the pool admin.", + "emergency_withdrawn": "Archived after an emergency withdrawal." + } } }, "explore": { @@ -1017,7 +1065,10 @@ "poolCount": "{count, plural, =0 {No pools to explore yet} one {Browse # pool — the health badge shows how reliably members have been depositing} other {Browse # pools — the health badge shows how reliably members have been depositing}}", "nothingToExplore": "Nothing to explore just yet", "nothingToExploreHint": "Once people start creating savings pools, they'll show up here for you to browse.", - "showingRange": "Showing {from}–{to} of {total} pools" + "showingRange": "Showing {from}–{to} of {total} pools", + "showArchived": "Show archived", + "showArchivedHint": "Include pools that have finished or gone inactive", + "archivedBadge": "Archived" }, "timeAgo": { "justNow": "just now", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index e91203c..7c3cc1b 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -372,6 +372,30 @@ "payout": "Pago", "refund": "Reembolso" } + }, + "archived": { + "title": "Grupos archivados", + "badge": "Archivado", + "tabActive": "Activos", + "tabArchived": "Archivados", + "count": "{count, plural, =0 {Aún no hay grupos archivados} one {# grupo archivado} other {# grupos archivados}}", + "empty": "Aún no hay nada archivado", + "emptyHint": "Los grupos aparecen aquí cuando terminan, o tras 90 días sin actividad y sin fondos de miembros. No se elimina nada: el historial completo sigue disponible.", + "fetchError": "No se pudieron cargar los grupos archivados", + "loadingLabel": "Cargando grupos archivados", + "showingRange": "Mostrando {from}–{to} de {total} grupos archivados", + "completedOn": "Completado", + "archivedOn": "Archivado", + "finalTvl": "TVL final", + "memberCount": "{count, plural, one {# miembro} other {# miembros}}", + "viewHistory": "Ver historial", + "cardAria": "Grupo archivado {name}. {reason}", + "reason": { + "completed": "Este grupo terminó y se archivó tras su periodo de revisión.", + "inactive_90d": "Archivado tras 90 días sin actividad y sin fondos de miembros.", + "admin_archived": "Archivado por el administrador del grupo.", + "emergency_withdrawn": "Archivado tras un retiro de emergencia." + } } }, "pool": { @@ -383,7 +407,8 @@ "status": { "active": "Activa", "completed": "Completada", - "paused": "Pausada" + "paused": "Pausada", + "emergency_withdrawn": "Retiro de emergencia" }, "create": { "intro": { @@ -1013,6 +1038,29 @@ "resolved_dismissed": "Rechazada", "expired": "Expirada" } + }, + "archived": { + "title": "Este grupo ha sido archivado", + "body": "Ya no está activo y se muestra en modo solo lectura. Los depósitos, retiros y acciones de administrador están deshabilitados. Todo el historial siguiente se conserva.", + "historicalActivity": "Actividad histórica: este grupo está archivado y no recibe eventos nuevos.", + "archivedOn": "Archivado el {date}", + "unarchive": "Restaurar grupo", + "unarchiving": "Restaurando…", + "unarchiveSuccess": "Grupo restaurado: vuelve a estar activo y visible.", + "unarchiveError": "No se pudo restaurar este grupo. Inténtalo de nuevo.", + "archive": "Archivar grupo", + "archiving": "Archivando…", + "archiveSuccess": "Grupo archivado. Su historial sigue disponible en la pestaña Archivados.", + "archiveError": "No se pudo archivar este grupo. Inténtalo de nuevo.", + "archiveConfirmTitle": "¿Archivar este grupo?", + "archiveConfirmBody": "Se ocultará de Explorar y de la pestaña de grupos activos, y pasará a solo lectura. No se elimina nada: se conservan miembros, actividad e historial, y puedes restaurarlo cuando quieras.", + "cancel": "Cancelar", + "reason": { + "completed": "Este grupo terminó y se archivó tras su periodo de revisión.", + "inactive_90d": "Archivado tras 90 días sin actividad y sin fondos de miembros.", + "admin_archived": "Archivado por el administrador del grupo.", + "emergency_withdrawn": "Archivado tras un retiro de emergencia." + } } }, "explore": { @@ -1025,7 +1073,10 @@ "poolCount": "{count, plural, =0 {Todavía no hay tandas para explorar} one {Explora # tanda — la insignia de salud muestra qué tan constantes han sido los depósitos de los miembros} other {Explora # tandas — la insignia de salud muestra qué tan constantes han sido los depósitos de los miembros}}", "nothingToExplore": "Todavía no hay nada que explorar", "nothingToExploreHint": "Cuando la gente empiece a crear tandas de ahorro, aparecerán aquí para que las explores.", - "showingRange": "Mostrando {from}–{to} de {total} tandas" + "showingRange": "Mostrando {from}–{to} de {total} tandas", + "showArchived": "Mostrar archivados", + "showArchivedHint": "Incluir grupos que han terminado o quedado inactivos", + "archivedBadge": "Archivado" }, "timeAgo": { "justNow": "justo ahora", diff --git a/frontend/package.json b/frontend/package.json index eea84d6..b180912 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,7 +12,7 @@ "format": "prettier --write .", "format:check": "prettier --check .", "start": "next start", - "test:unit": "tsx --test lib/csv-export.test.ts lib/activity-query.test.ts lib/soroban-event-mapping.test.ts lib/analytics.test.ts lib/batch-deposit.test.ts lib/deposit-calendar.test.ts lib/cctp-bridge.test.ts lib/ical-export.test.ts lib/pool-health.test.ts lib/form-validation.test.ts lib/member-filters.test.ts lib/contract-version.test.ts lib/error-reporting.test.ts lib/pending-transactions.test.ts hooks/use-keyboard-shortcuts.test.ts app/api/admin/audit-log/route.test.ts app/api/admin/actions/route.test.ts app/api/errors/route.test.ts components/error-boundary.test.ts app/api/notifications/route.test.ts app/api/user-profile/route.test.ts app/api/notifications/digest-preferences/route.test.ts app/api/notifications/digest/unsubscribe/route.test.ts app/api/cron/send-digests/route.test.ts app/api/portfolio/summary/route.test.ts app/api/templates/route.test.ts", + "test:unit": "tsx --test lib/archival.test.ts lib/csv-export.test.ts lib/activity-query.test.ts lib/soroban-event-mapping.test.ts lib/analytics.test.ts lib/batch-deposit.test.ts lib/deposit-calendar.test.ts lib/cctp-bridge.test.ts lib/ical-export.test.ts lib/pool-health.test.ts lib/form-validation.test.ts lib/member-filters.test.ts lib/contract-version.test.ts lib/error-reporting.test.ts lib/pending-transactions.test.ts hooks/use-keyboard-shortcuts.test.ts app/api/admin/audit-log/route.test.ts app/api/admin/actions/route.test.ts app/api/errors/route.test.ts components/error-boundary.test.ts app/api/notifications/route.test.ts app/api/user-profile/route.test.ts app/api/notifications/digest-preferences/route.test.ts app/api/notifications/digest/unsubscribe/route.test.ts app/api/cron/send-digests/route.test.ts app/api/portfolio/summary/route.test.ts app/api/templates/route.test.ts", "test:components": "vitest run", "test:components:coverage": "vitest run --coverage", "test:e2e": "playwright test", diff --git a/supabase/README.md b/supabase/README.md index bc63af9..8215758 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -11,6 +11,10 @@ Deno Edge Functions (`functions/`). | `send-deposit-reminders` | Scheduled (cron) | Reminds members who haven't deposited before a round deadline | | `cron/auto-trigger-payouts` | pg_cron, every 15 minutes | Auto-triggers `trigger_payout` for expired rotational pool rounds | +Pool archival runs as a Vercel Cron route rather than an Edge Function — +see [`docs/pool-archival.md`](../docs/pool-archival.md) for its schema, +criteria, and rollback path. + Deploy a function with: ```bash diff --git a/supabase/migrations/20260828000000_pool_archival.sql b/supabase/migrations/20260828000000_pool_archival.sql new file mode 100644 index 0000000..662294b --- /dev/null +++ b/supabase/migrations/20260828000000_pool_archival.sql @@ -0,0 +1,128 @@ +-- Migration: Automated pool archival and cleanup (issue #212) +-- +-- Adds the archival state to `pools` and an `archive_log` audit table. Archival +-- is purely an off-chain visibility layer: nothing here deletes pool metadata, +-- members, or activity, and the on-chain contract is untouched. An archived +-- pool is hidden from Explore / My Groups by default and rendered read-only, +-- but every historical row it owns stays queryable and exportable. + +-- ── pools: archival state ──────────────────────────────────────────────────── +-- `completed_at` / `emergency_withdrawn_at` did not exist yet; the cron needs +-- them to apply the grace periods (7 days after completion, 30 after an +-- emergency withdrawal) rather than archiving the moment a status flips. +ALTER TABLE public.pools + ADD COLUMN IF NOT EXISTS archived_at timestamptz, + ADD COLUMN IF NOT EXISTS archive_reason text, + ADD COLUMN IF NOT EXISTS completed_at timestamptz, + ADD COLUMN IF NOT EXISTS emergency_withdrawn_at timestamptz; + +DO $$ +BEGIN + -- Mirrors ARCHIVE_REASONS in frontend/lib/archival.ts. + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'pools_archive_reason_valid' + ) THEN + ALTER TABLE public.pools + ADD CONSTRAINT pools_archive_reason_valid CHECK ( + archive_reason IS NULL OR archive_reason IN + ('completed', 'inactive_90d', 'admin_archived', 'emergency_withdrawn') + ); + END IF; + + -- Both archival fields move together, so a row can never claim to be + -- archived without saying why (or carry a reason while still active). + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'pools_archive_fields_pair' + ) THEN + ALTER TABLE public.pools + ADD CONSTRAINT pools_archive_fields_pair CHECK ( + (archived_at IS NULL) = (archive_reason IS NULL) + ); + END IF; +END $$; + +-- The base `pools` table predates this repo's migrations, so its status CHECK +-- (if any) is dropped and rebuilt to admit the emergency-withdrawn state the +-- archival criteria key off. +DO $$ +DECLARE + status_constraint text; +BEGIN + SELECT conname INTO status_constraint + FROM pg_constraint + WHERE conrelid = 'public.pools'::regclass + AND contype = 'c' + AND pg_get_constraintdef(oid) ILIKE '%status%'; + + IF status_constraint IS NOT NULL THEN + EXECUTE format('ALTER TABLE public.pools DROP CONSTRAINT %I', status_constraint); + END IF; + + ALTER TABLE public.pools + ADD CONSTRAINT pools_status_valid CHECK ( + status IN ('active', 'completed', 'paused', 'emergency_withdrawn') + ); +END $$; + +-- Backfill so pools already sitting in a terminal state get a grace period +-- anchored to their last update instead of being archived on the first run. +UPDATE public.pools +SET completed_at = updated_at +WHERE status = 'completed' AND completed_at IS NULL; + +-- Explore and My Groups both filter on `archived_at IS NULL`. A partial index +-- over the active set keeps that the cheap path as archived rows accumulate — +-- the index only ever holds the pools the default queries actually return. +CREATE INDEX IF NOT EXISTS idx_pools_active_created + ON public.pools (created_at DESC) + WHERE archived_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_pools_active_creator + ON public.pools (creator_address, created_at DESC) + WHERE archived_at IS NULL; + +-- The cron's own sweep: find archivable pools without scanning archived ones. +CREATE INDEX IF NOT EXISTS idx_pools_archival_sweep + ON public.pools (status, completed_at, emergency_withdrawn_at) + WHERE archived_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_pools_archived_at + ON public.pools (archived_at DESC) + WHERE archived_at IS NOT NULL; + +-- ── archive_log: audit trail ───────────────────────────────────────────────── +-- Every archive and unarchive is recorded, automated or manual, so a pool that +-- vanished from discovery can always be traced back to the run that hid it. +CREATE TABLE IF NOT EXISTS public.archive_log ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + pool_id uuid NOT NULL REFERENCES public.pools(id) ON DELETE CASCADE, + action text NOT NULL CHECK (action IN ('archived', 'unarchived')), + reason text NOT NULL CHECK (reason IN + ('completed', 'inactive_90d', 'admin_archived', 'emergency_withdrawn')), + -- 'cron' for the daily sweep, otherwise the wallet that triggered it. + triggered_by text NOT NULL, + automated boolean NOT NULL DEFAULT false, + note text, + created_at timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT archive_log_triggered_by_lowercase + CHECK (triggered_by = lower(triggered_by)) +); + +CREATE INDEX IF NOT EXISTS idx_archive_log_pool_created + ON public.archive_log (pool_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_archive_log_created + ON public.archive_log (created_at DESC); + +-- ── Row-Level Security ─────────────────────────────────────────────────────── +ALTER TABLE public.archive_log ENABLE ROW LEVEL SECURITY; + +-- Public read, matching pool_activity / disputes: why a pool was archived is +-- part of its visible history. Writes go through the service-role key only. +CREATE POLICY "archive_log_select_public" + ON public.archive_log + FOR SELECT + USING (true); + +-- No INSERT/UPDATE/DELETE policies = direct anon writes stay denied. diff --git a/vercel.json b/vercel.json index cd6f997..845232c 100644 --- a/vercel.json +++ b/vercel.json @@ -11,6 +11,10 @@ { "path": "/api/disputes/expire", "schedule": "0 2 * * *" + }, + { + "path": "/api/cron/archive-pools", + "schedule": "0 2 * * *" } ] }