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:
@@ -654,3 +654,67 @@ Keine Commits in dieser Session.
|
|||||||
- backend/tests/conftest.py | 4 +
|
- 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 ++++++++++++++++++++++---
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
+1375
File diff suppressed because it is too large
Load Diff
@@ -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 = '<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.
|
||||||
@@ -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=<min. 32 zufällige Zeichen – openssl rand -hex 32>
|
||||||
|
FRONTEND_URL=https://yourdomain.com
|
||||||
|
ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com
|
||||||
|
|
||||||
|
# === Datenbank ===
|
||||||
|
DATABASE_URL=postgresql+asyncpg://timemaster:<passwort>@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_<key>
|
||||||
|
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=<starkes-passwort>
|
||||||
|
```
|
||||||
|
|
||||||
|
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 <commit-hash>"
|
||||||
|
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.
|
||||||
@@ -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/<modul>.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/<modul>.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/<modul>_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/<modul>.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_<modul>.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 (
|
||||||
|
<Layout>
|
||||||
|
<div className="p-6">
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Mein Feature</h1>
|
||||||
|
{/* Inhalt */}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MyFeaturePage
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Route registrieren
|
||||||
|
|
||||||
|
`frontend/src/App.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import MyFeaturePage from './pages/MyFeaturePage'
|
||||||
|
|
||||||
|
// In der Router-Konfiguration:
|
||||||
|
<Route path="/my-feature" element={
|
||||||
|
<ProtectedRoute roles={['COMPANY_ADMIN', 'HR']}>
|
||||||
|
<MyFeaturePage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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 <datei>)
|
||||||
|
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.
|
||||||
@@ -429,3 +429,25 @@ Keine Commits in dieser Session.
|
|||||||
Keine Änderungen ermittelbar.
|
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 ++++++++++++++++++
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
Reference in New Issue
Block a user