PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.4
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.4
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.4, at includes/Sync/Visibility_Observer.php

277 lines 10.6 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 update_option( self::SEED_VERSION_OPTION, self::SEED_VERSION, false );
138 }
139
140 /**
141 * Capture the hidden set before the write lands.
142 *
143 * Runs on `pre_update_option_{$option}`, which fires BEFORE the option row and its cache are
144 * updated — so `hidden_ids()` here still resolves the pre-write state. A pass-through filter:
145 * the value is returned untouched.
146 *
147 * @param mixed $value The value about to be written.
148 * @param mixed $old_value The value being replaced.
149 * @param string $option Option name.
150 *
151 * @return mixed
152 */
153 public function snapshot_hidden_ids( $value, $old_value = null, $option = '' ) {
154 if ( \is_string( $option ) && '' !== $option ) {
155 $this->hidden_before[ $option ] = $this->visibility->hidden_ids( Pos_Visibility::CATALOG );
156 }
157
158 return $value;
159 }
160
161 /**
162 * Journal the transitions an option update caused.
163 *
164 * @param mixed $old_value The replaced value.
165 * @param mixed $value The written value.
166 * @param string $option Option name.
167 */
168 public function record_updated_option( $old_value = null, $value = null, $option = '' ): void {
169 $this->consume_snapshot( $option );
170 }
171
172 /**
173 * Capture the hidden set before an option is deleted.
174 *
175 * Deleting either source moves the set exactly as writing it does: dropping the visibility option
176 * reveals every id it listed, and dropping the General option takes the `pos_only_products`
177 * feature down with it. Both are reachable from `Settings::delete_settings()`.
178 *
179 * @param string $option Option about to be deleted.
180 */
181 public function snapshot_before_delete( $option = '' ): void {
182 if ( ! \is_string( $option ) || ! \in_array( $option, Pos_Visibility::source_options(), true ) ) {
183 return;
184 }
185
186 $this->hidden_before[ $option ] = $this->visibility->hidden_ids( Pos_Visibility::CATALOG );
187 }
188
189 /**
190 * Journal the transitions deleting the option caused.
191 *
192 * @param string $option Option name.
193 */
194 public function record_deleted_option( $option = '' ): void {
195 $this->consume_snapshot( $option );
196 }
197
198 /**
199 * Journal the transitions adding the option caused.
200 *
201 * `add_option_{$option}` fires after the insert and has no pre-write counterpart, but it needs
202 * none: with the option absent the hidden set is necessarily empty — an unconfigured visibility
203 * option hides nothing, and an unconfigured general option leaves the `pos_only_products`
204 * feature off, which reports an empty set whatever the id lists hold.
205 *
206 * @param string $option Option name.
207 * @param mixed $value The inserted value.
208 */
209 public function record_added_option( $option = '', $value = null ): void {
210 $this->record_transitions( array() );
211 }
212
213 /**
214 * Diff against the snapshot this option's pre-write hook left, then discard it.
215 *
216 * The snapshot is consumed rather than merely read: a post-write hook that somehow arrives
217 * without its own pre-write counterpart must not diff against a stale baseline. A missing
218 * snapshot means the before state is unknown, and announcing the whole current set would be
219 * worse than announcing nothing.
220 *
221 * @param mixed $option Option name from the hook.
222 */
223 private function consume_snapshot( $option ): void {
224 if ( ! \is_string( $option ) || ! \array_key_exists( $option, $this->hidden_before ) ) {
225 return;
226 }
227
228 $before = $this->hidden_before[ $option ];
229 unset( $this->hidden_before[ $option ] );
230
231 $this->record_transitions( $before );
232 }
233
234 /**
235 * Append one journal row per record that entered or left the servable set.
236 *
237 * @param int[] $before The hidden set before the write.
238 */
239 private function record_transitions( array $before ): void {
240 $after = $this->visibility->hidden_ids( Pos_Visibility::CATALOG );
241
242 foreach ( array_diff( $after, $before ) as $id ) {
243 $this->record_transition( (int) $id, true );
244 }
245
246 foreach ( array_diff( $before, $after ) as $id ) {
247 $this->record_transition( (int) $id, false );
248 }
249 }
250
251 /**
252 * Record one record's servability change as the trash/untrash event it is.
253 *
254 * The post type is read from the post itself rather than from the id list the id came from: the
255 * lists are merchant-supplied, and a stale or mistyped id must not make the journal announce a
256 * change to whatever unrelated record now holds that number. An id with no post — deleted since
257 * it was hidden — resolves to no type and is skipped.
258 *
259 * @param int $id Post id whose POS servability changed.
260 * @param bool $hidden True when the record just left the servable set.
261 */
262 private function record_transition( int $id, bool $hidden ): void {
263 $post_type = get_post_type( $id );
264 if ( 'product' !== $post_type && 'product_variation' !== $post_type ) {
265 return;
266 }
267
268 if ( $hidden ) {
269 $this->journal->record_post_deleted( $id );
270
271 return;
272 }
273
274 $this->journal->record_post_untrashed( $id );
275 }
276 }
277