prefix . 'boardit_courts'; } /** * Tabel met reserveringen. * * @return string */ public static function get_reservations_table() { global $wpdb; return $wpdb->prefix . 'boardit_reservations'; } /** * Run dbDelta to add any missing columns when the DB schema is updated. * Compares against DB_VERSION (not BOARDIT_VERSION) so schema changes * are always picked up even without a plugin version bump. */ public static function maybe_upgrade() { $installed = get_option( 'boardit_db_version', '' ); if ( self::DB_VERSION !== $installed ) { self::create_tables(); self::migrate_extra_slot_setting(); update_option( 'boardit_db_version', self::DB_VERSION ); } } /** * Maakt de tabellen aan (of werkt ze bij) met dbDelta. */ public static function create_tables() { global $wpdb; $charset_collate = $wpdb->get_charset_collate(); // Opmaak zoals dbDelta() die verwacht: "CREATE TABLE naam (", één kolom per regel, // twee spaties na PRIMARY KEY en geen standaardwaarde bij TEXT (mag niet op MySQL 5.7). $courts_table = self::get_courts_table(); $sql_courts = "CREATE TABLE $courts_table ( id mediumint(9) NOT NULL AUTO_INCREMENT, name varchar(100) NOT NULL, description text, is_active tinyint(1) NOT NULL DEFAULT 1, sort_order int(11) NOT NULL DEFAULT 0, sport_type varchar(20) NOT NULL DEFAULT 'tennis', min_players tinyint(1) NOT NULL DEFAULT 1, max_players tinyint(1) NOT NULL DEFAULT 4, slot_minutes smallint(5) NOT NULL DEFAULT 0, allow_extra_slot tinyint(1) NOT NULL DEFAULT 0, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) $charset_collate;"; $reservations_table = self::get_reservations_table(); $sql_reservations = "CREATE TABLE $reservations_table ( id mediumint(9) NOT NULL AUTO_INCREMENT, court_id mediumint(9) NOT NULL, user_id bigint(20) DEFAULT NULL, date date NOT NULL, start_time time NOT NULL, end_time time NOT NULL, players_count tinyint(1) NOT NULL DEFAULT 1, extra_15min tinyint(1) NOT NULL DEFAULT 0, player_name varchar(100) NOT NULL DEFAULT '', partner_name varchar(200) DEFAULT '', partner_ids text, status varchar(20) NOT NULL DEFAULT 'confirmed', notes text, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY court_id (court_id), KEY user_id (user_id), KEY date (date) ) $charset_collate;"; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta( $sql_courts ); dbDelta( $sql_reservations ); // dbDelta voegt geen kolommen toe aan bestaande tabellen; doe dat expliciet. self::add_missing_columns(); } /** * Voeg ontbrekende kolommen toe aan de reserveringstabel via ALTER TABLE. * Elke kolom wordt alleen toegevoegd als hij nog niet bestaat. */ private static function add_missing_columns() { global $wpdb; $table = self::get_reservations_table(); // Haal bestaande kolomnamen op; leeg als de tabel (nog) niet bestaat. $existing = $wpdb->get_col( $wpdb->prepare( 'SHOW COLUMNS FROM %i', $table ), 0 ); if ( empty( $existing ) ) { return; } $to_add = array( 'players_count' => 'ADD COLUMN `players_count` tinyint(1) NOT NULL DEFAULT 1 AFTER `end_time`', 'extra_15min' => 'ADD COLUMN `extra_15min` tinyint(1) NOT NULL DEFAULT 0 AFTER `players_count`', 'player_name' => "ADD COLUMN `player_name` varchar(100) NOT NULL DEFAULT '' AFTER `extra_15min`", 'partner_name' => "ADD COLUMN `partner_name` varchar(200) DEFAULT '' AFTER `player_name`", 'partner_ids' => 'ADD COLUMN `partner_ids` text AFTER `partner_name`', 'notes' => 'ADD COLUMN `notes` text AFTER `partner_ids`', ); foreach ( $to_add as $col => $clause ) { if ( ! in_array( $col, $existing, true ) ) { $wpdb->query( $wpdb->prepare( 'ALTER TABLE %i ' . $clause, $table ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $clause komt uit de vaste lijst hierboven. } } // Zorg dat user_id NULL toestaat (voor geanonimiseerde reserveringen). $col_info = $wpdb->get_row( $wpdb->prepare( 'SHOW COLUMNS FROM %i LIKE %s', $table, 'user_id' ) ); if ( $col_info && false !== strpos( $col_info->Null, 'NO' ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- kolomnaam van MySQL. $wpdb->query( $wpdb->prepare( 'ALTER TABLE %i MODIFY `user_id` bigint(20) DEFAULT NULL', $table ) ); } // Ontbrekende kolommen in de courts-tabel. $courts = self::get_courts_table(); $existing_courts = $wpdb->get_col( $wpdb->prepare( 'SHOW COLUMNS FROM %i', $courts ), 0 ); if ( ! empty( $existing_courts ) ) { $courts_to_add = array( 'sport_type' => "ADD COLUMN `sport_type` varchar(20) NOT NULL DEFAULT 'tennis' AFTER `sort_order`", 'min_players' => 'ADD COLUMN `min_players` tinyint(1) NOT NULL DEFAULT 1 AFTER `sport_type`', 'max_players' => 'ADD COLUMN `max_players` tinyint(1) NOT NULL DEFAULT 4 AFTER `min_players`', 'slot_minutes' => 'ADD COLUMN `slot_minutes` smallint(5) NOT NULL DEFAULT 0 AFTER `max_players`', 'allow_extra_slot' => 'ADD COLUMN `allow_extra_slot` tinyint(1) NOT NULL DEFAULT 0 AFTER `slot_minutes`', ); foreach ( $courts_to_add as $col => $clause ) { if ( ! in_array( $col, $existing_courts, true ) ) { $wpdb->query( $wpdb->prepare( 'ALTER TABLE %i ' . $clause, $courts ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $clause komt uit de vaste lijst hierboven. } } // 1.9: tennis-banen mogen nooit een vaste slotduur hebben; reset naar 0. $wpdb->query( $wpdb->prepare( "UPDATE %i SET `slot_minutes` = 0 WHERE `sport_type` = 'tennis'", $courts ) ); } } /** * Eenmalige migratie (DB 2.0): de vroegere globale optie boardit_allow_extra_slot * wordt omgezet naar de per-baan kolom allow_extra_slot. * * Stond de optie aan, dan krijgen alle tennisbanen met variabele duur het * vinkje; padel- en pickleballbanen niet. Daarna wordt de optie verwijderd, * zodat een tweede aanroep niets meer doet. * * @return int Aantal banen dat is aangezet. */ public static function migrate_extra_slot_setting(): int { global $wpdb; $old = get_option( 'boardit_allow_extra_slot', null ); if ( null === $old ) { return 0; } $updated = 0; if ( 1 === intval( $old ) ) { $updated = (int) $wpdb->query( $wpdb->prepare( "UPDATE %i SET `allow_extra_slot` = 1 WHERE `sport_type` = 'tennis' AND `slot_minutes` = 0", self::get_courts_table() ) ); } delete_option( 'boardit_allow_extra_slot' ); return $updated; } /** * Banen in bordvolgorde (sort_order, id). * * @param bool $active_only Alleen actieve banen. * @return array */ public static function get_courts( $active_only = true ) { global $wpdb; $table = self::get_courts_table(); if ( $active_only ) { return $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE is_active = 1 ORDER BY sort_order ASC, id ASC', $table ) ); } return $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i ORDER BY sort_order ASC, id ASC', $table ) ); } /** * Eén baan. * * @param int $court_id Baan-ID. * @return object|null */ public static function get_court( $court_id ) { global $wpdb; return $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', self::get_courts_table(), $court_id ) ); } /** * Voegt een baan toe. * * @param array $data Kolommen en waarden. * @return int Nieuw ID. */ public static function insert_court( $data ) { global $wpdb; $wpdb->insert( self::get_courts_table(), $data ); return $wpdb->insert_id; } /** * Werkt een baan bij. * * @param int $id Baan-ID. * @param array $data Kolommen en waarden. * @return int|false Aantal gewijzigde rijen of false. */ public static function update_court( $id, $data ) { global $wpdb; return $wpdb->update( self::get_courts_table(), $data, array( 'id' => $id ) ); } /** * Verwijdert een baan. * * @param int $id Baan-ID. * @return int|false Aantal verwijderde rijen of false. */ public static function delete_court( $id ) { global $wpdb; return $wpdb->delete( self::get_courts_table(), array( 'id' => $id ) ); } /** * Bevestigde reserveringen op een datum, met de naam van de boeker. * * @param string $date Datum (Y-m-d). * @param int|null $court_id Optioneel: alleen deze baan. * @return array */ public static function get_reservations_for_date( $date, $court_id = null ) { global $wpdb; $table = self::get_reservations_table(); $deleted_label = __( 'Deleted user', 'boardit' ); if ( $court_id ) { return $wpdb->get_results( $wpdb->prepare( "SELECT r.*, COALESCE(u.display_name, %s) AS display_name FROM %i r LEFT JOIN {$wpdb->users} u ON r.user_id = u.ID WHERE r.date = %s AND r.court_id = %d AND r.status = 'confirmed' ORDER BY r.start_time ASC", $deleted_label, $table, $date, $court_id ) ); } return $wpdb->get_results( $wpdb->prepare( "SELECT r.*, COALESCE(u.display_name, %s) AS display_name FROM %i r LEFT JOIN {$wpdb->users} u ON r.user_id = u.ID WHERE r.date = %s AND r.status = 'confirmed' ORDER BY r.court_id ASC, r.start_time ASC", $deleted_label, $table, $date ) ); } /** * Eén reservering. * * @param int $id Reservering-ID. * @return object|null */ public static function get_reservation( $id ) { global $wpdb; return $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', self::get_reservations_table(), $id ) ); } /** * Voegt een reservering toe. * * @param array $data Kolommen en waarden. * @return int Nieuw ID, of 0 bij een databasefout. */ public static function insert_reservation( $data ) { global $wpdb; // Zet show_errors UIT zodat MySQL-fouten niet als HTML in de AJAX-response belanden. $prev_show = $wpdb->show_errors; $wpdb->show_errors = false; $result = $wpdb->insert( self::get_reservations_table(), $data ); $wpdb->show_errors = $prev_show; if ( false === $result ) { // Bewaar de fout in een transient zodat de admin hem kan zien. set_transient( 'boardit_last_db_error', $wpdb->last_error, 300 ); return 0; } return $wpdb->insert_id; } /** * Geef de laatste opgeslagen DB-foutmelding terug (voor diagnose). * * @return string */ public static function get_last_db_error() { $error = get_transient( 'boardit_last_db_error' ); return $error ? $error : ''; } /** * Annuleert een bevestigde reservering. * * @param int $id Reservering-ID. * @param int|null $user_id Alleen als deze gebruiker de boeker is (beheerders mogen altijd). * @return int|false Aantal gewijzigde rijen of false. */ public static function cancel_reservation( $id, $user_id = null ) { global $wpdb; $where = array( 'id' => $id, 'status' => 'confirmed', ); if ( $user_id && ! current_user_can( 'manage_options' ) && ! boardit_can_manage() ) { $where['user_id'] = $user_id; } return $wpdb->update( self::get_reservations_table(), array( 'status' => 'cancelled' ), $where ); } /** * Werkt een reservering bij. * * @param int $id Reservering-ID. * @param array $data Kolommen en waarden. * @return bool */ public static function update_reservation( int $id, array $data ): bool { global $wpdb; return false !== $wpdb->update( self::get_reservations_table(), $data, array( 'id' => $id ) ); } /** * Is het tijdvak vrij (geen reservering, en volgens Pro geen vaste reservering)? * * @param int $court_id Baan-ID. * @param string $date Datum (Y-m-d). * @param string $start_time Begintijd (H:i). * @param string $end_time Eindtijd (H:i). * @param int|null $exclude_id Reservering die niet meetelt (bij verplaatsen). * @return bool */ public static function is_slot_available( $court_id, $date, $start_time, $end_time, $exclude_id = null ) { global $wpdb; // 1. Gewone reserveringen. Zonder $exclude_id is het 0: "id != 0" sluit dan niets uit. $count = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE court_id = %d AND date = %s AND status = 'confirmed' AND start_time < %s AND end_time > %s AND id != %d", self::get_reservations_table(), $court_id, $date, $end_time, $start_time, (int) $exclude_id ) ); if ( intval( $count ) > 0 ) { return false; } /** * Is het tijdvak vrij? Pro maakt het bezet bij een vaste reservering. * * @param bool $available Standaard true (geen gewone reservering). * @param int $court_id Baan-ID. * @param string $date Datum (Y-m-d). * @param string $start_time Begintijd (H:i). * @param string $end_time Eindtijd (H:i). */ return (bool) apply_filters( 'boardit_slot_available', true, (int) $court_id, $date, $start_time, $end_time ); } /** * Aantal aankomende (bevestigde) reserveringen voor een gebruiker, * zowel als hoofdboeker als als meespeler (via partner_ids). * * @param int $user_id Gebruikers-ID. * @return int */ public static function get_user_upcoming_count( $user_id ) { global $wpdb; $user_id = intval( $user_id ); return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE status = 'confirmed' AND ( date > CURDATE() OR ( date = CURDATE() AND end_time > CURTIME() ) ) AND ( user_id = %d OR FIND_IN_SET(%d, partner_ids) > 0 )", self::get_reservations_table(), $user_id, $user_id ) ); } /** * Reserveringen van een gebruiker (als boeker), met de naam van de baan. * * @param int $user_id Gebruikers-ID. * @param bool $upcoming_only Alleen reserveringen die nog niet voorbij zijn. * @return array */ public static function get_user_reservations( $user_id, $upcoming_only = true ) { global $wpdb; $table = self::get_reservations_table(); $courts_table = self::get_courts_table(); if ( $upcoming_only ) { return $wpdb->get_results( $wpdb->prepare( "SELECT r.*, c.name as court_name FROM %i r LEFT JOIN %i c ON r.court_id = c.id WHERE r.user_id = %d AND r.status = 'confirmed' AND ( r.date > CURDATE() OR ( r.date = CURDATE() AND r.end_time > CURTIME() ) ) ORDER BY r.date ASC, r.start_time ASC", $table, $courts_table, $user_id ) ); } return $wpdb->get_results( $wpdb->prepare( "SELECT r.*, c.name as court_name FROM %i r LEFT JOIN %i c ON r.court_id = c.id WHERE r.user_id = %d AND r.status = 'confirmed' ORDER BY r.date ASC, r.start_time ASC", $table, $courts_table, $user_id ) ); } // ------------------------------------------------------------------------- // Ledenbeheer // ------------------------------------------------------------------------- /** * Haal alle WordPress-gebruikers op met hun reserveringsstatistieken. * * Zonder zoekterm is het patroon '%%': dat past op elke naam en elk e-mailadres. * * @param array $args Optionele argumenten: 'search' (string). * @return array */ public static function get_members( $args = array() ) { global $wpdb; $like = '%' . $wpdb->esc_like( (string) ( $args['search'] ?? '' ) ) . '%'; return $wpdb->get_results( $wpdb->prepare( "SELECT u.ID, u.display_name, u.user_email, u.user_registered, COALESCE(um.meta_value, 0) AS boardit_blocked, COUNT(DISTINCT r.id) AS total_reservations, SUM(CASE WHEN r.status = 'confirmed' AND ( r.date > CURDATE() OR ( r.date = CURDATE() AND r.end_time > CURTIME() ) ) THEN 1 ELSE 0 END) AS upcoming_reservations FROM {$wpdb->users} u LEFT JOIN %i r ON r.user_id = u.ID LEFT JOIN {$wpdb->usermeta} um ON um.user_id = u.ID AND um.meta_key = 'boardit_blocked' WHERE (u.display_name LIKE %s OR u.user_email LIKE %s) GROUP BY u.ID, u.display_name, u.user_email, u.user_registered, um.meta_value ORDER BY u.display_name ASC", self::get_reservations_table(), $like, $like ) ); } /** * Blokkeer of deblokkeer een lid voor het maken van reserveringen. * * @param int $user_id WordPress gebruikers-ID. * @param bool $blocked true = blokkeren, false = deblokkeren. * @return bool */ public static function set_member_blocked( $user_id, $blocked ) { update_user_meta( intval( $user_id ), 'boardit_blocked', $blocked ? 1 : 0 ); return true; } /** * Controleer of een lid geblokkeerd is. * * @param int $user_id WordPress gebruikers-ID. * @return bool */ public static function is_member_blocked( $user_id ) { return (bool) get_user_meta( intval( $user_id ), 'boardit_blocked', true ); } // ------------------------------------------------------------------------- // Medespelers // ------------------------------------------------------------------------- /** * Geeft de meest recente medespelers terug voor de opgegeven gebruiker, * op basis van de partner_ids in de reserveringshistorie. * * @param int $user_id WordPress gebruikers-ID van de booker. * @param int $limit Maximaal terug te geven aantal partners. * @param int $scan_rows Aantal recente reserveringen om te scannen. * @return array [ ['id'=>int, 'name'=>string], … ] */ public static function get_recent_partners( int $user_id, int $limit = 8, int $scan_rows = 50 ): array { global $wpdb; // Haal recente reserveringen op met partner_ids gevuld. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT partner_ids FROM %i WHERE user_id = %d AND status = 'confirmed' AND partner_ids != '' ORDER BY created_at DESC LIMIT %d", self::get_reservations_table(), $user_id, $scan_rows ) ); if ( empty( $rows ) ) { return array(); } // Tel hoe vaak elke partner voorkwam. $score = array(); foreach ( $rows as $row ) { $ids = array_filter( array_map( 'intval', explode( ',', $row->partner_ids ) ) ); foreach ( $ids as $pid ) { if ( $pid <= 0 ) { continue; } $score[ $pid ] = ( $score[ $pid ] ?? 0 ) + 1; } } if ( empty( $score ) ) { return array(); } arsort( $score ); $top_ids = array_keys( array_slice( $score, 0, $limit, true ) ); // Haal namen op uit wp_users. $placeholders = implode( ',', array_fill( 0, count( $top_ids ), '%d' ) ); $users = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $placeholders bevat alleen %d. $wpdb->prepare( "SELECT ID, display_name FROM {$wpdb->users} WHERE ID IN ($placeholders)", ...$top_ids ) ); $name_map = array(); foreach ( $users as $user ) { $name_map[ (int) $user->ID ] = $user->display_name; } // Bewaar de volgorde van top_ids. $result = array(); foreach ( $top_ids as $pid ) { if ( isset( $name_map[ $pid ] ) ) { $result[] = array( 'id' => $pid, 'name' => $name_map[ $pid ], ); } } return $result; } /** * Verwijdert de tabellen van BoardIt (bij het verwijderen van de plugin). */ public static function drop_tables() { global $wpdb; // Ook de tabellen van Pro (vaste reserveringen, betalingen), zodat er na het verwijderen niets achterblijft. foreach ( array( self::get_reservations_table(), self::get_courts_table(), $wpdb->prefix . 'boardit_fixed_reservations', $wpdb->prefix . 'boardit_payments' ) as $table ) { $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $table ) ); } } /** * Anonimiseer reserveringen ouder dan het opgegeven aantal jaren. * * Slotdata (baan, datum, tijd, spelersaantal) blijft bewaard voor statistieken. * Persoonsdata (user_id, player_name, partner_name, partner_ids) wordt gewist. * * @param int $years Retentieperiode in jaren. * @return int Aantal geanonimiseerde rijen. */ public static function anonymize_old_reservations( int $years ): int { global $wpdb; $cutoff = gmdate( 'Y-m-d', strtotime( "-{$years} years" ) ); $rows = $wpdb->query( $wpdb->prepare( "UPDATE %i SET user_id = NULL, player_name = '', partner_name = '', partner_ids = '' WHERE date < %s AND user_id IS NOT NULL", self::get_reservations_table(), $cutoff ) ); return (int) $rows; } }