Compare commits

..

8 Commits

Author SHA1 Message Date
Hermes Agent
5d87e4975c Add HttpOnly cookie auth for cross-domain SPA login
Problem: Frontend JS bundle used credentials:'same-origin' which
dropped cookies on cross-domain requests. Login worked (200) but
the subsequent /auth/me check returned 401, leaving the user stuck
on the login screen.

Fix:
- AuthController now sets a dtp_jwt HttpOnly cookie on login/refresh
- Cookie uses Domain=.fahrschultermin.de (shared between frontend
  and api subdomain), Secure, SameSite=Lax, Max-Age=8h
- JwtMiddleware reads JWT from Authorization header OR cookie
- Added AuthController::me() endpoint (was missing, caused 500)
- Logout endpoint clears the cookie
- Frontend index.php patches fetch() to use credentials:'include'
  for all /api/v1/* calls
2026-06-04 20:33:31 +02:00
Hermes Agent
5f753c15df Add InstructorAvailabilityService with vacation, units limits, break rules
- InstructorAvailabilityService checks:
  1. Vacation (instructor_vacations table)
  2. Regular availability (instructor_availability weekday/time windows)
  3. Daily units limit (max_daily_units + max_daily_override)
  4. Weekly units limit (max_weekly_units + max_weekly_override)
  5. Break rules (between appointments, continuous minutes)

- AppointmentsController now uses availabilityService
- Fixed getQueryParam() -> getQueryParams() compatibility
- Instructor model updated with max_*_units fields
2026-05-20 22:50:26 +02:00
Hermes Agent
f68400e282 feat: add refresh token flow for long-lived sessions
- Add RefreshToken model with rotation
- Add POST /auth/refresh endpoint
- Add POST /auth/logout that revokes refresh token
- Login returns JWT (8h) + refresh_token (30 days)
- Refresh rotates token (old is revoked)
- Fix Carbon\Carbon::now() calls instead of now() helper
- Update API docs
2026-05-20 17:57:18 +02:00
Hermes Agent
af2b179a49 feat: add student conflict detection in appointments
- Add hasStudentConflict() to Appointment model
- Check student conflicts in store() and update()
- Returns 409 Conflict when student has overlapping appointment
- Fixed PostgreSQL sequence for appointments id
2026-05-20 17:45:14 +02:00
Hermes Agent
09759b7619 feat: JWT secret in .env, conflict detection, docs, deploy script
- Move JWT_SECRET from hardcode to .env (security)
- Add hasInstructorConflict() to Appointment model
- Add conflict check in store() and update() — returns 409
- Add .env.example with placeholder
- Add API documentation (README.md)
- Add deploy_api.sh script
2026-05-20 17:30:24 +02:00
Hermes Agent
09324a9513 Clean: remove old custom PHP API from www/fahrschultermin.de 2026-05-20 16:36:18 +02:00
Hermes Agent
8fccf0e406 Backup: old custom PHP API code removed from fahrschultermin.de 2026-05-20 16:36:12 +02:00
Hermes Agent
8ab4e532fd Slim API deployment - composer install + full CRUD endpoints 2026-05-20 14:36:05 +02:00
1040 changed files with 8771 additions and 14 deletions

0
.gitignore vendored Normal file → Executable file
View File

84
api/README.md Executable file
View File

@@ -0,0 +1,84 @@
# DriveTime Planner API
## Base URL
`https://api.fahrschultermin.de/api/v1`
## Authentication
JWT Bearer Token. Login returns a token valid for 8 hours.
```
Authorization: Bearer <token>
```
## Endpoints
### Auth
| Method | Path | Description |
|--------|------|-------------|
| POST | `/auth/login` | Login with email/password, returns JWT + refresh_token |
| POST | `/auth/refresh` | Exchange refresh_token for new JWT + refresh_token |
| POST | `/auth/logout` | Revoke refresh token (logout) |
| GET | `/auth/me` | Current user info (requires auth) |
### Bootstrap
| Method | Path | Description |
|--------|------|-------------|
| GET | `/bootstrap` | Full frontend data: user, tenant, students, instructors, lessonTypes, templates (requires auth) |
### CRUD
| Method | Path | Description |
|--------|------|-------------|
| GET | `/students` | List students (requires auth) |
| POST | `/students` | Create student (requires auth) |
| PATCH | `/students/{id}` | Update student (requires auth) |
| DELETE | `/students/{id}` | Delete student (requires auth) |
| GET | `/instructors` | List instructors (requires auth) |
| POST | `/instructors` | Create instructor (requires auth) |
| PATCH | `/instructors/{id}` | Update instructor (requires auth) |
| GET | `/appointments` | List appointments, optionally filter by `from`/`to` query params (requires auth) |
| POST | `/appointments` | Create appointment with instructor conflict detection (requires auth) |
| PATCH | `/appointments/{id}` | Update appointment with conflict check (requires auth) |
| DELETE | `/appointments/{id}` | Delete appointment (requires auth) |
| GET | `/lesson-types` | List lesson types (requires auth) |
| GET | `/license-class-templates` | List license class templates with requirements (requires auth) |
## Error Responses
| Code | Meaning |
|------|---------|
| 400 | Bad Request — missing or invalid parameters |
| 401 | Unauthorized — missing or invalid JWT |
| 404 | Not Found — resource doesn't exist or wrong tenant |
| 409 | Conflict — e.g. instructor has overlapping appointment |
| 500 | Internal Server Error |
## Environment Variables
| Variable | Description |
|----------|-------------|
| `JWT_SECRET` | 256-bit secret for JWT signing (required) |
## Example
```bash
# Login
TOKEN=$(curl -s -X POST https://api.fahrschultermin.de/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@nordring.test","password":"Tanja82"}' | jq -r '.token')
# Get bootstrap data
curl https://api.fahrschultermin.de/api/v1/bootstrap \
-H "Authorization: Bearer $TOKEN" | jq .
# Create appointment
curl -X POST https://api.fahrschultermin.de/api/v1/appointments \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"instructor_id":1,"lesson_type_id":1,"start_at":"2026-05-25 10:00","end_at":"2026-05-25 11:00","title":"Test"}'
```
## Tech Stack
- Slim 4 (PSR-7 HTTP factory)
- Eloquent ORM (Illuminate Database)
- Firebase JWT for authentication
- PHP-DI for dependency injection
- CORS middleware (tuupola/cors-middleware)

29
api/composer.json Executable file
View 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
}
}

View File

@@ -0,0 +1,18 @@
-- Migration: Add refresh_tokens table
-- For JWT refresh token flow
CREATE TABLE IF NOT EXISTS refresh_tokens (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(64) NOT NULL UNIQUE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
revoked_at TIMESTAMP WITH TIME ZONE DEFAULT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Index for quick lookups
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_hash ON refresh_tokens(token_hash) WHERE revoked_at IS NULL;
COMMENT ON TABLE refresh_tokens IS 'OAuth2-style refresh tokens for long-lived sessions';

37
api/public/index.php Executable file
View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use App\Config\Container;
use Symfony\Component\Dotenv\Dotenv;
// Load .env file safely
$envFile = __DIR__ . '/../.env';
if (file_exists($envFile)) {
$dotenv = new 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();

60
api/src/Config/Container.php Executable file
View File

@@ -0,0 +1,60 @@
<?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();
$jwtSecret = $_ENV['JWT_SECRET'] ?? null;
if (!$jwtSecret) {
throw new \RuntimeException('JWT_SECRET environment variable not set');
}
$builder->addDefinitions([
'jwt.secret' => $jwtSecret,
'config' => [
'frontend_url' => 'https://fahrschultermin.de',
'api_base_url' => 'https://api.fahrschultermin.de',
'session_name' => 'drive_time_session',
],
]);
return $builder->build();
}
}

View File

@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Models\Appointment;
use App\Services\InstructorAvailabilityService;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
final class AppointmentsController
{
private InstructorAvailabilityService $availabilityService;
public function __construct()
{
$this->availabilityService = new InstructorAvailabilityService();
}
public function index(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$tenantId = $request->getAttribute('tenant_id');
$params = $request->getQueryParams();
$from = $params['from'] ?? null;
$to = $params['to'] ?? null;
$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);
// Conflict check for instructor
if (isset($data['instructor_id'], $data['start_at'], $data['end_at'])) {
if (Appointment::hasInstructorConflict(
(int) $data['instructor_id'],
$data['start_at'],
$data['end_at']
)) {
return $this->json($response, [
'message' => 'Instructor has a conflicting appointment in this time range'
], 409);
}
// Check instructor availability rules
$availCheck = $this->availabilityService->checkAvailability(
(int) $data['instructor_id'],
$data['start_at'],
$data['end_at'],
$data['units'] ?? 1
);
if (!$availCheck['available']) {
return $this->json($response, [
'message' => 'Instructor not available: ' . $availCheck['reason']
], 409);
}
}
// Conflict check for student
if (isset($data['student_id'], $data['start_at'], $data['end_at'])) {
if (Appointment::hasStudentConflict(
$data['student_id'] ? (int) $data['student_id'] : null,
$data['start_at'],
$data['end_at']
)) {
return $this->json($response, [
'message' => 'Student has a conflicting appointment in this time range'
], 409);
}
}
$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);
// Conflict check for instructor (exclude current appointment)
if (isset($data['instructor_id'], $data['start_at'], $data['end_at'])) {
if (Appointment::hasInstructorConflict(
(int) $data['instructor_id'],
$data['start_at'],
$data['end_at'],
(int) $args['id']
)) {
return $this->json($response, [
'message' => 'Instructor has a conflicting appointment in this time range'
], 409);
}
// Check instructor availability rules (exclude current appointment)
$availCheck = $this->availabilityService->checkAvailability(
(int) $data['instructor_id'],
$data['start_at'],
$data['end_at'],
$data['units'] ?? 1,
(int) $args['id']
);
if (!$availCheck['available']) {
return $this->json($response, [
'message' => 'Instructor not available: ' . $availCheck['reason']
], 409);
}
}
// Conflict check for student (exclude current appointment)
if (isset($data['student_id'], $data['start_at'], $data['end_at'])) {
if (Appointment::hasStudentConflict(
$data['student_id'] ? (int) $data['student_id'] : null,
$data['start_at'],
$data['end_at'],
(int) $args['id']
)) {
return $this->json($response, [
'message' => 'Student has a conflicting appointment in this time range'
], 409);
}
}
$data['updated_by'] = $user['id'];
$appointment->update(array_intersect_key($data, array_flip([
'student_id', 'instructor_id', 'lesson_type_id', 'title', 'start_at', 'end_at', 'units', 'status', 'notes', 'warning_acknowledged', 'updated_by'
])));
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');
}
}

View File

@@ -0,0 +1,195 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Models\User;
use App\Models\RefreshToken;
use Firebase\JWT\JWT;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
final class AuthController
{
private const JWT_EXPIRY = 8 * 60 * 60; // 8 hours
private const REFRESH_EXPIRY_DAYS = 30;
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
$jwt = $this->generateJwt($user);
// Generate refresh token
$refreshToken = $this->generateRefreshToken($user->id);
// Set JWT in HttpOnly cookie for cross-domain SPA auth.
// Domain=.fahrschultermin.de makes the cookie available to BOTH
// fahrschultermin.de and api.fahrschultermin.de, so the browser
// sends it automatically with credentials:'include' requests.
$response = $this->setAuthCookie($response, $jwt);
$userData = $user->toArray();
unset($userData['password_hash']);
return $this->json($response, [
'token' => $jwt,
'refresh_token' => $refreshToken,
'expires_in' => self::JWT_EXPIRY,
'user' => $userData,
]);
}
public function refresh(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$body = json_decode($request->getBody()->getContents(), true);
$refreshToken = $body['refresh_token'] ?? '';
if (empty($refreshToken)) {
return $this->json($response, ['message' => 'Refresh token required'], 400);
}
// Find and validate refresh token
$tokenRecord = RefreshToken::findValid($refreshToken);
if (!$tokenRecord) {
return $this->json($response, ['message' => 'Invalid or expired refresh token'], 401);
}
// Load user
$user = User::find($tokenRecord->user_id);
if (!$user || !$user->is_active) {
return $this->json($response, ['message' => 'User not found or disabled'], 401);
}
// Generate new JWT
$jwt = $this->generateJwt($user);
// Optionally rotate refresh token (revoke old, create new)
$tokenRecord->revoke();
$newRefreshToken = $this->generateRefreshToken($user->id);
// Update auth cookie with new JWT
$response = $this->setAuthCookie($response, $jwt);
$userData = $user->toArray();
unset($userData['password_hash']);
return $this->json($response, [
'token' => $jwt,
'refresh_token' => $newRefreshToken,
'expires_in' => self::JWT_EXPIRY,
'user' => $userData,
]);
}
/**
* Return the current authenticated user. Used by the frontend to
* restore a session from the dtp_jwt cookie on page reload.
*/
public function me(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$user = $request->getAttribute('user');
if (!$user) {
return $this->json($response, ['message' => 'Not authenticated'], 401);
}
return $this->json($response, $user);
}
public function logout(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$body = json_decode($request->getBody()->getContents(), true);
$refreshToken = $body['refresh_token'] ?? null;
if ($refreshToken) {
$tokenRecord = RefreshToken::findValid($refreshToken);
if ($tokenRecord) {
$tokenRecord->revoke();
}
}
// Clear auth cookie
$response = $this->clearAuthCookie($response);
return $this->json($response, ['message' => 'Logged out']);
}
private function setAuthCookie(ResponseInterface $response, string $jwt): ResponseInterface
{
// SameSite=Lax works for same-site requests (fahrschultermin.de → api.fahrschultermin.de
// is technically cross-origin but the registrable domain matches, so Lax is enough
// and is more browser-friendly than None). Secure is required for cross-site cookies.
$cookie = sprintf(
'dtp_jwt=%s; Path=/; Domain=.fahrschultermin.de; Max-Age=%d; Secure; HttpOnly; SameSite=Lax',
rawurlencode($jwt),
self::JWT_EXPIRY
);
return $response->withAddedHeader('Set-Cookie', $cookie);
}
private function clearAuthCookie(ResponseInterface $response): ResponseInterface
{
$cookie = 'dtp_jwt=; Path=/; Domain=.fahrschultermin.de; Max-Age=0; Secure; HttpOnly; SameSite=Lax';
return $response->withAddedHeader('Set-Cookie', $cookie);
}
private function generateJwt(User $user): string
{
$now = time();
$payload = [
'iss' => 'api.fahrschultermin.de',
'sub' => $user->id,
'tenant_id' => $user->tenant_id,
'role' => $user->role,
'iat' => $now,
'exp' => $now + self::JWT_EXPIRY,
];
$secret = $_ENV['JWT_SECRET'] ?? null;
if (!$secret) {
throw new \RuntimeException('JWT_SECRET environment variable not set');
}
return JWT::encode($payload, $secret, 'HS256');
}
private function generateRefreshToken(int $userId): string
{
$token = bin2hex(random_bytes(32)); // 64-char hex token
$hash = hash('sha256', $token);
RefreshToken::create([
'user_id' => $userId,
'token_hash' => $hash,
'expires_at' => \Carbon\Carbon::now()->addDays(self::REFRESH_EXPIRY_DAYS),
]);
return $token;
}
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');
}
}

