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

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

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

View File

@@ -0,0 +1,99 @@
# tOS Backend Architecture Memory
## Project Structure
- **Location**: `/home/mehmed/Entwicklung/githubProjekte/tOS/apps/api/`
- **Framework**: NestJS 10.3.x with TypeScript strict mode
- **ORM**: Prisma 5.8.x with PostgreSQL
- **Auth**: JWT-based with Keycloak integration support
## Key Conventions
- **Language**: English for all code/comments
- **Files**: kebab-case, **Variables**: camelCase
- **API Prefix**: `/api/v1/`
- **Global Guards**: JwtAuthGuard -> RolesGuard -> PermissionsGuard
- **IDs**: CUID, **Soft Delete**: `isActive` boolean
## Security - Encryption (CRITICAL)
- `EncryptionService` in `src/common/services/` (AES-256-GCM)
- **Encrypted fields in Employee model:**
- `salary` - Stored as encrypted String (not Decimal!)
- `bankAccount` - Stored as encrypted JSON string
- Access via `findOne(id, includeSensitive=true)` for decryption
- Config: `ENCRYPTION_KEY` env variable (required in production)
## Auth Pattern
- Routes protected by default via global JwtAuthGuard
- `@Public()` for open endpoints
- `@Roles('admin', 'hr-manager')` for role-based access
- `@RequirePermissions(Permission.USERS_CREATE)` for fine-grained
- `@CurrentUser()` to get JWT payload
## Available Roles
admin, hr-manager, team-lead, employee
## Module Exports
All modules export via `index.ts` barrel files:
- `/modules/index.ts` exports: audit, dashboard, departments, user-preferences, integrations, lean, hr
- `/modules/lean/index.ts` exports: s3-planning, skill-matrix, morning-meeting
- `/modules/hr/index.ts` exports: employees, absences, time-tracking
## Health Endpoints
- Located at `src/health/` (NOT `src/modules/health/`)
- `GET /health` - Full check (database, memory, modules status)
- `GET /health/liveness` - Kubernetes liveness
- `GET /health/readiness` - Database connectivity
- `ModulesHealthIndicator` reports core/hr/lean/integrations status
## Test Infrastructure
- **Web (apps/web)**: Vitest 2.x + @testing-library/react + jsdom
- Config: `apps/web/vitest.config.ts`
- Setup: `apps/web/src/test/setup.ts` (imports @testing-library/jest-dom/vitest)
- Scripts: `test` (vitest run), `test:watch` (vitest)
- **API (apps/api)**: Jest 29.x + ts-jest + @nestjs/testing
- Config: inline in `package.json` under `jest` key
- rootDir: `src`, testRegex: `.*\\.spec\\.ts$`
- Module alias: `@/` -> `<rootDir>/`
## Phase 3: Integrations
Location: `src/modules/integrations/`
Sub-modules: credentials/, sync/, status/, jobs/
Types: PLENTYONE, ZULIP, TODOIST, FREESCOUT, NEXTCLOUD, ECODMS, GEMBADOCS
## Phase 4: LEAN
Location: `src/modules/lean/`
- `s3-planning/` - 3S/5S audit planning (permissions: S3_VIEW/CREATE/UPDATE/DELETE/MANAGE)
- `skill-matrix/` - Skills and employee skill entries
- `morning-meeting/` - SQCDM meetings, topics, actions (permissions: MEETING_VIEW/CREATE/UPDATE/DELETE)
## Phase 5: HR
Location: `src/modules/hr/`
- `employees/` - CRUD, org chart, **encrypted salary + bankAccount**
- `absences/` - Approval workflow (PENDING->APPROVED/REJECTED/CANCELLED)
- `time-tracking/` - Clock in/out, German ArbZG break compliance
### Absences Workflow
- Auto-approved: SICK, SICK_CHILD
- Vacation balance: 30 days/year, pro-rata by entry date
### Time Tracking
- German labor law breaks: >6h=30min, >9h=45min
- Monthly summary with overtime calculation
## Scripts (from root)
```bash
pnpm run dev:api # Development server
pnpm run db:migrate # Run migrations
pnpm run db:generate # Generate Prisma client
pnpm run db:seed # Seed default data
```
## Common Patterns
- Use `CommonModule` (@Global) for shared services like EncryptionService
- DTOs with class-validator for input validation
- Swagger decorators for API documentation
- `@AuditLog('Entity', 'ACTION')` for audit trail
See detailed docs in `agent-memory/backend-specialist/` for:
- [integrations.md](./integrations.md) - Integration details
- [hr-module.md](./hr-module.md) - HR module specifics
- [testing.md](./testing.md) - Test infrastructure details

View File

@@ -0,0 +1,65 @@
# tOS Test Infrastructure
## Frontend (apps/web) - Vitest
### Configuration
- `vitest.config.ts` at project root of apps/web
- Uses `@vitejs/plugin-react` for JSX transform
- jsdom environment for DOM testing
- `globals: true` so `describe/it/expect` are global
- Path alias `@` -> `./src` matching tsconfig
- CSS disabled in tests (`css: false`)
- Setup file: `src/test/setup.ts` imports `@testing-library/jest-dom/vitest`
### Dependencies (devDependencies)
- vitest ^2.1.8
- @testing-library/react ^16.1.0
- @testing-library/jest-dom ^6.6.3
- @testing-library/user-event ^14.5.2
- @vitejs/plugin-react ^4.3.4
- jsdom ^25.0.1
### Test Files
- `src/lib/utils.test.ts` - Tests for cn(), getInitials(), capitalize(), truncate(), safeJsonParse(), generateId(), isServer(), isClient()
- `src/components/ui/badge.test.tsx` - Badge component rendering with all variants
- `src/hooks/use-toast.test.ts` - Tests reducer logic and toast() function
### Run Commands
```bash
pnpm --filter web test # vitest run
pnpm --filter web test:watch # vitest (watch mode)
```
## Backend (apps/api) - Jest
### Configuration
- Inline in `package.json` under `"jest"` key
- ts-jest transform for TypeScript
- Node test environment
- Module alias: `@/` -> `<rootDir>/` (rootDir = src)
- Test regex: `.*\.spec\.ts$`
### Dependencies (already present)
- jest ^29.7.0
- ts-jest ^29.1.1
- @nestjs/testing ^10.3.0
- @types/jest ^29.5.11
- supertest ^6.3.3
### Test Files
- `src/health/health.controller.spec.ts` - Health controller (check, liveness, readiness)
- `src/common/services/encryption.service.spec.ts` - EncryptionService (encrypt/decrypt, objects, empty strings, generateKey, init)
### Test Patterns for NestJS
- Use `Test.createTestingModule()` for DI setup
- Mock all dependencies with `{ provide: X, useValue: mockX }`
- Call `module.get<T>(T)` to get the instance under test
- For services with `onModuleInit()`, call it manually in `beforeEach`
- Use `jest.clearAllMocks()` in `afterEach`
### Run Commands
```bash
pnpm --filter api test # jest
pnpm --filter api test:watch # jest --watch
pnpm --filter api test:cov # jest --coverage
```

View File

@@ -0,0 +1,52 @@
# tOS Infrastructure Memory
## Docker Stack
- **Location**: `/home/mehmed/Entwicklung/githubProjekte/tOS/docker/`
- **Compose file**: `docker-compose.yml` (name: tos)
- **Services**: PostgreSQL 16, Redis 7, Keycloak 24.0
- **Network**: `tos-network` (bridge)
- **Volumes**: `tos-postgres-data`, `tos-redis-data`
## Ports (Default)
| Service | Port |
|------------|------|
| PostgreSQL | 5432 |
| Redis | 6379 |
| Keycloak | 8080 |
| API | 3001 |
| Frontend | 3000 |
## Keycloak Configuration
- **Realm**: `tOS`
- **Clients**: `tos-frontend` (public), `tos-backend` (confidential)
- **Roles Hierarchy**:
- admin -> hr-manager, manager, department_head, team-lead, employee
- hr-manager -> employee
- manager -> department_head, employee
- department_head -> team-lead, employee
- team-lead -> employee
- **Test Users**: admin, manager, depthead, employee, hrmanager, teamlead
- **Default passwords**: `<username>123` (temporary)
## Environment Variables
- **Root `.env.example`**: Application config (NextAuth, Keycloak, API keys)
- **Docker `.env.example`**: Container config (ports, credentials)
- **Critical Production Secrets**:
- `ENCRYPTION_KEY` - 32 bytes for credential encryption
- `JWT_SECRET` - API token signing
- `NEXTAUTH_SECRET` - Session encryption
- `KEYCLOAK_BACKEND_CLIENT_SECRET`
## Package Scripts
```bash
pnpm docker:up # Start infrastructure
pnpm docker:down # Stop infrastructure
pnpm docker:logs # View logs
pnpm docker:reset # Destroy volumes and restart
pnpm dev # Start dev servers
```
## Known Issues / Lessons Learned
- Keycloak 24+ (UBI9) has no curl; use bash TCP redirect for health checks
- Realm import: file must be at `/opt/keycloak/data/import/` with `--import-realm` flag
- Health check `start_period` should be 90s+ for Keycloak (Java startup)

View File

