Skip to content

feat: automated pool archival and cleanup with data preservation - #260

Merged
Sendi0011 merged 7 commits into
JointSave-org:mainfrom
olathedev:feat/pool-archival-cleanup
Aug 28, 2026
Merged

feat: automated pool archival and cleanup with data preservation#260
Sendi0011 merged 7 commits into
JointSave-org:mainfrom
olathedev:feat/pool-archival-cleanup

Conversation

@olathedev

Copy link
Copy Markdown
Contributor

Description

Implements automated pool archival and cleanup: completed, emergency-withdrawn, and genuinely dead pools are moved out of Explore and My Groups into a read-only Archived view, on a daily schedule, with manual admin control in both directions and a full audit trail.

Archival never deletes anything. 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; this is a Supabase-side visibility flag and nothing more. Every archival is reversible by the pool admin in one click.

Closes #212


What's included

Database — supabase/migrations/20260828000000_pool_archival.sql

pools gains archived_at and archive_reason, plus completed_at and emergency_withdrawn_at. A CHECK keeps archived_at and archive_reason in lockstep, so no row can claim to be archived without saying why. pools.status now also admits emergency_withdrawn.

New archive_log table records one row per archive and per unarchive — pool, action, reason, triggered_by (cron or a wallet), automated, and a note. Public SELECT, service-role writes only, matching pool_activity and disputes.

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. Because those indexes never hold archived rows, the default views get faster as pools are archived rather than slower — which is the performance acceptance criterion.

Archival rules — frontend/lib/archival.ts

Pure and framework-free, so they run under the node test runner without a database.

