Backup: old custom PHP API code removed from fahrschultermin.de

This commit is contained in:
Hermes Agent
2026-05-20 16:36:12 +02:00
parent 8ab4e532fd
commit 8fccf0e406
59 changed files with 5146 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,292 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
final class AppointmentRepository extends BaseRepository
{
public function listForTenant(int $tenantId, string $from, string $to, ?int $instructorId = null): array
{
$sql = 'SELECT a.*, s.first_name AS student_first_name, s.last_name AS student_last_name, s.needs_glasses AS student_needs_glasses,
i.first_name AS instructor_first_name, i.last_name AS instructor_last_name,
lt.name AS lesson_type_name, lt.color AS lesson_type_color, lt.is_rest_relevant, lt.allows_student, lt.category AS lesson_type_category,
(
SELECT au.requirement_key
FROM appointment_units au
WHERE au.appointment_id = a.id
ORDER BY au.id
LIMIT 1
) AS counted_requirement_key
FROM appointments a
JOIN instructors i ON i.id = a.instructor_id
LEFT JOIN students s ON s.id = a.student_id
JOIN lesson_types lt ON lt.id = a.lesson_type_id
WHERE a.tenant_id = :tenant_id
AND a.start_at < :to
AND a.end_at > :from';
$params = [
'tenant_id' => $tenantId,
'from' => $from,
'to' => $to,
];
if ($instructorId !== null) {
$sql .= ' AND a.instructor_id = :instructor_id';
$params['instructor_id'] = $instructorId;
}
$sql .= ' ORDER BY a.start_at';
$statement = $this->db->prepare($sql);
$statement->execute($params);
return $statement->fetchAll();
}
public function find(int $tenantId, int $id): ?array
{
$statement = $this->db->prepare('SELECT * FROM appointments WHERE tenant_id = :tenant_id AND id = :id');
$statement->execute(['tenant_id' => $tenantId, 'id' => $id]);
$appointment = $statement->fetch() ?: null;
if ($appointment !== null) {
$appointment['units_breakdown'] = $this->units((int) $appointment['id']);
}
return $appointment;
}
public function create(int $tenantId, array $data, int $actorId): array
{
$timestamp = $this->now();
$statement = $this->db->prepare(
'INSERT INTO appointments
(tenant_id, student_id, instructor_id, lesson_type_id, title, category, start_at, end_at, units, status, notes, notes_private, warning_acknowledged, created_by, updated_by, created_at, updated_at)
VALUES
(:tenant_id, :student_id, :instructor_id, :lesson_type_id, :title, :category, :start_at, :end_at, :units, :status, :notes, :notes_private, :warning_acknowledged, :created_by, :updated_by, :created_at, :updated_at)'
);
$statement->execute([
'tenant_id' => $tenantId,
'student_id' => $data['student_id'] ?: null,
'instructor_id' => $data['instructor_id'],
'lesson_type_id' => $data['lesson_type_id'],
'title' => $data['title'],
'category' => $data['category'],
'start_at' => $data['start_at'],
'end_at' => $data['end_at'],
'units' => (int) $data['units'],
'status' => $data['status'],
'notes' => $data['notes'] ?? '',
'notes_private' => $data['notes_private'] ?? '',
'warning_acknowledged' => !empty($data['warning_acknowledged']) ? 1 : 0,
'created_by' => $actorId,
'updated_by' => $actorId,
'created_at' => $timestamp,
'updated_at' => $timestamp,
]);
$appointmentId = (int) $this->lastInsertId();
$this->replaceUnits($appointmentId, $data['units_breakdown'] ?? []);
return $this->find($tenantId, $appointmentId);
}
public function update(int $tenantId, int $id, array $data, int $actorId): ?array
{
$statement = $this->db->prepare(
'UPDATE appointments
SET student_id = :student_id, instructor_id = :instructor_id, lesson_type_id = :lesson_type_id,
title = :title, category = :category, start_at = :start_at, end_at = :end_at, units = :units,
status = :status, notes = :notes, notes_private = :notes_private, warning_acknowledged = :warning_acknowledged,
updated_by = :updated_by, updated_at = :updated_at
WHERE tenant_id = :tenant_id AND id = :id'
);
$statement->execute([
'tenant_id' => $tenantId,
'id' => $id,
'student_id' => $data['student_id'] ?: null,
'instructor_id' => $data['instructor_id'],
'lesson_type_id' => $data['lesson_type_id'],
'title' => $data['title'],
'category' => $data['category'],
'start_at' => $data['start_at'],
'end_at' => $data['end_at'],
'units' => (int) $data['units'],
'status' => $data['status'],
'notes' => $data['notes'] ?? '',
'notes_private' => $data['notes_private'] ?? '',
'warning_acknowledged' => !empty($data['warning_acknowledged']) ? 1 : 0,
'updated_by' => $actorId,
'updated_at' => $this->now(),
]);
$this->replaceUnits($id, $data['units_breakdown'] ?? []);
return $this->find($tenantId, $id);
}
public function delete(int $tenantId, int $id): void
{
$statement = $this->db->prepare('DELETE FROM appointments WHERE tenant_id = :tenant_id AND id = :id');
$statement->execute(['tenant_id' => $tenantId, 'id' => $id]);
}
public function units(int $appointmentId): array
{
$statement = $this->db->prepare('SELECT * FROM appointment_units WHERE appointment_id = :appointment_id');
$statement->execute(['appointment_id' => $appointmentId]);
return $statement->fetchAll();
}
public function overlappingForInstructor(int $tenantId, int $instructorId, string $from, string $to, ?int $ignoreId = null): array
{
$sql = 'SELECT * FROM appointments
WHERE tenant_id = :tenant_id AND instructor_id = :instructor_id
AND start_at < :to AND end_at > :from';
$params = [
'tenant_id' => $tenantId,
'instructor_id' => $instructorId,
'from' => $from,
'to' => $to,
];
if ($ignoreId !== null) {
$sql .= ' AND id != :ignore_id';
$params['ignore_id'] = $ignoreId;
}
$statement = $this->db->prepare($sql);
$statement->execute($params);
return $statement->fetchAll();
}
public function overlappingForStudent(int $tenantId, int $studentId, string $from, string $to, ?int $ignoreId = null): array
{
$sql = 'SELECT * FROM appointments
WHERE tenant_id = :tenant_id AND student_id = :student_id
AND start_at < :to AND end_at > :from';
$params = [
'tenant_id' => $tenantId,
'student_id' => $studentId,
'from' => $from,
'to' => $to,
];
if ($ignoreId !== null) {
$sql .= ' AND id != :ignore_id';
$params['ignore_id'] = $ignoreId;
}
$statement = $this->db->prepare($sql);
$statement->execute($params);
return $statement->fetchAll();
}
public function lastRestRelevantForInstructor(int $tenantId, int $instructorId, string $beforeStart, ?int $ignoreId = null): ?array
{
$sql = 'SELECT a.*, lt.name AS lesson_type_name
FROM appointments a
JOIN lesson_types lt ON lt.id = a.lesson_type_id
WHERE a.tenant_id = :tenant_id
AND a.instructor_id = :instructor_id
AND lt.is_rest_relevant = 1
AND a.end_at <= :before_start';
$params = [
'tenant_id' => $tenantId,
'instructor_id' => $instructorId,
'before_start' => $beforeStart,
];
if ($ignoreId !== null) {
$sql .= ' AND a.id != :ignore_id';
$params['ignore_id'] = $ignoreId;
}
$sql .= ' ORDER BY a.end_at DESC LIMIT 1';
$statement = $this->db->prepare($sql);
$statement->execute($params);
return $statement->fetch() ?: null;
}
public function hasRestRelevantAfterStartOnDay(int $tenantId, int $instructorId, string $dayStart, string $dayEnd, string $startAt): bool
{
$statement = $this->db->prepare(
'SELECT 1
FROM appointments a
JOIN lesson_types lt ON lt.id = a.lesson_type_id
WHERE a.tenant_id = :tenant_id
AND a.instructor_id = :instructor_id
AND lt.is_rest_relevant = 1
AND a.start_at < :day_end
AND a.end_at > :day_start
AND a.end_at > :start_at
LIMIT 1'
);
$statement->execute([
'tenant_id' => $tenantId,
'instructor_id' => $instructorId,
'day_start' => $dayStart,
'day_end' => $dayEnd,
'start_at' => $startAt,
]);
return (bool) $statement->fetchColumn();
}
public function nextForTenant(int $tenantId, string $from, ?int $instructorId = null): ?array
{
$sql = 'SELECT a.*, s.first_name AS student_first_name, s.last_name AS student_last_name,
i.first_name AS instructor_first_name, i.last_name AS instructor_last_name,
lt.name AS lesson_type_name
FROM appointments a
JOIN instructors i ON i.id = a.instructor_id
LEFT JOIN students s ON s.id = a.student_id
JOIN lesson_types lt ON lt.id = a.lesson_type_id
WHERE a.tenant_id = :tenant_id
AND a.start_at >= :from';
$params = [
'tenant_id' => $tenantId,
'from' => $from,
];
if ($instructorId !== null) {
$sql .= ' AND a.instructor_id = :instructor_id';
$params['instructor_id'] = $instructorId;
}
$sql .= ' ORDER BY a.start_at ASC LIMIT 1';
$statement = $this->db->prepare($sql);
$statement->execute($params);
return $statement->fetch() ?: null;
}
private function replaceUnits(int $appointmentId, array $rows): void
{
$delete = $this->db->prepare('DELETE FROM appointment_units WHERE appointment_id = :appointment_id');
$delete->execute(['appointment_id' => $appointmentId]);
$insert = $this->db->prepare(
'INSERT INTO appointment_units (appointment_id, requirement_key, label, units_counted, created_at)
VALUES (:appointment_id, :requirement_key, :label, :units_counted, :created_at)'
);
foreach ($rows as $row) {
if ((int) ($row['units_counted'] ?? 0) <= 0) {
continue;
}
$insert->execute([
'appointment_id' => $appointmentId,
'requirement_key' => $row['requirement_key'] ?? null,
'label' => $row['label'] ?? null,
'units_counted' => (int) $row['units_counted'],
'created_at' => $this->now(),
]);
}
}
}

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
final class AppointmentRequestRepository extends BaseRepository
{
public function create(
int $studentUserId,
int $instructorId,
int $lessonTypeId,
string $start,
string $end,
string $notes = ''
): array {
$stmt = $this->db->prepare(
'INSERT INTO appointment_requests
(student_user_id, instructor_id, lesson_type_id, requested_start, requested_end, notes)
VALUES (:student_user_id, :instructor_id, :lesson_type_id, :start, :end, :notes)
RETURNING *'
);
$stmt->execute([
'student_user_id' => $studentUserId,
'instructor_id' => $instructorId,
'lesson_type_id' => $lessonTypeId,
'start' => $start,
'end' => $end,
'notes' => $notes,
]);
return $stmt->fetch();
}
public function findById(int $id): ?array
{
$stmt = $this->db->prepare('SELECT * FROM appointment_requests WHERE id = :id');
$stmt->execute(['id' => $id]);
$result = $stmt->fetch();
return $result ?: null;
}
public function getPendingForInstructor(int $instructorId): array
{
$stmt = $this->db->prepare(
'SELECT ar.*,
u.first_name AS student_first_name, u.last_name AS student_last_name,
lt.name AS lesson_type_name, lt.category
FROM appointment_requests ar
JOIN users u ON u.id = ar.student_user_id
JOIN lesson_types lt ON lt.id = ar.lesson_type_id
WHERE ar.instructor_id = :instructor_id AND ar.status = \'pending\'
ORDER BY ar.requested_start ASC'
);
$stmt->execute(['instructor_id' => $instructorId]);
return $stmt->fetchAll();
}
public function getForStudent(int $studentUserId): array
{
$stmt = $this->db->prepare(
'SELECT ar.*, lt.name AS lesson_type_name
FROM appointment_requests ar
JOIN lesson_types lt ON lt.id = ar.lesson_type_id
WHERE ar.student_user_id = :student_user_id
ORDER BY ar.requested_start DESC'
);
$stmt->execute(['student_user_id' => $studentUserId]);
return $stmt->fetchAll();
}
public function confirm(int $id, int $responseBy, int $appointmentId): bool
{
$stmt = $this->db->prepare(
'UPDATE appointment_requests
SET status = \'confirmed\', responded_at = NOW(), response_by = :response_by,
confirmed_appointment_id = :appointment_id
WHERE id = :id AND status = \'pending\'
RETURNING id'
);
$stmt->execute(['id' => $id, 'response_by' => $responseBy, 'appointment_id' => $appointmentId]);
return (bool) $stmt->fetch();
}
public function reject(int $id, int $responseBy): bool
{
$stmt = $this->db->prepare(
'UPDATE appointment_requests
SET status = \'rejected\', responded_at = NOW(), response_by = :response_by
WHERE id = :id AND status = \'pending\'
RETURNING id'
);
$stmt->execute(['id' => $id, 'response_by' => $responseBy]);
return (bool) $stmt->fetch();
}
public function cancel(int $id): bool
{
$stmt = $this->db->prepare(
'UPDATE appointment_requests SET status = \'cancelled\' WHERE id = :id RETURNING id'
);
$stmt->execute(['id' => $id]);
return (bool) $stmt->fetch();
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
use App\Support\Database;
use PDO;
abstract class BaseRepository
{
protected PDO $db;
public function __construct()
{
$this->db = Database::connection();
}
protected function lastInsertId(): string
{
return Database::lastInsertId();
}
protected function now(): string
{
return gmdate('c');
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
final class InstructorAvailabilityRepository extends BaseRepository
{
public function getForInstructor(int $instructorId): array
{
$stmt = $this->db->prepare(
'SELECT * FROM instructor_availability
WHERE instructor_id = :instructor_id
ORDER BY weekday, time_from'
);
$stmt->execute(['instructor_id' => $instructorId]);
return $stmt->fetchAll();
}
public function getActiveForInstructor(int $instructorId): array
{
$stmt = $this->db->prepare(
'SELECT * FROM instructor_availability
WHERE instructor_id = :instructor_id AND is_active = 1
ORDER BY weekday, time_from'
);
$stmt->execute(['instructor_id' => $instructorId]);
return $stmt->fetchAll();
}
public function upsert(int $instructorId, array $entries): void
{
// Delete all existing, then insert new (full replace)
$this->db->beginTransaction();
try {
$stmt = $this->db->prepare('DELETE FROM instructor_availability WHERE instructor_id = :instructor_id');
$stmt->execute(['instructor_id' => $instructorId]);
$insert = $this->db->prepare(
'INSERT INTO instructor_availability (instructor_id, weekday, time_from, time_to, is_active)
VALUES (:instructor_id, :weekday, :time_from, :time_to, :is_active)'
);
foreach ($entries as $e) {
$insert->execute([
'instructor_id' => $instructorId,
'weekday' => (int) $e['weekday'],
'time_from' => $e['time_from'],
'time_to' => $e['time_to'],
'is_active' => (int) ($e['is_active'] ?? 1),
]);
}
$this->db->commit();
} catch (\Throwable $e) {
$this->db->rollBack();
throw $e;
}
}
public function delete(int $id): bool
{
$stmt = $this->db->prepare('DELETE FROM instructor_availability WHERE id = :id RETURNING id');
$stmt->execute(['id' => $id]);
return (bool) $stmt->fetch();
}
}

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
final class InstructorBreakRuleRepository extends BaseRepository
{
public function getForInstructor(int $instructorId): array
{
$stmt = $this->db->prepare(
'SELECT * FROM instructor_break_rules
WHERE instructor_id = :instructor_id
ORDER BY condition_type, threshold'
);
$stmt->execute(['instructor_id' => $instructorId]);
return $stmt->fetchAll();
}
public function getActiveForInstructor(int $instructorId): array
{
$stmt = $this->db->prepare(
'SELECT * FROM instructor_break_rules
WHERE instructor_id = :instructor_id AND is_active = 1
ORDER BY condition_type, threshold'
);
$stmt->execute(['instructor_id' => $instructorId]);
return $stmt->fetchAll();
}
public function create(
int $instructorId,
string $conditionType,
string $operator,
float $threshold,
int $breakMinutes,
bool $isActive = true
): array {
$stmt = $this->db->prepare(
'INSERT INTO instructor_break_rules
(instructor_id, condition_type, operator, threshold, break_minutes, is_active)
VALUES (:instructor_id, :condition_type, :operator, :threshold, :break_minutes, :is_active)
RETURNING *'
);
$stmt->execute([
'instructor_id' => $instructorId,
'condition_type' => $conditionType,
'operator' => $operator,
'threshold' => $threshold,
'break_minutes' => $breakMinutes,
'is_active' => $isActive ? 1 : 0,
]);
return $stmt->fetch();
}
public function update(
int $id,
string $conditionType,
string $operator,
float $threshold,
int $breakMinutes,
bool $isActive
): bool {
$stmt = $this->db->prepare(
'UPDATE instructor_break_rules
SET condition_type = :condition_type, operator = :operator,
threshold = :threshold, break_minutes = :break_minutes, is_active = :is_active
WHERE id = :id RETURNING id'
);
$stmt->execute([
'id' => $id,
'condition_type' => $conditionType,
'operator' => $operator,
'threshold' => $threshold,
'break_minutes' => $breakMinutes,
'is_active' => $isActive ? 1 : 0,
]);
return (bool) $stmt->fetch();
}
public function delete(int $id): bool
{
$stmt = $this->db->prepare('DELETE FROM instructor_break_rules WHERE id = :id RETURNING id');
$stmt->execute(['id' => $id]);
return (bool) $stmt->fetch();
}
public function setActive(int $id, bool $isActive): bool
{
$stmt = $this->db->prepare(
'UPDATE instructor_break_rules SET is_active = :is_active WHERE id = :id RETURNING id'
);
$stmt->execute(['id' => $id, 'is_active' => $isActive ? 1 : 0]);
return (bool) $stmt->fetch();
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
final class InstructorHomeToTenantRepository extends BaseRepository
{
public function getForInstructor(int $instructorId): array
{
$stmt = $this->db->prepare(
'SELECT h.*, t.name AS tenant_name
FROM instructor_home_to_tenant h
JOIN tenants t ON t.id = h.tenant_id
WHERE h.instructor_id = :instructor_id
ORDER BY t.name'
);
$stmt->execute(['instructor_id' => $instructorId]);
return $stmt->fetchAll();
}
public function getMinutes(int $instructorId, int $tenantId): ?int
{
$stmt = $this->db->prepare(
'SELECT minutes FROM instructor_home_to_tenant
WHERE instructor_id = :instructor_id AND tenant_id = :tenant_id'
);
$stmt->execute(['instructor_id' => $instructorId, 'tenant_id' => $tenantId]);
$result = $stmt->fetch();
return $result ? (int) $result['minutes'] : null;
}
public function upsert(int $instructorId, int $tenantId, int $minutes): array
{
$stmt = $this->db->prepare(
'INSERT INTO instructor_home_to_tenant (instructor_id, tenant_id, minutes)
VALUES (:instructor_id, :tenant_id, :minutes)
ON CONFLICT (instructor_id, tenant_id)
DO UPDATE SET minutes = :minutes
RETURNING *'
);
$stmt->execute([
'instructor_id' => $instructorId,
'tenant_id' => $tenantId,
'minutes' => $minutes,
]);
return $stmt->fetch();
}
public function delete(int $id): bool
{
$stmt = $this->db->prepare('DELETE FROM instructor_home_to_tenant WHERE id = :id RETURNING id');
$stmt->execute(['id' => $id]);
return (bool) $stmt->fetch();
}
}

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
final class InstructorRepository extends BaseRepository
{
public function allForTenant(int $tenantId): array
{
$statement = $this->db->prepare('SELECT * FROM instructors WHERE tenant_id = :tenant_id ORDER BY last_name, first_name');
$statement->execute(['tenant_id' => $tenantId]);
return $statement->fetchAll();
}
public function findByUserId(int $tenantId, int $userId): ?array
{
$statement = $this->db->prepare(
'SELECT * FROM instructors WHERE tenant_id = :tenant_id AND user_id = :user_id LIMIT 1'
);
$statement->execute(['tenant_id' => $tenantId, 'user_id' => $userId]);
return $statement->fetch() ?: null;
}
public function create(int $tenantId, array $data): array
{
$timestamp = $this->now();
$statement = $this->db->prepare(
'INSERT INTO instructors
(tenant_id, user_id, first_name, last_name, color, notes, is_active, pre_start_buffer_minutes, created_at, updated_at)
VALUES (:tenant_id, :user_id, :first_name, :last_name, :color, :notes, :is_active, :pre_start_buffer_minutes, :created_at, :updated_at)'
);
$statement->execute([
'tenant_id' => $tenantId,
'user_id' => $data['user_id'] ?? null,
'first_name' => $data['first_name'],
'last_name' => $data['last_name'],
'color' => $data['color'],
'notes' => $data['notes'] ?? '',
'is_active' => !empty($data['is_active']) ? 1 : 0,
'pre_start_buffer_minutes' => max(0, (int) ($data['pre_start_buffer_minutes'] ?? 0)),
'created_at' => $timestamp,
'updated_at' => $timestamp,
]);
return $this->find($tenantId, (int) $this->lastInsertId());
}
public function update(int $tenantId, int $id, array $data): ?array
{
$statement = $this->db->prepare(
'UPDATE instructors
SET user_id = :user_id, first_name = :first_name, last_name = :last_name, color = :color,
notes = :notes, is_active = :is_active, pre_start_buffer_minutes = :pre_start_buffer_minutes, updated_at = :updated_at
WHERE tenant_id = :tenant_id AND id = :id'
);
$statement->execute([
'tenant_id' => $tenantId,
'id' => $id,
'user_id' => $data['user_id'] ?? null,
'first_name' => $data['first_name'],
'last_name' => $data['last_name'],
'color' => $data['color'],
'notes' => $data['notes'] ?? '',
'is_active' => !empty($data['is_active']) ? 1 : 0,
'pre_start_buffer_minutes' => max(0, (int) ($data['pre_start_buffer_minutes'] ?? 0)),
'updated_at' => $this->now(),
]);
return $this->find($tenantId, $id);
}
public function find(int $tenantId, int $id): ?array
{
$statement = $this->db->prepare('SELECT * FROM instructors WHERE tenant_id = :tenant_id AND id = :id');
$statement->execute(['tenant_id' => $tenantId, 'id' => $id]);
return $statement->fetch() ?: null;
}
public function findById(int $id): ?array
{
$statement = $this->db->prepare('SELECT * FROM instructors WHERE id = :id');
$statement->execute(['id' => $id]);
return $statement->fetch() ?: null;
}
public function listForTenant(int $tenantId): array
{
$statement = $this->db->prepare('SELECT * FROM instructors WHERE tenant_id = :tenant_id AND is_active = 1 ORDER BY last_name, first_name');
$statement->execute(['tenant_id' => $tenantId]);
return $statement->fetchAll();
}
public function updateSettings(int $instructorId, array $settings): bool
{
$allowed = ['max_daily_units', 'max_daily_override', 'max_weekly_units', 'max_weekly_override', 'booking_enabled'];
$sets = [];
$params = ['id' => $instructorId];
foreach ($allowed as $key) {
if (array_key_exists($key, $settings)) {
$sets[] = "{$key} = :{$key}";
$params[$key] = $settings[$key];
}
}
if (empty($sets)) {
return false;
}
$sql = 'UPDATE instructors SET ' . implode(', ', $sets) . ', updated_at = :updated_at WHERE id = :id';
$params['updated_at'] = $this->now();
$statement = $this->db->prepare($sql);
$statement->execute($params);
return (bool) $statement->rowCount();
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
final class InstructorTenantRepository extends BaseRepository
{
public function getTenantsForInstructor(int $instructorId): array
{
$stmt = $this->db->prepare(
'SELECT it.*, t.name AS tenant_name
FROM instructor_tenants it
JOIN tenants t ON t.id = it.tenant_id
WHERE it.instructor_id = :instructor_id
ORDER BY it.is_primary DESC, t.name'
);
$stmt->execute(['instructor_id' => $instructorId]);
return $stmt->fetchAll();
}
public function isPrimary(int $instructorId, int $tenantId): bool
{
$stmt = $this->db->prepare(
'SELECT 1 FROM instructor_tenants WHERE instructor_id = :instructor_id AND tenant_id = :tenant_id AND is_primary = 1'
);
$stmt->execute(['instructor_id' => $instructorId, 'tenant_id' => $tenantId]);
return (bool) $stmt->fetch();
}
public function add(int $instructorId, int $tenantId, bool $isPrimary = false): array
{
$stmt = $this->db->prepare(
'INSERT INTO instructor_tenants (instructor_id, tenant_id, is_primary)
VALUES (:instructor_id, :tenant_id, :is_primary)
ON CONFLICT (instructor_id, tenant_id) DO UPDATE SET is_primary = :is_primary
RETURNING *'
);
$stmt->execute([
'instructor_id' => $instructorId,
'tenant_id' => $tenantId,
'is_primary' => $isPrimary ? 1 : 0,
]);
return $stmt->fetch();
}
public function remove(int $instructorId, int $tenantId): bool
{
$stmt = $this->db->prepare(
'DELETE FROM instructor_tenants WHERE instructor_id = :instructor_id AND tenant_id = :tenant_id RETURNING id'
);
$stmt->execute(['instructor_id' => $instructorId, 'tenant_id' => $tenantId]);
return (bool) $stmt->fetch();
}
public function setPrimary(int $instructorId, int $tenantId): void
{
$this->db->beginTransaction();
try {
$stmt = $this->db->prepare('UPDATE instructor_tenants SET is_primary = 0 WHERE instructor_id = :instructor_id');
$stmt->execute(['instructor_id' => $instructorId]);
$stmt = $this->db->prepare('UPDATE instructor_tenants SET is_primary = 1 WHERE instructor_id = :instructor_id AND tenant_id = :tenant_id');
$stmt->execute(['instructor_id' => $instructorId, 'tenant_id' => $tenantId]);
$this->db->commit();
} catch (\Throwable $e) {
$this->db->rollBack();
throw $e;
}
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
final class InstructorTravelTimeRepository extends BaseRepository
{
public function getForInstructor(int $instructorId): array
{
$stmt = $this->db->prepare(
'SELECT t.*,
ft.name AS from_tenant_name,
tt.name AS to_tenant_name
FROM instructor_travel_times t
JOIN tenants ft ON ft.id = t.from_tenant_id
JOIN tenants tt ON tt.id = t.to_tenant_id
WHERE t.instructor_id = :instructor_id
ORDER BY ft.name, tt.name'
);
$stmt->execute(['instructor_id' => $instructorId]);
return $stmt->fetchAll();
}
public function getMinutes(int $instructorId, int $fromTenantId, int $toTenantId): ?int
{
$stmt = $this->db->prepare(
'SELECT minutes FROM instructor_travel_times
WHERE instructor_id = :instructor_id
AND from_tenant_id = :from_tenant_id
AND to_tenant_id = :to_tenant_id'
);
$stmt->execute([
'instructor_id' => $instructorId,
'from_tenant_id' => $fromTenantId,
'to_tenant_id' => $toTenantId,
]);
$result = $stmt->fetch();
return $result ? (int) $result['minutes'] : null;
}
public function upsert(int $instructorId, int $fromTenantId, int $toTenantId, int $minutes): array
{
$stmt = $this->db->prepare(
'INSERT INTO instructor_travel_times (instructor_id, from_tenant_id, to_tenant_id, minutes)
VALUES (:instructor_id, :from_tenant_id, :to_tenant_id, :minutes)
ON CONFLICT (instructor_id, from_tenant_id, to_tenant_id)
DO UPDATE SET minutes = :minutes
RETURNING *'
);
$stmt->execute([
'instructor_id' => $instructorId,
'from_tenant_id' => $fromTenantId,
'to_tenant_id' => $toTenantId,
'minutes' => $minutes,
]);
return $stmt->fetch();
}
public function delete(int $id): bool
{
$stmt = $this->db->prepare('DELETE FROM instructor_travel_times WHERE id = :id RETURNING id');
$stmt->execute(['id' => $id]);
return (bool) $stmt->fetch();
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
final class InstructorVacationRepository extends BaseRepository
{
public function getForInstructor(int $instructorId): array
{
$stmt = $this->db->prepare(
'SELECT * FROM instructor_vacations
WHERE instructor_id = :instructor_id
ORDER BY date_from ASC'
);
$stmt->execute(['instructor_id' => $instructorId]);
return $stmt->fetchAll();
}
public function getForInstructorInRange(int $instructorId, string $from, string $to): array
{
$stmt = $this->db->prepare(
'SELECT * FROM instructor_vacations
WHERE instructor_id = :instructor_id
AND date_from <= :to AND date_to >= :from
ORDER BY date_from ASC'
);
$stmt->execute(['instructor_id' => $instructorId, 'from' => $from, 'to' => $to]);
return $stmt->fetchAll();
}
public function create(int $instructorId, string $dateFrom, string $dateTo, string $notes = ''): array
{
$stmt = $this->db->prepare(
'INSERT INTO instructor_vacations (instructor_id, date_from, date_to, notes)
VALUES (:instructor_id, :date_from, :date_to, :notes)
RETURNING *'
);
$stmt->execute([
'instructor_id' => $instructorId,
'date_from' => $dateFrom,
'date_to' => $dateTo,
'notes' => $notes,
]);
return $stmt->fetch();
}
public function delete(int $id): bool
{
$stmt = $this->db->prepare('DELETE FROM instructor_vacations WHERE id = :id RETURNING id');
$stmt->execute(['id' => $id]);
return (bool) $stmt->fetch();
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
final class PrivateAppointmentRepository extends BaseRepository
{
public function getForInstructor(int $instructorId): array
{
$stmt = $this->db->prepare(
'SELECT * FROM private_appointments
WHERE instructor_id = :instructor_id
ORDER BY date ASC, time_from ASC'
);
$stmt->execute(['instructor_id' => $instructorId]);
return $stmt->fetchAll();
}
public function getForInstructorInRange(int $instructorId, string $from, string $to): array
{
$stmt = $this->db->prepare(
'SELECT * FROM private_appointments
WHERE instructor_id = :instructor_id
AND date >= :from AND date <= :to
ORDER BY date ASC, time_from ASC'
);
$stmt->execute(['instructor_id' => $instructorId, 'from' => $from, 'to' => $to]);
return $stmt->fetchAll();
}
public function create(int $instructorId, string $title, string $date, string $timeFrom, string $timeTo, string $color = '#9ca3af'): array
{
$stmt = $this->db->prepare(
'INSERT INTO private_appointments (instructor_id, title, date, time_from, time_to, color)
VALUES (:instructor_id, :title, :date, :time_from, :time_to, :color)
RETURNING *'
);
$stmt->execute([
'instructor_id' => $instructorId,
'title' => $title,
'date' => $date,
'time_from' => $timeFrom,
'time_to' => $timeTo,
'color' => $color,
]);
return $stmt->fetch();
}
public function delete(int $id): bool
{
$stmt = $this->db->prepare('DELETE FROM private_appointments WHERE id = :id RETURNING id');
$stmt->execute(['id' => $id]);
return (bool) $stmt->fetch();
}
}

View File

@@ -0,0 +1,515 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
final class ReferenceDataRepository extends BaseRepository
{
private const DEFAULT_PLANNING_TYPES = [
['system_key' => 'private_block', 'name' => 'Privat', 'color' => '#6B7280', 'default_duration' => 60, 'category' => 'private', 'sort_order' => 900],
['system_key' => 'work_vehicle_care', 'name' => 'Fahrzeugpflege', 'color' => '#0E5D80', 'default_duration' => 60, 'category' => 'work', 'sort_order' => 910],
['system_key' => 'work_office', 'name' => 'Buero', 'color' => '#8B5A2B', 'default_duration' => 60, 'category' => 'work', 'sort_order' => 920],
['system_key' => 'work_transfer_drive', 'name' => 'Wechselfahrt', 'color' => '#3B7A57', 'default_duration' => 45, 'category' => 'work', 'sort_order' => 930],
['system_key' => 'work_other', 'name' => 'Sonstige Taetigkeit', 'color' => '#7C3AED', 'default_duration' => 60, 'category' => 'work', 'sort_order' => 940],
['system_key' => 'theory_a', 'name' => 'Theorie A', 'color' => '#C2410C', 'default_duration' => 90, 'category' => 'theory', 'sort_order' => 950],
['system_key' => 'theory_b', 'name' => 'Theorie B', 'color' => '#A74826', 'default_duration' => 90, 'category' => 'theory', 'sort_order' => 960],
['system_key' => 'theory_c', 'name' => 'Theorie C', 'color' => '#B45309', 'default_duration' => 90, 'category' => 'theory', 'sort_order' => 970],
['system_key' => 'theory_d', 'name' => 'Theorie D', 'color' => '#BE123C', 'default_duration' => 90, 'category' => 'theory', 'sort_order' => 980],
['system_key' => 'theory_t', 'name' => 'Theorie T', 'color' => '#047857', 'default_duration' => 90, 'category' => 'theory', 'sort_order' => 990],
['system_key' => 'theory_bkf', 'name' => 'Theorie BKF', 'color' => '#1D4ED8', 'default_duration' => 90, 'category' => 'theory', 'sort_order' => 1000],
];
public function requirementKeyForLessonType(array $lessonType): string
{
$key = trim((string) ($lessonType['requirement_key'] ?? ''));
return $key !== '' ? $key : 'lesson_type_' . (int) $lessonType['id'];
}
public function lessonTypes(int $tenantId): array
{
$this->ensureDefaultPlanningTypes($tenantId);
$statement = $this->db->prepare('SELECT * FROM lesson_types WHERE tenant_id = :tenant_id ORDER BY sort_order, name');
$statement->execute(['tenant_id' => $tenantId]);
$lessonTypes = $statement->fetchAll();
$visibilityMap = $this->lessonTypeVisibilityMap($tenantId);
foreach ($lessonTypes as &$lessonType) {
$lessonType['visible_template_ids'] = $visibilityMap[(int) $lessonType['id']] ?? [];
$lessonType['effective_requirement_key'] = $this->requirementKeyForLessonType($lessonType);
}
return $lessonTypes;
}
public function createLessonType(int $tenantId, array $data): array
{
$timestamp = $this->now();
$statement = $this->db->prepare(
'INSERT INTO lesson_types
(tenant_id, name, color, default_duration, category, requirement_key, is_billable, is_counted, is_rest_relevant, fixed_duration, sort_order, created_at, updated_at)
VALUES
(:tenant_id, :name, :color, :default_duration, :category, :requirement_key, :is_billable, :is_counted, :is_rest_relevant, :fixed_duration, :sort_order, :created_at, :updated_at)'
);
$statement->execute([
'tenant_id' => $tenantId,
'name' => $data['name'],
'color' => $data['color'],
'default_duration' => (int) $data['default_duration'],
'category' => $data['category'],
'requirement_key' => $data['requirement_key'] ?: null,
'is_billable' => !empty($data['is_billable']) ? 1 : 0,
'is_counted' => !empty($data['is_counted']) ? 1 : 0,
'is_rest_relevant' => !empty($data['is_rest_relevant']) ? 1 : 0,
'fixed_duration' => !empty($data['fixed_duration']) ? 1 : 0,
'sort_order' => (int) ($data['sort_order'] ?? 0),
'created_at' => $timestamp,
'updated_at' => $timestamp,
]);
$lessonTypeId = (int) $this->lastInsertId();
$this->replaceLessonTypeVisibility($tenantId, $lessonTypeId, $data['visible_template_ids'] ?? null);
return $this->findLessonType($tenantId, $lessonTypeId);
}
public function updateLessonType(int $tenantId, int $id, array $data): ?array
{
$statement = $this->db->prepare(
'UPDATE lesson_types
SET name = :name, color = :color, default_duration = :default_duration, category = :category,
requirement_key = :requirement_key, is_billable = :is_billable, is_counted = :is_counted,
is_rest_relevant = :is_rest_relevant, fixed_duration = :fixed_duration, sort_order = :sort_order,
updated_at = :updated_at
WHERE id = :id AND tenant_id = :tenant_id'
);
$statement->execute([
'id' => $id,
'tenant_id' => $tenantId,
'name' => $data['name'],
'color' => $data['color'],
'default_duration' => (int) $data['default_duration'],
'category' => $data['category'],
'requirement_key' => $data['requirement_key'] ?: null,
'is_billable' => !empty($data['is_billable']) ? 1 : 0,
'is_counted' => !empty($data['is_counted']) ? 1 : 0,
'is_rest_relevant' => !empty($data['is_rest_relevant']) ? 1 : 0,
'fixed_duration' => !empty($data['fixed_duration']) ? 1 : 0,
'sort_order' => (int) ($data['sort_order'] ?? 0),
'updated_at' => $this->now(),
]);
$this->replaceLessonTypeVisibility($tenantId, $id, $data['visible_template_ids'] ?? null);
return $this->findLessonType($tenantId, $id);
}
public function deleteLessonType(int $tenantId, int $id): bool
{
$lessonType = $this->findLessonTypeRecord($tenantId, $id);
if ($lessonType === null) {
return false;
}
if (($lessonType['system_key'] ?? null) === 'private_block') {
throw new \RuntimeException('Der feste Privat-Baustein kann nicht geloescht werden.');
}
$usage = $this->db->prepare('SELECT COUNT(*) AS count FROM appointments WHERE tenant_id = :tenant_id AND lesson_type_id = :lesson_type_id');
$usage->execute(['tenant_id' => $tenantId, 'lesson_type_id' => $id]);
if ((int) ($usage->fetch()['count'] ?? 0) > 0) {
throw new \RuntimeException('Diese Stundenart wird bereits in Terminen verwendet und kann nicht geloescht werden.');
}
$this->db->beginTransaction();
try {
$visibility = $this->db->prepare('DELETE FROM lesson_type_template_visibility WHERE lesson_type_id = :lesson_type_id');
$visibility->execute(['lesson_type_id' => $id]);
$delete = $this->db->prepare('DELETE FROM lesson_types WHERE tenant_id = :tenant_id AND id = :id');
$delete->execute(['tenant_id' => $tenantId, 'id' => $id]);
$this->db->commit();
return true;
} catch (\Throwable $exception) {
$this->db->rollBack();
throw $exception;
}
}
public function reorderLessonTypes(int $tenantId, array $lessonTypeIds): array
{
$allowedIds = array_map(
static fn (array $lessonType): int => (int) $lessonType['id'],
$this->lessonTypes($tenantId)
);
$statement = $this->db->prepare(
'UPDATE lesson_types
SET sort_order = :sort_order, updated_at = :updated_at
WHERE tenant_id = :tenant_id AND id = :id'
);
$sortOrder = 1;
foreach ($lessonTypeIds as $lessonTypeId) {
$lessonTypeId = (int) $lessonTypeId;
if (!in_array($lessonTypeId, $allowedIds, true)) {
continue;
}
$statement->execute([
'sort_order' => $sortOrder,
'updated_at' => $this->now(),
'tenant_id' => $tenantId,
'id' => $lessonTypeId,
]);
$sortOrder++;
}
return $this->lessonTypes($tenantId);
}
public function findLessonType(int $tenantId, int $id): ?array
{
$statement = $this->db->prepare('SELECT * FROM lesson_types WHERE tenant_id = :tenant_id AND id = :id');
$statement->execute(['tenant_id' => $tenantId, 'id' => $id]);
$lessonType = $statement->fetch() ?: null;
if ($lessonType === null) {
return null;
}
$lessonType['visible_template_ids'] = $this->visibleTemplateIdsForLessonType($tenantId, $id);
$lessonType['effective_requirement_key'] = $this->requirementKeyForLessonType($lessonType);
return $lessonType;
}
public function templates(int $tenantId): array
{
$statement = $this->db->prepare(
'SELECT * FROM license_class_templates WHERE tenant_id = :tenant_id ORDER BY name'
);
$statement->execute(['tenant_id' => $tenantId]);
$templates = $statement->fetchAll();
foreach ($templates as &$template) {
$template['requirements'] = $this->templateRequirements((int) $template['id']);
$template['lesson_type_ids'] = $this->lessonTypeIdsForTemplate((int) $template['id']);
}
return $templates;
}
public function templateRequirements(int $templateId): array
{
$statement = $this->db->prepare(
'SELECT * FROM license_template_requirements WHERE template_id = :template_id ORDER BY sort_order, phase_label, label'
);
$statement->execute(['template_id' => $templateId]);
return $statement->fetchAll();
}
public function createTemplate(int $tenantId, array $data): array
{
$timestamp = $this->now();
$statement = $this->db->prepare(
'INSERT INTO license_class_templates
(tenant_id, code, name, is_combination, created_at, updated_at)
VALUES (:tenant_id, :code, :name, :is_combination, :created_at, :updated_at)'
);
$statement->execute([
'tenant_id' => $tenantId,
'code' => $data['code'],
'name' => $data['name'],
'is_combination' => !empty($data['is_combination']) ? 1 : 0,
'created_at' => $timestamp,
'updated_at' => $timestamp,
]);
$templateId = (int) $this->lastInsertId();
$this->replaceTemplateRequirements($templateId, $data['requirements'] ?? []);
$this->attachTemplateToExistingStudentLessonTypes($tenantId, $templateId);
return $this->findTemplate($tenantId, $templateId);
}
public function updateTemplate(int $tenantId, int $id, array $data): ?array
{
$statement = $this->db->prepare(
'UPDATE license_class_templates
SET code = :code, name = :name, is_combination = :is_combination, updated_at = :updated_at
WHERE id = :id AND tenant_id = :tenant_id'
);
$statement->execute([
'id' => $id,
'tenant_id' => $tenantId,
'code' => $data['code'],
'name' => $data['name'],
'is_combination' => !empty($data['is_combination']) ? 1 : 0,
'updated_at' => $this->now(),
]);
$this->replaceTemplateRequirements($id, $data['requirements'] ?? []);
return $this->findTemplate($tenantId, $id);
}
public function updateLessonTypeMatrix(int $tenantId, array $rows): array
{
$templates = $this->templates($tenantId);
$templateIds = array_map(static fn (array $template): int => (int) $template['id'], $templates);
$allowedLessonTypeIds = array_map(
static fn (array $lessonType): int => (int) $lessonType['id'],
array_filter($this->lessonTypes($tenantId), static fn (array $lessonType): bool => $lessonType['category'] === 'student')
);
$delete = $this->db->prepare(
'DELETE FROM lesson_type_template_visibility
WHERE template_id = :template_id
AND lesson_type_id IN (
SELECT id FROM lesson_types WHERE tenant_id = :tenant_id AND category = \'student\'
)'
);
$insert = $this->db->prepare(
'INSERT OR IGNORE INTO lesson_type_template_visibility (lesson_type_id, template_id)
VALUES (:lesson_type_id, :template_id)'
);
foreach ($rows as $row) {
$templateId = (int) ($row['template_id'] ?? 0);
if (!in_array($templateId, $templateIds, true)) {
continue;
}
$delete->execute([
'template_id' => $templateId,
'tenant_id' => $tenantId,
]);
foreach ((array) ($row['lesson_type_ids'] ?? []) as $lessonTypeId) {
$lessonTypeId = (int) $lessonTypeId;
if (!in_array($lessonTypeId, $allowedLessonTypeIds, true)) {
continue;
}
$insert->execute([
'lesson_type_id' => $lessonTypeId,
'template_id' => $templateId,
]);
}
}
return $this->templates($tenantId);
}
public function isLessonTypeVisibleForTemplate(int $tenantId, int $lessonTypeId, int $templateId): bool
{
$lessonType = $this->findLessonType($tenantId, $lessonTypeId);
if ($lessonType === null) {
return false;
}
if ($lessonType['category'] !== 'student') {
return true;
}
return in_array($templateId, array_map('intval', $lessonType['visible_template_ids'] ?? []), true);
}
private function replaceTemplateRequirements(int $templateId, array $requirements): void
{
$delete = $this->db->prepare('DELETE FROM license_template_requirements WHERE template_id = :template_id');
$delete->execute(['template_id' => $templateId]);
$insert = $this->db->prepare(
'INSERT INTO license_template_requirements
(template_id, requirement_key, label, required_units, sort_order, phase_label)
VALUES (:template_id, :requirement_key, :label, :required_units, :sort_order, :phase_label)'
);
foreach ($requirements as $index => $requirement) {
if (($requirement['label'] ?? '') === '') {
continue;
}
$insert->execute([
'template_id' => $templateId,
'requirement_key' => $requirement['requirement_key'],
'label' => $requirement['label'],
'required_units' => (int) $requirement['required_units'],
'sort_order' => (int) ($requirement['sort_order'] ?? $index),
'phase_label' => trim((string) ($requirement['phase_label'] ?? '')),
]);
}
}
public function findTemplate(int $tenantId, int $id): ?array
{
$statement = $this->db->prepare(
'SELECT * FROM license_class_templates WHERE tenant_id = :tenant_id AND id = :id'
);
$statement->execute(['tenant_id' => $tenantId, 'id' => $id]);
$template = $statement->fetch() ?: null;
if ($template !== null) {
$template['requirements'] = $this->templateRequirements($id);
$template['lesson_type_ids'] = $this->lessonTypeIdsForTemplate($id);
}
return $template;
}
private function replaceLessonTypeVisibility(int $tenantId, int $lessonTypeId, ?array $visibleTemplateIds): void
{
$lessonType = $this->findLessonTypeRecord($tenantId, $lessonTypeId);
if ($lessonType === null) {
return;
}
$delete = $this->db->prepare('DELETE FROM lesson_type_template_visibility WHERE lesson_type_id = :lesson_type_id');
$delete->execute(['lesson_type_id' => $lessonTypeId]);
if ($lessonType['category'] !== 'student') {
return;
}
$templateIds = $visibleTemplateIds ?? $this->templateIdsForTenant($tenantId);
$templateIds = array_values(array_unique(array_map('intval', $templateIds)));
if ($templateIds === []) {
return;
}
$allowedTemplateIds = $this->templateIdsForTenant($tenantId);
$insert = $this->db->prepare(
'INSERT OR IGNORE INTO lesson_type_template_visibility (lesson_type_id, template_id)
VALUES (:lesson_type_id, :template_id)'
);
foreach ($templateIds as $templateId) {
if (!in_array($templateId, $allowedTemplateIds, true)) {
continue;
}
$insert->execute([
'lesson_type_id' => $lessonTypeId,
'template_id' => $templateId,
]);
}
}
private function lessonTypeVisibilityMap(int $tenantId): array
{
$statement = $this->db->prepare(
'SELECT lt.id AS lesson_type_id, vis.template_id
FROM lesson_types lt
LEFT JOIN lesson_type_template_visibility vis ON vis.lesson_type_id = lt.id
WHERE lt.tenant_id = :tenant_id AND lt.category = \'student\'
ORDER BY vis.template_id'
);
$statement->execute(['tenant_id' => $tenantId]);
$map = [];
foreach ($statement->fetchAll() as $row) {
$lessonTypeId = (int) $row['lesson_type_id'];
$map[$lessonTypeId] ??= [];
if ($row['template_id'] !== null) {
$map[$lessonTypeId][] = (int) $row['template_id'];
}
}
return $map;
}
private function visibleTemplateIdsForLessonType(int $tenantId, int $lessonTypeId): array
{
$lessonType = $this->findLessonTypeRecord($tenantId, $lessonTypeId);
if ($lessonType === null || $lessonType['category'] !== 'student') {
return [];
}
$statement = $this->db->prepare(
'SELECT template_id FROM lesson_type_template_visibility
WHERE lesson_type_id = :lesson_type_id
ORDER BY template_id'
);
$statement->execute(['lesson_type_id' => $lessonTypeId]);
return array_map(
static fn (array $row): int => (int) $row['template_id'],
$statement->fetchAll()
);
}
private function lessonTypeIdsForTemplate(int $templateId): array
{
$statement = $this->db->prepare(
'SELECT lesson_type_id FROM lesson_type_template_visibility
WHERE template_id = :template_id
ORDER BY lesson_type_id'
);
$statement->execute(['template_id' => $templateId]);
return array_map(
static fn (array $row): int => (int) $row['lesson_type_id'],
$statement->fetchAll()
);
}
private function templateIdsForTenant(int $tenantId): array
{
$statement = $this->db->prepare('SELECT id FROM license_class_templates WHERE tenant_id = :tenant_id ORDER BY id');
$statement->execute(['tenant_id' => $tenantId]);
return array_map(
static fn (array $row): int => (int) $row['id'],
$statement->fetchAll()
);
}
private function attachTemplateToExistingStudentLessonTypes(int $tenantId, int $templateId): void
{
$statement = $this->db->prepare(
'INSERT OR IGNORE INTO lesson_type_template_visibility (lesson_type_id, template_id)
SELECT id, :template_id FROM lesson_types
WHERE tenant_id = :tenant_id AND category = \'student\''
);
$statement->execute([
'template_id' => $templateId,
'tenant_id' => $tenantId,
]);
}
private function findLessonTypeRecord(int $tenantId, int $id): ?array
{
$statement = $this->db->prepare('SELECT * FROM lesson_types WHERE tenant_id = :tenant_id AND id = :id');
$statement->execute(['tenant_id' => $tenantId, 'id' => $id]);
return $statement->fetch() ?: null;
}
private function ensureDefaultPlanningTypes(int $tenantId): void
{
$statement = $this->db->prepare(
'INSERT OR IGNORE INTO lesson_types
(tenant_id, name, color, default_duration, category, requirement_key, is_billable, is_counted, is_rest_relevant, fixed_duration, sort_order, system_key, created_at, updated_at)
VALUES
(:tenant_id, :name, :color, :default_duration, :category, NULL, 0, 0, :is_rest_relevant, 0, :sort_order, :system_key, :created_at, :updated_at)'
);
$timestamp = $this->now();
foreach (self::DEFAULT_PLANNING_TYPES as $row) {
$statement->execute([
'tenant_id' => $tenantId,
'name' => $row['name'],
'color' => $row['color'],
'default_duration' => $row['default_duration'],
'category' => $row['category'],
'is_rest_relevant' => $row['category'] === 'private' ? 0 : 1,
'sort_order' => $row['sort_order'],
'system_key' => $row['system_key'],
'created_at' => $timestamp,
'updated_at' => $timestamp,
]);
}
}
}

View File

@@ -0,0 +1,294 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
final class StudentRepository extends BaseRepository
{
public function allForTenant(int $tenantId): array
{
$statement = $this->db->prepare(
'SELECT s.*, t.code AS template_code, t.name AS template_name, t.is_combination AS template_is_combination
FROM students s
JOIN license_class_templates t ON t.id = s.template_id
WHERE s.tenant_id = :tenant_id
ORDER BY s.last_name, s.first_name'
);
$statement->execute(['tenant_id' => $tenantId]);
return $statement->fetchAll();
}
public function find(int $tenantId, int $id): ?array
{
$statement = $this->db->prepare(
'SELECT s.*, t.code AS template_code, t.name AS template_name, t.is_combination AS template_is_combination
FROM students s
JOIN license_class_templates t ON t.id = s.template_id
WHERE s.tenant_id = :tenant_id AND s.id = :id'
);
$statement->execute(['tenant_id' => $tenantId, 'id' => $id]);
$student = $statement->fetch() ?: null;
if ($student !== null) {
$student['requirements'] = $this->requirements($id);
}
return $student;
}
public function create(int $tenantId, array $data): array
{
$timestamp = $this->now();
$statement = $this->db->prepare(
'INSERT INTO students
(tenant_id, template_id, first_name, last_name, phone, email, needs_glasses, notes, status, created_at, updated_at)
VALUES (:tenant_id, :template_id, :first_name, :last_name, :phone, :email, :needs_glasses, :notes, :status, :created_at, :updated_at)'
);
$statement->execute([
'tenant_id' => $tenantId,
'template_id' => $data['template_id'],
'first_name' => $data['first_name'],
'last_name' => $data['last_name'],
'phone' => $data['phone'] ?? '',
'email' => $data['email'] ?? '',
'needs_glasses' => !empty($data['needs_glasses']) ? 1 : 0,
'notes' => $data['notes'] ?? '',
'status' => $data['status'] ?? 'active',
'created_at' => $timestamp,
'updated_at' => $timestamp,
]);
$studentId = (int) $this->lastInsertId();
$this->snapshotRequirements($studentId, (int) $data['template_id']);
return $this->find($tenantId, $studentId);
}
public function update(int $tenantId, int $id, array $data): ?array
{
$statement = $this->db->prepare(
'UPDATE students
SET template_id = :template_id, first_name = :first_name, last_name = :last_name, phone = :phone, email = :email, needs_glasses = :needs_glasses,
notes = :notes, status = :status, updated_at = :updated_at
WHERE tenant_id = :tenant_id AND id = :id'
);
$statement->execute([
'tenant_id' => $tenantId,
'id' => $id,
'template_id' => $data['template_id'],
'first_name' => $data['first_name'],
'last_name' => $data['last_name'],
'phone' => $data['phone'] ?? '',
'email' => $data['email'] ?? '',
'needs_glasses' => !empty($data['needs_glasses']) ? 1 : 0,
'notes' => $data['notes'] ?? '',
'status' => $data['status'] ?? 'active',
'updated_at' => $this->now(),
]);
return $this->find($tenantId, $id);
}
public function delete(int $tenantId, int $id): bool
{
$student = $this->find($tenantId, $id);
if ($student === null) {
return false;
}
$this->db->beginTransaction();
try {
$updateAppointments = $this->db->prepare(
'UPDATE appointments SET student_id = NULL, updated_at = :updated_at WHERE tenant_id = :tenant_id AND student_id = :student_id'
);
$updateAppointments->execute([
'tenant_id' => $tenantId,
'student_id' => $id,
'updated_at' => $this->now(),
]);
$deleteSnapshots = $this->db->prepare('DELETE FROM student_requirement_snapshots WHERE student_id = :student_id');
$deleteSnapshots->execute(['student_id' => $id]);
$deleteStudent = $this->db->prepare('DELETE FROM students WHERE tenant_id = :tenant_id AND id = :id');
$deleteStudent->execute(['tenant_id' => $tenantId, 'id' => $id]);
$this->db->commit();
return true;
} catch (\Throwable $exception) {
$this->db->rollBack();
throw $exception;
}
}
public function requirements(int $studentId): array
{
$this->syncRequirementSnapshots($studentId);
$statement = $this->db->prepare(
'SELECT * FROM student_requirement_snapshots WHERE student_id = :student_id ORDER BY sort_order, phase_label, label'
);
$statement->execute(['student_id' => $studentId]);
return $statement->fetchAll();
}
public function updatePriorCompletedUnits(int $tenantId, int $studentId, array $rows): ?array
{
$student = $this->find($tenantId, $studentId);
if ($student === null) {
return null;
}
$statement = $this->db->prepare(
'UPDATE student_requirement_snapshots
SET prior_completed_units = :prior_completed_units
WHERE student_id = :student_id AND requirement_key = :requirement_key'
);
foreach ($rows as $row) {
$statement->execute([
'student_id' => $studentId,
'requirement_key' => (string) ($row['requirement_key'] ?? ''),
'prior_completed_units' => max(0, (int) ($row['prior_completed_units'] ?? 0)),
]);
}
return $this->find($tenantId, $studentId);
}
private function snapshotRequirements(int $studentId, int $templateId): void
{
$this->syncRequirementSnapshots($studentId, $templateId);
}
private function syncRequirementSnapshots(int $studentId, ?int $templateId = null): void
{
if ($templateId === null) {
$student = $this->db->prepare('SELECT template_id FROM students WHERE id = :student_id');
$student->execute(['student_id' => $studentId]);
$templateId = (int) ($student->fetch()['template_id'] ?? 0);
}
if ($templateId <= 0) {
return;
}
$requirements = $this->db->prepare(
'SELECT requirement_key, label, required_units, sort_order, phase_label
FROM license_template_requirements WHERE template_id = :template_id'
);
$requirements->execute(['template_id' => $templateId]);
$rows = [];
foreach ($requirements->fetchAll() as $row) {
$rows[$row['requirement_key']] = $row;
}
$hasPhasedRequirements = array_reduce(
$rows,
static fn (bool $carry, array $row): bool => $carry || trim((string) ($row['phase_label'] ?? '')) !== '',
false
);
$lessonTypes = $this->db->prepare(
'SELECT COALESCE(NULLIF(TRIM(lt.requirement_key), \'\'), \'lesson_type_\' || lt.id) AS requirement_key,
lt.name AS label,
lt.sort_order
FROM lesson_types lt
JOIN lesson_type_template_visibility vis ON vis.lesson_type_id = lt.id
WHERE vis.template_id = :template_id
AND lt.category = \'student\'
AND lt.is_counted = 1
ORDER BY lt.sort_order, lt.name'
);
$lessonTypes->execute(['template_id' => $templateId]);
foreach ($lessonTypes->fetchAll() as $row) {
if (isset($rows[$row['requirement_key']])) {
continue;
}
if ($hasPhasedRequirements) {
$hasMatchingPhasedRequirement = false;
foreach ($rows as $existingRow) {
$phaseLabel = trim((string) ($existingRow['phase_label'] ?? ''));
if ($phaseLabel === '') {
continue;
}
if (str_ends_with((string) $existingRow['requirement_key'], '::' . $row['requirement_key'])) {
$hasMatchingPhasedRequirement = true;
break;
}
}
if ($hasMatchingPhasedRequirement) {
continue;
}
}
$rows[$row['requirement_key']] = [
'requirement_key' => $row['requirement_key'],
'label' => $row['label'],
'required_units' => 0,
'sort_order' => $row['sort_order'],
];
}
$insert = $this->db->prepare(
'INSERT INTO student_requirement_snapshots
(student_id, requirement_key, label, required_units, prior_completed_units, sort_order, phase_label)
SELECT :student_id, :requirement_key, :label, :required_units, 0, :sort_order, :phase_label
WHERE NOT EXISTS (
SELECT 1 FROM student_requirement_snapshots
WHERE student_id = :student_id AND requirement_key = :requirement_key
)'
);
$update = $this->db->prepare(
'UPDATE student_requirement_snapshots
SET label = :label,
required_units = :required_units,
sort_order = :sort_order,
phase_label = :phase_label
WHERE student_id = :student_id AND requirement_key = :requirement_key'
);
foreach ($rows as $row) {
$insert->execute([
'student_id' => $studentId,
'requirement_key' => $row['requirement_key'],
'label' => $row['label'],
'required_units' => $row['required_units'],
'sort_order' => $row['sort_order'],
'phase_label' => $row['phase_label'] ?? '',
]);
$update->execute([
'student_id' => $studentId,
'requirement_key' => $row['requirement_key'],
'label' => $row['label'],
'required_units' => $row['required_units'],
'sort_order' => $row['sort_order'],
'phase_label' => $row['phase_label'] ?? '',
]);
}
if ($rows !== []) {
$placeholders = implode(', ', array_fill(0, count($rows), '?'));
$delete = $this->db->prepare(
"DELETE FROM student_requirement_snapshots
WHERE student_id = ?
AND requirement_key NOT IN ($placeholders)
AND prior_completed_units = 0
AND NOT EXISTS (
SELECT 1 FROM appointment_units au
JOIN appointments a ON a.id = au.appointment_id
WHERE a.student_id = student_requirement_snapshots.student_id
AND au.requirement_key = student_requirement_snapshots.requirement_key
)"
);
$delete->execute(array_merge([$studentId], array_keys($rows)));
}
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
final class StudentTenantRepository extends BaseRepository
{
public function findByUserAndTenant(int $userId, int $tenantId): ?array
{
$stmt = $this->db->prepare(
'SELECT * FROM student_tenants WHERE student_user_id = :user_id AND tenant_id = :tenant_id'
);
$stmt->execute(['user_id' => $userId, 'tenant_id' => $tenantId]);
$result = $stmt->fetch();
return $result ?: null;
}
public function create(int $userId, int $tenantId, string $status = 'pending'): array
{
$stmt = $this->db->prepare(
'INSERT INTO student_tenants (student_user_id, tenant_id, status)
VALUES (:user_id, :tenant_id, :status) RETURNING *'
);
$stmt->execute([
'user_id' => $userId,
'tenant_id' => $tenantId,
'status' => $status,
]);
return $stmt->fetch();
}
public function confirm(int $id, int $confirmedBy): bool
{
$stmt = $this->db->prepare(
'UPDATE student_tenants
SET status = \'active\', confirmed_at = NOW(), confirmed_by = :confirmed_by
WHERE id = :id RETURNING id'
);
$stmt->execute(['id' => $id, 'confirmed_by' => $confirmedBy]);
return (bool) $stmt->fetch();
}
public function suspend(int $id): bool
{
$stmt = $this->db->prepare(
'UPDATE student_tenants SET status = \'suspended\' WHERE id = :id RETURNING id'
);
$stmt->execute(['id' => $id]);
return (bool) $stmt->fetch();
}
public function getPendingForTenant(int $tenantId): array
{
$stmt = $this->db->prepare(
'SELECT st.*, u.email, u.first_name, u.last_name
FROM student_tenants st
JOIN users u ON u.id = st.student_user_id
WHERE st.tenant_id = :tenant_id AND st.status = \'pending\'
ORDER BY st.registered_at DESC'
);
$stmt->execute(['tenant_id' => $tenantId]);
return $stmt->fetchAll();
}
public function getActiveForUser(int $userId): array
{
$stmt = $this->db->prepare(
'SELECT st.*, t.name AS tenant_name
FROM student_tenants st
JOIN tenants t ON t.id = st.tenant_id
WHERE st.student_user_id = :user_id AND st.status = \'active\''
);
$stmt->execute(['user_id' => $userId]);
return $stmt->fetchAll();
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
final class TenantRegistrationCodeRepository extends BaseRepository
{
public function findByCode(string $code): ?array
{
$stmt = $this->db->prepare(
'SELECT trc.*, t.name AS tenant_name
FROM tenant_registration_codes trc
JOIN tenants t ON t.id = trc.tenant_id
WHERE trc.code = :code AND trc.is_active = 1'
);
$stmt->execute(['code' => $code]);
$result = $stmt->fetch();
return $result ?: null;
}
public function findByTenant(int $tenantId): ?array
{
$stmt = $this->db->prepare(
'SELECT * FROM tenant_registration_codes WHERE tenant_id = :tenant_id AND is_active = 1'
);
$stmt->execute(['tenant_id' => $tenantId]);
$result = $stmt->fetch();
return $result ?: null;
}
public function create(int $tenantId, string $code): array
{
$stmt = $this->db->prepare(
'INSERT INTO tenant_registration_codes (tenant_id, code) VALUES (:tenant_id, :code) RETURNING *'
);
$stmt->execute(['tenant_id' => $tenantId, 'code' => $code]);
return $stmt->fetch();
}
public function update(int $tenantId, string $code): bool
{
$stmt = $this->db->prepare(
'UPDATE tenant_registration_codes SET code = :code WHERE tenant_id = :tenant_id RETURNING id'
);
$stmt->execute(['tenant_id' => $tenantId, 'code' => $code]);
return (bool) $stmt->fetch();
}
public function incrementUsedCount(int $tenantId): void
{
$stmt = $this->db->prepare(
'UPDATE tenant_registration_codes SET used_count = used_count + 1 WHERE tenant_id = :tenant_id'
);
$stmt->execute(['tenant_id' => $tenantId]);
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
use PDO;
final class TenantRepository extends BaseRepository
{
public function all(): array
{
return $this->db->query('SELECT * FROM tenants ORDER BY name')->fetchAll();
}
public function find(int $id): ?array
{
$statement = $this->db->prepare('SELECT * FROM tenants WHERE id = :id');
$statement->execute(['id' => $id]);
return $statement->fetch() ?: null;
}
public function create(array $data): array
{
$timestamp = $this->now();
$statement = $this->db->prepare(
'INSERT INTO tenants (name, timezone, federal_state, location_label, latitude, longitude, is_active, created_at, updated_at)
VALUES (:name, :timezone, :federal_state, :location_label, :latitude, :longitude, :is_active, :created_at, :updated_at)'
);
$statement->execute([
'name' => $data['name'],
'timezone' => $data['timezone'] ?? 'Europe/Berlin',
'federal_state' => $data['federal_state'] ?? 'BE',
'location_label' => $data['location_label'] ?? '',
'latitude' => $data['latitude'] !== '' ? $data['latitude'] : null,
'longitude' => $data['longitude'] !== '' ? $data['longitude'] : null,
'is_active' => !empty($data['is_active']) ? 1 : 0,
'created_at' => $timestamp,
'updated_at' => $timestamp,
]);
return $this->find((int) $this->lastInsertId());
}
public function update(int $id, array $data): ?array
{
$statement = $this->db->prepare(
'UPDATE tenants
SET name = :name, timezone = :timezone, federal_state = :federal_state, location_label = :location_label,
latitude = :latitude, longitude = :longitude, is_active = :is_active, updated_at = :updated_at
WHERE id = :id'
);
$statement->execute([
'id' => $id,
'name' => $data['name'],
'timezone' => $data['timezone'],
'federal_state' => $data['federal_state'] ?? 'BE',
'location_label' => $data['location_label'] ?? '',
'latitude' => $data['latitude'] !== '' ? $data['latitude'] : null,
'longitude' => $data['longitude'] !== '' ? $data['longitude'] : null,
'is_active' => !empty($data['is_active']) ? 1 : 0,
'updated_at' => $this->now(),
]);
return $this->find($id);
}
public function updateForTenantAdmin(int $id, array $data): ?array
{
$current = $this->find($id);
if ($current === null) {
return null;
}
return $this->update($id, [
'name' => $data['name'] ?? $current['name'],
'timezone' => $data['timezone'] ?? $current['timezone'],
'federal_state' => $data['federal_state'] ?? ($current['federal_state'] ?? 'BE'),
'location_label' => $data['location_label'] ?? ($current['location_label'] ?? ''),
'latitude' => array_key_exists('latitude', $data) ? $data['latitude'] : ($current['latitude'] ?? null),
'longitude' => array_key_exists('longitude', $data) ? $data['longitude'] : ($current['longitude'] ?? null),
'is_active' => $current['is_active'],
]);
}
}

View File

@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
final class UserRepository extends BaseRepository
{
public function allForTenant(int $tenantId): array
{
$statement = $this->db->prepare(
'SELECT id, tenant_id, role, email, first_name, last_name, is_active, created_at, updated_at
FROM users WHERE tenant_id = :tenant_id ORDER BY role, last_name, first_name'
);
$statement->execute(['tenant_id' => $tenantId]);
return $statement->fetchAll();
}
public function allUsers(): array
{
return $this->db->query(
'SELECT u.id, u.tenant_id, u.role, u.email, u.first_name, u.last_name, u.is_active,
u.created_at, u.updated_at, t.name AS tenant_name
FROM users u
LEFT JOIN tenants t ON t.id = u.tenant_id
ORDER BY COALESCE(t.name, "System"), u.role, u.last_name, u.first_name'
)->fetchAll();
}
public function findByEmail(string $email): ?array
{
$statement = $this->db->prepare(
'SELECT u.*, t.name AS tenant_name, t.timezone AS tenant_timezone, t.is_active AS tenant_is_active
FROM users u
LEFT JOIN tenants t ON t.id = u.tenant_id
WHERE u.email = :email'
);
$statement->execute(['email' => $email]);
return $statement->fetch() ?: null;
}
public function findById(int $id): ?array
{
$statement = $this->db->prepare(
'SELECT u.*, t.name AS tenant_name, t.timezone AS tenant_timezone, t.is_active AS tenant_is_active
FROM users u
LEFT JOIN tenants t ON t.id = u.tenant_id
WHERE u.id = :id'
);
$statement->execute(['id' => $id]);
return $statement->fetch() ?: null;
}
public function create(array $data): array
{
$timestamp = $this->now();
$statement = $this->db->prepare(
'INSERT INTO users
(tenant_id, role, email, password_hash, first_name, last_name, is_active, created_at, updated_at)
VALUES (:tenant_id, :role, :email, :password_hash, :first_name, :last_name, :is_active, :created_at, :updated_at)'
);
$statement->execute([
'tenant_id' => $data['tenant_id'],
'role' => $data['role'],
'email' => $data['email'],
'password_hash' => password_hash($data['password'], PASSWORD_DEFAULT),
'first_name' => $data['first_name'],
'last_name' => $data['last_name'],
'is_active' => !empty($data['is_active']) ? 1 : 0,
'created_at' => $timestamp,
'updated_at' => $timestamp,
]);
return $this->findById((int) $this->lastInsertId());
}
public function update(int $id, array $data): ?array
{
$current = $this->findById($id);
if ($current === null) {
return null;
}
$passwordHash = $current['password_hash'];
if (!empty($data['password'])) {
$passwordHash = password_hash($data['password'], PASSWORD_DEFAULT);
}
$statement = $this->db->prepare(
'UPDATE users
SET tenant_id = :tenant_id, role = :role, email = :email, password_hash = :password_hash,
first_name = :first_name, last_name = :last_name, is_active = :is_active, updated_at = :updated_at
WHERE id = :id'
);
$statement->execute([
'id' => $id,
'tenant_id' => $data['tenant_id'],
'role' => $data['role'],
'email' => $data['email'],
'password_hash' => $passwordHash,
'first_name' => $data['first_name'],
'last_name' => $data['last_name'],
'is_active' => !empty($data['is_active']) ? 1 : 0,
'updated_at' => $this->now(),
]);
return $this->findById($id);
}
}

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Repositories\AppointmentRepository;
use App\Repositories\ReferenceDataRepository;
use DateInterval;
use DateTimeImmutable;
final class CalendarService
{
public function validate(int $tenantId, array $payload, ?int $appointmentId = null): array
{
$appointmentRepository = new AppointmentRepository();
$lessonType = (new ReferenceDataRepository())->findLessonType($tenantId, (int) $payload['lesson_type_id']);
$warnings = [
'conflicts' => [],
'rest_period_warnings' => [],
];
$conflicts = $appointmentRepository->overlappingForInstructor(
$tenantId,
(int) $payload['instructor_id'],
$payload['start_at'],
$payload['end_at'],
$appointmentId
);
foreach ($conflicts as $conflict) {
$warnings['conflicts'][] = [
'type' => 'instructor_overlap',
'message' => 'Fahrlehrer ist in diesem Zeitraum bereits verplant.',
'appointment_id' => (int) $conflict['id'],
];
}
if (!empty($payload['student_id'])) {
$studentConflicts = $appointmentRepository->overlappingForStudent(
$tenantId,
(int) $payload['student_id'],
$payload['start_at'],
$payload['end_at'],
$appointmentId
);
foreach ($studentConflicts as $conflict) {
$warnings['conflicts'][] = [
'type' => 'student_overlap',
'message' => 'Fahrschueler ist in diesem Zeitraum bereits verplant.',
'appointment_id' => (int) $conflict['id'],
];
}
}
if ($lessonType !== null && (int) $lessonType['is_rest_relevant'] === 1) {
$newStart = new DateTimeImmutable($payload['start_at']);
$dayStart = $newStart->setTime(0, 0, 0);
$dayEnd = $dayStart->modify('+1 day');
$hasLaterRelevantOnDay = $appointmentRepository->hasRestRelevantAfterStartOnDay(
$tenantId,
(int) $payload['instructor_id'],
$dayStart->format(DATE_ATOM),
$dayEnd->format(DATE_ATOM),
$payload['start_at']
);
if ($hasLaterRelevantOnDay) {
return $warnings;
}
$lastRelevant = $appointmentRepository->lastRestRelevantForInstructor(
$tenantId,
(int) $payload['instructor_id'],
$payload['start_at'],
$appointmentId
);
if ($lastRelevant !== null) {
$restEnds = (new DateTimeImmutable($lastRelevant['end_at']))->add(new DateInterval('PT11H'));
if ($newStart < $restEnds) {
$warnings['rest_period_warnings'][] = [
'type' => 'rest_period',
'message' => 'Achtung: Dieser Termin liegt innerhalb der 11-Stunden-Ruhezeit.',
'rest_until' => $restEnds->format(DATE_ATOM),
'blocking_appointment_id' => (int) $lastRelevant['id'],
];
}
}
}
return $warnings;
}
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Services;
use DateInterval;
use DateTimeImmutable;
use DateTimeZone;
final class HolidayService
{
private const STATES_WITH_EPIPHANY = ['BW', 'BY', 'ST'];
private const STATES_WITH_WOMENS_DAY = ['BE', 'MV'];
private const STATES_WITH_CORPUS_CHRISTI = ['BW', 'BY', 'HE', 'NW', 'RP', 'SL'];
private const STATES_WITH_ASSUMPTION = ['BY', 'SL'];
private const STATES_WITH_REFORMATION = ['BB', 'HB', 'HH', 'MV', 'NI', 'SN', 'ST', 'SH', 'TH'];
private const STATES_WITH_ALL_SAINTS = ['BW', 'BY', 'NW', 'RP', 'SL'];
private const STATES_WITH_REPENTANCE = ['SN'];
public function forRange(array $tenant, string $from, string $to): array
{
$timezone = new DateTimeZone((string) ($tenant['timezone'] ?? 'Europe/Berlin'));
$state = strtoupper((string) ($tenant['federal_state'] ?? 'BE'));
$start = (new DateTimeImmutable($from, new DateTimeZone('UTC')))->setTimezone($timezone)->setTime(0, 0);
$end = (new DateTimeImmutable($to, new DateTimeZone('UTC')))->setTimezone($timezone)->setTime(0, 0);
$years = [];
$day = $start;
while ($day <= $end) {
$years[(int) $day->format('Y')] = true;
$day = $day->add(new DateInterval('P1D'));
}
$holidaysByDate = [];
foreach (array_keys($years) as $year) {
foreach ($this->holidaysForYear((int) $year, $state, $timezone) as $date => $name) {
$holidaysByDate[$date] = $name;
}
}
$result = [];
$day = $start;
while ($day < $end) {
$key = $day->format('Y-m-d');
$result[$key] = [
'isSunday' => (int) $day->format('N') === 7,
'holidayName' => $holidaysByDate[$key] ?? null,
];
$day = $day->add(new DateInterval('P1D'));
}
return $result;
}
private function holidaysForYear(int $year, string $state, DateTimeZone $timezone): array
{
$easter = (new DateTimeImmutable('@' . (string) easter_date($year)))->setTimezone($timezone)->setTime(0, 0);
$holidays = [
$this->dateKey($year, 1, 1, $timezone) => 'Neujahr',
$easter->sub(new DateInterval('P2D'))->format('Y-m-d') => 'Karfreitag',
$easter->add(new DateInterval('P1D'))->format('Y-m-d') => 'Ostermontag',
$this->dateKey($year, 5, 1, $timezone) => 'Tag der Arbeit',
$easter->add(new DateInterval('P39D'))->format('Y-m-d') => 'Christi Himmelfahrt',
$easter->add(new DateInterval('P50D'))->format('Y-m-d') => 'Pfingstmontag',
$this->dateKey($year, 10, 3, $timezone) => 'Tag der Deutschen Einheit',
$this->dateKey($year, 12, 25, $timezone) => '1. Weihnachtstag',
$this->dateKey($year, 12, 26, $timezone) => '2. Weihnachtstag',
];
if (in_array($state, self::STATES_WITH_EPIPHANY, true)) {
$holidays[$this->dateKey($year, 1, 6, $timezone)] = 'Heilige Drei Koenige';
}
if (in_array($state, self::STATES_WITH_WOMENS_DAY, true)) {
$holidays[$this->dateKey($year, 3, 8, $timezone)] = 'Internationaler Frauentag';
}
if (in_array($state, self::STATES_WITH_CORPUS_CHRISTI, true)) {
$holidays[$easter->add(new DateInterval('P60D'))->format('Y-m-d')] = 'Fronleichnam';
}
if (in_array($state, self::STATES_WITH_ASSUMPTION, true)) {
$holidays[$this->dateKey($year, 8, 15, $timezone)] = 'Mariae Himmelfahrt';
}
if (in_array($state, self::STATES_WITH_REFORMATION, true)) {
$holidays[$this->dateKey($year, 10, 31, $timezone)] = 'Reformationstag';
}
if (in_array($state, self::STATES_WITH_ALL_SAINTS, true)) {
$holidays[$this->dateKey($year, 11, 1, $timezone)] = 'Allerheiligen';
}
if (in_array($state, self::STATES_WITH_REPENTANCE, true)) {
$holidays[$this->repentanceDay($year, $timezone)->format('Y-m-d')] = 'Buss- und Bettag';
}
return $holidays;
}
private function dateKey(int $year, int $month, int $day, DateTimeZone $timezone): string
{
return (new DateTimeImmutable(sprintf('%04d-%02d-%02d', $year, $month, $day), $timezone))->format('Y-m-d');
}
private function repentanceDay(int $year, DateTimeZone $timezone): DateTimeImmutable
{
$day = new DateTimeImmutable(sprintf('%04d-11-23', $year), $timezone);
while ((int) $day->format('N') !== 3) {
$day = $day->sub(new DateInterval('P1D'));
}
return $day;
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Repositories\AppointmentRepository;
use App\Repositories\StudentRepository;
use App\Support\Database;
final class RequirementService
{
public function progressForStudent(int $studentId): array
{
$db = Database::connection();
$snapshots = (new StudentRepository())->requirements($studentId);
$statement = $db->prepare(
'SELECT au.requirement_key,
SUM(CASE WHEN a.status = "planned" THEN au.units_counted ELSE 0 END) AS planned_units,
SUM(CASE WHEN a.status = "completed" THEN au.units_counted ELSE 0 END) AS completed_units
FROM appointment_units au
JOIN appointments a ON a.id = au.appointment_id
WHERE a.student_id = :student_id
GROUP BY au.requirement_key'
);
$statement->execute(['student_id' => $studentId]);
$totals = [];
foreach ($statement->fetchAll() as $row) {
$totals[$row['requirement_key']] = [
'planned' => (int) $row['planned_units'],
'completed' => (int) $row['completed_units'],
];
}
return array_map(static function (array $snapshot) use ($totals): array {
$row = $totals[$snapshot['requirement_key']] ?? ['planned' => 0, 'completed' => 0];
$priorCompleted = (int) ($snapshot['prior_completed_units'] ?? 0);
$appointmentCompleted = (int) $row['completed'];
$completed = $priorCompleted + $appointmentCompleted;
$open = (int) $snapshot['required_units'] - $row['planned'] - $completed;
return [
'requirement_key' => $snapshot['requirement_key'],
'phase_label' => $snapshot['phase_label'] ?? '',
'label' => $snapshot['label'],
'required_units' => (int) $snapshot['required_units'],
'planned_units' => $row['planned'],
'completed_units' => $completed,
'prior_completed_units' => $priorCompleted,
'appointment_completed_units' => $appointmentCompleted,
'open_units' => $open,
];
}, $snapshots);
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Services;
use DateInterval;
use DateTimeImmutable;
use DateTimeZone;
final class SunTimesService
{
public function forRange(array $tenant, string $from, string $to): array
{
$latitude = isset($tenant['latitude']) ? (float) $tenant['latitude'] : null;
$longitude = isset($tenant['longitude']) ? (float) $tenant['longitude'] : null;
$timezoneName = (string) ($tenant['timezone'] ?? 'Europe/Berlin');
if ($latitude === null || $longitude === null || $latitude === 0.0 && $longitude === 0.0) {
return [];
}
$timezone = new DateTimeZone($timezoneName);
$start = new DateTimeImmutable($from, new DateTimeZone('UTC'));
$end = new DateTimeImmutable($to, new DateTimeZone('UTC'));
$day = $start->setTimezone($timezone)->setTime(0, 0);
$lastDay = $end->setTimezone($timezone)->setTime(0, 0);
$result = [];
while ($day < $lastDay) {
$solar = date_sun_info($day->setTime(12, 0)->getTimestamp(), $latitude, $longitude);
$key = $day->format('Y-m-d');
$result[$key] = [
'sunrise' => $this->formatSolarTimestamp($solar['sunrise'] ?? false, $timezone),
'sunset' => $this->formatSolarTimestamp($solar['sunset'] ?? false, $timezone),
];
$day = $day->add(new DateInterval('P1D'));
}
return $result;
}
private function formatSolarTimestamp(int|float|bool $timestamp, DateTimeZone $timezone): ?string
{
if (!is_int($timestamp) && !is_float($timestamp)) {
return null;
}
return (new DateTimeImmutable('@' . (string) (int) $timestamp))
->setTimezone($timezone)
->format('H:i');
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Repositories\UserRepository;
final class Auth
{
public static function user(): ?array
{
$userId = $_SESSION['user_id'] ?? null;
if (!$userId) {
return null;
}
return (new UserRepository())->findById((int) $userId);
}
public static function requireUser(): array
{
$user = self::user();
if ($user === null) {
Response::json(['message' => 'Unauthenticated'], 401);
}
return $user;
}
public static function requireRole(array|string $roles): array
{
$user = self::requireUser();
$roles = (array) $roles;
if (!in_array($user['role'], $roles, true)) {
Response::json(['message' => 'Forbidden'], 403);
}
return $user;
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Support;
use PDO;
use PDOException;
final class Database
{
private static ?PDO $connection = null;
private static array $config = [];
public static function configure(array $config): void
{
self::$config = $config;
self::$connection = null; // reset on reconfigure
}
public static function connection(): PDO
{
if (self::$connection instanceof PDO) {
return self::$connection;
}
$driver = self::$config['db_driver'] ?? 'sqlite';
if ($driver === 'pgsql') {
$host = self::$config['db_host'] ?? '127.0.0.1';
$port = self::$config['db_port'] ?? '5432';
$dbname = self::$config['db_database'] ?? '';
$user = self::$config['db_username'] ?? '';
$password = self::$config['db_password'] ?? '';
$dsn = "pgsql:host={$host};port={$port};dbname={$dbname}";
self::$connection = new PDO($dsn, $user, $password);
} else {
$path = self::$config['db_path'] ?? self::$config['db_path'] ?? '';
$directory = dirname($path);
if (!is_dir($directory)) {
mkdir($directory, 0775, true);
}
self::$connection = new PDO('sqlite:' . $path);
self::$connection->exec('PRAGMA foreign_keys = ON');
}
self::$connection->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
self::$connection->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
return self::$connection;
}
public static function isPostgres(): bool
{
return (self::$config['db_driver'] ?? 'sqlite') === 'pgsql';
}
public static function lastInsertId(): string
{
if (self::isPostgres()) {
return self::$connection->query('SELECT lastval()')->fetchColumn();
}
return self::$connection->lastInsertId();
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Support;
final class Request
{
public function method(): string
{
return strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
}
public function path(): string
{
$uri = $_SERVER['REQUEST_URI'] ?? '/';
$path = parse_url($uri, PHP_URL_PATH) ?: '/';
return rtrim($path, '/') ?: '/';
}
public function query(string $key, mixed $default = null): mixed
{
return $_GET[$key] ?? $default;
}
public function input(): array
{
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
if (str_contains($contentType, 'application/json')) {
$raw = file_get_contents('php://input');
$decoded = json_decode($raw ?: '[]', true);
return is_array($decoded) ? $decoded : [];
}
return $_POST;
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Support;
final class Response
{
public static function json(array $data, int $status = 200): never
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
exit;
}
public static function noContent(): never
{
http_response_code(204);
exit;
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Support;
final class Router
{
private array $routes = [];
public function __construct(private readonly Request $request)
{
}
public function get(string $path, callable $handler): void
{
$this->map('GET', $path, $handler);
}
public function post(string $path, callable $handler): void
{
$this->map('POST', $path, $handler);
}
public function patch(string $path, callable $handler): void
{
$this->map('PATCH', $path, $handler);
}
public function delete(string $path, callable $handler): void
{
$this->map('DELETE', $path, $handler);
}
public function dispatch(): void
{
$method = $this->request->method();
$path = $this->request->path();
foreach ($this->routes[$method] ?? [] as $route) {
$pattern = preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '(?P<$1>[^/]+)', $route['path']);
$pattern = '#^' . $pattern . '$#';
if (!preg_match($pattern, $path, $matches)) {
continue;
}
$params = array_filter($matches, static fn (string|int $key): bool => is_string($key), ARRAY_FILTER_USE_KEY);
$route['handler']($this->request, $params);
return;
}
Response::json(['message' => 'Route not found', 'path' => $path], 404);
}
private function map(string $method, string $path, callable $handler): void
{
$this->routes[$method][] = [
'path' => rtrim($path, '/') ?: '/',
'handler' => $handler,
];
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
use App\Support\Database;
use App\Support\Request;
use App\Support\Router;
const BASE_PATH = __DIR__ . '/..';
spl_autoload_register(static function (string $class): void {
$prefix = 'App\\';
if (!str_starts_with($class, $prefix)) {
return;
}
$relativeClass = substr($class, strlen($prefix));
$path = __DIR__ . '/' . str_replace('\\', '/', $relativeClass) . '.php';
if (is_file($path)) {
require_once $path;
}
});
$config = require BASE_PATH . '/config/app.php';
date_default_timezone_set('UTC');
session_name($config['session_name']);
$sessionPath = $config['session_path'];
if (!is_dir($sessionPath)) {
mkdir($sessionPath, 0775, true);
}
session_save_path($sessionPath);
session_start();
Database::configure($config);
return [
'config' => $config,
'router' => new Router(new Request()),
];