feat: complete tOS project with HR, LEAN, Dashboard and Integrations modules

Full enterprise web operating system including:
- Next.js 14 frontend with App Router, i18n (DE/EN), shadcn/ui
- NestJS 10 backend with Prisma, JWT auth, Swagger docs
- Keycloak 24 SSO with role-based access control
- HR module (employees, time tracking, absences, org chart)
- LEAN module (3S planning, morning meeting SQCDM, skill matrix)
- Integrations module (PlentyONE, Zulip, Todoist, FreeScout, Nextcloud, ecoDMS, GembaDocs)
- Dashboard with customizable drag & drop widget grid
- Docker Compose infrastructure (PostgreSQL 16, Redis 7, Keycloak 24)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-06 19:37:55 +01:00
parent a2b6612e9e
commit fe305f6fc8
509 changed files with 81111 additions and 1 deletions

80
apps/api/.env.example Normal file
View File

@@ -0,0 +1,80 @@
# Application
NODE_ENV=development
PORT=3001
API_PREFIX=api
# Database
# NOTE: App uses tos_app (separate from Keycloak's tos_db)
DATABASE_URL="postgresql://tos_user:tos_secret_password@localhost:5432/tos_app?schema=public"
# JWT / Keycloak
# IMPORTANT: Change JWT_SECRET in production! Use a cryptographically secure random string.
JWT_SECRET=your-super-secret-jwt-key-change-in-production
KEYCLOAK_URL=http://localhost:8080
KEYCLOAK_REALM=tOS
# NOTE: Client ID must match the Keycloak realm configuration in docker/keycloak/realm-export.json
KEYCLOAK_CLIENT_ID=tos-backend
KEYCLOAK_CLIENT_SECRET=your-keycloak-backend-client-secret
# CORS
CORS_ORIGINS=http://localhost:3000,http://localhost:5173
# Swagger
SWAGGER_ENABLED=true
# =============================================================================
# Phase 3: Integrations & Sync Jobs
# =============================================================================
# Encryption
# IMPORTANT: Generate a secure 32+ character key for production!
# You can generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
ENCRYPTION_KEY=your-32-byte-encryption-key-change-in-production
# Redis (required for BullMQ in production)
REDIS_HOST=localhost
REDIS_PORT=6379
# Sync Jobs
# Set to 'true' to enable automatic background sync jobs
ENABLE_SYNC_JOBS=false
# Sync Intervals (in minutes)
SYNC_INTERVAL_PLENTYONE=15
SYNC_INTERVAL_ZULIP=5
SYNC_INTERVAL_TODOIST=10
SYNC_INTERVAL_FREESCOUT=10
SYNC_INTERVAL_NEXTCLOUD=30
SYNC_INTERVAL_ECODMS=60
SYNC_INTERVAL_GEMBADOCS=30
# =============================================================================
# Phase 3: API Connector Credentials
# =============================================================================
# PlentyONE (OAuth2 Client Credentials)
PLENTYONE_BASE_URL=
PLENTYONE_CLIENT_ID=
PLENTYONE_CLIENT_SECRET=
# ZULIP (Basic Auth with API Key)
ZULIP_BASE_URL=
ZULIP_EMAIL=
ZULIP_API_KEY=
# Todoist (Bearer Token)
TODOIST_API_TOKEN=
# FreeScout (API Key)
FREESCOUT_API_URL=
FREESCOUT_API_KEY=
# Nextcloud (Basic Auth / App Password)
NEXTCLOUD_URL=
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
# ecoDMS (Session-based Auth)
ECODMS_API_URL=
ECODMS_USERNAME=
ECODMS_PASSWORD=

26
apps/api/.eslintrc.js Normal file
View File

@@ -0,0 +1,26 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
},
};

43
apps/api/.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# compiled output
/dist
/node_modules
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# Environment
.env
.env.local
.env.*.local
# Prisma
prisma/migrations/*_migration_lock.toml

7
apps/api/.prettierrc Normal file
View File

@@ -0,0 +1,7 @@
{
"singleQuote": true,
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"printWidth": 100
}

8
apps/api/nest-cli.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

101
apps/api/package.json Normal file
View File

@@ -0,0 +1,101 @@
{
"name": "@tos/api",
"version": "0.0.1",
"description": "tOS Backend API - NestJS Application",
"author": "tOS Team",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"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",
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:migrate": "prisma migrate dev",
"db:migrate:prod": "prisma migrate deploy",
"db:studio": "prisma studio",
"db:seed": "prisma db seed"
},
"dependencies": {
"@tos/shared": "workspace:*",
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.3.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.3.0",
"@nestjs/swagger": "^7.2.0",
"@nestjs/terminus": "^10.2.0",
"@prisma/client": "^5.8.0",
"axios": "^1.6.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"form-data": "^4.0.5",
"helmet": "^7.1.0",
"joi": "^17.12.0",
"jwks-rsa": "^3.1.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.1",
"rxjs": "^7.8.1",
"uuid": "^13.0.0"
},
"devDependencies": {
"@nestjs/cli": "^10.3.0",
"@nestjs/schematics": "^10.1.0",
"@nestjs/testing": "^10.3.0",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.11",
"@types/multer": "^2.0.0",
"@types/node": "^20.10.6",
"@types/passport-jwt": "^4.0.0",
"@types/supertest": "^6.0.2",
"@typescript-eslint/eslint-plugin": "^6.18.0",
"@typescript-eslint/parser": "^6.18.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.2",
"jest": "^29.7.0",
"prettier": "^3.1.1",
"prisma": "^5.8.0",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
"ts-jest": "^29.1.1",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.3.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node",
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/$1"
}
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
}
}

View File

@@ -0,0 +1,656 @@
// Prisma Schema for tOS - Team Operating System
// Database: PostgreSQL
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// =============================================================================
// ENUMS
// =============================================================================
enum ContractType {
FULL_TIME
PART_TIME
MINI_JOB
INTERN
WORKING_STUDENT
FREELANCE
TEMPORARY
}
enum AbsenceType {
VACATION
SICK
SICK_CHILD
SPECIAL_LEAVE
UNPAID_LEAVE
PARENTAL_LEAVE
HOME_OFFICE
BUSINESS_TRIP
TRAINING
COMPENSATION
}
enum ApprovalStatus {
PENDING
APPROVED
REJECTED
CANCELLED
}
enum TimeEntryType {
REGULAR
OVERTIME
HOLIDAY
SICK
VACATION
CORRECTION
}
enum ReviewType {
PROBATION
ANNUAL
PROMOTION
PROJECT
FEEDBACK
EXIT
}
enum S3StatusType {
GREEN
YELLOW
RED
NOT_APPLICABLE
}
enum S3Type {
SEIRI // Sort - Aussortieren
SEITON // Set in Order - Aufräumen
SEISO // Shine - Sauberkeit
}
enum SkillLevel {
NONE
BEGINNER
INTERMEDIATE
ADVANCED
EXPERT
TRAINER
}
enum IntegrationType {
DATEV
PERSONIO
SAP
CALENDAR
EMAIL
CUSTOM
PLENTYONE
ZULIP
TODOIST
FREESCOUT
NEXTCLOUD
ECODMS
GEMBADOCS
}
enum SyncStatus {
PENDING
RUNNING
SUCCESS
ERROR
}
// SQCDM Categories for Morning Meeting
enum SQCDMCategory {
SAFETY // S - Sicherheit
QUALITY // Q - Qualitaet
COST // C - Kosten
DELIVERY // D - Lieferung
MORALE // M - Moral/Mitarbeiter
}
// Meeting Status
enum MeetingStatus {
SCHEDULED
IN_PROGRESS
COMPLETED
CANCELLED
}
// KPI Status (Traffic Light)
enum KPIStatus {
GREEN
YELLOW
RED
NEUTRAL
}
// Trend Direction
enum Trend {
UP
DOWN
STABLE
}
// Action Status
enum ActionStatus {
OPEN
IN_PROGRESS
COMPLETED
CANCELLED
}
// Priority Level
enum Priority {
LOW
MEDIUM
HIGH
CRITICAL
}
// =============================================================================
// CORE MODELS
// =============================================================================
model User {
id String @id @default(cuid())
email String @unique
firstName String
lastName String
keycloakId String? @unique
avatar String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
departmentId String?
department Department? @relation("UserDepartment", fields: [departmentId], references: [id])
roles UserRole[]
employee Employee?
preferences UserPreference?
auditLogs AuditLog[]
// Manager relations
managedDepartments Department[] @relation("DepartmentManager")
correctedTimeEntries TimeEntry[] @relation("TimeEntryCorrectedBy")
approvedAbsences Absence[] @relation("AbsenceApprovedBy")
conductedReviews EmployeeReview[] @relation("ReviewConductedBy")
skillAssessments SkillMatrixEntry[] @relation("SkillAssessedBy")
integrationCredentials IntegrationCredential[]
// LEAN 3S relations
s3PlansCreated S3Plan[] @relation("S3PlanCreatedBy")
s3StatusCompleted S3Status[] @relation("S3StatusCompletedBy")
// Morning Meeting relations
conductedMeetings MorningMeeting[] @relation("MeetingConductor")
assignedMeetingActions MorningMeetingAction[] @relation("ActionAssignee")
@@index([email])
@@index([keycloakId])
@@index([departmentId])
}
model Department {
id String @id @default(cuid())
name String
code String? @unique
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Self-relation for hierarchy
parentId String?
parent Department? @relation("DepartmentHierarchy", fields: [parentId], references: [id])
children Department[] @relation("DepartmentHierarchy")
// Manager relation
managerId String?
manager User? @relation("DepartmentManager", fields: [managerId], references: [id])
// Relations
users User[] @relation("UserDepartment")
onboardingTasks OnboardingTask[]
s3Plans S3Plan[]
skills Skill[]
morningMeetings MorningMeeting[]
@@index([parentId])
@@index([managerId])
}
model Role {
id String @id @default(cuid())
name String @unique
description String?
permissions Json @default("[]")
isSystem Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
users UserRole[]
@@index([name])
}
model UserRole {
id String @id @default(cuid())
userId String
roleId String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
@@unique([userId, roleId])
@@index([userId])
@@index([roleId])
}
// =============================================================================
// HR MODELS
// =============================================================================
model Employee {
id String @id @default(cuid())
employeeNumber String @unique
position String
entryDate DateTime
exitDate DateTime?
contractType ContractType
workingHours Float @default(40)
salary String? // Encrypted salary (AES-256-GCM)
taxClass Int?
bankAccount Json? // Encrypted bank account (AES-256-GCM)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
timeEntries TimeEntry[]
absences Absence[]
reviews EmployeeReview[]
skillMatrixEntries SkillMatrixEntry[]
@@index([employeeNumber])
@@index([userId])
@@index([entryDate])
}
model TimeEntry {
id String @id @default(cuid())
date DateTime @db.Date
clockIn DateTime?
clockOut DateTime?
breakMinutes Int @default(0)
type TimeEntryType @default(REGULAR)
note String?
isLocked Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
correctedById String?
correctedBy User? @relation("TimeEntryCorrectedBy", fields: [correctedById], references: [id])
@@unique([employeeId, date])
@@index([employeeId])
@@index([date])
@@index([correctedById])
}
model Absence {
id String @id @default(cuid())
type AbsenceType
startDate DateTime @db.Date
endDate DateTime @db.Date
days Float
status ApprovalStatus @default(PENDING)
note String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
approvedById String?
approvedBy User? @relation("AbsenceApprovedBy", fields: [approvedById], references: [id])
@@index([employeeId])
@@index([startDate, endDate])
@@index([status])
@@index([approvedById])
}
model EmployeeReview {
id String @id @default(cuid())
date DateTime
type ReviewType
notes String? @db.Text
goals Json?
rating Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
reviewerId String
reviewer User @relation("ReviewConductedBy", fields: [reviewerId], references: [id])
@@index([employeeId])
@@index([reviewerId])
@@index([date])
@@index([type])
}
model OnboardingTask {
id String @id @default(cuid())
title String
description String? @db.Text
dueOffset Int @default(0) // Days from entry date
assignedTo String? // Role or specific user type
sortOrder Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
departmentId String?
department Department? @relation(fields: [departmentId], references: [id])
@@index([departmentId])
@@index([sortOrder])
}
// =============================================================================
// INTEGRATION MODELS
// =============================================================================
model IntegrationCredential {
id String @id @default(cuid())
type IntegrationType
name String
credentials String // Encrypted JSON (AES-256-GCM)
isActive Boolean @default(true)
lastUsed DateTime?
lastSync DateTime?
syncStatus SyncStatus @default(PENDING)
syncError String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById String
createdBy User @relation(fields: [createdById], references: [id])
// Relations
syncHistory IntegrationSyncHistory[]
@@unique([type, name])
@@index([type])
@@index([isActive])
@@index([createdById])
}
model IntegrationSyncHistory {
id String @id @default(cuid())
credentialId String
credential IntegrationCredential @relation(fields: [credentialId], references: [id], onDelete: Cascade)
status SyncStatus
startedAt DateTime @default(now())
completedAt DateTime?
duration Int? // Duration in milliseconds
recordsProcessed Int?
error String?
metadata Json? // Additional sync metadata
createdAt DateTime @default(now())
@@index([credentialId])
@@index([status])
@@index([startedAt])
}
// =============================================================================
// LEAN MODELS - 3S / 5S
// =============================================================================
model S3Plan {
id String @id @default(cuid())
year Int
month Int
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
departmentId String
department Department @relation(fields: [departmentId], references: [id])
createdById String
createdBy User @relation("S3PlanCreatedBy", fields: [createdById], references: [id])
categories S3Category[]
@@unique([departmentId, year, month])
@@index([departmentId])
@@index([year, month])
@@index([createdById])
}
model S3Category {
id String @id @default(cuid())
name String
s3Type S3Type // SEIRI, SEITON, SEISO
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
planId String
plan S3Plan @relation(fields: [planId], references: [id], onDelete: Cascade)
statuses S3Status[]
@@index([planId])
@@index([sortOrder])
@@index([s3Type])
}
model S3Status {
id String @id @default(cuid())
week Int // 1-53
status S3StatusType
photo String? // URL to uploaded image
note String?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
categoryId String
category S3Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
completedById String?
completedBy User? @relation("S3StatusCompletedBy", fields: [completedById], references: [id])
@@unique([categoryId, week])
@@index([categoryId])
@@index([week])
@@index([status])
@@index([completedById])
}
// =============================================================================
// LEAN MODELS - Morning Meeting (Shopfloor Management)
// =============================================================================
model MorningMeeting {
id String @id @default(cuid())
date DateTime @db.Date
startTime DateTime? // Actual start time
endTime DateTime? // Actual end time
duration Int? // Duration in minutes (calculated)
participants Json? // Array of participant user IDs
notes String? @db.Text
status MeetingStatus @default(SCHEDULED)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
departmentId String
department Department @relation(fields: [departmentId], references: [id])
conductorId String?
conductor User? @relation("MeetingConductor", fields: [conductorId], references: [id])
topics MorningMeetingTopic[]
actions MorningMeetingAction[]
@@unique([departmentId, date])
@@index([departmentId])
@@index([date])
@@index([status])
@@index([conductorId])
}
model MorningMeetingTopic {
id String @id @default(cuid())
category SQCDMCategory // SAFETY, QUALITY, COST, DELIVERY, MORALE
title String
value String? // Current value
target String? // Target value
unit String? // Unit (%, pieces, EUR, etc.)
status KPIStatus @default(NEUTRAL)
trend Trend? // UP, DOWN, STABLE
sortOrder Int @default(0)
note String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
meetingId String
meeting MorningMeeting @relation(fields: [meetingId], references: [id], onDelete: Cascade)
@@index([meetingId])
@@index([category])
@@index([sortOrder])
@@index([status])
}
model MorningMeetingAction {
id String @id @default(cuid())
title String
description String? @db.Text
dueDate DateTime? @db.Date
status ActionStatus @default(OPEN)
priority Priority @default(MEDIUM)
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
meetingId String
meeting MorningMeeting @relation(fields: [meetingId], references: [id], onDelete: Cascade)
assigneeId String?
assignee User? @relation("ActionAssignee", fields: [assigneeId], references: [id])
@@index([meetingId])
@@index([assigneeId])
@@index([status])
@@index([priority])
@@index([dueDate])
}
// =============================================================================
// LEAN MODELS - Skill Matrix
// =============================================================================
model Skill {
id String @id @default(cuid())
name String
description String?
category String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
departmentId String?
department Department? @relation(fields: [departmentId], references: [id])
entries SkillMatrixEntry[]
@@unique([name, departmentId])
@@index([departmentId])
@@index([category])
}
model SkillMatrixEntry {
id String @id @default(cuid())
level SkillLevel @default(NONE)
assessedAt DateTime @default(now())
note String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
skillId String
skill Skill @relation(fields: [skillId], references: [id], onDelete: Cascade)
assessedById String?
assessedBy User? @relation("SkillAssessedBy", fields: [assessedById], references: [id])
@@unique([employeeId, skillId])
@@index([employeeId])
@@index([skillId])
@@index([assessedById])
@@index([level])
}
// =============================================================================
// USER PREFERENCES
// =============================================================================
model UserPreference {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
theme String @default("system") // "light", "dark", "system"
language String @default("de") // "de", "en"
dashboardLayout Json? // Array of widget configurations
notifications Json? // Notification preferences
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
}
// =============================================================================
// AUDIT LOGGING
// =============================================================================
model AuditLog {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
action String // CREATE, UPDATE, DELETE, LOGIN, LOGOUT, etc.
entity String // User, Department, Employee, etc.
entityId String?
oldData Json?
newData Json?
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
@@index([userId])
@@index([entity, entityId])
@@index([action])
@@index([createdAt])
}

215
apps/api/prisma/seed.ts Normal file
View File

@@ -0,0 +1,215 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('Seeding database...');
// Create default roles
const roles = await Promise.all([
prisma.role.upsert({
where: { name: 'admin' },
update: {},
create: {
name: 'admin',
description: 'Administrator with full system access',
permissions: JSON.stringify([
'users:read',
'users:write',
'users:delete',
'employees:read',
'employees:write',
'employees:delete',
'departments:read',
'departments:write',
'departments:delete',
'roles:read',
'roles:write',
'roles:delete',
'settings:read',
'settings:write',
]),
isSystem: true,
},
}),
prisma.role.upsert({
where: { name: 'hr-manager' },
update: {},
create: {
name: 'hr-manager',
description: 'HR Manager with access to employee data',
permissions: JSON.stringify([
'users:read',
'users:write',
'employees:read',
'employees:write',
'departments:read',
'absences:read',
'absences:write',
'time-entries:read',
'time-entries:write',
'reviews:read',
'reviews:write',
]),
isSystem: true,
},
}),
prisma.role.upsert({
where: { name: 'team-lead' },
update: {},
create: {
name: 'team-lead',
description: 'Team leader with access to team data',
permissions: JSON.stringify([
'users:read',
'employees:read',
'departments:read',
'absences:read',
'absences:approve',
'time-entries:read',
'reviews:read',
'reviews:write',
'skills:read',
'skills:write',
]),
isSystem: true,
},
}),
prisma.role.upsert({
where: { name: 'employee' },
update: {},
create: {
name: 'employee',
description: 'Regular employee with basic access',
permissions: JSON.stringify([
'profile:read',
'profile:write',
'absences:read',
'absences:request',
'time-entries:read',
'time-entries:write',
'skills:read',
]),
isSystem: true,
},
}),
]);
console.log(`Created ${roles.length} roles`);
// Create default departments
const departments = await Promise.all([
prisma.department.upsert({
where: { code: 'MGMT' },
update: {},
create: {
name: 'Management',
code: 'MGMT',
},
}),
prisma.department.upsert({
where: { code: 'HR' },
update: {},
create: {
name: 'Human Resources',
code: 'HR',
},
}),
prisma.department.upsert({
where: { code: 'ENG' },
update: {},
create: {
name: 'Engineering',
code: 'ENG',
},
}),
prisma.department.upsert({
where: { code: 'PROD' },
update: {},
create: {
name: 'Production',
code: 'PROD',
},
}),
prisma.department.upsert({
where: { code: 'QA' },
update: {},
create: {
name: 'Quality Assurance',
code: 'QA',
},
}),
]);
console.log(`Created ${departments.length} departments`);
// Create admin user (for development)
if (process.env.NODE_ENV !== 'production') {
const adminRole = roles.find((r) => r.name === 'admin');
const mgmtDept = departments.find((d) => d.code === 'MGMT');
if (adminRole && mgmtDept) {
const adminUser = await prisma.user.upsert({
where: { email: 'admin@tos.local' },
update: {},
create: {
email: 'admin@tos.local',
firstName: 'System',
lastName: 'Administrator',
departmentId: mgmtDept.id,
roles: {
create: {
roleId: adminRole.id,
},
},
},
});
console.log(`Created admin user: ${adminUser.email}`);
}
}
// Create default skills
const skills = await Promise.all([
prisma.skill.upsert({
where: { name_departmentId: { name: 'Communication', departmentId: null as unknown as string } },
update: {},
create: {
name: 'Communication',
description: 'Effective verbal and written communication',
category: 'Soft Skills',
},
}),
prisma.skill.upsert({
where: { name_departmentId: { name: 'Problem Solving', departmentId: null as unknown as string } },
update: {},
create: {
name: 'Problem Solving',
description: 'Analytical thinking and problem resolution',
category: 'Soft Skills',
},
}),
prisma.skill.upsert({
where: { name_departmentId: { name: 'Team Collaboration', departmentId: null as unknown as string } },
update: {},
create: {
name: 'Team Collaboration',
description: 'Working effectively in a team environment',
category: 'Soft Skills',
},
}),
]);
console.log(`Created ${skills.length} skills`);
console.log('Database seeding completed!');
}
main()
.catch((e) => {
console.error('Error seeding database:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -0,0 +1 @@
export * from './queue.module';

View File

@@ -0,0 +1,91 @@
import { Module, DynamicModule, Logger } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
/**
* Queue Module for background job processing
*
* This module provides a factory for creating queue infrastructure.
* When BullMQ is available (Redis configured), it will use BullMQ.
* Otherwise, it falls back to in-memory processing.
*
* To use BullMQ, install the required packages:
* npm install @nestjs/bullmq bullmq
*
* And configure Redis in your environment:
* REDIS_HOST=localhost
* REDIS_PORT=6379
*/
@Module({})
export class QueueModule {
private static readonly logger = new Logger(QueueModule.name);
/**
* Registers the queue module with optional BullMQ support
*/
static forRoot(): DynamicModule {
return {
module: QueueModule,
imports: [ConfigModule],
providers: [
{
provide: 'QUEUE_CONFIG',
useFactory: (configService: ConfigService) => {
const redisHost = configService.get<string>('REDIS_HOST');
const redisPort = configService.get<number>('REDIS_PORT');
if (redisHost && redisPort) {
this.logger.log(
`Queue configured with Redis at ${redisHost}:${redisPort}`,
);
return {
type: 'bullmq',
connection: {
host: redisHost,
port: redisPort,
},
};
}
this.logger.warn(
'Redis not configured. Using in-memory queue processing. ' +
'For production, configure REDIS_HOST and REDIS_PORT.',
);
return {
type: 'memory',
};
},
inject: [ConfigService],
},
],
exports: ['QUEUE_CONFIG'],
};
}
/**
* Registers a specific queue
*/
static registerQueue(options: { name: string }): DynamicModule {
return {
module: QueueModule,
providers: [
{
provide: `QUEUE_${options.name.toUpperCase()}`,
useFactory: () => {
// In a full implementation, this would create a BullMQ queue
// For now, return a placeholder that can be expanded
return {
name: options.name,
add: async (jobName: string, data: any) => {
this.logger.debug(
`Job added to queue ${options.name}: ${jobName}`,
);
return { id: `job-${Date.now()}`, name: jobName, data };
},
};
},
},
],
exports: [`QUEUE_${options.name.toUpperCase()}`],
};
}
}

