From 344c30ea1923fe2cf7546491b34903bc51bb53eb Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sat, 27 Jun 2026 05:15:51 -0400 Subject: [PATCH] feat(web): soft-delete banner on ProjectDetail for staff (#113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit project-detail.md declares a staff-only banner across the top of a soft-deleted project with a Restore action; the SPA never rendered it because the project response didn't carry deletedAt. - Expose `deletedAt` on the project detail response (serializer + spec api/projects.md). Null for active projects; non-null only when staff fetch a soft-deleted one (non-staff get 404). - Render the yellow banner with a Restore button (reuses the existing action runner + project refetch). Gated on deletedAt + staff accountLevel. - Tests: banner shows for staff + deleted, absent for active. The "More ▼" header dropdown (the other half of this ProjectDetail pairing in #113) is a separate follow-up — it's a pure UI refactor with no API dependency. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/services/serializers/project.ts | 2 ++ apps/web/src/lib/api.ts | 1 + apps/web/src/screens/ProjectDetail.tsx | 22 ++++++++++++++++++++ apps/web/tests/ProjectDetail.test.tsx | 19 +++++++++++++++++ specs/api/projects.md | 5 ++++- 5 files changed, 48 insertions(+), 1 deletion(-) diff --git a/apps/api/src/services/serializers/project.ts b/apps/api/src/services/serializers/project.ts index dd996fb..2fbe21a 100644 --- a/apps/api/src/services/serializers/project.ts +++ b/apps/api/src/services/serializers/project.ts @@ -76,6 +76,7 @@ export interface ProjectDetail { readonly featured: boolean; readonly createdAt: string; readonly updatedAt: string; + readonly deletedAt: string | null; } export interface HelpWantedRoleSummary { @@ -243,5 +244,6 @@ export function serializeProjectDetail( featured: project.featured, createdAt: project.createdAt, updatedAt: project.updatedAt, + deletedAt: project.deletedAt ?? null, }; } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index aad8868..4a9c1d7 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -175,6 +175,7 @@ export interface ProjectDetail { readonly featured: boolean; readonly createdAt: string; readonly updatedAt: string; + readonly deletedAt: string | null; } export interface PersonListItem { diff --git a/apps/web/src/screens/ProjectDetail.tsx b/apps/web/src/screens/ProjectDetail.tsx index 2441aa6..2a4c5b9 100644 --- a/apps/web/src/screens/ProjectDetail.tsx +++ b/apps/web/src/screens/ProjectDetail.tsx @@ -136,6 +136,10 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { const isSoleMaintainer = (myMembership?.isMaintainer ?? false) && maintainerCount === 1; const canJoin = isSignedIn && !isMember; const canLeave = isMember && !isSoleMaintainer; + // Only staff can see a soft-deleted project at all (non-staff get 404), so a + // non-null deletedAt here means the viewer is staff; gate anyway for clarity. + const isStaff = person?.accountLevel === 'staff' || person?.accountLevel === 'administrator'; + const showDeletedBanner = project.deletedAt !== null && isStaff; const runMembership = async (fn: () => Promise): Promise => { setMemberBusy(true); @@ -154,6 +158,24 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { return (
+ {/* Soft-delete banner — staff only (project-detail.md) */} + {showDeletedBanner && ( +
+ This project is deleted — only staff can see it. + +
+ )} + {/* Header */}
diff --git a/apps/web/tests/ProjectDetail.test.tsx b/apps/web/tests/ProjectDetail.test.tsx index 5c2e654..baf4206 100644 --- a/apps/web/tests/ProjectDetail.test.tsx +++ b/apps/web/tests/ProjectDetail.test.tsx @@ -257,4 +257,23 @@ describe('ProjectDetail', () => { }); expect(screen.queryByRole('button', { name: /join project/i })).not.toBeInTheDocument(); }); + + it('shows the soft-delete banner + Restore for staff viewing a deleted project', async () => { + const deleted = { ...PROJECT, deletedAt: '2026-06-01T00:00:00Z' } as unknown as typeof PROJECT; + mockSignedIn(deleted, 'staff'); + renderDetail(); + await waitFor(() => { + expect(screen.getByText(/this project is deleted/i)).toBeInTheDocument(); + }); + expect(screen.getByRole('button', { name: /restore/i })).toBeInTheDocument(); + }); + + it('does not show the soft-delete banner for an active project', async () => { + mockSignedIn({ ...PROJECT, deletedAt: null } as unknown as typeof PROJECT, 'staff'); + renderDetail(); + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Sample Project', level: 1 })).toBeInTheDocument(); + }); + expect(screen.queryByText(/this project is deleted/i)).not.toBeInTheDocument(); + }); }); diff --git a/specs/api/projects.md b/specs/api/projects.md index f0ffb13..488607c 100644 --- a/specs/api/projects.md +++ b/specs/api/projects.md @@ -130,12 +130,15 @@ Fetches a single project by slug. "canDelete": false }, "createdAt": "...", - "updatedAt": "..." + "updatedAt": "...", + "deletedAt": "..." | null } ``` `permissions` is the *current caller's* permissions on this project — the frontend uses it to decide which actions to render. The server still enforces the same rules on each mutation endpoint. +`deletedAt` is `null` for active projects. It is non-null only when a soft-deleted project is fetched by staff (non-staff get `404`), so the SPA can render the soft-delete banner with a Restore action. + ### Errors - `404 not_found` — slug doesn't match (or is soft-deleted and caller can't see deleted).