# TimeMaster – API-Referenz

Alle Endpunkte sind unter dem Präfix `/api/v1` erreichbar.  
Authentifizierung: `Authorization: Bearer <access_token>` (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)
15. [Stunden-Auszahlung](#stunden-auszahlung)
16. [Öffentliches QR-Stempeln](#öffentliches-qr-stempeln)
17. [iCal-Abo-Feed](#ical-abo-feed)
18. [Reseller-Selfservice](#reseller-selfservice)
19. [Admin · Mandanten & Reseller](#admin--mandanten--reseller)
20. [Sondervertretungen](#sondervertretungen)
21. [Admin · TLS](#admin--tls)

---

## 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": "<jwt>",
  "refresh_token": "<opaque>",
  "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": "<reset-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": "<invite-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": "<partial-jwt>", "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: <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 <busylight-token>` (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": [] }`

---

## Stunden-Auszahlung

Router-Präfix: `/hr/payouts`

Firmen-Opt-in `payout_request_enabled`: entweder bucht HR eine Auszahlung direkt
(sofort genehmigt, Saldo wird sofort abgezogen), oder ein Mitarbeiter beantragt
seine eigene Auszahlung und HR genehmigt/lehnt ab (Saldo-Abzug erst bei
Genehmigung). Alle Aktionen erzeugen einen `AuditLog`-Eintrag.

### `GET /hr/payouts`

Auszahlungen der eigenen Firma, optional gefiltert (`user_id`, `year`, `month`, `status`).
EMPLOYEE/MANAGER sehen dabei ausschließlich eigene Anträge.

- **Erforderliche Rolle:** authentifiziert (jede Rolle)

**Response:** `HoursPayoutListResponse`

---

### `POST /hr/payouts`

HR bucht eine Auszahlung direkt – sofort genehmigt, Saldo wird sofort belastet.

- **Erforderliche Rolle:** HR, COMPANY_ADMIN, SUPER_ADMIN

**Response `201`:** `HoursPayoutOut`

---

### `POST /hr/payouts/request`

Mitarbeiter beantragt Auszahlung eigener Überstunden. Nur wenn die Firma
`payout_request_enabled` gesetzt hat (sonst `403`). Saldo wird erst bei
HR-Genehmigung abgezogen.

- **Erforderliche Rolle:** authentifiziert (jede Rolle)

**Response `201`:** `HoursPayoutOut`

---

### `POST /hr/payouts/{payout_id}/approve`

HR genehmigt einen offenen Antrag – erst jetzt wird der Überstunden-Saldo abgezogen.

- **Erforderliche Rolle:** HR, COMPANY_ADMIN, SUPER_ADMIN

**Response:** `HoursPayoutOut`

---

### `POST /hr/payouts/{payout_id}/reject`

HR lehnt einen offenen Antrag ab (kein Saldo-Abzug).

- **Erforderliche Rolle:** HR, COMPANY_ADMIN, SUPER_ADMIN

**Response:** `HoursPayoutOut`

---

### `POST /hr/payouts/{payout_id}/cancel`

Antragsteller zieht seinen eigenen, noch offenen Antrag zurück.

- **Erforderliche Rolle:** authentifiziert (jede Rolle, nur eigener Antrag)

**Response:** `HoursPayoutOut`

---

### `DELETE /hr/payouts/{payout_id}`

Auszahlung stornieren. Bei bereits genehmigten/gebuchten Auszahlungen werden die
Stunden dem Saldo zurückgebucht.

- **Erforderliche Rolle:** HR, COMPANY_ADMIN, SUPER_ADMIN

**Response:** `204 No Content`

---

## Öffentliches QR-Stempeln

Router-Präfix: `/time/public`

Statischer, firmenweiter QR-Code zum Aushängen (z. B. am Eingang). Mitarbeiter
scannen mit dem **eigenen** Handy und stempeln über Personalnummer + PIN.

⚠ **Kein Bearer-Token und keine Ed25519-Geräte-Signatur** (anders als der
Kiosk-Modus) – ein privates Handy hat keinen Geräteschlüssel. Absicherung
stattdessen über: Opt-in pro Firma (default OFF), PIN + Lockout, IP-Rate-Limit,
gehashtes rotierbares Token in der QR-URL, 120s-Kurzsession, AuditLog. Bewusst
schwächer als der Kiosk-Pfad (kein Replay-Nonce, keine IP-Whitelist) – tauscht
Sicherheit gegen BYOD-Komfort.

### `GET /time/public/company`

Firmen-Header für die Stempel-Seite (Name + `enabled`-Flag). Query-Param `t`
(Token aus dem QR-Code). Liefert auch bei deaktiviertem Feature den Namen,
damit die Seite einen Hinweis anzeigen kann.

- **Erforderliche Rolle:** öffentlich (kein Auth), rate-limited 30/min

**Response:** `PublicStampCompanyInfo`

---

### `POST /time/public/auth`

Personalnummer + PIN + Token → 120s-Session + aktueller Stempel-Status.

- **Erforderliche Rolle:** öffentlich (kein Auth), rate-limited 10/min

**Response:** `PublicStampAuthResponse`

---

### `POST /time/public/action`

Stempel-Aktion (`in`/`out`/`break_start`/`break_end`) über eine gültige
Kurz-Session ausführen.

- **Erforderliche Rolle:** öffentlich (kein Auth, gültige Session-Token nötig), rate-limited 30/min

**Response:** `PublicStampActionResponse`

---

## iCal-Abo-Feed

Router-Präfix: `/absences` (Feed) bzw. `/users/me` (Token-Verwaltung)

Abonnierbarer, read-only `text/calendar`-Feed pro Nutzer (Outlook/Apple/Google
pollen die URL selbst) – Ergänzung zum CalDAV-Client, der Events aktiv nach
Nextcloud pusht. **Kein JWT**: Kalender-Apps können kein Bearer-Token schicken,
Zugang läuft ausschließlich über ein geheimes, rotierbares Token in der URL
(gehasht in `users.ical_token_hash`). Feed zeigt ausschließlich die eigenen
Abwesenheiten (APPROVED/FIRST_APPROVED/CANCELLATION_REQUESTED) des
Token-Inhabers, rein lesend.

### `GET /absences/ical/{token}.ics`

Öffentlicher Feed – liefert eine `.ics`-Datei mit den bestätigten/laufenden
Abwesenheiten des zum Token gehörenden Nutzers.

- **Erforderliche Rolle:** öffentlich (kein JWT, Token in URL)

**Response:** `text/calendar`

---

### `GET /users/me/ical-token`

Status abfragen, ob der Feed aktiviert ist. Das Token selbst wird nie
zurückgegeben (nur der Hash ist gespeichert).

- **Erforderliche Rolle:** authentifiziert (jede Rolle)

**Response:** `{ "enabled": true|false }`

---

### `POST /users/me/ical-token`

Erzeugt ein neues Token (rotiert ein bestehendes) und liefert die Feed-URL
**einmalig** zurück.

- **Erforderliche Rolle:** authentifiziert (jede Rolle)

**Response:** `{ "enabled": true, "url": "https://.../api/v1/absences/ical/<token>.ics" }`

---

### `DELETE /users/me/ical-token`

Feed deaktivieren (Token löschen).

- **Erforderliche Rolle:** authentifiziert (jede Rolle)

**Response:** `204 No Content`

---

## Reseller-Selfservice

Router-Präfix: `/reseller`

Ein Reseller verwaltet ausschließlich die **von ihm selbst angelegten Firmen**
(Firmen-Ebene: Name, Slug, Plan, Aktiv-Status, erster COMPANY_ADMIN) – **kein
Zugriff auf personenbezogene Zeit-/Abwesenheitsdaten** (bewusste DSGVO-Abgrenzung).
RLS über `app.reseller_id` sorgt dafür, dass eine fremde Firmen-ID ins Leere läuft (`404`).

### `GET /reseller/companies`

Eigene Firmen mit Kennzahlen.

- **Erforderliche Rolle:** RESELLER

**Response:** `list[TenantOut]`

---

### `POST /reseller/companies`

Neue Firma anlegen (inkl. erstem COMPANY_ADMIN). Ohne E-Mail: interne
Login-Kennung + einmaliges Temp-Passwort, sofort aktiv.

- **Erforderliche Rolle:** RESELLER

**Response `201`:** `TenantOut` (enthält bei Temp-Passwort-Flow `initial_password` einmalig)

---

### `PATCH /reseller/companies/{company_id}`

Eigene Firma bearbeiten. Schreibt kurzzeitig mit RLS-Bypass (nach
Python-seitiger Eigentümerprüfung), da der Reseller keine `app.company_id` besitzt.

- **Erforderliche Rolle:** RESELLER (nur eigene Firma, sonst `404`)

**Response:** `TenantOut`

---

## Admin · Mandanten & Reseller

Router-Präfix: `/admin`

Nur **SUPER_ADMIN** – läuft mit vollem RLS-Bypass, sieht daher alle Firmen
firmenübergreifend.

### `GET /admin/tenants`

Alle Firmen mit Kennzahlen (mandantenübergreifend).

- **Erforderliche Rolle:** SUPER_ADMIN

**Response:** `list[TenantOut]`

---

### `POST /admin/tenants`

Neue Firma anlegen, optional direkt einem Reseller zugeordnet (`reseller_id`-Query-Param).

- **Erforderliche Rolle:** SUPER_ADMIN

**Response `201`:** `TenantOut`

---

### `PATCH /admin/tenants/{company_id}`

Firma bearbeiten (Name, Plan, Aktiv-Status, ...).

- **Erforderliche Rolle:** SUPER_ADMIN

**Response:** `TenantOut`

---

### `PATCH /admin/tenants/{company_id}/reseller`

Firma einem Reseller zuordnen (oder Zuordnung entfernen mit `reseller_id: null`).

- **Erforderliche Rolle:** SUPER_ADMIN

**Response:** `TenantOut`

---

### `GET /admin/resellers`

Alle Reseller mit Anzahl zugeordneter Firmen.

- **Erforderliche Rolle:** SUPER_ADMIN

**Response:** `list[ResellerOut]`

---

### `POST /admin/resellers`

Neuen Reseller anlegen. Mit E-Mail: Einladungs-Mail (7 Tage gültig). Ohne
E-Mail: interne Login-Kennung + einmaliges Temp-Passwort, sofort aktiv.

- **Erforderliche Rolle:** SUPER_ADMIN

**Response `201`:** `ResellerOut` (enthält bei Temp-Passwort-Flow `initial_password` einmalig)

---

### `PATCH /admin/resellers/{reseller_id}`

Reseller aktivieren/deaktivieren (`is_active`-Query-Param).

- **Erforderliche Rolle:** SUPER_ADMIN

**Response:** `ResellerOut`

---

### `POST /admin/run-retention-purge`

Globaler DSGVO-Purge über **alle** Firmen (Zeiterfassung/Auszahlungen je Firma
gemäß konfigurierter Frist, plus firmenübergreifend: `audit_logs` > 3 Jahre,
abgelaufene Sessions/Password-Resets). Gleiche Logik wie der tägliche
Scheduler-Job, hier manuell für alle Mandanten auf einmal auslösbar.

- **Erforderliche Rolle:** SUPER_ADMIN

**Response:** `{ "deleted": { ... } }`

---

## Sondervertretungen

Router-Präfix: `/users/{user_id}/special-assignments` (CRUD) bzw. `/reports/special-assignments` (Report)

Zeiträume mit Vertretungs-/Zuschlagsfaktor (z. B. Führungsvertretung) pro
Mitarbeiter, mit Überschneidungs-Prüfung.

### `GET /users/{user_id}/special-assignments`

Alle Zuweisungen eines Mitarbeiters.

- **Erforderliche Rolle:** MANAGER, HR, COMPANY_ADMIN, SUPER_ADMIN

**Response:** `list[SpecialAssignmentOut]`

---

### `POST /users/{user_id}/special-assignments`

Neue Zuweisung anlegen. `409` bei Überschneidung mit vorhandener Zuweisung.

- **Erforderliche Rolle:** MANAGER, HR, COMPANY_ADMIN, SUPER_ADMIN

**Response `201`:** `SpecialAssignmentOut`

---

### `PATCH /users/{user_id}/special-assignments/{assignment_id}`

Zuweisung bearbeiten (inkl. erneuter Überschneidungs-Prüfung).

- **Erforderliche Rolle:** MANAGER, HR, COMPANY_ADMIN, SUPER_ADMIN

**Response:** `SpecialAssignmentOut`

---

### `DELETE /users/{user_id}/special-assignments/{assignment_id}`

Zuweisung löschen.

- **Erforderliche Rolle:** MANAGER, HR, COMPANY_ADMIN, SUPER_ADMIN

**Response:** `204 No Content`

---

### `GET /reports/special-assignments/payroll`

Payroll-Report: je Mitarbeiter die Sondervertretungs-Stunden im gewählten
Monat (`year`, `month`), inkl. Normal-/Faktor-/Zuschlagsstunden auf Basis
genehmigter Zeiterfassungs-Einträge.

- **Erforderliche Rolle:** MANAGER, HR, COMPANY_ADMIN, SUPER_ADMIN

**Response:** `PayrollAssignmentReport`

---

## Admin · TLS

Router-Präfix: `/admin/tls`

**Server-lokal, ohne Mandanten-Kontext** – zeigt/verwaltet ausschließlich das
TLS-Zertifikat des Servers, auf dem der Request ankommt (kein
Cross-Server-Wissen zwischen 137/164). Läuft mit Root-Rechten
(`timemaster.service` läuft als `root`), deshalb strikt auf SUPER_ADMIN
begrenzt, plus AuditLog und Rate-Limit auf den Renewal-Endpunkten.

### `GET /admin/tls/status`

Aktueller Zertifikats-Status (Modus, Domain, gültig bis, erneuerbar).

- **Erforderliche Rolle:** SUPER_ADMIN

**Response:** `TlsStatusOut`

---

### `POST /admin/tls/renew/certbot`

Zertifikat per Certbot erneuern (öffentliche Domain).

- **Erforderliche Rolle:** SUPER_ADMIN, rate-limited 5/h

**Request:** `{ "domain": "timemaster.example.com" }`

**Response:** `TlsStatusOut`

---

### `POST /admin/tls/renew/internal`

Internes Zertifikat erneuern (Hostname/IP, kein öffentlicher Certbot-Weg).

- **Erforderliche Rolle:** SUPER_ADMIN, rate-limited 5/h

**Request:** `{ "hostname": "timemaster", "ip": "192.168.1.137" }`

**Response:** `TlsStatusOut`
