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
|
||||
cd api
|
||||
composer install
|
||||
## Authentication
|
||||
JWT Bearer Token. Login returns a token valid for 8 hours.
|
||||
|
||||
```
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
## 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)
|
||||
## 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();
|
||||
|
||||
$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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user