Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions apps/api/api/index.js
Original file line number Diff line number Diff line change
@@ -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;
13 changes: 7 additions & 6 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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"
Expand Down
Empty file added apps/api/public/.gitkeep
Empty file.
21 changes: 14 additions & 7 deletions apps/api/src/app.controller.ts
Original file line number Diff line number Diff line change
@@ -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<HealthDto> {
if (!this.db.enabled) {
return { status: 'ok', uptime: process.uptime(), database: 'fixtures' };
}
Expand Down
2 changes: 0 additions & 2 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -10,6 +9,5 @@ import { UsersModule } from './users/users.module';
@Module({
imports: [DatabaseModule, UsersModule, TasksModule, AttendanceModule, SurveysModule],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
8 changes: 0 additions & 8 deletions apps/api/src/app.service.ts

This file was deleted.

18 changes: 16 additions & 2 deletions apps/api/src/attendance/attendance.controller.ts
Original file line number Diff line number Diff line change
@@ -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<AttendanceRecord[]> {
return this.attendanceService.findAll(employeeId);
}

@Get('stats/:employeeId')
getStats(@Param('employeeId') employeeId: string): Promise<AttendanceStats> {
@ApiOperation({ summary: 'Per-employee attendance stats' })
@ApiParam({ name: 'employeeId', format: 'uuid' })
@ApiOkResponse({ type: AttendanceStatsDto })
getStats(@Param('employeeId', ParseUUIDPipe) employeeId: string): Promise<AttendanceStats> {
return this.attendanceService.getStatsForEmployee(employeeId);
}
}
148 changes: 148 additions & 0 deletions apps/api/src/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
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, type Request, type Response } 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<BootstrappedApp> {
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)));

setupApiDocs(app, server);

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 };
}

/**
* 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). @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()
.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, {
operationIdFactory: (_controllerKey: string, methodKey: string) => methodKey,
});

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);

// `/api` is the URL people already open; Scalar replaces the broken Swagger UI.
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 {
// 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 `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>WorkSight API</title>
</head>
<body>
<div id="app"></div>
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
<script>
Scalar.createApiReference('#app', {
url: ${JSON.stringify(specUrl)},
metaData: { title: 'WorkSight API' },
});
</script>
</body>
</html>`;
}

function cdnSwaggerHtml(specUrl: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>WorkSight API — Swagger UI</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.27.1/swagger-ui.css" />
<style>html { box-sizing: border-box; overflow-y: scroll; } body { margin: 0; background: #fafafa; }</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5.27.1/swagger-ui-bundle.js" crossorigin></script>
<script src="https://unpkg.com/swagger-ui-dist@5.27.1/swagger-ui-standalone-preset.js" crossorigin></script>
<script>
window.onload = function () {
window.ui = SwaggerUIBundle({
url: ${JSON.stringify(specUrl)},
dom_id: '#swagger-ui',
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
layout: 'StandaloneLayout',
});
};
</script>
</body>
</html>`;
}
34 changes: 7 additions & 27 deletions apps/api/src/main.ts
Original file line number Diff line number Diff line change
@@ -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();
Loading
Loading