Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/api/src/services/serializers/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export interface ProjectDetail {
readonly featured: boolean;
readonly createdAt: string;
readonly updatedAt: string;
readonly deletedAt: string | null;
}

export interface HelpWantedRoleSummary {
Expand Down Expand Up @@ -243,5 +244,6 @@ export function serializeProjectDetail(
featured: project.featured,
createdAt: project.createdAt,
updatedAt: project.updatedAt,
deletedAt: project.deletedAt ?? null,
};
}
1 change: 1 addition & 0 deletions apps/web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ export interface ProjectDetail {
readonly featured: boolean;
readonly createdAt: string;
readonly updatedAt: string;
readonly deletedAt: string | null;
}

export interface PersonListItem {
Expand Down
22 changes: 22 additions & 0 deletions apps/web/src/screens/ProjectDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>): Promise<void> => {
setMemberBusy(true);
Expand All @@ -154,6 +158,24 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) {

return (
<div className="container mx-auto px-4 py-8">
{/* Soft-delete banner — staff only (project-detail.md) */}
{showDeletedBanner && (
<div
role="status"
className="mb-6 flex flex-wrap items-center justify-between gap-3 rounded-md border border-yellow-400 bg-yellow-50 px-4 py-3 text-sm text-yellow-900"
>
<span>This project is deleted — only staff can see it.</span>
<Button
size="sm"
variant="outline"
disabled={memberBusy}
onClick={() => void runMembership(() => api.projects.restore(slug).then(() => undefined))}
>
{memberBusy ? 'Restoring…' : 'Restore'}
</Button>
</div>
)}

{/* Header */}
<div className="mb-6">
<div className="flex items-start justify-between gap-4 mb-3">
Expand Down
19 changes: 19 additions & 0 deletions apps/web/tests/ProjectDetail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
5 changes: 4 additions & 1 deletion specs/api/projects.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down