# 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.
