Slim API deployment - composer install + full CRUD endpoints

This commit is contained in:
Hermes Agent
2026-05-20 14:36:05 +02:00
parent 48bf9c7088
commit 8ab4e532fd
1727 changed files with 7746 additions and 7 deletions

View File

@@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Repositories\AppointmentRepository;
use App\Repositories\InstructorRepository;
use App\Repositories\ReferenceDataRepository;
use App\Repositories\StudentRepository;
use App\Services\CalendarService;
use App\Services\RequirementService;
use App\Support\Auth;
use App\Support\Request;
use App\Support\Response;
final class AppointmentsController
{
public function index(Request $request): void
{
$user = Auth::requireUser();
$from = (string) $request->query('from');
$to = (string) $request->query('to');
$instructorId = null;
if ($user['role'] === 'instructor') {
$instructor = (new InstructorRepository())->findByUserId((int) $user['tenant_id'], (int) $user['id']);
$instructorId = $instructor ? (int) $instructor['id'] : null;
}
$data = (new AppointmentRepository())->listForTenant(
(int) $user['tenant_id'],
$from,
$to,
$instructorId
);
Response::json(['data' => $data]);
}
public function validate(Request $request): void
{
$user = Auth::requireUser();
$payload = $this->normalizePayload((int) $user['tenant_id'], $request->input());
$warnings = (new CalendarService())->validate((int) $user['tenant_id'], $payload);
Response::json(['warnings' => $warnings, 'payload' => $payload]);
}
public function store(Request $request): void
{
$user = Auth::requireRole(['tenant_admin', 'dispatcher', 'instructor']);
$payload = $this->normalizePayload((int) $user['tenant_id'], $request->input());
$warnings = (new CalendarService())->validate((int) $user['tenant_id'], $payload);
if ($this->hasWarnings($warnings) && empty($payload['warning_acknowledged'])) {
Response::json(['message' => 'Warnings require acknowledgement', 'warnings' => $warnings], 409);
}
$record = (new AppointmentRepository())->create((int) $user['tenant_id'], $payload, (int) $user['id']);
Response::json([
'data' => $record,
'warnings' => $warnings,
'progress' => $payload['student_id'] ? (new RequirementService())->progressForStudent((int) $payload['student_id']) : [],
], 201);
}
public function update(Request $request, array $params): void
{
$user = Auth::requireRole(['tenant_admin', 'dispatcher', 'instructor']);
$payload = $this->normalizePayload((int) $user['tenant_id'], $request->input());
$warnings = (new CalendarService())->validate((int) $user['tenant_id'], $payload, (int) $params['id']);
if ($this->hasWarnings($warnings) && empty($payload['warning_acknowledged'])) {
Response::json(['message' => 'Warnings require acknowledgement', 'warnings' => $warnings], 409);
}
$record = (new AppointmentRepository())->update((int) $user['tenant_id'], (int) $params['id'], $payload, (int) $user['id']);
Response::json([
'data' => $record,
'warnings' => $warnings,
'progress' => $payload['student_id'] ? (new RequirementService())->progressForStudent((int) $payload['student_id']) : [],
]);
}
public function destroy(Request $request, array $params): void
{
$user = Auth::requireRole(['tenant_admin', 'dispatcher']);
(new AppointmentRepository())->delete((int) $user['tenant_id'], (int) $params['id']);
Response::noContent();
}
private function normalizePayload(int $tenantId, array $payload): array
{
$referenceRepository = new ReferenceDataRepository();
$lessonType = $referenceRepository->findLessonType($tenantId, (int) $payload['lesson_type_id']);
if ($lessonType === null) {
Response::json(['message' => 'Unbekannte Stundenart'], 422);
}
$student = null;
if (!empty($payload['student_id'])) {
$student = (new StudentRepository())->find($tenantId, (int) $payload['student_id']);
if ($student === null) {
Response::json(['message' => 'Unbekannter Fahrschueler'], 422);
}
if (
$lessonType['category'] === 'student'
&& !$referenceRepository->isLessonTypeVisibleForTemplate($tenantId, (int) $lessonType['id'], (int) $student['template_id'])
) {
Response::json(['message' => 'Diese Stundenart ist fuer die Fuehrerscheinklasse nicht freigegeben'], 422);
}
}
$startAt = new \DateTimeImmutable((string) $payload['start_at']);
$baseDuration = max(1, (int) $lessonType['default_duration']);
$providedEndAt = !empty($payload['end_at']) ? new \DateTimeImmutable((string) $payload['end_at']) : null;
if ($providedEndAt instanceof \DateTimeImmutable && $providedEndAt > $startAt) {
$endAt = $providedEndAt;
$duration = max(1, (int) round(($endAt->getTimestamp() - $startAt->getTimestamp()) / 60));
$units = (int) $lessonType['fixed_duration'] === 1
? 1
: max(1, (int) round($duration / $baseDuration));
} else {
$units = max(1, (int) ($payload['units'] ?? 1));
$duration = (int) $lessonType['fixed_duration'] === 1
? $baseDuration
: $baseDuration * $units;
$endAt = $startAt->modify(sprintf('+%d minutes', $duration));
}
$title = $payload['title'] ?? $lessonType['name'] ?? 'Termin';
$unitsBreakdown = [];
if ($lessonType && (int) $lessonType['is_counted'] === 1 && !empty($payload['student_id'])) {
$requirementKey = $lessonType['effective_requirement_key'] ?? $lessonType['requirement_key'];
if ($student !== null && (int) ($student['template_is_combination'] ?? 0) === 1) {
$requirementKey = $this->requirementKeyForPhase($student['requirements'] ?? [], $requirementKey, (string) ($payload['requirement_phase'] ?? ''));
}
$unitsBreakdown[] = [
'requirement_key' => $requirementKey,
'label' => $lessonType['name'],
'units_counted' => $units,
];
}
return [
'student_id' => $payload['student_id'] ?? null,
'instructor_id' => (int) $payload['instructor_id'],
'lesson_type_id' => (int) $payload['lesson_type_id'],
'title' => $title,
'category' => $lessonType['category'] ?? ($payload['category'] ?? 'student'),
'start_at' => $startAt->format(DATE_ATOM),
'end_at' => $endAt->format(DATE_ATOM),
'units' => $units,
'status' => $payload['status'] ?? 'planned',
'notes' => $payload['notes'] ?? '',
'warning_acknowledged' => !empty($payload['warning_acknowledged']),
'units_breakdown' => $unitsBreakdown,
];
}
private function hasWarnings(array $warnings): bool
{
return !empty($warnings['conflicts']) || !empty($warnings['rest_period_warnings']);
}
private function requirementKeyForPhase(array $requirements, string $baseRequirementKey, string $phaseLabel): string
{
$matchingRows = array_values(array_filter(
$requirements,
static fn (array $row): bool => str_ends_with((string) $row['requirement_key'], '::' . $baseRequirementKey)
|| (string) $row['requirement_key'] === $baseRequirementKey
));
if ($matchingRows === []) {
return $baseRequirementKey;
}
foreach ($matchingRows as $row) {
if ((string) ($row['phase_label'] ?? '') === $phaseLabel) {
return (string) $row['requirement_key'];
}
}
return (string) $matchingRows[0]['requirement_key'];
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Repositories\UserRepository;
use App\Support\Auth;
use App\Support\Request;
use App\Support\Response;
final class AuthController
{
public function login(Request $request): void
{
$payload = $request->input();
$user = (new UserRepository())->findByEmail(trim((string) ($payload['email'] ?? '')));
if ($user === null || !password_verify((string) ($payload['password'] ?? ''), $user['password_hash'])) {
Response::json(['message' => 'Ungueltige Zugangsdaten'], 422);
}
if (!(int) $user['is_active'] || ((int) ($user['tenant_is_active'] ?? 1) !== 1 && $user['role'] !== 'superadmin')) {
Response::json(['message' => 'Benutzer oder Mandant ist deaktiviert'], 403);
}
$_SESSION['user_id'] = (int) $user['id'];
Response::json(['user' => $this->sanitizeUser($user)]);
}
public function logout(): void
{
session_destroy();
Response::noContent();
}
public function me(): void
{
$user = Auth::user();
Response::json(['user' => $user ? $this->sanitizeUser($user) : null]);
}
private function sanitizeUser(array $user): array
{
unset($user['password_hash']);
return $user;
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Repositories\AppointmentRepository;
use App\Repositories\InstructorRepository;
use App\Repositories\ReferenceDataRepository;
use App\Repositories\StudentRepository;
use App\Repositories\TenantRepository;
use App\Repositories\UserRepository;
use App\Services\RequirementService;
use App\Services\HolidayService;
use App\Services\SunTimesService;
use App\Support\Auth;
use App\Support\Request;
use App\Support\Response;
final class BootstrapController
{
public function __invoke(Request $request): void
{
$user = Auth::requireUser();
unset($user['password_hash']);
$payload = ['session' => ['user' => $user]];
if ($user['role'] === 'superadmin') {
$payload['tenants'] = (new TenantRepository())->all();
$payload['users'] = (new UserRepository())->allUsers();
Response::json($payload);
}
$tenantId = (int) $user['tenant_id'];
$studentRepository = new StudentRepository();
$students = $studentRepository->allForTenant($tenantId);
$requirementService = new RequirementService();
foreach ($students as &$student) {
$student['progress'] = $requirementService->progressForStudent((int) $student['id']);
}
$payload['tenant'] = (new TenantRepository())->find($tenantId);
$payload['students'] = $students;
$payload['templates'] = (new ReferenceDataRepository())->templates($tenantId);
$payload['lessonTypes'] = (new ReferenceDataRepository())->lessonTypes($tenantId);
$payload['instructors'] = (new InstructorRepository())->allForTenant($tenantId);
$payload['users'] = $user['role'] === 'tenant_admin'
? (new UserRepository())->allForTenant($tenantId)
: [];
$instructorId = null;
if ($user['role'] === 'instructor') {
$instructor = (new InstructorRepository())->findByUserId($tenantId, (int) $user['id']);
$instructorId = $instructor ? (int) $instructor['id'] : null;
}
$from = $request->query('from', gmdate('Y-m-d\T00:00:00\Z', strtotime('monday this week')));
$to = $request->query('to', gmdate('Y-m-d\T00:00:00\Z', strtotime('+7 day', strtotime($from))));
$payload['sunTimes'] = (new SunTimesService())->forRange($payload['tenant'], (string) $from, (string) $to);
$payload['dayMeta'] = (new HolidayService())->forRange($payload['tenant'], (string) $from, (string) $to);
$payload['appointments'] = (new AppointmentRepository())->listForTenant(
$tenantId,
(string) $from,
(string) $to,
$instructorId
);
$payload['nextAppointment'] = (new AppointmentRepository())->nextForTenant(
$tenantId,
gmdate(DATE_ATOM),
$instructorId
);
Response::json($payload);
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Repositories\InstructorRepository;
use App\Support\Auth;
use App\Support\Request;
use App\Support\Response;
final class InstructorsController
{
public function index(): void
{
$user = Auth::requireUser();
Response::json(['data' => (new InstructorRepository())->allForTenant((int) $user['tenant_id'])]);
}
public function store(Request $request): void
{
$user = Auth::requireRole('tenant_admin');
$record = (new InstructorRepository())->create((int) $user['tenant_id'], $request->input());
Response::json(['data' => $record], 201);
}
public function update(Request $request, array $params): void
{
$user = Auth::requireRole('tenant_admin');
$record = (new InstructorRepository())->update((int) $user['tenant_id'], (int) $params['id'], $request->input());
Response::json(['data' => $record]);
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Repositories\ReferenceDataRepository;
use App\Support\Auth;
use App\Support\Request;
use App\Support\Response;
final class ReferenceDataController
{
public function lessonTypes(): void
{
$user = Auth::requireUser();
Response::json(['data' => (new ReferenceDataRepository())->lessonTypes((int) $user['tenant_id'])]);
}
public function storeLessonType(Request $request): void
{
$user = Auth::requireRole('tenant_admin');
$record = (new ReferenceDataRepository())->createLessonType((int) $user['tenant_id'], $request->input());
Response::json(['data' => $record], 201);
}
public function updateLessonType(Request $request, array $params): void
{
$user = Auth::requireRole('tenant_admin');
$record = (new ReferenceDataRepository())->updateLessonType((int) $user['tenant_id'], (int) $params['id'], $request->input());
Response::json(['data' => $record]);
}
public function destroyLessonType(Request $request, array $params): void
{
$user = Auth::requireRole('tenant_admin');
try {
$deleted = (new ReferenceDataRepository())->deleteLessonType((int) $user['tenant_id'], (int) $params['id']);
} catch (\RuntimeException $exception) {
Response::json(['message' => $exception->getMessage()], 409);
}
if (!$deleted) {
Response::json(['message' => 'Not found'], 404);
}
Response::noContent();
}
public function reorderLessonTypes(Request $request): void
{
$user = Auth::requireRole('tenant_admin');
$records = (new ReferenceDataRepository())->reorderLessonTypes(
(int) $user['tenant_id'],
(array) ($request->input()['lesson_type_ids'] ?? [])
);
Response::json(['data' => $records]);
}
public function updateLessonTypeMatrix(Request $request): void
{
$user = Auth::requireRole('tenant_admin');
$records = (new ReferenceDataRepository())->updateLessonTypeMatrix(
(int) $user['tenant_id'],
(array) ($request->input()['rows'] ?? [])
);
Response::json(['data' => $records]);
}
public function templates(): void
{
$user = Auth::requireUser();
Response::json(['data' => (new ReferenceDataRepository())->templates((int) $user['tenant_id'])]);
}
public function storeTemplate(Request $request): void
{
$user = Auth::requireRole('tenant_admin');
$record = (new ReferenceDataRepository())->createTemplate((int) $user['tenant_id'], $request->input());
Response::json(['data' => $record], 201);
}
public function updateTemplate(Request $request, array $params): void
{
$user = Auth::requireRole('tenant_admin');
$record = (new ReferenceDataRepository())->updateTemplate((int) $user['tenant_id'], (int) $params['id'], $request->input());
Response::json(['data' => $record]);
}
}

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Repositories\StudentRepository;
use App\Services\RequirementService;
use App\Support\Auth;
use App\Support\Request;
use App\Support\Response;
final class StudentsController
{
public function index(): void
{
$user = Auth::requireUser();
$students = (new StudentRepository())->allForTenant((int) $user['tenant_id']);
$requirements = new RequirementService();
foreach ($students as &$student) {
$student['progress'] = $requirements->progressForStudent((int) $student['id']);
}
Response::json(['data' => $students]);
}
public function store(Request $request): void
{
$user = Auth::requireRole(['tenant_admin', 'dispatcher']);
$student = (new StudentRepository())->create((int) $user['tenant_id'], $request->input());
$student['progress'] = (new RequirementService())->progressForStudent((int) $student['id']);
Response::json(['data' => $student], 201);
}
public function update(Request $request, array $params): void
{
$user = Auth::requireRole(['tenant_admin', 'dispatcher']);
$student = (new StudentRepository())->update((int) $user['tenant_id'], (int) $params['id'], $request->input());
if ($student === null) {
Response::json(['message' => 'Not found'], 404);
}
$student['progress'] = (new RequirementService())->progressForStudent((int) $student['id']);
Response::json(['data' => $student]);
}
public function updateTraining(Request $request, array $params): void
{
$user = Auth::requireRole(['tenant_admin', 'dispatcher']);
$student = (new StudentRepository())->updatePriorCompletedUnits(
(int) $user['tenant_id'],
(int) $params['id'],
$request->input()['rows'] ?? []
);
if ($student === null) {
Response::json(['message' => 'Not found'], 404);
}
$student['progress'] = (new RequirementService())->progressForStudent((int) $student['id']);
Response::json(['data' => $student]);
}
public function destroy(Request $request, array $params): void
{
$user = Auth::requireRole(['tenant_admin', 'dispatcher']);
$deleted = (new StudentRepository())->delete((int) $user['tenant_id'], (int) $params['id']);
if (!$deleted) {
Response::json(['message' => 'Not found'], 404);
}
Response::noContent();
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Repositories\TenantRepository;
use App\Support\Auth;
use App\Support\Request;
use App\Support\Response;
final class TenantsController
{
public function index(): void
{
Auth::requireRole('superadmin');
Response::json(['data' => (new TenantRepository())->all()]);
}
public function store(Request $request): void
{
Auth::requireRole('superadmin');
$tenant = (new TenantRepository())->create($request->input());
Response::json(['data' => $tenant], 201);
}
public function update(Request $request, array $params): void
{
Auth::requireRole('superadmin');
$tenant = (new TenantRepository())->update((int) $params['id'], $request->input());
Response::json(['data' => $tenant]);
}
public function updateOwn(Request $request): void
{
$user = Auth::requireRole('tenant_admin');
$tenant = (new TenantRepository())->updateForTenantAdmin((int) $user['tenant_id'], $request->input());
Response::json(['data' => $tenant]);
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Repositories\UserRepository;
use App\Support\Auth;
use App\Support\Request;
use App\Support\Response;
final class UsersController
{
public function index(): void
{
$user = Auth::requireRole(['tenant_admin', 'superadmin']);
if ($user['role'] === 'superadmin') {
Response::json(['data' => (new UserRepository())->allUsers()]);
}
Response::json(['data' => (new UserRepository())->allForTenant((int) $user['tenant_id'])]);
}
public function store(Request $request): void
{
$user = Auth::requireRole(['tenant_admin', 'superadmin']);
$payload = $request->input();
if ($user['role'] !== 'superadmin') {
$payload['tenant_id'] = (int) $user['tenant_id'];
}
$record = (new UserRepository())->create($payload);
unset($record['password_hash']);
Response::json(['data' => $record], 201);
}
public function update(Request $request, array $params): void
{
$user = Auth::requireRole(['tenant_admin', 'superadmin']);
$payload = $request->input();
if ($user['role'] !== 'superadmin') {
$payload['tenant_id'] = (int) $user['tenant_id'];
}
$record = (new UserRepository())->update((int) $params['id'], $payload);
if ($record === null) {
Response::json(['message' => 'Not found'], 404);
}
unset($record['password_hash']);
Response::json(['data' => $record]);
}
}