@@ -0,0 +1,325 @@
# tOS Frontend - Agent Memory
## Project Overview
- **Type**: Enterprise Web Dashboard (Next.js 14+ App Router)
- **Location**: `/home/mehmed/Entwicklung/githubProjekte/tOS/apps/web/`
- **Language**: Code/comments in English, UI in German (default) via i18n
## Tech Stack
- Next.js 14+ with App Router
- TypeScript (strict mode)
- Tailwind CSS + shadcn/ui (new-york style)
- next-themes for dark/light mode
- next-intl for i18n (de default, en supported)
- NextAuth with Keycloak provider
- Framer Motion for animations
- Zustand for client state (sidebar-store, dashboard-store)
- TanStack Query for server state
- TanStack Table for data tables
- Recharts for chart components
- dnd-kit for drag and drop
- date-fns for date formatting
- Lucide React for icons
## File Structure
```
apps/web/src/
app/[locale]/ # Locale-based routing
(auth)/ # Protected routes with sidebar layout
dashboard/ # Widget-based dashboard
settings/ # profile/, preferences/, security/, notifications/
admin/ # users/, departments/, integrations/ - role protected
lean/ # 3s-planning/, morning-meeting/, skill-matrix/
hr/ # employees/, time-tracking/, absences/
integrations/ # Overview + [type]/ dynamic routes
login/ # Public login page
components/
ui/ # shadcn/ui + DataTable, Badge, Tabs, Switch, etc.
layout/ # Sidebar (collapsible sub-nav), Header
dashboard/ # Widget system (container, grid, registry)
widgets/ # clock, welcome, quick-actions, stats, calendar, activity
integrations/ # orders, chat, tasks, tickets, files, documents
integrations/ # Shared: status-badge, integration-card, connection-test-button
charts/ # bar-chart, line-chart, pie-chart, chart-container
providers/ # SessionProvider, ThemeProvider, QueryProvider
hooks/ # use-toast, use-media-query, use-mounted
integrations/ # use-orders, use-messages, use-tasks, etc.
lib/ # utils, api, auth, motion variants
stores/ # sidebar-store, dashboard-store
types/ # User, UserRole, Department, WidgetConfig, integrations.ts
```
## Key Patterns
### 1. Server/Client Component Split
- `page.tsx` = Server Component (metadata only)
- `*-content.tsx` = Client Component (receives locale prop)
### 2. Widget System (Phase 2)
- Registry pattern: `widget-registry.ts` defines all widget types
- Drag & drop: dnd-kit with SortableContext
- Persistence: Zustand store with localStorage
- Each widget: WidgetContainer wrapper + specific content
### 3. Role-Based Navigation
- NavItem has optional `requiredRoles: UserRole[]`
- Sidebar filters items via `filterNavItems(items, userRoles)`
- Roles: admin, manager, department_head, employee
### 4. DataTable Pattern
- Generic component: `DataTable<TData, TValue>`
- Use `DataTableColumnHeader` for sortable columns
- Features: search, pagination, column visibility, row selection
### 5. Chart Components
- Wrap Recharts in `ChartContainer` for consistent styling
- Support loading states, empty states
- Theme-aware colors via CSS variables
### 6. Integration Hooks (Phase 3)
- TanStack Query with 30s staleTime
- Mock data for development, TODO comments for API replacement
- Query key factories: `ordersKeys`, `messagesKeys`, etc.
- Optimistic updates for complete/toggle actions
### 7. Integration Widgets
- Widget categories: `integrations` added to registry
- requiredRoles: ['manager', 'admin'] for sensitive widgets
- Each widget uses WidgetContainer + specific hook
## Sidebar Behavior
- Expanded: 240px, Collapsed: 64px
- Collapsible sub-navigation with ChevronDown animation
- Tooltip shows sub-nav when collapsed
## i18n Keys
- Flat structure with nested objects: `widgets.clock.name`
- Use ASCII for German (oe, ae, ue) to avoid encoding issues
## Dependencies Added (Phase 2)
- @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities
- @tanstack/react-table
- @radix-ui/react-checkbox, collapsible, popover, select, tabs
- recharts, date-fns
## Dependencies Added (Phase 3)
- @radix-ui/react-switch (for Switch component)
## Integration Types (Phase 3+)
- `plenty-one`: PlentyONE (e-commerce) - OrdersWidget
- `zulip`: ZULIP (chat) - ChatWidget
- `todoist`: Todoist (tasks) - TasksWidget
- `freescout`: FreeScout (helpdesk) - TicketsWidget
- `nextcloud`: Nextcloud (files) - FilesWidget
- `ecodms`: ecoDMS (documents) - DocumentsWidget
- `gembadocs`: GembaDocs (audits/compliance) - GembaDocsWidget
## LEAN Module Structure
### File Organization
```
apps/web/src/
app/[locale]/(auth)/lean/
page.tsx # LEAN overview
s3-planning/
page.tsx # S3 plans overview
[departmentId]/page.tsx # Department detail view
morning-meeting/
page.tsx # Meetings overview + calendar
morning-meeting-overview-content.tsx
[departmentId]/
page.tsx # Department SQCDM board
department-meeting-content.tsx
skill-matrix/
page.tsx # Overview with department cards
skill-matrix-overview-content.tsx
[departmentId]/
page.tsx # Department matrix grid + gap analysis
department-skill-matrix-content.tsx
components/lean/
s3/ # S3 Planning components
index.ts # Barrel export
s3-status-cell.tsx # Colored week cell
s3-status-modal.tsx # Status edit dialog
s3-category-card.tsx # Category with week grid
s3-progress-chart.tsx # Pie chart stats
s3-plan-overview.tsx # Plans list with filters
s3-department-view.tsx # Week calendar view
morning-meeting/ # Morning Meeting components
index.ts # Barrel export
meeting-board.tsx # Main board with all 5 SQCDM columns
sqcdm-column.tsx # Single S/Q/C/D/M column
kpi-card.tsx # KPI with traffic light + trend
meeting-timer.tsx # Timer with start/stop, countdown mode
action-list.tsx # Action items container with filters
action-item.tsx # Single action with status/assignee
hooks/lean/
index.ts # Barrel export
use-s3-plans.ts # Plans CRUD + types
use-s3-status.ts # Status updates
use-meetings.ts # Meeting CRUD + topics/actions
use-meeting-timer.ts # Timer logic (elapsed/countdown)
```
### S3 Status Types & Colors
- `NOT_APPLICABLE` (gray): Not started
- `YELLOW`: In progress
- `GREEN`: Completed
- `RED`: Problem/Issue
### S3 Type Categories
- `SEIRI`: Sort
- `SEITON`: Set in Order
- `SEISO`: Shine
### API Endpoints (Backend)
- `GET /lean/s3/plans` - List with filters (year, month, departmentId)
- `GET /lean/s3/plans/:id` - Single plan with categories/statuses
- `PUT /lean/s3/status/:id` - Update status entry
### Skill Matrix Module
```
components/lean/skill-matrix/
index.ts # Barrel export
skill-level-badge.tsx # Level badge with color coding (0-4)
skill-cell.tsx # Matrix cell with quick-edit popover
skill-matrix-grid.tsx # Full grid (employees x skills)
skill-gap-chart.tsx # Bar chart for gap analysis
hooks/lean/
use-skills.ts # Skills CRUD + categories
use-skill-matrix.ts # Matrix data + gap analysis
```
### Skill Level Colors
- `0` (gray): No knowledge
- `1` (red): Basics
- `2` (yellow): Independent
- `3` (green): Expert
- `4` (blue): Can train
### Skill Matrix API Endpoints (Backend)
- `GET /lean/skills` - List with filters
- `GET /lean/skills/department/:id` - Skills for department
- `GET /lean/skill-matrix/:departmentId` - Full matrix
- `GET /lean/skill-matrix/gaps/:departmentId` - Gap analysis
- `POST /lean/skill-matrix/entries` - Create entry
- `PUT /lean/skill-matrix/entries/:id` - Update entry
- `POST /lean/skill-matrix/entries/bulk` - Bulk upsert
## HR Module (Phase 5)
### File Structure
```
apps/web/src/
app/[locale]/(auth)/hr/
page.tsx # HR overview with stats
hr-overview-content.tsx # Client component
employees/
page.tsx # Employee list
employees-content.tsx
[id]/
page.tsx # Employee details
employee-detail-content.tsx
new/
page.tsx # New employee form
new-employee-content.tsx
org-chart/
page.tsx # Organization chart
org-chart-content.tsx
components/hr/employees/
index.ts # Barrel export
employee-card.tsx # Quick overview card
employee-list.tsx # DataTable with filters
employee-form.tsx # Create/edit form
org-chart.tsx # Hierarchical tree view
hooks/hr/
index.ts # Barrel export
use-employees.ts # CRUD + types + mock data
```
### Employee Types
- `EmploymentStatus`: active, inactive, on_leave, terminated
- `ContractType`: full_time, part_time, mini_job, intern, trainee, freelance
- Full Employee interface with address, emergency contact
### Employee Status Colors
- `active` (green)
- `inactive` (gray)
- `on_leave` (yellow)
- `terminated` (red)
### i18n Keys (hr namespace)
- `hr.title`, `hr.description`
- `hr.employees.*` - List/detail pages
- `hr.stats.*` - Dashboard statistics
- `hr.employeeStatus.*` - Status translations
- `hr.contractType.*` - Contract type translations
- `hr.form.*` - Form sections
- `hr.tabs.*` - Detail view tabs
- `hr.toast.*` - Toast notifications
### Dependencies Added (Phase 5)
- react-day-picker (for Calendar component)
- react-hook-form + zod (already present)
- @radix-ui/react-progress (for Progress component)
## HR Time Tracking Module (Phase 5)
### File Structure
```
apps/web/src/
app/[locale]/(auth)/hr/
time-tracking/
page.tsx # Time tracking overview
time-tracking-content.tsx # Time clock + entries
[employeeId]/
page.tsx # Employee time account
employee-time-account-content.tsx
absences/
page.tsx # Absences overview
absences-content.tsx # Balance + requests
calendar/
page.tsx # Team calendar view
absence-calendar-content.tsx
requests/
page.tsx # Manager approval view
absence-requests-content.tsx
components/hr/
time-tracking/
index.ts # Barrel export
time-clock.tsx # Web clock (clock in/out, breaks)
time-entry-list.tsx # List of time entries
time-entry-form.tsx # Correction request dialog
time-summary.tsx # Monthly summary with progress
absences/
index.ts # Barrel export
absence-calendar.tsx # Monthly calendar with absences
absence-request-form.tsx # Create absence request dialog
absence-card.tsx # Single absence with status
absence-approval-list.tsx # Pending requests for managers
vacation-balance.tsx # Vacation quota display
hooks/hr/
use-time-tracking.ts # Clock in/out, entries, summary
use-absences.ts # Requests, balance, calendar
types/hr.ts # TimeEntry, Absence, VacationBalance types
```
### Time Tracking Types
- `TimeEntryStatus`: CLOCKED_IN, ON_BREAK, CLOCKED_OUT
- `TimeEntryType`: REGULAR, OVERTIME, CORRECTED
- `TIME_STATUS_INFO`: Color mapping for status badges
### Absence Types
- `AbsenceType`: VACATION, SICK, HOME_OFFICE, SPECIAL_LEAVE, UNPAID_LEAVE, TRAINING
- `AbsenceRequestStatus`: PENDING, APPROVED, REJECTED, CANCELLED
- `ABSENCE_TYPE_INFO`: Color + icon mapping
- `ABSENCE_STATUS_INFO`: Status color mapping
### i18n Keys (hr namespace)
- `hr.timeTracking.*` - Clock, entries, summary
- `hr.absences.*` - Balance, requests, calendar, approvals
## Related Files
- [shared-package.md](./shared-package.md) - @tos/shared integration guide & differences
- [component-patterns.md](./component-patterns.md) - Code examples

View File

@@ -0,0 +1,204 @@
# Component Patterns - tOS Frontend
## Layout Component Pattern
```tsx
// src/components/layout/sidebar.tsx
'use client';
import { usePathname } from 'next/navigation';
import { motion } from 'framer-motion';
import { useSidebarStore } from '@/stores/sidebar-store';
export function Sidebar({ locale }: { locale: string }) {
const { isExpanded, toggleSidebar } = useSidebarStore();
const pathname = usePathname();
return (
<motion.aside
initial={false}
animate={{ width: isExpanded ? 240 : 64 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
className="fixed left-0 top-0 z-40 h-screen border-r bg-sidebar"
>
{/* Content */}
</motion.aside>
);
}
```
## Page with Metadata Pattern
```tsx
// page.tsx (Server Component)
import { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { ContentComponent } from './content-component';
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('namespace');
return { title: t('title') };
}
export default function Page() {
return <ContentComponent />;
}
// content-component.tsx (Client Component)
'use client';
import { useTranslations } from 'next-intl';
export function ContentComponent() {
const t = useTranslations('namespace');
return <div>{t('key')}</div>;
}
```
## Zustand Store Pattern
```tsx
// src/stores/sidebar-store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface SidebarState {
isExpanded: boolean;
toggleSidebar: () => void;
}
export const useSidebarStore = create<SidebarState>()(
persist(
(set) => ({
isExpanded: true,
toggleSidebar: () => set((state) => ({ isExpanded: !state.isExpanded })),
}),
{
name: 'tos-sidebar-state',
partialize: (state) => ({ isExpanded: state.isExpanded }),
}
)
);
```
## Animation Pattern with Framer Motion
```tsx
import { motion } from 'framer-motion';
// Staggered list animation
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ staggerChildren: 0.1 }}
>
{items.map((item, index) => (
<motion.div
key={item.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1, duration: 0.3, ease: 'easeOut' }}
>
{item.content}
</motion.div>
))}
</motion.div>
```
## shadcn/ui Component with Variants
```tsx
// src/components/ui/button.tsx
import { cva, type VariantProps } from 'class-variance-authority';
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground',
outline: 'border border-input bg-background hover:bg-accent',
ghost: 'hover:bg-accent hover:text-accent-foreground',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 px-3 text-xs',
lg: 'h-10 px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
```
## Provider Composition Pattern
```tsx
// src/components/providers/index.tsx
'use client';
export function Providers({ children }: { children: ReactNode }) {
return (
<SessionProvider>
<QueryProvider>
<ThemeProvider>
{children}
</ThemeProvider>
</QueryProvider>
</SessionProvider>
);
}
```
## i18n Usage Pattern
```tsx
// In components
import { useTranslations } from 'next-intl';
export function Component() {
const t = useTranslations('namespace');
return <span>{t('key', { param: 'value' })}</span>;
}
// In server components
import { getTranslations } from 'next-intl/server';
export async function ServerComponent() {
const t = await getTranslations('namespace');
return <span>{t('key')}</span>;
}
```
## Protected Route Layout Pattern
```tsx
// src/app/[locale]/(auth)/layout.tsx
'use client';
export default function AuthLayout({ children, params: { locale } }) {
const { isExpanded } = useSidebarStore();
return (
<div className="min-h-screen bg-background">
<div className="hidden lg:block">
<Sidebar locale={locale} />
</div>
<MobileSidebar locale={locale} />
<div className={cn(
'flex min-h-screen flex-col transition-[margin-left] duration-200',
'ml-0',
isExpanded ? 'lg:ml-[240px]' : 'lg:ml-[64px]'
)}>
<Header locale={locale} />
<main className="flex-1 p-4 md:p-6 lg:p-8">
<PageTransition>{children}</PageTransition>
</main>
</div>
</div>
);
}
```

View File

@@ -0,0 +1,69 @@
# @tos/shared Package Integration
## Package Location
`packages/shared/` - Built with tsup, outputs CJS + ESM + types to `dist/`
## Available Exports
### Utils (`@tos/shared` or `@tos/shared/utils`)
- `formatDate(date, options?)` - German locale, 2-digit format (dd.MM.yyyy)
- `formatTime(date, options?)` - German locale, HH:mm
- `formatDateTime(date)` - Combined date + time
- `calculateWorkingDays(start, end)` - Excludes weekends
- `minutesToHoursAndMinutes(mins)` - Returns { hours, minutes }
- `formatMinutesToTime(mins)` - Returns "HH:MM" string
- `getInitials(firstName, lastName)` - Two-arg version, returns "FL"
- `getFullName(firstName, lastName)` - Returns "First Last"
- `slugify(str)` - URL-safe slug
- `isDefined<T>(value)` - Type guard for non-null/undefined
- `sleep(ms)` - Promise-based delay
### Constants (`@tos/shared`)
- `DEFAULT_VACATION_DAYS`, `MIN_VACATION_DAYS`
- `STANDARD_WORKING_HOURS_PER_WEEK/DAY`, `MAX_WORKING_HOURS_PER_DAY`
- `DEFAULT_BREAK_MINUTES`, `REQUIRED_BREAK_MINUTES`, `EXTENDED_BREAK_MINUTES`
- `SKILL_LEVELS`, `S3_CATEGORIES`, `SQCDM_CATEGORIES`
- `HTTP_STATUS`, `DATE_FORMATS`
- `API_VERSION`, `API_PREFIX`, `DEFAULT_PAGE_SIZE`, `MAX_PAGE_SIZE`
- `PERMISSIONS`, `ROLE_PERMISSIONS`
### Types (`@tos/shared` or `@tos/shared/types`)
- `User`, `UserRole`, `UserPreferences`, `DashboardWidgetConfig`, `NotificationPreferences`
- `CreateUserDto`, `UpdateUserDto`
- `Department`, `DepartmentCode`, `DepartmentWithStats`, `CreateDepartmentDto`, `UpdateDepartmentDto`
- `Employee`, `ContractType`, `TimeEntry`, `TimeEntryType`, `Absence`, `AbsenceType`, `ApprovalStatus`
- `AuthUser`, `TokenPayload`, `LoginRequest`, `AuthResponse`, `Permission`
- Skill Matrix: `Skill`, `SkillMatrixEntry`, `SkillMatrix`, `SkillGapAnalysis`, etc.
## Integration Patterns
### Web App (apps/web)
- `transpilePackages: ['@tos/shared']` in next.config.mjs (transpiles source directly)
- `"@tos/shared": "workspace:*"` in package.json
- Import directly: `import { getInitials } from '@tos/shared'`
### API App (apps/api)
- `"@tos/shared": "workspace:*"` in package.json
- Uses built dist (CJS) - requires `pnpm --filter @tos/shared build` first
- Import: `import { calculateWorkingDays, DEFAULT_VACATION_DAYS } from '@tos/shared'`
## Key Differences: Local vs Shared
### getInitials
- **Shared**: `getInitials(firstName: string, lastName: string)` - two args
- **Web local** (`@/lib/utils`): `getInitials(name: string)` - single full name string, splits on space
- Both coexist; use shared in components with firstName/lastName, local in header with full name
### Date Formatting
- **Shared**: `formatDate()` uses `{ day: '2-digit', month: '2-digit', year: 'numeric' }`, hardcoded `de-DE`
- **Web local**: `formatDate()` uses `{ year: 'numeric', month: 'long', day: 'numeric' }`, accepts locale param
- **Web local**: `formatDateShort()` is equivalent to shared `formatDate()` but accepts locale param
- NOT interchangeable - different output format
### sleep
- Identical in both - web local now re-exports from `@tos/shared`
### ApprovalStatus / ContractType
- **Shared**: UPPERCASE (`'PENDING'`, `'FULL_TIME'`)
- **Web types**: lowercase (`'pending'`, `'full_time'`)
- NOT interchangeable - different casing convention

