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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user