Skip to content

feat(server): workspace restore + deleted-list endpoints (TASK-1970)#827

Merged
xarmian merged 1 commit into
mainfrom
feat/workspace-restore-backend
Jul 6, 2026
Merged

feat(server): workspace restore + deleted-list endpoints (TASK-1970)#827
xarmian merged 1 commit into
mainfrom
feat/workspace-restore-backend

Conversation

@xarmian

@xarmian xarmian commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

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; returns sql.ErrNoRows (→ 404) when nothing soft-deleted matched. Inverse of DeleteWorkspace.
  • ListDeletedWorkspaces(userID, cutoff) — owner-scoped (owner_id = ?), deleted_at within the window, ordered deleted_at DESC.
  • 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 (s.q / s.dialect); no migration (deleted_at already exists).

Handlers (internal/server/handlers_workspaces.go):

  • POST /api/v1/workspaces/{slug}/restore — owner-only. 200 + restored workspace on success; 403 for a live-owned non-owner; 404 for live/unknown/past-horizon/owner-gone. Logs a restored activity + publishes WorkspaceUpdated.
  • GET /api/v1/workspaces/deleted — owner-scoped list; each entry carries deleted_at, purge_at, and days_left.
  • Both routed outside the /{slug} RequireWorkspaceAccess subrouter (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). Web api.workspaces methods 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 overridable workspacePurgeRetention), so "N days left" never drifts from the real purge horizon.

Notes

  • A workspace delete only stamps workspaces.deleted_at; items/collections/members are untouched and hidden transitively, so restore re-surfaces everything intact (proven by a store test).
  • Account-deleted workspaces have no live owner, so they never appear in the list and can't be restored/probed by slug (owner-gone → 404, not 403).

Testing

  • Store tests: resurface-intact; double-restore/live → ErrNoRows; window boundary (29d IN / 31d OUT) + owner-scoping + DESC ordering.
  • Handler tests: owner restore 200; non-owner 403; live/unknown 404; past-horizon 404; owner-gone 404 (no slug probe); zero-user no-bypass; owner-scoped deleted-list with purge_at/days_left.
  • Gates: golangci-lint run ./... clean, go build ./..., go test ./... (SQLite), and make test-pg (Postgres :5445) — all green. Codex review: CLEAN.

Closes TASK-1970

https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST

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
@xarmian xarmian merged commit f5b437a into main Jul 6, 2026
4 checks passed
@xarmian xarmian deleted the feat/workspace-restore-backend branch July 6, 2026 14:07
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.

1 participant