View File

@@ -0,0 +1,94 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
import { HealthModule } from './health/health.module';
import { CommonModule } from './common/common.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { RolesGuard } from './auth/guards/roles.guard';
import { PermissionsGuard } from './auth/permissions/permissions.guard';
import { configValidationSchema } from './config/config.validation';
// Feature modules
import { AuditModule } from './modules/audit/audit.module';
import { AuditInterceptor } from './modules/audit/audit.interceptor';
import { DashboardModule } from './modules/dashboard/dashboard.module';
import { DepartmentsModule } from './modules/departments/departments.module';
import { UserPreferencesModule } from './modules/user-preferences/user-preferences.module';
// Phase 4 modules - LEAN
import { LeanModule } from './modules/lean/lean.module';
// Phase 5 modules - HR
import { HrModule } from './modules/hr/hr.module';
// Phase 6 modules - Integrations
import { IntegrationsModule } from './modules/integrations/integrations.module';
@Module({
imports: [
// Configuration
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ['.env.local', '.env'],
validationSchema: configValidationSchema,
validationOptions: {
allowUnknown: true,
abortEarly: false,
},
}),
// Database
PrismaModule,
// Common utilities
CommonModule,
// Feature modules
AuthModule,
UsersModule,
HealthModule,
// Phase 2 modules
AuditModule,
DashboardModule,
DepartmentsModule,
UserPreferencesModule,
// Phase 4 modules - LEAN
LeanModule,
// Phase 5 modules - HR
HrModule,
// Phase 6 modules - Integrations
IntegrationsModule,
],
providers: [
// Global JWT Guard - routes are protected by default
// Use @Public() decorator to make routes accessible without authentication
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
// Global Roles Guard - checks @Roles() decorator on routes
// Must be registered after JwtAuthGuard to ensure user is authenticated first
{
provide: APP_GUARD,
useClass: RolesGuard,
},
// Global Permissions Guard - checks @RequirePermissions() decorator
{
provide: APP_GUARD,
useClass: PermissionsGuard,
},
// Global Audit Interceptor - logs actions marked with @AuditLog()
{
provide: APP_INTERCEPTOR,
useClass: AuditInterceptor,
},
],
})
export class AppModule {}

View File

@@ -0,0 +1,55 @@
import { Controller, Get, Post, Body, UseGuards, Request } from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiBody,
} from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { Public } from './decorators/public.decorator';
import { CurrentUser } from './decorators/current-user.decorator';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { ValidateTokenDto } from './dto/validate-token.dto';
import { JwtPayload } from './interfaces/jwt-payload.interface';
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Public()
@Post('validate')
@ApiOperation({ summary: 'Validate a Keycloak token' })
@ApiBody({ type: ValidateTokenDto })
@ApiResponse({ status: 200, description: 'Token is valid' })
@ApiResponse({ status: 401, description: 'Invalid token' })
async validateToken(@Body() dto: ValidateTokenDto) {
const payload = await this.authService.validateKeycloakToken(dto.token);
return {
valid: true,
payload,
};
}
@Get('me')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Get current user information' })
@ApiResponse({ status: 200, description: 'Current user data' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async getCurrentUser(@CurrentUser() user: JwtPayload) {
const fullUser = await this.authService.getUserFromPayload(user);
return fullUser;
}
@Get('profile')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Get current user profile' })
@ApiResponse({ status: 200, description: 'User profile data' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async getProfile(@Request() req: { user: JwtPayload }) {
return req.user;
}
}

View File

@@ -0,0 +1,29 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './strategies/jwt.strategy';
import { UsersModule } from '../users/users.module';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'),
signOptions: {
expiresIn: '24h',
},
}),
inject: [ConfigService],
}),
UsersModule,
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService, JwtModule],
})
export class AuthModule {}

View File

@@ -0,0 +1,83 @@
import { Injectable, UnauthorizedException, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { UsersService } from '../users/users.service';
import { JwtPayload } from './interfaces/jwt-payload.interface';
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
private readonly usersService: UsersService,
) {}
/**
* Validate a Keycloak token and return the user
* Always verifies the token signature for security
*/
async validateKeycloakToken(token: string): Promise<JwtPayload> {
try {
const jwtSecret = this.configService.get<string>('JWT_SECRET');
if (!jwtSecret) {
this.logger.error('JWT_SECRET is not configured');
throw new UnauthorizedException('Server configuration error');
}
// Always verify the token signature - never just decode
// This prevents token forgery attacks
const decoded = this.jwtService.verify(token, {
secret: jwtSecret,
});
if (!decoded || !decoded.sub || !decoded.email) {
throw new UnauthorizedException('Invalid token payload');
}
return decoded;
} catch (error) {
if (error instanceof UnauthorizedException) {
throw error;
}
this.logger.error('Token validation failed', error);
throw new UnauthorizedException('Invalid or expired token');
}
}
/**
* Create an internal JWT token for a user
*/
async createToken(userId: string): Promise<string> {
const user = await this.usersService.findOne(userId);
const payload: JwtPayload = {
sub: user.id,
email: user.email,
roles: user.roles.map((ur) => ur.role.name),
};
return this.jwtService.sign(payload);
}
/**
* Verify an internal JWT token
*/
async verifyToken(token: string): Promise<JwtPayload> {
try {
return this.jwtService.verify(token);
} catch {
throw new UnauthorizedException('Invalid or expired token');
}
}
/**
* Get user from JWT payload
*/
async getUserFromPayload(payload: JwtPayload) {
const user = await this.usersService.findOne(payload.sub);
return user;
}
}

View File

@@ -0,0 +1,31 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { JwtPayload } from '../interfaces/jwt-payload.interface';
/**
* Parameter decorator to extract the current authenticated user from the request
* @example
* ```typescript
* @Get('profile')
* getProfile(@CurrentUser() user: JwtPayload) {
* return user;
* }
*
* // Or extract a specific property
* @Get('my-id')
* getMyId(@CurrentUser('sub') userId: string) {
* return userId;
* }
* ```
*/
export const CurrentUser = createParamDecorator(
(data: keyof JwtPayload | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user as JwtPayload;
if (!user) {
return null;
}
return data ? user[data] : user;
},
);

View File

@@ -0,0 +1,3 @@
export * from './public.decorator';
export * from './roles.decorator';
export * from './current-user.decorator';

View File

@@ -0,0 +1,16 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
/**
* Marks a route as publicly accessible without authentication
* @example
* ```typescript
* @Public()
* @Get('health')
* healthCheck() {
* return { status: 'ok' };
* }
* ```
*/
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@@ -0,0 +1,17 @@
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
/**
* Decorator to specify which roles are allowed to access a route
* @param roles - Array of role names that are allowed
* @example
* ```typescript
* @Roles('admin', 'hr-manager')
* @Get('employees')
* getEmployees() {
* return this.employeeService.findAll();
* }
* ```
*/
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);

View File

@@ -0,0 +1 @@
export * from './validate-token.dto';

View File

@@ -0,0 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty } from 'class-validator';
export class ValidateTokenDto {
@ApiProperty({
description: 'JWT token to validate',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
})
@IsString()
@IsNotEmpty()
token: string;
}

View File

@@ -0,0 +1,3 @@
export * from './jwt-auth.guard';
export * from './roles.guard';
export * from '../permissions/permissions.guard';

View File

@@ -0,0 +1,26 @@
import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
// Check if the route is marked as public
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
// Otherwise, use the default JWT authentication
return super.canActivate(context);
}
}

View File

@@ -0,0 +1,38 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';
import { JwtPayload } from '../interfaces/jwt-payload.interface';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
// If no roles are required, allow access
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest();
const user = request.user as JwtPayload;
if (!user || !user.roles) {
throw new ForbiddenException('Access denied: No roles assigned');
}
const hasRole = requiredRoles.some((role) => user.roles.includes(role));
if (!hasRole) {
throw new ForbiddenException(
`Access denied: Required roles: ${requiredRoles.join(', ')}`,
);
}
return true;
}
}

View File

@@ -0,0 +1,6 @@
export * from './auth.module';
export * from './auth.service';
export * from './guards';
export * from './decorators';
export * from './interfaces/jwt-payload.interface';
export * from './permissions';

View File

@@ -0,0 +1,26 @@
export interface JwtPayload {
/**
* Subject - typically the user ID
*/
sub: string;
/**
* User's email address
*/
email: string;
/**
* Array of role names assigned to the user
*/
roles: string[];
/**
* Token issued at timestamp (optional, set by JWT)
*/
iat?: number;
/**
* Token expiration timestamp (optional, set by JWT)
*/
exp?: number;
}

View File

@@ -0,0 +1,3 @@
export * from './permissions.enum';
export * from './permissions.decorator';
export * from './permissions.guard';

View File

@@ -0,0 +1,31 @@
import { SetMetadata } from '@nestjs/common';
import { Permission } from './permissions.enum';
export const PERMISSIONS_KEY = 'permissions';
/**
* Decorator to require specific permissions for a route
* @param permissions - Array of required permissions (ANY match grants access)
* @example
* ```typescript
* @RequirePermissions(Permission.USERS_CREATE, Permission.USERS_UPDATE)
* @Post()
* createUser() {}
* ```
*/
export const RequirePermissions = (...permissions: Permission[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);
/**
* Decorator to require ALL specified permissions for a route
* @param permissions - Array of required permissions (ALL must match)
* @example
* ```typescript
* @RequireAllPermissions(Permission.USERS_VIEW, Permission.USERS_UPDATE)
* @Patch()
* updateUser() {}
* ```
*/
export const PERMISSIONS_ALL_KEY = 'permissions_all';
export const RequireAllPermissions = (...permissions: Permission[]) =>
SetMetadata(PERMISSIONS_ALL_KEY, permissions);

View File

@@ -0,0 +1,175 @@
/**
* Fine-grained permissions for role-based access control
* Format: entity:action
*/
export enum Permission {
// Dashboard
DASHBOARD_VIEW = 'dashboard:view',
DASHBOARD_CUSTOMIZE = 'dashboard:customize',
// Users
USERS_VIEW = 'users:view',
USERS_VIEW_ALL = 'users:view_all',
USERS_CREATE = 'users:create',
USERS_UPDATE = 'users:update',
USERS_UPDATE_SELF = 'users:update_self',
USERS_DELETE = 'users:delete',
USERS_MANAGE_ROLES = 'users:manage_roles',
// Departments
DEPARTMENTS_VIEW = 'departments:view',
DEPARTMENTS_CREATE = 'departments:create',
DEPARTMENTS_UPDATE = 'departments:update',
DEPARTMENTS_DELETE = 'departments:delete',
DEPARTMENTS_MANAGE = 'departments:manage',
// Employees
EMPLOYEES_VIEW = 'employees:view',
EMPLOYEES_VIEW_ALL = 'employees:view_all',
EMPLOYEES_CREATE = 'employees:create',
EMPLOYEES_UPDATE = 'employees:update',
EMPLOYEES_DELETE = 'employees:delete',
// Time Entries
TIME_ENTRIES_VIEW = 'time_entries:view',
TIME_ENTRIES_VIEW_ALL = 'time_entries:view_all',
TIME_ENTRIES_CREATE = 'time_entries:create',
TIME_ENTRIES_UPDATE = 'time_entries:update',
TIME_ENTRIES_CORRECT = 'time_entries:correct',
TIME_ENTRIES_LOCK = 'time_entries:lock',
// Absences
ABSENCES_VIEW = 'absences:view',
ABSENCES_VIEW_OWN = 'absences:view:own',
ABSENCES_VIEW_DEPARTMENT = 'absences:view:department',
ABSENCES_VIEW_ALL = 'absences:view_all',
ABSENCES_CREATE = 'absences:create',
ABSENCES_APPROVE = 'absences:approve',
ABSENCES_CANCEL = 'absences:cancel',
// Reviews
REVIEWS_VIEW = 'reviews:view',
REVIEWS_VIEW_ALL = 'reviews:view_all',
REVIEWS_CREATE = 'reviews:create',
REVIEWS_UPDATE = 'reviews:update',
// Skills
SKILLS_VIEW = 'skills:view',
SKILLS_MANAGE = 'skills:manage',
SKILLS_ASSESS = 'skills:assess',
// Audit Logs
AUDIT_VIEW = 'audit:view',
AUDIT_VIEW_ALL = 'audit:view_all',
// Settings
SETTINGS_VIEW = 'settings:view',
SETTINGS_MANAGE = 'settings:manage',
// Integrations
INTEGRATIONS_VIEW = 'integrations:view',
INTEGRATIONS_MANAGE = 'integrations:manage',
INTEGRATIONS_SYNC = 'integrations:sync',
INTEGRATIONS_DELETE = 'integrations:delete',
// LEAN 3S Planning
S3_VIEW = 's3:view',
S3_CREATE = 's3:create',
S3_UPDATE = 's3:update',
S3_DELETE = 's3:delete',
S3_MANAGE = 's3:manage',
// LEAN Morning Meetings
MEETING_VIEW = 'meeting:view',
MEETING_CREATE = 'meeting:create',
MEETING_UPDATE = 'meeting:update',
MEETING_DELETE = 'meeting:delete',
}
/**
* Default permission sets for standard roles
*/
export const DEFAULT_ROLE_PERMISSIONS: Record<string, Permission[]> = {
admin: Object.values(Permission),
'hr-manager': [
Permission.DASHBOARD_VIEW,
Permission.DASHBOARD_CUSTOMIZE,
Permission.USERS_VIEW,
Permission.USERS_VIEW_ALL,
Permission.USERS_CREATE,
Permission.USERS_UPDATE,
Permission.USERS_UPDATE_SELF,
Permission.DEPARTMENTS_VIEW,
Permission.EMPLOYEES_VIEW,
Permission.EMPLOYEES_VIEW_ALL,
Permission.EMPLOYEES_CREATE,
Permission.EMPLOYEES_UPDATE,
Permission.TIME_ENTRIES_VIEW,
Permission.TIME_ENTRIES_VIEW_ALL,
Permission.TIME_ENTRIES_CORRECT,
Permission.ABSENCES_VIEW,
Permission.ABSENCES_VIEW_OWN,
Permission.ABSENCES_VIEW_DEPARTMENT,
Permission.ABSENCES_VIEW_ALL,
Permission.ABSENCES_CREATE,
Permission.ABSENCES_APPROVE,
Permission.ABSENCES_CANCEL,
Permission.REVIEWS_VIEW,
Permission.REVIEWS_VIEW_ALL,
Permission.REVIEWS_CREATE,
Permission.REVIEWS_UPDATE,
Permission.SKILLS_VIEW,
Permission.SKILLS_MANAGE,
Permission.SKILLS_ASSESS,
Permission.AUDIT_VIEW,
Permission.MEETING_VIEW,
Permission.MEETING_CREATE,
Permission.MEETING_UPDATE,
Permission.MEETING_DELETE,
],
'team-lead': [
Permission.DASHBOARD_VIEW,
Permission.DASHBOARD_CUSTOMIZE,
Permission.USERS_VIEW,
Permission.USERS_UPDATE_SELF,
Permission.DEPARTMENTS_VIEW,
Permission.EMPLOYEES_VIEW,
Permission.TIME_ENTRIES_VIEW,
Permission.TIME_ENTRIES_CREATE,
Permission.TIME_ENTRIES_UPDATE,
Permission.ABSENCES_VIEW,
Permission.ABSENCES_VIEW_OWN,
Permission.ABSENCES_VIEW_DEPARTMENT,
Permission.ABSENCES_CREATE,
Permission.ABSENCES_APPROVE,
Permission.ABSENCES_CANCEL,
Permission.REVIEWS_VIEW,
Permission.REVIEWS_CREATE,
Permission.SKILLS_VIEW,
Permission.SKILLS_ASSESS,
Permission.S3_VIEW,
Permission.S3_CREATE,
Permission.S3_UPDATE,
Permission.MEETING_VIEW,
Permission.MEETING_CREATE,
Permission.MEETING_UPDATE,
],
employee: [
Permission.DASHBOARD_VIEW,
Permission.USERS_UPDATE_SELF,
Permission.DEPARTMENTS_VIEW,
Permission.TIME_ENTRIES_VIEW,
Permission.TIME_ENTRIES_CREATE,
Permission.ABSENCES_VIEW,
Permission.ABSENCES_VIEW_OWN,
Permission.ABSENCES_CREATE,
Permission.ABSENCES_CANCEL,
Permission.SKILLS_VIEW,
Permission.S3_VIEW,
Permission.S3_UPDATE,
Permission.MEETING_VIEW,
],
};

View File

@@ -0,0 +1,91 @@
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Permission, DEFAULT_ROLE_PERMISSIONS } from './permissions.enum';
import { PERMISSIONS_KEY, PERMISSIONS_ALL_KEY } from './permissions.decorator';
import { JwtPayload } from '../interfaces/jwt-payload.interface';
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
// Check for ANY permission match
const requiredPermissions = this.reflector.getAllAndOverride<Permission[]>(
PERMISSIONS_KEY,
[context.getHandler(), context.getClass()],
);
// Check for ALL permissions match
const requiredAllPermissions = this.reflector.getAllAndOverride<Permission[]>(
PERMISSIONS_ALL_KEY,
[context.getHandler(), context.getClass()],
);
// If no permissions are required, allow access
if (
(!requiredPermissions || requiredPermissions.length === 0) &&
(!requiredAllPermissions || requiredAllPermissions.length === 0)
) {
return true;
}
const request = context.switchToHttp().getRequest();
const user = request.user as JwtPayload;
if (!user || !user.roles) {
throw new ForbiddenException('Access denied: No roles assigned');
}
// Collect all permissions from user's roles
const userPermissions = this.getUserPermissions(user.roles);
// Check ANY permission (at least one must match)
if (requiredPermissions && requiredPermissions.length > 0) {
const hasAnyPermission = requiredPermissions.some((permission) =>
userPermissions.has(permission),
);
if (!hasAnyPermission) {
throw new ForbiddenException(
`Access denied: Requires one of: ${requiredPermissions.join(', ')}`,
);
}
}
// Check ALL permissions (all must match)
if (requiredAllPermissions && requiredAllPermissions.length > 0) {
const hasAllPermissions = requiredAllPermissions.every((permission) =>
userPermissions.has(permission),
);
if (!hasAllPermissions) {
throw new ForbiddenException(
`Access denied: Requires all of: ${requiredAllPermissions.join(', ')}`,
);
}
}
return true;
}
/**
* Get all permissions for a user based on their roles
*/
private getUserPermissions(roles: string[]): Set<Permission> {
const permissions = new Set<Permission>();
for (const role of roles) {
const rolePermissions = DEFAULT_ROLE_PERMISSIONS[role];
if (rolePermissions) {
rolePermissions.forEach((permission) => permissions.add(permission));
}
}
return permissions;
}
}

View File