Reason Condition
completed status = 'completed', completed_at older than 7 days
emergency_withdrawn status = 'emergency_withdrawn', 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

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 and for the endpoint shape the issue specifies.

  • Two queries for the whole run (pools + their activity), not one query per pool.
  • The update re-asserts archived_at IS NULL in its WHERE, so a manual archive landing between the scan and the write is not overwritten.
  • 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 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 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.
  • Heartbeats into cron_job_logs, so it shows up in /api/cron/health like the existing jobs. Same Bearer ${CRON_SECRET} auth as the other crons.

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 (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 there is an embedded relation PostgREST cannot filter without dropping the membership rows being paginated.

Both mutation endpoints write an archive_log row and mirror into pool_activity, so members see why a pool went quiet rather than finding it silently missing from their list. Unarchive carries the previous reason onto its log row so a reversal records what it undid.

UI

  • Explore — "Show archived" switch, off by default. Archived pools render as grayed-out compact cards with an "Archived" badge.
  • My Groups — "Active" / "Archived" tabs. The archived tab fetches lazily, only once opened, and pages independently of the active tab.
  • components/shared/archived-pool-card.tsx — compact row with name, type, badge, reason, completion date, member count, final TVL, and "View History".
  • Pool detail page — archived pools lead with a banner stating that the pool is archived, why, and that nothing was lost. Admins get "Restore pool" there, 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 translated in messages/en.json and messages/es.json.


Design decisions worth reviewing

1. The issue assumed three columns that didn't exist. pools had no completed_at or emergency_withdrawn_at, and status only allowed active | completed | paused — but the criteria are written against all three. Without those anchors the grace periods are unimplementable: a pool would archive the instant its status flipped, and the 7-day review window in the issue would never exist. I added all three, and backfilled existing completed pools from updated_at so the first sweep gives them a grace period instead of archiving them all at once.

2. inactive_90d requires an empty balance, not just silence. This is the decision I'd most like a second opinion on. "No activity for 90 days" alone would archive a pool sitting quietly on real member deposits — and hiding a pool from the people whose money is in it is a trust problem, not a cleanup. So the rule 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), reusing the same aggregation as /api/analytics and the metrics cron, 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 the sweep must not quietly undo), and unparseable or future timestamps fail closed — a bad date keeps a pool visible rather than hiding it.

3. Read-only is enforced server-side, not just in the UI. Hiding the deposit button is presentation. lib/server/archival-guard.ts blocks writes aimed at an archived pool at the API boundary, returning 409 with the archival reason, on PATCH /api/pools, POST /api/pools/deposit, and 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 so archived pools stay fully readable and exportable.

On the page itself, read-only is done by not mounting the actions column, lending tab, and yield dashboard, rather than disabling each control individually — there is no disabled button to re-enable in devtools. Details, members, activity, audit logs, and export are unchanged, with the activity feed labelled historical.

4. The archived card deliberately does less than PoolCard. No on-chain read, no health badge, no sparkline. An archived pool's numbers are final, so per-card RPC calls would buy nothing, and the archived list is exactly where a page of them would be pure waste. It also keeps that list cheap however long it grows.


Type of Change

  • feat: new feature
  • fix: bug fix
  • chore: maintenance, tooling, dependencies
  • docs: documentation only
  • refactor: code restructuring (no functional changes)
  • test: adding or updating tests

How Has This Been Tested?

  • cargo testnot run: no smart-contract changes in this PR. Archival is entirely off-chain.
  • pnpm build succeeds — compiles clean, and all three new routes register (/api/cron/archive-pools, /api/pools/[id]/archive, /api/pools/[id]/unarchive)
  • pnpm lint passes with 0 errors
  • pnpm test:unit273 passing, including 30 new tests for the archival rules
  • pnpm test:components128 passing (up from 121; 7 new), 20 files
  • prettier --check clean on every file this PR touches
  • Manual testing — not performed. I don't have a Supabase instance with the migration applied, so the cron sweep and the archive/unarchive endpoints have not been exercised against a live database. Everything below the API boundary is covered by tests; a maintainer running this against staging before merge would be worthwhile, particularly the first cron run against real data.

New unit tests — lib/archival.test.ts (30)

Each criterion and both sides of every grace boundary (day 6 vs 7, day 29 vs 31, day 89 vs 90); the balance guard, including the case that matters most — a pool silent for 290 days but still holding funds is asserted not archived; paused pools exempt; idempotency across daily runs; NaN, unparseable, and future timestamps failing closed; and the balance/latest-activity derivation helpers.

New component tests — __tests__/archived-pool.test.tsx (7)

The archived banner and its reason text; the absence of every mutating control on an archived pool; 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 archival gate rather than a mock that never mounted them in the first place.

Note on the existing typecheck baseline

tsc --noEmit reports 73 errors on this branch — and 73 on main, unchanged. This PR introduces none, and none are in files it touches. They live in GroupClient.tsx, app/[locale]/join/[contractId]/page.tsx, and app/api/admin/pools/route.ts; next.config.mjs sets ignoreBuildErrors: true, so they don't block the build. Worth a separate issue, but out of scope here.


Checklist

  • My code follows the coding conventions of this project
  • I have added/updated tests if needed
  • I have updated documentation if needed — docs/pool-archival.md, plus CHANGELOG.md and a pointer from supabase/README.md (the archival job is a Vercel Cron route rather than an Edge Function, so it would otherwise not appear alongside the other scheduled jobs)
  • My changes generate no new warnings or errors

Deployment notes

  1. Apply supabase/migrations/20260828000000_pool_archival.sql. It is additive and nullable throughout; the only destructive step is dropping and rebuilding the pools.status CHECK constraint to admit emergency_withdrawn.
  2. vercel.json gains the 02:00 UTC cron entry. It needs the existing CRON_SECRET — no new environment variables.
  3. Consider running the sweep manually once against staging first and inspecting the response before letting the schedule fire:
    curl -X POST https://<host>/api/cron/archive-pools -H "Authorization: Bearer $CRON_SECRET"
    # → { "scanned": 412, "archived": 7, "byReason": { "completed": 5, "inactive_90d": 2 }, "errors": [] }

Rollback

Low-risk, because the feature is a visibility layer:

  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 full history of what had been archived and why.

The columns and table can be left in place — they are additive and nullable. Full detail in docs/pool-archival.md.

Introduces the data model and the pure decision logic behind automated pool
archival (issue JointSave-org#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.
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.
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.
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.
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.
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.
Resolves the conflict with the CCTP bridge feature (JointSave-org#253, PR JointSave-org#258).

Only frontend/package.json conflicted: both branches appended a suite to
test:unit. Kept both — lib/archival.test.ts and lib/cctp-bridge.test.ts —
rather than taking a side, so neither feature's tests stop running.

lib/supabase.ts, messages/en.json, and messages/es.json auto-merged cleanly;
each side added its own keys in a different part of the file. Verified both
features survived: archive_log/ArchiveReason and bridge_transactions are both
in the Database type, and both message files carry the archival keys and all
36 bridge keys.

Post-merge: 291 unit tests pass (273 + the bridge's 18), 128 component tests
pass, lint clean, build compiles and registers the routes from both features.
@Sendi0011
Sendi0011 self-requested a review August 28, 2026 09:21

@Sendi0011 Sendi0011 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lfg!

@Sendi0011
Sendi0011 merged commit 314f93b into JointSave-org:main Aug 28, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Implement automated pool archival and cleanup for completed/inactive pools with data preservation

2 participants