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
17 changes: 17 additions & 0 deletions apps/api/sql/002_attendance.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
-- WorkSight attendance schema. Aligns with @worksight/common AttendanceRecord.
-- Apply after 001_core.sql:
-- psql "$DATABASE_URL" -f apps/api/sql/002_attendance.sql

CREATE TABLE IF NOT EXISTS attendance (
system_id UUID PRIMARY KEY,
employee_id UUID NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
date DATE NOT NULL,
check_in TIMESTAMPTZ,
check_out TIMESTAMPTZ,
hours_worked NUMERIC(5,2),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (employee_id, date)
);

CREATE INDEX IF NOT EXISTS attendance_employee_id_idx ON attendance (employee_id);
CREATE INDEX IF NOT EXISTS attendance_date_idx ON attendance (date DESC);
15 changes: 13 additions & 2 deletions apps/api/src/app.controller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { Controller, Get, Header } from '@nestjs/common';
import { DatabaseService } from './db/database.service';

@Controller()
export class AppController {
constructor(private readonly db: DatabaseService) {}

@Get()
getHello(): string {
return `hello`;
Expand All @@ -14,7 +17,15 @@ export class AppController {
}

@Get('health')
health() {
return { status: 'ok', uptime: process.uptime() };
async health() {
if (!this.db.enabled) {
return { status: 'ok', uptime: process.uptime(), database: 'fixtures' };
}
try {
await this.db.query('SELECT 1');
return { status: 'ok', uptime: process.uptime(), database: 'postgres' };
} catch {
return { status: 'degraded', uptime: process.uptime(), database: 'unreachable' };
}
}
}
3 changes: 2 additions & 1 deletion apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
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 { TasksModule } from './tasks/tasks.module';
import { UsersModule } from './users/users.module';

@Module({
imports: [DatabaseModule, UsersModule, TasksModule],
imports: [DatabaseModule, UsersModule, TasksModule, AttendanceModule],
controllers: [AppController],
providers: [AppService],
})
Expand Down
18 changes: 18 additions & 0 deletions apps/api/src/attendance/attendance.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import type { AttendanceRecord, AttendanceStats } from '@worksight/common';
import { AttendanceService } from './attendance.service';

@Controller('attendance')
export class AttendanceController {
constructor(private readonly attendanceService: AttendanceService) {}

@Get()
getAll(@Query('employee_id') employeeId?: string): Promise<AttendanceRecord[]> {
return this.attendanceService.findAll(employeeId);
}

@Get('stats/:employeeId')
getStats(@Param('employeeId') employeeId: string): Promise<AttendanceStats> {
return this.attendanceService.getStatsForEmployee(employeeId);
}
}
9 changes: 9 additions & 0 deletions apps/api/src/attendance/attendance.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { AttendanceController } from './attendance.controller';
import { AttendanceService } from './attendance.service';

@Module({
controllers: [AttendanceController],
providers: [AttendanceService],
})
export class AttendanceModule {}
41 changes: 41 additions & 0 deletions apps/api/src/attendance/attendance.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Attendance } from '@worksight/common';
import type { WorksightRepository } from '../db/worksight.repository';
import { AttendanceService } from './attendance.service';

describe('AttendanceService', () => {
let service: AttendanceService;

beforeEach(() => {
const fixturesOnly = { enabled: false } as WorksightRepository;
service = new AttendanceService(fixturesOnly);
});

it('returns the shared attendance fixtures', async () => {
await expect(service.findAll()).resolves.toEqual(Attendance);
});

it('filters attendance by employee', async () => {
const employeeId = Attendance[0].employee_id;
const expected = Attendance.filter(r => r.employee_id === employeeId);
await expect(service.findAll(employeeId)).resolves.toEqual(expected);
});

it('computes per-employee stats from the fixtures', async () => {
const employeeId = Attendance[0].employee_id;
const records = Attendance.filter(r => r.employee_id === employeeId);
const stats = await service.getStatsForEmployee(employeeId);
expect(stats.totalRecords).toBe(records.length);
expect(stats.daysPresent).toBe(records.filter(r => r.check_in != null).length);
expect(stats.totalHours).toBeGreaterThan(0);
});

it('returns empty stats for unknown employees', async () => {
const stats = await service.getStatsForEmployee('00000000-0000-4000-8000-000000000000');
expect(stats).toEqual({
totalRecords: 0,
totalHours: 0,
daysPresent: 0,
averageHours: 0,
});
});
});
31 changes: 31 additions & 0 deletions apps/api/src/attendance/attendance.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Injectable } from '@nestjs/common';
import {
AttendanceLookup,
type AttendanceRecord,
type AttendanceStats,
} from '@worksight/common';
import { WorksightRepository } from '../db/worksight.repository';

