array( 'service_id' => 'bigint unsigned', 'version' => 'int unsigned', 'definition_json' => 'longtext', 'digest' => 'char(64)', 'updated_at' => 'datetime' ), 'booking_selections' => array( 'appointment_id' => 'bigint unsigned', 'schema_version' => 'int unsigned', 'definition_version' => 'int unsigned', 'config_digest' => 'char(64)', 'snapshot_json' => 'longtext' ) ); if ( ! OptionalStoreSchema::present( $read, 'abientot_selections_schema_version', '1', $defs, array( 'service_selection_definitions' => array( array( 'service_id' ) ), 'booking_selections' => array( array( 'appointment_id' ) ) ) ) ) { return false; } global $wpdb; foreach ( $defs as$table => $columns ) { $engine = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s', $read->table( $table ) ), ARRAY_A ), 'retained_capacity_storage_unavailable' ); if ( strtoupper( (string) ( $engine[0]['ENGINE'] ?? '' ) ) !== 'INNODB' ) { throw new \RuntimeException( 'selection_retained_engine' ); } foreach ( Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SHOW COLUMNS FROM %i', $read->table( $table ) ), ARRAY_A ), 'retained_capacity_storage_unavailable' )as$column ) { if ( isset( $columns[ $column['Field'] ] ) && $column['Null'] !== 'NO' ) { throw new \RuntimeException( 'selection_retained_nullability' ); } } } return true; } public function snapshot( int $id ): ?array { if ( ! $this->present() ) { return null; } global $wpdb; $read = new ResourceCapacityRepository(); $rows = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE appointment_id=%d', $read->table( 'booking_selections' ), $id ), ARRAY_A ), 'retained_capacity_storage_unavailable' ); if ( ! $rows ) { return null; } $row = $rows[0]; $snapshot = json_decode( $row['snapshot_json'], true, 32, JSON_THROW_ON_ERROR ); if ( (int) $row['schema_version'] !== 1 || ! is_array( $snapshot ) || ( $snapshot['schema_version'] ?? null ) !== 1 || ! is_array( $snapshot['quote'] ?? null ) || ! is_array( $snapshot['questions'] ?? null ) ) { throw new \RuntimeException( 'selection_retained_snapshot' ); } $quote = $snapshot['quote']; $digest = $quote['quote_digest'] ?? null; unset( $quote['quote_digest'] ); if ( ! is_string( $digest ) || ! hash_equals( hash( 'sha256', (string) wp_json_encode( $quote, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ) ), $digest ) ) { throw new \RuntimeException( 'selection_retained_digest' ); } if ( (int) ( $quote['definition_version'] ?? 0 ) !== (int) $row['definition_version'] || ( $quote['config_digest'] ?? null ) !== $row['config_digest'] || ! is_array( $quote['resources'] ?? null ) || count( $quote['resources'] ) > 200 ) { throw new \RuntimeException( 'selection_retained_quote' ); } return $snapshot; } public function guard( array $after, ?array $before ): ?array { $snapshot = $this->snapshot( (int) ( $after['id'] ?? 0 ) ); if ( ! $snapshot ) { return null; } $quote = $snapshot['quote']; foreach ( array( 'service_id', 'location_id', 'duration_minutes', 'price_cents', 'currency' )as$key ) { if ( (string) ( $after[ $key ] ?? '' ) !== (string) ( $quote[ $key ] ?? '' ) ) { throw new \DomainException( 'selection_requires_requote' ); } } $start = \DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', (string) $after['start_utc'], new \DateTimeZone( 'UTC' ) ); if ( ! $start || $start->format( 'Y-m-d H:i:s' ) !== $after['start_utc'] || $start->modify( '+' . (int) $after['duration_minutes'] . ' minutes' )->format( 'Y-m-d H:i:s' ) !== $after['end_utc'] ) { throw new \DomainException( 'selection_retained_interval' ); } return $quote; } public function erase_customer( int $id ): void { if ( ! $this->present() ) { return; } if ( ! BookingLock::is_held() ) { throw new \RuntimeException( 'selection_retained_owner' ); } global $wpdb; $read = new ResourceCapacityRepository(); $rows = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT b.appointment_id FROM %i b INNER JOIN %i a ON a.id=b.appointment_id WHERE a.customer_id=%d', $read->table( 'booking_selections' ), $read->table( 'appointments' ), $id ), ARRAY_A ), 'retained_capacity_storage_unavailable' ); foreach ( $rows as$row ) { $snapshot = $this->snapshot( (int) $row['appointment_id'] ); $snapshot['questions'] = array(); $json = wp_json_encode( $snapshot ); if ( ! is_string( $json ) || false === $wpdb->update( $read->table( 'booking_selections' ), array( 'snapshot_json' => $json ), array( 'appointment_id' => (int) $row['appointment_id'] ) ) || $wpdb->last_error !== '' ) { throw new \RuntimeException( 'selection_retained_erase' ); } } } }