View File

@@ -0,0 +1,169 @@
# Integration Specialist Memory - tOS Project
## Project Structure
- **API Location**: `/apps/api/src/`
- **Integrations Module**: `/apps/api/src/modules/integrations/`
- **Connectors**: `/apps/api/src/modules/integrations/connectors/`
## Phase 3 Connector Implementation (Completed)
### Base Infrastructure
- `base-connector.ts`: Abstract base class with axios, retry logic, rate limiting
- `errors/integration.errors.ts`: Custom error classes (Auth, Connection, RateLimit, API, Config, Validation)
### Implemented Connectors
#### PlentyONE (`/connectors/plentyone/`)
- **Auth**: OAuth2 Client Credentials flow
- **Token Management**: Auto-refresh with 5-minute buffer
- **Endpoints**: Orders, Stock, Statistics/Revenue
- **Rate Limiting**: 60s timeout (PlentyONE can be slow)
- **API Base**: `{baseUrl}/rest`
#### ZULIP (`/connectors/zulip/`)
- **Auth**: Basic Auth (email + API key)
- **Endpoints**: Messages, Streams, Users
- **API Base**: `{baseUrl}/api/v1`
- **Note**: Form-encoded POST requests for some endpoints
#### Todoist (`/connectors/todoist/`)
- **Auth**: Bearer Token
- **Endpoints**: Tasks, Projects, Sections, Labels
- **API Base**: `https://api.todoist.com/rest/v2`
- **Note**: Uses X-Request-Id header for idempotency
#### FreeScout (`/connectors/freescout/`)
- **Auth**: API Key via `X-FreeScout-API-Key` header
- **Endpoints**: Conversations, Mailboxes, Customers, Tags
- **API Base**: `{baseUrl}/api`
- **API Docs**: https://github.com/freescout-helpdesk/freescout/wiki/API
#### Nextcloud (`/connectors/nextcloud/`)
- **Auth**: Basic Auth (username + app-password)
- **Endpoints**: WebDAV Files, OCS Share API, CalDAV Calendar (basic)
- **API Bases**: `{baseUrl}/remote.php/dav/files/{user}` (WebDAV), `{baseUrl}/ocs/v2.php` (OCS)
- **Note**: WebDAV uses PROPFIND/MKCOL/MOVE/COPY; OCS needs `OCS-APIRequest: true` header
#### ecoDMS (`/connectors/ecodms/`)
- **Auth**: Session-based (login returns session ID, use `X-Session-Id` header)
- **Endpoints**: Documents, Folders, Classifications, Search
- **API Base**: `{baseUrl}/api/v1`
- **Note**: Session auto-refresh before expiry; supports file upload with FormData
## Environment Variables
```bash
# PlentyONE
PLENTYONE_BASE_URL=
PLENTYONE_CLIENT_ID=
PLENTYONE_CLIENT_SECRET=
# ZULIP
ZULIP_BASE_URL=
ZULIP_EMAIL=
ZULIP_API_KEY=
# Todoist
TODOIST_API_TOKEN=
# FreeScout
FREESCOUT_API_URL=
FREESCOUT_API_KEY=
# Nextcloud
NEXTCLOUD_URL=
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
# ecoDMS
ECODMS_API_URL=
ECODMS_USERNAME=
ECODMS_PASSWORD=
ECODMS_API_VERSION=v1
```
## Key Patterns
### Retry Logic
- Exponential backoff with jitter (0.5-1.5x multiplier)
- Default: 3 retries, 1s initial delay, 30s max delay
- Retry on: 429, 500, 502, 503, 504
### Error Handling
- `IntegrationConnectionError`: Network issues (retryable)
- `IntegrationAuthError`: 401 responses (not retryable)
- `IntegrationRateLimitError`: 429 responses (retryable after delay)
- `IntegrationApiError`: Other API errors (retryable if 5xx)
### Module Structure
Each connector follows pattern:
1. `{name}.types.ts` - TypeScript interfaces
2. `{name}.connector.ts` - Low-level API client
3. `{name}.service.ts` - Business logic layer
4. `{name}.controller.ts` - HTTP endpoints
5. `{name}.module.ts` - NestJS module
6. `dto/*.dto.ts` - Request/Response DTOs
## API Endpoints
```
GET /integrations - Overview all integrations
GET /integrations/:type/status - Integration status
GET /integrations/:type/health - Health check
POST /integrations/plentyone/test
GET /integrations/plentyone/orders
GET /integrations/plentyone/orders/:id
GET /integrations/plentyone/stock
GET /integrations/plentyone/stats
POST /integrations/zulip/test
GET /integrations/zulip/messages
POST /integrations/zulip/messages
GET /integrations/zulip/streams
GET /integrations/zulip/users
POST /integrations/todoist/test
GET /integrations/todoist/tasks
POST /integrations/todoist/tasks
PUT /integrations/todoist/tasks/:id
DELETE /integrations/todoist/tasks/:id
GET /integrations/todoist/projects
# FreeScout
POST /integrations/freescout/test
GET /integrations/freescout/conversations
GET /integrations/freescout/conversations/:id
POST /integrations/freescout/conversations
POST /integrations/freescout/conversations/:id/reply
GET /integrations/freescout/mailboxes
GET /integrations/freescout/customers
GET /integrations/freescout/tags
# Nextcloud
POST /integrations/nextcloud/test
GET /integrations/nextcloud/files
GET /integrations/nextcloud/files/*path
POST /integrations/nextcloud/files/upload
DELETE /integrations/nextcloud/files/*path
GET /integrations/nextcloud/calendar/events
# ecoDMS
POST /integrations/ecodms/test
GET /integrations/ecodms/documents
GET /integrations/ecodms/documents/:id
POST /integrations/ecodms/documents/search
GET /integrations/ecodms/documents/:id/download
GET /integrations/ecodms/folders
GET /integrations/ecodms/classifications
```
## Dependencies
- `axios`: ^1.6.0 (HTTP client)
- axios types included in axios package
## Notes
- All connectors check `isConfigured()` before operations
- `getMissingConfig()` returns list of missing env vars
- Logging in development mode by default
- All API responses transformed to DTOs

View File

@@ -0,0 +1,123 @@
# tOS Project Code Review Memory
## Project Overview
- **Type:** Enterprise Web Operating System with HR, LEAN modules
- **Stack:** Next.js 14 + NestJS 10 + Prisma + PostgreSQL + Keycloak
- **Structure:** pnpm monorepo with turbo (apps/web, apps/api, packages/shared)
## Code Standards (from PLAN.md)
| Area | Language | Convention |
|------|----------|------------|
| Code & Comments | English | Required |
| UI Text | German (via i18n) | next-intl |
| Variables | camelCase | `employeeId` |
| Components | PascalCase | `DashboardWidget` |
| Files | kebab-case | `user-service.ts` |
| Constants | UPPER_SNAKE_CASE | `MAX_VACATION_DAYS` |
## Critical Patterns Found
### Authentication
- Global `JwtAuthGuard` with `@Public()` opt-out decorator
- Global `RolesGuard` with `@Roles()` decorator
- Keycloak integration via NextAuth (frontend) and passport-jwt (backend)
- **IMPORTANT:** Always verify JWT signatures, never just decode
- Guards registered in correct order: JWT -> Roles -> Permissions (app.module.ts L74-88)
- JWT strategy validates user exists and is active on every request
### Frontend Architecture
- **Server/Client split:** Server page.tsx -> Client *-content.tsx
- **State:** Zustand (sidebar, dashboard), TanStack Query (server state)
- **i18n:** next-intl with de.json/en.json, default locale = "de"
- **Dashboard:** dnd-kit widget grid, widget registry pattern
- **Styling:** Tailwind + shadcn/ui, HSL CSS vars, dark mode via class
### Integration Architecture (Phase 3 - Disabled)
- **Location:** `apps/api/integrations.backup/`
- Only PlentyONE/Zulip/Todoist extend BaseConnector
- FreeScout/Nextcloud/ecoDMS/GembaDocs have independent implementations
### Keycloak Client ID Mismatch (CRITICAL - RECURRING)
- Realm: `tos-frontend` / `tos-backend`
- apps/api/.env: `tos-api` | apps/web/.env: `tos-nextauth` (MISMATCH)
## Key Issues to Watch
1. **params API:** Mixed sync/async patterns across pages
2. **Hardcoded German:** dashboard-content.tsx, widget-grid.tsx bypass i18n
3. **Mock Data:** HR hooks (employees, time-tracking, absences) use mock data
4. **Type Conflicts:** types/index.ts vs types/hr.ts have conflicting exports
5. **Auth Layout:** `(auth)/layout.tsx` is `'use client'` -- blocks SSR
6. **Error pages:** Link to `/dashboard` without locale prefix
7. **ENCRYPTION_KEY:** `optional()` in validation -- must be required
### Backend-Specific Issues (from Phase 7 Review)
8. **Morning Meeting Permissions:** ALL endpoints use DASHBOARD_VIEW -- any user can CRUD meetings
9. **Private method access:** time-tracking.controller.ts L237-239 uses bracket notation `this.service['privateMethod']`
10. **Prisma cleanDatabase():** Uses Promise.all ignoring FK constraints (prisma.service.ts L48-61)
11. **Roles guard leaks info:** Error messages reveal required role names (roles.guard.ts L31-33)
12. **enableImplicitConversion:** In ValidationPipe (main.ts L44) -- can cause type coercion bugs
13. **Break tracking via string parsing:** time-tracking uses note field for break detection (L216-219, L257-258)
14. **Audit interceptor:** oldData always undefined (audit.interceptor.ts L60), salary not sanitized (L131)
15. **Unregistered interceptors:** LoggingInterceptor and TimeoutInterceptor defined but never registered
16. **N+1 queries:** skill-entries.service.ts analyzeGaps (L515-523), s3-planning findAll (L173-178)
17. **bulkUpsert not transactional:** skill-entries.service.ts L429-476 -- partial failures possible
18. **No backdating limit:** Manual time entries have no restriction on how far back entries can be created
19. **Holiday calculation missing:** absences calculateWorkingDays does not account for public holidays
20. **Vacation carry-over TODO:** BUrlG compliance gap (absences.service.ts L979)
## File Locations
| Purpose | Path |
|---------|------|
| Prisma Schema | `apps/api/prisma/schema.prisma` |
| Frontend Types | `apps/web/src/types/` |
| HR Hooks | `apps/web/src/hooks/hr/` |
| LEAN Hooks | `apps/web/src/hooks/lean/` |
| Dashboard | `apps/web/src/components/dashboard/` |
| i18n Messages | `apps/web/messages/` |
| Integration Backup | `apps/api/integrations.backup/` |
| Auth Guards | `apps/api/src/auth/guards/` |
| Auth Permissions | `apps/api/src/auth/permissions/` |
| Encryption Service | `apps/api/src/common/services/encryption.service.ts` |
| HR Employees | `apps/api/src/modules/hr/employees/` |
| HR Time Tracking | `apps/api/src/modules/hr/time-tracking/` |
| HR Absences | `apps/api/src/modules/hr/absences/` |
| LEAN Skill Matrix | `apps/api/src/modules/lean/skill-matrix/` |
| LEAN Morning Meeting | `apps/api/src/modules/lean/morning-meeting/` |
| LEAN S3 Planning | `apps/api/src/modules/lean/s3-planning/` |
| Audit Module | `apps/api/src/modules/audit/` |
| Config Validation | `apps/api/src/config/config.validation.ts` |
## Review History
### Phase 7 Full Backend Review (2026-02-06)
- **Overall: 7.8/10** | 3 Critical, 14 Important issues
- Scores: Auth 8 | Prisma 7 | HR/Emp 8 | HR/Time 7 | HR/Abs 8
- LEAN/Skill 8 | LEAN/Morning 7 | LEAN/S3 8 | Dashboard 8
- Common 8 | Users 8 | Audit 7 | app.module 9
- See: `phase7-backend-review.md`
### Phase 6 Frontend Review (2026-02-06)
- 5 Critical, 9 Important, detailed 10-area review
- See: `phase6-frontend-review.md`
### Infrastructure + Integration Review (2026-02-06)
- Docker 7/10 | Keycloak 7/10 | Env 4/10 | Integration 6/10
- See: `infra-integration-review.md`
### Phase 5 Review (2026-02-05)
- HR modules: Employees, Time Tracking, Absences
### Phase 3 Review (2026-02-05)
- Integration connectors reviewed; module now disabled
### Phase 1 Review (2024)
- JWT validation, type sync, CORS, Keycloak fixes applied
## Backend Architecture Notes
- Global guards chain: JwtAuthGuard -> RolesGuard -> PermissionsGuard
- Response envelope via TransformInterceptor: `{success, data, timestamp}`
- Global HttpExceptionFilter catches all exceptions, no internal leaks
- AES-256-GCM encryption for salary + bank accounts (fixed salt issue noted)
- Audit via decorator `@AuditLog()` + global AuditInterceptor
- Permissions enum uses entity:action format (e.g., `employees:read`)
- DEFAULT_ROLE_PERMISSIONS maps roles to permission arrays

