array( 'series_id' => 'bigint unsigned', 'revision' => 'bigint unsigned' ), 'recurrence_exceptions' => array( 'appointment_id' => 'bigint unsigned', 'is_exception' => 'tinyint unsigned' ), 'recurrence_commands' => array( 'id' => 'bigint unsigned', 'submission_uuid' => 'char(36)', 'series_id' => 'bigint unsigned', 'command_hash' => 'char(64)', 'revision' => 'bigint unsigned', 'result_json' => 'longtext', 'created_at' => 'datetime' ), 'series_tokens' => array( 'id' => 'bigint unsigned', 'series_id' => 'bigint unsigned', 'customer_id' => 'bigint unsigned', 'token_hash' => 'char(64)', 'purpose' => 'varchar(20)', 'expires_at' => 'datetime', 'revoked_at' => 'datetime', 'created_at' => 'datetime' ) ); $keys = array( 'recurrence_revisions' => array( array( 'series_id' ) ), 'recurrence_exceptions' => array( array( 'appointment_id' ) ), 'recurrence_commands' => array( array( 'id' ), array( 'submission_uuid' ) ), 'series_tokens' => array( array( 'id' ), array( 'token_hash' ) ) ); if ( ! OptionalStoreSchema::present( $read, 'abientot_customer_series_schema_version', '1', $definitions, $keys ) ) { return false; } global $wpdb; foreach ( $definitions 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( 'series_transactional_storage' ); } 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 ) { $nullable = in_array( $table . '.' . $column['Field'], array( 'recurrence_commands.result_json', 'series_tokens.customer_id', 'series_tokens.revoked_at' ), true ); if ( isset( $columns[ $column['Field'] ] ) && ( $column['Null'] === 'YES' ) !== $nullable ) { throw new \RuntimeException( 'series_storage_nullability' ); } } } return true; } private function write( $result ): void { global $wpdb; if ( $result === false || $wpdb->last_error !== '' ) { throw new \RuntimeException( 'series_retained_write' ); } } /** Called inside the canonical mutation's owning transaction. */ public function individual_edit( array $after, ?array $before ): void { if ( ! $before || \Abientot\Support\BookingConstraints::batch_active() ) { return; } $changed = \Abientot\Support\BookingConstraints::structural( $after, $before ) || ( (string) ( $after['cancelled_at'] ?? '' ) !== (string) ( $before['cancelled_at'] ?? '' ) ); if ( ! $changed || ! $this->present() ) { return; } if ( ! BookingLock::is_held() ) { throw new \RuntimeException( 'series_retained_owner' ); } global $wpdb; $read = new ResourceCapacityRepository(); $id = (int) $after['id']; $members = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT series_id FROM %i WHERE appointment_id=%d', $read->table( 'recurrence_occurrences' ), $id ), ARRAY_A ), 'retained_capacity_storage_unavailable' ); if ( ! $members ) { return; } $this->write( $wpdb->query( $wpdb->prepare( 'INSERT INTO %i (appointment_id,is_exception) VALUES (%d,1) ON DUPLICATE KEY UPDATE is_exception=1', $read->table( 'recurrence_exceptions' ), $id ) ) ); foreach ( $members as$member ) { $this->write( $wpdb->query( $wpdb->prepare( 'INSERT INTO %i (series_id,revision) VALUES (%d,2) ON DUPLICATE KEY UPDATE revision=revision+1', $read->table( 'recurrence_revisions' ), (int) $member['series_id'] ) ) ); } } /** Erase before old membership cleanup; hashes/UUIDs remain non-replayable tombstones. */ public function erase_customer( int $customer ): void { if ( ! $this->present() ) { return; } if ( ! BookingLock::is_held() ) { throw new \RuntimeException( 'series_retained_owner' ); } global $wpdb; $p = $wpdb->prefix . 'abientot_'; $now = gmdate( 'Y-m-d H:i:s' ); $this->write( $wpdb->query( $wpdb->prepare( "UPDATE %i t LEFT JOIN %i m ON m.series_id=t.series_id LEFT JOIN %i a ON a.id=m.appointment_id SET t.customer_id=NULL,t.revoked_at=%s WHERE t.customer_id=%d OR a.customer_id=%d", $p . 'series_tokens', $p . 'recurrence_occurrences', $p . 'appointments', $now, $customer, $customer ) ) ); $this->write( $wpdb->query( $wpdb->prepare( "UPDATE %i c INNER JOIN %i m ON m.series_id=c.series_id INNER JOIN %i a ON a.id=m.appointment_id SET c.result_json=NULL,c.command_hash='' WHERE a.customer_id=%d", $p . 'recurrence_commands', $p . 'recurrence_occurrences', $p . 'appointments', $customer ) ) ); $this->write( $wpdb->query( $wpdb->prepare( "DELETE e FROM %i e INNER JOIN %i a ON a.id=e.appointment_id WHERE a.customer_id=%d", $p . 'recurrence_exceptions', $p . 'appointments', $customer ) ) ); $this->write( $wpdb->query( $wpdb->prepare( "UPDATE %i r INNER JOIN %i m ON m.series_id=r.series_id INNER JOIN %i a ON a.id=m.appointment_id SET r.revision=r.revision+1 WHERE a.customer_id=%d", $p . 'recurrence_revisions', $p . 'recurrence_occurrences', $p . 'appointments', $customer ) ) ); } }