@Injectable()
export class AttendanceService {
private readonly attendance = new AttendanceLookup();

constructor(private readonly repo: WorksightRepository) {}

async findAll(employeeId?: string): Promise<AttendanceRecord[]> {
if (this.repo.enabled) {
return this.repo.listAttendance(employeeId);
}
if (employeeId) {
return this.attendance.filter({ employee_id: employeeId }).all();
}
return this.attendance.all();
}

async getStatsForEmployee(employeeId: string): Promise<AttendanceStats> {
if (this.repo.enabled) {
return this.repo.getAttendanceStats(employeeId);
}
return this.attendance.getStats(employeeId);
}
}
30 changes: 25 additions & 5 deletions apps/api/src/db/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*
* Idempotent: truncates core tables then reloads. Requires 001_core.sql applied.
*/
import { Activities, Assignments, Employees, Teams } from '@worksight/common';
import { Activities, Assignments, Attendance, Employees, Teams } from '@worksight/common';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { Pool } from 'pg';
Expand All @@ -20,12 +20,14 @@ async function main() {
const client = await pool.connect();

try {
const schemaSql = readFileSync(join(__dirname, '..', '..', 'sql', '001_core.sql'), 'utf8');
await client.query(schemaSql);
for (const file of ['001_core.sql', '002_attendance.sql']) {
const schemaSql = readFileSync(join(__dirname, '..', '..', 'sql', file), 'utf8');
await client.query(schemaSql);
}

await client.query('BEGIN');
await client.query(
`TRUNCATE activities, assignments, teams, employees RESTART IDENTITY CASCADE`
`TRUNCATE attendance, activities, assignments, teams, employees RESTART IDENTITY CASCADE`
);

// Insert managers before reports so manager_id FKs resolve.
Expand Down Expand Up @@ -123,14 +125,32 @@ async function main() {
);
}

for (const record of Attendance) {
await client.query(
`INSERT INTO attendance
(system_id, employee_id, date, check_in, check_out, hours_worked, created_at)
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[
record.system_id,
record.employee_id,
record.date,
record.check_in,
record.check_out,
record.hours_worked,
record.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`
(SELECT count(*)::int FROM activities) AS activities,
(SELECT count(*)::int FROM attendance) AS attendance`
);
console.log('Seeded', counts.rows[0]);
} catch (error) {
Expand Down
67 changes: 66 additions & 1 deletion apps/api/src/db/worksight.repository.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { Injectable } from '@nestjs/common';
import type { Activity, Assignment, EmployeeProfile, Team } from '@worksight/common';
import type {
Activity,
Assignment,
AttendanceRecord,
AttendanceStats,
EmployeeProfile,
Team,
} from '@worksight/common';
import { DatabaseService } from './database.service';

type EmployeeRow = {
Expand Down Expand Up @@ -44,6 +51,23 @@ type AssignmentRow = {
updated_at: Date;
};

type AttendanceRow = {
system_id: string;
employee_id: string;
date: Date;
check_in: Date | null;
check_out: Date | null;
// NUMERIC arrives as a string from node-postgres.
hours_worked: string | null;
created_at: Date;
};

type AttendanceStatsRow = {
total_records: number;
total_hours: number;
days_present: number;
};

type ActivityRow = {
id: string;
source_id: string;
Expand Down Expand Up @@ -122,6 +146,35 @@ export class WorksightRepository {
);
return rows.map(toActivity);
}

async listAttendance(employeeId?: string): Promise<AttendanceRecord[]> {
const { rows } = employeeId
? await this.db.query<AttendanceRow>(
`SELECT * FROM attendance WHERE employee_id = $1 ORDER BY date DESC`,
[employeeId]
)
: await this.db.query<AttendanceRow>(`SELECT * FROM attendance ORDER BY date DESC`);
return rows.map(toAttendance);
}

async getAttendanceStats(employeeId: string): Promise<AttendanceStats> {
const { rows } = await this.db.query<AttendanceStatsRow>(
`SELECT count(*)::int AS total_records,
COALESCE(sum(hours_worked), 0)::float AS total_hours,
count(check_in)::int AS days_present
FROM attendance
WHERE employee_id = $1`,
[employeeId]
);
const { total_records, total_hours, days_present } = rows[0];
const average = days_present > 0 ? total_hours / days_present : 0;
return {
totalRecords: total_records,
totalHours: Math.round(total_hours * 100) / 100,
daysPresent: days_present,
averageHours: Math.round(average * 100) / 100,
};
}
}

function toEmployee(row: EmployeeRow): EmployeeProfile {
Expand Down Expand Up @@ -172,6 +225,18 @@ function toAssignment(row: AssignmentRow): Assignment {
};
}

function toAttendance(row: AttendanceRow): AttendanceRecord {
return {
system_id: row.system_id,
employee_id: row.employee_id,
date: new Date(row.date),
check_in: row.check_in ? new Date(row.check_in) : null,
check_out: row.check_out ? new Date(row.check_out) : null,
hours_worked: row.hours_worked === null ? null : Number(row.hours_worked),
created_at: new Date(row.created_at),
};
}

function toActivity(row: ActivityRow): Activity {
return {
id: row.id,
Expand Down
20 changes: 18 additions & 2 deletions docs/handoffs/2026-07-26-api-postgres.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,24 @@ the same fixtures the offline demo uses.
- `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.
Survey fixtures still contain invalid UUIDs — they are out of the API surface
for now.

## Attendance (follow-up branch `feat/api-attendance`)

- Attendance fixtures repaired: 77 invalid `system_id`s regenerated as
deterministic UUIDs (`a77e0000-…`), and stale/placeholder `employee_id`s
(`0001`, `admin`, `guest`, old hex ids) remapped to the repaired employee
UUIDs using the per-block comments in the fixture file.
`AttendanceSchema.employee_id` is now `z.uuid()`.
- `apps/api/sql/002_attendance.sql` — `attendance` table with an
`(employee_id, date)` unique constraint; seed script applies it and loads
the fixtures (77 rows on Neon).
- New endpoints: `GET /attendance` (optional `?employee_id=`) and
`GET /attendance/stats/:employeeId`. DB mode computes stats with a SQL
aggregate; fixture mode uses `AttendanceLookup`, same rounding.
- `GET /health` now reports the data source: `database` is `fixtures`,
`postgres`, or `unreachable` (with `status: degraded`).

## Related

Expand Down
Loading
Loading