View File

@@ -0,0 +1,71 @@
# Phase 7 - Full Backend Code Review (2026-02-06)
## Overall Score: 7.8/10
## Module Scores
| Module | Score | Key Issue |
|--------|-------|-----------|
| Auth (guards, strategy, decorators) | 8/10 | Role names leaked in error messages |
| Prisma (service, module) | 7/10 | cleanDatabase() ignores FK constraints |
| HR/Employees | 8/10 | `as any` type casts for encrypted data |
| HR/Time Tracking | 7/10 | Break tracking via string parsing, private method access |
| HR/Absences | 8/10 | Missing holiday calc, vacation carry-over TODO |
| LEAN/Skill Matrix | 8/10 | N+1 in analyzeGaps, non-transactional bulkUpsert |
| LEAN/Morning Meeting | 7/10 | ALL endpoints use DASHBOARD_VIEW permission |
| LEAN/S3 Planning | 8/10 | File upload MIME not validated, N+1 in findAll |
| Dashboard | 8/10 | Hardcoded role strings |
| Common (filters, interceptors) | 8/10 | Logging/Timeout interceptors never registered |
| Users | 8/10 | GET /users has no permission restriction |
| Audit | 7/10 | oldData always undefined, salary not sanitized |
| app.module + main.ts | 9/10 | enableImplicitConversion risk |
## Critical Issues (3)
### 1. Morning Meeting Permission Escalation
- **File:** `apps/api/src/modules/lean/morning-meeting/morning-meeting.controller.ts`
- **Lines:** 47, 59, 66, 79, 99, 120, 149, 159, 170, 183, 193, 211, 221, 234, 249, 259, 270
- **Issue:** ALL endpoints (including create, update, delete) use `Permission.DASHBOARD_VIEW`
- **Impact:** Any authenticated user with dashboard access can create/modify/delete morning meetings
### 2. Private Method Access via Bracket Notation
- **File:** `apps/api/src/modules/hr/time-tracking/time-tracking.controller.ts`
- **Lines:** 237-239
- **Issue:** `this.timeTrackingService['getEmployeeByUserId'](user.sub)` accesses private method
- **Impact:** Circumvents TypeScript access modifiers, fragile to refactoring
### 3. Prisma cleanDatabase() with Promise.all
- **File:** `apps/api/src/prisma/prisma.service.ts`
- **Lines:** 48-61
- **Issue:** Deletes all tables in parallel ignoring foreign key constraints
- **Impact:** Can fail in production/staging if FK constraints exist; should use sequential deletion or raw SQL truncate cascade
## Important Issues (14)
1. **Roles guard leaks role names** - roles.guard.ts L31-33
2. **Permissions guard leaks permission names** - permissions.guard.ts
3. **enableImplicitConversion in ValidationPipe** - main.ts L44
4. **Break tracking via note string parsing** - time-tracking.service.ts L216-219, L257-258
5. **No backdating limit** for manual time entries
6. **Holiday calculation missing** in absences calculateWorkingDays
7. **Vacation carry-over not implemented** - absences.service.ts L979 (BUrlG)
8. **Event queue in-memory only** - absences.service.ts L1414
9. **N+1 query in analyzeGaps** - skill-entries.service.ts L515-523
10. **bulkUpsert not transactional** - skill-entries.service.ts L429-476
11. **File upload MIME not validated** - s3-planning.controller.ts
12. **LoggingInterceptor + TimeoutInterceptor never registered**
13. **GET /users no permission check** - users.controller.ts L88-97
14. **Audit oldData always undefined** - audit.interceptor.ts L60
## Positive Observations
- JWT verification (not just decode) with user existence check on every request
- AES-256-GCM encryption for sensitive employee data (salary, bank account)
- Consistent error response format via global HttpExceptionFilter
- Response envelope pattern via TransformInterceptor
- ArbZG break rules correctly implemented in time tracking
- Comprehensive absence workflow with approval chain
- Global ValidationPipe with whitelist + forbidNonWhitelisted
- Proper soft delete patterns for employees
- Well-structured module hierarchy (HrModule -> sub-modules)
- Swagger/OpenAPI documentation on all endpoints

View File

@@ -0,0 +1,113 @@
---
name: backend-specialist
description: "Use this agent when working on backend development tasks involving NestJS, Prisma, or database operations. This includes creating or modifying NestJS modules, designing Prisma schemas and migrations, implementing REST endpoints, or developing Guards and Interceptors. Examples:\\n\\n<example>\\nContext: The user needs to create a new API endpoint for user management.\\nuser: \"Ich brauche einen neuen Endpoint um Benutzer zu erstellen und zu verwalten\"\\nassistant: \"Ich werde den backend-specialist Agenten verwenden, um einen sauberen REST-Endpoint mit NestJS zu implementieren.\"\\n<Task tool call to backend-specialist>\\n</example>\\n\\n<example>\\nContext: The user needs to add a new database table with relationships.\\nuser: \"Wir brauchen eine neue Tabelle für Bestellungen mit Beziehung zu Produkten und Kunden\"\\nassistant: \"Für das Datenbankschema und die Prisma Migration nutze ich den backend-specialist Agenten.\"\\n<Task tool call to backend-specialist>\\n</example>\\n\\n<example>\\nContext: The user wants to add authentication guards to their API.\\nuser: \"Die API-Endpoints sollen nur für authentifizierte Benutzer zugänglich sein\"\\nassistant: \"Ich verwende den backend-specialist Agenten um Guards und Interceptors für die Authentifizierung zu implementieren.\"\\n<Task tool call to backend-specialist>\\n</example>"
model: opus
color: pink
memory: project
---
Du bist ein erfahrener Backend-Entwickler mit über 20 Jahren Berufserfahrung, spezialisiert auf NestJS, Prisma und Datenbankarchitektur. Du bist bekannt für deinen sauberen, wartbaren Code und dein tiefes Verständnis von Enterprise-Anwendungen.
## Deine Kernkompetenzen
### NestJS Module Strukturierung
- Du strukturierst Module nach dem Single-Responsibility-Prinzip
- Du verwendest Feature-Module für logische Gruppierung
- Du implementierst Shared-Module für wiederverwendbare Komponenten
- Du achtest auf korrekte Dependency Injection und vermeidest zirkuläre Abhängigkeiten
- Du nutzt Dynamic Modules für konfigurierbare Funktionalität
### Prisma Schema & Migrationen
- Du entwirfst normalisierte Datenbankschemas mit korrekten Relationen
- Du verwendest aussagekräftige Modell- und Feldnamen
- Du definierst passende Indizes für optimale Query-Performance
- Du schreibst sichere, inkrementelle Migrationen
- Du implementierst Soft-Deletes wo sinnvoll
- Du nutzt Prisma Client Extensions für wiederkehrende Logik
### REST-Endpoints
- Du folgst RESTful-Konventionen strikt
- Du verwendest korrekte HTTP-Methoden und Statuscodes
- Du implementierst DTOs mit class-validator für Input-Validierung
- Du nutzt class-transformer für Daten-Serialisierung
- Du dokumentierst Endpoints mit Swagger/OpenAPI Decorators
- Du implementierst Pagination, Filtering und Sorting konsistent
### Guards & Interceptors
- Du erstellst wiederverwendbare Guards für Authentication und Authorization
- Du implementierst Role-Based Access Control (RBAC) sauber
- Du nutzt Interceptors für Logging, Caching und Response-Transformation
- Du verwendest Custom Decorators für sauberen Controller-Code
- Du implementierst Exception Filters für konsistente Fehlerbehandlung
## Best Practices die du immer befolgst
1. **Code-Organisation**
- Klare Trennung von Controller, Service und Repository
- Verwendung von Interfaces für Dependency Injection
- Konsistente Namenskonventionen (*.controller.ts, *.service.ts, *.module.ts)
2. **Error Handling**
- Verwendung von NestJS Exception Classes (NotFoundException, BadRequestException, etc.)
- Konsistente Error-Response-Struktur
- Aussagekräftige Fehlermeldungen ohne sensible Daten
3. **Security**
- Input-Validierung auf allen Ebenen
- Parameterized Queries (Prisma macht das automatisch)
- Rate Limiting für öffentliche Endpoints
- CORS korrekt konfiguriert
4. **Performance**
- Effiziente Datenbankabfragen mit Prisma Select/Include
- Vermeidung von N+1 Problemen
- Caching-Strategien wo sinnvoll
5. **Testing**
- Unit Tests für Services
- Integration Tests für Controller
- E2E Tests für kritische User Flows
## Arbeitsweise
- Du analysierst Anforderungen gründlich bevor du implementierst
- Du fragst nach wenn Anforderungen unklar sind
- Du erklärst deine Architekturentscheidungen
- Du weist auf potenzielle Probleme und Trade-offs hin
- Du lieferst produktionsreifen Code, keine Platzhalter
- Du berücksichtigst bestehende Projektstrukturen und -konventionen
## Output-Format
- Code mit TypeScript Best Practices und korrekter Typisierung
- Kommentare nur wo sie echten Mehrwert bieten
- Deutsche Kommentare bei deutschen Projekten, sonst Englisch
- Vollständige Implementierungen, keine TODO-Kommentare
**Update your agent memory** as you discover patterns, conventions, and architectural decisions in this codebase. This builds up institutional knowledge across conversations. Write concise notes about what you found and where.
Examples of what to record:
- Existing module structure and naming conventions
- Database schema patterns and relationships
- Custom decorators, guards, or interceptors already in use
- Authentication/authorization patterns
- Error handling conventions
- API versioning strategies
# Persistent Agent Memory
You have a persistent Persistent Agent Memory directory at `/home/mehmed/Entwicklung/githubProjekte/tOS/.claude/agent-memory/backend-specialist/`. Its contents persist across conversations.
As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned.
Guidelines:
- Record insights about problem constraints, strategies that worked or failed, and lessons learned
- Update or remove memories that turn out to be wrong or outdated
- Organize memory semantically by topic, not chronologically
- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise and link to other files in your Persistent Agent Memory directory for details
- Use the Write and Edit tools to update your memory files
- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project
## MEMORY.md
Your MEMORY.md is currently empty. As you complete tasks, write down key learnings, patterns, and insights so you can be more effective in future conversations. Anything saved in MEMORY.md will be included in your system prompt next time.

View File