View 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');
}
}

View 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');
}
}

View 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');
}
}

View 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');
}
}

View 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');
}
}

View File

@@ -0,0 +1,90 @@
<?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
{
$token = $this->extractToken($request);
if ($token === null) {
$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));
}
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);
}
/**
* Extract JWT from Authorization header (Bearer) or from `dtp_jwt` cookie.
* Cookie is used for cross-domain SPA auth when the browser auto-sends it.
*/
private function extractToken(ServerRequestInterface $request): ?string
{
// 1. Try Authorization header first
$authHeader = $request->getHeaderLine('Authorization');
if (str_starts_with($authHeader, 'Bearer ')) {
return substr($authHeader, 7);
}
// 2. Fallback: read from cookie
$cookieHeader = $request->getHeaderLine('Cookie');
if (!empty($cookieHeader)) {
$cookies = [];
foreach (explode(';', $cookieHeader) as $pair) {
$parts = explode('=', trim($pair), 2);
if (count($parts) === 2) {
$cookies[$parts[0]] = urldecode($parts[1]);
}
}
if (isset($cookies['dtp_jwt']) && !empty($cookies['dtp_jwt'])) {
return $cookies['dtp_jwt'];
}
}
return null;
}
}

84
api/src/Models/Appointment.php Executable file
View File

