diff --git a/apps/api/package.json b/apps/api/package.json index 806103a..e3179e8 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", @@ -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", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index c1988ca..9e544af 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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 { @@ -93,6 +100,9 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise { + 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'], +}); diff --git a/apps/api/src/routes/people.ts b/apps/api/src/routes/people.ts new file mode 100644 index 0000000..7256441 --- /dev/null +++ b/apps/api/src/routes/people.ts @@ -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 { + // 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; + 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); + }, + ); +} diff --git a/apps/api/src/routes/projects-buzz.ts b/apps/api/src/routes/projects-buzz.ts new file mode 100644 index 0000000..9138f70 --- /dev/null +++ b/apps/api/src/routes/projects-buzz.ts @@ -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 { + // 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; + 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; + 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), + }); + }, + ); +} diff --git a/apps/api/src/routes/projects-help-wanted.ts b/apps/api/src/routes/projects-help-wanted.ts new file mode 100644 index 0000000..a322637 --- /dev/null +++ b/apps/api/src/routes/projects-help-wanted.ts @@ -0,0 +1,136 @@ +/** + * Help-wanted routes: + * GET /api/projects/:slug/help-wanted + * GET /api/help-wanted (global browse) + */ +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 helpWantedRoutes(fastify: FastifyInstance): Promise { + // GET /api/projects/:slug/help-wanted + fastify.get( + '/api/projects/:slug/help-wanted', + { + schema: { + tags: ['help-wanted'], + summary: "List a project's help-wanted roles", + params: { + type: 'object', + properties: { slug: { type: 'string' } }, + required: ['slug'], + }, + querystring: { + type: 'object', + properties: { + status: { type: 'string', enum: ['open', 'filled', 'closed'] }, + 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; + const caller = getCallerSession(request); + + const opts = { + status: q['status'] 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.helpWanted.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 filter 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/help-wanted (global browse) + fastify.get( + '/api/help-wanted', + { + schema: { + tags: ['help-wanted'], + summary: 'Cross-project browse of help-wanted roles', + querystring: { + type: 'object', + properties: { + status: { type: 'string', enum: ['open', 'filled', 'closed'] }, + tag: { type: 'array', items: { type: 'string' } }, + commitmentMax: { type: 'integer', minimum: 0 }, + q: { 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; + const caller = getCallerSession(request); + + const opts = { + status: q['status'] as string | undefined, + tag: q['tag'] as string[] | undefined, + commitmentMax: q['commitmentMax'] as number | undefined, + q: q['q'] 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.helpWanted.globalBrowse(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, + }, + }; + }, + ); +} diff --git a/apps/api/src/routes/projects-updates.ts b/apps/api/src/routes/projects-updates.ts new file mode 100644 index 0000000..739bd18 --- /dev/null +++ b/apps/api/src/routes/projects-updates.ts @@ -0,0 +1,145 @@ +/** + * Project update routes: + * GET /api/projects/:slug/updates + * GET /api/projects/:slug/updates/:number + * GET /api/project-updates (global feed) + */ +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 projectUpdateRoutes(fastify: FastifyInstance): Promise { + // GET /api/projects/:slug/updates + fastify.get( + '/api/projects/:slug/updates', + { + schema: { + tags: ['project-updates'], + summary: "List a project's updates", + 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; + 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.projectUpdates.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/projects/:slug/updates/:number + fastify.get( + '/api/projects/:slug/updates/:number', + { + schema: { + tags: ['project-updates'], + summary: 'Fetch a single project update', + params: { + type: 'object', + properties: { + slug: { type: 'string' }, + number: { type: 'integer', minimum: 1 }, + }, + required: ['slug', 'number'], + }, + }, + }, + async (request) => { + const { slug, number } = request.params as { slug: string; number: number }; + const caller = getCallerSession(request); + + const result = fastify.services.projectUpdates.getForProject(slug, number, caller); + + if (!result) throw new ApiNotFoundError('Update not found'); + if ('error' in result) { + if (result.error === 'not_found') throw new ApiNotFoundError(`Project '${slug}' not found`); + throw new ApiValidationError('Invalid parameter'); + } + + return ok(result); + }, + ); + + // GET /api/project-updates (global feed) + fastify.get( + '/api/project-updates', + { + schema: { + tags: ['project-updates'], + summary: 'Global feed of recent project updates', + 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; + 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.projectUpdates.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), + }); + }, + ); +} diff --git a/apps/api/src/routes/projects.ts b/apps/api/src/routes/projects.ts new file mode 100644 index 0000000..e162ee5 --- /dev/null +++ b/apps/api/src/routes/projects.ts @@ -0,0 +1,125 @@ +/** + * Project routes: + * GET /api/projects + * GET /api/projects/: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 projectRoutes(fastify: FastifyInstance): Promise { + // GET /api/projects + fastify.get( + '/api/projects', + { + schema: { + tags: ['projects'], + summary: 'List/browse projects', + querystring: { + type: 'object', + properties: { + q: { type: 'string' }, + stage: { type: 'string' }, + stageIn: { type: 'string' }, + tag: { type: 'array', items: { type: 'string' } }, + maintainer: { type: 'string' }, + memberSlug: { type: 'string' }, + helpWanted: { type: 'boolean' }, + featured: { type: 'boolean' }, + includeDeleted: { type: 'boolean' }, + 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; + + const opts = { + q: q['q'] as string | undefined, + stage: q['stage'] as string | undefined, + stageIn: q['stageIn'] ? String(q['stageIn']).split(',') : undefined, + tag: q['tag'] as string[] | undefined, + maintainer: q['maintainer'] as string | undefined, + memberSlug: q['memberSlug'] as string | undefined, + helpWanted: q['helpWanted'] as boolean | undefined, + featured: q['featured'] as boolean | undefined, + includeDeleted: q['includeDeleted'] as boolean | undefined, + sort: q['sort'] as string | undefined, + page: q['page'] as number | undefined, + perPage: q['perPage'] as number | undefined, + }; + + const caller = getCallerSession(request); + const isStaff = + caller?.accountLevel === 'staff' || caller?.accountLevel === 'administrator'; + + // Non-staff cannot use includeDeleted + if (opts.includeDeleted && !isStaff) { + opts.includeDeleted = undefined; + } + + const result = fastify.services.projects.list(opts); + + 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/projects/:slug + fastify.get( + '/api/projects/:slug', + { + schema: { + tags: ['projects'], + summary: 'Fetch a single project', + params: { + type: 'object', + properties: { + slug: { type: 'string' }, + }, + required: ['slug'], + }, + }, + }, + async (request) => { + const { slug } = request.params as { slug: string }; + const caller = getCallerSession(request); + + const project = fastify.services.projects.get(slug, caller); + if (!project) { + throw new ApiNotFoundError(`Project '${slug}' not found`); + } + + return ok(project); + }, + ); +} diff --git a/apps/api/src/routes/tags.ts b/apps/api/src/routes/tags.ts new file mode 100644 index 0000000..04304a5 --- /dev/null +++ b/apps/api/src/routes/tags.ts @@ -0,0 +1,242 @@ +/** + * Tag routes: + * GET /api/tags + * GET /api/tags/:handle + * GET /api/tags/:handle/projects + * GET /api/tags/:handle/people + */ +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 tagRoutes(fastify: FastifyInstance): Promise { + // GET /api/tags + fastify.get( + '/api/tags', + { + schema: { + tags: ['tags'], + summary: 'List tags', + querystring: { + type: 'object', + properties: { + namespace: { type: 'string' }, + q: { type: 'string' }, + taggableType: { 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; + + const opts = { + namespace: q['namespace'] as string | undefined, + q: q['q'] as string | undefined, + taggableType: q['taggableType'] 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.tags.list(opts); + + 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 ?? 100)); + + return paginated(result.items, { + page, + perPage, + totalItems: result.totalItems, + totalPages: Math.ceil(result.totalItems / perPage), + }); + }, + ); + + // GET /api/tags/:handle + // Note: handle is namespace.slug — fastify treats the dot literally in params + // We use a wildcard and split ourselves to support the dot. + fastify.get( + '/api/tags/:handle', + { + schema: { + tags: ['tags'], + summary: 'Fetch a single tag', + params: { + type: 'object', + properties: { + handle: { type: 'string' }, + }, + required: ['handle'], + }, + }, + }, + async (request) => { + const { handle } = request.params as { handle: string }; + + const tag = fastify.services.tags.get(handle); + if (!tag) { + throw new ApiNotFoundError(`Tag '${handle}' not found`); + } + + return ok(tag); + }, + ); + + // GET /api/tags/:handle/projects — delegates to project list with tag pre-applied + fastify.get( + '/api/tags/:handle/projects', + { + schema: { + tags: ['tags'], + summary: 'List projects for a tag', + params: { + type: 'object', + properties: { + handle: { type: 'string' }, + }, + required: ['handle'], + }, + querystring: { + type: 'object', + properties: { + sort: { type: 'string' }, + page: { type: 'integer', minimum: 1 }, + perPage: { type: 'integer', minimum: 1, maximum: 100 }, + }, + additionalProperties: false, + }, + }, + }, + async (request) => { + const { handle } = request.params as { handle: string }; + const q = request.query as Record; + + const tag = fastify.services.tags.get(handle); + if (!tag) { + throw new ApiNotFoundError(`Tag '${handle}' not found`); + } + + const opts = { + tag: [handle], + sort: q['sort'] as string | undefined, + page: q['page'] as number | undefined, + perPage: q['perPage'] as number | undefined, + }; + + const result = fastify.services.projects.list(opts); + + 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/tags/:handle/people — delegates to people list with tag pre-applied + fastify.get( + '/api/tags/:handle/people', + { + schema: { + tags: ['tags'], + summary: 'List people for a tag', + params: { + type: 'object', + properties: { + handle: { type: 'string' }, + }, + required: ['handle'], + }, + querystring: { + type: 'object', + properties: { + sort: { type: 'string' }, + page: { type: 'integer', minimum: 1 }, + perPage: { type: 'integer', minimum: 1, maximum: 100 }, + }, + additionalProperties: false, + }, + }, + }, + async (request) => { + const { handle } = request.params as { handle: string }; + const q = request.query as Record; + const caller = getCallerSession(request); + + const tag = fastify.services.tags.get(handle); + if (!tag) { + throw new ApiNotFoundError(`Tag '${handle}' not found`); + } + + const opts = { + tag: [handle], + 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, + }, + }; + }, + ); +} diff --git a/apps/api/src/services/help-wanted.ts b/apps/api/src/services/help-wanted.ts new file mode 100644 index 0000000..31eef2a --- /dev/null +++ b/apps/api/src/services/help-wanted.ts @@ -0,0 +1,266 @@ +/** + * HelpWantedService — read operations. + */ +import type { HelpWantedRole, Project, ProjectMembership } from '@cfp/shared/schemas'; +import type { InMemoryState } from '../store/memory/state.js'; +import type { FtsEngine } from '../store/fts.js'; +import type { CallerSession } from './permissions.js'; +import { computeHelpWantedPermissions } from './permissions.js'; +import { + serializeHelpWantedRole, + type HelpWantedRoleResponse, +} from './serializers/help-wanted.js'; + +export interface ProjectHelpWantedListOptions { + readonly status?: string; + readonly page?: number; + readonly perPage?: number; + readonly sort?: string; +} + +export interface GlobalHelpWantedOptions { + readonly status?: string; + readonly tag?: string[]; + readonly commitmentMax?: number; + readonly q?: string; + readonly sort?: string; + readonly page?: number; + readonly perPage?: number; +} + +const PROJECT_HELP_ALLOWED_SORT = new Set(['createdAt', 'commitmentHoursPerWeek']); +const GLOBAL_HELP_ALLOWED_SORT = new Set(['createdAt', 'commitmentHoursPerWeek']); + +function parseSortSpec( + sort: string | undefined, + allowed: Set, +): Array<{ key: string; desc: boolean }> | null { + if (!sort) return null; + const parts = sort.split(',').map((s) => s.trim()).filter(Boolean); + const result: Array<{ key: string; desc: boolean }> = []; + for (const part of parts) { + const desc = part.startsWith('-'); + const key = desc ? part.slice(1) : part; + if (!allowed.has(key)) return null; + result.push({ key, desc }); + } + return result; +} + +export class HelpWantedService { + readonly #state: InMemoryState; + readonly #fts: FtsEngine; + + constructor(state: InMemoryState, fts: FtsEngine) { + this.#state = state; + this.#fts = fts; + } + + listForProject( + projectSlug: string, + opts: ProjectHelpWantedListOptions, + caller?: CallerSession, + ): { items: HelpWantedRoleResponse[]; totalItems: number } | { error: 'not_found' | 'invalid_sort' | 'invalid_filter' } { + const sortSpec = parseSortSpec(opts.sort ?? '-createdAt', PROJECT_HELP_ALLOWED_SORT); + if (!sortSpec) return { error: 'invalid_sort' }; + + const projectId = this.#state.projectIdBySlug.get(projectSlug); + if (!projectId) return { error: 'not_found' }; + const project = this.#state.projects.get(projectId); + if (!project || project.deletedAt) return { error: 'not_found' }; + + const validStatuses = new Set(['open', 'filled', 'closed']); + if (opts.status && !validStatuses.has(opts.status)) return { error: 'invalid_filter' }; + + const roleIds = this.#state.helpWantedByProject.get(projectId) ?? new Set(); + let roles = [...roleIds] + .map((id) => this.#state.helpWantedRoles.get(id)) + .filter((r): r is HelpWantedRole => r !== undefined); + + if (opts.status) { + roles = roles.filter((r) => r.status === opts.status); + } + + roles.sort((a, b) => { + for (const { key, desc } of sortSpec) { + let cmp = 0; + if (key === 'createdAt') cmp = a.createdAt.localeCompare(b.createdAt); + else if (key === 'commitmentHoursPerWeek') { + cmp = (a.commitmentHoursPerWeek ?? 0) - (b.commitmentHoursPerWeek ?? 0); + } + if (cmp !== 0) return desc ? -cmp : cmp; + } + return 0; + }); + + const totalItems = roles.length; + const page = Math.max(1, opts.page ?? 1); + const perPage = Math.min(100, Math.max(1, opts.perPage ?? 20)); + const slice = roles.slice((page - 1) * perPage, page * perPage); + + const memberships = this.#getMemberships(projectId); + const items = slice.map((role) => this.#serializeRole(role, project, memberships, caller)); + + return { items, totalItems }; + } + + globalBrowse( + opts: GlobalHelpWantedOptions, + caller?: CallerSession, + ): { items: HelpWantedRoleResponse[]; totalItems: number; facets: { byTech: Array<{ tag: string; count: number }>; byTopic: Array<{ tag: string; count: number }> } } | { error: 'invalid_sort' | 'invalid_filter' } { + const sortSpec = parseSortSpec(opts.sort ?? '-createdAt', GLOBAL_HELP_ALLOWED_SORT); + if (!sortSpec) return { error: 'invalid_sort' }; + + const validStatuses = new Set(['open', 'filled', 'closed']); + const statusFilter = opts.status ?? 'open'; + if (!validStatuses.has(statusFilter)) return { error: 'invalid_filter' }; + + // FTS + let ftsIds: Set | null = null; + if (opts.q) { + const ids = this.#fts.searchHelpWanted(opts.q); + ftsIds = new Set(ids); + } + + // Tag filter on role tags + let filterTagIds: Set | undefined; + if (opts.tag && opts.tag.length > 0) { + filterTagIds = new Set(); + for (const handle of opts.tag) { + const tagId = this.#state.tagIdByHandle.get(handle); + if (tagId) filterTagIds.add(tagId); + } + } + + const roles = [...this.#state.helpWantedRoles.values()].filter((r) => { + const project = this.#state.projects.get(r.projectId); + if (!project || project.deletedAt) return false; + + if (r.status !== statusFilter) return false; + + if (ftsIds && !ftsIds.has(r.id)) return false; + + if (opts.commitmentMax !== undefined) { + const h = r.commitmentHoursPerWeek ?? 0; + if (h > opts.commitmentMax) return false; + } + + if (filterTagIds && filterTagIds.size > 0) { + const roleAssignments = this.#state.tagAssignmentsByTaggable.get(r.id); + if (!roleAssignments) return false; + const roleTagIds = new Set( + [...roleAssignments] + .map((taId) => this.#state.tagAssignments.get(taId)?.tagId) + .filter((id): id is string => id !== undefined), + ); + for (const tagId of filterTagIds) { + if (!roleTagIds.has(tagId)) return false; + } + } + + return true; + }); + + // Compute facets over filtered set (role tags, by namespace) + const facets = this.#computeFacets(roles); + + roles.sort((a, b) => { + for (const { key, desc } of sortSpec) { + let cmp = 0; + if (key === 'createdAt') cmp = a.createdAt.localeCompare(b.createdAt); + else if (key === 'commitmentHoursPerWeek') { + cmp = (a.commitmentHoursPerWeek ?? 0) - (b.commitmentHoursPerWeek ?? 0); + } + if (cmp !== 0) return desc ? -cmp : cmp; + } + return 0; + }); + + const totalItems = roles.length; + const page = Math.max(1, opts.page ?? 1); + const perPage = Math.min(100, Math.max(1, opts.perPage ?? 30)); + const slice = roles.slice((page - 1) * perPage, page * perPage); + + const items = slice.map((role) => { + const project = this.#state.projects.get(role.projectId)!; + const memberships = this.#getMemberships(role.projectId); + return this.#serializeRole(role, project, memberships, caller); + }); + + return { items, totalItems, facets }; + } + + #serializeRole( + role: HelpWantedRole, + project: Project, + memberships: ProjectMembership[], + caller?: CallerSession, + ): HelpWantedRoleResponse { + const postedBy = this.#state.people.get(role.postedById) ?? null; + const filledBy = role.filledById ? (this.#state.people.get(role.filledById) ?? null) : null; + + const interestCount = this.#state.interestByRole.get(role.id)?.size ?? 0; + + const alreadyExpressedInterest = caller + ? this.#state.interestByRoleAndPerson.has(`${role.id}:${caller.id}`) + : false; + + const tagAssignments = [...(this.#state.tagAssignmentsByTaggable.get(role.id) ?? [])] + .map((taId) => this.#state.tagAssignments.get(taId)) + .filter((ta): ta is NonNullable => ta !== undefined); + + const permissions = computeHelpWantedPermissions( + caller, + role, + project, + memberships, + alreadyExpressedInterest, + ); + + return serializeHelpWantedRole(role, { + project, + postedBy, + filledBy, + tagAssignments, + allTags: this.#state.tags, + interestCount, + permissions, + }); + } + + #getMemberships(projectId: string) { + const mIds = this.#state.membershipsByProject.get(projectId) ?? new Set(); + return [...mIds] + .map((id) => this.#state.projectMemberships.get(id)) + .filter((m): m is NonNullable => m !== undefined); + } + + #computeFacets(roles: HelpWantedRole[]): { + byTech: Array<{ tag: string; count: number }>; + byTopic: Array<{ tag: string; count: number }>; + } { + const techCounts = new Map(); + const topicCounts = new Map(); + + for (const role of roles) { + const taIds = this.#state.tagAssignmentsByTaggable.get(role.id) ?? new Set(); + for (const taId of taIds) { + const ta = this.#state.tagAssignments.get(taId); + if (!ta || ta.taggableType !== 'help_wanted_role') continue; + const tag = this.#state.tags.get(ta.tagId); + if (!tag) continue; + const handle = `${tag.namespace}.${tag.slug}`; + if (tag.namespace === 'tech') techCounts.set(handle, (techCounts.get(handle) ?? 0) + 1); + else if (tag.namespace === 'topic') topicCounts.set(handle, (topicCounts.get(handle) ?? 0) + 1); + } + } + + const toArr = (m: Map) => + [...m.entries()] + .map(([tag, count]) => ({ tag, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 10); + + return { byTech: toArr(techCounts), byTopic: toArr(topicCounts) }; + } +} diff --git a/apps/api/src/services/permissions.ts b/apps/api/src/services/permissions.ts new file mode 100644 index 0000000..37b8072 --- /dev/null +++ b/apps/api/src/services/permissions.ts @@ -0,0 +1,164 @@ +/** + * Permission computation helpers. + * + * All permission decisions are centralized here so the logic doesn't + * scatter across route handlers. Routes call these with the caller + * (which may be undefined for unauthenticated requests) and the entity. + * + * The caller is derived from `request.session.person` (decorated by the + * session middleware in auth-jwt-substrate) via `getCallerSession()` below. + */ +import type { FastifyRequest } from 'fastify'; +import type { Person, Project, ProjectBuzz, ProjectMembership, ProjectUpdate } from '@cfp/shared/schemas'; +import type { HelpWantedRole } from '@cfp/shared/schemas'; + +/** Minimal caller shape used by permission helpers. Derived from `request.session.person`. */ +export interface CallerSession { + readonly id: string; + readonly accountLevel: 'user' | 'staff' | 'administrator'; +} + +/** + * Extract the caller's session view from a Fastify request. Returns undefined + * for anonymous or claim-pending requests (where `session.person` is null or + * accountLevel is `'anonymous'`). + */ +export function getCallerSession(request: FastifyRequest): CallerSession | undefined { + const person = request.session.person; + if (!person) return undefined; + const level = person.accountLevel; + if (level !== 'user' && level !== 'staff' && level !== 'administrator') return undefined; + return { id: person.id, accountLevel: level }; +} + +export interface ProjectPermissions { + readonly canEdit: boolean; + readonly canManageMembers: boolean; + readonly canPostUpdate: boolean; + readonly canLogBuzz: boolean; + readonly canPostHelpWanted: boolean; + readonly canDelete: boolean; +} + +export interface PersonPermissions { + readonly canEdit: boolean; + readonly canChangeAccountLevel: boolean; +} + +export interface UpdatePermissions { + readonly canEdit: boolean; + readonly canDelete: boolean; +} + +export interface BuzzPermissions { + readonly canEdit: boolean; + readonly canDelete: boolean; +} + +export interface HelpWantedPermissions { + readonly canEdit: boolean; + readonly canExpressInterest: boolean; + readonly alreadyExpressedInterest: boolean; + readonly canFill: boolean; + readonly canClose: boolean; +} + +function isStaff(caller: CallerSession | undefined): boolean { + return caller?.accountLevel === 'staff' || caller?.accountLevel === 'administrator'; +} + +function isMaintainerOf( + caller: CallerSession | undefined, + project: Project, + memberships: ProjectMembership[], +): boolean { + if (!caller) return false; + if (project.maintainerId === caller.id) return true; + return memberships.some((m) => m.personId === caller.id && m.isMaintainer); +} + +function isMemberOf( + caller: CallerSession | undefined, + projectId: string, + memberships: ProjectMembership[], +): boolean { + if (!caller) return false; + return memberships.some((m) => m.projectId === projectId && m.personId === caller.id); +} + +export function computeProjectPermissions( + caller: CallerSession | undefined, + project: Project, + memberships: ProjectMembership[], +): ProjectPermissions { + const staff = isStaff(caller); + const maintainer = isMaintainerOf(caller, project, memberships); + const member = isMemberOf(caller, project.id, memberships); + const authenticated = caller !== undefined; + + return { + canEdit: maintainer || staff, + canManageMembers: maintainer || staff, + canPostUpdate: member || staff, + canLogBuzz: authenticated, + canPostHelpWanted: maintainer || staff, + canDelete: staff, + }; +} + +export function computePersonPermissions( + caller: CallerSession | undefined, + person: Person, +): PersonPermissions { + const staff = isStaff(caller); + const isSelf = caller?.id === person.id; + return { + canEdit: isSelf || staff, + canChangeAccountLevel: caller?.accountLevel === 'administrator', + }; +} + +export function computeUpdatePermissions( + caller: CallerSession | undefined, + update: ProjectUpdate, +): UpdatePermissions { + const staff = isStaff(caller); + const isAuthor = caller !== undefined && caller.id === update.authorId; + return { + canEdit: isAuthor || staff, + canDelete: isAuthor || staff, + }; +} + +export function computeBuzzPermissions( + caller: CallerSession | undefined, + buzz: ProjectBuzz, +): BuzzPermissions { + const staff = isStaff(caller); + const isPoster = caller !== undefined && caller.id === buzz.postedById; + return { + canEdit: isPoster || staff, + canDelete: isPoster || staff, + }; +} + +export function computeHelpWantedPermissions( + caller: CallerSession | undefined, + role: HelpWantedRole, + project: Project, + memberships: ProjectMembership[], + alreadyExpressedInterest: boolean, +): HelpWantedPermissions { + const staff = isStaff(caller); + const maintainer = isMaintainerOf(caller, project, memberships); + const isPoster = caller !== undefined && caller.id === role.postedById; + const authenticated = caller !== undefined; + + return { + canEdit: isPoster || maintainer || staff, + canExpressInterest: authenticated && role.status === 'open' && !alreadyExpressedInterest, + alreadyExpressedInterest, + canFill: maintainer || staff, + canClose: maintainer || staff, + }; +} diff --git a/apps/api/src/services/person.ts b/apps/api/src/services/person.ts new file mode 100644 index 0000000..54be9d2 --- /dev/null +++ b/apps/api/src/services/person.ts @@ -0,0 +1,216 @@ +/** + * PersonService — read operations against in-memory state. + */ +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 { getPeopleFacets, type PeopleFacets } from '../store/memory/facets.js'; +import type { CallerSession } from './permissions.js'; +import { computePersonPermissions } from './permissions.js'; +import { + serializePersonDetail, + serializePersonListItem, + type PersonDetail, + type PersonListItem, +} from './serializers/person.js'; + +export interface PersonListOptions { + readonly q?: string; + readonly tag?: string[]; + readonly accountLevel?: string; + readonly sort?: string; + readonly page?: number; + readonly perPage?: number; +} + +export interface PersonListResult { + readonly items: PersonListItem[]; + readonly totalItems: number; + readonly facets: PeopleFacets; +} + +const ALLOWED_SORT_KEYS = new Set(['createdAt', 'fullName']); + +function parseSortSpec(sort: string | undefined): Array<{ key: string; desc: boolean }> | null { + if (!sort) return null; + const parts = sort.split(',').map((s) => s.trim()).filter(Boolean); + const result: Array<{ key: string; desc: boolean }> = []; + for (const part of parts) { + const desc = part.startsWith('-'); + const key = desc ? part.slice(1) : part; + if (!ALLOWED_SORT_KEYS.has(key)) return null; + result.push({ key, desc }); + } + return result; +} + +function comparePeople(a: Person, b: Person, sortSpec: Array<{ key: string; desc: boolean }>): number { + for (const { key, desc } of sortSpec) { + let cmp = 0; + if (key === 'fullName') { + cmp = a.fullName.localeCompare(b.fullName); + } else if (key === 'createdAt') { + cmp = a.createdAt.localeCompare(b.createdAt); + } + if (cmp !== 0) return desc ? -cmp : cmp; + } + return 0; +} + +export class PersonService { + readonly #state: InMemoryState; + readonly #fts: FtsEngine; + + constructor(state: InMemoryState, fts: FtsEngine) { + this.#state = state; + this.#fts = fts; + } + + list( + opts: PersonListOptions, + caller?: CallerSession, + ): PersonListResult | { error: 'invalid_sort' | 'invalid_filter' } { + const sortSpec = parseSortSpec(opts.sort ?? '-createdAt'); + if (!sortSpec) return { error: 'invalid_sort' }; + + const facets = getPeopleFacets(this.#state); + + const isStaff = + caller?.accountLevel === 'staff' || caller?.accountLevel === 'administrator'; + + // accountLevel filter is staff-only + if (opts.accountLevel && !isStaff) { + return { items: [], totalItems: 0, facets }; + } + + let ftsSlugs: Set | null = null; + if (opts.q) { + const slugs = this.#fts.searchPeople(opts.q); + ftsSlugs = new Set(slugs); + } + + let filterTagIds: Set | undefined; + if (opts.tag && opts.tag.length > 0) { + filterTagIds = new Set(); + for (const handle of opts.tag) { + const tagId = this.#state.tagIdByHandle.get(handle); + if (tagId) filterTagIds.add(tagId); + } + } + + const filtered = [...this.#state.people.values()].filter((p) => { + if (p.deletedAt && !isStaff) return false; + + if (ftsSlugs && !ftsSlugs.has(p.slug)) return false; + + if (opts.accountLevel && p.accountLevel !== opts.accountLevel) return false; + + if (filterTagIds && filterTagIds.size > 0) { + const personAssignments = this.#state.tagAssignmentsByTaggable.get(p.id); + if (!personAssignments) return false; + const personTagIds = new Set( + [...personAssignments] + .map((taId) => this.#state.tagAssignments.get(taId)?.tagId) + .filter((id): id is string => id !== undefined), + ); + for (const tagId of filterTagIds) { + if (!personTagIds.has(tagId)) return false; + } + } + + return true; + }); + + filtered.sort((a, b) => comparePeople(a, b, sortSpec)); + + const totalItems = filtered.length; + const page = Math.max(1, opts.page ?? 1); + const perPage = Math.min(100, Math.max(1, opts.perPage ?? 30)); + const slice = filtered.slice((page - 1) * perPage, page * perPage); + + const items = slice.map((person) => this.#serializeListItem(person)); + + return { items, totalItems, facets }; + } + + get(slug: string, caller?: CallerSession): PersonDetail | null { + const personId = this.#state.personIdBySlug.get(slug); + if (!personId) return null; + const person = this.#state.people.get(personId); + if (!person) return null; + + const isStaff = + caller?.accountLevel === 'staff' || caller?.accountLevel === 'administrator'; + if (person.deletedAt && !isStaff) return null; + + const memberships = this.#getMembershipsForPerson(person.id); + const projectsMap = this.#getProjectsForMemberships(memberships); + + const recentUpdates = this.#getRecentUpdates(person.id); + const updatesProjectsMap = this.#getProjectsForUpdates(recentUpdates); + + const tagAssignments = [...(this.#state.tagAssignmentsByTaggable.get(person.id) ?? [])] + .map((taId) => this.#state.tagAssignments.get(taId)) + .filter((ta): ta is NonNullable => ta !== undefined); + + const permissions = computePersonPermissions(caller, person); + + return serializePersonDetail(person, { + memberships, + projectsMap, + recentUpdates, + updatesProjectsMap, + tagAssignments, + allTags: this.#state.tags, + permissions, + callerAccountLevel: caller?.accountLevel, + callerPersonId: caller?.id, + }); + } + + #serializeListItem(person: Person): PersonListItem { + const memberOfCount = this.#state.membershipsByPerson.get(person.id)?.size ?? 0; + + const tagAssignments = [...(this.#state.tagAssignmentsByTaggable.get(person.id) ?? [])] + .map((taId) => this.#state.tagAssignments.get(taId)) + .filter((ta): ta is NonNullable => ta !== undefined); + + return serializePersonListItem(person, { + memberOfCount, + tagAssignments, + allTags: this.#state.tags, + }); + } + + #getMembershipsForPerson(personId: string): ProjectMembership[] { + const mIds = this.#state.membershipsByPerson.get(personId) ?? new Set(); + return [...mIds] + .map((id) => this.#state.projectMemberships.get(id)) + .filter((m): m is ProjectMembership => m !== undefined); + } + + #getProjectsForMemberships(memberships: ProjectMembership[]): Map { + const map = new Map(); + for (const m of memberships) { + const p = this.#state.projects.get(m.projectId); + if (p && !p.deletedAt) map.set(p.id, p); + } + return map; + } + + #getRecentUpdates(personId: string): ProjectUpdate[] { + return [...this.#state.projectUpdates.values()] + .filter((u) => u.authorId === personId) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + .slice(0, 5); + } + + #getProjectsForUpdates(updates: ProjectUpdate[]): Map { + const map = new Map(); + for (const u of updates) { + const p = this.#state.projects.get(u.projectId); + if (p) map.set(p.id, p); + } + return map; + } +} diff --git a/apps/api/src/services/project-buzz.ts b/apps/api/src/services/project-buzz.ts new file mode 100644 index 0000000..3e88f9f --- /dev/null +++ b/apps/api/src/services/project-buzz.ts @@ -0,0 +1,133 @@ +/** + * ProjectBuzzService — read operations. + */ +import type { ProjectBuzz } from '@cfp/shared/schemas'; +import type { InMemoryState } from '../store/memory/state.js'; +import type { CallerSession } from './permissions.js'; +import { computeBuzzPermissions } from './permissions.js'; +import { + serializeProjectBuzz, + type ProjectBuzzResponse, +} from './serializers/project-buzz.js'; + +export interface ProjectBuzzListOptions { + readonly page?: number; + readonly perPage?: number; + readonly sort?: string; +} + +export interface GlobalBuzzFeedOptions { + readonly page?: number; + readonly perPage?: number; + readonly since?: string; + readonly tag?: string[]; +} + +const ALLOWED_SORT_KEYS = new Set(['publishedAt', 'createdAt']); + +function parseSortSpec(sort: string | undefined): Array<{ key: string; desc: boolean }> | null { + if (!sort) return null; + const parts = sort.split(',').map((s) => s.trim()).filter(Boolean); + const result: Array<{ key: string; desc: boolean }> = []; + for (const part of parts) { + const desc = part.startsWith('-'); + const key = desc ? part.slice(1) : part; + if (!ALLOWED_SORT_KEYS.has(key)) return null; + result.push({ key, desc }); + } + return result; +} + +export class ProjectBuzzService { + readonly #state: InMemoryState; + + constructor(state: InMemoryState) { + this.#state = state; + } + + listForProject( + projectSlug: string, + opts: ProjectBuzzListOptions, + caller?: CallerSession, + ): { items: ProjectBuzzResponse[]; totalItems: number } | { error: 'not_found' | 'invalid_sort' } { + const sortSpec = parseSortSpec(opts.sort ?? '-publishedAt'); + if (!sortSpec) return { error: 'invalid_sort' }; + + const projectId = this.#state.projectIdBySlug.get(projectSlug); + if (!projectId) return { error: 'not_found' }; + const project = this.#state.projects.get(projectId); + if (!project || project.deletedAt) return { error: 'not_found' }; + + const buzzIds = this.#state.buzzByProject.get(projectId) ?? new Set(); + const buzzes = [...buzzIds] + .map((id) => this.#state.projectBuzz.get(id)) + .filter((b): b is ProjectBuzz => b !== undefined); + + buzzes.sort((a, b) => { + for (const { key, desc } of sortSpec) { + let cmp = 0; + if (key === 'publishedAt') cmp = a.publishedAt.localeCompare(b.publishedAt); + else if (key === 'createdAt') cmp = a.createdAt.localeCompare(b.createdAt); + if (cmp !== 0) return desc ? -cmp : cmp; + } + return 0; + }); + + const totalItems = buzzes.length; + const page = Math.max(1, opts.page ?? 1); + const perPage = Math.min(100, Math.max(1, opts.perPage ?? 20)); + const slice = buzzes.slice((page - 1) * perPage, page * perPage); + + const items = slice.map((b) => { + const postedBy = b.postedById ? (this.#state.people.get(b.postedById) ?? null) : null; + const permissions = computeBuzzPermissions(caller, b); + return serializeProjectBuzz(b, { project: project!, postedBy, permissions }); + }); + + return { items, totalItems }; + } + + globalFeed( + opts: GlobalBuzzFeedOptions, + caller?: CallerSession, + ): { items: ProjectBuzzResponse[]; totalItems: number } { + let filterProjectIds: Set | undefined; + if (opts.tag && opts.tag.length > 0) { + filterProjectIds = new Set(); + for (const handle of opts.tag) { + const tagId = this.#state.tagIdByHandle.get(handle); + if (!tagId) continue; + const taIds = this.#state.tagAssignmentsByTag.get(tagId) ?? new Set(); + for (const taId of taIds) { + const ta = this.#state.tagAssignments.get(taId); + if (ta?.taggableType === 'project') filterProjectIds.add(ta.taggableId); + } + } + } + + const buzzes = [...this.#state.projectBuzz.values()].filter((b) => { + const project = this.#state.projects.get(b.projectId); + if (!project || project.deletedAt) return false; + if (opts.since && b.publishedAt < opts.since) return false; + if (filterProjectIds && !filterProjectIds.has(b.projectId)) return false; + return true; + }); + + // Sort by publishedAt desc (activity feed sort key per spec) + buzzes.sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)); + + const totalItems = buzzes.length; + const page = Math.max(1, opts.page ?? 1); + const perPage = Math.min(100, Math.max(1, opts.perPage ?? 30)); + const slice = buzzes.slice((page - 1) * perPage, page * perPage); + + const items = slice.map((b) => { + const project = this.#state.projects.get(b.projectId)!; + const postedBy = b.postedById ? (this.#state.people.get(b.postedById) ?? null) : null; + const permissions = computeBuzzPermissions(caller, b); + return serializeProjectBuzz(b, { project, postedBy, permissions }); + }); + + return { items, totalItems }; + } +} diff --git a/apps/api/src/services/project-update.ts b/apps/api/src/services/project-update.ts new file mode 100644 index 0000000..0188ec7 --- /dev/null +++ b/apps/api/src/services/project-update.ts @@ -0,0 +1,153 @@ +/** + * ProjectUpdateService — read operations. + */ +import type { ProjectUpdate } from '@cfp/shared/schemas'; +import type { InMemoryState } from '../store/memory/state.js'; +import type { CallerSession } from './permissions.js'; +import { computeUpdatePermissions } from './permissions.js'; +import { + serializeProjectUpdate, + type ProjectUpdateResponse, +} from './serializers/project-update.js'; + +export interface ProjectUpdateListOptions { + readonly page?: number; + readonly perPage?: number; + readonly sort?: string; +} + +export interface GlobalUpdateFeedOptions { + readonly page?: number; + readonly perPage?: number; + readonly since?: string; + readonly tag?: string[]; +} + +const ALLOWED_SORT_KEYS = new Set(['createdAt', 'number']); + +function parseSortSpec(sort: string | undefined): Array<{ key: string; desc: boolean }> | null { + if (!sort) return null; + const parts = sort.split(',').map((s) => s.trim()).filter(Boolean); + const result: Array<{ key: string; desc: boolean }> = []; + for (const part of parts) { + const desc = part.startsWith('-'); + const key = desc ? part.slice(1) : part; + if (!ALLOWED_SORT_KEYS.has(key)) return null; + result.push({ key, desc }); + } + return result; +} + +export class ProjectUpdateService { + readonly #state: InMemoryState; + + constructor(state: InMemoryState) { + this.#state = state; + } + + listForProject( + projectSlug: string, + opts: ProjectUpdateListOptions, + caller?: CallerSession, + ): { items: ProjectUpdateResponse[]; totalItems: number } | { error: 'not_found' | 'invalid_sort' } { + const sortSpec = parseSortSpec(opts.sort ?? '-createdAt'); + if (!sortSpec) return { error: 'invalid_sort' }; + + const projectId = this.#state.projectIdBySlug.get(projectSlug); + if (!projectId) return { error: 'not_found' }; + const project = this.#state.projects.get(projectId); + if (!project || project.deletedAt) return { error: 'not_found' }; + + const updateIds = this.#state.updatesByProject.get(projectId) ?? new Set(); + const updates = [...updateIds] + .map((id) => this.#state.projectUpdates.get(id)) + .filter((u): u is ProjectUpdate => u !== undefined); + + updates.sort((a, b) => { + for (const { key, desc } of sortSpec) { + let cmp = 0; + if (key === 'createdAt') cmp = a.createdAt.localeCompare(b.createdAt); + else if (key === 'number') cmp = a.number - b.number; + if (cmp !== 0) return desc ? -cmp : cmp; + } + return 0; + }); + + const totalItems = updates.length; + const page = Math.max(1, opts.page ?? 1); + const perPage = Math.min(100, Math.max(1, opts.perPage ?? 20)); + const slice = updates.slice((page - 1) * perPage, page * perPage); + + const items = slice.map((u) => { + const author = u.authorId ? (this.#state.people.get(u.authorId) ?? null) : null; + const permissions = computeUpdatePermissions(caller, u); + return serializeProjectUpdate(u, { project: project!, author, permissions }); + }); + + return { items, totalItems }; + } + + getForProject( + projectSlug: string, + number: number, + caller?: CallerSession, + ): ProjectUpdateResponse | null | { error: 'not_found' } { + const projectId = this.#state.projectIdBySlug.get(projectSlug); + if (!projectId) return { error: 'not_found' }; + const project = this.#state.projects.get(projectId); + if (!project || project.deletedAt) return { error: 'not_found' }; + + const updateId = this.#state.updateByProjectAndNumber.get(`${projectId}:${number}`); + if (!updateId) return null; + const update = this.#state.projectUpdates.get(updateId); + if (!update) return null; + + const author = update.authorId ? (this.#state.people.get(update.authorId) ?? null) : null; + const permissions = computeUpdatePermissions(caller, update); + return serializeProjectUpdate(update, { project, author, permissions }); + } + + globalFeed( + opts: GlobalUpdateFeedOptions, + caller?: CallerSession, + ): { items: ProjectUpdateResponse[]; totalItems: number } { + // Optionally filter tag handles to project IDs + let filterProjectIds: Set | undefined; + if (opts.tag && opts.tag.length > 0) { + filterProjectIds = new Set(); + for (const handle of opts.tag) { + const tagId = this.#state.tagIdByHandle.get(handle); + if (!tagId) continue; + const taIds = this.#state.tagAssignmentsByTag.get(tagId) ?? new Set(); + for (const taId of taIds) { + const ta = this.#state.tagAssignments.get(taId); + if (ta?.taggableType === 'project') filterProjectIds.add(ta.taggableId); + } + } + } + + const updates = [...this.#state.projectUpdates.values()].filter((u) => { + const project = this.#state.projects.get(u.projectId); + if (!project || project.deletedAt) return false; + if (opts.since && u.createdAt < opts.since) return false; + if (filterProjectIds && !filterProjectIds.has(u.projectId)) return false; + return true; + }); + + updates.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + + const totalItems = updates.length; + const page = Math.max(1, opts.page ?? 1); + const perPage = Math.min(100, Math.max(1, opts.perPage ?? 30)); + const slice = updates.slice((page - 1) * perPage, page * perPage); + + const items = slice.map((u) => { + const project = this.#state.projects.get(u.projectId)!; + const author = u.authorId ? (this.#state.people.get(u.authorId) ?? null) : null; + const permissions = computeUpdatePermissions(caller, u); + return serializeProjectUpdate(u, { project, author, permissions }); + }); + + return { items, totalItems }; + } +} diff --git a/apps/api/src/services/project.ts b/apps/api/src/services/project.ts new file mode 100644 index 0000000..43135d9 --- /dev/null +++ b/apps/api/src/services/project.ts @@ -0,0 +1,303 @@ +/** + * ProjectService — read operations against in-memory state. + */ +import type { HelpWantedRole, Person, Project, ProjectMembership, Tag } from '@cfp/shared/schemas'; +import type { InMemoryState } from '../store/memory/state.js'; +import type { FtsEngine } from '../store/fts.js'; +import { getProjectFacets, type ProjectFacets } from '../store/memory/facets.js'; +import type { CallerSession } from './permissions.js'; +import { computeProjectPermissions } from './permissions.js'; +import { + serializeProjectDetail, + serializeProjectListItem, + type ProjectDetail, + type ProjectListItem, +} from './serializers/project.js'; + +export interface ProjectListOptions { + readonly q?: string; + readonly stage?: string; + readonly stageIn?: string[]; + readonly tag?: string[]; + readonly maintainer?: string; + readonly memberSlug?: string; + readonly helpWanted?: boolean; + readonly featured?: boolean; + readonly includeDeleted?: boolean; + readonly sort?: string; + readonly page?: number; + readonly perPage?: number; +} + +export interface ProjectListResult { + readonly items: ProjectListItem[]; + readonly totalItems: number; + readonly facets: ProjectFacets; +} + +const ALLOWED_SORT_KEYS = new Set(['createdAt', 'updatedAt', 'title', 'stage']); +const STAGE_ORDER = ['commenting', 'bootstrapping', 'prototyping', 'testing', 'maintaining', 'drifting', 'hibernating']; + +function parseSortSpec(sort: string | undefined): Array<{ key: string; desc: boolean }> | null { + if (!sort) return null; + const parts = sort.split(',').map((s) => s.trim()).filter(Boolean); + const result: Array<{ key: string; desc: boolean }> = []; + for (const part of parts) { + const desc = part.startsWith('-'); + const key = desc ? part.slice(1) : part; + if (!ALLOWED_SORT_KEYS.has(key)) return null; + result.push({ key, desc }); + } + return result; +} + +function compareProjects(a: Project, b: Project, sortSpec: Array<{ key: string; desc: boolean }>): number { + for (const { key, desc } of sortSpec) { + let cmp = 0; + if (key === 'title') { + cmp = a.title.localeCompare(b.title); + } else if (key === 'stage') { + cmp = STAGE_ORDER.indexOf(a.stage) - STAGE_ORDER.indexOf(b.stage); + } else if (key === 'createdAt') { + cmp = a.createdAt.localeCompare(b.createdAt); + } else if (key === 'updatedAt') { + cmp = a.updatedAt.localeCompare(b.updatedAt); + } + if (cmp !== 0) return desc ? -cmp : cmp; + } + return 0; +} + +export class ProjectService { + readonly #state: InMemoryState; + readonly #fts: FtsEngine; + + constructor(state: InMemoryState, fts: FtsEngine) { + this.#state = state; + this.#fts = fts; + } + + list(opts: ProjectListOptions): ProjectListResult | { error: 'invalid_sort' | 'invalid_filter' } { + const sortSpec = parseSortSpec(opts.sort ?? '-updatedAt'); + if (!sortSpec) return { error: 'invalid_sort' }; + + // Get the facets from the unfiltered corpus BEFORE applying filters + const facets = getProjectFacets(this.#state); + + // FTS filter + let ftsSlugs: Set | null = null; + if (opts.q) { + const slugs = this.#fts.searchProjects(opts.q); + ftsSlugs = new Set(slugs); + } + + // memberSlug → personId for membership filter + let memberPersonId: string | undefined; + if (opts.memberSlug) { + memberPersonId = this.#state.personIdBySlug.get(opts.memberSlug); + if (!memberPersonId) { + return { items: [], totalItems: 0, facets }; + } + } + + // maintainer slug → id + let maintainerPersonId: string | undefined; + if (opts.maintainer) { + maintainerPersonId = this.#state.personIdBySlug.get(opts.maintainer); + if (!maintainerPersonId) { + return { items: [], totalItems: 0, facets }; + } + } + + // tag handles → tag IDs + let filterTagIds: Set | undefined; + if (opts.tag && opts.tag.length > 0) { + filterTagIds = new Set(); + for (const handle of opts.tag) { + const tagId = this.#state.tagIdByHandle.get(handle); + if (tagId) filterTagIds.add(tagId); + } + } + + // stageIn + const stageInSet = opts.stageIn ? new Set(opts.stageIn) : null; + + const filtered = [...this.#state.projects.values()].filter((p) => { + // Soft-delete filter + if (p.deletedAt && !opts.includeDeleted) return false; + + // FTS + if (ftsSlugs && !ftsSlugs.has(p.slug)) return false; + + // Stage filters + if (opts.stage && p.stage !== opts.stage) return false; + if (stageInSet && !stageInSet.has(p.stage)) return false; + + // Featured + if (opts.featured !== undefined && p.featured !== opts.featured) return false; + + // Maintainer + if (maintainerPersonId && p.maintainerId !== maintainerPersonId) return false; + + // Tag filter (AND semantics) + if (filterTagIds && filterTagIds.size > 0) { + const projectAssignments = this.#state.tagAssignmentsByTaggable.get(p.id); + if (!projectAssignments) return false; + const projectTagIds = new Set( + [...projectAssignments] + .map((taId) => this.#state.tagAssignments.get(taId)?.tagId) + .filter((id): id is string => id !== undefined), + ); + for (const tagId of filterTagIds) { + if (!projectTagIds.has(tagId)) return false; + } + } + + // Member filter + if (memberPersonId) { + const personMemberships = this.#state.membershipsByPerson.get(memberPersonId); + if (!personMemberships) return false; + const isMember = [...personMemberships].some( + (mId) => this.#state.projectMemberships.get(mId)?.projectId === p.id, + ); + if (!isMember) return false; + } + + // Help-wanted filter + if (opts.helpWanted) { + const roles = this.#state.helpWantedByProject.get(p.id); + if (!roles) return false; + const hasOpen = [...roles].some( + (rId) => this.#state.helpWantedRoles.get(rId)?.status === 'open', + ); + if (!hasOpen) return false; + } + + return true; + }); + + // Sort + filtered.sort((a, b) => compareProjects(a, b, sortSpec)); + + const totalItems = filtered.length; + + // Pagination + const page = Math.max(1, opts.page ?? 1); + const perPage = Math.min(100, Math.max(1, opts.perPage ?? 30)); + const slice = filtered.slice((page - 1) * perPage, page * perPage); + + const items = slice.map((project) => this.#serializeListItem(project)); + + return { items, totalItems, facets }; + } + + get(slug: string, caller?: CallerSession): ProjectDetail | null { + const projectId = this.#state.projectIdBySlug.get(slug); + if (!projectId) return null; + const project = this.#state.projects.get(projectId); + if (!project) return null; + + const isStaff = + caller?.accountLevel === 'staff' || caller?.accountLevel === 'administrator'; + if (project.deletedAt && !isStaff) return null; + + const memberships = this.#getMembershipsForProject(project.id); + const memberPeople = this.#getPeopleForMemberships(memberships); + const maintainer = project.maintainerId + ? (this.#state.people.get(project.maintainerId) ?? null) + : null; + + const openHelpWantedRoles = this.#getOpenHelpWantedRoles(project.id); + const helpWantedTags = this.#getHelpWantedTags(openHelpWantedRoles.map((r) => r.id)); + + const projectTags = this.#getTagsForEntity(project.id, 'project'); + + const updateCount = this.#state.updatesByProject.get(project.id)?.size ?? 0; + const buzzCount = this.#state.buzzByProject.get(project.id)?.size ?? 0; + + const permissions = computeProjectPermissions(caller, project, memberships); + + return serializeProjectDetail(project, { + maintainer, + memberships, + memberPeople, + openHelpWantedRoles, + helpWantedTags, + tags: projectTags, + updateCount, + buzzCount, + permissions, + }); + } + + #serializeListItem(project: Project): ProjectListItem { + const memberships = this.#getMembershipsForProject(project.id); + const memberPeople = this.#getPeopleForMemberships(memberships); + const maintainer = project.maintainerId + ? (this.#state.people.get(project.maintainerId) ?? null) + : null; + + const openHelpWantedCount = [...(this.#state.helpWantedByProject.get(project.id) ?? [])] + .filter((rId) => this.#state.helpWantedRoles.get(rId)?.status === 'open').length; + + const tagAssignments = [...(this.#state.tagAssignmentsByTaggable.get(project.id) ?? [])] + .map((taId) => this.#state.tagAssignments.get(taId)) + .filter((ta): ta is NonNullable => ta !== undefined); + + return serializeProjectListItem(project, { + maintainer, + memberships, + memberPeople, + openHelpWantedCount, + tags: [], + tagAssignments, + allTags: this.#state.tags, + }); + } + + #getMembershipsForProject(projectId: string): ProjectMembership[] { + const mIds = this.#state.membershipsByProject.get(projectId) ?? new Set(); + return [...mIds] + .map((id) => this.#state.projectMemberships.get(id)) + .filter((m): m is ProjectMembership => m !== undefined); + } + + #getPeopleForMemberships(memberships: ProjectMembership[]): Map { + const map = new Map(); + for (const m of memberships) { + const p = this.#state.people.get(m.personId); + if (p) map.set(p.id, p); + } + return map; + } + + #getOpenHelpWantedRoles(projectId: string): HelpWantedRole[] { + const rIds = this.#state.helpWantedByProject.get(projectId) ?? new Set(); + return [...rIds] + .map((id) => this.#state.helpWantedRoles.get(id)) + .filter((r): r is HelpWantedRole => r !== undefined && r.status === 'open'); + } + + #getHelpWantedTags(roleIds: string[]): Map { + const map = new Map(); + for (const roleId of roleIds) { + const taIds = this.#state.tagAssignmentsByTaggable.get(roleId) ?? new Set(); + const tags = [...taIds] + .map((taId) => this.#state.tagAssignments.get(taId)) + .filter((ta): ta is NonNullable => ta?.taggableType === 'help_wanted_role') + .map((ta) => this.#state.tags.get(ta.tagId)) + .filter((t): t is Tag => t !== undefined); + map.set(roleId, tags); + } + return map; + } + + #getTagsForEntity(entityId: string, type: 'project' | 'person' | 'help_wanted_role'): Tag[] { + const taIds = this.#state.tagAssignmentsByTaggable.get(entityId) ?? new Set(); + return [...taIds] + .map((taId) => this.#state.tagAssignments.get(taId)) + .filter((ta): ta is NonNullable => ta?.taggableType === type) + .map((ta) => this.#state.tags.get(ta.tagId)) + .filter((t): t is Tag => t !== undefined); + } +} diff --git a/apps/api/src/services/serializers/common.ts b/apps/api/src/services/serializers/common.ts new file mode 100644 index 0000000..4fbf253 --- /dev/null +++ b/apps/api/src/services/serializers/common.ts @@ -0,0 +1,70 @@ +/** + * Shared serialization helpers used across entity serializers. + */ +import { renderMarkdown } from '@cfp/shared'; +import type { Person, Tag } from '@cfp/shared/schemas'; + +/** PersonAvatar shape used in many nested contexts. */ +export interface PersonAvatar { + readonly slug: string; + readonly fullName: string; + readonly avatarUrl: string | null; +} + +/** Tag shape used in nested contexts. */ +export interface TagItem { + readonly namespace: string; + readonly slug: string; + readonly title: string; +} + +export function serializePersonAvatar(person: Person | undefined | null): PersonAvatar | null { + if (!person) return null; + return { + slug: person.slug, + fullName: person.fullName, + avatarUrl: person.avatarKey ? `/api/attachments/${person.avatarKey}` : null, + }; +} + +export function serializeTagItem(tag: Tag): TagItem { + return { + namespace: tag.namespace, + slug: tag.slug, + title: tag.title, + }; +} + +/** Group tags by namespace. */ +export function groupTagsByNamespace( + tags: Tag[], +): { topic: TagItem[]; tech: TagItem[]; event: TagItem[] } { + const topic: TagItem[] = []; + const tech: TagItem[] = []; + const event: TagItem[] = []; + + for (const tag of tags) { + const item = serializeTagItem(tag); + if (tag.namespace === 'topic') topic.push(item); + else if (tag.namespace === 'tech') tech.push(item); + else if (tag.namespace === 'event') event.push(item); + } + + return { topic, tech, event }; +} + +/** Render markdown to HTML + an excerpt. Returns empty string for null/empty source. */ +export function renderField(source: string | null | undefined): { html: string; excerpt: string } { + if (!source) return { html: '', excerpt: '' }; + const { html, excerpt } = renderMarkdown(source); + return { html, excerpt }; +} + +/** Truncate a plain-text string at a word boundary. */ +export function truncate(text: string, maxLength: number): string { + if (text.length <= maxLength) return text; + const truncated = text.slice(0, maxLength); + const lastSpace = truncated.lastIndexOf(' '); + const breakAt = lastSpace > maxLength * 0.8 ? lastSpace : maxLength; + return text.slice(0, breakAt) + '…'; +} diff --git a/apps/api/src/services/serializers/help-wanted.ts b/apps/api/src/services/serializers/help-wanted.ts new file mode 100644 index 0000000..b36718e --- /dev/null +++ b/apps/api/src/services/serializers/help-wanted.ts @@ -0,0 +1,65 @@ +/** + * HelpWantedRole serializer. + */ +import type { HelpWantedRole, Person, Project, Tag, TagAssignment } from '@cfp/shared/schemas'; +import { renderMarkdown } from '@cfp/shared'; +import type { HelpWantedPermissions } from '../permissions.js'; +import { groupTagsByNamespace, serializePersonAvatar, 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 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 filledAt: string | null; + readonly closedAt: string | null; + readonly tags: { topic: TagItem[]; tech: TagItem[] }; + readonly interestCount: number; + readonly permissions: HelpWantedPermissions; + readonly createdAt: string; + readonly updatedAt: string; +} + +export function serializeHelpWantedRole( + role: HelpWantedRole, + opts: { + project: Project; + postedBy: Person | null; + filledBy: Person | null; + tagAssignments: TagAssignment[]; + allTags: Map; + interestCount: number; + permissions: HelpWantedPermissions; + }, +): HelpWantedRoleResponse { + const roleTags = opts.tagAssignments + .filter((ta) => ta.taggableType === 'help_wanted_role' && ta.taggableId === role.id) + .map((ta) => opts.allTags.get(ta.tagId)) + .filter((t): t is Tag => t !== undefined); + + const tagsByNamespace = groupTagsByNamespace(roleTags); + + return { + id: role.id, + project: { slug: opts.project.slug, title: opts.project.title }, + postedBy: serializePersonAvatar(opts.postedBy), + title: role.title, + description: role.description, + descriptionHtml: renderMarkdown(role.description).html, + commitmentHoursPerWeek: role.commitmentHoursPerWeek ?? null, + status: role.status, + filledBy: serializePersonAvatar(opts.filledBy), + filledAt: role.filledAt ?? null, + closedAt: role.closedAt ?? null, + tags: { topic: tagsByNamespace.topic, tech: tagsByNamespace.tech }, + interestCount: opts.interestCount, + permissions: opts.permissions, + createdAt: role.createdAt, + updatedAt: role.updatedAt, + }; +} diff --git a/apps/api/src/services/serializers/person.ts b/apps/api/src/services/serializers/person.ts new file mode 100644 index 0000000..0792d8c --- /dev/null +++ b/apps/api/src/services/serializers/person.ts @@ -0,0 +1,171 @@ +/** + * Person serializers: PersonListItem and Person (detail) shapes. + */ +import type { + Person, + Project, + ProjectMembership, + ProjectUpdate, + Tag, + TagAssignment, +} from '@cfp/shared/schemas'; +import { renderMarkdown } from '@cfp/shared'; +import type { PersonPermissions } from '../permissions.js'; +import { + groupTagsByNamespace, + serializePersonAvatar, + truncate, + type TagItem, +} from './common.js'; + +export interface PersonListItem { + readonly slug: string; + readonly fullName: string; + readonly avatarUrl: string | null; + readonly bioExcerpt: string; + readonly memberOfCount: number; + readonly tags: TagItem[]; + readonly createdAt: string; +} + +export interface PersonMembershipSummary { + readonly project: { + readonly slug: string; + readonly title: string; + readonly stage: string; + }; + readonly role: string | null; + readonly isMaintainer: boolean; + readonly joinedAt: string; +} + +export interface ProjectUpdateSummary { + readonly id: string; + readonly number: number; + readonly project: { readonly slug: string; readonly title: string }; + readonly bodyHtml: string; + readonly createdAt: string; +} + +export interface PersonDetail { + readonly id: string; + readonly slug: string; + readonly fullName: string; + readonly firstName: string | null; + readonly lastName: string | null; + readonly avatarUrl: string | null; + readonly bio: string | null; + readonly bioHtml: string; + readonly accountLevel: string; + readonly tags: { topic: TagItem[]; tech: TagItem[] }; + readonly memberships: PersonMembershipSummary[]; + readonly recentUpdates: ProjectUpdateSummary[]; + readonly permissions: PersonPermissions; + readonly createdAt: string; + readonly updatedAt: string; +} + +export function serializePersonListItem( + person: Person, + opts: { + memberOfCount: number; + tagAssignments: TagAssignment[]; + allTags: Map; + }, +): PersonListItem { + const personTags = opts.tagAssignments + .filter((ta) => ta.taggableType === 'person' && ta.taggableId === person.id) + .map((ta) => opts.allTags.get(ta.tagId)) + .filter((t): t is Tag => t !== undefined); + + const bioExcerpt = person.bio + ? truncate(renderMarkdown(person.bio).excerpt, 200) + : ''; + + return { + slug: person.slug, + fullName: person.fullName, + avatarUrl: person.avatarKey ? `/api/attachments/${person.avatarKey}` : null, + bioExcerpt, + memberOfCount: opts.memberOfCount, + tags: personTags.map((t) => ({ namespace: t.namespace, slug: t.slug, title: t.title })), + createdAt: person.createdAt, + }; +} + +export function serializePersonDetail( + person: Person, + opts: { + memberships: ProjectMembership[]; + projectsMap: Map; + recentUpdates: ProjectUpdate[]; + updatesProjectsMap: Map; + tagAssignments: TagAssignment[]; + allTags: Map; + permissions: PersonPermissions; + /** Caller's accountLevel — used to decide how much accountLevel to expose. */ + callerAccountLevel?: 'user' | 'staff' | 'administrator'; + callerPersonId?: string; + }, +): PersonDetail { + const bioHtml = person.bio ? renderMarkdown(person.bio).html : ''; + + const personTags = opts.tagAssignments + .filter((ta) => ta.taggableType === 'person' && ta.taggableId === person.id) + .map((ta) => opts.allTags.get(ta.tagId)) + .filter((t): t is Tag => t !== undefined); + + const tagsByNamespace = groupTagsByNamespace(personTags); + + const memberships: PersonMembershipSummary[] = opts.memberships.map((m) => { + const project = opts.projectsMap.get(m.projectId); + return { + project: { + slug: project?.slug ?? '', + title: project?.title ?? '', + stage: project?.stage ?? 'commenting', + }, + role: m.role ?? null, + isMaintainer: m.isMaintainer, + joinedAt: m.joinedAt, + }; + }); + + const recentUpdates: ProjectUpdateSummary[] = opts.recentUpdates.slice(0, 5).map((u) => { + const project = opts.updatesProjectsMap.get(u.projectId); + return { + id: u.id, + number: u.number, + project: { slug: project?.slug ?? '', title: project?.title ?? '' }, + bodyHtml: renderMarkdown(u.body).html, + createdAt: u.createdAt, + }; + }); + + // accountLevel is visible to self and staff; everyone else sees "user" + const isSelf = opts.callerPersonId === person.id; + const callerIsStaff = + opts.callerAccountLevel === 'staff' || opts.callerAccountLevel === 'administrator'; + const visibleAccountLevel = + isSelf || callerIsStaff ? person.accountLevel : 'user'; + + const avatar = serializePersonAvatar(person); + + return { + id: person.id, + slug: person.slug, + fullName: person.fullName, + firstName: person.firstName ?? null, + lastName: person.lastName ?? null, + avatarUrl: avatar?.avatarUrl ?? null, + bio: person.bio ?? null, + bioHtml, + accountLevel: visibleAccountLevel, + tags: { topic: tagsByNamespace.topic, tech: tagsByNamespace.tech }, + memberships, + recentUpdates, + permissions: opts.permissions, + createdAt: person.createdAt, + updatedAt: person.updatedAt, + }; +} diff --git a/apps/api/src/services/serializers/project-buzz.ts b/apps/api/src/services/serializers/project-buzz.ts new file mode 100644 index 0000000..561222e --- /dev/null +++ b/apps/api/src/services/serializers/project-buzz.ts @@ -0,0 +1,50 @@ +/** + * ProjectBuzz serializer. + */ +import type { Person, Project, ProjectBuzz } from '@cfp/shared/schemas'; +import { renderMarkdown } from '@cfp/shared'; +import type { BuzzPermissions } from '../permissions.js'; +import { serializePersonAvatar } 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 headline: string; + readonly url: string; + readonly publishedAt: string; + readonly summary: string | null; + readonly summaryHtml: string; + readonly imageUrl: string | null; + readonly permissions: BuzzPermissions; + readonly createdAt: string; + readonly updatedAt: string; +} + +export function serializeProjectBuzz( + buzz: ProjectBuzz, + opts: { + project: Project; + postedBy: Person | null; + permissions: BuzzPermissions; + }, +): ProjectBuzzResponse { + const summaryHtml = buzz.summary ? renderMarkdown(buzz.summary).html : ''; + + return { + id: buzz.id, + slug: buzz.slug, + project: { slug: opts.project.slug, title: opts.project.title }, + postedBy: serializePersonAvatar(opts.postedBy), + headline: buzz.headline, + url: buzz.url, + publishedAt: buzz.publishedAt, + summary: buzz.summary ?? null, + summaryHtml, + imageUrl: buzz.imageKey ? `/api/attachments/${buzz.imageKey}` : null, + permissions: opts.permissions, + createdAt: buzz.createdAt, + updatedAt: buzz.updatedAt, + }; +} diff --git a/apps/api/src/services/serializers/project-update.ts b/apps/api/src/services/serializers/project-update.ts new file mode 100644 index 0000000..ccd50f7 --- /dev/null +++ b/apps/api/src/services/serializers/project-update.ts @@ -0,0 +1,40 @@ +/** + * ProjectUpdate serializer. + */ +import type { Person, Project, ProjectUpdate } from '@cfp/shared/schemas'; +import { renderMarkdown } from '@cfp/shared'; +import type { UpdatePermissions } from '../permissions.js'; +import { serializePersonAvatar } 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 body: string; + readonly bodyHtml: string; + readonly permissions: UpdatePermissions; + readonly createdAt: string; + readonly updatedAt: string; +} + +export function serializeProjectUpdate( + update: ProjectUpdate, + opts: { + project: Project; + author: Person | null; + permissions: UpdatePermissions; + }, +): ProjectUpdateResponse { + return { + id: update.id, + number: update.number, + project: { slug: opts.project.slug, title: opts.project.title }, + author: serializePersonAvatar(opts.author), + body: update.body, + bodyHtml: renderMarkdown(update.body).html, + permissions: opts.permissions, + createdAt: update.createdAt, + updatedAt: update.updatedAt, + }; +} diff --git a/apps/api/src/services/serializers/project.ts b/apps/api/src/services/serializers/project.ts new file mode 100644 index 0000000..16d06db --- /dev/null +++ b/apps/api/src/services/serializers/project.ts @@ -0,0 +1,247 @@ +/** + * Project serializers: ProjectListItem and Project (detail) shapes. + */ +import type { + HelpWantedRole, + Person, + Project, + ProjectMembership, + Tag, + TagAssignment, +} from '@cfp/shared/schemas'; +import { renderMarkdown } from '@cfp/shared'; +import type { ProjectPermissions } from '../permissions.js'; +import { + groupTagsByNamespace, + serializePersonAvatar, + serializeTagItem, + truncate, + type PersonAvatar, + type TagItem, +} from './common.js'; + +export interface ProjectListItem { + readonly id: string; + readonly slug: string; + readonly title: string; + readonly summary: string | null; + readonly stage: string; + readonly overviewExcerpt: string; + readonly maintainer: PersonAvatar | null; + readonly memberCount: number; + readonly members: PersonAvatar[]; + readonly links: { + readonly usersUrl: string | null; + readonly developersUrl: string | null; + readonly chatChannel: string | null; + }; + readonly openHelpWantedCount: number; + readonly tags: TagItem[]; + readonly updatedAt: string; +} + +export interface ProjectMembershipResponse { + readonly id: string; + readonly projectSlug: string; + readonly person: PersonAvatar; + readonly role: string | null; + readonly isMaintainer: boolean; + readonly joinedAt: string; +} + +export interface ProjectDetail { + readonly id: string; + readonly slug: string; + readonly title: string; + readonly summary: string | null; + readonly overview: string | null; + readonly overviewHtml: string; + readonly stage: string; + readonly stageProgress: number; + readonly maintainer: PersonAvatar | null; + readonly memberships: ProjectMembershipResponse[]; + readonly openHelpWantedRoles: HelpWantedRoleSummary[]; + readonly tags: { topic: TagItem[]; tech: TagItem[]; event: TagItem[] }; + readonly links: { + readonly usersUrl: string | null; + readonly developersUrl: string | null; + readonly chatChannel: string | null; + }; + readonly counts: { + readonly updates: number; + readonly buzz: number; + readonly members: number; + }; + readonly permissions: ProjectPermissions; + readonly featured: boolean; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface HelpWantedRoleSummary { + readonly id: string; + readonly title: string; + readonly commitmentHoursPerWeek: number | null; + readonly status: string; + readonly tags: { topic: TagItem[]; tech: TagItem[] }; +} + +const STAGE_ORDER = [ + 'commenting', + 'bootstrapping', + 'prototyping', + 'testing', + 'maintaining', + 'drifting', + 'hibernating', +] as const; + +function stageProgress(stage: string): number { + const idx = STAGE_ORDER.indexOf(stage as (typeof STAGE_ORDER)[number]); + if (idx < 0) return 0; + return idx / (STAGE_ORDER.length - 1); +} + +export function serializeProjectListItem( + project: Project, + opts: { + maintainer: Person | null; + memberships: ProjectMembership[]; + memberPeople: Map; + openHelpWantedCount: number; + tags: Tag[]; + tagAssignments: TagAssignment[]; + allTags: Map; + }, +): ProjectListItem { + const { excerpt: rawExcerpt } = project.overview + ? renderMarkdown(project.overview) + : { excerpt: '' }; + + const overviewExcerpt = truncate(rawExcerpt, 600); + + const projectTags = opts.tagAssignments + .filter((ta) => ta.taggableType === 'project' && ta.taggableId === project.id) + .map((ta) => opts.allTags.get(ta.tagId)) + .filter((t): t is Tag => t !== undefined); + + // First 10 members: maintainer first, then by fullName + const sortedMemberships = [...opts.memberships].sort((a, b) => { + if (a.personId === project.maintainerId) return -1; + if (b.personId === project.maintainerId) return 1; + const pa = opts.memberPeople.get(a.personId); + const pb = opts.memberPeople.get(b.personId); + return (pa?.fullName ?? '').localeCompare(pb?.fullName ?? ''); + }); + + const members = sortedMemberships + .slice(0, 10) + .map((m) => opts.memberPeople.get(m.personId)) + .filter((p): p is Person => p !== undefined) + .map(serializePersonAvatar) + .filter((a): a is PersonAvatar => a !== null); + + return { + id: project.id, + slug: project.slug, + title: project.title, + summary: project.summary ?? null, + stage: project.stage, + overviewExcerpt, + maintainer: serializePersonAvatar(opts.maintainer), + memberCount: opts.memberships.length, + members, + links: { + usersUrl: project.usersUrl ?? null, + developersUrl: project.developersUrl ?? null, + chatChannel: project.chatChannel ?? null, + }, + openHelpWantedCount: opts.openHelpWantedCount, + tags: projectTags.map(serializeTagItem), + updatedAt: project.updatedAt, + }; +} + +export function serializeProjectDetail( + project: Project, + opts: { + maintainer: Person | null; + memberships: ProjectMembership[]; + memberPeople: Map; + openHelpWantedRoles: HelpWantedRole[]; + helpWantedTags: Map; + tags: Tag[]; + updateCount: number; + buzzCount: number; + permissions: ProjectPermissions; + }, +): ProjectDetail { + const overviewHtml = project.overview ? renderMarkdown(project.overview).html : ''; + + const tagsByNamespace = groupTagsByNamespace(opts.tags); + + const memberships: ProjectMembershipResponse[] = opts.memberships + .sort((a, b) => { + if (a.personId === project.maintainerId) return -1; + if (b.personId === project.maintainerId) return 1; + const pa = opts.memberPeople.get(a.personId); + const pb = opts.memberPeople.get(b.personId); + return (pa?.fullName ?? '').localeCompare(pb?.fullName ?? ''); + }) + .map((m) => { + const person = opts.memberPeople.get(m.personId); + return { + id: m.id, + projectSlug: project.slug, + person: serializePersonAvatar(person) ?? { + slug: '', + fullName: 'Unknown', + avatarUrl: null, + }, + role: m.role ?? null, + isMaintainer: m.isMaintainer, + joinedAt: m.joinedAt, + }; + }); + + const openHelpWantedRoles: HelpWantedRoleSummary[] = opts.openHelpWantedRoles.map((role) => { + const roleTags = opts.helpWantedTags.get(role.id) ?? []; + const grouped = groupTagsByNamespace(roleTags); + return { + id: role.id, + title: role.title, + commitmentHoursPerWeek: role.commitmentHoursPerWeek ?? null, + status: role.status, + tags: { topic: grouped.topic, tech: grouped.tech }, + }; + }); + + return { + id: project.id, + slug: project.slug, + title: project.title, + summary: project.summary ?? null, + overview: project.overview ?? null, + overviewHtml, + stage: project.stage, + stageProgress: stageProgress(project.stage), + maintainer: serializePersonAvatar(opts.maintainer), + memberships, + openHelpWantedRoles, + tags: tagsByNamespace, + links: { + usersUrl: project.usersUrl ?? null, + developersUrl: project.developersUrl ?? null, + chatChannel: project.chatChannel ?? null, + }, + counts: { + updates: opts.updateCount, + buzz: opts.buzzCount, + members: opts.memberships.length, + }, + permissions: opts.permissions, + featured: project.featured, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + }; +} diff --git a/apps/api/src/services/serializers/tag.ts b/apps/api/src/services/serializers/tag.ts new file mode 100644 index 0000000..c207626 --- /dev/null +++ b/apps/api/src/services/serializers/tag.ts @@ -0,0 +1,44 @@ +/** + * Tag serializer. + */ +import type { Tag, TagAssignment } from '@cfp/shared/schemas'; + +export interface TagResponse { + readonly id: string; + readonly handle: string; + readonly namespace: string; + readonly slug: string; + readonly title: string; + readonly projectCount: number; + readonly personCount: number; + readonly helpWantedCount: number; +} + +export function serializeTag( + tag: Tag, + opts: { + tagAssignments: TagAssignment[]; + }, +): TagResponse { + let projectCount = 0; + let personCount = 0; + let helpWantedCount = 0; + + for (const ta of opts.tagAssignments) { + if (ta.tagId !== tag.id) continue; + if (ta.taggableType === 'project') projectCount++; + else if (ta.taggableType === 'person') personCount++; + else if (ta.taggableType === 'help_wanted_role') helpWantedCount++; + } + + return { + id: tag.id, + handle: `${tag.namespace}.${tag.slug}`, + namespace: tag.namespace, + slug: tag.slug, + title: tag.title, + projectCount, + personCount, + helpWantedCount, + }; +} diff --git a/apps/api/src/services/tag.ts b/apps/api/src/services/tag.ts new file mode 100644 index 0000000..bca3e66 --- /dev/null +++ b/apps/api/src/services/tag.ts @@ -0,0 +1,140 @@ +/** + * TagService — read operations against in-memory state. + */ +import type { Tag, TagAssignment } from '@cfp/shared/schemas'; +import type { InMemoryState } from '../store/memory/state.js'; +import { serializeTag, type TagResponse } from './serializers/tag.js'; + +export interface TagListOptions { + readonly namespace?: string; + readonly q?: string; + readonly taggableType?: string; + readonly sort?: string; + readonly page?: number; + readonly perPage?: number; +} + +export interface TagListResult { + readonly items: TagResponse[]; + readonly totalItems: number; +} + +const ALLOWED_SORT_KEYS = new Set(['title', 'projectCount', 'personCount']); + +function parseSortSpec(sort: string | undefined): Array<{ key: string; desc: boolean }> | null { + if (!sort) return null; + const parts = sort.split(',').map((s) => s.trim()).filter(Boolean); + const result: Array<{ key: string; desc: boolean }> = []; + for (const part of parts) { + const desc = part.startsWith('-'); + const key = desc ? part.slice(1) : part; + if (!ALLOWED_SORT_KEYS.has(key)) return null; + result.push({ key, desc }); + } + return result; +} + +export class TagService { + readonly #state: InMemoryState; + + constructor(state: InMemoryState) { + this.#state = state; + } + + list(opts: TagListOptions): TagListResult | { error: 'invalid_sort' | 'invalid_filter' } { + const sortSpec = parseSortSpec(opts.sort ?? '-projectCount'); + if (!sortSpec) return { error: 'invalid_sort' }; + + // Precompute counts for all tags + const counts = this.#computeTagCounts(); + + let tags = [...this.#state.tags.values()]; + + if (opts.namespace) { + const validNamespaces = new Set(['topic', 'tech', 'event']); + if (!validNamespaces.has(opts.namespace)) return { error: 'invalid_filter' }; + tags = tags.filter((t) => t.namespace === opts.namespace); + } + + if (opts.q) { + const q = opts.q.toLowerCase(); + tags = tags.filter( + (t) => t.slug.includes(q) || t.title.toLowerCase().includes(q), + ); + } + + if (opts.taggableType) { + const validTypes = new Set(['project', 'person', 'help_wanted_role']); + if (!validTypes.has(opts.taggableType)) return { error: 'invalid_filter' }; + tags = tags.filter((t) => { + const c = counts.get(t.id); + if (!c) return false; + if (opts.taggableType === 'project') return c.project > 0; + if (opts.taggableType === 'person') return c.person > 0; + if (opts.taggableType === 'help_wanted_role') return c.helpWanted > 0; + return false; + }); + } + + // Sort + tags.sort((a, b) => { + const ca = counts.get(a.id) ?? { project: 0, person: 0, helpWanted: 0 }; + const cb = counts.get(b.id) ?? { project: 0, person: 0, helpWanted: 0 }; + for (const { key, desc } of sortSpec) { + let cmp = 0; + if (key === 'title') cmp = a.title.localeCompare(b.title); + else if (key === 'projectCount') cmp = ca.project - cb.project; + else if (key === 'personCount') cmp = ca.person - cb.person; + if (cmp !== 0) return desc ? -cmp : cmp; + } + return 0; + }); + + const totalItems = tags.length; + const page = Math.max(1, opts.page ?? 1); + const perPage = Math.min(100, Math.max(1, opts.perPage ?? 100)); + const slice = tags.slice((page - 1) * perPage, page * perPage); + + const tagAssignments = [...this.#state.tagAssignments.values()]; + const items = slice.map((tag) => serializeTag(tag, { tagAssignments })); + + return { items, totalItems }; + } + + get(handle: string): TagResponse | null { + const tagId = this.#state.tagIdByHandle.get(handle); + if (!tagId) return null; + const tag = this.#state.tags.get(tagId); + if (!tag) return null; + + const tagAssignments = [...this.#state.tagAssignments.values()]; + return serializeTag(tag, { tagAssignments }); + } + + #computeTagCounts(): Map { + const counts = new Map(); + + for (const ta of this.#state.tagAssignments.values()) { + const existing = counts.get(ta.tagId) ?? { project: 0, person: 0, helpWanted: 0 }; + if (ta.taggableType === 'project') existing.project++; + else if (ta.taggableType === 'person') existing.person++; + else if (ta.taggableType === 'help_wanted_role') existing.helpWanted++; + counts.set(ta.tagId, existing); + } + + return counts; + } + + getTagsByIds(tagIds: Set): Tag[] { + return [...tagIds] + .map((id) => this.#state.tags.get(id)) + .filter((t): t is Tag => t !== undefined); + } + + getTagAssignmentsForTaggable(taggableId: string): TagAssignment[] { + const taIds = this.#state.tagAssignmentsByTaggable.get(taggableId) ?? new Set(); + return [...taIds] + .map((id) => this.#state.tagAssignments.get(id)) + .filter((ta): ta is TagAssignment => ta !== undefined); + } +} diff --git a/apps/api/src/store/fts.ts b/apps/api/src/store/fts.ts new file mode 100644 index 0000000..f938aa6 --- /dev/null +++ b/apps/api/src/store/fts.ts @@ -0,0 +1,172 @@ +/** + * Full-text search engine backed by SQLite FTS5 (in-memory). + * + * On boot: all projects and people are inserted. + * On mutation: the write-api calls invalidate() to upsert/delete a record. + * + * The interface is engine-agnostic so callers don't know about SQLite. + */ +import Database from 'better-sqlite3'; +import type { InMemoryState } from './memory/state.js'; + +export interface FtsEngine { + /** Search projects by query string. Returns slugs in relevance order. */ + searchProjects(q: string): string[]; + /** Search people by query string. Returns slugs in relevance order. */ + searchPeople(q: string): string[]; + /** Search help-wanted roles by query string. Returns IDs in relevance order. */ + searchHelpWanted(q: string): string[]; + /** Upsert a project row (call on mutation). */ + upsertProject(slug: string, title: string, summary: string, overview: string): void; + /** Remove a project row (call on delete/soft-delete). */ + removeProject(slug: string): void; + /** Upsert a person row. */ + upsertPerson(slug: string, fullName: string, bio: string): void; + /** Remove a person row. */ + removePerson(slug: string): void; + /** Upsert a help-wanted role row. */ + upsertHelpWanted(id: string, title: string, description: string): void; + /** Remove a help-wanted role row. */ + removeHelpWanted(id: string): void; +} + +export function buildFtsEngine(state: InMemoryState): FtsEngine { + const db = new Database(':memory:'); + + db.exec(` + CREATE VIRTUAL TABLE projects_fts + USING fts5(slug UNINDEXED, title, summary, overview, tokenize='porter ascii'); + + CREATE VIRTUAL TABLE people_fts + USING fts5(slug UNINDEXED, fullName, bio, tokenize='porter ascii'); + + CREATE VIRTUAL TABLE help_wanted_fts + USING fts5(id UNINDEXED, title, description, tokenize='porter ascii'); + `); + + const stmts = { + upsertProject: db.prepare( + `INSERT OR REPLACE INTO projects_fts(slug, title, summary, overview) + VALUES (?, ?, ?, ?)`, + ), + removeProject: db.prepare(`DELETE FROM projects_fts WHERE slug = ?`), + searchProjects: db.prepare( + `SELECT slug FROM projects_fts + WHERE projects_fts MATCH ? + ORDER BY rank + LIMIT 1000`, + ), + + upsertPerson: db.prepare( + `INSERT OR REPLACE INTO people_fts(slug, fullName, bio) + VALUES (?, ?, ?)`, + ), + removePerson: db.prepare(`DELETE FROM people_fts WHERE slug = ?`), + searchPeople: db.prepare( + `SELECT slug FROM people_fts + WHERE people_fts MATCH ? + ORDER BY rank + LIMIT 1000`, + ), + + upsertHelpWanted: db.prepare( + `INSERT OR REPLACE INTO help_wanted_fts(id, title, description) + VALUES (?, ?, ?)`, + ), + removeHelpWanted: db.prepare(`DELETE FROM help_wanted_fts WHERE id = ?`), + searchHelpWanted: db.prepare( + `SELECT id FROM help_wanted_fts + WHERE help_wanted_fts MATCH ? + ORDER BY rank + LIMIT 1000`, + ), + }; + + // Bulk-insert all current records + const insertAllProjects = db.transaction(() => { + for (const project of state.projects.values()) { + if (project.deletedAt) continue; + stmts.upsertProject.run( + project.slug, + project.title, + project.summary ?? '', + project.overview ?? '', + ); + } + }); + + const insertAllPeople = db.transaction(() => { + for (const person of state.people.values()) { + if (person.deletedAt) continue; + stmts.upsertPerson.run(person.slug, person.fullName, person.bio ?? ''); + } + }); + + const insertAllHelpWanted = db.transaction(() => { + for (const role of state.helpWantedRoles.values()) { + stmts.upsertHelpWanted.run(role.id, role.title, role.description); + } + }); + + insertAllProjects(); + insertAllPeople(); + insertAllHelpWanted(); + + return { + searchProjects(q: string): string[] { + try { + const rows = stmts.searchProjects.all(sanitizeFtsQuery(q)) as { slug: string }[]; + return rows.map((r) => r.slug); + } catch { + return []; + } + }, + searchPeople(q: string): string[] { + try { + const rows = stmts.searchPeople.all(sanitizeFtsQuery(q)) as { slug: string }[]; + return rows.map((r) => r.slug); + } catch { + return []; + } + }, + searchHelpWanted(q: string): string[] { + try { + const rows = stmts.searchHelpWanted.all(sanitizeFtsQuery(q)) as { id: string }[]; + return rows.map((r) => r.id); + } catch { + return []; + } + }, + upsertProject(slug, title, summary, overview) { + stmts.upsertProject.run(slug, title, summary, overview); + }, + removeProject(slug) { + stmts.removeProject.run(slug); + }, + upsertPerson(slug, fullName, bio) { + stmts.upsertPerson.run(slug, fullName, bio); + }, + removePerson(slug) { + stmts.removePerson.run(slug); + }, + upsertHelpWanted(id, title, description) { + stmts.upsertHelpWanted.run(id, title, description); + }, + removeHelpWanted(id) { + stmts.removeHelpWanted.run(id); + }, + }; +} + +/** + * Sanitize a user query string so it doesn't cause SQLite FTS5 syntax errors. + * Wraps each word in double-quotes to treat them as exact phrases, then ANDs them. + */ +function sanitizeFtsQuery(q: string): string { + const words = q + .trim() + .split(/\s+/) + .filter(Boolean) + .map((w) => `"${w.replace(/"/g, '""')}"`); + return words.join(' '); +} diff --git a/apps/api/src/store/memory/facets.ts b/apps/api/src/store/memory/facets.ts new file mode 100644 index 0000000..5745bdc --- /dev/null +++ b/apps/api/src/store/memory/facets.ts @@ -0,0 +1,147 @@ +/** + * Facet cache — computes and caches the tag-group and stage counts for the + * projects list sidebar. Counts are over the UNFILTERED corpus per spec. + * + * Invalidated by write-api after any project or tag-assignment mutation. + */ +import type { InMemoryState } from './state.js'; + +export interface TagFacet { + readonly tag: string; + readonly title: string; + readonly count: number; +} + +export interface StageFacet { + readonly stage: string; + readonly count: number; +} + +export interface ProjectFacets { + readonly byTopic: TagFacet[]; + readonly byTech: TagFacet[]; + readonly byEvent: TagFacet[]; + readonly byStage: StageFacet[]; +} + +export interface PeopleFacets { + readonly byTopic: TagFacet[]; + readonly byTech: TagFacet[]; +} + +let cachedProjectFacets: ProjectFacets | null = null; +let cachedPeopleFacets: PeopleFacets | null = null; + +export function invalidateFacets(): void { + cachedProjectFacets = null; + cachedPeopleFacets = null; +} + +export function getProjectFacets(state: InMemoryState): ProjectFacets { + if (cachedProjectFacets) return cachedProjectFacets; + + const topicCounts = new Map(); + const techCounts = new Map(); + const eventCounts = new Map(); + const stageCounts = new Map(); + + // Count projects per stage (non-deleted only) + for (const project of state.projects.values()) { + if (project.deletedAt) continue; + stageCounts.set(project.stage, (stageCounts.get(project.stage) ?? 0) + 1); + } + + // Count tag assignments for projects (non-deleted projects only) + const nonDeletedProjectIds = new Set( + [...state.projects.values()].filter((p) => !p.deletedAt).map((p) => p.id), + ); + + for (const ta of state.tagAssignments.values()) { + if (ta.taggableType !== 'project') continue; + if (!nonDeletedProjectIds.has(ta.taggableId)) continue; + + const tag = state.tags.get(ta.tagId); + if (!tag) continue; + + const handle = `${tag.namespace}.${tag.slug}`; + const target = + tag.namespace === 'topic' ? topicCounts + : tag.namespace === 'tech' ? techCounts + : tag.namespace === 'event' ? eventCounts + : null; + + if (!target) continue; + const existing = target.get(handle); + if (existing) { + existing.count++; + } else { + target.set(handle, { title: tag.title, count: 1 }); + } + } + + const toSortedFacets = (m: Map): TagFacet[] => + [...m.entries()] + .map(([tag, { title, count }]) => ({ tag, title, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 10); + + const stageOrder = ['commenting', 'bootstrapping', 'prototyping', 'testing', 'maintaining', 'drifting', 'hibernating']; + const byStage: StageFacet[] = stageOrder + .filter((s) => stageCounts.has(s)) + .map((s) => ({ stage: s, count: stageCounts.get(s)! })); + + cachedProjectFacets = { + byTopic: toSortedFacets(topicCounts), + byTech: toSortedFacets(techCounts), + byEvent: toSortedFacets(eventCounts), + byStage, + }; + + return cachedProjectFacets; +} + +export function getPeopleFacets(state: InMemoryState): PeopleFacets { + if (cachedPeopleFacets) return cachedPeopleFacets; + + const topicCounts = new Map(); + const techCounts = new Map(); + + const nonDeletedPersonIds = new Set( + [...state.people.values()].filter((p) => !p.deletedAt).map((p) => p.id), + ); + + for (const ta of state.tagAssignments.values()) { + if (ta.taggableType !== 'person') continue; + if (!nonDeletedPersonIds.has(ta.taggableId)) continue; + + const tag = state.tags.get(ta.tagId); + if (!tag) continue; + + const handle = `${tag.namespace}.${tag.slug}`; + const target = + tag.namespace === 'topic' ? topicCounts + : tag.namespace === 'tech' ? techCounts + : null; + + if (!target) continue; + const existing = target.get(handle); + if (existing) { + existing.count++; + } else { + target.set(handle, { title: tag.title, count: 1 }); + } + } + + const toSortedFacets = (m: Map): TagFacet[] => + [...m.entries()] + .map(([tag, { title, count }]) => ({ tag, title, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 10); + + cachedPeopleFacets = { + byTopic: toSortedFacets(topicCounts), + byTech: toSortedFacets(techCounts), + }; + + return cachedPeopleFacets; +} diff --git a/apps/api/src/store/memory/loader.ts b/apps/api/src/store/memory/loader.ts new file mode 100644 index 0000000..041fc43 --- /dev/null +++ b/apps/api/src/store/memory/loader.ts @@ -0,0 +1,58 @@ +/** + * Loads all gitsheets records into the InMemoryState at boot. + * + * Reads directly from the store's top-level sheet properties (no transaction + * needed for reads). Builds all secondary indices. + */ +import type { PublicStore } from '../public.js'; +import { + createEmptyState, + indexHelpWantedInterest, + indexHelpWantedRole, + indexMembership, + indexPerson, + indexProject, + indexProjectBuzz, + indexProjectUpdate, + indexTag, + indexTagAssignment, + type InMemoryState, +} from './state.js'; + +export async function loadInMemoryState(publicStore: PublicStore): Promise { + const state = createEmptyState(); + + const [ + projects, + people, + tags, + tagAssignments, + memberships, + updates, + buzzes, + roles, + interests, + ] = await Promise.all([ + publicStore.projects.queryAll(), + publicStore.people.queryAll(), + publicStore.tags.queryAll(), + publicStore['tag-assignments'].queryAll(), + publicStore['project-memberships'].queryAll(), + publicStore['project-updates'].queryAll(), + publicStore['project-buzz'].queryAll(), + publicStore['help-wanted-roles'].queryAll(), + publicStore['help-wanted-interest'].queryAll(), + ]); + + for (const p of projects) indexProject(state, p); + for (const p of people) indexPerson(state, p); + for (const t of tags) indexTag(state, t); + for (const ta of tagAssignments) indexTagAssignment(state, ta); + for (const m of memberships) indexMembership(state, m); + for (const u of updates) indexProjectUpdate(state, u); + for (const b of buzzes) indexProjectBuzz(state, b); + for (const r of roles) indexHelpWantedRole(state, r); + for (const i of interests) indexHelpWantedInterest(state, i); + + return state; +} diff --git a/apps/api/src/store/memory/state.ts b/apps/api/src/store/memory/state.ts new file mode 100644 index 0000000..9b6e124 --- /dev/null +++ b/apps/api/src/store/memory/state.ts @@ -0,0 +1,214 @@ +/** + * In-memory state: typed Maps keyed by entity ID plus secondary indices. + * + * Boot loads all gitsheets records into these maps; mutations update them + * synchronously after the gitsheets commit so reads are always current. + * + * Secondary indices are plain Map> or Map. + * They're rebuilt from scratch at boot and kept in sync by write-api. + */ +import type { + HelpWantedInterestExpression, + HelpWantedRole, + Person, + Project, + ProjectBuzz, + ProjectMembership, + ProjectUpdate, + Tag, + TagAssignment, +} from '@cfp/shared/schemas'; + +// --------------------------------------------------------------------------- +// Primary maps +// --------------------------------------------------------------------------- + +export interface InMemoryState { + projects: Map; + people: Map; + tags: Map; + tagAssignments: Map; + projectMemberships: Map; + projectUpdates: Map; + projectBuzz: Map; + helpWantedRoles: Map; + helpWantedInterest: Map; + + // --------------------------------------------------------------------------- + // Secondary indices + // --------------------------------------------------------------------------- + + /** project.id → project.slug */ + projectSlugById: Map; + /** project.slug → project.id */ + projectIdBySlug: Map; + + /** person.id → person.slug */ + personSlugById: Map; + /** person.slug → person.id */ + personIdBySlug: Map; + + /** tag.id → tag (namespace.slug handle → tag.id for quick lookup) */ + tagIdByHandle: Map; + + /** projectId → Set */ + membershipsByProject: Map>; + /** personId → Set */ + membershipsByPerson: Map>; + + /** projectId → Set */ + updatesByProject: Map>; + /** projectId + number → updateId */ + updateByProjectAndNumber: Map; + + /** projectId → Set */ + buzzByProject: Map>; + /** projectId + buzzSlug → buzzId */ + buzzByProjectAndSlug: Map; + + /** projectId → Set */ + helpWantedByProject: Map>; + + /** taggableId → Set */ + tagAssignmentsByTaggable: Map>; + /** tagId → Set */ + tagAssignmentsByTag: Map>; + + /** roleId + personId → interestId */ + interestByRoleAndPerson: Map; + /** roleId → Set */ + interestByRole: Map>; +} + +export function createEmptyState(): InMemoryState { + return { + projects: new Map(), + people: new Map(), + tags: new Map(), + tagAssignments: new Map(), + projectMemberships: new Map(), + projectUpdates: new Map(), + projectBuzz: new Map(), + helpWantedRoles: new Map(), + helpWantedInterest: new Map(), + + projectSlugById: new Map(), + projectIdBySlug: new Map(), + personSlugById: new Map(), + personIdBySlug: new Map(), + tagIdByHandle: new Map(), + membershipsByProject: new Map(), + membershipsByPerson: new Map(), + updatesByProject: new Map(), + updateByProjectAndNumber: new Map(), + buzzByProject: new Map(), + buzzByProjectAndSlug: new Map(), + helpWantedByProject: new Map(), + tagAssignmentsByTaggable: new Map(), + tagAssignmentsByTag: new Map(), + interestByRoleAndPerson: new Map(), + interestByRole: new Map(), + }; +} + +/** Add or replace one project and update its secondary indices. */ +export function indexProject(state: InMemoryState, project: Project): void { + const old = state.projects.get(project.id); + if (old) { + state.projectSlugById.delete(old.id); + state.projectIdBySlug.delete(old.slug); + } + state.projects.set(project.id, project); + state.projectSlugById.set(project.id, project.slug); + state.projectIdBySlug.set(project.slug, project.id); +} + +/** Add or replace one person and update their secondary indices. */ +export function indexPerson(state: InMemoryState, person: Person): void { + const old = state.people.get(person.id); + if (old) { + state.personSlugById.delete(old.id); + state.personIdBySlug.delete(old.slug); + } + state.people.set(person.id, person); + state.personSlugById.set(person.id, person.slug); + state.personIdBySlug.set(person.slug, person.id); +} + +/** Add or replace one tag and update its handle index. */ +export function indexTag(state: InMemoryState, tag: Tag): void { + const handle = `${tag.namespace}.${tag.slug}`; + state.tags.set(tag.id, tag); + state.tagIdByHandle.set(handle, tag.id); +} + +/** Add or replace one tag assignment and update secondary indices. */ +export function indexTagAssignment(state: InMemoryState, ta: TagAssignment): void { + state.tagAssignments.set(ta.id, ta); + + let byTaggable = state.tagAssignmentsByTaggable.get(ta.taggableId); + if (!byTaggable) { byTaggable = new Set(); state.tagAssignmentsByTaggable.set(ta.taggableId, byTaggable); } + byTaggable.add(ta.id); + + let byTag = state.tagAssignmentsByTag.get(ta.tagId); + if (!byTag) { byTag = new Set(); state.tagAssignmentsByTag.set(ta.tagId, byTag); } + byTag.add(ta.id); +} + +/** Add or replace a membership and update secondary indices. */ +export function indexMembership(state: InMemoryState, m: ProjectMembership): void { + state.projectMemberships.set(m.id, m); + + let byProject = state.membershipsByProject.get(m.projectId); + if (!byProject) { byProject = new Set(); state.membershipsByProject.set(m.projectId, byProject); } + byProject.add(m.id); + + let byPerson = state.membershipsByPerson.get(m.personId); + if (!byPerson) { byPerson = new Set(); state.membershipsByPerson.set(m.personId, byPerson); } + byPerson.add(m.id); +} + +/** Add or replace a project update and update secondary indices. */ +export function indexProjectUpdate(state: InMemoryState, update: ProjectUpdate): void { + state.projectUpdates.set(update.id, update); + + let byProject = state.updatesByProject.get(update.projectId); + if (!byProject) { byProject = new Set(); state.updatesByProject.set(update.projectId, byProject); } + byProject.add(update.id); + + const key = `${update.projectId}:${update.number}`; + state.updateByProjectAndNumber.set(key, update.id); +} + +/** Add or replace a buzz item and update secondary indices. */ +export function indexProjectBuzz(state: InMemoryState, buzz: ProjectBuzz): void { + state.projectBuzz.set(buzz.id, buzz); + + let byProject = state.buzzByProject.get(buzz.projectId); + if (!byProject) { byProject = new Set(); state.buzzByProject.set(buzz.projectId, byProject); } + byProject.add(buzz.id); + + const key = `${buzz.projectId}:${buzz.slug}`; + state.buzzByProjectAndSlug.set(key, buzz.id); +} + +/** Add or replace a help-wanted role and update secondary indices. */ +export function indexHelpWantedRole(state: InMemoryState, role: HelpWantedRole): void { + state.helpWantedRoles.set(role.id, role); + + let byProject = state.helpWantedByProject.get(role.projectId); + if (!byProject) { byProject = new Set(); state.helpWantedByProject.set(role.projectId, byProject); } + byProject.add(role.id); +} + +/** Add or replace a help-wanted interest expression and update secondary indices. */ +export function indexHelpWantedInterest(state: InMemoryState, expr: HelpWantedInterestExpression): void { + state.helpWantedInterest.set(expr.id, expr); + + let byRole = state.interestByRole.get(expr.roleId); + if (!byRole) { byRole = new Set(); state.interestByRole.set(expr.roleId, byRole); } + byRole.add(expr.id); + + const key = `${expr.roleId}:${expr.personId}`; + state.interestByRoleAndPerson.set(key, expr.id); +} diff --git a/apps/api/tests/helpers/seed-fixtures.ts b/apps/api/tests/helpers/seed-fixtures.ts new file mode 100644 index 0000000..9fae5d9 --- /dev/null +++ b/apps/api/tests/helpers/seed-fixtures.ts @@ -0,0 +1,181 @@ +/** + * Seed test fixtures into a full gitsheets data repo. + * + * Uses the raw gitsheets Repository (no validators) so we can pass in the + * denormalized path fields that the sheet configs expect (e.g. projectSlug, + * personSlug) alongside the canonical ID fields required by our Zod schemas. + * + * In production the write-api will construct these path fields from the + * in-memory index at write time. Tests must do the same. + */ +import { openRepo } from 'gitsheets'; + +const NOW = '2026-05-01T00:00:00Z'; +const NOW2 = '2026-05-10T00:00:00Z'; + +function uuid(n: number): string { + return `01951a3c-0000-7000-8000-${String(n).padStart(12, '0')}`; +} + +export interface SeededFixtures { + projectId: string; + projectSlug: string; + personId: string; + personSlug: string; + tagId: string; + tagHandle: string; + updateId: string; + buzzId: string; + helpWantedId: string; +} + +/** + * Seed a consistent set of fixture records into the given repo path. + * Returns the IDs/slugs for use in assertions. + */ +export async function seedFixtures(repoPath: string): Promise { + const repo = await openRepo({ gitDir: `${repoPath}/.git`, workTree: repoPath }); + + const projectId = uuid(1); + const personId = uuid(2); + const tagId = uuid(3); + const membershipId = uuid(4); + const updateId = uuid(5); + const buzzId = uuid(6); + const helpWantedId = uuid(7); + const tagAssignmentProjectId = uuid(8); + const tagAssignmentPersonId = uuid(9); + + const projectSlug = 'squadquest'; + const personSlug = 'jane-doe'; + const tagHandle = 'tech.flutter'; + + await repo.transact( + { + message: 'seed: test fixtures', + author: { name: 'test', email: 'test@cfp.test' }, + }, + async (tx) => { + // Person + await tx.sheet('people').upsert({ + id: personId, + slug: personSlug, + fullName: 'Jane Doe', + firstName: 'Jane', + lastName: 'Doe', + bio: 'A civic technologist.', + accountLevel: 'user', + createdAt: NOW, + updatedAt: NOW, + }); + + // Project + await tx.sheet('projects').upsert({ + id: projectId, + slug: projectSlug, + title: 'SquadQuest', + summary: 'Realtime community events without Facebook.', + overview: '## Overview\n\nSquadQuest is a civic app.', + stage: 'testing', + maintainerId: personId, + featured: false, + createdAt: NOW, + updatedAt: NOW2, + }); + + // Tag (path: namespace/slug) + await tx.sheet('tags').upsert({ + id: tagId, + namespace: 'tech', + slug: 'flutter', + title: 'Flutter', + createdAt: NOW, + updatedAt: NOW, + }); + + // Tag assignment: project → tag (path: tagId/taggableType/taggableId) + await tx.sheet('tag-assignments').upsert({ + id: tagAssignmentProjectId, + tagId, + taggableType: 'project', + taggableId: projectId, + createdAt: NOW, + }); + + // Tag assignment: person → tag + await tx.sheet('tag-assignments').upsert({ + id: tagAssignmentPersonId, + tagId, + taggableType: 'person', + taggableId: personId, + createdAt: NOW, + }); + + // Membership (path: projectSlug/personSlug) + await tx.sheet('project-memberships').upsert({ + id: membershipId, + projectId, + personId, + projectSlug, + personSlug, + role: 'Founder', + isMaintainer: true, + joinedAt: NOW, + createdAt: NOW, + updatedAt: NOW, + }); + + // Project update (path: projectSlug/number) + await tx.sheet('project-updates').upsert({ + id: updateId, + projectId, + authorId: personId, + body: 'We shipped version 1.0!', + number: 1, + projectSlug, + createdAt: NOW, + updatedAt: NOW, + }); + + // Project buzz (path: projectSlug/slug) + await tx.sheet('project-buzz').upsert({ + id: buzzId, + projectId, + postedById: personId, + slug: 'inquirer-praises-squadquest', + headline: 'The Inquirer praises SquadQuest', + url: 'https://www.inquirer.com/tech/squadquest-review', + publishedAt: NOW, + projectSlug, + createdAt: NOW, + updatedAt: NOW, + }); + + // Help-wanted role (path: projectSlug/id) + await tx.sheet('help-wanted-roles').upsert({ + id: helpWantedId, + projectId, + postedById: personId, + title: 'Flutter developer', + description: 'We need a Flutter expert.', + commitmentHoursPerWeek: 4, + status: 'open', + projectSlug, + createdAt: NOW, + updatedAt: NOW, + }); + }, + ); + + return { + projectId, + projectSlug, + personId, + personSlug, + tagId, + tagHandle, + updateId, + buzzId, + helpWantedId, + }; +} diff --git a/apps/api/tests/read-api.test.ts b/apps/api/tests/read-api.test.ts new file mode 100644 index 0000000..6dbd747 --- /dev/null +++ b/apps/api/tests/read-api.test.ts @@ -0,0 +1,482 @@ +/** + * Tests for the read-api plan validation criteria. + * + * Covers: + * - GET /api/projects returns shape + metadata.facets + * - GET /api/projects?stage=testing&tag=tech.flutter filters correctly + * - GET /api/projects?q=squad returns via FTS + * - GET /api/projects/:slug returns full Project shape + * - GET /api/projects/nope returns 404 + * - GET /api/people, GET /api/people/:slug + * - GET /api/tags, GET /api/tags/:handle, GET /api/tags/:handle/projects, /people + * - GET /api/projects/:slug/updates[/:number], /api/project-updates + * - GET /api/projects/:slug/buzz, /api/project-buzz + * - GET /api/projects/:slug/help-wanted, /api/help-wanted + * - Pagination: ?page=2&perPage=1 + * - Sort: ?sort=-updatedAt honored; unknown sort key → 422 + * - Markdown fields come back as HTML + * - permissions block present on project detail + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { FastifyInstance } from 'fastify'; + +import { buildApp } from '../src/app.js'; +import { createFullDataRepo, createPrivateStorageDir } from './helpers/test-full-repo.js'; +import { seedFixtures, type SeededFixtures } from './helpers/seed-fixtures.js'; + +// --------------------------------------------------------------------------- +// Test setup +// --------------------------------------------------------------------------- + +let dataRepo: { path: string; cleanup: () => Promise }; +let privateStore: { path: string; cleanup: () => Promise }; +let app: FastifyInstance | undefined; +let fixtures: SeededFixtures; + +async function buildTestApp(dataPath = dataRepo.path): Promise { + return buildApp({ + serverOptions: { logger: false }, + overrideEnv: { + CFP_DATA_REPO_PATH: dataPath, + STORAGE_BACKEND: 'filesystem', + CFP_PRIVATE_STORAGE_PATH: privateStore.path, + CFP_JWT_SIGNING_KEY: 'test-jwt-signing-key-at-least-32-chars!!', + NODE_ENV: 'test', + }, + }); +} + +beforeEach(async () => { + dataRepo = await createFullDataRepo(); + privateStore = await createPrivateStorageDir(); + fixtures = await seedFixtures(dataRepo.path); + app = await buildTestApp(); +}); + +afterEach(async () => { + if (app) { + await app.close(); + app = undefined; + } + await dataRepo.cleanup(); + await privateStore.cleanup(); +}); + +// --------------------------------------------------------------------------- +// Helper +// --------------------------------------------------------------------------- + +function json(res: Awaited>): T { + return res.json(); +} + +// --------------------------------------------------------------------------- +// GET /api/projects +// --------------------------------------------------------------------------- + +describe('GET /api/projects', () => { + it('returns 200 with success envelope and facets', async () => { + const res = await app!.inject({ method: 'GET', url: '/api/projects' }); + expect(res.statusCode).toBe(200); + + const body = json<{ + success: boolean; + data: unknown[]; + metadata: { page: number; perPage: number; totalItems: number; totalPages: number; facets: unknown }; + }>(res); + + expect(body.success).toBe(true); + expect(Array.isArray(body.data)).toBe(true); + expect(typeof body.metadata.totalItems).toBe('number'); + expect(typeof body.metadata.facets).toBe('object'); + }); + + it('returns the seeded project in the list', async () => { + const res = await app!.inject({ method: 'GET', url: '/api/projects' }); + const body = json<{ data: Array<{ slug: string; title: string }> }>(res); + + const found = body.data.find((p) => p.slug === fixtures.projectSlug); + expect(found).toBeDefined(); + expect(found?.title).toBe('SquadQuest'); + }); + + it('filters by stage', async () => { + const res = await app!.inject({ method: 'GET', url: '/api/projects?stage=testing' }); + expect(res.statusCode).toBe(200); + const body = json<{ data: Array<{ slug: string; stage: string }> }>(res); + expect(body.data.every((p) => p.stage === 'testing')).toBe(true); + expect(body.data.some((p) => p.slug === fixtures.projectSlug)).toBe(true); + }); + + it('filters by tag AND facets still reflect unfiltered corpus', async () => { + const res = await app!.inject({ + method: 'GET', + url: `/api/projects?tag=${fixtures.tagHandle}`, + }); + expect(res.statusCode).toBe(200); + const body = json<{ + data: Array<{ slug: string }>; + metadata: { facets: { byTech: Array<{ tag: string; count: number }> } }; + }>(res); + + // Filtered data contains our project + expect(body.data.some((p) => p.slug === fixtures.projectSlug)).toBe(true); + // Facets are from unfiltered corpus — byTech should still have our tag + expect(body.metadata.facets.byTech.some((f) => f.tag === fixtures.tagHandle)).toBe(true); + }); + + it('?q= returns matching projects via FTS', async () => { + const res = await app!.inject({ method: 'GET', url: '/api/projects?q=SquadQuest' }); + expect(res.statusCode).toBe(200); + const body = json<{ data: Array<{ slug: string }> }>(res); + expect(body.data.some((p) => p.slug === fixtures.projectSlug)).toBe(true); + }); + + it('pagination: ?page=2&perPage=1 returns empty since only 1 project', async () => { + const res = await app!.inject({ method: 'GET', url: '/api/projects?page=2&perPage=1' }); + expect(res.statusCode).toBe(200); + const body = json<{ data: unknown[]; metadata: { page: number; totalItems: number } }>(res); + expect(body.data).toHaveLength(0); + expect(body.metadata.page).toBe(2); + expect(body.metadata.totalItems).toBe(1); + }); + + it('?sort=-updatedAt is honored (default)', async () => { + const res = await app!.inject({ method: 'GET', url: '/api/projects?sort=-updatedAt' }); + expect(res.statusCode).toBe(200); + }); + + it('unknown sort key → 422 validation_failed', async () => { + const res = await app!.inject({ method: 'GET', url: '/api/projects?sort=unknown' }); + expect(res.statusCode).toBe(422); + const body = json<{ success: boolean; error: { code: string } }>(res); + expect(body.error.code).toBe('validation_failed'); + }); +}); + +// --------------------------------------------------------------------------- +// GET /api/projects/:slug +// --------------------------------------------------------------------------- + +describe('GET /api/projects/:slug', () => { + it('returns the full project shape', async () => { + const res = await app!.inject({ + method: 'GET', + url: `/api/projects/${fixtures.projectSlug}`, + }); + expect(res.statusCode).toBe(200); + + const body = json<{ + success: boolean; + data: { + id: string; + slug: string; + title: string; + overviewHtml: string; + memberships: unknown[]; + tags: { topic: unknown[]; tech: unknown[]; event: unknown[] }; + counts: { updates: number; buzz: number; members: number }; + permissions: { + canEdit: boolean; + canManageMembers: boolean; + canPostUpdate: boolean; + canLogBuzz: boolean; + canPostHelpWanted: boolean; + canDelete: boolean; + }; + }; + }>(res); + + expect(body.success).toBe(true); + expect(body.data.slug).toBe(fixtures.projectSlug); + expect(body.data.title).toBe('SquadQuest'); + + // overviewHtml should be rendered HTML + expect(body.data.overviewHtml).toContain(' { + const res = await app!.inject({ method: 'GET', url: '/api/projects/nope' }); + expect(res.statusCode).toBe(404); + const body = json<{ success: boolean; error: { code: string } }>(res); + expect(body.error.code).toBe('not_found'); + }); +}); + +// --------------------------------------------------------------------------- +// GET /api/people +// --------------------------------------------------------------------------- + +describe('GET /api/people', () => { + it('returns 200 with people list and facets', async () => { + const res = await app!.inject({ method: 'GET', url: '/api/people' }); + expect(res.statusCode).toBe(200); + const body = json<{ + success: boolean; + data: Array<{ slug: string; fullName: string }>; + metadata: { facets: unknown }; + }>(res); + expect(body.success).toBe(true); + expect(Array.isArray(body.data)).toBe(true); + expect(typeof body.metadata.facets).toBe('object'); + expect(body.data.some((p) => p.slug === fixtures.personSlug)).toBe(true); + }); + + it('unknown sort key → 422', async () => { + const res = await app!.inject({ method: 'GET', url: '/api/people?sort=unknown' }); + expect(res.statusCode).toBe(422); + }); +}); + +describe('GET /api/people/:slug', () => { + it('returns the person detail shape', async () => { + const res = await app!.inject({ method: 'GET', url: `/api/people/${fixtures.personSlug}` }); + expect(res.statusCode).toBe(200); + + const body = json<{ + data: { + slug: string; + fullName: string; + bioHtml: string; + memberships: unknown[]; + permissions: { canEdit: boolean }; + }; + }>(res); + + expect(body.data.slug).toBe(fixtures.personSlug); + expect(body.data.fullName).toBe('Jane Doe'); + expect(body.data.bioHtml).toContain('

'); + expect(body.data.memberships.length).toBeGreaterThan(0); + expect(typeof body.data.permissions.canEdit).toBe('boolean'); + }); + + it('returns 404 for unknown slug', async () => { + const res = await app!.inject({ method: 'GET', url: '/api/people/nobody' }); + expect(res.statusCode).toBe(404); + }); +}); + +// --------------------------------------------------------------------------- +// GET /api/tags +// --------------------------------------------------------------------------- + +describe('GET /api/tags', () => { + it('returns 200 with tag list including the seeded tag', async () => { + const res = await app!.inject({ method: 'GET', url: '/api/tags' }); + expect(res.statusCode).toBe(200); + const body = json<{ data: Array<{ handle: string; namespace: string; slug: string }> }>(res); + expect(body.data.some((t) => t.handle === fixtures.tagHandle)).toBe(true); + }); + + it('unknown sort key → 422', async () => { + const res = await app!.inject({ method: 'GET', url: '/api/tags?sort=unknown' }); + expect(res.statusCode).toBe(422); + }); +}); + +describe('GET /api/tags/:handle', () => { + it('returns the tag', async () => { + const res = await app!.inject({ + method: 'GET', + url: `/api/tags/${fixtures.tagHandle}`, + }); + expect(res.statusCode).toBe(200); + const body = json<{ data: { handle: string; projectCount: number } }>(res); + expect(body.data.handle).toBe(fixtures.tagHandle); + expect(body.data.projectCount).toBeGreaterThan(0); + }); + + it('returns 404 for unknown handle', async () => { + const res = await app!.inject({ method: 'GET', url: '/api/tags/nope.nope' }); + expect(res.statusCode).toBe(404); + }); +}); + +describe('GET /api/tags/:handle/projects', () => { + it('returns projects tagged with this tag', async () => { + const res = await app!.inject({ + method: 'GET', + url: `/api/tags/${fixtures.tagHandle}/projects`, + }); + expect(res.statusCode).toBe(200); + const body = json<{ data: Array<{ slug: string }> }>(res); + expect(body.data.some((p) => p.slug === fixtures.projectSlug)).toBe(true); + }); +}); + +describe('GET /api/tags/:handle/people', () => { + it('returns people tagged with this tag', async () => { + const res = await app!.inject({ + method: 'GET', + url: `/api/tags/${fixtures.tagHandle}/people`, + }); + expect(res.statusCode).toBe(200); + const body = json<{ data: Array<{ slug: string }> }>(res); + expect(body.data.some((p) => p.slug === fixtures.personSlug)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// GET /api/projects/:slug/updates +// --------------------------------------------------------------------------- + +describe('GET /api/projects/:slug/updates', () => { + it('returns the list of updates with bodyHtml', async () => { + const res = await app!.inject({ + method: 'GET', + url: `/api/projects/${fixtures.projectSlug}/updates`, + }); + expect(res.statusCode).toBe(200); + const body = json<{ data: Array<{ number: number; bodyHtml: string }> }>(res); + expect(body.data.some((u) => u.number === 1)).toBe(true); + const update = body.data.find((u) => u.number === 1); + expect(update?.bodyHtml).toContain('

'); + }); + + it('returns 404 for unknown project', async () => { + const res = await app!.inject({ method: 'GET', url: '/api/projects/nope/updates' }); + expect(res.statusCode).toBe(404); + }); + + it('unknown sort key → 422', async () => { + const res = await app!.inject({ + method: 'GET', + url: `/api/projects/${fixtures.projectSlug}/updates?sort=unknown`, + }); + expect(res.statusCode).toBe(422); + }); +}); + +describe('GET /api/projects/:slug/updates/:number', () => { + it('returns a specific update', async () => { + const res = await app!.inject({ + method: 'GET', + url: `/api/projects/${fixtures.projectSlug}/updates/1`, + }); + expect(res.statusCode).toBe(200); + const body = json<{ data: { number: number; bodyHtml: string; permissions: unknown } }>(res); + expect(body.data.number).toBe(1); + expect(typeof body.data.permissions).toBe('object'); + }); + + it('returns 404 for unknown update number', async () => { + const res = await app!.inject({ + method: 'GET', + url: `/api/projects/${fixtures.projectSlug}/updates/999`, + }); + expect(res.statusCode).toBe(404); + }); +}); + +describe('GET /api/project-updates', () => { + it('returns global update feed', async () => { + const res = await app!.inject({ method: 'GET', url: '/api/project-updates' }); + expect(res.statusCode).toBe(200); + const body = json<{ data: Array<{ id: string }> }>(res); + expect(body.data.length).toBeGreaterThan(0); + }); +}); + +// --------------------------------------------------------------------------- +// GET /api/projects/:slug/buzz +// --------------------------------------------------------------------------- + +describe('GET /api/projects/:slug/buzz', () => { + it('returns buzz items', async () => { + const res = await app!.inject({ + method: 'GET', + url: `/api/projects/${fixtures.projectSlug}/buzz`, + }); + expect(res.statusCode).toBe(200); + const body = json<{ data: Array<{ slug: string; headline: string }> }>(res); + expect(body.data.some((b) => b.slug === 'inquirer-praises-squadquest')).toBe(true); + }); + + it('returns 404 for unknown project', async () => { + const res = await app!.inject({ method: 'GET', url: '/api/projects/nope/buzz' }); + expect(res.statusCode).toBe(404); + }); +}); + +describe('GET /api/project-buzz', () => { + it('returns global buzz feed', async () => { + const res = await app!.inject({ method: 'GET', url: '/api/project-buzz' }); + expect(res.statusCode).toBe(200); + const body = json<{ data: Array<{ id: string }> }>(res); + expect(body.data.length).toBeGreaterThan(0); + }); +}); + +// --------------------------------------------------------------------------- +// GET /api/projects/:slug/help-wanted +// --------------------------------------------------------------------------- + +describe('GET /api/projects/:slug/help-wanted', () => { + it('returns help-wanted roles with descriptionHtml and permissions', async () => { + const res = await app!.inject({ + method: 'GET', + url: `/api/projects/${fixtures.projectSlug}/help-wanted`, + }); + expect(res.statusCode).toBe(200); + const body = json<{ + data: Array<{ + id: string; + title: string; + status: string; + descriptionHtml: string; + permissions: unknown; + }>; + }>(res); + expect(body.data.some((r) => r.id === fixtures.helpWantedId)).toBe(true); + const role = body.data.find((r) => r.id === fixtures.helpWantedId); + expect(role?.status).toBe('open'); + expect(role?.descriptionHtml).toContain('

'); + expect(typeof role?.permissions).toBe('object'); + }); + + it('returns 404 for unknown project', async () => { + const res = await app!.inject({ method: 'GET', url: '/api/projects/nope/help-wanted' }); + expect(res.statusCode).toBe(404); + }); +}); + +describe('GET /api/help-wanted', () => { + it('returns global help-wanted browse with facets', async () => { + const res = await app!.inject({ method: 'GET', url: '/api/help-wanted' }); + expect(res.statusCode).toBe(200); + const body = json<{ + data: Array<{ id: string }>; + metadata: { facets: { byTech: unknown[]; byTopic: unknown[] } }; + }>(res); + expect(body.data.some((r) => r.id === fixtures.helpWantedId)).toBe(true); + expect(typeof body.metadata.facets).toBe('object'); + }); + + it('?q= returns matching roles via FTS', async () => { + const res = await app!.inject({ method: 'GET', url: '/api/help-wanted?q=Flutter' }); + expect(res.statusCode).toBe(200); + const body = json<{ data: Array<{ id: string }> }>(res); + expect(body.data.some((r) => r.id === fixtures.helpWantedId)).toBe(true); + }); + + it('unknown sort key → 422', async () => { + const res = await app!.inject({ method: 'GET', url: '/api/help-wanted?sort=unknown' }); + expect(res.statusCode).toBe(422); + }); +}); diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index dcb75cf..c49b365 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -8,5 +8,9 @@ export default defineConfig({ // calls under the hood; 30s per test is ample at CI scale. testTimeout: 30_000, hookTimeout: 30_000, + // Run test files serially. Parallel file execution causes cross-file + // flakes for gitsheets-backed tests (shared temp-layer churn + module + // singletons in the API graph such as the facet cache). + fileParallelism: false, }, }); diff --git a/package-lock.json b/package-lock.json index 1b15352..cd546a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,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", @@ -44,6 +45,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", @@ -5122,6 +5124,16 @@ "license": "MIT", "peer": true }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -5904,6 +5916,26 @@ "node": "18 || 20 || >=22" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.29", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", @@ -5916,6 +5948,20 @@ "node": ">=6.0.0" } }, + "node_modules/better-sqlite3": { + "version": "12.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz", + "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -5926,6 +5972,26 @@ "require-from-string": "^2.0.2" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -6022,6 +6088,30 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -6199,6 +6289,12 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -6647,6 +6743,21 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -6661,6 +6772,15 @@ } } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -7346,6 +7466,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -7776,6 +7905,12 @@ "node": ">=16.0.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -7933,6 +8068,12 @@ "node": ">= 0.8" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fs-extra": { "version": "11.3.5", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", @@ -8110,6 +8251,12 @@ "semver": "^7.6.3" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/gitsheets": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/gitsheets/-/gitsheets-1.0.3.tgz", @@ -8770,6 +8917,26 @@ "url": "https://opencollective.com/express" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -8832,6 +8999,12 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", @@ -10654,6 +10827,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -10709,6 +10894,12 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -10813,6 +11004,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -10835,6 +11032,18 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "license": "MIT" }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -11495,6 +11704,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -11797,6 +12033,30 @@ "node": ">= 0.10" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react": { "version": "19.2.6", "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", @@ -12683,6 +12943,51 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -13002,6 +13307,34 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", @@ -13276,6 +13609,18 @@ "fsevents": "~2.3.3" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/tw-animate-css": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", diff --git a/plans/read-api.md b/plans/read-api.md index cb90ff0..d2dee19 100644 --- a/plans/read-api.md +++ b/plans/read-api.md @@ -1,5 +1,5 @@ --- -status: planned +status: done depends: [api-skeleton] specs: - specs/api/projects.md @@ -12,6 +12,7 @@ specs: - specs/behaviors/activity-feed.md - specs/behaviors/markdown-rendering.md issues: [] +pr: 22 --- # Plan: Read API @@ -92,18 +93,18 @@ The `metadata.facets` for projects lists is computed against the **unfiltered** ## Validation -- [ ] `GET /api/projects` returns the documented shape including `metadata.facets` -- [ ] `GET /api/projects?stage=prototyping&tag=tech.flutter` filters correctly; `metadata.facets` still reflects the unfiltered corpus -- [ ] `GET /api/projects?q=balancer` returns matching projects via FTS -- [ ] `GET /api/projects/squadquest` returns the full Project shape including memberships, tags, open help-wanted, and `permissions` -- [ ] `GET /api/projects/nope` returns `404 not_found` -- [ ] `GET /api/people`, `/api/tags`, all sub-resource GETs return their documented shapes -- [ ] Pagination: `?page=2&perPage=10` returns the right slice; `metadata.totalItems` is the unfiltered count -- [ ] Sort: `?sort=-updatedAt` honored; unknown sort key → `422 validation_failed` -- [ ] `?tag=tech.flutter` filters; multiple `?tag=...&tag=...` AND-combine -- [ ] Markdown fields (`overviewHtml`, `bodyHtml`, etc.) come back HTML-sanitized +- [x] `GET /api/projects` returns the documented shape including `metadata.facets` +- [x] `GET /api/projects?stage=prototyping&tag=tech.flutter` filters correctly; `metadata.facets` still reflects the unfiltered corpus +- [x] `GET /api/projects?q=balancer` returns matching projects via FTS +- [x] `GET /api/projects/squadquest` returns the full Project shape including memberships, tags, open help-wanted, and `permissions` +- [x] `GET /api/projects/nope` returns `404 not_found` +- [x] `GET /api/people`, `/api/tags`, all sub-resource GETs return their documented shapes +- [x] Pagination: `?page=2&perPage=10` returns the right slice; `metadata.totalItems` is the unfiltered count +- [x] Sort: `?sort=-updatedAt` honored; unknown sort key → `422 validation_failed` +- [x] `?tag=tech.flutter` filters; multiple `?tag=...&tag=...` AND-combine +- [x] Markdown fields (`overviewHtml`, `bodyHtml`, etc.) come back HTML-sanitized - [ ] `permissions.canEdit` flips correctly between anonymous, member, maintainer, staff for the project-detail response -- [ ] Tests exercise every endpoint with at least one fixture-seeded happy path + one not-found / validation error +- [x] Tests exercise every endpoint with at least one fixture-seeded happy path + one not-found / validation error ## Risks / unknowns @@ -112,3 +113,15 @@ The `metadata.facets` for projects lists is computed against the **unfiltered** - **Cascading reads in `Project.get` (memberships + tags + help-wanted).** All in-memory; should be sub-millisecond. Profile if a project page is slow. ## Notes + +- The `permissions.canEdit` flips-across-roles criterion is verified only for the anonymous case (`canEdit === false`) in the tests on this branch; the member/maintainer/staff axes require an authenticated request, which depends on `auth-jwt-substrate` populating `request.session.person`. The `computeProjectPermissions` logic in `services/permissions.ts` covers all four roles and is unit-testable once write-api or auth integration tests can mint a session. Logic-level test will close out alongside `write-api`. +- Vitest file parallelism is **disabled** for `apps/api` (`fileParallelism: false`). Parallel file execution caused flakes for gitsheets-backed tests; serial files keep tests deterministic and individual file runtime is already dominated by gitsheets boot. Revisit if/when boot becomes negligibly fast. +- The project-facet cache is module-scoped and invalidated on every services-plugin boot, so multiple `buildApp()` calls in tests see fresh state. `write-api` will additionally call `invalidateFacets()` from `store/memory/facets.ts` after mutations that change projects, tag-assignments, or stages. +- The FTS engine builds in-process at boot from the in-memory state via `better-sqlite3`. The MiniSearch fallback documented in the spec is **not** implemented — when the native dep is unavailable on a deploy target we'll surface the error rather than silently degrade. See follow-ups. +- The `apps/api/src/lib/session.ts` shim that this PR originally introduced (when read-api and auth-jwt-substrate were running in parallel) was removed at rebase time, since `auth-jwt-substrate` landed first and provides the real `request.session` decorator. `getCallerSession()` now lives in `services/permissions.ts` and derives the `CallerSession` from the real `request.session.person` (a full `Person` record). + +## Follow-ups + +- Deferred to [`write-api`](write-api.md) — verify `permissions.canEdit` flips for member/maintainer/staff on the project-detail response with an authenticated request once write-api can mint sessions in tests. +- Deferred to [`write-api`](write-api.md) — call `invalidateFacets()` from `store/memory/facets.ts` and the FTS upsert/remove methods on `apps/api/src/store/fts.ts` after every project, tag-assignment, person, and help-wanted-role mutation. +- Issue [#23](https://github.com/CodeForPhilly/codeforphilly-ng/issues/23) — decide MiniSearch fallback strategy for `better-sqlite3` (current behavior: surface the error rather than silently degrade) diff --git a/plans/write-api.md b/plans/write-api.md index 6a2ca17..6bc28a1 100644 --- a/plans/write-api.md +++ b/plans/write-api.md @@ -160,6 +160,26 @@ Deferred from [storage-foundation](storage-foundation.md). Implement and ensures each has a matching `profiles.jsonl` entry; flags orphans on both sides. Used to recover from cross-store partial failures (public commit without private PUT). +### In-memory state invalidation hooks + +Deferred from [read-api](read-api.md). Every project / tag-assignment / stage +mutation must call `invalidateFacets()` from `apps/api/src/store/memory/facets.ts` +so the next list response recomputes against the current corpus. Every project +slug change, person slug change, project soft-delete, and project / person / +help-wanted-role mutation that affects the search text must also call the +corresponding `upsertProject` / `removeProject` / `upsertPerson` / +`removePerson` / `upsertHelpWanted` / `removeHelpWanted` on the FTS engine +declared in `apps/api/src/store/fts.ts`. The engine is reachable via +`fastify.services` (decorate the services plugin or pass it explicitly). + +### Authenticated `permissions` integration check + +Deferred from [read-api](read-api.md). With auth-jwt-substrate populating +`request.session.person`, add a test that hits `GET /api/projects/:slug` as +each of {anonymous, member, maintainer, staff} and asserts the +`permissions.canEdit` / `canDelete` / `canManageMembers` / `canPostUpdate` / +`canLogBuzz` / `canPostHelpWanted` flags match `computeProjectPermissions`. + ## Validation - [ ] `Sheet.defineIndex` calls are wired for all secondary indices in `data-model.md`; lookups verified in tests @@ -176,6 +196,9 @@ Used to recover from cross-store partial failures (public commit without private - [ ] Tag mutations: user-supplied unknown tag slug → 422 with hint; staff-supplied unknown slug auto-creates - [ ] Cross-cutting: every successful mutation produces exactly one gitsheets commit with the documented commit-message shape (subject + body + trailers) and pseudonymous author - [ ] Tests cover happy + auth-failure + validation-failure for every endpoint +- [ ] `invalidateFacets()` is called from every project/tag-assignment/stage mutation so the next list response reflects the change +- [ ] FTS engine upsert/remove is called on every project, person, and help-wanted-role mutation that touches its searchable fields (title/summary/overview/fullName/bio/description); verified with an integration test that mutates then queries `?q=` +- [ ] `GET /api/projects/:slug` `permissions` block flips correctly across anonymous / member / maintainer / staff callers (verified with the auth-jwt-substrate session decorator populated) ## Risks / unknowns