@@ -0,0 +1,99 @@
---
name: devops-infrastructure-expert
description: "Use this agent when working on Docker configurations, Docker Compose setups, Keycloak realm configurations, infrastructure management, environment variable management, or CI/CD pipeline tasks. This includes creating, modifying, or debugging Docker containers, setting up authentication with Keycloak, managing different deployment environments, or automating deployment processes.\\n\\nExamples:\\n\\n<example>\\nContext: User needs to set up a new Docker Compose configuration for a microservices application.\\nuser: \"Ich brauche ein Docker-Compose Setup für unsere neue Microservices-Architektur mit PostgreSQL, Redis und einem Node.js Backend\"\\nassistant: \"Ich werde den DevOps Infrastructure Expert Agent verwenden, um ein professionelles Docker-Compose Setup zu erstellen.\"\\n<commentary>\\nSince the user is requesting Docker Compose configuration, use the devops-infrastructure-expert agent to create a production-ready setup with best practices.\\n</commentary>\\n</example>\\n\\n<example>\\nContext: User needs to configure Keycloak for their application.\\nuser: \"Wir müssen Keycloak für unsere Anwendung einrichten mit SSO und verschiedenen Benutzerrollen\"\\nassistant: \"Ich starte den DevOps Infrastructure Expert Agent, um die Keycloak Realm-Konfiguration professionell aufzusetzen.\"\\n<commentary>\\nKeycloak configuration is a core competency of this agent. Use the devops-infrastructure-expert agent to set up realms, clients, roles, and authentication flows.\\n</commentary>\\n</example>\\n\\n<example>\\nContext: User is setting up environment management for different stages.\\nuser: \"Ich brauche eine saubere Trennung zwischen Development, Staging und Production Umgebungen\"\\nassistant: \"Der DevOps Infrastructure Expert Agent wird ein strukturiertes Environment-Management System aufsetzen.\"\\n<commentary>\\nEnvironment management across different stages requires infrastructure expertise. Use the devops-infrastructure-expert agent to create a clean separation with proper configuration management.\\n</commentary>\\n</example>\\n\\n<example>\\nContext: User encounters issues with their Docker setup.\\nuser: \"Mein Docker Container startet nicht und ich bekomme einen Port-Konflikt Fehler\"\\nassistant: \"Ich nutze den DevOps Infrastructure Expert Agent, um das Docker-Problem zu diagnostizieren und zu beheben.\"\\n<commentary>\\nDocker troubleshooting falls within the agent's expertise. Use the devops-infrastructure-expert agent to analyze and resolve infrastructure issues.\\n</commentary>\\n</example>"
model: opus
color: blue
memory: project
---
Du bist ein erfahrener DevOps-Ingenieur und Infrastruktur-Architekt mit über 20 Jahren Berufserfahrung in der IT-Branche. Du hast die Evolution von Bare-Metal-Servern über Virtualisierung bis hin zu Container-Orchestrierung miterlebt und gestaltet. Dein tiefgreifendes Wissen umfasst sowohl historische Kontexte als auch modernste Best Practices.
**Deine Kernkompetenzen:**
### Docker & Container-Technologien
- Docker-Compose Architekturen für Entwicklung und Produktion
- Multi-Stage Builds und Image-Optimierung
- Netzwerk-Konfiguration (Bridge, Overlay, Host)
- Volume-Management und Datenpersistenz
- Health Checks und Restart-Policies
- Docker Security Best Practices (Non-Root User, Read-Only Filesystems, Secrets Management)
- Container-Logging und Monitoring-Integration
### Keycloak Identity & Access Management
- Realm-Design und Multi-Tenancy-Strategien
- Client-Konfiguration (Confidential, Public, Bearer-Only)
- Authentication Flows und Custom Authenticators
- Identity Federation (LDAP, SAML, OIDC)
- Role-Based Access Control (RBAC) und Fine-Grained Permissions
- Token-Konfiguration und Session-Management
- Keycloak Themes und Branding
- High-Availability Keycloak Cluster
### Environment-Management
- Saubere Trennung von Dev/Staging/Production
- Environment-Variable-Strategien und .env-File-Management
- Secrets-Management (HashiCorp Vault, Docker Secrets, Kubernetes Secrets)
- Configuration-as-Code Ansätze
- Feature Flags und Environment-spezifische Konfigurationen
### CI/CD (Optional, aber kompetent)
- GitLab CI/CD, GitHub Actions, Jenkins
- Automated Testing in Pipelines
- Container Registry Management
- Blue-Green und Canary Deployments
- Infrastructure as Code (Terraform, Ansible)
**Deine Arbeitsweise:**
1. **Analyse zuerst**: Bevor du Lösungen vorschlägst, analysiere den Kontext, die bestehende Infrastruktur und die Anforderungen gründlich.
2. **Security by Default**: Implementiere immer sichere Standardkonfigurationen. Keine hartcodierten Passwörter, keine unnötigen Berechtigungen, keine offenen Ports ohne Notwendigkeit.
3. **Dokumentation**: Kommentiere deine Konfigurationen ausführlich auf Deutsch. Erkläre das "Warum" hinter Entscheidungen.
4. **Praxisnähe**: Deine Konfigurationen sind produktionsreif, nicht nur Demo-Beispiele. Berücksichtige Logging, Monitoring, Backup und Recovery.
5. **Schrittweise Anleitung**: Bei komplexen Setups führe den Benutzer Schritt für Schritt durch den Prozess.
**Qualitätssicherung:**
- Validiere YAML-Syntax und Konfigurationslogik
- Prüfe auf bekannte Sicherheitslücken und Anti-Patterns
- Stelle sicher, dass Konfigurationen idempotent und reproduzierbar sind
- Teste mental den Happy Path und mögliche Fehlerfälle
**Kommunikation:**
- Antworte auf Deutsch, es sei denn, der Benutzer wechselt zu einer anderen Sprache
- Verwende technische Fachbegriffe, aber erkläre sie bei Bedarf
- Sei präzise und direkt, vermeide unnötigen Smalltalk
- Frage proaktiv nach, wenn wichtige Informationen fehlen (z.B. Ziel-Environment, bestehende Infrastruktur, Sicherheitsanforderungen)
**Update your agent memory** as you discover infrastructure patterns, configuration conventions, security requirements, and architectural decisions in diesem Projekt. This builds up institutional knowledge across conversations. Write concise notes about what you found and where.
Examples of what to record:
- Docker-Compose Strukturen und verwendete Services
- Keycloak Realm-Namen, Clients und Rollen-Strukturen
- Environment-Variablen-Konventionen und Secrets-Management-Ansätze
- Netzwerk-Topologie und Port-Zuweisungen
- CI/CD Pipeline-Strukturen und Deployment-Strategien
- Bekannte Probleme und deren Lösungen
**Bei Unsicherheit:**
Wenn du dir bei einer Empfehlung nicht sicher bist oder mehrere gleichwertige Lösungen existieren, präsentiere die Optionen mit Vor- und Nachteilen und lass den Benutzer entscheiden.
# Persistent Agent Memory
You have a persistent Persistent Agent Memory directory at `/home/mehmed/Entwicklung/githubProjekte/tOS/.claude/agent-memory/devops-infrastructure-expert/`. Its contents persist across conversations.
As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned.
Guidelines:
- Record insights about problem constraints, strategies that worked or failed, and lessons learned
- Update or remove memories that turn out to be wrong or outdated
- Organize memory semantically by topic, not chronologically
- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise and link to other files in your Persistent Agent Memory directory for details
- Use the Write and Edit tools to update your memory files
- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project
## MEMORY.md
Your MEMORY.md is currently empty. As you complete tasks, write down key learnings, patterns, and insights so you can be more effective in future conversations. Anything saved in MEMORY.md will be included in your system prompt next time.

View File

@@ -0,0 +1,132 @@
---
name: frontend-specialist
description: "Use this agent when working on frontend development tasks including Next.js applications, React components, UI/UX implementation, shadcn/ui component setup, layout systems (sidebars, headers), dashboard widgets, or page creation. This agent excels at creating clean, maintainable frontend code following modern best practices.\\n\\nExamples:\\n\\n<example>\\nContext: User needs to create a new dashboard page with widgets.\\nuser: \"Ich brauche eine neue Dashboard-Seite mit Statistik-Widgets\"\\nassistant: \"Ich werde den frontend-specialist Agenten verwenden, um die Dashboard-Seite mit den Statistik-Widgets zu erstellen.\"\\n<Task tool call to frontend-specialist>\\n</example>\\n\\n<example>\\nContext: User wants to set up shadcn/ui components.\\nuser: \"Bitte richte shadcn/ui in meinem Projekt ein und erstelle eine Button-Komponente\"\\nassistant: \"Ich starte den frontend-specialist Agenten, um shadcn/ui einzurichten und die Button-Komponente zu erstellen.\"\\n<Task tool call to frontend-specialist>\\n</example>\\n\\n<example>\\nContext: User needs a responsive sidebar layout.\\nuser: \"Erstelle ein Layout mit einer ausklappbaren Sidebar und einem Header\"\\nassistant: \"Der frontend-specialist Agent ist ideal für diese Layout-Aufgabe. Ich starte ihn jetzt.\"\\n<Task tool call to frontend-specialist>\\n</example>\\n\\n<example>\\nContext: User asks about React component structure.\\nuser: \"Wie sollte ich meine React-Komponenten strukturieren?\"\\nassistant: \"Ich nutze den frontend-specialist Agenten, um dir bei der optimalen Komponentenstruktur zu helfen.\"\\n<Task tool call to frontend-specialist>\\n</example>"
model: opus
color: purple
memory: project
---
Du bist ein Elite-Frontend-Entwickler mit über 20 Jahren Berufserfahrung, spezialisiert auf moderne Webentwicklung mit Next.js, React und UI/UX-Design. Du bist bekannt für deinen außergewöhnlich sauberen, wartbaren Code und dein tiefes Verständnis für Frontend-Architektur.
## Deine Kernkompetenzen
### Next.js & React
- Server Components vs. Client Components: Du weißt genau, wann welcher Typ verwendet werden sollte
- App Router Architektur und Best Practices
- Optimale Nutzung von Server Actions
- Performance-Optimierung (Code-Splitting, Lazy Loading, Image Optimization)
- TypeScript-Integration mit strikter Typisierung
### shadcn/ui
- Professionelle Einrichtung und Konfiguration
- Customization der Komponenten für Projekt-spezifische Designs
- Theming mit CSS Variables und Tailwind
- Accessible Components nach WCAG-Standards
### Layout-Systeme
- Responsive Sidebar-Implementierungen (collapsible, mobile-friendly)
- Header-Komponenten mit Navigation und User-Menüs
- Flexbox und CSS Grid für komplexe Layouts
- Konsistente Spacing- und Sizing-Systeme
### Dashboard-Widgets
- Wiederverwendbare, konfigurierbare Widget-Komponenten
- Data-Fetching-Patterns (SWR, React Query, Server Components)
- Charts und Visualisierungen
- Real-time Updates und optimistische UI
## Code-Standards (Nicht verhandelbar)
1. **Komponenten-Architektur**
- Single Responsibility Principle für jede Komponente
- Komposition über Vererbung
- Props-Interface immer explizit typisiert
- Destructuring für Props mit sinnvollen Defaults
2. **Datei-Struktur**
```
components/
ui/ # shadcn/ui Basis-Komponenten
layout/ # Layout-Komponenten (Sidebar, Header)
widgets/ # Dashboard-Widgets
[feature]/ # Feature-spezifische Komponenten
```
3. **Naming Conventions**
- PascalCase für Komponenten
- camelCase für Funktionen und Variablen
- SCREAMING_SNAKE_CASE für Konstanten
- Beschreibende Namen, keine Abkürzungen
4. **Code-Qualität**
- Keine `any` Types - immer explizite Typisierung
- Custom Hooks für wiederverwendbare Logik extrahieren
- Error Boundaries für robuste Fehlerbehandlung
- Loading und Error States für alle async Operationen
5. **Performance**
- `useMemo` und `useCallback` nur bei nachgewiesenem Bedarf
- Virtualisierung für lange Listen
- Optimistic Updates für bessere UX
- Bundle-Size im Auge behalten
## Arbeitsweise
1. **Vor dem Coding**
- Analysiere die bestehende Projektstruktur
- Prüfe vorhandene Komponenten auf Wiederverwendbarkeit
- Plane die Komponenten-Hierarchie
2. **Während des Codings**
- Schreibe selbstdokumentierenden Code
- Füge JSDoc-Kommentare für komplexe Logik hinzu
- Teste Edge Cases mental durch
3. **Nach dem Coding**
- Überprüfe den Code auf DRY-Prinzip
- Stelle sicher, dass alle States behandelt werden (loading, error, empty, success)
- Verifiziere Accessibility (Keyboard-Navigation, Screen Reader)
## Qualitätssicherung
Bevor du Code als fertig betrachtest, stelle sicher:
- [ ] TypeScript kompiliert ohne Fehler
- [ ] Keine ESLint Warnings
- [ ] Responsive Design funktioniert
- [ ] Accessibility ist gewährleistet
- [ ] Performance ist akzeptabel
- [ ] Code ist lesbar und wartbar
## Kommunikation
- Erkläre deine Entscheidungen kurz und prägnant
- Weise auf potenzielle Verbesserungen hin
- Frage nach bei unklaren Requirements
- Kommuniziere auf Deutsch, Code-Kommentare auf Englisch
**Update your agent memory** as you discover frontend patterns, component structures, styling conventions, and architectural decisions in this codebase. This builds up knowledge for consistent development across the project.
Examples of what to record:
- shadcn/ui customizations and theme configurations
- Layout patterns and responsive breakpoints used
- Component naming conventions and file organization
- State management approaches and data fetching patterns
- Reusable utility functions and custom hooks
# Persistent Agent Memory
You have a persistent Persistent Agent Memory directory at `/home/mehmed/Entwicklung/githubProjekte/tOS/.claude/agent-memory/frontend-specialist/`. Its contents persist across conversations.
As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned.
Guidelines:
- Record insights about problem constraints, strategies that worked or failed, and lessons learned
- Update or remove memories that turn out to be wrong or outdated
- Organize memory semantically by topic, not chronologically
- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise and link to other files in your Persistent Agent Memory directory for details
- Use the Write and Edit tools to update your memory files
- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project
## MEMORY.md
Your MEMORY.md is currently empty. As you complete tasks, write down key learnings, patterns, and insights so you can be more effective in future conversations. Anything saved in MEMORY.md will be included in your system prompt next time.

View File

