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
booktics / core / appointment / services / appointment-validator.php

appointment-validator.php in Booktics – Appointment Booking Calendar for Service Businesses 1.0.28, at core/appointment/services/appointment-validator.php

327 lines 12.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace Booktics\Appointment\Services;
3
4 use Booktics\Models\Appointment_Model;
5 use Booktics\Models\Service_Model;
6 use Booktics\Utils\Singleton;
7 use WP_Error;
8
9 class Appointment_Validator {
10
11 use Singleton;
12
13 /**
14 * Validate data for creating an appointment.
15 * @param array $data
16 * @return WP_Error|null
17 */
18 public static function validate_create( array $data ) {
19 if ( empty( $data['start_time'] ) ) {
20 return new WP_Error( 'missing_start_time', __( 'Start time is required', 'booktics' ) );
21 }
22 if ( empty( $data['end_time'] ) ) {
23 return new WP_Error( 'missing_end_time', __( 'End time is required', 'booktics' ) );
24 }
25 if ( empty( $data['date'] ) ) {
26 return new WP_Error( 'missing_date', __( 'Date is required', 'booktics' ) );
27 }
28 if ( empty( $data['service_id'] ) ) {
29 return new WP_Error( 'missing_service_id', __( 'Service ID is required', 'booktics' ) );
30 }
31 if ( empty( $data['team_member_id'] ) ) {
32 return new WP_Error( 'missing_team_member_id', __( 'Team member ID is required', 'booktics' ) );
33 }
34 // Add more validation as needed
35 return null;
36 }
37
38 /**
39 * Validate data for updating an appointment.
40 * @param array $data
41 * @return WP_Error|null
42 */
43 public static function validate_update( array $data ): ?WP_Error {
44 if ( empty( $data ) ) {
45 return new WP_Error( 'no_data', __( 'No data provided for update', 'booktics' ) );
46 }
47 // Optionally, check for required fields on update
48 return null;
49 }
50
51 /**
52 * Validate data for rescheduling an appointment.
53 * @param array $data
54 * @return WP_Error|null
55 */
56 public static function validate_reschedule( array $data, $id ) {
57 if ( empty( $data['start_time'] ) ) {
58 return new WP_Error( 'missing_start_time', __( 'Start time is required for reschedule', 'booktics' ) );
59 }
60 if ( empty( $data['end_time'] ) ) {
61 return new WP_Error( 'missing_end_time', __( 'End time is required for reschedule', 'booktics' ) );
62 }
63 if ( empty( $data['date'] ) ) {
64 return new WP_Error( 'missing_date', __( 'Date is required for reschedule', 'booktics' ) );
65 }
66 if ( ! booktics_get_option( 'allow_customer_rescheduling', false ) ) {
67 return new WP_Error( 'reschedule_setting', __( 'Customer rescheduling is not allowed', 'booktics' ) );
68 }
69
70 if ( empty( $data['security_token'] ) ) {
71 return new WP_Error( 'missing_appointment_token', __( 'Appointment token is required for reschedule', 'booktics' ) );
72 }
73
74 $appointment = Appointment_Model::find( $id );
75
76 if ( ! $appointment ) {
77 return new WP_Error( 'appointment_not_found', __( 'Appointment not found', 'booktics' ) );
78 }
79
80 if ( 'canceled' === $appointment->status ) {
81 return new WP_Error( 'appointment_cancelled', __( 'This Appointment has already been cancelled', 'booktics' ) );
82 }
83
84 $token = isset( $data['security_token'] ) ? (string) $data['security_token'] : '';
85 if ( ! hash_equals( (string) $appointment->security_token, $token ) && ! current_user_can( 'manage_options' ) ) {
86 return new WP_Error( 'invalid_security_token', __( 'You dont have permission for rescheduling this appointment', 'booktics' ) );
87 }
88
89 $reschedule_restrictions = booktics_get_option( 'reschedule_restriction', array( 'enable' => false ) );
90 if ( isset( $reschedule_restrictions['enable'] ) && $reschedule_restrictions['enable'] ) {
91 $restricted_time = $reschedule_restrictions['restricted_time'] ?? 0;
92 $restricted_unit = $reschedule_restrictions['restricted_unit'] ?? 'hr';
93 $restricted_min = ('min' === $restricted_unit) ? $restricted_time : $restricted_time * 60;
94
95 $current_time = (int) wp_date( 'U' );
96 $appointment_time = strtotime( $appointment->date . ' ' . $appointment->start_time );
97 if ( $appointment_time < $current_time + $restricted_min ) {
98 return new WP_Error(
99 'reschedule_restricted', sprintf(
100 /* translators: %1$d: restricted time value, %2$s: unit (e.g., 'min' or 'hr') */
101 __( 'You can only reschedule appointments at least %1$d %2$s before the appointment time.', 'booktics' ),
102 $restricted_time,
103 $restricted_unit
104 )
105 );
106 }
107 }
108
109 return null;
110 }
111
112 /**
113 * Validate data for canceling an appointment.
114 * @param int $id
115 * @return WP_Error|null
116 */
117 public function validate_cancel( $id ) {
118 if ( ! booktics_get_option( 'allow_customer_cancellation', false ) ) {
119 return new WP_Error( 'cancellation_setting', __( 'Customer cancellation is not allowed', 'booktics' ) );
120 }
121
122 $cancellation_restrictions = booktics_get_option( 'cancellation_restriction', array( 'enable' => false ) );
123 if ( isset( $cancellation_restrictions['enable'] ) && $cancellation_restrictions['enable'] ) {
124 $restricted_time = $cancellation_restrictions['restricted_time'] ?? 0;
125 $restricted_unit = $cancellation_restrictions['restricted_unit'] ?? 'hr';
126 $restricted_min = $restricted_unit === 'min' ? $restricted_time : $restricted_time * 60;
127 $appointment = Appointment_Model::find( $id );
128
129 if ( ! $appointment ) {
130 return new WP_Error( 'appointment_not_found', __( 'Appointment not found', 'booktics' ) );
131 }
132
133 $current_time = (int) wp_date( 'U' );
134 $appointment_time = strtotime( $appointment->date . ' ' . $appointment->start_time );
135 if ( $appointment_time < $current_time + $restricted_min ) {
136 return new WP_Error(
137 'cancellation_restricted', sprintf(
138 /* translators: %1$d: number of units, %2$s: unit (e.g., 'hr' or 'min') */
139 __( 'You can only cancel appointments at least %1$d %2$s before the appointment time.', 'booktics' ),
140 $restricted_time,
141 $restricted_unit
142 )
143 );
144 }
145 }
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 );
325 }
326 }
327