diff --git a/DEVLOG.md b/DEVLOG.md index 51dfcad..99e084f 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -654,3 +654,67 @@ Keine Commits in dieser Session. - backend/tests/conftest.py | 4 + --- +## 2026-05-23 22:00 – 22:35 (34m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +- dd3e069 fix: router db.refresh() nach commit bricht RLS-Kontext + +### Geänderte Dateien +- DEVLOG.md | 16 ++ +- backend/app/routers/absences.py | 10 - +- backend/app/routers/caldav.py | 2 - +- backend/app/routers/kiosk.py | 3 - +- backend/app/routers/ldap.py | 2 - +- backend/app/routers/projects.py | 2 - +- backend/app/routers/smtp.py | 1 - +- backend/app/routers/time_entries.py | 10 - +- backend/migrations/env.py | 4 - +- .../migrations/versions/0024_row_level_security.py | 223 +++++---------------- +- backend/tests/conftest.py | 48 +++++ +- backend/tests/test_rls.py | 190 ++++++++++++++++++ + +--- +## 2026-05-23 22:43 – 22:52 (9m) +**Beschreibung:** Claude Code Session +**Projekt:** frontend + +### Commits +- eb12280 fix: 8 pre-existing Test-Fehler behoben + +### Geänderte Dateien +- backend/app/services/absence_service.py | 3 ++- +- backend/tests/test_absences.py | 30 +++++++++++++++++++++++++----- +- backend/tests/test_reports.py | 2 +- +- backend/tests/test_time.py | 25 ++++++++++++++++++++++--- + +--- +## 2026-05-23 23:08 – 23:08 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- backend/app/services/absence_service.py | 3 ++- +- backend/tests/test_absences.py | 30 +++++++++++++++++++++++++----- +- backend/tests/test_reports.py | 2 +- +- backend/tests/test_time.py | 25 ++++++++++++++++++++++--- + +--- +## 2026-05-23 23:12 – 10:21 (11h 09m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- backend/app/services/absence_service.py | 3 ++- +- backend/tests/test_absences.py | 30 +++++++++++++++++++++++++----- +- backend/tests/test_reports.py | 2 +- +- backend/tests/test_time.py | 25 ++++++++++++++++++++++--- + +--- diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..e774d4d --- /dev/null +++ b/docs/api.md @@ -0,0 +1,1375 @@ +# TimeMaster – API-Referenz + +Alle Endpunkte sind unter dem Präfix `/api/v1` erreichbar. +Authentifizierung: `Authorization: Bearer ` (JWT, 30 min gültig). +In der Entwicklungsumgebung ist die interaktive Dokumentation unter `/docs` (Swagger UI) und `/redoc` verfügbar. + +--- + +## Inhaltsverzeichnis + +1. [System](#system) +2. [Authentifizierung & 2FA](#authentifizierung--2fa) +3. [Benutzer](#benutzer) +4. [Firmen & Abteilungen](#firmen--abteilungen) +5. [Zeiterfassung](#zeiterfassung) +6. [Abwesenheiten](#abwesenheiten) +7. [Dashboard & Reports](#dashboard--reports) +8. [Audit-Log](#audit-log) +9. [Kiosk-Geräte](#kiosk-geräte) +10. [LDAP-Integration](#ldap-integration) +11. [SMTP-Konfiguration](#smtp-konfiguration) +12. [CalDAV-Integration](#caldav-integration) +13. [Busylight-Integration](#busylight-integration) +14. [Kimai-Import](#kimai-import) + +--- + +## Rollen-Hierarchie + +| Rolle | Beschreibung | +|-------|-------------| +| `SUPER_ADMIN` | Systemadministrator, sieht alle Firmen, umgeht RLS | +| `COMPANY_ADMIN` | Firmenadministrator, verwaltet alles in seiner Firma | +| `HR` | Personalverwaltung, genehmigt Abwesenheiten, sieht alle Mitarbeiter | +| `MANAGER` | Teamleiter, genehmigt Einträge/Abwesenheiten seiner Mitarbeiter | +| `EMPLOYEE` | Normaler Mitarbeiter, sieht nur eigene Daten | + +--- + +## System + +### `GET /health` + +Gesundheitsprüfung ohne Authentifizierung. + +**Response:** +```json +{ "status": "ok", "app": "TimeMaster", "env": "production" } +``` + +--- + +## Authentifizierung & 2FA + +Router-Präfix: `/auth` + +### `POST /auth/register` + +Neue Firma + Admin-Konto anlegen. + +- **Rate-Limit:** 3/Stunde +- **Erforderliche Rolle:** keine (öffentlich) + +**Request:** +```json +{ + "company_name": "Acme GmbH", + "email": "admin@acme.de", + "password": "Sicher123", + "first_name": "Max", + "last_name": "Mustermann" +} +``` + +**Response `201`:** `TokenResponse` (siehe unten) + +**Fehler:** +- `400` – E-Mail bereits registriert +- `429` – Rate-Limit überschritten + +--- + +### `POST /auth/login` + +Einloggen mit E-Mail + Passwort. Gibt bei aktivem TOTP einen `partial_token` zurück. + +- **Rate-Limit:** 10/Minute +- **Erforderliche Rolle:** keine + +**Request:** +```json +{ "email": "user@example.de", "password": "Passwort123" } +``` + +**Response:** `TokenResponse` + +```json +{ + "access_token": "", + "refresh_token": "", + "token_type": "bearer", + "requires_totp": false, + "partial_token": null +} +``` + +Wenn `requires_totp: true`, wird `partial_token` zurückgegeben und `access_token`/`refresh_token` sind leer. Anschließend `POST /auth/totp/login` aufrufen. + +**Fehler:** +- `401` – Ungültige Anmeldedaten +- `403` – Konto deaktiviert + +--- + +### `POST /auth/refresh` + +Access Token erneuern (Token Rotation – der alte Refresh Token wird dabei ungültig). + +**Request:** `{ "refresh_token": "..." }` +**Response:** `TokenResponse` + +**Fehler:** `401` – Ungültiger oder abgelaufener Token + +--- + +### `POST /auth/logout` + +Sitzung beenden (Refresh Token invalidieren). + +**Request:** `{ "refresh_token": "..." }` +**Response:** `{ "message": "Logged out successfully" }` + +--- + +### `POST /auth/password-reset` + +Passwort-Reset anfordern (Link per E-Mail). + +- **Rate-Limit:** 3/Stunde +- **Erforderliche Rolle:** keine + +**Request:** `{ "email": "user@example.de" }` + +**Fehler:** `400` – Konto wird über LDAP verwaltet (Passwort-Reset nicht möglich) + +--- + +### `POST /auth/password-reset/confirm` + +Neues Passwort setzen mit Reset-Token aus der E-Mail. + +- **Rate-Limit:** 5/Stunde + +**Request:** +```json +{ "token": "", "new_password": "NeuesPasswort123" } +``` + +**Fehler:** `400` – Token ungültig/abgelaufen, Passwort zu schwach + +--- + +### `POST /auth/invite/accept` + +Einladungstoken einlösen und Konto aktivieren. + +**Request:** +```json +{ + "token": "", + "password": "MeinPasswort1", + "first_name": "Anna", + "last_name": "Beispiel" +} +``` + +**Response:** `UserOut` + +--- + +### `GET /auth/me` + +Eigenes Profil abrufen. + +- **Erforderliche Rolle:** beliebig (eingeloggt) + +**Response:** `UserOut` + +--- + +### `POST /auth/change-password` + +Passwort ändern (benötigt aktuelles Passwort). + +- **Erforderliche Rolle:** beliebig + +**Request:** +```json +{ "current_password": "Alt123", "new_password": "Neu456" } +``` + +**Validierung:** min. 8 Zeichen, 1 Großbuchstabe, 1 Ziffer + +--- + +### TOTP / Zwei-Faktor-Authentifizierung + +#### `POST /auth/totp/setup` + +TOTP-Secret generieren und `otpauth://` URI für den Authenticator zurückgeben. Noch nicht aktiviert. + +**Response:** `{ "secret": "BASE32...", "otpauth_uri": "otpauth://..." }` + +--- + +#### `POST /auth/totp/setup/save` + +Generiertes Secret in DB persistieren (ohne Aktivierung). + +--- + +#### `POST /auth/totp/confirm` + +Ersten Code bestätigen und 2FA aktivieren. + +**Request:** `{ "code": "123456" }` + +**Fehler:** `400` – Kein Secret vorhanden / Ungültiger Code + +--- + +#### `POST /auth/totp/disable` + +2FA deaktivieren. Benötigt aktuelles Passwort + gültigen TOTP-Code. + +**Request:** +```json +{ "password": "MeinPasswort", "code": "123456" } +``` + +--- + +#### `POST /auth/totp/login` + +Zweiter Login-Schritt bei aktivem TOTP: `partial_token` + TOTP-Code → vollständige Tokens. + +- **Rate-Limit:** 10/Minute + +**Request:** +```json +{ "partial_token": "", "code": "123456" } +``` + +**Response:** `TokenResponse` + +**Fehler:** `401` – Ungültiger partial_token | `400` – Ungültiger Code + +--- + +## Benutzer + +Router-Präfix: `/users` + +### `GET /users/` + +Alle Benutzer der eigenen Firma auflisten. + +- **Erforderliche Rolle:** MANAGER, HR, COMPANY_ADMIN, SUPER_ADMIN +- **Query-Parameter:** + - `skip` (int, default 0) + - `limit` (int, default 50, max 500) + - `active_only` (bool, default true) + - `search` (str, max 100) – sucht in Name, E-Mail und Personalnummer + +**Response:** +```json +{ "total": 42, "items": [ /* UserOut */ ] } +``` + +--- + +### `POST /users/invite` + +Neuen Benutzer einladen (E-Mail mit Einladungslink). + +- **Erforderliche Rolle:** COMPANY_ADMIN, SUPER_ADMIN + +**Request:** +```json +{ + "email": "mitarbeiter@example.de", + "first_name": "Maria", + "last_name": "Muster", + "role": "EMPLOYEE", + "department_id": null, + "personnel_number": "0042" +} +``` + +**Response `201`:** `UserOut` + +**Fehler:** `400` – E-Mail bereits vergeben | `422` – Personalnummer bereits belegt + +--- + +### `GET /users/me` + +Eigenes Profil (identisch mit `GET /auth/me`). + +--- + +### `GET /users/next-personnel-number` + +Nächste freie Personalnummer vorschlagen (Counter wird nicht erhöht). + +- **Erforderliche Rolle:** MANAGER, HR, COMPANY_ADMIN, SUPER_ADMIN + +**Response:** `{ "next": "0043" }` + +--- + +### `GET /users/by-personnel/{number}` + +Benutzer per Personalnummer suchen (Lookup für externe Integrationen). + +- **Erforderliche Rolle:** MANAGER, HR, COMPANY_ADMIN, SUPER_ADMIN + +**Fehler:** `404` – Nicht gefunden + +--- + +### `GET /users/import-template.csv` + +CSV-Vorlage für Massen-Import herunterladen. + +- **Erforderliche Rolle:** COMPANY_ADMIN, SUPER_ADMIN + +**Response:** CSV-Datei + +--- + +### `POST /users/import/preview` + +CSV-Import vorschauen (keine DB-Änderungen). + +- **Erforderliche Rolle:** COMPANY_ADMIN, SUPER_ADMIN +- **Content-Type:** `multipart/form-data`, Feld `file` + +**Response:** `UserImportResult` mit Zeilen-Status (create/reactivate/error) + +--- + +### `POST /users/import/apply` + +CSV-Import durchführen. + +- **Erforderliche Rolle:** COMPANY_ADMIN, SUPER_ADMIN + +**Verhalten:** +- E-Mail existiert aktiv → Fehler +- E-Mail existiert, aber deaktiviert → Reaktivieren mit neuen Daten +- Leere Personalnummer → Auto-Vergabe + +--- + +### `GET /users/{user_id}` + +Einzelnen Benutzer abrufen. + +- **Erforderliche Rolle:** MANAGER, HR, COMPANY_ADMIN, SUPER_ADMIN + +--- + +### `PATCH /users/{user_id}` + +Benutzer bearbeiten. + +- **Erforderliche Rolle:** COMPANY_ADMIN, SUPER_ADMIN + +**Editierbare Felder:** `first_name`, `last_name`, `role`, `department_id`, `work_schedule_id`, `kuerzel`, `personnel_number`, `can_manual_time_entry` + +--- + +### `POST /users/{user_id}/deactivate` + +Benutzer deaktivieren (kein Hard-Delete, `is_active = false`, Personalnummer bleibt reserviert). + +- **Erforderliche Rolle:** COMPANY_ADMIN, SUPER_ADMIN + +--- + +### `POST /users/{user_id}/reactivate` + +Deaktivierten Benutzer reaktivieren. + +- **Erforderliche Rolle:** COMPANY_ADMIN, SUPER_ADMIN + +--- + +### `POST /users/{user_id}/kiosk-pin` + +Kiosk-PIN setzen. Eigene PIN: jeder Benutzer. Fremde PIN: COMPANY_ADMIN/SUPER_ADMIN. + +**Request:** `{ "pin": "1234" }` + +--- + +## Firmen & Abteilungen + +Router-Präfix: `/companies` + +### `GET /companies/me` + +Eigene Firmen-Daten abrufen. + +- **Erforderliche Rolle:** beliebig + +**Response:** `CompanyOut` + +--- + +### `PATCH /companies/me` + +Firma bearbeiten. + +- **Erforderliche Rolle:** COMPANY_ADMIN, SUPER_ADMIN + +**Editierbare Felder:** `name`, `country`, `state`, `logo_url`, `settings`, `personnel_number_required`, `personnel_number_mode`, `sick_note_required_after_days` + +--- + +### `GET /companies/me/departments` + +Alle Abteilungen auflisten. + +- **Erforderliche Rolle:** beliebig + +--- + +### `POST /companies/me/departments` + +Neue Abteilung anlegen. + +- **Erforderliche Rolle:** COMPANY_ADMIN, SUPER_ADMIN + +**Request:** `{ "name": "Entwicklung", "description": null }` + +--- + +### `PATCH /companies/me/departments/{dept_id}` + +Abteilung bearbeiten. + +- **Erforderliche Rolle:** COMPANY_ADMIN, SUPER_ADMIN + +--- + +### `DELETE /companies/me/departments/{dept_id}` + +Abteilung löschen. + +- **Erforderliche Rolle:** COMPANY_ADMIN, SUPER_ADMIN + +**Fehler:** `404` – Nicht gefunden + +--- + +## Zeiterfassung + +Router-Präfix: `/time` + +### Stempeluhr + +#### `POST /time/stamp-in` + +Einstempeln – startet einen neuen Zeiterfassungseintrag. + +- **Erforderliche Rolle:** beliebig + +**Request:** +```json +{ + "source": "web", + "project_id": null, + "note": "Projekt X" +} +``` + +**Response:** `TimeEntryWithWarnings` +```json +{ + "entry": { /* TimeEntryOut */ }, + "warnings": ["Bei mehr als 6h Anwesenheit sind mind. 30 min Pause vorgeschrieben – ArbZG §4"] +} +``` + +**Fehler:** `409` – Bereits eingestempelt + +--- + +#### `POST /time/stamp-out` + +Ausstempeln – schließt den offenen Eintrag. Enthält ArbZG-Warnungen falls zutreffend. + +**Request:** `{ "note": null }` + +**Fehler:** `404` – Kein offener Eintrag + +--- + +#### `POST /time/break-start` + +Pause beginnen. + +**Fehler:** `404` – Kein offener Eintrag | `409` – Bereits in Pause + +--- + +#### `POST /time/break-end` + +Pause beenden. + +--- + +### Einträge + +#### `GET /time/today` + +Alle Einträge des heutigen Tages des eingeloggten Benutzers. + +**Response:** `list[TimeEntryOut]` + +--- + +#### `GET /time/entries` + +Zeiterfassungseinträge auflisten. + +- **Query-Parameter:** + - `user_id` (UUID, optional) – EMPLOYEE sieht nur eigene + - `date_from` / `date_to` (date) + - `status` (`pending` | `approved` | `rejected`) + - `skip` / `limit` (int) + +**Response:** `{ "total": int, "items": [TimeEntryOut] }` + +--- + +#### `POST /time/entries` + +Manuellen Eintrag anlegen. + +- Mitarbeiter nur mit Berechtigung `can_manual_time_entry` +- MANAGER/HR/ADMIN können `user_id` setzen + +**Request:** +```json +{ + "date": "2026-05-23", + "start_time": "08:00", + "end_time": "17:00", + "break_minutes": 30, + "note": "Nacherfassung" +} +``` + +**Response:** `TimeEntryWithWarnings` + +--- + +#### `PATCH /time/entries/{entry_id}` + +Eintrag korrigieren. Mitarbeiter: nur eigene ausstehende. Manager: alle der Company. + +**Request:** `{ "start_time": "08:30", "correction_note": "..." }` + +--- + +#### `POST /time/entries/{entry_id}/approve` + +Eintrag genehmigen. Self-Approval-Schutz: Eigene Einträge können nicht selbst genehmigt werden. + +- **Erforderliche Rolle:** MANAGER, HR, COMPANY_ADMIN, SUPER_ADMIN + +--- + +#### `POST /time/entries/{entry_id}/reject` + +Eintrag ablehnen. + +- **Erforderliche Rolle:** MANAGER, HR, COMPANY_ADMIN, SUPER_ADMIN + +**Request:** `{ "rejection_note": "Bitte korrigieren" }` + +--- + +#### `DELETE /time/entries/{entry_id}` + +Eintrag löschen. Mitarbeiter: nur eigene offene/ausstehende. Manager: alle außer genehmigte (außer HR/Admin). + +**Response:** `204 No Content` + +--- + +### Überstundenkonto + +#### `GET /time/balance/me` + +Eigenes Überstundenkonto. + +- **Query-Parameter:** `period_start` / `period_end` (date, optional) + +**Response:** +```json +{ + "user_id": "uuid", + "period_start": "2026-05-01", + "period_end": "2026-05-23", + "total_hours_worked": 92.5, + "expected_hours": 88.0, + "overtime_hours": 4.5, + "approved_entries": 18, + "pending_entries": 2 +} +``` + +--- + +#### `GET /time/balance/{user_id}` + +Überstundenkonto für einen Benutzer. + +- **Erforderliche Rolle:** EMPLOYEE sieht nur sich selbst, sonst MANAGER+ + +--- + +### Arbeitspläne + +#### `GET /time/schedules` + +Alle Arbeitspläne der Firma. + +- **Erforderliche Rolle:** MANAGER, HR, COMPANY_ADMIN, SUPER_ADMIN + +--- + +#### `POST /time/schedules` + +Neuen Arbeitsplan anlegen. + +- **Erforderliche Rolle:** COMPANY_ADMIN, SUPER_ADMIN + +**Request:** +```json +{ + "name": "Vollzeit", + "mon_h": 8.0, "tue_h": 8.0, "wed_h": 8.0, "thu_h": 8.0, "fri_h": 8.0, + "sat_h": 0.0, "sun_h": 0.0, + "valid_from": "2026-01-01" +} +``` + +--- + +#### `PATCH /time/schedules/{schedule_id}` + +Arbeitsplan bearbeiten. + +- **Erforderliche Rolle:** COMPANY_ADMIN, SUPER_ADMIN + +--- + +#### `DELETE /time/schedules/{schedule_id}` + +Arbeitsplan löschen. + +- **Erforderliche Rolle:** COMPANY_ADMIN, SUPER_ADMIN + +--- + +## Abwesenheiten + +Kein Router-Präfix (direkt unter `/api/v1/`). + +### Abwesenheitstypen + +#### `GET /absence-types/` + +Alle Abwesenheitstypen der Firma. + +- **Erforderliche Rolle:** beliebig + +--- + +#### `POST /absence-types/` + +Neuen Abwesenheitstyp anlegen. + +- **Erforderliche Rolle:** COMPANY_ADMIN, SUPER_ADMIN + +**Request:** +```json +{ + "name": "Urlaub", + "color": "#3B82F6", + "category": "vacation", + "requires_approval": true, + "deducts_vacation": true, + "affects_overtime_balance": false, + "requires_certificate": false, + "certificate_after_days": 3, + "is_paid": true, + "max_days_per_year": 30 +} +``` + +**Kategorien:** `vacation` | `sick` | `overtime_comp` | `training` | `business_trip` | `other` + +--- + +#### `PATCH /absence-types/{type_id}` + +Abwesenheitstyp bearbeiten. + +- **Erforderliche Rolle:** COMPANY_ADMIN, SUPER_ADMIN + +--- + +### Feiertage + +#### `GET /public-holidays/` + +Feiertage auflisten. + +- **Query-Parameter:** `year` (int, Pflicht), `country` (default "DE"), `state` (optional, z.B. "BY") + +--- + +#### `POST /public-holidays/` + +Feiertag manuell anlegen. + +- **Erforderliche Rolle:** COMPANY_ADMIN, SUPER_ADMIN + +--- + +### Abwesenheiten + +#### `GET /absences/calendar` + +Team-Kalender: alle Abwesenheiten im Zeitraum. + +- **Query-Parameter:** `year` (int, Pflicht), `month` (1–12, optional) + +--- + +#### `GET /absences/balance` + +Eigenes Urlaubskonto (Jahr erforderlich). + +- **Query-Parameter:** `year` (int, Pflicht) + +**Response:** +```json +{ + "user_id": "uuid", + "year": 2026, + "base_days": 30, + "special_days": 0, + "carried_over_days": 5, + "carried_over_expires_at": "2026-03-31", + "carried_over_expired": false, + "used_days": 12.0, + "pending_days": 3.0, + "remaining_days": 20.0 +} +``` + +--- + +#### `GET /absences/balance/{user_id}` + +Urlaubskonto eines Mitarbeiters. + +- **Erforderliche Rolle:** MANAGER, HR, COMPANY_ADMIN, SUPER_ADMIN + +--- + +#### `POST /absences/quick-sick` + +Sofort-Krankmeldung. Automatisch genehmigt, nutzt den ersten aktiven SICK-Abwesenheitstyp. + +**Request:** +```json +{ "start_date": "2026-05-23", "end_date": "2026-05-23" } +``` + +--- + +#### `GET /absences/sick-stats` + +Krankheitsstatistik (rolling 12 Monate) inkl. Bradford-Faktor. + +- **Erforderliche Rolle:** MANAGER, HR, COMPANY_ADMIN, SUPER_ADMIN +- **Query-Parameter:** `user_id` (optional), `ref_date` (optional, default heute) + +**Response:** Liste von `SickStatsOut`: +```json +[{ + "user_id": "uuid", + "bradford_factor": 7.2, + "episodes": 3, + "total_days": 9.0, + "certificate_overdue": false +}] +``` + +--- + +#### `GET /absences/overtime-balance` + +Eigenes Überstunden-Ausgleichskonto. + +--- + +#### `GET /absences/` + +Abwesenheiten auflisten. + +- **Query-Parameter:** `user_id`, `type_id`, `status`, `year` (alle optional) +- EMPLOYEE sieht nur eigene Abwesenheiten + +--- + +#### `POST /absences/` + +Abwesenheitsantrag stellen. Mit `for_user_id` können MANAGER+ für andere beantragen. + +**Request:** +```json +{ + "type_id": "uuid", + "start_date": "2026-06-01", + "end_date": "2026-06-05", + "half_day_start": false, + "half_day_end": false, + "substitute_id": null, + "note": "Jahresurlaub" +} +``` + +--- + +#### `GET /absences/{absence_id}` + +Einzelne Abwesenheit abrufen. + +--- + +#### `PATCH /absences/{absence_id}` + +Ausstehenden Antrag bearbeiten. Nur solange Status `pending`. + +--- + +#### `DELETE /absences/{absence_id}` + +Eigenen ausstehenden Antrag stornieren. + +**Response:** `204 No Content` + +--- + +#### `POST /absences/{absence_id}/approve` + +Abwesenheit genehmigen. Self-Approval-Schutz aktiv. + +- **Erforderliche Rolle:** MANAGER, HR, COMPANY_ADMIN, SUPER_ADMIN + +--- + +#### `POST /absences/{absence_id}/reject` + +Abwesenheit ablehnen. + +- **Erforderliche Rolle:** MANAGER, HR, COMPANY_ADMIN, SUPER_ADMIN + +**Request:** `{ "rejection_reason": "Zu viele gleichzeitige Abwesenheiten" }` + +--- + +#### `PATCH /absences/{absence_id}/certificate` + +AU-Bescheinigung als eingegangen markieren. + +- **Erforderliche Rolle:** HR, COMPANY_ADMIN, SUPER_ADMIN + +**Request:** `{ "received_at": "2026-05-23" }` + +--- + +#### `PATCH /absences/balance/{user_id}` + +Urlaubskonto eines Mitarbeiters anpassen (Grundurlaub, Sondertage, Resturlaub). + +- **Erforderliche Rolle:** MANAGER, HR, COMPANY_ADMIN, SUPER_ADMIN +- **Query-Parameter:** `year` (int, Pflicht) + +--- + +## Dashboard & Reports + +Kein Router-Präfix. + +### Dashboard + +#### `GET /dashboard/me` + +Mitarbeiter-Dashboard: eigene Stunden, Urlaubsstand, Status heute. + +**Response:** `EmployeeDashboard` + +--- + +#### `GET /dashboard/team` + +Team-Dashboard: Anwesenheit heute, ausstehende Genehmigungen. + +- **Erforderliche Rolle:** MANAGER, HR, COMPANY_ADMIN, SUPER_ADMIN + +--- + +#### `GET /dashboard/company` + +Unternehmens-Dashboard: Gesamtübersicht, Überstunden, kommende Abwesenheiten. + +- **Erforderliche Rolle:** COMPANY_ADMIN, SUPER_ADMIN + +--- + +### Reports (JSON) + +#### `GET /reports/time` + +Zeiterfassungsbericht. EMPLOYEE sieht nur eigene Einträge. + +- **Query-Parameter:** `date_from`, `date_to` (default: aktueller Monat), `user_id` (optional) + +--- + +#### `GET /reports/absences` + +Abwesenheitsbericht. EMPLOYEE sieht nur eigene Abwesenheiten. + +--- + +#### `GET /reports/overtime` + +Überstundenbericht. + +--- + +#### `GET /reports/overtime/detail` + +Erweiterter Überstundenbericht mit Wochen- und Tagesaufschlüsselung. + +--- + +### Export + +#### `GET /reports/time/export` + +Zeiterfassungsbericht herunterladen. + +- **Query-Parameter:** `format` (`csv` | `xlsx` | `pdf`, default `csv`) + +--- + +#### `GET /reports/absences/export` + +Abwesenheitsbericht herunterladen. + +- **Query-Parameter:** `format` (`csv` | `xlsx` | `pdf`) + +--- + +#### `GET /reports/overtime/export` + +Überstundenbericht herunterladen (Detailansicht). + +- **Query-Parameter:** `format` (`csv` | `xlsx` | `pdf`) + +--- + +## Audit-Log + +Kein Router-Präfix. + +### `GET /audit-logs` + +Audit-Log-Einträge auflisten. + +- **Erforderliche Rolle:** COMPANY_ADMIN, SUPER_ADMIN +- SUPER_ADMIN sieht alle Firmen, COMPANY_ADMIN nur die eigene +- **Query-Parameter:** + - `user_id` (UUID, optional) + - `action` (string, Teilstring-Suche) + - `entity_type` (string, exakt) + - `date_from` / `date_to` (datetime) + - `limit` (default 50, max 500) + - `offset` (int) + +**Response:** +```json +{ + "total": 1234, + "items": [{ + "id": "uuid", + "user_id": "uuid", + "user_name": "Max Mustermann", + "action": "absence_approved", + "entity_type": "absence", + "entity_id": "uuid", + "old_value": null, + "new_value": { "status": "approved" }, + "ip_address": "192.168.1.1", + "created_at": "2026-05-23T10:00:00Z" + }] +} +``` + +--- + +### `GET /audit-logs/actions` + +Alle vorhandenen Action-Werte für Filter-Dropdown. + +- **Erforderliche Rolle:** COMPANY_ADMIN, SUPER_ADMIN + +**Response:** `["absence_approved", "busylight_token_rotated", ...]` + +--- + +### `GET /audit-logs/entity-types` + +Alle vorhandenen Entity-Typen für Filter-Dropdown. + +- **Erforderliche Rolle:** COMPANY_ADMIN, SUPER_ADMIN + +--- + +## Kiosk-Geräte + +Router-Präfix: `/kiosk` + +### `GET /kiosk/devices` + +Alle registrierten Kiosk-Geräte der Firma. + +- **Erforderliche Rolle:** COMPANY_ADMIN, SUPER_ADMIN + +--- + +### `POST /kiosk/devices` + +Neues Kiosk-Gerät registrieren. Token wird nur einmalig zurückgegeben. + +- **Erforderliche Rolle:** COMPANY_ADMIN, SUPER_ADMIN + +**Request:** +```json +{ + "name": "Eingang Berlin", + "location": "Hauptgebäude" +} +``` + +**Response `201`:** `KioskDeviceCreated` (enthält einmalig das Klartext-Token) + +--- + +### `GET /kiosk/devices/{device_id}` + +Einzelnes Gerät abrufen. + +--- + +### `PATCH /kiosk/devices/{device_id}` + +Gerät bearbeiten (Name, Standort). + +--- + +### `POST /kiosk/devices/{device_id}/rotate-token` + +Token rotieren – das alte Token wird sofort ungültig. + +**Response:** `KioskDeviceCreated` (neues Token) + +--- + +### `DELETE /kiosk/devices/{device_id}` + +Gerät löschen. + +--- + +### `GET /kiosk/me` + +Kiosk-Gerät authentifiziert sich und aktualisiert `last_seen_at`. + +- **Authentifizierung:** Header `X-Kiosk-Token: ` (kein JWT) + +--- + +## LDAP-Integration + +Router-Präfix: `/ldap` +Alle Endpunkte erfordern: **COMPANY_ADMIN** oder **SUPER_ADMIN** + +### `GET /ldap/config` + +Aktuelle LDAP-Konfiguration abrufen. + +--- + +### `POST /ldap/config` + +LDAP-Konfiguration erstellen oder überschreiben. + +**Request:** +```json +{ + "enabled": true, + "host": "ldap.example.de", + "port": 389, + "use_ssl": false, + "use_tls": true, + "bind_dn": "cn=admin,dc=example,dc=de", + "bind_password": "secret", + "base_dn": "dc=example,dc=de", + "user_search_filter": "(objectClass=person)", + "attr_email": "mail", + "attr_firstname": "givenName", + "attr_lastname": "sn", + "attr_username": "uid", + "attr_department": "department" +} +``` + +--- + +### `PATCH /ldap/config` + +LDAP-Konfiguration teilweise bearbeiten. + +--- + +### `POST /ldap/test` + +LDAP-Verbindung testen. + +**Response:** `{ "success": true, "message": "..." }` + +--- + +### `GET /ldap/preview` + +Ersten 50 LDAP-Benutzer zur Vorschau vor dem Sync. + +--- + +### `POST /ldap/sync` + +LDAP-Benutzer synchronisieren. + +**Request:** `{ "default_role": "EMPLOYEE" }` + +**Response:** `{ "created": 5, "updated": 12, "deactivated": 1, "errors": [] }` + +--- + +## SMTP-Konfiguration + +Router-Präfix: `/smtp` +Alle Endpunkte erfordern: **COMPANY_ADMIN** oder **SUPER_ADMIN** + +### `GET /smtp/config` + +SMTP-Konfiguration abrufen. + +--- + +### `POST /smtp/config` + +SMTP-Konfiguration erstellen oder überschreiben. + +**Request:** +```json +{ + "host": "smtp.example.de", + "port": 587, + "use_tls": false, + "use_starttls": true, + "username": "noreply@example.de", + "from_email": "noreply@example.de", + "from_name": "TimeMaster", + "password": "secret", + "is_enabled": true +} +``` + +--- + +### `POST /smtp/test` + +Test-E-Mail senden. + +**Request:** `{ "to": "test@example.de" }` + +**Fehler:** `502` – SMTP-Fehler + +--- + +## CalDAV-Integration + +Router-Präfix: `/caldav` + +### Firmenkalender + +#### `GET /caldav/company/config` + +Firmen-CalDAV-Konfiguration abrufen. + +- **Erforderliche Rolle:** COMPANY_ADMIN, SUPER_ADMIN + +--- + +#### `POST /caldav/company/config` + +Firmen-CalDAV-Konfiguration speichern. + +**Request:** +```json +{ + "enabled": true, + "principal_url": "https://cal.example.de/remote.php/dav", + "calendar_url": "https://cal.example.de/remote.php/dav/calendars/user/timemaster/", + "username": "user", + "password": "secret", + "calendar_display_name": "TimeMaster", + "verify_ssl": true +} +``` + +--- + +#### `POST /caldav/company/test` + +Firmen-CalDAV-Verbindung testen. + +--- + +#### `POST /caldav/company/resync` + +Alle genehmigten Abwesenheiten neu in den Firmenkalender synchronisieren. + +**Response:** `{ "synced": 42, "errors": 0 }` + +--- + +### Persönlicher Kalender + +#### `GET /caldav/user/config` + +Persönliche CalDAV-Konfiguration (eigener Benutzer). + +--- + +#### `POST /caldav/user/config` + +Persönliche CalDAV-Konfiguration speichern. + +--- + +#### `POST /caldav/user/test` + +Persönliche CalDAV-Verbindung testen. + +--- + +## Busylight-Integration + +Kein Router-Präfix. + +### Token-Verwaltung + +#### `GET /companies/me/busylight-token` + +Status des Busylight-Pull-Tokens. + +- **Erforderliche Rolle:** COMPANY_ADMIN, SUPER_ADMIN + +**Response:** `{ "configured": true, "created_at": "2026-05-07T..." }` + +--- + +#### `POST /companies/me/busylight-token/rotate` + +Neues Busylight-Token generieren (altes wird ungültig). Token wird einmalig im Klartext zurückgegeben. + +**Response:** `{ "token": "abc...", "created_at": "..." }` + +--- + +#### `DELETE /companies/me/busylight-token` + +Busylight-Token widerrufen. + +--- + +### Pull-Endpunkt + +#### `GET /busylight/users` + +Anwesenheitsstatus aller Mitarbeiter mit Personalnummer. + +- **Authentifizierung:** `Authorization: Bearer ` (kein JWT, firmenspezifisches Token) +- **Rate-Limit:** 60/Minute +- Nur Mitarbeiter mit gesetzter Personalnummer werden zurückgegeben + +**Response:** +```json +{ + "date": "2026-05-23", + "users": [{ + "personnel_number": "0042", + "full_name": "Maria Muster", + "absences_today": [{ "type": "Urlaub", "category": "vacation" }] + }] +} +``` + +--- + +## Kimai-Import + +Router-Präfix: `/import` +Erforderliche Rolle: **HR**, **COMPANY_ADMIN**, **SUPER_ADMIN** + +### `POST /import/kimai/preview` + +Kimai-CSV-Export vorschauen (keine DB-Änderungen). + +- **Content-Type:** `multipart/form-data` +- **Felder:** `user_id` (UUID als String), `file` (CSV-Datei) + +**Response:** +```json +{ + "preview": [{ "date": "...", "start": "...", "end": "...", "type": "time" }], + "time_count": 150, + "absence_count": 12, + "skip_count": 3, + "errors": [] +} +``` + +--- + +### `POST /import/kimai/apply` + +Kimai-CSV-Import durchführen. + +**Response:** `{ "time_imported": 150, "absence_imported": 12, "skipped": 3, "errors": [] }` diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..12e5a7a --- /dev/null +++ b/docs/architecture.md @@ -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/ # 0001–0024 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 = ''` + - `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: "" } + ↓ +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. diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..ce4dac4 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,429 @@ +# TimeMaster – Deployment-Guide + +Stand: 2026-05-24 + +--- + +## Infrastruktur-Übersicht + +| Server | IP | Rolle | +|--------|----|-------| +| Primary | 192.168.1.137 | Produktion, Tests, Primär-DB | +| Secondary | 192.168.1.164 | Replikat / Fallback | + +Beide Server laufen Ubuntu 22.04 oder 24.04 LTS (amd64). Kein Docker in Phase 1 – alle Dienste laufen nativ als systemd-Units. + +--- + +## Voraussetzungen + +Auf jedem Server müssen folgende Pakete installiert sein: + +```bash +apt-get install -y \ + python3 python3-venv python3-dev python3-pip \ + postgresql postgresql-contrib \ + redis-server \ + nginx \ + git curl build-essential libpq-dev +``` + +Node.js 20 für den Frontend-Build wird nur auf der Entwicklungsmaschine benötigt, nicht auf den Servern. Das Frontend wird lokal gebaut und als statisches `dist/`-Verzeichnis per rsync übertragen. + +--- + +## Erstes Setup (einmalig) + +Das Setup-Skript `setup_server.sh` übernimmt alle Schritte vollautomatisch: + +```bash +# Auf dem Server als root +bash /opt/timemaster/setup_server.sh +``` + +### Was das Skript tut + +**Schritt 1 – System-Pakete:** +`apt-get update && apt-get upgrade -y` sowie alle oben genannten Abhängigkeiten. + +**Schritt 2 – PostgreSQL:** + +```sql +CREATE ROLE timemaster LOGIN PASSWORD 'timemaster_secret_change_me'; +CREATE DATABASE timemaster_db OWNER timemaster; +CREATE DATABASE timemaster_test OWNER timemaster; -- für pytest +GRANT ALL PRIVILEGES ON DATABASE timemaster_db TO timemaster; +``` + +Das Passwort in Produktion sofort nach Setup ändern (siehe `.env`). + +**Schritt 3 – Redis:** +`systemctl enable redis-server && systemctl start redis-server` + +**Schritt 4 – Python venv:** +```bash +cd /opt/timemaster/backend +python3 -m venv venv +source venv/bin/activate +pip install --upgrade pip +pip install -r requirements.txt +``` + +**Schritt 5 – Alembic-Migrationen:** +```bash +alembic upgrade head +``` +Führt alle Migrationen von 0001 bis zur aktuellen Kopfversion aus. + +**Schritt 6 – nginx:** +Legt `/etc/nginx/sites-available/timemaster` an, verlinkt nach `sites-enabled` und startet nginx. + +### Manuelles Setup der .env-Datei + +Vor dem ersten Start muss `/opt/timemaster/backend/.env` angelegt werden: + +```bash +cp /opt/timemaster/backend/.env.example /opt/timemaster/backend/.env +nano /opt/timemaster/backend/.env +``` + +Pflichtfelder in Produktion (siehe nächster Abschnitt). + +### systemd-Service aktivieren + +```bash +cp /opt/timemaster/timemaster.service /etc/systemd/system/ +systemctl daemon-reload +systemctl enable timemaster +systemctl start timemaster +``` + +--- + +## Umgebungsvariablen (.env) + +Datei: `/opt/timemaster/backend/.env` + +```bash +# === App === +APP_NAME=TimeMaster +APP_ENV=production # production | development +SECRET_KEY= +FRONTEND_URL=https://yourdomain.com +ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com + +# === Datenbank === +DATABASE_URL=postgresql+asyncpg://timemaster:@localhost:5432/timemaster_db + +# === Redis === +REDIS_URL=redis://localhost:6379/0 + +# === JWT === +ACCESS_TOKEN_EXPIRE_MINUTES=30 +REFRESH_TOKEN_EXPIRE_DAYS=30 + +# === E-Mail (Resend.com) === +RESEND_API_KEY=re_ +EMAIL_FROM=noreply@yourdomain.com +EMAIL_FROM_NAME=TimeMaster + +# === Erster Super-Admin (wird beim ersten Start angelegt) === +FIRST_SUPERADMIN_EMAIL=admin@yourdomain.com +FIRST_SUPERADMIN_PASSWORD= +``` + +Pflicht in Produktion: +- `SECRET_KEY` muss einmalig und zufällig sein (min. 32 Zeichen). Die Applikation verweigert den Start mit dem Default-Wert `change-me-in-production`. +- `DATABASE_URL` mit dem echten Passwort des `timemaster`-Datenbankusers. +- `RESEND_API_KEY` für ausgehende E-Mails (Einladungen, Passwort-Reset, Willkommensmails). + +--- + +## Systemd-Service + +Datei: `/etc/systemd/system/timemaster.service` + +```ini +[Unit] +Description=TimeMaster FastAPI Backend +After=network.target postgresql.service redis.service +Requires=postgresql.service redis.service + +[Service] +Type=exec +User=www-data +Group=www-data +WorkingDirectory=/opt/timemaster/backend +EnvironmentFile=/opt/timemaster/backend/.env +ExecStart=/opt/timemaster/backend/venv/bin/uvicorn app.main:app \ + --host 127.0.0.1 \ + --port 8000 \ + --workers 4 \ + --log-level info \ + --access-log +ExecReload=/bin/kill -HUP $MAINPID +Restart=on-failure +RestartSec=5 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=timemaster + +# Sicherheitsoptionen +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ReadWritePaths=/opt/timemaster/backend + +[Install] +WantedBy=multi-user.target +``` + +Uvicorn läuft mit 4 Workern auf Port 8000, nur auf localhost. nginx leitet eingehende Anfragen weiter. + +Service-Befehle: +```bash +systemctl start timemaster +systemctl stop timemaster +systemctl restart timemaster +systemctl status timemaster +journalctl -u timemaster -f # Live-Logs +journalctl -u timemaster -n 100 # Letzte 100 Zeilen +``` + +--- + +## nginx-Konfiguration + +Datei: `/etc/nginx/sites-available/timemaster` (symlink nach `sites-enabled`) + +```nginx +# HTTP → HTTPS Redirect +server { + listen 80; + server_name yourdomain.com www.yourdomain.com; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name yourdomain.com www.yourdomain.com; + + ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + # HSTS (nach agent-08 verpflichtend) + add_header Strict-Transport-Security "max-age=31536000" always; + + client_max_body_size 20M; + + # API Backend + location /api/ { + proxy_pass http://127.0.0.1:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 60s; + } + + # FastAPI Swagger Docs (nur in dev-Deployments!) + location /docs { + proxy_pass http://127.0.0.1:8000/docs; + } + location /openapi.json { + proxy_pass http://127.0.0.1:8000/openapi.json; + } + + # React Frontend (SPA – alle Routen über index.html) + location / { + root /opt/timemaster/frontend/dist; + index index.html; + try_files $uri $uri/ /index.html; + expires 1d; + add_header Cache-Control "public, must-revalidate"; + } + + # Statische Backend-Uploads + location /static/ { + alias /opt/timemaster/backend/static/; + expires 7d; + } +} +``` + +TLS-Zertifikat mit Let's Encrypt: +```bash +apt-get install certbot python3-certbot-nginx +certbot --nginx -d yourdomain.com -d www.yourdomain.com +``` + +Nach nginx-Konfigurationsänderungen: +```bash +nginx -t && systemctl reload nginx +``` + +--- + +## Deployment-Workflow (reguläre Updates) + +Der gesamte Deployment-Prozess ist in `update.sh` automatisiert. + +### Vollständiges Deployment + +```bash +cd /home/sysops/Dokumente/Scripte/timemaster +./update.sh +``` + +Führt alle Schritte für beide Server durch: +1. Tests auf Server 137 (pytest -x -q) +2. Frontend lokal bauen (npm run build) +3. `git pull --ff-only origin main` auf Server(n) +4. Alembic: `alembic upgrade head` +5. Service: `systemctl restart timemaster` +6. Health-Check: `curl http://localhost:8000/health` (3 Versuche) +7. Frontend-Dist per rsync übertragen + +### Optionen + +```bash +./update.sh --no-tests # Tests überspringen (schneller, nur im Notfall) +./update.sh --no-frontend # Frontend-Build überspringen (nur Backend-Änderungen) +./update.sh --server 137 # Nur Primary +./update.sh --server 164 # Nur Secondary +./update.sh --dry-run # Alle Befehle zeigen, nichts ausführen +``` + +### Manueller Deploy (ohne update.sh) + +```bash +# 1. Tests +ssh root@192.168.1.137 "cd /opt/timemaster/backend && source venv/bin/activate && pytest -x -q" + +# 2. Frontend lokal bauen +cd /home/sysops/Dokumente/Scripte/timemaster/frontend +npm run build + +# 3. Code auf Server synchronisieren +ssh root@192.168.1.137 "cd /opt/timemaster && git pull --ff-only origin main" + +# 4. Migrationen +ssh root@192.168.1.137 "cd /opt/timemaster/backend && source venv/bin/activate && alembic upgrade head" + +# 5. Service neustarten +ssh root@192.168.1.137 "systemctl restart timemaster" + +# 6. Frontend-Dist synchronisieren +rsync -avz --delete \ + /home/sysops/Dokumente/Scripte/timemaster/frontend/dist/ \ + root@192.168.1.137:/opt/timemaster/frontend/dist/ + +# 7. Logs prüfen +ssh root@192.168.1.137 "journalctl -u timemaster -n 50" +``` + +--- + +## Monitoring und Logs + +### Applikations-Logs + +```bash +# Live-Stream +ssh root@192.168.1.137 "journalctl -u timemaster -f" + +# Letzte 100 Zeilen +ssh root@192.168.1.137 "journalctl -u timemaster -n 100 --no-pager" + +# Fehler der letzten Stunde +ssh root@192.168.1.137 "journalctl -u timemaster --since='1 hour ago' -p err" +``` + +### Health-Endpoint + +```bash +curl https://yourdomain.com/health +# → { "status": "ok", "database": "ok", "redis": "ok" } +``` + +### Service-Status + +```bash +ssh root@192.168.1.137 "systemctl status timemaster" +ssh root@192.168.1.137 "systemctl status nginx" +ssh root@192.168.1.137 "systemctl status postgresql" +ssh root@192.168.1.137 "systemctl status redis-server" +``` + +--- + +## Backup-Strategie + +### PostgreSQL-Dump + +```bash +# Vollständiger Dump (täglich per cron) +ssh root@192.168.1.137 "pg_dump -U timemaster timemaster_db | gzip > /opt/backups/timemaster_$(date +%Y%m%d).sql.gz" + +# Wiederherstellung +ssh root@192.168.1.137 "gunzip -c /opt/backups/timemaster_20260524.sql.gz | psql -U timemaster timemaster_db" +``` + +Empfehlung: täglicher pg_dump-Cron + Upload auf externen S3-kompatiblen Speicher. Backup 30 Tage aufbewahren. + +### Frontend-Dist + +Das `dist/`-Verzeichnis kann jederzeit aus dem lokalen Build reproduziert werden und muss nicht separat gesichert werden. + +--- + +## Rollback-Verfahren + +### Code-Rollback + +```bash +# Auf Server: bestimmten Commit auschecken +ssh root@192.168.1.137 "cd /opt/timemaster && git log --oneline -10" +ssh root@192.168.1.137 "cd /opt/timemaster && git checkout " +ssh root@192.168.1.137 "systemctl restart timemaster" +``` + +### Alembic-Rollback + +```bash +# Eine Version zurück +ssh root@192.168.1.137 "cd /opt/timemaster/backend && source venv/bin/activate && alembic downgrade -1" + +# Auf bestimmte Version zurück +ssh root@192.168.1.137 "cd /opt/timemaster/backend && source venv/bin/activate && alembic downgrade 0023" +``` + +Achtung: Datenverlust möglich, wenn die `downgrade()`-Funktion Spalten löscht. Vor dem Downgrade immer einen pg_dump anlegen. + +### Alembic-Diagnosebefehle + +```bash +# Aktuelle Migration-Version +ssh root@192.168.1.137 "cd /opt/timemaster/backend && source venv/bin/activate && alembic current" + +# Migrationsverlauf +ssh root@192.168.1.137 "cd /opt/timemaster/backend && source venv/bin/activate && alembic history --verbose" +``` + +--- + +## Zwei-Server-Setup + +Server 137 ist der Primary. Server 164 ist das Fallback/Replikat. + +Beide Server beziehen den Code über `git pull` aus demselben Gitea-Repository (`gitea.perlbach24.de/scripte/timemaster.git`). Jeder Server hat seine eigene PostgreSQL-Instanz. Es gibt keine automatische Replikation zwischen den Datenbanken – bei Failover muss manuell ein pg_dump vom Primary wiederhergestellt werden. + +Update-Reihenfolge: +1. Tests immer nur auf Server 137 ausführen (update.sh-Default) +2. Migration zuerst auf 137, dann auf 164 +3. Service-Restart auf 137, dann auf 164 + +Bei divergentem Datenbankzustand zwischen den Servern: Server 164 aus dem Dump von Server 137 wiederherstellen. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..1a179db --- /dev/null +++ b/docs/development.md @@ -0,0 +1,531 @@ +# TimeMaster – Developer-Guide + +Stand: 2026-05-24 + +--- + +## Entwicklungsumgebung aufsetzen + +### Voraussetzungen (lokal) + +- Python 3.12+ +- Node.js 20+ und npm +- SSH-Zugang zu `root@192.168.1.137` (Tests und Datenbank laufen dort) +- Git-Zugang zu `gitea.perlbach24.de/scripte/timemaster.git` + +Lokale PostgreSQL oder Redis-Instanz ist **nicht** erforderlich. Alle Backend-Operationen laufen auf dem Entwicklungsserver via SSH. + +### Repository klonen + +```bash +git clone git@gitea.perlbach24.de:scripte/timemaster.git +cd timemaster +``` + +### Frontend lokal einrichten + +```bash +cd frontend +npm install +cp .env.example .env.local # falls vorhanden, sonst: +# Inhalt von .env.local: +# VITE_API_URL=http://192.168.1.137:8000 +npm run dev # Vite Dev-Server auf localhost:5173 +``` + +Das Frontend spricht dann direkt gegen den Backend-Server 137. Für Produktions-Builds: + +```bash +npm run build # Output in frontend/dist/ +``` + +### Backend: kein lokales Setup nötig + +Backend-Dateien werden bearbeitet und per `update.sh` auf den Server synchronisiert. Tests laufen remote (siehe Abschnitt Tests). + +--- + +## Tests ausführen + +Tests laufen ausschließlich auf `root@192.168.1.137`. Nie lokal – die Tests benötigen eine PostgreSQL-Instanz mit der `timemaster_test`-Datenbank sowie Redis. + +```bash +# Alle Tests +ssh root@192.168.1.137 "cd /opt/timemaster/backend && source venv/bin/activate && pytest -v" + +# Schnell (bricht beim ersten Fehler ab) +ssh root@192.168.1.137 "cd /opt/timemaster/backend && source venv/bin/activate && pytest -x -q" + +# Einzelne Testdatei +ssh root@192.168.1.137 "cd /opt/timemaster/backend && source venv/bin/activate && pytest -v tests/test_absences.py" + +# Einzelner Test +ssh root@192.168.1.137 "cd /opt/timemaster/backend && source venv/bin/activate && pytest -v tests/test_absences.py::test_quick_sick" +``` + +### pytest-Konfiguration + +`pytest.ini` / `pyproject.toml` enthält: + +```ini +[pytest] +asyncio_mode = auto +asyncio_default_fixture_loop_scope = session +``` + +Alle Fixtures in `conftest.py` haben `scope="session"` und `loop_scope="session"`. Das ist zwingend erforderlich für pytest-asyncio 1.x mit asyncpg – ein einzelner Event-Loop wird für die gesamte Test-Session geteilt. + +### Wichtige Fixtures in conftest.py + +```python +setup_db # scope=session, autouse=True + # Legt Test-DB-Schema neu an (DROP+CREATE SCHEMA public) + # Wendet alle RLS-Policies an (identisch zu Migration 0024) + +db_session # scope=session + # Gemeinsame AsyncSession für alle Tests + +client # scope=session + # httpx AsyncClient mit ASGI-Transport (kein echter HTTP-Stack) + # Override get_db → nutzt db_session + # Rate-Limiter wird deaktiviert + +registered_user # scope=session + # Legt einmalig eine Firma "Test GmbH" + Admin-User an + # Gibt { tokens, user } zurück +``` + +### RLS in Tests + +Die Test-DB-Session setzt `bypass_rls = 'on'` – alle Test-Queries sind damit nicht durch RLS eingeschränkt. Das entspricht dem Verhalten von unauthentifizierten Routen im Produktivbetrieb. Tests, die RLS-Verhalten prüfen wollen, müssen `app.company_id` explizit setzen. + +--- + +## Neuen API-Endpunkt anlegen + +### Schritt 1: Schema (Pydantic) + +Datei: `backend/app/schemas/.py` + +```python +from pydantic import BaseModel, UUID4 +from datetime import datetime + +class MyResourceCreate(BaseModel): + name: str + company_id: UUID4 # bei manuellen Creates; bei auth-geschützten Endpunkten aus current_user + +class MyResourceOut(BaseModel): + id: UUID4 + name: str + created_at: datetime + + model_config = {"from_attributes": True} +``` + +Pydantic v2: `model_validate(obj)` statt `.from_orm(obj)`. `from_attributes = True` aktiviert ORM-Mode. + +### Schritt 2: Model (SQLAlchemy) + +Datei: `backend/app/models/.py` + +```python +import uuid +from datetime import datetime +from sqlalchemy import DateTime, ForeignKey, String, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column +from app.core.database import Base + +class MyResource(Base): + __tablename__ = "my_resources" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + company_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("companies.id", ondelete="CASCADE")) + name: Mapped[str] = mapped_column(String(255), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) +``` + +Model in `backend/app/models/__init__.py` importieren. + +### Schritt 3: Service + +Datei: `backend/app/services/_service.py` + +```python +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from fastapi import HTTPException +from app.models.my_resource import MyResource +from app.schemas.my_module import MyResourceCreate + +class MyService: + async def create(self, data: MyResourceCreate, company_id: UUID, db: AsyncSession) -> MyResource: + obj = MyResource(company_id=company_id, name=data.name) + db.add(obj) + await db.flush() # flush statt commit – commit macht get_db() nach yield + return obj + + async def get_all(self, company_id: UUID, db: AsyncSession) -> list[MyResource]: + result = await db.execute( + select(MyResource).where(MyResource.company_id == company_id) + ) + return result.scalars().all() + +my_service = MyService() +``` + +**Regel:** Kein `db.commit()` in Services. `get_db()` committed nach `yield`. Nur `db.flush()` in Services, wenn Werte (z.B. generierte IDs) vor dem finalen Commit benötigt werden. + +### Schritt 4: Router + +Datei: `backend/app/routers/.py` + +```python +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from app.core.database import get_db +from app.core.dependencies import CurrentUser, require_role +from app.models.user import UserRole +from app.schemas.my_module import MyResourceCreate, MyResourceOut +from app.services.my_service import my_service + +router = APIRouter(prefix="/my-resources", tags=["MyResources"]) + +@router.get("/", response_model=list[MyResourceOut]) +async def list_resources( + current_user: CurrentUser, + db: AsyncSession = Depends(get_db), +): + return await my_service.get_all(current_user.company_id, db) + +@router.post("/", response_model=MyResourceOut, status_code=201) +async def create_resource( + data: MyResourceCreate, + current_user: CurrentUser = Depends(require_role(UserRole.COMPANY_ADMIN, UserRole.HR)), + db: AsyncSession = Depends(get_db), +): + return await my_service.create(data, current_user.company_id, db) +``` + +Router in `backend/app/main.py` registrieren: + +```python +from app.routers import my_module +app.include_router(my_module.router, prefix=API_PREFIX) +``` + +### Schritt 5: Tests + +Datei: `backend/tests/test_.py` + +```python +import pytest +from httpx import AsyncClient + +@pytest.mark.asyncio +async def test_create_resource(client: AsyncClient, registered_user): + headers = {"Authorization": f"Bearer {registered_user['tokens']['access_token']}"} + resp = await client.post("/api/v1/my-resources/", json={"name": "Test"}, headers=headers) + assert resp.status_code == 201 + data = resp.json() + assert data["name"] == "Test" + assert "id" in data +``` + +--- + +## Neue Alembic-Migration anlegen + +```bash +ssh root@192.168.1.137 "cd /opt/timemaster/backend && source venv/bin/activate && \ + alembic revision --autogenerate -m 'add_my_feature'" +``` + +Die generierte Datei in `backend/migrations/versions/` erscheint auf dem Server. Sie muss dann lokal per `git pull` geholt werden (oder die Datei manuell kopieren). + +**Wichtig:** Autogenerate erkennt nicht alles zuverlässig. Immer die generierte Datei prüfen, bevor sie eingecheckt wird. Besonders: +- Partial Unique Indexes (müssen manuell mit `postgresql_where` Klausel ergänzt werden) +- CHECK Constraints +- RLS-Policies (nie autogeneriert – immer manuell, wie in 0024 gezeigt) +- Enum-Änderungen + +Migrationsdatei-Namenskonvention: `XXXX_kurzbeschreibung.py` (fortlaufend, nächste: `0025_...`). + +Migration testen: +```bash +ssh root@192.168.1.137 "cd /opt/timemaster/backend && source venv/bin/activate && alembic upgrade head" +# Rollback prüfen: +ssh root@192.168.1.137 "cd /opt/timemaster/backend && source venv/bin/activate && alembic downgrade -1" +ssh root@192.168.1.137 "cd /opt/timemaster/backend && source venv/bin/activate && alembic upgrade head" +``` + +--- + +## Neue Frontend-Seite anlegen + +### 1. Page-Komponente + +Datei: `frontend/src/pages/MyFeaturePage.tsx` + +```tsx +import React, { useEffect, useState } from 'react' +import Layout from '../components/Layout' + +const MyFeaturePage: React.FC = () => { + return ( + +
+

