feat: JWT secret in .env, conflict detection, docs, deploy script
- Move JWT_SECRET from hardcode to .env (security) - Add hasInstructorConflict() to Appointment model - Add conflict check in store() and update() — returns 409 - Add .env.example with placeholder - Add API documentation (README.md) - Add deploy_api.sh script
This commit is contained in:
105
api/README.md
105
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
|
## Authentication
|
||||||
cd api
|
JWT Bearer Token. Login returns a token valid for 8 hours.
|
||||||
composer install
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <token>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Run
|
## Endpoints
|
||||||
|
|
||||||
```bash
|
|
||||||
php -S localhost:8080 -t public/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Routes
|
|
||||||
|
|
||||||
|
### Auth
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| POST | /api/v1/auth/login | Login (returns JWT) |
|
| POST | `/auth/login` | Login with email/password, returns JWT |
|
||||||
| POST | /api/v1/auth/logout | Logout |
|
| POST | `/auth/logout` | Logout (client-side token discard) |
|
||||||
| GET | /api/v1/auth/me | Current user |
|
| GET | `/auth/me` | Current user info (requires auth) |
|
||||||
| 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 |
|
|
||||||
|
|
||||||
## 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)
|
## 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)
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?php
|
|
||||||
require __DIR__."/vendor/autoload.php";
|
|
||||||
$pdo = new PDO("pgsql:host=127.0.0.1;port=5433;dbname=fahrschultermin", "fahrschultermin_api", "X9wL3pN7kRtHvMbZs4cGfYu2Dj6qAe8t");
|
|
||||||
$pdo->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";
|
|
||||||
@@ -42,8 +42,12 @@ final class Container
|
|||||||
|
|
||||||
$builder = new \DI\ContainerBuilder();
|
$builder = new \DI\ContainerBuilder();
|
||||||
|
|
||||||
|
$jwtSecret = $_ENV['JWT_SECRET'] ?? null;
|
||||||
|
if (!$jwtSecret) {
|
||||||
|
throw new \RuntimeException('JWT_SECRET environment variable not set');
|
||||||
|
}
|
||||||
$builder->addDefinitions([
|
$builder->addDefinitions([
|
||||||
'jwt.secret' => 'your-256-bit-secret-change-in-production',
|
'jwt.secret' => $jwtSecret,
|
||||||
'config' => [
|
'config' => [
|
||||||
'frontend_url' => 'https://fahrschultermin.de',
|
'frontend_url' => 'https://fahrschultermin.de',
|
||||||
'api_base_url' => 'https://api.fahrschultermin.de',
|
'api_base_url' => 'https://api.fahrschultermin.de',
|
||||||
|
|||||||
@@ -29,6 +29,19 @@ final class AppointmentsController
|
|||||||
$user = $request->getAttribute('user');
|
$user = $request->getAttribute('user');
|
||||||
$data = json_decode($request->getBody()->getContents(), true);
|
$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([
|
$appointment = Appointment::create([
|
||||||
'tenant_id' => $tenantId,
|
'tenant_id' => $tenantId,
|
||||||
'student_id' => $data['student_id'] ?? null,
|
'student_id' => $data['student_id'] ?? null,
|
||||||
@@ -56,6 +69,21 @@ final class AppointmentsController
|
|||||||
if (!$appointment) return $this->json($response, ['message' => 'Not found'], 404);
|
if (!$appointment) return $this->json($response, ['message' => 'Not found'], 404);
|
||||||
|
|
||||||
$data = json_decode($request->getBody()->getContents(), true);
|
$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'];
|
$data['updated_by'] = $user['id'];
|
||||||
$appointment->update(array_intersect_key($data, array_flip([
|
$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'
|
'student_id', 'instructor_id', 'lesson_type_id', 'title', 'start_at', 'end_at', 'units', 'status', 'notes', 'warning_acknowledged', 'updated_by'
|
||||||
|
|||||||
@@ -25,4 +25,31 @@ final class Appointment extends Model
|
|||||||
public function student(): BelongsTo { return $this->belongsTo(Student::class, 'student_id'); }
|
public function student(): BelongsTo { return $this->belongsTo(Student::class, 'student_id'); }
|
||||||
public function instructor(): BelongsTo { return $this->belongsTo(Instructor::class, 'instructor_id'); }
|
public function instructor(): BelongsTo { return $this->belongsTo(Instructor::class, 'instructor_id'); }
|
||||||
public function lessonType(): BelongsTo { return $this->belongsTo(LessonType::class, 'lesson_type_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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?php
|
|
||||||
require __DIR__."/vendor/autoload.php";
|
|
||||||
try {
|
|
||||||
$pdo = new PDO("pgsql:host=127.0.0.1;port=5433;dbname=fahrschultermin", "fahrschultermin_api", "X9wL3pN7kRtHvMbZs4cGfYu2Dj6qAe8t");
|
|
||||||
$pdo->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(); }
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<?php
|
|
||||||
require __DIR__."/vendor/autoload.php";
|
|
||||||
use App\Config\Container;
|
|
||||||
Container::boot();
|
|
||||||
$user = \App\Models\User::where("email", "admin@nordring.test")->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";
|
|
||||||
}
|
|
||||||
354
docs/plans/2026-05-20-slim-api-improvements.md
Normal file
354
docs/plans/2026-05-20-slim-api-improvements.md
Normal file
@@ -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=<base64 string>`
|
||||||
|
|
||||||
|
**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
|
||||||
|
<?php
|
||||||
|
// Test password hash compatibility
|
||||||
|
$hash = '$2y$12$HvzRPK0v0HbtyrTxMz6jE.B0ewrnNEkoPSvHoe3o/Ndf2VGL13kRG';
|
||||||
|
echo "Hash: $hash\n";
|
||||||
|
echo "Verify local: " . (password_verify('Tanja82', $hash) ? 'OK' : 'FAIL') . "\n";
|
||||||
|
echo "Cost: " . password_get_info($hash)['options']['cost'] . "\n";
|
||||||
|
echo "PHP version: " . PHP_VERSION . "\n";
|
||||||
|
```
|
||||||
|
|
||||||
|
**Schritt 2: Auf Server testen**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rsync test_hash.php fahrschuldesk@fahrschultermin.de:/srv/web/fahrschuldesk/domains/api.fahrschultermin.de/test_hash.php
|
||||||
|
ssh -p 2225 fahrschuldesk@fahrschultermin.de 'cd /srv/web/fahrschuldesk/domains/api.fahrschultermin.de && php test_hash.php'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Schritt 3: Ergebnis analysieren**
|
||||||
|
|
||||||
|
Falls Server OK, local FAIL → PHP Version Unterschied (bcrypt cost 12 wird auf beiden unterstützt)
|
||||||
|
|
||||||
|
**Schritt 4: Cleanup**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -p 2225 fahrschuldesk@fahrschultermin.de 'rm /srv/web/fahrschuldesk/domains/api.fahrschultermin.de/test_hash.php'
|
||||||
|
git add -A && git commit -m "chore: cleanup test files"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Appointments Controller erweitern — Konflikterkennung
|
||||||
|
|
||||||
|
**Ziel:** Beim Anlegen/Ändern eines Termins prüfen ob Instructor oder Student schon belegt
|
||||||
|
|
||||||
|
**Dateien:**
|
||||||
|
- Modifizieren: `api/src/Controllers/AppointmentsController.php`
|
||||||
|
- Modifizieren: `api/src/Models/Appointment.php`
|
||||||
|
|
||||||
|
**Schritt 1: Check Methode hinzufügen**
|
||||||
|
|
||||||
|
```php
|
||||||
|
// In Appointment model, add:
|
||||||
|
public static function hasConflict(int $instructorId, string $start, string $end, ?int $excludeId = null): bool
|
||||||
|
{
|
||||||
|
$query = self::where('instructor_id', $instructorId)
|
||||||
|
->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 <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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
|
||||||
50
scripts/deploy_api.sh
Normal file
50
scripts/deploy_api.sh
Normal file
@@ -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 ==="
|
||||||
Reference in New Issue
Block a user