Slim API deployment - composer install + full CRUD endpoints
This commit is contained in:
44
api/README.md
Normal file
44
api/README.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# DriveTime Planner API (Slim 4)
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd api
|
||||
composer install
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
php -S localhost:8080 -t public/
|
||||
```
|
||||
|
||||
## Routes
|
||||
|
||||
| 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 |
|
||||
|
||||
## Auth
|
||||
|
||||
JWT Bearer token in Authorization header.
|
||||
|
||||
## Database
|
||||
|
||||
PostgreSQL (configured in src/Config/Container.php)
|
||||
29
api/composer.json
Normal file
29
api/composer.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "drivetimeplaner/api",
|
||||
"description": "DriveTime Planner API - Slim 4",
|
||||
"type": "project",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"slim/slim": "^4.12",
|
||||
"slim/psr7": "^1.6",
|
||||
"php-di/php-di": "^7.0",
|
||||
"illuminate/database": "^11.0",
|
||||
"firebase/php-jwt": "^7.0",
|
||||
"respect/validation": "^2.2",
|
||||
"robmorgan/phinx": "^0.14",
|
||||
"tuupola/cors-middleware": "^1.2",
|
||||
"symfony/dotenv": "^7.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "src/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"migrate": "phinx migrate",
|
||||
"migrate:rollback": "phinx rollback"
|
||||
},
|
||||
"config": {
|
||||
"optimize-autoloader": true
|
||||
}
|
||||
}
|
||||
36
api/public/index.php
Normal file
36
api/public/index.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
use App\Config\Container;
|
||||
|
||||
// Load .env file safely
|
||||
$envFile = __DIR__ . '/../.env';
|
||||
if (file_exists($envFile)) {
|
||||
$dotenv = new \Symfony\Component\Dotenv\Dotenv();
|
||||
$dotenv->load($envFile);
|
||||
}
|
||||
|
||||
// Boot Eloquent BEFORE creating the app
|
||||
Container::boot();
|
||||
|
||||
$container = Container::build();
|
||||
\Slim\Factory\AppFactory::setContainer($container);
|
||||
|
||||
$app = \Slim\Factory\AppFactory::create();
|
||||
|
||||
// CORS Middleware
|
||||
$app->add(new \Tuupola\Middleware\CorsMiddleware([
|
||||
'origin' => ['https://fahrschultermin.de', 'https://www.fahrschultermin.de'],
|
||||
'methods' => ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
'headers.allow' => ['Content-Type', 'Authorization', 'X-Requested-With'],
|
||||
'credentials' => true,
|
||||
'cache.maxage' => 86400,
|
||||
]));
|
||||
|
||||
// Routes
|
||||
(require __DIR__ . '/../src/Routes/api.php')($app);
|
||||
|
||||
$app->run();
|
||||
9
api/reset_pass.php
Normal file
9
api/reset_pass.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?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";
|
||||
56
api/src/Config/Container.php
Normal file
56
api/src/Config/Container.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Config;
|
||||
|
||||
use Illuminate\Database\Capsule\Manager as Capsule;
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
final class Container
|
||||
{
|
||||
private static ?Capsule $capsule = null;
|
||||
|
||||
public static function boot(): void
|
||||
{
|
||||
if (self::$capsule !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::$capsule = new Capsule();
|
||||
|
||||
self::$capsule->addConnection([
|
||||
'driver' => 'pgsql',
|
||||
'host' => '127.0.0.1',
|
||||
'port' => '5433',
|
||||
'database' => 'fahrschultermin',
|
||||
'username' => 'fahrschultermin_api',
|
||||
'password' => 'X9wL3pN7kRtHvMbZs4cGfYu2Dj6qAe8t',
|
||||
'charset' => 'utf8',
|
||||
'prefix' => '',
|
||||
'schema' => 'public',
|
||||
'sslmode' => 'prefer',
|
||||
]);
|
||||
|
||||
self::$capsule->setAsGlobal();
|
||||
self::$capsule->bootEloquent();
|
||||
}
|
||||
|
||||
public static function build(): ContainerInterface
|
||||
{
|
||||
self::boot();
|
||||
|
||||
$builder = new \DI\ContainerBuilder();
|
||||
|
||||
$builder->addDefinitions([
|
||||
'jwt.secret' => 'your-256-bit-secret-change-in-production',
|
||||
'config' => [
|
||||
'frontend_url' => 'https://fahrschultermin.de',
|
||||
'api_base_url' => 'https://api.fahrschultermin.de',
|
||||
'session_name' => 'drive_time_session',
|
||||
],
|
||||
]);
|
||||
|
||||
return $builder->build();
|
||||
}
|
||||
}
|
||||
81
api/src/Controllers/AppointmentsController.php
Normal file
81
api/src/Controllers/AppointmentsController.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Models\Appointment;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
final class AppointmentsController
|
||||
{
|
||||
public function index(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$tenantId = $request->getAttribute('tenant_id');
|
||||
$from = $request->getQueryParam('from');
|
||||
$to = $request->getQueryParam('to');
|
||||
|
||||
$query = Appointment::where('tenant_id', $tenantId)->with(['student', 'instructor', 'lessonType']);
|
||||
if ($from && $to) {
|
||||
$query->whereBetween('start_at', [$from, $to]);
|
||||
}
|
||||
return $this->json($response, $query->get()->toArray());
|
||||
}
|
||||
|
||||
public function store(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$tenantId = $request->getAttribute('tenant_id');
|
||||
$user = $request->getAttribute('user');
|
||||
$data = json_decode($request->getBody()->getContents(), true);
|
||||
|
||||
$appointment = Appointment::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'student_id' => $data['student_id'] ?? null,
|
||||
'instructor_id' => $data['instructor_id'],
|
||||
'lesson_type_id' => $data['lesson_type_id'],
|
||||
'title' => $data['title'] ?? '',
|
||||
'category' => $data['category'] ?? 'student',
|
||||
'start_at' => $data['start_at'],
|
||||
'end_at' => $data['end_at'],
|
||||
'units' => $data['units'] ?? 1,
|
||||
'status' => 'planned',
|
||||
'notes' => $data['notes'] ?? '',
|
||||
'created_by' => $user['id'],
|
||||
'updated_by' => $user['id'],
|
||||
]);
|
||||
|
||||
return $this->json($response, $appointment->toArray(), 201);
|
||||
}
|
||||
|
||||
public function update(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
|
||||
{
|
||||
$tenantId = $request->getAttribute('tenant_id');
|
||||
$user = $request->getAttribute('user');
|
||||
$appointment = Appointment::where('id', $args['id'])->where('tenant_id', $tenantId)->first();
|
||||
if (!$appointment) return $this->json($response, ['message' => 'Not found'], 404);
|
||||
|
||||
$data = json_decode($request->getBody()->getContents(), true);
|
||||
$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'
|
||||
])));
|
||||
|
||||
return $this->json($response, $appointment->fresh()->toArray());
|
||||
}
|
||||
|
||||
public function destroy(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
|
||||
{
|
||||
$tenantId = $request->getAttribute('tenant_id');
|
||||
$appointment = Appointment::where('id', $args['id'])->where('tenant_id', $tenantId)->first();
|
||||
if (!$appointment) return $this->json($response, ['message' => 'Not found'], 404);
|
||||
$appointment->delete();
|
||||
return $this->json($response, ['message' => 'Deleted']);
|
||||
}
|
||||
|
||||
private function json(ResponseInterface $response, array $data, int $status = 200): ResponseInterface
|
||||
{
|
||||
$response->getBody()->write(json_encode($data));
|
||||
return $response->withStatus($status)->withHeader('Content-Type', 'application/json');
|
||||
}
|
||||
}
|
||||
80
api/src/Controllers/AuthController.php
Normal file
80
api/src/Controllers/AuthController.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use Firebase\JWT\JWT;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
final class AuthController
|
||||
{
|
||||
public function login(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$body = json_decode($request->getBody()->getContents(), true);
|
||||
$email = $body['email'] ?? '';
|
||||
$password = $body['password'] ?? '';
|
||||
|
||||
if (empty($email) || empty($password)) {
|
||||
return $this->json($response, ['message' => 'Email and password required'], 400);
|
||||
}
|
||||
|
||||
$user = User::where('email', $email)->first();
|
||||
|
||||
if (!$user || !password_verify($password, $user->password_hash)) {
|
||||
return $this->json($response, ['message' => 'Invalid credentials'], 401);
|
||||
}
|
||||
|
||||
if (!$user->is_active) {
|
||||
return $this->json($response, ['message' => 'Account disabled'], 403);
|
||||
}
|
||||
|
||||
// Generate JWT
|
||||
$now = time();
|
||||
$payload = [
|
||||
'iss' => 'api.fahrschultermin.de',
|
||||
'sub' => $user->id,
|
||||
'tenant_id' => $user->tenant_id,
|
||||
'role' => $user->role,
|
||||
'iat' => $now,
|
||||
'exp' => $now + (8 * 60 * 60), // 8 hours
|
||||
];
|
||||
|
||||
$jwt = JWT::encode($payload, $_ENV['JWT_SECRET'] ?? 'this-is-a-32-character-secret-key-for-testing', 'HS256');
|
||||
|
||||
$userData = $user->toArray();
|
||||
unset($userData['password_hash']);
|
||||
|
||||
return $this->json($response, [
|
||||
'token' => $jwt,
|
||||
'user' => $userData,
|
||||
]);
|
||||
}
|
||||
|
||||
public function me(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$user = $request->getAttribute('user');
|
||||
|
||||
if (!$user) {
|
||||
return $this->json($response, ['message' => 'Unauthenticated'], 401);
|
||||
}
|
||||
|
||||
return $this->json($response, ['user' => $user]);
|
||||
}
|
||||
|
||||
public function logout(ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
// JWT is stateless, logout is client-side (discard token)
|
||||
return $this->json($response, ['message' => 'Logged out']);
|
||||
}
|
||||
|
||||
private function json(ResponseInterface $response, array $data, int $status = 200): ResponseInterface
|
||||
{
|
||||
$response->getBody()->write(json_encode($data));
|
||||
return $response
|
||||
->withStatus($status)
|
||||
->withHeader('Content-Type', 'application/json');
|
||||
}
|
||||
}
|
||||
67
api/src/Controllers/BootstrapController.php
Normal file
67
api/src/Controllers/BootstrapController.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Student;
|
||||
use App\Models\Instructor;
|
||||
use App\Models\Appointment;
|
||||
use App\Models\LessonType;
|
||||
use App\Models\LicenseClassTemplate;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
final class BootstrapController
|
||||
{
|
||||
public function handle(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$user = $request->getAttribute('user');
|
||||
$tenantId = (int) $user['tenant_id'];
|
||||
|
||||
// Load tenant
|
||||
$tenant = Tenant::find($tenantId);
|
||||
if (!$tenant) {
|
||||
return $this->json($response, ['message' => 'Tenant not found'], 404);
|
||||
}
|
||||
|
||||
// Load all related data
|
||||
$students = Student::where('tenant_id', $tenantId)->with('template')->get();
|
||||
$instructors = Instructor::where('tenant_id', $tenantId)->get();
|
||||
$lessonTypes = LessonType::where('tenant_id', $tenantId)->orderBy('sort_order')->get();
|
||||
$templates = LicenseClassTemplate::where('tenant_id', $tenantId)->with('requirements')->get();
|
||||
|
||||
// Date range from query params
|
||||
$params = $request->getQueryParams();
|
||||
$from = $params['from'] ?? gmdate('Y-m-d\TH:i:s\Z', strtotime('monday this week'));
|
||||
$to = $params['to'] ?? gmdate('Y-m-d\TH:i:s\Z', strtotime('+7 day', strtotime($from)));
|
||||
|
||||
// Load appointments
|
||||
$appointments = Appointment::where('tenant_id', $tenantId)
|
||||
->whereBetween('start_at', [$from, $to])
|
||||
->with(['student', 'instructor', 'lessonType'])
|
||||
->get();
|
||||
|
||||
$payload = [
|
||||
'session' => ['user' => $user],
|
||||
'tenant' => $tenant->toArray(),
|
||||
'students' => $students->toArray(),
|
||||
'instructors' => $instructors->toArray(),
|
||||
'lessonTypes' => $lessonTypes->toArray(),
|
||||
'templates' => $templates->toArray(),
|
||||
'appointments' => $appointments->toArray(),
|
||||
];
|
||||
|
||||
return $this->json($response, $payload);
|
||||
}
|
||||
|
||||
private function json(ResponseInterface $response, array $data, int $status = 200): ResponseInterface
|
||||
{
|
||||
$response->getBody()->write(json_encode($data));
|
||||
return $response
|
||||
->withStatus($status)
|
||||
->withHeader('Content-Type', 'application/json');
|
||||
}
|
||||
}
|
||||
55
api/src/Controllers/InstructorsController.php
Normal file
55
api/src/Controllers/InstructorsController.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Models\Instructor;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
final class InstructorsController
|
||||
{
|
||||
public function index(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$tenantId = $request->getAttribute('tenant_id');
|
||||
$instructors = Instructor::where('tenant_id', $tenantId)->get();
|
||||
return $this->json($response, $instructors->toArray());
|
||||
}
|
||||
|
||||
public function store(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$tenantId = $request->getAttribute('tenant_id');
|
||||
$data = json_decode($request->getBody()->getContents(), true);
|
||||
|
||||
$instructor = Instructor::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'first_name' => $data['first_name'] ?? '',
|
||||
'last_name' => $data['last_name'] ?? '',
|
||||
'color' => $data['color'] ?? '#0e5d80',
|
||||
'notes' => $data['notes'] ?? '',
|
||||
'is_active' => 1,
|
||||
'pre_start_buffer_minutes' => $data['pre_start_buffer_minutes'] ?? 60,
|
||||
]);
|
||||
|
||||
return $this->json($response, $instructor->toArray(), 201);
|
||||
}
|
||||
|
||||
public function update(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
|
||||
{
|
||||
$tenantId = $request->getAttribute('tenant_id');
|
||||
$instructor = Instructor::where('id', $args['id'])->where('tenant_id', $tenantId)->first();
|
||||
if (!$instructor) return $this->json($response, ['message' => 'Not found'], 404);
|
||||
|
||||
$data = json_decode($request->getBody()->getContents(), true);
|
||||
$instructor->update(array_intersect_key($data, array_flip(['first_name', 'last_name', 'color', 'notes', 'is_active', 'pre_start_buffer_minutes'])));
|
||||
|
||||
return $this->json($response, $instructor->fresh()->toArray());
|
||||
}
|
||||
|
||||
private function json(ResponseInterface $response, array $data, int $status = 200): ResponseInterface
|
||||
{
|
||||
$response->getBody()->write(json_encode($data));
|
||||
return $response->withStatus($status)->withHeader('Content-Type', 'application/json');
|
||||
}
|
||||
}
|
||||
25
api/src/Controllers/LessonTypesController.php
Normal file
25
api/src/Controllers/LessonTypesController.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Models\LessonType;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
final class LessonTypesController
|
||||
{
|
||||
public function index(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$tenantId = $request->getAttribute('tenant_id');
|
||||
$types = LessonType::where('tenant_id', $tenantId)->orderBy('sort_order')->get();
|
||||
return $this->json($response, $types->toArray());
|
||||
}
|
||||
|
||||
private function json(ResponseInterface $response, array $data, int $status = 200): ResponseInterface
|
||||
{
|
||||
$response->getBody()->write(json_encode($data));
|
||||
return $response->withStatus($status)->withHeader('Content-Type', 'application/json');
|
||||
}
|
||||
}
|
||||
75
api/src/Controllers/StudentsController.php
Normal file
75
api/src/Controllers/StudentsController.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Models\Student;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
final class StudentsController
|
||||
{
|
||||
public function index(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$tenantId = $request->getAttribute('tenant_id');
|
||||
$students = Student::where('tenant_id', $tenantId)->with('template')->get();
|
||||
return $this->json($response, $students->toArray());
|
||||
}
|
||||
|
||||
public function store(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$tenantId = $request->getAttribute('tenant_id');
|
||||
$data = json_decode($request->getBody()->getContents(), true);
|
||||
|
||||
$student = Student::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'template_id' => $data['template_id'] ?? null,
|
||||
'first_name' => $data['first_name'] ?? '',
|
||||
'last_name' => $data['last_name'] ?? '',
|
||||
'email' => $data['email'] ?? '',
|
||||
'phone' => $data['phone'] ?? '',
|
||||
'needs_glasses' => $data['needs_glasses'] ?? 0,
|
||||
'status' => $data['status'] ?? 'active',
|
||||
'notes' => $data['notes'] ?? '',
|
||||
]);
|
||||
|
||||
return $this->json($response, $student->toArray(), 201);
|
||||
}
|
||||
|
||||
public function update(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
|
||||
{
|
||||
$tenantId = $request->getAttribute('tenant_id');
|
||||
$student = Student::where('id', $args['id'])->where('tenant_id', $tenantId)->first();
|
||||
|
||||
if (!$student) {
|
||||
return $this->json($response, ['message' => 'Student not found'], 404);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getBody()->getContents(), true);
|
||||
$student->update(array_intersect_key($data, array_flip([
|
||||
'first_name', 'last_name', 'email', 'phone', 'needs_glasses', 'status', 'notes', 'template_id'
|
||||
])));
|
||||
|
||||
return $this->json($response, $student->fresh()->toArray());
|
||||
}
|
||||
|
||||
public function destroy(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
|
||||
{
|
||||
$tenantId = $request->getAttribute('tenant_id');
|
||||
$student = Student::where('id', $args['id'])->where('tenant_id', $tenantId)->first();
|
||||
|
||||
if (!$student) {
|
||||
return $this->json($response, ['message' => 'Student not found'], 404);
|
||||
}
|
||||
|
||||
$student->delete();
|
||||
return $this->json($response, ['message' => 'Deleted']);
|
||||
}
|
||||
|
||||
private function json(ResponseInterface $response, array $data, int $status = 200): ResponseInterface
|
||||
{
|
||||
$response->getBody()->write(json_encode($data));
|
||||
return $response->withStatus($status)->withHeader('Content-Type', 'application/json');
|
||||
}
|
||||
}
|
||||
27
api/src/Controllers/TemplatesController.php
Normal file
27
api/src/Controllers/TemplatesController.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Models\LicenseClassTemplate;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
final class TemplatesController
|
||||
{
|
||||
public function index(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$tenantId = $request->getAttribute('tenant_id');
|
||||
$templates = LicenseClassTemplate::where('tenant_id', $tenantId)
|
||||
->with('requirements')
|
||||
->get();
|
||||
return $this->json($response, $templates->toArray());
|
||||
}
|
||||
|
||||
private function json(ResponseInterface $response, array $data, int $status = 200): ResponseInterface
|
||||
{
|
||||
$response->getBody()->write(json_encode($data));
|
||||
return $response->withStatus($status)->withHeader('Content-Type', 'application/json');
|
||||
}
|
||||
}
|
||||
62
api/src/Middleware/JwtMiddleware.php
Normal file
62
api/src/Middleware/JwtMiddleware.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Middleware;
|
||||
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use App\Models\User;
|
||||
|
||||
final class JwtMiddleware implements \Psr\Http\Server\MiddlewareInterface
|
||||
{
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
$authHeader = $request->getHeaderLine('Authorization') ?? '';
|
||||
|
||||
if (!str_starts_with($authHeader, 'Bearer ')) {
|
||||
$body = json_encode(['message' => 'Missing or invalid Authorization header']);
|
||||
return new \Slim\Psr7\Response(401)
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withBody((new \Slim\Psr7\Factory\StreamFactory())->createStream($body));
|
||||
}
|
||||
|
||||
$token = substr($authHeader, 7);
|
||||
|
||||
try {
|
||||
$secret = $_ENV['JWT_SECRET'] ?? 'this-is-a-32-character-secret-key-for-testing';
|
||||
$decoded = JWT::decode($token, new Key($secret, 'HS256'));
|
||||
} catch (\Throwable $e) {
|
||||
$body = json_encode(['message' => 'Invalid or expired token']);
|
||||
return new \Slim\Psr7\Response(401)
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withBody((new \Slim\Psr7\Factory\StreamFactory())->createStream($body));
|
||||
}
|
||||
|
||||
// Load user from DB
|
||||
$user = User::find($decoded->sub);
|
||||
|
||||
if (!$user) {
|
||||
$body = json_encode(['message' => 'User not found']);
|
||||
return new \Slim\Psr7\Response(401)
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withBody((new \Slim\Psr7\Factory\StreamFactory())->createStream($body));
|
||||
}
|
||||
|
||||
if (!$user->is_active) {
|
||||
$body = json_encode(['message' => 'Account disabled']);
|
||||
return new \Slim\Psr7\Response(401)
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withBody((new \Slim\Psr7\Factory\StreamFactory())->createStream($body));
|
||||
}
|
||||
|
||||
// Attach user and tenant to request
|
||||
$request = $request->withAttribute('user', $user->toArray());
|
||||
$request = $request->withAttribute('tenant_id', $user->tenant_id);
|
||||
|
||||
return $handler->handle($request);
|
||||
}
|
||||
}
|
||||
28
api/src/Models/Appointment.php
Normal file
28
api/src/Models/Appointment.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
final class Appointment extends Model
|
||||
{
|
||||
protected $table = 'appointments';
|
||||
protected $fillable = [
|
||||
'tenant_id', 'student_id', 'instructor_id', 'lesson_type_id',
|
||||
'title', 'category', 'start_at', 'end_at', 'units',
|
||||
'status', 'notes', 'warning_acknowledged', 'created_by', 'updated_by',
|
||||
];
|
||||
protected $casts = [
|
||||
'student_id' => 'integer', 'instructor_id' => 'integer',
|
||||
'lesson_type_id' => 'integer', 'units' => 'integer',
|
||||
'warning_acknowledged' => 'integer', 'created_by' => 'integer', 'updated_by' => 'integer',
|
||||
];
|
||||
|
||||
public function tenant(): BelongsTo { return $this->belongsTo(Tenant::class, 'tenant_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 lessonType(): BelongsTo { return $this->belongsTo(LessonType::class, 'lesson_type_id'); }
|
||||
}
|
||||
28
api/src/Models/Instructor.php
Normal file
28
api/src/Models/Instructor.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
final class Instructor extends Model
|
||||
{
|
||||
protected $table = 'instructors';
|
||||
protected $fillable = [
|
||||
'tenant_id', 'user_id', 'first_name', 'last_name',
|
||||
'color', 'notes', 'is_active', 'pre_start_buffer_minutes',
|
||||
];
|
||||
protected $casts = ['is_active' => 'integer', 'tenant_id' => 'integer'];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class, 'tenant_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
}
|
||||
27
api/src/Models/LessonType.php
Normal file
27
api/src/Models/LessonType.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
final class LessonType extends Model
|
||||
{
|
||||
protected $table = 'lesson_types';
|
||||
protected $fillable = [
|
||||
'tenant_id', 'name', 'color', 'default_duration', 'category',
|
||||
'requirement_key', 'is_billable', 'is_counted', 'is_rest_relevant',
|
||||
'fixed_duration', 'sort_order', 'system_key',
|
||||
];
|
||||
protected $casts = [
|
||||
'is_billable' => 'integer', 'is_counted' => 'integer',
|
||||
'is_rest_relevant' => 'integer', 'fixed_duration' => 'integer',
|
||||
];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class, 'tenant_id');
|
||||
}
|
||||
}
|
||||
14
api/src/Models/LessonTypeTemplateVisibility.php
Normal file
14
api/src/Models/LessonTypeTemplateVisibility.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class LessonTypeTemplateVisibility extends Model
|
||||
{
|
||||
protected $table = 'lesson_type_template_visibility';
|
||||
public $timestamps = false;
|
||||
protected $fillable = ['lesson_type_id', 'template_id'];
|
||||
}
|
||||
31
api/src/Models/LicenseClassTemplate.php
Normal file
31
api/src/Models/LicenseClassTemplate.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
final class LicenseClassTemplate extends Model
|
||||
{
|
||||
protected $table = 'license_class_templates';
|
||||
protected $fillable = ['tenant_id', 'code', 'name', 'is_combination'];
|
||||
protected $casts = ['is_combination' => 'integer'];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class, 'tenant_id');
|
||||
}
|
||||
|
||||
public function requirements(): HasMany
|
||||
{
|
||||
return $this->hasMany(LicenseTemplateRequirement::class, 'template_id');
|
||||
}
|
||||
|
||||
public function students(): HasMany
|
||||
{
|
||||
return $this->hasMany(Student::class, 'template_id');
|
||||
}
|
||||
}
|
||||
17
api/src/Models/LicenseTemplateRequirement.php
Normal file
17
api/src/Models/LicenseTemplateRequirement.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class LicenseTemplateRequirement extends Model
|
||||
{
|
||||
protected $table = 'license_template_requirements';
|
||||
protected $fillable = [
|
||||
'template_id', 'requirement_key', 'label',
|
||||
'required_units', 'sort_order', 'phase_label',
|
||||
];
|
||||
protected $casts = ['required_units' => 'integer', 'sort_order' => 'integer'];
|
||||
}
|
||||
28
api/src/Models/Student.php
Normal file
28
api/src/Models/Student.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
final class Student extends Model
|
||||
{
|
||||
protected $table = 'students';
|
||||
protected $fillable = [
|
||||
'tenant_id', 'template_id', 'first_name', 'last_name',
|
||||
'email', 'phone', 'needs_glasses', 'status', 'notes',
|
||||
];
|
||||
protected $casts = ['needs_glasses' => 'integer', 'tenant_id' => 'integer', 'template_id' => 'integer'];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class, 'tenant_id');
|
||||
}
|
||||
|
||||
public function template(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(LicenseClassTemplate::class, 'template_id');
|
||||
}
|
||||
}
|
||||
37
api/src/Models/Tenant.php
Normal file
37
api/src/Models/Tenant.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
final class Tenant extends Model
|
||||
{
|
||||
protected $table = 'tenants';
|
||||
protected $fillable = [
|
||||
'name', 'timezone', 'is_active', 'location_label',
|
||||
'latitude', 'longitude', 'federal_state',
|
||||
];
|
||||
protected $casts = [
|
||||
'is_active' => 'integer',
|
||||
'latitude' => 'float',
|
||||
'longitude' => 'float',
|
||||
];
|
||||
|
||||
public function users(): HasMany
|
||||
{
|
||||
return $this->hasMany(User::class, 'tenant_id');
|
||||
}
|
||||
|
||||
public function students(): HasMany
|
||||
{
|
||||
return $this->hasMany(Student::class, 'tenant_id');
|
||||
}
|
||||
|
||||
public function instructors(): HasMany
|
||||
{
|
||||
return $this->hasMany(Instructor::class, 'tenant_id');
|
||||
}
|
||||
}
|
||||
29
api/src/Models/User.php
Normal file
29
api/src/Models/User.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
final class User extends Model
|
||||
{
|
||||
protected $table = 'users';
|
||||
protected $fillable = [
|
||||
'tenant_id', 'role', 'email', 'password_hash',
|
||||
'first_name', 'last_name', 'is_active',
|
||||
];
|
||||
protected $casts = ['is_active' => 'integer'];
|
||||
protected $hidden = ['password_hash'];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class, 'tenant_id');
|
||||
}
|
||||
|
||||
public function instructor()
|
||||
{
|
||||
return $this->hasOne(Instructor::class, 'user_id');
|
||||
}
|
||||
}
|
||||
62
api/src/Routes/api.php
Normal file
62
api/src/Routes/api.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Controllers\AuthController;
|
||||
use App\Controllers\BootstrapController;
|
||||
use App\Controllers\StudentsController;
|
||||
use App\Controllers\InstructorsController;
|
||||
use App\Controllers\AppointmentsController;
|
||||
use App\Controllers\LessonTypesController;
|
||||
use App\Controllers\TemplatesController;
|
||||
use App\Middleware\JwtMiddleware;
|
||||
|
||||
return function (\Slim\App $app): void {
|
||||
// Auth routes (public)
|
||||
$app->post('/api/v1/auth/login', [AuthController::class, 'login']);
|
||||
$app->post('/api/v1/auth/logout', [AuthController::class, 'logout']);
|
||||
|
||||
// Protected routes
|
||||
$app->get('/api/v1/auth/me', [AuthController::class, 'me'])
|
||||
->add(JwtMiddleware::class);
|
||||
|
||||
$app->get('/api/v1/bootstrap', [BootstrapController::class, 'handle'])
|
||||
->add(JwtMiddleware::class);
|
||||
|
||||
// CRUD routes
|
||||
$app->get('/api/v1/students', [StudentsController::class, 'index'])
|
||||
->add(JwtMiddleware::class);
|
||||
$app->post('/api/v1/students', [StudentsController::class, 'store'])
|
||||
->add(JwtMiddleware::class);
|
||||
$app->patch('/api/v1/students/{id}', [StudentsController::class, 'update'])
|
||||
->add(JwtMiddleware::class);
|
||||
$app->delete('/api/v1/students/{id}', [StudentsController::class, 'destroy'])
|
||||
->add(JwtMiddleware::class);
|
||||
|
||||
$app->get('/api/v1/instructors', [InstructorsController::class, 'index'])
|
||||
->add(JwtMiddleware::class);
|
||||
$app->post('/api/v1/instructors', [InstructorsController::class, 'store'])
|
||||
->add(JwtMiddleware::class);
|
||||
$app->patch('/api/v1/instructors/{id}', [InstructorsController::class, 'update'])
|
||||
->add(JwtMiddleware::class);
|
||||
|
||||
$app->get('/api/v1/appointments', [AppointmentsController::class, 'index'])
|
||||
->add(JwtMiddleware::class);
|
||||
$app->post('/api/v1/appointments', [AppointmentsController::class, 'store'])
|
||||
->add(JwtMiddleware::class);
|
||||
$app->patch('/api/v1/appointments/{id}', [AppointmentsController::class, 'update'])
|
||||
->add(JwtMiddleware::class);
|
||||
$app->delete('/api/v1/appointments/{id}', [AppointmentsController::class, 'destroy'])
|
||||
->add(JwtMiddleware::class);
|
||||
|
||||
// Reference data
|
||||
$app->get('/api/v1/lesson-types', [LessonTypesController::class, 'index'])
|
||||
->add(JwtMiddleware::class);
|
||||
$app->get('/api/v1/license-class-templates', [TemplatesController::class, 'index'])
|
||||
->add(JwtMiddleware::class);
|
||||
|
||||
// Fallback 404
|
||||
$app->any('/api/v1/{path}', function ($request, $response) {
|
||||
return $this->get('response')->withStatus(404)->withJson(['message' => 'Not Found']);
|
||||
});
|
||||
};
|
||||
9
api/test_db.php
Normal file
9
api/test_db.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?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(); }
|
||||
12
api/test_login.php
Normal file
12
api/test_login.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?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";
|
||||
}
|
||||
1
backup/fahrschultermin_20260518_223313/docs/README.txt
Normal file
1
backup/fahrschultermin_20260518_223313/docs/README.txt
Normal file
@@ -0,0 +1 @@
|
||||
No docs directory on server - placeholder created
|
||||
@@ -0,0 +1,2 @@
|
||||
<?php
|
||||
echo 'api.fahrschultermin.de - ' . date('Y-m-d H:i:s');
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
final class AppointmentRepository extends BaseRepository
|
||||
{
|
||||
public function listForTenant(int $tenantId, string $from, string $to, ?int $instructorId = null): array
|
||||
{
|
||||
$sql = 'SELECT a.*, s.first_name AS student_first_name, s.last_name AS student_last_name, s.needs_glasses AS student_needs_glasses,
|
||||
i.first_name AS instructor_first_name, i.last_name AS instructor_last_name,
|
||||
lt.name AS lesson_type_name, lt.color AS lesson_type_color, lt.is_rest_relevant,
|
||||
(
|
||||
SELECT au.requirement_key
|
||||
FROM appointment_units au
|
||||
WHERE au.appointment_id = a.id
|
||||
ORDER BY au.id
|
||||
LIMIT 1
|
||||
) AS counted_requirement_key
|
||||
FROM appointments a
|
||||
JOIN instructors i ON i.id = a.instructor_id
|
||||
LEFT JOIN students s ON s.id = a.student_id
|
||||
JOIN lesson_types lt ON lt.id = a.lesson_type_id
|
||||
WHERE a.tenant_id = :tenant_id
|
||||
AND a.start_at < :to
|
||||
AND a.end_at > :from';
|
||||
$params = [
|
||||
'tenant_id' => $tenantId,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
];
|
||||
|
||||
if ($instructorId !== null) {
|
||||
$sql .= ' AND a.instructor_id = :instructor_id';
|
||||
$params['instructor_id'] = $instructorId;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY a.start_at';
|
||||
$statement = $this->db->prepare($sql);
|
||||
$statement->execute($params);
|
||||
|
||||
return $statement->fetchAll();
|
||||
}
|
||||
|
||||
public function find(int $tenantId, int $id): ?array
|
||||
{
|
||||
$statement = $this->db->prepare('SELECT * FROM appointments WHERE tenant_id = :tenant_id AND id = :id');
|
||||
$statement->execute(['tenant_id' => $tenantId, 'id' => $id]);
|
||||
$appointment = $statement->fetch() ?: null;
|
||||
|
||||
if ($appointment !== null) {
|
||||
$appointment['units_breakdown'] = $this->units((int) $appointment['id']);
|
||||
}
|
||||
|
||||
return $appointment;
|
||||
}
|
||||
|
||||
public function create(int $tenantId, array $data, int $actorId): array
|
||||
{
|
||||
$timestamp = $this->now();
|
||||
$statement = $this->db->prepare(
|
||||
'INSERT INTO appointments
|
||||
(tenant_id, student_id, instructor_id, lesson_type_id, title, category, start_at, end_at, units, status, notes, warning_acknowledged, created_by, updated_by, created_at, updated_at)
|
||||
VALUES
|
||||
(:tenant_id, :student_id, :instructor_id, :lesson_type_id, :title, :category, :start_at, :end_at, :units, :status, :notes, :warning_acknowledged, :created_by, :updated_by, :created_at, :updated_at)'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'student_id' => $data['student_id'] ?: null,
|
||||
'instructor_id' => $data['instructor_id'],
|
||||
'lesson_type_id' => $data['lesson_type_id'],
|
||||
'title' => $data['title'],
|
||||
'category' => $data['category'],
|
||||
'start_at' => $data['start_at'],
|
||||
'end_at' => $data['end_at'],
|
||||
'units' => (int) $data['units'],
|
||||
'status' => $data['status'],
|
||||
'notes' => $data['notes'] ?? '',
|
||||
'warning_acknowledged' => !empty($data['warning_acknowledged']) ? 1 : 0,
|
||||
'created_by' => $actorId,
|
||||
'updated_by' => $actorId,
|
||||
'created_at' => $timestamp,
|
||||
'updated_at' => $timestamp,
|
||||
]);
|
||||
|
||||
$appointmentId = (int) $this->db->lastInsertId();
|
||||
$this->replaceUnits($appointmentId, $data['units_breakdown'] ?? []);
|
||||
|
||||
return $this->find($tenantId, $appointmentId);
|
||||
}
|
||||
|
||||
public function update(int $tenantId, int $id, array $data, int $actorId): ?array
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'UPDATE appointments
|
||||
SET student_id = :student_id, instructor_id = :instructor_id, lesson_type_id = :lesson_type_id,
|
||||
title = :title, category = :category, start_at = :start_at, end_at = :end_at, units = :units,
|
||||
status = :status, notes = :notes, warning_acknowledged = :warning_acknowledged,
|
||||
updated_by = :updated_by, updated_at = :updated_at
|
||||
WHERE tenant_id = :tenant_id AND id = :id'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'id' => $id,
|
||||
'student_id' => $data['student_id'] ?: null,
|
||||
'instructor_id' => $data['instructor_id'],
|
||||
'lesson_type_id' => $data['lesson_type_id'],
|
||||
'title' => $data['title'],
|
||||
'category' => $data['category'],
|
||||
'start_at' => $data['start_at'],
|
||||
'end_at' => $data['end_at'],
|
||||
'units' => (int) $data['units'],
|
||||
'status' => $data['status'],
|
||||
'notes' => $data['notes'] ?? '',
|
||||
'warning_acknowledged' => !empty($data['warning_acknowledged']) ? 1 : 0,
|
||||
'updated_by' => $actorId,
|
||||
'updated_at' => $this->now(),
|
||||
]);
|
||||
|
||||
$this->replaceUnits($id, $data['units_breakdown'] ?? []);
|
||||
|
||||
return $this->find($tenantId, $id);
|
||||
}
|
||||
|
||||
public function delete(int $tenantId, int $id): void
|
||||
{
|
||||
$statement = $this->db->prepare('DELETE FROM appointments WHERE tenant_id = :tenant_id AND id = :id');
|
||||
$statement->execute(['tenant_id' => $tenantId, 'id' => $id]);
|
||||
}
|
||||
|
||||
public function units(int $appointmentId): array
|
||||
{
|
||||
$statement = $this->db->prepare('SELECT * FROM appointment_units WHERE appointment_id = :appointment_id');
|
||||
$statement->execute(['appointment_id' => $appointmentId]);
|
||||
|
||||
return $statement->fetchAll();
|
||||
}
|
||||
|
||||
public function overlappingForInstructor(int $tenantId, int $instructorId, string $from, string $to, ?int $ignoreId = null): array
|
||||
{
|
||||
$sql = 'SELECT * FROM appointments
|
||||
WHERE tenant_id = :tenant_id AND instructor_id = :instructor_id
|
||||
AND start_at < :to AND end_at > :from';
|
||||
$params = [
|
||||
'tenant_id' => $tenantId,
|
||||
'instructor_id' => $instructorId,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
];
|
||||
|
||||
if ($ignoreId !== null) {
|
||||
$sql .= ' AND id != :ignore_id';
|
||||
$params['ignore_id'] = $ignoreId;
|
||||
}
|
||||
|
||||
$statement = $this->db->prepare($sql);
|
||||
$statement->execute($params);
|
||||
|
||||
return $statement->fetchAll();
|
||||
}
|
||||
|
||||
public function overlappingForStudent(int $tenantId, int $studentId, string $from, string $to, ?int $ignoreId = null): array
|
||||
{
|
||||
$sql = 'SELECT * FROM appointments
|
||||
WHERE tenant_id = :tenant_id AND student_id = :student_id
|
||||
AND start_at < :to AND end_at > :from';
|
||||
$params = [
|
||||
'tenant_id' => $tenantId,
|
||||
'student_id' => $studentId,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
];
|
||||
|
||||
if ($ignoreId !== null) {
|
||||
$sql .= ' AND id != :ignore_id';
|
||||
$params['ignore_id'] = $ignoreId;
|
||||
}
|
||||
|
||||
$statement = $this->db->prepare($sql);
|
||||
$statement->execute($params);
|
||||
|
||||
return $statement->fetchAll();
|
||||
}
|
||||
|
||||
public function lastRestRelevantForInstructor(int $tenantId, int $instructorId, string $beforeStart, ?int $ignoreId = null): ?array
|
||||
{
|
||||
$sql = 'SELECT a.*, lt.name AS lesson_type_name
|
||||
FROM appointments a
|
||||
JOIN lesson_types lt ON lt.id = a.lesson_type_id
|
||||
WHERE a.tenant_id = :tenant_id
|
||||
AND a.instructor_id = :instructor_id
|
||||
AND lt.is_rest_relevant = 1
|
||||
AND a.end_at <= :before_start';
|
||||
$params = [
|
||||
'tenant_id' => $tenantId,
|
||||
'instructor_id' => $instructorId,
|
||||
'before_start' => $beforeStart,
|
||||
];
|
||||
|
||||
if ($ignoreId !== null) {
|
||||
$sql .= ' AND a.id != :ignore_id';
|
||||
$params['ignore_id'] = $ignoreId;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY a.end_at DESC LIMIT 1';
|
||||
$statement = $this->db->prepare($sql);
|
||||
$statement->execute($params);
|
||||
|
||||
return $statement->fetch() ?: null;
|
||||
}
|
||||
|
||||
public function hasRestRelevantAfterStartOnDay(int $tenantId, int $instructorId, string $dayStart, string $dayEnd, string $startAt): bool
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'SELECT 1
|
||||
FROM appointments a
|
||||
JOIN lesson_types lt ON lt.id = a.lesson_type_id
|
||||
WHERE a.tenant_id = :tenant_id
|
||||
AND a.instructor_id = :instructor_id
|
||||
AND lt.is_rest_relevant = 1
|
||||
AND a.start_at < :day_end
|
||||
AND a.end_at > :day_start
|
||||
AND a.end_at > :start_at
|
||||
LIMIT 1'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'instructor_id' => $instructorId,
|
||||
'day_start' => $dayStart,
|
||||
'day_end' => $dayEnd,
|
||||
'start_at' => $startAt,
|
||||
]);
|
||||
|
||||
return (bool) $statement->fetchColumn();
|
||||
}
|
||||
|
||||
public function nextForTenant(int $tenantId, string $from, ?int $instructorId = null): ?array
|
||||
{
|
||||
$sql = 'SELECT a.*, s.first_name AS student_first_name, s.last_name AS student_last_name,
|
||||
i.first_name AS instructor_first_name, i.last_name AS instructor_last_name,
|
||||
lt.name AS lesson_type_name
|
||||
FROM appointments a
|
||||
JOIN instructors i ON i.id = a.instructor_id
|
||||
LEFT JOIN students s ON s.id = a.student_id
|
||||
JOIN lesson_types lt ON lt.id = a.lesson_type_id
|
||||
WHERE a.tenant_id = :tenant_id
|
||||
AND a.start_at >= :from';
|
||||
$params = [
|
||||
'tenant_id' => $tenantId,
|
||||
'from' => $from,
|
||||
];
|
||||
|
||||
if ($instructorId !== null) {
|
||||
$sql .= ' AND a.instructor_id = :instructor_id';
|
||||
$params['instructor_id'] = $instructorId;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY a.start_at ASC LIMIT 1';
|
||||
$statement = $this->db->prepare($sql);
|
||||
$statement->execute($params);
|
||||
|
||||
return $statement->fetch() ?: null;
|
||||
}
|
||||
|
||||
private function replaceUnits(int $appointmentId, array $rows): void
|
||||
{
|
||||
$delete = $this->db->prepare('DELETE FROM appointment_units WHERE appointment_id = :appointment_id');
|
||||
$delete->execute(['appointment_id' => $appointmentId]);
|
||||
|
||||
$insert = $this->db->prepare(
|
||||
'INSERT INTO appointment_units (appointment_id, requirement_key, label, units_counted, created_at)
|
||||
VALUES (:appointment_id, :requirement_key, :label, :units_counted, :created_at)'
|
||||
);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
if ((int) ($row['units_counted'] ?? 0) <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$insert->execute([
|
||||
'appointment_id' => $appointmentId,
|
||||
'requirement_key' => $row['requirement_key'] ?? null,
|
||||
'label' => $row['label'] ?? null,
|
||||
'units_counted' => (int) $row['units_counted'],
|
||||
'created_at' => $this->now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Support\Database;
|
||||
use PDO;
|
||||
|
||||
abstract class BaseRepository
|
||||
{
|
||||
protected PDO $db;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->db = Database::connection();
|
||||
}
|
||||
|
||||
protected function now(): string
|
||||
{
|
||||
return gmdate('c');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
final class InstructorRepository extends BaseRepository
|
||||
{
|
||||
public function allForTenant(int $tenantId): array
|
||||
{
|
||||
$statement = $this->db->prepare('SELECT * FROM instructors WHERE tenant_id = :tenant_id ORDER BY last_name, first_name');
|
||||
$statement->execute(['tenant_id' => $tenantId]);
|
||||
|
||||
return $statement->fetchAll();
|
||||
}
|
||||
|
||||
public function findByUserId(int $tenantId, int $userId): ?array
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'SELECT * FROM instructors WHERE tenant_id = :tenant_id AND user_id = :user_id LIMIT 1'
|
||||
);
|
||||
$statement->execute(['tenant_id' => $tenantId, 'user_id' => $userId]);
|
||||
|
||||
return $statement->fetch() ?: null;
|
||||
}
|
||||
|
||||
public function create(int $tenantId, array $data): array
|
||||
{
|
||||
$timestamp = $this->now();
|
||||
$statement = $this->db->prepare(
|
||||
'INSERT INTO instructors
|
||||
(tenant_id, user_id, first_name, last_name, color, notes, is_active, pre_start_buffer_minutes, created_at, updated_at)
|
||||
VALUES (:tenant_id, :user_id, :first_name, :last_name, :color, :notes, :is_active, :pre_start_buffer_minutes, :created_at, :updated_at)'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $data['user_id'] ?? null,
|
||||
'first_name' => $data['first_name'],
|
||||
'last_name' => $data['last_name'],
|
||||
'color' => $data['color'],
|
||||
'notes' => $data['notes'] ?? '',
|
||||
'is_active' => !empty($data['is_active']) ? 1 : 0,
|
||||
'pre_start_buffer_minutes' => max(0, (int) ($data['pre_start_buffer_minutes'] ?? 0)),
|
||||
'created_at' => $timestamp,
|
||||
'updated_at' => $timestamp,
|
||||
]);
|
||||
|
||||
return $this->find($tenantId, (int) $this->db->lastInsertId());
|
||||
}
|
||||
|
||||
public function update(int $tenantId, int $id, array $data): ?array
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'UPDATE instructors
|
||||
SET user_id = :user_id, first_name = :first_name, last_name = :last_name, color = :color,
|
||||
notes = :notes, is_active = :is_active, pre_start_buffer_minutes = :pre_start_buffer_minutes, updated_at = :updated_at
|
||||
WHERE tenant_id = :tenant_id AND id = :id'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'id' => $id,
|
||||
'user_id' => $data['user_id'] ?? null,
|
||||
'first_name' => $data['first_name'],
|
||||
'last_name' => $data['last_name'],
|
||||
'color' => $data['color'],
|
||||
'notes' => $data['notes'] ?? '',
|
||||
'is_active' => !empty($data['is_active']) ? 1 : 0,
|
||||
'pre_start_buffer_minutes' => max(0, (int) ($data['pre_start_buffer_minutes'] ?? 0)),
|
||||
'updated_at' => $this->now(),
|
||||
]);
|
||||
|
||||
return $this->find($tenantId, $id);
|
||||
}
|
||||
|
||||
public function find(int $tenantId, int $id): ?array
|
||||
{
|
||||
$statement = $this->db->prepare('SELECT * FROM instructors WHERE tenant_id = :tenant_id AND id = :id');
|
||||
$statement->execute(['tenant_id' => $tenantId, 'id' => $id]);
|
||||
|
||||
return $statement->fetch() ?: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
final class ReferenceDataRepository extends BaseRepository
|
||||
{
|
||||
private const DEFAULT_PLANNING_TYPES = [
|
||||
['system_key' => 'private_block', 'name' => 'Privat', 'color' => '#6B7280', 'default_duration' => 60, 'category' => 'private', 'sort_order' => 900],
|
||||
['system_key' => 'work_vehicle_care', 'name' => 'Fahrzeugpflege', 'color' => '#0E5D80', 'default_duration' => 60, 'category' => 'work', 'sort_order' => 910],
|
||||
['system_key' => 'work_office', 'name' => 'Buero', 'color' => '#8B5A2B', 'default_duration' => 60, 'category' => 'work', 'sort_order' => 920],
|
||||
['system_key' => 'work_transfer_drive', 'name' => 'Wechselfahrt', 'color' => '#3B7A57', 'default_duration' => 45, 'category' => 'work', 'sort_order' => 930],
|
||||
['system_key' => 'work_other', 'name' => 'Sonstige Taetigkeit', 'color' => '#7C3AED', 'default_duration' => 60, 'category' => 'work', 'sort_order' => 940],
|
||||
['system_key' => 'theory_a', 'name' => 'Theorie A', 'color' => '#C2410C', 'default_duration' => 90, 'category' => 'theory', 'sort_order' => 950],
|
||||
['system_key' => 'theory_b', 'name' => 'Theorie B', 'color' => '#A74826', 'default_duration' => 90, 'category' => 'theory', 'sort_order' => 960],
|
||||
['system_key' => 'theory_c', 'name' => 'Theorie C', 'color' => '#B45309', 'default_duration' => 90, 'category' => 'theory', 'sort_order' => 970],
|
||||
['system_key' => 'theory_d', 'name' => 'Theorie D', 'color' => '#BE123C', 'default_duration' => 90, 'category' => 'theory', 'sort_order' => 980],
|
||||
['system_key' => 'theory_t', 'name' => 'Theorie T', 'color' => '#047857', 'default_duration' => 90, 'category' => 'theory', 'sort_order' => 990],
|
||||
['system_key' => 'theory_bkf', 'name' => 'Theorie BKF', 'color' => '#1D4ED8', 'default_duration' => 90, 'category' => 'theory', 'sort_order' => 1000],
|
||||
];
|
||||
|
||||
public function requirementKeyForLessonType(array $lessonType): string
|
||||
{
|
||||
$key = trim((string) ($lessonType['requirement_key'] ?? ''));
|
||||
return $key !== '' ? $key : 'lesson_type_' . (int) $lessonType['id'];
|
||||
}
|
||||
|
||||
public function lessonTypes(int $tenantId): array
|
||||
{
|
||||
$this->ensureDefaultPlanningTypes($tenantId);
|
||||
$statement = $this->db->prepare('SELECT * FROM lesson_types WHERE tenant_id = :tenant_id ORDER BY sort_order, name');
|
||||
$statement->execute(['tenant_id' => $tenantId]);
|
||||
$lessonTypes = $statement->fetchAll();
|
||||
$visibilityMap = $this->lessonTypeVisibilityMap($tenantId);
|
||||
|
||||
foreach ($lessonTypes as &$lessonType) {
|
||||
$lessonType['visible_template_ids'] = $visibilityMap[(int) $lessonType['id']] ?? [];
|
||||
$lessonType['effective_requirement_key'] = $this->requirementKeyForLessonType($lessonType);
|
||||
}
|
||||
|
||||
return $lessonTypes;
|
||||
}
|
||||
|
||||
public function createLessonType(int $tenantId, array $data): array
|
||||
{
|
||||
$timestamp = $this->now();
|
||||
$statement = $this->db->prepare(
|
||||
'INSERT INTO lesson_types
|
||||
(tenant_id, name, color, default_duration, category, requirement_key, is_billable, is_counted, is_rest_relevant, fixed_duration, sort_order, created_at, updated_at)
|
||||
VALUES
|
||||
(:tenant_id, :name, :color, :default_duration, :category, :requirement_key, :is_billable, :is_counted, :is_rest_relevant, :fixed_duration, :sort_order, :created_at, :updated_at)'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'name' => $data['name'],
|
||||
'color' => $data['color'],
|
||||
'default_duration' => (int) $data['default_duration'],
|
||||
'category' => $data['category'],
|
||||
'requirement_key' => $data['requirement_key'] ?: null,
|
||||
'is_billable' => !empty($data['is_billable']) ? 1 : 0,
|
||||
'is_counted' => !empty($data['is_counted']) ? 1 : 0,
|
||||
'is_rest_relevant' => !empty($data['is_rest_relevant']) ? 1 : 0,
|
||||
'fixed_duration' => !empty($data['fixed_duration']) ? 1 : 0,
|
||||
'sort_order' => (int) ($data['sort_order'] ?? 0),
|
||||
'created_at' => $timestamp,
|
||||
'updated_at' => $timestamp,
|
||||
]);
|
||||
$lessonTypeId = (int) $this->db->lastInsertId();
|
||||
$this->replaceLessonTypeVisibility($tenantId, $lessonTypeId, $data['visible_template_ids'] ?? null);
|
||||
|
||||
return $this->findLessonType($tenantId, $lessonTypeId);
|
||||
}
|
||||
|
||||
public function updateLessonType(int $tenantId, int $id, array $data): ?array
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'UPDATE lesson_types
|
||||
SET name = :name, color = :color, default_duration = :default_duration, category = :category,
|
||||
requirement_key = :requirement_key, is_billable = :is_billable, is_counted = :is_counted,
|
||||
is_rest_relevant = :is_rest_relevant, fixed_duration = :fixed_duration, sort_order = :sort_order,
|
||||
updated_at = :updated_at
|
||||
WHERE id = :id AND tenant_id = :tenant_id'
|
||||
);
|
||||
$statement->execute([
|
||||
'id' => $id,
|
||||
'tenant_id' => $tenantId,
|
||||
'name' => $data['name'],
|
||||
'color' => $data['color'],
|
||||
'default_duration' => (int) $data['default_duration'],
|
||||
'category' => $data['category'],
|
||||
'requirement_key' => $data['requirement_key'] ?: null,
|
||||
'is_billable' => !empty($data['is_billable']) ? 1 : 0,
|
||||
'is_counted' => !empty($data['is_counted']) ? 1 : 0,
|
||||
'is_rest_relevant' => !empty($data['is_rest_relevant']) ? 1 : 0,
|
||||
'fixed_duration' => !empty($data['fixed_duration']) ? 1 : 0,
|
||||
'sort_order' => (int) ($data['sort_order'] ?? 0),
|
||||
'updated_at' => $this->now(),
|
||||
]);
|
||||
|
||||
$this->replaceLessonTypeVisibility($tenantId, $id, $data['visible_template_ids'] ?? null);
|
||||
|
||||
return $this->findLessonType($tenantId, $id);
|
||||
}
|
||||
|
||||
public function deleteLessonType(int $tenantId, int $id): bool
|
||||
{
|
||||
$lessonType = $this->findLessonTypeRecord($tenantId, $id);
|
||||
if ($lessonType === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (($lessonType['system_key'] ?? null) === 'private_block') {
|
||||
throw new \RuntimeException('Der feste Privat-Baustein kann nicht geloescht werden.');
|
||||
}
|
||||
|
||||
$usage = $this->db->prepare('SELECT COUNT(*) AS count FROM appointments WHERE tenant_id = :tenant_id AND lesson_type_id = :lesson_type_id');
|
||||
$usage->execute(['tenant_id' => $tenantId, 'lesson_type_id' => $id]);
|
||||
if ((int) ($usage->fetch()['count'] ?? 0) > 0) {
|
||||
throw new \RuntimeException('Diese Stundenart wird bereits in Terminen verwendet und kann nicht geloescht werden.');
|
||||
}
|
||||
|
||||
$this->db->beginTransaction();
|
||||
try {
|
||||
$visibility = $this->db->prepare('DELETE FROM lesson_type_template_visibility WHERE lesson_type_id = :lesson_type_id');
|
||||
$visibility->execute(['lesson_type_id' => $id]);
|
||||
|
||||
$delete = $this->db->prepare('DELETE FROM lesson_types WHERE tenant_id = :tenant_id AND id = :id');
|
||||
$delete->execute(['tenant_id' => $tenantId, 'id' => $id]);
|
||||
|
||||
$this->db->commit();
|
||||
return true;
|
||||
} catch (\Throwable $exception) {
|
||||
$this->db->rollBack();
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
public function reorderLessonTypes(int $tenantId, array $lessonTypeIds): array
|
||||
{
|
||||
$allowedIds = array_map(
|
||||
static fn (array $lessonType): int => (int) $lessonType['id'],
|
||||
$this->lessonTypes($tenantId)
|
||||
);
|
||||
|
||||
$statement = $this->db->prepare(
|
||||
'UPDATE lesson_types
|
||||
SET sort_order = :sort_order, updated_at = :updated_at
|
||||
WHERE tenant_id = :tenant_id AND id = :id'
|
||||
);
|
||||
|
||||
$sortOrder = 1;
|
||||
foreach ($lessonTypeIds as $lessonTypeId) {
|
||||
$lessonTypeId = (int) $lessonTypeId;
|
||||
if (!in_array($lessonTypeId, $allowedIds, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$statement->execute([
|
||||
'sort_order' => $sortOrder,
|
||||
'updated_at' => $this->now(),
|
||||
'tenant_id' => $tenantId,
|
||||
'id' => $lessonTypeId,
|
||||
]);
|
||||
$sortOrder++;
|
||||
}
|
||||
|
||||
return $this->lessonTypes($tenantId);
|
||||
}
|
||||
|
||||
public function findLessonType(int $tenantId, int $id): ?array
|
||||
{
|
||||
$statement = $this->db->prepare('SELECT * FROM lesson_types WHERE tenant_id = :tenant_id AND id = :id');
|
||||
$statement->execute(['tenant_id' => $tenantId, 'id' => $id]);
|
||||
$lessonType = $statement->fetch() ?: null;
|
||||
if ($lessonType === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$lessonType['visible_template_ids'] = $this->visibleTemplateIdsForLessonType($tenantId, $id);
|
||||
$lessonType['effective_requirement_key'] = $this->requirementKeyForLessonType($lessonType);
|
||||
|
||||
return $lessonType;
|
||||
}
|
||||
|
||||
public function templates(int $tenantId): array
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'SELECT * FROM license_class_templates WHERE tenant_id = :tenant_id ORDER BY name'
|
||||
);
|
||||
$statement->execute(['tenant_id' => $tenantId]);
|
||||
$templates = $statement->fetchAll();
|
||||
|
||||
foreach ($templates as &$template) {
|
||||
$template['requirements'] = $this->templateRequirements((int) $template['id']);
|
||||
$template['lesson_type_ids'] = $this->lessonTypeIdsForTemplate((int) $template['id']);
|
||||
}
|
||||
|
||||
return $templates;
|
||||
}
|
||||
|
||||
public function templateRequirements(int $templateId): array
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'SELECT * FROM license_template_requirements WHERE template_id = :template_id ORDER BY sort_order, phase_label, label'
|
||||
);
|
||||
$statement->execute(['template_id' => $templateId]);
|
||||
|
||||
return $statement->fetchAll();
|
||||
}
|
||||
|
||||
public function createTemplate(int $tenantId, array $data): array
|
||||
{
|
||||
$timestamp = $this->now();
|
||||
$statement = $this->db->prepare(
|
||||
'INSERT INTO license_class_templates
|
||||
(tenant_id, code, name, is_combination, created_at, updated_at)
|
||||
VALUES (:tenant_id, :code, :name, :is_combination, :created_at, :updated_at)'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'code' => $data['code'],
|
||||
'name' => $data['name'],
|
||||
'is_combination' => !empty($data['is_combination']) ? 1 : 0,
|
||||
'created_at' => $timestamp,
|
||||
'updated_at' => $timestamp,
|
||||
]);
|
||||
$templateId = (int) $this->db->lastInsertId();
|
||||
|
||||
$this->replaceTemplateRequirements($templateId, $data['requirements'] ?? []);
|
||||
$this->attachTemplateToExistingStudentLessonTypes($tenantId, $templateId);
|
||||
|
||||
return $this->findTemplate($tenantId, $templateId);
|
||||
}
|
||||
|
||||
public function updateTemplate(int $tenantId, int $id, array $data): ?array
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'UPDATE license_class_templates
|
||||
SET code = :code, name = :name, is_combination = :is_combination, updated_at = :updated_at
|
||||
WHERE id = :id AND tenant_id = :tenant_id'
|
||||
);
|
||||
$statement->execute([
|
||||
'id' => $id,
|
||||
'tenant_id' => $tenantId,
|
||||
'code' => $data['code'],
|
||||
'name' => $data['name'],
|
||||
'is_combination' => !empty($data['is_combination']) ? 1 : 0,
|
||||
'updated_at' => $this->now(),
|
||||
]);
|
||||
|
||||
$this->replaceTemplateRequirements($id, $data['requirements'] ?? []);
|
||||
|
||||
return $this->findTemplate($tenantId, $id);
|
||||
}
|
||||
|
||||
public function updateLessonTypeMatrix(int $tenantId, array $rows): array
|
||||
{
|
||||
$templates = $this->templates($tenantId);
|
||||
$templateIds = array_map(static fn (array $template): int => (int) $template['id'], $templates);
|
||||
$allowedLessonTypeIds = array_map(
|
||||
static fn (array $lessonType): int => (int) $lessonType['id'],
|
||||
array_filter($this->lessonTypes($tenantId), static fn (array $lessonType): bool => $lessonType['category'] === 'student')
|
||||
);
|
||||
|
||||
$delete = $this->db->prepare(
|
||||
'DELETE FROM lesson_type_template_visibility
|
||||
WHERE template_id = :template_id
|
||||
AND lesson_type_id IN (
|
||||
SELECT id FROM lesson_types WHERE tenant_id = :tenant_id AND category = \'student\'
|
||||
)'
|
||||
);
|
||||
$insert = $this->db->prepare(
|
||||
'INSERT OR IGNORE INTO lesson_type_template_visibility (lesson_type_id, template_id)
|
||||
VALUES (:lesson_type_id, :template_id)'
|
||||
);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$templateId = (int) ($row['template_id'] ?? 0);
|
||||
if (!in_array($templateId, $templateIds, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$delete->execute([
|
||||
'template_id' => $templateId,
|
||||
'tenant_id' => $tenantId,
|
||||
]);
|
||||
|
||||
foreach ((array) ($row['lesson_type_ids'] ?? []) as $lessonTypeId) {
|
||||
$lessonTypeId = (int) $lessonTypeId;
|
||||
if (!in_array($lessonTypeId, $allowedLessonTypeIds, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$insert->execute([
|
||||
'lesson_type_id' => $lessonTypeId,
|
||||
'template_id' => $templateId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->templates($tenantId);
|
||||
}
|
||||
|
||||
public function isLessonTypeVisibleForTemplate(int $tenantId, int $lessonTypeId, int $templateId): bool
|
||||
{
|
||||
$lessonType = $this->findLessonType($tenantId, $lessonTypeId);
|
||||
if ($lessonType === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($lessonType['category'] !== 'student') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($templateId, array_map('intval', $lessonType['visible_template_ids'] ?? []), true);
|
||||
}
|
||||
|
||||
private function replaceTemplateRequirements(int $templateId, array $requirements): void
|
||||
{
|
||||
$delete = $this->db->prepare('DELETE FROM license_template_requirements WHERE template_id = :template_id');
|
||||
$delete->execute(['template_id' => $templateId]);
|
||||
|
||||
$insert = $this->db->prepare(
|
||||
'INSERT INTO license_template_requirements
|
||||
(template_id, requirement_key, label, required_units, sort_order, phase_label)
|
||||
VALUES (:template_id, :requirement_key, :label, :required_units, :sort_order, :phase_label)'
|
||||
);
|
||||
|
||||
foreach ($requirements as $index => $requirement) {
|
||||
if (($requirement['label'] ?? '') === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$insert->execute([
|
||||
'template_id' => $templateId,
|
||||
'requirement_key' => $requirement['requirement_key'],
|
||||
'label' => $requirement['label'],
|
||||
'required_units' => (int) $requirement['required_units'],
|
||||
'sort_order' => (int) ($requirement['sort_order'] ?? $index),
|
||||
'phase_label' => trim((string) ($requirement['phase_label'] ?? '')),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function findTemplate(int $tenantId, int $id): ?array
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'SELECT * FROM license_class_templates WHERE tenant_id = :tenant_id AND id = :id'
|
||||
);
|
||||
$statement->execute(['tenant_id' => $tenantId, 'id' => $id]);
|
||||
$template = $statement->fetch() ?: null;
|
||||
|
||||
if ($template !== null) {
|
||||
$template['requirements'] = $this->templateRequirements($id);
|
||||
$template['lesson_type_ids'] = $this->lessonTypeIdsForTemplate($id);
|
||||
}
|
||||
|
||||
return $template;
|
||||
}
|
||||
|
||||
private function replaceLessonTypeVisibility(int $tenantId, int $lessonTypeId, ?array $visibleTemplateIds): void
|
||||
{
|
||||
$lessonType = $this->findLessonTypeRecord($tenantId, $lessonTypeId);
|
||||
if ($lessonType === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$delete = $this->db->prepare('DELETE FROM lesson_type_template_visibility WHERE lesson_type_id = :lesson_type_id');
|
||||
$delete->execute(['lesson_type_id' => $lessonTypeId]);
|
||||
|
||||
if ($lessonType['category'] !== 'student') {
|
||||
return;
|
||||
}
|
||||
|
||||
$templateIds = $visibleTemplateIds ?? $this->templateIdsForTenant($tenantId);
|
||||
$templateIds = array_values(array_unique(array_map('intval', $templateIds)));
|
||||
if ($templateIds === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$allowedTemplateIds = $this->templateIdsForTenant($tenantId);
|
||||
$insert = $this->db->prepare(
|
||||
'INSERT OR IGNORE INTO lesson_type_template_visibility (lesson_type_id, template_id)
|
||||
VALUES (:lesson_type_id, :template_id)'
|
||||
);
|
||||
|
||||
foreach ($templateIds as $templateId) {
|
||||
if (!in_array($templateId, $allowedTemplateIds, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$insert->execute([
|
||||
'lesson_type_id' => $lessonTypeId,
|
||||
'template_id' => $templateId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function lessonTypeVisibilityMap(int $tenantId): array
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'SELECT lt.id AS lesson_type_id, vis.template_id
|
||||
FROM lesson_types lt
|
||||
LEFT JOIN lesson_type_template_visibility vis ON vis.lesson_type_id = lt.id
|
||||
WHERE lt.tenant_id = :tenant_id AND lt.category = \'student\'
|
||||
ORDER BY vis.template_id'
|
||||
);
|
||||
$statement->execute(['tenant_id' => $tenantId]);
|
||||
|
||||
$map = [];
|
||||
foreach ($statement->fetchAll() as $row) {
|
||||
$lessonTypeId = (int) $row['lesson_type_id'];
|
||||
$map[$lessonTypeId] ??= [];
|
||||
if ($row['template_id'] !== null) {
|
||||
$map[$lessonTypeId][] = (int) $row['template_id'];
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function visibleTemplateIdsForLessonType(int $tenantId, int $lessonTypeId): array
|
||||
{
|
||||
$lessonType = $this->findLessonTypeRecord($tenantId, $lessonTypeId);
|
||||
if ($lessonType === null || $lessonType['category'] !== 'student') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$statement = $this->db->prepare(
|
||||
'SELECT template_id FROM lesson_type_template_visibility
|
||||
WHERE lesson_type_id = :lesson_type_id
|
||||
ORDER BY template_id'
|
||||
);
|
||||
$statement->execute(['lesson_type_id' => $lessonTypeId]);
|
||||
|
||||
return array_map(
|
||||
static fn (array $row): int => (int) $row['template_id'],
|
||||
$statement->fetchAll()
|
||||
);
|
||||
}
|
||||
|
||||
private function lessonTypeIdsForTemplate(int $templateId): array
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'SELECT lesson_type_id FROM lesson_type_template_visibility
|
||||
WHERE template_id = :template_id
|
||||
ORDER BY lesson_type_id'
|
||||
);
|
||||
$statement->execute(['template_id' => $templateId]);
|
||||
|
||||
return array_map(
|
||||
static fn (array $row): int => (int) $row['lesson_type_id'],
|
||||
$statement->fetchAll()
|
||||
);
|
||||
}
|
||||
|
||||
private function templateIdsForTenant(int $tenantId): array
|
||||
{
|
||||
$statement = $this->db->prepare('SELECT id FROM license_class_templates WHERE tenant_id = :tenant_id ORDER BY id');
|
||||
$statement->execute(['tenant_id' => $tenantId]);
|
||||
|
||||
return array_map(
|
||||
static fn (array $row): int => (int) $row['id'],
|
||||
$statement->fetchAll()
|
||||
);
|
||||
}
|
||||
|
||||
private function attachTemplateToExistingStudentLessonTypes(int $tenantId, int $templateId): void
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'INSERT OR IGNORE INTO lesson_type_template_visibility (lesson_type_id, template_id)
|
||||
SELECT id, :template_id FROM lesson_types
|
||||
WHERE tenant_id = :tenant_id AND category = \'student\''
|
||||
);
|
||||
$statement->execute([
|
||||
'template_id' => $templateId,
|
||||
'tenant_id' => $tenantId,
|
||||
]);
|
||||
}
|
||||
|
||||
private function findLessonTypeRecord(int $tenantId, int $id): ?array
|
||||
{
|
||||
$statement = $this->db->prepare('SELECT * FROM lesson_types WHERE tenant_id = :tenant_id AND id = :id');
|
||||
$statement->execute(['tenant_id' => $tenantId, 'id' => $id]);
|
||||
|
||||
return $statement->fetch() ?: null;
|
||||
}
|
||||
|
||||
private function ensureDefaultPlanningTypes(int $tenantId): void
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'INSERT OR IGNORE INTO lesson_types
|
||||
(tenant_id, name, color, default_duration, category, requirement_key, is_billable, is_counted, is_rest_relevant, fixed_duration, sort_order, system_key, created_at, updated_at)
|
||||
VALUES
|
||||
(:tenant_id, :name, :color, :default_duration, :category, NULL, 0, 0, :is_rest_relevant, 0, :sort_order, :system_key, :created_at, :updated_at)'
|
||||
);
|
||||
|
||||
$timestamp = $this->now();
|
||||
foreach (self::DEFAULT_PLANNING_TYPES as $row) {
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'name' => $row['name'],
|
||||
'color' => $row['color'],
|
||||
'default_duration' => $row['default_duration'],
|
||||
'category' => $row['category'],
|
||||
'is_rest_relevant' => $row['category'] === 'private' ? 0 : 1,
|
||||
'sort_order' => $row['sort_order'],
|
||||
'system_key' => $row['system_key'],
|
||||
'created_at' => $timestamp,
|
||||
'updated_at' => $timestamp,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
final class StudentRepository extends BaseRepository
|
||||
{
|
||||
public function allForTenant(int $tenantId): array
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'SELECT s.*, t.code AS template_code, t.name AS template_name, t.is_combination AS template_is_combination
|
||||
FROM students s
|
||||
JOIN license_class_templates t ON t.id = s.template_id
|
||||
WHERE s.tenant_id = :tenant_id
|
||||
ORDER BY s.last_name, s.first_name'
|
||||
);
|
||||
$statement->execute(['tenant_id' => $tenantId]);
|
||||
|
||||
return $statement->fetchAll();
|
||||
}
|
||||
|
||||
public function find(int $tenantId, int $id): ?array
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'SELECT s.*, t.code AS template_code, t.name AS template_name, t.is_combination AS template_is_combination
|
||||
FROM students s
|
||||
JOIN license_class_templates t ON t.id = s.template_id
|
||||
WHERE s.tenant_id = :tenant_id AND s.id = :id'
|
||||
);
|
||||
$statement->execute(['tenant_id' => $tenantId, 'id' => $id]);
|
||||
$student = $statement->fetch() ?: null;
|
||||
|
||||
if ($student !== null) {
|
||||
$student['requirements'] = $this->requirements($id);
|
||||
}
|
||||
|
||||
return $student;
|
||||
}
|
||||
|
||||
public function create(int $tenantId, array $data): array
|
||||
{
|
||||
$timestamp = $this->now();
|
||||
$statement = $this->db->prepare(
|
||||
'INSERT INTO students
|
||||
(tenant_id, template_id, first_name, last_name, phone, email, needs_glasses, notes, status, created_at, updated_at)
|
||||
VALUES (:tenant_id, :template_id, :first_name, :last_name, :phone, :email, :needs_glasses, :notes, :status, :created_at, :updated_at)'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'template_id' => $data['template_id'],
|
||||
'first_name' => $data['first_name'],
|
||||
'last_name' => $data['last_name'],
|
||||
'phone' => $data['phone'] ?? '',
|
||||
'email' => $data['email'] ?? '',
|
||||
'needs_glasses' => !empty($data['needs_glasses']) ? 1 : 0,
|
||||
'notes' => $data['notes'] ?? '',
|
||||
'status' => $data['status'] ?? 'active',
|
||||
'created_at' => $timestamp,
|
||||
'updated_at' => $timestamp,
|
||||
]);
|
||||
|
||||
$studentId = (int) $this->db->lastInsertId();
|
||||
$this->snapshotRequirements($studentId, (int) $data['template_id']);
|
||||
|
||||
return $this->find($tenantId, $studentId);
|
||||
}
|
||||
|
||||
public function update(int $tenantId, int $id, array $data): ?array
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'UPDATE students
|
||||
SET template_id = :template_id, first_name = :first_name, last_name = :last_name, phone = :phone, email = :email, needs_glasses = :needs_glasses,
|
||||
notes = :notes, status = :status, updated_at = :updated_at
|
||||
WHERE tenant_id = :tenant_id AND id = :id'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'id' => $id,
|
||||
'template_id' => $data['template_id'],
|
||||
'first_name' => $data['first_name'],
|
||||
'last_name' => $data['last_name'],
|
||||
'phone' => $data['phone'] ?? '',
|
||||
'email' => $data['email'] ?? '',
|
||||
'needs_glasses' => !empty($data['needs_glasses']) ? 1 : 0,
|
||||
'notes' => $data['notes'] ?? '',
|
||||
'status' => $data['status'] ?? 'active',
|
||||
'updated_at' => $this->now(),
|
||||
]);
|
||||
|
||||
return $this->find($tenantId, $id);
|
||||
}
|
||||
|
||||
public function delete(int $tenantId, int $id): bool
|
||||
{
|
||||
$student = $this->find($tenantId, $id);
|
||||
if ($student === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->db->beginTransaction();
|
||||
try {
|
||||
$updateAppointments = $this->db->prepare(
|
||||
'UPDATE appointments SET student_id = NULL, updated_at = :updated_at WHERE tenant_id = :tenant_id AND student_id = :student_id'
|
||||
);
|
||||
$updateAppointments->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'student_id' => $id,
|
||||
'updated_at' => $this->now(),
|
||||
]);
|
||||
|
||||
$deleteSnapshots = $this->db->prepare('DELETE FROM student_requirement_snapshots WHERE student_id = :student_id');
|
||||
$deleteSnapshots->execute(['student_id' => $id]);
|
||||
|
||||
$deleteStudent = $this->db->prepare('DELETE FROM students WHERE tenant_id = :tenant_id AND id = :id');
|
||||
$deleteStudent->execute(['tenant_id' => $tenantId, 'id' => $id]);
|
||||
|
||||
$this->db->commit();
|
||||
return true;
|
||||
} catch (\Throwable $exception) {
|
||||
$this->db->rollBack();
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
public function requirements(int $studentId): array
|
||||
{
|
||||
$this->syncRequirementSnapshots($studentId);
|
||||
|
||||
$statement = $this->db->prepare(
|
||||
'SELECT * FROM student_requirement_snapshots WHERE student_id = :student_id ORDER BY sort_order, phase_label, label'
|
||||
);
|
||||
$statement->execute(['student_id' => $studentId]);
|
||||
|
||||
return $statement->fetchAll();
|
||||
}
|
||||
|
||||
public function updatePriorCompletedUnits(int $tenantId, int $studentId, array $rows): ?array
|
||||
{
|
||||
$student = $this->find($tenantId, $studentId);
|
||||
if ($student === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$statement = $this->db->prepare(
|
||||
'UPDATE student_requirement_snapshots
|
||||
SET prior_completed_units = :prior_completed_units
|
||||
WHERE student_id = :student_id AND requirement_key = :requirement_key'
|
||||
);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$statement->execute([
|
||||
'student_id' => $studentId,
|
||||
'requirement_key' => (string) ($row['requirement_key'] ?? ''),
|
||||
'prior_completed_units' => max(0, (int) ($row['prior_completed_units'] ?? 0)),
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->find($tenantId, $studentId);
|
||||
}
|
||||
|
||||
private function snapshotRequirements(int $studentId, int $templateId): void
|
||||
{
|
||||
$this->syncRequirementSnapshots($studentId, $templateId);
|
||||
}
|
||||
|
||||
private function syncRequirementSnapshots(int $studentId, ?int $templateId = null): void
|
||||
{
|
||||
if ($templateId === null) {
|
||||
$student = $this->db->prepare('SELECT template_id FROM students WHERE id = :student_id');
|
||||
$student->execute(['student_id' => $studentId]);
|
||||
$templateId = (int) ($student->fetch()['template_id'] ?? 0);
|
||||
}
|
||||
|
||||
if ($templateId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$requirements = $this->db->prepare(
|
||||
'SELECT requirement_key, label, required_units, sort_order, phase_label
|
||||
FROM license_template_requirements WHERE template_id = :template_id'
|
||||
);
|
||||
$requirements->execute(['template_id' => $templateId]);
|
||||
$rows = [];
|
||||
|
||||
foreach ($requirements->fetchAll() as $row) {
|
||||
$rows[$row['requirement_key']] = $row;
|
||||
}
|
||||
$hasPhasedRequirements = array_reduce(
|
||||
$rows,
|
||||
static fn (bool $carry, array $row): bool => $carry || trim((string) ($row['phase_label'] ?? '')) !== '',
|
||||
false
|
||||
);
|
||||
|
||||
$lessonTypes = $this->db->prepare(
|
||||
'SELECT COALESCE(NULLIF(TRIM(lt.requirement_key), \'\'), \'lesson_type_\' || lt.id) AS requirement_key,
|
||||
lt.name AS label,
|
||||
lt.sort_order
|
||||
FROM lesson_types lt
|
||||
JOIN lesson_type_template_visibility vis ON vis.lesson_type_id = lt.id
|
||||
WHERE vis.template_id = :template_id
|
||||
AND lt.category = \'student\'
|
||||
AND lt.is_counted = 1
|
||||
ORDER BY lt.sort_order, lt.name'
|
||||
);
|
||||
$lessonTypes->execute(['template_id' => $templateId]);
|
||||
|
||||
foreach ($lessonTypes->fetchAll() as $row) {
|
||||
if (isset($rows[$row['requirement_key']])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($hasPhasedRequirements) {
|
||||
$hasMatchingPhasedRequirement = false;
|
||||
foreach ($rows as $existingRow) {
|
||||
$phaseLabel = trim((string) ($existingRow['phase_label'] ?? ''));
|
||||
if ($phaseLabel === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_ends_with((string) $existingRow['requirement_key'], '::' . $row['requirement_key'])) {
|
||||
$hasMatchingPhasedRequirement = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasMatchingPhasedRequirement) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$rows[$row['requirement_key']] = [
|
||||
'requirement_key' => $row['requirement_key'],
|
||||
'label' => $row['label'],
|
||||
'required_units' => 0,
|
||||
'sort_order' => $row['sort_order'],
|
||||
];
|
||||
}
|
||||
|
||||
$insert = $this->db->prepare(
|
||||
'INSERT INTO student_requirement_snapshots
|
||||
(student_id, requirement_key, label, required_units, prior_completed_units, sort_order, phase_label)
|
||||
SELECT :student_id, :requirement_key, :label, :required_units, 0, :sort_order, :phase_label
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM student_requirement_snapshots
|
||||
WHERE student_id = :student_id AND requirement_key = :requirement_key
|
||||
)'
|
||||
);
|
||||
$update = $this->db->prepare(
|
||||
'UPDATE student_requirement_snapshots
|
||||
SET label = :label,
|
||||
required_units = :required_units,
|
||||
sort_order = :sort_order,
|
||||
phase_label = :phase_label
|
||||
WHERE student_id = :student_id AND requirement_key = :requirement_key'
|
||||
);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$insert->execute([
|
||||
'student_id' => $studentId,
|
||||
'requirement_key' => $row['requirement_key'],
|
||||
'label' => $row['label'],
|
||||
'required_units' => $row['required_units'],
|
||||
'sort_order' => $row['sort_order'],
|
||||
'phase_label' => $row['phase_label'] ?? '',
|
||||
]);
|
||||
$update->execute([
|
||||
'student_id' => $studentId,
|
||||
'requirement_key' => $row['requirement_key'],
|
||||
'label' => $row['label'],
|
||||
'required_units' => $row['required_units'],
|
||||
'sort_order' => $row['sort_order'],
|
||||
'phase_label' => $row['phase_label'] ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($rows !== []) {
|
||||
$placeholders = implode(', ', array_fill(0, count($rows), '?'));
|
||||
$delete = $this->db->prepare(
|
||||
"DELETE FROM student_requirement_snapshots
|
||||
WHERE student_id = ?
|
||||
AND requirement_key NOT IN ($placeholders)
|
||||
AND prior_completed_units = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM appointment_units au
|
||||
JOIN appointments a ON a.id = au.appointment_id
|
||||
WHERE a.student_id = student_requirement_snapshots.student_id
|
||||
AND au.requirement_key = student_requirement_snapshots.requirement_key
|
||||
)"
|
||||
);
|
||||
$delete->execute(array_merge([$studentId], array_keys($rows)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class TenantRepository extends BaseRepository
|
||||
{
|
||||
public function all(): array
|
||||
{
|
||||
return $this->db->query('SELECT * FROM tenants ORDER BY name')->fetchAll();
|
||||
}
|
||||
|
||||
public function find(int $id): ?array
|
||||
{
|
||||
$statement = $this->db->prepare('SELECT * FROM tenants WHERE id = :id');
|
||||
$statement->execute(['id' => $id]);
|
||||
|
||||
return $statement->fetch() ?: null;
|
||||
}
|
||||
|
||||
public function create(array $data): array
|
||||
{
|
||||
$timestamp = $this->now();
|
||||
$statement = $this->db->prepare(
|
||||
'INSERT INTO tenants (name, timezone, federal_state, location_label, latitude, longitude, is_active, created_at, updated_at)
|
||||
VALUES (:name, :timezone, :federal_state, :location_label, :latitude, :longitude, :is_active, :created_at, :updated_at)'
|
||||
);
|
||||
$statement->execute([
|
||||
'name' => $data['name'],
|
||||
'timezone' => $data['timezone'] ?? 'Europe/Berlin',
|
||||
'federal_state' => $data['federal_state'] ?? 'BE',
|
||||
'location_label' => $data['location_label'] ?? '',
|
||||
'latitude' => $data['latitude'] !== '' ? $data['latitude'] : null,
|
||||
'longitude' => $data['longitude'] !== '' ? $data['longitude'] : null,
|
||||
'is_active' => !empty($data['is_active']) ? 1 : 0,
|
||||
'created_at' => $timestamp,
|
||||
'updated_at' => $timestamp,
|
||||
]);
|
||||
|
||||
return $this->find((int) $this->db->lastInsertId());
|
||||
}
|
||||
|
||||
public function update(int $id, array $data): ?array
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'UPDATE tenants
|
||||
SET name = :name, timezone = :timezone, federal_state = :federal_state, location_label = :location_label,
|
||||
latitude = :latitude, longitude = :longitude, is_active = :is_active, updated_at = :updated_at
|
||||
WHERE id = :id'
|
||||
);
|
||||
$statement->execute([
|
||||
'id' => $id,
|
||||
'name' => $data['name'],
|
||||
'timezone' => $data['timezone'],
|
||||
'federal_state' => $data['federal_state'] ?? 'BE',
|
||||
'location_label' => $data['location_label'] ?? '',
|
||||
'latitude' => $data['latitude'] !== '' ? $data['latitude'] : null,
|
||||
'longitude' => $data['longitude'] !== '' ? $data['longitude'] : null,
|
||||
'is_active' => !empty($data['is_active']) ? 1 : 0,
|
||||
'updated_at' => $this->now(),
|
||||
]);
|
||||
|
||||
return $this->find($id);
|
||||
}
|
||||
|
||||
public function updateForTenantAdmin(int $id, array $data): ?array
|
||||
{
|
||||
$current = $this->find($id);
|
||||
if ($current === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->update($id, [
|
||||
'name' => $data['name'] ?? $current['name'],
|
||||
'timezone' => $data['timezone'] ?? $current['timezone'],
|
||||
'federal_state' => $data['federal_state'] ?? ($current['federal_state'] ?? 'BE'),
|
||||
'location_label' => $data['location_label'] ?? ($current['location_label'] ?? ''),
|
||||
'latitude' => array_key_exists('latitude', $data) ? $data['latitude'] : ($current['latitude'] ?? null),
|
||||
'longitude' => array_key_exists('longitude', $data) ? $data['longitude'] : ($current['longitude'] ?? null),
|
||||
'is_active' => $current['is_active'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
final class UserRepository extends BaseRepository
|
||||
{
|
||||
public function allForTenant(int $tenantId): array
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'SELECT id, tenant_id, role, email, first_name, last_name, is_active, created_at, updated_at
|
||||
FROM users WHERE tenant_id = :tenant_id ORDER BY role, last_name, first_name'
|
||||
);
|
||||
$statement->execute(['tenant_id' => $tenantId]);
|
||||
|
||||
return $statement->fetchAll();
|
||||
}
|
||||
|
||||
public function allUsers(): array
|
||||
{
|
||||
return $this->db->query(
|
||||
'SELECT u.id, u.tenant_id, u.role, u.email, u.first_name, u.last_name, u.is_active,
|
||||
u.created_at, u.updated_at, t.name AS tenant_name
|
||||
FROM users u
|
||||
LEFT JOIN tenants t ON t.id = u.tenant_id
|
||||
ORDER BY COALESCE(t.name, "System"), u.role, u.last_name, u.first_name'
|
||||
)->fetchAll();
|
||||
}
|
||||
|
||||
public function findByEmail(string $email): ?array
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'SELECT u.*, t.name AS tenant_name, t.timezone AS tenant_timezone, t.is_active AS tenant_is_active
|
||||
FROM users u
|
||||
LEFT JOIN tenants t ON t.id = u.tenant_id
|
||||
WHERE u.email = :email'
|
||||
);
|
||||
$statement->execute(['email' => $email]);
|
||||
|
||||
return $statement->fetch() ?: null;
|
||||
}
|
||||
|
||||
public function findById(int $id): ?array
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'SELECT u.*, t.name AS tenant_name, t.timezone AS tenant_timezone, t.is_active AS tenant_is_active
|
||||
FROM users u
|
||||
LEFT JOIN tenants t ON t.id = u.tenant_id
|
||||
WHERE u.id = :id'
|
||||
);
|
||||
$statement->execute(['id' => $id]);
|
||||
|
||||
return $statement->fetch() ?: null;
|
||||
}
|
||||
|
||||
public function create(array $data): array
|
||||
{
|
||||
$timestamp = $this->now();
|
||||
$statement = $this->db->prepare(
|
||||
'INSERT INTO users
|
||||
(tenant_id, role, email, password_hash, first_name, last_name, is_active, created_at, updated_at)
|
||||
VALUES (:tenant_id, :role, :email, :password_hash, :first_name, :last_name, :is_active, :created_at, :updated_at)'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $data['tenant_id'],
|
||||
'role' => $data['role'],
|
||||
'email' => $data['email'],
|
||||
'password_hash' => password_hash($data['password'], PASSWORD_DEFAULT),
|
||||
'first_name' => $data['first_name'],
|
||||
'last_name' => $data['last_name'],
|
||||
'is_active' => !empty($data['is_active']) ? 1 : 0,
|
||||
'created_at' => $timestamp,
|
||||
'updated_at' => $timestamp,
|
||||
]);
|
||||
|
||||
return $this->findById((int) $this->db->lastInsertId());
|
||||
}
|
||||
|
||||
public function update(int $id, array $data): ?array
|
||||
{
|
||||
$current = $this->findById($id);
|
||||
if ($current === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$passwordHash = $current['password_hash'];
|
||||
if (!empty($data['password'])) {
|
||||
$passwordHash = password_hash($data['password'], PASSWORD_DEFAULT);
|
||||
}
|
||||
|
||||
$statement = $this->db->prepare(
|
||||
'UPDATE users
|
||||
SET tenant_id = :tenant_id, role = :role, email = :email, password_hash = :password_hash,
|
||||
first_name = :first_name, last_name = :last_name, is_active = :is_active, updated_at = :updated_at
|
||||
WHERE id = :id'
|
||||
);
|
||||
$statement->execute([
|
||||
'id' => $id,
|
||||
'tenant_id' => $data['tenant_id'],
|
||||
'role' => $data['role'],
|
||||
'email' => $data['email'],
|
||||
'password_hash' => $passwordHash,
|
||||
'first_name' => $data['first_name'],
|
||||
'last_name' => $data['last_name'],
|
||||
'is_active' => !empty($data['is_active']) ? 1 : 0,
|
||||
'updated_at' => $this->now(),
|
||||
]);
|
||||
|
||||
return $this->findById($id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Repositories\AppointmentRepository;
|
||||
use App\Repositories\ReferenceDataRepository;
|
||||
use DateInterval;
|
||||
use DateTimeImmutable;
|
||||
|
||||
final class CalendarService
|
||||
{
|
||||
public function validate(int $tenantId, array $payload, ?int $appointmentId = null): array
|
||||
{
|
||||
$appointmentRepository = new AppointmentRepository();
|
||||
$lessonType = (new ReferenceDataRepository())->findLessonType($tenantId, (int) $payload['lesson_type_id']);
|
||||
$warnings = [
|
||||
'conflicts' => [],
|
||||
'rest_period_warnings' => [],
|
||||
];
|
||||
|
||||
$conflicts = $appointmentRepository->overlappingForInstructor(
|
||||
$tenantId,
|
||||
(int) $payload['instructor_id'],
|
||||
$payload['start_at'],
|
||||
$payload['end_at'],
|
||||
$appointmentId
|
||||
);
|
||||
|
||||
foreach ($conflicts as $conflict) {
|
||||
$warnings['conflicts'][] = [
|
||||
'type' => 'instructor_overlap',
|
||||
'message' => 'Fahrlehrer ist in diesem Zeitraum bereits verplant.',
|
||||
'appointment_id' => (int) $conflict['id'],
|
||||
];
|
||||
}
|
||||
|
||||
if (!empty($payload['student_id'])) {
|
||||
$studentConflicts = $appointmentRepository->overlappingForStudent(
|
||||
$tenantId,
|
||||
(int) $payload['student_id'],
|
||||
$payload['start_at'],
|
||||
$payload['end_at'],
|
||||
$appointmentId
|
||||
);
|
||||
foreach ($studentConflicts as $conflict) {
|
||||
$warnings['conflicts'][] = [
|
||||
'type' => 'student_overlap',
|
||||
'message' => 'Fahrschueler ist in diesem Zeitraum bereits verplant.',
|
||||
'appointment_id' => (int) $conflict['id'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($lessonType !== null && (int) $lessonType['is_rest_relevant'] === 1) {
|
||||
$newStart = new DateTimeImmutable($payload['start_at']);
|
||||
$dayStart = $newStart->setTime(0, 0, 0);
|
||||
$dayEnd = $dayStart->modify('+1 day');
|
||||
$hasLaterRelevantOnDay = $appointmentRepository->hasRestRelevantAfterStartOnDay(
|
||||
$tenantId,
|
||||
(int) $payload['instructor_id'],
|
||||
$dayStart->format(DATE_ATOM),
|
||||
$dayEnd->format(DATE_ATOM),
|
||||
$payload['start_at']
|
||||
);
|
||||
|
||||
if ($hasLaterRelevantOnDay) {
|
||||
return $warnings;
|
||||
}
|
||||
|
||||
$lastRelevant = $appointmentRepository->lastRestRelevantForInstructor(
|
||||
$tenantId,
|
||||
(int) $payload['instructor_id'],
|
||||
$payload['start_at'],
|
||||
$appointmentId
|
||||
);
|
||||
if ($lastRelevant !== null) {
|
||||
$restEnds = (new DateTimeImmutable($lastRelevant['end_at']))->add(new DateInterval('PT11H'));
|
||||
if ($newStart < $restEnds) {
|
||||
$warnings['rest_period_warnings'][] = [
|
||||
'type' => 'rest_period',
|
||||
'message' => 'Achtung: Dieser Termin liegt innerhalb der 11-Stunden-Ruhezeit.',
|
||||
'rest_until' => $restEnds->format(DATE_ATOM),
|
||||
'blocking_appointment_id' => (int) $lastRelevant['id'],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $warnings;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use DateInterval;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
|
||||
final class HolidayService
|
||||
{
|
||||
private const STATES_WITH_EPIPHANY = ['BW', 'BY', 'ST'];
|
||||
private const STATES_WITH_WOMENS_DAY = ['BE', 'MV'];
|
||||
private const STATES_WITH_CORPUS_CHRISTI = ['BW', 'BY', 'HE', 'NW', 'RP', 'SL'];
|
||||
private const STATES_WITH_ASSUMPTION = ['BY', 'SL'];
|
||||
private const STATES_WITH_REFORMATION = ['BB', 'HB', 'HH', 'MV', 'NI', 'SN', 'ST', 'SH', 'TH'];
|
||||
private const STATES_WITH_ALL_SAINTS = ['BW', 'BY', 'NW', 'RP', 'SL'];
|
||||
private const STATES_WITH_REPENTANCE = ['SN'];
|
||||
|
||||
public function forRange(array $tenant, string $from, string $to): array
|
||||
{
|
||||
$timezone = new DateTimeZone((string) ($tenant['timezone'] ?? 'Europe/Berlin'));
|
||||
$state = strtoupper((string) ($tenant['federal_state'] ?? 'BE'));
|
||||
$start = (new DateTimeImmutable($from, new DateTimeZone('UTC')))->setTimezone($timezone)->setTime(0, 0);
|
||||
$end = (new DateTimeImmutable($to, new DateTimeZone('UTC')))->setTimezone($timezone)->setTime(0, 0);
|
||||
|
||||
$years = [];
|
||||
$day = $start;
|
||||
while ($day <= $end) {
|
||||
$years[(int) $day->format('Y')] = true;
|
||||
$day = $day->add(new DateInterval('P1D'));
|
||||
}
|
||||
|
||||
$holidaysByDate = [];
|
||||
foreach (array_keys($years) as $year) {
|
||||
foreach ($this->holidaysForYear((int) $year, $state, $timezone) as $date => $name) {
|
||||
$holidaysByDate[$date] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
$result = [];
|
||||
$day = $start;
|
||||
while ($day < $end) {
|
||||
$key = $day->format('Y-m-d');
|
||||
$result[$key] = [
|
||||
'isSunday' => (int) $day->format('N') === 7,
|
||||
'holidayName' => $holidaysByDate[$key] ?? null,
|
||||
];
|
||||
$day = $day->add(new DateInterval('P1D'));
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function holidaysForYear(int $year, string $state, DateTimeZone $timezone): array
|
||||
{
|
||||
$easter = (new DateTimeImmutable('@' . (string) easter_date($year)))->setTimezone($timezone)->setTime(0, 0);
|
||||
$holidays = [
|
||||
$this->dateKey($year, 1, 1, $timezone) => 'Neujahr',
|
||||
$easter->sub(new DateInterval('P2D'))->format('Y-m-d') => 'Karfreitag',
|
||||
$easter->add(new DateInterval('P1D'))->format('Y-m-d') => 'Ostermontag',
|
||||
$this->dateKey($year, 5, 1, $timezone) => 'Tag der Arbeit',
|
||||
$easter->add(new DateInterval('P39D'))->format('Y-m-d') => 'Christi Himmelfahrt',
|
||||
$easter->add(new DateInterval('P50D'))->format('Y-m-d') => 'Pfingstmontag',
|
||||
$this->dateKey($year, 10, 3, $timezone) => 'Tag der Deutschen Einheit',
|
||||
$this->dateKey($year, 12, 25, $timezone) => '1. Weihnachtstag',
|
||||
$this->dateKey($year, 12, 26, $timezone) => '2. Weihnachtstag',
|
||||
];
|
||||
|
||||
if (in_array($state, self::STATES_WITH_EPIPHANY, true)) {
|
||||
$holidays[$this->dateKey($year, 1, 6, $timezone)] = 'Heilige Drei Koenige';
|
||||
}
|
||||
if (in_array($state, self::STATES_WITH_WOMENS_DAY, true)) {
|
||||
$holidays[$this->dateKey($year, 3, 8, $timezone)] = 'Internationaler Frauentag';
|
||||
}
|
||||
if (in_array($state, self::STATES_WITH_CORPUS_CHRISTI, true)) {
|
||||
$holidays[$easter->add(new DateInterval('P60D'))->format('Y-m-d')] = 'Fronleichnam';
|
||||
}
|
||||
if (in_array($state, self::STATES_WITH_ASSUMPTION, true)) {
|
||||
$holidays[$this->dateKey($year, 8, 15, $timezone)] = 'Mariae Himmelfahrt';
|
||||
}
|
||||
if (in_array($state, self::STATES_WITH_REFORMATION, true)) {
|
||||
$holidays[$this->dateKey($year, 10, 31, $timezone)] = 'Reformationstag';
|
||||
}
|
||||
if (in_array($state, self::STATES_WITH_ALL_SAINTS, true)) {
|
||||
$holidays[$this->dateKey($year, 11, 1, $timezone)] = 'Allerheiligen';
|
||||
}
|
||||
if (in_array($state, self::STATES_WITH_REPENTANCE, true)) {
|
||||
$holidays[$this->repentanceDay($year, $timezone)->format('Y-m-d')] = 'Buss- und Bettag';
|
||||
}
|
||||
|
||||
return $holidays;
|
||||
}
|
||||
|
||||
private function dateKey(int $year, int $month, int $day, DateTimeZone $timezone): string
|
||||
{
|
||||
return (new DateTimeImmutable(sprintf('%04d-%02d-%02d', $year, $month, $day), $timezone))->format('Y-m-d');
|
||||
}
|
||||
|
||||
private function repentanceDay(int $year, DateTimeZone $timezone): DateTimeImmutable
|
||||
{
|
||||
$day = new DateTimeImmutable(sprintf('%04d-11-23', $year), $timezone);
|
||||
while ((int) $day->format('N') !== 3) {
|
||||
$day = $day->sub(new DateInterval('P1D'));
|
||||
}
|
||||
return $day;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Repositories\AppointmentRepository;
|
||||
use App\Repositories\StudentRepository;
|
||||
use App\Support\Database;
|
||||
|
||||
final class RequirementService
|
||||
{
|
||||
public function progressForStudent(int $studentId): array
|
||||
{
|
||||
$db = Database::connection();
|
||||
$snapshots = (new StudentRepository())->requirements($studentId);
|
||||
|
||||
$statement = $db->prepare(
|
||||
'SELECT au.requirement_key,
|
||||
SUM(CASE WHEN a.status = "planned" THEN au.units_counted ELSE 0 END) AS planned_units,
|
||||
SUM(CASE WHEN a.status = "completed" THEN au.units_counted ELSE 0 END) AS completed_units
|
||||
FROM appointment_units au
|
||||
JOIN appointments a ON a.id = au.appointment_id
|
||||
WHERE a.student_id = :student_id
|
||||
GROUP BY au.requirement_key'
|
||||
);
|
||||
$statement->execute(['student_id' => $studentId]);
|
||||
$totals = [];
|
||||
foreach ($statement->fetchAll() as $row) {
|
||||
$totals[$row['requirement_key']] = [
|
||||
'planned' => (int) $row['planned_units'],
|
||||
'completed' => (int) $row['completed_units'],
|
||||
];
|
||||
}
|
||||
|
||||
return array_map(static function (array $snapshot) use ($totals): array {
|
||||
$row = $totals[$snapshot['requirement_key']] ?? ['planned' => 0, 'completed' => 0];
|
||||
$priorCompleted = (int) ($snapshot['prior_completed_units'] ?? 0);
|
||||
$appointmentCompleted = (int) $row['completed'];
|
||||
$completed = $priorCompleted + $appointmentCompleted;
|
||||
$open = (int) $snapshot['required_units'] - $row['planned'] - $completed;
|
||||
|
||||
return [
|
||||
'requirement_key' => $snapshot['requirement_key'],
|
||||
'phase_label' => $snapshot['phase_label'] ?? '',
|
||||
'label' => $snapshot['label'],
|
||||
'required_units' => (int) $snapshot['required_units'],
|
||||
'planned_units' => $row['planned'],
|
||||
'completed_units' => $completed,
|
||||
'prior_completed_units' => $priorCompleted,
|
||||
'appointment_completed_units' => $appointmentCompleted,
|
||||
'open_units' => $open,
|
||||
];
|
||||
}, $snapshots);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use DateInterval;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
|
||||
final class SunTimesService
|
||||
{
|
||||
public function forRange(array $tenant, string $from, string $to): array
|
||||
{
|
||||
$latitude = isset($tenant['latitude']) ? (float) $tenant['latitude'] : null;
|
||||
$longitude = isset($tenant['longitude']) ? (float) $tenant['longitude'] : null;
|
||||
$timezoneName = (string) ($tenant['timezone'] ?? 'Europe/Berlin');
|
||||
|
||||
if ($latitude === null || $longitude === null || $latitude === 0.0 && $longitude === 0.0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$timezone = new DateTimeZone($timezoneName);
|
||||
$start = new DateTimeImmutable($from, new DateTimeZone('UTC'));
|
||||
$end = new DateTimeImmutable($to, new DateTimeZone('UTC'));
|
||||
$day = $start->setTimezone($timezone)->setTime(0, 0);
|
||||
$lastDay = $end->setTimezone($timezone)->setTime(0, 0);
|
||||
|
||||
$result = [];
|
||||
while ($day < $lastDay) {
|
||||
$solar = date_sun_info($day->setTime(12, 0)->getTimestamp(), $latitude, $longitude);
|
||||
$key = $day->format('Y-m-d');
|
||||
|
||||
$result[$key] = [
|
||||
'sunrise' => $this->formatSolarTimestamp($solar['sunrise'] ?? false, $timezone),
|
||||
'sunset' => $this->formatSolarTimestamp($solar['sunset'] ?? false, $timezone),
|
||||
];
|
||||
|
||||
$day = $day->add(new DateInterval('P1D'));
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function formatSolarTimestamp(int|float|bool $timestamp, DateTimeZone $timezone): ?string
|
||||
{
|
||||
if (!is_int($timestamp) && !is_float($timestamp)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (new DateTimeImmutable('@' . (string) (int) $timestamp))
|
||||
->setTimezone($timezone)
|
||||
->format('H:i');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Repositories\UserRepository;
|
||||
|
||||
final class Auth
|
||||
{
|
||||
public static function user(): ?array
|
||||
{
|
||||
$userId = $_SESSION['user_id'] ?? null;
|
||||
if (!$userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (new UserRepository())->findById((int) $userId);
|
||||
}
|
||||
|
||||
public static function requireUser(): array
|
||||
{
|
||||
$user = self::user();
|
||||
if ($user === null) {
|
||||
Response::json(['message' => 'Unauthenticated'], 401);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
public static function requireRole(array|string $roles): array
|
||||
{
|
||||
$user = self::requireUser();
|
||||
$roles = (array) $roles;
|
||||
|
||||
if (!in_array($user['role'], $roles, true)) {
|
||||
Response::json(['message' => 'Forbidden'], 403);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class Database
|
||||
{
|
||||
private static string $path;
|
||||
private static ?PDO $connection = null;
|
||||
|
||||
public static function configure(string $path): void
|
||||
{
|
||||
self::$path = $path;
|
||||
}
|
||||
|
||||
public static function connection(): PDO
|
||||
{
|
||||
if (self::$connection instanceof PDO) {
|
||||
return self::$connection;
|
||||
}
|
||||
|
||||
$directory = dirname(self::$path);
|
||||
if (!is_dir($directory)) {
|
||||
mkdir($directory, 0775, true);
|
||||
}
|
||||
|
||||
self::$connection = new PDO('sqlite:' . self::$path);
|
||||
self::$connection->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
self::$connection->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
self::$connection->exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
return self::$connection;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
final class Request
|
||||
{
|
||||
public function method(): string
|
||||
{
|
||||
return strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
|
||||
}
|
||||
|
||||
public function path(): string
|
||||
{
|
||||
$uri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||
$path = parse_url($uri, PHP_URL_PATH) ?: '/';
|
||||
|
||||
return rtrim($path, '/') ?: '/';
|
||||
}
|
||||
|
||||
public function query(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $_GET[$key] ?? $default;
|
||||
}
|
||||
|
||||
public function input(): array
|
||||
{
|
||||
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
|
||||
|
||||
if (str_contains($contentType, 'application/json')) {
|
||||
$raw = file_get_contents('php://input');
|
||||
$decoded = json_decode($raw ?: '[]', true);
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
return $_POST;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
final class Response
|
||||
{
|
||||
public static function json(array $data, int $status = 200): never
|
||||
{
|
||||
http_response_code($status);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
public static function noContent(): never
|
||||
{
|
||||
http_response_code(204);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
final class Router
|
||||
{
|
||||
private array $routes = [];
|
||||
|
||||
public function __construct(private readonly Request $request)
|
||||
{
|
||||
}
|
||||
|
||||
public function get(string $path, callable $handler): void
|
||||
{
|
||||
$this->map('GET', $path, $handler);
|
||||
}
|
||||
|
||||
public function post(string $path, callable $handler): void
|
||||
{
|
||||
$this->map('POST', $path, $handler);
|
||||
}
|
||||
|
||||
public function patch(string $path, callable $handler): void
|
||||
{
|
||||
$this->map('PATCH', $path, $handler);
|
||||
}
|
||||
|
||||
public function delete(string $path, callable $handler): void
|
||||
{
|
||||
$this->map('DELETE', $path, $handler);
|
||||
}
|
||||
|
||||
public function dispatch(): void
|
||||
{
|
||||
$method = $this->request->method();
|
||||
$path = $this->request->path();
|
||||
|
||||
foreach ($this->routes[$method] ?? [] as $route) {
|
||||
$pattern = preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '(?P<$1>[^/]+)', $route['path']);
|
||||
$pattern = '#^' . $pattern . '$#';
|
||||
|
||||
if (!preg_match($pattern, $path, $matches)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$params = array_filter($matches, static fn (string|int $key): bool => is_string($key), ARRAY_FILTER_USE_KEY);
|
||||
$route['handler']($this->request, $params);
|
||||
return;
|
||||
}
|
||||
|
||||
Response::json(['message' => 'Route not found', 'path' => $path], 404);
|
||||
}
|
||||
|
||||
private function map(string $method, string $path, callable $handler): void
|
||||
{
|
||||
$this->routes[$method][] = [
|
||||
'path' => rtrim($path, '/') ?: '/',
|
||||
'handler' => $handler,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Database;
|
||||
use App\Support\Request;
|
||||
use App\Support\Router;
|
||||
|
||||
const BASE_PATH = __DIR__ . '/..';
|
||||
|
||||
spl_autoload_register(static function (string $class): void {
|
||||
$prefix = 'App\\';
|
||||
|
||||
if (!str_starts_with($class, $prefix)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$relativeClass = substr($class, strlen($prefix));
|
||||
$path = __DIR__ . '/' . str_replace('\\', '/', $relativeClass) . '.php';
|
||||
|
||||
if (is_file($path)) {
|
||||
require_once $path;
|
||||
}
|
||||
});
|
||||
|
||||
$config = require BASE_PATH . '/config/app.php';
|
||||
|
||||
date_default_timezone_set('UTC');
|
||||
session_name($config['session_name']);
|
||||
|
||||
$sessionPath = $config['session_path'];
|
||||
if (!is_dir($sessionPath)) {
|
||||
mkdir($sessionPath, 0775, true);
|
||||
}
|
||||
|
||||
session_save_path($sessionPath);
|
||||
session_start();
|
||||
|
||||
Database::configure($config['db_path']);
|
||||
|
||||
return [
|
||||
'config' => $config,
|
||||
'router' => new Router(new Request()),
|
||||
];
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$bootstrap = require __DIR__ . '/../app/bootstrap.php';
|
||||
|
||||
use App\Support\Database;
|
||||
|
||||
$db = Database::connection();
|
||||
$migrations = glob(__DIR__ . '/../database/migrations/*.sql');
|
||||
sort($migrations);
|
||||
|
||||
foreach ($migrations as $migration) {
|
||||
$statements = array_filter(array_map('trim', explode(';', file_get_contents($migration))));
|
||||
foreach ($statements as $statement) {
|
||||
if (preg_match('/^ALTER\s+TABLE\s+([A-Za-z0-9_]+)\s+ADD\s+COLUMN\s+([A-Za-z0-9_]+)/i', $statement, $matches) === 1) {
|
||||
$columns = $db->query('PRAGMA table_info(' . $matches[1] . ')')->fetchAll();
|
||||
$columnNames = array_map(static fn (array $column): string => strtolower((string) $column['name']), $columns);
|
||||
if (in_array(strtolower($matches[2]), $columnNames, true)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$db->exec($statement);
|
||||
}
|
||||
fwrite(STDOUT, "Applied: " . basename($migration) . PHP_EOL);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$bootstrap = require __DIR__ . '/../app/bootstrap.php';
|
||||
|
||||
use App\Repositories\InstructorRepository;
|
||||
use App\Repositories\ReferenceDataRepository;
|
||||
use App\Repositories\StudentRepository;
|
||||
use App\Repositories\TenantRepository;
|
||||
use App\Repositories\UserRepository;
|
||||
use App\Support\Database;
|
||||
|
||||
$db = Database::connection();
|
||||
|
||||
$tenantRepository = new TenantRepository();
|
||||
$userRepository = new UserRepository();
|
||||
$referenceRepository = new ReferenceDataRepository();
|
||||
$instructorRepository = new InstructorRepository();
|
||||
$studentRepository = new StudentRepository();
|
||||
|
||||
$existing = $db->query('SELECT COUNT(*) AS count FROM users')->fetch();
|
||||
if ((int) $existing['count'] > 0) {
|
||||
fwrite(STDOUT, "Seed skipped: data already exists." . PHP_EOL);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$tenant = $tenantRepository->create([
|
||||
'name' => 'Fahrschule Nordring',
|
||||
'timezone' => 'Europe/Berlin',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$db->prepare('INSERT INTO settings (tenant_id, key_name, value) VALUES (:tenant_id, :key_name, :value)')
|
||||
->execute([
|
||||
'tenant_id' => $tenant['id'],
|
||||
'key_name' => 'app.theme',
|
||||
'value' => 'signal',
|
||||
]);
|
||||
|
||||
$superadmin = $userRepository->create([
|
||||
'tenant_id' => null,
|
||||
'role' => 'superadmin',
|
||||
'email' => 'superadmin@example.com',
|
||||
'password' => 'secret123',
|
||||
'first_name' => 'System',
|
||||
'last_name' => 'Admin',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$tenantAdmin = $userRepository->create([
|
||||
'tenant_id' => $tenant['id'],
|
||||
'role' => 'tenant_admin',
|
||||
'email' => 'admin@nordring.test',
|
||||
'password' => 'secret123',
|
||||
'first_name' => 'Jana',
|
||||
'last_name' => 'Koester',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$dispatcher = $userRepository->create([
|
||||
'tenant_id' => $tenant['id'],
|
||||
'role' => 'dispatcher',
|
||||
'email' => 'planung@nordring.test',
|
||||
'password' => 'secret123',
|
||||
'first_name' => 'Mara',
|
||||
'last_name' => 'Dispo',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$instructorUser = $userRepository->create([
|
||||
'tenant_id' => $tenant['id'],
|
||||
'role' => 'instructor',
|
||||
'email' => 'fahrlehrer@nordring.test',
|
||||
'password' => 'secret123',
|
||||
'first_name' => 'Tobias',
|
||||
'last_name' => 'Fahr',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$instructors = [
|
||||
['user_id' => $instructorUser['id'], 'first_name' => 'Tobias', 'last_name' => 'Fahr', 'color' => '#0E5D80'],
|
||||
['user_id' => null, 'first_name' => 'Leonie', 'last_name' => 'Kraft', 'color' => '#A74826'],
|
||||
];
|
||||
|
||||
foreach ($instructors as $instructor) {
|
||||
$instructorRepository->create($tenant['id'], $instructor + ['notes' => '', 'is_active' => true]);
|
||||
}
|
||||
|
||||
$lessonTypes = [
|
||||
['name' => 'Einweisung', 'color' => '#0E5D80', 'default_duration' => 45, 'category' => 'student', 'requirement_key' => 'intro', 'is_billable' => 1, 'is_counted' => 1, 'is_rest_relevant' => 1, 'fixed_duration' => 0, 'sort_order' => 1],
|
||||
['name' => 'Uebungsstunde', 'color' => '#2C7A4B', 'default_duration' => 45, 'category' => 'student', 'requirement_key' => 'practice', 'is_billable' => 1, 'is_counted' => 1, 'is_rest_relevant' => 1, 'fixed_duration' => 0, 'sort_order' => 2],
|
||||
['name' => 'Bundes- / Landstrasse', 'color' => '#D99A1A', 'default_duration' => 45, 'category' => 'student', 'requirement_key' => 'rural', 'is_billable' => 1, 'is_counted' => 1, 'is_rest_relevant' => 1, 'fixed_duration' => 0, 'sort_order' => 3],
|
||||
['name' => 'Autobahn', 'color' => '#C86C1E', 'default_duration' => 45, 'category' => 'student', 'requirement_key' => 'highway', 'is_billable' => 1, 'is_counted' => 1, 'is_rest_relevant' => 1, 'fixed_duration' => 0, 'sort_order' => 4],
|
||||
['name' => 'Daemmerung / Dunkelheit', 'color' => '#4B356B', 'default_duration' => 45, 'category' => 'student', 'requirement_key' => 'night', 'is_billable' => 1, 'is_counted' => 1, 'is_rest_relevant' => 1, 'fixed_duration' => 0, 'sort_order' => 5],
|
||||
['name' => 'Pruefungsfahrt', 'color' => '#A83333', 'default_duration' => 70, 'category' => 'student', 'requirement_key' => null, 'is_billable' => 1, 'is_counted' => 0, 'is_rest_relevant' => 1, 'fixed_duration' => 1, 'sort_order' => 6],
|
||||
['name' => 'Theorieunterricht', 'color' => '#2B8793', 'default_duration' => 90, 'category' => 'other', 'requirement_key' => null, 'is_billable' => 0, 'is_counted' => 0, 'is_rest_relevant' => 0, 'fixed_duration' => 1, 'sort_order' => 7],
|
||||
['name' => 'Sonstige Arbeiten', 'color' => '#70747C', 'default_duration' => 60, 'category' => 'other', 'requirement_key' => null, 'is_billable' => 0, 'is_counted' => 0, 'is_rest_relevant' => 0, 'fixed_duration' => 1, 'sort_order' => 8],
|
||||
['name' => 'Privater Termin', 'color' => '#44444A', 'default_duration' => 60, 'category' => 'private', 'requirement_key' => null, 'is_billable' => 0, 'is_counted' => 0, 'is_rest_relevant' => 0, 'fixed_duration' => 1, 'sort_order' => 9],
|
||||
['name' => 'Sperrzeit / Blockierung', 'color' => '#111111', 'default_duration' => 60, 'category' => 'blocked', 'requirement_key' => null, 'is_billable' => 0, 'is_counted' => 0, 'is_rest_relevant' => 0, 'fixed_duration' => 1, 'sort_order' => 10],
|
||||
];
|
||||
|
||||
foreach ($lessonTypes as $lessonType) {
|
||||
$referenceRepository->createLessonType($tenant['id'], $lessonType);
|
||||
}
|
||||
|
||||
$templates = [
|
||||
[
|
||||
'code' => 'C',
|
||||
'name' => 'Klasse C',
|
||||
'is_combination' => false,
|
||||
'requirements' => [
|
||||
['requirement_key' => 'practice', 'label' => 'Uebungsstunden', 'required_units' => 8],
|
||||
['requirement_key' => 'rural', 'label' => 'Ueberland', 'required_units' => 5],
|
||||
['requirement_key' => 'highway', 'label' => 'Autobahn', 'required_units' => 4],
|
||||
['requirement_key' => 'night', 'label' => 'Nachtfahrt', 'required_units' => 3],
|
||||
],
|
||||
],
|
||||
[
|
||||
'code' => 'CE',
|
||||
'name' => 'Klasse CE',
|
||||
'is_combination' => false,
|
||||
'requirements' => [
|
||||
['requirement_key' => 'practice', 'label' => 'Uebungsstunden', 'required_units' => 10],
|
||||
['requirement_key' => 'rural', 'label' => 'Ueberland', 'required_units' => 5],
|
||||
['requirement_key' => 'highway', 'label' => 'Autobahn', 'required_units' => 5],
|
||||
['requirement_key' => 'night', 'label' => 'Nachtfahrt', 'required_units' => 3],
|
||||
],
|
||||
],
|
||||
[
|
||||
'code' => 'C+CE',
|
||||
'name' => 'Klasse C + CE',
|
||||
'is_combination' => true,
|
||||
'requirements' => [
|
||||
['requirement_key' => 'C::practice', 'phase_label' => 'C', 'label' => 'Uebungsstunden', 'required_units' => 8],
|
||||
['requirement_key' => 'C::rural', 'phase_label' => 'C', 'label' => 'Ueberland', 'required_units' => 5],
|
||||
['requirement_key' => 'C::highway', 'phase_label' => 'C', 'label' => 'Autobahn', 'required_units' => 4],
|
||||
['requirement_key' => 'C::night', 'phase_label' => 'C', 'label' => 'Nachtfahrt', 'required_units' => 3],
|
||||
['requirement_key' => 'CE::practice', 'phase_label' => 'CE', 'label' => 'Uebungsstunden', 'required_units' => 6],
|
||||
['requirement_key' => 'CE::rural', 'phase_label' => 'CE', 'label' => 'Ueberland', 'required_units' => 3],
|
||||
['requirement_key' => 'CE::highway', 'phase_label' => 'CE', 'label' => 'Autobahn', 'required_units' => 3],
|
||||
['requirement_key' => 'CE::night', 'phase_label' => 'CE', 'label' => 'Nachtfahrt', 'required_units' => 2],
|
||||
],
|
||||
],
|
||||
[
|
||||
'code' => 'C1',
|
||||
'name' => 'Klasse C1',
|
||||
'is_combination' => false,
|
||||
'requirements' => [
|
||||
['requirement_key' => 'practice', 'label' => 'Uebungsstunden', 'required_units' => 6],
|
||||
['requirement_key' => 'rural', 'label' => 'Ueberland', 'required_units' => 4],
|
||||
['requirement_key' => 'highway', 'label' => 'Autobahn', 'required_units' => 3],
|
||||
['requirement_key' => 'night', 'label' => 'Nachtfahrt', 'required_units' => 2],
|
||||
],
|
||||
],
|
||||
[
|
||||
'code' => 'C1E',
|
||||
'name' => 'Klasse C1E',
|
||||
'is_combination' => false,
|
||||
'requirements' => [
|
||||
['requirement_key' => 'practice', 'label' => 'Uebungsstunden', 'required_units' => 7],
|
||||
['requirement_key' => 'rural', 'label' => 'Ueberland', 'required_units' => 4],
|
||||
['requirement_key' => 'highway', 'label' => 'Autobahn', 'required_units' => 3],
|
||||
['requirement_key' => 'night', 'label' => 'Nachtfahrt', 'required_units' => 2],
|
||||
],
|
||||
],
|
||||
[
|
||||
'code' => 'C1+C1E',
|
||||
'name' => 'Klasse C1 + C1E',
|
||||
'is_combination' => true,
|
||||
'requirements' => [
|
||||
['requirement_key' => 'C1::practice', 'phase_label' => 'C1', 'label' => 'Uebungsstunden', 'required_units' => 6],
|
||||
['requirement_key' => 'C1::rural', 'phase_label' => 'C1', 'label' => 'Ueberland', 'required_units' => 4],
|
||||
['requirement_key' => 'C1::highway', 'phase_label' => 'C1', 'label' => 'Autobahn', 'required_units' => 3],
|
||||
['requirement_key' => 'C1::night', 'phase_label' => 'C1', 'label' => 'Nachtfahrt', 'required_units' => 2],
|
||||
['requirement_key' => 'C1E::practice', 'phase_label' => 'C1E', 'label' => 'Uebungsstunden', 'required_units' => 4],
|
||||
['requirement_key' => 'C1E::rural', 'phase_label' => 'C1E', 'label' => 'Ueberland', 'required_units' => 2],
|
||||
['requirement_key' => 'C1E::highway', 'phase_label' => 'C1E', 'label' => 'Autobahn', 'required_units' => 2],
|
||||
['requirement_key' => 'C1E::night', 'phase_label' => 'C1E', 'label' => 'Nachtfahrt', 'required_units' => 1],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$templateIds = [];
|
||||
foreach ($templates as $template) {
|
||||
$record = $referenceRepository->createTemplate($tenant['id'], $template);
|
||||
$templateIds[$record['code']] = $record['id'];
|
||||
}
|
||||
|
||||
$studentRepository->create($tenant['id'], [
|
||||
'template_id' => $templateIds['C+CE'],
|
||||
'first_name' => 'Mila',
|
||||
'last_name' => 'Hansen',
|
||||
'phone' => '0170 1234567',
|
||||
'notes' => 'Startet naechste Woche mit Nachtfahrten.',
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$studentRepository->create($tenant['id'], [
|
||||
'template_id' => $templateIds['C1'],
|
||||
'first_name' => 'Jonas',
|
||||
'last_name' => 'Winter',
|
||||
'phone' => '0172 999000',
|
||||
'notes' => 'Braucht fruehe Termine.',
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
fwrite(STDOUT, "Seed complete." . PHP_EOL);
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'app_name' => 'DriveTime Planner',
|
||||
'env' => 'local',
|
||||
'debug' => true,
|
||||
'db_path' => __DIR__ . '/../storage/database/app.sqlite',
|
||||
'session_path' => __DIR__ . '/../storage/sessions',
|
||||
'session_name' => 'drive_time_session',
|
||||
'frontend_entry' => __DIR__ . '/../public/assets/index.js',
|
||||
];
|
||||
@@ -0,0 +1,145 @@
|
||||
CREATE TABLE IF NOT EXISTS tenants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
timezone TEXT NOT NULL DEFAULT 'Europe/Berlin',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tenant_id INTEGER NULL,
|
||||
role TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT NOT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS instructors (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tenant_id INTEGER NOT NULL,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT NOT NULL,
|
||||
color TEXT NOT NULL,
|
||||
notes TEXT DEFAULT '',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lesson_types (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tenant_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT NOT NULL,
|
||||
default_duration INTEGER NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
requirement_key TEXT DEFAULT NULL,
|
||||
is_billable INTEGER NOT NULL DEFAULT 0,
|
||||
is_counted INTEGER NOT NULL DEFAULT 0,
|
||||
is_rest_relevant INTEGER NOT NULL DEFAULT 0,
|
||||
fixed_duration INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS license_class_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tenant_id INTEGER NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
is_combination INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE (tenant_id, code),
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS license_template_requirements (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
template_id INTEGER NOT NULL,
|
||||
requirement_key TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
required_units INTEGER NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (template_id) REFERENCES license_class_templates (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS students (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tenant_id INTEGER NOT NULL,
|
||||
template_id INTEGER NOT NULL,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT NOT NULL,
|
||||
phone TEXT DEFAULT '',
|
||||
notes TEXT DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (template_id) REFERENCES license_class_templates (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS student_requirement_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
student_id INTEGER NOT NULL,
|
||||
requirement_key TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
required_units INTEGER NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (student_id) REFERENCES students (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS appointments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tenant_id INTEGER NOT NULL,
|
||||
student_id INTEGER DEFAULT NULL,
|
||||
instructor_id INTEGER NOT NULL,
|
||||
lesson_type_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
start_at TEXT NOT NULL,
|
||||
end_at TEXT NOT NULL,
|
||||
units INTEGER NOT NULL DEFAULT 1,
|
||||
status TEXT NOT NULL DEFAULT 'planned',
|
||||
notes TEXT DEFAULT '',
|
||||
warning_acknowledged INTEGER NOT NULL DEFAULT 0,
|
||||
created_by INTEGER NOT NULL,
|
||||
updated_by INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (student_id) REFERENCES students (id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (instructor_id) REFERENCES instructors (id),
|
||||
FOREIGN KEY (lesson_type_id) REFERENCES lesson_types (id),
|
||||
FOREIGN KEY (created_by) REFERENCES users (id),
|
||||
FOREIGN KEY (updated_by) REFERENCES users (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS appointment_units (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
appointment_id INTEGER NOT NULL,
|
||||
requirement_key TEXT DEFAULT NULL,
|
||||
label TEXT DEFAULT NULL,
|
||||
units_counted INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (appointment_id) REFERENCES appointments (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tenant_id INTEGER NOT NULL,
|
||||
key_name TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
UNIQUE (tenant_id, key_name),
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE instructors ADD COLUMN user_id INTEGER DEFAULT NULL;
|
||||
@@ -0,0 +1,11 @@
|
||||
UPDATE instructors
|
||||
SET user_id = (
|
||||
SELECT u.id
|
||||
FROM users u
|
||||
WHERE u.tenant_id = instructors.tenant_id
|
||||
AND u.role = 'instructor'
|
||||
AND u.first_name = instructors.first_name
|
||||
AND u.last_name = instructors.last_name
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE user_id IS NULL;
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS lesson_type_template_visibility (
|
||||
lesson_type_id INTEGER NOT NULL,
|
||||
template_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (lesson_type_id, template_id),
|
||||
FOREIGN KEY (lesson_type_id) REFERENCES lesson_types (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (template_id) REFERENCES license_class_templates (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO lesson_type_template_visibility (lesson_type_id, template_id)
|
||||
SELECT lt.id, tpl.id
|
||||
FROM lesson_types lt
|
||||
JOIN license_class_templates tpl ON tpl.tenant_id = lt.tenant_id
|
||||
WHERE lt.category = 'student';
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE tenants ADD COLUMN location_label TEXT DEFAULT '';
|
||||
ALTER TABLE tenants ADD COLUMN latitude REAL DEFAULT NULL;
|
||||
ALTER TABLE tenants ADD COLUMN longitude REAL DEFAULT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE tenants ADD COLUMN federal_state TEXT DEFAULT 'BE';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE student_requirement_snapshots ADD COLUMN prior_completed_units INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE lesson_types ADD COLUMN system_key TEXT DEFAULT NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_lesson_types_tenant_system_key ON lesson_types (tenant_id, system_key) WHERE system_key IS NOT NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE license_template_requirements ADD COLUMN phase_label TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE student_requirement_snapshots ADD COLUMN phase_label TEXT NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE students ADD COLUMN email TEXT NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE students ADD COLUMN email TEXT DEFAULT '';
|
||||
ALTER TABLE students ADD COLUMN needs_glasses INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE students ADD COLUMN needs_glasses INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,183 @@
|
||||
DROP TABLE IF EXISTS student_requirement_snapshots_old;
|
||||
CREATE TEMP TABLE student_requirement_snapshots_old AS
|
||||
SELECT *
|
||||
FROM student_requirement_snapshots
|
||||
WHERE student_id IN (
|
||||
SELECT s.id
|
||||
FROM students s
|
||||
JOIN license_class_templates t ON t.id = s.template_id
|
||||
WHERE t.code IN ('C+CE', 'C1+C1E')
|
||||
);
|
||||
|
||||
DELETE FROM license_template_requirements
|
||||
WHERE template_id IN (
|
||||
SELECT id FROM license_class_templates WHERE code IN ('C+CE', 'C1+C1E')
|
||||
);
|
||||
|
||||
INSERT INTO license_template_requirements (template_id, requirement_key, label, required_units, sort_order, phase_label)
|
||||
SELECT id, 'C::practice', 'Uebungsstunden', 8, 0, 'C'
|
||||
FROM license_class_templates
|
||||
WHERE code = 'C+CE';
|
||||
|
||||
INSERT INTO license_template_requirements (template_id, requirement_key, label, required_units, sort_order, phase_label)
|
||||
SELECT id, 'C::rural', 'Ueberland', 5, 1, 'C'
|
||||
FROM license_class_templates
|
||||
WHERE code = 'C+CE';
|
||||
|
||||
INSERT INTO license_template_requirements (template_id, requirement_key, label, required_units, sort_order, phase_label)
|
||||
SELECT id, 'C::highway', 'Autobahn', 4, 2, 'C'
|
||||
FROM license_class_templates
|
||||
WHERE code = 'C+CE';
|
||||
|
||||
INSERT INTO license_template_requirements (template_id, requirement_key, label, required_units, sort_order, phase_label)
|
||||
SELECT id, 'C::night', 'Nachtfahrt', 3, 3, 'C'
|
||||
FROM license_class_templates
|
||||
WHERE code = 'C+CE';
|
||||
|
||||
INSERT INTO license_template_requirements (template_id, requirement_key, label, required_units, sort_order, phase_label)
|
||||
SELECT id, 'CE::practice', 'Uebungsstunden', 6, 100, 'CE'
|
||||
FROM license_class_templates
|
||||
WHERE code = 'C+CE';
|
||||
|
||||
INSERT INTO license_template_requirements (template_id, requirement_key, label, required_units, sort_order, phase_label)
|
||||
SELECT id, 'CE::rural', 'Ueberland', 3, 101, 'CE'
|
||||
FROM license_class_templates
|
||||
WHERE code = 'C+CE';
|
||||
|
||||
INSERT INTO license_template_requirements (template_id, requirement_key, label, required_units, sort_order, phase_label)
|
||||
SELECT id, 'CE::highway', 'Autobahn', 3, 102, 'CE'
|
||||
FROM license_class_templates
|
||||
WHERE code = 'C+CE';
|
||||
|
||||
INSERT INTO license_template_requirements (template_id, requirement_key, label, required_units, sort_order, phase_label)
|
||||
SELECT id, 'CE::night', 'Nachtfahrt', 2, 103, 'CE'
|
||||
FROM license_class_templates
|
||||
WHERE code = 'C+CE';
|
||||
|
||||
INSERT INTO license_template_requirements (template_id, requirement_key, label, required_units, sort_order, phase_label)
|
||||
SELECT id, 'C1::practice', 'Uebungsstunden', 6, 0, 'C1'
|
||||
FROM license_class_templates
|
||||
WHERE code = 'C1+C1E';
|
||||
|
||||
INSERT INTO license_template_requirements (template_id, requirement_key, label, required_units, sort_order, phase_label)
|
||||
SELECT id, 'C1::rural', 'Ueberland', 4, 1, 'C1'
|
||||
FROM license_class_templates
|
||||
WHERE code = 'C1+C1E';
|
||||
|
||||
INSERT INTO license_template_requirements (template_id, requirement_key, label, required_units, sort_order, phase_label)
|
||||
SELECT id, 'C1::highway', 'Autobahn', 3, 2, 'C1'
|
||||
FROM license_class_templates
|
||||
WHERE code = 'C1+C1E';
|
||||
|
||||
INSERT INTO license_template_requirements (template_id, requirement_key, label, required_units, sort_order, phase_label)
|
||||
SELECT id, 'C1::night', 'Nachtfahrt', 2, 3, 'C1'
|
||||
FROM license_class_templates
|
||||
WHERE code = 'C1+C1E';
|
||||
|
||||
INSERT INTO license_template_requirements (template_id, requirement_key, label, required_units, sort_order, phase_label)
|
||||
SELECT id, 'C1E::practice', 'Uebungsstunden', 4, 100, 'C1E'
|
||||
FROM license_class_templates
|
||||
WHERE code = 'C1+C1E';
|
||||
|
||||
INSERT INTO license_template_requirements (template_id, requirement_key, label, required_units, sort_order, phase_label)
|
||||
SELECT id, 'C1E::rural', 'Ueberland', 2, 101, 'C1E'
|
||||
FROM license_class_templates
|
||||
WHERE code = 'C1+C1E';
|
||||
|
||||
INSERT INTO license_template_requirements (template_id, requirement_key, label, required_units, sort_order, phase_label)
|
||||
SELECT id, 'C1E::highway', 'Autobahn', 2, 102, 'C1E'
|
||||
FROM license_class_templates
|
||||
WHERE code = 'C1+C1E';
|
||||
|
||||
INSERT INTO license_template_requirements (template_id, requirement_key, label, required_units, sort_order, phase_label)
|
||||
SELECT id, 'C1E::night', 'Nachtfahrt', 1, 103, 'C1E'
|
||||
FROM license_class_templates
|
||||
WHERE code = 'C1+C1E';
|
||||
|
||||
UPDATE appointment_units
|
||||
SET requirement_key = 'C::' || requirement_key
|
||||
WHERE requirement_key IN ('practice', 'rural', 'highway', 'night')
|
||||
AND appointment_id IN (
|
||||
SELECT a.id
|
||||
FROM appointments a
|
||||
JOIN students s ON s.id = a.student_id
|
||||
JOIN license_class_templates t ON t.id = s.template_id
|
||||
WHERE t.code = 'C+CE'
|
||||
);
|
||||
|
||||
UPDATE appointment_units
|
||||
SET requirement_key = 'C1::' || requirement_key
|
||||
WHERE requirement_key IN ('practice', 'rural', 'highway', 'night')
|
||||
AND appointment_id IN (
|
||||
SELECT a.id
|
||||
FROM appointments a
|
||||
JOIN students s ON s.id = a.student_id
|
||||
JOIN license_class_templates t ON t.id = s.template_id
|
||||
WHERE t.code = 'C1+C1E'
|
||||
);
|
||||
|
||||
DELETE FROM student_requirement_snapshots
|
||||
WHERE student_id IN (
|
||||
SELECT s.id
|
||||
FROM students s
|
||||
JOIN license_class_templates t ON t.id = s.template_id
|
||||
WHERE t.code IN ('C+CE', 'C1+C1E')
|
||||
);
|
||||
|
||||
INSERT INTO student_requirement_snapshots (student_id, requirement_key, label, required_units, sort_order, prior_completed_units, phase_label)
|
||||
SELECT
|
||||
s.id,
|
||||
r.requirement_key,
|
||||
r.label,
|
||||
r.required_units,
|
||||
r.sort_order,
|
||||
CASE
|
||||
WHEN r.requirement_key = 'C::practice' THEN COALESCE((
|
||||
SELECT prior_completed_units
|
||||
FROM student_requirement_snapshots_old old
|
||||
WHERE old.student_id = s.id AND old.requirement_key = 'practice'
|
||||
), 0)
|
||||
WHEN r.requirement_key = 'C::rural' THEN COALESCE((
|
||||
SELECT prior_completed_units
|
||||
FROM student_requirement_snapshots_old old
|
||||
WHERE old.student_id = s.id AND old.requirement_key = 'rural'
|
||||
), 0)
|
||||
WHEN r.requirement_key = 'C::highway' THEN COALESCE((
|
||||
SELECT prior_completed_units
|
||||
FROM student_requirement_snapshots_old old
|
||||
WHERE old.student_id = s.id AND old.requirement_key = 'highway'
|
||||
), 0)
|
||||
WHEN r.requirement_key = 'C::night' THEN COALESCE((
|
||||
SELECT prior_completed_units
|
||||
FROM student_requirement_snapshots_old old
|
||||
WHERE old.student_id = s.id AND old.requirement_key = 'night'
|
||||
), 0)
|
||||
WHEN r.requirement_key = 'C1::practice' THEN COALESCE((
|
||||
SELECT prior_completed_units
|
||||
FROM student_requirement_snapshots_old old
|
||||
WHERE old.student_id = s.id AND old.requirement_key = 'practice'
|
||||
), 0)
|
||||
WHEN r.requirement_key = 'C1::rural' THEN COALESCE((
|
||||
SELECT prior_completed_units
|
||||
FROM student_requirement_snapshots_old old
|
||||
WHERE old.student_id = s.id AND old.requirement_key = 'rural'
|
||||
), 0)
|
||||
WHEN r.requirement_key = 'C1::highway' THEN COALESCE((
|
||||
SELECT prior_completed_units
|
||||
FROM student_requirement_snapshots_old old
|
||||
WHERE old.student_id = s.id AND old.requirement_key = 'highway'
|
||||
), 0)
|
||||
WHEN r.requirement_key = 'C1::night' THEN COALESCE((
|
||||
SELECT prior_completed_units
|
||||
FROM student_requirement_snapshots_old old
|
||||
WHERE old.student_id = s.id AND old.requirement_key = 'night'
|
||||
), 0)
|
||||
ELSE 0
|
||||
END,
|
||||
r.phase_label
|
||||
FROM students s
|
||||
JOIN license_class_templates t ON t.id = s.template_id
|
||||
JOIN license_template_requirements r ON r.template_id = t.id
|
||||
WHERE t.code IN ('C+CE', 'C1+C1E');
|
||||
|
||||
DROP TABLE IF EXISTS student_requirement_snapshots_old;
|
||||
@@ -0,0 +1,42 @@
|
||||
UPDATE appointment_units
|
||||
SET requirement_key = COALESCE(
|
||||
(
|
||||
SELECT r.requirement_key
|
||||
FROM appointments a
|
||||
JOIN students s ON s.id = a.student_id
|
||||
JOIN license_class_templates t ON t.id = s.template_id
|
||||
JOIN license_template_requirements r ON r.template_id = t.id
|
||||
WHERE a.id = appointment_units.appointment_id
|
||||
AND t.is_combination = 1
|
||||
AND COALESCE(r.phase_label, '') <> ''
|
||||
AND substr(r.requirement_key, instr(r.requirement_key, '::') + 2) = appointment_units.requirement_key
|
||||
ORDER BY r.sort_order
|
||||
LIMIT 1
|
||||
),
|
||||
requirement_key
|
||||
)
|
||||
WHERE requirement_key NOT LIKE '%::%'
|
||||
AND appointment_id IN (
|
||||
SELECT a.id
|
||||
FROM appointments a
|
||||
JOIN students s ON s.id = a.student_id
|
||||
JOIN license_class_templates t ON t.id = s.template_id
|
||||
WHERE t.is_combination = 1
|
||||
);
|
||||
|
||||
DELETE FROM student_requirement_snapshots
|
||||
WHERE requirement_key NOT LIKE '%::%'
|
||||
AND student_id IN (
|
||||
SELECT s.id
|
||||
FROM students s
|
||||
JOIN license_class_templates t ON t.id = s.template_id
|
||||
WHERE t.is_combination = 1
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM students s
|
||||
JOIN license_template_requirements r ON r.template_id = s.template_id
|
||||
WHERE s.id = student_requirement_snapshots.student_id
|
||||
AND COALESCE(r.phase_label, '') <> ''
|
||||
AND substr(r.requirement_key, instr(r.requirement_key, '::') + 2) = student_requirement_snapshots.requirement_key
|
||||
);
|
||||
@@ -0,0 +1,170 @@
|
||||
UPDATE appointment_units
|
||||
SET requirement_key = COALESCE((
|
||||
SELECT 'lesson_type_' || lt.id
|
||||
FROM appointments a
|
||||
JOIN students s ON s.id = a.student_id
|
||||
JOIN lesson_type_template_visibility vis ON vis.template_id = s.template_id
|
||||
JOIN lesson_types lt ON lt.id = vis.lesson_type_id
|
||||
WHERE a.id = appointment_units.appointment_id
|
||||
AND lt.category = 'student'
|
||||
AND lt.is_counted = 1
|
||||
AND (
|
||||
(appointment_units.requirement_key = 'intro' AND lt.name = 'Einweisung')
|
||||
OR (appointment_units.requirement_key = 'practice' AND lt.name IN ('Uebungsstunde', 'Übungsstunde', 'Uebungsstunden', 'Übungsstunden'))
|
||||
OR (appointment_units.requirement_key = 'rural' AND lt.name IN ('Ueberlandfahrt', 'Überlandfahrt', 'Bundes- / Landstrasse'))
|
||||
OR (appointment_units.requirement_key = 'highway' AND lt.name IN ('Autobahn', 'Autobahnfahrt'))
|
||||
OR (appointment_units.requirement_key = 'night' AND lt.name IN ('Nachtfahrt', 'Nachtfahrten', 'Daemmerung / Dunkelheit'))
|
||||
)
|
||||
ORDER BY lt.sort_order, lt.id
|
||||
LIMIT 1
|
||||
), requirement_key)
|
||||
WHERE requirement_key IN ('intro', 'practice', 'rural', 'highway', 'night');
|
||||
|
||||
UPDATE student_requirement_snapshots AS target
|
||||
SET prior_completed_units = prior_completed_units + COALESCE((
|
||||
SELECT SUM(src.prior_completed_units)
|
||||
FROM student_requirement_snapshots src
|
||||
WHERE src.student_id = target.student_id
|
||||
AND src.requirement_key = 'intro'
|
||||
), 0)
|
||||
WHERE target.requirement_key = COALESCE((
|
||||
SELECT 'lesson_type_' || lt.id
|
||||
FROM students s
|
||||
JOIN lesson_type_template_visibility vis ON vis.template_id = s.template_id
|
||||
JOIN lesson_types lt ON lt.id = vis.lesson_type_id
|
||||
WHERE s.id = target.student_id
|
||||
AND lt.category = 'student'
|
||||
AND lt.is_counted = 1
|
||||
AND lt.name = 'Einweisung'
|
||||
ORDER BY lt.sort_order, lt.id
|
||||
LIMIT 1
|
||||
), target.requirement_key)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM student_requirement_snapshots src
|
||||
WHERE src.student_id = target.student_id
|
||||
AND src.requirement_key = 'intro'
|
||||
);
|
||||
|
||||
UPDATE student_requirement_snapshots AS target
|
||||
SET prior_completed_units = prior_completed_units + COALESCE((
|
||||
SELECT SUM(src.prior_completed_units)
|
||||
FROM student_requirement_snapshots src
|
||||
WHERE src.student_id = target.student_id
|
||||
AND src.requirement_key = 'practice'
|
||||
), 0)
|
||||
WHERE target.requirement_key = COALESCE((
|
||||
SELECT 'lesson_type_' || lt.id
|
||||
FROM students s
|
||||
JOIN lesson_type_template_visibility vis ON vis.template_id = s.template_id
|
||||
JOIN lesson_types lt ON lt.id = vis.lesson_type_id
|
||||
WHERE s.id = target.student_id
|
||||
AND lt.category = 'student'
|
||||
AND lt.is_counted = 1
|
||||
AND lt.name IN ('Uebungsstunde', 'Übungsstunde', 'Uebungsstunden', 'Übungsstunden')
|
||||
ORDER BY lt.sort_order, lt.id
|
||||
LIMIT 1
|
||||
), target.requirement_key)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM student_requirement_snapshots src
|
||||
WHERE src.student_id = target.student_id
|
||||
AND src.requirement_key = 'practice'
|
||||
);
|
||||
|
||||
UPDATE student_requirement_snapshots AS target
|
||||
SET prior_completed_units = prior_completed_units + COALESCE((
|
||||
SELECT SUM(src.prior_completed_units)
|
||||
FROM student_requirement_snapshots src
|
||||
WHERE src.student_id = target.student_id
|
||||
AND src.requirement_key = 'rural'
|
||||
), 0)
|
||||
WHERE target.requirement_key = COALESCE((
|
||||
SELECT 'lesson_type_' || lt.id
|
||||
FROM students s
|
||||
JOIN lesson_type_template_visibility vis ON vis.template_id = s.template_id
|
||||
JOIN lesson_types lt ON lt.id = vis.lesson_type_id
|
||||
WHERE s.id = target.student_id
|
||||
AND lt.category = 'student'
|
||||
AND lt.is_counted = 1
|
||||
AND lt.name IN ('Ueberlandfahrt', 'Überlandfahrt', 'Bundes- / Landstrasse')
|
||||
ORDER BY lt.sort_order, lt.id
|
||||
LIMIT 1
|
||||
), target.requirement_key)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM student_requirement_snapshots src
|
||||
WHERE src.student_id = target.student_id
|
||||
AND src.requirement_key = 'rural'
|
||||
);
|
||||
|
||||
UPDATE student_requirement_snapshots AS target
|
||||
SET prior_completed_units = prior_completed_units + COALESCE((
|
||||
SELECT SUM(src.prior_completed_units)
|
||||
FROM student_requirement_snapshots src
|
||||
WHERE src.student_id = target.student_id
|
||||
AND src.requirement_key = 'highway'
|
||||
), 0)
|
||||
WHERE target.requirement_key = COALESCE((
|
||||
SELECT 'lesson_type_' || lt.id
|
||||
FROM students s
|
||||
JOIN lesson_type_template_visibility vis ON vis.template_id = s.template_id
|
||||
JOIN lesson_types lt ON lt.id = vis.lesson_type_id
|
||||
WHERE s.id = target.student_id
|
||||
AND lt.category = 'student'
|
||||
AND lt.is_counted = 1
|
||||
AND lt.name IN ('Autobahn', 'Autobahnfahrt')
|
||||
ORDER BY lt.sort_order, lt.id
|
||||
LIMIT 1
|
||||
), target.requirement_key)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM student_requirement_snapshots src
|
||||
WHERE src.student_id = target.student_id
|
||||
AND src.requirement_key = 'highway'
|
||||
);
|
||||
|
||||
UPDATE student_requirement_snapshots AS target
|
||||
SET prior_completed_units = prior_completed_units + COALESCE((
|
||||
SELECT SUM(src.prior_completed_units)
|
||||
FROM student_requirement_snapshots src
|
||||
WHERE src.student_id = target.student_id
|
||||
AND src.requirement_key = 'night'
|
||||
), 0)
|
||||
WHERE target.requirement_key = COALESCE((
|
||||
SELECT 'lesson_type_' || lt.id
|
||||
FROM students s
|
||||
JOIN lesson_type_template_visibility vis ON vis.template_id = s.template_id
|
||||
JOIN lesson_types lt ON lt.id = vis.lesson_type_id
|
||||
WHERE s.id = target.student_id
|
||||
AND lt.category = 'student'
|
||||
AND lt.is_counted = 1
|
||||
AND lt.name IN ('Nachtfahrt', 'Nachtfahrten', 'Daemmerung / Dunkelheit')
|
||||
ORDER BY lt.sort_order, lt.id
|
||||
LIMIT 1
|
||||
), target.requirement_key)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM student_requirement_snapshots src
|
||||
WHERE src.student_id = target.student_id
|
||||
AND src.requirement_key = 'night'
|
||||
);
|
||||
|
||||
DELETE FROM student_requirement_snapshots
|
||||
WHERE requirement_key IN ('intro', 'practice', 'rural', 'highway', 'night')
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM students s
|
||||
JOIN lesson_type_template_visibility vis ON vis.template_id = s.template_id
|
||||
JOIN lesson_types lt ON lt.id = vis.lesson_type_id
|
||||
WHERE s.id = student_requirement_snapshots.student_id
|
||||
AND lt.category = 'student'
|
||||
AND lt.is_counted = 1
|
||||
AND (
|
||||
(student_requirement_snapshots.requirement_key = 'intro' AND lt.name = 'Einweisung')
|
||||
OR (student_requirement_snapshots.requirement_key = 'practice' AND lt.name IN ('Uebungsstunde', 'Übungsstunde', 'Uebungsstunden', 'Übungsstunden'))
|
||||
OR (student_requirement_snapshots.requirement_key = 'rural' AND lt.name IN ('Ueberlandfahrt', 'Überlandfahrt', 'Bundes- / Landstrasse'))
|
||||
OR (student_requirement_snapshots.requirement_key = 'highway' AND lt.name IN ('Autobahn', 'Autobahnfahrt'))
|
||||
OR (student_requirement_snapshots.requirement_key = 'night' AND lt.name IN ('Nachtfahrt', 'Nachtfahrten', 'Daemmerung / Dunkelheit'))
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE instructors ADD COLUMN pre_start_buffer_minutes INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,4 @@
|
||||
RewriteEngine On
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^ index.php [QSA,L]
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"src/main.jsx": {
|
||||
"file": "assets/main-F76uNDS1.js",
|
||||
"name": "main",
|
||||
"src": "src/main.jsx",
|
||||
"isEntry": true,
|
||||
"css": [
|
||||
"assets/main-C54NZrM5.css"
|
||||
]
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>fahrschultermin.de – Erfolgreich eingerichtet</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; line-height: 1.6; max-width: 800px; margin: 2rem auto; padding: 0 1rem; }
|
||||
h1 { color: #2c3e50; }
|
||||
.box { background: #f8f9fa; border-left: 4px solid #3498db; padding: 1rem; margin: 1.5rem 0; }
|
||||
code { background: #eee; padding: 0.2rem 0.4rem; border-radius: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>✅ fahrschultermin.de ist betriebsbereit</h1>
|
||||
<p>Diese Seite wurde automatisch vom Webhosting‑Stack erzeugt.</p>
|
||||
<div class="box">
|
||||
<strong>Nächste Schritte:</strong>
|
||||
<ul>
|
||||
<li>Ersetze diesen Inhalt durch deine eigene Website.</li>
|
||||
<li>Lade Dateien in das öffentliche Verzeichnis dieser Domain hoch.</li>
|
||||
<li>Für PHP‑Anwendungen lege eine <code>index.php</code> an.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p><small>Server: core</small></p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$bootstrap = require __DIR__ . '/../app/bootstrap.php';
|
||||
|
||||
use App\Http\Controllers\AppointmentsController;
|
||||
use App\Http\Controllers\AuthController;
|
||||
use App\Http\Controllers\BootstrapController;
|
||||
use App\Http\Controllers\InstructorsController;
|
||||
use App\Http\Controllers\ReferenceDataController;
|
||||
use App\Http\Controllers\StudentsController;
|
||||
use App\Http\Controllers\TenantsController;
|
||||
use App\Http\Controllers\UsersController;
|
||||
|
||||
$router = $bootstrap['router'];
|
||||
$request = new App\Support\Request();
|
||||
$path = $request->path();
|
||||
|
||||
if ($request->method() === 'OPTIONS') {
|
||||
http_response_code(204);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (str_starts_with($path, '/api/v1')) {
|
||||
$auth = new AuthController();
|
||||
$bootstrapController = new BootstrapController();
|
||||
$students = new StudentsController();
|
||||
$references = new ReferenceDataController();
|
||||
$appointments = new AppointmentsController();
|
||||
$users = new UsersController();
|
||||
$tenants = new TenantsController();
|
||||
$instructors = new InstructorsController();
|
||||
|
||||
$router->post('/api/v1/auth/login', [$auth, 'login']);
|
||||
$router->post('/api/v1/auth/logout', [$auth, 'logout']);
|
||||
$router->get('/api/v1/auth/me', [$auth, 'me']);
|
||||
$router->get('/api/v1/bootstrap', $bootstrapController);
|
||||
$router->get('/api/v1/students', [$students, 'index']);
|
||||
$router->post('/api/v1/students', [$students, 'store']);
|
||||
$router->patch('/api/v1/students/{id}/training', [$students, 'updateTraining']);
|
||||
$router->patch('/api/v1/students/{id}', [$students, 'update']);
|
||||
$router->delete('/api/v1/students/{id}', [$students, 'destroy']);
|
||||
$router->get('/api/v1/lesson-types', [$references, 'lessonTypes']);
|
||||
$router->post('/api/v1/lesson-types', [$references, 'storeLessonType']);
|
||||
$router->post('/api/v1/lesson-types/reorder', [$references, 'reorderLessonTypes']);
|
||||
$router->patch('/api/v1/lesson-types/{id}', [$references, 'updateLessonType']);
|
||||
$router->delete('/api/v1/lesson-types/{id}', [$references, 'destroyLessonType']);
|
||||
$router->post('/api/v1/lesson-types/visibility', [$references, 'updateLessonTypeMatrix']);
|
||||
$router->get('/api/v1/license-class-templates', [$references, 'templates']);
|
||||
$router->post('/api/v1/license-class-templates', [$references, 'storeTemplate']);
|
||||
$router->patch('/api/v1/license-class-templates/{id}', [$references, 'updateTemplate']);
|
||||
$router->get('/api/v1/instructors', [$instructors, 'index']);
|
||||
$router->post('/api/v1/instructors', [$instructors, 'store']);
|
||||
$router->patch('/api/v1/instructors/{id}', [$instructors, 'update']);
|
||||
$router->get('/api/v1/users', [$users, 'index']);
|
||||
$router->post('/api/v1/users', [$users, 'store']);
|
||||
$router->patch('/api/v1/users/{id}', [$users, 'update']);
|
||||
$router->get('/api/v1/tenants', [$tenants, 'index']);
|
||||
$router->post('/api/v1/tenants', [$tenants, 'store']);
|
||||
$router->patch('/api/v1/tenants/{id}', [$tenants, 'update']);
|
||||
$router->patch('/api/v1/tenant/profile', [$tenants, 'updateOwn']);
|
||||
$router->get('/api/v1/appointments', [$appointments, 'index']);
|
||||
$router->post('/api/v1/appointments/validate', [$appointments, 'validate']);
|
||||
$router->post('/api/v1/appointments', [$appointments, 'store']);
|
||||
$router->patch('/api/v1/appointments/{id}', [$appointments, 'update']);
|
||||
$router->delete('/api/v1/appointments/{id}', [$appointments, 'destroy']);
|
||||
|
||||
$router->dispatch();
|
||||
exit;
|
||||
}
|
||||
|
||||
$assetManifestPath = __DIR__ . '/assets/.vite/manifest.json';
|
||||
$mainAsset = '/assets/index.js';
|
||||
$styleAsset = '/assets/index.css';
|
||||
|
||||
if (is_file($assetManifestPath)) {
|
||||
$manifest = json_decode(file_get_contents($assetManifestPath), true);
|
||||
$entry = $manifest['src/main.jsx'] ?? null;
|
||||
if (is_array($entry)) {
|
||||
$mainAsset = '/assets/' . ltrim((string) $entry['file'], '/');
|
||||
if (!empty($entry['css'][0])) {
|
||||
$styleAsset = '/assets/' . ltrim((string) $entry['css'][0], '/');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
?><!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DriveTime Planner</title>
|
||||
<link rel="stylesheet" href="<?= htmlspecialchars($styleAsset, ENT_QUOTES) ?>">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="<?= htmlspecialchars($mainAsset, ENT_QUOTES) ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user