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/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/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/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/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/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/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 dd996fb..2c99a44 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 { @@ -194,7 +195,7 @@ export function serializeProjectDetail( id: m.id, projectSlug: project.slug, person: serializePersonAvatar(person) ?? { - slug: '', + slug: null, fullName: 'Unknown', avatarUrl: null, }, @@ -243,5 +244,6 @@ export function serializeProjectDetail( featured: project.featured, createdAt: project.createdAt, updatedAt: project.updatedAt, + deletedAt: project.deletedAt ?? 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/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 { 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/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 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 3a7bcb9..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 { @@ -175,6 +178,7 @@ export interface ProjectDetail { readonly featured: boolean; readonly createdAt: string; readonly updatedAt: string; + readonly deletedAt: string | null; } export interface PersonListItem { @@ -205,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 { @@ -227,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 { @@ -296,6 +306,7 @@ export interface BlogPostResponse { readonly featuredImageUrl: string | null; readonly body: string; readonly bodyHtml: string; + readonly tags: TagItem[]; readonly createdAt: string; readonly updatedAt: string; } @@ -591,6 +602,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', @@ -693,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/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() {