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