Files
drivetimeplaner/www/fahrschultermin.de/app/Repositories/AppointmentRepository.php
Hermes Agent 2d6c0ad3aa Split API and frontend, migrate SQLite to PostgreSQL
- api.fahrschultermin.de: full API entry point with CORS + session auth
- fahrschultermin.de: frontend only, no more /api/v1 routing
- Database.php: PostgreSQL support (pgsql driver + lastval())
- All repositories: use Database::lastInsertId() for PG compatibility
- config/app.php: PostgreSQL connection settings + CORS config
- bootstrap.php: pass full $config to Database::configure()
- public/index.php: fetch-patch to redirect /api/v1 to api.fahrschultermin.de
- database/schema_pgsql.sql: full PostgreSQL schema (BIGSERIAL)
- database/import_pgsql.sql: 518 rows migrated from SQLite
2026-05-18 23:50:00 +02:00

291 lines
11 KiB
PHP

<?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,
(
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, 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, :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'] ?? '',
'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, 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'] ?? '',
'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(),
]);
}
}
}