Files
patrick ada1b51f33 docs: vollständige Projektdokumentation hinzugefügt
- docs/api.md: komplette API-Referenz (1375 Zeilen, alle Endpunkte)
- docs/architecture.md: Tech-Stack, DB-Schema, RLS-Architektur, Auth-Flow
- docs/deployment.md: Setup, nginx, systemd, update.sh, Backup/Rollback
- docs/development.md: Dev-Setup, Test-Workflow, Code-Konventionen, Fallstricke

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:29:44 +02:00

462 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TimeMaster Architektur-Dokumentation
Stand: 2026-05-24
---
## Tech-Stack
| Schicht | Technologie | Version |
|---------|-------------|---------|
| Backend | Python / FastAPI | 3.12 / 0.115+ |
| ORM | SQLAlchemy async | 2.x |
| Datenbank | PostgreSQL | 16 |
| Cache / Sessions | Redis | 7 |
| Frontend | React + TypeScript | 18 / 5 |
| CSS-Framework | Tailwind CSS | 3 |
| Build-Tool | Vite | 5 |
| Prozess-Manager | systemd | |
| Reverse Proxy | nginx | 1.24+ |
| Migrationen | Alembic | 1.x |
| Authentifizierung | JWT (python-jose) + bcrypt | |
| 2FA | pyotp (TOTP / RFC 6238) | |
| E-Mail | Resend.com HTTP API | |
| Tests | pytest + pytest-asyncio + httpx | |
---
## Verzeichnisstruktur
```
/opt/timemaster/
├── backend/
│ ├── app/
│ │ ├── core/
│ │ │ ├── config.py # Pydantic Settings (liest .env)
│ │ │ ├── database.py # Engine, AsyncSessionLocal, Base
│ │ │ ├── dependencies.py # FastAPI Depends: get_current_user, require_role
│ │ │ ├── limiter.py # slowapi Rate-Limiter
│ │ │ └── security.py # JWT, Passwort-Hash, Token-Utilities
│ │ ├── models/
│ │ │ ├── company.py # Company, Department
│ │ │ ├── user.py # User, UserRole, AuthProvider
│ │ │ ├── session.py # Refresh-Token-Sessions
│ │ │ ├── password_reset.py # Passwort-Reset-Tokens
│ │ │ ├── audit_log.py # AuditLog
│ │ │ ├── absence.py # Absence, AbsenceStatus
│ │ │ ├── absence_type.py # AbsenceType
│ │ │ ├── vacation_balance.py
│ │ │ ├── public_holiday.py # Feiertage
│ │ │ ├── overtime_balance.py
│ │ │ ├── time_entry.py # TimeEntry, WorkSchedule
│ │ │ ├── work_schedule.py
│ │ │ ├── kiosk_device.py
│ │ │ ├── caldav_config.py # CalDAV-Konfig (Firma + User)
│ │ │ ├── ldap_config.py
│ │ │ ├── smtp_config.py
│ │ │ └── project.py # (derzeit nicht aktiv, Migration 0014 entfernt)
│ │ ├── schemas/ # Pydantic v2 Ein-/Ausgabe-Schemas
│ │ │ ├── auth.py
│ │ │ ├── user.py
│ │ │ ├── absence.py
│ │ │ ├── time_entry.py
│ │ │ └── ...
│ │ ├── routers/ # FastAPI APIRouter ein File pro Modul
│ │ │ ├── auth.py # /api/v1/auth/*
│ │ │ ├── users.py # /api/v1/users/*
│ │ │ ├── companies.py # /api/v1/companies/*
│ │ │ ├── absences.py # /api/v1/absences/*
│ │ │ ├── time_entries.py # /api/v1/time/*
│ │ │ ├── kiosk.py # /api/v1/kiosk/*
│ │ │ ├── reports.py # /api/v1/reports/*
│ │ │ ├── audit.py # /api/v1/audit-logs/
│ │ │ ├── caldav.py
│ │ │ ├── ldap.py
│ │ │ ├── smtp.py
│ │ │ └── busylight.py
│ │ └── services/ # Business-Logik kein HTTP-Wissen
│ │ ├── auth_service.py
│ │ ├── user_service.py
│ │ ├── absence_service.py
│ │ ├── time_service.py # inkl. ArbZG-Checks
│ │ ├── email_service.py
│ │ ├── ldap_service.py
│ │ └── caldav_service.py
│ ├── migrations/
│ │ ├── env.py # Alembic-Konfiguration
│ │ └── versions/ # 00010024 Migrationsdateien
│ ├── tests/
│ │ ├── conftest.py # pytest-Fixtures (session-scope, RLS-Setup)
│ │ ├── test_auth.py
│ │ ├── test_users.py
│ │ ├── test_absences.py
│ │ └── ...
│ ├── .env # Umgebungsvariablen (nie committen)
│ ├── alembic.ini
│ └── requirements.txt
├── frontend/
│ ├── src/
│ │ ├── api/ # Axios-Client-Funktionen pro Ressource
│ │ ├── components/
│ │ │ ├── Layout.tsx # Haupt-Layout mit Navigation
│ │ │ ├── Modal.tsx
│ │ │ ├── ProtectedRoute.tsx
│ │ │ ├── Spinner.tsx
│ │ │ └── absences/ # Abwesenheits-Unterkomponenten
│ │ ├── context/ # React Context: AuthContext
│ │ ├── hooks/ # Custom Hooks (useAuth, useAbsences, ...)
│ │ ├── pages/ # Eine Datei pro Seite/Route
│ │ │ ├── LoginPage.tsx
│ │ │ ├── DashboardPage.tsx
│ │ │ ├── TimeTrackingPage.tsx
│ │ │ ├── AbsencesPage.tsx
│ │ │ ├── UsersPage.tsx
│ │ │ ├── ReportsPage.tsx
│ │ │ ├── ProfilePage.tsx
│ │ │ ├── CompanySettingsPage.tsx
│ │ │ ├── KioskDevicesPage.tsx
│ │ │ ├── AuditLogPage.tsx
│ │ │ └── ...
│ │ ├── types/ # TypeScript-Interfaces
│ │ ├── utils/ # Hilfsfunktionen
│ │ ├── App.tsx # Router-Konfiguration
│ │ └── main.tsx
│ ├── dist/ # Build-Output (von nginx ausgeliefert)
│ └── package.json
├── nginx.conf
├── timemaster.service
├── setup_server.sh
└── update.sh
```
---
## Datenbankschema
### Tabellenübersicht und Beziehungen
```
companies
├──< departments (company_id)
├──< users (company_id)
│ ├──< sessions (user_id) -- Refresh-Token-Sessions
│ ├──< password_resets (user_id)
│ ├──< time_entries (user_id)
│ ├──< absences (user_id)
│ │ └──> absence_types (type_id)
│ ├──< vacation_balances (user_id)
│ └── work_schedule_id → work_schedules
├──< work_schedules (company_id)
├──< kiosk_devices (company_id)
├──< audit_logs (company_id)
├──< overtime_balances (company_id)
├── ldap_configs
├── smtp_configs
└── caldav_company_configs
```
### Wichtige Tabellen
**companies** Mandanten-Stammdaten
- `id` UUID PK
- `name`, `slug` (URL-freundlich, unique)
- `sick_note_required_after_days` INTEGER DEFAULT 3
- `personnel_number_required` BOOLEAN DEFAULT FALSE
- `personnel_number_mode` VARCHAR(10) DEFAULT 'manual'
- `personnel_number_next` INTEGER DEFAULT 1
**users**
- `id`, `company_id`, `department_id`
- `email` unique, `password_hash`, `first_name`, `last_name`
- `role` ENUM: SUPER_ADMIN | COMPANY_ADMIN | HR | MANAGER | EMPLOYEE
- `auth_provider` ENUM: local | ldap
- `personnel_number` VARCHAR(50) nur Ziffern, partial unique per Firma
- `totp_secret`, `totp_enabled` TOTP 2FA
- `kiosk_pin_hash`, `kiosk_qr_token`
- `can_manual_time_entry` BOOLEAN DEFAULT FALSE
- `is_active` BOOLEAN kein Hard-Delete
- `created_at`
**sessions** Refresh-Token-Verwaltung
- `id`, `user_id`, `token_hash` (gehashter Refresh-Token)
- `expires_at`, `created_at`, `ip_address`, `user_agent`
- Bei Rotation: altes Token wird gelöscht, neues wird angelegt
**absence_types**
- `company_id`, `name`, `code`
- `affects_vacation` BOOLEAN zieht vom Urlaubskonto ab
- `requires_approval` BOOLEAN
- `certificate_after_days` INTEGER NULL per-Typ-Override für AU-Pflicht
- `color` UI-Darstellung
**absences**
- `user_id`, `type_id`
- `start_date`, `end_date`, `half_day_start`, `half_day_end`
- `working_days` NUMERIC(5,1)
- `status` ENUM: pending | approved | rejected | cancelled
- `approved_by`, `substitute_id`
- `certificate_required_by` DATE auto-berechnet bei SICK-Absences
- `certificate_received` BOOLEAN DEFAULT FALSE
- `meta` JSONB flexible Zusatzdaten (Weiterbildung, Dienstreise etc.)
**vacation_balances**
- `user_id`, `year`
- `base_days`, `special_days`, `carried_over_days`, `used_days`
- `comment`
**time_entries**
- `user_id`, `date`
- `start_time`, `end_time`, `break_minutes`
- `working_minutes` COMPUTED
- `status` ENUM: draft | submitted | approved | rejected
- `source` ENUM: manual | stamp | import
- `arbzg_warning` ArbZG-Verletzungs-Flag
**work_schedules**
- `company_id`, `name`
- `days_per_week`, wöchentliche Soll-Stunden
- Für ArbZG-Berechnung und Überstunden-Balance
**kiosk_devices**
- `company_id`, `name`, `location`
- `device_token_hash` aktuell TOKEN-basiert (agent-08: Ed25519-Upgrade geplant)
- `is_active` wird in 0021 durch `status` ENUM ersetzt
**audit_logs**
- `company_id`, `user_id`
- `action`, `entity_type`, `entity_id`
- `old_value`, `new_value` JSONB
- `ip_address`, `created_at`
**public_holidays** global (keine RLS), pro Bundesland/Datum
### Alembic-Migrationskette
```
0001_initial companies, departments, users, sessions, password_resets, audit_logs
0002_time_entries time_entries, work_schedules
0003_absences absence_types, absences, vacation_balances, public_holidays
0004_ldap ldap_configs
0005_extensions Erweiterungsfelder (company/user)
0006_smtp smtp_configs
0007_caldav_and_fixes caldav_company_configs, caldav_user_configs; Bugfixes
0008_ldap_tls_verify ldap_configs.tls_verify Spalte
0009_absence_correction absences.correction_note
0010_public_holidays public_holidays.federal_state
0011_caldav_name_format caldav Name-Format-Felder
0012_caldav_template CalDAV-Template + users.kuerzel
0013_projects projects Tabelle (temporär)
0014_remove_projects projects Tabelle entfernt (Feature verschoben)
0015_totp users.totp_secret, totp_enabled
0016_vacation_special vacation_balances.special_days + Feiertagskalender-Erweiterungen
[0017 übersprungen] Nummer wurde nie in die Alembic-Kette aufgenommen
0018_kiosk_devices kiosk_devices Tabelle
0019_manual_time_entry users.can_manual_time_entry
0020_personnel_number users.personnel_number, companies.personnel_number_*
0021_kiosk_security TODO (agent-08): Ed25519 Public-Key, Enrollment, Heartbeat
0022_sick_note_config companies.sick_note_required_after_days
0023_busylight_pull companies.busylight_pull_token
0024_row_level_security PostgreSQL RLS auf allen Tabellen
```
Hinweis: Migration 0017 wurde übersprungen und existiert nicht in der Kette. Neuen Migrationen folgen auf 0024.
---
## Row Level Security (RLS)
Seit Migration 0024 setzt TimeMaster PostgreSQL Row Level Security für vollständige Mandanten-Isolation auf Datenbankebene ein. Selbst bei einem Fehler in der Applikationsschicht kann kein User Daten einer anderen Firma sehen.
### Prinzip
Jede Tabelle hat vier Policies (SELECT, INSERT, UPDATE, DELETE). Die Policies prüfen zwei PostgreSQL-Session-Variablen:
```sql
-- Bypass-Flag: 'on' = alle Zeilen sichtbar (für unauthentifizierte Endpunkte)
app.bypass_rls
-- Company-ID des eingeloggten Users (UUID als Text)
app.company_id
```
### Policy-Typen
**COMPANY_COL** Tabellen mit direkter `company_id`-Spalte:
`absence_types, audit_logs, caldav_company_configs, departments, kiosk_devices, ldap_configs, overtime_balances, smtp_configs, users, work_schedules`
```sql
(COALESCE(current_setting('app.bypass_rls', true), 'off') = 'on')
OR company_id = NULLIF(current_setting('app.company_id', true), '')::uuid
```
**USER_JOIN** Tabellen ohne direkte `company_id`, verknüpft über `user_id`:
`absences, caldav_user_configs, password_resets, sessions, time_entries, vacation_balances`
```sql
(bypass) OR user_id IN (SELECT id FROM users WHERE company_id = ...)
```
**companies** selbst Policy auf `id` (die Firma darf nur sich selbst sehen):
```sql
(bypass) OR id = NULLIF(current_setting('app.company_id', true), '')::uuid
```
### Ablauf pro HTTP-Request
1. `get_db()` öffnet eine AsyncSession, setzt `SET LOCAL app.bypass_rls = 'on'`
2. `get_current_user()` Dependency lädt den User (bypass noch aktiv)
3. Falls der User kein SUPER_ADMIN ist:
- `SET LOCAL app.company_id = '<uuid>'`
- `SET LOCAL app.bypass_rls = 'off'`
4. Alle folgenden Queries in dieser Transaktion sind automatisch auf die Firma des Users gefiltert
5. SUPER_ADMIN behält bypass aktiv kann alle Firmen sehen
### expire_on_commit=False
Die `AsyncSessionLocal` ist mit `expire_on_commit=False` konfiguriert. Das ist zwingend notwendig: Nach einem `db.commit()` würde SQLAlchemy sonst alle Attribute als "expired" markieren und beim nächsten Zugriff einen neuen SELECT auslösen. Da der RLS-Kontext (`SET LOCAL`) transaktionsgebunden ist, wäre dieser zweite SELECT ggf. ohne Kontext und würde entweder leer zurückkehren oder scheitern.
**Konsequenz:** `db.refresh(obj)` nach einem `commit()` ist verboten. Stattdessen beim `flush()` vor dem Commit die benötigten Werte sichern.
---
## Authentifizierungs-Flow
### Standard-Login (JWT + Refresh-Token)
```
Client FastAPI PostgreSQL / Redis
│ │ │
│── POST /api/v1/auth/login ─────>│ │
│ { email, password } │── SELECT user WHERE email ────>│
│ │<─ User-Objekt ─────────────────│
│ │── verify_password() │
│ │── INSERT sessions (token_hash)─>│
│<── { access_token, refresh_token } │
│ (JWT 30min, opaque 30 Tage) │
│ │ │
│── GET /api/v1/... (Bearer)─────>│ │
│ │── decode_access_token() │
│ │── db.get(User, user_id) │
│ │── SET LOCAL app.company_id │
│ │── SET LOCAL app.bypass_rls=off │
│<── Response ───────────────────│ │
│ │ │
│── POST /api/v1/auth/refresh────>│ │
│ { refresh_token } │── hash_token(refresh_token) │
│ │── SELECT sessions WHERE hash──>│
│ │── DELETE old session │
│ │── INSERT new session │
│<── { neue access_token, refresh_token } │
```
- Access Token: JWT (HS256), Payload: `sub` = user_id (UUID), `exp`
- Refresh Token: 64-byte-Zufallsstring, in DB nur als SHA-256-Hash gespeichert
- Rotation: Jedes Refresh erzeugt ein neues Token-Paar. Das alte Refresh-Token wird sofort invalidiert.
### TOTP / 2FA-Flow
Wenn `user.totp_enabled = True`:
```
POST /auth/login → { totp_required: true, partial_token: "<JWT>" }
POST /auth/login/totp { partial_token, totp_code }
↓ pyotp.TOTP(secret).verify(code, valid_window=1)
{ access_token, refresh_token } (vollständige Session)
```
TOTP-Setup (Profil-Seite):
1. `GET /auth/totp/setup``{ secret, qr_code_url }` (base32-Secret, otpauth:// URI)
2. User scannt QR in Authenticator-App
3. `POST /auth/totp/confirm { code }` verifiziert ersten Code, setzt `totp_enabled = True`
4. `POST /auth/totp/disable { password, code }` deaktiviert 2FA nach Bestätigung
### Rollen-Hierarchie
```
SUPER_ADMIN
└── COMPANY_ADMIN (Vollzugriff eigene Firma, sieht ALLE User der Firma)
├── HR (Personalakten, Reports, Attest-Verwaltung)
├── MANAGER (Genehmigungen für eigenes Team)
└── EMPLOYEE (Eigene Daten, eigene Anträge)
```
SUPER_ADMIN ist der Plattform-Betreiber und hat RLS-Bypass. COMPANY_ADMIN registriert sich selbst beim `/auth/register`-Endpunkt und legt damit automatisch eine neue Firma an.
---
## Datenfluss: Zeiterfassung
```
POST /time/stamp-in
→ time_service.stamp_in(user_id, db)
→ Prüfe: kein offener Eintrag für heute
→ INSERT time_entries(user_id, date, start_time, status='draft')
POST /time/stamp-out
→ time_service.stamp_out(user_id, db)
→ Hole offenen Eintrag
→ Berechne working_minutes = end - start - break_minutes
→ ArbZG-Check:
if working_minutes > 480: warn (max 8h; bis 600 möglich mit Warnung)
if working_minutes > 360 and break_minutes < 30: warn (Pause fehlt ab 6h)
if working_minutes > 540 and break_minutes < 45: warn (Pause fehlt ab 9h)
→ UPDATE time_entries(end_time, working_minutes, arbzg_warning)
PATCH /time/entries/{id}/approve [MANAGER/HR/ADMIN]
→ UPDATE status = 'approved'
→ INSERT audit_log
PATCH /time/entries/{id}/reject [MANAGER/HR/ADMIN]
→ UPDATE status = 'rejected', rejection_reason
```
---
## Datenfluss: Abwesenheiten
```
POST /absences (Antrag stellen)
→ absence_service.create(data, current_user, db)
→ Berechne working_days (Wochentage - Feiertage)
→ Prüfe Urlaubskonto falls affects_vacation = True
→ Auto-calculate certificate_required_by falls AbsenceType.code = 'SICK'
(start_date + sick_note_required_after_days - 1)
→ INSERT absences(status='pending')
→ INSERT audit_log
POST /absences/{id}/approve [MANAGER/HR/ADMIN]
→ UPDATE absences.status = 'approved', approved_by = current_user.id
→ Falls affects_vacation: UPDATE vacation_balances.used_days += working_days
→ INSERT audit_log
POST /absences/quick-sick [EMPLOYEE]
→ Holt ersten aktiven SICK-AbsenceType der Firma
→ Legt Absence mit status='approved' an (keine Genehmigung nötig)
→ Berechnet certificate_required_by
PATCH /absences/{id}/certificate [HR/ADMIN]
→ UPDATE absences.certificate_received = True
→ INSERT audit_log
GET /absences/sick-stats?user_id=&ref_date=
→ Berechnet Bradford-Faktor: S² × D
S = Episoden (Krankheitsepisoden im rollenden 12-Monats-Fenster)
D = Tage (Gesamtkranktage im selben Fenster)
```
---
## Mandanten-Isolation
Mandanten-Isolation ist in TimeMaster dreischichtig implementiert:
1. **Applikationsschicht**: Jeder Endpunkt, der Daten schreibt oder liest, wird über `CurrentUser` Dependency abgesichert. Beim Anlegen von Objekten wird immer `company_id = current_user.company_id` gesetzt. Beim Lesen filtert der Service aktiv nach `company_id`.
2. **Datenbankschicht (RLS)**: PostgreSQL Row Level Security (Migration 0024) verhindert Datenzugriff über Firmengrenzen auf DB-Ebene. Auch direkte psql-Verbindungen mit dem App-User werden gefiltert (FORCE ROW LEVEL SECURITY).
3. **Session-Kontext**: `SET LOCAL app.company_id` gilt nur für die aktuelle Transaktion. Jede neue Anfrage beginnt mit einem frischen Kontext.
SUPER_ADMIN ist die einzige Rolle mit `bypass_rls = 'on'` und kann alle Firmen sehen. Diese Rolle ist ausschließlich für den Plattform-Betreiber gedacht.