From 5646dab359eb3c1cd8f252de5011f42b3980928c Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Fri, 26 Jun 2026 10:58:06 -0400 Subject: [PATCH 01/15] fix(web): enlarge header logo to fill the navbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The horizontal logo sat at h-8 (32px) in the h-14 (56px) bar, leaving ~12px of dead space above/below. Bump to h-12 (48px) so it fills ~86% of the bar height with a 4px gap top and bottom — a fuller, more present lockup. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/components/AppHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/AppHeader.tsx b/apps/web/src/components/AppHeader.tsx index 5a8d1f6..c0d6516 100644 --- a/apps/web/src/components/AppHeader.tsx +++ b/apps/web/src/components/AppHeader.tsx @@ -197,7 +197,7 @@ export function AppHeader() { Code for Philly From 8321e21f52112d40095809cd4e820b12c9b33ef4 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Fri, 26 Jun 2026 13:21:49 -0400 Subject: [PATCH 02/15] feat(web): add Join/Leave project buttons (#113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProjectDetail declared "Join Project" / "Leave project" actions in the spec (project-detail.md) and the API endpoints existed, but the SPA never rendered the buttons. Add them to the sidebar: - Join Project — signed-in users who aren't members (POST .../members/join) - Leave project — members, except a sole maintainer who must transfer the role first (POST .../members/leave); shows an explanatory hint instead. Membership is computed client-side from the members list + the signed-in user (the project response carries no per-viewer flag). Added api client join/leave methods and ProjectDetail tests for both states. First slice of the #113 UI-gaps umbrella. The soft-delete banner (the other half of the ProjectDetail pairing) is deferred — it needs the project response to expose `deletedAt`, a small API/serializer change tracked under #113. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/lib/api.ts | 4 ++ apps/web/src/screens/ProjectDetail.tsx | 63 ++++++++++++++++++++++++ apps/web/tests/ProjectDetail.test.tsx | 66 ++++++++++++++++++++++++++ 3 files changed, 133 insertions(+) diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 3a7bcb9..aad8868 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -591,6 +591,10 @@ export const api = { request(`/api/projects/${encodeURIComponent(slug)}`, { method: 'DELETE' }), restore: (slug: string): Promise> => request(`/api/projects/${encodeURIComponent(slug)}/restore`, { method: 'POST' }), + join: (slug: string): Promise => + request(`/api/projects/${encodeURIComponent(slug)}/members/join`, { method: 'POST' }), + leave: (slug: string): Promise => + request(`/api/projects/${encodeURIComponent(slug)}/members/leave`, { method: 'POST' }), changeMaintainer: (slug: string, personSlug: string): Promise> => request(`/api/projects/${encodeURIComponent(slug)}/change-maintainer`, { method: 'POST', diff --git a/apps/web/src/screens/ProjectDetail.tsx b/apps/web/src/screens/ProjectDetail.tsx index 7a27e7f..2441aa6 100644 --- a/apps/web/src/screens/ProjectDetail.tsx +++ b/apps/web/src/screens/ProjectDetail.tsx @@ -51,6 +51,8 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { const [interestRole, setInterestRole] = useState(null); const [fillRole, setFillRole] = useState(null); const [stageInfoOpen, setStageInfoOpen] = useState(false); + const [memberBusy, setMemberBusy] = useState(false); + const [memberError, setMemberError] = useState(null); // Allow ?openModal=help-wanted (from /help-wanted "Post a role" picker). // Use the state-sync pattern so we don't trigger a cascading re-render. @@ -122,6 +124,32 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { const helpWantedRoles = helpWantedQ.data?.data ?? []; const perms = project.permissions; + // #113 — Join / Leave the project. The endpoints exist; the UI was missing. + // The project response carries no per-viewer membership flag, so membership is + // derived from the members list + the signed-in user. + const myMembership = person + ? project.memberships.find((m) => m.person.slug === person.slug) + : undefined; + const isMember = myMembership !== undefined; + const maintainerCount = project.memberships.filter((m) => m.isMaintainer).length; + // A sole maintainer must transfer the role before leaving (project-detail.md authz). + const isSoleMaintainer = (myMembership?.isMaintainer ?? false) && maintainerCount === 1; + const canJoin = isSignedIn && !isMember; + const canLeave = isMember && !isSoleMaintainer; + + const runMembership = async (fn: () => Promise): Promise => { + setMemberBusy(true); + setMemberError(null); + try { + await fn(); + await projectQ.refetch(); + } catch (err) { + setMemberError(err instanceof ApiError ? err.message : 'Something went wrong. Please try again.'); + } finally { + setMemberBusy(false); + } + }; + const allTags = [...project.tags.tech, ...project.tags.topic, ...project.tags.event]; return ( @@ -297,6 +325,41 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { {/* Sidebar */} ); diff --git a/apps/web/tests/PersonDetail.test.tsx b/apps/web/tests/PersonDetail.test.tsx index 18f69ea..e5d2642 100644 --- a/apps/web/tests/PersonDetail.test.tsx +++ b/apps/web/tests/PersonDetail.test.tsx @@ -99,4 +99,54 @@ describe('PersonDetail Contact sidebar', () => { expect(mailto).toHaveAttribute('href', 'mailto:jane@example.com'); }); }); + + // #113 — "Manage account" link, self only + it('shows a "Manage account" link to /account when viewing your own profile', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string) => { + if (input.startsWith('/api/auth/me')) { + return Promise.resolve( + new Response( + JSON.stringify( + mockOk({ + person: { id: BASE_PERSON.id, slug: 'jane-doe', fullName: 'Jane Doe', accountLevel: 'user', avatarUrl: null }, + accountLevel: 'user', + }), + ), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ); + } + if (input.startsWith('/api/people/jane-doe')) { + return Promise.resolve(new Response(JSON.stringify(mockOk(BASE_PERSON)), { status: 200, headers: { 'content-type': 'application/json' } })); + } + return Promise.resolve(new Response(null, { status: 404 })); + }) as typeof fetch); + renderScreen( + + + } /> + + , + { initialEntries: ['/members/jane-doe'] }, + ); + await waitFor(() => { + expect(screen.getByRole('link', { name: /manage account/i })).toHaveAttribute('href', '/account'); + }); + }); + + it('does not show "Manage account" for anonymous viewers', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation(makeFetchMock(BASE_PERSON)); + renderScreen( + + + } /> + + , + { initialEntries: ['/members/jane-doe'] }, + ); + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Jane Doe', level: 1 })).toBeInTheDocument(); + }); + expect(screen.queryByRole('link', { name: /manage account/i })).not.toBeInTheDocument(); + }); }); From 344c30ea1923fe2cf7546491b34903bc51bb53eb Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sat, 27 Jun 2026 05:15:51 -0400 Subject: [PATCH 04/15] 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). From d0ab542fcdde787fb350981bbc70662f3eefc471 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sat, 27 Jun 2026 05:30:19 -0400 Subject: [PATCH 05/15] feat(blog): excerpt fallback + tag chips (#113) Two blog UI gaps from the spec-drift audit: - BlogIndex (blog-index.md): when `summary` is null, fall back to the first paragraph of `bodyHtml` truncated to ~280 chars (plain-text, derived client-side from the already-sanitized HTML). - BlogDetail (blog-detail.md): render the post's tags as chips in the footer linking to `/blog?tag=`. The blog response didn't carry tags, so the serializer now resolves them (tag-assignments where taggableType=blog_post) and includes a `tags` field (spec api/blog.md updated). Web type + UI added. Tests: BlogIndex excerpt fallback; BlogDetail chips present/absent; API blog suite still green (tags in response). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/services/blog-post.ts | 13 +++- .../api/src/services/serializers/blog-post.ts | 14 +++- apps/web/src/lib/api.ts | 1 + apps/web/src/screens/BlogDetail.tsx | 13 ++++ apps/web/src/screens/BlogIndex.tsx | 23 +++++- apps/web/tests/BlogDetail.test.tsx | 70 +++++++++++++++++++ apps/web/tests/BlogIndex.test.tsx | 29 ++++++++ specs/api/blog.md | 1 + 8 files changed, 156 insertions(+), 8 deletions(-) create mode 100644 apps/web/tests/BlogDetail.test.tsx diff --git a/apps/api/src/services/blog-post.ts b/apps/api/src/services/blog-post.ts index aedceed..62f032c 100644 --- a/apps/api/src/services/blog-post.ts +++ b/apps/api/src/services/blog-post.ts @@ -4,7 +4,7 @@ * Per specs/api/blog.md. Writes happen via PR to the data repo, not the * runtime — no mutation methods here. */ -import type { BlogPost } from '@cfp/shared/schemas'; +import type { BlogPost, Tag } from '@cfp/shared/schemas'; import type { InMemoryState } from '../store/memory/state.js'; import { serializeBlogPost, type BlogPostResponse } from './serializers/blog-post.js'; @@ -66,6 +66,15 @@ export class BlogPostService { #serialize(post: BlogPost): BlogPostResponse { const author = post.authorId ? (this.#state.people.get(post.authorId) ?? null) : null; - return serializeBlogPost(post, { author }); + return serializeBlogPost(post, { author, tags: this.#tagsFor(post.id) }); + } + + #tagsFor(postId: string): Tag[] { + const taIds = this.#state.tagAssignmentsByTaggable.get(postId) ?? new Set(); + return [...taIds] + .map((taId) => this.#state.tagAssignments.get(taId)) + .filter((ta): ta is NonNullable => ta?.taggableType === 'blog_post') + .map((ta) => this.#state.tags.get(ta.tagId)) + .filter((t): t is Tag => t !== undefined); } } diff --git a/apps/api/src/services/serializers/blog-post.ts b/apps/api/src/services/serializers/blog-post.ts index 5b5747e..e4de21d 100644 --- a/apps/api/src/services/serializers/blog-post.ts +++ b/apps/api/src/services/serializers/blog-post.ts @@ -1,8 +1,14 @@ /** * BlogPost serializer. */ -import type { BlogPost, Person } from '@cfp/shared/schemas'; -import { renderMarkdown, serializePersonAvatar, type PersonAvatar } from './common.js'; +import type { BlogPost, Person, Tag } from '@cfp/shared/schemas'; +import { + renderMarkdown, + serializePersonAvatar, + serializeTagItem, + type PersonAvatar, + type TagItem, +} from './common.js'; export interface BlogPostResponse { readonly id: string; @@ -16,13 +22,14 @@ export interface BlogPostResponse { readonly featuredImageUrl: string | null; readonly body: string; readonly bodyHtml: string; + readonly tags: TagItem[]; readonly createdAt: string; readonly updatedAt: string; } export function serializeBlogPost( post: BlogPost, - opts: { author: Person | null }, + opts: { author: Person | null; tags?: Tag[] }, ): BlogPostResponse { return { id: post.id, @@ -38,6 +45,7 @@ export function serializeBlogPost( : null, body: post.body, bodyHtml: renderMarkdown(post.body).html, + tags: (opts.tags ?? []).map(serializeTagItem), createdAt: post.createdAt, updatedAt: post.updatedAt, }; diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 4a9c1d7..94130a3 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -297,6 +297,7 @@ export interface BlogPostResponse { readonly featuredImageUrl: string | null; readonly body: string; readonly bodyHtml: string; + readonly tags: TagItem[]; readonly createdAt: string; readonly updatedAt: string; } diff --git a/apps/web/src/screens/BlogDetail.tsx b/apps/web/src/screens/BlogDetail.tsx index e7e2a12..8fcbb1c 100644 --- a/apps/web/src/screens/BlogDetail.tsx +++ b/apps/web/src/screens/BlogDetail.tsx @@ -68,6 +68,19 @@ export function BlogDetail() {
+ {post.tags.length > 0 && ( +
+ {post.tags.map((t) => ( + + {t.title} + + ))} +
+ )} ← Back to all posts diff --git a/apps/web/src/screens/BlogIndex.tsx b/apps/web/src/screens/BlogIndex.tsx index 36e1253..8d3c383 100644 --- a/apps/web/src/screens/BlogIndex.tsx +++ b/apps/web/src/screens/BlogIndex.tsx @@ -147,15 +147,32 @@ function BlogIndexCard({ post }: { post: BlogPostResponse }) { ) : null}
- {post.summary && ( -

{post.summary}

- )} + {(() => { + // blog-index.md Display Rules: show `summary`; if absent, fall back + // to the first paragraph of bodyHtml truncated to ~280 chars. + const text = post.summary ?? excerptFromHtml(post.bodyHtml); + return text ? ( +

{text}

+ ) : null; + })()}
); } +/** + * Plain-text excerpt from already-sanitized post HTML: the first paragraph's + * text, truncated to ~280 chars at a word boundary. Only strips tags for the + * preview — the full post renders via the server-sanitized HTML elsewhere. + */ +function excerptFromHtml(html: string): string { + const firstParagraph = /]*>(.*?)<\/p>/is.exec(html)?.[1] ?? html; + const text = firstParagraph.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(); + if (text.length <= 280) return text; + return text.slice(0, 280).replace(/\s+\S*$/, '') + '…'; +} + function formatPostedAt(iso: string): string { const d = new Date(iso); return d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); diff --git a/apps/web/tests/BlogDetail.test.tsx b/apps/web/tests/BlogDetail.test.tsx new file mode 100644 index 0000000..a33e23e --- /dev/null +++ b/apps/web/tests/BlogDetail.test.tsx @@ -0,0 +1,70 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import { Routes, Route } from 'react-router'; +import { renderScreen, mockOk } from './test-utils.js'; +import { BlogDetail } from '../src/screens/BlogDetail.js'; +import { AuthProvider } from '../src/hooks/useAuth.js'; + +const POST = { + id: '01951a3c-0000-7000-8000-bbbbbbbbbbbb', + slug: 'roundup', + title: 'Civic Tech Roundup', + summary: null, + author: null, + postedAt: '2026-05-10T12:00:00Z', + editedAt: null, + featuredImageKey: null, + featuredImageUrl: null, + body: '# x', + bodyHtml: '

Body

', + tags: [{ namespace: 'topic', slug: 'transit', title: 'Transit' }], + createdAt: '2026-05-10T12:00:00Z', + updatedAt: '2026-05-10T12:00:00Z', +}; + +describe('BlogDetail tag chips', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + function mock(post: typeof POST): void { + vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string) => { + if (input.startsWith('/api/auth/me')) return Promise.resolve(new Response(null, { status: 404 })); + if (input.startsWith('/api/blog-posts/roundup')) { + return Promise.resolve( + new Response(JSON.stringify(mockOk(post)), { status: 200, headers: { 'content-type': 'application/json' } }), + ); + } + return Promise.resolve(new Response(null, { status: 404 })); + }) as typeof fetch); + } + + function render(): void { + renderScreen( + + + } /> + + , + { initialEntries: ['/blog/roundup'] }, + ); + } + + it('renders tag chips linking to /blog?tag=', async () => { + mock(POST); + render(); + await waitFor(() => { + expect(screen.getByRole('link', { name: 'Transit' })).toBeInTheDocument(); + }); + expect(screen.getByRole('link', { name: 'Transit' })).toHaveAttribute('href', '/blog?tag=topic.transit'); + }); + + it('renders no tag chips when the post has no tags', async () => { + mock({ ...POST, tags: [] } as unknown as typeof POST); + render(); + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Civic Tech Roundup' })).toBeInTheDocument(); + }); + expect(screen.queryByRole('link', { name: 'Transit' })).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/tests/BlogIndex.test.tsx b/apps/web/tests/BlogIndex.test.tsx index 9b0f06b..8a4048c 100644 --- a/apps/web/tests/BlogIndex.test.tsx +++ b/apps/web/tests/BlogIndex.test.tsx @@ -68,6 +68,35 @@ describe('BlogIndex', () => { expect(screen.getByText('A short blurb.')).toBeInTheDocument(); }); + it('falls back to a bodyHtml first-paragraph excerpt when summary is null', async () => { + const noSummary = { + ...SAMPLE_POST, + summary: null, + bodyHtml: '

Heading

First paragraph of the body.

Second.

', + }; + vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string) => { + if (input.startsWith('/api/auth/me')) return Promise.resolve(new Response(null, { status: 404 })); + if (input.startsWith('/api/blog-posts')) { + return Promise.resolve( + new Response(JSON.stringify(mockPaginated([noSummary], { totalItems: 1 })), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + } + return Promise.resolve(new Response(null, { status: 404 })); + }) as typeof fetch); + renderScreen( + + + , + { initialEntries: ['/blog'] }, + ); + await waitFor(() => { + expect(screen.getByText('First paragraph of the body.')).toBeInTheDocument(); + }); + }); + it('renders the empty state when no posts are returned', async () => { vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string) => { if (input.startsWith('/api/auth/me')) { diff --git a/specs/api/blog.md b/specs/api/blog.md index 99072fa..4447190 100644 --- a/specs/api/blog.md +++ b/specs/api/blog.md @@ -60,6 +60,7 @@ Standard 404 envelope (per [conventions.md](conventions.md)). Slug-history redir "featuredImageUrl": "/api/attachments/blog-posts/civic-tech-roundup-2026/cover.jpg", // or null — derived from featuredImageKey "body": "Markdown source", "bodyHtml": "

...

", // sanitized HTML, server-rendered + "tags": [{ "namespace": "topic", "slug": "transit", "title": "Transit" }, ...], // tags assigned to the post; [] when none "createdAt": "...", "updatedAt": "..." } From 786d8f3d426277e1f8a673d586926931d4820227 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sat, 27 Jun 2026 05:38:11 -0400 Subject: [PATCH 06/15] =?UTF-8?q?feat(web):=20consolidate=20ProjectDetail?= =?UTF-8?q?=20header=20actions=20into=20"More=20=E2=96=BE"=20(#113)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per project-detail.md, the header keeps "Edit Project" as the primary button and moves the secondary actions into a "More ▾" dropdown: Add Member, Log Buzz, Post Update, Post Help-Wanted Role, Manage Members, and (admin) Delete Project behind a confirm dialog. The previously-contextual section buttons (Post Update, Log Buzz, Post new role) are removed now that they live in the header dropdown — single source per the spec. Delete soft-deletes via the existing endpoint and refetches, so the soft-delete banner (from the prior PR) appears for staff immediately. Tests: dropdown trigger present for management perms, absent for anonymous. Completes the #113 UI-gaps umbrella. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/screens/ProjectDetail.tsx | 133 +++++++++++++++++++------ apps/web/tests/ProjectDetail.test.tsx | 28 ++++++ 2 files changed, 131 insertions(+), 30 deletions(-) diff --git a/apps/web/src/screens/ProjectDetail.tsx b/apps/web/src/screens/ProjectDetail.tsx index 2a4c5b9..35d592e 100644 --- a/apps/web/src/screens/ProjectDetail.tsx +++ b/apps/web/src/screens/ProjectDetail.tsx @@ -2,6 +2,21 @@ import { useEffect, useMemo, useState } from 'react'; import { Link, useParams, useSearchParams } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, +} from '@/components/ui/dropdown-menu'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; import { MarkdownView } from '@/components/MarkdownView'; import { StageProgressBar, StageBadge } from '@/components/StageBadge'; import { StageInfoDialog } from '@/components/StageInfoDialog'; @@ -53,6 +68,7 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { const [stageInfoOpen, setStageInfoOpen] = useState(false); const [memberBusy, setMemberBusy] = useState(false); const [memberError, setMemberError] = useState(null); + const [deleteOpen, setDeleteOpen] = useState(false); // Allow ?openModal=help-wanted (from /help-wanted "Post a role" picker). // Use the state-sync pattern so we don't trigger a cascading re-render. @@ -154,6 +170,20 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { } }; + const doDelete = async (): Promise => { + setMemberBusy(true); + setMemberError(null); + try { + await api.projects.delete(slug); + await projectQ.refetch(); + setDeleteOpen(false); + } catch (err) { + setMemberError(err instanceof ApiError ? err.message : 'Could not delete the project.'); + } finally { + setMemberBusy(false); + } + }; + const allTags = [...project.tags.tech, ...project.tags.topic, ...project.tags.event]; return ( @@ -186,15 +216,54 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { Edit Project )} - {perms.canManageMembers && ( - - )} - {perms.canManageMembers && ( - + {(perms.canManageMembers || + perms.canPostUpdate || + perms.canLogBuzz || + perms.canPostHelpWanted || + perms.canDelete) && ( + + + + + + {perms.canManageMembers && ( + setAddMemberOpen(true)}> + Add Member + + )} + {perms.canLogBuzz && ( + + Log Buzz + + )} + {perms.canPostUpdate && ( + setUpdateModalOpen(true)}> + Post Update + + )} + {perms.canPostHelpWanted && ( + setHelpWantedModalOpen(true)}> + Post Help-Wanted Role + + )} + {perms.canManageMembers && ( + setManageMembersOpen(true)}> + Manage Members + + )} + {perms.canDelete && ( + <> + + setDeleteOpen(true)} + > + Delete Project + + + )} + + )} {!isSignedIn && ( - )} {helpWantedRoles.length === 0 ? (

No open roles right now.

@@ -303,22 +367,6 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) {

Project Activity

-
- {perms.canPostUpdate && ( - - )} - {isSignedIn && ( - - )} -
{updatesQ.isLoading || buzzQ.isLoading ? ( @@ -604,6 +652,31 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { roleTitle={fillRole.title} /> )} + + + + + Delete this project? + + “{project.title}” will be soft-deleted and hidden from public lists. Staff can + restore it afterward. + + + {memberError && ( +

+ {memberError} +

+ )} + + + + +
+
); } diff --git a/apps/web/tests/ProjectDetail.test.tsx b/apps/web/tests/ProjectDetail.test.tsx index baf4206..ff4c486 100644 --- a/apps/web/tests/ProjectDetail.test.tsx +++ b/apps/web/tests/ProjectDetail.test.tsx @@ -276,4 +276,32 @@ describe('ProjectDetail', () => { }); expect(screen.queryByText(/this project is deleted/i)).not.toBeInTheDocument(); }); + + it('shows the "More" actions dropdown for users with management permissions', async () => { + const asAdmin = { + ...PROJECT, + permissions: { ...PROJECT.permissions, canManageMembers: true, canPostUpdate: true, canDelete: true }, + } as unknown as typeof PROJECT; + mockSignedIn(asAdmin, 'administrator'); + renderDetail(); + await waitFor(() => { + expect(screen.getByRole('button', { name: /more/i })).toBeInTheDocument(); + }); + }); + + it('shows no "More" dropdown for anonymous viewers', async () => { + // default beforeEach mock is anonymous with no permissions + renderScreen( + + + } /> + + , + { initialEntries: ['/projects/sample-project'] }, + ); + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Sample Project', level: 1 })).toBeInTheDocument(); + }); + expect(screen.queryByRole('button', { name: /more/i })).not.toBeInTheDocument(); + }); }); From 21eef0016e9992b5f71e5ff4be61d8d25911a2b8 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sat, 27 Jun 2026 18:01:14 -0400 Subject: [PATCH 07/15] chore(plans): open import-person-avatars Co-Authored-By: Claude Opus 4.8 (1M context) --- plans/import-person-avatars.md | 67 ++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 plans/import-person-avatars.md diff --git a/plans/import-person-avatars.md b/plans/import-person-avatars.md new file mode 100644 index 0000000..f9a5a07 --- /dev/null +++ b/plans/import-person-avatars.md @@ -0,0 +1,67 @@ +--- +status: done +depends: [] +specs: [] +issues: + - 130 +pr: +--- + +# Plan: import legacy person avatars + +## Scope + +Leadership feedback (#130): legacy users show initials where their codeforphilly.org +photo used to be. The importer brought blog-post media but never person avatars, +so imported people had `avatarKey: null`. + +A spike against the live laddr API found the source: `person.PrimaryPhotoID` → +the image at `GET /media/` (confirmed 200, image/jpeg). Projects have **no** +image field in laddr, so this is person avatars only. + +What ships: + +- **json-fetcher**: `RawPersonSchema` now parses `PrimaryPhotoID`. +- **importer**: for each person with a `PrimaryPhotoID`, fetch `/media/`, + run it through the existing `processAvatar` (square original + 128px thumb), + store both as gitsheets attachments (`avatar.jpg` + `avatar-128.jpg`) and set + `avatarKey = people//avatar.jpg` — exactly the convention the avatar + upload route uses. Reuses the proven `fetchMediaBytes` + `BlobObject.write` + + `setAttachments` machinery (same as blog media), concurrency 4. + +## Implements + +# 130. No spec change — the avatar storage contract (api/people.md, behaviors/ +storage.md attachments) already exists; this just populates it at import time. + +## Approach + +`fetchAndMaterializePersonAvatars(photoIdBySlug, sourceHost, …)` mirrors +`fetchAndMaterializeBlogMedia`: parallel fetch + `processAvatar`, returning +slug → {original, thumbnail}. The transact's people loop wires the attachments +- `avatarKey` for people that have one; failed fetches/decodes are skipped with +a warning (the person still imports). `hologit` hoisted to the transact top +(shared by people + blog attachment writes). + +## Validation + +- [x] `RawPersonSchema` parses `PrimaryPhotoID`. +- [x] Importer test: a person with `PrimaryPhotoID` gets `people//avatar.jpg` + + `avatar-128.jpg` attachments and `avatarKey` set; a person without one + gets neither. (import-laddr 37/37.) +- [x] `npm run type-check && npm run lint` clean. + +## Risks + +- Fetch volume: one image per photo-bearing person. Concurrency-capped at 4 and + failures are non-fatal, matching blog media. `--limit` bounds it for testing. +- Many photo-bearing accounts are spam — but the spam-prune (#133) removes them + downstream, so net imported avatars skew to real members. + +## Notes + +## Follow-ups + +- The first photo-bearing accounts sampled in the spike were spam; harmless + (pruned later), but a reminder that import → prune ordering matters (already + documented in spam-detection.md / cutover.md). From 4b712c3fe88357e38716cdb8e5d9f941a1a594aa Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sat, 27 Jun 2026 18:01:14 -0400 Subject: [PATCH 08/15] feat(import): import legacy person avatars (#130) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Legacy users showed initials where their codeforphilly.org photo used to be — the importer brought blog media but never person avatars. A spike found the source: person.PrimaryPhotoID → GET /media/. (Projects have no image field in laddr, so this is avatars only.) RawPersonSchema now parses PrimaryPhotoID. For each person with one, the importer fetches /media/, runs it through the existing processAvatar (square original + 128px thumb), stores both as gitsheets attachments (avatar.jpg + avatar-128.jpg), and sets avatarKey — the same convention as the avatar-upload route. Reuses the blog-media machinery (fetchMediaBytes + BlobObject.write + setAttachments), concurrency 4, failures non-fatal. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/scripts/import-laddr/importer.ts | 100 ++++++++++++++++-- apps/api/scripts/import-laddr/json-fetcher.ts | 2 + apps/api/tests/import-laddr.test.ts | 48 +++++++++ 3 files changed, 144 insertions(+), 6 deletions(-) diff --git a/apps/api/scripts/import-laddr/importer.ts b/apps/api/scripts/import-laddr/importer.ts index 1e72026..80b4386 100644 --- a/apps/api/scripts/import-laddr/importer.ts +++ b/apps/api/scripts/import-laddr/importer.ts @@ -58,6 +58,7 @@ import type { } from '@cfp/shared/schemas'; import { openPublicStore, type PublicStore } from '../../src/store/public.js'; +import { processAvatar } from '../../src/lib/avatar.js'; import { fetchAllPages, RawBlogPostSchema, @@ -235,6 +236,9 @@ export async function importLaddrFromJson(opts: ImportOptions): Promise` and stored as gitsheets attachments. + const photoIdBySlug = new Map(); for await (const row of fetchAllPages( '/people', RawPersonSchema, @@ -253,6 +257,9 @@ export async function importLaddrFromJson(opts: ImportOptions): Promise`) into + // square original + 128px thumbnail buffers, keyed by person slug. + const avatarsBySlug = await fetchAndMaterializePersonAvatars( + photoIdBySlug, + opts.sourceHost, + fetchOpts, + log, + warnings, + ); + // ------------------------------------------------------------------------- // 3. One atomic gitsheets transaction: // - clear() each importer-owned sheet (deletes capture for free) @@ -480,13 +497,33 @@ export async function importLaddrFromJson(opts: ImportOptions): Promise { + if (publicRepo === null) { + throw new Error('[import-laddr] internal: publicRepo not opened'); + } + const hologit = publicRepo.hologitRepo; + log(`[import] clear + upsert tags (${tags.length})`); await tx.tags.clear(); for (const t of tags) await tx.tags.upsert(t); - log(`[import] clear + upsert people (${people.length})`); + log(`[import] clear + upsert people (${people.length}, avatars: ${avatarsBySlug.size})`); await tx.people.clear(); - for (const p of people) await tx.people.upsert(p); + for (const p of people) { + const avatar = avatarsBySlug.get(p.slug); + if (avatar) { + // Mirror POST /api/people/:slug/avatar: store original + 128 thumb + // as attachments and point avatarKey at the conventional path. + const originalBlob = await BlobObject.write(hologit, avatar.original as unknown as string); + const thumbnailBlob = await BlobObject.write(hologit, avatar.thumbnail as unknown as string); + await tx.people.setAttachments(p, { + 'avatar.jpg': originalBlob, + 'avatar-128.jpg': thumbnailBlob, + }); + await tx.people.upsert({ ...p, avatarKey: `people/${p.slug}/avatar.jpg` }); + } else { + await tx.people.upsert(p); + } + } log(`[import] clear + upsert projects (${projects.length})`); await tx.projects.clear(); @@ -540,10 +577,6 @@ export async function importLaddrFromJson(opts: ImportOptions): Promise 0) { @@ -839,6 +872,61 @@ async function fetchMediaBytes( } } +/** + * Fetch each person's laddr photo (`/media/`) and process it + * into a square original + 128px thumbnail (the same outputs the avatar-upload + * route produces). Returns a map of person slug → buffers for the transact + * callback to wire in via setAttachments. Failed fetches/decodes are skipped + * with a warning — the person still imports, just without an avatar. + * + * Concurrency 4, matching the blog-media fetcher's politeness compromise. + */ +async function fetchAndMaterializePersonAvatars( + photoIdBySlug: Map, + sourceHost: string, + fetchOpts: FetchOptions, + log: (msg: string) => void, + warnings: Warnings, +): Promise> { + const fetchImpl = fetchOpts.fetchImpl ?? fetch; + const userAgent = fetchOpts.userAgent ?? 'cfp-importer/dev'; + const entries = [...photoIdBySlug.entries()]; + const out = new Map(); + if (entries.length === 0) return out; + + log(`[import] fetching ${entries.length} person avatars`); + + const CONCURRENCY = 4; + let cursor = 0; + const workers: Promise[] = []; + for (let w = 0; w < CONCURRENCY; w++) { + workers.push( + (async () => { + while (true) { + const idx = cursor++; + if (idx >= entries.length) return; + const [slug, photoId] = entries[idx]!; + const url = `https://${sourceHost}/media/${photoId}`; + const fetched = await fetchMediaBytes(url, fetchImpl, userAgent); + if (fetched === null) { + warnings.push(`[people] avatar fetch failed: ${url} (/${slug})`); + continue; + } + try { + const processed = await processAvatar(fetched.bytes); + out.set(slug, { original: processed.original, thumbnail: processed.thumbnail }); + } catch (err) { + warnings.push(`[people] avatar decode failed for /${slug} (${url}): ${describe(err)}`); + } + } + })(), + ); + } + await Promise.all(workers); + log(`[import] processed ${out.size}/${entries.length} person avatars`); + return out; +} + /** * Fetch every distinct media asset referenced across all blog posts, * derive the final filename per asset, then rewrite each post's body to diff --git a/apps/api/scripts/import-laddr/json-fetcher.ts b/apps/api/scripts/import-laddr/json-fetcher.ts index 5515835..26f2e07 100644 --- a/apps/api/scripts/import-laddr/json-fetcher.ts +++ b/apps/api/scripts/import-laddr/json-fetcher.ts @@ -75,6 +75,8 @@ export const RawPersonSchema = z AccountLevel: z.string().nullable().optional(), Newsletter: z.union([z.boolean(), z.number(), z.string()]).nullable().optional(), Twitter: z.string().nullable().optional(), + /** Emergence Media ID of the person's photo; fetch at `/media/`. */ + PrimaryPhotoID: z.number().int().nullable().optional(), Created: z.number().int().nullable().optional(), Modified: z.number().int().nullable().optional(), /** Present when `?include=Tags` */ diff --git a/apps/api/tests/import-laddr.test.ts b/apps/api/tests/import-laddr.test.ts index 1b35e50..3b461e2 100644 --- a/apps/api/tests/import-laddr.test.ts +++ b/apps/api/tests/import-laddr.test.ts @@ -55,6 +55,10 @@ function makeFetch(routes: MockRoutes): typeof fetch { return new Response('Not found', { status: 404 }); } const body = queue.shift()!; + // Binary routes (e.g. /media/) queue a Buffer — serve it raw. + if (Buffer.isBuffer(body)) { + return new Response(body, { status: 200, headers: { 'content-type': 'image/png' } }); + } return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' }, @@ -62,6 +66,12 @@ function makeFetch(routes: MockRoutes): typeof fetch { }) as typeof fetch; } +/** Smallest valid PNG (1×1) — sharp can decode it, so processAvatar works. */ +const TINY_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + 'base64', +); + function envelope(rows: unknown[], total: number, limit: number, offset: number) { return { success: true, @@ -1009,6 +1019,44 @@ describe('importLaddrFromJson — orchestrator', () => { } }); + it('imports a person avatar from /media/ and sets avatarKey', async () => { + const { path: repo, cleanup } = await makeRepo(); + try { + const routes = mockRoutes(); + // Give alice a photo + serve it as a tiny PNG at /media/555. + const peopleResp = routes.responses.get( + '/people?format=json&include=Tags&limit=200&offset=0', + ) as Array<{ data: Array> }>; + peopleResp[0]!.data[0]!.PrimaryPhotoID = 555; + routes.responses.set('/media/555?', [TINY_PNG]); + + const report = await importLaddrFromJson({ + sourceHost: 'example.test', + dataRepo: repo, + branch: 'legacy-import', + initialParent: 'empty', + now: '2026-05-18T00:00:00.000Z', + delayMs: 0, + pageSize: 200, + fetchImpl: makeFetch(routes), + }); + expect(report.commitHash).not.toBeNull(); + + const tree = await exec('git', ['ls-tree', '-r', '--name-only', 'HEAD'], { cwd: repo }); + const paths = tree.stdout.split('\n').filter(Boolean); + // alice has a photo → original + thumbnail attachments + expect(paths).toContain('people/alice/avatar.jpg'); + expect(paths).toContain('people/alice/avatar-128.jpg'); + // bob has no photo → no avatar attachments + expect(paths).not.toContain('people/bob/avatar.jpg'); + + const aliceToml = await exec('git', ['show', 'HEAD:people/alice.toml'], { cwd: repo }); + expect(aliceToml.stdout).toContain('avatarKey = "people/alice/avatar.jpg"'); + } finally { + await cleanup(); + } + }); + it('is idempotent: re-running on identical mock data makes no new commit', async () => { const { path: repo, cleanup } = await makeRepo(); try { From a603f3d3f24f3d1924bf467f77bb361fa7376065 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sat, 27 Jun 2026 18:02:13 -0400 Subject: [PATCH 09/15] chore(plans): mark import-person-avatars done (PR #143) Co-Authored-By: Claude Opus 4.8 (1M context) --- plans/import-person-avatars.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plans/import-person-avatars.md b/plans/import-person-avatars.md index f9a5a07..5dbfd19 100644 --- a/plans/import-person-avatars.md +++ b/plans/import-person-avatars.md @@ -4,7 +4,7 @@ depends: [] specs: [] issues: - 130 -pr: +pr: 143 --- # Plan: import legacy person avatars @@ -32,6 +32,7 @@ What ships: ## Implements # 130. No spec change — the avatar storage contract (api/people.md, behaviors/ + storage.md attachments) already exists; this just populates it at import time. ## Approach @@ -39,6 +40,7 @@ storage.md attachments) already exists; this just populates it at import time. `fetchAndMaterializePersonAvatars(photoIdBySlug, sourceHost, …)` mirrors `fetchAndMaterializeBlogMedia`: parallel fetch + `processAvatar`, returning slug → {original, thumbnail}. The transact's people loop wires the attachments + - `avatarKey` for people that have one; failed fetches/decodes are skipped with a warning (the person still imports). `hologit` hoisted to the transact top (shared by people + blog attachment writes). From 3cd31495a5d6a82b4f5f8c41e9a1b098ae68f002 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sat, 27 Jun 2026 18:43:11 -0400 Subject: [PATCH 10/15] =?UTF-8?q?docs(specs):=20person=20lifecycle=20?= =?UTF-8?q?=E2=80=94=20deactivate=20/=20reactivate=20/=20purge=20(#129)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two removal verbs: deactivate (soft, self-service, reversible, login not blocked, "Deactivated user" placeholder on references) and purge (admin-only cascading hard delete, git-revertable). Authz + endpoints + placeholder shape. Co-Authored-By: Claude Opus 4.8 (1M context) --- specs/api/people.md | 55 +++++++++++++++++++++++++++-- specs/behaviors/person-lifecycle.md | 41 +++++++++++++++++++++ 2 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 specs/behaviors/person-lifecycle.md diff --git a/specs/api/people.md b/specs/api/people.md index 908c332..4145610 100644 --- a/specs/api/people.md +++ b/specs/api/people.md @@ -13,7 +13,9 @@ See [data-model.md](../data-model.md#person). | `PATCH` | `/api/people/:slug` | self \| staff | Update profile. | | `POST` | `/api/people/:slug/avatar` | self \| staff | Upload an avatar image (multipart). | | `PATCH` | `/api/people/:slug/newsletter` | self \| staff | Update newsletter opt-in state (private-store mutation; no public commit). | -| `DELETE` | `/api/people/:slug` | administrator | Soft-delete (close account). | +| `POST` | `/api/people/:slug/deactivate` | self \| staff | Soft-deactivate (sets `deletedAt`). | +| `POST` | `/api/people/:slug/reactivate` | self \| staff | Reactivate (clears `deletedAt`). | +| `POST` | `/api/people/:slug/purge` | administrator | Cascading hard-delete of person + their content. | ## GET /api/people @@ -166,12 +168,59 @@ Server crops to a square and stores the original plus the 128x128 thumbnail as g { "success": true, "data": { "avatarUrl": "https://..." } } ``` -## DELETE /api/people/:slug +## POST /api/people/:slug/deactivate -Administrator-only. Sets `deletedAt = now()`. Profile becomes 404 to non-staff; their authored updates and buzz remain with `author = null`. +Self or staff. Sets `deletedAt = now()`. Profile becomes 404 to non-staff. References to this person in other records render a placeholder. The person can still sign in and reactivate. + +### Response — 200 + +```json +{ "success": true, "data": Person } +``` + +### Errors + +- `403 forbidden` — caller is not the person themselves, staff, or admin +- `404 not_found` — slug doesn't exist + +## POST /api/people/:slug/reactivate + +Self or staff. Clears `deletedAt`. Person becomes visible again. + +### Response — 200 + +```json +{ "success": true, "data": Person } +``` + +### Errors + +- `403 forbidden` — caller is not the person themselves, staff, or admin +- `404 not_found` — slug doesn't exist (even for non-staff, to allow self-reactivation) + +## POST /api/people/:slug/purge + +Administrator-only. Atomically hard-deletes the person record and cascades: project-memberships, help-wanted-interest, person tag-assignments, project-updates (authored), project-buzz (posted), and blog-posts (authored). All in one gitsheets commit. Git-revertable. + +Unlike the offline spam-prune (which nulls `authorId` on updates), purge DELETES the authored content — it is the on-demand garbage-collection path for spam accounts. ### Response — 204 +### Errors + +- `403 forbidden` — caller is not an administrator +- `404 not_found` — slug doesn't exist + +## Deactivated person placeholder + +When a deactivated person is referenced in a serialized response (e.g. project member, update author, blog author, help-wanted postedBy), the reference must be substituted with a placeholder rather than omitted, so counts and history stay coherent: + +```json +{ "slug": null, "fullName": "Deactivated user", "avatarUrl": null, "deactivated": true } +``` + +This placeholder shape applies to the `PersonAvatar` reference type used in: project memberships, project-update `author`, project-buzz `postedBy`, help-wanted `postedBy`/`filledBy`, and blog-post `author`. + ## Staff-only sub-endpoints (deferred to staff specs) - `POST /api/people/:slug/account-level` — change `accountLevel` (admin-only). Body: `{ "level": "staff" }`. Audit-logged. diff --git a/specs/behaviors/person-lifecycle.md b/specs/behaviors/person-lifecycle.md new file mode 100644 index 0000000..60b3734 --- /dev/null +++ b/specs/behaviors/person-lifecycle.md @@ -0,0 +1,41 @@ +# Person lifecycle: deactivate & purge + +A person record has two removal paths with very different intent and reversibility. + +| State | Set by | Effect | Reversible | +| ----- | ------ | ------ | ---------- | +| **Active** | default | Normal — visible in lists, detail, and as a reference on content. | — | +| **Deactivated** | self **or** staff/admin | Soft hide. `deletedAt` set. Hidden from public lists + detail; references render a placeholder. The person **can still sign in** and reactivate. | Reactivate (clears `deletedAt`). | +| **Purged** | admin only | Cascading hard delete of the person + their content, in a single commit. | Via git history only (revert the commit). | + +## Deactivate (soft, self-service) + +The privacy / self-removal path — members should be able to remove themselves; CfP gets these requests often. + +- **Who:** a person may deactivate/reactivate their OWN account; staff and administrators may deactivate/reactivate ANY account. +- **Mechanism:** sets `person.deletedAt = now()` (reactivate clears it). The record and relationships stay intact. +- **Visibility while deactivated:** excluded from public list endpoints; `GET /api/people/:slug` returns 404 for non-staff (staff may still fetch it, with `deletedAt` populated). Anywhere a deactivated person is referenced (project member grids, project-update/project-buzz authors, help-wanted "posted by", blog author) the serialized reference is a **"Deactivated user" placeholder** (no slug link, generic avatar) rather than the person — substitute, do not omit, so counts/history stay coherent. +- **Login is NOT blocked** — a deactivated user can still authenticate and reactivate themselves. No session revocation. +- **Surfaces:** self at `/account` ("Deactivate my account" / "Reactivate"); staff/admin via a person "Danger Zone". + +## Purge (cascading hard delete, admin only) + +The garbage-collection path for spam — the runtime sibling of the offline spam-prune (behaviors/spam-exclusion.md). + +- **Who:** administrators only. +- **Mechanism:** one write-mutex transaction that hard-deletes: the `people` record; their `project-membership`; their `help-wanted-interest`; their person `tag-assignment`; AND their authored `project-update`, `project-buzz`, and `blog-post` records (unlike the prune which nulls authorId — purge DELETES the content, it's garbage). +- **Atomic + git-revertable** (one commit). +- **Surface:** person "Danger Zone" (admin only), behind a confirm dialog. + +## Authorization summary + +| Action | Self | Staff | Admin | +| ------ | ---- | ----- | ----- | +| Deactivate / Reactivate | ✓ (own) | ✓ (any) | ✓ (any) | +| Purge | – | – | ✓ | + +## Relationship to other specs + +- storage.md — all writes go through the in-process mutex; purge is one transaction. +- spam-exclusion.md — offline prune and on-demand purge share cascade semantics; keep aligned. +- API endpoints + response placeholder shape are specified in api/people.md. From dde3f9de2cf927caef269b92a827fd352f380397 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sat, 27 Jun 2026 18:43:11 -0400 Subject: [PATCH 11/15] feat(person): deactivate / reactivate / purge (#129) - API: POST /api/people/:slug/{deactivate,reactivate} (self | staff) and /purge (administrator), via the write mutex. - Read: people.get returns a deactivated person only to staff or self (for reactivation); lists exclude deactivated for non-staff; serializePersonAvatar (+ author/member serializers) emits a "Deactivated user" placeholder. - Purge cascades: person + memberships + help-wanted-interest + person tag-assignments + authored updates/buzz/blog-posts, in one commit. - Web: /account self deactivate/reactivate; admin Danger Zone; placeholder rendering. Implements specs/behaviors/person-lifecycle.md + api/people.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/routes/people.ts | 82 ++++++++- apps/api/src/services/permissions.ts | 9 +- apps/api/src/services/person.ts | 5 +- apps/api/src/services/person.write.ts | 171 +++++++++++++++++- apps/api/src/services/serializers/common.ts | 15 +- .../src/services/serializers/help-wanted.ts | 6 +- apps/api/src/services/serializers/person.ts | 4 + .../src/services/serializers/project-buzz.ts | 4 +- .../services/serializers/project-update.ts | 4 +- apps/api/src/services/serializers/project.ts | 2 +- apps/api/src/store/state-apply.ts | 43 +++++ apps/web/src/components/PersonAvatar.tsx | 8 +- .../components/modals/ManageMembersModal.tsx | 48 ++--- apps/web/src/hooks/useAuth.tsx | 2 + apps/web/src/lib/api.ts | 17 +- apps/web/src/screens/Account.tsx | 99 +++++++++- apps/web/src/screens/PersonDetail.tsx | 137 +++++++++++++- 17 files changed, 603 insertions(+), 53 deletions(-) diff --git a/apps/api/src/routes/people.ts b/apps/api/src/routes/people.ts index 5a8d179..a107c71 100644 --- a/apps/api/src/routes/people.ts +++ b/apps/api/src/routes/people.ts @@ -3,7 +3,9 @@ * GET /api/people * GET /api/people/:slug * PATCH /api/people/:slug - * DELETE /api/people/:slug + * POST /api/people/:slug/deactivate + * POST /api/people/:slug/reactivate + * POST /api/people/:slug/purge * PATCH /api/people/:slug/newsletter (private-only mutation) */ import type { FastifyInstance } from 'fastify'; @@ -139,11 +141,11 @@ export async function peopleRoutes(fastify: FastifyInstance): Promise { return ok(await fastify.services.people.get(result.value.person.slug, caller)); }); - // DELETE /api/people/:slug (admin-only soft-delete) + // DELETE /api/people/:slug (admin-only soft-delete — legacy, kept for backward compat) fastify.delete('/api/people/:slug', { schema: { tags: ['people'], - summary: 'Soft-delete a person (admin only)', + summary: 'Soft-delete a person (admin only, legacy)', params: { type: 'object', properties: { slug: { type: 'string' } }, required: ['slug'] }, }, }, async (request, reply) => { @@ -162,6 +164,80 @@ export async function peopleRoutes(fastify: FastifyInstance): Promise { return reply.code(204).send(); }); + // POST /api/people/:slug/deactivate (self | staff) + // Spec: specs/behaviors/person-lifecycle.md, specs/api/people.md + fastify.post('/api/people/:slug/deactivate', { + schema: { + tags: ['people'], + summary: 'Deactivate a person account (self or staff)', + params: { type: 'object', properties: { slug: { type: 'string' } }, required: ['slug'] }, + }, + }, async (request) => { + const { slug } = request.params as { slug: string }; + const result = await fastify.store.transact( + buildTransactionOptions({ + request, + action: 'person.deactivate', + subjectType: 'person', + subjectSlug: slug, + responseCode: 200, + }), + async (tx) => fastify.services.peopleWrite.deactivate(tx, slug, request.session), + ); + result.value.stateApply.apply(fastify.inMemoryState, fastify.fts); + const caller = getCallerSession(request); + return ok(await fastify.services.people.get(result.value.person.slug, caller)); + }); + + // POST /api/people/:slug/reactivate (self | staff) + // Spec: specs/behaviors/person-lifecycle.md, specs/api/people.md + fastify.post('/api/people/:slug/reactivate', { + schema: { + tags: ['people'], + summary: 'Reactivate a deactivated person account (self or staff)', + params: { type: 'object', properties: { slug: { type: 'string' } }, required: ['slug'] }, + }, + }, async (request) => { + const { slug } = request.params as { slug: string }; + const result = await fastify.store.transact( + buildTransactionOptions({ + request, + action: 'person.reactivate', + subjectType: 'person', + subjectSlug: slug, + responseCode: 200, + }), + async (tx) => fastify.services.peopleWrite.reactivate(tx, slug, request.session), + ); + result.value.stateApply.apply(fastify.inMemoryState, fastify.fts); + const caller = getCallerSession(request); + return ok(await fastify.services.people.get(result.value.person.slug, caller)); + }); + + // POST /api/people/:slug/purge (administrator only) + // Spec: specs/behaviors/person-lifecycle.md, specs/api/people.md + fastify.post('/api/people/:slug/purge', { + schema: { + tags: ['people'], + summary: 'Purge a person and all their content (admin only)', + params: { type: 'object', properties: { slug: { type: 'string' } }, required: ['slug'] }, + }, + }, async (request, reply) => { + const { slug } = request.params as { slug: string }; + const result = await fastify.store.transact( + buildTransactionOptions({ + request, + action: 'person.purge', + subjectType: 'person', + subjectSlug: slug, + responseCode: 204, + }), + async (tx) => fastify.services.peopleWrite.purge(tx, slug, request.session), + ); + result.value.stateApply.apply(fastify.inMemoryState, fastify.fts); + return reply.code(204).send(); + }); + // PATCH /api/people/:slug/newsletter (private-store only — no public commit) fastify.patch('/api/people/:slug/newsletter', { schema: { diff --git a/apps/api/src/services/permissions.ts b/apps/api/src/services/permissions.ts index 37b8072..0e809ed 100644 --- a/apps/api/src/services/permissions.ts +++ b/apps/api/src/services/permissions.ts @@ -43,6 +43,10 @@ export interface ProjectPermissions { export interface PersonPermissions { readonly canEdit: boolean; readonly canChangeAccountLevel: boolean; + /** Self or staff: can deactivate/reactivate this account. */ + readonly canDeactivate: boolean; + /** Admin only: can purge this person and all their content. */ + readonly canPurge: boolean; } export interface UpdatePermissions { @@ -111,10 +115,13 @@ export function computePersonPermissions( person: Person, ): PersonPermissions { const staff = isStaff(caller); + const admin = caller?.accountLevel === 'administrator'; const isSelf = caller?.id === person.id; return { canEdit: isSelf || staff, - canChangeAccountLevel: caller?.accountLevel === 'administrator', + canChangeAccountLevel: admin, + canDeactivate: isSelf || staff, + canPurge: admin, }; } diff --git a/apps/api/src/services/person.ts b/apps/api/src/services/person.ts index 0aa8705..a6d7302 100644 --- a/apps/api/src/services/person.ts +++ b/apps/api/src/services/person.ts @@ -144,7 +144,10 @@ export class PersonService { const isStaff = caller?.accountLevel === 'staff' || caller?.accountLevel === 'administrator'; - if (person.deletedAt && !isStaff) return null; + const isSelfCaller = caller?.id === person.id; + // Deactivated profiles 404 for everyone except staff and the person + // themselves (self may view to reactivate). Per specs/behaviors/person-lifecycle.md. + if (person.deletedAt && !isStaff && !isSelfCaller) return null; const memberships = this.#getMembershipsForPerson(person.id); const projectsMap = this.#getProjectsForMemberships(memberships); diff --git a/apps/api/src/services/person.write.ts b/apps/api/src/services/person.write.ts index d6d5d8f..739ad23 100644 --- a/apps/api/src/services/person.write.ts +++ b/apps/api/src/services/person.write.ts @@ -1,8 +1,10 @@ /** * Person writes: - * - PATCH /api/people/:slug (self | staff) - * - DELETE /api/people/:slug (administrator) - * - PATCH /api/people/:slug/newsletter (self | staff) — private-store only + * - PATCH /api/people/:slug (self | staff) + * - POST /api/people/:slug/deactivate (self | staff) + * - POST /api/people/:slug/reactivate (self | staff) + * - POST /api/people/:slug/purge (administrator) + * - PATCH /api/people/:slug/newsletter (self | staff) — private-store only * * Avatar upload is handled by a separate multipart route handler that * stages an attachment then calls a Person update; it is not covered by @@ -189,6 +191,169 @@ export class PersonWriteService { return { stateApply }; } + /** + * Deactivate — self-service or staff. + * Sets `deletedAt = now()`. The person can still sign in and reactivate. + * Spec: specs/behaviors/person-lifecycle.md + */ + async deactivate( + tx: DualStoreTx, + slug: string, + session: SessionContext, + ): Promise<{ person: Person; stateApply: StateApply }> { + // Look up by slug even if already deleted — self-service path may need it. + // #personOrThrow filters out deleted; use the raw map here. + const id = this.#state.personIdBySlug.get(slug); + if (!id) throw new ApiNotFoundError(`Person '${slug}' not found`); + const existing = this.#state.people.get(id); + if (!existing) throw new ApiNotFoundError(`Person '${slug}' not found`); + + requireAuth('self | staff', { session, selfId: existing.id }); + + if (existing.deletedAt) { + // Already deactivated — idempotent, return current state. + return { person: existing, stateApply: new StateApply() }; + } + + const now = nowIso(); + const updated: Person = PersonSchema.parse({ + ...existing, + deletedAt: now, + updatedAt: now, + }); + + await tx.public.people.upsert(updated); + const stateApply = new StateApply().upsertPerson(updated); + return { person: updated, stateApply }; + } + + /** + * Reactivate — self-service or staff. Clears `deletedAt`. + * Note: the route must look up the person even when deactivated so the + * self-service reactivation path works. Non-staff callers may reactivate + * ONLY their own account (enforced by `requireAuth('self | staff', ...)`). + * Spec: specs/behaviors/person-lifecycle.md + */ + async reactivate( + tx: DualStoreTx, + slug: string, + session: SessionContext, + ): Promise<{ person: Person; stateApply: StateApply }> { + // Must look up even if deleted (deactivated users can self-reactivate). + const id = this.#state.personIdBySlug.get(slug); + if (!id) throw new ApiNotFoundError(`Person '${slug}' not found`); + const existing = this.#state.people.get(id); + if (!existing) throw new ApiNotFoundError(`Person '${slug}' not found`); + + requireAuth('self | staff', { session, selfId: existing.id }); + + if (!existing.deletedAt) { + // Already active — idempotent. + return { person: existing, stateApply: new StateApply() }; + } + + const now = nowIso(); + const updated: Person = PersonSchema.parse({ + ...existing, + deletedAt: null, + updatedAt: now, + }); + + await tx.public.people.upsert(updated); + const stateApply = new StateApply().upsertPerson(updated); + return { person: updated, stateApply }; + } + + /** + * Purge — admin-only cascading hard delete. + * + * One transaction that hard-deletes: + * - the person record + * - all project-memberships for the person + * - all help-wanted-interest expressions for the person + * - all tag-assignments where taggableType = 'person' + taggableId = personId + * - all project-updates authored by the person (DELETE, not null) + * - all project-buzz posted by the person (DELETE, not null) + * - all blog-posts authored by the person (DELETE, not null) + * + * Unlike the offline spam-prune (which nulls authorId on updates), purge + * DELETES authored content — it is the on-demand garbage-collection path. + * Spec: specs/behaviors/person-lifecycle.md + */ + async purge( + tx: DualStoreTx, + slug: string, + session: SessionContext, + ): Promise<{ stateApply: StateApply }> { + requireAuth('administrator', { session }); + + const id = this.#state.personIdBySlug.get(slug); + if (!id) throw new ApiNotFoundError(`Person '${slug}' not found`); + const existing = this.#state.people.get(id); + if (!existing) throw new ApiNotFoundError(`Person '${slug}' not found`); + + const stateApply = new StateApply(); + const personId = existing.id; + + // 1. Delete the person record. + await tx.public.people.delete(existing); + stateApply.removePerson(personId, existing.slug); + + // 2. Cascade-delete project-memberships. + const membershipIds = this.#state.membershipsByPerson.get(personId) ?? new Set(); + for (const mId of membershipIds) { + const m = this.#state.projectMemberships.get(mId); + if (m) { + await tx.public['project-memberships'].delete(m); + stateApply.removeMembership(m); + } + } + + // 3. Cascade-delete help-wanted-interest. + for (const interest of this.#state.helpWantedInterest.values()) { + if (interest.personId === personId) { + await tx.public['help-wanted-interest'].delete(interest); + stateApply.removeInterest(interest); + } + } + + // 4. Cascade-delete person tag-assignments. + const taIds = this.#state.tagAssignmentsByTaggable.get(personId) ?? new Set(); + for (const taId of taIds) { + const ta = this.#state.tagAssignments.get(taId); + if (ta && ta.taggableType === 'person') { + await tx.public['tag-assignments'].delete(ta); + stateApply.removeTagAssignment(ta); + } + } + + // 5. Delete authored project-updates. + for (const update of this.#state.projectUpdates.values()) { + if (update.authorId === personId) { + await tx.public['project-updates'].delete(update); + stateApply.removeProjectUpdate(update); + } + } + + // 6. Delete posted project-buzz. + for (const buzz of this.#state.projectBuzz.values()) { + if (buzz.postedById === personId) { + await tx.public['project-buzz'].delete(buzz); + stateApply.removeProjectBuzz(buzz); + } + } + + // 7. Delete authored blog-posts. + for (const post of this.#state.blogPosts.values()) { + if (post.authorId === personId) { + await tx.public['blog-posts'].delete(post); + stateApply.removeBlogPost(post); + } + } + + return { stateApply }; + } + async updateNewsletter( slug: string, optedIn: boolean, diff --git a/apps/api/src/services/serializers/common.ts b/apps/api/src/services/serializers/common.ts index 2dcfae0..423025c 100644 --- a/apps/api/src/services/serializers/common.ts +++ b/apps/api/src/services/serializers/common.ts @@ -34,9 +34,11 @@ export function renderMarkdown(source: string): RenderMarkdownResult { /** PersonAvatar shape used in many nested contexts. */ export interface PersonAvatar { - readonly slug: string; + readonly slug: string | null; readonly fullName: string; readonly avatarUrl: string | null; + /** Present and true when the person is deactivated; omitted otherwise. */ + readonly deactivated?: true; } /** Tag shape used in nested contexts. */ @@ -46,8 +48,19 @@ export interface TagItem { readonly title: string; } +/** + * Serialize a person reference as a PersonAvatar. + * + * If the person is deactivated (`deletedAt` is set), returns a placeholder + * per specs/api/people.md#deactivated-person-placeholder and + * specs/behaviors/person-lifecycle.md. The placeholder substitutes rather + * than omits the reference so counts and history stay coherent. + */ export function serializePersonAvatar(person: Person | undefined | null): PersonAvatar | null { if (!person) return null; + if (person.deletedAt) { + return { slug: null, fullName: 'Deactivated user', avatarUrl: null, deactivated: true }; + } return { slug: person.slug, fullName: person.fullName, diff --git a/apps/api/src/services/serializers/help-wanted.ts b/apps/api/src/services/serializers/help-wanted.ts index 960ec3e..a06c9cb 100644 --- a/apps/api/src/services/serializers/help-wanted.ts +++ b/apps/api/src/services/serializers/help-wanted.ts @@ -3,18 +3,18 @@ */ import type { HelpWantedRole, Person, Project, Tag, TagAssignment } from '@cfp/shared/schemas'; import type { HelpWantedPermissions } from '../permissions.js'; -import { groupTagsByNamespace, renderMarkdown, serializePersonAvatar, type TagItem } from './common.js'; +import { groupTagsByNamespace, renderMarkdown, serializePersonAvatar, type PersonAvatar, type TagItem } from './common.js'; export interface HelpWantedRoleResponse { readonly id: string; readonly project: { readonly slug: string; readonly title: string }; - readonly postedBy: { readonly slug: string; readonly fullName: string; readonly avatarUrl: string | null } | null; + readonly postedBy: PersonAvatar | null; readonly title: string; readonly description: string; readonly descriptionHtml: string; readonly commitmentHoursPerWeek: number | null; readonly status: string; - readonly filledBy: { readonly slug: string; readonly fullName: string; readonly avatarUrl: string | null } | null; + readonly filledBy: PersonAvatar | null; readonly filledAt: string | null; readonly closedAt: string | null; readonly tags: { topic: TagItem[]; tech: TagItem[] }; diff --git a/apps/api/src/services/serializers/person.ts b/apps/api/src/services/serializers/person.ts index a12072f..8e0393c 100644 --- a/apps/api/src/services/serializers/person.ts +++ b/apps/api/src/services/serializers/person.ts @@ -69,6 +69,8 @@ export interface PersonDetail { readonly permissions: PersonPermissions; readonly createdAt: string; readonly updatedAt: string; + /** Set when the person is deactivated; visible to staff and self callers only. */ + readonly deletedAt: string | null; } export function serializePersonListItem( @@ -181,5 +183,7 @@ export function serializePersonDetail( permissions: opts.permissions, createdAt: person.createdAt, updatedAt: person.updatedAt, + // deletedAt is visible to self and staff; everyone else gets null. + deletedAt: (isSelf || callerIsStaff) ? (person.deletedAt ?? null) : null, }; } diff --git a/apps/api/src/services/serializers/project-buzz.ts b/apps/api/src/services/serializers/project-buzz.ts index fddeba6..7e45878 100644 --- a/apps/api/src/services/serializers/project-buzz.ts +++ b/apps/api/src/services/serializers/project-buzz.ts @@ -3,13 +3,13 @@ */ import type { Person, Project, ProjectBuzz } from '@cfp/shared/schemas'; import type { BuzzPermissions } from '../permissions.js'; -import { renderMarkdown, serializePersonAvatar } from './common.js'; +import { renderMarkdown, serializePersonAvatar, type PersonAvatar } from './common.js'; export interface ProjectBuzzResponse { readonly id: string; readonly slug: string; readonly project: { readonly slug: string; readonly title: string }; - readonly postedBy: { readonly slug: string; readonly fullName: string; readonly avatarUrl: string | null } | null; + readonly postedBy: PersonAvatar | null; readonly headline: string; readonly url: string; readonly publishedAt: string; diff --git a/apps/api/src/services/serializers/project-update.ts b/apps/api/src/services/serializers/project-update.ts index c215e99..1e0501f 100644 --- a/apps/api/src/services/serializers/project-update.ts +++ b/apps/api/src/services/serializers/project-update.ts @@ -3,13 +3,13 @@ */ import type { Person, Project, ProjectUpdate } from '@cfp/shared/schemas'; import type { UpdatePermissions } from '../permissions.js'; -import { renderMarkdown, serializePersonAvatar } from './common.js'; +import { renderMarkdown, serializePersonAvatar, type PersonAvatar } from './common.js'; export interface ProjectUpdateResponse { readonly id: string; readonly number: number; readonly project: { readonly slug: string; readonly title: string }; - readonly author: { readonly slug: string; readonly fullName: string; readonly avatarUrl: string | null } | null; + readonly author: PersonAvatar | null; readonly body: string; readonly bodyHtml: string; readonly permissions: UpdatePermissions; diff --git a/apps/api/src/services/serializers/project.ts b/apps/api/src/services/serializers/project.ts index 2fbe21a..2c99a44 100644 --- a/apps/api/src/services/serializers/project.ts +++ b/apps/api/src/services/serializers/project.ts @@ -195,7 +195,7 @@ export function serializeProjectDetail( id: m.id, projectSlug: project.slug, person: serializePersonAvatar(person) ?? { - slug: '', + slug: null, fullName: 'Unknown', avatarUrl: null, }, diff --git a/apps/api/src/store/state-apply.ts b/apps/api/src/store/state-apply.ts index c8968b3..3bfcf12 100644 --- a/apps/api/src/store/state-apply.ts +++ b/apps/api/src/store/state-apply.ts @@ -8,6 +8,7 @@ * applied — in-memory state stays in sync with the on-disk gitsheets state. */ import type { + BlogPost, HelpWantedInterestExpression, HelpWantedRole, Person, @@ -106,6 +107,17 @@ export class StateApply { return this; } + removePerson(personId: string, slug: string): this { + this.#ops.push((state, fts) => { + state.people.delete(personId); + state.personSlugById.delete(personId); + state.personIdBySlug.delete(slug); + fts.removePerson(slug); + }); + this.#invalidateFacets = true; + return this; + } + renamePersonSlug(_personId: string, oldSlug: string, _newSlug: string): this { void _personId; void _newSlug; @@ -214,6 +226,37 @@ export class StateApply { return this; } + removeInterest(e: HelpWantedInterestExpression): this { + this.#ops.push((state) => { + state.helpWantedInterest.delete(e.id); + state.interestByRole.get(e.roleId)?.delete(e.id); + state.interestByRoleAndPerson.delete(`${e.roleId}:${e.personId}`); + }); + return this; + } + + upsertBlogPost(post: BlogPost): this { + this.#ops.push((state) => { + state.blogPosts.set(post.id, post); + state.blogPostIdBySlug.set(post.slug, post.id); + if (typeof post.legacyId === 'number') { + state.blogPostIdByLegacyId.set(post.legacyId, post.id); + } + }); + return this; + } + + removeBlogPost(post: BlogPost): this { + this.#ops.push((state) => { + state.blogPosts.delete(post.id); + state.blogPostIdBySlug.delete(post.slug); + if (typeof post.legacyId === 'number') { + state.blogPostIdByLegacyId.delete(post.legacyId); + } + }); + return this; + } + /** * Mirror a SlugHistory upsert into the in-memory map so the slug-redirect * plugin sees it on the very next request. Expiry filtering happens inside diff --git a/apps/web/src/components/PersonAvatar.tsx b/apps/web/src/components/PersonAvatar.tsx index 2e7c31c..fa74652 100644 --- a/apps/web/src/components/PersonAvatar.tsx +++ b/apps/web/src/components/PersonAvatar.tsx @@ -26,7 +26,10 @@ export function PersonAvatar({ person, size = 32, asLink = true, className, titl ); - if (!asLink) return inner; + // Deactivated users or callers that set asLink=false do not link. + if (!asLink || !person.slug || person.deactivated) return inner; return ( diff --git a/apps/web/src/components/modals/ManageMembersModal.tsx b/apps/web/src/components/modals/ManageMembersModal.tsx index 853206c..ca2f95b 100644 --- a/apps/web/src/components/modals/ManageMembersModal.tsx +++ b/apps/web/src/components/modals/ManageMembersModal.tsx @@ -28,9 +28,9 @@ export function ManageMembersModal({ open, onOpenChange, project }: ManageMember const refresh = () => queryClient.invalidateQueries({ queryKey: ['project', project.slug] }); - const handleRemove = async (personSlug: string) => { + const handleRemove = async (personSlug: string, rowKey: string) => { if (!window.confirm(`Remove ${personSlug} from this project?`)) return; - setBusySlug(personSlug); + setBusySlug(rowKey); try { await api.projects.removeMember(project.slug, personSlug); toast.success(`Removed ${personSlug}`); @@ -48,9 +48,9 @@ export function ManageMembersModal({ open, onOpenChange, project }: ManageMember } }; - const handleChangeMaintainer = async (personSlug: string) => { + const handleChangeMaintainer = async (personSlug: string, rowKey: string) => { if (!window.confirm(`Make ${personSlug} the maintainer? You'll become a regular member.`)) return; - setBusySlug(personSlug); + setBusySlug(rowKey); try { await api.projects.changeMaintainer(project.slug, personSlug); toast.success(`Maintainer transferred to ${personSlug}`); @@ -62,10 +62,10 @@ export function ManageMembersModal({ open, onOpenChange, project }: ManageMember } }; - const handleSaveRole = async (personSlug: string) => { - const role = editingRole[personSlug]; + const handleSaveRole = async (rowKey: string, personSlug: string) => { + const role = editingRole[rowKey]; if (role === undefined) return; - setBusySlug(personSlug); + setBusySlug(rowKey); try { await api.projects.updateMember(project.slug, personSlug, { role: role.trim() || null, @@ -73,7 +73,7 @@ export function ManageMembersModal({ open, onOpenChange, project }: ManageMember toast.success('Role updated'); setEditingRole((m) => { const next = { ...m }; - delete next[personSlug]; + delete next[rowKey]; return next; }); await refresh(); @@ -96,8 +96,11 @@ export function ManageMembersModal({ open, onOpenChange, project }: ManageMember
    {project.memberships.map((m) => { - const slug = m.person.slug; - const isEditingThisRow = editingRole[slug] !== undefined; + // Use membership ID as the stable row key. + // person.slug may be null for deactivated members (placeholder shape). + const rowKey = m.id; + const personSlug = m.person.slug; + const isEditingThisRow = editingRole[rowKey] !== undefined; return (
  • @@ -105,9 +108,9 @@ export function ManageMembersModal({ open, onOpenChange, project }: ManageMember
    {m.person.fullName}
    {isEditingThisRow ? ( - setEditingRole((r) => ({ ...r, [slug]: e.target.value })) + setEditingRole((r) => ({ ...r, [rowKey]: e.target.value })) } placeholder="Role" className="h-7 mt-1 text-xs" @@ -124,13 +127,16 @@ export function ManageMembersModal({ open, onOpenChange, project }: ManageMember )}
- {isEditingThisRow ? ( + {/* Deactivated members: no edit actions available. */} + {m.person.deactivated ? ( + Deactivated + ) : isEditingThisRow ? ( <> @@ -141,7 +147,7 @@ export function ManageMembersModal({ open, onOpenChange, project }: ManageMember onClick={() => setEditingRole((r) => { const next = { ...r }; - delete next[slug]; + delete next[rowKey]; return next; }) } @@ -156,7 +162,7 @@ export function ManageMembersModal({ open, onOpenChange, project }: ManageMember size="sm" variant="outline" onClick={() => - setEditingRole((r) => ({ ...r, [slug]: m.role ?? '' })) + setEditingRole((r) => ({ ...r, [rowKey]: m.role ?? '' })) } > Edit role @@ -166,8 +172,8 @@ export function ManageMembersModal({ open, onOpenChange, project }: ManageMember type="button" size="sm" variant="outline" - onClick={() => handleChangeMaintainer(slug)} - disabled={busySlug === slug} + onClick={() => personSlug && handleChangeMaintainer(personSlug, rowKey)} + disabled={busySlug === rowKey} > Make maintainer @@ -177,8 +183,8 @@ export function ManageMembersModal({ open, onOpenChange, project }: ManageMember type="button" size="sm" variant="ghost" - onClick={() => handleRemove(slug)} - disabled={busySlug === slug} + onClick={() => personSlug && handleRemove(personSlug, rowKey)} + disabled={busySlug === rowKey} className="text-destructive hover:text-destructive" > Remove diff --git a/apps/web/src/hooks/useAuth.tsx b/apps/web/src/hooks/useAuth.tsx index 055384d..8873334 100644 --- a/apps/web/src/hooks/useAuth.tsx +++ b/apps/web/src/hooks/useAuth.tsx @@ -18,6 +18,8 @@ export interface AuthPerson { fullName: string; avatarUrl: string | null; accountLevel: AccountLevel; + /** Set when the account is deactivated; surfaced so /account can offer reactivation. */ + deletedAt?: string | null; } export interface AuthState { diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 94130a3..74174d2 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -90,9 +90,12 @@ export class ApiError extends Error { } export interface PersonAvatar { - readonly slug: string; + /** null when the person is deactivated (placeholder). */ + readonly slug: string | null; readonly fullName: string; readonly avatarUrl: string | null; + /** True when the person has deactivated their account. */ + readonly deactivated?: true; } export interface TagItem { @@ -206,6 +209,10 @@ export interface ProjectUpdateSummary { export interface PersonPermissions { readonly canEdit: boolean; readonly canChangeAccountLevel: boolean; + /** Self or staff: can deactivate/reactivate this account. */ + readonly canDeactivate: boolean; + /** Admin only: can purge this person and all their content. */ + readonly canPurge: boolean; } export interface PersonDetail { @@ -228,6 +235,8 @@ export interface PersonDetail { readonly permissions: PersonPermissions; readonly createdAt: string; readonly updatedAt: string; + /** Set when the person is deactivated. Staff-only visibility. */ + readonly deletedAt: string | null; } export interface TagResponse { @@ -699,6 +708,12 @@ export const api = { method: 'PATCH', body: JSON.stringify({ optedIn }), }), + deactivate: (slug: string): Promise> => + request(`/api/people/${encodeURIComponent(slug)}/deactivate`, { method: 'POST' }), + reactivate: (slug: string): Promise> => + request(`/api/people/${encodeURIComponent(slug)}/reactivate`, { method: 'POST' }), + purge: (slug: string): Promise => + request(`/api/people/${encodeURIComponent(slug)}/purge`, { method: 'POST' }), }, tags: { list: (params: TagListParams = {}): Promise> => diff --git a/apps/web/src/screens/Account.tsx b/apps/web/src/screens/Account.tsx index 54f5f57..87a05b2 100644 --- a/apps/web/src/screens/Account.tsx +++ b/apps/web/src/screens/Account.tsx @@ -5,6 +5,14 @@ import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; import { useAuth } from '@/hooks/useAuth'; import { api, ApiError } from '@/lib/api'; import { formatRelativeTime, formatAbsoluteDate } from '@/lib/time'; @@ -70,6 +78,8 @@ export function Account() { // false and update from the PATCH response. const [optedIn, setOptedIn] = useState(false); const [savingNewsletter, setSavingNewsletter] = useState(false); + const [deactivating, setDeactivating] = useState(false); + const [confirmDeactivateOpen, setConfirmDeactivateOpen] = useState(false); useEffect(() => { if (!loading && !person) { @@ -112,6 +122,35 @@ export function Account() { void navigate('/', { replace: true }); }; + const handleDeactivate = async () => { + setDeactivating(true); + try { + await api.people.deactivate(person.slug); + toast.success('Your account has been deactivated. You can reactivate at any time.'); + // Reload auth so the deactivated state is reflected in session display. + await reload(); + } catch (err) { + toast.error(err instanceof ApiError ? err.message : 'Failed to deactivate account'); + } finally { + setDeactivating(false); + } + }; + + const handleReactivate = async () => { + setDeactivating(true); + try { + await api.people.reactivate(person.slug); + toast.success('Your account has been reactivated and is visible again.'); + await reload(); + } catch (err) { + toast.error(err instanceof ApiError ? err.message : 'Failed to reactivate account'); + } finally { + setDeactivating(false); + } + }; + + const isDeactivated = !!person.deletedAt; + const sessions = sessionsQ.data?.data ?? []; return ( @@ -280,18 +319,58 @@ export function Account() { Danger zone - Closing your account hides your profile from new visitors. Past - contributions remain visible to staff. This isn't reversible - self-serve — email{' '} - - accounts@codeforphilly.org - {' '} - to request closure. + {isDeactivated + ? 'Your account is currently deactivated. Your profile is hidden from public views. You can reactivate at any time.' + : 'Deactivating your account hides your profile from public views. Past contributions remain. You can reactivate at any time by signing back in.'} + + {isDeactivated ? ( + + ) : ( + <> + + + + + Deactivate your account? + + Your profile will be hidden from public views. Past contributions remain + in our records. You can sign back in and reactivate at any time. + + + + + + + + + + )} +
); diff --git a/apps/web/src/screens/PersonDetail.tsx b/apps/web/src/screens/PersonDetail.tsx index 8db13c4..ebd8a68 100644 --- a/apps/web/src/screens/PersonDetail.tsx +++ b/apps/web/src/screens/PersonDetail.tsx @@ -1,6 +1,17 @@ -import { Link, useParams } from 'react-router'; -import { useQuery } from '@tanstack/react-query'; +import { useState } from 'react'; +import { Link, useNavigate, useParams } from 'react-router'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; import { MarkdownView } from '@/components/MarkdownView'; import { StageBadge } from '@/components/StageBadge'; import { TagChip } from '@/components/TagChip'; @@ -13,6 +24,11 @@ export function PersonDetail() { const params = useParams(); const slug = params['slug']!; const { person: viewer } = useAuth(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const [actionPending, setActionPending] = useState(false); + const [confirmPurgeOpen, setConfirmPurgeOpen] = useState(false); const personQ = useQuery({ queryKey: ['person', slug], @@ -42,6 +58,44 @@ export function PersonDetail() { const isSelf = viewer !== null && viewer.slug === person.slug; const allTags = [...person.tags.tech, ...person.tags.topic]; + const handleDeactivate = async () => { + setActionPending(true); + try { + await api.people.deactivate(person.slug); + await queryClient.invalidateQueries({ queryKey: ['person', slug] }); + toast.success(`${person.fullName}'s account has been deactivated.`); + } catch (err) { + toast.error(err instanceof ApiError ? err.message : 'Failed to deactivate account'); + } finally { + setActionPending(false); + } + }; + + const handleReactivate = async () => { + setActionPending(true); + try { + await api.people.reactivate(person.slug); + await queryClient.invalidateQueries({ queryKey: ['person', slug] }); + toast.success(`${person.fullName}'s account has been reactivated.`); + } catch (err) { + toast.error(err instanceof ApiError ? err.message : 'Failed to reactivate account'); + } finally { + setActionPending(false); + } + }; + + const handlePurge = async () => { + setActionPending(true); + try { + await api.people.purge(person.slug); + toast.success(`${person.fullName} and all their content have been permanently purged.`); + void navigate('/members', { replace: true }); + } catch (err) { + toast.error(err instanceof ApiError ? err.message : 'Failed to purge account'); + setActionPending(false); + } + }; + // Memberships sorted: maintainer desc, joinedAt desc const memberships = [...person.memberships].sort((a, b) => { if (a.isMaintainer !== b.isMaintainer) return a.isMaintainer ? -1 : 1; @@ -203,6 +257,85 @@ export function PersonDetail() {
)} + + {/* Danger Zone — staff/admin only */} + {(person.permissions.canDeactivate || person.permissions.canPurge) && !isSelf && ( + + + Danger zone + {person.deletedAt && ( + + This account is deactivated. + + )} + + + {person.permissions.canDeactivate && ( + <> + {person.deletedAt ? ( + + ) : ( + + )} + + )} + {person.permissions.canPurge && ( + <> + + + + + Permanently purge {person.fullName}? + + This will permanently delete this person record and ALL their + content (project updates, buzz, blog posts, memberships). This + cannot be undone except via git history. Only use this for spam + accounts. + + + + + + + + + + )} + + + )} ); From 94414045adc177f67c4a827131b7c5795a1e47c6 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sat, 27 Jun 2026 18:43:11 -0400 Subject: [PATCH 12/15] test(person): cover deactivate / reactivate / purge + placeholder (#129) API guard + cascade tests (14) and web tests for self-deactivate and the deactivated-reference placeholder. Fixes the draft test's mintCookies helper, which ignored its level arg so staff/admin callers authenticated as plain users (spurious 403s). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/tests/people-lifecycle.test.ts | 493 ++++++++++++++++++ apps/web/tests/AccountDeactivate.test.tsx | 107 ++++ .../tests/PersonAvatarDeactivated.test.tsx | 33 ++ 3 files changed, 633 insertions(+) create mode 100644 apps/api/tests/people-lifecycle.test.ts create mode 100644 apps/web/tests/AccountDeactivate.test.tsx create mode 100644 apps/web/tests/PersonAvatarDeactivated.test.tsx diff --git a/apps/api/tests/people-lifecycle.test.ts b/apps/api/tests/people-lifecycle.test.ts new file mode 100644 index 0000000..43cbebc --- /dev/null +++ b/apps/api/tests/people-lifecycle.test.ts @@ -0,0 +1,493 @@ +/** + * Tests for the person deactivate / reactivate / purge feature. + * + * Spec: specs/behaviors/person-lifecycle.md, specs/api/people.md + * + * Covers: + * - POST /api/people/:slug/deactivate — self + * - POST /api/people/:slug/deactivate — staff + * - POST /api/people/:slug/deactivate — anonymous → 401 + * - POST /api/people/:slug/deactivate — other regular user → 403 + * - Deactivated person hidden from GET /api/people (non-staff) + * - Deactivated person 404s GET /api/people/:slug (non-staff) + * - Staff can still GET /api/people/:slug for deactivated person + * - POST /api/people/:slug/reactivate — self + * - POST /api/people/:slug/reactivate — staff + * - POST /api/people/:slug/reactivate — anonymous → 401 + * - POST /api/people/:slug/purge — admin deletes person + authored content + * - POST /api/people/:slug/purge — staff (non-admin) → 403 + * - POST /api/people/:slug/purge — anonymous → 401 + * - Deactivated reference in project membership renders placeholder + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { FastifyInstance } from 'fastify'; + +import { buildApp } from '../src/app.js'; +import { mintSessionFor } from '../src/auth/issue.js'; +import { createFullDataRepo, createPrivateStorageDir } from './helpers/test-full-repo.js'; +import { seedRawToml } from './helpers/seed-fixtures.js'; + +const JWT_KEY = 'test-jwt-signing-key-at-least-32-chars!!'; + +// --------------------------------------------------------------------------- +// Shared fixtures: IDs +// --------------------------------------------------------------------------- + +const IDS = { + alice: '01951a3c-0000-7000-8000-a0000000cafe', + bob: '01951a3c-0000-7000-8000-b0000000cafe', + staff: '01951a3c-0000-7000-8000-c0000000cafe', + admin: '01951a3c-0000-7000-8000-d0000000cafe', + project: '01951a3c-0000-7000-8000-e0000000cafe', + membership: '01951a3c-0000-7000-8000-f0000000cafe', + update: '01951a3c-0000-7000-8000-a1000000cafe', +}; + +// The session token carries the caller's accountLevel claim, which is what the +// auth guards (isStaff/isAdministrator) read — so it must match the seeded +// person's level. The level arg was previously ignored (hardcoded 'user'), +// which made every staff/admin caller authenticate as a plain user. +async function mintCookies( + personId: string, + level: 'user' | 'staff' | 'administrator' = 'user', +): Promise { + const { accessToken } = await mintSessionFor(personId, level, JWT_KEY); + return `cfp_session=${accessToken}`; +} + +// --------------------------------------------------------------------------- +// Suite: deactivate / reactivate / auth guard +// --------------------------------------------------------------------------- +// Uses a fresh app per describe to allow mutations without leaking state. + +describe('POST /api/people/:slug/deactivate', () => { + let dataRepo: { path: string; cleanup: () => Promise }; + let privateStore: { path: string; cleanup: () => Promise }; + let app: FastifyInstance; + + beforeAll(async () => { + dataRepo = await createFullDataRepo(); + privateStore = await createPrivateStorageDir(); + + // Seed four persons + for (const [slug, id, level] of [ + ['alice', IDS.alice, 'user'], + ['bob', IDS.bob, 'user'], + ['staff-user', IDS.staff, 'staff'], + ['admin-user', IDS.admin, 'administrator'], + ] as const) { + const toml = [ + `id = "${id}"`, + `slug = "${slug}"`, + `fullName = "Test ${slug}"`, + `accountLevel = "${level}"`, + `createdAt = "2026-05-01T00:00:00Z"`, + `updatedAt = "2026-05-01T00:00:00Z"`, + ].join('\n'); + await seedRawToml(dataRepo.path, `people/${slug}.toml`, toml, `seed ${slug}`); + } + + app = await buildApp({ + serverOptions: { logger: false }, + overrideEnv: { + CFP_DATA_REPO_PATH: dataRepo.path, + STORAGE_BACKEND: 'filesystem', + CFP_PRIVATE_STORAGE_PATH: privateStore.path, + CFP_JWT_SIGNING_KEY: JWT_KEY, + NODE_ENV: 'test', + }, + }); + }, 60_000); + + afterAll(async () => { + await app.close(); + await dataRepo.cleanup(); + await privateStore.cleanup(); + }); + + it('anonymous → 401', async () => { + const res = await app.inject({ method: 'POST', url: '/api/people/alice/deactivate' }); + expect(res.statusCode).toBe(401); + }); + + it('other regular user → 403', async () => { + const cookies = await mintCookies(IDS.bob); + const res = await app.inject({ + method: 'POST', + url: '/api/people/alice/deactivate', + headers: { cookie: cookies }, + }); + expect(res.statusCode).toBe(403); + }); + + it('self can deactivate own account', async () => { + const cookies = await mintCookies(IDS.alice); + const res = await app.inject({ + method: 'POST', + url: '/api/people/alice/deactivate', + headers: { cookie: cookies }, + }); + expect(res.statusCode).toBe(200); + const body = res.json<{ success: boolean; data: { deletedAt: string | null } }>(); + expect(body.success).toBe(true); + expect(body.data.deletedAt).not.toBeNull(); + }); + + it('deactivated person is excluded from GET /api/people (non-staff)', async () => { + // alice is already deactivated from the previous test + const listRes = await app.inject({ method: 'GET', url: '/api/people' }); + expect(listRes.statusCode).toBe(200); + const list = listRes.json<{ data: Array<{ slug: string }> }>(); + expect(list.data.map((p) => p.slug)).not.toContain('alice'); + }); + + it('deactivated person 404s GET /api/people/:slug for non-staff', async () => { + const getRes = await app.inject({ method: 'GET', url: '/api/people/alice' }); + expect(getRes.statusCode).toBe(404); + }); + + it('staff can still GET /api/people/:slug for deactivated person', async () => { + const cookies = await mintCookies(IDS.staff, 'staff'); + const getRes = await app.inject({ + method: 'GET', + url: '/api/people/alice', + headers: { cookie: cookies }, + }); + expect(getRes.statusCode).toBe(200); + const body = getRes.json<{ data: { deletedAt: string | null } }>(); + expect(body.data.deletedAt).not.toBeNull(); + }); +}); + +describe('POST /api/people/:slug/reactivate', () => { + let dataRepo: { path: string; cleanup: () => Promise }; + let privateStore: { path: string; cleanup: () => Promise }; + let app: FastifyInstance; + + beforeAll(async () => { + dataRepo = await createFullDataRepo(); + privateStore = await createPrivateStorageDir(); + + for (const [slug, id, level] of [ + ['alice', IDS.alice, 'user'], + ['staff-user', IDS.staff, 'staff'], + ] as const) { + const toml = [ + `id = "${id}"`, + `slug = "${slug}"`, + `fullName = "Test ${slug}"`, + `accountLevel = "${level}"`, + `createdAt = "2026-05-01T00:00:00Z"`, + `updatedAt = "2026-05-01T00:00:00Z"`, + ].join('\n'); + await seedRawToml(dataRepo.path, `people/${slug}.toml`, toml, `seed ${slug}`); + } + + app = await buildApp({ + serverOptions: { logger: false }, + overrideEnv: { + CFP_DATA_REPO_PATH: dataRepo.path, + STORAGE_BACKEND: 'filesystem', + CFP_PRIVATE_STORAGE_PATH: privateStore.path, + CFP_JWT_SIGNING_KEY: JWT_KEY, + NODE_ENV: 'test', + }, + }); + + // Deactivate alice first so reactivation tests have something to reactivate + const staffCookies = await mintCookies(IDS.staff, 'staff'); + await app.inject({ + method: 'POST', + url: '/api/people/alice/deactivate', + headers: { cookie: staffCookies }, + }); + }, 60_000); + + afterAll(async () => { + await app.close(); + await dataRepo.cleanup(); + await privateStore.cleanup(); + }); + + it('anonymous → 401', async () => { + const res = await app.inject({ method: 'POST', url: '/api/people/alice/reactivate' }); + expect(res.statusCode).toBe(401); + }); + + it('staff can reactivate', async () => { + const cookies = await mintCookies(IDS.staff, 'staff'); + const res = await app.inject({ + method: 'POST', + url: '/api/people/alice/reactivate', + headers: { cookie: cookies }, + }); + expect(res.statusCode).toBe(200); + const body = res.json<{ data: { deletedAt: string | null } }>(); + expect(body.data.deletedAt).toBeNull(); + + // alice visible again in the public list + const listRes = await app.inject({ method: 'GET', url: '/api/people' }); + const list = listRes.json<{ data: Array<{ slug: string }> }>(); + expect(list.data.map((p) => p.slug)).toContain('alice'); + }); + + it('self can reactivate own deactivated account', async () => { + // Re-deactivate alice as staff first + const staffCookies = await mintCookies(IDS.staff, 'staff'); + await app.inject({ + method: 'POST', + url: '/api/people/alice/deactivate', + headers: { cookie: staffCookies }, + }); + + // Alice reactivates herself + const aliceCookies = await mintCookies(IDS.alice); + const res = await app.inject({ + method: 'POST', + url: '/api/people/alice/reactivate', + headers: { cookie: aliceCookies }, + }); + expect(res.statusCode).toBe(200); + const body = res.json<{ data: { deletedAt: string | null } }>(); + expect(body.data.deletedAt).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Suite: purge (admin only) +// --------------------------------------------------------------------------- + +describe('POST /api/people/:slug/purge', () => { + let dataRepo: { path: string; cleanup: () => Promise }; + let privateStore: { path: string; cleanup: () => Promise }; + let app: FastifyInstance; + + beforeAll(async () => { + dataRepo = await createFullDataRepo(); + privateStore = await createPrivateStorageDir(); + + for (const [slug, id, level] of [ + ['alice', IDS.alice, 'user'], + ['staff-user', IDS.staff, 'staff'], + ['admin-user', IDS.admin, 'administrator'], + ] as const) { + const toml = [ + `id = "${id}"`, + `slug = "${slug}"`, + `fullName = "Test ${slug}"`, + `accountLevel = "${level}"`, + `createdAt = "2026-05-01T00:00:00Z"`, + `updatedAt = "2026-05-01T00:00:00Z"`, + ].join('\n'); + await seedRawToml(dataRepo.path, `people/${slug}.toml`, toml, `seed ${slug}`); + } + + // Seed a project with alice as a member and author of an update + const projectToml = [ + `id = "${IDS.project}"`, + `slug = "test-project"`, + `title = "Test Project"`, + `stage = "prototyping"`, + `maintainerId = "${IDS.admin}"`, + `createdAt = "2026-05-01T00:00:00Z"`, + `updatedAt = "2026-05-01T00:00:00Z"`, + ].join('\n'); + await seedRawToml(dataRepo.path, `projects/test-project.toml`, projectToml, 'seed project'); + + const membershipToml = [ + `id = "${IDS.membership}"`, + `projectId = "${IDS.project}"`, + `projectSlug = "test-project"`, + `personId = "${IDS.alice}"`, + `personSlug = "alice"`, + `isMaintainer = false`, + `joinedAt = "2026-05-01T00:00:00Z"`, + `updatedAt = "2026-05-01T00:00:00Z"`, + ].join('\n'); + await seedRawToml( + dataRepo.path, + `project-memberships/test-project/alice.toml`, + membershipToml, + 'seed membership', + ); + + const updateToml = [ + `id = "${IDS.update}"`, + `projectId = "${IDS.project}"`, + `projectSlug = "test-project"`, + `number = 1`, + `body = "Alice's update"`, + `authorId = "${IDS.alice}"`, + `authorSlug = "alice"`, + `createdAt = "2026-05-01T00:00:00Z"`, + `updatedAt = "2026-05-01T00:00:00Z"`, + ].join('\n'); + await seedRawToml( + dataRepo.path, + `project-updates/test-project/1.toml`, + updateToml, + 'seed update', + ); + + app = await buildApp({ + serverOptions: { logger: false }, + overrideEnv: { + CFP_DATA_REPO_PATH: dataRepo.path, + STORAGE_BACKEND: 'filesystem', + CFP_PRIVATE_STORAGE_PATH: privateStore.path, + CFP_JWT_SIGNING_KEY: JWT_KEY, + NODE_ENV: 'test', + }, + }); + }, 60_000); + + afterAll(async () => { + await app.close(); + await dataRepo.cleanup(); + await privateStore.cleanup(); + }); + + it('anonymous → 401', async () => { + const res = await app.inject({ method: 'POST', url: '/api/people/alice/purge' }); + expect(res.statusCode).toBe(401); + }); + + it('staff (non-admin) → 403', async () => { + const cookies = await mintCookies(IDS.staff, 'staff'); + const res = await app.inject({ + method: 'POST', + url: '/api/people/alice/purge', + headers: { cookie: cookies }, + }); + expect(res.statusCode).toBe(403); + }); + + it('admin can purge a person — 204 and person gone', async () => { + const cookies = await mintCookies(IDS.admin, 'administrator'); + const res = await app.inject({ + method: 'POST', + url: '/api/people/alice/purge', + headers: { cookie: cookies }, + }); + expect(res.statusCode).toBe(204); + + // alice no longer in list + const listRes = await app.inject({ method: 'GET', url: '/api/people' }); + const list = listRes.json<{ data: Array<{ slug: string }> }>(); + expect(list.data.map((p) => p.slug)).not.toContain('alice'); + + // alice 404s + const getRes = await app.inject({ method: 'GET', url: '/api/people/alice' }); + expect(getRes.statusCode).toBe(404); + }); + + it('purge cascades — alice membership removed from project', async () => { + // alice is already purged from the previous test; check project has no alice membership + const projectRes = await app.inject({ method: 'GET', url: '/api/projects/test-project' }); + expect(projectRes.statusCode).toBe(200); + const project = projectRes.json<{ + data: { memberships: Array<{ person: { slug: string | null } }> }; + }>(); + const slugs = project.data.memberships.map((m) => m.person.slug); + expect(slugs).not.toContain('alice'); + }); +}); + +// --------------------------------------------------------------------------- +// Suite: deactivated person placeholder in references +// --------------------------------------------------------------------------- + +describe('Deactivated person reference placeholder', () => { + let dataRepo: { path: string; cleanup: () => Promise }; + let privateStore: { path: string; cleanup: () => Promise }; + let app: FastifyInstance; + + beforeAll(async () => { + dataRepo = await createFullDataRepo(); + privateStore = await createPrivateStorageDir(); + + for (const [slug, id, level] of [ + ['alice', IDS.alice, 'user'], + ['admin-user', IDS.admin, 'administrator'], + ] as const) { + const toml = [ + `id = "${id}"`, + `slug = "${slug}"`, + `fullName = "Test ${slug}"`, + `accountLevel = "${level}"`, + `createdAt = "2026-05-01T00:00:00Z"`, + `updatedAt = "2026-05-01T00:00:00Z"`, + ].join('\n'); + await seedRawToml(dataRepo.path, `people/${slug}.toml`, toml, `seed ${slug}`); + } + + const projectToml = [ + `id = "${IDS.project}"`, + `slug = "test-project"`, + `title = "Test Project"`, + `stage = "prototyping"`, + `maintainerId = "${IDS.admin}"`, + `createdAt = "2026-05-01T00:00:00Z"`, + `updatedAt = "2026-05-01T00:00:00Z"`, + ].join('\n'); + await seedRawToml(dataRepo.path, `projects/test-project.toml`, projectToml, 'seed project'); + + const membershipToml = [ + `id = "${IDS.membership}"`, + `projectId = "${IDS.project}"`, + `projectSlug = "test-project"`, + `personId = "${IDS.alice}"`, + `personSlug = "alice"`, + `isMaintainer = false`, + `joinedAt = "2026-05-01T00:00:00Z"`, + `updatedAt = "2026-05-01T00:00:00Z"`, + ].join('\n'); + await seedRawToml( + dataRepo.path, + `project-memberships/test-project/alice.toml`, + membershipToml, + 'seed membership', + ); + + app = await buildApp({ + serverOptions: { logger: false }, + overrideEnv: { + CFP_DATA_REPO_PATH: dataRepo.path, + STORAGE_BACKEND: 'filesystem', + CFP_PRIVATE_STORAGE_PATH: privateStore.path, + CFP_JWT_SIGNING_KEY: JWT_KEY, + NODE_ENV: 'test', + }, + }); + + // Deactivate alice + const { accessToken } = await mintSessionFor(IDS.admin, 'administrator', JWT_KEY); + await app.inject({ + method: 'POST', + url: '/api/people/alice/deactivate', + headers: { cookie: `cfp_session=${accessToken}` }, + }); + }, 60_000); + + afterAll(async () => { + await app.close(); + await dataRepo.cleanup(); + await privateStore.cleanup(); + }); + + it('deactivated person reference in project membership shows placeholder', async () => { + const res = await app.inject({ method: 'GET', url: '/api/projects/test-project' }); + expect(res.statusCode).toBe(200); + + type MemberShape = { person: { slug: string | null; fullName: string; deactivated?: boolean } }; + const body = res.json<{ data: { memberships: MemberShape[] } }>(); + + // alice should appear as "Deactivated user" placeholder + const placeholder = body.data.memberships.find( + (m) => m.person.fullName === 'Deactivated user', + ); + expect(placeholder).toBeDefined(); + expect(placeholder!.person.slug).toBeNull(); + expect(placeholder!.person.deactivated).toBe(true); + }); +}); diff --git a/apps/web/tests/AccountDeactivate.test.tsx b/apps/web/tests/AccountDeactivate.test.tsx new file mode 100644 index 0000000..b37b086 --- /dev/null +++ b/apps/web/tests/AccountDeactivate.test.tsx @@ -0,0 +1,107 @@ +/** + * Account screen — self deactivate / reactivate Danger Zone. + * + * Spec: specs/behaviors/person-lifecycle.md, specs/api/people.md + */ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderScreen, mockOk } from './test-utils.js'; +import { Account } from '../src/screens/Account.js'; +import { AuthProvider } from '../src/hooks/useAuth.js'; + +interface MeShape { + person: { id: string; slug: string; fullName: string; accountLevel: string; avatarUrl: string | null } | null; + accountLevel: string; + hasGitHubLink: boolean; + lastLoginMethod: 'github' | 'legacy_password' | 'password_reset' | null; +} + +const ME: MeShape = { + person: { + id: '01951a3c-0000-7000-8000-0000ffffff10', + slug: 'jane-doe', + fullName: 'Jane Doe', + accountLevel: 'user', + avatarUrl: null, + }, + accountLevel: 'user', + hasGitHubLink: true, + lastLoginMethod: 'github', +}; + +function mockApi(deactivateImpl?: () => Response): void { + vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string, init?: RequestInit) => { + if (input.startsWith('/api/auth/me')) { + return Promise.resolve( + new Response(JSON.stringify(mockOk(ME)), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + } + if (input.startsWith('/api/auth/sessions')) { + return Promise.resolve( + new Response(JSON.stringify(mockOk([])), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + } + if (input.startsWith('/api/people/jane-doe/deactivate') && init?.method === 'POST') { + return Promise.resolve(deactivateImpl ? deactivateImpl() : new Response( + JSON.stringify(mockOk({ ...ME.person, deletedAt: '2026-06-01T00:00:00Z' })), + { status: 200, headers: { 'content-type': 'application/json' } }, + )); + } + return Promise.resolve(new Response(null, { status: 404 })); + }) as typeof fetch); +} + +describe('Account — Danger zone deactivate', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('shows a "Deactivate my account" button', async () => { + mockApi(); + renderScreen( + + + , + { initialEntries: ['/account'] }, + ); + await waitFor(() => { + expect( + screen.getByRole('button', { name: /deactivate my account/i }), + ).toBeInTheDocument(); + }); + }); + + it('opens a confirm dialog and calls the deactivate endpoint on confirm', async () => { + const deactivateSpy = vi.fn(() => + new Response( + JSON.stringify(mockOk({ ...ME.person, deletedAt: '2026-06-01T00:00:00Z' })), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ); + mockApi(deactivateSpy); + const user = userEvent.setup(); + renderScreen( + + + , + { initialEntries: ['/account'] }, + ); + await waitFor(() => { + expect(screen.getByRole('button', { name: /deactivate my account/i })).toBeInTheDocument(); + }); + await user.click(screen.getByRole('button', { name: /deactivate my account/i })); + // The confirm dialog appears with its own Deactivate action. + const confirmBtn = await screen.findByRole('button', { name: /^deactivate$/i }); + await user.click(confirmBtn); + await waitFor(() => { + expect(deactivateSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/web/tests/PersonAvatarDeactivated.test.tsx b/apps/web/tests/PersonAvatarDeactivated.test.tsx new file mode 100644 index 0000000..92730c1 --- /dev/null +++ b/apps/web/tests/PersonAvatarDeactivated.test.tsx @@ -0,0 +1,33 @@ +/** + * Tests for the deactivated-person placeholder rendering in PersonAvatar. + * + * Spec: specs/behaviors/person-lifecycle.md, specs/api/people.md + * A deactivated person reference renders a non-linking placeholder. + */ +import { describe, expect, it } from 'vitest'; +import { screen } from '@testing-library/react'; +import { renderWithRouter } from './test-utils.js'; +import { PersonAvatar } from '../src/components/PersonAvatar.js'; + +describe('PersonAvatar — deactivated placeholder', () => { + it('does not render a link when the person is deactivated (slug null)', () => { + renderWithRouter( + , + ); + // No member link should be produced for a deactivated reference. + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); + + it('renders a link for an active person reference', () => { + renderWithRouter( + , + ); + expect(screen.getByRole('link')).toHaveAttribute('href', '/members/jane-doe'); + }); +}); From 0491973e0f5c577f7f0345c7d82a891c5f748e36 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sat, 27 Jun 2026 18:44:05 -0400 Subject: [PATCH 13/15] chore(plans): mark person-deactivate-purge done (PR #144) Co-Authored-By: Claude Opus 4.8 (1M context) --- plans/person-deactivate-purge.md | 75 ++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 plans/person-deactivate-purge.md diff --git a/plans/person-deactivate-purge.md b/plans/person-deactivate-purge.md new file mode 100644 index 0000000..e89aec7 --- /dev/null +++ b/plans/person-deactivate-purge.md @@ -0,0 +1,75 @@ +--- +status: done +depends: [] +specs: + - specs/behaviors/person-lifecycle.md + - specs/api/people.md +issues: + - 129 +pr: 144 +--- + +# Plan: person deactivate / reactivate / purge + +## Scope + +Leadership ask (#129): admins can edit users but can't delete them. Implement +two verbs (per discussion): + +- **Deactivate** (soft, self-service): self or staff/admin sets `deletedAt`; + person hidden from public lists + 404 on detail for non-staff (self + staff + still see it); references render a "Deactivated user" placeholder. The person + can still sign in and reactivate. Reactivate clears `deletedAt`. +- **Purge** (admin only): cascading hard delete of the person + their + memberships, help-wanted-interest, person tag-assignments, and authored + updates/buzz/blog-posts, in one commit (git-revertable). + +## Implements + +- [person-lifecycle.md](../specs/behaviors/person-lifecycle.md) — the two verbs, + authz, placeholder, login-not-blocked. +- [api/people.md](../specs/api/people.md) — endpoints + placeholder response. + +## Approach + +- **API:** `POST /api/people/:slug/deactivate|reactivate` (self | staff) and + `POST /api/people/:slug/purge` (administrator), via the write mutex. Authz + through the existing `requireAuth` markers. Purge cascade mirrors the offline + spam-prune but deletes authored content. +- **Read/serialize:** `people.get` returns a deactivated person only to staff or + self (for reactivation); list excludes deactivated for non-staff; + `serializePersonAvatar` (+ the author/member serializers that use it) emits a + "Deactivated user" placeholder for deactivated references. +- **Web:** `/account` self deactivate/reactivate; admin Danger Zone on the + person screen; placeholder rendering in `PersonAvatar`. + +## Validation + +- [x] deactivate: self ✓, staff ✓, anon → 401, other user → 403; response + carries `deletedAt`. +- [x] deactivated hidden from list (non-staff), 404 on detail (non-staff), + visible to staff + self. +- [x] reactivate: self + staff clear `deletedAt`. +- [x] purge: admin → person + authored content + memberships removed; staff + (non-admin) → 403; anon → 401; cascade verified on a project membership. +- [x] placeholder renders for a deactivated reference. +- [x] `type-check` + `lint` clean; people-lifecycle 14/14; read-api/project/ + blog/help-wanted/people 67/67; web 85/85. + +## Risks + +- Authz hinges on the session's accountLevel claim (not data) — verified by the + guard tests. + +## Notes + +- Drafted by a subagent in an isolated worktree; it hit a context limit before + committing/validating. Taken over here: the implementation was sound, the only + defect was the test's `mintCookies` ignoring its `level` argument (so staff/ + admin callers authenticated as plain users → spurious 403s). Fixed that; all + suites green. + +## Follow-ups + +- Purge and the offline spam-prune (#133) now both cascade person content; keep + their semantics aligned if either changes. From 906a3d1d47c3ef42f8346b63966a8c89fbbf79e3 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sun, 28 Jun 2026 15:24:14 -0400 Subject: [PATCH 14/15] chore(deps): bump gitsheets to 1.4.1 Command: npm install gitsheets@^1.4.1 -w apps/api Picks up gitsheets 1.4.1, which switches the TOML record parser from @iarna/toml to smol-toml (JarvusInnovations/gitsheets#197). This fixes the #132 boot-heap blowup at its root: @iarna's parser pinned ~12x each record's source in V8 sliced/cons-strings. Measured end-to-end against the full `published` import via the boot path: retained heap 581 MB -> 88 MB (6.6x) transient peak 532 MB -> 71 MB (7.5x) rss 806 MB -> 292 MB type-check + lint clean; api 412/412 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/package.json | 2 +- package-lock.json | 21 +++++++++++++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index 0859033..b5425ed 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -35,7 +35,7 @@ "bcryptjs": "^3.0.3", "better-sqlite3": "^12.10.0", "fastify": "^5.8.5", - "gitsheets": "^1.3.1", + "gitsheets": "^1.4.1", "jose": "^6.2.3", "resend": "^6.12.4", "samlify": "^2.13.0", diff --git a/package-lock.json b/package-lock.json index 61741f4..1dbe57d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,7 @@ "bcryptjs": "^3.0.3", "better-sqlite3": "^12.10.0", "fastify": "^5.8.5", - "gitsheets": "^1.3.1", + "gitsheets": "^1.4.1", "jose": "^6.2.3", "resend": "^6.12.4", "samlify": "^2.13.0", @@ -9003,9 +9003,9 @@ "license": "MIT" }, "node_modules/gitsheets": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/gitsheets/-/gitsheets-1.3.1.tgz", - "integrity": "sha512-5s+ewIZu76nytdtTQKjdVWL4u5Ci+VYBT609I6JPDem+Xm9V2GpeV5BGUfJ7QWYdhqytYAaUyeipj/3pjHmUwQ==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/gitsheets/-/gitsheets-1.4.1.tgz", + "integrity": "sha512-/GIsDgsjweXcNk8ThYjAsWYctGr/xn0iVFE2J4u7yvJgmQdLSsOdLgqAwlny8MinhStniqpPm1XE+tvjt615JQ==", "license": "Apache-2.0", "dependencies": { "@iarna/toml": "^2.2.5", @@ -9016,6 +9016,7 @@ "hologit": "^0.50.2", "markdownlint": "^0.40.0", "rfc6902": "^5.2.0", + "smol-toml": "^1.7.0", "sort-keys": "^6.0.0", "yargs": "^18.0.0" }, @@ -14090,6 +14091,18 @@ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "license": "MIT" }, + "node_modules/smol-toml": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz", + "integrity": "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", From 4f6525fceb02407e8989c1c6d4bd29eab40c9dd5 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sun, 28 Jun 2026 17:08:45 -0400 Subject: [PATCH 15/15] chore(deploy): lower memory limits after gitsheets 1.4.1 heap fix gitsheets 1.4.1 (smol-toml parser, #132) cut the boot footprint dramatically. Measured against the full `published` import: heap >500 MB -> ~123 MB RSS ~806 MB (incident) -> ~321 MB Resize from the leak-era ceilings to the real working set, with margin: NODE_OPTIONS --max-old-space-size 2048 -> 512 (~4x boot heap) requests.memory 1Gi -> 512Mi (> ~321Mi steady RSS) limits.memory 2.5Gi -> 1Gi (~3x steady RSS) Hands the ~3.9Gi nodes ~1.5Gi back vs the old 2.5Gi limit, removing the node-starvation risk that drove the earlier NodeNotReady. Co-Authored-By: Claude Opus 4.8 (1M context) --- deploy/kustomize/base/configmap.yaml | 20 +++++++++----------- deploy/kustomize/base/deployment.yaml | 20 +++++++++++--------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/deploy/kustomize/base/configmap.yaml b/deploy/kustomize/base/configmap.yaml index 925de97..aafce58 100644 --- a/deploy/kustomize/base/configmap.yaml +++ b/deploy/kustomize/base/configmap.yaml @@ -19,18 +19,16 @@ data: NODE_ENV: "production" # Bump Node's old-space heap ceiling above the default (~400Mi on Linux # containers) so the in-memory record set + secondary indices + FTS fit. - # Keep ~25% headroom below the container's memory limit for ephemeral - # work (request handling, gitsheets tree mutations, etc.). # - # 2048 (was 1536): a fresh boot rebuilding in-memory state from the full - # `published` import (~31.8k people + ~10.4k tag-assignments + secondary - # indices) OOM'd at the 1536 ceiling. The native FTS5 store is off-heap; - # the V8 heap holds the record maps + indices. 2048 boots cleanly and runs - # stable; it is deliberately kept modest because these nodes are only - # ~3.9Gi — an earlier 3072/3.5Gi trial let the pod grow until it starved - # the node's kubelet (NodeNotReady). See the memory-optimization issue for - # the (suspiciously large ~60x) on-disk-to-heap expansion worth reducing. - NODE_OPTIONS: "--max-old-space-size=2048" + # 512 (was 2048): gitsheets 1.4.1 switched the TOML parser to smol-toml, + # fixing the #132 heap blowup at its root (@iarna's parser pinned ~12x each + # record's source in V8 sliced/cons-strings). A full boot from the + # `published` import now measures ~123Mi heap / ~321Mi RSS (FTS5 is off-heap + # and tiny, ~40Mi). 512 is ~4x the measured boot heap — ample for request + # handling + gitsheets tree mutations — while leaving the node (~3.9Gi) most + # of its memory back. The earlier 2048/2.5Gi was sized for the leak, not the + # real working set. + NODE_OPTIONS: "--max-old-space-size=512" PORT: "3001" STORAGE_BACKEND: "filesystem" # The runtime-served branch. `published` is the long-term sandbox + prod diff --git a/deploy/kustomize/base/deployment.yaml b/deploy/kustomize/base/deployment.yaml index bddf30b..7bd881f 100644 --- a/deploy/kustomize/base/deployment.yaml +++ b/deploy/kustomize/base/deployment.yaml @@ -64,19 +64,21 @@ spec: resources: requests: cpu: 100m - memory: 1Gi + # ~321Mi measured steady-state RSS (full `published` import + + # indices + FTS) with gitsheets 1.4.1; request sits above that. + memory: 512Mi limits: cpu: 1000m # Holds the full public dataset + secondary indices + FTS in # memory (~31.8k people, 268 projects, 10.4k tag-assignments, …). - # 2.5Gi (was 2Gi): a cold boot rebuilding state from the full - # `published` import exceeded the prior 1536Mi heap; raised to - # NODE_OPTIONS=--max-old-space-size=2048 (see configmap.yaml), - # with the container limit just above that for the off-heap FTS5 - # store + ephemeral request work. Kept at 2.5Gi (not higher) so - # a single pod can't starve a node — these nodes are only ~3.9Gi - # and a 3.5Gi trial drove one NodeNotReady. ~1.4Gi node headroom. - memory: 2560Mi + # 1Gi (was 2.5Gi): gitsheets 1.4.1's smol-toml parser fixed the + # #132 heap blowup — a full boot now measures ~123Mi heap / + # ~321Mi RSS (vs >500Mi heap before). With the heap ceiling at + # NODE_OPTIONS=--max-old-space-size=512 (see configmap.yaml), 1Gi + # gives ~3x the steady RSS for heap-ceiling growth + off-heap FTS5 + # + ephemeral request work, and hands the ~3.9Gi node ~1.5Gi back + # vs the old 2.5Gi limit. + memory: 1Gi securityContext: runAsNonRoot: true runAsUser: 1000