Skip to content

Commit b80936d

Browse files
Merge pull request #22 from CodeForPhilly/feat/read-api
feat(read-api): GET endpoints for projects, people, tags + FTS + serializers
2 parents 4fac008 + aeb7254 commit b80936d

33 files changed

Lines changed: 4651 additions & 12 deletions

apps/api/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"@fastify/rate-limit": "^10.3.0",
2323
"@fastify/swagger": "^9.7.0",
2424
"@fastify/swagger-ui": "^5.2.6",
25+
"better-sqlite3": "^12.10.0",
2526
"fastify": "^5.8.5",
2627
"gitsheets": "^1.0.3",
2728
"jose": "^6.2.3",
@@ -30,6 +31,7 @@
3031
},
3132
"devDependencies": {
3233
"@faker-js/faker": "^10.4.0",
34+
"@types/better-sqlite3": "^7.6.13",
3335
"@types/node": "^25.8.0",
3436
"msw": "^2.14.6",
3537
"pino-pretty": "^13.1.3",

apps/api/src/app.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,18 @@ import { envJsonSchema, type Env } from './env.js';
2828
import { mapError } from './lib/errors.js';
2929
import traceIdPlugin from './plugins/trace-id.js';
3030
import storePlugin from './plugins/store.js';
31+
import servicesPlugin from './plugins/services.js';
3132
import rateLimitPlugin from './plugins/rate-limit.js';
3233
import idempotencyPlugin from './plugins/idempotency.js';
3334
import sessionMiddlewarePlugin from './auth/middleware.js';
3435
import { healthRoutes } from './routes/health.js';
3536
import { authRoutes } from './routes/auth.js';
37+
import { projectRoutes } from './routes/projects.js';
38+
import { peopleRoutes } from './routes/people.js';
39+
import { tagRoutes } from './routes/tags.js';
40+
import { projectUpdateRoutes } from './routes/projects-updates.js';
41+
import { projectBuzzRoutes } from './routes/projects-buzz.js';
42+
import { helpWantedRoutes } from './routes/projects-help-wanted.js';
3643

3744
declare module 'fastify' {
3845
interface FastifyInstance {
@@ -93,6 +100,9 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise<FastifyInsta
93100
// ----- 6. Store (boots gitsheets + private-store) -----
94101
await fastify.register(storePlugin);
95102

103+
// ----- 6b. Services (loads in-memory state + FTS, boots after store) -----
104+
await fastify.register(servicesPlugin);
105+
96106
// ----- 7. Rate limiting -----
97107
await fastify.register(rateLimitPlugin);
98108

@@ -128,6 +138,12 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise<FastifyInsta
128138
// ----- 11. Routes -----
129139
await fastify.register(healthRoutes);
130140
await fastify.register(authRoutes);
141+
await fastify.register(projectRoutes);
142+
await fastify.register(peopleRoutes);
143+
await fastify.register(tagRoutes);
144+
await fastify.register(projectUpdateRoutes);
145+
await fastify.register(projectBuzzRoutes);
146+
await fastify.register(helpWantedRoutes);
131147

132148
// Serve the OpenAPI JSON at the spec-mandated path /api/_openapi.json
133149
// (swagger-ui also exposes it at /api/_docs/json, but the spec names this path)

apps/api/src/plugins/services.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* Services plugin.
3+
*
4+
* Loads in-memory state from the public store, builds the FTS engine,
5+
* and decorates fastify with service instances that route handlers use.
6+
*
7+
* Depends on the store plugin (for fastify.store).
8+
*/
9+
import type { FastifyInstance } from 'fastify';
10+
import fp from 'fastify-plugin';
11+
import { loadInMemoryState } from '../store/memory/loader.js';
12+
import { invalidateFacets } from '../store/memory/facets.js';
13+
import { buildFtsEngine } from '../store/fts.js';
14+
import { ProjectService } from '../services/project.js';
15+
import { PersonService } from '../services/person.js';
16+
import { TagService } from '../services/tag.js';
17+
import { ProjectUpdateService } from '../services/project-update.js';
18+
import { ProjectBuzzService } from '../services/project-buzz.js';
19+
import { HelpWantedService } from '../services/help-wanted.js';
20+
21+
declare module 'fastify' {
22+
interface FastifyInstance {
23+
services: {
24+
projects: ProjectService;
25+
people: PersonService;
26+
tags: TagService;
27+
projectUpdates: ProjectUpdateService;
28+
projectBuzz: ProjectBuzzService;
29+
helpWanted: HelpWantedService;
30+
};
31+
}
32+
}
33+
34+
async function servicesPlugin(fastify: FastifyInstance): Promise<void> {
35+
const publicStore = fastify.store.public;
36+
const state = await loadInMemoryState(publicStore);
37+
// Reset module-level facet cache so a fresh boot reflects current state
38+
// (relevant in tests where multiple buildApp() runs share the module).
39+
invalidateFacets();
40+
const fts = buildFtsEngine(state);
41+
42+
fastify.decorate('services', {
43+
projects: new ProjectService(state, fts),
44+
people: new PersonService(state, fts),
45+
tags: new TagService(state),
46+
projectUpdates: new ProjectUpdateService(state),
47+
projectBuzz: new ProjectBuzzService(state),
48+
helpWanted: new HelpWantedService(state, fts),
49+
});
50+
}
51+
52+
export default fp(servicesPlugin, {
53+
name: 'services',
54+
fastify: '5.x',
55+
dependencies: ['store'],
56+
});

apps/api/src/routes/people.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* People routes:
3+
* GET /api/people
4+
* GET /api/people/:slug
5+
*/
6+
import type { FastifyInstance } from 'fastify';
7+
import { ok, paginated } from '../lib/response.js';
8+
import { ApiNotFoundError, ApiValidationError } from '../lib/errors.js';
9+
import { getCallerSession } from '../services/permissions.js';
10+
11+
export async function peopleRoutes(fastify: FastifyInstance): Promise<void> {
12+
// GET /api/people
13+
fastify.get(
14+
'/api/people',
15+
{
16+
schema: {
17+
tags: ['people'],
18+
summary: 'Browse members',
19+
querystring: {
20+
type: 'object',
21+
properties: {
22+
q: { type: 'string' },
23+
tag: { type: 'array', items: { type: 'string' } },
24+
accountLevel: { type: 'string' },
25+
sort: { type: 'string' },
26+
page: { type: 'integer', minimum: 1 },
27+
perPage: { type: 'integer', minimum: 1, maximum: 100 },
28+
},
29+
additionalProperties: false,
30+
},
31+
},
32+
},
33+
async (request) => {
34+
const q = request.query as Record<string, unknown>;
35+
const caller = getCallerSession(request);
36+
37+
const opts = {
38+
q: q['q'] as string | undefined,
39+
tag: q['tag'] as string[] | undefined,
40+
accountLevel: q['accountLevel'] as string | undefined,
41+
sort: q['sort'] as string | undefined,
42+
page: q['page'] as number | undefined,
43+
perPage: q['perPage'] as number | undefined,
44+
};
45+
46+
const result = fastify.services.people.list(opts, caller);
47+
48+
if ('error' in result) {
49+
if (result.error === 'invalid_sort') {
50+
throw new ApiValidationError('Unknown sort key', { sort: 'unknown sort key' });
51+
}
52+
throw new ApiValidationError('Invalid filter parameter');
53+
}
54+
55+
const page = Math.max(1, opts.page ?? 1);
56+
const perPage = Math.min(100, Math.max(1, opts.perPage ?? 30));
57+
58+
return {
59+
...paginated(result.items, {
60+
page,
61+
perPage,
62+
totalItems: result.totalItems,
63+
totalPages: Math.ceil(result.totalItems / perPage),
64+
}),
65+
metadata: {
66+
timestamp: new Date().toISOString(),
67+
page,
68+
perPage,
69+
totalItems: result.totalItems,
70+
totalPages: Math.ceil(result.totalItems / perPage),
71+
facets: result.facets,
72+
},
73+
};
74+
},
75+
);
76+
77+
// GET /api/people/:slug
78+
fastify.get(
79+
'/api/people/:slug',
80+
{
81+
schema: {
82+
tags: ['people'],
83+
summary: 'Fetch a single person profile',
84+
params: {
85+
type: 'object',
86+
properties: {
87+
slug: { type: 'string' },
88+
},
89+
required: ['slug'],
90+
},
91+
},
92+
},
93+
async (request) => {
94+
const { slug } = request.params as { slug: string };
95+
const caller = getCallerSession(request);
96+
97+
const person = fastify.services.people.get(slug, caller);
98+
if (!person) {
99+
throw new ApiNotFoundError(`Person '${slug}' not found`);
100+
}
101+
102+
return ok(person);
103+
},
104+
);
105+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/**
2+
* Project buzz routes:
3+
* GET /api/projects/:slug/buzz
4+
* GET /api/project-buzz (global feed)
5+
*/
6+
import type { FastifyInstance } from 'fastify';
7+
import { paginated } from '../lib/response.js';
8+
import { ApiNotFoundError, ApiValidationError } from '../lib/errors.js';
9+
import { getCallerSession } from '../services/permissions.js';
10+
11+
export async function projectBuzzRoutes(fastify: FastifyInstance): Promise<void> {
12+
// GET /api/projects/:slug/buzz
13+
fastify.get(
14+
'/api/projects/:slug/buzz',
15+
{
16+
schema: {
17+
tags: ['project-buzz'],
18+
summary: "List a project's buzz",
19+
params: {
20+
type: 'object',
21+
properties: { slug: { type: 'string' } },
22+
required: ['slug'],
23+
},
24+
querystring: {
25+
type: 'object',
26+
properties: {
27+
sort: { type: 'string' },
28+
page: { type: 'integer', minimum: 1 },
29+
perPage: { type: 'integer', minimum: 1, maximum: 100 },
30+
},
31+
additionalProperties: false,
32+
},
33+
},
34+
},
35+
async (request) => {
36+
const { slug } = request.params as { slug: string };
37+
const q = request.query as Record<string, unknown>;
38+
const caller = getCallerSession(request);
39+
40+
const opts = {
41+
sort: q['sort'] as string | undefined,
42+
page: q['page'] as number | undefined,
43+
perPage: q['perPage'] as number | undefined,
44+
};
45+
46+
const result = fastify.services.projectBuzz.listForProject(slug, opts, caller);
47+
48+
if ('error' in result) {
49+
if (result.error === 'not_found') throw new ApiNotFoundError(`Project '${slug}' not found`);
50+
if (result.error === 'invalid_sort') {
51+
throw new ApiValidationError('Unknown sort key', { sort: 'unknown sort key' });
52+
}
53+
throw new ApiValidationError('Invalid parameter');
54+
}
55+
56+
const page = Math.max(1, opts.page ?? 1);
57+
const perPage = Math.min(100, Math.max(1, opts.perPage ?? 20));
58+
59+
return paginated(result.items, {
60+
page,
61+
perPage,
62+
totalItems: result.totalItems,
63+
totalPages: Math.ceil(result.totalItems / perPage),
64+
});
65+
},
66+
);
67+
68+
// GET /api/project-buzz (global feed)
69+
fastify.get(
70+
'/api/project-buzz',
71+
{
72+
schema: {
73+
tags: ['project-buzz'],
74+
summary: 'Global buzz feed',
75+
querystring: {
76+
type: 'object',
77+
properties: {
78+
page: { type: 'integer', minimum: 1 },
79+
perPage: { type: 'integer', minimum: 1, maximum: 100 },
80+
since: { type: 'string' },
81+
tag: { type: 'array', items: { type: 'string' } },
82+
},
83+
additionalProperties: false,
84+
},
85+
},
86+
},
87+
async (request) => {
88+
const q = request.query as Record<string, unknown>;
89+
const caller = getCallerSession(request);
90+
91+
const opts = {
92+
page: q['page'] as number | undefined,
93+
perPage: q['perPage'] as number | undefined,
94+
since: q['since'] as string | undefined,
95+
tag: q['tag'] as string[] | undefined,
96+
};
97+
98+
const result = fastify.services.projectBuzz.globalFeed(opts, caller);
99+
100+
const page = Math.max(1, opts.page ?? 1);
101+
const perPage = Math.min(100, Math.max(1, opts.perPage ?? 30));
102+
103+
return paginated(result.items, {
104+
page,
105+
perPage,
106+
totalItems: result.totalItems,
107+
totalPages: Math.ceil(result.totalItems / perPage),
108+
});
109+
},
110+
);
111+
}

0 commit comments

Comments
 (0)