From 7e877bb76c57c8dc9cad9b2697b1991391683436 Mon Sep 17 00:00:00 2001 From: olathedev Date: Fri, 28 Aug 2026 07:50:21 +0100 Subject: [PATCH 1/6] feat(archival): add pool archival schema and eligibility rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the data model and the pure decision logic behind automated pool archival (issue #212). Archival is an off-chain visibility layer only — no pool metadata, member, or activity row is ever deleted, and the on-chain contract is untouched. Schema: - pools gains archived_at, archive_reason, plus the completed_at and emergency_withdrawn_at anchors the grace periods need (neither existed). - pools.status now admits 'emergency_withdrawn'. - New archive_log table records every archive/unarchive, automated or manual, so a pool that left discovery can always be traced back to the run that hid it. Public SELECT, service-role writes only, matching pool_activity. - Partial indexes over archived_at IS NULL keep the Explore and My Groups default queries off a full scan as archived rows accumulate. Rules (lib/archival.ts, framework-free so it runs under node --test): - completed + 7 day grace, emergency_withdrawn + 30 day grace, and inactive_90d, which requires silence *and* an empty balance. A quiet pool still holding member funds is deliberately never swept — that false positive would hide real money. - Paused pools are exempt: pausing is an admin decision the sweep must not undo. Unparseable or future timestamps fail closed. 30 unit tests cover each criterion, the grace boundaries, idempotency across daily runs, and the balance-derivation helpers. --- frontend/lib/archival.test.ts | 302 ++++++++++++++++++ frontend/lib/archival.ts | 193 +++++++++++ frontend/lib/supabase.ts | 47 ++- frontend/package.json | 2 +- .../20260828000000_pool_archival.sql | 128 ++++++++ 5 files changed, 668 insertions(+), 4 deletions(-) create mode 100644 frontend/lib/archival.test.ts create mode 100644 frontend/lib/archival.ts create mode 100644 supabase/migrations/20260828000000_pool_archival.sql diff --git a/frontend/lib/archival.test.ts b/frontend/lib/archival.test.ts new file mode 100644 index 0000000..db1023c --- /dev/null +++ b/frontend/lib/archival.test.ts @@ -0,0 +1,302 @@ +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/supabase.ts b/frontend/lib/supabase.ts index d9232e0..a29c37a 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/package.json b/frontend/package.json index 53b3a35..eb09ff5 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/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/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/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. From d2b02bd448ab7599aaa127c9af3005e02abaec48 Mon Sep 17 00:00:00 2001 From: olathedev Date: Fri, 28 Aug 2026 07:54:32 +0100 Subject: [PATCH 2/6] feat(api): filter archived pools and add admin archive/unarchive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/pools takes an `archived` param, default off, across all four list branches (explore, creator, member, fallback): omitted excludes archived pools, `true` includes them, `only` returns just the archived set for the My Groups "Archived" tab. The creator and explore branches filter in the query so the partial indexes do the work; the member branch filters after the join, since `pools` is an embedded relation PostgREST cannot filter without dropping the membership rows being paginated. PUT /api/pools/[id]/archive and .../unarchive give the pool creator manual control. Both are admin-only, both write an archive_log row, and both mirror into pool_activity so members see why a pool went quiet instead of finding it silently missing. Unarchive carries the previous reason onto its log row so a reversal records what it undid. Read-only is enforced server-side, not just in the UI: a shared guard blocks writes to archived pools in PATCH /api/pools, the deposit verifier, and pool chat, returning 409 with the archival reason. Hidden buttons alone would leave a stale tab or a direct request able to mutate an archived pool. GET paths are untouched — archived pools stay fully readable and exportable. --- frontend/app/api/pools/[id]/archive/route.ts | 103 ++++++++++++++++++ .../app/api/pools/[id]/unarchive/route.ts | 89 +++++++++++++++ frontend/app/api/pools/deposit/route.ts | 6 + frontend/app/api/pools/messages/route.ts | 6 + frontend/app/api/pools/route.ts | 49 +++++++-- frontend/lib/archival.test.ts | 5 +- frontend/lib/server/archival-guard.ts | 59 ++++++++++ 7 files changed, 304 insertions(+), 13 deletions(-) create mode 100644 frontend/app/api/pools/[id]/archive/route.ts create mode 100644 frontend/app/api/pools/[id]/unarchive/route.ts create mode 100644 frontend/lib/server/archival-guard.ts 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/lib/archival.test.ts b/frontend/lib/archival.test.ts index db1023c..2b6629f 100644 --- a/frontend/lib/archival.test.ts +++ b/frontend/lib/archival.test.ts @@ -295,8 +295,5 @@ test("archival — unparseable activity timestamps are skipped", () => { 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 - ) + assert.strictEqual(isArchived({ archived_at: daysAgo(1), archive_reason: "completed" }), true) }) 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 } + ) +} From 26672fec4f0395976c7d17c5ae6e728b5565b1b9 Mon Sep 17 00:00:00 2001 From: olathedev Date: Fri, 28 Aug 2026 07:55:48 +0100 Subject: [PATCH 3/6] feat(cron): add daily pool archival sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST/GET /api/cron/archive-pools, scheduled at 02:00 UTC. Vercel Cron issues a GET, so GET is the real handler and POST delegates to it for manual runs. Applies the lib/archival.ts criteria, sets archived_at and archive_reason, writes an archive_log row per pool, and notifies each affected pool's admin with the reason in plain language plus how to get the pool back. Deletes nothing. Notes on the implementation: - One batched pool_activity read for the whole sweep rather than a query per pool, unlike snapshot-pool-metrics — this runs over the same table and the per-pool loop there is already the slowest cron in the project. - The update re-asserts `archived_at IS NULL`, so a manual archive landing between the scan and the write is not overwritten. - A run wanting to archive more than 200 pools stops and reports instead. At that volume a bad backfill or clock skew is likelier than a real cliff of dead pools, and the failure mode is an emptied Explore page. - archive_log and notification failures are collected, not thrown: neither should roll back an archival that already succeeded. - Every run heartbeats into cron_job_logs, so it shows up in /api/cron/health like the existing jobs. --- frontend/app/api/cron/archive-pools/route.ts | 214 +++++++++++++++++++ vercel.json | 4 + 2 files changed, 218 insertions(+) create mode 100644 frontend/app/api/cron/archive-pools/route.ts 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/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 * * *" } ] } From 7fb1b703ef32b81eec9e4230ac4750b57080cdb6 Mon Sep 17 00:00:00 2001 From: olathedev Date: Fri, 28 Aug 2026 08:00:46 +0100 Subject: [PATCH 4/6] feat(ui): hide archived pools from discovery behind a toggle and tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explore gets a "Show archived" switch, off by default, so the feed is active pools only until asked otherwise. My Groups splits into Active and Archived tabs. Both keep their state in the URL, so an archived view survives a refresh and a back navigation from a pool's history page, and both use their own query params so paging in one does not disturb the other. New components/shared/archived-pool-card.tsx renders the compact archived row: name, type, an Archived badge, the archival reason in plain language, completion date, member count, final TVL, and View History. It deliberately skips the on-chain read, health badge, and sparkline that PoolCard does — an archived pool's numbers are final, so a page of per-card RPC calls would buy nothing. That also keeps the archived list cheap no matter how long it grows. The archived tab fetches lazily, only once opened, so never looking at it costs nothing. Full en/es strings for every new surface. --- frontend/components/dashboard/explore.tsx | 70 +++- frontend/components/dashboard/my-groups.tsx | 323 ++++++++++++++---- .../components/shared/archived-pool-card.tsx | 93 +++++ frontend/messages/en.json | 55 ++- frontend/messages/es.json | 55 ++- 5 files changed, 513 insertions(+), 83 deletions(-) create mode 100644 frontend/components/shared/archived-pool-card.tsx 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/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/messages/en.json b/frontend/messages/en.json index 3bd4381..945dc04 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 b1dab2b..89f8621 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", From 8c86c422ddcf9a81c6e8234605e2c34ad8a548e3 Mon Sep 17 00:00:00 2001 From: olathedev Date: Fri, 28 Aug 2026 08:05:33 +0100 Subject: [PATCH 5/6] feat(ui): make archived pool pages read-only with an explaining banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An archived pool's detail page now leads with a banner saying it is archived, why, and that nothing was lost — a member arriving from a bookmark should not have to guess why the deposit button disappeared. Read-only is done by removing the whole actions column, the lending tab, and the yield dashboard rather than disabling each control: the surfaces that mutate simply do not mount, and the API refuses those writes independently. The read surfaces — details, members, activity, audit logs, export — are untouched, with the activity feed labelled historical. Admins get both directions: a confirmed "Archive pool" control on an active pool, and "Restore pool" in the banner, which is the escape hatch if the daily sweep ever hides something it should not have. 7 component tests cover the banner and its reason text, the absence of every mutating control, the historical activity label, and the archived card's fields and View History link. The not-archived case asserts Quick Actions *is* present, so the hidden-controls test is proving the gate rather than a mock that never mounted them. --- frontend/__tests__/archived-pool.test.tsx | 145 ++++++++++++++ .../[locale]/dashboard/group/[id]/page.tsx | 114 +++++++---- .../components/group/archived-pool-banner.tsx | 183 ++++++++++++++++++ 3 files changed, 409 insertions(+), 33 deletions(-) create mode 100644 frontend/__tests__/archived-pool.test.tsx create mode 100644 frontend/components/group/archived-pool-banner.tsx 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/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")} + + + + + + + + + ) +} From 8745affb320559b9e6c0e7251df49a38f8a30243 Mon Sep 17 00:00:00 2001 From: olathedev Date: Fri, 28 Aug 2026 08:10:17 +0100 Subject: [PATCH 6/6] docs: document the pool archival system docs/pool-archival.md covers the schema and why completed_at / emergency_withdrawn_at had to be added, the three automated criteria and the reasoning behind the balance check in the inactivity rule, the sweep's idempotency and 200-pool safety valve, the API surface including server-side read-only enforcement, the UI, and a rollback path. Also linked from CHANGELOG.md and supabase/README.md, since the archival job is a Vercel Cron route rather than an Edge Function and would otherwise not appear alongside the other scheduled jobs. --- CHANGELOG.md | 1 + docs/pool-archival.md | 214 ++++++++++++++++++++++++++++++++++++++++++ supabase/README.md | 4 + 3 files changed, 219 insertions(+) create mode 100644 docs/pool-archival.md 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/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