From bf26081b8390828a640a7612a6facf847b845fcc Mon Sep 17 00:00:00 2001 From: kuyacarlo <106532351+kuyacarlo@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:23:33 +0800 Subject: [PATCH] feat(api): wire Nest API to @worksight/common types and fixtures (#17) Serve users/teams/tasks/activities endpoints from the shared @worksight/common contract instead of placeholder responses, so web and API agree on payload shapes. - users module returns EmployeeProfile/Team fixtures; new tasks module returns Assignment/Activity fixtures, all typed from common - fix api build emitting nothing (inherited noEmit) and switch to CJS emit resolved against the built common dist; add tsconfig.build.json - fix common dist being unloadable ESM (directory imports) via tsc-alias --resolve-full-paths in the build script - fix BaseLookup.filter() constructing subclasses with the wrong argument order, which broke every chained lookup/getById - fix department stats matching against array-valued departments - add jest specs for both services; add node/jest globals to eslint Co-authored-by: Cursor --- apps/api/src/app.module.ts | 12 ++-- apps/api/src/tasks/tasks.controller.ts | 37 +++++++++++++ apps/api/src/tasks/tasks.module.ts | 9 +++ apps/api/src/tasks/tasks.service.spec.ts | 40 ++++++++++++++ apps/api/src/tasks/tasks.service.ts | 30 ++++++++++ apps/api/src/users/users.controller.ts | 46 +++++++++++++--- apps/api/src/users/users.module.ts | 6 +- apps/api/src/users/users.service.spec.ts | 36 ++++++++++++ apps/api/src/users/users.service.ts | 26 ++++++++- apps/api/tsconfig.build.json | 4 ++ apps/api/tsconfig.json | 11 ++-- docs/handoffs/2026-07-25-wire-api-common.md | 61 +++++++++++++++++---- eslint.config.ts | 12 ++++ packages/common/package.json | 2 +- packages/common/src/utils/base.ts | 48 ++++++++++------ packages/common/src/utils/employees.ts | 13 +++-- 16 files changed, 336 insertions(+), 57 deletions(-) create mode 100644 apps/api/src/tasks/tasks.controller.ts create mode 100644 apps/api/src/tasks/tasks.module.ts create mode 100644 apps/api/src/tasks/tasks.service.spec.ts create mode 100644 apps/api/src/tasks/tasks.service.ts create mode 100644 apps/api/src/users/users.service.spec.ts create mode 100644 apps/api/tsconfig.build.json diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 693773b..8dcbeb7 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,12 +1,12 @@ import { Module } from '@nestjs/common'; -import { AppController } from './app.controller.js'; -import { AppService } from './app.service.js'; -// import { AttendanceModule } from './attendance/attendance.module'; -import { UsersModule } from './users/users.module.js'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; +import { TasksModule } from './tasks/tasks.module'; +import { UsersModule } from './users/users.module'; @Module({ - imports: [UsersModule], + imports: [UsersModule, TasksModule], controllers: [AppController], providers: [AppService], }) -export class AppModule { } +export class AppModule {} diff --git a/apps/api/src/tasks/tasks.controller.ts b/apps/api/src/tasks/tasks.controller.ts new file mode 100644 index 0000000..124314f --- /dev/null +++ b/apps/api/src/tasks/tasks.controller.ts @@ -0,0 +1,37 @@ +import { Controller, Get, NotFoundException, Param, Query } from '@nestjs/common'; +import type { Activity, Assignment } from '@worksight/common'; +import { TasksService } from './tasks.service'; + +@Controller('tasks') +export class TasksController { + constructor(private readonly tasksService: TasksService) {} + + @Get() + getAll(@Query('employee_id') employeeId?: string): Assignment[] { + return this.tasksService.findAll(employeeId); + } + + @Get('stats/:employeeId') + getStats(@Param('employeeId') employeeId: string) { + return this.tasksService.getStatsForEmployee(employeeId); + } + + @Get(':id') + getOne(@Param('id') id: string): Assignment { + const assignment = this.tasksService.findById(id); + if (!assignment) { + throw new NotFoundException(`Task ${id} not found`); + } + return assignment; + } +} + +@Controller('activities') +export class ActivitiesController { + constructor(private readonly tasksService: TasksService) {} + + @Get() + getAll(@Query('employee_id') employeeId?: string): Activity[] { + return this.tasksService.findAllActivities(employeeId); + } +} diff --git a/apps/api/src/tasks/tasks.module.ts b/apps/api/src/tasks/tasks.module.ts new file mode 100644 index 0000000..4a986cc --- /dev/null +++ b/apps/api/src/tasks/tasks.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { ActivitiesController, TasksController } from './tasks.controller'; +import { TasksService } from './tasks.service'; + +@Module({ + controllers: [TasksController, ActivitiesController], + providers: [TasksService], +}) +export class TasksModule {} diff --git a/apps/api/src/tasks/tasks.service.spec.ts b/apps/api/src/tasks/tasks.service.spec.ts new file mode 100644 index 0000000..456027a --- /dev/null +++ b/apps/api/src/tasks/tasks.service.spec.ts @@ -0,0 +1,40 @@ +import { Activities, Assignments } from '@worksight/common'; +import { TasksService } from './tasks.service'; + +describe('TasksService', () => { + let service: TasksService; + + beforeEach(() => { + service = new TasksService(); + }); + + it('returns the shared assignment fixtures', () => { + expect(service.findAll()).toEqual(Assignments); + }); + + it('filters assignments by employee', () => { + const employeeId = Assignments[0].employee_id; + const expected = Assignments.filter(a => a.employee_id === employeeId); + expect(service.findAll(employeeId)).toEqual(expected); + }); + + it('finds an assignment by id', () => { + expect(service.findById(Assignments[0].id)).toEqual(Assignments[0]); + expect(service.findById('does-not-exist')).toBeNull(); + }); + + it('computes per-employee stats from the fixtures', () => { + const employeeId = Assignments[0].employee_id; + const expectedTotal = Assignments.filter(a => a.employee_id === employeeId).length; + const stats = service.getStatsForEmployee(employeeId); + expect(stats.totalTasks).toBe(expectedTotal); + expect(stats.completionRate).toBeGreaterThanOrEqual(0); + }); + + it('returns the shared activity fixtures', () => { + expect(service.findAllActivities()).toEqual(Activities); + const employeeId = Activities[0].employee_id; + const expected = Activities.filter(a => a.employee_id === employeeId); + expect(service.findAllActivities(employeeId)).toEqual(expected); + }); +}); diff --git a/apps/api/src/tasks/tasks.service.ts b/apps/api/src/tasks/tasks.service.ts new file mode 100644 index 0000000..79595d8 --- /dev/null +++ b/apps/api/src/tasks/tasks.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import { Activity, ActivityLookup, Assignment, AssignmentLookup } from '@worksight/common'; + +@Injectable() +export class TasksService { + private readonly assignments = new AssignmentLookup(); + private readonly activities = new ActivityLookup(); + + findAll(employeeId?: string): Assignment[] { + if (employeeId) { + return this.assignments.getAssignmentsByEmployee(employeeId).all(); + } + return this.assignments.all(); + } + + findById(id: string): Assignment | null { + return this.assignments.filter({ id }).first(); + } + + getStatsForEmployee(employeeId: string): ReturnType { + return this.assignments.getStats(employeeId); + } + + findAllActivities(employeeId?: string): Activity[] { + if (employeeId) { + return this.activities.getActivitiesByEmployee(employeeId).all(); + } + return this.activities.all(); + } +} diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts index f5d9f4c..9c7f4f7 100644 --- a/apps/api/src/users/users.controller.ts +++ b/apps/api/src/users/users.controller.ts @@ -1,16 +1,46 @@ -import { Controller, Get, Header, Param } from '@nestjs/common'; -import { Roles } from '@worksight/common/types'; +import { Controller, Get, NotFoundException, Param } from '@nestjs/common'; +import type { EmployeeProfile, Team } from '@worksight/common'; +import { UsersService } from './users.service'; + @Controller('users') export class UsersController { + constructor(private readonly usersService: UsersService) {} + + @Get() + getAll(): EmployeeProfile[] { + return this.usersService.findAll(); + } + + @Get('stats') + getStats() { + return this.usersService.getStats(); + } + + @Get(':id') + getOne(@Param('id') id: string): EmployeeProfile { + const employee = this.usersService.findById(id); + if (!employee) { + throw new NotFoundException(`User ${id} not found`); + } + return employee; + } +} + +@Controller('teams') +export class TeamsController { + constructor(private readonly usersService: UsersService) {} + @Get() - getAll() { - const role = Roles; - return { message: `Hello, NestJS!`, anotherMessage: `Hi, Karlo!`, test: "balls", role }; + getAll(): Team[] { + return this.usersService.findAllTeams(); } @Get(':id') - @Header('Content-Type', 'text/plain') - getOne(@Param('id') id: string) { - return `stuff ${id}`; + getOne(@Param('id') id: string): Team { + const team = this.usersService.findTeamById(id); + if (!team) { + throw new NotFoundException(`Team ${id} not found`); + } + return team; } } diff --git a/apps/api/src/users/users.module.ts b/apps/api/src/users/users.module.ts index a16f6e5..d8ec8bc 100644 --- a/apps/api/src/users/users.module.ts +++ b/apps/api/src/users/users.module.ts @@ -1,9 +1,9 @@ import { Module } from '@nestjs/common'; -import { UsersController } from './users.controller'; +import { TeamsController, UsersController } from './users.controller'; import { UsersService } from './users.service'; @Module({ - controllers: [UsersController], + controllers: [UsersController, TeamsController], providers: [UsersService], }) -export class UsersModule { } +export class UsersModule {} diff --git a/apps/api/src/users/users.service.spec.ts b/apps/api/src/users/users.service.spec.ts new file mode 100644 index 0000000..ae13fef --- /dev/null +++ b/apps/api/src/users/users.service.spec.ts @@ -0,0 +1,36 @@ +import { Employees, Teams } from '@worksight/common'; +import { UsersService } from './users.service'; + +describe('UsersService', () => { + let service: UsersService; + + beforeEach(() => { + service = new UsersService(); + }); + + it('returns the shared employee fixtures', () => { + expect(service.findAll()).toEqual(Employees); + expect(service.findAll().length).toBeGreaterThan(0); + }); + + it('finds an employee by id', () => { + const employee = Employees[0]; + expect(service.findById(employee.id)).toEqual(employee); + }); + + it('returns null for an unknown employee id', () => { + expect(service.findById('does-not-exist')).toBeNull(); + }); + + it('computes stats over the shared fixtures', () => { + const stats = service.getStats(); + expect(stats.totalEmployees).toBe(Employees.length); + const employeeRole = stats.roles.find(r => r.role === 'employee'); + expect(employeeRole?.count).toBe(Employees.filter(e => e.role === 'employee').length); + }); + + it('returns the shared team fixtures', () => { + expect(service.findAllTeams()).toEqual(Teams); + expect(service.findTeamById(Teams[0].id)).toEqual(Teams[0]); + }); +}); diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts index 7e30c6b..d1d7840 100644 --- a/apps/api/src/users/users.service.ts +++ b/apps/api/src/users/users.service.ts @@ -1,4 +1,28 @@ import { Injectable } from '@nestjs/common'; +import { EmployeeLookup, EmployeeProfile, Team, TeamLookup, Teams } from '@worksight/common'; @Injectable() -export class UsersService { } +export class UsersService { + private readonly employees = new EmployeeLookup(); + private readonly teams = new TeamLookup(Teams); + + findAll(): EmployeeProfile[] { + return this.employees.all(); + } + + findById(id: string): EmployeeProfile | null { + return this.employees.getById(id); + } + + getStats(): ReturnType { + return this.employees.getStats(); + } + + findAllTeams(): Team[] { + return this.teams.all(); + } + + findTeamById(id: string): Team | null { + return this.teams.getById(id); + } +} diff --git a/apps/api/tsconfig.build.json b/apps/api/tsconfig.build.json new file mode 100644 index 0000000..cc01185 --- /dev/null +++ b/apps/api/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*.spec.ts"] +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index a0bcac0..1b9034c 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -1,8 +1,9 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "module": "es2022", - "moduleResolution": "bundler", + "module": "commonjs", + "moduleResolution": "node", + "noEmit": false, "declaration": true, "removeComments": true, "emitDecoratorMetadata": true, @@ -20,8 +21,10 @@ "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": true, "paths": { - "@worksight/common": ["../../packages/common/src"], - "@worksight/common/*": ["../../packages/common/src/*"] + // Resolve against the built package so type-check matches what Node + // loads at runtime (requires `pnpm --filter @worksight/common build`). + "@worksight/common": ["../../packages/common/dist"], + "@worksight/common/*": ["../../packages/common/dist/*"] } }, "include": ["src/**/*"], diff --git a/docs/handoffs/2026-07-25-wire-api-common.md b/docs/handoffs/2026-07-25-wire-api-common.md index c6df164..40bb746 100644 --- a/docs/handoffs/2026-07-25-wire-api-common.md +++ b/docs/handoffs/2026-07-25-wire-api-common.md @@ -1,28 +1,67 @@ # HANDOFF — Wire API to common (#17) -**Status:** Planned -**Branch:** `feat/mvp-wire-api` +**Status:** Done (MVP slice) +**Branch:** `feat/mvp-wire-api-17` **Issue(s):** #17 **Last updated:** 2026-07-25 ## Bottom line -Nest API returns/accepts shapes from `@worksight/common` types; at least one list endpoint serves common-shaped data. + +Nest API returns/accepts shapes from `@worksight/common` types; users, teams, +tasks, and activities endpoints serve the shared fixtures. ## Current state -- `users.controller.ts` imports `Roles` from `@worksight/common/types` -- common package linked in api `package.json` + +- `@worksight/common` is a `workspace:*` dependency of the API; the api tsconfig + resolves it against the built `packages/common/dist` so type-check matches + what Node loads at runtime (build common first). +- `apps/api` now emits real output: the api tsconfig previously inherited + `noEmit: true` from the root, so `nest build` produced an empty `dist/`. Fixed + with `noEmit: false` + CommonJS emit; added `tsconfig.build.json` so spec + files stay out of `dist/`. +- `packages/common` build now runs `tsc-alias --resolve-full-paths`; the + previous dist was unloadable ESM (`export * from './data'` directory imports). +- Fixed `BaseLookup.filter()` in common: it reconstructed subclasses with + `new Cls(schema, entries)` while subclasses take `(entries)`, so every chained + filter/`getById` silently returned garbage. +- Endpoints (all fixture-backed, typed from common): + - `GET /users` → `EmployeeProfile[]`, `GET /users/:id` (404 if missing), + `GET /users/stats` + - `GET /teams` → `Team[]`, `GET /teams/:id` + - `GET /tasks` → `Assignment[]` (`?employee_id=` filter), `GET /tasks/:id`, + `GET /tasks/stats/:employeeId` + - `GET /activities` → `Activity[]` (`?employee_id=` filter) +- Unit tests for `UsersService` / `TasksService` assert responses equal the + shared fixtures. ## Hook points -- `apps/api/src/**` controllers/services/DTOs -- Map DB/Supabase rows → common types if needed + +- `apps/api/src/users/**`, `apps/api/src/tasks/**` +- Swap the `*Lookup` fixture sources for DB/Supabase rows mapped to common types + when real persistence lands. ## How to verify + ```bash +pnpm --filter @worksight/common build # required before api type-check/build pnpm --filter @worksight/api type-check -pnpm --filter @worksight/api start:dev -# curl list endpoint; shape matches common types +pnpm --filter @worksight/api build +pnpm --filter @worksight/api test +pnpm --filter @worksight/api lint +PORT=3123 node apps/api/dist/main.js & +curl localhost:3123/users # shape matches EmployeeProfile[] +curl localhost:3123/tasks # shape matches Assignment[] ``` ## Done means -- [ ] Shared types on request/response path -- [ ] Type-check green + +- [x] Shared types on request/response path +- [x] Type-check green + +## Known gaps + +- Responses are fixture-backed only; no DB/Supabase reads yet. +- Attendance / survey / burnout types exist in common but have no API endpoints + yet (attendance module is still commented out in `app.module.ts`). +- Employee fixture ids in common are not all valid UUIDs, so responses fail + strict `EmployeeProfileSchema.parse` even though the TS shapes match. diff --git a/eslint.config.ts b/eslint.config.ts index 97e1abe..9075bdd 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -2,6 +2,7 @@ import eslint from '@eslint/js'; import tseslint from '@typescript-eslint/eslint-plugin'; import tsparser from '@typescript-eslint/parser'; import prettier from 'eslint-config-prettier'; +import globals from 'globals'; export default [ eslint.configs.recommended, @@ -13,6 +14,9 @@ export default [ ecmaVersion: 'latest', sourceType: 'module', }, + globals: { + ...globals.node, + }, }, plugins: { '@typescript-eslint': tseslint, @@ -23,6 +27,14 @@ export default [ '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], }, }, + { + files: ['**/*.spec.ts', '**/*.test.ts'], + languageOptions: { + globals: { + ...globals.jest, + }, + }, + }, prettier, { ignores: ['node_modules/', 'dist/', 'build/', '.next/', '**/*.d.ts'], diff --git a/packages/common/package.json b/packages/common/package.json index a0890ac..c28e706 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -27,7 +27,7 @@ "dist" ], "scripts": { - "build": "tsc -b", + "build": "tsc -b && tsc-alias -p tsconfig.json --resolve-full-paths", "dev": "tsc -w", "lint": "eslint src --ext .ts", "lint:fix": "eslint src --ext .ts --fix", diff --git a/packages/common/src/utils/base.ts b/packages/common/src/utils/base.ts index cf26d5d..796f04b 100644 --- a/packages/common/src/utils/base.ts +++ b/packages/common/src/utils/base.ts @@ -1,5 +1,5 @@ // base.ts -import { z, ZodObject } from "zod"; +import { z, ZodObject } from 'zod'; export type StatsConfig = { numericFields?: (keyof T)[]; @@ -28,22 +28,22 @@ export class BaseLookup> { * Supports value equality, "__has_value__", functions, and date ranges. */ filter(params: Partial, any>>): this { - const filtered = this.entries.filter((entry) => + const filtered = this.entries.filter(entry => Object.entries(params).every(([key, value]) => { const entryValue = entry[key as keyof z.infer]; // Function predicate - if (typeof value === "function") { + if (typeof value === 'function') { return value(entryValue); } // "__has_value__" filter - if (value === "__has_value__") { + if (value === '__has_value__') { return entryValue !== null && entryValue !== undefined; } // Date range filter - if (entryValue instanceof Date && value && typeof value === "object") { + if (entryValue instanceof Date && value && typeof value === 'object') { const { from, to, is } = value as { from?: Date; to?: Date; is?: Date }; if (from && entryValue < from) return false; if (to && entryValue > to) return false; @@ -56,8 +56,13 @@ export class BaseLookup> { }) ); - const Cls = this.constructor as new (schema: T, entries: z.infer[]) => this; - return new Cls(this.schema, filtered); + // Clone via the prototype instead of the constructor: subclasses take + // (entries) rather than (schema, entries), so `new Cls(schema, filtered)` + // would silently pass the schema in as the entry list. + const clone = Object.create(Object.getPrototypeOf(this) as object) as this; + clone.schema = this.schema; + clone.entries = filtered; + return clone; } /** Returns all entries */ @@ -79,12 +84,19 @@ export class BaseLookup> { * Compute statistics dynamically based on a stats configuration */ computeStats(config: StatsConfig> = {}) { - const { numericFields = [], booleanFields = [], categoricalFields = [], arrayFields = [] } = config; + const { + numericFields = [], + booleanFields = [], + categoricalFields = [], + arrayFields = [], + } = config; const stats: any = { total: this.count() }; // Numeric fields: min, max, average - numericFields.forEach((field) => { - const values = this.entries.map((e) => e[field] as unknown as number).filter((v) => typeof v === "number"); + numericFields.forEach(field => { + const values = this.entries + .map(e => e[field] as unknown as number) + .filter(v => typeof v === 'number'); if (values.length) { stats[field] = { min: Math.min(...values), @@ -97,16 +109,16 @@ export class BaseLookup> { }); // Boolean fields: count of true/false - booleanFields.forEach((field) => { - const trues = this.entries.filter((e) => e[field] === true).length; - const falses = this.entries.filter((e) => e[field] === false).length; + booleanFields.forEach(field => { + const trues = this.entries.filter(e => e[field] === true).length; + const falses = this.entries.filter(e => e[field] === false).length; stats[field] = { true: trues, false: falses }; }); // Categorical fields: count per category - categoricalFields.forEach((field) => { + categoricalFields.forEach(field => { const breakdown: Record = {}; - this.entries.forEach((e) => { + this.entries.forEach(e => { const val = e[field] as unknown as string; if (val !== undefined && val !== null) { breakdown[val] = (breakdown[val] || 0) + 1; @@ -116,10 +128,10 @@ export class BaseLookup> { }); // Array fields: counts of array lengths and average length - arrayFields.forEach((field) => { + arrayFields.forEach(field => { const lengths = this.entries - .map((e) => (Array.isArray(e[field]) ? (e[field] as unknown[]).length : 0)) - .filter((len) => len !== undefined); + .map(e => (Array.isArray(e[field]) ? (e[field] as unknown[]).length : 0)) + .filter(len => len !== undefined); stats[field] = { min: lengths.length ? Math.min(...lengths) : 0, max: lengths.length ? Math.max(...lengths) : 0, diff --git a/packages/common/src/utils/employees.ts b/packages/common/src/utils/employees.ts index 6723858..b1852a0 100644 --- a/packages/common/src/utils/employees.ts +++ b/packages/common/src/utils/employees.ts @@ -24,17 +24,20 @@ export class EmployeeLookup extends BaseLookup { /** Summary statistics about employees */ public getStats() { - const roles = Roles.map((role) => ({ + const roles = Roles.map(role => ({ role, count: this.filter({ role }).count(), })); - const departments = Departments.map((department) => ({ + const departments = Departments.map(department => ({ department, - count: this.filter({ department }).count(), + // `department` is an array field, so match by membership + count: this.filter({ + department: (d: unknown) => Array.isArray(d) && d.includes(department), + }).count(), })); - const adminCount = this.entries.filter((e) => !e.manager_id).length; + const adminCount = this.entries.filter(e => !e.manager_id).length; return { totalEmployees: this.entries.length, @@ -95,7 +98,7 @@ export class TeamLookup extends BaseLookup { const avgMembers = totalTeams ? totalMembers / totalTeams : 0; const departmentCounts: Record = {}; - allTeams.forEach((t) => { + allTeams.forEach(t => { departmentCounts[t.department] = (departmentCounts[t.department] || 0) + 1; });