table = \ABeCe\Booking\Infrastructure\Database\Tables::name( $wpdb, 'bookings' ); } public function find( int|string $key ): ?array { $wpdb = $this->wpdb; $by_id = is_int( $key ) || ctype_digit( $key ); $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE ' . ( $by_id ? 'id = %d' : 'booking_reference = %s' ), $this->table, $key ), ARRAY_A ); $this->check(); return $row ? $this->serialize( $row ) : null; } public function list( ?int $user_id, string $filter, int $page, int $per_page, string $now ): array { $wpdb = $this->wpdb; $where = ' WHERE 1=1'; $values = []; if ( null !== $user_id ) { $where .= " AND status IN ('pending','confirmed') AND customer_user_id = %d"; $values[] = $user_id; } if ( 'all' !== $filter && 'voided' !== $filter ) { $where .= " AND status <> 'voided'"; } if ( in_array( $filter, [ 'cancelled', 'no_show', 'voided' ], true ) ) { $where .= ' AND status = %s'; $values[] = $filter; } elseif ( in_array( $filter, [ 'upcoming', 'past' ], true ) ) { $where .= ' AND status <> %s AND starts_at ' . ( 'upcoming' === $filter ? '>' : '<=' ) . ' %s'; $values[] = 'cancelled'; $values[] = $now; } $items = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i' . $where . ' ORDER BY starts_at DESC, id DESC LIMIT %d OFFSET %d', $this->table, ...array_merge( $values, [ $per_page, ( $page - 1 ) * $per_page ] ) ), ARRAY_A ); $this->check(); $total = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i' . $where, $this->table, ...$values ) ); $this->check(); return [ 'items' => array_map( [ $this, 'serialize' ], $items ), 'total' => (int) $total ]; } /** Active online reservations assigned to a user count; dates are WordPress civil days converted to UTC. */ public function dailyCount(int $user,string $from,string $to): int { $wpdb = $this->wpdb; $count=$wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM %i WHERE customer_user_id=%d AND booking_source='web' AND status IN ('pending','confirmed') AND starts_at >= %s AND starts_at < %s",$this->table,$user,$from,$to));$this->check();return (int)$count; } public function customerCreate(?int $user,callable $operation): mixed { return (new CustomerBookingLockRepository($this->wpdb))->run($user,$operation); } /** Shared customer lock precedes resource transactions for creates and financial changes. */ public function customerOperation(array $customer,callable $operation): mixed {return $this->customerOperations([$customer],$operation);} public function customerOperations(array $customers,callable $operation): mixed { $keys=[]; foreach($customers as $customer){ $identity=(new OperationsRepository($this->wpdb))->identity($customer); $key=$identity['customer_user_id']??($identity['customer_member_id']?'member:'.$identity['customer_member_id']:($identity['external_contact_id']?'external:'.$identity['external_contact_id']:null)); if($key!==null)$keys[]=(string)$key; } $keys=array_values(array_unique($keys));sort($keys,SORT_STRING); return $this->customerLocks($keys,0,$operation); } private function customerLocks(array $keys,int $position,callable $operation): mixed { if(!isset($keys[$position]))return $operation(); return (new CustomerBookingLockRepository($this->wpdb))->run($keys[$position],fn()=>$this->customerLocks($keys,$position+1,$operation)); } public function customerTransaction(int $id,?array $resources,callable $operation,?array $targetCustomer=null): mixed { $before=$this->find($id); if(!$before)throw new CatalogException('abece_booking_not_found',__('The booking was not found.','abece-booking'),404); return $this->customerOperations($targetCustomer===null?[$before]:[$before,array_replace($before,$targetCustomer)],fn()=>(new ResourceLockRepository($this->wpdb))->run($resources,function()use($id,$before,$operation){ $current=$this->find($id); foreach(['customer_user_id','customer_member_id','external_contact_id'] as $field)if($current[$field]!==$before[$field])throw new CatalogException('abece_booking_booking_changed',__('This booking changed while you were editing it. Reload and try again.','abece-booking'),409); return $operation(); })); } public function collides( int $resource_id, string $start, string $end, ?int $exclude = null ): bool { $wpdb = $this->wpdb; $id = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM %i WHERE resource_id = %d AND status NOT IN ('cancelled','voided') AND starts_at < %s AND ends_at > %s AND id <> %d LIMIT 1", $this->table, $resource_id, $end, $start, $exclude ?? 0 ) ); $this->check(); return null !== $id; } public function insert( array $data ): int { $this->write( fn() => $this->wpdb->insert( $this->table, $this->columns( $data ) ) ); return (int) $this->wpdb->insert_id; } public function update( int $id, array $data ): void { $this->write( fn() => $this->wpdb->update( $this->table, $this->columns( $data ), [ 'id' => $id ] ) ); } /** Under resource locks, update current participants; historical snapshots remain in history metadata. */ public function removeParticipants( int $id, array $member_ids ): void { foreach ( $member_ids as $member_id ) { $this->write( fn() => $this->wpdb->delete( \ABeCe\Booking\Infrastructure\Database\Tables::name( $this->wpdb, 'booking_participants' ), [ 'booking_id' => $id, 'member_id' => $member_id ] ) ); } } public function lock( int $id ): ?array { $wpdb = $this->wpdb; $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE id = %d FOR UPDATE', $this->table, $id ), ARRAY_A ); $this->check(); return $row ? $this->serialize( $row ) : null; } public function addParticipant( array $data ): void { $this->write( fn() => $this->wpdb->insert( \ABeCe\Booking\Infrastructure\Database\Tables::name( $this->wpdb, 'booking_participants' ), $data ) ); } public function appendHistory( array $data ): void { $this->write( fn() => $this->wpdb->insert( \ABeCe\Booking\Infrastructure\Database\Tables::name( $this->wpdb, 'booking_history' ), $data ) ); } public function participants( int $id ): array { return $this->children( 'booking_participants', $id ); } public function history( int $id ): array { return $this->children( 'booking_history', $id ); } private function children( string $suffix, int $id ): array { $wpdb = $this->wpdb; $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE booking_id = %d ORDER BY id', \ABeCe\Booking\Infrastructure\Database\Tables::name( $wpdb, $suffix ), $id ), ARRAY_A ); $this->check(); return array_map( [ $this, 'serialize' ], $rows ); } private function columns( array $data ): array { if ( array_key_exists( 'reference', $data ) ) { $data['booking_reference'] = $data['reference']; unset( $data['reference'] ); } return $data; } private function serialize( array $row ): array { if ( isset( $row['booking_reference'] ) ) { $row['reference'] = $row['booking_reference']; unset( $row['booking_reference'] ); } foreach ( [ 'id', 'booking_id', 'series_id', 'series_is_exception', 'resource_id', 'customer_user_id', 'customer_member_id', 'external_contact_id', 'created_by_user_id', 'participant_count', 'user_id', 'member_id', 'base_amount', 'discount_amount', 'final_amount', 'price_adjustment', 'cancelled_by_user_id', 'no_show_by_user_id', 'performed_by_user_id', 'cancellation_fee', 'no_show_fee' ] as $field ) { if ( array_key_exists( $field, $row ) ) { $row[ $field ] = null === $row[ $field ] ? null : (int) $row[ $field ]; } } return $row; } private function write( callable $operation ): void { $old = $this->wpdb->suppress_errors( true ); try { if ( false === $operation() ) { $this->fail(); } } finally { $this->wpdb->suppress_errors( $old ); } } private function check(): void { if ( $this->wpdb->last_error ) { $this->fail(); } } private function fail(): never { throw new CatalogException( 'abece_booking_booking_database_error', __( 'The booking could not be saved or loaded. Please try again.', 'abece-booking' ), 500 ); } }