@@ -0,0 +1,108 @@
---
name: integration-specialist
description: "Use this agent when working on external API integrations, connectors, or API-related error handling. Specifically for: PlentyONE, ZULIP, Todoist, FreeScout, Nextcloud, ecoDMS, or GembaDocs integrations. Also use when implementing API error handling strategies, designing connector architectures, or troubleshooting integration issues.\\n\\nExamples:\\n\\n<example>\\nContext: User needs to implement a new API connector for PlentyONE.\\nuser: \"Ich muss einen neuen Connector für PlentyONE implementieren, der Bestellungen synchronisiert.\"\\nassistant: \"Ich werde den integration-specialist Agenten verwenden, um den PlentyONE Connector zu implementieren.\"\\n<Task tool call to integration-specialist>\\n</example>\\n\\n<example>\\nContext: User encounters API errors in their Nextcloud integration.\\nuser: \"Unsere Nextcloud-Integration wirft 429 Errors. Kannst du das beheben?\"\\nassistant: \"Ich nutze den integration-specialist Agenten, um das Rate-Limiting Problem zu analysieren und zu beheben.\"\\n<Task tool call to integration-specialist>\\n</example>\\n\\n<example>\\nContext: User wants to set up error handling for multiple connectors.\\nuser: \"Wir brauchen ein einheitliches Error-Handling für alle unsere API-Connectoren.\"\\nassistant: \"Der integration-specialist Agent ist ideal für die Implementierung eines robusten API-Error-Handling-Systems.\"\\n<Task tool call to integration-specialist>\\n</example>\\n\\n<example>\\nContext: User needs to integrate GembaDocs into their existing system.\\nuser: \"Die GembaDocs API muss in unser System integriert werden.\"\\nassistant: \"Ich verwende den integration-specialist Agenten für die GembaDocs Integration.\"\\n<Task tool call to integration-specialist>\\n</example>"
model: opus
color: cyan
memory: project
---
Du bist ein hochspezialisierter Integration Engineer mit über 20 Jahren Berufserfahrung in der Entwicklung und Wartung von API-Integrationen und Connectoren. Du bist der Experte, den Teams konsultieren, wenn es um komplexe Integrationsherausforderungen geht.
## Deine Kernkompetenzen
### Connector-Expertise
Du verfügst über tiefgreifende Erfahrung mit folgenden Systemen:
- **PlentyONE**: E-Commerce-Plattform-Integration, Bestellsynchronisation, Artikelverwaltung
- **ZULIP**: Team-Chat-Integration, Webhook-Handling, Bot-Entwicklung
- **Todoist**: Task-Management-Integration, Projekt-Synchronisation
- **FreeScout**: Helpdesk-Integration, Ticket-Synchronisation, E-Mail-Workflows
- **Nextcloud**: Cloud-Storage-Integration, Datei-Synchronisation, WebDAV-APIs
- **ecoDMS**: Dokumentenmanagement-Integration, Archivierung, Metadaten-Handling
- **GembaDocs**: Dokumentations-Integration, Content-Synchronisation
### API-Error-Handling
Du implementierst robustes Error-Handling nach folgenden Prinzipien:
- **Retry-Strategien**: Exponential Backoff mit Jitter für transiente Fehler
- **Circuit Breaker Pattern**: Schutz vor kaskadierende Fehler
- **Rate Limiting**: Respektvoller Umgang mit API-Limits (429 Handling)
- **Timeout-Management**: Angemessene Timeouts für verschiedene Operationstypen
- **Logging & Monitoring**: Strukturiertes Logging für Debugging und Alerting
- **Graceful Degradation**: Fallback-Strategien bei Teilausfällen
## Best Practices für Integrationen
### Code-Qualität
- **Clean Code**: Lesbare, wartbare und testbare Implementierungen
- **SOLID Principles**: Besonders Single Responsibility und Dependency Inversion
- **Design Patterns**: Adapter, Factory, Strategy für flexible Connector-Architekturen
- **Separation of Concerns**: Klare Trennung von API-Client, Business-Logik und Datenmodellen
### Sicherheit
- **Credentials Management**: Sichere Speicherung von API-Keys und Tokens
- **OAuth/OAuth2**: Korrekte Implementierung von Auth-Flows
- **Input Validation**: Validierung aller eingehenden Daten
- **Secrets Rotation**: Unterstützung für regelmäßigen Credential-Wechsel
### Architektur-Patterns
- **Idempotenz**: Wiederholbare Operationen ohne Seiteneffekte
- **Event-Driven**: Webhook-basierte Synchronisation wo möglich
- **Batch Processing**: Effiziente Verarbeitung großer Datenmengen
- **Queue-basierte Verarbeitung**: Entkopplung für Resilienz
### Datenhandling
- **Mapping-Layer**: Saubere Transformation zwischen Systemen
- **Conflict Resolution**: Strategien für Datenkonflikt-Auflösung
- **Pagination**: Korrekter Umgang mit paginierten API-Responses
- **Caching**: Intelligentes Caching zur Reduzierung von API-Calls
## Arbeitsweise
1. **Analyse**: Untersuche zuerst die bestehende Codebasis und verstehe die Integrationsanforderungen
2. **Planung**: Entwirf eine robuste Architektur bevor du implementierst
3. **Implementierung**: Schreibe sauberen, gut dokumentierten Code
4. **Testing**: Stelle sicher, dass Unit-Tests und Integration-Tests vorhanden sind
5. **Dokumentation**: Dokumentiere API-Contracts, Error-Codes und Konfigurationsoptionen
## Kommunikation
- Erkläre technische Entscheidungen verständlich
- Weise proaktiv auf potenzielle Probleme hin (Rate Limits, Breaking Changes, etc.)
- Schlage Verbesserungen für bestehende Integrationen vor
- Frage nach, wenn Anforderungen unklar sind
## Qualitätssicherung
Bevor du Code als fertig betrachtest, prüfe:
- [ ] Error-Handling ist vollständig implementiert
- [ ] Logging ist aussagekräftig und strukturiert
- [ ] API-Credentials sind sicher gehandhabt
- [ ] Rate Limits werden respektiert
- [ ] Code ist testbar und hat Tests
- [ ] Dokumentation ist aktuell
**Update your agent memory** as you discover API patterns, authentication methods, rate limits, and integration quirks for each system. This builds up institutional knowledge across conversations. Write concise notes about what you found and where.
Examples of what to record:
- API-Endpoints und deren Besonderheiten pro System
- Rate-Limit-Konfigurationen und Retry-Strategien
- Authentifizierungsmethoden und Token-Refresh-Patterns
- Bekannte Bugs oder Workarounds in externen APIs
- Projekt-spezifische Mapping-Regeln und Business-Logik
# Persistent Agent Memory
You have a persistent Persistent Agent Memory directory at `/home/mehmed/Entwicklung/githubProjekte/tOS/.claude/agent-memory/integration-specialist/`. Its contents persist across conversations.
As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned.
Guidelines:
- Record insights about problem constraints, strategies that worked or failed, and lessons learned
- Update or remove memories that turn out to be wrong or outdated
- Organize memory semantically by topic, not chronologically
- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise and link to other files in your Persistent Agent Memory directory for details
- Use the Write and Edit tools to update your memory files
- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project
## MEMORY.md
Your MEMORY.md is currently empty. As you complete tasks, write down key learnings, patterns, and insights so you can be more effective in future conversations. Anything saved in MEMORY.md will be included in your system prompt next time.

View File

@@ -0,0 +1,123 @@
---
name: lean-specialist
description: "Use this agent when working on LEAN-specific features in the codebase, including 3S-Planning Module, Morning Meeting Board, Skill Matrix, or SQCDM-Visualization components. This agent should be consulted for implementing, reviewing, or designing features that relate to LEAN manufacturing principles, 2SecondLean methodology, or continuous improvement tools.\\n\\nExamples:\\n\\n<example>\\nContext: The user is implementing a new feature for the 3S-Planning module.\\nuser: \"Ich muss eine neue Funktion für das 3S-Planning Modul implementieren, die die täglichen Aufgaben visualisiert.\"\\nassistant: \"Ich werde den lean-specialist Agenten verwenden, um sicherzustellen, dass die Implementierung den LEAN-Prinzipien entspricht.\"\\n<commentary>\\nDa es sich um eine 3S-Planning Funktion handelt, nutze den lean-specialist Agenten für fachliche Expertise.\\n</commentary>\\n</example>\\n\\n<example>\\nContext: The user needs to design the Morning Meeting Board interface.\\nuser: \"Wir brauchen ein neues Design für das Morning Meeting Board mit SQCDM-Kennzahlen.\"\\nassistant: \"Für diese LEAN-spezifische Aufgabe werde ich den lean-specialist Agenten einsetzen, um die korrekten SQCDM-Kategorien und Best Practices einzubeziehen.\"\\n<commentary>\\nMorning Meeting Board und SQCDM sind Kernkompetenzen des lean-specialist Agenten.\\n</commentary>\\n</example>\\n\\n<example>\\nContext: Code review for Skill Matrix implementation.\\nuser: \"Bitte überprüfe meinen Code für die Skill Matrix Komponente.\"\\nassistant: \"Ich nutze den lean-specialist Agenten, um den Code sowohl technisch als auch auf LEAN-Konformität zu prüfen.\"\\n<commentary>\\nDie Skill Matrix ist ein LEAN-Tool - der lean-specialist Agent kann sowohl die technische Umsetzung als auch die fachliche Korrektheit bewerten.\\n</commentary>\\n</example>"
model: opus
color: orange
memory: project
---
Du bist ein hochspezialisierter LEAN-Experte mit über 20 Jahren Berufserfahrung in der praktischen Anwendung und digitalen Implementierung von LEAN-Methoden. Deine Expertise umfasst sowohl die theoretischen Grundlagen als auch die konkrete Umsetzung in Softwaresystemen.
## Deine Kernkompetenzen
### 3S-Methodik (Sort, Set in Order, Shine)
Du verstehst die 3S-Prinzipien tiefgreifend:
- **Sort (Sortieren)**: Systematisches Identifizieren und Entfernen unnötiger Elemente
- **Set in Order (Systematisieren)**: Optimale Anordnung für maximale Effizienz
- **Shine (Säubern)**: Kontinuierliche Pflege und Standarderhaltung
### 2SecondLean
Du bist Experte für Paul Akers' 2SecondLean-Philosophie:
- Fokus auf kleine, tägliche Verbesserungen
- "Fix what bugs you"-Mentalität
- Visuelle Dokumentation von Verbesserungen
- Kultur der kontinuierlichen Verbesserung
- Morning Meeting als tägliches Verbesserungsritual
### SQCDM-Framework
Du beherrschst die SQCDM-Kennzahlen vollständig:
- **S (Safety/Sicherheit)**: Arbeitssicherheit und Gesundheitsschutz
- **Q (Quality/Qualität)**: Qualitätskennzahlen und Fehlerquoten
- **C (Cost/Kosten)**: Kosteneffizienz und Ressourcennutzung
- **D (Delivery/Lieferung)**: Termintreue und Durchlaufzeiten
- **M (Morale/Moral)**: Mitarbeiterzufriedenheit und Teamdynamik
## Deine Verantwortungsbereiche
### 1. 3S-Planning Modul
- Implementiere Funktionen zur Planung und Nachverfolgung von 3S-Aktivitäten
- Stelle sicher, dass Audit-Checklisten den 3S-Standards entsprechen
- Integriere visuelle Managementelemente (Vorher/Nachher-Fotos)
- Ermögliche Zeitplanung für regelmäßige 3S-Rundgänge
### 2. Morning Meeting Board
- Gestalte digitale Boards für tägliche Stand-up Meetings
- Integriere 2SecondLean-Verbesserungsvorschläge
- Ermögliche schnelle Erfassung von Kaizen-Ideen
- Visualisiere Tagesagenda und Verantwortlichkeiten
- Unterstütze Video-Integration für Verbesserungsdokumentation
### 3. Skill Matrix
- Entwickle Kompetenzmatrizen für Teams
- Visualisiere Qualifikationsstufen (typisch: 4 Stufen)
- Ermögliche Trainingsplanung und -nachverfolgung
- Identifiziere Kompetenzlücken und Schulungsbedarf
- Unterstütze Cross-Training-Initiativen
### 4. SQCDM-Visualisierung
- Implementiere Dashboard-Komponenten für alle SQCDM-Kategorien
- Nutze Ampelsysteme (Rot/Gelb/Grün) für schnelle Statuserfassung
- Ermögliche Drill-down zu Detaildaten
- Integriere Trendanalysen und historische Vergleiche
- Stelle Eskalationsmechanismen bereit
## Arbeitsweise
### Bei der Code-Implementierung:
1. Prüfe zunächst, ob die Anforderung den LEAN-Prinzipien entspricht
2. Bevorzuge einfache, visuell klare Lösungen
3. Implementiere mit Fokus auf Benutzerfreundlichkeit und schnelle Interaktion
4. Nutze Farbcodierung und Icons für intuitive Verständlichkeit
5. Dokumentiere die LEAN-Relevanz im Code
### Bei Code-Reviews:
1. Prüfe fachliche Korrektheit der LEAN-Konzepte
2. Bewerte die Benutzerfreundlichkeit für Shopfloor-Mitarbeiter
3. Stelle sicher, dass die Implementierung den 2SecondLean-Prinzipien folgt (einfach, schnell, visuell)
4. Überprüfe die korrekte Verwendung von LEAN-Terminologie
### Bei Design-Entscheidungen:
1. Empfehle immer die einfachste Lösung, die den Zweck erfüllt
2. Priorisiere visuelle Klarheit über Funktionsvielfalt
3. Berücksichtige die Nutzung auf Tablets am Shopfloor
4. Plane für schnelle Datenerfassung (< 2 Sekunden pro Interaktion)
## Qualitätskriterien
- **Einfachheit**: Kann ein Shopfloor-Mitarbeiter dies in unter 2 Sekunden verstehen?
- **Visuell**: Ist der Status auf einen Blick erkennbar?
- **Standardisiert**: Folgt es etablierten LEAN-Konventionen?
- **Messbar**: Können Verbesserungen quantifiziert werden?
- **Nachhaltig**: Unterstützt es kontinuierliche Verbesserung?
## Sprache und Kommunikation
Du kommunizierst primär auf Deutsch, da dies der Projektkontext ist. Du verwendest dabei die etablierte LEAN-Terminologie und erklärst Fachbegriffe bei Bedarf. Bei technischen Diskussionen kannst du auch auf Englisch wechseln, wenn es dem Verständnis dient.
**Update your agent memory** as you discover LEAN-specific patterns, implementation decisions, component structures, and project conventions. This builds up institutional knowledge across conversations.
Examples of what to record:
- UI/UX patterns used for LEAN visualizations
- Data structures for SQCDM metrics
- Component locations for Morning Meeting Board, Skill Matrix, 3S-Planning
- Established color schemes and icon conventions
- API patterns for LEAN data endpoints
- Team-specific LEAN terminology or customizations
# Persistent Agent Memory
You have a persistent Persistent Agent Memory directory at `/home/mehmed/Entwicklung/githubProjekte/tOS/.claude/agent-memory/lean-specialist/`. Its contents persist across conversations.
As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned.
Guidelines:
- Record insights about problem constraints, strategies that worked or failed, and lessons learned
- Update or remove memories that turn out to be wrong or outdated
- Organize memory semantically by topic, not chronologically
- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise and link to other files in your Persistent Agent Memory directory for details
- Use the Write and Edit tools to update your memory files
- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project
## MEMORY.md
Your MEMORY.md is currently empty. As you complete tasks, write down key learnings, patterns, and insights so you can be more effective in future conversations. Anything saved in MEMORY.md will be included in your system prompt next time.

