PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.14
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.14
1.10.19 1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 All 163 releases
woocommerce-pos / includes / Sync / Visibility_Observer.php

Visibility_Observer.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.14, at includes/Sync/Visibility_Observer.php

280 lines 10.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WCPOS POS-visibility change observer.
4 *
5 * @package WCPOS\WooCommercePOS\Sync
6 */
7
8 namespace WCPOS\WooCommercePOS\Sync;
9
10 /**
11 * Journals the moment a record enters or leaves the POS servable set.
12 *
13 * # Why this exists
14 *
15 * The catalogue change stream ({@see \WCPOS\WooCommercePOS\API\V2\Changes_Controller::sequence_log})
16 * drops update rows for records {@see Pos_Visibility} hides, because the catalog lane will never
17 * serve them: announcing one costs every till a targeted pull that comes back empty, inflates the
18 * replay backlog the client re-baselines on, and moves a head no till can act on.
19 *
20 * Dropping them is only safe because the TRANSITION is announced. A record that just became hidden
21 * is still resident on every till, and the one message about it a client must still receive is
22 * "drop this" — so hiding appends a TOMBSTONE and un-hiding appends an ordinary update row. The
23 * stream never filters tombstones, so the removal always lands.
24 *
25 * To the stream, hiding a record is indistinguishable from trashing it and un-hiding it from
26 * untrashing it, which is why both directions go through the journal's existing
27 * `record_post_deleted()` / `record_post_untrashed()` handlers rather than re-deriving the object
28 * type, the revision and the parent-product re-announcement. A client needs no new vocabulary: the
29 * `deleted` row runs through the same local-work-protected delete path a real delete does.
30 *
31 * # Why it observes OPTIONS rather than the settings API
32 *
33 * `Visibility_Section::update_visibility_settings()` is only one of the writers. The POS app PATCHes
34 * the whole section through the settings REST endpoint, and another plugin or wp-cli can call
35 * `update_option()` directly. Every one of those paths funnels through `update_option()`, so
36 * observing the options is what makes this self-healing in the same way
37 * {@see Config_Fingerprint} recomputes from live options instead of trusting a hook counter.
38 *
39 * The diff is taken over the RESOLVED hidden set — `Pos_Visibility::hidden_ids()`, the same call the
40 * stream filters on — not over the raw stored id lists. That is what makes the `pos_only_products`
41 * feature toggle work: flipping it moves the entire hidden set without touching a single id list.
42 * It also means an extension filtering the visibility settings is honoured here exactly as it is on
43 * every read lane.
44 */
45 final class Visibility_Observer {
46 /**
47 * Bump when the seeded set changes shape and every install must re-announce it.
48 *
49 * @var int
50 */
51 public const SEED_VERSION = 1;
52
53 /**
54 * The one-time seed latch.
55 *
56 * @var string
57 */
58 public const SEED_VERSION_OPTION = 'woocommerce_pos_sync_visibility_tombstone_seed';
59
60 /**
61 * The journal rows are appended to.
62 *
63 * @var Sync_Journal
64 */
65 private Sync_Journal $journal;
66
67 /**
68 * The POS servable-set authority.
69 *
70 * @var Pos_Visibility
71 */
72 private Pos_Visibility $visibility;
73
74 /**
75 * The resolved hidden set as it stood before an in-flight option write, keyed by option name.
76 *
77 * Keyed per option because a no-op write fires `pre_update_option_{$option}` and then NO
78 * `update_option_{$option}`; an unkeyed snapshot would be consumed by whichever option wrote
79 * next and diffed against the wrong baseline.
80 *
81 * @var array<string, int[]>
82 */
83 private array $hidden_before = array();
84
85 /**
86 * Constructor.
87 *
88 * @param null|Sync_Journal $journal Journal to append to.
89 * @param null|Pos_Visibility $visibility Servable-set authority.
90 */
91 public function __construct( ?Sync_Journal $journal = null, ?Pos_Visibility $visibility = null ) {
92 $this->journal = $journal ?? new Sync_Journal();
93 $this->visibility = $visibility ?? new Pos_Visibility();
94 }
95
96 /**
97 * Watch every option that can move the POS servable set.
98 */
99 public function register_hooks(): void {
100 // `delete_option` is the GENERIC pre-delete action — WordPress has no per-option form that
101 // fires before the row is gone (`delete_option_{$option}` fires after). Registered once and
102 // gated on the option name inside the callback.
103 add_action( 'delete_option', array( $this, 'snapshot_before_delete' ), 10, 1 );
104
105 foreach ( Pos_Visibility::source_options() as $option ) {
106 add_filter( "pre_update_option_{$option}", array( $this, 'snapshot_hidden_ids' ), 10, 3 );
107 add_action( "update_option_{$option}", array( $this, 'record_updated_option' ), 10, 3 );
108 add_action( "add_option_{$option}", array( $this, 'record_added_option' ), 10, 2 );
109 add_action( "delete_option_{$option}", array( $this, 'record_deleted_option' ), 10, 1 );
110 }
111 }
112
113 /**
114 * Announce every record that was hidden before this observer existed.
115 *
116 * An install that already had records hidden transitioned them while nothing was watching, so no
117 * tombstone was ever written for them. A till still holding one used to drop it on that record's
118 * next edit — the update row, the empty pull, the client's shortfall prune — and the stream no
119 * longer carries that update row. Without this pass the upgrade would strand exactly those
120 * records until a tier 2 sweep.
121 *
122 * Latched by its own option rather than the sync schema version: no table changed, and bumping
123 * the schema would re-run the unrelated customer compensation pass on every install. Two
124 * concurrent requests can both seed before either latches; the duplicate rows are identical
125 * tombstones at different sequences and a client applies them idempotently, which is the right
126 * trade at this tier.
127 */
128 public function maybe_seed_hidden_tombstones(): void {
129 if ( (int) get_option( self::SEED_VERSION_OPTION, 0 ) >= self::SEED_VERSION ) {
130 return;
131 }
132
133 $this->journal->append_catalogue_tombstones( $this->visibility->hidden_ids( Pos_Visibility::CATALOG ) );
134
135 // Latched even when the hidden set is empty — otherwise every request on a store that hides
136 // nothing would resolve the set again forever.
137 // Autoloaded: the Init constructor reads this latch on every request.
138 // Existing rows from older releases are flipped by
139 // Activator::autoload_request_latches() on upgrade.
140 update_option( self::SEED_VERSION_OPTION, self::SEED_VERSION, true );
141 }
142
143 /**
144 * Capture the hidden set before the write lands.
145 *
146 * Runs on `pre_update_option_{$option}`, which fires BEFORE the option row and its cache are
147 * updated — so `hidden_ids()` here still resolves the pre-write state. A pass-through filter:
148 * the value is returned untouched.
149 *
150 * @param mixed $value The value about to be written.
151 * @param mixed $old_value The value being replaced.
152 * @param string $option Option name.
153 *
154 * @return mixed
155 */
156 public function snapshot_hidden_ids( $value, $old_value = null, $option = '' ) {
157 if ( \is_string( $option ) && '' !== $option ) {
158 $this->hidden_before[ $option ] = $this->visibility->hidden_ids( Pos_Visibility::CATALOG );
159 }
160
161 return $value;
162 }
163
164 /**
165 * Journal the transitions an option update caused.
166 *
167 * @param mixed $old_value The replaced value.
168 * @param mixed $value The written value.
169 * @param string $option Option name.
170 */
171 public function record_updated_option( $old_value = null, $value = null, $option = '' ): void {
172 $this->consume_snapshot( $option );
173 }
174
175 /**
176 * Capture the hidden set before an option is deleted.
177 *
178 * Deleting either source moves the set exactly as writing it does: dropping the visibility option
179 * reveals every id it listed, and dropping the General option takes the `pos_only_products`
180 * feature down with it. Both are reachable from `Settings::delete_settings()`.
181 *
182 * @param string $option Option about to be deleted.
183 */
184 public function snapshot_before_delete( $option = '' ): void {
185 if ( ! \is_string( $option ) || ! \in_array( $option, Pos_Visibility::source_options(), true ) ) {
186 return;
187 }
188
189 $this->hidden_before[ $option ] = $this->visibility->hidden_ids( Pos_Visibility::CATALOG );
190 }
191
192 /**
193 * Journal the transitions deleting the option caused.
194 *
195 * @param string $option Option name.
196 */
197 public function record_deleted_option( $option = '' ): void {
198 $this->consume_snapshot( $option );
199 }
200
201 /**
202 * Journal the transitions adding the option caused.
203 *
204 * `add_option_{$option}` fires after the insert and has no pre-write counterpart, but it needs
205 * none: with the option absent the hidden set is necessarily empty — an unconfigured visibility
206 * option hides nothing, and an unconfigured general option leaves the `pos_only_products`
207 * feature off, which reports an empty set whatever the id lists hold.
208 *
209 * @param string $option Option name.
210 * @param mixed $value The inserted value.
211 */
212 public function record_added_option( $option = '', $value = null ): void {
213 $this->record_transitions( array() );
214 }
215
216 /**
217 * Diff against the snapshot this option's pre-write hook left, then discard it.
218 *
219 * The snapshot is consumed rather than merely read: a post-write hook that somehow arrives
220 * without its own pre-write counterpart must not diff against a stale baseline. A missing
221 * snapshot means the before state is unknown, and announcing the whole current set would be
222 * worse than announcing nothing.
223 *
224 * @param mixed $option Option name from the hook.
225 */
226 private function consume_snapshot( $option ): void {
227 if ( ! \is_string( $option ) || ! \array_key_exists( $option, $this->hidden_before ) ) {
228 return;
229 }
230
231 $before = $this->hidden_before[ $option ];
232 unset( $this->hidden_before[ $option ] );
233
234 $this->record_transitions( $before );
235 }
236
237 /**
238 * Append one journal row per record that entered or left the servable set.
239 *
240 * @param int[] $before The hidden set before the write.
241 */
242 private function record_transitions( array $before ): void {
243 $after = $this->visibility->hidden_ids( Pos_Visibility::CATALOG );
244
245 foreach ( array_diff( $after, $before ) as $id ) {
246 $this->record_transition( (int) $id, true );
247 }
248
249 foreach ( array_diff( $before, $after ) as $id ) {
250 $this->record_transition( (int) $id, false );
251 }
252 }
253
254 /**
255 * Record one record's servability change as the trash/untrash event it is.
256 *
257 * The post type is read from the post itself rather than from the id list the id came from: the
258 * lists are merchant-supplied, and a stale or mistyped id must not make the journal announce a
259 * change to whatever unrelated record now holds that number. An id with no post — deleted since
260 * it was hidden — resolves to no type and is skipped.
261 *
262 * @param int $id Post id whose POS servability changed.
263 * @param bool $hidden True when the record just left the servable set.
264 */
265 private function record_transition( int $id, bool $hidden ): void {
266 $post_type = get_post_type( $id );
267 if ( 'product' !== $post_type && 'product_variation' !== $post_type ) {
268 return;
269 }
270
271 if ( $hidden ) {
272 $this->journal->record_post_deleted( $id );
273
274 return;
275 }
276
277 $this->journal->record_post_untrashed( $id );
278 }
279 }
280