array( 'service_id' => 'bigint unsigned', 'revision' => 'int unsigned', 'definition_json' => 'longtext', 'digest' => 'char(64)', 'updated_at' => 'datetime' ), 'booking_instructions' => array( 'appointment_id' => 'bigint unsigned', 'revision' => 'int unsigned', 'snapshot_json' => 'longtext' ), 'instruction_jobs' => array( 'id' => 'bigint unsigned', 'appointment_id' => 'bigint unsigned', 'event_key' => 'varchar(20)', 'content_revision' => 'char(64)', 'booking_digest' => 'char(64)', 'due_at' => 'datetime', 'expires_at' => 'datetime', 'next_attempt_at' => 'datetime', 'state' => 'varchar(20)', 'attempts' => 'int unsigned', 'lease_hash' => 'char(64)', 'lease_until' => 'datetime', 'last_code' => 'varchar(40)', 'accepted_at' => 'datetime', 'created_at' => 'datetime', 'updated_at' => 'datetime' ) ); } public function present(): bool { $read = new ResourceCapacityRepository(); $defs = self::definitions(); if ( ! OptionalStoreSchema::present( $read, 'abientot_instructions_schema_version', '1', $defs, array( 'service_instructions' => array( array( 'service_id' ) ), 'booking_instructions' => array( array( 'appointment_id' ) ), 'instruction_jobs' => array( array( 'id' ), array( 'appointment_id', 'event_key', 'content_revision' ) ) ) ) ) { return false; } global $wpdb; foreach ( $defs as$table => $columns ) { $seen = array(); 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 = $table === 'instruction_jobs' && in_array( $column['Field'], array( 'lease_hash', 'lease_until', 'accepted_at' ), true ); if ( ! isset( $columns[ $column['Field'] ] ) || ( $column['Null'] === 'YES' ) !== $nullable ) { throw new \RuntimeException( 'instructions_retained_shape' ); } $seen[ $column['Field'] ] = true; } if ( count( $seen ) !== count( $columns ) ) { throw new \RuntimeException( 'instructions_retained_shape' ); } $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( 'instructions_retained_engine' ); } } 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 snapshot_json FROM %i WHERE appointment_id=%d', $read->table( 'booking_instructions' ), $id ), ARRAY_A ), 'retained_capacity_storage_unavailable' ); if ( ! $rows ) { return null; } $snapshot = json_decode( $rows[0]['snapshot_json'], true, 32, JSON_THROW_ON_ERROR ); if ( ! is_array( $snapshot ) || ( $snapshot['version'] ?? null ) !== 1 || ! isset( $snapshot['service_id'], $snapshot['config']['definition']['preparation'] ) ) { throw new \RuntimeException( 'instructions_retained_snapshot' ); } return $snapshot; } public function guard( array $after, ?array $before ): void { $snapshot = $this->snapshot( (int) $after['id'] ); if ( ! $snapshot ) { return; } if ( (int) $snapshot['service_id'] !== (int) $after['service_id'] ) { throw new \DomainException( 'instructions_requires_service' ); } if ( $before && ! class_exists( 'Abientot\\Premium\\Instructions\\Service', false ) ) { foreach ( array( 'customer_id', 'staff_id', 'location_id', 'start_utc', 'end_utc', 'timezone', 'status_id' )as$key ) { if ( (string) $after[ $key ] !== (string) $before[ $key ] ) { $this->cancel( (int) $after['id'], 'edition_change' ); break; } } } } private function cancel( int $id, string $reason ): void { if ( ! BookingLock::is_held() ) { throw new \RuntimeException( 'instructions_retained_owner' ); } global $wpdb; $read = new ResourceCapacityRepository(); $result = $wpdb->query( $wpdb->prepare( 'UPDATE %i' . " SET state=IF(state='sending','uncertain','cancelled'),booking_digest='',lease_hash=NULL,lease_until=NULL,last_code=%s,updated_at=%s WHERE appointment_id=%d AND state IN ('pending','retry','leased','sending')", $read->table( 'instruction_jobs' ), $reason, gmdate( 'Y-m-d H:i:s' ), $id ) ); if ( $result === false || $wpdb->last_error !== '' ) { throw new \RuntimeException( 'instructions_retained_write' ); } } public function erase_customer( int $id ): void { if ( ! $this->present() ) { return; } global $wpdb; $read = new ResourceCapacityRepository(); $rows = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT id FROM %i WHERE customer_id=%d', $read->table( 'appointments' ), $id ), ARRAY_A ), 'retained_capacity_storage_unavailable' ); foreach ( $rows as$row ) { $this->cancel( (int) $row['id'], 'erased' ); if ( false === $wpdb->delete( $read->table( 'booking_instructions' ), array( 'appointment_id' => (int) $row['id'] ) ) || $wpdb->last_error !== '' ) { throw new \RuntimeException( 'instructions_retained_write' ); } } } }