@@ -0,0 +1,44 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { JwtPayload } from '../interfaces/jwt-payload.interface';
import { UsersService } from '../../users/users.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private readonly configService: ConfigService,
private readonly usersService: UsersService,
) {
const secret = configService.get<string>('JWT_SECRET');
if (!secret) {
throw new Error('JWT_SECRET is not defined');
}
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: secret,
});
}
async validate(payload: JwtPayload): Promise<JwtPayload> {
// Optionally validate that the user still exists and is active
try {
const user = await this.usersService.findOne(payload.sub);
if (!user.isActive) {
throw new UnauthorizedException('User account is deactivated');
}
// Return the payload to be attached to the request
return {
sub: payload.sub,
email: payload.email,
roles: payload.roles,
};
} catch {
throw new UnauthorizedException('Invalid token');
}
}
}

View File

@@ -0,0 +1,10 @@
import { Module, Global } from '@nestjs/common';
import { EncryptionService } from './services/encryption.service';
@Global()
@Module({
imports: [],
providers: [EncryptionService],
exports: [EncryptionService],
})
export class CommonModule {}

View File

@@ -0,0 +1,50 @@
import { applyDecorators, Type } from '@nestjs/common';
import { ApiExtraModels, ApiOkResponse, getSchemaPath } from '@nestjs/swagger';
export interface PaginatedResponseMeta {
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface PaginatedResponse<T> {
data: T[];
meta: PaginatedResponseMeta;
}
export const ApiPaginatedResponse = <TModel extends Type<unknown>>(model: TModel) => {
return applyDecorators(
ApiExtraModels(model),
ApiOkResponse({
schema: {
allOf: [
{
properties: {
success: { type: 'boolean', example: true },
data: {
type: 'object',
properties: {
data: {
type: 'array',
items: { $ref: getSchemaPath(model) },
},
meta: {
type: 'object',
properties: {
total: { type: 'number', example: 100 },
page: { type: 'number', example: 1 },
limit: { type: 'number', example: 20 },
totalPages: { type: 'number', example: 5 },
},
},
},
},
timestamp: { type: 'string', example: '2024-01-15T10:30:00.000Z' },
},
},
],
},
}),
);
};

View File

@@ -0,0 +1 @@
export * from './api-paginated-response.decorator';

View File

@@ -0,0 +1,76 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
interface ErrorResponse {
statusCode: number;
message: string | string[];
error: string;
timestamp: string;
path: string;
method: string;
}
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
let status: number;
let message: string | string[];
let error: string;
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
if (typeof exceptionResponse === 'object' && exceptionResponse !== null) {
const responseObj = exceptionResponse as Record<string, unknown>;
message = (responseObj.message as string | string[]) || exception.message;
error = (responseObj.error as string) || exception.name;
} else {
message = exception.message;
error = exception.name;
}
} else if (exception instanceof Error) {
status = HttpStatus.INTERNAL_SERVER_ERROR;
message = 'Internal server error';
error = 'Internal Server Error';
// Log the actual error for debugging (but don't expose to client)
this.logger.error(`Unhandled exception: ${exception.message}`, exception.stack);
} else {
status = HttpStatus.INTERNAL_SERVER_ERROR;
message = 'Internal server error';
error = 'Internal Server Error';
this.logger.error('Unknown exception type', exception);
}
const errorResponse: ErrorResponse = {
statusCode: status,
message,
error,
timestamp: new Date().toISOString(),
path: request.url,
method: request.method,
};
// Log client errors in development
if (process.env.NODE_ENV === 'development' && status >= 400) {
this.logger.warn(`${request.method} ${request.url} - ${status}: ${JSON.stringify(message)}`);
}
response.status(status).json(errorResponse);
}
}

View File

@@ -0,0 +1 @@
export * from './http-exception.filter';

View File

@@ -0,0 +1,6 @@
export * from './common.module';
export * from './filters';
export * from './interceptors';
export * from './decorators';
export * from './pipes';
export * from './services';

View File

@@ -0,0 +1,3 @@
export * from './transform.interceptor';
export * from './logging.interceptor';
export * from './timeout.interceptor';

View File

@@ -0,0 +1,44 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
Logger,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { Request } from 'express';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger('HTTP');
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const request = context.switchToHttp().getRequest<Request>();
const { method, url, ip } = request;
const userAgent = request.get('user-agent') || '';
const startTime = Date.now();
return next.handle().pipe(
tap({
next: () => {
const response = context.switchToHttp().getResponse();
const { statusCode } = response;
const duration = Date.now() - startTime;
this.logger.log(
`${method} ${url} ${statusCode} - ${duration}ms - ${ip} - ${userAgent}`,
);
},
error: (error) => {
const duration = Date.now() - startTime;
const statusCode = error.status || 500;
this.logger.error(
`${method} ${url} ${statusCode} - ${duration}ms - ${ip} - ${error.message}`,
);
},
}),
);
}
}

View File

@@ -0,0 +1,26 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
RequestTimeoutException,
} from '@nestjs/common';
import { Observable, throwError, TimeoutError } from 'rxjs';
import { catchError, timeout } from 'rxjs/operators';
@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
constructor(private readonly timeoutMs: number = 30000) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
return next.handle().pipe(
timeout(this.timeoutMs),
catchError((err) => {
if (err instanceof TimeoutError) {
return throwError(() => new RequestTimeoutException('Request timed out'));
}
return throwError(() => err);
}),
);
}
}

View File

@@ -0,0 +1,27 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
export interface ApiResponse<T> {
success: boolean;
data: T;
timestamp: string;
}
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
intercept(context: ExecutionContext, next: CallHandler): Observable<ApiResponse<T>> {
return next.handle().pipe(
map((data) => ({
success: true,
data,
timestamp: new Date().toISOString(),
})),
);
}
}

View File

@@ -0,0 +1 @@
export * from './parse-cuid.pipe';

View File

@@ -0,0 +1,19 @@
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
/**
* Validates that a string is a valid CUID
* CUID format: starts with 'c' followed by 24 alphanumeric characters
*/
@Injectable()
export class ParseCuidPipe implements PipeTransform<string, string> {
transform(value: string): string {
// CUID pattern: c followed by 24 alphanumeric characters
const cuidRegex = /^c[a-z0-9]{24}$/;
if (!cuidRegex.test(value)) {
throw new BadRequestException(`Invalid ID format: ${value}`);
}
return value;
}
}

View File

@@ -0,0 +1,206 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { EncryptionService } from './encryption.service';
describe('EncryptionService', () => {
let service: EncryptionService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
EncryptionService,
{
provide: ConfigService,
useValue: {
get: jest.fn((key: string, defaultValue?: string) => {
if (key === 'ENCRYPTION_KEY') return 'test-encryption-key-for-unit-tests';
if (key === 'NODE_ENV') return defaultValue ?? 'test';
return defaultValue;
}),
},
},
],
}).compile();
service = module.get<EncryptionService>(EncryptionService);
service.onModuleInit();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('encrypt / decrypt roundtrip', () => {
it('should encrypt and decrypt a simple string', () => {
const plaintext = 'Hello, World!';
const encrypted = service.encrypt(plaintext);
expect(encrypted).not.toBe(plaintext);
expect(encrypted.length).toBeGreaterThan(0);
const decrypted = service.decrypt(encrypted);
expect(decrypted).toBe(plaintext);
});
it('should encrypt and decrypt a long string', () => {
const plaintext = 'A'.repeat(10000);
const encrypted = service.encrypt(plaintext);
const decrypted = service.decrypt(encrypted);
expect(decrypted).toBe(plaintext);
});
it('should encrypt and decrypt unicode characters', () => {
const plaintext = 'Hallo Welt! Umlaute: aou Emojis: ';
const encrypted = service.encrypt(plaintext);
const decrypted = service.decrypt(encrypted);
expect(decrypted).toBe(plaintext);
});
it('should produce different ciphertexts for the same plaintext (random IV)', () => {
const plaintext = 'same input';
const encrypted1 = service.encrypt(plaintext);
const encrypted2 = service.encrypt(plaintext);
expect(encrypted1).not.toBe(encrypted2);
});
it('should produce base64-encoded output', () => {
const plaintext = 'test';
const encrypted = service.encrypt(plaintext);
// Base64 regex
expect(encrypted).toMatch(/^[A-Za-z0-9+/]+=*$/);
});
});
describe('empty string handling', () => {
it('should return empty string when encrypting empty string', () => {
expect(service.encrypt('')).toBe('');
});
it('should return empty string when decrypting empty string', () => {
expect(service.decrypt('')).toBe('');
});
});
describe('encryptObject / decryptObject roundtrip', () => {
it('should encrypt and decrypt a simple object', () => {
const data = { name: 'John', salary: 50000 };
const encrypted = service.encryptObject(data);
expect(typeof encrypted).toBe('string');
expect(encrypted.length).toBeGreaterThan(0);
const decrypted = service.decryptObject<typeof data>(encrypted);
expect(decrypted).toEqual(data);
});
it('should encrypt and decrypt a nested object', () => {
const data = {
employee: {
name: 'Jane',
bankAccount: {
iban: 'DE89370400440532013000',
bic: 'COBADEFFXXX',
},
},
};
const encrypted = service.encryptObject(data);
const decrypted = service.decryptObject<typeof data>(encrypted);
expect(decrypted).toEqual(data);
});
it('should encrypt and decrypt an array', () => {
const data = [1, 2, 3, 'four', { five: 5 }];
const encrypted = service.encryptObject(data);
const decrypted = service.decryptObject<typeof data>(encrypted);
expect(decrypted).toEqual(data);
});
it('should encrypt and decrypt null values in objects', () => {
const data = { key: null, other: 'value' };
const encrypted = service.encryptObject(data);
const decrypted = service.decryptObject<typeof data>(encrypted);
expect(decrypted).toEqual(data);
});
});
describe('decryption failure', () => {
it('should throw on tampered data', () => {
const encrypted = service.encrypt('secret');
// Tamper with the encrypted data by flipping a character
const tampered = encrypted.slice(0, -5) + 'XXXXX';
expect(() => service.decrypt(tampered)).toThrow('Failed to decrypt data');
});
it('should throw on invalid base64 input', () => {
expect(() => service.decrypt('not-valid-base64!!!')).toThrow();
});
});
describe('generateKey (static)', () => {
it('should generate a hex string of the correct length (default 32 bytes = 64 hex chars)', () => {
const key = EncryptionService.generateKey();
expect(key).toMatch(/^[a-f0-9]+$/);
expect(key).toHaveLength(64);
});
it('should generate a hex string of custom length', () => {
const key = EncryptionService.generateKey(16);
expect(key).toMatch(/^[a-f0-9]+$/);
expect(key).toHaveLength(32); // 16 bytes = 32 hex chars
});
it('should generate unique keys', () => {
const key1 = EncryptionService.generateKey();
const key2 = EncryptionService.generateKey();
expect(key1).not.toBe(key2);
});
});
describe('initialization', () => {
it('should throw in production without ENCRYPTION_KEY', async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
EncryptionService,
{
provide: ConfigService,
useValue: {
get: jest.fn((key: string, defaultValue?: string) => {
if (key === 'ENCRYPTION_KEY') return undefined;
if (key === 'NODE_ENV') return 'production';
return defaultValue;
}),
},
},
],
}).compile();
const svc = module.get<EncryptionService>(EncryptionService);
expect(() => svc.onModuleInit()).toThrow('ENCRYPTION_KEY environment variable is required in production');
});
it('should use fallback key in development without ENCRYPTION_KEY', async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
EncryptionService,
{
provide: ConfigService,
useValue: {
get: jest.fn((key: string, defaultValue?: string) => {
if (key === 'ENCRYPTION_KEY') return undefined;
if (key === 'NODE_ENV') return defaultValue ?? 'development';
if (key === 'JWT_SECRET') return defaultValue ?? 'dev-fallback-key';
return defaultValue;
}),
},
},
],
}).compile();
const svc = module.get<EncryptionService>(EncryptionService);
// Should not throw in development
expect(() => svc.onModuleInit()).not.toThrow();
// Service should still work
const encrypted = svc.encrypt('test');
expect(svc.decrypt(encrypted)).toBe('test');
});
});
});

View File

@@ -0,0 +1,171 @@
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as crypto from 'crypto';
/**
* Service for encrypting and decrypting sensitive data using AES-256-GCM
*
* Security features:
* - AES-256-GCM encryption with authenticated encryption
* - Random IV for each encryption operation
* - Auth tag for data integrity verification
* - Secure key derivation from environment variable
*/
@Injectable()
export class EncryptionService implements OnModuleInit {
private readonly logger = new Logger(EncryptionService.name);
private readonly algorithm = 'aes-256-gcm';
private readonly ivLength = 16; // 128 bits
private readonly tagLength = 16; // 128 bits
private readonly keyLength = 32; // 256 bits
private encryptionKey: Buffer;
constructor(private readonly configService: ConfigService) {}
onModuleInit(): void {
const key = this.configService.get<string>('ENCRYPTION_KEY');
const nodeEnv = this.configService.get<string>('NODE_ENV', 'development');
const isProduction = nodeEnv === 'production';
if (!key) {
if (isProduction) {
// CRITICAL: In production, ENCRYPTION_KEY must be set
throw new Error(
'ENCRYPTION_KEY environment variable is required in production. ' +
'Generate one with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"'
);
}
this.logger.warn(
'ENCRYPTION_KEY not configured. Using derived key from JWT_SECRET. ' +
'This is acceptable for development but NOT for production!',
);
// Fallback for development only: derive key from JWT_SECRET
const jwtSecret = this.configService.get<string>('JWT_SECRET', 'dev-fallback-key');
this.encryptionKey = this.deriveKey(jwtSecret);
} else if (key.length < this.keyLength) {
// Derive a proper key from the provided string
this.encryptionKey = this.deriveKey(key);
} else {
// Use the first 32 bytes if key is longer
this.encryptionKey = Buffer.from(key.slice(0, this.keyLength), 'utf-8');
}
}
/**
* Derives a 256-bit key from a password/passphrase using PBKDF2
*/
private deriveKey(password: string): Buffer {
// Using a fixed salt is acceptable here since we're just deriving
// an encryption key from a secret, not storing passwords
const salt = 'tos-encryption-salt-v1';
return crypto.pbkdf2Sync(password, salt, 100000, this.keyLength, 'sha256');
}
/**
* Encrypts a string using AES-256-GCM
*
* @param plaintext - The string to encrypt
* @returns Base64-encoded string containing IV + ciphertext + auth tag
*/
encrypt(plaintext: string): string {
if (!plaintext) {
return '';
}
// Generate random IV for each encryption
const iv = crypto.randomBytes(this.ivLength);
// Create cipher
const cipher = crypto.createCipheriv(this.algorithm, this.encryptionKey, iv, {
authTagLength: this.tagLength,
});
// Encrypt the data
const encrypted = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
]);
// Get the auth tag
const authTag = cipher.getAuthTag();
// Combine IV + ciphertext + authTag and encode as base64
const combined = Buffer.concat([iv, encrypted, authTag]);
return combined.toString('base64');
}
/**
* Decrypts a string that was encrypted with the encrypt method
*
* @param encryptedData - Base64-encoded string from encrypt()
* @returns The original plaintext string
* @throws Error if decryption fails (tampered data or wrong key)
*/
decrypt(encryptedData: string): string {
if (!encryptedData) {
return '';
}
try {
// Decode the combined data
const combined = Buffer.from(encryptedData, 'base64');
// Extract IV, ciphertext, and auth tag
const iv = combined.subarray(0, this.ivLength);
const authTag = combined.subarray(combined.length - this.tagLength);
const ciphertext = combined.subarray(this.ivLength, combined.length - this.tagLength);
// Create decipher
const decipher = crypto.createDecipheriv(this.algorithm, this.encryptionKey, iv, {
authTagLength: this.tagLength,
});
// Set the auth tag for verification
decipher.setAuthTag(authTag);
// Decrypt the data
const decrypted = Buffer.concat([
decipher.update(ciphertext),
decipher.final(),
]);
return decrypted.toString('utf8');
} catch (error) {
this.logger.error('Decryption failed - data may be corrupted or tampered with');
throw new Error('Failed to decrypt data');
}
}
/**
* Encrypts a JavaScript object as JSON
*
* @param data - Object to encrypt
* @returns Encrypted string
*/
encryptObject<T>(data: T): string {
return this.encrypt(JSON.stringify(data));
}
/**
* Decrypts a string back to a JavaScript object
*
* @param encryptedData - Encrypted string from encryptObject()
* @returns The original object
*/
decryptObject<T>(encryptedData: string): T {
const json = this.decrypt(encryptedData);
return JSON.parse(json) as T;
}
/**
* Generates a secure random string suitable for use as an encryption key
*
* @param length - Length of the key in bytes (default: 32 for AES-256)
* @returns Hex-encoded random string
*/
static generateKey(length: number = 32): string {
return crypto.randomBytes(length).toString('hex');
}
}

View File

@@ -0,0 +1 @@
export * from './encryption.service';

View File

@@ -0,0 +1,81 @@
import * as Joi from 'joi';
export const configValidationSchema = Joi.object({
// Application
NODE_ENV: Joi.string().valid('development', 'production', 'test').default('development'),
PORT: Joi.number().default(3001),
API_PREFIX: Joi.string().default('api'),
// Database
DATABASE_URL: Joi.string().required(),
// JWT / Keycloak
JWT_SECRET: Joi.string().when('NODE_ENV', {
is: 'production',
then: Joi.required(),
otherwise: Joi.string().default('dev-secret-key-change-in-production'),
}),
KEYCLOAK_URL: Joi.string().uri().optional(),
KEYCLOAK_REALM: Joi.string().optional(),
KEYCLOAK_CLIENT_ID: Joi.string().optional(),
KEYCLOAK_CLIENT_SECRET: Joi.string().optional(),
// CORS
CORS_ORIGINS: Joi.string().default('http://localhost:3000'),
// Swagger
SWAGGER_ENABLED: Joi.string().valid('true', 'false').default('true'),
// Encryption (Phase 3)
ENCRYPTION_KEY: Joi.string().min(32).when('NODE_ENV', {
is: 'production',
then: Joi.required(),
otherwise: Joi.optional(),
}),
// Redis (Phase 3 - for BullMQ)
REDIS_HOST: Joi.string().optional(),
REDIS_PORT: Joi.number().optional(),
// Sync Jobs (Phase 3)
ENABLE_SYNC_JOBS: Joi.string().valid('true', 'false').default('false'),
SYNC_INTERVAL_PLENTYONE: Joi.number().min(1).default(15),
SYNC_INTERVAL_ZULIP: Joi.number().min(1).default(5),
SYNC_INTERVAL_TODOIST: Joi.number().min(1).default(10),
SYNC_INTERVAL_FREESCOUT: Joi.number().min(1).default(10),
SYNC_INTERVAL_NEXTCLOUD: Joi.number().min(1).default(30),
SYNC_INTERVAL_ECODMS: Joi.number().min(1).default(60),
SYNC_INTERVAL_GEMBADOCS: Joi.number().min(1).default(30),
// ============================================================================
// Integration Credentials (Phase 3 - API Connectors)
// ============================================================================
// PlentyONE (OAuth2 Client Credentials)
PLENTYONE_BASE_URL: Joi.string().uri().optional(),
PLENTYONE_CLIENT_ID: Joi.string().optional(),
PLENTYONE_CLIENT_SECRET: Joi.string().optional(),
// ZULIP (Basic Auth with API Key)
ZULIP_BASE_URL: Joi.string().uri().optional(),
ZULIP_EMAIL: Joi.string().email().optional(),
ZULIP_API_KEY: Joi.string().optional(),
// Todoist (Bearer Token)
TODOIST_API_TOKEN: Joi.string().optional(),
// FreeScout (API Key)
FREESCOUT_API_URL: Joi.string().uri().optional(),
FREESCOUT_API_KEY: Joi.string().optional(),
// Nextcloud (Basic Auth / App Password)
NEXTCLOUD_URL: Joi.string().uri().optional(),
NEXTCLOUD_USERNAME: Joi.string().optional(),
NEXTCLOUD_PASSWORD: Joi.string().optional(),
// ecoDMS (Session-based Auth)
ECODMS_API_URL: Joi.string().uri().optional(),
ECODMS_USERNAME: Joi.string().optional(),
ECODMS_PASSWORD: Joi.string().optional(),
ECODMS_API_VERSION: Joi.string().default('v1'),
});

View File

@@ -0,0 +1 @@
export * from './config.validation';

View File

