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
14 changes: 14 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
@@ -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
7 changes: 6 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
},
Expand All @@ -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",
Expand All @@ -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"
}
}
82 changes: 82 additions & 0 deletions apps/api/sql/001_core.sql
Original file line number Diff line number Diff line change
@@ -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);
3 changes: 2 additions & 1 deletion apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -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],
})
Expand Down
10 changes: 10 additions & 0 deletions apps/api/src/db/database.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
64 changes: 64 additions & 0 deletions apps/api/src/db/database.service.ts
Original file line number Diff line number Diff line change
@@ -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<T extends QueryResultRow = QueryResultRow>(
text: string,
params?: unknown[]
): Promise<QueryResult<T>> {
if (!this.pool) {
throw new Error('DATABASE_URL is not configured');
}
return this.pool.query<T>(text, params);
}

async withClient<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {
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<void> {
await this.pool?.end();
}

private redact(url: string): string {
return url.replace(/:\/\/([^:]+):([^@]+)@/, '://$1:***@');
}
}
148 changes: 148 additions & 0 deletions apps/api/src/db/seed.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading
Loading