feat(server): workspace restore + deleted-list endpoints (TASK-1970)#827
Merged
Conversation
Foundation for PLAN-1969 (user-recoverable workspace soft-delete). A
workspace delete only stamps workspaces.deleted_at; items/collections/
members are untouched, hidden transitively. Restore clears deleted_at so
everything re-surfaces intact.
Store (internal/store/workspaces.go):
- RestoreWorkspace(slug): UPDATE ... SET deleted_at = NULL WHERE slug=?
AND deleted_at IS NOT NULL. Returns sql.ErrNoRows (-> 404) when no
soft-deleted row matched (already live or purged).
- ListDeletedWorkspaces(userID, cutoff): owner-scoped, deleted_at within
the window, ordered deleted_at DESC. Account-deleted workspaces have no
live owner, so they never leak.
- GetDeletedWorkspaceBySlug(slug): resolves a soft-deleted row (the normal
resolvers filter deleted_at IS NULL) so the handler can tell 403 from 404.
- Dual-dialect via s.q/s.dialect; no migration (deleted_at already exists).
Handlers (internal/server/handlers_workspaces.go):
- POST /api/v1/workspaces/{slug}/restore: owner-only; 404 not-restorable,
403 non-owner, 200 + restored workspace; logs a "restored" activity.
- GET /api/v1/workspaces/deleted: owner-scoped list with per-entry
purge_at + days_left, both derived from workspacePurgeRetention so
restore and the purge sweeper share ONE 30-day window (no drift).
- Both routed outside the /{slug} RequireWorkspaceAccess subrouter (which
resolves only live workspaces); restore enforces owner authz inline.
CLI client (internal/cli/client.go): RestoreWorkspace + ListDeletedWorkspaces.
TS type (web/src/lib/types/index.ts): Workspace.deleted_at + DeletedWorkspace.
Tests: store (resurface-intact; double-restore/live -> ErrNoRows; window
boundary 29d IN / 31d OUT + owner-scoping) and handler (owner-only 403,
404 live/unknown, 200 restore, owner-scoped deleted-list). Green on
SQLite and Postgres (make test-pg); golangci-lint clean.
Closes TASK-1970
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Backend foundation for PLAN-1969 (user-recoverable workspace soft-delete). Adds the store methods, HTTP endpoints, CLI client methods, and TS type needed to restore a soft-deleted workspace and list the ones still inside the recovery window. No product-behavior change beyond adding restore/list.
Store (
internal/store/workspaces.go):RestoreWorkspace(slug)—UPDATE workspaces SET deleted_at = NULL WHERE slug=? AND deleted_at IS NOT NULL; returnssql.ErrNoRows(→ 404) when nothing soft-deleted matched. Inverse ofDeleteWorkspace.ListDeletedWorkspaces(userID, cutoff)— owner-scoped (owner_id = ?),deleted_atwithin the window, ordereddeleted_at DESC.GetDeletedWorkspaceBySlug(slug)— resolves a soft-deleted row (the normal resolvers filterdeleted_at IS NULL) so the handler can tell 403 from 404.s.q/s.dialect); no migration (deleted_atalready exists).Handlers (
internal/server/handlers_workspaces.go):POST /api/v1/workspaces/{slug}/restore— owner-only.200+ restored workspace on success;403for a live-owned non-owner;404for live/unknown/past-horizon/owner-gone. Logs arestoredactivity + publishesWorkspaceUpdated.GET /api/v1/workspaces/deleted— owner-scoped list; each entry carriesdeleted_at,purge_at, anddays_left./{slug}RequireWorkspaceAccesssubrouter (that middleware resolves only live workspaces and would 404 a soft-deleted one); restore enforces owner authz inline.CLI client (
internal/cli/client.go):RestoreWorkspace(slug)+ListDeletedWorkspaces()(T3/CLI depend on these).TS type (
web/src/lib/types/index.ts):Workspace.deleted_at+DeletedWorkspace(purge_at,days_left). Webapi.workspacesmethods land in TASK-1971.Why
The 30-day soft-delete purge window (TASK-1966) is now user-recoverable. Restore + the deleted-list share one window — both derive it from
Server.effectivePurgeRetention()(the sweeper's overridableworkspacePurgeRetention), so "N days left" never drifts from the real purge horizon.Notes
workspaces.deleted_at; items/collections/members are untouched and hidden transitively, so restore re-surfaces everything intact (proven by a store test).Testing
ErrNoRows; window boundary (29d IN / 31d OUT) + owner-scoping + DESC ordering.purge_at/days_left.golangci-lint run ./...clean,go build ./...,go test ./...(SQLite), andmake test-pg(Postgres :5445) — all green. Codex review: CLEAN.Closes TASK-1970
https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST