Files
patrick ada1b51f33 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>
2026-05-24 11:29:44 +02:00

19 KiB
Raw Permalink Blame History

TimeMaster Architektur-Dokumentation

Stand: 2026-05-24


Tech-Stack

Schicht Technologie Version
Backend Python / FastAPI 3.12 / 0.115+
ORM SQLAlchemy async 2.x
Datenbank PostgreSQL 16
Cache / Sessions Redis 7
Frontend React + TypeScript 18 / 5
CSS-Framework Tailwind CSS 3
Build-Tool Vite 5
Prozess-Manager systemd
Reverse Proxy nginx 1.24+
Migrationen Alembic 1.x
Authentifizierung JWT (python-jose) + bcrypt
2FA pyotp (TOTP / RFC 6238)
E-Mail Resend.com HTTP API
Tests pytest + pytest-asyncio + httpx

Verzeichnisstruktur

/opt/timemaster/
├── backend/
│   ├── app/
│   │   ├── core/
│   │   │   ├── config.py          # Pydantic Settings (liest .env)
│   │   │   ├── database.py        # Engine, AsyncSessionLocal, Base
│   │   │   ├── dependencies.py    # FastAPI Depends: get_current_user, require_role
│   │   │   ├── limiter.py         # slowapi Rate-Limiter
│   │   │   └── security.py        # JWT, Passwort-Hash, Token-Utilities
│   │   ├── models/
│   │   │   ├── company.py         # Company, Department
│   │   │   ├── user.py            # User, UserRole, AuthProvider
│   │   │   ├── session.py         # Refresh-Token-Sessions
│   │   │   ├── password_reset.py  # Passwort-Reset-Tokens
│   │   │   ├── audit_log.py       # AuditLog
│   │   │   ├── absence.py         # Absence, AbsenceStatus
│   │   │   ├── absence_type.py    # AbsenceType
│   │   │   ├── vacation_balance.py
│   │   │   ├── public_holiday.py  # Feiertage
│   │   │   ├── overtime_balance.py
│   │   │   ├── time_entry.py      # TimeEntry, WorkSchedule
│   │   │   ├── work_schedule.py
│   │   │   ├── kiosk_device.py
│   │   │   ├── caldav_config.py   # CalDAV-Konfig (Firma + User)
│   │   │   ├── ldap_config.py
│   │   │   ├── smtp_config.py
│   │   │   └── project.py         # (derzeit nicht aktiv, Migration 0014 entfernt)
│   │   ├── schemas/               # Pydantic v2 Ein-/Ausgabe-Schemas
│   │   │   ├── auth.py
│   │   │   ├── user.py
│   │   │   ├── absence.py
│   │   │   ├── time_entry.py
│   │   │   └── ...
│   │   ├── routers/               # FastAPI APIRouter  ein File pro Modul
│   │   │   ├── auth.py            # /api/v1/auth/*
│   │   │   ├── users.py           # /api/v1/users/*
│   │   │   ├── companies.py       # /api/v1/companies/*
│   │   │   ├── absences.py        # /api/v1/absences/*
│   │   │   ├── time_entries.py    # /api/v1/time/*
│   │   │   ├── kiosk.py           # /api/v1/kiosk/*
│   │   │   ├── reports.py         # /api/v1/reports/*
│   │   │   ├── audit.py           # /api/v1/audit-logs/
│   │   │   ├── caldav.py
│   │   │   ├── ldap.py
│   │   │   ├── smtp.py
│   │   │   └── busylight.py
│   │   └── services/              # Business-Logik  kein HTTP-Wissen
│   │       ├── auth_service.py
│   │       ├── user_service.py
│   │       ├── absence_service.py
│   │       ├── time_service.py    # inkl. ArbZG-Checks
│   │       ├── email_service.py
│   │       ├── ldap_service.py
│   │       └── caldav_service.py
│   ├── migrations/
│   │   ├── env.py                 # Alembic-Konfiguration
│   │   └── versions/              # 00010024 Migrationsdateien
│   ├── tests/
│   │   ├── conftest.py            # pytest-Fixtures (session-scope, RLS-Setup)
│   │   ├── test_auth.py
│   │   ├── test_users.py
│   │   ├── test_absences.py
│   │   └── ...
│   ├── .env                       # Umgebungsvariablen (nie committen)
│   ├── alembic.ini
│   └── requirements.txt
├── frontend/
│   ├── src/
│   │   ├── api/                   # Axios-Client-Funktionen pro Ressource
│   │   ├── components/
│   │   │   ├── Layout.tsx         # Haupt-Layout mit Navigation
│   │   │   ├── Modal.tsx
│   │   │   ├── ProtectedRoute.tsx
│   │   │   ├── Spinner.tsx
│   │   │   └── absences/          # Abwesenheits-Unterkomponenten
│   │   ├── context/               # React Context: AuthContext
│   │   ├── hooks/                 # Custom Hooks (useAuth, useAbsences, ...)
│   │   ├── pages/                 # Eine Datei pro Seite/Route
│   │   │   ├── LoginPage.tsx
│   │   │   ├── DashboardPage.tsx
│   │   │   ├── TimeTrackingPage.tsx
│   │   │   ├── AbsencesPage.tsx
│   │   │   ├── UsersPage.tsx
│   │   │   ├── ReportsPage.tsx
│   │   │   ├── ProfilePage.tsx
│   │   │   ├── CompanySettingsPage.tsx
│   │   │   ├── KioskDevicesPage.tsx
│   │   │   ├── AuditLogPage.tsx
│   │   │   └── ...
│   │   ├── types/                 # TypeScript-Interfaces
│   │   ├── utils/                 # Hilfsfunktionen
│   │   ├── App.tsx                # Router-Konfiguration
│   │   └── main.tsx
│   ├── dist/                      # Build-Output (von nginx ausgeliefert)
│   └── package.json
├── nginx.conf
├── timemaster.service
├── setup_server.sh
└── update.sh

Datenbankschema

Tabellenübersicht und Beziehungen

companies
  ├──< departments (company_id)
  ├──< users (company_id)
  │     ├──< sessions (user_id)           -- Refresh-Token-Sessions
  │     ├──< password_resets (user_id)
  │     ├──< time_entries (user_id)
  │     ├──< absences (user_id)
  │     │     └──> absence_types (type_id)
  │     ├──< vacation_balances (user_id)
  │     └──  work_schedule_id → work_schedules
  ├──< work_schedules (company_id)
  ├──< kiosk_devices (company_id)
  ├──< audit_logs (company_id)
  ├──< overtime_balances (company_id)
  ├──  ldap_configs
  ├──  smtp_configs
  └──  caldav_company_configs

Wichtige Tabellen

companies Mandanten-Stammdaten

  • id UUID PK
  • name, slug (URL-freundlich, unique)
  • sick_note_required_after_days INTEGER DEFAULT 3
  • personnel_number_required BOOLEAN DEFAULT FALSE
  • personnel_number_mode VARCHAR(10) DEFAULT 'manual'
  • personnel_number_next INTEGER DEFAULT 1

users

  • id, company_id, department_id
  • email unique, password_hash, first_name, last_name
  • role ENUM: SUPER_ADMIN | COMPANY_ADMIN | HR | MANAGER | EMPLOYEE
  • auth_provider ENUM: local | ldap
  • personnel_number VARCHAR(50) nur Ziffern, partial unique per Firma
  • totp_secret, totp_enabled TOTP 2FA
  • kiosk_pin_hash, kiosk_qr_token
  • can_manual_time_entry BOOLEAN DEFAULT FALSE
  • is_active BOOLEAN kein Hard-Delete
  • created_at

sessions Refresh-Token-Verwaltung

  • id, user_id, token_hash (gehashter Refresh-Token)
  • expires_at, created_at, ip_address, user_agent
  • Bei Rotation: altes Token wird gelöscht, neues wird angelegt

