diff --git a/api/README.md b/api/README.md index 0985a4b..efd0d03 100644 --- a/api/README.md +++ b/api/README.md @@ -1,44 +1,83 @@ -# DriveTime Planner API (Slim 4) +# DriveTime Planner API -## Setup +## Base URL +`https://api.fahrschultermin.de/api/v1` -```bash -cd api -composer install +## Authentication +JWT Bearer Token. Login returns a token valid for 8 hours. + +``` +Authorization: Bearer ``` -## Run - -```bash -php -S localhost:8080 -t public/ -``` - -## Routes +## Endpoints +### Auth | Method | Path | Description | |--------|------|-------------| -| POST | /api/v1/auth/login | Login (returns JWT) | -| POST | /api/v1/auth/logout | Logout | -| GET | /api/v1/auth/me | Current user | -| GET | /api/v1/bootstrap | Full app data | -| GET | /api/v1/students | List students | -| POST | /api/v1/students | Create student | -| PATCH | /api/v1/students/{id} | Update student | -| DELETE | /api/v1/students/{id} | Delete student | -| GET | /api/v1/instructors | List instructors | -| POST | /api/v1/instructors | Create instructor | -| PATCH | /api/v1/instructors/{id} | Update instructor | -| GET | /api/v1/appointments | List appointments | -| POST | /api/v1/appointments | Create appointment | -| PATCH | /api/v1/appointments/{id} | Update appointment | -| DELETE | /api/v1/appointments/{id} | Delete appointment | -| GET | /api/v1/lesson-types | List lesson types | -| GET | /api/v1/license-class-templates | List templates | +| POST | `/auth/login` | Login with email/password, returns JWT | +| POST | `/auth/logout` | Logout (client-side token discard) | +| GET | `/auth/me` | Current user info (requires auth) | -## Auth +### Bootstrap +| Method | Path | Description | +|--------|------|-------------| +| GET | `/bootstrap` | Full frontend data: user, tenant, students, instructors, lessonTypes, templates (requires auth) | -JWT Bearer token in Authorization header. +### CRUD +| Method | Path | Description | +|--------|------|-------------| +| GET | `/students` | List students (requires auth) | +| POST | `/students` | Create student (requires auth) | +| PATCH | `/students/{id}` | Update student (requires auth) | +| DELETE | `/students/{id}` | Delete student (requires auth) | +| GET | `/instructors` | List instructors (requires auth) | +| POST | `/instructors` | Create instructor (requires auth) | +| PATCH | `/instructors/{id}` | Update instructor (requires auth) | +| GET | `/appointments` | List appointments, optionally filter by `from`/`to` query params (requires auth) | +| POST | `/appointments` | Create appointment with instructor conflict detection (requires auth) | +| PATCH | `/appointments/{id}` | Update appointment with conflict check (requires auth) | +| DELETE | `/appointments/{id}` | Delete appointment (requires auth) | +| GET | `/lesson-types` | List lesson types (requires auth) | +| GET | `/license-class-templates` | List license class templates with requirements (requires auth) | -## Database +## Error Responses +| Code | Meaning | +|------|---------| +| 400 | Bad Request — missing or invalid parameters | +| 401 | Unauthorized — missing or invalid JWT | +| 404 | Not Found — resource doesn't exist or wrong tenant | +| 409 | Conflict — e.g. instructor has overlapping appointment | +| 500 | Internal Server Error | -PostgreSQL (configured in src/Config/Container.php) \ No newline at end of file +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `JWT_SECRET` | 256-bit secret for JWT signing (required) | + +## Example + +```bash +# Login +TOKEN=$(curl -s -X POST https://api.fahrschultermin.de/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@nordring.test","password":"Tanja82"}' | jq -r '.token') + +# Get bootstrap data +curl https://api.fahrschultermin.de/api/v1/bootstrap \ + -H "Authorization: Bearer $TOKEN" | jq . + +# Create appointment +curl -X POST https://api.fahrschultermin.de/api/v1/appointments \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"instructor_id":1,"lesson_type_id":1,"start_at":"2026-05-25 10:00","end_at":"2026-05-25 11:00","title":"Test"}' +``` + +## Tech Stack +- Slim 4 (PSR-7 HTTP factory) +- Eloquent ORM (Illuminate Database) +- Firebase JWT for authentication +- PHP-DI for dependency injection +- CORS middleware (tuupola/cors-middleware) \ No newline at end of file diff --git a/api/reset_pass.php b/api/reset_pass.php deleted file mode 100644 index 96c33a8..0000000 --- a/api/reset_pass.php +++ /dev/null @@ -1,9 +0,0 @@ -setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); -$hash = password_hash('Tanja82', PASSWORD_BCRYPT, ['cost' => 12]); -echo "New hash: $hash\n"; -$stmt = $pdo->prepare("UPDATE users SET password_hash = :hash WHERE email = :email"); -$stmt->execute([':hash' => $hash, ':email' => 'admin@nordring.test']); -echo "Updated: " . $stmt->rowCount() . " rows\n"; \ No newline at end of file diff --git a/api/src/Config/Container.php b/api/src/Config/Container.php index 081815a..c3aae13 100644 --- a/api/src/Config/Container.php +++ b/api/src/Config/Container.php @@ -42,8 +42,12 @@ final class Container $builder = new \DI\ContainerBuilder(); + $jwtSecret = $_ENV['JWT_SECRET'] ?? null; + if (!$jwtSecret) { + throw new \RuntimeException('JWT_SECRET environment variable not set'); + } $builder->addDefinitions([ - 'jwt.secret' => 'your-256-bit-secret-change-in-production', + 'jwt.secret' => $jwtSecret, 'config' => [ 'frontend_url' => 'https://fahrschultermin.de', 'api_base_url' => 'https://api.fahrschultermin.de', diff --git a/api/src/Controllers/AppointmentsController.php b/api/src/Controllers/AppointmentsController.php index 1d7d462..8c07065 100644 --- a/api/src/Controllers/AppointmentsController.php +++ b/api/src/Controllers/AppointmentsController.php @@ -29,6 +29,19 @@ final class AppointmentsController $user = $request->getAttribute('user'); $data = json_decode($request->getBody()->getContents(), true); + // Conflict check for instructor + if (isset($data['instructor_id'], $data['start_at'], $data['end_at'])) { + if (Appointment::hasInstructorConflict( + (int) $data['instructor_id'], + $data['start_at'], + $data['end_at'] + )) { + return $this->json($response, [ + 'message' => 'Instructor has a conflicting appointment in this time range' + ], 409); + } + } + $appointment = Appointment::create([ 'tenant_id' => $tenantId, 'student_id' => $data['student_id'] ?? null, @@ -56,6 +69,21 @@ final class AppointmentsController if (!$appointment) return $this->json($response, ['message' => 'Not found'], 404); $data = json_decode($request->getBody()->getContents(), true); + + // Conflict check for instructor (exclude current appointment) + if (isset($data['instructor_id'], $data['start_at'], $data['end_at'])) { + if (Appointment::hasInstructorConflict( + (int) $data['instructor_id'], + $data['start_at'], + $data['end_at'], + (int) $args['id'] + )) { + return $this->json($response, [ + 'message' => 'Instructor has a conflicting appointment in this time range' + ], 409); + } + } + $data['updated_by'] = $user['id']; $appointment->update(array_intersect_key($data, array_flip([ 'student_id', 'instructor_id', 'lesson_type_id', 'title', 'start_at', 'end_at', 'units', 'status', 'notes', 'warning_acknowledged', 'updated_by' diff --git a/api/src/Models/Appointment.php b/api/src/Models/Appointment.php index d03c281..da59ae8 100644 --- a/api/src/Models/Appointment.php +++ b/api/src/Models/Appointment.php @@ -25,4 +25,31 @@ final class Appointment extends Model public function student(): BelongsTo { return $this->belongsTo(Student::class, 'student_id'); } public function instructor(): BelongsTo { return $this->belongsTo(Instructor::class, 'instructor_id'); } public function lessonType(): BelongsTo { return $this->belongsTo(LessonType::class, 'lesson_type_id'); } + + /** + * Check if instructor has a conflicting appointment in the given time range. + * Excludes the appointment with $excludeId (for updates). + */ + public static function hasInstructorConflict( + int $instructorId, + string $start, + string $end, + ?int $excludeId = null + ): bool { + $query = self::where('instructor_id', $instructorId) + ->where('status', '!=', 'cancelled') + ->where(function ($q) use ($start, $end) { + $q->whereBetween('start_at', [$start, $end]) + ->orWhereBetween('end_at', [$start, $end]) + ->orWhere(function ($q2) use ($start, $end) { + $q2->where('start_at', '<=', $start)->where('end_at', '>=', $end); + }); + }); + + if ($excludeId !== null) { + $query->where('id', '!=', $excludeId); + } + + return $query->exists(); + } } \ No newline at end of file diff --git a/api/test_db.php b/api/test_db.php deleted file mode 100644 index e0b8bb1..0000000 --- a/api/test_db.php +++ /dev/null @@ -1,9 +0,0 @@ -setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - $stmt = $pdo->prepare("SELECT id, email, password_hash FROM users LIMIT 1"); - $stmt->execute(); - print_r($stmt->fetch(PDO::FETCH_ASSOC)); -} catch (Exception $e) { echo $e->getMessage(); } \ No newline at end of file diff --git a/api/test_login.php b/api/test_login.php deleted file mode 100644 index e79e86b..0000000 --- a/api/test_login.php +++ /dev/null @@ -1,12 +0,0 @@ -first(); -if ($user) { - echo "User found: " . $user->email . "\n"; - echo "Hash: " . $user->password_hash . "\n"; - echo "Verify: " . (password_verify("Tanja82", $user->password_hash) ? "OK" : "FAIL") . "\n"; -} else { - echo "User not found"; -} \ No newline at end of file diff --git a/docs/plans/2026-05-20-slim-api-improvements.md b/docs/plans/2026-05-20-slim-api-improvements.md new file mode 100644 index 0000000..ee0e416 --- /dev/null +++ b/docs/plans/2026-05-20-slim-api-improvements.md @@ -0,0 +1,354 @@ +# Slim API Verbesserungen — Implementierung Plan + +> **Für Hermes:** Mit subagent-driven-development skill umsetzen, task für task. + +**Ziel:** Produktionsreife Slim API für fahrschultermin.de — Security, Robustheit, Multi-Tenant Isolation + +**Architektur:** +- Frontend (React) → `fahrschultermin.de` → API Calls direkt an `api.fahrschultermin.de` +- API (Slim 4) → PostgreSQL auf `127.0.0.1:5433` +- JWT Bearer Token Auth +- Multi-Tenant via `tenant_id` Filter in allen Queries + +**Tech Stack:** Slim 4, Eloquent ORM, Firebase JWT, PHP-DI + +--- + +## Task 1: JWT Secret in `.env` auslagern + +**Ziel:** Hardcoded Secret durch Environment Variable ersetzen + +**Dateien:** +- Erstellen: `api/.env` +- Modifizieren: `api/src/Config/Container.php:45` + +**Schritt 1: `.env` Datei erstellen** + +```bash +echo 'JWT_SECRET=your-256-bit-secret-change-in-production' > api/.env +``` + +**Schritt 2: Container.php anpassen** + +Ersetze in `Container.php` Zeile 45: +```php +'jwt.secret' => 'your-256-bit-secret-change-in-production', +``` +mit: +```php +'jwt.secret' => $_ENV['JWT_SECRET'] ?? throw new \RuntimeException('JWT_SECRET not set'), +``` + +**Schritt 3: Testen** + +```bash +ssh -p 2225 fahrschuldesk@fahrschultermin.de "cd /srv/web/fahrschuldesk/domains/api.fahrschultermin.de && ls .env && echo 'JWT_SECRET defined'" +``` + +Erwartet: `JWT_SECRET defined` + +**Schritt 4: Commit** + +```bash +git add api/.env api/src/Config/Container.php +git commit -m "security: move JWT secret to .env" +git push +``` + +--- + +## Task 2: JWT Secret generieren & auf Server deployen + +**Ziel:** Echten 256-bit Secret generieren und sicher auf Server bringen + +**Schritt 1: Secret generieren** + +```bash +openssl rand -base64 32 +``` + +**Schritt 2: Secret in Git encrypted speichern** + +```bash +# Neues Secret als GitHub Secret oder verschlüsselt speichern +# Hier: Nur auf Server setzen via ssh +ssh -p 2225 fahrschuldesk@fahrschultermin.de 'echo "JWT_SECRET=$(openssl rand -base64 32)" >> /srv/web/fahrschuldesk/domains/api.fahrschultermin.de/.env' +``` + +**Schritt 3: Verifizieren** + +```bash +ssh -p 2225 fahrschuldesk@fahrschultermin.de 'grep JWT_SECRET /srv/web/fahrschuldesk/domains/api.fahrschultermin.de/.env' +``` + +Erwartet: `JWT_SECRET=` + +**Schritt 4: Commit** + +```bash +git add api/.env.example +git commit -m "security: add .env.example with JWT_SECRET placeholder" +git push +``` + +--- + +## Task 3: Multi-Tenant Isolation — Tenant ID in allen Queries + +**Ziel:** Sicherstellen dass jede Query nur Daten des eigenen Tenants zurückgibt + +**Dateien:** +- Modifizieren: `api/src/Controllers/AppointmentsController.php` +- Modifizieren: `api/src/Controllers/StudentsController.php` +- Modifizieren: `api/src/Controllers/InstructorsController.php` +- Modifizieren: `api/src/Controllers/LessonTypesController.php` +- Modifizieren: `api/src/Controllers/TemplatesController.php` + +**Schritt 1: Prüfen dass alle Controller `tenant_id` nutzen** + +Alle Controller müssen bei `index()`, `store()`, `update()`, `destroy()` den tenant_id Filter haben. + +**Schritt 2: BootstrapController prüfen** (hat bereits tenant isolation) + +**Schritt 3: Commit** + +```bash +git add api/src/Controllers/ +git commit -m "security: ensure tenant_id filtering on all endpoints" +git push +``` + +--- + +## Task 4: Password Hash System verstehen und fixen + +**Ziel:** Verstehen warum lokales `password_verify` FAIL gab, Server OK + +**Schritt 1: Test-Script schreiben** + +```php +where(function($q) use ($start, $end) { + $q->whereBetween('start_at', [$start, $end]) + ->orWhereBetween('end_at', [$start, $end]) + ->orWhere(function($q2) use ($start, $end) { + $q2->where('start_at', '<=', $start)->where('end_at', '>=', $end); + }); + }) + ->where('status', '!=', 'cancelled'); + + if ($excludeId) { + $query->where('id', '!=', $excludeId); + } + + return $query->exists(); +} +``` + +**Schritt 2: Store und Update anpassen** + +Vor dem Erstellen: `if (Appointment::hasConflict($data['instructor_id'], $data['start_at'], $data['end_at'])) { return $this->json($response, ['message' => 'Instructor conflict'], 409); }` + +**Schritt 3: Commit** + +```bash +git add api/src/Controllers/AppointmentsController.php api/src/Models/Appointment.php +git commit -m "feat: add conflict detection for instructor scheduling" +git push +``` + +--- + +## Task 6: Frontend → API Integration testen + +**Ziel:** Verify dass React App mit neuer Slim API funktioniert + +**Schritt 1: API Endpoints testen mit curl** + +```bash +# Login +TOKEN=$(curl -s -X POST https://api.fahrschultermin.de/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@nordring.test","password":"Tanja82"}' | jq -r '.token') + +echo "Token: ${TOKEN:0:20}..." + +# Bootstrap +curl -s https://api.fahrschultermin.de/api/v1/bootstrap \ + -H "Authorization: Bearer $TOKEN" | jq '.tenant.name' + +# Students +curl -s https://api.fahrschultermin.de/api/v1/students \ + -H "Authorization: Bearer $TOKEN" | jq 'length' +``` + +**Schritt 2: Browser testen** (manuell) + +- Öffne https://fahrschultermin.de +- Login mit admin@nordring.test / Tanja82 +- Prüfe Console Errors +- Prüfe Network Tab → api.fahrschultermin.de calls + +**Schritt 3: Commit** + +```bash +git add -A && git commit -m "test: verify frontend API integration" +git push +``` + +--- + +## Task 7: API Documentation mit README + +**Ziel:** API dokumentieren für zukünftige Entwicklung + +**Dateien:** +- Erstellen: `api/README.md` + +**Schritt 1: README schreiben** + +```markdown +# DriveTime Planner API + +## Base URL +`https://api.fahrschultermin.de/api/v1` + +## Authentication +JWT Bearer Token + +``` +Authorization: Bearer +``` + +## Endpoints + +### Auth +- `POST /auth/login` — Login mit email/password +- `POST /auth/logout` — Logout (client-side token verwerfen) +- `GET /auth/me` — Aktueller User (requires auth) + +### Bootstrap +- `GET /bootstrap` — Vollständige Daten für Frontend (requires auth) + +### CRUD +- `GET/POST /students` — Schüler Liste/Anlegen +- `PATCH/DELETE /students/{id}` — Schüler Ändern/Löschen +- `GET/POST /instructors` — Fahrlehrer Liste/Anlegen +- `PATCH /instructors/{id}` — Fahrlehrer Ändern +- `GET/POST /appointments` — Termine Liste/Anlegen +- `PATCH/DELETE /appointments/{id}` — Termine Ändern/Löschen +- `GET /lesson-types` — Unterrichtstypen +- `GET /license-class-templates` — Führerschein-Klassen + +## Error Responses +- 400: Bad Request +- 401: Unauthorized +- 404: Not Found +- 409: Conflict (z.B. Terminkonflikt) +- 500: Server Error + +## Environment Variables +- `JWT_SECRET`: 256-bit secret für JWT signing +``` + +**Schritt 2: Commit** + +```bash +git add api/README.md +git commit -m "docs: add API documentation" +git push +``` + +--- + +## Task 8: Deployment Script erstellen + +**Ziel:** Ein Script das API auf Server deployed mit `composer install` + +**Dateien:** +- Erstellen: `scripts/deploy_api.sh` + +**Schritt 1: Script schreiben** + +```bash +#!/bin/bash +set -e + +API_DIR="/srv/web/fahrschuldesk/domains/api.fahrschultermin.de" + +echo "=== Deploying Slim API ===" + +# Rsync +rsync -avz --delete -e "ssh -p 2225" \ + /home/admin-drive-time-planer/drivetimeplaner/api/ \ + fahrschuldesk@fahrschultermin.de:$API_DIR/ + +# Composer install +ssh -p 2225 fahrschuldesk@fahrschultermin.de "cd $API_DIR && composer install --no-dev --optimize-autoloader" + +echo "=== Deployment complete ===" +``` + +**Schritt 2: Commit** + +```bash +git add scripts/deploy_api.sh +git commit -m "script: add API deployment script" +git push +``` + +--- + +## Reihenfolge + +1. Task 1: JWT Secret auslagern +2. Task 2: Secret generieren & deployen +3. Task 3: Multi-Tenant Isolation prüfen +4. Task 4: Password Hash verstehen +5. Task 5: Konflikterkennung (wichtig für Produktion) +6. Task 6: Frontend → API testen +7. Task 7: API Docs +8. Task 8: Deployment Script \ No newline at end of file diff --git a/scripts/deploy_api.sh b/scripts/deploy_api.sh new file mode 100644 index 0000000..3194e7a --- /dev/null +++ b/scripts/deploy_api.sh @@ -0,0 +1,50 @@ +#!/bin/bash +set -e + +# DriveTime API Deployment Script +# Usage: ./scripts/deploy_api.sh + +API_LOCAL="/home/admin-drive-time-planer/drivetimeplaner/api" +API_REMOTE="/srv/web/fahrschuldesk/domains/api.fahrschultermin.de" +SSH_OPTS="-p 2225" + +echo "=== Deploying DriveTime Slim API ===" + +# 1. Rsync source files (exclude vendor, .env, storage) +echo "[1/4] Syncing files..." +rsync -avz --delete \ + --exclude='vendor/' \ + --exclude='.env' \ + --exclude='storage/' \ + -e "ssh $SSH_OPTS" \ + "$API_LOCAL/" \ + "fahrschuldesk@fahrschultermin.de:$API_REMOTE/" + +# 2. Composer install on server +echo "[2/4] Running composer install..." +ssh $SSH_OPTS "fahrschuldesk@fahrschultermin.de" \ + "cd $API_REMOTE && composer install --no-dev --optimize-autoloader 2>&1" + +# 3. Clear OPcache if needed +echo "[3/4] Checking PHP OPcache..." +ssh $SSH_OPTS "fahrschuldesk@fahrschultermin.de" \ + "php -r 'if(function_exists(\"opcache_get_status\")) { opcache_get_status(); }' 2>/dev/null && echo 'OPcache active' || echo 'No OPcache'" + +# 4. Test login endpoint +echo "[4/4] Testing API..." +TOKEN=$(curl -sf -X POST "https://api.fahrschultermin.de/api/v1/auth/login" \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@nordring.test","password":"Tanja82"}' | jq -r '.token') + +if [ -n "$TOKEN" ] && [ "$TOKEN" != "null" ]; then + echo "✓ Login successful" + curl -sf "https://api.fahrschultermin.de/api/v1/bootstrap" \ + -H "Authorization: Bearer $TOKEN" | jq -e '.tenant.name' > /dev/null 2>&1 + echo "✓ Bootstrap endpoint working" +else + echo "✗ Login failed - check JWT_SECRET on server" + exit 1 +fi + +echo "" +echo "=== Deployment complete ===" \ No newline at end of file