@@ -0,0 +1,84 @@
<?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'); }
/**
* Check if instructor has a conflicting appointment in the given time range.
*/
public static function hasInstructorConflict(
int $instructorId,
string $start,
string $end,
?int $excludeId = null
): bool {
$query = self::where('instructor_id', $instructorId)
->where('status', '!=', 'cancelled')
->where(function ($q) use ($start, $end) {
$q->whereBetween('start_at', [$start, $end])
->orWhereBetween('end_at', [$start, $end])
->orWhere(function ($q2) use ($start, $end) {
$q2->where('start_at', '<=', $start)->where('end_at', '>=', $end);
});
});
if ($excludeId !== null) {
$query->where('id', '!=', $excludeId);
}
return $query->exists();
}
/**
* Check if student has a conflicting appointment in the given time range.
*/
public static function hasStudentConflict(
?int $studentId,
string $start,
string $end,
?int $excludeId = null
): bool {
if ($studentId === null) {
return false; // No student assigned, no conflict possible
}
$query = self::where('student_id', $studentId)
->where('status', '!=', 'cancelled')
->where(function ($q) use ($start, $end) {
$q->whereBetween('start_at', [$start, $end])
->orWhereBetween('end_at', [$start, $end])
->orWhere(function ($q2) use ($start, $end) {
$q2->where('start_at', '<=', $start)->where('end_at', '>=', $end);
});
});
if ($excludeId !== null) {
$query->where('id', '!=', $excludeId);
}
return $query->exists();
}
}

