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: 2 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"@fastify/rate-limit": "^10.3.0",
"@fastify/swagger": "^9.7.0",
"@fastify/swagger-ui": "^5.2.6",
"better-sqlite3": "^12.10.0",
"fastify": "^5.8.5",
"gitsheets": "^1.0.3",
"jose": "^6.2.3",
Expand All @@ -30,6 +31,7 @@
},
"devDependencies": {
"@faker-js/faker": "^10.4.0",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^25.8.0",
"msw": "^2.14.6",
"pino-pretty": "^13.1.3",
Expand Down
16 changes: 16 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,18 @@ import { envJsonSchema, type Env } from './env.js';
import { mapError } from './lib/errors.js';
import traceIdPlugin from './plugins/trace-id.js';
import storePlugin from './plugins/store.js';
import servicesPlugin from './plugins/services.js';
import rateLimitPlugin from './plugins/rate-limit.js';
import idempotencyPlugin from './plugins/idempotency.js';
import sessionMiddlewarePlugin from './auth/middleware.js';
import { healthRoutes } from './routes/health.js';
import { authRoutes } from './routes/auth.js';
import { projectRoutes } from './routes/projects.js';
import { peopleRoutes } from './routes/people.js';
import { tagRoutes } from './routes/tags.js';
import { projectUpdateRoutes } from './routes/projects-updates.js';
import { projectBuzzRoutes } from './routes/projects-buzz.js';
import { helpWantedRoutes } from './routes/projects-help-wanted.js';

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

// ----- 6b. Services (loads in-memory state + FTS, boots after store) -----
await fastify.register(servicesPlugin);

// ----- 7. Rate limiting -----
await fastify.register(rateLimitPlugin);

Expand Down Expand Up @@ -128,6 +138,12 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise<FastifyInsta
// ----- 11. Routes -----
await fastify.register(healthRoutes);
await fastify.register(authRoutes);
await fastify.register(projectRoutes);
await fastify.register(peopleRoutes);
await fastify.register(tagRoutes);
await fastify.register(projectUpdateRoutes);
await fastify.register(projectBuzzRoutes);
await fastify.register(helpWantedRoutes);

// Serve the OpenAPI JSON at the spec-mandated path /api/_openapi.json
// (swagger-ui also exposes it at /api/_docs/json, but the spec names this path)
Expand Down
56 changes: 56 additions & 0 deletions apps/api/src/plugins/services.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Services plugin.
*
* Loads in-memory state from the public store, builds the FTS engine,
* and decorates fastify with service instances that route handlers use.
*
* Depends on the store plugin (for fastify.store).
*/
import type { FastifyInstance } from 'fastify';
import fp from 'fastify-plugin';
import { loadInMemoryState } from '../store/memory/loader.js';
import { invalidateFacets } from '../store/memory/facets.js';
import { buildFtsEngine } from '../store/fts.js';
import { ProjectService } from '../services/project.js';
import { PersonService } from '../services/person.js';
import { TagService } from '../services/tag.js';
import { ProjectUpdateService } from '../services/project-update.js';
import { ProjectBuzzService } from '../services/project-buzz.js';
import { HelpWantedService } from '../services/help-wanted.js';

declare module 'fastify' {
interface FastifyInstance {
services: {
projects: ProjectService;
people: PersonService;
tags: TagService;
projectUpdates: ProjectUpdateService;
projectBuzz: ProjectBuzzService;
helpWanted: HelpWantedService;
};
}
}

async function servicesPlugin(fastify: FastifyInstance): Promise<void> {
const publicStore = fastify.store.public;
const state = await loadInMemoryState(publicStore);
// Reset module-level facet cache so a fresh boot reflects current state
// (relevant in tests where multiple buildApp() runs share the module).
invalidateFacets();
const fts = buildFtsEngine(state);

fastify.decorate('services', {
projects: new ProjectService(state, fts),
people: new PersonService(state, fts),
tags: new TagService(state),
projectUpdates: new ProjectUpdateService(state),
projectBuzz: new ProjectBuzzService(state),
helpWanted: new HelpWantedService(state, fts),
});
}

export default fp(servicesPlugin, {
name: 'services',
fastify: '5.x',
dependencies: ['store'],
});
105 changes: 105 additions & 0 deletions apps/api/src/routes/people.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* People routes:
* GET /api/people
* GET /api/people/:slug
*/
import type { FastifyInstance } from 'fastify';
import { ok, paginated } from '../lib/response.js';
import { ApiNotFoundError, ApiValidationError } from '../lib/errors.js';
import { getCallerSession } from '../services/permissions.js';

export async function peopleRoutes(fastify: FastifyInstance): Promise<void> {
// GET /api/people
fastify.get(
'/api/people',
{
schema: {
tags: ['people'],
summary: 'Browse members',
querystring: {
type: 'object',
properties: {
q: { type: 'string' },
tag: { type: 'array', items: { type: 'string' } },
accountLevel: { type: 'string' },
sort: { type: 'string' },
page: { type: 'integer', minimum: 1 },
perPage: { type: 'integer', minimum: 1, maximum: 100 },
},
additionalProperties: false,
},
},
},
async (request) => {
const q = request.query as Record<string, unknown>;
const caller = getCallerSession(request);

const opts = {
q: q['q'] as string | undefined,
tag: q['tag'] as string[] | undefined,
accountLevel: q['accountLevel'] as string | undefined,
sort: q['sort'] as string | undefined,
page: q['page'] as number | undefined,
perPage: q['perPage'] as number | undefined,
};

const result = fastify.services.people.list(opts, caller);

if ('error' in result) {
if (result.error === 'invalid_sort') {
throw new ApiValidationError('Unknown sort key', { sort: 'unknown sort key' });
}
throw new ApiValidationError('Invalid filter parameter');
}

const page = Math.max(1, opts.page ?? 1);
const perPage = Math.min(100, Math.max(1, opts.perPage ?? 30));

return {
...paginated(result.items, {
page,
perPage,
totalItems: result.totalItems,
totalPages: Math.ceil(result.totalItems / perPage),
}),
metadata: {
timestamp: new Date().toISOString(),
page,
perPage,
totalItems: result.totalItems,
totalPages: Math.ceil(result.totalItems / perPage),
facets: result.facets,
},
};
},
);

// GET /api/people/:slug
fastify.get(
'/api/people/:slug',
{
schema: {
tags: ['people'],
summary: 'Fetch a single person profile',
params: {
type: 'object',
properties: {
slug: { type: 'string' },
},
required: ['slug'],
},
},
},
async (request) => {
const { slug } = request.params as { slug: string };
const caller = getCallerSession(request);

const person = fastify.services.people.get(slug, caller);
if (!person) {
throw new ApiNotFoundError(`Person '${slug}' not found`);
}

return ok(person);
},
);
}
111 changes: 111 additions & 0 deletions apps/api/src/routes/projects-buzz.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* Project buzz routes:
* GET /api/projects/:slug/buzz
* GET /api/project-buzz (global feed)
*/
import type { FastifyInstance } from 'fastify';
import { paginated } from '../lib/response.js';
import { ApiNotFoundError, ApiValidationError } from '../lib/errors.js';
import { getCallerSession } from '../services/permissions.js';

export async function projectBuzzRoutes(fastify: FastifyInstance): Promise<void> {
// GET /api/projects/:slug/buzz
fastify.get(
'/api/projects/:slug/buzz',
{
schema: {
tags: ['project-buzz'],
summary: "List a project's buzz",
params: {
type: 'object',
properties: { slug: { type: 'string' } },
required: ['slug'],
},
querystring: {
type: 'object',
properties: {
sort: { type: 'string' },
page: { type: 'integer', minimum: 1 },
perPage: { type: 'integer', minimum: 1, maximum: 100 },
},
additionalProperties: false,
},
},
},
async (request) => {
const { slug } = request.params as { slug: string };
const q = request.query as Record<string, unknown>;
const caller = getCallerSession(request);

const opts = {
sort: q['sort'] as string | undefined,
page: q['page'] as number | undefined,
perPage: q['perPage'] as number | undefined,
};

const result = fastify.services.projectBuzz.listForProject(slug, opts, caller);

if ('error' in result) {
if (result.error === 'not_found') throw new ApiNotFoundError(`Project '${slug}' not found`);
if (result.error === 'invalid_sort') {
throw new ApiValidationError('Unknown sort key', { sort: 'unknown sort key' });
}
throw new ApiValidationError('Invalid parameter');
}

const page = Math.max(1, opts.page ?? 1);
const perPage = Math.min(100, Math.max(1, opts.perPage ?? 20));

return paginated(result.items, {
page,
perPage,
totalItems: result.totalItems,
totalPages: Math.ceil(result.totalItems / perPage),
});
},
);

// GET /api/project-buzz (global feed)
fastify.get(
'/api/project-buzz',
{
schema: {
tags: ['project-buzz'],
summary: 'Global buzz feed',
querystring: {
type: 'object',
properties: {
page: { type: 'integer', minimum: 1 },
perPage: { type: 'integer', minimum: 1, maximum: 100 },
since: { type: 'string' },
tag: { type: 'array', items: { type: 'string' } },
},
additionalProperties: false,
},
},
},
async (request) => {
const q = request.query as Record<string, unknown>;
const caller = getCallerSession(request);

const opts = {
page: q['page'] as number | undefined,
perPage: q['perPage'] as number | undefined,
since: q['since'] as string | undefined,
tag: q['tag'] as string[] | undefined,
};

const result = fastify.services.projectBuzz.globalFeed(opts, caller);

const page = Math.max(1, opts.page ?? 1);
const perPage = Math.min(100, Math.max(1, opts.perPage ?? 30));

return paginated(result.items, {
page,
perPage,
totalItems: result.totalItems,
totalPages: Math.ceil(result.totalItems / perPage),
});
},
);
}
Loading