Compare commits
10 Commits
33d61c2fc7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d87e4975c | ||
|
|
5f753c15df | ||
|
|
f68400e282 | ||
|
|
af2b179a49 | ||
|
|
09759b7619 | ||
|
|
09324a9513 | ||
|
|
8fccf0e406 | ||
|
|
8ab4e532fd | ||
|
|
48bf9c7088 | ||
|
|
2d6c0ad3aa |
0
.gitignore
vendored
Normal file → Executable file
0
.gitignore
vendored
Normal file → Executable file
84
api/README.md
Executable file
84
api/README.md
Executable 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
29
api/composer.json
Executable 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
|
||||
}
|
||||
}
|
||||
18
api/migrations/001_add_refresh_tokens.sql
Executable file
18
api/migrations/001_add_refresh_tokens.sql
Executable 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
37
api/public/index.php
Executable 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
60
api/src/Config/Container.php
Executable 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();
|
||||
}
|
||||
}
|
||||
172
api/src/Controllers/AppointmentsController.php
Executable file
172
api/src/Controllers/AppointmentsController.php
Executable 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');
|
||||
}
|
||||
}
|
||||
195
api/src/Controllers/AuthController.php
Executable file
195
api/src/Controllers/AuthController.php
Executable 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');
|
||||
}
|
||||
}
|
||||
67
api/src/Controllers/BootstrapController.php
Executable file
67
api/src/Controllers/BootstrapController.php
Executable 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
Executable file
55
api/src/Controllers/InstructorsController.php
Executable 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
Executable file
25
api/src/Controllers/LessonTypesController.php
Executable 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
Executable file
75
api/src/Controllers/StudentsController.php
Executable 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
Executable file
27
api/src/Controllers/TemplatesController.php
Executable 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');
|
||||
}
|
||||
}
|
||||
90
api/src/Middleware/JwtMiddleware.php
Executable file
90
api/src/Middleware/JwtMiddleware.php
Executable 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
84
api/src/Models/Appointment.php
Executable 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
37
api/src/Models/Instructor.php
Executable 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
27
api/src/Models/LessonType.php
Executable 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
Executable file
14
api/src/Models/LessonTypeTemplateVisibility.php
Executable 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
Executable file
31
api/src/Models/LicenseClassTemplate.php
Executable 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
Executable file
17
api/src/Models/LicenseTemplateRequirement.php
Executable 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
52
api/src/Models/RefreshToken.php
Executable 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
28
api/src/Models/Student.php
Executable 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
37
api/src/Models/Tenant.php
Executable 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
29
api/src/Models/User.php
Executable 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
63
api/src/Routes/api.php
Executable 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']);
|
||||
});
|
||||
};
|
||||
238
api/src/Services/InstructorAvailabilityService.php
Executable file
238
api/src/Services/InstructorAvailabilityService.php
Executable 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];
|
||||
}
|
||||
}
|
||||
1
backup/fahrschultermin_20260518_223313/docs/README.txt
Executable file
1
backup/fahrschultermin_20260518_223313/docs/README.txt
Executable 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
www/fahrschultermin.de/app/Support/Auth.php → backup/fahrschultermin_20260518_223313/www/fahrschultermin.de/app/Support/Auth.php
Normal file → Executable file
0
www/fahrschultermin.de/app/Support/Auth.php → backup/fahrschultermin_20260518_223313/www/fahrschultermin.de/app/Support/Auth.php
Normal file → Executable file
0
www/fahrschultermin.de/app/bootstrap.php → backup/fahrschultermin_20260518_223313/www/fahrschultermin.de/app/bootstrap.php
Normal file → Executable file
0
www/fahrschultermin.de/app/bootstrap.php → backup/fahrschultermin_20260518_223313/www/fahrschultermin.de/app/bootstrap.php
Normal file → Executable file
0
www/fahrschultermin.de/bin/migrate.php → backup/fahrschultermin_20260518_223313/www/fahrschultermin.de/bin/migrate.php
Normal file → Executable file
0
www/fahrschultermin.de/bin/migrate.php → backup/fahrschultermin_20260518_223313/www/fahrschultermin.de/bin/migrate.php
Normal file → Executable file
0
www/fahrschultermin.de/bin/seed.php → backup/fahrschultermin_20260518_223313/www/fahrschultermin.de/bin/seed.php
Normal file → Executable file
0
www/fahrschultermin.de/bin/seed.php → backup/fahrschultermin_20260518_223313/www/fahrschultermin.de/bin/seed.php
Normal file → Executable file
0
www/fahrschultermin.de/config/app.php → backup/fahrschultermin_20260518_223313/www/fahrschultermin.de/config/app.php
Normal file → Executable file
0
www/fahrschultermin.de/config/app.php → backup/fahrschultermin_20260518_223313/www/fahrschultermin.de/config/app.php
Normal file → Executable file
@@ -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>
|
||||
100
backup/fahrschultermin_20260518_223313/www/fahrschultermin.de/public/index.php
Executable file
100
backup/fahrschultermin_20260518_223313/www/fahrschultermin.de/public/index.php
Executable 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>
|
||||
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