37
api/src/Models/Instructor.php Executable file
View File

@@ -0,0 +1,37 @@
<?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',
'max_daily_units', 'max_daily_override',
'max_weekly_units', 'max_weekly_override',
];
protected $casts = [
'is_active' => 'integer',
'tenant_id' => 'integer',
'max_daily_units' => 'integer',
'max_daily_override' => 'integer',
'max_weekly_units' => 'integer',
'max_weekly_override' => '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 Executable file
View 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');
}
}

View 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'];
}

View 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');
}
}

View 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'];
}

52
api/src/Models/RefreshToken.php Executable file
View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
final class RefreshToken extends Model
{
protected $table = 'refresh_tokens';
protected $fillable = [
'user_id', 'token_hash', 'expires_at', 'revoked_at',
];
protected $casts = [
'expires_at' => 'datetime',
'revoked_at' => 'datetime',
];
/**
* Find a valid (non-revoked, non-expired) refresh token.
*/
public static function findValid(string $token): ?self
{
$hash = hash('sha256', $token);
return self::where('token_hash', $hash)
->whereNull('revoked_at')
->where('expires_at', '>', \Carbon\Carbon::now())
->first();
}
/**
* Revoke this refresh token (logout).
*/
public function revoke(): void
{
$this->update(['revoked_at' => \Carbon\Carbon::now()]);
}
/**
* Revoke all refresh tokens for a user.
*/
public static function revokeAllForUser(int $userId): void
{
self::where('user_id', $userId)
->whereNull('revoked_at')
->update(['revoked_at' => \Carbon\Carbon::now()]);
}
}

