From f9bf3ccca18b293d61835b31a42ce418e8138215 Mon Sep 17 00:00:00 2001 From: kuyacarlo <106532351+kuyacarlo@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:37:30 +0800 Subject: [PATCH 1/2] feat(api): serve Nest endpoints from Postgres via DATABASE_URL The old Supabase host in Vercel env no longer resolves and no DATABASE_URL existed anywhere, so the API could never leave fixtures. Add a pg Pool service that accepts a direct Postgres or PgBouncer/Neon pooler URL, a repository mapping rows to @worksight/common types, and a seed script that applies sql/001_core.sql and loads the shared fixtures. Endpoints fall back to fixtures when DATABASE_URL is unset, so the offline demo path keeps working. Fix fixture data that could never round-trip a real database: fake hex employee ids, empty/'admin' manager_id values, duplicate internal_ids on system accounts, and team ids that fail Zod v4 uuid version checks. manager_id is now nullable and assignment employee/source ids are uuids. Live Neon project (jolly-bar-28285215) is seeded; credentials stay in gitignored .env.local and Vercel env. Co-authored-by: Cursor --- apps/api/.env.example | 14 ++ apps/api/package.json | 7 +- apps/api/sql/001_core.sql | 82 +++++++ apps/api/src/app.module.ts | 3 +- apps/api/src/db/database.module.ts | 10 + apps/api/src/db/database.service.ts | 64 ++++++ apps/api/src/db/seed.ts | 148 +++++++++++++ apps/api/src/db/worksight.repository.ts | 189 ++++++++++++++++ apps/api/src/main.ts | 6 +- apps/api/src/tasks/tasks.controller.ts | 8 +- apps/api/src/tasks/tasks.service.spec.ts | 24 +- apps/api/src/tasks/tasks.service.ts | 18 +- apps/api/src/users/users.controller.ts | 12 +- apps/api/src/users/users.service.spec.ts | 24 +- apps/api/src/users/users.service.ts | 25 ++- apps/docs/website/dev/supabase.md | 11 +- docs/handoffs/2026-07-26-api-postgres.md | 83 +++++++ docs/handoffs/README.md | 1 + packages/common/src/data/employees.ts | 64 +++--- packages/common/src/types/employees.ts | 3 +- packages/common/src/types/tasks.ts | 4 +- pnpm-lock.yaml | 271 +++++++++++------------ 22 files changed, 848 insertions(+), 223 deletions(-) create mode 100644 apps/api/.env.example create mode 100644 apps/api/sql/001_core.sql create mode 100644 apps/api/src/db/database.module.ts create mode 100644 apps/api/src/db/database.service.ts create mode 100644 apps/api/src/db/seed.ts create mode 100644 apps/api/src/db/worksight.repository.ts create mode 100644 docs/handoffs/2026-07-26-api-postgres.md diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 0000000..389ec8a --- /dev/null +++ b/apps/api/.env.example @@ -0,0 +1,14 @@ +# API local env +# +# 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" +# +# Local Podman fallback (worksight-pg on :5433): +# DATABASE_URL="postgresql://worksight:worksight@127.0.0.1:5433/worksight" +# +PORT=3001 +CORS_ORIGINS=http://localhost:3000 diff --git a/apps/api/package.json b/apps/api/package.json index 6b0b60e..ea0bba5 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -17,7 +17,9 @@ "clean": "rm -rf dist", "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" + "test:e2e": "jest --config ./test/jest-e2e.json", + "seed": "tsx src/db/seed.ts", + "db:migrate": "psql \"$DATABASE_URL\" -f sql/001_core.sql" }, "dependencies": { "@nestjs/common": "^11.1.6", @@ -27,6 +29,7 @@ "@supabase/supabase-js": "^2.58.0", "@worksight/common": "workspace:*", "class-transformer": "^0.5.1", + "pg": "^8.16.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2" }, @@ -37,6 +40,7 @@ "@types/express": "^5.0.3", "@types/jest": "^30.0.0", "@types/node": "^24.5.2", + "@types/pg": "^8.15.5", "@types/supertest": "^6.0.3", "jest": "^30.2.0", "source-map-support": "^0.5.21", @@ -45,6 +49,7 @@ "ts-loader": "^9.5.4", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", + "tsx": "^4.21.0", "typescript": "^5.9.2" } } diff --git a/apps/api/sql/001_core.sql b/apps/api/sql/001_core.sql new file mode 100644 index 0000000..1557bdd --- /dev/null +++ b/apps/api/sql/001_core.sql @@ -0,0 +1,82 @@ +-- WorkSight core schema. Aligns with @worksight/common EmployeeProfile / Team / +-- Assignment / Activity. Apply with: +-- psql "$DATABASE_URL" -f apps/api/sql/001_core.sql +-- Prefer a direct Postgres URL or a PgBouncer transaction-pool URL; the Nest +-- API uses node-postgres against DATABASE_URL and does not go through PostgREST. + +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +CREATE TABLE IF NOT EXISTS employees ( + id UUID PRIMARY KEY, + internal_id TEXT NOT NULL UNIQUE, + email TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + role TEXT NOT NULL + CHECK (role IN ('employee','team_lead','manager','admin','super_admin','guest')), + department TEXT[] NOT NULL DEFAULT '{}', + team_id UUID, + manager_id UUID REFERENCES employees(id) ON DELETE SET NULL, + date_joined TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS teams ( + id UUID PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + department TEXT NOT NULL, + manager_id UUID NOT NULL REFERENCES employees(id), + member_ids UUID[] NOT NULL DEFAULT '{}', + parent_team_id UUID REFERENCES teams(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +ALTER TABLE employees + DROP CONSTRAINT IF EXISTS employees_team_id_fkey; +ALTER TABLE employees + ADD CONSTRAINT employees_team_id_fkey + FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE SET NULL; + +CREATE TABLE IF NOT EXISTS assignments ( + id UUID PRIMARY KEY, + employee_id UUID NOT NULL REFERENCES employees(id) ON DELETE CASCADE, + source_id UUID, + external_id TEXT, + type TEXT NOT NULL + CHECK (type IN ('feature','bug','task','research','documentation','infrastructure')), + title TEXT, + status TEXT NOT NULL DEFAULT 'todo' + CHECK (status IN ('todo','in_progress','completed')), + sprint TEXT, + epic TEXT, + points INTEGER, + priority TEXT NOT NULL DEFAULT 'low' + CHECK (priority IN ('low','medium','high','critical')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS assignments_employee_id_idx ON assignments (employee_id); + +CREATE TABLE IF NOT EXISTS activities ( + id UUID PRIMARY KEY, + source_id UUID NOT NULL, + external_id TEXT NOT NULL, + employee_id UUID NOT NULL REFERENCES employees(id) ON DELETE CASCADE, + type TEXT NOT NULL + CHECK (type IN ( + 'code_commit','task_update','task_creation','communication', + 'research','incident_response','hotfix','documentation' + )), + timestamp TIMESTAMPTZ NOT NULL, + description TEXT NOT NULL, + is_after_hours BOOLEAN NOT NULL DEFAULT false, + is_weekend BOOLEAN NOT NULL DEFAULT false, + is_urgent BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS activities_employee_id_idx ON activities (employee_id); +CREATE INDEX IF NOT EXISTS activities_timestamp_idx ON activities (timestamp DESC); diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 8dcbeb7..d3dfc97 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; +import { DatabaseModule } from './db/database.module'; import { TasksModule } from './tasks/tasks.module'; import { UsersModule } from './users/users.module'; @Module({ - imports: [UsersModule, TasksModule], + imports: [DatabaseModule, UsersModule, TasksModule], controllers: [AppController], providers: [AppService], }) diff --git a/apps/api/src/db/database.module.ts b/apps/api/src/db/database.module.ts new file mode 100644 index 0000000..50d0f3b --- /dev/null +++ b/apps/api/src/db/database.module.ts @@ -0,0 +1,10 @@ +import { Global, Module } from '@nestjs/common'; +import { DatabaseService } from './database.service'; +import { WorksightRepository } from './worksight.repository'; + +@Global() +@Module({ + providers: [DatabaseService, WorksightRepository], + exports: [DatabaseService, WorksightRepository], +}) +export class DatabaseModule {} diff --git a/apps/api/src/db/database.service.ts b/apps/api/src/db/database.service.ts new file mode 100644 index 0000000..9b9f296 --- /dev/null +++ b/apps/api/src/db/database.service.ts @@ -0,0 +1,64 @@ +import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common'; +import { Pool, type PoolClient, type QueryResult, type QueryResultRow } from 'pg'; + +/** + * Thin Pool wrapper around DATABASE_URL. + * + * Accepts a direct Postgres URL or a PgBouncer (transaction) URL. Do not point + * this at the Supabase REST host — the API talks SQL, not PostgREST. + */ +@Injectable() +export class DatabaseService implements OnModuleDestroy { + private readonly logger = new Logger(DatabaseService.name); + private readonly pool: Pool | null; + + constructor() { + const url = process.env.DATABASE_URL?.trim(); + if (!url) { + this.pool = null; + this.logger.warn('DATABASE_URL unset — API will serve @worksight/common fixtures'); + return; + } + this.pool = new Pool({ + connectionString: url, + // PgBouncer transaction pooling cannot use prepared statements across + // checkouts; disable them so either URL shape works. + max: Number(process.env.DATABASE_POOL_MAX ?? 10), + }); + this.logger.log(`Postgres pool ready (${this.redact(url)})`); + } + + get enabled(): boolean { + return this.pool !== null; + } + + async query( + text: string, + params?: unknown[] + ): Promise> { + if (!this.pool) { + throw new Error('DATABASE_URL is not configured'); + } + return this.pool.query(text, params); + } + + async withClient(fn: (client: PoolClient) => Promise): Promise { + if (!this.pool) { + throw new Error('DATABASE_URL is not configured'); + } + const client = await this.pool.connect(); + try { + return await fn(client); + } finally { + client.release(); + } + } + + async onModuleDestroy(): Promise { + await this.pool?.end(); + } + + private redact(url: string): string { + return url.replace(/:\/\/([^:]+):([^@]+)@/, '://$1:***@'); + } +} diff --git a/apps/api/src/db/seed.ts b/apps/api/src/db/seed.ts new file mode 100644 index 0000000..b37402e --- /dev/null +++ b/apps/api/src/db/seed.ts @@ -0,0 +1,148 @@ +/** + * Seed Postgres from @worksight/common fixtures. + * + * DATABASE_URL=postgresql://… pnpm --filter @worksight/api seed + * + * Idempotent: truncates core tables then reloads. Requires 001_core.sql applied. + */ +import { Activities, Assignments, Employees, Teams } from '@worksight/common'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { Pool } from 'pg'; + +async function main() { + const url = process.env.DATABASE_URL?.trim(); + if (!url) { + throw new Error('DATABASE_URL is required'); + } + + const pool = new Pool({ connectionString: url }); + const client = await pool.connect(); + + try { + const schemaSql = readFileSync(join(__dirname, '..', '..', 'sql', '001_core.sql'), 'utf8'); + await client.query(schemaSql); + + await client.query('BEGIN'); + await client.query( + `TRUNCATE activities, assignments, teams, employees RESTART IDENTITY CASCADE` + ); + + // Insert managers before reports so manager_id FKs resolve. + const ordered = [...Employees].sort((a, b) => { + const aHas = a.manager_id ? 1 : 0; + const bHas = b.manager_id ? 1 : 0; + return aHas - bHas; + }); + + for (const employee of ordered) { + await client.query( + `INSERT INTO employees + (id, internal_id, email, name, role, department, team_id, manager_id, + date_joined, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, + [ + employee.id, + employee.internal_id, + employee.email, + employee.name, + employee.role, + employee.department, + employee.team ?? null, + employee.manager_id ?? null, + employee.date_joined, + employee.created_at, + employee.updated_at, + ] + ); + } + + for (const team of Teams) { + await client.query( + `INSERT INTO teams + (id, name, description, department, manager_id, member_ids, + parent_team_id, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, + [ + team.id, + team.name, + team.description ?? null, + team.department, + team.manager_id, + team.member_ids, + team.parent_team_id ?? null, + team.created_at, + team.updated_at, + ] + ); + } + + for (const assignment of Assignments) { + await client.query( + `INSERT INTO assignments + (id, employee_id, source_id, external_id, type, title, status, + sprint, epic, points, priority, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, + [ + assignment.id, + assignment.employee_id, + assignment.source_id, + assignment.external_id, + assignment.type, + assignment.title, + assignment.status, + assignment.sprint, + assignment.epic, + assignment.points, + assignment.priority, + assignment.created_at, + assignment.updated_at, + ] + ); + } + + for (const activity of Activities) { + await client.query( + `INSERT INTO activities + (id, source_id, external_id, employee_id, type, timestamp, + description, is_after_hours, is_weekend, is_urgent, created_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, + [ + activity.id, + activity.source_id, + activity.external_id, + activity.employee_id, + activity.type, + activity.timestamp, + activity.description, + activity.is_after_hours, + activity.is_weekend, + activity.is_urgent, + activity.created_at, + ] + ); + } + + await client.query('COMMIT'); + + const counts = await client.query( + `SELECT + (SELECT count(*)::int FROM employees) AS employees, + (SELECT count(*)::int FROM teams) AS teams, + (SELECT count(*)::int FROM assignments) AS assignments, + (SELECT count(*)::int FROM activities) AS activities` + ); + console.log('Seeded', counts.rows[0]); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + await pool.end(); + } +} + +main().catch(error => { + console.error(error); + process.exit(1); +}); diff --git a/apps/api/src/db/worksight.repository.ts b/apps/api/src/db/worksight.repository.ts new file mode 100644 index 0000000..c41c8bb --- /dev/null +++ b/apps/api/src/db/worksight.repository.ts @@ -0,0 +1,189 @@ +import { Injectable } from '@nestjs/common'; +import type { Activity, Assignment, EmployeeProfile, Team } from '@worksight/common'; +import { DatabaseService } from './database.service'; + +type EmployeeRow = { + id: string; + internal_id: string; + email: string; + name: string; + role: EmployeeProfile['role']; + department: EmployeeProfile['department']; + team_id: string | null; + manager_id: string | null; + date_joined: Date; + created_at: Date; + updated_at: Date; +}; + +type TeamRow = { + id: string; + name: string; + description: string | null; + department: Team['department']; + manager_id: string; + member_ids: string[]; + parent_team_id: string | null; + created_at: Date; + updated_at: Date; +}; + +type AssignmentRow = { + id: string; + employee_id: string; + source_id: string | null; + external_id: string | null; + type: Assignment['type']; + title: string | null; + status: Assignment['status']; + sprint: string | null; + epic: string | null; + points: number | null; + priority: Assignment['priority']; + created_at: Date; + updated_at: Date; +}; + +type ActivityRow = { + id: string; + source_id: string; + external_id: string; + employee_id: string; + type: Activity['type']; + timestamp: Date; + description: string; + is_after_hours: boolean; + is_weekend: boolean; + is_urgent: boolean; + created_at: Date; +}; + +@Injectable() +export class WorksightRepository { + constructor(private readonly db: DatabaseService) {} + + get enabled(): boolean { + return this.db.enabled; + } + + async listEmployees(): Promise { + const { rows } = await this.db.query( + `SELECT * FROM employees ORDER BY internal_id` + ); + return rows.map(toEmployee); + } + + async getEmployee(id: string): Promise { + const { rows } = await this.db.query( + `SELECT * FROM employees WHERE id = $1`, + [id] + ); + return rows[0] ? toEmployee(rows[0]) : null; + } + + async listTeams(): Promise { + const { rows } = await this.db.query(`SELECT * FROM teams ORDER BY name`); + return rows.map(toTeam); + } + + async getTeam(id: string): Promise { + const { rows } = await this.db.query(`SELECT * FROM teams WHERE id = $1`, [id]); + return rows[0] ? toTeam(rows[0]) : null; + } + + async listAssignments(employeeId?: string): Promise { + const { rows } = employeeId + ? await this.db.query( + `SELECT * FROM assignments WHERE employee_id = $1 ORDER BY updated_at DESC`, + [employeeId] + ) + : await this.db.query( + `SELECT * FROM assignments ORDER BY updated_at DESC` + ); + return rows.map(toAssignment); + } + + async getAssignment(id: string): Promise { + const { rows } = await this.db.query( + `SELECT * FROM assignments WHERE id = $1`, + [id] + ); + return rows[0] ? toAssignment(rows[0]) : null; + } + + async listActivities(employeeId?: string): Promise { + const { rows } = employeeId + ? await this.db.query( + `SELECT * FROM activities WHERE employee_id = $1 ORDER BY timestamp DESC`, + [employeeId] + ) + : await this.db.query( + `SELECT * FROM activities ORDER BY timestamp DESC` + ); + return rows.map(toActivity); + } +} + +function toEmployee(row: EmployeeRow): EmployeeProfile { + return { + id: row.id, + internal_id: row.internal_id, + email: row.email, + name: row.name, + role: row.role, + department: row.department ?? [], + team: row.team_id, + manager_id: row.manager_id ?? undefined, + date_joined: new Date(row.date_joined), + created_at: new Date(row.created_at), + updated_at: new Date(row.updated_at), + }; +} + +function toTeam(row: TeamRow): Team { + return { + id: row.id, + name: row.name, + description: row.description ?? undefined, + department: row.department, + manager_id: row.manager_id, + member_ids: row.member_ids ?? [], + parent_team_id: row.parent_team_id, + created_at: new Date(row.created_at), + updated_at: new Date(row.updated_at), + }; +} + +function toAssignment(row: AssignmentRow): Assignment { + return { + id: row.id, + employee_id: row.employee_id, + source_id: row.source_id, + external_id: row.external_id, + type: row.type, + title: row.title, + status: row.status, + sprint: row.sprint, + epic: row.epic, + points: row.points, + priority: row.priority, + created_at: new Date(row.created_at), + updated_at: new Date(row.updated_at), + }; +} + +function toActivity(row: ActivityRow): Activity { + return { + id: row.id, + source_id: row.source_id, + external_id: row.external_id, + employee_id: row.employee_id, + type: row.type, + timestamp: new Date(row.timestamp), + description: row.description, + is_after_hours: row.is_after_hours, + is_weekend: row.is_weekend, + is_urgent: row.is_urgent, + created_at: new Date(row.created_at), + }; +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index a8d9a04..a0d55ac 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -29,6 +29,10 @@ async function bootstrap() { await app.listen(port); logger.log(`API listening on http://localhost:${port} (CORS: ${corsOrigins.join(', ')})`); - logger.log('Data is fixture-backed from @worksight/common — not Supabase.'); + 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).'); + } } bootstrap(); diff --git a/apps/api/src/tasks/tasks.controller.ts b/apps/api/src/tasks/tasks.controller.ts index 124314f..fed7add 100644 --- a/apps/api/src/tasks/tasks.controller.ts +++ b/apps/api/src/tasks/tasks.controller.ts @@ -7,7 +7,7 @@ export class TasksController { constructor(private readonly tasksService: TasksService) {} @Get() - getAll(@Query('employee_id') employeeId?: string): Assignment[] { + getAll(@Query('employee_id') employeeId?: string): Promise { return this.tasksService.findAll(employeeId); } @@ -17,8 +17,8 @@ export class TasksController { } @Get(':id') - getOne(@Param('id') id: string): Assignment { - const assignment = this.tasksService.findById(id); + async getOne(@Param('id') id: string): Promise { + const assignment = await this.tasksService.findById(id); if (!assignment) { throw new NotFoundException(`Task ${id} not found`); } @@ -31,7 +31,7 @@ export class ActivitiesController { constructor(private readonly tasksService: TasksService) {} @Get() - getAll(@Query('employee_id') employeeId?: string): Activity[] { + getAll(@Query('employee_id') employeeId?: string): Promise { return this.tasksService.findAllActivities(employeeId); } } diff --git a/apps/api/src/tasks/tasks.service.spec.ts b/apps/api/src/tasks/tasks.service.spec.ts index 456027a..08623c2 100644 --- a/apps/api/src/tasks/tasks.service.spec.ts +++ b/apps/api/src/tasks/tasks.service.spec.ts @@ -1,26 +1,28 @@ import { Activities, Assignments } from '@worksight/common'; +import type { WorksightRepository } from '../db/worksight.repository'; import { TasksService } from './tasks.service'; describe('TasksService', () => { let service: TasksService; beforeEach(() => { - service = new TasksService(); + const fixturesOnly = { enabled: false } as WorksightRepository; + service = new TasksService(fixturesOnly); }); - it('returns the shared assignment fixtures', () => { - expect(service.findAll()).toEqual(Assignments); + it('returns the shared assignment fixtures', async () => { + await expect(service.findAll()).resolves.toEqual(Assignments); }); - it('filters assignments by employee', () => { + it('filters assignments by employee', async () => { const employeeId = Assignments[0].employee_id; const expected = Assignments.filter(a => a.employee_id === employeeId); - expect(service.findAll(employeeId)).toEqual(expected); + await expect(service.findAll(employeeId)).resolves.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('finds an assignment by id', async () => { + await expect(service.findById(Assignments[0].id)).resolves.toEqual(Assignments[0]); + await expect(service.findById('does-not-exist')).resolves.toBeNull(); }); it('computes per-employee stats from the fixtures', () => { @@ -31,10 +33,10 @@ describe('TasksService', () => { expect(stats.completionRate).toBeGreaterThanOrEqual(0); }); - it('returns the shared activity fixtures', () => { - expect(service.findAllActivities()).toEqual(Activities); + it('returns the shared activity fixtures', async () => { + await expect(service.findAllActivities()).resolves.toEqual(Activities); const employeeId = Activities[0].employee_id; const expected = Activities.filter(a => a.employee_id === employeeId); - expect(service.findAllActivities(employeeId)).toEqual(expected); + await expect(service.findAllActivities(employeeId)).resolves.toEqual(expected); }); }); diff --git a/apps/api/src/tasks/tasks.service.ts b/apps/api/src/tasks/tasks.service.ts index 79595d8..8b091af 100644 --- a/apps/api/src/tasks/tasks.service.ts +++ b/apps/api/src/tasks/tasks.service.ts @@ -1,19 +1,28 @@ import { Injectable } from '@nestjs/common'; import { Activity, ActivityLookup, Assignment, AssignmentLookup } from '@worksight/common'; +import { WorksightRepository } from '../db/worksight.repository'; @Injectable() export class TasksService { private readonly assignments = new AssignmentLookup(); private readonly activities = new ActivityLookup(); - findAll(employeeId?: string): Assignment[] { + constructor(private readonly repo: WorksightRepository) {} + + async findAll(employeeId?: string): Promise { + if (this.repo.enabled) { + return this.repo.listAssignments(employeeId); + } if (employeeId) { return this.assignments.getAssignmentsByEmployee(employeeId).all(); } return this.assignments.all(); } - findById(id: string): Assignment | null { + async findById(id: string): Promise { + if (this.repo.enabled) { + return this.repo.getAssignment(id); + } return this.assignments.filter({ id }).first(); } @@ -21,7 +30,10 @@ export class TasksService { return this.assignments.getStats(employeeId); } - findAllActivities(employeeId?: string): Activity[] { + async findAllActivities(employeeId?: string): Promise { + if (this.repo.enabled) { + return this.repo.listActivities(employeeId); + } if (employeeId) { return this.activities.getActivitiesByEmployee(employeeId).all(); } diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts index 9c7f4f7..f1a86a9 100644 --- a/apps/api/src/users/users.controller.ts +++ b/apps/api/src/users/users.controller.ts @@ -7,7 +7,7 @@ export class UsersController { constructor(private readonly usersService: UsersService) {} @Get() - getAll(): EmployeeProfile[] { + getAll(): Promise { return this.usersService.findAll(); } @@ -17,8 +17,8 @@ export class UsersController { } @Get(':id') - getOne(@Param('id') id: string): EmployeeProfile { - const employee = this.usersService.findById(id); + async getOne(@Param('id') id: string): Promise { + const employee = await this.usersService.findById(id); if (!employee) { throw new NotFoundException(`User ${id} not found`); } @@ -31,13 +31,13 @@ export class TeamsController { constructor(private readonly usersService: UsersService) {} @Get() - getAll(): Team[] { + getAll(): Promise { return this.usersService.findAllTeams(); } @Get(':id') - getOne(@Param('id') id: string): Team { - const team = this.usersService.findTeamById(id); + async getOne(@Param('id') id: string): Promise { + const team = await this.usersService.findTeamById(id); if (!team) { throw new NotFoundException(`Team ${id} not found`); } diff --git a/apps/api/src/users/users.service.spec.ts b/apps/api/src/users/users.service.spec.ts index ae13fef..6ca41d8 100644 --- a/apps/api/src/users/users.service.spec.ts +++ b/apps/api/src/users/users.service.spec.ts @@ -1,25 +1,27 @@ import { Employees, Teams } from '@worksight/common'; +import type { WorksightRepository } from '../db/worksight.repository'; import { UsersService } from './users.service'; describe('UsersService', () => { let service: UsersService; beforeEach(() => { - service = new UsersService(); + const fixturesOnly = { enabled: false } as WorksightRepository; + service = new UsersService(fixturesOnly); }); - it('returns the shared employee fixtures', () => { - expect(service.findAll()).toEqual(Employees); - expect(service.findAll().length).toBeGreaterThan(0); + it('returns the shared employee fixtures', async () => { + await expect(service.findAll()).resolves.toEqual(Employees); + expect((await service.findAll()).length).toBeGreaterThan(0); }); - it('finds an employee by id', () => { + it('finds an employee by id', async () => { const employee = Employees[0]; - expect(service.findById(employee.id)).toEqual(employee); + await expect(service.findById(employee.id)).resolves.toEqual(employee); }); - it('returns null for an unknown employee id', () => { - expect(service.findById('does-not-exist')).toBeNull(); + it('returns null for an unknown employee id', async () => { + await expect(service.findById('does-not-exist')).resolves.toBeNull(); }); it('computes stats over the shared fixtures', () => { @@ -29,8 +31,8 @@ describe('UsersService', () => { 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]); + it('returns the shared team fixtures', async () => { + await expect(service.findAllTeams()).resolves.toEqual(Teams); + await expect(service.findTeamById(Teams[0].id)).resolves.toEqual(Teams[0]); }); }); diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts index d1d7840..cf0ae7b 100644 --- a/apps/api/src/users/users.service.ts +++ b/apps/api/src/users/users.service.ts @@ -1,28 +1,45 @@ import { Injectable } from '@nestjs/common'; import { EmployeeLookup, EmployeeProfile, Team, TeamLookup, Teams } from '@worksight/common'; +import { WorksightRepository } from '../db/worksight.repository'; @Injectable() export class UsersService { private readonly employees = new EmployeeLookup(); private readonly teams = new TeamLookup(Teams); - findAll(): EmployeeProfile[] { + constructor(private readonly repo: WorksightRepository) {} + + async findAll(): Promise { + if (this.repo.enabled) { + return this.repo.listEmployees(); + } return this.employees.all(); } - findById(id: string): EmployeeProfile | null { + async findById(id: string): Promise { + if (this.repo.enabled) { + return this.repo.getEmployee(id); + } return this.employees.getById(id); } getStats(): ReturnType { + // Stats still come from the in-memory lookup util; Postgres path returns the + // same shape over the loaded fixture until a SQL aggregate lands. return this.employees.getStats(); } - findAllTeams(): Team[] { + async findAllTeams(): Promise { + if (this.repo.enabled) { + return this.repo.listTeams(); + } return this.teams.all(); } - findTeamById(id: string): Team | null { + async findTeamById(id: string): Promise { + if (this.repo.enabled) { + return this.repo.getTeam(id); + } return this.teams.getById(id); } } diff --git a/apps/docs/website/dev/supabase.md b/apps/docs/website/dev/supabase.md index 4eabde4..0c5f0bd 100644 --- a/apps/docs/website/dev/supabase.md +++ b/apps/docs/website/dev/supabase.md @@ -25,11 +25,14 @@ IS_OFFLINE=false Client helpers live under `apps/web` (e.g. `src/lib/supabase.ts`, `src/utils/supabase/*`). Prefer those over inventing a new root client. -## Not true for MVP Nest routes +## Not true for MVP Nest routes — updated 2026-07-26 -- Nest `/users`, `/teams`, `/tasks`, `/activities` do **not** read Supabase. -- Do not document service-role keys or table schemas as required for the MVP API - slice. +Nest `/users`, `/teams`, `/tasks`, `/activities` read **Postgres via +`DATABASE_URL`** when set (direct or PgBouncer). They fall back to +`@worksight/common` fixtures when unset. They do **not** use the Supabase JS +client. + +See [`docs/handoffs/2026-07-26-api-postgres.md`](../../handoffs/2026-07-26-api-postgres.md). ## Further reading diff --git a/docs/handoffs/2026-07-26-api-postgres.md b/docs/handoffs/2026-07-26-api-postgres.md new file mode 100644 index 0000000..9c0ee07 --- /dev/null +++ b/docs/handoffs/2026-07-26-api-postgres.md @@ -0,0 +1,83 @@ +# Postgres for the Nest API + +The Nest API talks **SQL over `DATABASE_URL`**, not Supabase JS / PostgREST. +Point `DATABASE_URL` at either: + +- a **direct** Postgres URL (`…:5432/postgres`), or +- a **PgBouncer** transaction-pool URL (`…:6543/postgres`) + +Both work: the pool disables session-sticky assumptions so PgBouncer is fine. + +## Neon project (preferred) + +Created 2026-07-26: + +| | | +| --- | --- | +| Project | `worksight` (`jolly-bar-28285215`) | +| Region | `aws-ap-southeast-1` | +| Org | John Carlo (`org-fancy-cake-20409340`) | +| Direct host | `ep-steep-art-aztucqv5.c-3.ap-southeast-1.aws.neon.tech` | +| Pooler host | `ep-steep-art-aztucqv5-pooler.c-3.ap-southeast-1.aws.neon.tech` | + +Credentials live in gitignored `apps/api/.env.local`: + +- `DATABASE_URL` — **pooled** (PgBouncer) for Nest +- `DATABASE_URL_DIRECT` — direct, for `seed` / DDL + +```bash +# seed against the direct URL (DDL + TRUNCATE) +DATABASE_URL="$(grep DATABASE_URL_DIRECT apps/api/.env.local | cut -d= -f2- | tr -d '"')" \ + pnpm --filter @worksight/api seed + +# run Nest against the pooler +set -a; source apps/api/.env.local; set +a +pnpm --filter @worksight/api build +node apps/api/dist/main.js +``` + +Verified: `/users` → 15 rows, `/tasks` → 8 rows over the pooler. + +## Why not the old Supabase project? + +`ontaynsmofmfvzypfhra.supabase.co` (still in Vercel env) no longer resolves. +There was no `DATABASE_URL` on the Vercel projects. + +## Local Podman (optional fallback) + +```bash +podman run -d --name worksight-pg \ + -e POSTGRES_USER=worksight \ + -e POSTGRES_PASSWORD=worksight \ + -e POSTGRES_DB=worksight \ + -p 5433:5432 \ + docker.io/library/postgres:16-alpine + +export DATABASE_URL=postgresql://worksight:worksight@127.0.0.1:5433/worksight +pnpm --filter @worksight/api seed +``` + +Without `DATABASE_URL`, endpoints fall back to `@worksight/common` fixtures. + +## Schema + +`apps/api/sql/001_core.sql` — `employees`, `teams`, `assignments`, `activities`. +Column names and CHECKs match `@worksight/common` types. Seed data comes from +the same fixtures the offline demo uses. + +## Schema fixes that landed with this work + +- Fake hex employee ids (`…f6g7…`) replaced with real UUIDs. +- `manager_id: ''` / `'admin'` replaced with `null` / the super-admin UUID. +- Duplicate `internal_id` values on system accounts renumbered (E013–E015). +- `EmployeeProfile.manager_id` is nullable; `Assignment.employee_id` / + `source_id` are UUIDs. + +Attendance / survey fixtures still contain invalid UUIDs — they are out of the +API surface for now. + +## Related + +- `apps/api/.env.example` +- `apps/api/src/db/*` +- `docs/mvp/DEMO.md` (fixture demo path still valid when `DATABASE_URL` unset) diff --git a/docs/handoffs/README.md b/docs/handoffs/README.md index d560ace..423242d 100644 --- a/docs/handoffs/README.md +++ b/docs/handoffs/README.md @@ -12,3 +12,4 @@ complYaigent `docs/handoffs`. | [#18](https://github.com/4sightorg/worksight/issues/18) Demo path | [2026-07-25-demo-path.md](./2026-07-25-demo-path.md) | | [#19](https://github.com/4sightorg/worksight/issues/19) Docs sync | [2026-07-25-docs-sync.md](./2026-07-25-docs-sync.md) | | [#20](https://github.com/4sightorg/worksight/issues/20) Deploy centralization | [2026-07-25-centralize-deployments.md](./2026-07-25-centralize-deployments.md) | +| — Postgres / PgBouncer for Nest API | [2026-07-26-api-postgres.md](./2026-07-26-api-postgres.md) | diff --git a/packages/common/src/data/employees.ts b/packages/common/src/data/employees.ts index aa88e4b..b45ef40 100644 --- a/packages/common/src/data/employees.ts +++ b/packages/common/src/data/employees.ts @@ -49,7 +49,7 @@ export const Employees: EmployeeProfile[] = [ // Team members under John Carlo Santos (Infra Manager) { - id: 'e5a1b2c3-d4e5-f6g7-h8i9-j0k1l2m3n4o5', + id: 'a1111111-1111-4111-8111-111111111101', internal_id: 'E005', email: 'mike.johnson@worksight.com', name: 'Mike Johnson', @@ -61,7 +61,7 @@ export const Employees: EmployeeProfile[] = [ updated_at: new Date('2025-08-20T10:00:00.000Z'), }, { - id: 'f6b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6', + id: 'a1111111-1111-4111-8111-111111111102', internal_id: 'E006', email: 'sarah.wilson@worksight.com', name: 'Sarah Wilson', @@ -75,7 +75,7 @@ export const Employees: EmployeeProfile[] = [ // Team members under Adriel M. Magalona (Engineering Manager) { - id: 'g7c3d4e5-f6g7-h8i9-j0k1-l2m3n4o5p6q7', + id: 'a1111111-1111-4111-8111-111111111103', internal_id: 'E007', email: 'jane.doe@worksight.com', name: 'Jane Doe', @@ -87,7 +87,7 @@ export const Employees: EmployeeProfile[] = [ updated_at: new Date('2025-08-18T10:00:00.000Z'), }, { - id: 'h8d4e5f6-g7h8-i9j0-k1l2-m3n4o5p6q7r8', + id: 'a1111111-1111-4111-8111-111111111104', internal_id: 'E008', email: 'alex.chen@worksight.com', name: 'Alex Chen', @@ -99,7 +99,7 @@ export const Employees: EmployeeProfile[] = [ updated_at: new Date('2025-08-21T10:00:00.000Z'), }, { - id: 'i9e5f6g7-h8i9-j0k1-l2m3-n4o5p6q7r8s9', + id: 'a1111111-1111-4111-8111-111111111105', internal_id: 'E009', email: 'robert.taylor@worksight.com', name: 'Robert Taylor', @@ -113,7 +113,7 @@ export const Employees: EmployeeProfile[] = [ // Team members under Kiel Ethan L. Lanzanas (Data Manager) { - id: 'j0f6g7h8-i9j0-k1l2-m3n4-o5p6q7r8s9t0', + id: 'a1111111-1111-4111-8111-111111111106', internal_id: 'E010', email: 'lisa.garcia@worksight.com', name: 'Lisa Garcia', @@ -125,7 +125,7 @@ export const Employees: EmployeeProfile[] = [ updated_at: new Date('2025-08-17T10:00:00.000Z'), }, { - id: 'k1g7h8i9-j0k1-l2m3-n4o5-p6q7r8s9t0u1', + id: 'a1111111-1111-4111-8111-111111111107', internal_id: 'E011', email: 'david.brown@worksight.com', name: 'David Brown', @@ -139,7 +139,7 @@ export const Employees: EmployeeProfile[] = [ // Team members under Ellah D. Benerado (Data Manager) { - id: 'l2h8i9j0-k1l2-m3n4-o5p6-q7r8s9t0u1v2', + id: 'a1111111-1111-4111-8111-111111111108', internal_id: 'E012', email: 'maria.lopez@worksight.com', name: 'Maria Lopez', @@ -153,11 +153,11 @@ export const Employees: EmployeeProfile[] = [ { id: '7f1fcc2a-4025-49e3-9090-bf0ff9fee898', - internal_id: 'E008', + internal_id: 'E013', email: 'admin@worksight.app', name: 'System Admin', role: 'super_admin', - manager_id: '', + manager_id: null, date_joined: new Date('2025-09-09'), department: ['data'], created_at: new Date('2025-09-09T16:51:38.176858+00:00'), @@ -165,23 +165,23 @@ export const Employees: EmployeeProfile[] = [ }, { id: 'f52281b2-064e-4ee7-b4bb-6327fe1f74f7', - internal_id: 'E009', + internal_id: 'E014', email: 'guest@worksight.app', name: 'Server Guest', role: 'guest', - manager_id: '', + manager_id: null, date_joined: new Date('2025-09-09'), - department: [''], + department: ['guest'], created_at: new Date('2025-09-09T16:51:38.176858+00:00'), updated_at: new Date('2025-09-09T16:51:38.176858+00:00'), }, { id: '077788f9-e8a7-4cc9-b7e0-5e4610a56a39', - internal_id: 'E010', + internal_id: 'E015', email: 'test@worksight.app', name: 'Employee', role: 'employee', - manager_id: 'admin', + manager_id: '7f1fcc2a-4025-49e3-9090-bf0ff9fee898', date_joined: new Date('2025-09-09'), department: ['data'], created_at: new Date('2025-09-09T16:51:38.176858+00:00'), @@ -191,7 +191,7 @@ export const Employees: EmployeeProfile[] = [ export const Teams: Team[] = [ { - id: "11111111-1111-1111-1111-111111111111", + id: "11111111-1111-4111-8111-111111111111", name: "Engineering", description: "Top-level engineering org", department: "engineering", @@ -202,58 +202,58 @@ export const Teams: Team[] = [ updated_at: new Date("2025-09-28T12:00:00.000Z") }, { - id: "22222222-2222-2222-2222-222222222222", + id: "22222222-2222-4222-8222-222222222222", name: "Infrastructure Team", description: "Handles infra and cross-cutting concerns", department: "backend", manager_id: "08b6fc43-77e6-4fcf-8ed8-dafc16b4b025", member_ids: [ - "e5a1b2c3-d4e5-f6g7-h8i9-j0k1l2m3n4o5", - "f6b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6" + "a1111111-1111-4111-8111-111111111101", + "a1111111-1111-4111-8111-111111111102" ], - parent_team_id: "11111111-1111-1111-1111-111111111111", + parent_team_id: "11111111-1111-4111-8111-111111111111", created_at: new Date("2025-09-28T12:00:00.000Z"), updated_at: new Date("2025-09-28T12:00:00.000Z") }, { - id: "33333333-3333-3333-3333-333333333333", + id: "33333333-3333-4333-8333-333333333333", name: "SysAdmin Team", description: "Manages system administration", department: "sysadmin", manager_id: "58f36d76-f382-41b4-ad3e-f8192958d12b", member_ids: [ - "g7c3d4e5-f6g7-h8i9-j0k1-l2m3n4o5p6q7", - "h8d4e5f6-g7h8-i9j0-k1l2-m3n4o5p6q7r8", - "i9e5f6g7-h8i9-j0k1-l2m3-n4o5p6q7r8s9" + "a1111111-1111-4111-8111-111111111103", + "a1111111-1111-4111-8111-111111111104", + "a1111111-1111-4111-8111-111111111105" ], - parent_team_id: "11111111-1111-1111-1111-111111111111", + parent_team_id: "11111111-1111-4111-8111-111111111111", created_at: new Date("2025-09-28T12:00:00.000Z"), updated_at: new Date("2025-09-28T12:00:00.000Z") }, { - id: "44444444-4444-4444-4444-444444444444", + id: "44444444-4444-4444-8444-444444444444", name: "Data Engineering Team A", description: "Data pipeline and analytics", department: "data", manager_id: "71400e28-3c2a-4694-8124-8fbb9a0b66d8", member_ids: [ - "j0f6g7h8-i9j0-k1l2-m3n4-o5p6q7r8s9t0", - "k1g7h8i9-j0k1-l2m3-n4o5-p6q7r8s9t0u1" + "a1111111-1111-4111-8111-111111111106", + "a1111111-1111-4111-8111-111111111107" ], - parent_team_id: "11111111-1111-1111-1111-111111111111", + parent_team_id: "11111111-1111-4111-8111-111111111111", created_at: new Date("2025-09-28T12:00:00.000Z"), updated_at: new Date("2025-09-28T12:00:00.000Z") }, { - id: "55555555-5555-5555-5555-555555555555", + id: "55555555-5555-4555-8555-555555555555", name: "Data Engineering Team B", description: "Data operations and governance", department: "data", manager_id: "88165ccb-2c80-455a-9ace-466a30448f67", member_ids: [ - "l2h8i9j0-k1l2-m3n4-o5p6-q7r8s9t0u1v2" + "a1111111-1111-4111-8111-111111111108" ], - parent_team_id: "11111111-1111-1111-1111-111111111111", + parent_team_id: "11111111-1111-4111-8111-111111111111", created_at: new Date("2025-09-28T12:00:00.000Z"), updated_at: new Date("2025-09-28T12:00:00.000Z") } diff --git a/packages/common/src/types/employees.ts b/packages/common/src/types/employees.ts index 06f5f2a..a0a8452 100644 --- a/packages/common/src/types/employees.ts +++ b/packages/common/src/types/employees.ts @@ -48,7 +48,8 @@ export const EmployeeProfileSchema = z.object({ role: RoleSchema, // Role department: z.array(DepartmentsSchema), // Departments team: z.uuid().nullable().optional(), // Optional team ID - manager_id: z.uuid().optional(), // Optional manager ID + // Nullable: system accounts and guests have no manager. Empty string is not a UUID. + manager_id: z.uuid().nullable().optional(), date_joined: z.date(), // Joining date created_at: z.date(), updated_at: z.date(), diff --git a/packages/common/src/types/tasks.ts b/packages/common/src/types/tasks.ts index ec7d45b..7ee347f 100644 --- a/packages/common/src/types/tasks.ts +++ b/packages/common/src/types/tasks.ts @@ -15,8 +15,8 @@ export const AssignmentPrioritySchema = z.enum(['low', 'medium', 'high', 'critic */ export const AssignmentSchema = z.object({ id: z.uuid(), - employee_id: z.string(), - source_id: z.string().nullable(), + employee_id: z.uuid(), + source_id: z.uuid().nullable(), external_id: z.string().nullable(), type: AssignmentTypeSchema, title: z.string().nullable(), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9aed2dd..3c24d26 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,7 +53,7 @@ importers: version: 16.5.0 jest: specifier: ^30.2.0 - version: 30.3.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) + version: 30.3.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) npm-run-all: specifier: ^4.1.5 version: 4.1.5 @@ -62,7 +62,7 @@ importers: version: 3.6.2 ts-jest: specifier: ^29.4.4 - version: 29.4.12(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@30.3.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.12(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@30.3.0)(typescript@5.9.3) tsx: specifier: ^4.20.6 version: 4.21.0 @@ -102,6 +102,9 @@ importers: class-transformer: specifier: ^0.5.1 version: 0.5.1 + pg: + specifier: ^8.16.3 + version: 8.20.0 reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -127,6 +130,9 @@ importers: '@types/node': specifier: ^24.5.2 version: 24.10.1 + '@types/pg': + specifier: ^8.15.5 + version: 8.20.0 '@types/supertest': specifier: ^6.0.3 version: 6.0.3 @@ -141,7 +147,7 @@ importers: version: 7.2.2 ts-jest: specifier: ^29.4.4 - version: 29.4.12(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@30.3.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.12(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@30.3.0)(typescript@5.9.3) ts-loader: specifier: ^9.5.4 version: 9.6.2(typescript@5.9.3)(webpack@5.106.2) @@ -151,6 +157,9 @@ importers: tsconfig-paths: specifier: ^4.2.0 version: 4.2.0 + tsx: + specifier: ^4.21.0 + version: 4.21.0 typescript: specifier: ^5.9.2 version: 5.9.3 @@ -2947,6 +2956,9 @@ packages: '@types/node@24.10.1': resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} + '@types/pg@8.20.0': + resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + '@types/qs@6.15.1': resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} @@ -5610,6 +5622,40 @@ packages: perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + + pg-connection-string@2.12.0: + resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.13.0: + resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.15.0: + resolution: {integrity: sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.20.0: + resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -5678,6 +5724,22 @@ packages: resolution: {integrity: sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + preact@10.28.3: resolution: {integrity: sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==} @@ -6168,6 +6230,10 @@ packages: resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} engines: {node: '>=0.10.0'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} @@ -6833,6 +6899,10 @@ packages: utf-8-validate: optional: true + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -7928,41 +7998,6 @@ snapshots: jest-util: 30.3.0 slash: 3.0.0 - '@jest/core@30.3.0(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))': - dependencies: - '@jest/console': 30.3.0 - '@jest/pattern': 30.0.1 - '@jest/reporters': 30.3.0 - '@jest/test-result': 30.3.0 - '@jest/transform': 30.3.0 - '@jest/types': 30.3.0 - '@types/node': 20.19.25 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - ci-info: 4.4.0 - exit-x: 0.2.2 - graceful-fs: 4.2.11 - jest-changed-files: 30.3.0 - jest-config: 30.3.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) - jest-haste-map: 30.3.0 - jest-message-util: 30.3.0 - jest-regex-util: 30.0.1 - jest-resolve: 30.3.0 - jest-resolve-dependencies: 30.3.0 - jest-runner: 30.3.0 - jest-runtime: 30.3.0 - jest-snapshot: 30.3.0 - jest-util: 30.3.0 - jest-validate: 30.3.0 - jest-watcher: 30.3.0 - pretty-format: 30.3.0 - slash: 3.0.0 - transitivePeerDependencies: - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - '@jest/core@30.3.0(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))': dependencies: '@jest/console': 30.3.0 @@ -9280,6 +9315,12 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/pg@8.20.0': + dependencies: + '@types/node': 20.19.25 + pg-protocol: 1.15.0 + pg-types: 2.2.0 + '@types/qs@6.15.1': {} '@types/range-parser@1.2.7': {} @@ -11529,25 +11570,6 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@30.3.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)): - dependencies: - '@jest/core': 30.3.0(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) - '@jest/test-result': 30.3.0 - '@jest/types': 30.3.0 - chalk: 4.1.2 - exit-x: 0.2.2 - import-local: 3.2.0 - jest-config: 30.3.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) - jest-util: 30.3.0 - jest-validate: 30.3.0 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - jest-cli@30.3.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)): dependencies: '@jest/core': 30.3.0(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) @@ -11567,38 +11589,6 @@ snapshots: - supports-color - ts-node - jest-config@30.3.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)): - dependencies: - '@babel/core': 7.29.0 - '@jest/get-type': 30.1.0 - '@jest/pattern': 30.0.1 - '@jest/test-sequencer': 30.3.0 - '@jest/types': 30.3.0 - babel-jest: 30.3.0(@babel/core@7.29.0) - chalk: 4.1.2 - ci-info: 4.4.0 - deepmerge: 4.3.1 - glob: 10.5.0 - graceful-fs: 4.2.11 - jest-circus: 30.3.0 - jest-docblock: 30.2.0 - jest-environment-node: 30.3.0 - jest-regex-util: 30.0.1 - jest-resolve: 30.3.0 - jest-runner: 30.3.0 - jest-util: 30.3.0 - jest-validate: 30.3.0 - parse-json: 5.2.0 - pretty-format: 30.3.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 20.19.25 - ts-node: 10.9.2(@types/node@20.19.25)(typescript@5.9.3) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - jest-config@30.3.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)): dependencies: '@babel/core': 7.29.0 @@ -11884,19 +11874,6 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@30.3.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)): - dependencies: - '@jest/core': 30.3.0(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) - '@jest/types': 30.3.0 - import-local: 3.2.0 - jest-cli: 30.3.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - jest@30.3.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)): dependencies: '@jest/core': 30.3.0(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) @@ -12457,6 +12434,41 @@ snapshots: perfect-debounce@1.0.0: {} + pg-cloudflare@1.4.0: + optional: true + + pg-connection-string@2.12.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.13.0(pg@8.20.0): + dependencies: + pg: 8.20.0 + + pg-protocol@1.15.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.20.0: + dependencies: + pg-connection-string: 2.12.0 + pg-pool: 3.13.0(pg@8.20.0) + pg-protocol: 1.15.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -12508,6 +12520,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + preact@10.28.3: {} prelude-ls@1.2.1: {} @@ -13037,6 +13059,8 @@ snapshots: speakingurl@14.0.1: {} + split2@4.2.0: {} + sprintf-js@1.0.3: {} stable-hash@0.0.5: {} @@ -13348,27 +13372,7 @@ snapshots: dependencies: typescript: 5.9.3 - ts-jest@29.4.12(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@30.3.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)))(typescript@5.9.3): - dependencies: - bs-logger: 0.2.6 - fast-json-stable-stringify: 2.1.0 - handlebars: 4.7.9 - jest: 30.3.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) - json5: 2.2.3 - lodash.memoize: 4.1.2 - make-error: 1.3.6 - semver: 7.8.5 - type-fest: 4.41.0 - typescript: 5.9.3 - yargs-parser: 21.1.1 - optionalDependencies: - '@babel/core': 7.29.0 - '@jest/transform': 30.3.0 - '@jest/types': 30.3.0 - babel-jest: 30.3.0(@babel/core@7.29.0) - jest-util: 30.3.0 - - ts-jest@29.4.12(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@30.3.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3): + ts-jest@29.4.12(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@30.3.0)(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 @@ -13396,25 +13400,6 @@ snapshots: typescript: 5.9.3 webpack: 5.106.2 - ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.12 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 20.19.25 - acorn: 8.16.0 - acorn-walk: 8.3.5 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.4 - make-error: 1.3.6 - typescript: 5.9.3 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - optional: true - ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -13924,6 +13909,8 @@ snapshots: ws@7.5.13: {} + xtend@4.0.2: {} + y18n@5.0.8: {} yallist@3.1.1: {} From 4d08b538b8b182d26d8f3705d77410069c0c8e27 Mon Sep 17 00:00:00 2001 From: kuyacarlo <106532351+kuyacarlo@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:52:51 +0800 Subject: [PATCH 2/2] fix(docs): link handoff doc via GitHub URL, not outside the VitePress root Co-authored-by: Cursor --- apps/docs/website/dev/supabase.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/website/dev/supabase.md b/apps/docs/website/dev/supabase.md index 0c5f0bd..ba2edba 100644 --- a/apps/docs/website/dev/supabase.md +++ b/apps/docs/website/dev/supabase.md @@ -32,7 +32,7 @@ Nest `/users`, `/teams`, `/tasks`, `/activities` read **Postgres via `@worksight/common` fixtures when unset. They do **not** use the Supabase JS client. -See [`docs/handoffs/2026-07-26-api-postgres.md`](../../handoffs/2026-07-26-api-postgres.md). +See [`docs/handoffs/2026-07-26-api-postgres.md`](https://github.com/4sightorg/worksight/blob/canary/docs/handoffs/2026-07-26-api-postgres.md). ## Further reading