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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/api/src/plugins/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ async function servicesPlugin(fastify: FastifyInstance): Promise<void> {
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),
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/routes/people.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export async function peopleRoutes(fastify: FastifyInstance): Promise<void> {
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`);
}
Expand Down Expand Up @@ -136,7 +136,7 @@ export async function peopleRoutes(fastify: FastifyInstance): Promise<void> {
);
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)
Expand Down
18 changes: 16 additions & 2 deletions apps/api/src/services/person.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<PersonDetail | null> {
const personId = this.#state.personIdBySlug.get(slug);
if (!personId) return null;
const person = this.#state.people.get(personId);
Expand All @@ -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,
Expand All @@ -165,6 +178,7 @@ export class PersonService {
permissions,
callerAccountLevel: caller?.accountLevel,
callerPersonId: caller?.id,
visibleEmail,
});
}

Expand Down
14 changes: 14 additions & 0 deletions apps/api/src/services/serializers/person.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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 : '';
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions apps/api/tests/helpers/seed-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export async function seedFixtures(repoPath: string): Promise<SeededFixtures> {
lastName: 'Doe',
bio: 'A civic technologist.',
accountLevel: 'user',
slackHandle: 'jane-doe',
createdAt: NOW,
updatedAt: NOW,
});
Expand Down
13 changes: 13 additions & 0 deletions apps/api/tests/read-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
31 changes: 31 additions & 0 deletions apps/web/src/screens/PersonDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,37 @@ export function PersonDetail() {
</div>

<aside className="space-y-4 text-sm">
{(person.slackHandle || person.email) && (
<section>
<h3 className="text-sm font-semibold mb-2 text-muted-foreground uppercase tracking-wide">
Contact
</h3>
<ul className="space-y-1">
{person.slackHandle && (
<li>
<a
href={`https://codeforphilly.slack.com/team/${encodeURIComponent(person.slackHandle)}`}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline hover:no-underline"
>
DM on Slack
</a>
</li>
)}
{person.email && (
<li>
<a
href={`mailto:${person.email}`}
className="text-primary underline hover:no-underline"
>
{person.email}
</a>
</li>
)}
</ul>
</section>
)}
<section>
<h3 className="text-sm font-semibold mb-2 text-muted-foreground uppercase tracking-wide">
Member since
Expand Down
102 changes: 102 additions & 0 deletions apps/web/tests/PersonDetail.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { Routes, Route } from 'react-router';
import { renderScreen, mockOk } from './test-utils.js';
import { PersonDetail } from '../src/screens/PersonDetail.js';
import { AuthProvider } from '../src/hooks/useAuth.js';

const BASE_PERSON = {
id: '01951a3c-0000-7000-8000-000000000001',
slug: 'jane-doe',
fullName: 'Jane Doe',
firstName: 'Jane',
lastName: 'Doe',
avatarUrl: null,
bio: 'A civic technologist.',
bioHtml: '<p>A civic technologist.</p>',
accountLevel: 'user',
slackHandle: null,
email: null,
tags: { topic: [], tech: [] },
memberships: [],
recentUpdates: [],
permissions: { canEdit: false, canDelete: false },
createdAt: '2025-01-01T00:00:00Z',
updatedAt: '2025-01-01T00:00:00Z',
};

function makeFetchMock(person: typeof BASE_PERSON) {
return ((input: string) => {
if (input.startsWith('/api/auth/me')) {
return Promise.resolve(new Response(null, { status: 404 }));
}
if (input.startsWith('/api/people/jane-doe')) {
return Promise.resolve(
new Response(JSON.stringify(mockOk(person)), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
}
return Promise.resolve(new Response(null, { status: 404 }));
}) as typeof fetch;
}

describe('PersonDetail Contact sidebar', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it('renders neither Contact section nor email when both fields are absent', async () => {
vi.spyOn(globalThis, 'fetch').mockImplementation(makeFetchMock(BASE_PERSON));
renderScreen(
<AuthProvider>
<Routes>
<Route path="/members/:slug" element={<PersonDetail />} />
</Routes>
</AuthProvider>,
{ initialEntries: ['/members/jane-doe'] },
);
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Jane Doe', level: 1 })).toBeInTheDocument();
});
expect(screen.queryByRole('heading', { name: /^contact$/i })).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: /dm on slack/i })).not.toBeInTheDocument();
});

it('renders DM on Slack link when slackHandle is set', async () => {
vi.spyOn(globalThis, 'fetch').mockImplementation(
makeFetchMock({ ...BASE_PERSON, slackHandle: 'jane-doe' }),
);
renderScreen(
<AuthProvider>
<Routes>
<Route path="/members/:slug" element={<PersonDetail />} />
</Routes>
</AuthProvider>,
{ initialEntries: ['/members/jane-doe'] },
);
await waitFor(() => {
const dm = screen.getByRole('link', { name: /dm on slack/i });
expect(dm).toHaveAttribute('href', 'https://codeforphilly.slack.com/team/jane-doe');
});
});

it('renders mailto link when email is present', async () => {
vi.spyOn(globalThis, 'fetch').mockImplementation(
makeFetchMock({ ...BASE_PERSON, email: 'jane@example.com' }),
);
renderScreen(
<AuthProvider>
<Routes>
<Route path="/members/:slug" element={<PersonDetail />} />
</Routes>
</AuthProvider>,
{ initialEntries: ['/members/jane-doe'] },
);
await waitFor(() => {
const mailto = screen.getByRole('link', { name: /jane@example\.com/i });
expect(mailto).toHaveAttribute('href', 'mailto:jane@example.com');
});
});
});
Loading