plugin = $plugin; } /** * Flag a product as having something to tell Rezolve Ai. * * `$fields` exists for one case and it is not optional there: `dismiss()` * DELETES the pending meta, so by the time the hourly drain runs there is * nothing left to name the fields the merchant refused. The caller reads them * first and hands them over; every other caller can leave it empty and let * the drain derive them. * * `seq` is unique per call. `drain()` compares the whole flag before and * after its report, and a merchant edit landing while the report is in * flight must read as a change even within the same second. * * @param int $product_id Product id. * @param string $reason One of enrich|approve|dismiss|revert|backfill|edit. * @param string[] $fields Field names, when the caller is about to * destroy the meta they would be read from. */ public function mark_dirty( $product_id, $reason, array $fields = array() ) { $product_id = (int) $product_id; if ( ! $product_id ) { return; } $flag = array( 'reason' => (string) $reason, 'at' => gmdate( 'c' ), 'seq' => uniqid( '', true ), ); if ( ! empty( $fields ) ) { $flag['fields'] = array_values( array_unique( array_map( 'strval', $fields ) ) ); } update_post_meta( $product_id, Meta::SYNC_DIRTY, $flag ); // Near-real-time for a site with wp-cron alive, hourly for everyone // else. Same self-scheduling trick as `Cron::auto_apply_sweep()`. if ( ! wp_next_scheduled( Cron::REVIEW_SYNC ) ) { wp_schedule_single_event( time() + 60, Cron::REVIEW_SYNC ); } } /** * Rezolve Ai's status vocabulary for a product's local state. * * `revert` is why `mark_dirty()` records a reason. `Apply::revert()` puts * the product back to STATUS_ENRICHED, which is indistinguishable from a * fresh proposal by status alone -- and reporting it as `pending` would put * content the merchant just took down back in front of them as new. * * @param string $status Meta::STATUS value. * @param string $reason Dirty reason. * @return string|null Null when there is nothing to report. */ public static function remote_status( $status, $reason ) { if ( 'revert' === $reason ) { return 'reverted'; } switch ( $status ) { case Meta::STATUS_ENRICHED: return 'pending'; case Meta::STATUS_APPLIED: return 'applied'; case Meta::STATUS_DISMISSED: return 'dismissed'; case Meta::STATUS_FAILED: return 'failed'; default: // STATUS_QUEUED is "asked for, nothing back yet". There is no // proposal to decide, so there is nothing to say. return null; } } /** * The decisions to report for one product. * * Rezolve Ai keys its review rows by CONTENT TYPE and this plugin holds * flat FIELDS, so the field names below are the shared vocabulary and are * mirrored from `src/lib/review-field-mapping.ts` in the platform repo. PHP * cannot import that file; `tests/test-runner.php` pins this list against * it, and `src/lib/__tests__/review-field-mapping.test.ts` pins the other * side, so a change to either fails a build rather than silently filing a * merchant's decision against the wrong content. * * `short_description` is deliberately absent. It is a WooCommerce-only * native field with no review content type of its own; the platform would * report it as an unknown field, and `description` already carries the * decision for that row. * * EDITS (4.18.0). A field the merchant edited on the Review screen carries * `value`: the edited content, in the same field vocabulary, so Rezolve Ai * writes it into the review row in the same update. Only edited fields do, * so a report never re-sends generated copy as if the merchant had typed it, * and only for statuses the endpoint accepts a value with. Its `decidedAt` * is the later of the edit and the decision, so an edit to live content * reads as `applied` at the moment it was republished. * * LIVE PAGE (4.20.1). An `applied` decision carries `productUrl`, the * product's page when it is published: what the web review queue's "View * live" opens, and what Rezolve Ai registers for traffic attribution. No * report carried one before, so content applied here never had a live link * on the web. * * @param int $product_id Product id. * @param string $reason Dirty reason. * @param string[] $known Fields captured before the meta was destroyed. * @return array List of { productId, field, status, decidedAt, value?, productUrl? }. */ public function build_decisions( $product_id, $reason, array $known = array() ) { $product_id = (int) $product_id; $status = (string) get_post_meta( $product_id, Meta::STATUS, true ); $remote = self::remote_status( $status, $reason ); if ( null === $remote ) { return array(); } $live_url = 'applied' === $remote ? Review::live_url( $product_id ) : ''; // Fields the caller captured before deleting the meta they live in win // outright: re-deriving from an emptied `Meta::PENDING` would report // nothing and the dismissal would never reach Rezolve Ai. if ( ! empty( $known ) ) { $decisions = array(); foreach ( $known as $field ) { $decision = array( 'productId' => (string) $product_id, 'field' => (string) $field, 'status' => $remote, 'decidedAt' => gmdate( 'c' ), ); if ( '' !== $live_url ) { $decision['productUrl'] = $live_url; } $decisions[] = $decision; } return $decisions; } $fields = self::proposal_fields( $product_id, $status ); if ( empty( $fields ) ) { return array(); } $decided_at = (string) get_post_meta( $product_id, Meta::APPLIED_AT, true ); if ( '' === $decided_at ) { $decided_at = (string) get_post_meta( $product_id, Meta::ENRICHED_AT, true ); } $edited_any = get_post_meta( $product_id, Meta::EDITED, true ); $edited_any = is_array( $edited_any ) ? $edited_any : array(); $edited = in_array( $remote, self::VALUE_STATUSES, true ) ? $edited_any : array(); $pending = get_post_meta( $product_id, Meta::PENDING, true ); $pending = is_array( $pending ) ? $pending : array(); $content = get_post_meta( $product_id, Meta::CONTENT, true ); $content = is_array( $content ) ? $content : array(); $decisions = array(); foreach ( $fields as $field ) { $decision = array( 'productId' => (string) $product_id, 'field' => $field, 'status' => $remote, ); $at = $decided_at; if ( ! empty( $edited[ $field ] ) ) { $value = Edit_Values::stored_value( $field, $pending, $content ); if ( null !== $value ) { $decision['value'] = $value; if ( '' === $at || strtotime( (string) $edited[ $field ] ) > strtotime( $at ) ) { $at = (string) $edited[ $field ]; } } } elseif ( empty( $edited_any[ $field ] ) ) { $proposed = self::proposed_value( $field, $pending, $content ); if ( null !== $proposed ) { $decision['proposedValue'] = $proposed; } } if ( '' !== $at ) { $decision['decidedAt'] = $at; } if ( '' !== $live_url ) { $decision['productUrl'] = $live_url; } $decisions[] = $decision; } return $decisions; } /** * The proposal a decision is about, sent as `proposedValue` (4.19.1). * * Rezolve Ai files a decision on its review row for the same product and * field, and content this plugin generated need not be that row's content. * Without the proposal, approving this site's own copy marked a different * web copy approved and live (seen on the Shopify app, 16 Sep 2026, and the * same report shape here). Rezolve Ai compares it with the row and never * stores it. An edited field sends none: its text is the merchant's and would * never match, even when the row it came from is Rezolve Ai's own. * * @param string $field Field name. * @param array $pending Meta::PENDING. * @param array $content Meta::CONTENT. * @return mixed|null Null when there is nothing to compare. */ public static function proposed_value( $field, array $pending, array $content ) { if ( 'irl_cards' === $field ) { foreach ( array( 'irl-scenarios', 'irl-cards' ) as $key ) { if ( ! empty( $content[ $key ] ) ) { return $content[ $key ]; } } return null; } return Edit_Values::stored_value( $field, $pending, $content ); } /** * The platform field names a product currently holds a proposal for. * * Shared by the decisions report above and by {@see Quality}, which asks * Rezolve Ai for the scores of exactly these fields. One derivation, so the * plugin can never report a decision about a field it does not ask the * score of, or the other way round. * * @param int $product_id Product id. * @param string $status Meta::STATUS value. * @return string[] Unique field names, in a stable order. */ public static function proposal_fields( $product_id, $status ) { $product_id = (int) $product_id; $pending = get_post_meta( $product_id, Meta::PENDING, true ); $pending = is_array( $pending ) ? $pending : array(); $content = get_post_meta( $product_id, Meta::CONTENT, true ); $content = is_array( $content ) ? $content : array(); $fields = array(); foreach ( array( 'title', 'description', 'seo_title', 'seo_description' ) as $field ) { if ( isset( $pending[ $field ] ) && '' !== $pending[ $field ] ) { $fields[] = $field; } } // Content types the plugin applies through `Apply::apply_content_types`. // Every spelling the platform has emitted is accepted, matching that // method and the Shopify app's `deriveProposals`. $from_content = array( 'json_ld' => array( 'jsonld-schema', 'json-ld-schema' ), 'irl_cards' => array( 'irl-scenarios', 'irl-cards' ), 'internal_links' => array( 'internal-links' ), 'highlights' => array( 'highlights' ), 'product_qa' => array( 'product-qa' ), 'product_attributes' => array( 'product-attributes' ), 'image_alt' => array( 'image-alt' ), 'custom_fields' => array( 'custom-fields' ), ); foreach ( $from_content as $field => $keys ) { foreach ( $keys as $key ) { if ( isset( $content[ $key ] ) && ! empty( $content[ $key ] ) ) { $fields[] = $field; break; } } } // An APPLIED product whose pending meta was cleared still has a decision // worth reporting: the applied ledger says which types went live. if ( empty( $fields ) && Meta::STATUS_APPLIED === $status ) { $applied = get_post_meta( $product_id, Meta::APPLIED_TYPES, true ); $applied = is_array( $applied ) ? $applied : array(); $map = array( 'meta-tags' => array( 'seo_title', 'seo_description' ), 'jsonld-schema' => array( 'json_ld' ), 'json-ld-schema' => array( 'json_ld' ), 'irl-scenarios' => array( 'irl_cards' ), 'internal-links' => array( 'internal_links' ), 'product-title' => array( 'title' ), 'highlights' => array( 'highlights' ), 'product-qa' => array( 'product_qa' ), 'product-attributes' => array( 'product_attributes' ), 'image-alt' => array( 'image_alt' ), 'custom-fields' => array( 'custom_fields' ), ); // A plain LIST of type ids (Apply stores `array_keys( $applied_types )`). // This used to loop over `array_keys( $applied )`, i.e. 0, 1, 2, so // the map above never matched anything and an applied product whose // pending meta was cleared reported no decision at all. foreach ( $applied as $type ) { if ( isset( $map[ $type ] ) ) { $fields = array_merge( $fields, $map[ $type ] ); } } } return array_values( array_unique( $fields ) ); } /** * The review-sync tick: report decisions, then refresh cached quality scores. * * One tick rather than a new cron event, because both are "keep this site's * review state and Rezolve Ai's in step", both already ride the hourly * heartbeat, and one scheduled event is one thing to keep alive. Decisions * go first: a product whose decision just reached Rezolve Ai is one whose * review row, and so whose score, now exists. * * @return array{reported:int,skipped:int,failed:int} */ public function run() { $counts = $this->drain(); $this->refresh_quality(); return $counts; } /** * Refresh cached quality scores for products with pending or applied content. * * Without this the Products screen and the dashboard, which read only the * cache, would show whatever was true the last time a merchant happened to * open Review, and a product scored after that would read "Not scored" for * ever. * * Bounded three ways, since every product is a read against Rezolve Ai: * at most `Quality::REFRESH_LIMIT` products, oldest fetch first (never * fetched before anything), none fetched within the last hour, and the whole * refresh at most once per `Quality::REFRESH_INTERVAL`. `drain()` re-arms a * tick every minute while a backlog of decisions clears, and that must not * turn into a read of 200 products a minute. * * @param int $limit Products per pass. * @return int Products refreshed. */ public function refresh_quality( $limit = Quality::REFRESH_LIMIT ) { if ( ! $this->plugin->settings()->is_connected() ) { return 0; } if ( false !== get_transient( Quality::REFRESH_LOCK ) ) { return 0; } set_transient( Quality::REFRESH_LOCK, 1, Quality::REFRESH_INTERVAL ); $ids = $this->plugin->repository()->ids_for_quality_refresh( (int) $limit, time() - HOUR_IN_SECONDS ); if ( empty( $ids ) ) { return 0; } $result = $this->plugin->quality()->fetch( $ids ); if ( ! empty( $result['errors'] ) ) { // Cached scores stay as they were: a failed read is not a reason to // forget a measurement. The next tick retries the same products, // because their fetch time did not move. $this->plugin->logger()->log( 'quality refresh could not read ' . count( $result['errors'] ) . ' products: ' . reset( $result['errors'] ), 'warning' ); } return count( $result['records'] ); } /** * Report a bounded batch of flagged products, then self-reschedule if more * remain. Mirrors `Cron::auto_apply_sweep()`, so a large backlog drains in * minutes rather than one batch per hour. * * @param int $limit Products per pass. * @return array{reported:int,skipped:int,failed:int} */ public function drain( $limit = self::BATCH ) { $empty = array( 'reported' => 0, 'skipped' => 0, 'failed' => 0, ); if ( ! $this->plugin->settings()->is_connected() ) { return $empty; } $ids = get_posts( array( 'post_type' => 'product', 'post_status' => 'any', 'fields' => 'ids', 'posts_per_page' => (int) $limit, 'orderby' => 'ID', 'order' => 'ASC', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query 'meta_query' => array( array( 'key' => Meta::SYNC_DIRTY, 'compare' => 'EXISTS', ), ), ) ); if ( empty( $ids ) ) { return $empty; } $decisions = array(); $by_product = array(); $flags = array(); $counts = $empty; foreach ( $ids as $id ) { $flag = get_post_meta( $id, Meta::SYNC_DIRTY, true ); $flags[ $id ] = $flag; $reason = ( is_array( $flag ) && isset( $flag['reason'] ) ) ? (string) $flag['reason'] : ''; $known = ( is_array( $flag ) && isset( $flag['fields'] ) && is_array( $flag['fields'] ) ) ? $flag['fields'] : array(); $built = $this->build_decisions( $id, $reason, $known ); if ( empty( $built ) ) { // Nothing to say about this product. Clear the flag rather than // carrying it for ever: a product with no proposals is not a // failure, and leaving it flagged would make every drain report // the same empty work. delete_post_meta( $id, Meta::SYNC_DIRTY ); delete_post_meta( $id, Meta::SYNC_ATTEMPTS ); ++$counts['skipped']; continue; } $decisions = array_merge( $decisions, $built ); $by_product[ $id ] = true; } if ( empty( $decisions ) ) { return $counts; } // AFTER every flag and every value was read, so anything written before // this moment is in the report. See Apply_Queue::deferred_by_edit(). $built_at = gmdate( 'c' ); $response = $this->plugin->api()->review_decisions( $this->plugin->settings()->get_store_id(), $decisions ); if ( is_wp_error( $response ) ) { // The decisions are still true; only the telling failed. Leave every // flag in place so the next tick retries, and count the attempt so a // permanently unreachable platform stops filling the log. foreach ( array_keys( $by_product ) as $id ) { $attempts = (int) get_post_meta( $id, Meta::SYNC_ATTEMPTS, true ) + 1; update_post_meta( $id, Meta::SYNC_ATTEMPTS, $attempts ); if ( $attempts >= self::MAX_ATTEMPTS ) { delete_post_meta( $id, Meta::SYNC_DIRTY ); } } $counts['failed'] = count( $by_product ); $this->plugin->logger()->log( 'review sync failed, decisions stay queued: ' . $response->get_error_message(), 'warning' ); return $counts; } foreach ( array_keys( $by_product ) as $id ) { delete_post_meta( $id, Meta::SYNC_ATTEMPTS ); // A flag that changed while the report was in flight is a NEW thing // to say (a merchant edit, usually), which this report did not // carry. Keep it for the next tick, and do not claim this product's // state as sent, so the apply queue keeps holding back its approvals. if ( wp_json_encode( get_post_meta( $id, Meta::SYNC_DIRTY, true ) ) !== wp_json_encode( $flags[ $id ] ) ) { continue; } delete_post_meta( $id, Meta::SYNC_DIRTY ); update_post_meta( $id, Meta::REVIEW_SYNCED_AT, $built_at ); } $counts['reported'] = count( $by_product ); // More flagged products behind this batch: re-arm a near-term tick // rather than waiting an hour per batch. if ( count( $ids ) >= (int) $limit && ! wp_next_scheduled( Cron::REVIEW_SYNC ) ) { wp_schedule_single_event( time() + 60, Cron::REVIEW_SYNC ); } return $counts; } /** * Flag every product that already carries a decision, once, on upgrade. * * WHY THIS EXISTS AT ALL. The Shopify half of this work backfills its * history with one SQL pass, because those rows live in the platform's own * database. This site's review state lives in `wp_postmeta` on the * merchant's server, where no query from Rezolve Ai can reach it. The only * way its history can ever appear in the web review queue is for the plugin * to volunteer it, so that is what this does. * * Bounded and resumable: a page of products per pass, a cursor in options, * self-rescheduling while work remains. A 50k-product store must not try to * do this in one request. * * @param int $limit Products per pass. * @return int Products flagged in this pass. */ public function backfill( $limit = self::BATCH ) { $cursor = (int) get_option( 'agencoen_review_backfill_cursor', 0 ); if ( $cursor < 0 ) { return 0; } $ids = get_posts( array( 'post_type' => 'product', 'post_status' => 'any', 'fields' => 'ids', 'posts_per_page' => (int) $limit, 'orderby' => 'ID', 'order' => 'ASC', 'include' => array(), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query 'meta_query' => array( array( 'key' => Meta::STATUS, 'value' => array( Meta::STATUS_ENRICHED, Meta::STATUS_APPLIED, Meta::STATUS_DISMISSED ), 'compare' => 'IN', ), ), 'post__not_in' => array(), 'offset' => $cursor, ) ); foreach ( $ids as $id ) { $this->mark_dirty( $id, 'backfill' ); } if ( count( $ids ) < (int) $limit ) { // Done. -1 rather than deleting the option, so a later upgrade does // not silently start the whole walk again. update_option( 'agencoen_review_backfill_cursor', -1, false ); } else { update_option( 'agencoen_review_backfill_cursor', $cursor + count( $ids ), false ); if ( ! wp_next_scheduled( Cron::REVIEW_BACKFILL ) ) { wp_schedule_single_event( time() + 120, Cron::REVIEW_BACKFILL ); } } return count( $ids ); } }