PluginProbe
Booktics – Appointment Booking Calendar for Service Businesses / 1.0.28
Booktics – Appointment Booking Calendar for Service Businesses v1.0.28
1.0.29 1.0.28 1.0.27 1.0.26 1.0.25 1.0.24 1.0.23 1.0.22 1.0.21 1.0.20 1.0.19 trunk 1.0.0 1.0.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.15 1.0.16 1.0.17 1.0.18 1.0.2 1.0.3 All 31 releases
← All changes | core/appointment/services/appointment-validator.php +178 -0 1.0.21 → 1.0.28 View file →
@@ -1,8 +1,9 @@
1 1 <?php
2 2 namespace Booktics\Appointment\Services;
3 3
4 4 use Booktics\Models\Appointment_Model;
5 +use Booktics\Models\Service_Model;
5 6 use Booktics\Utils\Singleton;
6 7 use WP_Error;
7 8
8 9 class Appointment_Validator {
@@ -143,6 +144,183 @@
143 144 }
144 145 }
145 146
146 147 return null;
148 + }
149 +
150 + /**
151 + * Check that the requested slot is still free for the requested team member.
152 + *
153 + * Guards the window between a customer seeing a slot and submitting the
154 + * booking. Overlap is checked against the same date and team member, so an
155 + * appointment that starts before the request ends and ends after it starts
156 + * is a conflict.
157 + *
158 + * @param array $data Appointment data. Requires date, start_time, team_member_id.
159 + *
160 + * @return \WP_Error|null Null when the slot is free.
161 + */
162 + public static function validate_slot_available( array $data ) {
163 + $team_member_id = absint( $data['team_member_id'] ?? 0 );
164 + $date = $data['date'] ?? '';
165 + $start = $data['start_time'] ?? '';
166 +
167 + if ( ! $team_member_id || ! $date || ! $start ) {
168 + return null;
169 + }
170 +
171 + $request_start = strtotime( $start );
172 +
173 + // strtotime() answers false on anything it cannot read, and false + 1800
174 + // is 1800, which is a window near the epoch that matches nothing. That
175 + // would pass every booking through, so refuse instead.
176 + if ( false === $request_start ) {
177 + return new WP_Error(
178 + 'invalid_start_time',
179 + __( 'That start time could not be read. Please choose another slot.', 'booktics' )
180 + );
181 + }
182 +
183 + $duration = self::duration_in_minutes( $data, $request_start );
184 +
185 + if ( $duration < 1 ) {
186 + return new WP_Error(
187 + 'invalid_duration',
188 + __( 'That appointment length could not be worked out. Please choose another slot.', 'booktics' )
189 + );
190 + }
191 +
192 + $request_end = $request_start + ( $duration * 60 );
193 + $requested = max( 1, absint( $data['total_attendees'] ?? 1 ) );
194 + $capacity = self::slot_capacity( absint( $data['service_id'] ?? 0 ) );
195 +
196 + $existing = Appointment_Model::get_appointment_ids_for_slot( $date, $team_member_id, self::blocking_statuses() );
197 +
198 + $booked = 0;
199 +
200 + foreach ( $existing as $appointment_id ) {
201 + if ( ! empty( $data['id'] ) && (int) $data['id'] === (int) $appointment_id ) {
202 + continue;
203 + }
204 +
205 + $existing_start = strtotime( (string) get_post_meta( $appointment_id, 'start_time', true ) );
206 +
207 + // An unreadable stored time cannot be compared. Treat it as
208 + // blocking rather than silently letting a second booking in.
209 + if ( false === $existing_start ) {
210 + $booked += 1;
211 + continue;
212 + }
213 +
214 + $existing_duration = absint( get_post_meta( $appointment_id, 'duration', true ) );
215 + if ( $existing_duration < 1 ) {
216 + $existing_duration = $duration;
217 + }
218 +
219 + $existing_end = $existing_start + ( $existing_duration * 60 );
220 +
221 + if ( $request_start < $existing_end && $request_end > $existing_start ) {
222 + $booked += max( 1, absint( get_post_meta( $appointment_id, 'total_attendees', true ) ) );
223 + }
224 + }
225 +
226 + if ( $booked + $requested > $capacity ) {
227 + return new WP_Error(
228 + 'slot_no_longer_available',
229 + __( 'That time was just taken. Please choose another slot.', 'booktics' )
230 + );
231 + }
232 +
233 + return null;
234 + }
235 +
236 + /**
237 + * How long the appointment being checked runs for, in minutes.
238 + *
239 + * Prefers an explicit duration, then the gap between the submitted start
240 + * and end times, then the service's own length. A bare literal is never
241 + * used: guessing 30 on a 60 minute service would miss every conflict in
242 + * the second half hour.
243 + *
244 + * @param array $data Appointment data.
245 + * @param int $request_start Parsed start timestamp.
246 + *
247 + * @return int Minutes, or 0 when it cannot be worked out.
248 + */
249 + private static function duration_in_minutes( array $data, $request_start ) {
250 + $duration = absint( $data['duration'] ?? 0 );
251 +
252 + if ( $duration > 0 ) {
253 + return $duration;
254 + }
255 +
256 + $end = strtotime( (string) ( $data['end_time'] ?? '' ) );
257 +
258 + if ( false !== $end && $end > $request_start ) {
259 + return (int) round( ( $end - $request_start ) / 60 );
260 + }
261 +
262 + $service = absint( $data['service_id'] ?? 0 );
263 +
264 + if ( $service > 0 ) {
265 + $model = Service_Model::find( $service );
266 +
267 + // Post_Model has __get() but no __isset(), so read the value and
268 + // then test it rather than using empty() on the property.
269 + $durations = $model ? $model->additional_durations : array();
270 + $first = is_array( $durations ) && isset( $durations[0] ) ? $durations[0] : array();
271 +
272 + if ( ! empty( $first['duration_time'] ) ) {
273 + $length = absint( $first['duration_time'] );
274 +
275 + return ( isset( $first['duration_unit'] ) && 'hour' === $first['duration_unit'] ) ? $length * 60 : $length;
276 + }
277 + }
278 +
279 + return 0;
280 + }
281 +
282 + /**
283 + * Statuses that make an existing appointment block a slot.
284 + *
285 + * The site setting decides, the same as it does for slot generation, but a
286 + * cancelled appointment never blocks. Both spellings are dropped because
287 + * both are accepted statuses and older rows use the British one.
288 + *
289 + * @return array
290 + */
291 + private static function blocking_statuses() {
292 + $statuses = (array) booktics_get_option( 'appointment_statuses_for_blocked_slot', array( 'publish', 'active', 'pending' ) );
293 + $statuses = array_diff( $statuses, array( 'canceled', 'cancelled' ) );
294 +
295 + return $statuses ? array_values( $statuses ) : array( 'publish', 'active', 'pending' );
296 + }
297 +
298 + /**
299 + * How many attendees one slot of this service can hold.
300 + *
301 + * Core holds one. Booktics Pro raises it to the group booking maximum
302 + * through the filter, so a second attendee joining an existing booking is
303 + * not treated as a conflict.
304 + *
305 + * @param int $service_id Service post ID.
306 + *
307 + * @return int Capacity, at least 1.
308 + */
309 + private static function slot_capacity( $service_id ) {
310 + if ( $service_id < 1 ) {
311 + return 1;
312 + }
313 +
314 + /**
315 + * Filters how many attendees one slot of a service can hold.
316 + *
317 + * @since 1.0.26
318 + *
319 + * @param int $capacity Capacity, at least 1.
320 + * @param int $service_id Service post ID.
321 + */
322 + $capacity = apply_filters( 'booktics_slot_capacity', 1, $service_id );
323 +
324 + return max( 1, (int) $capacity );
147 325 }
148 326 }