@@ -0,0 +1,144 @@
import { Test, TestingModule } from '@nestjs/testing';
import { HealthController } from './health.controller';
import {
HealthCheckService,
MemoryHealthIndicator,
DiskHealthIndicator,
HealthCheckResult,
} from '@nestjs/terminus';
import { PrismaHealthIndicator } from './prisma-health.indicator';
import { ModulesHealthIndicator } from './modules-health.indicator';
describe('HealthController', () => {
let controller: HealthController;
let healthCheckService: HealthCheckService;
const mockHealthCheckService = {
check: jest.fn(),
};
const mockMemoryHealth = {
checkHeap: jest.fn(),
checkRSS: jest.fn(),
};
const mockDiskHealth = {
checkStorage: jest.fn(),
};
const mockPrismaHealth = {
isHealthy: jest.fn(),
};
const mockModulesHealth = {
isHealthy: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [HealthController],
providers: [
{ provide: HealthCheckService, useValue: mockHealthCheckService },
{ provide: MemoryHealthIndicator, useValue: mockMemoryHealth },
{ provide: DiskHealthIndicator, useValue: mockDiskHealth },
{ provide: PrismaHealthIndicator, useValue: mockPrismaHealth },
{ provide: ModulesHealthIndicator, useValue: mockModulesHealth },
],
}).compile();
controller = module.get<HealthController>(HealthController);
healthCheckService = module.get<HealthCheckService>(HealthCheckService);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
describe('check()', () => {
it('should call health check service with all indicators', async () => {
const expectedResult: HealthCheckResult = {
status: 'ok',
info: {
database: { status: 'up' },
memory_heap: { status: 'up' },
memory_rss: { status: 'up' },
modules: { status: 'up' },
},
error: {},
details: {
database: { status: 'up' },
memory_heap: { status: 'up' },
memory_rss: { status: 'up' },
modules: { status: 'up' },
},
};
mockHealthCheckService.check.mockResolvedValue(expectedResult);
const result = await controller.check();
expect(result).toEqual(expectedResult);
expect(mockHealthCheckService.check).toHaveBeenCalledWith(
expect.arrayContaining([
expect.any(Function),
expect.any(Function),
expect.any(Function),
expect.any(Function),
]),
);
});
it('should pass an array of 4 health indicator callbacks', async () => {
mockHealthCheckService.check.mockResolvedValue({ status: 'ok' });
await controller.check();
const checkArgs = mockHealthCheckService.check.mock.calls[0][0];
expect(checkArgs).toHaveLength(4);
});
});
describe('liveness()', () => {
it('should return ok status with timestamp', () => {
const result = controller.liveness();
expect(result).toHaveProperty('status', 'ok');
expect(result).toHaveProperty('timestamp');
expect(typeof result.timestamp).toBe('string');
});
it('should return a valid ISO timestamp', () => {
const result = controller.liveness();
const parsed = new Date(result.timestamp);
expect(parsed.toISOString()).toBe(result.timestamp);
});
});
describe('readiness()', () => {
it('should call health check service with database check only', async () => {
const expectedResult: HealthCheckResult = {
status: 'ok',
info: { database: { status: 'up' } },
error: {},
details: { database: { status: 'up' } },
};
mockHealthCheckService.check.mockResolvedValue(expectedResult);
const result = await controller.readiness();
expect(result).toEqual(expectedResult);
expect(mockHealthCheckService.check).toHaveBeenCalledWith(
expect.arrayContaining([expect.any(Function)]),
);
});
it('should pass exactly 1 health indicator callback for readiness', async () => {
mockHealthCheckService.check.mockResolvedValue({ status: 'ok' });
await controller.readiness();
const checkArgs = mockHealthCheckService.check.mock.calls[0][0];
expect(checkArgs).toHaveLength(1);
});
});
});

View File

@@ -0,0 +1,109 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import {
HealthCheck,
HealthCheckService,
MemoryHealthIndicator,
DiskHealthIndicator,
} from '@nestjs/terminus';
import { Public } from '../auth/decorators/public.decorator';
import { PrismaHealthIndicator } from './prisma-health.indicator';
import { ModulesHealthIndicator } from './modules-health.indicator';
@ApiTags('health')
@Controller('health')
export class HealthController {
constructor(
private readonly health: HealthCheckService,
private readonly memory: MemoryHealthIndicator,
private readonly disk: DiskHealthIndicator,
private readonly prismaHealth: PrismaHealthIndicator,
private readonly modulesHealth: ModulesHealthIndicator,
) {}
@Get()
@Public()
@HealthCheck()
@ApiOperation({ summary: 'Health check endpoint' })
@ApiResponse({
status: 200,
description: 'Application is healthy',
schema: {
type: 'object',
properties: {
status: { type: 'string', example: 'ok' },
info: {
type: 'object',
properties: {
database: {
type: 'object',
properties: {
status: { type: 'string', example: 'up' },
},
},
},
},
error: { type: 'object' },
details: { type: 'object' },
},
},
})
@ApiResponse({
status: 503,
description: 'Application is unhealthy',
})
check() {
return this.health.check([
// Database connectivity
() => this.prismaHealth.isHealthy('database'),
// Memory usage (heap should not exceed 150MB)
() => this.memory.checkHeap('memory_heap', 150 * 1024 * 1024),
// RSS memory should not exceed 300MB
() => this.memory.checkRSS('memory_rss', 300 * 1024 * 1024),
// Application modules status
() => this.modulesHealth.isHealthy('modules'),
]);
}
@Get('liveness')
@Public()
@ApiOperation({ summary: 'Liveness probe for Kubernetes' })
@ApiResponse({
status: 200,
description: 'Application is alive',
schema: {
type: 'object',
properties: {
status: { type: 'string', example: 'ok' },
timestamp: { type: 'string', example: '2024-01-15T10:30:00.000Z' },
},
},
})
liveness() {
return {
status: 'ok',
timestamp: new Date().toISOString(),
};
}
@Get('readiness')
@Public()
@HealthCheck()
@ApiOperation({ summary: 'Readiness probe for Kubernetes' })
@ApiResponse({
status: 200,
description: 'Application is ready to serve traffic',
})
@ApiResponse({
status: 503,
description: 'Application is not ready',
})
readiness() {
return this.health.check([
() => this.prismaHealth.isHealthy('database'),
]);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import { HealthController } from './health.controller';
import { PrismaHealthIndicator } from './prisma-health.indicator';
import { ModulesHealthIndicator } from './modules-health.indicator';
@Module({
imports: [TerminusModule],
controllers: [HealthController],
providers: [PrismaHealthIndicator, ModulesHealthIndicator],
})
export class HealthModule {}

View File

@@ -0,0 +1,4 @@
export * from './health.module';
export * from './health.controller';
export * from './prisma-health.indicator';
export * from './modules-health.indicator';

View File

@@ -0,0 +1,141 @@
import { Injectable } from '@nestjs/common';
import {
HealthIndicator,
HealthIndicatorResult,
} from '@nestjs/terminus';
import { PrismaService } from '../prisma/prisma.service';
interface ModuleStatus {
name: string;
status: 'up' | 'down' | 'degraded';
tables?: number;
message?: string;
}
/**
* Health indicator that reports the status of application modules
* by checking their associated database tables.
*/
@Injectable()
export class ModulesHealthIndicator extends HealthIndicator {
constructor(private readonly prisma: PrismaService) {
super();
}
async isHealthy(key: string): Promise<HealthIndicatorResult> {
const moduleStatuses: ModuleStatus[] = [];
let overallStatus: 'up' | 'degraded' = 'up';
// Check core modules
const coreStatus = await this.checkCoreModule();
moduleStatuses.push(coreStatus);
if (coreStatus.status !== 'up') overallStatus = 'degraded';
// Check HR module
const hrStatus = await this.checkHrModule();
moduleStatuses.push(hrStatus);
if (hrStatus.status !== 'up') overallStatus = 'degraded';
// Check LEAN module
const leanStatus = await this.checkLeanModule();
moduleStatuses.push(leanStatus);
if (leanStatus.status !== 'up') overallStatus = 'degraded';
// Check Integrations module
const integrationsStatus = await this.checkIntegrationsModule();
moduleStatuses.push(integrationsStatus);
if (integrationsStatus.status !== 'up') overallStatus = 'degraded';
return this.getStatus(key, overallStatus === 'up', {
status: overallStatus,
modules: moduleStatuses,
});
}
private async checkCoreModule(): Promise<ModuleStatus> {
try {
const [users, departments, roles] = await Promise.all([
this.prisma.user.count(),
this.prisma.department.count(),
this.prisma.role.count(),
]);
return {
name: 'core',
status: 'up',
message: `Users: ${users}, Departments: ${departments}, Roles: ${roles}`,
};
} catch (error) {
return {
name: 'core',
status: 'down',
message: error instanceof Error ? error.message : 'Unknown error',
};
}
}
private async checkHrModule(): Promise<ModuleStatus> {
try {
const [employees, absences, timeEntries] = await Promise.all([
this.prisma.employee.count(),
this.prisma.absence.count(),
this.prisma.timeEntry.count(),
]);
return {
name: 'hr',
status: 'up',
message: `Employees: ${employees}, Absences: ${absences}, TimeEntries: ${timeEntries}`,
};
} catch (error) {
return {
name: 'hr',
status: 'down',
message: error instanceof Error ? error.message : 'Unknown error',
};
}
}
private async checkLeanModule(): Promise<ModuleStatus> {
try {
const [s3Plans, skills, meetings] = await Promise.all([
this.prisma.s3Plan.count(),
this.prisma.skill.count(),
this.prisma.morningMeeting.count(),
]);
return {
name: 'lean',
status: 'up',
message: `S3Plans: ${s3Plans}, Skills: ${skills}, Meetings: ${meetings}`,
};
} catch (error) {
return {
name: 'lean',
status: 'down',
message: error instanceof Error ? error.message : 'Unknown error',
};
}
}
private async checkIntegrationsModule(): Promise<ModuleStatus> {
try {
const [credentials, syncHistory] = await Promise.all([
this.prisma.integrationCredential.count(),
this.prisma.integrationSyncHistory.count(),
]);
return {
name: 'integrations',
status: 'up',
message: `Credentials: ${credentials}, SyncHistory: ${syncHistory}`,
};
} catch (error) {
return {
name: 'integrations',
status: 'down',
message: error instanceof Error ? error.message : 'Unknown error',
};
}
}
}

View File

@@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import { HealthIndicator, HealthIndicatorResult, HealthCheckError } from '@nestjs/terminus';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class PrismaHealthIndicator extends HealthIndicator {
constructor(private readonly prisma: PrismaService) {
super();
}
async isHealthy(key: string): Promise<HealthIndicatorResult> {
try {
// Execute a simple query to check database connectivity
await this.prisma.$queryRaw`SELECT 1`;
return this.getStatus(key, true, {
message: 'Database connection is healthy',
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
throw new HealthCheckError(
'Prisma health check failed',
this.getStatus(key, false, {
message: errorMessage,
}),
);
}
}
}

100
apps/api/src/main.ts Normal file
View File

@@ -0,0 +1,100 @@
import { NestFactory } from '@nestjs/core';
import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import helmet from 'helmet';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
async function bootstrap() {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
// Security
app.use(helmet());
// CORS
const corsOrigins = configService.get<string>('CORS_ORIGINS')?.split(',').map((origin) => origin.trim()) || [
'http://localhost:3000',
];
app.enableCors({
origin: corsOrigins,
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS',
credentials: true,
});
// API Prefix
const apiPrefix = configService.get<string>('API_PREFIX') || 'api';
app.setGlobalPrefix(apiPrefix);
// API Versioning
app.enableVersioning({
type: VersioningType.URI,
defaultVersion: '1',
});
// Global Pipes
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: {
enableImplicitConversion: true,
},
}),
);
// Global Filters
app.useGlobalFilters(new HttpExceptionFilter());
// Global Interceptors
app.useGlobalInterceptors(new TransformInterceptor());
// Swagger Documentation
const swaggerEnabled = configService.get<string>('SWAGGER_ENABLED') === 'true';
if (swaggerEnabled) {
const config = new DocumentBuilder()
.setTitle('tOS API')
.setDescription('Team Operating System - Backend API Documentation')
.setVersion('1.0')
.addBearerAuth(
{
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
name: 'JWT',
description: 'Enter JWT token',
in: 'header',
},
'JWT-auth',
)
.addTag('auth', 'Authentication endpoints')
.addTag('users', 'User management endpoints')
.addTag('health', 'Health check endpoints')
.addTag('dashboard', 'Dashboard widgets and statistics')
.addTag('departments', 'Department management')
.addTag('user-preferences', 'User preferences and settings')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup(`${apiPrefix}/docs`, app, document, {
swaggerOptions: {
persistAuthorization: true,
},
});
}
// Start server
const port = configService.get<number>('PORT') || 3001;
await app.listen(port);
logger.log(`Application is running on: http://localhost:${port}/${apiPrefix}`);
if (swaggerEnabled) {
logger.log(`Swagger documentation: http://localhost:${port}/${apiPrefix}/docs`);
}
}
bootstrap();

View File

@@ -0,0 +1,146 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { Reflector } from '@nestjs/core';
import { AuditService } from './audit.service';
import { JwtPayload } from '../../auth/interfaces/jwt-payload.interface';
export const AUDIT_ACTION_KEY = 'audit_action';
export const AUDIT_ENTITY_KEY = 'audit_entity';
/**
* Decorator to mark a route for audit logging
*/
export function AuditLog(entity: string, action: string) {
return (target: object, key?: string | symbol, descriptor?: PropertyDescriptor) => {
if (descriptor) {
Reflect.defineMetadata(AUDIT_ACTION_KEY, action, descriptor.value);
Reflect.defineMetadata(AUDIT_ENTITY_KEY, entity, descriptor.value);
}
return descriptor;
};
}
@Injectable()
export class AuditInterceptor implements NestInterceptor {
constructor(
private readonly auditService: AuditService,
private readonly reflector: Reflector,
) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const action = this.reflector.get<string>(
AUDIT_ACTION_KEY,
context.getHandler(),
);
const entity = this.reflector.get<string>(
AUDIT_ENTITY_KEY,
context.getHandler(),
);
// If no audit metadata, skip logging
if (!action || !entity) {
return next.handle();
}
const request = context.switchToHttp().getRequest();
const user = request.user as JwtPayload | undefined;
// Skip if no authenticated user
if (!user?.sub) {
return next.handle();
}
const entityId = request.params?.id || undefined;
const oldData = request.body ? undefined : undefined; // Old data would need to be fetched before operation
const ipAddress = this.getClientIp(request);
const userAgent = request.headers['user-agent'];
return next.handle().pipe(
tap({
next: (result) => {
// Log successful operation
this.auditService
.log({
userId: user.sub,
action,
entity,
entityId,
oldData,
newData: this.sanitizeData(result),
ipAddress,
userAgent,
})
.catch((error) => {
// Log error but don't fail the request
console.error('Failed to write audit log:', error);
});
},
error: () => {
// Optionally log failed operations
this.auditService
.log({
userId: user.sub,
action: `${action}_FAILED`,
entity,
entityId,
ipAddress,
userAgent,
})
.catch((error) => {
console.error('Failed to write audit log:', error);
});
},
}),
);
}
/**
* Extract client IP address from request
*/
private getClientIp(request: {
headers: Record<string, string | string[] | undefined>;
ip?: string;
connection?: { remoteAddress?: string };
}): string | undefined {
const forwardedFor = request.headers['x-forwarded-for'];
if (forwardedFor) {
const ips = Array.isArray(forwardedFor)
? forwardedFor[0]
: forwardedFor.split(',')[0];
return ips.trim();
}
return request.ip || request.connection?.remoteAddress;
}
/**
* Remove sensitive data from audit log
*/
private sanitizeData(
data: unknown,
): Record<string, unknown> | undefined {
if (!data || typeof data !== 'object') {
return undefined;
}
const sensitiveKeys = ['password', 'token', 'secret', 'credentials', 'bankAccount'];
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
if (sensitiveKeys.some((sk) => key.toLowerCase().includes(sk))) {
result[key] = '[REDACTED]';
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
result[key] = this.sanitizeData(value);
} else {
result[key] = value;
}
}
return result;
}
}

View File

@@ -0,0 +1,10 @@
import { Module, Global } from '@nestjs/common';
import { AuditService } from './audit.service';
import { AuditInterceptor } from './audit.interceptor';
@Global()
@Module({
providers: [AuditService, AuditInterceptor],
exports: [AuditService, AuditInterceptor],
})
export class AuditModule {}

View File

@@ -0,0 +1,215 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { Prisma } from '@prisma/client';
export interface AuditLogParams {
userId: string;
action: string;
entity: string;
entityId?: string;
oldData?: Record<string, unknown>;
newData?: Record<string, unknown>;
ipAddress?: string;
userAgent?: string;
}
export interface PaginationParams {
page?: number;
limit?: number;
}
@Injectable()
export class AuditService {
constructor(private readonly prisma: PrismaService) {}
/**
* Log an audit event
*/
async log(params: AuditLogParams) {
return this.prisma.auditLog.create({
data: {
userId: params.userId,
action: params.action,
entity: params.entity,
entityId: params.entityId,
oldData: params.oldData as Prisma.JsonObject | undefined,
newData: params.newData as Prisma.JsonObject | undefined,
ipAddress: params.ipAddress,
userAgent: params.userAgent,
},
});
}
/**
* Get audit logs by user
*/
async getByUser(userId: string, pagination: PaginationParams = {}) {
const { page = 1, limit = 20 } = pagination;
const skip = (page - 1) * limit;
const [logs, total] = await Promise.all([
this.prisma.auditLog.findMany({
where: { userId },
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
user: {
select: {
id: true,
email: true,
firstName: true,
lastName: true,
},
},
},
}),
this.prisma.auditLog.count({ where: { userId } }),
]);
return {
data: logs,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* Get audit logs by entity
*/
async getByEntity(
entity: string,
entityId: string,
pagination: PaginationParams = {},
) {
const { page = 1, limit = 20 } = pagination;
const skip = (page - 1) * limit;
const where: Prisma.AuditLogWhereInput = {
entity,
entityId,
};
const [logs, total] = await Promise.all([
this.prisma.auditLog.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
user: {
select: {
id: true,
email: true,
firstName: true,
lastName: true,
},
},
},
}),
this.prisma.auditLog.count({ where }),
]);
return {
data: logs,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* Get all audit logs with filtering
*/
async findAll(params: {
page?: number;
limit?: number;
action?: string;
entity?: string;
userId?: string;
startDate?: Date;
endDate?: Date;
}) {
const {
page = 1,
limit = 20,
action,
entity,
userId,
startDate,
endDate,
} = params;
const skip = (page - 1) * limit;
const where: Prisma.AuditLogWhereInput = {
...(action && { action }),
...(entity && { entity }),
...(userId && { userId }),
...(startDate || endDate
? {
createdAt: {
...(startDate && { gte: startDate }),
...(endDate && { lte: endDate }),
},
}
: {}),
};
const [logs, total] = await Promise.all([
this.prisma.auditLog.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
user: {
select: {
id: true,
email: true,
firstName: true,
lastName: true,
},
},
},
}),
this.prisma.auditLog.count({ where }),
]);
return {
data: logs,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* Get recent activity summary
*/
async getRecentActivity(limit = 10) {
return this.prisma.auditLog.findMany({
take: limit,
orderBy: { createdAt: 'desc' },
include: {
user: {
select: {
id: true,
email: true,
firstName: true,
lastName: true,
},
},
},
});
}
}

View File

@@ -0,0 +1,3 @@
export * from './audit.module';
export * from './audit.service';
export * from './audit.interceptor';

View File

