From 408a1c099703278e7b92d635341d894494fcfb1c Mon Sep 17 00:00:00 2001 From: kuyacarlo <106532351+kuyacarlo@users.noreply.github.com> Date: Sun, 26 Jul 2026 06:49:22 +0800 Subject: [PATCH 1/7] fix(api): stop Vercel FUNCTION_INVOCATION_FAILED (ESM + serverless) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production crashed on every request with ERR_REQUIRE_ESM: Nest emits CommonJS but @worksight/common was ESM-only, so require() of the workspace package killed the isolate. Dual-build common to dist/esm + dist/cjs and wire package exports accordingly. Also replace the broken "outputDirectory: dist" deploy (Vercel treated compiled files as functions, and main.ts called app.listen()) with a real serverless entry: api/index.js → dist/vercel.js over an Express adapter, rewrite /(.*) → /api. Local boot still uses main.ts + listen. Co-authored-by: Cursor --- apps/api/.env.example | 8 +-- apps/api/api/index.js | 6 +++ apps/api/package.json | 13 ++--- apps/api/src/bootstrap.ts | 54 +++++++++++++++++++ apps/api/src/main.ts | 34 +++--------- apps/api/src/vercel.ts | 25 +++++++++ apps/api/tsconfig.json | 4 +- apps/api/vercel.json | 2 +- docs/handoffs/2026-07-26-api-postgres.md | 16 ++++++ packages/common/package.json | 37 +++++++------ packages/common/scripts/write-cjs-package.mjs | 7 +++ packages/common/tsconfig.cjs.json | 10 ++++ packages/common/tsconfig.esm.json | 9 ++++ pnpm-lock.yaml | 3 ++ 14 files changed, 174 insertions(+), 54 deletions(-) create mode 100644 apps/api/api/index.js create mode 100644 apps/api/src/bootstrap.ts create mode 100644 apps/api/src/vercel.ts create mode 100644 packages/common/scripts/write-cjs-package.mjs create mode 100644 packages/common/tsconfig.cjs.json create mode 100644 packages/common/tsconfig.esm.json diff --git a/apps/api/.env.example b/apps/api/.env.example index 389ec8a..384f0f1 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -1,14 +1,16 @@ -# API local env +# API local env — copy to `.env` (gitignored) and fill in. +# Scripts (`dev`, `start`, `seed`, `db:migrate`) load `.env` via Node --env-file. # # Prefer a direct Postgres URL or a PgBouncer transaction-pool URL. # Do not use the Supabase REST host here — Nest talks SQL via `pg`. # # Neon (preferred) — copy from console or neonctl; quote values (URLs contain &). -# DATABASE_URL="postgresql://…@….pooler.…neon.tech/neondb?sslmode=require" -# DATABASE_URL_DIRECT="postgresql://…@….neon.tech/neondb?sslmode=require" +DATABASE_URL="postgresql://…@….pooler.…neon.tech/neondb?sslmode=require" +DATABASE_URL_DIRECT="postgresql://…@….neon.tech/neondb?sslmode=require" # # Local Podman fallback (worksight-pg on :5433): # DATABASE_URL="postgresql://worksight:worksight@127.0.0.1:5433/worksight" +# DATABASE_URL_DIRECT="postgresql://worksight:worksight@127.0.0.1:5433/worksight" # PORT=3001 CORS_ORIGINS=http://localhost:3000 diff --git a/apps/api/api/index.js b/apps/api/api/index.js new file mode 100644 index 0000000..99c7f3b --- /dev/null +++ b/apps/api/api/index.js @@ -0,0 +1,6 @@ +/** + * Vercel serverless entry (Root Directory = apps/api). + * Thin CJS shim over the Nest build so Vercel does not recompile TypeScript / + * decorators itself — `nest build` already emitted dist/vercel.js. + */ +module.exports = require('../dist/vercel.js').default; diff --git a/apps/api/package.json b/apps/api/package.json index ea0bba5..b1fe754 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -4,10 +4,10 @@ "private": true, "scripts": { "build": "nest build", - "dev": "nest start --watch", - "start": "nest start", - "start:debug": "nest start --debug --watch", - "start:prod": "node dist/main", + "dev": "node --env-file=.env ./node_modules/@nestjs/cli/bin/nest.js start --watch", + "start": "node --env-file=.env ./node_modules/@nestjs/cli/bin/nest.js start", + "start:debug": "node --env-file=.env ./node_modules/@nestjs/cli/bin/nest.js start --debug --watch", + "start:prod": "node --env-file=.env dist/main", "type-check": "tsc --noEmit", "lint": "eslint \"src/**/*.ts\"", "lint:fix": "eslint \"src/**/*.ts\" --fix", @@ -18,8 +18,8 @@ "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json", - "seed": "tsx src/db/seed.ts", - "db:migrate": "psql \"$DATABASE_URL\" -f sql/001_core.sql" + "seed": "tsx --env-file=.env src/db/seed.ts", + "db:migrate": "tsx --env-file=.env -e \"import { execFileSync } from 'node:child_process'; for (const f of ['sql/001_core.sql','sql/002_attendance.sql','sql/003_surveys.sql']) execFileSync('psql',[process.env.DATABASE_URL_DIRECT!,'-f',f],{stdio:'inherit'})\"" }, "dependencies": { "@nestjs/common": "^11.1.6", @@ -29,6 +29,7 @@ "@supabase/supabase-js": "^2.58.0", "@worksight/common": "workspace:*", "class-transformer": "^0.5.1", + "express": "^5.2.1", "pg": "^8.16.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2" diff --git a/apps/api/src/bootstrap.ts b/apps/api/src/bootstrap.ts new file mode 100644 index 0000000..10c5f08 --- /dev/null +++ b/apps/api/src/bootstrap.ts @@ -0,0 +1,54 @@ +import { ClassSerializerInterceptor, type INestApplication, Logger } from '@nestjs/common'; +import { NestFactory, Reflector } from '@nestjs/core'; +import { ExpressAdapter } from '@nestjs/platform-express'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import express, { type Express } from 'express'; +import { AppModule } from './app.module'; + +export type BootstrappedApp = { + app: INestApplication; + server: Express; +}; + +/** + * Shared Nest bootstrap for local (`main.ts`) and Vercel (`api/index.ts`). + * Returns the underlying Express instance so serverless can hand requests to it + * without calling `app.listen()`. + */ +export async function createApp(): Promise { + const server = express(); + const app = await NestFactory.create(AppModule, new ExpressAdapter(server), { + logger: ['error', 'warn', 'log'], + }); + + const corsOrigins = (process.env.CORS_ORIGINS ?? 'http://localhost:3000') + .split(',') + .map(origin => origin.trim()) + .filter(Boolean); + + app.enableCors({ + origin: corsOrigins, + methods: ['GET', 'HEAD', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE'], + }); + app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector))); + + const config = new DocumentBuilder() + .setTitle('WorkSight') + .setDescription('Check your tasks, manage your well-being') + .setVersion('1.0') + .addBearerAuth() + .build(); + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api', app, document); + + await app.init(); + + const logger = new Logger('Bootstrap'); + if (process.env.DATABASE_URL) { + logger.log('Data source: Postgres via DATABASE_URL (direct or PgBouncer).'); + } else { + logger.log('Data source: @worksight/common fixtures (set DATABASE_URL to use Postgres).'); + } + + return { app, server }; +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index a0d55ac..b62977e 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,38 +1,18 @@ -import { ClassSerializerInterceptor, Logger } from '@nestjs/common'; -import { NestFactory, Reflector } from '@nestjs/core'; -import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; -import { AppModule } from './app.module'; +import { Logger } from '@nestjs/common'; +import { createApp } from './bootstrap'; async function bootstrap() { - const app = await NestFactory.create(AppModule); - const logger = new Logger('Bootstrap'); + const { app } = await createApp(); const port = Number(process.env.PORT ?? 3001); const corsOrigins = (process.env.CORS_ORIGINS ?? 'http://localhost:3000') .split(',') .map(origin => origin.trim()) .filter(Boolean); - app.enableCors({ - origin: corsOrigins, - methods: ['GET', 'HEAD', 'OPTIONS'], - }); - app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector))); - - const config = new DocumentBuilder() - .setTitle('WorkSight') - .setDescription('Check your tasks, manage your well-being') - .setVersion('1.0') - .addBearerAuth() - .build(); - const document = SwaggerModule.createDocument(app, config); - SwaggerModule.setup('api', app, document); - await app.listen(port); - logger.log(`API listening on http://localhost:${port} (CORS: ${corsOrigins.join(', ')})`); - if (process.env.DATABASE_URL) { - logger.log('Data source: Postgres via DATABASE_URL (direct or PgBouncer).'); - } else { - logger.log('Data source: @worksight/common fixtures (set DATABASE_URL to use Postgres).'); - } + Logger.log( + `API listening on http://localhost:${port} (CORS: ${corsOrigins.join(', ')})`, + 'Bootstrap' + ); } bootstrap(); diff --git a/apps/api/src/vercel.ts b/apps/api/src/vercel.ts new file mode 100644 index 0000000..203d846 --- /dev/null +++ b/apps/api/src/vercel.ts @@ -0,0 +1,25 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { Express } from 'express'; +import { createApp } from './bootstrap'; + +let cached: Express | undefined; +let boot: Promise | undefined; + +async function getServer(): Promise { + if (cached) { + return cached; + } + boot ??= createApp().then(({ server }) => { + cached = server; + return server; + }); + return boot; +} + +/** + * Vercel Node serverless entry. Cached across warm invocations in the same isolate. + */ +export default async function handler(req: IncomingMessage, res: ServerResponse): Promise { + const server = await getServer(); + server(req, res); +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 1b9034c..645cf27 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -23,8 +23,8 @@ "paths": { // 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/*"] + "@worksight/common": ["../../packages/common/dist/esm"], + "@worksight/common/*": ["../../packages/common/dist/esm/*"] } }, "include": ["src/**/*"], diff --git a/apps/api/vercel.json b/apps/api/vercel.json index 0a14dae..9aa356f 100644 --- a/apps/api/vercel.json +++ b/apps/api/vercel.json @@ -2,5 +2,5 @@ "$schema": "https://openapi.vercel.sh/vercel.json", "installCommand": "pnpm install --frozen-lockfile", "buildCommand": "pnpm turbo run build --filter=@worksight/api", - "outputDirectory": "dist" + "rewrites": [{ "source": "/(.*)", "destination": "/api" }] } diff --git a/docs/handoffs/2026-07-26-api-postgres.md b/docs/handoffs/2026-07-26-api-postgres.md index 374530e..40972d0 100644 --- a/docs/handoffs/2026-07-26-api-postgres.md +++ b/docs/handoffs/2026-07-26-api-postgres.md @@ -73,6 +73,22 @@ the same fixtures the offline demo uses. - `EmployeeProfile.manager_id` is nullable; `Assignment.employee_id` / `source_id` are UUIDs. +## Vercel serverless fix (follow-up branch `fix/api-vercel-esm`) + +Production was crashing with `FUNCTION_INVOCATION_FAILED` / +`ERR_REQUIRE_ESM`: Nest emits CommonJS, but `@worksight/common` was +ESM-only (`"type": "module"`), so the serverless function died on the +first `require('@worksight/common')`. Also, `main.ts` called +`app.listen()`, which is wrong for Vercel. + +- `@worksight/common` now dual-builds `dist/esm` + `dist/cjs` (with + matching `exports.require` / `exports.import`). +- Nest bootstrap is shared (`src/bootstrap.ts`); local still uses + `main.ts` + listen, Vercel uses `api/index.js` → `dist/vercel.js` + (Express adapter, cached warm isolate). +- `apps/api/vercel.json` drops `outputDirectory: dist` (that made Vercel + treat every compiled file as a function) and rewrites `/(.*)` → `/api`. + ## DB-backed stats (follow-up branch `feat/api-db-stats`) - `GET /users/stats` and `GET /tasks/stats/:employeeId` now hydrate the diff --git a/packages/common/package.json b/packages/common/package.json index c28e706..07f9609 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -2,33 +2,40 @@ "name": "@worksight/common", "version": "1.0.0", "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", "exports": { ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "require": "./dist/index.js" + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.js", + "default": "./dist/esm/index.js" }, "./data": { - "types": "./dist/data/index.d.ts", - "import": "./dist/data/index.js" + "types": "./dist/esm/data/index.d.ts", + "import": "./dist/esm/data/index.js", + "require": "./dist/cjs/data/index.js" }, "./types": { - "types": "./dist/types/index.d.ts", - "import": "./dist/types/index.js" + "types": "./dist/esm/types/index.d.ts", + "import": "./dist/esm/types/index.js", + "require": "./dist/cjs/types/index.js" }, "./utils": { - "types": "./dist/utils/index.d.ts", - "import": "./dist/utils/index.js" + "types": "./dist/esm/utils/index.d.ts", + "import": "./dist/esm/utils/index.js", + "require": "./dist/cjs/utils/index.js" } }, "files": [ "dist" ], "scripts": { - "build": "tsc -b && tsc-alias -p tsconfig.json --resolve-full-paths", - "dev": "tsc -w", + "build": "pnpm clean && pnpm build:esm && pnpm build:cjs", + "build:esm": "tsc -p tsconfig.esm.json && tsc-alias -p tsconfig.esm.json --resolve-full-paths", + "build:cjs": "tsc -p tsconfig.cjs.json && tsc-alias -p tsconfig.cjs.json --resolve-full-paths && node ./scripts/write-cjs-package.mjs", + "dev": "tsc -w -p tsconfig.esm.json", "lint": "eslint src --ext .ts", "lint:fix": "eslint src --ext .ts --fix", "lint:strict": "eslint --max-warnings 30 \"src/**/*.ts\"", @@ -36,8 +43,8 @@ "prettier:check": "prettier --check \"**/*.{js,ts,md}\"", "format": "run-s prettier lint:fix", "format:check": "run-s prettier:check lint:strict", - "type-check": "tsc --noEmit", - "clean": "rimraf .next dist .turbo tsconfig.tsbuildinfo" + "type-check": "tsc --noEmit -p tsconfig.esm.json", + "clean": "rm -rf dist .turbo tsconfig.tsbuildinfo tsconfig.esm.tsbuildinfo tsconfig.cjs.tsbuildinfo" }, "peerDependencies": { "zod": "^4.1.11" diff --git a/packages/common/scripts/write-cjs-package.mjs b/packages/common/scripts/write-cjs-package.mjs new file mode 100644 index 0000000..fec456d --- /dev/null +++ b/packages/common/scripts/write-cjs-package.mjs @@ -0,0 +1,7 @@ +import { writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +writeFileSync(join(root, 'dist/cjs/package.json'), JSON.stringify({ type: 'commonjs' }, null, 2) + '\n'); +writeFileSync(join(root, 'dist/esm/package.json'), JSON.stringify({ type: 'module' }, null, 2) + '\n'); diff --git a/packages/common/tsconfig.cjs.json b/packages/common/tsconfig.cjs.json new file mode 100644 index 0000000..481f092 --- /dev/null +++ b/packages/common/tsconfig.cjs.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "moduleResolution": "node", + "outDir": "dist/cjs", + "declaration": false, + "declarationMap": false + } +} diff --git a/packages/common/tsconfig.esm.json b/packages/common/tsconfig.esm.json new file mode 100644 index 0000000..df6651f --- /dev/null +++ b/packages/common/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "esnext", + "moduleResolution": "bundler", + "outDir": "dist/esm", + "declarationDir": "dist/esm" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c24d26..ec1a994 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,6 +102,9 @@ importers: class-transformer: specifier: ^0.5.1 version: 0.5.1 + express: + specifier: ^5.2.1 + version: 5.2.1 pg: specifier: ^8.16.3 version: 8.20.0 From 14d82552fb191535d3b1487df26df3e7b726dbfb Mon Sep 17 00:00:00 2001 From: kuyacarlo <106532351+kuyacarlo@users.noreply.github.com> Date: Sun, 26 Jul 2026 06:52:58 +0800 Subject: [PATCH 2/7] fix(api): disable NestJS framework preset on Vercel (use api/ handler) Co-authored-by: Cursor --- apps/api/vercel.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/api/vercel.json b/apps/api/vercel.json index 9aa356f..d0daa06 100644 --- a/apps/api/vercel.json +++ b/apps/api/vercel.json @@ -1,5 +1,6 @@ { "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": null, "installCommand": "pnpm install --frozen-lockfile", "buildCommand": "pnpm turbo run build --filter=@worksight/api", "rewrites": [{ "source": "/(.*)", "destination": "/api" }] From 9d67efb72723be03329d4acd5d524e9df956ff9b Mon Sep 17 00:00:00 2001 From: kuyacarlo <106532351+kuyacarlo@users.noreply.github.com> Date: Sun, 26 Jul 2026 06:54:53 +0800 Subject: [PATCH 3/7] fix(api): ship empty public/ + include dist in the Vercel function Co-authored-by: Cursor --- apps/api/public/.gitkeep | 0 apps/api/vercel.json | 9 ++++++++- turbo.json | 7 ++++++- 3 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 apps/api/public/.gitkeep diff --git a/apps/api/public/.gitkeep b/apps/api/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/vercel.json b/apps/api/vercel.json index d0daa06..b670552 100644 --- a/apps/api/vercel.json +++ b/apps/api/vercel.json @@ -3,5 +3,12 @@ "framework": null, "installCommand": "pnpm install --frozen-lockfile", "buildCommand": "pnpm turbo run build --filter=@worksight/api", - "rewrites": [{ "source": "/(.*)", "destination": "/api" }] + "outputDirectory": "public", + "rewrites": [{ "source": "/(.*)", "destination": "/api" }], + "functions": { + "api/index.js": { + "includeFiles": "dist/**", + "maxDuration": 30 + } + } } diff --git a/turbo.json b/turbo.json index 94cac85..6a54581 100644 --- a/turbo.json +++ b/turbo.json @@ -15,7 +15,12 @@ "NEXT_PUBLIC_IS_OFFLINE", "IS_OFFLINE", "NEXT_PUBLIC_USE_API", - "NEXT_PUBLIC_API_URL" + "NEXT_PUBLIC_API_URL", + "DATABASE_URL", + "DATABASE_URL_DIRECT", + "DATABASE_POOL_MAX", + "CORS_ORIGINS", + "PORT" ] }, "dev": { From 696b8a43b5fbef3290401d7a905781edb8e22acc Mon Sep 17 00:00:00 2001 From: kuyacarlo <106532351+kuyacarlo@users.noreply.github.com> Date: Sun, 26 Jul 2026 07:25:32 +0800 Subject: [PATCH 4/7] fix(api): serve Scalar docs; fix Swagger UI on Vercel Nest Swagger UI returned HTML but its local swagger-ui-dist CSS/JS never shipped in the serverless bundle (404). Stop mounting that UI. Expose OpenAPI at /openapi.json (and /api-json), put Scalar at /api and /reference, and keep a CDN-backed Swagger UI at /swagger. Co-authored-by: Cursor --- apps/api/package.json | 1 + apps/api/src/bootstrap.ts | 73 ++++++++++++++++++++--- docs/handoffs/2026-07-26-api-postgres.md | 10 ++++ pnpm-lock.yaml | 74 ++++++++++++++++++++++++ 4 files changed, 149 insertions(+), 9 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index b1fe754..8b52ced 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -26,6 +26,7 @@ "@nestjs/core": "^11.1.6", "@nestjs/platform-express": "^11.1.6", "@nestjs/swagger": "^11.2.0", + "@scalar/nestjs-api-reference": "^1.2.11", "@supabase/supabase-js": "^2.58.0", "@worksight/common": "workspace:*", "class-transformer": "^0.5.1", diff --git a/apps/api/src/bootstrap.ts b/apps/api/src/bootstrap.ts index 10c5f08..b733070 100644 --- a/apps/api/src/bootstrap.ts +++ b/apps/api/src/bootstrap.ts @@ -2,7 +2,8 @@ import { ClassSerializerInterceptor, type INestApplication, Logger } from '@nest import { NestFactory, Reflector } from '@nestjs/core'; import { ExpressAdapter } from '@nestjs/platform-express'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; -import express, { type Express } from 'express'; +import { apiReference } from '@scalar/nestjs-api-reference'; +import express, { type Express, type Request, type Response } from 'express'; import { AppModule } from './app.module'; export type BootstrappedApp = { @@ -32,14 +33,7 @@ export async function createApp(): Promise { }); app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector))); - const config = new DocumentBuilder() - .setTitle('WorkSight') - .setDescription('Check your tasks, manage your well-being') - .setVersion('1.0') - .addBearerAuth() - .build(); - const document = SwaggerModule.createDocument(app, config); - SwaggerModule.setup('api', app, document); + setupApiDocs(app, server); await app.init(); @@ -52,3 +46,64 @@ export async function createApp(): Promise { return { app, server }; } + +/** + * Docs on Vercel serverless: + * Nest's default Swagger UI ships local swagger-ui-dist assets that never make + * it into the function bundle (HTML 200, CSS/JS 404). Serve OpenAPI JSON + * ourselves, Scalar as the primary UI, and a CDN-backed Swagger UI fallback. + */ +function setupApiDocs(app: INestApplication, server: Express): void { + const config = new DocumentBuilder() + .setTitle('WorkSight') + .setDescription('Check your tasks, manage your well-being') + .setVersion('1.0') + .addBearerAuth() + .build(); + const document = SwaggerModule.createDocument(app, config); + + const sendOpenApi = (_req: Request, res: Response) => { + res.json(document); + }; + server.get('/openapi.json', sendOpenApi); + // Back-compat with Nest's previous `/api-json` URL. + server.get('/api-json', sendOpenApi); + + const scalar = apiReference({ + content: document, + pageTitle: 'WorkSight API', + }); + // `/api` is the URL people already open; Scalar replaces the broken Swagger UI. + app.use('/api', scalar); + app.use('/reference', scalar); + + server.get('/swagger', (_req: Request, res: Response) => { + res.type('html').send(cdnSwaggerHtml('/openapi.json')); + }); +} + +function cdnSwaggerHtml(specUrl: string): string { + // Classic Swagger UI from CDN — no local swagger-ui-dist files required. + return ` + + + + WorkSight API — Swagger UI + + + + +
+ + + + +`; +} diff --git a/docs/handoffs/2026-07-26-api-postgres.md b/docs/handoffs/2026-07-26-api-postgres.md index 40972d0..137ba4a 100644 --- a/docs/handoffs/2026-07-26-api-postgres.md +++ b/docs/handoffs/2026-07-26-api-postgres.md @@ -73,6 +73,16 @@ the same fixtures the offline demo uses. - `EmployeeProfile.manager_id` is nullable; `Assignment.employee_id` / `source_id` are UUIDs. +## API docs (Scalar + CDN Swagger) + +Nest's default Swagger UI 404'd on Vercel: the HTML rendered, but +`swagger-ui.css` / `*.js` from `swagger-ui-dist` are not in the function +bundle. Docs now: + +- `/openapi.json` (+ `/api-json`) — OpenAPI document +- `/api` and `/reference` — [Scalar](https://scalar.com) API reference +- `/swagger` — classic Swagger UI via unpkg CDN + ## Vercel serverless fix (follow-up branch `fix/api-vercel-esm`) Production was crashing with `FUNCTION_INVOCATION_FAILED` / diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec1a994..fe5dc63 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,6 +93,9 @@ importers: '@nestjs/swagger': specifier: ^11.2.0 version: 11.4.6(@nestjs/common@11.1.28(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(class-transformer@0.5.1)(reflect-metadata@0.2.2) + '@scalar/nestjs-api-reference': + specifier: ^1.2.11 + version: 1.2.11 '@supabase/supabase-js': specifier: ^2.58.0 version: 2.110.8 @@ -2627,6 +2630,30 @@ packages: '@rushstack/eslint-patch@1.16.1': resolution: {integrity: sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==} + '@scalar/client-side-rendering@0.3.4': + resolution: {integrity: sha512-kb3B+FGjvAUr2DU0fe9dVKBLwot1TjOO+iHCTmY8r8FUJySmYKGQYCicRzzilOyTjvKspiZBCEr03PWaWW+1gw==} + engines: {node: '>=22'} + + '@scalar/helpers@0.9.2': + resolution: {integrity: sha512-hjyMpMZjTBZQhyByZmz5oUgRKUQJO5V5AOiJxsVEGbUmgA7sJRQeTrXLB+BEwzaKS5nm2opJeNyMBYLFNK4hiQ==} + engines: {node: '>=22'} + + '@scalar/nestjs-api-reference@1.2.11': + resolution: {integrity: sha512-miUmMGI3ZmcfiMi8Bd2KpNXwvvQtnZuyK25NUQCYQrq8UINtbWARLMdsvRx2hUSboZ1DdqZXvwtHV0FUwhEInQ==} + engines: {node: '>=22'} + + '@scalar/schemas@0.7.4': + resolution: {integrity: sha512-Or31zxR+ceGGhkVU5XBO2Zv7oyGDtjsJOjM2Rr0WRZ1/tRsQc2/FsVv+9kPmCA7sqFL8BKWlNfTXcPITI7WRHw==} + engines: {node: '>=22'} + + '@scalar/types@0.16.4': + resolution: {integrity: sha512-fLf0ANAC3iQq0sIVdmH6aGM+pFSLyD8GfGBxSWcLpI0jE0iFAzX2A3p0qVZm7vqBT4TQnn0fSwg5U+h+FZ52BA==} + engines: {node: '>=22'} + + '@scalar/validation@0.6.2': + resolution: {integrity: sha512-Sc1TkcwGV6aVCO51AyKeaGiP8gpwAHxEtO5d3tZzPV+KsnlC/YokQxFxwBrbIXw73k9hmcExnJyGu3k5i6n6VA==} + engines: {node: '>=20'} + '@scarf/scarf@1.4.0': resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} @@ -5404,6 +5431,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.16: + resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==} + engines: {node: ^18 || >=20} + hasBin: true + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -6421,6 +6453,10 @@ packages: resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} engines: {node: '>=10.0.0'} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tailwind-merge@3.4.0: resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==} @@ -6593,6 +6629,10 @@ packages: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} + type-fest@5.8.0: + resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==} + engines: {node: '>=20'} + type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -8981,6 +9021,32 @@ snapshots: '@rushstack/eslint-patch@1.16.1': {} + '@scalar/client-side-rendering@0.3.4': + dependencies: + '@scalar/schemas': 0.7.4 + '@scalar/types': 0.16.4 + '@scalar/validation': 0.6.2 + + '@scalar/helpers@0.9.2': {} + + '@scalar/nestjs-api-reference@1.2.11': + dependencies: + '@scalar/client-side-rendering': 0.3.4 + + '@scalar/schemas@0.7.4': + dependencies: + '@scalar/helpers': 0.9.2 + '@scalar/validation': 0.6.2 + + '@scalar/types@0.16.4': + dependencies: + '@scalar/helpers': 0.9.2 + nanoid: 5.1.16 + type-fest: 5.8.0 + zod: 4.3.6 + + '@scalar/validation@0.6.2': {} + '@scarf/scarf@1.4.0': {} '@shikijs/core@2.5.0': @@ -12201,6 +12267,8 @@ snapshots: nanoid@3.3.16: {} + nanoid@5.1.16: {} + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} @@ -13317,6 +13385,8 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + tagged-tag@1.0.0: {} + tailwind-merge@3.4.0: {} tailwindcss@4.2.2: {} @@ -13481,6 +13551,10 @@ snapshots: type-fest@4.41.0: {} + type-fest@5.8.0: + dependencies: + tagged-tag: 1.0.0 + type-is@1.6.18: dependencies: media-typer: 0.3.0 From 5d8cf08ccda5748b350678a5339f0ab8ab8a428e Mon Sep 17 00:00:00 2001 From: kuyacarlo <106532351+kuyacarlo@users.noreply.github.com> Date: Sun, 26 Jul 2026 07:28:01 +0800 Subject: [PATCH 5/7] fix(api): serve Scalar from CDN (no ESM Nest package) @scalar/nestjs-api-reference pulls ESM-only client-side-rendering and crashes the CJS Nest serverless bundle with ERR_REQUIRE_ESM on every request. Drop that dependency; serve Scalar and Swagger UI as small HTML shells that load their UIs from CDN against /openapi.json. Co-authored-by: Cursor --- apps/api/package.json | 1 - apps/api/src/bootstrap.ts | 39 +++++++++++++++------ pnpm-lock.yaml | 74 --------------------------------------- 3 files changed, 29 insertions(+), 85 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index 8b52ced..b1fe754 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -26,7 +26,6 @@ "@nestjs/core": "^11.1.6", "@nestjs/platform-express": "^11.1.6", "@nestjs/swagger": "^11.2.0", - "@scalar/nestjs-api-reference": "^1.2.11", "@supabase/supabase-js": "^2.58.0", "@worksight/common": "workspace:*", "class-transformer": "^0.5.1", diff --git a/apps/api/src/bootstrap.ts b/apps/api/src/bootstrap.ts index b733070..4e8039a 100644 --- a/apps/api/src/bootstrap.ts +++ b/apps/api/src/bootstrap.ts @@ -2,7 +2,6 @@ import { ClassSerializerInterceptor, type INestApplication, Logger } from '@nest import { NestFactory, Reflector } from '@nestjs/core'; import { ExpressAdapter } from '@nestjs/platform-express'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; -import { apiReference } from '@scalar/nestjs-api-reference'; import express, { type Express, type Request, type Response } from 'express'; import { AppModule } from './app.module'; @@ -50,8 +49,9 @@ export async function createApp(): Promise { /** * Docs on Vercel serverless: * Nest's default Swagger UI ships local swagger-ui-dist assets that never make - * it into the function bundle (HTML 200, CSS/JS 404). Serve OpenAPI JSON - * ourselves, Scalar as the primary UI, and a CDN-backed Swagger UI fallback. + * it into the function bundle (HTML 200, CSS/JS 404). @scalar/nestjs-api-reference + * is ESM-only and crashes Nest's CJS build with ERR_REQUIRE_ESM. Serve OpenAPI + * JSON ourselves and load Scalar / Swagger UI from CDN. */ function setupApiDocs(app: INestApplication, server: Express): void { const config = new DocumentBuilder() @@ -69,21 +69,40 @@ function setupApiDocs(app: INestApplication, server: Express): void { // Back-compat with Nest's previous `/api-json` URL. server.get('/api-json', sendOpenApi); - const scalar = apiReference({ - content: document, - pageTitle: 'WorkSight API', - }); // `/api` is the URL people already open; Scalar replaces the broken Swagger UI. - app.use('/api', scalar); - app.use('/reference', scalar); + server.get(['/api', '/reference'], (_req: Request, res: Response) => { + res.type('html').send(cdnScalarHtml('/openapi.json')); + }); server.get('/swagger', (_req: Request, res: Response) => { res.type('html').send(cdnSwaggerHtml('/openapi.json')); }); } +function cdnScalarHtml(specUrl: string): string { + return ` + + + + + WorkSight API + + + + + +`; +} + function cdnSwaggerHtml(specUrl: string): string { - // Classic Swagger UI from CDN — no local swagger-ui-dist files required. return ` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe5dc63..ec1a994 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,9 +93,6 @@ importers: '@nestjs/swagger': specifier: ^11.2.0 version: 11.4.6(@nestjs/common@11.1.28(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(class-transformer@0.5.1)(reflect-metadata@0.2.2) - '@scalar/nestjs-api-reference': - specifier: ^1.2.11 - version: 1.2.11 '@supabase/supabase-js': specifier: ^2.58.0 version: 2.110.8 @@ -2630,30 +2627,6 @@ packages: '@rushstack/eslint-patch@1.16.1': resolution: {integrity: sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==} - '@scalar/client-side-rendering@0.3.4': - resolution: {integrity: sha512-kb3B+FGjvAUr2DU0fe9dVKBLwot1TjOO+iHCTmY8r8FUJySmYKGQYCicRzzilOyTjvKspiZBCEr03PWaWW+1gw==} - engines: {node: '>=22'} - - '@scalar/helpers@0.9.2': - resolution: {integrity: sha512-hjyMpMZjTBZQhyByZmz5oUgRKUQJO5V5AOiJxsVEGbUmgA7sJRQeTrXLB+BEwzaKS5nm2opJeNyMBYLFNK4hiQ==} - engines: {node: '>=22'} - - '@scalar/nestjs-api-reference@1.2.11': - resolution: {integrity: sha512-miUmMGI3ZmcfiMi8Bd2KpNXwvvQtnZuyK25NUQCYQrq8UINtbWARLMdsvRx2hUSboZ1DdqZXvwtHV0FUwhEInQ==} - engines: {node: '>=22'} - - '@scalar/schemas@0.7.4': - resolution: {integrity: sha512-Or31zxR+ceGGhkVU5XBO2Zv7oyGDtjsJOjM2Rr0WRZ1/tRsQc2/FsVv+9kPmCA7sqFL8BKWlNfTXcPITI7WRHw==} - engines: {node: '>=22'} - - '@scalar/types@0.16.4': - resolution: {integrity: sha512-fLf0ANAC3iQq0sIVdmH6aGM+pFSLyD8GfGBxSWcLpI0jE0iFAzX2A3p0qVZm7vqBT4TQnn0fSwg5U+h+FZ52BA==} - engines: {node: '>=22'} - - '@scalar/validation@0.6.2': - resolution: {integrity: sha512-Sc1TkcwGV6aVCO51AyKeaGiP8gpwAHxEtO5d3tZzPV+KsnlC/YokQxFxwBrbIXw73k9hmcExnJyGu3k5i6n6VA==} - engines: {node: '>=20'} - '@scarf/scarf@1.4.0': resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} @@ -5431,11 +5404,6 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - nanoid@5.1.16: - resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==} - engines: {node: ^18 || >=20} - hasBin: true - napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -6453,10 +6421,6 @@ packages: resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} engines: {node: '>=10.0.0'} - tagged-tag@1.0.0: - resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} - engines: {node: '>=20'} - tailwind-merge@3.4.0: resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==} @@ -6629,10 +6593,6 @@ packages: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} - type-fest@5.8.0: - resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==} - engines: {node: '>=20'} - type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -9021,32 +8981,6 @@ snapshots: '@rushstack/eslint-patch@1.16.1': {} - '@scalar/client-side-rendering@0.3.4': - dependencies: - '@scalar/schemas': 0.7.4 - '@scalar/types': 0.16.4 - '@scalar/validation': 0.6.2 - - '@scalar/helpers@0.9.2': {} - - '@scalar/nestjs-api-reference@1.2.11': - dependencies: - '@scalar/client-side-rendering': 0.3.4 - - '@scalar/schemas@0.7.4': - dependencies: - '@scalar/helpers': 0.9.2 - '@scalar/validation': 0.6.2 - - '@scalar/types@0.16.4': - dependencies: - '@scalar/helpers': 0.9.2 - nanoid: 5.1.16 - type-fest: 5.8.0 - zod: 4.3.6 - - '@scalar/validation@0.6.2': {} - '@scarf/scarf@1.4.0': {} '@shikijs/core@2.5.0': @@ -12267,8 +12201,6 @@ snapshots: nanoid@3.3.16: {} - nanoid@5.1.16: {} - napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} @@ -13385,8 +13317,6 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 - tagged-tag@1.0.0: {} - tailwind-merge@3.4.0: {} tailwindcss@4.2.2: {} @@ -13551,10 +13481,6 @@ snapshots: type-fest@4.41.0: {} - type-fest@5.8.0: - dependencies: - tagged-tag: 1.0.0 - type-is@1.6.18: dependencies: media-typer: 0.3.0 From e65ca7f22b4805050399c73b925dcb6be8b0d8a2 Mon Sep 17 00:00:00 2001 From: kuyacarlo <106532351+kuyacarlo@users.noreply.github.com> Date: Sun, 26 Jul 2026 09:35:28 +0800 Subject: [PATCH 6/7] fix(api): use current Scalar CDN createApiReference embed The data-url + standalone.min.js shell rendered a blank page. Switch to Scalar.createApiReference('#app', { url }) per current html-js docs. Co-authored-by: Cursor --- apps/api/src/bootstrap.ts | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/apps/api/src/bootstrap.ts b/apps/api/src/bootstrap.ts index 4e8039a..c8bba18 100644 --- a/apps/api/src/bootstrap.ts +++ b/apps/api/src/bootstrap.ts @@ -80,6 +80,8 @@ function setupApiDocs(app: INestApplication, server: Express): void { } function cdnScalarHtml(specUrl: string): string { + // Current Scalar CDN API — the old data-url + standalone.min.js embed is a blank page. + // https://scalar.com/products/api-references/integrations/html-js return ` @@ -88,16 +90,14 @@ function cdnScalarHtml(specUrl: string): string { WorkSight API - + - + }); + `; } @@ -107,21 +107,24 @@ function cdnSwaggerHtml(specUrl: string): string { + WorkSight API — Swagger UI - +
`; From 10f11168bc43806eb03eddfb5789bd5dbf18abea Mon Sep 17 00:00:00 2001 From: kuyacarlo <106532351+kuyacarlo@users.noreply.github.com> Date: Sun, 26 Jul 2026 09:42:54 +0800 Subject: [PATCH 7/7] docs(api): drop GET / hello and fully annotate OpenAPI Remove the root hello handler (404s now). Add Nest Swagger decorators and DTO schemas for every remaining endpoint so Scalar/Swagger show request/response shapes, optional query params, and error responses. Co-authored-by: Cursor --- apps/api/src/app.controller.ts | 21 +- apps/api/src/app.module.ts | 2 - apps/api/src/app.service.ts | 8 - .../src/attendance/attendance.controller.ts | 18 +- apps/api/src/bootstrap.ts | 23 +- apps/api/src/openapi/schemas.ts | 374 ++++++++++++++++++ apps/api/src/surveys/surveys.controller.ts | 50 ++- apps/api/src/tasks/tasks.controller.ts | 50 ++- apps/api/src/users/users.controller.ts | 35 +- 9 files changed, 550 insertions(+), 31 deletions(-) delete mode 100644 apps/api/src/app.service.ts create mode 100644 apps/api/src/openapi/schemas.ts diff --git a/apps/api/src/app.controller.ts b/apps/api/src/app.controller.ts index 05be9bb..c801966 100644 --- a/apps/api/src/app.controller.ts +++ b/apps/api/src/app.controller.ts @@ -1,23 +1,30 @@ import { Controller, Get, Header } from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, ApiProduces, ApiTags } from '@nestjs/swagger'; import { DatabaseService } from './db/database.service'; +import { HealthDto } from './openapi/schemas'; +@ApiTags('system') @Controller() export class AppController { constructor(private readonly db: DatabaseService) {} - @Get() - getHello(): string { - return `hello`; - } - @Get('ping') @Header('Content-Type', 'text/plain') - ping() { + @ApiOperation({ summary: 'Liveness probe', description: 'Returns plain-text `pong`.' }) + @ApiProduces('text/plain') + @ApiOkResponse({ description: 'Service is up', schema: { type: 'string', example: 'pong' } }) + ping(): string { return `pong`; } @Get('health') - async health() { + @ApiOperation({ + summary: 'Readiness / data-source check', + description: + 'Reports process uptime and whether the API is serving Postgres, fixtures, or cannot reach the database.', + }) + @ApiOkResponse({ type: HealthDto }) + async health(): Promise { if (!this.db.enabled) { return { status: 'ok', uptime: process.uptime(), database: 'fixtures' }; } diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 8c67fda..afd0e6e 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,6 +1,5 @@ import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; -import { AppService } from './app.service'; import { AttendanceModule } from './attendance/attendance.module'; import { DatabaseModule } from './db/database.module'; import { SurveysModule } from './surveys/surveys.module'; @@ -10,6 +9,5 @@ import { UsersModule } from './users/users.module'; @Module({ imports: [DatabaseModule, UsersModule, TasksModule, AttendanceModule, SurveysModule], controllers: [AppController], - providers: [AppService], }) export class AppModule {} diff --git a/apps/api/src/app.service.ts b/apps/api/src/app.service.ts deleted file mode 100644 index 927d7cc..0000000 --- a/apps/api/src/app.service.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -@Injectable() -export class AppService { - getHello(): string { - return 'Hello World!'; - } -} diff --git a/apps/api/src/attendance/attendance.controller.ts b/apps/api/src/attendance/attendance.controller.ts index 9bf9483..b2d9c0a 100644 --- a/apps/api/src/attendance/attendance.controller.ts +++ b/apps/api/src/attendance/attendance.controller.ts @@ -1,18 +1,32 @@ -import { Controller, Get, Param, Query } from '@nestjs/common'; +import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, ApiParam, ApiQuery, ApiTags } from '@nestjs/swagger'; import type { AttendanceRecord, AttendanceStats } from '@worksight/common'; +import { AttendanceRecordDto, AttendanceStatsDto } from '../openapi/schemas'; import { AttendanceService } from './attendance.service'; +@ApiTags('attendance') @Controller('attendance') export class AttendanceController { constructor(private readonly attendanceService: AttendanceService) {} @Get() + @ApiOperation({ summary: 'List attendance records' }) + @ApiQuery({ + name: 'employee_id', + required: false, + format: 'uuid', + description: 'When set, only records for this employee', + }) + @ApiOkResponse({ type: AttendanceRecordDto, isArray: true }) getAll(@Query('employee_id') employeeId?: string): Promise { return this.attendanceService.findAll(employeeId); } @Get('stats/:employeeId') - getStats(@Param('employeeId') employeeId: string): Promise { + @ApiOperation({ summary: 'Per-employee attendance stats' }) + @ApiParam({ name: 'employeeId', format: 'uuid' }) + @ApiOkResponse({ type: AttendanceStatsDto }) + getStats(@Param('employeeId', ParseUUIDPipe) employeeId: string): Promise { return this.attendanceService.getStatsForEmployee(employeeId); } } diff --git a/apps/api/src/bootstrap.ts b/apps/api/src/bootstrap.ts index c8bba18..7cb9900 100644 --- a/apps/api/src/bootstrap.ts +++ b/apps/api/src/bootstrap.ts @@ -55,12 +55,29 @@ export async function createApp(): Promise { */ function setupApiDocs(app: INestApplication, server: Express): void { const config = new DocumentBuilder() - .setTitle('WorkSight') - .setDescription('Check your tasks, manage your well-being') + .setTitle('WorkSight API') + .setDescription( + [ + 'Wellness-aware workforce API. Reads Postgres when `DATABASE_URL` is set;', + 'otherwise serves `@worksight/common` fixtures.', + '', + 'Interactive docs: `/api` or `/reference` (Scalar), `/swagger` (Swagger UI).', + 'Machine-readable OpenAPI: `/openapi.json`.', + ].join('\n') + ) .setVersion('1.0') + .addTag('system', 'Liveness and readiness') + .addTag('users', 'Employees') + .addTag('teams', 'Teams') + .addTag('tasks', 'Assignments / tasks') + .addTag('activities', 'Activity feed') + .addTag('attendance', 'Attendance records') + .addTag('surveys', 'Burnout / wellness surveys') .addBearerAuth() .build(); - const document = SwaggerModule.createDocument(app, config); + const document = SwaggerModule.createDocument(app, config, { + operationIdFactory: (_controllerKey: string, methodKey: string) => methodKey, + }); const sendOpenApi = (_req: Request, res: Response) => { res.json(document); diff --git a/apps/api/src/openapi/schemas.ts b/apps/api/src/openapi/schemas.ts new file mode 100644 index 0000000..4546137 --- /dev/null +++ b/apps/api/src/openapi/schemas.ts @@ -0,0 +1,374 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +/** OpenAPI models mirroring @worksight/common wire shapes. */ + +export class HealthDto { + @ApiProperty({ enum: ['ok', 'degraded'] }) + status!: 'ok' | 'degraded'; + + @ApiProperty({ description: 'Process uptime in seconds' }) + uptime!: number; + + @ApiProperty({ + enum: ['fixtures', 'postgres', 'unreachable'], + description: 'Active data source (or connectivity state when Postgres is configured)', + }) + database!: 'fixtures' | 'postgres' | 'unreachable'; +} + +export class EmployeeProfileDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty({ example: 'E001' }) + internal_id!: string; + + @ApiProperty({ format: 'email' }) + email!: string; + + @ApiProperty() + name!: string; + + @ApiProperty({ + enum: ['employee', 'team_lead', 'manager', 'admin', 'super_admin', 'guest'], + }) + role!: string; + + @ApiProperty({ + isArray: true, + enum: ['frontend', 'backend', 'data', 'sysadmin', 'guest', 'business', 'engineering', ''], + }) + department!: string[]; + + @ApiPropertyOptional({ format: 'uuid', nullable: true }) + team?: string | null; + + @ApiPropertyOptional({ format: 'uuid', nullable: true }) + manager_id?: string | null; + + @ApiProperty({ type: String, format: 'date-time' }) + date_joined!: Date; + + @ApiProperty({ type: String, format: 'date-time' }) + created_at!: Date; + + @ApiProperty({ type: String, format: 'date-time' }) + updated_at!: Date; +} + +export class RoleCountDto { + @ApiProperty() + role!: string; + + @ApiProperty() + count!: number; +} + +export class DepartmentCountDto { + @ApiProperty() + department!: string; + + @ApiProperty() + count!: number; +} + +export class EmployeeStatsDto { + @ApiProperty() + totalEmployees!: number; + + @ApiProperty({ type: [RoleCountDto] }) + roles!: RoleCountDto[]; + + @ApiProperty({ type: [DepartmentCountDto] }) + departments!: DepartmentCountDto[]; + + @ApiProperty({ description: 'Employees with no manager_id' }) + adminCount!: number; +} + +export class TeamDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty() + name!: string; + + @ApiPropertyOptional() + description?: string; + + @ApiProperty() + department!: string; + + @ApiProperty({ format: 'uuid' }) + manager_id!: string; + + @ApiProperty({ type: [String], format: 'uuid' }) + member_ids!: string[]; + + @ApiPropertyOptional({ format: 'uuid', nullable: true }) + parent_team_id?: string | null; + + @ApiProperty({ type: String, format: 'date-time' }) + created_at!: Date; + + @ApiProperty({ type: String, format: 'date-time' }) + updated_at!: Date; +} + +export class AssignmentDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty({ format: 'uuid' }) + employee_id!: string; + + @ApiPropertyOptional({ format: 'uuid', nullable: true }) + source_id!: string | null; + + @ApiPropertyOptional({ nullable: true }) + external_id!: string | null; + + @ApiProperty({ + enum: ['feature', 'bug', 'task', 'research', 'documentation', 'infrastructure'], + }) + type!: string; + + @ApiPropertyOptional({ nullable: true }) + title!: string | null; + + @ApiProperty({ enum: ['todo', 'in_progress', 'completed'] }) + status!: string; + + @ApiPropertyOptional({ nullable: true }) + sprint!: string | null; + + @ApiPropertyOptional({ nullable: true }) + epic!: string | null; + + @ApiPropertyOptional({ nullable: true }) + points!: number | null; + + @ApiProperty({ enum: ['low', 'medium', 'high', 'critical'] }) + priority!: string; + + @ApiProperty({ type: String, format: 'date-time' }) + created_at!: Date; + + @ApiProperty({ type: String, format: 'date-time' }) + updated_at!: Date; +} + +export class ActivityDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty({ format: 'uuid' }) + source_id!: string; + + @ApiProperty() + external_id!: string; + + @ApiProperty({ format: 'uuid' }) + employee_id!: string; + + @ApiProperty({ + enum: [ + 'code_commit', + 'task_update', + 'task_creation', + 'communication', + 'research', + 'incident_response', + 'hotfix', + 'documentation', + ], + }) + type!: string; + + @ApiProperty({ type: String, format: 'date-time' }) + timestamp!: Date; + + @ApiProperty() + description!: string; + + @ApiProperty() + is_after_hours!: boolean; + + @ApiProperty() + is_weekend!: boolean; + + @ApiProperty() + is_urgent!: boolean; + + @ApiProperty({ type: String, format: 'date-time' }) + created_at!: Date; +} + +export class TaskStatsDto { + @ApiProperty() + totalTasks!: number; + + @ApiProperty() + completedTasks!: number; + + @ApiProperty({ description: 'Percent of tasks completed (0–100)' }) + completionRate!: number; + + @ApiProperty() + totalStoryPoints!: number; + + @ApiProperty() + completedStoryPoints!: number; + + @ApiProperty({ description: 'Percent of story points completed (0–100)' }) + storyPointsCompletionRate!: number; + + @ApiProperty() + totalActivities!: number; + + @ApiProperty() + afterHoursActivities!: number; + + @ApiProperty() + weekendActivities!: number; + + @ApiProperty() + urgentActivities!: number; + + @ApiProperty({ description: 'Derived score; lower when after-hours/weekend activity is high' }) + workLifeBalanceScore!: number; +} + +export class AttendanceRecordDto { + @ApiProperty({ format: 'uuid' }) + system_id!: string; + + @ApiProperty({ format: 'uuid' }) + employee_id!: string; + + @ApiProperty({ type: String, format: 'date-time' }) + date!: Date; + + @ApiPropertyOptional({ type: String, format: 'date-time', nullable: true }) + check_in!: Date | null; + + @ApiPropertyOptional({ type: String, format: 'date-time', nullable: true }) + check_out!: Date | null; + + @ApiPropertyOptional({ nullable: true }) + hours_worked!: number | null; + + @ApiProperty({ type: String, format: 'date-time' }) + created_at!: Date; +} + +export class AttendanceStatsDto { + @ApiProperty() + totalRecords!: number; + + @ApiProperty() + totalHours!: number; + + @ApiProperty() + daysPresent!: number; + + @ApiProperty() + averageHours!: number; +} + +export class SurveyDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty({ format: 'uuid' }) + created_by!: string; + + @ApiProperty({ type: String, format: 'date-time' }) + created_at!: Date; + + @ApiProperty() + num_questions!: number; +} + +export class SurveyQuestionDto { + @ApiProperty() + id!: number; + + @ApiProperty({ format: 'uuid' }) + survey_id!: string; + + @ApiProperty() + question_text!: string; + + @ApiPropertyOptional() + question_subtext?: string; + + @ApiProperty({ + description: 'Wellness dimension label, or a list of dimensions', + oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }], + }) + dimension!: string | string[]; + + @ApiProperty({ enum: ['scale', 'text', 'radio', 'number', 'email'] }) + type!: string; + + @ApiProperty() + required!: boolean; + + @ApiPropertyOptional({ type: [String] }) + options?: string[]; + + @ApiProperty() + reverseScore!: boolean; + + @ApiPropertyOptional() + min_value?: number; + + @ApiPropertyOptional() + min_label?: string; + + @ApiPropertyOptional() + max_value?: number; + + @ApiPropertyOptional() + max_label?: string; + + @ApiPropertyOptional({ oneOf: [{ type: 'string' }, { type: 'number' }] }) + defaultValue?: string | number; +} + +export class SurveyResponseMetadataDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty({ format: 'uuid' }) + survey_id!: string; + + @ApiProperty({ format: 'uuid' }) + employee_id!: string; + + @ApiProperty({ type: String, format: 'date-time' }) + submitted_at!: Date; + + @ApiPropertyOptional({ nullable: true, description: 'Mean of numeric answers (2 dp), or null' }) + avg_score!: number | null; +} + +export class SurveyAnswerDto { + @ApiProperty({ minimum: 0 }) + question_id!: number; + + @ApiPropertyOptional({ + nullable: true, + oneOf: [{ type: 'string' }, { type: 'number' }, { type: 'null' }], + }) + response!: string | number | null; +} + +export class SurveySubmissionDto { + @ApiProperty({ format: 'uuid' }) + employee_id!: string; + + @ApiProperty({ type: [SurveyAnswerDto], minItems: 1 }) + answers!: SurveyAnswerDto[]; +} diff --git a/apps/api/src/surveys/surveys.controller.ts b/apps/api/src/surveys/surveys.controller.ts index 7d35f18..1671811 100644 --- a/apps/api/src/surveys/surveys.controller.ts +++ b/apps/api/src/surveys/surveys.controller.ts @@ -5,39 +5,85 @@ import { Get, HttpCode, Param, + ParseUUIDPipe, Post, Query, } from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiBody, + ApiCreatedResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiQuery, + ApiTags, +} from '@nestjs/swagger'; import { SurveySubmissionSchema, type Survey, type SurveyQuestion, type SurveyResponseMetadata, } from '@worksight/common'; +import { + SurveyDto, + SurveyQuestionDto, + SurveyResponseMetadataDto, + SurveySubmissionDto, +} from '../openapi/schemas'; import { SurveysService } from './surveys.service'; +@ApiTags('surveys') @Controller('surveys') export class SurveysController { constructor(private readonly surveysService: SurveysService) {} @Get() + @ApiOperation({ summary: 'List survey templates' }) + @ApiOkResponse({ type: SurveyDto, isArray: true }) getAll(): Promise { return this.surveysService.findAll(); } @Get('responses') + @ApiOperation({ summary: 'List survey submissions' }) + @ApiQuery({ + name: 'employee_id', + required: false, + format: 'uuid', + description: 'When set, only submissions from this employee', + }) + @ApiOkResponse({ type: SurveyResponseMetadataDto, isArray: true }) getSubmissions(@Query('employee_id') employeeId?: string): Promise { return this.surveysService.findSubmissions(employeeId); } @Get(':id/questions') - getQuestions(@Param('id') id: string): Promise { + @ApiOperation({ summary: 'List questions for a survey' }) + @ApiParam({ name: 'id', format: 'uuid', description: 'Survey id' }) + @ApiOkResponse({ type: SurveyQuestionDto, isArray: true }) + @ApiNotFoundResponse({ description: 'Survey not found' }) + getQuestions(@Param('id', ParseUUIDPipe) id: string): Promise { return this.surveysService.findQuestions(id); } @Post(':id/responses') @HttpCode(201) - submit(@Param('id') id: string, @Body() body: unknown): Promise { + @ApiOperation({ + summary: 'Submit survey answers', + description: + 'Validates the body with SurveySubmissionSchema. avg_score is the mean of numeric answers.', + }) + @ApiParam({ name: 'id', format: 'uuid', description: 'Survey id' }) + @ApiBody({ type: SurveySubmissionDto }) + @ApiCreatedResponse({ type: SurveyResponseMetadataDto }) + @ApiBadRequestResponse({ description: 'Invalid submission payload' }) + @ApiNotFoundResponse({ description: 'Survey not found' }) + submit( + @Param('id', ParseUUIDPipe) id: string, + @Body() body: SurveySubmissionDto + ): Promise { const parsed = SurveySubmissionSchema.safeParse(body); if (!parsed.success) { throw new BadRequestException(parsed.error.issues); diff --git a/apps/api/src/tasks/tasks.controller.ts b/apps/api/src/tasks/tasks.controller.ts index fed7add..dd13a18 100644 --- a/apps/api/src/tasks/tasks.controller.ts +++ b/apps/api/src/tasks/tasks.controller.ts @@ -1,23 +1,58 @@ -import { Controller, Get, NotFoundException, Param, Query } from '@nestjs/common'; +import { + Controller, + Get, + NotFoundException, + Param, + ParseUUIDPipe, + Query, +} from '@nestjs/common'; +import { + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiQuery, + ApiTags, +} from '@nestjs/swagger'; import type { Activity, Assignment } from '@worksight/common'; +import { ActivityDto, AssignmentDto, TaskStatsDto } from '../openapi/schemas'; import { TasksService } from './tasks.service'; +@ApiTags('tasks') @Controller('tasks') export class TasksController { constructor(private readonly tasksService: TasksService) {} @Get() + @ApiOperation({ summary: 'List assignments (tasks)' }) + @ApiQuery({ + name: 'employee_id', + required: false, + format: 'uuid', + description: 'When set, only assignments for this employee', + }) + @ApiOkResponse({ type: AssignmentDto, isArray: true }) getAll(@Query('employee_id') employeeId?: string): Promise { return this.tasksService.findAll(employeeId); } @Get('stats/:employeeId') - getStats(@Param('employeeId') employeeId: string) { + @ApiOperation({ + summary: 'Per-employee task + activity stats', + description: 'Completion rates, story points, and work-life signals for one employee.', + }) + @ApiParam({ name: 'employeeId', format: 'uuid' }) + @ApiOkResponse({ type: TaskStatsDto }) + getStats(@Param('employeeId', ParseUUIDPipe) employeeId: string): Promise { return this.tasksService.getStatsForEmployee(employeeId); } @Get(':id') - async getOne(@Param('id') id: string): Promise { + @ApiOperation({ summary: 'Get assignment by id' }) + @ApiParam({ name: 'id', format: 'uuid' }) + @ApiOkResponse({ type: AssignmentDto }) + @ApiNotFoundResponse({ description: 'Assignment not found' }) + async getOne(@Param('id', ParseUUIDPipe) id: string): Promise { const assignment = await this.tasksService.findById(id); if (!assignment) { throw new NotFoundException(`Task ${id} not found`); @@ -26,11 +61,20 @@ export class TasksController { } } +@ApiTags('activities') @Controller('activities') export class ActivitiesController { constructor(private readonly tasksService: TasksService) {} @Get() + @ApiOperation({ summary: 'List activities' }) + @ApiQuery({ + name: 'employee_id', + required: false, + format: 'uuid', + description: 'When set, only activities for this employee', + }) + @ApiOkResponse({ type: ActivityDto, isArray: true }) getAll(@Query('employee_id') employeeId?: string): Promise { return this.tasksService.findAllActivities(employeeId); } diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts index f1a86a9..c183e35 100644 --- a/apps/api/src/users/users.controller.ts +++ b/apps/api/src/users/users.controller.ts @@ -1,23 +1,43 @@ -import { Controller, Get, NotFoundException, Param } from '@nestjs/common'; +import { Controller, Get, NotFoundException, Param, ParseUUIDPipe } from '@nestjs/common'; +import { + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiTags, +} from '@nestjs/swagger'; import type { EmployeeProfile, Team } from '@worksight/common'; +import { EmployeeProfileDto, EmployeeStatsDto, TeamDto } from '../openapi/schemas'; import { UsersService } from './users.service'; +@ApiTags('users') @Controller('users') export class UsersController { constructor(private readonly usersService: UsersService) {} @Get() + @ApiOperation({ summary: 'List employees' }) + @ApiOkResponse({ type: EmployeeProfileDto, isArray: true }) getAll(): Promise { return this.usersService.findAll(); } @Get('stats') - getStats() { + @ApiOperation({ + summary: 'Aggregate employee stats', + description: 'Role and department counts over the active data source (Postgres or fixtures).', + }) + @ApiOkResponse({ type: EmployeeStatsDto }) + getStats(): Promise { return this.usersService.getStats(); } @Get(':id') - async getOne(@Param('id') id: string): Promise { + @ApiOperation({ summary: 'Get employee by id' }) + @ApiParam({ name: 'id', format: 'uuid' }) + @ApiOkResponse({ type: EmployeeProfileDto }) + @ApiNotFoundResponse({ description: 'Employee not found' }) + async getOne(@Param('id', ParseUUIDPipe) id: string): Promise { const employee = await this.usersService.findById(id); if (!employee) { throw new NotFoundException(`User ${id} not found`); @@ -26,17 +46,24 @@ export class UsersController { } } +@ApiTags('teams') @Controller('teams') export class TeamsController { constructor(private readonly usersService: UsersService) {} @Get() + @ApiOperation({ summary: 'List teams' }) + @ApiOkResponse({ type: TeamDto, isArray: true }) getAll(): Promise { return this.usersService.findAllTeams(); } @Get(':id') - async getOne(@Param('id') id: string): Promise { + @ApiOperation({ summary: 'Get team by id' }) + @ApiParam({ name: 'id', format: 'uuid' }) + @ApiOkResponse({ type: TeamDto }) + @ApiNotFoundResponse({ description: 'Team not found' }) + async getOne(@Param('id', ParseUUIDPipe) id: string): Promise { const team = await this.usersService.findTeamById(id); if (!team) { throw new NotFoundException(`Team ${id} not found`);