28
api/src/Models/Student.php Executable file
View 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 Executable file
View 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 Executable file
View 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');
}
}

63
api/src/Routes/api.php Executable file
View File

@@ -0,0 +1,63 @@
<?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/refresh', [AuthController::class, 'refresh']);
$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']);
});
};

View File

@@ -0,0 +1,238 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Instructor;
use App\Models\Appointment;
use Carbon\Carbon;
final class InstructorAvailabilityService
{
private ?Instructor $instructor = null;
/**
* Check if instructor is available for a new appointment.
* Returns array ['available' => bool, 'reason' => string|null]
*/
public function checkAvailability(
int $instructorId,
string $startAt,
string $endAt,
int $units = 1,
?int $excludeAppointmentId = null
): array {
$instructor = Instructor::find($instructorId);
if (!$instructor) {
return ['available' => false, 'reason' => 'Instructor not found'];
}
$this->instructor = $instructor;
if (!$instructor->is_active) {
return ['available' => false, 'reason' => 'Instructor is inactive'];
}
$start = Carbon::parse($startAt);
$end = Carbon::parse($endAt);
$date = $start->toDateString();
// 1. Check vacation
if ($this->isOnVacation($instructorId, $date)) {
return ['available' => false, 'reason' => 'Instructor is on vacation on this day'];
}
// 2. Check regular availability (weekday + time window)
if (!$this->isWithinAvailability($instructorId, $start, $end)) {
return ['available' => false, 'reason' => 'Outside instructor\'s regular availability window'];
}
// 3. Check daily units limit
$dailyCheck = $this->checkDailyUnitsLimit($instructorId, $date, $units, $excludeAppointmentId);
if (!$dailyCheck['allowed']) {
return $dailyCheck;
}
// 4. Check weekly units limit
$weekStart = $start->copy()->startOfWeek()->toDateString();
$weekEnd = $start->copy()->endOfWeek()->toDateString();
$weeklyCheck = $this->checkWeeklyUnitsLimit($instructorId, $weekStart, $weekEnd, $units, $excludeAppointmentId);
if (!$weeklyCheck['allowed']) {
return $weeklyCheck;
}
// 5. Check break rules (between appointments)
$breakCheck = $this->checkBreakRules($instructorId, $start, $end, $excludeAppointmentId);
if (!$breakCheck['allowed']) {
return $breakCheck;
}
return ['available' => true, 'reason' => null];
}
private function isOnVacation(int $instructorId, string $date): bool
{
$result = \DB::select("
SELECT 1 FROM instructor_vacations
WHERE instructor_id = ?
AND ?::date BETWEEN date_from AND date_to
LIMIT 1
", [$instructorId, $date]);
return count($result) > 0;
}
private function isWithinAvailability(int $instructorId, Carbon $start, Carbon $end): bool
{
$weekday = $start->dayOfWeek;
// First check if there's any availability record for this weekday
$avail = \DB::select("
SELECT 1 FROM instructor_availability
WHERE instructor_id = ? AND weekday = ? AND is_active = 1
LIMIT 1
", [$instructorId, $weekday]);
if (count($avail) === 0) {
return false; // No availability for this day
}
// Get all availability windows for this day
$windows = \DB::select("
SELECT time_from, time_to FROM instructor_availability
WHERE instructor_id = ? AND weekday = ? AND is_active = 1
", [$instructorId, $weekday]);
$dateStr = $start->toDateString();
foreach ($windows as $window) {
$winStart = Carbon::parse($dateStr . ' ' . $window->time_from);
$winEnd = Carbon::parse($dateStr . ' ' . $window->time_to);
if ($start->gte($winStart) && $end->lte($winEnd)) {
return true; // Appointment fits within this window
}
}
return false;
}
private function checkDailyUnitsLimit(int $instructorId, string $date, int $units, ?int $excludeId): array
{
$instructor = $this->instructor;
$maxDaily = $instructor->max_daily_units ?? 0;
$maxOverride = $instructor->max_daily_override ?? 0;
$maxUnits = $maxDaily + $maxOverride;
if ($maxUnits <= 0) {
return ['allowed' => true]; // No limit set
}
$excludeClause = $excludeId ? "AND id != {$excludeId}" : "";
$result = \DB::select("
SELECT COALESCE(SUM(units), 0) as total_units
FROM appointments
WHERE instructor_id = ?
AND DATE(start_at) = ?
AND status != 'cancelled'
{$excludeClause}
", [$instructorId, $date]);
$currentUnits = (int) $result[0]->total_units;
if ($currentUnits + $units > $maxUnits) {
return [
'allowed' => false,
'reason' => "Daily units limit exceeded ({$currentUnits}/{$maxUnits} units, trying to add {$units})"
];
}
return ['allowed' => true];
}
private function checkWeeklyUnitsLimit(int $instructorId, string $weekStart, string $weekEnd, int $units, ?int $excludeId): array
{
$instructor = $this->instructor;
$maxWeekly = $instructor->max_weekly_units ?? 0;
$maxOverride = $instructor->max_weekly_override ?? 0;
$maxUnits = $maxWeekly + $maxOverride;
if ($maxUnits <= 0) {
return ['allowed' => true]; // No limit set
}
$excludeClause = $excludeId ? "AND id != {$excludeId}" : "";
$result = \DB::select("
SELECT COALESCE(SUM(units), 0) as total_units
FROM appointments
WHERE instructor_id = ?
AND DATE(start_at) BETWEEN ? AND ?
AND status != 'cancelled'
{$excludeClause}
", [$instructorId, $weekStart, $weekEnd]);
$currentUnits = (int) $result[0]->total_units;
if ($currentUnits + $units > $maxUnits) {
return [
'allowed' => false,
'reason' => "Weekly units limit exceeded ({$currentUnits}/{$maxUnits} units)"
];
}
return ['allowed' => true];
}
private function checkBreakRules(int $instructorId, Carbon $start, Carbon $end, ?int $excludeId): array
{
// Get break rules for this instructor
$rules = \DB::select("
SELECT * FROM instructor_break_rules
WHERE instructor_id = ? AND is_active = 1
", [$instructorId]);
foreach ($rules as $rule) {
switch ($rule->condition_type) {
case 'between_appointments':
// Check if there's an appointment ending within threshold before start
$excludeClause = $excludeId ? "AND id != {$excludeId}" : "";
$threshold = (int) $rule->threshold;
$prevAppt = \DB::select("
SELECT id, end_at FROM appointments
WHERE instructor_id = ?
AND end_at > ? - INTERVAL '{$threshold} minutes'
AND end_at <= ?
AND status != 'cancelled'
{$excludeClause}
ORDER BY end_at DESC
LIMIT 1
", [$instructorId, $start->toDateTimeString(), $start->toDateTimeString()]);
if (count($prevAppt) > 0) {
$gapMinutes = $start->diffInMinutes(Carbon::parse($prevAppt[0]->end_at));
if ($gapMinutes < $threshold) {
return [
'allowed' => false,
'reason' => "Break rule violated: need {$rule->break_minutes}min break after previous appointment"
];
}
}
break;
case 'continuous_minutes':
// Check if this appointment exceeds continuous minutes
$durationMinutes = $end->diffInMinutes($start);
if ($durationMinutes > (int) $rule->threshold) {
return [
'allowed' => false,
'reason' => "Continuous lesson exceeds {$rule->threshold} minutes"
];
}
break;
}
}
return ['allowed' => true];
}
}

View File

@@ -0,0 +1 @@
No docs directory on server - placeholder created

View File

@@ -0,0 +1,2 @@
<?php
echo 'api.fahrschultermin.de - ' . date('Y-m-d H:i:s');

View File

@@ -84,7 +84,7 @@ final class AppointmentRepository extends BaseRepository
'updated_at' => $timestamp, 'updated_at' => $timestamp,
]); ]);
$appointmentId = (int) $this->lastInsertId(); $appointmentId = (int) $this->db->lastInsertId();
$this->replaceUnits($appointmentId, $data['units_breakdown'] ?? []); $this->replaceUnits($appointmentId, $data['units_breakdown'] ?? []);
return $this->find($tenantId, $appointmentId); return $this->find($tenantId, $appointmentId);

View File

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

View File

@@ -45,7 +45,7 @@ final class InstructorRepository extends BaseRepository
'updated_at' => $timestamp, 'updated_at' => $timestamp,
]); ]);
return $this->find($tenantId, (int) $this->lastInsertId()); return $this->find($tenantId, (int) $this->db->lastInsertId());
} }
public function update(int $tenantId, int $id, array $data): ?array public function update(int $tenantId, int $id, array $data): ?array

View File

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

View File

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

View File

@@ -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'],
]);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]

View File

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

View File

@@ -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 WebhostingStack 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 PHPAnwendungen lege eine <code>index.php</code> an.</li>
</ul>
</div>
<p><small>Server: core</small></p>
</body>
</html>

View File

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

Some files were not shown because too many files have changed in this diff Show More