read = new ResourceCapacityRepository(); } public static function definitions(): array { return array( 'dependants' => array( 'id' => 'bigint unsigned', 'customer_id' => 'bigint unsigned', 'display_name' => 'varchar(120)', 'notes' => 'varchar(500)', 'relation_confirmed' => 'tinyint unsigned', 'state' => 'varchar(20)', 'command_uuid' => 'char(36)', 'payload_hash' => 'char(64)', 'created_at' => 'datetime' ), 'party_commands' => array( 'id' => 'bigint unsigned', 'command_uuid' => 'char(36)', 'payload_hash' => 'char(64)', 'operation' => 'varchar(20)', 'customer_id' => 'bigint unsigned', 'session_id' => 'bigint unsigned', 'appointment_id' => 'bigint unsigned', 'state' => 'varchar(20)', 'result_json' => 'longtext', 'created_at' => 'datetime' ), 'booking_attendees' => array( 'id' => 'bigint unsigned', 'appointment_id' => 'bigint unsigned', 'ordinal' => 'int unsigned', 'dependant_id' => 'bigint unsigned', 'display_name' => 'varchar(120)', 'notes' => 'varchar(500)', 'checked_in_at' => 'datetime', 'checked_in_by' => 'bigint unsigned', 'revision' => 'bigint unsigned' ), 'guardian_authorities' => array( 'id' => 'bigint unsigned', 'command_uuid' => 'char(36)', 'payload_hash' => 'char(64)', 'customer_id' => 'bigint unsigned', 'purpose' => 'varchar(20)', 'token_hash' => 'char(64)', 'expires_at' => 'datetime', 'revoked_at' => 'datetime', 'created_at' => 'datetime' ), 'parties_configuration' => array( 'id' => 'bigint unsigned', 'max_seats' => 'int unsigned', 'revision' => 'bigint unsigned', 'updated_at' => 'datetime' ) ); } public function present(): bool { $definitions = self::definitions(); $keys = array(); foreach ( $definitions as $table => $columns ) { $keys[ $table ] = array( array( 'id' ) ); if ( $table === 'booking_attendees' ) { $keys[ $table ][] = array( 'appointment_id', 'ordinal' ); } elseif ( $table !== 'parties_configuration' ) { $keys[ $table ][] = array( 'command_uuid' ); } if ( $table === 'guardian_authorities' ) { $keys[ $table ][] = array( 'token_hash' ); } } if ( ! OptionalStoreSchema::present( $this->read, 'abientot_parties_schema_version', '1', $definitions, $keys ) ) { return false; } global $wpdb; $nullable = array( 'dependants.customer_id', 'party_commands.customer_id', 'party_commands.session_id', 'party_commands.appointment_id', 'booking_attendees.dependant_id', 'booking_attendees.checked_in_at', 'booking_attendees.checked_in_by', 'guardian_authorities.customer_id', 'guardian_authorities.revoked_at' ); global $wpdb; foreach ( $definitions as $table => $columns ) { $seen = array(); foreach ( Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SHOW COLUMNS FROM %i', $this->read->table( $table ) ), ARRAY_A ), 'retained_capacity_storage_unavailable' ) as $column ) { $name = $column['Field']; if ( ! isset( $columns[ $name ] ) || ( $column['Null'] === 'YES' ) !== in_array( $table . '.' . $name, $nullable, true ) || ( $name === 'id' && $column['Extra'] !== ( $table === 'parties_configuration' ? '' : 'auto_increment' ) ) ) { throw new \RuntimeException( 'party_retained_shape' ); } $seen[ $name ] = true; } if ( count( $seen ) !== count( $columns ) ) { throw new \RuntimeException( 'party_retained_shape' ); } $primary = array(); foreach ( Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SHOW INDEX FROM %i', $this->read->table( $table ) ), ARRAY_A ), 'retained_capacity_storage_unavailable' ) as $index ) { if ( $index['Key_name'] === 'PRIMARY' ) { $primary[ (int) $index['Seq_in_index'] ] = $index['Column_name']; } } if ( $primary !== array( 1 => 'id' ) ) { throw new \RuntimeException( 'party_retained_primary' ); } } foreach ( array_merge( array_keys( $definitions ), array( 'appointments', 'customers', 'activity_log' ) ) as $table ) { $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', $this->read->table( $table ) ), ARRAY_A ), 'retained_capacity_storage_unavailable' ); if ( strtoupper( (string) ( $engine[0]['ENGINE'] ?? '' ) ) !== 'INNODB' ) { throw new \RuntimeException( 'party_retained_engine' ); } } return true; } /** Complete immutable identity snapshot, including anonymous erased seat rows. */ public function snapshot( int $appointment ): array { if ( ! $this->present() ) { return array(); } global $wpdb; $rows = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE appointment_id=%d ORDER BY ordinal', $this->read->table( 'booking_attendees' ), $appointment ), ARRAY_A ), 'retained_capacity_storage_unavailable' ); if ( count( $rows ) > 20 ) { throw new \RuntimeException( 'party_retained_count' ); } foreach ( $rows as $index => $row ) { if ( (int) $row['ordinal'] !== $index + 1 ) { throw new \RuntimeException( 'party_retained_ordinal' ); } } return $rows; } public function guard( array $after, ?array $before ): void { $id = (int) ( $after['id'] ?? 0 ); if ( $id < 1 ) { throw new \RuntimeException( 'party_retained_booking' ); } $rows = $this->snapshot( $id ); $membership = ( new GroupBookingRepository() )->membership( $id ); if ( ! $rows && ! $membership ) { return; } if ( ! BookingLock::is_held() ) { throw new \RuntimeException( 'party_retained_owner' ); } if ( $membership ) { $seats = (int) ( $membership['seat_count'] ?? 0 ); if ( $seats < 1 || $seats > 20 ) { throw new \DomainException( 'party_retained_seats' ); } if ( ( $rows && count( $rows ) !== $seats ) || ( ! $rows && $seats !== 1 ) ) { throw new \DomainException( 'party_retained_count' ); } if ( $rows && get_option( 'abientot_groups_schema_version', '' ) !== '2' ) { throw new \DomainException( 'party_retained_group_version' ); } global $wpdb; $session = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE id=%d', $this->read->table( 'group_sessions' ), (int) $membership['session_id'] ), ARRAY_A ), 'retained_capacity_storage_unavailable' )[0] ?? null; if ( ! $session ) { throw new \RuntimeException( 'party_retained_session' ); } foreach ( array( 'service_id', 'staff_id', 'location_id', 'start_utc', 'end_utc', 'duration_minutes', 'timezone', 'currency' ) as $key ) { if ( ! array_key_exists( $key, $after ) || ! array_key_exists( $key, $session ) || (string) $after[ $key ] !== (string) $session[ $key ] ) { throw new \DomainException( 'party_retained_session' ); } } if ( get_option( 'abientot_groups_schema_version', '' ) === '2' ) { $unit = (int) $membership['unit_price_cents']; if ( $unit < 0 || $unit > intdiv( 2147483647, $seats ) || (int) $after['price_cents'] !== $unit * $seats || $unit !== (int) $session['price_cents'] ) { throw new \DomainException( 'party_retained_amount' ); } } } elseif ( count( $rows ) > 1 ) { throw new \DomainException( 'party_retained_ordinary' ); } if ( $rows && $before ) { foreach ( array( 'customer_id', 'service_id', 'price_cents', 'currency' ) as $key ) { if ( (string) ( $before[ $key ] ?? '' ) !== (string) ( $after[ $key ] ?? '' ) ) { throw new \DomainException( 'party_retained_immutable' ); } } } } private function write( $result ): void { global $wpdb; if ( $result === false || $wpdb->last_error !== '' ) { throw new \RuntimeException( 'party_retained_write' ); } } /** Caller supplies its private checked transaction witness. Never starts/commits SQL. */ public function erase_customer( int $customer, callable $owner ): void { if ( $customer < 1 ) { throw new \DomainException( 'party_retained_customer' ); } if ( ! BookingLock::is_held() || $owner() !== true ) { throw new \RuntimeException( 'party_retained_owner' ); } if ( ! $this->present() ) { return; } global $wpdb; $p = $wpdb->prefix . 'abientot_'; $now = gmdate( 'Y-m-d H:i:s' ); // Snapshot/profile links are scrubbed before the parent clears canonical customer links. $this->write( $wpdb->query( $wpdb->prepare( "UPDATE %i t LEFT JOIN %i a ON a.id=t.appointment_id LEFT JOIN %i d ON d.id=t.dependant_id SET t.display_name='',t.notes='',t.dependant_id=NULL,t.checked_in_at=NULL,t.checked_in_by=NULL,t.revision=t.revision+1 WHERE a.customer_id=%d OR d.customer_id=%d", $p . 'booking_attendees', $p . 'appointments', $p . 'dependants', $customer, $customer ) ) ); $authorities = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( "SELECT id FROM %i WHERE customer_id=%d", $p . 'guardian_authorities', $customer ), ARRAY_A ), 'retained_capacity_storage_unavailable' ); foreach ( $authorities as $authority ) { $this->write( $wpdb->update( $p . 'guardian_authorities', array( 'customer_id' => null, 'token_hash' => hash( 'sha256', random_bytes( 32 ) ), 'payload_hash' => '', 'revoked_at' => $now ), array( 'id' => (int) $authority['id'] ) ) ); } $this->write( $wpdb->query( $wpdb->prepare( "UPDATE %i c LEFT JOIN %i a ON a.id=c.appointment_id SET c.customer_id=NULL,c.result_json='[]',c.payload_hash='',c.state='erased' WHERE c.customer_id=%d OR a.customer_id=%d", $p . 'party_commands', $p . 'appointments', $customer, $customer ) ) ); $this->write( $wpdb->query( $wpdb->prepare( "UPDATE %i SET customer_id=NULL,display_name='',notes='',payload_hash='',state='erased',relation_confirmed=0 WHERE customer_id=%d", $p . 'dependants', $customer ) ) ); if ( ! BookingLock::is_held() || $owner() !== true ) { throw new \RuntimeException( 'party_retained_owner' ); } } /** Minimal personal-data export; caller is the canonical privacy authority. */ public function export_customer( int $customer ): array { if ( $customer < 1 ) { throw new \DomainException( 'party_retained_customer' ); } if ( ! $this->present() ) { return array( 'dependants' => array(), 'attendees' => array() ); } global $wpdb; $p = $wpdb->prefix . 'abientot_'; return array( 'dependants' => Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( "SELECT id,display_name,notes,relation_confirmed,state FROM %i WHERE customer_id=%d ORDER BY id", $p . 'dependants', $customer ), ARRAY_A ), 'retained_capacity_storage_unavailable' ), 'attendees' => Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( "SELECT t.id,t.appointment_id,t.ordinal,t.display_name,t.notes,t.checked_in_at FROM %i t INNER JOIN %i a ON a.id=t.appointment_id WHERE a.customer_id=%d ORDER BY t.appointment_id,t.ordinal", $p . 'booking_attendees', $p . 'appointments', $customer ), ARRAY_A ), 'retained_capacity_storage_unavailable' ) ); } }