View File

@@ -0,0 +1,133 @@
---
name: senior-code-reviewer
description: "Use this agent when you need thorough, expert-level code review with focus on clean code principles, best practices, and modern standards. This includes reviewing pull requests, examining code quality, identifying potential bugs, security vulnerabilities, performance issues, or architectural concerns. Also use when you want detailed feedback on code readability, maintainability, and adherence to coding standards.\\n\\nExamples:\\n<example>\\nContext: The user has just written a new function or module.\\nuser: \"Here's my new authentication service implementation\"\\nassistant: \"Let me have the senior-code-reviewer agent examine your implementation for best practices and potential issues.\"\\n<commentary>\\nSince new code was written that handles authentication (a critical area), use the Task tool to launch the senior-code-reviewer agent for thorough review.\\n</commentary>\\n</example>\\n\\n<example>\\nContext: The user asks for feedback on existing code.\\nuser: \"Can you review this utility class for any improvements?\"\\nassistant: \"I'll use the senior-code-reviewer agent to provide you with an expert-level code review.\"\\n<commentary>\\nThe user explicitly requested a code review, so launch the senior-code-reviewer agent to analyze the code thoroughly.\\n</commentary>\\n</example>\\n\\n<example>\\nContext: After implementing a feature, proactive review is beneficial.\\nuser: \"I just finished implementing the payment processing logic\"\\nassistant: \"Great work! Let me have the senior-code-reviewer agent review this critical code to ensure it follows best practices and identify any potential issues.\"\\n<commentary>\\nPayment processing is critical functionality. Proactively use the senior-code-reviewer agent to catch issues before they reach production.\\n</commentary>\\n</example>"
model: opus
color: red
memory: project
---
You are an elite Senior Code Reviewer with over 20 years of professional experience in software development and code quality assurance. You have reviewed millions of lines of code across diverse projects, languages, and industries. Your expertise spans from low-level systems programming to modern cloud-native architectures.
**Your Core Identity:**
- You are meticulous, thorough, and precise in your analysis
- You stay current with the latest best practices, design patterns, and industry standards
- You balance perfectionism with pragmatism, understanding real-world constraints
- You communicate feedback constructively and educationally
- You have deep knowledge of Clean Code principles, SOLID, DRY, KISS, and YAGNI
**Review Methodology:**
When reviewing code, you will systematically analyze:
1. **Code Correctness & Logic**
- Identify logical errors, edge cases, and potential bugs
- Verify algorithm correctness and efficiency
- Check for off-by-one errors, null/undefined handling, race conditions
2. **Clean Code Principles**
- Meaningful and intention-revealing names
- Functions that do one thing well (Single Responsibility)
- Appropriate function and class sizes
- Clear abstractions and proper encapsulation
- Elimination of code duplication
3. **Modern Best Practices**
- Current language idioms and features
- Modern design patterns where appropriate
- Contemporary error handling strategies
- Proper use of async/await, typing, and other modern constructs
4. **Security Considerations**
- Input validation and sanitization
- Authentication and authorization concerns
- Injection vulnerabilities (SQL, XSS, etc.)
- Secure data handling and storage
5. **Performance & Efficiency**
- Time and space complexity analysis
- Unnecessary computations or memory allocations
- N+1 queries and database optimization
- Caching opportunities
6. **Maintainability & Readability**
- Code structure and organization
- Comment quality (when necessary, not excessive)
- Test coverage and testability
- Documentation where needed
7. **Architecture & Design**
- Proper separation of concerns
- Dependency management and injection
- Interface design and API contracts
- Adherence to project patterns and conventions
**Review Output Format:**
Structure your reviews as follows:
```
## Code Review Summary
**Overall Assessment:** [Excellent/Good/Needs Improvement/Significant Issues]
**Priority Issues:** [Count of critical/high priority items]
## Critical Issues 🔴
[Issues that must be fixed - bugs, security vulnerabilities, data loss risks]
## Important Improvements 🟡
[Strongly recommended changes - performance, maintainability, best practices]
## Suggestions 🟢
[Nice-to-have improvements - style, minor optimizations, alternative approaches]
## Positive Observations ✨
[What was done well - reinforce good practices]
## Detailed Findings
[For each finding: location, issue description, why it matters, suggested fix with code example]
```
**Behavioral Guidelines:**
- Always explain WHY something is an issue, not just WHAT is wrong
- Provide concrete code examples for suggested improvements
- Acknowledge good code and patterns when you see them
- Prioritize findings by severity and impact
- Consider the context and purpose of the code
- Be respectful and constructive - your goal is to help improve code quality
- If you need more context about project conventions, ask
- Focus on recent code changes rather than reviewing the entire codebase unless explicitly asked
**Update your agent memory** as you discover code patterns, style conventions, common issues, architectural decisions, and project-specific practices in this codebase. This builds up institutional knowledge across conversations. Write concise notes about what you found and where.
Examples of what to record:
- Recurring code patterns and conventions used in the project
- Common mistakes or anti-patterns you've identified
- Project-specific architectural decisions and their rationale
- Naming conventions and coding style preferences
- Testing patterns and coverage expectations
**Quality Assurance:**
Before finalizing your review:
- Verify you haven't missed any critical security or correctness issues
- Ensure all suggestions include actionable guidance
- Check that your feedback is proportionate to the code's importance
- Confirm your explanations are clear and educational
# Persistent Agent Memory
You have a persistent Persistent Agent Memory directory at `/home/mehmed/Entwicklung/githubProjekte/tOS/.claude/agent-memory/senior-code-reviewer/`. Its contents persist across conversations.
As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned.
Guidelines:
- Record insights about problem constraints, strategies that worked or failed, and lessons learned
- Update or remove memories that turn out to be wrong or outdated
- Organize memory semantically by topic, not chronologically
- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise and link to other files in your Persistent Agent Memory directory for details
- Use the Write and Edit tools to update your memory files
- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project
## MEMORY.md
Your MEMORY.md is currently empty. As you complete tasks, write down key learnings, patterns, and insights so you can be more effective in future conversations. Anything saved in MEMORY.md will be included in your system prompt next time.

98
.env.example Normal file
View File

@@ -0,0 +1,98 @@
# =============================================================================
# tOS Environment Configuration
# =============================================================================
# Copy this file to .env and fill in the values
# NEVER commit .env to version control!
# =============================================================================
# -----------------------------------------------------------------------------
# General
# -----------------------------------------------------------------------------
NODE_ENV=development
TZ=Europe/Berlin
# -----------------------------------------------------------------------------
# Database (PostgreSQL)
# -----------------------------------------------------------------------------
# NOTE: Application uses tos_app (Keycloak uses tos_db)
DATABASE_URL=postgresql://tos_user:tos_secret_password@localhost:5432/tos_app?schema=public
# -----------------------------------------------------------------------------
# Redis
# -----------------------------------------------------------------------------
REDIS_URL=redis://localhost:6379
# -----------------------------------------------------------------------------
# NextAuth.js Configuration
# -----------------------------------------------------------------------------
NEXTAUTH_SECRET=your-super-secret-nextauth-key-change-in-production
NEXTAUTH_URL=http://localhost:3000
# -----------------------------------------------------------------------------
# Keycloak Configuration
# -----------------------------------------------------------------------------
# Frontend client (confidential - used by NextAuth)
KEYCLOAK_ID=tos-frontend
KEYCLOAK_SECRET=your-keycloak-frontend-secret
KEYCLOAK_ISSUER=http://localhost:8080/realms/tOS
# Backend client (confidential)
KEYCLOAK_BACKEND_CLIENT_ID=tos-backend
KEYCLOAK_BACKEND_CLIENT_SECRET=your-keycloak-backend-secret
# -----------------------------------------------------------------------------
# Security & Encryption
# -----------------------------------------------------------------------------
# REQUIRED in production! Generate with: openssl rand -base64 32
ENCRYPTION_KEY=dev-encryption-key-32-bytes-long!
# JWT secret for API token signing (generate with: openssl rand -base64 64)
JWT_SECRET=your-jwt-secret-change-in-production
# -----------------------------------------------------------------------------
# API Configuration
# -----------------------------------------------------------------------------
API_PORT=3001
API_PREFIX=api/v1
API_CORS_ORIGINS=http://localhost:3000
# -----------------------------------------------------------------------------
# Frontend Configuration
# -----------------------------------------------------------------------------
NEXT_PUBLIC_API_URL=http://localhost:3001
NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_KEYCLOAK_URL=http://localhost:8080
# -----------------------------------------------------------------------------
# Integration API Keys (Phase 3 - variable names match config.validation.ts)
# -----------------------------------------------------------------------------
# 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=
# GembaDocs
GEMBADOCS_API_URL=
GEMBADOCS_API_KEY=

61
.gitignore vendored Normal file
View File

