PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / trunk
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings vtrunk
7.2.2 7.2.1 7.2 7.1.2 7.1.1 7.1 7.0.4 7.0.6 7.0.7 6.3.8 6.3.7 6.3.6 6.3.5 6.3.4 6.3.3 6.3.1 trunk 5.7.3 5.7.5 5.8.1 5.8.2 5.8.3 5.8.4 5.8.6 6.0.4 All 37 releases
mlsimport / includes / standalone / class-mlsimport-saved-search.php

class-mlsimport-saved-search.php in MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings trunk, at includes/standalone/class-mlsimport-saved-search.php

343 lines 13.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Saved Search (Standalone mode only, ADR-0018): storage and lifecycle.
4 *
5 * A Saved Search is a set of listing search criteria kept for one Recipient
6 * (name + email, no WordPress account needed) so matching listings can be
7 * emailed to them once a day. This file owns:
8 *
9 * - the non-public `mlsimport_search` post type (one post per search);
10 * - create(): validate a save request, store it as PENDING, mail the
11 * double-opt-in confirmation link;
12 * - confirm(): pending -> active, and tell the site owner;
13 * - unsubscribe(): active/pending -> unsubscribed (the link in every mail);
14 * - get(): read one Saved Search back as a plain array.
15 *
16 * Lifecycle is one-way: pending -> active -> unsubscribed. There is no pause,
17 * no resubscribe and no editing; changing criteria means saving a new search.
18 *
19 * What it does NOT do: the daily matching + alert mail live in
20 * class-mlsimport-saved-search-alerts.php; mail rendering lives in
21 * class-mlsimport-saved-search-mailer.php.
22 *
23 * Extension points (WooCommerce style): mlsimport_saved_search_validation,
24 * mlsimport_saved_search_created, mlsimport_saved_search_confirmed,
25 * mlsimport_saved_search_deactivated.
26 *
27 * @package Mlsimport
28 */
29
30 if ( ! defined( 'ABSPATH' ) ) {
31 exit;
32 }
33
34 /**
35 * Stores Saved Searches and moves them through their lifecycle.
36 */
37 class Mlsimport_Saved_Search {
38
39 const POST_TYPE = 'mlsimport_search'; // WordPress caps post type names at 20 characters.
40
41 /**
42 * Request keys that pass the listings whitelist but are NOT search criteria:
43 * sorting, paging and the map viewport describe how the visitor was looking
44 * at the results, not which listings they want. They are never stored.
45 */
46 const NOT_CRITERIA = array( 'orderby', 'order', 'limit', 'page', 'lat_min', 'lat_max', 'lng_min', 'lng_max' );
47
48 /**
49 * Register the post type. Never public on the front end. In wp-admin it shows
50 * as a plain LIST under the MLS Import menu (columns + row actions come from
51 * Mlsimport_Saved_Search_Admin): nobody can add one there — a Saved Search is
52 * only ever created from the search results — so create_posts is denied.
53 *
54 * @return void
55 */
56 public static function register(): void {
57 register_post_type(
58 self::POST_TYPE,
59 array(
60 'labels' => array(
61 'name' => __( 'Saved Searches', 'mlsimport' ),
62 'singular_name' => __( 'Saved Search', 'mlsimport' ),
63 'not_found' => __( 'No saved searches yet.', 'mlsimport' ),
64 ),
65 'public' => false,
66 'show_ui' => true,
67 'show_in_menu' => 'mlsimport_plugin_options',
68 'show_in_rest' => false,
69 'exclude_from_search' => true,
70 'rewrite' => false,
71 'query_var' => false,
72 'supports' => array( 'title' ),
73 'capability_type' => 'post',
74 'capabilities' => array( 'create_posts' => 'do_not_allow' ),
75 'map_meta_cap' => true,
76 )
77 );
78 }
79
80 /**
81 * Whether the feature is switched on (Design Settings -> Saved Search) AND the
82 * site runs in Standalone mode — the only mode that has the fast table.
83 *
84 * @return bool
85 */
86 public static function enabled(): bool {
87 return function_exists( 'mlsimport_is_standalone_mode' )
88 && mlsimport_is_standalone_mode()
89 && 'no' !== mlsimport_standalone_option( 'saved_search_enabled', 'yes' );
90 }
91
92 /**
93 * Validate a save request and store it as a PENDING Saved Search.
94 *
95 * Pure of the nonce/HTTP layer (the AJAX shell calls this), so it is testable
96 * on its own. $input is the raw unslashed request: the modal's fields plus the
97 * results page's current filter params as top-level keys.
98 *
99 * @param array $input Raw (unslashed) request fields.
100 * @return array{ok:bool,message:string,id:int}
101 */
102 public static function create( array $input ): array {
103 // Step 1 — honeypot: a filled hidden field is a bot. Answer "success" so it
104 // learns nothing, store nothing.
105 if ( ! empty( $input['mlsimport_hp'] ) ) {
106 return self::result( true, __( 'Check your email to confirm your saved search.', 'mlsimport' ) );
107 }
108
109 // Step 2 — the Recipient: a name and a valid email are both required.
110 $name = isset( $input['mlsimport_name'] ) ? sanitize_text_field( (string) $input['mlsimport_name'] ) : '';
111 $email = isset( $input['mlsimport_email'] ) ? sanitize_email( (string) $input['mlsimport_email'] ) : '';
112 if ( '' === $name || ! is_email( $email ) ) {
113 return self::result( false, __( 'Please provide your name and a valid email.', 'mlsimport' ) );
114 }
115
116 // Step 3 — consent is enforced here; the browser's `required` is not enough.
117 if ( empty( $input['mlsimport_consent'] ) ) {
118 return self::result( false, __( 'Please accept the privacy policy to save your search.', 'mlsimport' ) );
119 }
120
121 // Step 4 — the criteria. atts_to_args() is the same whitelist the results
122 // page itself applies to the URL, so only real filter keys survive (an
123 // injected `post_ids` is dropped here). Then strip sort/paging/viewport.
124 $params = Mlsimport_Standalone_Shortcodes::atts_to_args( $input );
125 $params = array_diff_key( $params, array_flip( self::NOT_CRITERIA ) );
126 if ( empty( $params ) ) {
127 return self::result( false, __( 'Choose at least one filter first.', 'mlsimport' ) );
128 }
129
130 // Step 5 — the results page the search was saved from. Every alert mail links
131 // back to it, so it must be a URL on THIS site: anything else would let a
132 // stranger make the site email arbitrary links to a victim's inbox.
133 $results_url = isset( $input['results_url'] ) ? esc_url_raw( (string) $input['results_url'] ) : '';
134 if ( '' === $results_url || wp_parse_url( $results_url, PHP_URL_HOST ) !== wp_parse_url( home_url(), PHP_URL_HOST ) ) {
135 return self::result( false, __( 'This search cannot be saved from here.', 'mlsimport' ) );
136 }
137 // Keep the PAGE, replace its query with the saved criteria. The visitor's
138 // address bar is not trustworthy for this: the results page refines over AJAX
139 // without ever updating the URL, so its query can describe an older search.
140 $results_url = esc_url_raw( add_query_arg( urlencode_deep( $params ), strtok( $results_url, '?#' ) ) );
141
142 // Step 6 — extension validation (reCAPTCHA, blocklists, rate limits...): a
143 // non-empty errors list rejects the save with its first message.
144 /** Filter Saved Search validation errors. @since 7.3 */
145 $errors = (array) apply_filters( 'mlsimport_saved_search_validation', array(), $input, $params );
146 if ( ! empty( $errors ) ) {
147 return self::result( false, (string) reset( $errors ) );
148 }
149
150 // Step 7 — store. The post is only a container; everything lives in meta.
151 $id = wp_insert_post(
152 array(
153 'post_type' => self::POST_TYPE,
154 'post_status' => 'publish',
155 'post_title' => $name . ' <' . $email . '>',
156 )
157 );
158 if ( ! $id || is_wp_error( $id ) ) {
159 return self::result( false, __( 'Sorry, your search could not be saved. Please try again.', 'mlsimport' ) );
160 }
161 $id = (int) $id;
162
163 // The token is the only credential the confirm/unsubscribe links carry.
164 update_post_meta( $id, 'mlsimport_ss_name', $name );
165 update_post_meta( $id, 'mlsimport_ss_email', $email );
166 update_post_meta( $id, 'mlsimport_ss_params', $params );
167 update_post_meta( $id, 'mlsimport_ss_results_url', $results_url );
168 update_post_meta( $id, 'mlsimport_ss_status', 'pending' );
169 update_post_meta( $id, 'mlsimport_ss_token', wp_generate_password( 32, false ) );
170 update_post_meta( $id, 'mlsimport_ss_user_id', get_current_user_id() );
171
172 /** Fires after a Saved Search is stored as pending. @since 7.3 */
173 do_action( 'mlsimport_saved_search_created', $id, self::get( $id ) );
174
175 // Step 8 — double opt-in: nothing is sent until this link is clicked.
176 Mlsimport_Saved_Search_Mailer::send_confirmation( self::get( $id ) );
177
178 return self::result( true, __( 'Check your email to confirm your saved search.', 'mlsimport' ), $id );
179 }
180
181 /**
182 * Confirm a pending Saved Search from its emailed link: pending -> active, then
183 * tell the site owner. Any other state is left alone (a second click on the
184 * link, or a click after unsubscribing, changes nothing and notifies nobody).
185 *
186 * @param string $token Token from the confirmation link.
187 * @return bool True when the search is active after the call.
188 */
189 public static function confirm( string $token ): bool {
190 $id = self::id_for_token( $token );
191 if ( ! $id ) {
192 return false;
193 }
194 $status = (string) get_post_meta( $id, 'mlsimport_ss_status', true );
195 if ( 'pending' !== $status ) {
196 return 'active' === $status;
197 }
198
199 update_post_meta( $id, 'mlsimport_ss_status', 'active' );
200
201 /** Fires when the Recipient confirms a Saved Search. @since 7.3 */
202 do_action( 'mlsimport_saved_search_confirmed', $id, self::get( $id ) );
203
204 // The owner hears about a Saved Search only now — never about unconfirmed ones.
205 Mlsimport_Saved_Search_Mailer::send_owner_notice( self::get( $id ) );
206 return true;
207 }
208
209 /**
210 * Stop a Saved Search for good (the unsubscribe link, or wp-admin "Deactivate").
211 * The post is kept so the owner still sees it in the list as unsubscribed.
212 *
213 * @param string $token Token from the unsubscribe link.
214 * @return bool True when a Saved Search with that token exists.
215 */
216 public static function unsubscribe( string $token ): bool {
217 $id = self::id_for_token( $token );
218 if ( ! $id ) {
219 return false;
220 }
221 self::deactivate( $id );
222 return true;
223 }
224
225 /**
226 * Mark one Saved Search unsubscribed by id. Shared by the emailed link and the
227 * wp-admin row action so both fire the same hook.
228 *
229 * @param int $id Saved Search post ID.
230 * @return void
231 */
232 public static function deactivate( int $id ): void {
233 if ( 'unsubscribed' === get_post_meta( $id, 'mlsimport_ss_status', true ) ) {
234 return;
235 }
236 update_post_meta( $id, 'mlsimport_ss_status', 'unsubscribed' );
237
238 /** Fires when a Saved Search stops sending (link or admin). @since 7.3 */
239 do_action( 'mlsimport_saved_search_deactivated', $id, self::get( $id ) );
240 }
241
242 /**
243 * Read one Saved Search as a plain array, or null when the id is not one.
244 *
245 * @param int $id Saved Search post ID.
246 * @return array{id:int,name:string,email:string,params:array,results_url:string,status:string,token:string,last_sent:string,user_id:int,created:string}|null
247 */
248 public static function get( int $id ) {
249 if ( self::POST_TYPE !== get_post_type( $id ) ) {
250 return null;
251 }
252 return array(
253 'id' => $id,
254 'name' => (string) get_post_meta( $id, 'mlsimport_ss_name', true ),
255 'email' => (string) get_post_meta( $id, 'mlsimport_ss_email', true ),
256 'params' => (array) get_post_meta( $id, 'mlsimport_ss_params', true ),
257 'results_url' => (string) get_post_meta( $id, 'mlsimport_ss_results_url', true ),
258 'status' => (string) get_post_meta( $id, 'mlsimport_ss_status', true ),
259 'token' => (string) get_post_meta( $id, 'mlsimport_ss_token', true ),
260 'last_sent' => (string) get_post_meta( $id, 'mlsimport_ss_last_sent', true ),
261 'user_id' => (int) get_post_meta( $id, 'mlsimport_ss_user_id', true ),
262 'created' => (string) get_post_field( 'post_date', $id ),
263 );
264 }
265
266 /**
267 * IDs of every Saved Search in a given status, optionally only those created
268 * before a cutoff (the daily job's "unconfirmed for 7 days" cleanup).
269 *
270 * @param string $status 'pending' | 'active' | 'unsubscribed'.
271 * @param string $before Optional strtotime()-style cutoff, e.g. '7 days ago'.
272 * @return int[]
273 */
274 public static function ids_by_status( string $status, string $before = '' ): array {
275 $query = array(
276 'post_type' => self::POST_TYPE,
277 'post_status' => 'publish',
278 'posts_per_page' => -1,
279 'fields' => 'ids',
280 'no_found_rows' => true,
281 'meta_key' => 'mlsimport_ss_status', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
282 'meta_value' => $status, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
283 );
284 if ( '' !== $before ) {
285 $query['date_query'] = array( array( 'before' => $before ) );
286 }
287 return array_map( 'intval', get_posts( $query ) );
288 }
289
290 /**
291 * The results page (with the saved criteria in its query) a token's Saved Search
292 * was saved from. The confirm link lands the Recipient there.
293 *
294 * @param string $token Token from an emailed link.
295 * @return string URL, or '' when the token matches nothing.
296 */
297 public static function results_url_for_token( string $token ): string {
298 $id = self::id_for_token( $token );
299 return $id ? (string) get_post_meta( $id, 'mlsimport_ss_results_url', true ) : '';
300 }
301
302 /**
303 * Find the Saved Search a link token belongs to.
304 *
305 * @param string $token Token from an emailed link.
306 * @return int Post ID, or 0 when the token matches nothing.
307 */
308 private static function id_for_token( string $token ): int {
309 // Tokens are 32 alphanumerics; refuse anything else before touching the DB.
310 if ( ! preg_match( '/^[A-Za-z0-9]{32}$/', $token ) ) {
311 return 0;
312 }
313 $ids = get_posts(
314 array(
315 'post_type' => self::POST_TYPE,
316 'post_status' => 'publish',
317 'posts_per_page' => 1,
318 'fields' => 'ids',
319 'no_found_rows' => true,
320 'meta_key' => 'mlsimport_ss_token', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
321 'meta_value' => $token, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
322 )
323 );
324 return $ids ? (int) $ids[0] : 0;
325 }
326
327 /**
328 * Shape the { ok, message, id } answer create() returns.
329 *
330 * @param bool $ok Whether the save succeeded.
331 * @param string $message Visitor-facing message.
332 * @param int $id New Saved Search ID (0 when nothing was stored).
333 * @return array{ok:bool,message:string,id:int}
334 */
335 private static function result( bool $ok, string $message, int $id = 0 ): array {
336 return array(
337 'ok' => $ok,
338 'message' => $message,
339 'id' => $id,
340 );
341 }
342 }
343