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