Mein Feature

+ {/* Inhalt */} +
+
+ ) +} + +export default MyFeaturePage +``` + +### 2. Route registrieren + +`frontend/src/App.tsx`: + +```tsx +import MyFeaturePage from './pages/MyFeaturePage' + +// In der Router-Konfiguration: + + + +} /> +``` + +### 3. Navigation in Layout.tsx + +`frontend/src/components/Layout.tsx` – Navigation-Links in der Sidebar oder im Settings-Dropdown ergänzen. Sichtbarkeit über `currentUser.role` steuern. + +### 4. API-Calls + +`frontend/src/api/myFeature.ts`: + +```typescript +import axios from './client' // konfigurierter Axios-Client mit Bearer-Token + +export const getMyResources = () => + axios.get('/api/v1/my-resources/').then(r => r.data) + +export const createMyResource = (data: { name: string }) => + axios.post('/api/v1/my-resources/', data).then(r => r.data) +``` + +--- + +## Code-Konventionen + +### Backend + +**Async überall:** Alle DB-Operationen nutzen `AsyncSession`. Kein Mischen von sync/async SQLAlchemy. + +```python +# Korrekt +result = await db.execute(select(User).where(User.email == email)) +user = result.scalar_one_or_none() + +# Falsch +user = db.query(User).filter_by(email=email).first() # sync API +``` + +**Fehler als HTTPException:** + +```python +raise HTTPException(status_code=404, detail="User not found") +raise HTTPException(status_code=403, detail="Insufficient permissions") +``` + +Kein `raise ValueError()` oder `return None` aus Routers. Services dürfen HTTPException werfen, wenn der Fehler inhärent HTTP-natur hat (z.B. Duplikat-Fehler bei eindeutigem Feld). + +**Tokens nie im Klartext in DB:** + +```python +from app.core.security import hash_token +db_token = hash_token(raw_token) # SHA-256 +``` + +**Kein Hard-Delete bei Users:** + +```python +user.is_active = False # Deaktivierung +# Nicht: await db.delete(user) +``` + +**Pydantic v2:** + +```python +# Serialisierung +out = MyResourceOut.model_validate(orm_obj) + +# Nicht mehr: +out = MyResourceOut.from_orm(orm_obj) # Pydantic v1 Syntax +``` + +**AuditLog schreiben bei sensitiven Aktionen:** + +```python +from app.models.audit_log import AuditLog +audit = AuditLog( + company_id=current_user.company_id, + user_id=current_user.id, + action="user.role_changed", + entity_type="user", + entity_id=str(target_user.id), + old_value={"role": old_role}, + new_value={"role": new_role}, + ip_address=request.client.host, +) +db.add(audit) +``` + +### Frontend + +- TypeScript strict – keine `any`-Typen +- Tailwind CSS für Styling – keine CSS-Module oder Styled Components +- Formular-Validierung inline oder mit React Hook Form +- Axios-Client aus `api/client.ts` verwenden (handhabt Token-Refresh automatisch) +- Fehlermeldungen aus `error.response?.data?.detail` extrahieren + +--- + +## ArbZG-Regeln (Arbeitszeitgesetz) + +Implementiert in `backend/app/services/time_service.py`: + +| Bedingung | Regel | Reaktion | +|-----------|-------|----------| +| Arbeitszeit > 8h (480 min) | Maximalarbeitszeit überschritten | Warnung | +| Arbeitszeit > 10h (600 min) | Absolutes Maximum | Warnung + Flag | +| Arbeitszeit >= 6h, Pause < 30 min | Pflichtpause ab 6h fehlt | Warnung | +| Arbeitszeit >= 9h, Pause < 45 min | Pflichtpause ab 9h fehlt | Warnung | +| Zeit zwischen Schichtende und nächstem Beginn < 11h | Ruhezeit unterschritten | Warnung | + +Warnungen werden in `time_entries.arbzg_warning` gespeichert. Es gibt kein hartes Blockieren – Manager und HR sehen die Warnungen und können reagieren. + +--- + +## RLS-Entwicklungshinweise + +### db.refresh() nach commit() ist verboten + +```python +# FALSCH – führt zu Fehler oder leerem Ergebnis wegen RLS-Kontext-Verlust +await db.commit() +await db.refresh(obj) # zweiter SELECT außerhalb Transaktionskontext + +# RICHTIG – Werte vor commit() sichern mit flush() +await db.flush() # schreibt in DB, commit noch ausstehend +obj_id = obj.id # ID jetzt verfügbar (durch flush zugewiesen) +# Werte, die nach commit() gebraucht werden, vor dem commit() lesen +await db.commit() +# obj.id ist immer noch verfügbar wegen expire_on_commit=False +``` + +### expire_on_commit=False + +Die Session-Factory ist mit `expire_on_commit=False` konfiguriert. Das bedeutet: Attribute werden nach einem commit() nicht als "expired" markiert. Bereits geladene Werte bleiben im Python-Objekt erhalten. Ein erneuter SELECT (durch Attributzugriff nach commit) findet nicht statt. + +### SET LOCAL gilt nur für die aktuelle Transaktion + +```python +# In get_db(): +await session.execute(text("SET LOCAL app.bypass_rls = 'on'")) +yield session +# Nach yield: commit → Transaktion endet → SET LOCAL wird zurückgesetzt +``` + +Bei neuen Requests beginnt immer eine frische Transaktion mit `bypass_rls = 'on'`. `get_current_user()` setzt dann ggf. `bypass_rls = 'off'` und `company_id`. + +### SUPER_ADMIN und company_id = None + +SUPER_ADMIN hat `company_id = None`. Für diesen User bleibt `bypass_rls = 'on'`. Services müssen prüfen, ob ein SUPER_ADMIN-Request eine `company_id` als Query-Parameter übergibt, wenn er firmenbezogene Daten abfragt. + +--- + +## Git-Workflow + +### Branch-Strategie + +Alle Änderungen landen auf `main`. Kein Feature-Branch-Workflow in Phase 1. + +```bash +# Vor jeder Änderung: aktuellen Stand holen +git pull origin main + +# Änderungen committen +git add -p # interaktiv (oder git add ) +git commit -m "feat: kurze Beschreibung was geändert wurde" + +# Auf Gitea pushen +git push origin main +``` + +Commit-Messages folgen Conventional Commits: +- `feat:` neues Feature +- `fix:` Bugfix +- `refactor:` Code-Umstrukturierung ohne Verhaltensänderung +- `test:` Testzusätze/-korrekturen +- `chore:` Wartungsarbeiten (Dependencies, Konfiguration) + +### Deployment nach Push + +```bash +./update.sh # deployt auf beide Server +``` + +--- + +## Bekannte Fallstricke + +### Saturday/Sunday in Tests + +Abwesenheits-Berechnungen überspringen Wochenenden. Wenn ein Test-Datum auf ein Wochenende fällt, ist `working_days = 0`. Testdaten immer mit Wochentagen (Mo-Fr) anlegen oder dynamisch berechnen: + +```python +from datetime import date, timedelta + +def next_monday(): + today = date.today() + days_ahead = 7 - today.weekday() # 0=Monday + return today + timedelta(days=days_ahead % 7 or 7) +``` + +### Self-Approval + +Ein User kann seinen eigenen Abwesenheitsantrag nicht genehmigen. Der `absence_service` prüft `approved_by != user_id`. In Tests muss ein separater User mit MANAGER/HR-Rolle für Genehmigungen angelegt werden. + +### RLS-Kontext nach commit() + +Wie oben beschrieben: `db.refresh(obj)` nach `db.commit()` schlägt fehl oder liefert leere Ergebnisse, weil der RLS-Kontext (`SET LOCAL`) transaktionsgebunden ist und nach dem commit() zurückgesetzt wurde. Statt dessen: + +1. `db.flush()` vor dem commit() für sofortige ID-Zuweisung +2. Benötigte Attribute vor dem commit() lesen +3. `expire_on_commit=False` stellt sicher, dass bereits geladene Attribute erhalten bleiben + +### LDAP-Authentifizierung und Tests + +LDAP-Tests benötigen einen erreichbaren LDAP-Server oder müssen gemockt werden. Standardmäßig sind LDAP-Tests in der Test-Suite mit `pytest.mark.skip` oder durch fehlende Konfig deaktiviert. + +### Alembic-Kette und Migration 0017 + +Migration 0017 existiert nicht – die Nummer wurde übersprungen. Die Alembic-Kette ist: `...0016 → 0018 → 0019 → 0020 → 0022 → 0023 → 0024`. Neue Migrationen chained auf `0024`. + +### Bradford-Faktor Berechnung + +Der Bradford-Faktor (`S² × D`) nutzt ein rollendes 12-Monats-Fenster ab `ref_date`. `ref_date` ist optional und defaulted auf `date.today()`. Bei Tests immer ein explizites `ref_date` übergeben, da sich sonst der Test-Zeitraum mit der Zeit verschiebt. + +### Rate-Limiter in Tests + +`limiter.enabled = False` wird in `conftest.py` gesetzt. Falls ein neuer Router Rate-Limiting hinzufügt, ist das in Tests automatisch deaktiviert. In manuellen Integration-Tests gegen den echten Server gelten die Limits. + +### Passwort-Validierung + +Min. 8 Zeichen, 1 Großbuchstabe, 1 Ziffer. Diese Regel ist in `security.py` implementiert und gilt für Register, Invite-Accept und Change-Password. Schwache Test-Passwörter wie `password123` scheitern – mindestens `Password1` oder `Secret123` verwenden. diff --git a/frontend/DEVLOG.md b/frontend/DEVLOG.md index f01fab5..f70e235 100644 --- a/frontend/DEVLOG.md +++ b/frontend/DEVLOG.md @@ -429,3 +429,25 @@ Keine Commits in dieser Session. Keine Änderungen ermittelbar. --- +## 2026-05-23 22:38 – 22:42 (3m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 16 ++ +- backend/app/routers/absences.py | 10 - +- backend/app/routers/caldav.py | 2 - +- backend/app/routers/kiosk.py | 3 - +- backend/app/routers/ldap.py | 2 - +- backend/app/routers/projects.py | 2 - +- backend/app/routers/smtp.py | 1 - +- backend/app/routers/time_entries.py | 10 - +- backend/migrations/env.py | 4 - +- .../migrations/versions/0024_row_level_security.py | 223 +++++---------------- +- backend/tests/conftest.py | 48 +++++ +- backend/tests/test_rls.py | 190 ++++++++++++++++++ + +---