diff --git a/apps/api/src/plugins/services.ts b/apps/api/src/plugins/services.ts index 4beeab8..92bcbd5 100644 --- a/apps/api/src/plugins/services.ts +++ b/apps/api/src/plugins/services.ts @@ -87,7 +87,7 @@ async function servicesPlugin(fastify: FastifyInstance): Promise { const githubAccount = new GitHubAccountService(state); fastify.decorate('services', { projects: new ProjectService(state, fts), - people: new PersonService(state, fts), + people: new PersonService(state, fts, fastify.store.private), tags: new TagService(state), projectUpdates: new ProjectUpdateService(state), projectBuzz: new ProjectBuzzService(state), diff --git a/apps/api/src/routes/people.ts b/apps/api/src/routes/people.ts index 84cd27f..5a8d179 100644 --- a/apps/api/src/routes/people.ts +++ b/apps/api/src/routes/people.ts @@ -104,7 +104,7 @@ export async function peopleRoutes(fastify: FastifyInstance): Promise { const { slug } = request.params as { slug: string }; const caller = getCallerSession(request); - const person = fastify.services.people.get(slug, caller); + const person = await fastify.services.people.get(slug, caller); if (!person) { throw new ApiNotFoundError(`Person '${slug}' not found`); } @@ -136,7 +136,7 @@ export async function peopleRoutes(fastify: FastifyInstance): Promise { ); result.value.stateApply.apply(fastify.inMemoryState, fastify.fts); const caller = getCallerSession(request); - return ok(fastify.services.people.get(result.value.person.slug, caller)); + return ok(await fastify.services.people.get(result.value.person.slug, caller)); }); // DELETE /api/people/:slug (admin-only soft-delete) diff --git a/apps/api/src/services/person.ts b/apps/api/src/services/person.ts index 54be9d2..0aa8705 100644 --- a/apps/api/src/services/person.ts +++ b/apps/api/src/services/person.ts @@ -4,6 +4,7 @@ import type { Person, Project, ProjectMembership, ProjectUpdate } from '@cfp/shared/schemas'; import type { InMemoryState } from '../store/memory/state.js'; import type { FtsEngine } from '../store/fts.js'; +import type { PrivateStore } from '../store/private/interface.js'; import { getPeopleFacets, type PeopleFacets } from '../store/memory/facets.js'; import type { CallerSession } from './permissions.js'; import { computePersonPermissions } from './permissions.js'; @@ -60,10 +61,12 @@ function comparePeople(a: Person, b: Person, sortSpec: Array<{ key: string; desc export class PersonService { readonly #state: InMemoryState; readonly #fts: FtsEngine; + readonly #privateStore: PrivateStore; - constructor(state: InMemoryState, fts: FtsEngine) { + constructor(state: InMemoryState, fts: FtsEngine, privateStore: PrivateStore) { this.#state = state; this.#fts = fts; + this.#privateStore = privateStore; } list( @@ -133,7 +136,7 @@ export class PersonService { return { items, totalItems, facets }; } - get(slug: string, caller?: CallerSession): PersonDetail | null { + async get(slug: string, caller?: CallerSession): Promise { const personId = this.#state.personIdBySlug.get(slug); if (!personId) return null; const person = this.#state.people.get(personId); @@ -155,6 +158,16 @@ export class PersonService { const permissions = computePersonPermissions(caller, person); + // email is gated to self + staff per specs/screens/person-detail.md. + // The private-store read only happens when the caller is allowed to + // see it — anonymous reads stay free of any private-store touch. + const isSelf = caller?.id === person.id; + let visibleEmail: string | null = null; + if (isSelf || isStaff) { + const profile = await this.#privateStore.getProfile(person.id); + visibleEmail = profile?.email ?? null; + } + return serializePersonDetail(person, { memberships, projectsMap, @@ -165,6 +178,7 @@ export class PersonService { permissions, callerAccountLevel: caller?.accountLevel, callerPersonId: caller?.id, + visibleEmail, }); } diff --git a/apps/api/src/services/serializers/person.ts b/apps/api/src/services/serializers/person.ts index 4f9c270..a12072f 100644 --- a/apps/api/src/services/serializers/person.ts +++ b/apps/api/src/services/serializers/person.ts @@ -57,6 +57,12 @@ export interface PersonDetail { readonly bio: string | null; readonly bioHtml: string; readonly accountLevel: string; + readonly slackHandle: string | null; + /** + * Set to the target's email for self/staff callers; null otherwise. + * Per specs/screens/person-detail.md authorization table. + */ + readonly email: string | null; readonly tags: { topic: TagItem[]; tech: TagItem[] }; readonly memberships: PersonMembershipSummary[]; readonly recentUpdates: ProjectUpdateSummary[]; @@ -106,6 +112,12 @@ export function serializePersonDetail( /** Caller's accountLevel — used to decide how much accountLevel to expose. */ callerAccountLevel?: 'user' | 'staff' | 'administrator'; callerPersonId?: string; + /** + * The target's email, when the caller is allowed to see it (self or + * staff). The service is responsible for the gating + private-store + * read; the serializer just passes through whatever's supplied. + */ + visibleEmail?: string | null; }, ): PersonDetail { const bioHtml = person.bio ? renderMarkdown(person.bio).html : ''; @@ -161,6 +173,8 @@ export function serializePersonDetail( bio: person.bio ?? null, bioHtml, accountLevel: visibleAccountLevel, + slackHandle: person.slackHandle ?? null, + email: opts.visibleEmail ?? null, tags: { topic: tagsByNamespace.topic, tech: tagsByNamespace.tech }, memberships, recentUpdates, diff --git a/apps/api/tests/helpers/seed-fixtures.ts b/apps/api/tests/helpers/seed-fixtures.ts index 3df249b..9dede46 100644 --- a/apps/api/tests/helpers/seed-fixtures.ts +++ b/apps/api/tests/helpers/seed-fixtures.ts @@ -145,6 +145,7 @@ export async function seedFixtures(repoPath: string): Promise { lastName: 'Doe', bio: 'A civic technologist.', accountLevel: 'user', + slackHandle: 'jane-doe', createdAt: NOW, updatedAt: NOW, }); diff --git a/apps/api/tests/read-api.test.ts b/apps/api/tests/read-api.test.ts index 6dbd747..ee431c3 100644 --- a/apps/api/tests/read-api.test.ts +++ b/apps/api/tests/read-api.test.ts @@ -266,6 +266,19 @@ describe('GET /api/people/:slug', () => { expect(typeof body.data.permissions.canEdit).toBe('boolean'); }); + it('exposes slackHandle to anonymous callers (public field)', async () => { + const res = await app!.inject({ method: 'GET', url: `/api/people/${fixtures.personSlug}` }); + expect(res.statusCode).toBe(200); + const body = json<{ data: { slackHandle: string | null } }>(res); + expect(body.data.slackHandle).toBe('jane-doe'); + }); + + it('does NOT expose email to anonymous callers', async () => { + const res = await app!.inject({ method: 'GET', url: `/api/people/${fixtures.personSlug}` }); + const body = json<{ data: { email: string | null } }>(res); + expect(body.data.email).toBeNull(); + }); + it('returns 404 for unknown slug', async () => { const res = await app!.inject({ method: 'GET', url: '/api/people/nobody' }); expect(res.statusCode).toBe(404); diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index da26c50..c108f54 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -205,6 +205,10 @@ export interface PersonDetail { readonly bio: string | null; readonly bioHtml: string; readonly accountLevel: string; + /** Public slack handle; renders as a DM link when present. */ + readonly slackHandle: string | null; + /** Set for self + staff callers per specs/screens/person-detail.md. */ + readonly email: string | null; readonly tags: { topic: TagItem[]; tech: TagItem[] }; readonly memberships: PersonMembershipSummary[]; readonly recentUpdates: ProjectUpdateSummary[]; diff --git a/apps/web/src/screens/PersonDetail.tsx b/apps/web/src/screens/PersonDetail.tsx index daf7170..484808b 100644 --- a/apps/web/src/screens/PersonDetail.tsx +++ b/apps/web/src/screens/PersonDetail.tsx @@ -156,6 +156,37 @@ export function PersonDetail() {