@@ -0,0 +1,174 @@
import { Controller, Get, Put, Body } from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
import { DashboardService } from './dashboard.service';
import { UpdateLayoutDto } from './dto/dashboard-layout.dto';
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
import { JwtPayload } from '../../auth/interfaces/jwt-payload.interface';
import { RequirePermissions } from '../../auth/permissions/permissions.decorator';
import { Permission } from '../../auth/permissions/permissions.enum';
@ApiTags('dashboard')
@ApiBearerAuth('JWT-auth')
@Controller('dashboard')
export class DashboardController {
constructor(private readonly dashboardService: DashboardService) {}
@Get('stats')
@RequirePermissions(Permission.DASHBOARD_VIEW)
@ApiOperation({ summary: 'Get dashboard statistics (role-filtered)' })
@ApiResponse({
status: 200,
description: 'Dashboard statistics retrieved successfully (filtered by role)',
schema: {
type: 'object',
properties: {
users: {
type: 'object',
description: 'Only visible for admin/manager',
properties: {
total: { type: 'number', example: 150 },
active: { type: 'number', example: 142 },
newThisMonth: { type: 'number', example: 5 },
},
},
departments: {
type: 'object',
properties: {
total: { type: 'number', example: 12 },
active: { type: 'number', example: 10 },
},
},
employees: {
type: 'object',
description: 'Only visible for admin/manager',
properties: {
total: { type: 'number', example: 120 },
active: { type: 'number', example: 115 },
},
},
absences: {
type: 'object',
description: 'Scoped by role - admin/manager see all, dept head sees department',
properties: {
pending: { type: 'number', example: 8 },
approved: { type: 'number', example: 45 },
total: { type: 'number', example: 53 },
},
},
},
},
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
getStats(@CurrentUser() user: JwtPayload) {
return this.dashboardService.getStats(user.sub, user.roles || []);
}
@Get('widgets')
@RequirePermissions(Permission.DASHBOARD_VIEW)
@ApiOperation({ summary: 'Get available widget types' })
@ApiResponse({
status: 200,
description: 'List of available widget types',
schema: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string', example: 'statistics' },
name: { type: 'string', example: 'Statistics Overview' },
description: { type: 'string', example: 'Display key metrics' },
minWidth: { type: 'number', example: 2 },
minHeight: { type: 'number', example: 1 },
maxWidth: { type: 'number', example: 4 },
maxHeight: { type: 'number', example: 2 },
category: { type: 'string', example: 'overview' },
requiredPermissions: {
type: 'array',
items: { type: 'string' },
example: ['audit:view'],
},
},
},
},
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
getWidgets() {
return this.dashboardService.getWidgetTypes();
}
@Get('layout')
@RequirePermissions(Permission.DASHBOARD_VIEW)
@ApiOperation({ summary: "Get user's dashboard layout" })
@ApiResponse({
status: 200,
description: 'Dashboard layout retrieved successfully',
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
getLayout(@CurrentUser() user: JwtPayload) {
return this.dashboardService.getLayout(user.sub);
}
@Put('layout')
@RequirePermissions(Permission.DASHBOARD_CUSTOMIZE)
@ApiOperation({ summary: "Save user's dashboard layout" })
@ApiResponse({
status: 200,
description: 'Dashboard layout saved successfully',
})
@ApiResponse({ status: 400, description: 'Bad request' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
saveLayout(
@CurrentUser() user: JwtPayload,
@Body() updateDto: UpdateLayoutDto,
) {
return this.dashboardService.saveLayout(user.sub, updateDto.layout);
}
@Get('activity')
@RequirePermissions(Permission.DASHBOARD_VIEW)
@ApiOperation({ summary: 'Get recent activity for dashboard (role-filtered)' })
@ApiResponse({
status: 200,
description: 'Recent activity retrieved successfully (admins see all, others see own)',
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
getActivity(@CurrentUser() user: JwtPayload) {
return this.dashboardService.getRecentActivity(user.sub, user.roles || []);
}
@Get('absences/upcoming')
@RequirePermissions(Permission.ABSENCES_VIEW)
@ApiOperation({ summary: 'Get upcoming absences for dashboard (role-filtered)' })
@ApiResponse({
status: 200,
description: 'Upcoming absences retrieved successfully (scoped by role)',
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
getUpcomingAbsences(@CurrentUser() user: JwtPayload) {
return this.dashboardService.getUpcomingAbsences(user.sub, user.roles || []);
}
@Get('approvals/pending/count')
@RequirePermissions(Permission.ABSENCES_APPROVE)
@ApiOperation({ summary: 'Get pending approvals count' })
@ApiResponse({
status: 200,
description: 'Pending approvals count',
schema: {
type: 'object',
properties: {
count: { type: 'number', example: 5 },
},
},
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
async getPendingApprovalsCount() {
const count = await this.dashboardService.getPendingApprovalsCount();
return { count };
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { DashboardController } from './dashboard.controller';
import { DashboardService } from './dashboard.service';
import { UserPreferencesModule } from '../user-preferences/user-preferences.module';
@Module({
imports: [UserPreferencesModule],
controllers: [DashboardController],
providers: [DashboardService],
exports: [DashboardService],
})
export class DashboardModule {}

View File

@@ -0,0 +1,376 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { UserPreferencesService } from '../user-preferences/user-preferences.service';
import { Prisma, ApprovalStatus } from '@prisma/client';
export interface DashboardStats {
users?: {
total: number;
active: number;
newThisMonth: number;
};
departments: {
total: number;
active: number;
};
employees?: {
total: number;
active: number;
};
absences?: {
pending: number;
approved: number;
total: number;
};
// Basic stats for all users
myDepartment?: {
name: string;
employeeCount: number;
};
}
export interface WidgetType {
id: string;
name: string;
description: string;
minWidth: number;
minHeight: number;
maxWidth: number;
maxHeight: number;
category: string;
requiredPermissions?: string[];
}
@Injectable()
export class DashboardService {
constructor(
private readonly prisma: PrismaService,
private readonly preferencesService: UserPreferencesService,
) {}
/**
* Get dashboard statistics based on user role
* - Admin/Manager: Full statistics
* - Department Head: Department-specific statistics
* - Employee: Basic statistics only
*/
async getStats(userId: string, userRoles: string[]): Promise<DashboardStats> {
const isAdmin = userRoles.includes('admin');
const isManager = userRoles.includes('manager') || userRoles.includes('hr-manager');
const isDeptHead = userRoles.includes('department_head') || userRoles.includes('team-lead');
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
// Basic stats for all users - department count only
const [totalDepartments, activeDepartments] = await Promise.all([
this.prisma.department.count(),
this.prisma.department.count({ where: { isActive: true } }),
]);
const baseStats: DashboardStats = {
departments: {
total: totalDepartments,
active: activeDepartments,
},
};
// Admin and Manager get full statistics
if (isAdmin || isManager) {
const [
totalUsers,
activeUsers,
newUsersThisMonth,
totalEmployees,
activeEmployees,
pendingAbsences,
approvedAbsences,
totalAbsences,
] = await Promise.all([
this.prisma.user.count(),
this.prisma.user.count({ where: { isActive: true } }),
this.prisma.user.count({ where: { createdAt: { gte: startOfMonth } } }),
this.prisma.employee.count(),
this.prisma.employee.count({ where: { isActive: true } }),
this.prisma.absence.count({ where: { status: ApprovalStatus.PENDING } }),
this.prisma.absence.count({ where: { status: ApprovalStatus.APPROVED } }),
this.prisma.absence.count(),
]);
return {
...baseStats,
users: { total: totalUsers, active: activeUsers, newThisMonth: newUsersThisMonth },
employees: { total: totalEmployees, active: activeEmployees },
absences: { pending: pendingAbsences, approved: approvedAbsences, total: totalAbsences },
};
}
// Department Head gets department-specific stats
if (isDeptHead) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: { department: true },
});
if (user?.departmentId) {
const [deptEmployees, deptAbsences] = await Promise.all([
this.prisma.employee.count({ where: { user: { departmentId: user.departmentId } } }),
this.prisma.absence.count({
where: {
employee: { user: { departmentId: user.departmentId } },
status: ApprovalStatus.PENDING,
},
}),
]);
return {
...baseStats,
myDepartment: {
name: user.department?.name || 'Unknown',
employeeCount: deptEmployees,
},
absences: { pending: deptAbsences, approved: 0, total: 0 },
};
}
}
// Regular employee - basic stats only
return baseStats;
}
/**
* Get available widget types
*/
getWidgetTypes(): WidgetType[] {
return [
{
id: 'statistics',
name: 'Statistics Overview',
description: 'Display key metrics and statistics',
minWidth: 2,
minHeight: 1,
maxWidth: 4,
maxHeight: 2,
category: 'overview',
},
{
id: 'activity',
name: 'Recent Activity',
description: 'Show recent system activity and events',
minWidth: 2,
minHeight: 2,
maxWidth: 4,
maxHeight: 4,
category: 'overview',
requiredPermissions: ['audit:view'],
},
{
id: 'actions',
name: 'Quick Actions',
description: 'Shortcuts to common actions',
minWidth: 1,
minHeight: 1,
maxWidth: 2,
maxHeight: 2,
category: 'productivity',
},
{
id: 'absences',
name: 'Upcoming Absences',
description: 'View upcoming team absences',
minWidth: 2,
minHeight: 2,
maxWidth: 4,
maxHeight: 4,
category: 'hr',
requiredPermissions: ['absences:view'],
},
{
id: 'team',
name: 'Team Overview',
description: 'Quick view of your team members',
minWidth: 2,
minHeight: 2,
maxWidth: 4,
maxHeight: 4,
category: 'hr',
requiredPermissions: ['employees:view'],
},
{
id: 'calendar',
name: 'Calendar',
description: 'View upcoming events and deadlines',
minWidth: 2,
minHeight: 2,
maxWidth: 4,
maxHeight: 4,
category: 'productivity',
},
{
id: 'time-tracking',
name: 'Time Tracking',
description: 'Quick time entry and overview',
minWidth: 2,
minHeight: 1,
maxWidth: 4,
maxHeight: 2,
category: 'productivity',
requiredPermissions: ['time_entries:view'],
},
{
id: 'announcements',
name: 'Announcements',
description: 'System and company announcements',
minWidth: 2,
minHeight: 2,
maxWidth: 4,
maxHeight: 3,
category: 'overview',
},
{
id: 'department-stats',
name: 'Department Statistics',
description: 'Statistics for managed departments',
minWidth: 2,
minHeight: 2,
maxWidth: 4,
maxHeight: 4,
category: 'hr',
requiredPermissions: ['departments:view'],
},
{
id: 'pending-approvals',
name: 'Pending Approvals',
description: 'Requests waiting for your approval',
minWidth: 2,
minHeight: 2,
maxWidth: 4,
maxHeight: 4,
category: 'hr',
requiredPermissions: ['absences:approve'],
},
];
}
/**
* Get user's dashboard layout
*/
async getLayout(userId: string) {
const preferences = await this.preferencesService.findByUserId(userId);
return {
layout: preferences.dashboardLayout || this.getDefaultLayout(),
};
}
/**
* Save user's dashboard layout
*/
async saveLayout(userId: string, layout: unknown[]) {
return this.preferencesService.updateDashboardLayout(userId, layout);
}
/**
* Get recent activity for dashboard widget
* Admins see all activity, others see only their own
*/
async getRecentActivity(userId: string, userRoles: string[], limit = 10) {
const isAdmin = userRoles.includes('admin');
const isManager = userRoles.includes('manager') || userRoles.includes('hr-manager');
// Only admins and managers can see all activity
const where = isAdmin || isManager ? {} : { userId };
return this.prisma.auditLog.findMany({
where,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
},
},
});
}
/**
* Get upcoming absences for dashboard widget
* Filtered by user role and department
*/
async getUpcomingAbsences(userId: string, userRoles: string[], limit = 5) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const isAdmin = userRoles.includes('admin');
const isManager = userRoles.includes('manager') || userRoles.includes('hr-manager');
const isDeptHead = userRoles.includes('department_head') || userRoles.includes('team-lead');
// Build where clause based on role
let additionalWhere = {};
if (!isAdmin && !isManager) {
if (isDeptHead) {
// Department head sees their department's absences
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { departmentId: true },
});
if (user?.departmentId) {
additionalWhere = { employee: { user: { departmentId: user.departmentId } } };
}
} else {
// Regular employee sees only their own absences
additionalWhere = { employee: { userId } };
}
}
return this.prisma.absence.findMany({
where: {
startDate: { gte: today },
status: { in: [ApprovalStatus.PENDING, ApprovalStatus.APPROVED] },
...additionalWhere,
},
take: limit,
orderBy: { startDate: 'asc' },
include: {
employee: {
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
},
},
},
},
},
});
}
/**
* Get pending approvals count
*/
async getPendingApprovalsCount() {
return this.prisma.absence.count({
where: { status: 'PENDING' },
});
}
/**
* Default dashboard layout
*/
private getDefaultLayout(): Prisma.JsonArray {
return [
{ id: 'stats-overview', type: 'statistics', x: 0, y: 0, w: 4, h: 1 },
{ id: 'recent-activity', type: 'activity', x: 0, y: 1, w: 2, h: 2 },
{ id: 'quick-actions', type: 'actions', x: 2, y: 1, w: 2, h: 2 },
{ id: 'upcoming-absences', type: 'absences', x: 0, y: 3, w: 2, h: 2 },
{ id: 'team-overview', type: 'team', x: 2, y: 3, w: 2, h: 2 },
];
}
}

View File

@@ -0,0 +1,69 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsOptional, IsNumber, IsObject, IsArray, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class WidgetLayoutDto {
@ApiProperty({
description: 'Unique widget identifier',
example: 'stats-overview',
})
@IsString()
id: string;
@ApiProperty({
description: 'Widget type',
example: 'statistics',
})
@IsString()
type: string;
@ApiPropertyOptional({
description: 'Grid position X',
example: 0,
})
@IsOptional()
@IsNumber()
x?: number;
@ApiPropertyOptional({
description: 'Grid position Y',
example: 0,
})
@IsOptional()
@IsNumber()
y?: number;
@ApiPropertyOptional({
description: 'Widget width in grid units',
example: 2,
})
@IsOptional()
@IsNumber()
w?: number;
@ApiPropertyOptional({
description: 'Widget height in grid units',
example: 2,
})
@IsOptional()
@IsNumber()
h?: number;
@ApiPropertyOptional({
description: 'Widget-specific settings',
})
@IsOptional()
@IsObject()
settings?: Record<string, unknown>;
}
export class UpdateLayoutDto {
@ApiProperty({
description: 'Array of widget configurations',
type: [WidgetLayoutDto],
})
@IsArray()
@ValidateNested({ each: true })
@Type(() => WidgetLayoutDto)
layout: WidgetLayoutDto[];
}

View File

@@ -0,0 +1 @@
export * from './dashboard-layout.dto';

View File

@@ -0,0 +1,4 @@
export * from './dashboard.module';
export * from './dashboard.service';
export * from './dashboard.controller';
export * from './dto';

View File

@@ -0,0 +1,145 @@
import {
Controller,
Get,
Post,
Body,
Put,
Param,
Delete,
Query,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiParam,
} from '@nestjs/swagger';
import { DepartmentsService } from './departments.service';
import { CreateDepartmentDto } from './dto/create-department.dto';
import { UpdateDepartmentDto } from './dto/update-department.dto';
import { QueryDepartmentsDto } from './dto/query-departments.dto';
import { Roles } from '../../auth/decorators/roles.decorator';
import { RequirePermissions } from '../../auth/permissions/permissions.decorator';
import { Permission } from '../../auth/permissions/permissions.enum';
import { AuditLog } from '../audit/audit.interceptor';
@ApiTags('departments')
@ApiBearerAuth('JWT-auth')
@Controller('departments')
export class DepartmentsController {
constructor(private readonly departmentsService: DepartmentsService) {}
@Post()
@Roles('admin')
@RequirePermissions(Permission.DEPARTMENTS_CREATE)
@AuditLog('Department', 'CREATE')
@ApiOperation({ summary: 'Create a new department' })
@ApiResponse({
status: 201,
description: 'Department created successfully',
})
@ApiResponse({ status: 400, description: 'Bad request' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden' })
@ApiResponse({ status: 409, description: 'Department code already exists' })
create(@Body() createDto: CreateDepartmentDto) {
return this.departmentsService.create(createDto);
}
@Get()
@RequirePermissions(Permission.DEPARTMENTS_VIEW)
@ApiOperation({ summary: 'Get all departments with pagination and filtering' })
@ApiResponse({
status: 200,
description: 'List of departments',
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
findAll(@Query() query: QueryDepartmentsDto) {
return this.departmentsService.findAll(query);
}
@Get('tree')
@RequirePermissions(Permission.DEPARTMENTS_VIEW)
@ApiOperation({ summary: 'Get hierarchical department tree' })
@ApiResponse({
status: 200,
description: 'Department tree structure',
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
getTree() {
return this.departmentsService.getTree();
}
@Get(':id')
@RequirePermissions(Permission.DEPARTMENTS_VIEW)
@ApiOperation({ summary: 'Get a department by ID' })
@ApiParam({ name: 'id', description: 'Department ID' })
@ApiResponse({
status: 200,
description: 'Department found',
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 404, description: 'Department not found' })
findOne(@Param('id') id: string) {
return this.departmentsService.findOne(id);
}
@Get(':id/employees')
@RequirePermissions(Permission.DEPARTMENTS_VIEW)
@ApiOperation({ summary: 'Get employees of a department' })
@ApiParam({ name: 'id', description: 'Department ID' })
@ApiResponse({
status: 200,
description: 'List of department employees',
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 404, description: 'Department not found' })
getEmployees(
@Param('id') id: string,
@Query('page') page?: number,
@Query('limit') limit?: number,
) {
return this.departmentsService.getEmployees(id, { page, limit });
}
@Put(':id')
@Roles('admin')
@RequirePermissions(Permission.DEPARTMENTS_UPDATE)
@AuditLog('Department', 'UPDATE')
@ApiOperation({ summary: 'Update a department' })
@ApiParam({ name: 'id', description: 'Department ID' })
@ApiResponse({
status: 200,
description: 'Department updated successfully',
})
@ApiResponse({ status: 400, description: 'Bad request' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden' })
@ApiResponse({ status: 404, description: 'Department not found' })
@ApiResponse({ status: 409, description: 'Department code already exists' })
update(@Param('id') id: string, @Body() updateDto: UpdateDepartmentDto) {
return this.departmentsService.update(id, updateDto);
}
@Delete(':id')
@Roles('admin')
@RequirePermissions(Permission.DEPARTMENTS_DELETE)
@AuditLog('Department', 'DELETE')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Deactivate a department' })
@ApiParam({ name: 'id', description: 'Department ID' })
@ApiResponse({
status: 200,
description: 'Department deactivated successfully',
})
@ApiResponse({ status: 400, description: 'Cannot deactivate department with active users/children' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden' })
@ApiResponse({ status: 404, description: 'Department not found' })
remove(@Param('id') id: string) {
return this.departmentsService.remove(id);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { DepartmentsController } from './departments.controller';
import { DepartmentsService } from './departments.service';
@Module({
controllers: [DepartmentsController],
providers: [DepartmentsService],
exports: [DepartmentsService],
})
export class DepartmentsModule {}

View File

@@ -0,0 +1,462 @@
import {
Injectable,
NotFoundException,
ConflictException,
BadRequestException,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateDepartmentDto } from './dto/create-department.dto';
import { UpdateDepartmentDto } from './dto/update-department.dto';
import { QueryDepartmentsDto } from './dto/query-departments.dto';
import { Prisma } from '@prisma/client';
export interface DepartmentWithChildren {
id: string;
name: string;
code: string | null;
parentId: string | null;
managerId: string | null;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
children?: DepartmentWithChildren[];
manager?: {
id: string;
firstName: string;
lastName: string;
email: string;
} | null;
_count?: {
users: number;
children: number;
};
}
@Injectable()
export class DepartmentsService {
constructor(private readonly prisma: PrismaService) {}
/**
* Create a new department
*/
async create(createDto: CreateDepartmentDto) {
// Check for duplicate code
if (createDto.code) {
const existingCode = await this.prisma.department.findUnique({
where: { code: createDto.code },
});
if (existingCode) {
throw new ConflictException(
`Department with code ${createDto.code} already exists`,
);
}
}
// Validate parent exists if provided
if (createDto.parentId) {
const parent = await this.prisma.department.findUnique({
where: { id: createDto.parentId },
});
if (!parent) {
throw new NotFoundException(
`Parent department with ID ${createDto.parentId} not found`,
);
}
}
// Validate manager exists if provided
if (createDto.managerId) {
const manager = await this.prisma.user.findUnique({
where: { id: createDto.managerId },
});
if (!manager) {
throw new NotFoundException(
`Manager with ID ${createDto.managerId} not found`,
);
}
}
return this.prisma.department.create({
data: {
name: createDto.name,
code: createDto.code,
parentId: createDto.parentId,
managerId: createDto.managerId,
isActive: createDto.isActive ?? true,
},
include: {
parent: true,
manager: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
},
_count: {
select: {
users: true,
children: true,
},
},
},
});
}
/**
* Find all departments with filtering and pagination
*/
async findAll(query: QueryDepartmentsDto) {
const {
page = 1,
limit = 20,
search,
parentId,
isActive,
sortBy,
sortOrder,
} = query;
const skip = (page - 1) * limit;
const where: Prisma.DepartmentWhereInput = {
...(search && {
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ code: { contains: search, mode: 'insensitive' } },
],
}),
...(parentId === 'null' ? { parentId: null } : parentId && { parentId }),
...(isActive !== undefined && { isActive }),
};
const orderBy: Prisma.DepartmentOrderByWithRelationInput = sortBy
? { [sortBy]: sortOrder || 'asc' }
: { name: 'asc' };
const [departments, total] = await Promise.all([
this.prisma.department.findMany({
where,
skip,
take: limit,
orderBy,
include: {
parent: true,
manager: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
},
_count: {
select: {
users: true,
children: true,
},
},
},
}),
this.prisma.department.count({ where }),
]);
return {
data: departments,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* Find a single department by ID
*/
async findOne(id: string) {
const department = await this.prisma.department.findUnique({
where: { id },
include: {
parent: true,
children: true,
manager: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
},
_count: {
select: {
users: true,
children: true,
},
},
},
});
if (!department) {
throw new NotFoundException(`Department with ID ${id} not found`);
}
return department;
}
/**
* Update a department
*/
async update(id: string, updateDto: UpdateDepartmentDto) {
await this.findOne(id);
// Check for duplicate code if updating
if (updateDto.code) {
const existingCode = await this.prisma.department.findFirst({
where: {
code: updateDto.code,
id: { not: id },
},
});
if (existingCode) {
throw new ConflictException(
`Department with code ${updateDto.code} already exists`,
);
}
}
// Prevent circular parent reference
if (updateDto.parentId) {
if (updateDto.parentId === id) {
throw new BadRequestException('Department cannot be its own parent');
}
// Check if the new parent is a child of this department
const isCircular = await this.isDescendant(updateDto.parentId, id);
if (isCircular) {
throw new BadRequestException(
'Cannot set a descendant department as parent',
);
}
const parent = await this.prisma.department.findUnique({
where: { id: updateDto.parentId },
});
if (!parent) {
throw new NotFoundException(
`Parent department with ID ${updateDto.parentId} not found`,
);
}
}
// Validate manager exists if provided
if (updateDto.managerId) {
const manager = await this.prisma.user.findUnique({
where: { id: updateDto.managerId },
});
if (!manager) {
throw new NotFoundException(
`Manager with ID ${updateDto.managerId} not found`,
);
}
}
return this.prisma.department.update({
where: { id },
data: updateDto,
include: {
parent: true,
manager: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
},
_count: {
select: {
users: true,
children: true,
},
},
},
});
}
/**
* Delete a department (soft delete by deactivating)
*/
async remove(id: string) {
const department = await this.findOne(id);
// Check if department has active users
const hasActiveUsers = await this.prisma.user.count({
where: { departmentId: id, isActive: true },
});
if (hasActiveUsers > 0) {
throw new BadRequestException(
`Cannot deactivate department with ${hasActiveUsers} active users`,
);
}
// Check if department has active children
const hasActiveChildren = await this.prisma.department.count({
where: { parentId: id, isActive: true },
});
if (hasActiveChildren > 0) {
throw new BadRequestException(
`Cannot deactivate department with ${hasActiveChildren} active sub-departments`,
);
}
return this.prisma.department.update({
where: { id },
data: { isActive: false },
include: {
parent: true,
manager: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
},
_count: {
select: {
users: true,
children: true,
},
},
},
});
}
/**
* Get employees of a department
*/
async getEmployees(
departmentId: string,
pagination: { page?: number; limit?: number } = {},
) {
await this.findOne(departmentId);
const { page = 1, limit = 20 } = pagination;
const skip = (page - 1) * limit;
const [users, total] = await Promise.all([
this.prisma.user.findMany({
where: { departmentId, isActive: true },
skip,
take: limit,
orderBy: { lastName: 'asc' },
include: {
employee: true,
roles: {
include: {
role: true,
},
},
},
}),
this.prisma.user.count({
where: { departmentId, isActive: true },
}),
]);
return {
data: users,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* Get hierarchical department tree
*/
async getTree(): Promise<DepartmentWithChildren[]> {
const allDepartments = await this.prisma.department.findMany({
where: { isActive: true },
orderBy: { name: 'asc' },
include: {
manager: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
},
_count: {
select: {
users: true,
children: true,
},
},
},
});
return this.buildTree(allDepartments, null);
}
/**
* Build tree structure from flat list
*/
private buildTree(
departments: DepartmentWithChildren[],
parentId: string | null,
): DepartmentWithChildren[] {
return departments
.filter((dept) => dept.parentId === parentId)
.map((dept) => ({
...dept,
children: this.buildTree(departments, dept.id),
}));
}
/**
* Check if potentialDescendant is a descendant of ancestorId
*/
private async isDescendant(
potentialDescendantId: string,
ancestorId: string,
): Promise<boolean> {
const descendants = await this.getAllDescendants(ancestorId);
return descendants.includes(potentialDescendantId);
}
/**
* Get all descendant IDs of a department
*/
private async getAllDescendants(departmentId: string): Promise<string[]> {
const children = await this.prisma.department.findMany({
where: { parentId: departmentId },
select: { id: true },
});
if (children.length === 0) {
return [];
}
const childIds = children.map((c) => c.id);
const grandchildIds = await Promise.all(
childIds.map((id) => this.getAllDescendants(id)),
);
return [...childIds, ...grandchildIds.flat()];
}
}

View File

@@ -0,0 +1,60 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsString,
IsOptional,
IsBoolean,
MinLength,
MaxLength,
Matches,
} from 'class-validator';
export class CreateDepartmentDto {
@ApiProperty({
description: 'Department name',
example: 'Engineering',
minLength: 2,
maxLength: 100,
})
@IsString()
@MinLength(2)
@MaxLength(100)
name: string;
@ApiPropertyOptional({
description: 'Unique department code',
example: 'ENG',
maxLength: 20,
})
@IsOptional()
@IsString()
@MaxLength(20)
@Matches(/^[A-Z0-9_-]+$/i, {
message: 'Code must contain only letters, numbers, underscores, and hyphens',
})
code?: string;
@ApiPropertyOptional({
description: 'Parent department ID for hierarchy',
example: 'clq1234567890abcdef',
})
@IsOptional()
@IsString()
parentId?: string;
@ApiPropertyOptional({
description: 'Department manager user ID',
example: 'clq1234567890abcdef',
})
@IsOptional()
@IsString()
managerId?: string;
@ApiPropertyOptional({
description: 'Whether the department is active',
example: true,
default: true,
})
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View File

@@ -0,0 +1,3 @@
export * from './create-department.dto';
export * from './update-department.dto';
export * from './query-departments.dto';

View File

@@ -0,0 +1,98 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import {
IsOptional,
IsString,
IsBoolean,
IsInt,
Min,
Max,
IsEnum,
} from 'class-validator';
import { Transform, Type } from 'class-transformer';
export enum DepartmentSortField {
CREATED_AT = 'createdAt',
UPDATED_AT = 'updatedAt',
NAME = 'name',
CODE = 'code',
}
export enum SortOrder {
ASC = 'asc',
DESC = 'desc',
}
export class QueryDepartmentsDto {
@ApiPropertyOptional({
description: 'Page number for pagination',
example: 1,
minimum: 1,
default: 1,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({
description: 'Number of items per page',
example: 20,
minimum: 1,
maximum: 100,
default: 20,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 20;
@ApiPropertyOptional({
description: 'Search term for name or code',
example: 'engineering',
})
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({
description: 'Filter by parent department ID (use "null" for root departments)',
example: 'clq1234567890abcdef',
})
@IsOptional()
@IsString()
parentId?: string;
@ApiPropertyOptional({
description: 'Filter by active status',
example: true,
})
@IsOptional()
@Transform(({ value }) => {
if (value === 'true') return true;
if (value === 'false') return false;
return value;
})
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional({
description: 'Field to sort by',
enum: DepartmentSortField,
example: DepartmentSortField.NAME,
})
@IsOptional()
@IsEnum(DepartmentSortField)
sortBy?: DepartmentSortField;
@ApiPropertyOptional({
description: 'Sort order',
enum: SortOrder,
example: SortOrder.ASC,
})
@IsOptional()
@IsEnum(SortOrder)
sortOrder?: SortOrder;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateDepartmentDto } from './create-department.dto';
export class UpdateDepartmentDto extends PartialType(CreateDepartmentDto) {}

View File

@@ -0,0 +1,4 @@
export * from './departments.module';
export * from './departments.service';
export * from './departments.controller';
export * from './dto';

View File

@@ -0,0 +1,232 @@
import {
Controller,
Get,
Post,
Body,
Put,
Param,
Delete,
Query,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiParam,
} from '@nestjs/swagger';
import { AbsencesService } from './absences.service';
import { CreateAbsenceDto } from './dto/create-absence.dto';
import { UpdateAbsenceDto } from './dto/update-absence.dto';
import {
QueryAbsencesDto,
QueryCalendarDto,
QueryConflictsDto,
} from './dto/query-absences.dto';
import { ApproveAbsenceDto, RejectAbsenceDto } from './dto/approve-absence.dto';
import { RequirePermissions } from '../../../auth/permissions/permissions.decorator';
import { Permission } from '../../../auth/permissions/permissions.enum';
import { CurrentUser } from '../../../auth/decorators/current-user.decorator';
import { JwtPayload } from '../../../auth/interfaces/jwt-payload.interface';
import { AuditLog } from '../../audit/audit.interceptor';
@ApiTags('hr/absences')
@ApiBearerAuth('JWT-auth')
@Controller('hr/absences')
export class AbsencesController {
constructor(private readonly absencesService: AbsencesService) {}
@Post()
@RequirePermissions(Permission.ABSENCES_CREATE)
@AuditLog('Absence', 'CREATE')
@ApiOperation({ summary: 'Create a new absence request' })
@ApiResponse({
status: 201,
description: 'Absence request created successfully',
})
@ApiResponse({ status: 400, description: 'Bad request - validation error or conflict' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden' })
create(
@Body() createDto: CreateAbsenceDto,
@CurrentUser() currentUser: JwtPayload,
) {
return this.absencesService.create(createDto, currentUser);
}
@Get()
@RequirePermissions(Permission.ABSENCES_VIEW)
@ApiOperation({ summary: 'Get all absences with pagination and filtering' })
@ApiResponse({
status: 200,
description: 'List of absences with pagination metadata',
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
findAll(
@Query() query: QueryAbsencesDto,
@CurrentUser() currentUser: JwtPayload,
) {
return this.absencesService.findAll(query, currentUser);
}
@Get('pending')
@RequirePermissions(Permission.ABSENCES_APPROVE)
@ApiOperation({ summary: 'Get pending absence requests for approval' })
@ApiResponse({
status: 200,
description: 'List of pending absences',
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden - requires approval permission' })
getPending(@CurrentUser() currentUser: JwtPayload) {
return this.absencesService.getPending(currentUser);
}
@Get('calendar')
@RequirePermissions(Permission.ABSENCES_VIEW)
@ApiOperation({ summary: 'Get calendar data for approved absences' })
@ApiResponse({
status: 200,
description: 'Calendar entries for approved absences',
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
getCalendar(
@Query() query: QueryCalendarDto,
@CurrentUser() currentUser: JwtPayload,
) {
return this.absencesService.getCalendar(query, currentUser);
}
@Get('conflicts')
@RequirePermissions(Permission.ABSENCES_VIEW)
@ApiOperation({ summary: 'Check for absence conflicts and team coverage' })
@ApiResponse({
status: 200,
description: 'Conflict check results',
})
@ApiResponse({ status: 400, description: 'Bad request - missing required parameters' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
checkConflicts(
@Query() query: QueryConflictsDto,
@CurrentUser() currentUser: JwtPayload,
) {
return this.absencesService.checkConflicts(query, currentUser);
}
@Get('balance/:employeeId')
@RequirePermissions(Permission.ABSENCES_VIEW)
@ApiOperation({ summary: 'Get vacation balance for an employee' })
@ApiParam({ name: 'employeeId', description: 'Employee ID' })
@ApiResponse({
status: 200,
description: 'Vacation balance details',
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 404, description: 'Employee not found' })
getBalance(
@Param('employeeId') employeeId: string,
@Query('year') year?: number,
) {
return this.absencesService.getBalance(employeeId, year);
}
@Get(':id')
@RequirePermissions(Permission.ABSENCES_VIEW)
@ApiOperation({ summary: 'Get a single absence by ID' })
@ApiParam({ name: 'id', description: 'Absence ID' })
@ApiResponse({
status: 200,
description: 'Absence details',
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden - no access to this absence' })
@ApiResponse({ status: 404, description: 'Absence not found' })
findOne(@Param('id') id: string, @CurrentUser() currentUser: JwtPayload) {
return this.absencesService.findOne(id, currentUser);
}
@Put(':id')
@RequirePermissions(Permission.ABSENCES_CREATE)
@AuditLog('Absence', 'UPDATE')
@ApiOperation({ summary: 'Update a pending absence' })
@ApiParam({ name: 'id', description: 'Absence ID' })
@ApiResponse({
status: 200,
description: 'Absence updated successfully',
})
@ApiResponse({ status: 400, description: 'Bad request - only PENDING absences can be updated' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden' })
@ApiResponse({ status: 404, description: 'Absence not found' })
update(
@Param('id') id: string,
@Body() updateDto: UpdateAbsenceDto,
@CurrentUser() currentUser: JwtPayload,
) {
return this.absencesService.update(id, updateDto, currentUser);
}
@Delete(':id')
@RequirePermissions(Permission.ABSENCES_CANCEL)
@AuditLog('Absence', 'CANCEL')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Cancel an absence (PENDING or APPROVED)' })
@ApiParam({ name: 'id', description: 'Absence ID' })
@ApiResponse({
status: 200,
description: 'Absence cancelled successfully',
})
@ApiResponse({ status: 400, description: 'Bad request - cannot cancel this absence' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden' })
@ApiResponse({ status: 404, description: 'Absence not found' })
cancel(@Param('id') id: string, @CurrentUser() currentUser: JwtPayload) {
return this.absencesService.cancel(id, currentUser);
}
@Post(':id/approve')
@RequirePermissions(Permission.ABSENCES_APPROVE)
@AuditLog('Absence', 'APPROVE')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Approve a pending absence request' })
@ApiParam({ name: 'id', description: 'Absence ID' })
@ApiResponse({
status: 200,
description: 'Absence approved successfully',
})
@ApiResponse({ status: 400, description: 'Bad request - only PENDING absences can be approved' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden - cannot approve own absence or no permission' })
@ApiResponse({ status: 404, description: 'Absence not found' })
approve(
@Param('id') id: string,
@Body() approveDto: ApproveAbsenceDto,
@CurrentUser() currentUser: JwtPayload,
) {
return this.absencesService.approve(id, approveDto, currentUser);
}
@Post(':id/reject')
@RequirePermissions(Permission.ABSENCES_APPROVE)
@AuditLog('Absence', 'REJECT')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Reject a pending absence request' })
@ApiParam({ name: 'id', description: 'Absence ID' })
@ApiResponse({
status: 200,
description: 'Absence rejected successfully',
})
@ApiResponse({ status: 400, description: 'Bad request - only PENDING absences can be rejected' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden - cannot reject own absence or no permission' })
@ApiResponse({ status: 404, description: 'Absence not found' })
reject(
@Param('id') id: string,
@Body() rejectDto: RejectAbsenceDto,
@CurrentUser() currentUser: JwtPayload,
) {
return this.absencesService.reject(id, rejectDto, currentUser);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AbsencesController } from './absences.controller';
import { AbsencesService } from './absences.service';
@Module({
controllers: [AbsencesController],
providers: [AbsencesService],
exports: [AbsencesService],
})
export class AbsencesModule {}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsOptional, MaxLength } from 'class-validator';
export class ApproveAbsenceDto {
@ApiPropertyOptional({
description: 'Optional comment from approver',
example: 'Approved. Please ensure handover before leaving.',
maxLength: 500,
})
@IsOptional()
@IsString()
@MaxLength(500)
comment?: string;
}
export class RejectAbsenceDto {
@ApiPropertyOptional({
description: 'Reason for rejection',
example: 'Team coverage not sufficient during this period',
maxLength: 500,
})
@IsOptional()
@IsString()
@MaxLength(500)
reason?: string;
}

View File

@@ -0,0 +1,63 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsEnum,
IsString,
IsOptional,
IsDateString,
IsNumber,
Min,
MaxLength,
} from 'class-validator';
import { AbsenceType } from '@prisma/client';
export class CreateAbsenceDto {
@ApiProperty({
description: 'Type of absence',
enum: AbsenceType,
example: AbsenceType.VACATION,
})
@IsEnum(AbsenceType)
type: AbsenceType;
@ApiProperty({
description: 'Start date of the absence (ISO 8601 format)',
example: '2024-03-15',
})
@IsDateString()
startDate: string;
@ApiProperty({
description: 'End date of the absence (ISO 8601 format)',
example: '2024-03-20',
})
@IsDateString()
endDate: string;
@ApiPropertyOptional({
description: 'Number of days (calculated automatically if not provided)',
example: 5,
minimum: 0.5,
})
@IsOptional()
@IsNumber()
@Min(0.5)
days?: number;
@ApiPropertyOptional({
description: 'Optional note or reason for the absence',
example: 'Family vacation',
maxLength: 1000,
})
@IsOptional()
@IsString()
@MaxLength(1000)
note?: string;
@ApiPropertyOptional({
description: 'Employee ID (required for managers creating absences for others)',
example: 'clq1234567890abcdef',
})
@IsOptional()
@IsString()
employeeId?: string;
}

View File

@@ -0,0 +1,4 @@
export * from './create-absence.dto';
export * from './update-absence.dto';
export * from './query-absences.dto';
export * from './approve-absence.dto';

View File

@@ -0,0 +1,229 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import {
IsOptional,
IsString,
IsEnum,
IsInt,
Min,
Max,
IsDateString,
IsBoolean,
} from 'class-validator';
import { Transform, Type } from 'class-transformer';
import { AbsenceType, ApprovalStatus } from '@prisma/client';
export enum AbsenceSortField {
CREATED_AT = 'createdAt',
UPDATED_AT = 'updatedAt',
START_DATE = 'startDate',
END_DATE = 'endDate',
DAYS = 'days',
TYPE = 'type',
STATUS = 'status',
}
export enum SortOrder {
ASC = 'asc',
DESC = 'desc',
}
export class QueryAbsencesDto {
@ApiPropertyOptional({
description: 'Page number for pagination',
example: 1,
minimum: 1,
default: 1,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({
description: 'Number of items per page',
example: 20,
minimum: 1,
maximum: 100,
default: 20,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 20;
@ApiPropertyOptional({
description: 'Filter by employee ID',
example: 'clq1234567890abcdef',
})
@IsOptional()
@IsString()
employeeId?: string;
@ApiPropertyOptional({
description: 'Filter by absence type',
enum: AbsenceType,
example: AbsenceType.VACATION,
})
@IsOptional()
@IsEnum(AbsenceType)
type?: AbsenceType;
@ApiPropertyOptional({
description: 'Filter by approval status',
enum: ApprovalStatus,
example: ApprovalStatus.PENDING,
})
@IsOptional()
@IsEnum(ApprovalStatus)
status?: ApprovalStatus;
@ApiPropertyOptional({
description: 'Filter absences starting from this date',
example: '2024-01-01',
})
@IsOptional()
@IsDateString()
startDateFrom?: string;
@ApiPropertyOptional({
description: 'Filter absences starting until this date',
example: '2024-12-31',
})
@IsOptional()
@IsDateString()
startDateTo?: string;
@ApiPropertyOptional({
description: 'Filter absences ending from this date',
example: '2024-01-01',
})
@IsOptional()
@IsDateString()
endDateFrom?: string;
@ApiPropertyOptional({
description: 'Filter absences ending until this date',
example: '2024-12-31',
})
@IsOptional()
@IsDateString()
endDateTo?: string;
@ApiPropertyOptional({
description: 'Filter by department ID (for managers viewing their team)',
example: 'clq1234567890abcdef',
})
@IsOptional()
@IsString()
departmentId?: string;
@ApiPropertyOptional({
description: 'Include only own absences (for employees)',
example: true,
})
@IsOptional()
@Transform(({ value }) => {
if (value === 'true') return true;
if (value === 'false') return false;
return value;
})
@IsBoolean()
ownOnly?: boolean;
@ApiPropertyOptional({
description: 'Field to sort by',
enum: AbsenceSortField,
example: AbsenceSortField.START_DATE,
})
@IsOptional()
@IsEnum(AbsenceSortField)
sortBy?: AbsenceSortField;
@ApiPropertyOptional({
description: 'Sort order',
enum: SortOrder,
example: SortOrder.DESC,
})
@IsOptional()
@IsEnum(SortOrder)
sortOrder?: SortOrder;
}
export class QueryCalendarDto {
@ApiPropertyOptional({
description: 'Start date for calendar view',
example: '2024-01-01',
})
@IsOptional()
@IsDateString()
startDate?: string;
@ApiPropertyOptional({
description: 'End date for calendar view',
example: '2024-12-31',
})
@IsOptional()
@IsDateString()
endDate?: string;
@ApiPropertyOptional({
description: 'Filter by department ID',
example: 'clq1234567890abcdef',
})
@IsOptional()
@IsString()
departmentId?: string;
@ApiPropertyOptional({
description: 'Include absence types (comma-separated)',
example: 'VACATION,SICK,HOME_OFFICE',
})
@IsOptional()
@IsString()
types?: string;
}
export class QueryConflictsDto {
@ApiPropertyOptional({
description: 'Employee ID to check for conflicts',
example: 'clq1234567890abcdef',
})
@IsOptional()
@IsString()
employeeId?: string;
@ApiPropertyOptional({
description: 'Department ID to check for team conflicts',
example: 'clq1234567890abcdef',
})
@IsOptional()
@IsString()
departmentId?: string;
@ApiPropertyOptional({
description: 'Start date to check',
example: '2024-03-15',
})
@IsOptional()
@IsDateString()
startDate?: string;
@ApiPropertyOptional({
description: 'End date to check',
example: '2024-03-20',
})
@IsOptional()
@IsDateString()
endDate?: string;
@ApiPropertyOptional({
description: 'Absence ID to exclude from conflict check (for updates)',
example: 'clq1234567890abcdef',
})
@IsOptional()
@IsString()
excludeId?: string;
}

View File

@@ -0,0 +1,58 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import {
IsEnum,
IsString,
IsOptional,
IsDateString,
IsNumber,
Min,
MaxLength,
} from 'class-validator';
import { AbsenceType } from '@prisma/client';
export class UpdateAbsenceDto {
@ApiPropertyOptional({
description: 'Type of absence',
enum: AbsenceType,
example: AbsenceType.VACATION,
})
@IsOptional()
@IsEnum(AbsenceType)
type?: AbsenceType;
@ApiPropertyOptional({
description: 'Start date of the absence (ISO 8601 format)',
example: '2024-03-15',
})
@IsOptional()
@IsDateString()
startDate?: string;
@ApiPropertyOptional({
description: 'End date of the absence (ISO 8601 format)',
example: '2024-03-20',
})
@IsOptional()
@IsDateString()
endDate?: string;
@ApiPropertyOptional({
description: 'Number of days (recalculated if dates change)',
example: 5,
minimum: 0.5,
})
@IsOptional()
@IsNumber()
@Min(0.5)
days?: number;
@ApiPropertyOptional({
description: 'Optional note or reason for the absence',
example: 'Updated vacation reason',
maxLength: 1000,
})
@IsOptional()
@IsString()
@MaxLength(1000)
note?: string;
}

View File

@@ -0,0 +1,4 @@
export * from './absences.module';
export * from './absences.service';
export * from './absences.controller';
export * from './dto';

View File

@@ -0,0 +1,161 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsString,
IsOptional,
IsNumber,
IsDate,
IsEnum,
IsBoolean,
IsInt,
Min,
Max,
MinLength,
MaxLength,
IsObject,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ContractType } from '@prisma/client';
export class BankAccountDto {
@ApiProperty({
description: 'IBAN of the bank account',
example: 'DE89370400440532013000',
})
@IsString()
@MinLength(15)
@MaxLength(34)
iban: string;
@ApiPropertyOptional({
description: 'BIC/SWIFT code',
example: 'COBADEFFXXX',
})
@IsOptional()
@IsString()
@MaxLength(11)
bic?: string;
@ApiPropertyOptional({
description: 'Bank name',
example: 'Commerzbank',
})
@IsOptional()
@IsString()
@MaxLength(100)
bankName?: string;
@ApiPropertyOptional({
description: 'Account holder name',
example: 'Max Mustermann',
})
@IsOptional()
@IsString()
@MaxLength(100)
accountHolder?: string;
}
export class CreateEmployeeDto {
@ApiProperty({
description: 'User ID to link this employee record to',
example: 'clq1234567890abcdef',
})
@IsString()
userId: string;
@ApiProperty({
description: 'Unique employee number',
example: 'EMP-2024-001',
})
@IsString()
@MinLength(1)
@MaxLength(50)
employeeNumber: string;
@ApiProperty({
description: 'Job position/title',
example: 'Senior Software Engineer',
})
@IsString()
@MinLength(2)
@MaxLength(100)
position: string;
@ApiProperty({
description: 'Employment start date',
example: '2024-01-15',
})
@Type(() => Date)
@IsDate()
entryDate: Date;
@ApiPropertyOptional({
description: 'Employment end date (for terminated employees)',
example: '2025-12-31',
})
@IsOptional()
@Type(() => Date)
@IsDate()
exitDate?: Date;
@ApiProperty({
description: 'Type of employment contract',
enum: ContractType,
example: ContractType.FULL_TIME,
})
@IsEnum(ContractType)
contractType: ContractType;
@ApiPropertyOptional({
description: 'Weekly working hours',
example: 40,
minimum: 0,
maximum: 60,
default: 40,
})
@IsOptional()
@IsNumber()
@Min(0)
@Max(60)
workingHours?: number;
@ApiPropertyOptional({
description: 'Monthly gross salary in EUR (sensitive - will be encrypted)',
example: 5000,
})
@IsOptional()
@IsNumber()
@Min(0)
salary?: number;
@ApiPropertyOptional({
description: 'German tax class (1-6)',
example: 1,
minimum: 1,
maximum: 6,
})
@IsOptional()
@IsInt()
@Min(1)
@Max(6)
taxClass?: number;
@ApiPropertyOptional({
description: 'Bank account details (sensitive - will be encrypted)',
type: BankAccountDto,
})
@IsOptional()
@IsObject()
@ValidateNested()
@Type(() => BankAccountDto)
bankAccount?: BankAccountDto;
@ApiPropertyOptional({
description: 'Whether the employee is active',
example: true,
default: true,
})
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View File

@@ -0,0 +1,3 @@
export * from './create-employee.dto';
export * from './update-employee.dto';
export * from './query-employees.dto';

View File

@@ -0,0 +1,109 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import {
IsOptional,
IsString,
IsBoolean,
IsInt,
IsEnum,
Min,
Max,
} from 'class-validator';
import { Transform, Type } from 'class-transformer';
import { ContractType } from '@prisma/client';
export enum EmployeeSortField {
CREATED_AT = 'createdAt',
UPDATED_AT = 'updatedAt',
EMPLOYEE_NUMBER = 'employeeNumber',
POSITION = 'position',
ENTRY_DATE = 'entryDate',
}
export enum SortOrder {
ASC = 'asc',
DESC = 'desc',
}
export class QueryEmployeesDto {
@ApiPropertyOptional({
description: 'Page number for pagination',
example: 1,
minimum: 1,
default: 1,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({
description: 'Number of items per page',
example: 20,
minimum: 1,
maximum: 100,
default: 20,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 20;
@ApiPropertyOptional({
description: 'Search term for employee number, position, or user name/email',
example: 'engineer',
})
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({
description: 'Filter by department ID',
example: 'clq1234567890abcdef',
})
@IsOptional()
@IsString()
departmentId?: string;
@ApiPropertyOptional({
description: 'Filter by contract type',
enum: ContractType,
example: ContractType.FULL_TIME,
})
@IsOptional()
@IsEnum(ContractType)
contractType?: ContractType;
@ApiPropertyOptional({
description: 'Filter by active status',
example: true,
})
@IsOptional()
@Transform(({ value }) => {
if (value === 'true') return true;
if (value === 'false') return false;
return value;
})
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional({
description: 'Field to sort by',
enum: EmployeeSortField,
example: EmployeeSortField.EMPLOYEE_NUMBER,
})
@IsOptional()
@IsEnum(EmployeeSortField)
sortBy?: EmployeeSortField;
@ApiPropertyOptional({
description: 'Sort order',
enum: SortOrder,
example: SortOrder.ASC,
})
@IsOptional()
@IsEnum(SortOrder)
sortOrder?: SortOrder;
}

View File

@@ -0,0 +1,119 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import {
IsString,
IsOptional,
IsNumber,
IsDate,
IsEnum,
IsBoolean,
IsInt,
Min,
Max,
MinLength,
MaxLength,
IsObject,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ContractType } from '@prisma/client';
import { BankAccountDto } from './create-employee.dto';
export class UpdateEmployeeDto {
@ApiPropertyOptional({
description: 'Unique employee number',
example: 'EMP-2024-001',
})
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(50)
employeeNumber?: string;
@ApiPropertyOptional({
description: 'Job position/title',
example: 'Senior Software Engineer',
})
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(100)
position?: string;
@ApiPropertyOptional({
description: 'Employment start date',
example: '2024-01-15',
})
@IsOptional()
@Type(() => Date)
@IsDate()
entryDate?: Date;
@ApiPropertyOptional({
description: 'Employment end date (for terminated employees)',
example: '2025-12-31',
})
@IsOptional()
@Type(() => Date)
@IsDate()
exitDate?: Date;
@ApiPropertyOptional({
description: 'Type of employment contract',
enum: ContractType,
example: ContractType.FULL_TIME,
})
@IsOptional()
@IsEnum(ContractType)
contractType?: ContractType;
@ApiPropertyOptional({
description: 'Weekly working hours',
example: 40,
minimum: 0,
maximum: 60,
})
@IsOptional()
@IsNumber()
@Min(0)
@Max(60)
workingHours?: number;
@ApiPropertyOptional({
description: 'Monthly gross salary in EUR (sensitive - will be encrypted)',
example: 5000,
})
@IsOptional()
@IsNumber()
@Min(0)
salary?: number;
@ApiPropertyOptional({
description: 'German tax class (1-6)',
example: 1,
minimum: 1,
maximum: 6,
})
@IsOptional()
@IsInt()
@Min(1)
@Max(6)
taxClass?: number;
@ApiPropertyOptional({
description: 'Bank account details (sensitive - will be encrypted)',
type: BankAccountDto,
})
@IsOptional()
@IsObject()
@ValidateNested()
@Type(() => BankAccountDto)
bankAccount?: BankAccountDto;
@ApiPropertyOptional({
description: 'Whether the employee is active',
example: true,
})
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View File

@@ -0,0 +1,193 @@
import {
Controller,
Get,
Post,
Body,
Put,
Param,
Delete,
Query,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiParam,
ApiQuery,
} from '@nestjs/swagger';
import { EmployeesService } from './employees.service';
import { CreateEmployeeDto } from './dto/create-employee.dto';
import { UpdateEmployeeDto } from './dto/update-employee.dto';
import { QueryEmployeesDto } from './dto/query-employees.dto';
import { Roles } from '../../../auth/decorators/roles.decorator';
import { RequirePermissions } from '../../../auth/permissions/permissions.decorator';
import { Permission } from '../../../auth/permissions/permissions.enum';
import { AuditLog } from '../../audit/audit.interceptor';
import { CurrentUser } from '../../../auth/decorators/current-user.decorator';
import { JwtPayload } from '../../../auth/interfaces/jwt-payload.interface';
@ApiTags('hr/employees')
@ApiBearerAuth('JWT-auth')
@Controller('hr/employees')
export class EmployeesController {
constructor(private readonly employeesService: EmployeesService) {}
@Post()
@Roles('admin', 'hr-manager')
@RequirePermissions(Permission.EMPLOYEES_CREATE)
@AuditLog('Employee', 'CREATE')
@ApiOperation({ summary: 'Create a new employee record' })
@ApiResponse({
status: 201,
description: 'Employee created successfully',
})
@ApiResponse({ status: 400, description: 'Bad request - validation error' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden - insufficient permissions' })
@ApiResponse({ status: 404, description: 'User not found' })
@ApiResponse({ status: 409, description: 'Employee number already exists or user already has employee record' })
create(@Body() createDto: CreateEmployeeDto) {
return this.employeesService.create(createDto);
}
@Get()
@RequirePermissions(Permission.EMPLOYEES_VIEW)
@ApiOperation({ summary: 'Get all employees with pagination and filtering' })
@ApiResponse({
status: 200,
description: 'List of employees (sensitive data redacted)',
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
findAll(@Query() query: QueryEmployeesDto) {
return this.employeesService.findAll(query);
}
@Get('org-chart')
@RequirePermissions(Permission.EMPLOYEES_VIEW)
@ApiOperation({ summary: 'Get organizational chart data (hierarchical)' })
@ApiResponse({
status: 200,
description: 'Hierarchical org chart structure',
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
getOrgChart() {
return this.employeesService.getOrgChart();
}
@Get('me')
@ApiOperation({ summary: 'Get current user employee record' })
@ApiResponse({
status: 200,
description: 'Current user employee record',
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 404, description: 'Employee record not found' })
async getMyEmployee(@CurrentUser() user: JwtPayload) {
const employee = await this.employeesService.findByUserId(user.sub);
if (!employee) {
return { data: null, message: 'No employee record found for current user' };
}
return employee;
}
@Get(':id')
@RequirePermissions(Permission.EMPLOYEES_VIEW)
@ApiOperation({ summary: 'Get an employee by ID' })
@ApiParam({ name: 'id', description: 'Employee ID' })
@ApiResponse({
status: 200,
description: 'Employee found (sensitive data redacted)',
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 404, description: 'Employee not found' })
findOne(@Param('id') id: string) {
return this.employeesService.findOne(id, false);
}
@Get(':id/sensitive')
@Roles('admin', 'hr-manager')
@RequirePermissions(Permission.EMPLOYEES_VIEW_ALL)
@AuditLog('Employee', 'VIEW_SENSITIVE')
@ApiOperation({ summary: 'Get employee with sensitive data (HR/Admin only)' })
@ApiParam({ name: 'id', description: 'Employee ID' })
@ApiResponse({
status: 200,
description: 'Employee found with all data including salary and bank account',
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden - insufficient permissions' })
@ApiResponse({ status: 404, description: 'Employee not found' })
findOneSensitive(@Param('id') id: string) {
return this.employeesService.findOne(id, true);
}
@Get(':id/time-entries')
@RequirePermissions(Permission.TIME_ENTRIES_VIEW)
@ApiOperation({ summary: 'Get time entries for an employee' })
@ApiParam({ name: 'id', description: 'Employee ID' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'startDate', required: false, type: String, description: 'ISO date string' })
@ApiQuery({ name: 'endDate', required: false, type: String, description: 'ISO date string' })
@ApiResponse({
status: 200,
description: 'List of time entries for the employee',
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 404, description: 'Employee not found' })
getTimeEntries(
@Param('id') id: string,
@Query('page') page?: number,
@Query('limit') limit?: number,
@Query('startDate') startDate?: string,
@Query('endDate') endDate?: string,
) {
return this.employeesService.getTimeEntries(id, {
page,
limit,
startDate: startDate ? new Date(startDate) : undefined,
endDate: endDate ? new Date(endDate) : undefined,
});
}
@Put(':id')
@Roles('admin', 'hr-manager')
@RequirePermissions(Permission.EMPLOYEES_UPDATE)
@AuditLog('Employee', 'UPDATE')
@ApiOperation({ summary: 'Update an employee' })
@ApiParam({ name: 'id', description: 'Employee ID' })
@ApiResponse({
status: 200,
description: 'Employee updated successfully',
})
@ApiResponse({ status: 400, description: 'Bad request - validation error' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden - insufficient permissions' })
@ApiResponse({ status: 404, description: 'Employee not found' })
@ApiResponse({ status: 409, description: 'Employee number already exists' })
update(@Param('id') id: string, @Body() updateDto: UpdateEmployeeDto) {
return this.employeesService.update(id, updateDto);
}
@Delete(':id')
@Roles('admin', 'hr-manager')
@RequirePermissions(Permission.EMPLOYEES_DELETE)
@AuditLog('Employee', 'DELETE')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Deactivate an employee (soft delete)' })
@ApiParam({ name: 'id', description: 'Employee ID' })
@ApiResponse({
status: 200,
description: 'Employee deactivated successfully',
})
@ApiResponse({ status: 400, description: 'Employee already deactivated' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden - insufficient permissions' })
@ApiResponse({ status: 404, description: 'Employee not found' })
remove(@Param('id') id: string) {
return this.employeesService.remove(id);
}
}

View File

@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { EmployeesController } from './employees.controller';
import { EmployeesService } from './employees.service';
import { CommonModule } from '../../../common/common.module';
/**
* Employees Module
*
* Handles employee data management including:
* - CRUD operations for employee records
* - Encryption of sensitive data (salary, bank account)
* - Organizational chart generation
* - Integration with time tracking
*
* Security considerations:
* - Sensitive fields (salary, bankAccount) are encrypted at rest
* - Access to sensitive data requires elevated permissions
* - All modifications are audit logged
*/
@Module({
imports: [CommonModule],
controllers: [EmployeesController],
providers: [EmployeesService],
exports: [EmployeesService],
})
export class EmployeesModule {}

View File

@@ -0,0 +1,606 @@
import {
Injectable,
NotFoundException,
ConflictException,
BadRequestException,
} from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { EncryptionService } from '../../../common/services/encryption.service';
import { CreateEmployeeDto, BankAccountDto } from './dto/create-employee.dto';
import { UpdateEmployeeDto } from './dto/update-employee.dto';
import { QueryEmployeesDto } from './dto/query-employees.dto';
import { Prisma, Employee } from '@prisma/client';
export interface EmployeeWithUser extends Employee {
user: {
id: string;
email: string;
firstName: string;
lastName: string;
avatar: string | null;
department: {
id: string;
name: string;
code: string | null;
} | null;
};
}
export interface OrgChartNode {
id: string;
employeeNumber: string;
position: string;
user: {
id: string;
email: string;
firstName: string;
lastName: string;
avatar: string | null;
department: {
id: string;
name: string;
code: string | null;
} | null;
};
managerId: string | null;
subordinates: OrgChartNode[];
}
@Injectable()
export class EmployeesService {
constructor(
private readonly prisma: PrismaService,
private readonly encryptionService: EncryptionService,
) {}
/**
* Create a new employee record
*/
async create(createDto: CreateEmployeeDto): Promise<EmployeeWithUser> {
// Check if user exists
const user = await this.prisma.user.findUnique({
where: { id: createDto.userId },
});
if (!user) {
throw new NotFoundException(`User with ID ${createDto.userId} not found`);
}
// Check if user already has an employee record
const existingEmployee = await this.prisma.employee.findUnique({
where: { userId: createDto.userId },
});
if (existingEmployee) {
throw new ConflictException(
`User ${createDto.userId} already has an employee record`,
);
}
// Check for duplicate employee number
const existingNumber = await this.prisma.employee.findUnique({
where: { employeeNumber: createDto.employeeNumber },
});
if (existingNumber) {
throw new ConflictException(
`Employee number ${createDto.employeeNumber} already exists`,
);
}
// Encrypt sensitive data
const encryptedBankAccount = createDto.bankAccount
? this.encryptionService.encryptObject(createDto.bankAccount)
: null;
// Encrypt salary as string for secure storage
const encryptedSalary = createDto.salary
? this.encryptionService.encrypt(createDto.salary.toString())
: null;
const employee = await this.prisma.employee.create({
data: {
userId: createDto.userId,
employeeNumber: createDto.employeeNumber,
position: createDto.position,
entryDate: createDto.entryDate,
exitDate: createDto.exitDate,
contractType: createDto.contractType,
workingHours: createDto.workingHours ?? 40,
salary: encryptedSalary,
taxClass: createDto.taxClass,
bankAccount: encryptedBankAccount as any,
isActive: createDto.isActive ?? true,
},
include: {
user: {
select: {
id: true,
email: true,
firstName: true,
lastName: true,
avatar: true,
department: {
select: {
id: true,
name: true,
code: true,
},
},
},
},
},
});
return this.sanitizeEmployee(employee as EmployeeWithUser);
}
/**
* Find all employees with filtering and pagination
*/
async findAll(query: QueryEmployeesDto) {
const {
page = 1,
limit = 20,
search,
departmentId,
contractType,
isActive,
sortBy,
sortOrder,
} = query;
const skip = (page - 1) * limit;
const where: Prisma.EmployeeWhereInput = {
...(search && {
OR: [
{ employeeNumber: { contains: search, mode: 'insensitive' } },
{ position: { contains: search, mode: 'insensitive' } },
{ user: { firstName: { contains: search, mode: 'insensitive' } } },
{ user: { lastName: { contains: search, mode: 'insensitive' } } },
{ user: { email: { contains: search, mode: 'insensitive' } } },
],
}),
...(departmentId && {
user: { departmentId },
}),
...(contractType && { contractType }),
...(isActive !== undefined && { isActive }),
};
const orderBy: Prisma.EmployeeOrderByWithRelationInput = sortBy
? { [sortBy]: sortOrder || 'asc' }
: { employeeNumber: 'asc' };
const [employees, total] = await Promise.all([
this.prisma.employee.findMany({
where,
skip,
take: limit,
orderBy,
include: {
user: {
select: {
id: true,
email: true,
firstName: true,
lastName: true,
avatar: true,
department: {
select: {
id: true,
name: true,
code: true,
},
},
},
},
},
}),
this.prisma.employee.count({ where }),
]);
return {
data: employees.map((emp) =>
this.sanitizeEmployee(emp as EmployeeWithUser),
),
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* Find a single employee by ID
*/
async findOne(id: string, includeSensitive = false): Promise<EmployeeWithUser> {
const employee = await this.prisma.employee.findUnique({
where: { id },
include: {
user: {
select: {
id: true,
email: true,
firstName: true,
lastName: true,
avatar: true,
department: {
select: {
id: true,
name: true,
code: true,
},
},
},
},
},
});
if (!employee) {
throw new NotFoundException(`Employee with ID ${id} not found`);
}
if (includeSensitive) {
return this.decryptEmployee(employee as EmployeeWithUser);
}
return this.sanitizeEmployee(employee as EmployeeWithUser);
}
/**
* Find employee by user ID
*/
async findByUserId(userId: string): Promise<EmployeeWithUser | null> {
const employee = await this.prisma.employee.findUnique({
where: { userId },
include: {
user: {
select: {
id: true,
email: true,
firstName: true,
lastName: true,
avatar: true,
department: {
select: {
id: true,
name: true,
code: true,
},
},
},
},
},
});
if (!employee) {
return null;
}
return this.sanitizeEmployee(employee as EmployeeWithUser);
}
/**
* Update an employee
*/
async update(
id: string,
updateDto: UpdateEmployeeDto,
): Promise<EmployeeWithUser> {
await this.findOne(id);
// Check for duplicate employee number if updating
if (updateDto.employeeNumber) {
const existingNumber = await this.prisma.employee.findFirst({
where: {
employeeNumber: updateDto.employeeNumber,
id: { not: id },
},
});
if (existingNumber) {
throw new ConflictException(
`Employee number ${updateDto.employeeNumber} already exists`,
);
}
}
// Prepare update data with encryption
const updateData: Prisma.EmployeeUpdateInput = {
...(updateDto.employeeNumber && {
employeeNumber: updateDto.employeeNumber,
}),
...(updateDto.position && { position: updateDto.position }),
...(updateDto.entryDate && { entryDate: updateDto.entryDate }),
...(updateDto.exitDate !== undefined && { exitDate: updateDto.exitDate }),
...(updateDto.contractType && { contractType: updateDto.contractType }),
...(updateDto.workingHours !== undefined && {
workingHours: updateDto.workingHours,
}),
...(updateDto.taxClass !== undefined && { taxClass: updateDto.taxClass }),
...(updateDto.isActive !== undefined && { isActive: updateDto.isActive }),
};
// Encrypt salary if provided
if (updateDto.salary !== undefined) {
updateData.salary = updateDto.salary
? this.encryptionService.encrypt(updateDto.salary.toString())
: null;
}
// Encrypt bank account if provided
if (updateDto.bankAccount !== undefined) {
updateData.bankAccount = updateDto.bankAccount
? (this.encryptionService.encryptObject(
updateDto.bankAccount,
) as any)
: Prisma.JsonNull;
}
const employee = await this.prisma.employee.update({
where: { id },
data: updateData,
include: {
user: {
select: {
id: true,
email: true,
firstName: true,
lastName: true,
avatar: true,
department: {
select: {
id: true,
name: true,
code: true,
},
},
},
},
},
});
return this.sanitizeEmployee(employee as EmployeeWithUser);
}
/**
* Soft delete an employee (deactivate)
*/
async remove(id: string): Promise<EmployeeWithUser> {
const employee = await this.findOne(id);
if (!employee.isActive) {
throw new BadRequestException(
`Employee ${id} is already deactivated`,
);
}
const updated = await this.prisma.employee.update({
where: { id },
data: {
isActive: false,
exitDate: new Date(),
},
include: {
user: {
select: {
id: true,
email: true,
firstName: true,
lastName: true,
avatar: true,
department: {
select: {
id: true,
name: true,
code: true,
},
},
},
},
},
});
return this.sanitizeEmployee(updated as EmployeeWithUser);
}
/**
* Get time entries for an employee
*/
async getTimeEntries(
employeeId: string,
pagination: { page?: number; limit?: number; startDate?: Date; endDate?: Date } = {},
) {
await this.findOne(employeeId);
const { page = 1, limit = 20, startDate, endDate } = pagination;
const skip = (page - 1) * limit;
const where: Prisma.TimeEntryWhereInput = {
employeeId,
...(startDate || endDate
? {
date: {
...(startDate && { gte: startDate }),
...(endDate && { lte: endDate }),
},
}
: {}),
};
const [entries, total] = await Promise.all([
this.prisma.timeEntry.findMany({
where,
skip,
take: limit,
orderBy: { date: 'desc' },
include: {
correctedBy: {
select: {
id: true,
firstName: true,
lastName: true,
},
},
},
}),
this.prisma.timeEntry.count({ where }),
]);
return {
data: entries,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* Get organizational chart data (hierarchical)
*/
async getOrgChart(): Promise<OrgChartNode[]> {
// Get all active employees with their department info
const employees = await this.prisma.employee.findMany({
where: { isActive: true },
include: {
user: {
select: {
id: true,
email: true,
firstName: true,
lastName: true,
avatar: true,
department: {
select: {
id: true,
name: true,
code: true,
managerId: true,
},
},
},
},
},
});
// Build a map of user ID to employee for quick lookup
const userToEmployee = new Map<string, typeof employees[0]>();
employees.forEach((emp) => {
userToEmployee.set(emp.user.id, emp);
});
// Build manager relationships based on department managers
const employeeNodes: Map<string, OrgChartNode> = new Map();
// First pass: create all nodes
employees.forEach((emp) => {
const managerId = emp.user.department?.managerId ?? null;
employeeNodes.set(emp.id, {
id: emp.id,
employeeNumber: emp.employeeNumber,
position: emp.position,
user: {
id: emp.user.id,
email: emp.user.email,
firstName: emp.user.firstName,
lastName: emp.user.lastName,
avatar: emp.user.avatar,
department: emp.user.department
? {
id: emp.user.department.id,
name: emp.user.department.name,
code: emp.user.department.code,
}
: null,
},
managerId:
managerId && managerId !== emp.user.id
? (userToEmployee.get(managerId)?.id ?? null)
: null,
subordinates: [],
});
});
// Second pass: build hierarchy
const rootNodes: OrgChartNode[] = [];
employeeNodes.forEach((node) => {
if (node.managerId && employeeNodes.has(node.managerId)) {
const manager = employeeNodes.get(node.managerId)!;
manager.subordinates.push(node);
} else {
rootNodes.push(node);
}
});
// Sort subordinates by name at each level
const sortSubordinates = (nodes: OrgChartNode[]) => {
nodes.sort((a, b) =>
`${a.user.lastName} ${a.user.firstName}`.localeCompare(
`${b.user.lastName} ${b.user.firstName}`,
),
);
nodes.forEach((node) => sortSubordinates(node.subordinates));
};
sortSubordinates(rootNodes);
return rootNodes;
}
/**
* Remove sensitive fields from employee data
*/
private sanitizeEmployee(employee: EmployeeWithUser): EmployeeWithUser {
return {
...employee,
salary: null,
bankAccount: null,
};
}
/**
* Decrypt sensitive employee data
*/
private decryptEmployee(employee: EmployeeWithUser): EmployeeWithUser {
let decryptedBankAccount: BankAccountDto | null = null;
let decryptedSalary: number | null = null;
// Decrypt bank account
if (employee.bankAccount && typeof employee.bankAccount === 'string') {
try {
decryptedBankAccount = this.encryptionService.decryptObject<BankAccountDto>(
employee.bankAccount,
);
} catch (error) {
// If decryption fails, return null
decryptedBankAccount = null;
}
}
// Decrypt salary (stored as encrypted string in DB)
if (employee.salary) {
try {
const decryptedSalaryStr = this.encryptionService.decrypt(employee.salary);
decryptedSalary = parseFloat(decryptedSalaryStr);
} catch (error) {
// If decryption fails, return null
decryptedSalary = null;
}
}
return {
...employee,
// Return decrypted salary as a string representation of the number for API response
salary: decryptedSalary !== null ? String(decryptedSalary) : null,
bankAccount: decryptedBankAccount as unknown as Prisma.JsonValue,
};
}
}

View File

@@ -0,0 +1,4 @@
export * from './employees.module';
export * from './employees.service';
export * from './employees.controller';
export * from './dto';

View File

@@ -0,0 +1,29 @@
import { Module } from '@nestjs/common';
import { EmployeesModule } from './employees/employees.module';
import { AbsencesModule } from './absences/absences.module';
import { TimeTrackingModule } from './time-tracking/time-tracking.module';
/**
* HR (Human Resources) Module
*
* Aggregates all HR-related sub-modules:
* - EmployeesModule: Employee data management, org chart, sensitive data handling
* - AbsencesModule: Absence/vacation management with approval workflow
* - TimeTrackingModule: Clock in/out, breaks, overtime, monthly summaries
*
* Features:
* - Encrypted sensitive employee data (salary, bank account)
* - German labor law compliant break calculations
* - Overtime tracking and monthly summaries
* - Audit trail for all HR data changes
*
* API Routes:
* - /hr/employees/* - Employee management
* - /hr/absences/* - Absence management
* - /hr/time/* - Time tracking
*/
@Module({
imports: [EmployeesModule, AbsencesModule, TimeTrackingModule],
exports: [EmployeesModule, AbsencesModule, TimeTrackingModule],
})
export class HrModule {}

View File

@@ -0,0 +1 @@
export { HrModule } from './hr.module';

View File

@@ -0,0 +1,14 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, MaxLength } from 'class-validator';
export class ClockInDto {
@ApiPropertyOptional({
description: 'Optional note for the clock-in entry',
example: 'Starting from home office today',
maxLength: 500,
})
@IsOptional()
@IsString()
@MaxLength(500)
note?: string;
}

View File

@@ -0,0 +1,14 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, MaxLength } from 'class-validator';
export class ClockOutDto {
@ApiPropertyOptional({
description: 'Optional note for the clock-out entry',
example: 'Finished project milestone',
maxLength: 500,
})
@IsOptional()
@IsString()
@MaxLength(500)
note?: string;
}

View File

@@ -0,0 +1,81 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsString,
IsOptional,
IsDate,
IsEnum,
IsInt,
Min,
Max,
MaxLength,
} from 'class-validator';
import { Type } from 'class-transformer';
import { TimeEntryType } from '@prisma/client';
export class CreateTimeEntryDto {
@ApiProperty({
description: 'Employee ID for the time entry',
example: 'clq1234567890abcdef',
})
@IsString()
employeeId: string;
@ApiProperty({
description: 'Date of the time entry',
example: '2024-01-15',
})
@Type(() => Date)
@IsDate()
date: Date;
@ApiPropertyOptional({
description: 'Clock-in time',
example: '2024-01-15T08:00:00.000Z',
})
@IsOptional()
@Type(() => Date)
@IsDate()
clockIn?: Date;
@ApiPropertyOptional({
description: 'Clock-out time',
example: '2024-01-15T17:00:00.000Z',
})
@IsOptional()
@Type(() => Date)
@IsDate()
clockOut?: Date;
@ApiPropertyOptional({
description: 'Total break time in minutes',
example: 45,
minimum: 0,
maximum: 480,
default: 0,
})
@IsOptional()
@IsInt()
@Min(0)
@Max(480)
breakMinutes?: number;
@ApiPropertyOptional({
description: 'Type of time entry',
enum: TimeEntryType,
example: TimeEntryType.REGULAR,
default: TimeEntryType.REGULAR,
})
@IsOptional()
@IsEnum(TimeEntryType)
type?: TimeEntryType;
@ApiPropertyOptional({
description: 'Note or reason for the entry (especially for corrections)',
example: 'Manual entry: Forgot to clock in',
maxLength: 500,
})
@IsOptional()
@IsString()
@MaxLength(500)
note?: string;
}

View File

@@ -0,0 +1,5 @@
export * from './clock-in.dto';
export * from './clock-out.dto';
export * from './create-time-entry.dto';
export * from './update-time-entry.dto';
export * from './query-time-entries.dto';

View File

@@ -0,0 +1,111 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import {
IsOptional,
IsString,
IsBoolean,
IsInt,
IsEnum,
IsDate,
Min,
Max,
} from 'class-validator';
import { Transform, Type } from 'class-transformer';
import { TimeEntryType } from '@prisma/client';
export enum TimeEntrySortField {
DATE = 'date',
CLOCK_IN = 'clockIn',
CLOCK_OUT = 'clockOut',
CREATED_AT = 'createdAt',
}
export enum SortOrder {
ASC = 'asc',
DESC = 'desc',
}
export class QueryTimeEntriesDto {
@ApiPropertyOptional({
description: 'Page number for pagination',
example: 1,
minimum: 1,
default: 1,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({
description: 'Number of items per page',
example: 20,
minimum: 1,
maximum: 100,
default: 20,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 20;
@ApiPropertyOptional({
description: 'Filter by start date (inclusive)',
example: '2024-01-01',
})
@IsOptional()
@Type(() => Date)
@IsDate()
startDate?: Date;
@ApiPropertyOptional({
description: 'Filter by end date (inclusive)',
example: '2024-01-31',
})
@IsOptional()
@Type(() => Date)
@IsDate()
endDate?: Date;
@ApiPropertyOptional({
description: 'Filter by entry type',
enum: TimeEntryType,
example: TimeEntryType.REGULAR,
})
@IsOptional()
@IsEnum(TimeEntryType)
type?: TimeEntryType;
@ApiPropertyOptional({
description: 'Filter by locked status',
example: false,
})
@IsOptional()
@Transform(({ value }) => {
if (value === 'true') return true;
if (value === 'false') return false;
return value;
})
@IsBoolean()
isLocked?: boolean;
@ApiPropertyOptional({
description: 'Field to sort by',
enum: TimeEntrySortField,
example: TimeEntrySortField.DATE,
})
@IsOptional()
@IsEnum(TimeEntrySortField)
sortBy?: TimeEntrySortField;
@ApiPropertyOptional({
description: 'Sort order',
enum: SortOrder,
example: SortOrder.DESC,
})
@IsOptional()
@IsEnum(SortOrder)
sortOrder?: SortOrder;
}

View File

@@ -0,0 +1,73 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import {
IsString,
IsOptional,
IsDate,
IsEnum,
IsInt,
IsBoolean,
Min,
Max,
MaxLength,
} from 'class-validator';
import { Type } from 'class-transformer';
import { TimeEntryType } from '@prisma/client';
export class UpdateTimeEntryDto {
@ApiPropertyOptional({
description: 'Clock-in time',
example: '2024-01-15T08:00:00.000Z',
})
@IsOptional()
@Type(() => Date)
@IsDate()
clockIn?: Date;
@ApiPropertyOptional({
description: 'Clock-out time',
example: '2024-01-15T17:00:00.000Z',
})
@IsOptional()
@Type(() => Date)
@IsDate()
clockOut?: Date;
@ApiPropertyOptional({
description: 'Total break time in minutes',
example: 45,
minimum: 0,
maximum: 480,
})
@IsOptional()
@IsInt()
@Min(0)
@Max(480)
breakMinutes?: number;
@ApiPropertyOptional({
description: 'Type of time entry',
enum: TimeEntryType,
example: TimeEntryType.CORRECTION,
})
@IsOptional()
@IsEnum(TimeEntryType)
type?: TimeEntryType;
@ApiPropertyOptional({
description: 'Note or reason for the correction',
example: 'Correction: Wrong clock-out time entered',
maxLength: 500,
})
@IsOptional()
@IsString()
@MaxLength(500)
note?: string;
@ApiPropertyOptional({
description: 'Lock the entry to prevent further modifications',
example: true,
})
@IsOptional()
@IsBoolean()
isLocked?: boolean;
}

View File

@@ -0,0 +1,4 @@
export * from './time-tracking.module';
export * from './time-tracking.service';
export * from './time-tracking.controller';
export * from './dto';

View File

@@ -0,0 +1,242 @@
import {
Controller,
Get,
Post,
Body,
Put,
Param,
Query,
ParseIntPipe,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiParam,
ApiQuery,
} from '@nestjs/swagger';
import { TimeTrackingService } from './time-tracking.service';
import { ClockInDto } from './dto/clock-in.dto';
import { ClockOutDto } from './dto/clock-out.dto';
import { CreateTimeEntryDto } from './dto/create-time-entry.dto';
import { UpdateTimeEntryDto } from './dto/update-time-entry.dto';
import { QueryTimeEntriesDto } from './dto/query-time-entries.dto';
import { Roles } from '../../../auth/decorators/roles.decorator';
import { RequirePermissions } from '../../../auth/permissions/permissions.decorator';
import { Permission } from '../../../auth/permissions/permissions.enum';
import { AuditLog } from '../../audit/audit.interceptor';
import { CurrentUser } from '../../../auth/decorators/current-user.decorator';
import { JwtPayload } from '../../../auth/interfaces/jwt-payload.interface';
@ApiTags('hr/time-tracking')
@ApiBearerAuth('JWT-auth')
@Controller('hr/time')
export class TimeTrackingController {
constructor(private readonly timeTrackingService: TimeTrackingService) {}
// =====================================================
// SELF-SERVICE ENDPOINTS (Employee's own time)
// =====================================================
@Post('clock-in')
@RequirePermissions(Permission.TIME_ENTRIES_CREATE)
@AuditLog('TimeEntry', 'CLOCK_IN')
@ApiOperation({ summary: 'Clock in for the current day' })
@ApiResponse({
status: 201,
description: 'Successfully clocked in',
})
@ApiResponse({ status: 400, description: 'Bad request - not clocked in or already clocked out' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 404, description: 'Employee record not found' })
@ApiResponse({ status: 409, description: 'Already clocked in for today' })
clockIn(@CurrentUser() user: JwtPayload, @Body() dto: ClockInDto) {
return this.timeTrackingService.clockIn(user.sub, dto);
}
@Post('clock-out')
@RequirePermissions(Permission.TIME_ENTRIES_CREATE)
@AuditLog('TimeEntry', 'CLOCK_OUT')
@ApiOperation({ summary: 'Clock out for the current day' })
@ApiResponse({
status: 201,
description: 'Successfully clocked out',
})
@ApiResponse({ status: 400, description: 'Not clocked in or entry is locked' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 404, description: 'Employee record not found' })
@ApiResponse({ status: 409, description: 'Already clocked out for today' })
clockOut(@CurrentUser() user: JwtPayload, @Body() dto: ClockOutDto) {
return this.timeTrackingService.clockOut(user.sub, dto);
}
@Post('break/start')
@RequirePermissions(Permission.TIME_ENTRIES_CREATE)
@AuditLog('TimeEntry', 'BREAK_START')
@ApiOperation({ summary: 'Start a break' })
@ApiResponse({
status: 201,
description: 'Break started',
})
@ApiResponse({ status: 400, description: 'Not clocked in or already clocked out' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 404, description: 'Employee record not found' })
startBreak(@CurrentUser() user: JwtPayload) {
return this.timeTrackingService.startBreak(user.sub);
}
@Post('break/end')
@RequirePermissions(Permission.TIME_ENTRIES_CREATE)
@AuditLog('TimeEntry', 'BREAK_END')
@ApiOperation({ summary: 'End a break' })
@ApiResponse({
status: 201,
description: 'Break ended',
})
@ApiResponse({ status: 400, description: 'No active break found' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 404, description: 'Employee record not found' })
endBreak(@CurrentUser() user: JwtPayload) {
return this.timeTrackingService.endBreak(user.sub);
}
@Get('current')
@RequirePermissions(Permission.TIME_ENTRIES_VIEW)
@ApiOperation({ summary: 'Get current time tracking status' })
@ApiResponse({
status: 200,
description: 'Current status (clocked in, on break, today entry)',
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 404, description: 'Employee record not found' })
getCurrentStatus(@CurrentUser() user: JwtPayload) {
return this.timeTrackingService.getCurrentStatus(user.sub);
}
@Get('entries')
@RequirePermissions(Permission.TIME_ENTRIES_VIEW)
@ApiOperation({ summary: 'Get own time entries' })
@ApiResponse({
status: 200,
description: 'List of own time entries',
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 404, description: 'Employee record not found' })
getMyEntries(
@CurrentUser() user: JwtPayload,
@Query() query: QueryTimeEntriesDto,
) {
return this.timeTrackingService.getMyEntries(user.sub, query);
}
// =====================================================
// HR/ADMIN ENDPOINTS (Other employees' time)
// =====================================================
@Get('entries/:employeeId')
@Roles('admin', 'hr-manager', 'team-lead')
@RequirePermissions(Permission.TIME_ENTRIES_VIEW_ALL)
@ApiOperation({ summary: 'Get time entries for a specific employee (HR)' })
@ApiParam({ name: 'employeeId', description: 'Employee ID' })
@ApiResponse({
status: 200,
description: 'List of time entries for the employee',
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden - insufficient permissions' })
@ApiResponse({ status: 404, description: 'Employee not found' })
getEmployeeEntries(
@Param('employeeId') employeeId: string,
@Query() query: QueryTimeEntriesDto,
) {
return this.timeTrackingService.getEntriesByEmployee(employeeId, query);
}
@Post('entries')
@Roles('admin', 'hr-manager')
@RequirePermissions(Permission.TIME_ENTRIES_CORRECT)
@AuditLog('TimeEntry', 'CREATE_MANUAL')
@ApiOperation({ summary: 'Create a manual time entry (correction)' })
@ApiResponse({
status: 201,
description: 'Manual time entry created',
})
@ApiResponse({ status: 400, description: 'Validation error or invalid times' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden - insufficient permissions' })
@ApiResponse({ status: 404, description: 'Employee not found' })
@ApiResponse({ status: 409, description: 'Entry already exists for this date' })
createManualEntry(
@CurrentUser() user: JwtPayload,
@Body() dto: CreateTimeEntryDto,
) {
return this.timeTrackingService.createManualEntry(dto, user.sub);
}
@Put('entries/:id')
@Roles('admin', 'hr-manager')
@RequirePermissions(Permission.TIME_ENTRIES_CORRECT)
@AuditLog('TimeEntry', 'CORRECT')
@ApiOperation({ summary: 'Update/correct a time entry' })
@ApiParam({ name: 'id', description: 'Time Entry ID' })
@ApiResponse({
status: 200,
description: 'Time entry updated',
})
@ApiResponse({ status: 400, description: 'Entry is locked or validation error' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden - insufficient permissions' })
@ApiResponse({ status: 404, description: 'Time entry not found' })
updateEntry(
@CurrentUser() user: JwtPayload,
@Param('id') id: string,
@Body() dto: UpdateTimeEntryDto,
) {
return this.timeTrackingService.updateEntry(id, dto, user.sub);
}
@Get('summary/:employeeId/:year/:month')
@Roles('admin', 'hr-manager', 'team-lead')
@RequirePermissions(Permission.TIME_ENTRIES_VIEW_ALL)
@ApiOperation({ summary: 'Get monthly time summary for an employee' })
@ApiParam({ name: 'employeeId', description: 'Employee ID' })
@ApiParam({ name: 'year', description: 'Year (e.g., 2024)', example: 2024 })
@ApiParam({ name: 'month', description: 'Month (1-12)', example: 1 })
@ApiResponse({
status: 200,
description: 'Monthly summary with totals and daily breakdown',
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden - insufficient permissions' })
@ApiResponse({ status: 404, description: 'Employee not found' })
getMonthlySummary(
@Param('employeeId') employeeId: string,
@Param('year', ParseIntPipe) year: number,
@Param('month', ParseIntPipe) month: number,
) {
return this.timeTrackingService.getMonthlySummary(employeeId, year, month);
}
@Get('summary/me/:year/:month')
@RequirePermissions(Permission.TIME_ENTRIES_VIEW)
@ApiOperation({ summary: 'Get own monthly time summary' })
@ApiParam({ name: 'year', description: 'Year (e.g., 2024)', example: 2024 })
@ApiParam({ name: 'month', description: 'Month (1-12)', example: 1 })
@ApiResponse({
status: 200,
description: 'Monthly summary with totals and daily breakdown',
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 404, description: 'Employee record not found' })
async getMyMonthlySummary(
@CurrentUser() user: JwtPayload,
@Param('year', ParseIntPipe) year: number,
@Param('month', ParseIntPipe) month: number,
) {
const employee = await this.timeTrackingService['getEmployeeByUserId'](
user.sub,
);
return this.timeTrackingService.getMonthlySummary(employee.id, year, month);
}
}

View File

@@ -0,0 +1,29 @@
import { Module } from '@nestjs/common';
import { TimeTrackingController } from './time-tracking.controller';
import { TimeTrackingService } from './time-tracking.service';
import { EmployeesModule } from '../employees/employees.module';
/**
* Time Tracking Module
*
* Handles employee time tracking including:
* - Clock-in/clock-out functionality
* - Break tracking
* - Manual entry corrections
* - Monthly summaries with overtime calculation
*
* Features:
* - Automatic break calculation per German labor law (ArbZG)
* - > 6 hours: minimum 30 min break
* - > 9 hours: minimum 45 min break
* - Overtime calculation based on employee's contracted hours
* - Audit trail for all corrections
* - Entry locking for payroll finalization
*/
@Module({
imports: [EmployeesModule],
controllers: [TimeTrackingController],
providers: [TimeTrackingService],
exports: [TimeTrackingService],
})
export class TimeTrackingModule {}

Some files were not shown because too many files have changed in this diff Show More