>, totals: array, coupons: string[]}|null */ private static function source( AgentAdapt_Context $c ): ?array { $days = self::retention_days(); if ( $days < 1 || $c->subject_id < 1 || AgentAdapt_Agents::is_public( $c->agent_meta ) ) { return null; } global $wpdb; $t = AgentAdapt_Schema::table( 'sessions' ); $rows = (array) $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "SELECT session_id, wc_session_key, bound_user_id FROM %i WHERE client_hash = %s AND subject_id = %d AND session_id <> %s AND wc_session_key <> '' AND created <= ( SELECT created FROM %i WHERE session_id = %s ) ORDER BY last_used DESC LIMIT %d", $t, $c->client_hash, $c->subject_id, $c->session_id, $t, $c->session_id, self::CANDIDATES ), ARRAY_A ); $stored = AgentAdapt_WC_Session::stored( array_column( $rows, 'wc_session_key' ) ); $best = null; foreach ( $rows as $row ) { $data = $stored[ (string) $row['wc_session_key'] ] ?? null; $changed = (int) ( $data[ AgentAdapt_WC_Session::CHANGED ] ?? 0 ); if ( null !== $data && $changed > 0 && ( null === $best || $changed > $best['changed'] ) ) { $best = array( 'row' => $row, 'data' => $data, 'changed' => $changed ); } } if ( null === $best || $best['changed'] < time() - $days * DAY_IN_SECONDS ) { return null; } $cart = array(); foreach ( (array) ( $best['data']['cart'] ?? array() ) as $key => $line ) { if ( is_array( $line ) && (int) ( $line['product_id'] ?? 0 ) > 0 && (int) ( $line['quantity'] ?? 0 ) > 0 ) { $cart[ (string) $key ] = $line; } } $bound = (int) $best['row']['bound_user_id']; if ( ! $cart || ( $bound > 0 && $bound !== $c->user_id ) || self::handed_off( (string) $best['row']['session_id'], $best['changed'] ) ) { return null; } return array( 'session_id' => (string) $best['row']['session_id'], 'changed' => $best['changed'], 'cart' => $cart, 'totals' => (array) ( $best['data']['cart_totals'] ?? array() ), 'coupons' => array_values( array_map( 'strval', (array) ( $best['data']['applied_coupons'] ?? array() ) ) ), ); } /** * Whether a Session's handoff link was opened at or after a time. The * event trail records it (`handoff.redeemed`); with events kept for less * than the cart window, an older handoff is no longer known. * * @param string $session_id Session. * @param int $since Unix time. */ private static function handed_off( string $session_id, int $since ): bool { global $wpdb; $at = $wpdb->get_var( $wpdb->prepare( "SELECT MAX(created) FROM %i WHERE session_id = %s AND event = 'handoff.redeemed'", AgentAdapt_Schema::table( 'events' ), $session_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery return null !== $at && (int) $at >= $since; } /** * Whether the bound Session already restored this version of the cart. * * @param array $source source(). */ private static function restored( array $source ): bool { $done = WC()->session->get( self::RESTORED ); return is_array( $done ) && ( $done['from'] ?? '' ) === $source['session_id'] && (int) ( $done['changed'] ?? 0 ) === (int) $source['changed']; } /** * `saved_cart` for the bound Session's cart answer: what an earlier * Session left, or null. * * @param AgentAdapt_Context $c Who is asking (its cart is bound). * @return array|null */ public static function summary( AgentAdapt_Context $c ): ?array { $src = self::source( $c ); if ( null === $src || self::restored( $src ) ) { return null; } $cur = get_woocommerce_currency(); $count = 0; $lines = array(); foreach ( $src['cart'] as $line ) { $count += (int) $line['quantity']; if ( count( $lines ) < self::SUMMARY_LINES ) { $lines[] = self::describe( $line ); } } return array( 'item_count' => $count, 'line_count' => count( $src['cart'] ), 'lines' => $lines, // As that Session last calculated them; restored lines show today's // prices, with a price_changed notice where they differ. 'totals' => array( 'subtotal' => AgentAdapt_Money::from_decimal( $src['totals']['subtotal'] ?? null, $cur ), 'total' => AgentAdapt_Money::from_decimal( $src['totals']['total'] ?? null, $cur ), ), 'coupons' => $src['coupons'], 'last_changed' => gmdate( 'c', $src['changed'] ), ); } /** * A saved line, named as the store names it today. * * @param array $line Stored cart line. * @return array{product_id: int, variation_id: int, name: string, quantity: int} */ private static function describe( array $line ): array { $pid = (int) $line['product_id']; $vid = (int) ( $line['variation_id'] ?? 0 ); $product = wc_get_product( $vid ? $vid : $pid ); return array( 'product_id' => $pid, 'variation_id' => $vid, 'name' => $product ? AgentAdapt_Catalog::text( $product->get_name() ) : '', 'quantity' => (int) $line['quantity'], ); } /** * cart.restore * * @param AgentAdapt_Context $context Context. * @param array $p Params (none). * @return array|WP_Error */ public static function restore( AgentAdapt_Context $context, array $p ) { $result = null; $cart = AgentAdapt_Cart::run( $context, static function () use ( $context, &$result ) { $src = self::source( $context ); if ( null === $src ) { return new WP_Error( 'not_found', 'there is no saved cart to restore', array( 'status' => 404 ) ); } if ( self::restored( $src ) ) { $result = array( 'status' => 'already_restored', 'restored' => 0, 'skipped' => array() ); return null; } $result = array( 'status' => 'restored' ) + self::merge( $src['cart'] ); WC()->session->set( self::RESTORED, array( 'from' => $src['session_id'], 'changed' => $src['changed'] ) ); AgentAdapt_Events::record( 'cart.restored', $context, array( 'value' => $result['restored'], 'meta' => array( 'from' => $src['session_id'], 'skipped' => count( $result['skipped'] ) ) ) ); return null; } ); if ( is_wp_error( $cart ) ) { return $cart; } $cart['restore'] = $result; return $cart; } /** * Merge saved lines into the bound cart: a line already here keeps the * larger quantity, a new line keeps its earlier price as its baseline. * * @param array> $lines Saved lines. * @return array{restored: int, skipped: array>} */ private static function merge( array $lines ): array { $restored = 0; $skipped = array(); $skip = static function ( array $line, string $reason, string $message ) use ( &$skipped ): void { $skipped[] = self::describe( $line ) + array( 'reason' => $reason, 'message' => $message ); }; AgentAdapt_Cart_Notices::pause( true ); try { foreach ( $lines as $line ) { $pid = (int) $line['product_id']; $vid = (int) ( $line['variation_id'] ?? 0 ); $attrs = array_map( 'strval', (array) ( $line['variation'] ?? array() ) ); $want = min( AgentAdapt_Cart::MAX_QTY, (int) $line['quantity'] ); $product = wc_get_product( $vid ? $vid : $pid ); if ( ! $product || array_diff_key( $line, array_flip( self::LINE_KEYS ) ) || ! AgentAdapt_Catalog::resolvable( $vid ? $vid : $pid ) ) { $skip( $line, 'unavailable', 'this item is no longer sold here, or has options that cannot be restored' ); continue; } if ( ! $product->is_in_stock() ) { $skip( $line, 'out_of_stock', 'this item is out of stock' ); continue; } $here = (string) WC()->cart->find_product_in_cart( WC()->cart->generate_cart_id( $pid, $vid, $attrs ) ); $have = '' !== $here ? (int) WC()->cart->cart_contents[ $here ]['quantity'] : 0; if ( $have >= $want ) { ++$restored; continue; } if ( '' === $here && count( WC()->cart->get_cart() ) >= AgentAdapt_Cart::MAX_LINES ) { $skip( $line, 'rejected', 'the cart is full' ); continue; } wc_clear_notices(); $key = WC()->cart->add_to_cart( $pid, $want - $have, $vid, $attrs ); if ( ! $key ) { $skip( $line, 'rejected', AgentAdapt_Cart::notice_text( 'the store would not add this item' ) ); continue; } if ( null === AgentAdapt_Cart_Notices::seen( WC()->cart->cart_contents[ $key ] ?? array() ) ) { $seen = AgentAdapt_Cart_Notices::seen( $line ); if ( null !== $seen ) { WC()->cart->cart_contents[ $key ][ AgentAdapt_Cart_Notices::SEEN ] = $seen; } else { AgentAdapt_Cart_Notices::mark_now( (string) $key ); } } ++$restored; } } finally { AgentAdapt_Cart_Notices::pause( false ); } wc_clear_notices(); return array( 'restored' => $restored, 'skipped' => $skipped ); } /** * Hourly: delete the carts of Sessions that can no longer be used (ended, * or past their idle lifetime) once the retention window has passed since * they were last used, then forget the Session's cart key so each is * visited once. A Session that can still be used keeps its cart, whatever * its age: emptying it would change an agent's cart behind its back. */ public static function purge(): void { global $wpdb; $now = time(); $rows = (array) $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "SELECT session_id, wc_session_key FROM %i WHERE wc_session_key <> '' AND ( ended = 1 OR expires < %d ) AND last_used < %d ORDER BY last_used ASC LIMIT %d", AgentAdapt_Schema::table( 'sessions' ), $now, $now - self::retention_days() * DAY_IN_SECONDS, self::PURGE_BATCH ), ARRAY_A ); if ( ! $rows ) { return; } AgentAdapt_WC_Session::delete_stored( array_column( $rows, 'wc_session_key' ) ); AgentAdapt_Sessions::forget_carts( array_column( $rows, 'session_id' ) ); } }