From 3cd31495a5d6a82b4f5f8c41e9a1b098ae68f002 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sat, 27 Jun 2026 18:43:11 -0400 Subject: [PATCH 1/4] =?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 2/4] 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 3/4] 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 4/4] 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.