absence_types

  • company_id, name, code
  • affects_vacation BOOLEAN zieht vom Urlaubskonto ab
  • requires_approval BOOLEAN
  • certificate_after_days INTEGER NULL per-Typ-Override für AU-Pflicht
  • color UI-Darstellung

absences

  • user_id, type_id
  • start_date, end_date, half_day_start, half_day_end
  • working_days NUMERIC(5,1)
  • status ENUM: pending | approved | rejected | cancelled
  • approved_by, substitute_id
  • certificate_required_by DATE auto-berechnet bei SICK-Absences
  • certificate_received BOOLEAN DEFAULT FALSE
  • meta JSONB flexible Zusatzdaten (Weiterbildung, Dienstreise etc.)

vacation_balances

  • user_id, year
  • base_days, special_days, carried_over_days, used_days
  • comment

time_entries

  • user_id, date
  • start_time, end_time, break_minutes
  • working_minutes COMPUTED
  • status ENUM: draft | submitted | approved | rejected
  • source ENUM: manual | stamp | import
  • arbzg_warning ArbZG-Verletzungs-Flag

work_schedules

  • company_id, name
  • days_per_week, wöchentliche Soll-Stunden
  • Für ArbZG-Berechnung und Überstunden-Balance

kiosk_devices

  • company_id, name, location
  • device_token_hash aktuell TOKEN-basiert (agent-08: Ed25519-Upgrade geplant)
  • is_active wird in 0021 durch status ENUM ersetzt

audit_logs

  • company_id, user_id
  • action, entity_type, entity_id
  • old_value, new_value JSONB
  • ip_address, created_at

public_holidays global (keine RLS), pro Bundesland/Datum

Alembic-Migrationskette

0001_initial              companies, departments, users, sessions, password_resets, audit_logs
0002_time_entries         time_entries, work_schedules
0003_absences             absence_types, absences, vacation_balances, public_holidays
0004_ldap                 ldap_configs
0005_extensions           Erweiterungsfelder (company/user)
0006_smtp                 smtp_configs
0007_caldav_and_fixes     caldav_company_configs, caldav_user_configs; Bugfixes
0008_ldap_tls_verify      ldap_configs.tls_verify Spalte
0009_absence_correction   absences.correction_note
0010_public_holidays      public_holidays.federal_state
0011_caldav_name_format   caldav Name-Format-Felder
0012_caldav_template      CalDAV-Template + users.kuerzel
0013_projects             projects Tabelle (temporär)
0014_remove_projects      projects Tabelle entfernt (Feature verschoben)
0015_totp                 users.totp_secret, totp_enabled
0016_vacation_special     vacation_balances.special_days + Feiertagskalender-Erweiterungen
[0017 übersprungen]       Nummer wurde nie in die Alembic-Kette aufgenommen
0018_kiosk_devices        kiosk_devices Tabelle
0019_manual_time_entry    users.can_manual_time_entry
0020_personnel_number     users.personnel_number, companies.personnel_number_*
0021_kiosk_security       TODO (agent-08): Ed25519 Public-Key, Enrollment, Heartbeat
0022_sick_note_config     companies.sick_note_required_after_days
0023_busylight_pull       companies.busylight_pull_token
0024_row_level_security   PostgreSQL RLS auf allen Tabellen

Hinweis: Migration 0017 wurde übersprungen und existiert nicht in der Kette. Neuen Migrationen folgen auf 0024.


Row Level Security (RLS)

Seit Migration 0024 setzt TimeMaster PostgreSQL Row Level Security für vollständige Mandanten-Isolation auf Datenbankebene ein. Selbst bei einem Fehler in der Applikationsschicht kann kein User Daten einer anderen Firma sehen.

Prinzip

Jede Tabelle hat vier Policies (SELECT, INSERT, UPDATE, DELETE). Die Policies prüfen zwei PostgreSQL-Session-Variablen:

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

(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

(bypass) OR user_id IN (SELECT id FROM users WHERE company_id = ...)

companies selbst Policy auf id (die Firma darf nur sich selbst sehen):

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