Problem: Frontend JS bundle used credentials:'same-origin' which dropped cookies on cross-domain requests. Login worked (200) but the subsequent /auth/me check returned 401, leaving the user stuck on the login screen. Fix: - AuthController now sets a dtp_jwt HttpOnly cookie on login/refresh - Cookie uses Domain=.fahrschultermin.de (shared between frontend and api subdomain), Secure, SameSite=Lax, Max-Age=8h - JwtMiddleware reads JWT from Authorization header OR cookie - Added AuthController::me() endpoint (was missing, caused 500) - Logout endpoint clears the cookie - Frontend index.php patches fetch() to use credentials:'include' for all /api/v1/* calls
293 lines
11 KiB
PHP
Executable File
293 lines
11 KiB
PHP
Executable File
<?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(),
|
|
]);
|
|
}
|
|
}
|
|
}
|