table = $db->prefix . 'us_lessons'; } public function insert( Lesson $lesson ): int { $this->db->insert( $this->table, [ 'slot_id' => $lesson->slotId, 'offering_id' => $lesson->offeringId, 'student_id' => $lesson->studentId, 'instructor_id' => $lesson->instructorId, 'recurrence' => $lesson->recurrence, 'series_id' => $lesson->seriesId, 'status' => $lesson->status, 'payment_id' => $lesson->paymentId, 'notes' => $lesson->notes, 'created_at' => current_time( 'mysql' ), ], [ '%d', '%d', '%d', '%d', '%s', '%d', '%s', '%d', '%s', '%s' ] ); return $this->db->insert_id; } /** * Create a weekly lesson series — one lesson per slot, all sharing a * `series_id` (the id of the first lesson row). * * @param list $slotIds Availability slot IDs to reserve, in order. * @return list Inserted lesson IDs. */ public function insertSeries( Lesson $template, array $slotIds ): array { $ids = []; $seriesId = 0; foreach ( $slotIds as $slotId ) { $id = $this->insert( new Lesson( slotId: $slotId, studentId: $template->studentId, instructorId: $template->instructorId, offeringId: $template->offeringId, recurrence: Lesson::RECURRENCE_WEEKLY, seriesId: $seriesId > 0 ? $seriesId : null, status: $template->status, notes: $template->notes, ) ); if ( 0 === $seriesId ) { $seriesId = $id; $this->setSeriesId( $id, $seriesId ); } $ids[] = $id; } return $ids; } private function setSeriesId( int $id, int $seriesId ): void { $this->db->update( $this->table, [ 'series_id' => $seriesId ], [ 'id' => $id ], [ '%d' ], [ '%d' ] ); } public function findById( int $id ): ?Lesson { $row = $this->db->get_row( $this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id ) ); return $row ? Lesson::fromRow( $row ) : null; } /** * Upcoming lessons for an instructor (status != cancelled, slot in the future). * * @return list */ public function findUpcomingForInstructor( int $instructorId ): array { $avTable = str_replace( 'us_lessons', 'us_availability', $this->table ); $rows = $this->db->get_results( $this->db->prepare( 'SELECT l.* FROM %i l JOIN %i a ON a.id = l.slot_id WHERE l.instructor_id = %d AND l.status != %s AND a.start_dt >= %s ORDER BY a.start_dt ASC', $this->table, $avTable, $instructorId, Lesson::STATUS_CANCELLED, current_time( 'mysql' ) ) ); return array_map( Lesson::fromRow( ... ), $rows ?? [] ); } /** * Upcoming lessons for a student (status != cancelled, slot in the future). * * @return list */ public function findUpcomingForStudent( int $studentId ): array { $avTable = str_replace( 'us_lessons', 'us_availability', $this->table ); $rows = $this->db->get_results( $this->db->prepare( 'SELECT l.* FROM %i l JOIN %i a ON a.id = l.slot_id WHERE l.student_id = %d AND l.status != %s AND a.start_dt >= %s ORDER BY a.start_dt ASC', $this->table, $avTable, $studentId, Lesson::STATUS_CANCELLED, current_time( 'mysql' ) ) ); return array_map( Lesson::fromRow( ... ), $rows ?? [] ); } /** * Count a student's upcoming, non-cancelled lessons (slot in the future). */ public function countUpcomingForStudent( int $studentId ): int { $avTable = str_replace( 'us_lessons', 'us_availability', $this->table ); return (int) $this->db->get_var( $this->db->prepare( 'SELECT COUNT(*) FROM %i l JOIN %i a ON a.id = l.slot_id WHERE l.student_id = %d AND l.status != %s AND a.start_dt >= %s', $this->table, $avTable, $studentId, Lesson::STATUS_CANCELLED, current_time( 'mysql' ) ) ); } /** * All lessons for a student. * * @return list */ public function findByStudent( int $studentId ): array { $rows = $this->db->get_results( $this->db->prepare( 'SELECT * FROM %i WHERE student_id = %d ORDER BY created_at DESC', $this->table, $studentId ) ); return array_map( Lesson::fromRow( ... ), $rows ?? [] ); } /** * All upcoming lessons across all instructors (admin view). * * @return list */ public function findAllUpcoming(): array { $avTable = str_replace( 'us_lessons', 'us_availability', $this->table ); $rows = $this->db->get_results( $this->db->prepare( 'SELECT l.* FROM %i l JOIN %i a ON a.id = l.slot_id WHERE l.status != %s AND a.start_dt >= %s ORDER BY a.start_dt ASC', $this->table, $avTable, Lesson::STATUS_CANCELLED, current_time( 'mysql' ) ) ); return array_map( Lesson::fromRow( ... ), $rows ?? [] ); } /** * Not-yet-billed lessons on a scheduled-billing (weekly / monthly) offering: * status not cancelled and no payment attached yet. Each row carries the slot * start time and the offering's billing fields so the daily billing scan can * decide what is due without a second query per lesson. Ordered by student, * offering and time so the scan can group a student's monthly lessons cheaply. * * @return list<\stdClass> Rows: id, student_id, instructor_id, offering_id, * start_dt, billing_mode, title, price, currency, * etransfer_email. */ public function findUnbilledScheduledLessons(): array { $avTable = str_replace( 'us_lessons', 'us_availability', $this->table ); $offTable = str_replace( 'us_lessons', 'us_offerings', $this->table ); $rows = $this->db->get_results( $this->db->prepare( 'SELECT l.id, l.student_id, l.instructor_id, l.offering_id, a.start_dt, o.billing_mode, o.title, o.price, o.currency, o.etransfer_email FROM %i l JOIN %i a ON a.id = l.slot_id JOIN %i o ON o.id = l.offering_id WHERE l.status != %s AND l.payment_id IS NULL AND o.billing_mode IN ( %s, %s ) ORDER BY l.student_id ASC, l.offering_id ASC, a.start_dt ASC', $this->table, $avTable, $offTable, Lesson::STATUS_CANCELLED, \Unsupervised\Schedular\Offering\Offering::BILLING_WEEKLY, \Unsupervised\Schedular\Offering\Offering::BILLING_MONTHLY ) ); return $rows ?? []; } /** * How many lessons a payment covers — every lesson pointed at it, cancelled or * not, since the payment was billed for all of them. Used to split a paid * payment's total into a per-lesson share when one covered lesson is cancelled * and credited. Never below zero. */ public function countByPaymentId( int $paymentId ): int { return (int) $this->db->get_var( $this->db->prepare( 'SELECT COUNT(*) FROM %i WHERE payment_id = %d', $this->table, $paymentId ) ); } /** * How many lessons belong to a weekly series — the whole reservation an upfront * (full-term) payment covers, so cancelling one lesson credits its per-lesson * share. Counts every lesson in the series, cancelled or not. */ public function countBySeries( int $seriesId ): int { return (int) $this->db->get_var( $this->db->prepare( 'SELECT COUNT(*) FROM %i WHERE series_id = %d', $this->table, $seriesId ) ); } public function setPaymentId( int $id, int $paymentId ): bool { return false !== $this->db->update( $this->table, [ 'payment_id' => $paymentId ], [ 'id' => $id ], [ '%d' ], [ '%d' ] ); } /** * Update every non-cancelled lesson in a weekly series at once — e.g. * confirming the whole reservation when its single upfront payment settles. */ public function updateStatusForSeries( int $seriesId, string $status ): bool { if ( ! in_array( $status, Lesson::VALID_STATUSES, true ) ) { return false; } $sql = $this->db->prepare( 'UPDATE %i SET status = %s WHERE series_id = %d AND status != %s', $this->table, $status, $seriesId, Lesson::STATUS_CANCELLED ); return null !== $sql && false !== $this->db->query( $sql ); } public function updateStatus( int $id, string $status ): bool { if ( ! in_array( $status, Lesson::VALID_STATUSES, true ) ) { return false; } return (bool) $this->db->update( $this->table, [ 'status' => $status ], [ 'id' => $id ], [ '%s' ], [ '%d' ] ); } }