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:
80
apps/api/.env.example
Normal file
80
apps/api/.env.example
Normal 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
26
apps/api/.eslintrc.js
Normal 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
43
apps/api/.gitignore
vendored
Normal 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
7
apps/api/.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
8
apps/api/nest-cli.json
Normal file
8
apps/api/nest-cli.json
Normal 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
101
apps/api/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
656
apps/api/prisma/schema.prisma
Normal file
656
apps/api/prisma/schema.prisma
Normal 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
215
apps/api/prisma/seed.ts
Normal 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();
|
||||
});
|
||||
1
apps/api/queue.backup/index.ts
Normal file
1
apps/api/queue.backup/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './queue.module';
|
||||
91
apps/api/queue.backup/queue.module.ts
Normal file
91
apps/api/queue.backup/queue.module.ts
Normal 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()}`],
|
||||
};
|
||||
}
|
||||
}
|
||||
94
apps/api/src/app.module.ts
Normal file
94
apps/api/src/app.module.ts
Normal 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 {}
|
||||
55
apps/api/src/auth/auth.controller.ts
Normal file
55
apps/api/src/auth/auth.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
29
apps/api/src/auth/auth.module.ts
Normal file
29
apps/api/src/auth/auth.module.ts
Normal 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 {}
|
||||
83
apps/api/src/auth/auth.service.ts
Normal file
83
apps/api/src/auth/auth.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
31
apps/api/src/auth/decorators/current-user.decorator.ts
Normal file
31
apps/api/src/auth/decorators/current-user.decorator.ts
Normal 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;
|
||||
},
|
||||
);
|
||||
3
apps/api/src/auth/decorators/index.ts
Normal file
3
apps/api/src/auth/decorators/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './public.decorator';
|
||||
export * from './roles.decorator';
|
||||
export * from './current-user.decorator';
|
||||
16
apps/api/src/auth/decorators/public.decorator.ts
Normal file
16
apps/api/src/auth/decorators/public.decorator.ts
Normal 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);
|
||||
17
apps/api/src/auth/decorators/roles.decorator.ts
Normal file
17
apps/api/src/auth/decorators/roles.decorator.ts
Normal 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);
|
||||
1
apps/api/src/auth/dto/index.ts
Normal file
1
apps/api/src/auth/dto/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './validate-token.dto';
|
||||
12
apps/api/src/auth/dto/validate-token.dto.ts
Normal file
12
apps/api/src/auth/dto/validate-token.dto.ts
Normal 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;
|
||||
}
|
||||
3
apps/api/src/auth/guards/index.ts
Normal file
3
apps/api/src/auth/guards/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './jwt-auth.guard';
|
||||
export * from './roles.guard';
|
||||
export * from '../permissions/permissions.guard';
|
||||
26
apps/api/src/auth/guards/jwt-auth.guard.ts
Normal file
26
apps/api/src/auth/guards/jwt-auth.guard.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
38
apps/api/src/auth/guards/roles.guard.ts
Normal file
38
apps/api/src/auth/guards/roles.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
6
apps/api/src/auth/index.ts
Normal file
6
apps/api/src/auth/index.ts
Normal 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';
|
||||
26
apps/api/src/auth/interfaces/jwt-payload.interface.ts
Normal file
26
apps/api/src/auth/interfaces/jwt-payload.interface.ts
Normal 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;
|
||||
}
|
||||
3
apps/api/src/auth/permissions/index.ts
Normal file
3
apps/api/src/auth/permissions/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './permissions.enum';
|
||||
export * from './permissions.decorator';
|
||||
export * from './permissions.guard';
|
||||
31
apps/api/src/auth/permissions/permissions.decorator.ts
Normal file
31
apps/api/src/auth/permissions/permissions.decorator.ts
Normal 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);
|
||||
175
apps/api/src/auth/permissions/permissions.enum.ts
Normal file
175
apps/api/src/auth/permissions/permissions.enum.ts
Normal 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,
|
||||
],
|
||||
};
|
||||
91
apps/api/src/auth/permissions/permissions.guard.ts
Normal file
91
apps/api/src/auth/permissions/permissions.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
44
apps/api/src/auth/strategies/jwt.strategy.ts
Normal file
44
apps/api/src/auth/strategies/jwt.strategy.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
10
apps/api/src/common/common.module.ts
Normal file
10
apps/api/src/common/common.module.ts
Normal 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 {}
|
||||
@@ -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' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
1
apps/api/src/common/decorators/index.ts
Normal file
1
apps/api/src/common/decorators/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './api-paginated-response.decorator';
|
||||
76
apps/api/src/common/filters/http-exception.filter.ts
Normal file
76
apps/api/src/common/filters/http-exception.filter.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
1
apps/api/src/common/filters/index.ts
Normal file
1
apps/api/src/common/filters/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './http-exception.filter';
|
||||
6
apps/api/src/common/index.ts
Normal file
6
apps/api/src/common/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './common.module';
|
||||
export * from './filters';
|
||||
export * from './interceptors';
|
||||
export * from './decorators';
|
||||
export * from './pipes';
|
||||
export * from './services';
|
||||
3
apps/api/src/common/interceptors/index.ts
Normal file
3
apps/api/src/common/interceptors/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './transform.interceptor';
|
||||
export * from './logging.interceptor';
|
||||
export * from './timeout.interceptor';
|
||||
44
apps/api/src/common/interceptors/logging.interceptor.ts
Normal file
44
apps/api/src/common/interceptors/logging.interceptor.ts
Normal 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}`,
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
26
apps/api/src/common/interceptors/timeout.interceptor.ts
Normal file
26
apps/api/src/common/interceptors/timeout.interceptor.ts
Normal 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);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
27
apps/api/src/common/interceptors/transform.interceptor.ts
Normal file
27
apps/api/src/common/interceptors/transform.interceptor.ts
Normal 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(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
1
apps/api/src/common/pipes/index.ts
Normal file
1
apps/api/src/common/pipes/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './parse-cuid.pipe';
|
||||
19
apps/api/src/common/pipes/parse-cuid.pipe.ts
Normal file
19
apps/api/src/common/pipes/parse-cuid.pipe.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
206
apps/api/src/common/services/encryption.service.spec.ts
Normal file
206
apps/api/src/common/services/encryption.service.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
171
apps/api/src/common/services/encryption.service.ts
Normal file
171
apps/api/src/common/services/encryption.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
1
apps/api/src/common/services/index.ts
Normal file
1
apps/api/src/common/services/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './encryption.service';
|
||||
81
apps/api/src/config/config.validation.ts
Normal file
81
apps/api/src/config/config.validation.ts
Normal 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'),
|
||||
});
|
||||
1
apps/api/src/config/index.ts
Normal file
1
apps/api/src/config/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './config.validation';
|
||||
144
apps/api/src/health/health.controller.spec.ts
Normal file
144
apps/api/src/health/health.controller.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
109
apps/api/src/health/health.controller.ts
Normal file
109
apps/api/src/health/health.controller.ts
Normal 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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
12
apps/api/src/health/health.module.ts
Normal file
12
apps/api/src/health/health.module.ts
Normal 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 {}
|
||||
4
apps/api/src/health/index.ts
Normal file
4
apps/api/src/health/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './health.module';
|
||||
export * from './health.controller';
|
||||
export * from './prisma-health.indicator';
|
||||
export * from './modules-health.indicator';
|
||||
141
apps/api/src/health/modules-health.indicator.ts
Normal file
141
apps/api/src/health/modules-health.indicator.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
30
apps/api/src/health/prisma-health.indicator.ts
Normal file
30
apps/api/src/health/prisma-health.indicator.ts
Normal 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
100
apps/api/src/main.ts
Normal 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();
|
||||
146
apps/api/src/modules/audit/audit.interceptor.ts
Normal file
146
apps/api/src/modules/audit/audit.interceptor.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
10
apps/api/src/modules/audit/audit.module.ts
Normal file
10
apps/api/src/modules/audit/audit.module.ts
Normal 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 {}
|
||||
215
apps/api/src/modules/audit/audit.service.ts
Normal file
215
apps/api/src/modules/audit/audit.service.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
3
apps/api/src/modules/audit/index.ts
Normal file
3
apps/api/src/modules/audit/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './audit.module';
|
||||
export * from './audit.service';
|
||||
export * from './audit.interceptor';
|
||||
174
apps/api/src/modules/dashboard/dashboard.controller.ts
Normal file
174
apps/api/src/modules/dashboard/dashboard.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
12
apps/api/src/modules/dashboard/dashboard.module.ts
Normal file
12
apps/api/src/modules/dashboard/dashboard.module.ts
Normal 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 {}
|
||||
376
apps/api/src/modules/dashboard/dashboard.service.ts
Normal file
376
apps/api/src/modules/dashboard/dashboard.service.ts
Normal 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 },
|
||||
];
|
||||
}
|
||||
}
|
||||
69
apps/api/src/modules/dashboard/dto/dashboard-layout.dto.ts
Normal file
69
apps/api/src/modules/dashboard/dto/dashboard-layout.dto.ts
Normal 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[];
|
||||
}
|
||||
1
apps/api/src/modules/dashboard/dto/index.ts
Normal file
1
apps/api/src/modules/dashboard/dto/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './dashboard-layout.dto';
|
||||
4
apps/api/src/modules/dashboard/index.ts
Normal file
4
apps/api/src/modules/dashboard/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './dashboard.module';
|
||||
export * from './dashboard.service';
|
||||
export * from './dashboard.controller';
|
||||
export * from './dto';
|
||||
145
apps/api/src/modules/departments/departments.controller.ts
Normal file
145
apps/api/src/modules/departments/departments.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/modules/departments/departments.module.ts
Normal file
10
apps/api/src/modules/departments/departments.module.ts
Normal 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 {}
|
||||
462
apps/api/src/modules/departments/departments.service.ts
Normal file
462
apps/api/src/modules/departments/departments.service.ts
Normal 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()];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
3
apps/api/src/modules/departments/dto/index.ts
Normal file
3
apps/api/src/modules/departments/dto/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './create-department.dto';
|
||||
export * from './update-department.dto';
|
||||
export * from './query-departments.dto';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateDepartmentDto } from './create-department.dto';
|
||||
|
||||
export class UpdateDepartmentDto extends PartialType(CreateDepartmentDto) {}
|
||||
4
apps/api/src/modules/departments/index.ts
Normal file
4
apps/api/src/modules/departments/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './departments.module';
|
||||
export * from './departments.service';
|
||||
export * from './departments.controller';
|
||||
export * from './dto';
|
||||
232
apps/api/src/modules/hr/absences/absences.controller.ts
Normal file
232
apps/api/src/modules/hr/absences/absences.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/modules/hr/absences/absences.module.ts
Normal file
10
apps/api/src/modules/hr/absences/absences.module.ts
Normal 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 {}
|
||||
1406
apps/api/src/modules/hr/absences/absences.service.ts
Normal file
1406
apps/api/src/modules/hr/absences/absences.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
26
apps/api/src/modules/hr/absences/dto/approve-absence.dto.ts
Normal file
26
apps/api/src/modules/hr/absences/dto/approve-absence.dto.ts
Normal 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;
|
||||
}
|
||||
63
apps/api/src/modules/hr/absences/dto/create-absence.dto.ts
Normal file
63
apps/api/src/modules/hr/absences/dto/create-absence.dto.ts
Normal 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;
|
||||
}
|
||||
4
apps/api/src/modules/hr/absences/dto/index.ts
Normal file
4
apps/api/src/modules/hr/absences/dto/index.ts
Normal 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';
|
||||
229
apps/api/src/modules/hr/absences/dto/query-absences.dto.ts
Normal file
229
apps/api/src/modules/hr/absences/dto/query-absences.dto.ts
Normal 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;
|
||||
}
|
||||
58
apps/api/src/modules/hr/absences/dto/update-absence.dto.ts
Normal file
58
apps/api/src/modules/hr/absences/dto/update-absence.dto.ts
Normal 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;
|
||||
}
|
||||
4
apps/api/src/modules/hr/absences/index.ts
Normal file
4
apps/api/src/modules/hr/absences/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './absences.module';
|
||||
export * from './absences.service';
|
||||
export * from './absences.controller';
|
||||
export * from './dto';
|
||||
161
apps/api/src/modules/hr/employees/dto/create-employee.dto.ts
Normal file
161
apps/api/src/modules/hr/employees/dto/create-employee.dto.ts
Normal 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;
|
||||
}
|
||||
3
apps/api/src/modules/hr/employees/dto/index.ts
Normal file
3
apps/api/src/modules/hr/employees/dto/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './create-employee.dto';
|
||||
export * from './update-employee.dto';
|
||||
export * from './query-employees.dto';
|
||||
109
apps/api/src/modules/hr/employees/dto/query-employees.dto.ts
Normal file
109
apps/api/src/modules/hr/employees/dto/query-employees.dto.ts
Normal 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;
|
||||
}
|
||||
119
apps/api/src/modules/hr/employees/dto/update-employee.dto.ts
Normal file
119
apps/api/src/modules/hr/employees/dto/update-employee.dto.ts
Normal 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;
|
||||
}
|
||||
193
apps/api/src/modules/hr/employees/employees.controller.ts
Normal file
193
apps/api/src/modules/hr/employees/employees.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
26
apps/api/src/modules/hr/employees/employees.module.ts
Normal file
26
apps/api/src/modules/hr/employees/employees.module.ts
Normal 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 {}
|
||||
606
apps/api/src/modules/hr/employees/employees.service.ts
Normal file
606
apps/api/src/modules/hr/employees/employees.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
4
apps/api/src/modules/hr/employees/index.ts
Normal file
4
apps/api/src/modules/hr/employees/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './employees.module';
|
||||
export * from './employees.service';
|
||||
export * from './employees.controller';
|
||||
export * from './dto';
|
||||
29
apps/api/src/modules/hr/hr.module.ts
Normal file
29
apps/api/src/modules/hr/hr.module.ts
Normal 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 {}
|
||||
1
apps/api/src/modules/hr/index.ts
Normal file
1
apps/api/src/modules/hr/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { HrModule } from './hr.module';
|
||||
14
apps/api/src/modules/hr/time-tracking/dto/clock-in.dto.ts
Normal file
14
apps/api/src/modules/hr/time-tracking/dto/clock-in.dto.ts
Normal 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;
|
||||
}
|
||||
14
apps/api/src/modules/hr/time-tracking/dto/clock-out.dto.ts
Normal file
14
apps/api/src/modules/hr/time-tracking/dto/clock-out.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
5
apps/api/src/modules/hr/time-tracking/dto/index.ts
Normal file
5
apps/api/src/modules/hr/time-tracking/dto/index.ts
Normal 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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
4
apps/api/src/modules/hr/time-tracking/index.ts
Normal file
4
apps/api/src/modules/hr/time-tracking/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './time-tracking.module';
|
||||
export * from './time-tracking.service';
|
||||
export * from './time-tracking.controller';
|
||||
export * from './dto';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user