array( 'id' => 'bigint unsigned', 'submission_uuid' => 'char(36)', 'command_hash' => 'char(64)', 'entry_id' => 'bigint unsigned', 'customer_id' => 'bigint unsigned', 'contact_json' => 'longtext', 'contact_digest' => 'char(64)', 'service_id' => 'bigint unsigned', 'location_id' => 'bigint unsigned', 'session_id' => 'bigint unsigned', 'staff_id' => 'bigint unsigned', 'date_from' => 'date', 'date_to' => 'date', 'time_from' => 'char(5)', 'time_to' => 'char(5)', 'timezone' => 'varchar(100)', 'requested_seats' => 'int unsigned', 'consent_version' => 'varchar(20)', 'consented_at' => 'datetime', 'state' => 'varchar(20)', 'verify_nonce' => 'char(64)', 'verify_hash' => 'char(64)', 'verify_expires' => 'datetime', 'unsubscribe_nonce' => 'char(64)', 'unsubscribe_hash' => 'char(64)', 'unsubscribe_expires' => 'datetime', 'created_at' => 'datetime', 'updated_at' => 'datetime' ), 'waitlist_automation' => array( 'id' => 'bigint unsigned', 'preference_id' => 'bigint unsigned', 'offer_id' => 'bigint unsigned', 'candidate_digest' => 'char(64)', 'nonce' => 'char(64)', 'contact_digest' => 'char(64)', 'created_at' => 'datetime' ), 'waitlist_jobs' => array( 'id' => 'bigint unsigned', 'preference_id' => 'bigint unsigned', 'automation_id' => 'bigint unsigned', 'event_key' => 'varchar(20)', 'revision' => 'int unsigned', 'channel' => 'varchar(20)', 'state' => 'varchar(20)', 'attempts' => 'int unsigned', 'next_attempt_at' => 'datetime', 'lease_hash' => 'char(64)', 'lease_until' => 'datetime', 'last_code' => 'varchar(30)', 'created_at' => 'datetime', 'updated_at' => 'datetime' ) ); if ( $extended ) { $definitions['waitlist_preferences']['intent_json'] = 'longtext'; } return $definitions; } public static function unique(): array { return array( 'waitlist_preferences' => array( array( 'id' ), array( 'submission_uuid' ), array( 'entry_id' ), array( 'verify_hash' ), array( 'unsubscribe_hash' ) ), 'waitlist_automation' => array( array( 'id' ), array( 'offer_id' ), array( 'candidate_digest', 'preference_id' ) ), 'waitlist_jobs' => array( array( 'id' ), array( 'event_key', 'preference_id', 'automation_id', 'revision', 'channel' ) ) ); } public function present(): bool { if ( ! OptionalStoreSchema::present( new ResourceCapacityRepository(), 'abientot_public_waitlist_schema_version', ( get_option( 'abientot_public_waitlist_schema_version' ) === '1' ? '1' : '2' ), self::definitions( get_option( 'abientot_public_waitlist_schema_version' ) !== '1' ), self::unique() ) ) { return false; } global $wpdb; foreach ( array_keys( self::definitions() )as$suffix ) { $table = $wpdb->prefix . 'abientot_' . $suffix; $engine = $wpdb->get_var( $wpdb->prepare( 'SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s', $table ) ); if ( $wpdb->last_error || strtoupper( (string) $engine ) !== 'INNODB' ) { throw new \RuntimeException( 'Public waitlist storage must support transactional erasure.' ); } } return true; } /** Same check as before 1.1.2: database errors stay visible to WP_DEBUG, the read fails closed. */ private function read( $rows ): array { global $wpdb; if ( $wpdb->last_error || ! is_array( $rows ) ) { throw new \RuntimeException( 'Public waitlist privacy read failed.' ); } return $rows; } private function write( $result ): void { if ( $result === false ) { throw new \RuntimeException( 'Public waitlist privacy write failed.' ); } } /** Canonical pending contact lookup; invalid JSON raises a checked privacy failure. */ public function pending( string $email, int $limit = 100, int $offset = 0 ): array { $email = strtolower( trim( $email ) ); if ( ! is_email( $email ) || ! $this->present() ) { return array(); } global $wpdb; $p = $wpdb->prefix . 'abientot_'; $limit = max( 1, min( 101, $limit ) ); $offset = max( 0, $offset ); if ( get_option( 'abientot_public_waitlist_schema_version' ) === '2' ) { return $this->read( $wpdb->get_results( $wpdb->prepare( "SELECT id,contact_json,intent_json,service_id,location_id,session_id,staff_id,date_from,date_to,time_from,time_to,timezone,requested_seats,consent_version,consented_at,state,created_at FROM %i WHERE contact_json IS NOT NULL AND LOWER(JSON_UNQUOTE(JSON_EXTRACT(contact_json,'$.email')))=%s ORDER BY id LIMIT %d OFFSET %d", $p . 'waitlist_preferences', $email, $limit, $offset ), ARRAY_A ) ); } return $this->read( $wpdb->get_results( $wpdb->prepare( "SELECT id,contact_json,service_id,location_id,session_id,staff_id,date_from,date_to,time_from,time_to,timezone,requested_seats,consent_version,consented_at,state,created_at FROM %i WHERE contact_json IS NOT NULL AND LOWER(JSON_UNQUOTE(JSON_EXTRACT(contact_json,'$.email')))=%s ORDER BY id LIMIT %d OFFSET %d", $p . 'waitlist_preferences', $email, $limit, $offset ), ARRAY_A ) ); } /** Caller owns the canonical checked SQL transaction and mutex. Tombstone IDs preserve unique candidate/job history. */ public function erase_ids( array $ids ): void { if ( ! $ids ) { return; } if ( ! BookingLock::is_held() || ! $this->present() ) { throw new \RuntimeException( 'Public waitlist privacy ownership required.' ); } global $wpdb; $p = $wpdb->prefix . 'abientot_'; $ids = array_map( 'intval', $ids ); $this->write( $wpdb->query( $wpdb->prepare( "UPDATE %i SET state=CASE WHEN state IN ('sending','uncertain') THEN 'uncertain' WHEN state='accepted' THEN 'accepted' ELSE 'cancelled' END,lease_hash=NULL,lease_until=NULL,last_code='privacy_erased',updated_at=UTC_TIMESTAMP() WHERE preference_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ")", array_merge( array( $p . 'waitlist_jobs' ), $ids ) ) ) ); $this->write( $wpdb->query( $wpdb->prepare( "UPDATE %i SET offer_id=NULL,nonce=NULL,contact_digest='' WHERE preference_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ")", array_merge( array( $p . 'waitlist_automation' ), $ids ) ) ) ); if ( get_option( 'abientot_public_waitlist_schema_version' ) === '2' ) { $this->write( $wpdb->query( $wpdb->prepare( "UPDATE %i SET intent_json=NULL WHERE id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ")", array_merge( array( $p . 'waitlist_preferences' ), $ids ) ) ) ); } $this->write( $wpdb->query( $wpdb->prepare( "UPDATE %i SET state='erased',entry_id=NULL,customer_id=NULL,contact_json=NULL,contact_digest='',command_hash='',service_id=0,location_id=0,session_id=NULL,staff_id=0,date_from='1970-01-01',date_to='1970-01-01',time_from='00:00',time_to='00:00',timezone='UTC',requested_seats=1,consent_version='',consented_at='1970-01-01 00:00:00',verify_nonce=NULL,verify_hash=NULL,verify_expires=NULL,unsubscribe_nonce=NULL,unsubscribe_hash=NULL,unsubscribe_expires=NULL,updated_at=UTC_TIMESTAMP() WHERE id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ")", array_merge( array( $p . 'waitlist_preferences' ), $ids ) ) ) ); } public function erase_customer( int $customer ): void { if ( ! $this->present() ) { return; } global $wpdb; $p = $wpdb->prefix . 'abientot_'; do { $rows = $this->read( $wpdb->get_results( $wpdb->prepare( "SELECT id FROM %i WHERE customer_id=%d ORDER BY id LIMIT 100", $p . 'waitlist_preferences', $customer ), ARRAY_A ) ); $this->erase_ids( array_column( $rows, 'id' ) ); } while ( count( $rows ) === 100 ); } }