PluginProbe
SupportCandy – AI Customer Support Ticket System & Live Chatbot Agent / trunk
SupportCandy – AI Customer Support Ticket System & Live Chatbot Agent vtrunk
3.5.4 3.5.3 3.5.2 3.5.1 3.4.9 3.5.0 3.4.8 3.4.7 trunk 2.3.1 3.3.6 3.3.7 3.3.8 3.3.9 3.4.0 3.4.1 3.4.2 3.4.3 3.4.4 3.4.5 3.4.6
supportcandy / includes / class-wpsc-stats-cache.php

class-wpsc-stats-cache.php in SupportCandy – AI Customer Support Ticket System & Live Chatbot Agent trunk, at includes/class-wpsc-stats-cache.php

311 lines 12.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 if ( ! defined( 'ABSPATH' ) ) {
3 exit; // Exit if accessed directly!
4 }
5
6 if ( ! class_exists( 'WPSC_Stats_Cache' ) ) :
7
8 /**
9 * Shared read-through cache for computed ticket statistics - used by both the dashboard
10 * cards/widgets and the Reports section.
11 *
12 * Every expensive aggregate query is wrapped with remember(), keyed by the caller's own slug
13 * plus whatever arguments affect its result (e.g. a date range). Instead of tracking down and
14 * deleting each cached key by hand whenever a ticket changes, a single "generation" number is
15 * bumped on any ticket event that could affect these numbers; that number is folded into every
16 * cache key, so a bump instantly makes all previously cached keys unreachable without needing to
17 * know what they were. On sites without a persistent object cache (Redis/Memcached), a transient
18 * is really just two rows in wp_options, and WordPress only ever cleans those up lazily (on a
19 * request for that exact key, or via its own non-guaranteed periodic sweep) - so an orphaned
20 * prior-generation row can otherwise sit in wp_options indefinitely. To avoid that, every key this
21 * class writes is recorded against the generation it belongs to, and bump_generation() explicitly
22 * delete_transient()'s all of them before advancing the counter.
23 *
24 * The Reports section uses remember_report() instead: longer-lived entries that ignore the
25 * generation and are only refreshed on expiry or when the viewer explicitly recalculates.
26 */
27 final class WPSC_Stats_Cache {
28
29 /**
30 * Option name holding the current cache generation number.
31 *
32 * @var string
33 */
34 const GENERATION_OPTION = 'wpsc_stats_cache_gen';
35
36 /**
37 * Prefix for the option that tracks which transient keys were written under a given
38 * generation, e.g. 'wpsc_stats_cache_keys_14'. Used to explicitly clean up that generation's
39 * transients once it's superseded, instead of leaving them for WordPress to lazily expire.
40 *
41 * @var string
42 */
43 const KEYS_OPTION_PREFIX = 'wpsc_stats_cache_keys_';
44
45 /**
46 * Safety-net TTL (seconds) applied to every cached entry.
47 *
48 * @var int
49 */
50 const DEFAULT_TTL = 300; // 5 * MINUTE_IN_SECONDS, kept literal so this file has no load-order dependency.
51
52 /**
53 * TTL (seconds) for Reports-section entries written by remember_report().
54 *
55 * @var int
56 */
57 const REPORT_TTL = 43200; // 12 * HOUR_IN_SECONDS.
58
59 /**
60 * Option name holding the Reports-section cache version. Unlike the generation above it is
61 * NOT bumped on ticket events - only by flush_reports() - so report entries survive until
62 * their TTL runs out or the viewer explicitly recalculates.
63 *
64 * @var string
65 */
66 const REPORT_VERSION_OPTION = 'wpsc_stats_cache_report_ver';
67
68 /**
69 * Request flag (POST) asking remember_report() to skip the cached value and recompute.
70 *
71 * @var string
72 */
73 const RECALCULATE_PARAM = 'wpsc_rp_recalculate';
74
75 /**
76 * Response header carrying when the data served by this request was calculated (unix time).
77 * The Reports UI reads it to show "last calculated" without every report's response shape
78 * having to change.
79 *
80 * @var string
81 */
82 const COMPUTED_AT_HEADER = 'X-WPSC-Report-Computed-At';
83
84 /**
85 * Oldest calculation time among the report entries served in this request.
86 *
87 * @var int|null
88 */
89 private static $report_computed_at = null;
90
91 /**
92 * Initialize this class
93 */
94 public static function init() {
95
96 // Any of these events can change what a cached card/widget/report would show. Bumping the
97 // generation on all of them is deliberately broad (a tag change also invalidates entries
98 // that never look at tags) - over-invalidating once in a while is cheap; missing one of
99 // these and showing stale numbers is the bug we are specifically trying to avoid.
100 $events = array(
101 'wpsc_create_new_ticket',
102 'wpsc_delete_ticket',
103 'wpsc_ticket_delete_permanently',
104 'wpsc_ticket_archive',
105 'wpsc_ticket_restore',
106 'wpsc_change_ticket_status',
107 'wpsc_change_assignee',
108 'wpsc_change_ticket_category',
109 'wpsc_change_ticket_priority',
110 'wpsc_change_raised_by',
111 'wpsc_change_tag',
112 'wpsc_change_ticket_fields', // generic custom-field edits (single-select, checkbox, multi-select, radio-button, ...).
113 'wpsc_change_agentonly_fields', // same, for agent-only custom fields.
114 'wpsc_post_reply',
115 'wpsc_submit_note',
116 'wpsc_change_usergroup', // fired by the usergroup addon; harmless no-op if that addon isn't active.
117 'wpsc_change_ticket_rating', // fired by the satisfaction-survey addon; harmless no-op if inactive.
118 'wpsc_agent_role_update', // agent role capability changes.
119 'after_set_add_agent', // agent added (note: fires once per bulk "add agent" submit, not once per agent).
120 'wpsc_delete_agent', // agent removed/deactivated - also fires when its WP user account is deleted.
121 'wpsc_agentgroup_created', // fired by the agentgroup addon; harmless no-op if inactive.
122 'wpsc_agentgroup_updated', // same - agents/supervisors added or removed from an agentgroup.
123 'wpsc_agentgroup_deleted', // same - agentgroup removed entirely.
124 'wpsc_set_add_new_usergroup', // fired by the usergroup addon; harmless no-op if inactive.
125 'wpsc_clone_usergroup', // same - a usergroup created by cloning an existing one.
126 'wpsc_set_edit_usergroup', // same - covers members/supervisors added or removed (and any other edit).
127 'wpsc_before_destroy_usergroup', // same - usergroup deleted entirely (explicitly, or automatically once its last member is gone).
128 'delete_user', // a WP user's agent/usergroup membership can be cleaned up silently when their account is deleted.
129 );
130
131 foreach ( $events as $event ) {
132 add_action( $event, array( __CLASS__, 'bump_generation' ) );
133 }
134 }
135
136 /**
137 * Advance the cache generation, instantly invalidating every previously cached entry, and
138 * explicitly delete the now-orphaned transients for the generation being retired so they
139 * don't linger in wp_options waiting for WordPress's own lazy expiry.
140 *
141 * @return void
142 */
143 public static function bump_generation() {
144
145 $retiring_generation = self::generation();
146
147 update_option( self::GENERATION_OPTION, $retiring_generation + 1, false );
148
149 self::flush_generation_keys( $retiring_generation );
150 }
151
152 /**
153 * Current cache generation number.
154 *
155 * @return int
156 */
157 private static function generation() {
158
159 return (int) get_option( self::GENERATION_OPTION, 1 );
160 }
161
162 /**
163 * Record that $key was written under the current generation, so it can be explicitly
164 * deleted once that generation is retired.
165 *
166 * @param string $key Transient key, as produced in remember().
167 * @return void
168 */
169 private static function track_key( $key ) {
170
171 $option_name = self::KEYS_OPTION_PREFIX . self::generation();
172 $keys = get_option( $option_name, array() );
173
174 if ( ! in_array( $key, $keys, true ) ) {
175 $keys[] = $key;
176 update_option( $option_name, $keys, false );
177 }
178 }
179
180 /**
181 * Delete every transient recorded against $generation, then discard the tracking option
182 * itself - both the cached values and the bookkeeping that pointed at them are cleaned up
183 * together, leaving nothing behind in wp_options for a retired generation.
184 *
185 * @param int $generation Generation number being retired.
186 * @return void
187 */
188 private static function flush_generation_keys( $generation ) {
189
190 $option_name = self::KEYS_OPTION_PREFIX . $generation;
191 $keys = get_option( $option_name, array() );
192
193 foreach ( $keys as $key ) {
194 delete_transient( $key );
195 }
196
197 delete_option( $option_name );
198 }
199
200 /**
201 * Get a cached value, computing and caching it on a miss.
202 *
203 * @param string $slug Unique identifier for the card/widget/report, e.g. 'agent-workload'.
204 * @param array $args Any inputs that affect the result, e.g. a date range. Two calls
205 * with different $args for the same slug are cached separately.
206 * @param callable $callback Computes the value on a cache miss. Called with no arguments.
207 * @param boolean $per_viewer Pass true when the result differs per viewing agent (i.e. the
208 * query uses WPSC_Current_User::get_tl_system_query()/get_atl_system_query()
209 * or otherwise depends on who is looking). Left false, one cache
210 * entry is shared by every viewer - only set this when the data
211 * actually differs per viewer, otherwise this cache loses most of
212 * its benefit (each viewer pays the full cost on their own).
213 * @param integer $ttl Safety-net TTL in seconds, only relevant for changes that bypass
214 * the hooks in init() (e.g. a direct DB import).
215 * @return mixed
216 */
217 public static function remember( $slug, $args, $callback, $per_viewer = false, $ttl = self::DEFAULT_TTL ) {
218
219 $key_parts = array( $slug, self::generation(), wp_json_encode( $args ) );
220
221 if ( $per_viewer ) {
222 $key_parts[] = self::viewer_key_part();
223 }
224
225 $key = 'wpsc_sc_' . md5( implode( '|', $key_parts ) );
226
227 $value = get_transient( $key );
228 if ( false === $value ) {
229 $value = call_user_func( $callback );
230 set_transient( $key, $value, $ttl );
231 self::track_key( $key );
232 }
233
234 return $value;
235 }
236
237 /**
238 * Reports-section variant of remember(): cached for REPORT_TTL and deliberately NOT
239 * invalidated by ticket events, so a report shows the same numbers until they expire or the
240 * viewer hits "Recalculate" (which sends RECALCULATE_PARAM and forces a fresh computation that
241 * overwrites the entry). The calculation time is stored with the value and reported back via
242 * COMPUTED_AT_HEADER so the UI can tell the viewer how old the numbers are.
243 *
244 * Must be called before the handler prints anything, otherwise the header can't be sent.
245 *
246 * @param string $slug Unique identifier for the report, e.g. 'rp-ticket-statistics'.
247 * @param array $args Any inputs that affect the result (date range, filters, ...).
248 * @param callable $callback Computes the value on a cache miss. Called with no arguments.
249 * @param boolean $per_viewer Same meaning as in remember().
250 * @return mixed
251 */
252 public static function remember_report( $slug, $args, $callback, $per_viewer = false ) {
253
254 $key_parts = array( $slug, (int) get_option( self::REPORT_VERSION_OPTION, 1 ), wp_json_encode( $args ) );
255
256 if ( $per_viewer ) {
257 $key_parts[] = self::viewer_key_part();
258 }
259
260 $key = 'wpsc_rpc_' . md5( implode( '|', $key_parts ) );
261
262 // Only ever reached from report handlers that have already verified their nonce and the
263 // viewer's 'view-reports' capability.
264 $recalculate = ! empty( $_POST[ self::RECALCULATE_PARAM ] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
265
266 $entry = $recalculate ? false : get_transient( $key );
267 if ( ! is_array( $entry ) || ! array_key_exists( 'value', $entry ) ) {
268 $entry = array(
269 'value' => call_user_func( $callback ),
270 'computed_at' => time(),
271 );
272 set_transient( $key, $entry, self::REPORT_TTL );
273 }
274
275 self::$report_computed_at = null === self::$report_computed_at
276 ? $entry['computed_at']
277 : min( self::$report_computed_at, $entry['computed_at'] );
278
279 if ( ! headers_sent() ) {
280 header( self::COMPUTED_AT_HEADER . ': ' . self::$report_computed_at );
281 }
282
283 return $entry['value'];
284 }
285
286 /**
287 * Make every Reports-section entry unreachable, for changes that bypass the ticket hooks but
288 * alter report numbers (e.g. the bulk ticket-metrics recalculation). The orphaned transients
289 * are left to expire (REPORT_TTL) and be swept by WordPress's daily expired-transient cleanup.
290 *
291 * @return void
292 */
293 public static function flush_reports() {
294
295 update_option( self::REPORT_VERSION_OPTION, (int) get_option( self::REPORT_VERSION_OPTION, 1 ) + 1, false );
296 }
297
298 /**
299 * Cache key part identifying the current viewer, for per-viewer entries.
300 *
301 * @return string
302 */
303 private static function viewer_key_part() {
304
305 $current_user = WPSC_Current_User::$current_user;
306 return $current_user->is_agent ? 'agent_' . $current_user->agent->id : 'customer_' . $current_user->customer->id;
307 }
308 }
309 endif;
310 WPSC_Stats_Cache::init();
311