"Calculation Type"). * Fields not listed here use their raw name, humanized. */ private const DISPLAY_KEYS = [ 'calc_type' => 'calculation_type', 'calc_params' => 'calculation_details', ]; /** * Constructor. */ public function __construct() { parent::__construct( 'pointics-update-channel', __( 'Partially update a Pointics earn channel. Call pointics-list-channels and pointics-get-channel first for a real ID and current state, and call pointics-list-channel-building-blocks before changing trigger, integration, calculation, or conditions. For frequency, use an existing channel value when that response reports no frequency vocabulary. Never guess IDs or dynamic values. Requires approval.', 'aisentic' ) ); } /** * Define tool properties. * * @return array */ protected function properties(): array { return [ new ToolProperty( name: 'channel_id', type: PropertyType::INTEGER, description: __( 'Real channel ID from pointics-list-channels.', 'aisentic' ), required: true ), new ToolProperty( name: 'name', type: PropertyType::STRING, description: __( 'Optional replacement channel name.', 'aisentic' ), required: false ), new ToolProperty( name: 'trigger', type: PropertyType::STRING, description: __( 'Optional live trigger replacement.', 'aisentic' ), required: false ), new ToolProperty( name: 'integration', type: PropertyType::STRING, description: __( 'Optional live integration replacement.', 'aisentic' ), required: false ), new ToolProperty( name: 'calc_type', type: PropertyType::STRING, description: __( 'Optional live calculation strategy replacement.', 'aisentic' ), required: false ), new ToolProperty( name: 'calc_params', type: PropertyType::STRING, description: __( 'Optional replacement calculation parameters, as a JSON object string matching the live strategy schema — e.g. {"points":100} for the fixed strategy, {"rate":1} for per_currency.', 'aisentic' ), required: false ), new ToolProperty( name: 'frequency', type: PropertyType::STRING, description: __( 'Optional frequency replacement. Pointics validates it; use an existing channel value when the building-block response reports no frequency vocabulary.', 'aisentic' ), required: false ), new ToolProperty( name: 'expiry_days', type: PropertyType::INTEGER, description: __( 'Optional replacement expiry days.', 'aisentic' ), required: false ), new ToolProperty( name: 'conditions', type: PropertyType::STRING, description: __( 'Optional replacement condition tree, as a JSON object string.', 'aisentic' ), required: false ), ]; } /** * Approval card label. * * @return string */ public function get_approval_label(): string { return __( 'Update Pointics Channel', 'aisentic' ); } /** * Approval card summary with before -> after values. * * @return string */ public function format_approval_summary(): string { $inputs = $this->getInputs(); $id = (int) ( $inputs['channel_id'] ?? 0 ); $before = $this->approval_channel( $id ); $name = (string) ( $before['name'] ?? sprintf( /* translators: %d: channel ID. */ __( 'channel #%d', 'aisentic' ), $id ) ); $changes = $this->approval_changes( $before, $inputs, $this->channel_vocabulary_labels() ); return sprintf( /* translators: 1: channel name, 2: changed fields. */ __( 'Update %1$s: %2$s', 'aisentic' ), $name, [] === $changes ? __( 'no supplied fields', 'aisentic' ) : implode( ', ', $changes ) ); } /** * Approval card inputs: resolved channel identity plus a before/after diff. * * Built fresh rather than layered onto the raw getInputs() so the card * never shows the same field twice — once as a raw slug/JSON blob at the * top level and again, resolved, inside 'after'. * * @return array */ public function get_approval_inputs(): array { $inputs = $this->getInputs(); $id = (int) ( $inputs['channel_id'] ?? 0 ); $before = $this->approval_channel( $id ); $labels = $this->channel_vocabulary_labels(); return [ 'channel' => isset( $before['name'] ) /* translators: 1: channel name, 2: channel ID. */ ? sprintf( __( '%1$s (ID: %2$d)', 'aisentic' ), $before['name'], $id ) /* translators: %d: channel ID. */ : sprintf( __( 'Channel #%d', 'aisentic' ), $id ), 'before' => $this->approval_values( $before, $labels ), 'after' => $this->approval_values( array_merge( $before, $inputs ), $labels ), ]; } /** * Execute the tool after approval. * * @param int $channel_id Channel ID. * @param string|null $name New name. * @param string|null $trigger New trigger. * @param string|null $integration New integration. * @param string|null $calc_type New calculation strategy. * @param mixed $calc_params New calculation parameters as a JSON object string (or an array, when the model sends one). * @param string|null $frequency New frequency. * @param int|null $expiry_days New expiry days. * @param mixed $conditions New conditions as a JSON object string (or an array, when the model sends one). * @return array */ public function __invoke( int $channel_id, ?string $name = null, ?string $trigger = null, ?string $integration = null, ?string $calc_type = null, $calc_params = null, ?string $frequency = null, ?int $expiry_days = null, $conditions = null ): array { $check = $this->guard( 'manage_pointics', 'manage_options' ); if ( $check ) { return $check; } $ready = $this->pointics_tables_ready(); if ( $ready ) { return $this->steer( $ready ); } if ( $channel_id <= 0 ) { return $this->steer( [ 'error' => __( 'channel_id must be a positive integer. Use pointics-list-channels to find it.', 'aisentic' ), 'code' => 'validation', 'status' => 422, ] ); } $calc_params = $this->normalize_param_map( $calc_params ); if ( false === $calc_params ) { return $this->param_map_error( 'calc_params' ); } $conditions = $this->normalize_param_map( $conditions ); if ( false === $conditions ) { return $this->param_map_error( 'conditions' ); } $arguments = compact( 'name', 'trigger', 'integration', 'calc_type', 'calc_params', 'frequency', 'expiry_days', 'conditions' ); $inputs = $this->getInputs(); $body = []; foreach ( self::UPDATABLE_FIELDS as $field ) { if ( null !== $arguments[ $field ] ) { $body[ $field ] = $arguments[ $field ]; } elseif ( array_key_exists( $field, $inputs ) && ! in_array( $field, self::PARAM_MAP_FIELDS, true ) ) { // calc_params/conditions arrive as JSON object strings; the // normalized value above is authoritative, so a null there means // "not supplied" and the raw input must not be resurrected. $body[ $field ] = $inputs[ $field ]; } } if ( [] === $body ) { return $this->steer( [ 'error' => __( 'At least one whitelisted channel field is required. Use pointics-get-channel to inspect the current state first.', 'aisentic' ), 'code' => 'validation', 'status' => 422, ] ); } try { $controller = pointics_container()->make( \Pointics\Api\Controllers\Channels_Controller::class ); $get = new \WP_REST_Request( 'GET' ); $get->set_param( 'id', $channel_id ); $before = $this->extract_response( $controller->get_channel( $get ), __( 'Failed to retrieve the current Pointics channel.', 'aisentic' ), 'pointics-get-channel' ); if ( $this->is_error( $before ) ) { return $before; } // The effective strategy is the one being set, or the channel's // current one when the update only replaces its parameters. Either // way an unusable calc_params map would silently zero the rule out. if ( array_key_exists( 'calc_params', $body ) ) { $invalid = $this->validate_calc_params( (string) ( $body['calc_type'] ?? $before['calc_type'] ?? '' ), $body['calc_params'] ); if ( $invalid ) { return $invalid; } } $clean_body = []; foreach ( $body as $field => $value ) { $clean_body[ $field ] = is_string( $value ) ? sanitize_text_field( $value ) : $value; } $request = $this->json_request( 'PUT', '/pointics/v1/channels/' . $channel_id, $clean_body ); $request->set_param( 'id', $channel_id ); $after = $this->extract_response( $controller->update_channel( $request ), __( 'Failed to update the Pointics channel.', 'aisentic' ), 'pointics-update-channel' ); if ( $this->is_error( $after ) ) { return $after; } return [ 'success' => true, 'channel_id' => $channel_id, 'before' => $this->shape_channel( $before ), 'after' => $this->shape_channel( $after ), 'message' => __( 'Pointics channel updated successfully.', 'aisentic' ), ]; } catch ( \Throwable $e ) { Log::error( 'Pointics_Update_Channel_Tool error', [ 'error' => $e->getMessage() ] ); return $this->steer( [ 'error' => __( 'Failed to update the Pointics channel.', 'aisentic' ), 'code' => 'internal', 'status' => null, ] ); } } /** * Build before-to-after approval changes. * Resolve the current channel for an approval card. * * @param int $channel_id Channel ID. * @return array */ private function approval_channel( int $channel_id ): array { return $this->entity_lookup( \Pointics\Api\Controllers\Channels_Controller::class, 'get_channel', $channel_id, 'pointics-get-channel', __( 'Unable to resolve the channel for approval.', 'aisentic' ) ); } /** * Select fields for approval display. * * @param array $before Current channel. * @param array $inputs Proposed inputs. * @param array{triggers: array, strategies: array} $labels Live vocabulary labels. * @return array */ private function approval_changes( array $before, array $inputs, array $labels ): array { $changes = []; foreach ( self::UPDATABLE_FIELDS as $field ) { if ( ! array_key_exists( $field, $inputs ) ) { continue; } $old = $this->approval_value( $field, $before[ $field ] ?? null, $labels ); $new = $this->approval_value( $field, $inputs[ $field ], $labels ); $changes[] = sprintf( /* translators: 1: field name, 2: old value, 3: new value. */ __( '%1$s: %2$s -> %3$s', 'aisentic' ), $this->humanize_slug( self::DISPLAY_KEYS[ $field ] ?? $field ), $old, $new ); } return $changes; } /** * Format channel fields for approval display. * * @param array $channel Channel data. * @param array{triggers: array, strategies: array} $labels Live vocabulary labels. * @return array */ private function approval_values( array $channel, array $labels ): array { $raw = $this->pluck( $channel, self::UPDATABLE_FIELDS ); $values = []; foreach ( $raw as $field => $value ) { $values[ self::DISPLAY_KEYS[ $field ] ?? $field ] = $this->approval_value( $field, $value, $labels ); } return $values; } /** * Format one field's value for approval, resolving live identifiers * (trigger, calc_type, integration) to human labels. * * @param string $field Channel field name. * @param mixed $value Value to display. * @param array{triggers: array, strategies: array} $labels Live vocabulary labels. * @return string */ private function approval_value( string $field, $value, array $labels ): string { if ( null === $value ) { return __( 'null', 'aisentic' ); } if ( 'trigger' === $field ) { return $this->resolved_label( $labels['triggers'], (string) $value ); } if ( 'calc_type' === $field ) { return $this->resolved_label( $labels['strategies'], (string) $value ); } if ( 'integration' === $field ) { return $this->humanize_slug( (string) $value ); } if ( 'calc_params' === $field ) { // Proposed values arrive as a JSON object string, the channel's own // stored value as an array; normalize both before formatting. $params = $this->normalize_param_map( $value ); return is_array( $params ) ? $this->format_calc_params( $params ) : __( 'None', 'aisentic' ); } if ( is_array( $value ) ) { return (string) wp_json_encode( $value ); } return (string) $value; } }