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:
Hermes Agent
2026-05-20 17:30:24 +02:00
parent 09324a9513
commit 09759b7619
9 changed files with 536 additions and 64 deletions

View File

@@ -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',

View File

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

View File

@@ -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();
}
}