Slim API deployment - composer install + full CRUD endpoints
This commit is contained in:
56
api/src/Config/Container.php
Normal file
56
api/src/Config/Container.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Config;
|
||||
|
||||
use Illuminate\Database\Capsule\Manager as Capsule;
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
final class Container
|
||||
{
|
||||
private static ?Capsule $capsule = null;
|
||||
|
||||
public static function boot(): void
|
||||
{
|
||||
if (self::$capsule !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::$capsule = new Capsule();
|
||||
|
||||
self::$capsule->addConnection([
|
||||
'driver' => 'pgsql',
|
||||
'host' => '127.0.0.1',
|
||||
'port' => '5433',
|
||||
'database' => 'fahrschultermin',
|
||||
'username' => 'fahrschultermin_api',
|
||||
'password' => 'X9wL3pN7kRtHvMbZs4cGfYu2Dj6qAe8t',
|
||||
'charset' => 'utf8',
|
||||
'prefix' => '',
|
||||
'schema' => 'public',
|
||||
'sslmode' => 'prefer',
|
||||
]);
|
||||
|
||||
self::$capsule->setAsGlobal();
|
||||
self::$capsule->bootEloquent();
|
||||
}
|
||||
|
||||
public static function build(): ContainerInterface
|
||||
{
|
||||
self::boot();
|
||||
|
||||
$builder = new \DI\ContainerBuilder();
|
||||
|
||||
$builder->addDefinitions([
|
||||
'jwt.secret' => 'your-256-bit-secret-change-in-production',
|
||||
'config' => [
|
||||
'frontend_url' => 'https://fahrschultermin.de',
|
||||
'api_base_url' => 'https://api.fahrschultermin.de',
|
||||
'session_name' => 'drive_time_session',
|
||||
],
|
||||
]);
|
||||
|
||||
return $builder->build();
|
||||
}
|
||||
}
|
||||
81
api/src/Controllers/AppointmentsController.php
Normal file
81
api/src/Controllers/AppointmentsController.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Models\Appointment;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
final class AppointmentsController
|
||||
{
|
||||
public function index(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$tenantId = $request->getAttribute('tenant_id');
|
||||
$from = $request->getQueryParam('from');
|
||||
$to = $request->getQueryParam('to');
|
||||
|
||||
$query = Appointment::where('tenant_id', $tenantId)->with(['student', 'instructor', 'lessonType']);
|
||||
if ($from && $to) {
|
||||
$query->whereBetween('start_at', [$from, $to]);
|
||||
}
|
||||
return $this->json($response, $query->get()->toArray());
|
||||
}
|
||||
|
||||
public function store(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$tenantId = $request->getAttribute('tenant_id');
|
||||
$user = $request->getAttribute('user');
|
||||
$data = json_decode($request->getBody()->getContents(), true);
|
||||
|
||||
$appointment = Appointment::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'student_id' => $data['student_id'] ?? null,
|
||||
'instructor_id' => $data['instructor_id'],
|
||||
'lesson_type_id' => $data['lesson_type_id'],
|
||||
'title' => $data['title'] ?? '',
|
||||
'category' => $data['category'] ?? 'student',
|
||||
'start_at' => $data['start_at'],
|
||||
'end_at' => $data['end_at'],
|
||||
'units' => $data['units'] ?? 1,
|
||||
'status' => 'planned',
|
||||
'notes' => $data['notes'] ?? '',
|
||||
'created_by' => $user['id'],
|
||||
'updated_by' => $user['id'],
|
||||
]);
|
||||
|
||||
return $this->json($response, $appointment->toArray(), 201);
|
||||
}
|
||||
|
||||
public function update(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
|
||||
{
|
||||
$tenantId = $request->getAttribute('tenant_id');
|
||||
$user = $request->getAttribute('user');
|
||||
$appointment = Appointment::where('id', $args['id'])->where('tenant_id', $tenantId)->first();
|
||||
if (!$appointment) return $this->json($response, ['message' => 'Not found'], 404);
|
||||
|
||||
$data = json_decode($request->getBody()->getContents(), true);
|
||||
$data['updated_by'] = $user['id'];
|
||||
$appointment->update(array_intersect_key($data, array_flip([
|
||||
'student_id', 'instructor_id', 'lesson_type_id', 'title', 'start_at', 'end_at', 'units', 'status', 'notes', 'warning_acknowledged', 'updated_by'
|
||||
])));
|
||||
|
||||
return $this->json($response, $appointment->fresh()->toArray());
|
||||
}
|
||||
|
||||
public function destroy(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
|
||||
{
|
||||
$tenantId = $request->getAttribute('tenant_id');
|
||||
$appointment = Appointment::where('id', $args['id'])->where('tenant_id', $tenantId)->first();
|
||||
if (!$appointment) return $this->json($response, ['message' => 'Not found'], 404);
|
||||
$appointment->delete();
|
||||
return $this->json($response, ['message' => 'Deleted']);
|
||||
}
|
||||
|
||||
private function json(ResponseInterface $response, array $data, int $status = 200): ResponseInterface
|
||||
{
|
||||
$response->getBody()->write(json_encode($data));
|
||||
return $response->withStatus($status)->withHeader('Content-Type', 'application/json');
|
||||
}
|
||||
}
|
||||
80
api/src/Controllers/AuthController.php
Normal file
80
api/src/Controllers/AuthController.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use Firebase\JWT\JWT;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
final class AuthController
|
||||
{
|
||||
public function login(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$body = json_decode($request->getBody()->getContents(), true);
|
||||
$email = $body['email'] ?? '';
|
||||
$password = $body['password'] ?? '';
|
||||
|
||||
if (empty($email) || empty($password)) {
|
||||
return $this->json($response, ['message' => 'Email and password required'], 400);
|
||||
}
|
||||
|
||||
$user = User::where('email', $email)->first();
|
||||
|
||||
if (!$user || !password_verify($password, $user->password_hash)) {
|
||||
return $this->json($response, ['message' => 'Invalid credentials'], 401);
|
||||
}
|
||||
|
||||
if (!$user->is_active) {
|
||||
return $this->json($response, ['message' => 'Account disabled'], 403);
|
||||
}
|
||||
|
||||
// Generate JWT
|
||||
$now = time();
|
||||
$payload = [
|
||||
'iss' => 'api.fahrschultermin.de',
|
||||
'sub' => $user->id,
|
||||
'tenant_id' => $user->tenant_id,
|
||||
'role' => $user->role,
|
||||
'iat' => $now,
|
||||
'exp' => $now + (8 * 60 * 60), // 8 hours
|
||||
];
|
||||
|
||||
$jwt = JWT::encode($payload, $_ENV['JWT_SECRET'] ?? 'this-is-a-32-character-secret-key-for-testing', 'HS256');
|
||||
|
||||
$userData = $user->toArray();
|
||||
unset($userData['password_hash']);
|
||||
|
||||
return $this->json($response, [
|
||||
'token' => $jwt,
|
||||
'user' => $userData,
|
||||
]);
|
||||
}
|
||||
|
||||
public function me(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$user = $request->getAttribute('user');
|
||||
|
||||
if (!$user) {
|
||||
return $this->json($response, ['message' => 'Unauthenticated'], 401);
|
||||
}
|
||||
|
||||
return $this->json($response, ['user' => $user]);
|
||||
}
|
||||
|
||||
public function logout(ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
// JWT is stateless, logout is client-side (discard token)
|
||||
return $this->json($response, ['message' => 'Logged out']);
|
||||
}
|
||||
|
||||
private function json(ResponseInterface $response, array $data, int $status = 200): ResponseInterface
|
||||
{
|
||||
$response->getBody()->write(json_encode($data));
|
||||
return $response
|
||||
->withStatus($status)
|
||||
->withHeader('Content-Type', 'application/json');
|
||||
}
|
||||
}
|
||||
67
api/src/Controllers/BootstrapController.php
Normal file
67
api/src/Controllers/BootstrapController.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Student;
|
||||
use App\Models\Instructor;
|
||||
use App\Models\Appointment;
|
||||
use App\Models\LessonType;
|
||||
use App\Models\LicenseClassTemplate;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
final class BootstrapController
|
||||
{
|
||||
public function handle(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$user = $request->getAttribute('user');
|
||||
$tenantId = (int) $user['tenant_id'];
|
||||
|
||||
// Load tenant
|
||||
$tenant = Tenant::find($tenantId);
|
||||
if (!$tenant) {
|
||||
return $this->json($response, ['message' => 'Tenant not found'], 404);
|
||||
}
|
||||
|
||||
// Load all related data
|
||||
$students = Student::where('tenant_id', $tenantId)->with('template')->get();
|
||||
$instructors = Instructor::where('tenant_id', $tenantId)->get();
|
||||
$lessonTypes = LessonType::where('tenant_id', $tenantId)->orderBy('sort_order')->get();
|
||||
$templates = LicenseClassTemplate::where('tenant_id', $tenantId)->with('requirements')->get();
|
||||
|
||||
// Date range from query params
|
||||
$params = $request->getQueryParams();
|
||||
$from = $params['from'] ?? gmdate('Y-m-d\TH:i:s\Z', strtotime('monday this week'));
|
||||
$to = $params['to'] ?? gmdate('Y-m-d\TH:i:s\Z', strtotime('+7 day', strtotime($from)));
|
||||
|
||||
// Load appointments
|
||||
$appointments = Appointment::where('tenant_id', $tenantId)
|
||||
->whereBetween('start_at', [$from, $to])
|
||||
->with(['student', 'instructor', 'lessonType'])
|
||||
->get();
|
||||
|
||||
$payload = [
|
||||
'session' => ['user' => $user],
|
||||
'tenant' => $tenant->toArray(),
|
||||
'students' => $students->toArray(),
|
||||
'instructors' => $instructors->toArray(),
|
||||
'lessonTypes' => $lessonTypes->toArray(),
|
||||
'templates' => $templates->toArray(),
|
||||
'appointments' => $appointments->toArray(),
|
||||
];
|
||||
|
||||
return $this->json($response, $payload);
|
||||
}
|
||||
|
||||
private function json(ResponseInterface $response, array $data, int $status = 200): ResponseInterface
|
||||
{
|
||||
$response->getBody()->write(json_encode($data));
|
||||
return $response
|
||||
->withStatus($status)
|
||||
->withHeader('Content-Type', 'application/json');
|
||||
}
|
||||
}
|
||||
55
api/src/Controllers/InstructorsController.php
Normal file
55
api/src/Controllers/InstructorsController.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Models\Instructor;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
final class InstructorsController
|
||||
{
|
||||
public function index(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$tenantId = $request->getAttribute('tenant_id');
|
||||
$instructors = Instructor::where('tenant_id', $tenantId)->get();
|
||||
return $this->json($response, $instructors->toArray());
|
||||
}
|
||||
|
||||
public function store(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$tenantId = $request->getAttribute('tenant_id');
|
||||
$data = json_decode($request->getBody()->getContents(), true);
|
||||
|
||||
$instructor = Instructor::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'first_name' => $data['first_name'] ?? '',
|
||||
'last_name' => $data['last_name'] ?? '',
|
||||
'color' => $data['color'] ?? '#0e5d80',
|
||||
'notes' => $data['notes'] ?? '',
|
||||
'is_active' => 1,
|
||||
'pre_start_buffer_minutes' => $data['pre_start_buffer_minutes'] ?? 60,
|
||||
]);
|
||||
|
||||
return $this->json($response, $instructor->toArray(), 201);
|
||||
}
|
||||
|
||||
public function update(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
|
||||
{
|
||||
$tenantId = $request->getAttribute('tenant_id');
|
||||
$instructor = Instructor::where('id', $args['id'])->where('tenant_id', $tenantId)->first();
|
||||
if (!$instructor) return $this->json($response, ['message' => 'Not found'], 404);
|
||||
|
||||
$data = json_decode($request->getBody()->getContents(), true);
|
||||
$instructor->update(array_intersect_key($data, array_flip(['first_name', 'last_name', 'color', 'notes', 'is_active', 'pre_start_buffer_minutes'])));
|
||||
|
||||
return $this->json($response, $instructor->fresh()->toArray());
|
||||
}
|
||||
|
||||
private function json(ResponseInterface $response, array $data, int $status = 200): ResponseInterface
|
||||
{
|
||||
$response->getBody()->write(json_encode($data));
|
||||
return $response->withStatus($status)->withHeader('Content-Type', 'application/json');
|
||||
}
|
||||
}
|
||||
25
api/src/Controllers/LessonTypesController.php
Normal file
25
api/src/Controllers/LessonTypesController.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Models\LessonType;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
final class LessonTypesController
|
||||
{
|
||||
public function index(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$tenantId = $request->getAttribute('tenant_id');
|
||||
$types = LessonType::where('tenant_id', $tenantId)->orderBy('sort_order')->get();
|
||||
return $this->json($response, $types->toArray());
|
||||
}
|
||||
|
||||
private function json(ResponseInterface $response, array $data, int $status = 200): ResponseInterface
|
||||
{
|
||||
$response->getBody()->write(json_encode($data));
|
||||
return $response->withStatus($status)->withHeader('Content-Type', 'application/json');
|
||||
}
|
||||
}
|
||||
75
api/src/Controllers/StudentsController.php
Normal file
75
api/src/Controllers/StudentsController.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Models\Student;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
final class StudentsController
|
||||
{
|
||||
public function index(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$tenantId = $request->getAttribute('tenant_id');
|
||||
$students = Student::where('tenant_id', $tenantId)->with('template')->get();
|
||||
return $this->json($response, $students->toArray());
|
||||
}
|
||||
|
||||
public function store(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$tenantId = $request->getAttribute('tenant_id');
|
||||
$data = json_decode($request->getBody()->getContents(), true);
|
||||
|
||||
$student = Student::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'template_id' => $data['template_id'] ?? null,
|
||||
'first_name' => $data['first_name'] ?? '',
|
||||
'last_name' => $data['last_name'] ?? '',
|
||||
'email' => $data['email'] ?? '',
|
||||
'phone' => $data['phone'] ?? '',
|
||||
'needs_glasses' => $data['needs_glasses'] ?? 0,
|
||||
'status' => $data['status'] ?? 'active',
|
||||
'notes' => $data['notes'] ?? '',
|
||||
]);
|
||||
|
||||
return $this->json($response, $student->toArray(), 201);
|
||||
}
|
||||
|
||||
public function update(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
|
||||
{
|
||||
$tenantId = $request->getAttribute('tenant_id');
|
||||
$student = Student::where('id', $args['id'])->where('tenant_id', $tenantId)->first();
|
||||
|
||||
if (!$student) {
|
||||
return $this->json($response, ['message' => 'Student not found'], 404);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getBody()->getContents(), true);
|
||||
$student->update(array_intersect_key($data, array_flip([
|
||||
'first_name', 'last_name', 'email', 'phone', 'needs_glasses', 'status', 'notes', 'template_id'
|
||||
])));
|
||||
|
||||
return $this->json($response, $student->fresh()->toArray());
|
||||
}
|
||||
|
||||
public function destroy(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
|
||||
{
|
||||
$tenantId = $request->getAttribute('tenant_id');
|
||||
$student = Student::where('id', $args['id'])->where('tenant_id', $tenantId)->first();
|
||||
|
||||
if (!$student) {
|
||||
return $this->json($response, ['message' => 'Student not found'], 404);
|
||||
}
|
||||
|
||||
$student->delete();
|
||||
return $this->json($response, ['message' => 'Deleted']);
|
||||
}
|
||||
|
||||
private function json(ResponseInterface $response, array $data, int $status = 200): ResponseInterface
|
||||
{
|
||||
$response->getBody()->write(json_encode($data));
|
||||
return $response->withStatus($status)->withHeader('Content-Type', 'application/json');
|
||||
}
|
||||
}
|
||||
27
api/src/Controllers/TemplatesController.php
Normal file
27
api/src/Controllers/TemplatesController.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Models\LicenseClassTemplate;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
final class TemplatesController
|
||||
{
|
||||
public function index(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$tenantId = $request->getAttribute('tenant_id');
|
||||
$templates = LicenseClassTemplate::where('tenant_id', $tenantId)
|
||||
->with('requirements')
|
||||
->get();
|
||||
return $this->json($response, $templates->toArray());
|
||||
}
|
||||
|
||||
private function json(ResponseInterface $response, array $data, int $status = 200): ResponseInterface
|
||||
{
|
||||
$response->getBody()->write(json_encode($data));
|
||||
return $response->withStatus($status)->withHeader('Content-Type', 'application/json');
|
||||
}
|
||||
}
|
||||
62
api/src/Middleware/JwtMiddleware.php
Normal file
62
api/src/Middleware/JwtMiddleware.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Middleware;
|
||||
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use App\Models\User;
|
||||
|
||||
final class JwtMiddleware implements \Psr\Http\Server\MiddlewareInterface
|
||||
{
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
$authHeader = $request->getHeaderLine('Authorization') ?? '';
|
||||
|
||||
if (!str_starts_with($authHeader, 'Bearer ')) {
|
||||
$body = json_encode(['message' => 'Missing or invalid Authorization header']);
|
||||
return new \Slim\Psr7\Response(401)
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withBody((new \Slim\Psr7\Factory\StreamFactory())->createStream($body));
|
||||
}
|
||||
|
||||
$token = substr($authHeader, 7);
|
||||
|
||||
try {
|
||||
$secret = $_ENV['JWT_SECRET'] ?? 'this-is-a-32-character-secret-key-for-testing';
|
||||
$decoded = JWT::decode($token, new Key($secret, 'HS256'));
|
||||
} catch (\Throwable $e) {
|
||||
$body = json_encode(['message' => 'Invalid or expired token']);
|
||||
return new \Slim\Psr7\Response(401)
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withBody((new \Slim\Psr7\Factory\StreamFactory())->createStream($body));
|
||||
}
|
||||
|
||||
// Load user from DB
|
||||
$user = User::find($decoded->sub);
|
||||
|
||||
if (!$user) {
|
||||
$body = json_encode(['message' => 'User not found']);
|
||||
return new \Slim\Psr7\Response(401)
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withBody((new \Slim\Psr7\Factory\StreamFactory())->createStream($body));
|
||||
}
|
||||
|
||||
if (!$user->is_active) {
|
||||
$body = json_encode(['message' => 'Account disabled']);
|
||||
return new \Slim\Psr7\Response(401)
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withBody((new \Slim\Psr7\Factory\StreamFactory())->createStream($body));
|
||||
}
|
||||
|
||||
// Attach user and tenant to request
|
||||
$request = $request->withAttribute('user', $user->toArray());
|
||||
$request = $request->withAttribute('tenant_id', $user->tenant_id);
|
||||
|
||||
return $handler->handle($request);
|
||||
}
|
||||
}
|
||||
28
api/src/Models/Appointment.php
Normal file
28
api/src/Models/Appointment.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
final class Appointment extends Model
|
||||
{
|
||||
protected $table = 'appointments';
|
||||
protected $fillable = [
|
||||
'tenant_id', 'student_id', 'instructor_id', 'lesson_type_id',
|
||||
'title', 'category', 'start_at', 'end_at', 'units',
|
||||
'status', 'notes', 'warning_acknowledged', 'created_by', 'updated_by',
|
||||
];
|
||||
protected $casts = [
|
||||
'student_id' => 'integer', 'instructor_id' => 'integer',
|
||||
'lesson_type_id' => 'integer', 'units' => 'integer',
|
||||
'warning_acknowledged' => 'integer', 'created_by' => 'integer', 'updated_by' => 'integer',
|
||||
];
|
||||
|
||||
public function tenant(): BelongsTo { return $this->belongsTo(Tenant::class, 'tenant_id'); }
|
||||
public function student(): BelongsTo { return $this->belongsTo(Student::class, 'student_id'); }
|
||||
public function instructor(): BelongsTo { return $this->belongsTo(Instructor::class, 'instructor_id'); }
|
||||
public function lessonType(): BelongsTo { return $this->belongsTo(LessonType::class, 'lesson_type_id'); }
|
||||
}
|
||||
28
api/src/Models/Instructor.php
Normal file
28
api/src/Models/Instructor.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
final class Instructor extends Model
|
||||
{
|
||||
protected $table = 'instructors';
|
||||
protected $fillable = [
|
||||
'tenant_id', 'user_id', 'first_name', 'last_name',
|
||||
'color', 'notes', 'is_active', 'pre_start_buffer_minutes',
|
||||
];
|
||||
protected $casts = ['is_active' => 'integer', 'tenant_id' => 'integer'];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class, 'tenant_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
}
|
||||
27
api/src/Models/LessonType.php
Normal file
27
api/src/Models/LessonType.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
final class LessonType extends Model
|
||||
{
|
||||
protected $table = 'lesson_types';
|
||||
protected $fillable = [
|
||||
'tenant_id', 'name', 'color', 'default_duration', 'category',
|
||||
'requirement_key', 'is_billable', 'is_counted', 'is_rest_relevant',
|
||||
'fixed_duration', 'sort_order', 'system_key',
|
||||
];
|
||||
protected $casts = [
|
||||
'is_billable' => 'integer', 'is_counted' => 'integer',
|
||||
'is_rest_relevant' => 'integer', 'fixed_duration' => 'integer',
|
||||
];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class, 'tenant_id');
|
||||
}
|
||||
}
|
||||
14
api/src/Models/LessonTypeTemplateVisibility.php
Normal file
14
api/src/Models/LessonTypeTemplateVisibility.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class LessonTypeTemplateVisibility extends Model
|
||||
{
|
||||
protected $table = 'lesson_type_template_visibility';
|
||||
public $timestamps = false;
|
||||
protected $fillable = ['lesson_type_id', 'template_id'];
|
||||
}
|
||||
31
api/src/Models/LicenseClassTemplate.php
Normal file
31
api/src/Models/LicenseClassTemplate.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
final class LicenseClassTemplate extends Model
|
||||
{
|
||||
protected $table = 'license_class_templates';
|
||||
protected $fillable = ['tenant_id', 'code', 'name', 'is_combination'];
|
||||
protected $casts = ['is_combination' => 'integer'];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class, 'tenant_id');
|
||||
}
|
||||
|
||||
public function requirements(): HasMany
|
||||
{
|
||||
return $this->hasMany(LicenseTemplateRequirement::class, 'template_id');
|
||||
}
|
||||
|
||||
public function students(): HasMany
|
||||
{
|
||||
return $this->hasMany(Student::class, 'template_id');
|
||||
}
|
||||
}
|
||||
17
api/src/Models/LicenseTemplateRequirement.php
Normal file
17
api/src/Models/LicenseTemplateRequirement.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class LicenseTemplateRequirement extends Model
|
||||
{
|
||||
protected $table = 'license_template_requirements';
|
||||
protected $fillable = [
|
||||
'template_id', 'requirement_key', 'label',
|
||||
'required_units', 'sort_order', 'phase_label',
|
||||
];
|
||||
protected $casts = ['required_units' => 'integer', 'sort_order' => 'integer'];
|
||||
}
|
||||
28
api/src/Models/Student.php
Normal file
28
api/src/Models/Student.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
final class Student extends Model
|
||||
{
|
||||
protected $table = 'students';
|
||||
protected $fillable = [
|
||||
'tenant_id', 'template_id', 'first_name', 'last_name',
|
||||
'email', 'phone', 'needs_glasses', 'status', 'notes',
|
||||
];
|
||||
protected $casts = ['needs_glasses' => 'integer', 'tenant_id' => 'integer', 'template_id' => 'integer'];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class, 'tenant_id');
|
||||
}
|
||||
|
||||
public function template(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(LicenseClassTemplate::class, 'template_id');
|
||||
}
|
||||
}
|
||||
37
api/src/Models/Tenant.php
Normal file
37
api/src/Models/Tenant.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
final class Tenant extends Model
|
||||
{
|
||||
protected $table = 'tenants';
|
||||
protected $fillable = [
|
||||
'name', 'timezone', 'is_active', 'location_label',
|
||||
'latitude', 'longitude', 'federal_state',
|
||||
];
|
||||
protected $casts = [
|
||||
'is_active' => 'integer',
|
||||
'latitude' => 'float',
|
||||
'longitude' => 'float',
|
||||
];
|
||||
|
||||
public function users(): HasMany
|
||||
{
|
||||
return $this->hasMany(User::class, 'tenant_id');
|
||||
}
|
||||
|
||||
public function students(): HasMany
|
||||
{
|
||||
return $this->hasMany(Student::class, 'tenant_id');
|
||||
}
|
||||
|
||||
public function instructors(): HasMany
|
||||
{
|
||||
return $this->hasMany(Instructor::class, 'tenant_id');
|
||||
}
|
||||
}
|
||||
29
api/src/Models/User.php
Normal file
29
api/src/Models/User.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
final class User extends Model
|
||||
{
|
||||
protected $table = 'users';
|
||||
protected $fillable = [
|
||||
'tenant_id', 'role', 'email', 'password_hash',
|
||||
'first_name', 'last_name', 'is_active',
|
||||
];
|
||||
protected $casts = ['is_active' => 'integer'];
|
||||
protected $hidden = ['password_hash'];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class, 'tenant_id');
|
||||
}
|
||||
|
||||
public function instructor()
|
||||
{
|
||||
return $this->hasOne(Instructor::class, 'user_id');
|
||||
}
|
||||
}
|
||||
62
api/src/Routes/api.php
Normal file
62
api/src/Routes/api.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Controllers\AuthController;
|
||||
use App\Controllers\BootstrapController;
|
||||
use App\Controllers\StudentsController;
|
||||
use App\Controllers\InstructorsController;
|
||||
use App\Controllers\AppointmentsController;
|
||||
use App\Controllers\LessonTypesController;
|
||||
use App\Controllers\TemplatesController;
|
||||
use App\Middleware\JwtMiddleware;
|
||||
|
||||
return function (\Slim\App $app): void {
|
||||
// Auth routes (public)
|
||||
$app->post('/api/v1/auth/login', [AuthController::class, 'login']);
|
||||
$app->post('/api/v1/auth/logout', [AuthController::class, 'logout']);
|
||||
|
||||
// Protected routes
|
||||
$app->get('/api/v1/auth/me', [AuthController::class, 'me'])
|
||||
->add(JwtMiddleware::class);
|
||||
|
||||
$app->get('/api/v1/bootstrap', [BootstrapController::class, 'handle'])
|
||||
->add(JwtMiddleware::class);
|
||||
|
||||
// CRUD routes
|
||||
$app->get('/api/v1/students', [StudentsController::class, 'index'])
|
||||
->add(JwtMiddleware::class);
|
||||
$app->post('/api/v1/students', [StudentsController::class, 'store'])
|
||||
->add(JwtMiddleware::class);
|
||||
$app->patch('/api/v1/students/{id}', [StudentsController::class, 'update'])
|
||||
->add(JwtMiddleware::class);
|
||||
$app->delete('/api/v1/students/{id}', [StudentsController::class, 'destroy'])
|
||||
->add(JwtMiddleware::class);
|
||||
|
||||
$app->get('/api/v1/instructors', [InstructorsController::class, 'index'])
|
||||
->add(JwtMiddleware::class);
|
||||
$app->post('/api/v1/instructors', [InstructorsController::class, 'store'])
|
||||
->add(JwtMiddleware::class);
|
||||
$app->patch('/api/v1/instructors/{id}', [InstructorsController::class, 'update'])
|
||||
->add(JwtMiddleware::class);
|
||||
|
||||
$app->get('/api/v1/appointments', [AppointmentsController::class, 'index'])
|
||||
->add(JwtMiddleware::class);
|
||||
$app->post('/api/v1/appointments', [AppointmentsController::class, 'store'])
|
||||
->add(JwtMiddleware::class);
|
||||
$app->patch('/api/v1/appointments/{id}', [AppointmentsController::class, 'update'])
|
||||
->add(JwtMiddleware::class);
|
||||
$app->delete('/api/v1/appointments/{id}', [AppointmentsController::class, 'destroy'])
|
||||
->add(JwtMiddleware::class);
|
||||
|
||||
// Reference data
|
||||
$app->get('/api/v1/lesson-types', [LessonTypesController::class, 'index'])
|
||||
->add(JwtMiddleware::class);
|
||||
$app->get('/api/v1/license-class-templates', [TemplatesController::class, 'index'])
|
||||
->add(JwtMiddleware::class);
|
||||
|
||||
// Fallback 404
|
||||
$app->any('/api/v1/{path}', function ($request, $response) {
|
||||
return $this->get('response')->withStatus(404)->withJson(['message' => 'Not Found']);
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user