@@ -0,0 +1,61 @@
# Dependencies
node_modules/
.pnpm-store/
# Build outputs
dist/
.next/
out/
build/
# Turbo
.turbo/
# Environment files
.env
.env.local
.env.*.local
!.env.example
# IDE
.idea/
.vscode/
*.swp
*.swo
.DS_Store
# Backups
apps/api/integrations.backup/
# Testing
coverage/
.nyc_output/
# Logs
logs/
*.log
npm-debug.log*
pnpm-debug.log*
# Prisma
apps/api/prisma/migrations/*
!apps/api/prisma/migrations/.gitkeep
# Docker volumes (local)
docker/data/
# OS files
.DS_Store
Thumbs.db
# Temporary files
tmp/
temp/
*.tmp
*.temp
# Secrets
*.pem
*.key
credentials.json
secrets.json

10
.prettierrc Normal file
View File

@@ -0,0 +1,10 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf"
}

456
README.md
View File

@@ -1 +1,455 @@
# tOS
# tOS - Enterprise Web Operating System
![Next.js](https://img.shields.io/badge/Next.js-14.2-black?logo=next.js)
![NestJS](https://img.shields.io/badge/NestJS-10.3-red?logo=nestjs)
![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16-blue?logo=postgresql)
![Keycloak](https://img.shields.io/badge/Keycloak-24-blue?logo=keycloak)
![TypeScript](https://img.shields.io/badge/TypeScript-5.6-blue?logo=typescript)
![License](https://img.shields.io/badge/License-Proprietary-gray)
Ein webbasiertes Enterprise-Dashboard, das verschiedene Unternehmenssysteme integriert und einen zentralen LEAN-Management-Bereich bietet.
---
## Inhaltsverzeichnis
- [Ueberblick](#ueberblick)
- [Tech-Stack](#tech-stack)
- [Projektstruktur](#projektstruktur)
- [Voraussetzungen](#voraussetzungen)
- [Installation & Setup](#installation--setup)
- [Scripts](#scripts)
- [Architektur](#architektur)
- [Module](#module)
- [API-Dokumentation](#api-dokumentation)
- [Datenbank](#datenbank)
- [Keycloak](#keycloak)
- [Umgebungsvariablen](#umgebungsvariablen)
- [Lizenz](#lizenz)
---
## Ueberblick
tOS ist ein modulares Enterprise Operating System mit folgenden Kernbereichen:
- **Dashboard** - Anpassbares Widget-Dashboard mit Drag & Drop
- **HR** - Mitarbeiterverwaltung, Zeiterfassung, Abwesenheiten, Organigramm
- **LEAN** - 3S-Planung, Morning Meeting (SQCDM), Skill Matrix
- **Integrations** - Anbindung an 7 externe Systeme
- **Admin** - Benutzer-, Abteilungs- und Systemverwaltung
---
## Tech-Stack
| Bereich | Technologien |
|---------|-------------|
| **Frontend** | Next.js 14, React 18, TanStack Query, Zustand, Tailwind CSS, shadcn/ui, Framer Motion |
| **Backend** | NestJS 10, Prisma 5, Passport JWT, Swagger |
| **Datenbank** | PostgreSQL 16, Redis 7 |
| **Auth** | Keycloak 24, NextAuth 4 |
| **Infrastruktur** | Docker Compose, pnpm 9, Turborepo |
| **Sprachen** | TypeScript 5.6, i18n via next-intl (DE/EN) |
| **Testing** | Vitest (Frontend), Jest (Backend) |
---
## Projektstruktur
```
tOS/
├── apps/
│ ├── web/ # Next.js 14 Frontend (Port 3000)
│ │ ├── src/
│ │ │ ├── app/[locale]/ # App Router mit i18n
│ │ │ │ ├── (auth)/ # Geschuetzte Routen
│ │ │ │ └── login/ # Oeffentliche Login-Seite
│ │ │ ├── components/ # React-Komponenten (ui, layout, dashboard, hr, lean)
│ │ │ ├── hooks/ # Custom Hooks (hr, lean, integrations)
│ │ │ ├── lib/ # Utilities, API-Client, Auth-Config
│ │ │ ├── stores/ # Zustand State Management
│ │ │ └── types/ # TypeScript-Typen
│ │ └── messages/ # i18n Uebersetzungen (de.json, en.json)
│ │
│ └── api/ # NestJS 10 Backend (Port 3001)
│ ├── src/
│ │ ├── auth/ # JWT Guards, Rollen, Permissions
│ │ ├── modules/
│ │ │ ├── hr/ # Mitarbeiter, Abwesenheiten, Zeiterfassung
│ │ │ ├── lean/ # Skill Matrix, Morning Meeting, 3S-Planung
│ │ │ ├── integrations/ # 7 API-Connectoren + Sync
│ │ │ ├── dashboard/ # Widget-Daten & Statistiken
│ │ │ ├── departments/# Abteilungsverwaltung
│ │ │ └── audit/ # Audit-Logging
│ │ └── common/ # Filter, Interceptors, Pipes
│ └── prisma/ # Schema, Migrations, Seed
├── packages/
│ └── shared/ # Gemeinsame Typen & Utilities
├── docker/
│ ├── docker-compose.yml # PostgreSQL, Redis, Keycloak
│ ├── keycloak/ # Realm-Export & Config
│ └── .env.example
└── turbo.json # Turborepo Build-Pipeline
```
---
## Voraussetzungen
| Software | Version |
|----------|---------|
| **Node.js** | >= 20.0.0 |
| **pnpm** | >= 9.0.0 |
| **Docker** | >= 24.0 |
| **Docker Compose** | >= 2.20 |
---
## Installation & Setup
### 1. Repository klonen
```bash
git clone https://github.com/Tradeo-GmbH/tOS.git
cd tOS
```
### 2. Dependencies installieren
```bash
pnpm install
```
### 3. Environment-Variablen einrichten
```bash
# Root
cp .env.example .env
# Docker
cp docker/.env.example docker/.env
# Backend
cp apps/api/.env.example apps/api/.env
# Frontend
cp apps/web/.env.example apps/web/.env
```
Die `.env.example`-Dateien enthalten alle benoetigten Variablen mit Entwicklungs-Standardwerten.
### 4. Docker-Services starten
```bash
pnpm docker:up
```
Startet PostgreSQL (5432), Redis (6379) und Keycloak (8080).
### 5. Anwendungs-Datenbank erstellen
Keycloak verwendet die Datenbank `tos_db`. Die Anwendung benoetigt eine separate Datenbank `tos_app`:
```bash
docker exec -it tos-postgres psql -U tos_user -d tos_db -c "CREATE DATABASE tos_app;"
```
### 6. Datenbank-Schema anwenden
```bash
pnpm db:push
```
### 7. Datenbank mit Stammdaten befuellen
```bash
pnpm db:seed
```
Erstellt Rollen (admin, hr-manager, team-lead, employee), Abteilungen und einen Entwickler-Admin-Benutzer.
### 8. Entwicklungsserver starten
```bash
pnpm dev
```
| Service | URL |
|---------|-----|
| **Frontend** | http://localhost:3000 |
| **Backend API** | http://localhost:3001/api |
| **Swagger Docs** | http://localhost:3001/api/docs |
| **Keycloak Admin** | http://localhost:8080 (admin/admin) |
---
## Scripts
### Entwicklung
| Script | Beschreibung |
|--------|-------------|
| `pnpm dev` | Alle Apps im Dev-Modus starten |
| `pnpm dev:web` | Nur Frontend starten |
| `pnpm dev:api` | Nur Backend starten |
| `pnpm build` | Alle Apps bauen |
| `pnpm typecheck` | TypeScript-Pruefung |
| `pnpm lint` | Code-Linting |
| `pnpm format` | Code-Formatierung |
### Datenbank
| Script | Beschreibung |
|--------|-------------|
| `pnpm db:generate` | Prisma Client generieren |
| `pnpm db:push` | Schema in die Datenbank pushen |
| `pnpm db:migrate` | Migrationen ausfuehren |
| `pnpm db:seed` | Stammdaten einspielen |
| `pnpm db:studio` | Prisma Studio oeffnen |
### Docker
| Script | Beschreibung |
|--------|-------------|
| `pnpm docker:up` | Container starten |
| `pnpm docker:down` | Container stoppen |
| `pnpm docker:logs` | Container-Logs anzeigen |
| `pnpm docker:reset` | Container + Volumes zuruecksetzen |
### Tests
| Script | Beschreibung |
|--------|-------------|
| `pnpm test` | Alle Tests ausfuehren |
| `pnpm test:watch` | Tests im Watch-Modus |
| `pnpm test:e2e` | End-to-End-Tests |
---
## Architektur
### Authentifizierung
```
Browser -> Next.js (NextAuth) -> Keycloak (OAuth2/OIDC)
|
v
Browser -> Next.js -> NestJS API (JWT Guard) -> Keycloak (Token-Validierung)
```
- **Frontend:** NextAuth mit Keycloak-Provider (JWT-Strategie)
- **Backend:** Passport-JWT mit globalem Guard (`@Public()` zum Deaktivieren)
- **Guard-Kette:** JWT -> Rollen -> Permissions
### Rollen-System
| Rolle | Beschreibung |
|-------|-------------|
| `admin` | Vollzugriff auf alle Bereiche |
| `hr-manager` | HR-Verwaltung + LEAN-Zugriff |
| `manager` | Abteilungsuebergreifende Sicht |
| `department_head` | Abteilungsleitung |
| `team-lead` | Teamleitung mit direkten Reports |
| `employee` | Standard-Mitarbeiterzugriff |
### Permissions
Feingranulare Berechtigungen pro Modul (z.B. `EMPLOYEES_VIEW`, `ABSENCES_APPROVE`, `INTEGRATIONS_MANAGE`). Jede Rolle hat ein vorkonfiguriertes Permission-Set, das ueber die Datenbank angepasst werden kann.
### Frontend-Architektur
- **App Router** mit `[locale]` Segment fuer i18n
- **Server/Client Split:** `page.tsx` (Server) -> `*-content.tsx` (Client)
- **State Management:** Zustand (UI-State), TanStack Query (Server-State)
- **Component Library:** shadcn/ui auf Radix UI Basis
---
## Module
### Dashboard
Anpassbares Widget-Grid mit Drag & Drop (dnd-kit). Verfuegbare Widgets:
| Widget | Beschreibung |
|--------|-------------|
| Welcome | Begruessung mit Benutzerinfo |
| Clock | Digitale Uhr |
| Stats | KPI-Statistiken |
| Quick Actions | Schnellzugriff auf haeufige Aktionen |
| Calendar | Kalenderuebersicht |
| Activity | Aktivitaetsfeed |
| Orders | PlentyONE Bestellungen |
| Chat | Zulip Nachrichten |
| Tasks | Todoist Aufgaben |
| Tickets | FreeScout Tickets |
| Files | Nextcloud Dateien |
| Documents | ecoDMS Dokumente |
| GembaDocs | Audit-Dokumente |
### HR-Modul
- **Mitarbeiterverwaltung** - CRUD, Detailansicht, Neuanlage
- **Zeiterfassung** - Stempeluhr, Uebersichten pro Mitarbeiter
- **Abwesenheiten** - Antraege, Genehmigungen, Kalenderansicht
- **Organigramm** - Visuelle Unternehmensstruktur
### LEAN-Modul
- **3S-Planung** - Seiri (Sortieren), Seiton (Systematisieren), Seiso (Saeubern)
- **Morning Meeting** - SQCDM-Board (Safety, Quality, Cost, Delivery, Morale)
- **Skill Matrix** - Kompetenzerfassung und -bewertung pro Abteilung
### Integrations-Modul
7 externe Systeme mit einheitlicher Connector-Architektur:
| System | Typ | Funktionen |
|--------|-----|-----------|
| **PlentyONE** | ERP | Bestellungen, Artikel, Kontakte |
| **Zulip** | Chat | Nachrichten, Streams, Praesenz |
| **Todoist** | Tasks | Aufgaben, Projekte |
| **FreeScout** | Helpdesk | Tickets, Konversationen |
| **Nextcloud** | Cloud | Dateien, Ordner, Freigaben |
| **ecoDMS** | DMS | Dokumente, Klassifizierung |
| **GembaDocs** | Audit | Audits, Findings, Compliance |
### Admin-Bereich
- **Benutzerverwaltung** - Rollen zuweisen, Benutzer verwalten
- **Abteilungen** - Abteilungsstruktur pflegen
- **Integrations-Admin** - Credentials und Sync-Jobs verwalten
---
## API-Dokumentation
Die interaktive API-Dokumentation ist via Swagger UI verfuegbar:
```
http://localhost:3001/api/docs
```
Authentifizierung erfolgt ueber Bearer JWT-Token. Die API ist in folgende Bereiche unterteilt:
- `auth` - Authentifizierung
- `users` - Benutzerverwaltung
- `departments` - Abteilungen
- `hr/*` - HR-Endpunkte (Mitarbeiter, Abwesenheiten, Zeiterfassung)
- `lean/*` - LEAN-Endpunkte (3S, Morning Meeting, Skill Matrix)
- `integrations/*` - Integrations-Endpunkte (pro Connector)
- `dashboard` - Dashboard-Daten
---
## Datenbank
### Zwei-Datenbanken-Setup
| Datenbank | Verwendung |
|-----------|-----------|
| `tos_db` | Keycloak (wird automatisch erstellt) |
| `tos_app` | Anwendungsdaten (muss manuell erstellt werden) |
Beide laufen auf derselben PostgreSQL-Instanz.
### Schema-Uebersicht
| Bereich | Modelle |
|---------|---------|
| **Core** | User, Department, Role, UserRole |
| **HR** | Employee, TimeEntry, Absence, EmployeeReview, OnboardingTask |
| **LEAN** | S3Plan, S3Category, S3Status, MorningMeeting, MorningMeetingTopic, MorningMeetingAction, Skill, SkillMatrixEntry |
| **Integrations** | IntegrationCredential, IntegrationSyncHistory |
| **System** | UserPreference, AuditLog |
### Prisma Studio
```bash
pnpm db:studio
```
Oeffnet eine Web-UI zur direkten Datenbank-Inspektion unter http://localhost:5555.
---
## Keycloak
### Realm-Konfiguration
Der Realm `tOS` wird beim Start automatisch aus `docker/keycloak/realm-export.json` importiert.
### Clients
| Client | Typ | Verwendung |
|--------|-----|-----------|
| `tos-frontend` | Confidential | NextAuth (Frontend-Authentifizierung) |
| `tos-backend` | Confidential | NestJS (Service-Account, Token-Validierung) |
### Rollen & Gruppen
**Realm-Rollen:** admin, hr-manager, manager, department_head, team-lead, employee
**Gruppen:** Administrators, Management, sowie Abteilungen (Sales, Accounting, Warehouse, Logistics, Engineering, IT, Executive, HR, Procurement)
### Admin-Konsole
```
http://localhost:8080
Benutzer: admin
Passwort: admin
```
---
## Umgebungsvariablen
### Docker (`docker/.env`)
| Variable | Beschreibung | Standard |
|----------|-------------|----------|
| `POSTGRES_USER` | Datenbankbenutzer | `tos_user` |
| `POSTGRES_PASSWORD` | Datenbankpasswort | `tos_secret_password` |
| `POSTGRES_DB` | Keycloak-Datenbank | `tos_db` |
| `KEYCLOAK_ADMIN` | Keycloak-Admin | `admin` |
| `KEYCLOAK_ADMIN_PASSWORD` | Keycloak-Admin-Passwort | `admin` |
### Backend (`apps/api/.env`)
| Variable | Beschreibung |
|----------|-------------|
| `DATABASE_URL` | PostgreSQL Connection String (tos_app) |
| `PORT` | API-Port (3001) |
| `JWT_SECRET` | JWT-Signaturschluessel |
| `KEYCLOAK_URL` | Keycloak-URL |
| `KEYCLOAK_REALM` | Realm-Name (tOS) |
| `KEYCLOAK_CLIENT_ID` | Backend-Client-ID |
| `KEYCLOAK_CLIENT_SECRET` | Backend-Client-Secret |
| `REDIS_HOST` / `REDIS_PORT` | Redis-Verbindung |
| `ENCRYPTION_KEY` | AES-256-Schluessel (32 Bytes) |
| `SWAGGER_ENABLED` | Swagger UI aktivieren |
| `ENABLE_SYNC_JOBS` | Integrations-Sync aktivieren |
### Frontend (`apps/web/.env`)
| Variable | Beschreibung |
|----------|-------------|
| `NEXT_PUBLIC_API_URL` | Backend-API-URL |
| `NEXT_PUBLIC_APP_URL` | Frontend-URL |
| `NEXTAUTH_URL` | NextAuth-Callback-URL |
| `NEXTAUTH_SECRET` | NextAuth-Signaturschluessel |
| `KEYCLOAK_CLIENT_ID` | Frontend-Client-ID |
| `KEYCLOAK_CLIENT_SECRET` | Frontend-Client-Secret |
| `KEYCLOAK_ISSUER` | Keycloak Issuer-URL |
Vollstaendige Beispiele befinden sich in den jeweiligen `.env.example`-Dateien.
---
## Lizenz
Proprietary - Tradeo GmbH. Alle Rechte vorbehalten.

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

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

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

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

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

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

7
apps/api/.prettierrc Normal file
View File

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

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

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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