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).