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>
This commit is contained in:
2026-05-24 11:29:44 +02:00
parent eb122802b2
commit ada1b51f33
6 changed files with 2882 additions and 0 deletions
+461
View File
@@ -0,0 +1,461 @@
# 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.