[ from statuses, to status ]. */ const TRANSITIONS = [ 'confirm' => [ [ self::STATUS_PENDING ], self::STATUS_CONFIRMED ], 'complete' => [ [ self::STATUS_CONFIRMED ], self::STATUS_COMPLETED ], 'cancel' => [ [ self::STATUS_PENDING, self::STATUS_CONFIRMED ], self::STATUS_CANCELLED ], 'no_show' => [ [ self::STATUS_CONFIRMED ], self::STATUS_NO_SHOW ], ]; /** Columns a transition may write besides status / updated_at (keys of transition()'s $extra). */ const TRANSITION_EXTRA_COLUMNS = [ 'cancel_reason' ]; /** Client idempotency keys (the widget sends a UUID). */ const IDEMPOTENCY_KEY_RE = '/^[A-Za-z0-9-]{8,64}$/'; /** * A live booking created from the same request: same idempotency key, * created within $window_seconds, not cancelled. The widget scopes the * key to the customer email; the admin (no email needed) to its source. * Null if none, or when neither an email nor a source is given. */ public static function findByIdempotencyKey( string $key, string $email, int $window_seconds, string $source = '' ): ?object { global $wpdb; if ( ! preg_match( self::IDEMPOTENCY_KEY_RE, $key ) || ( '' === $email && '' === $source ) ) { return null; } $table = self::table(); $join = ''; $where = 'b.idempotency_key = %s AND b.created_at >= %s AND b.status <> %s'; $values = [ $key, gmdate( 'Y-m-d H:i:s', time() - $window_seconds ), self::STATUS_CANCELLED ]; if ( '' !== $email ) { $join = "INNER JOIN {$wpdb->prefix}vizyt_customers c ON c.id = b.customer_id"; $where .= ' AND c.email = %s'; $values[] = $email; } if ( '' !== $source ) { $where .= ' AND b.source = %s'; $values[] = $source; } // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $join and $where are fixed SQL fragments chosen above (every value goes through prepare()). $id = $wpdb->get_var( $wpdb->prepare( "SELECT b.id FROM {$table} b {$join} WHERE {$where} ORDER BY b.id DESC LIMIT 1", ...$values ) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter return $id ? self::getById( (int) $id ) : null; } private static function table(): string { global $wpdb; return $wpdb->prefix . 'vizyt_bookings'; } /** * List with filters (admin Bookings screen, calendar, dashboard). * * Args: page, per_page, orderby (id|start_datetime|end_datetime|status|created_at), order, * status (string, comma list or array), customer_id, staff_id / service_id (int, comma list or array), * date_from / date_to (YYYY-MM-DD, by start), q (customer name, email, phone, or booking #id), * when (upcoming|past: end time after / before now, business timezone). * * @return array{items: object[], total: int, pages: int} */ public static function getAll( array $args = [] ): array { global $wpdb; $page = max( 1, (int) ( $args['page'] ?? 1 ) ); $per_page = max( 1, (int) ( $args['per_page'] ?? 20 ) ); $orderby = $args['orderby'] ?? 'start_datetime'; $order = strtoupper( (string) ( $args['order'] ?? 'DESC' ) ) === 'ASC' ? 'ASC' : 'DESC'; $allowed_orderby = [ 'id', 'start_datetime', 'end_datetime', 'status', 'created_at' ]; if ( ! in_array( $orderby, $allowed_orderby, true ) ) { $orderby = 'start_datetime'; } [ $where_sql, $values ] = self::where( $args ); $table = self::table(); $from = "{$table} b LEFT JOIN {$wpdb->prefix}vizyt_customers c ON c.id = b.customer_id"; $offset = ( $page - 1 ) * $per_page; // Secondary key keeps pages stable when many bookings share a start time. $count_sql = "SELECT COUNT(*) FROM {$from} {$where_sql}"; $items_sql = "SELECT b.* FROM {$from} {$where_sql} ORDER BY b.{$orderby} {$order}, b.id {$order} LIMIT %d OFFSET %d"; // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $where_sql comes from where(): fixed "column = %s" fragments, every value goes through prepare(); $orderby and $order are whitelisted. $total = (int) $wpdb->get_var( $values ? $wpdb->prepare( $count_sql, ...$values ) : $count_sql ); $items = (array) $wpdb->get_results( $wpdb->prepare( $items_sql, ...array_merge( $values, [ $per_page, $offset ] ) ) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter return [ 'items' => $items, 'total' => $total, 'pages' => (int) ceil( $total / $per_page ), ]; } /** * Bookings per status for the same filters, ignoring the status filter * (the Bookings tabs). Every status has a key, plus "all". * * @return array */ public static function countsByStatus( array $args = [] ): array { global $wpdb; unset( $args['status'] ); [ $where_sql, $values ] = self::where( $args ); $sql = "SELECT b.status, COUNT(*) AS n FROM " . self::table() . " b LEFT JOIN {$wpdb->prefix}vizyt_customers c ON c.id = b.customer_id {$where_sql} GROUP BY b.status"; // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $where_sql comes from where(): fixed "column = %s" fragments, every value goes through prepare(). $rows = (array) $wpdb->get_results( $values ? $wpdb->prepare( $sql, ...$values ) : $sql ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter $out = array_fill_keys( self::STATUSES, 0 ); foreach ( $rows as $row ) { if ( isset( $out[ $row->status ] ) ) { $out[ $row->status ] = (int) $row->n; } } return [ 'all' => array_sum( $out ) ] + $out; } /** * Ids from an int, a "1,2" string or an array; zero and junk dropped. * * @return int[] */ public static function idList( $value ): array { if ( null === $value || '' === $value ) { return []; } $list = is_array( $value ) ? $value : explode( ',', (string) $value ); return array_values( array_unique( array_filter( array_map( 'absint', array_filter( $list, 'is_scalar' ) ) ) ) ); } /** * Known statuses from a string, a "a,b" string or an array. * * @return string[] */ public static function statusList( $value ): array { if ( null === $value || '' === $value ) { return []; } $list = is_array( $value ) ? $value : explode( ',', (string) $value ); $list = array_map( static fn( $s ) => is_scalar( $s ) ? trim( (string) $s ) : '', $list ); return array_values( array_intersect( self::STATUSES, $list ) ); } /** * WHERE clause (on b = bookings, c = customers) and its values. * * @return array{0: string, 1: array} */ private static function where( array $args ): array { global $wpdb; $where = []; $values = []; if ( isset( $args['status'] ) && '' !== $args['status'] ) { $statuses = self::statusList( $args['status'] ); if ( ! $statuses ) { $where[] = '1 = 0'; } else { $where[] = 'b.status IN (' . implode( ',', array_fill( 0, count( $statuses ), '%s' ) ) . ')'; array_push( $values, ...$statuses ); } } if ( ! empty( $args['customer_id'] ) ) { $where[] = 'b.customer_id = %d'; $values[] = (int) $args['customer_id']; } foreach ( [ 'staff_id', 'service_id' ] as $col ) { $ids = self::idList( $args[ $col ] ?? null ); if ( $ids ) { $where[] = "b.{$col} IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ')'; array_push( $values, ...$ids ); } } if ( ! empty( $args['date_from'] ) ) { $where[] = 'b.start_datetime >= %s'; $values[] = $args['date_from'] . ' 00:00:00'; } if ( ! empty( $args['date_to'] ) ) { $where[] = 'b.start_datetime <= %s'; $values[] = $args['date_to'] . ' 23:59:59'; } if ( isset( $args['when'] ) && in_array( $args['when'], [ 'upcoming', 'past' ], true ) ) { $where[] = 'upcoming' === $args['when'] ? 'b.end_datetime > %s' : 'b.end_datetime <= %s'; $values[] = ( new \DateTimeImmutable( 'now', vizyt_timezone() ) )->format( 'Y-m-d H:i:s' ); } $q = trim( (string) ( $args['q'] ?? '' ) ); if ( '' !== $q ) { $like = '%' . $wpdb->esc_like( $q ) . '%'; $parts = [ 'c.first_name LIKE %s', 'c.last_name LIKE %s', "CONCAT_WS(' ', c.first_name, c.last_name) LIKE %s", 'c.email LIKE %s', 'c.phone LIKE %s' ]; array_push( $values, $like, $like, $like, $like, $like ); $digits = preg_replace( '/\D+/', '', $q ); if ( strlen( $digits ) >= 5 ) { $parts[] = "REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(c.phone, ' ', ''), '-', ''), '(', ''), ')', ''), '+', '') LIKE %s"; $values[] = '%' . $digits . '%'; } if ( preg_match( '/^#?(\d{1,10})$/', $q, $m ) ) { $parts[] = 'b.id = %d'; $values[] = (int) $m[1]; } $where[] = '(' . implode( ' OR ', $parts ) . ')'; } return [ $where ? 'WHERE ' . implode( ' AND ', $where ) : '', $values ]; } public static function getById( int $id ): ?object { global $wpdb; return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}vizyt_bookings WHERE id = %d", $id ) ) ?: null; } public static function getByToken( string $token ): ?object { global $wpdb; return $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE token = %s', self::table(), $token ) ) ?: null; } /** * True when a pending or confirmed booking of this staff member overlaps * the half-open interval [start, end). $exclude_booking_id: a booking * that does not count (the one a customer is moving). */ public static function hasConflict( int $staff_id, string $start_datetime, string $end_datetime, int $exclude_booking_id = 0 ): bool { global $wpdb; return (bool) $wpdb->get_var( $wpdb->prepare( "SELECT id FROM %i WHERE staff_id = %d AND status IN ('pending','confirmed') AND start_datetime < %s AND end_datetime > %s AND id <> %d LIMIT 1", self::table(), $staff_id, $end_datetime, $start_datetime, $exclude_booking_id ) ); } /** * Pending + confirmed bookings per calendar day and staff member * (input for StaffAssigner). * * @param int[] $staff_ids * @return array> 'Y-m-d' => [ staff_id => count ] */ public static function countActiveByDay( array $staff_ids, string $date_from, string $date_to ): array { global $wpdb; $ids = array_values( array_unique( array_filter( array_map( 'absint', $staff_ids ) ) ) ); if ( ! $ids ) { return []; } $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT DATE(start_datetime) AS day, staff_id, COUNT(*) AS n FROM %i WHERE staff_id IN (' . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ") AND status IN ('pending','confirmed') AND start_datetime >= %s AND start_datetime <= %s GROUP BY day, staff_id", array_merge( [ self::table() ], $ids, [ $date_from . ' 00:00:00', $date_to . ' 23:59:59' ] ) ) ); $out = []; foreach ( (array) $rows as $row ) { $out[ (string) $row->day ][ (int) $row->staff_id ] = (int) $row->n; } return $out; } public static function create( array $data ): int|false { global $wpdb; $customer_id = isset( $data['customer_id'] ) ? absint( $data['customer_id'] ) : 0; $service_id = isset( $data['service_id'] ) ? absint( $data['service_id'] ) : 0; $staff_id = isset( $data['staff_id'] ) ? absint( $data['staff_id'] ) : 0; if ( ! $customer_id || ! $service_id || ! $staff_id ) { return false; } $start_datetime = isset( $data['start_datetime'] ) ? sanitize_text_field( $data['start_datetime'] ) : ''; $end_datetime = isset( $data['end_datetime'] ) ? sanitize_text_field( $data['end_datetime'] ) : ''; if ( ! $start_datetime || ! $end_datetime ) { return false; } $fields = [ 'customer_id' => $customer_id, 'service_id' => $service_id, 'staff_id' => $staff_id, 'start_datetime' => $start_datetime, 'end_datetime' => $end_datetime, 'status' => self::STATUS_PENDING, 'token' => self::generateToken(), ]; if ( isset( $data['location_id'] ) ) { $fields['location_id'] = absint( $data['location_id'] ) ?: null; } // Price and currency are a snapshot of the service at booking time // (the dashboard revenue sums them), unless the caller sets them. $service = ( ! isset( $data['price'] ) || ! isset( $data['currency'] ) ) ? Service::getById( $service_id ) : null; if ( isset( $data['price'] ) ) { $fields['price'] = (float) $data['price']; } elseif ( $service ) { $fields['price'] = (float) $service->price; } if ( isset( $data['currency'] ) ) { $fields['currency'] = substr( sanitize_text_field( $data['currency'] ), 0, 3 ); } elseif ( $service && '' !== (string) $service->currency ) { $fields['currency'] = substr( (string) $service->currency, 0, 3 ); } if ( isset( $data['payment_status'] ) ) { $allowed_payment = [ 'not_required', 'pending', 'paid', 'refunded' ]; $fields['payment_status'] = in_array( $data['payment_status'], $allowed_payment, true ) ? $data['payment_status'] : 'not_required'; } if ( isset( $data['internal_note'] ) ) { $fields['internal_note'] = sanitize_textarea_field( $data['internal_note'] ); } if ( isset( $data['source'] ) ) { $allowed_sources = [ 'widget', 'admin', 'api' ]; $fields['source'] = in_array( $data['source'], $allowed_sources, true ) ? $data['source'] : 'widget'; } if ( isset( $data['idempotency_key'] ) && is_string( $data['idempotency_key'] ) && preg_match( self::IDEMPOTENCY_KEY_RE, $data['idempotency_key'] ) ) { $fields['idempotency_key'] = $data['idempotency_key']; } $now = current_time( 'mysql', true ); $fields['created_at'] = $now; $fields['updated_at'] = $now; if ( ! $wpdb->insert( self::table(), $fields ) ) { return false; } $id = (int) $wpdb->insert_id; $source = $fields['source'] ?? 'widget'; if ( 'widget' === $source ) { // Made by the visitor, even when a logged-in user filled the widget. BookingEvent::add( $id, 'created', [ 'source' => $source, 'by' => 'customer' ], 0 ); } else { BookingEvent::add( $id, 'created', [ 'source' => $source ] ); } return $id; } public static function update( int $id, array $data ): bool { global $wpdb; $fields = []; if ( isset( $data['internal_note'] ) ) { $fields['internal_note'] = sanitize_textarea_field( $data['internal_note'] ); } if ( isset( $data['payment_status'] ) ) { $allowed_payment = [ 'not_required', 'pending', 'paid', 'refunded' ]; if ( in_array( $data['payment_status'], $allowed_payment, true ) ) { $fields['payment_status'] = $data['payment_status']; } } if ( empty( $fields ) ) { return false; } $fields['updated_at'] = current_time( 'mysql', true ); return $wpdb->update( self::table(), $fields, [ 'id' => $id ] ) !== false; } /** * Customer reschedule: one conditional UPDATE of the time and specialist * (… WHERE id AND the specialist, start, service and an active status * that were read), so a concurrent move or edit makes it fail instead of * being overwritten. Raises ics_sequence: calendar apps replace the event. * * @param array{start_datetime: string, staff_id: int, service_id: int} $from what the caller read */ public static function move( int $id, array $from, string $start_datetime, string $end_datetime, int $staff_id ): bool { global $wpdb; $result = $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}vizyt_bookings SET start_datetime = %s, end_datetime = %s, staff_id = %d, ics_sequence = ics_sequence + 1, updated_at = %s WHERE id = %d AND staff_id = %d AND start_datetime = %s AND service_id = %d AND status IN ('pending','confirmed')", $start_datetime, $end_datetime, $staff_id, current_time( 'mysql', true ), $id, (int) $from['staff_id'], (string) $from['start_datetime'], (int) $from['service_id'] ) ); return 1 === $result; } /** * State machine: pending → confirmed */ public static function confirm( int $id ): bool { return self::transition( $id, 'confirm' ); } /** * State machine: confirmed → completed */ public static function complete( int $id ): bool { return self::transition( $id, 'complete' ); } /** * State machine: pending|confirmed → cancelled * * @param bool $by_customer the customer's own cancel (link or public API): * history actor 0 + by=customer instead of the current user. */ public static function cancel( int $id, string $reason, bool $by_customer = false ): bool { return self::transition( $id, 'cancel', [ 'cancel_reason' => sanitize_textarea_field( $reason ) ], $by_customer ? 0 : null, $by_customer ? [ 'by' => 'customer' ] : [] ); } /** * State machine: confirmed → no_show ("Не прийшов"). */ public static function noShow( int $id ): bool { return self::transition( $id, 'no_show' ); } /** * One conditional UPDATE (… WHERE id = ? AND status = ), so two concurrent requests cannot both move the same booking * and the "from" reported is the status the row really left. When a * concurrent move lands between the read and the UPDATE, the booking is * read once more and the move retried if it is still allowed. On success * the history gets an event and "vizyt_booking_status_changed" fires. * * @param string $action key of TRANSITIONS * @param array $extra more columns to write; only the keys of TRANSITION_EXTRA_COLUMNS (cancel_reason), others are ignored * @param int|null $actor history actor: null = current user, 0 = customer / system * @param array $meta more history details (by=customer) */ public static function transition( int $id, string $action, array $extra = [], ?int $actor = null, array $meta = [] ): bool { global $wpdb; if ( ! isset( self::TRANSITIONS[ $action ] ) ) { return false; } [ $from, $to ] = self::TRANSITIONS[ $action ]; $extra = array_intersect_key( $extra, array_flip( self::TRANSITION_EXTRA_COLUMNS ) ); $set = array_merge( $extra, [ 'status' => $to, 'updated_at' => current_time( 'mysql', true ) ] ); $cols = implode( ', ', array_fill( 0, count( $set ), '%i = %s' ) ); $sql = 'UPDATE ' . self::table() . " SET {$cols} WHERE id = %d AND status = %s"; $args = []; foreach ( $set as $col => $value ) { $args[] = $col; $args[] = $value; } $previous = null; for ( $attempt = 0; $attempt < 2 && null === $previous; $attempt++ ) { $booking = self::getById( $id ); if ( ! $booking || ! in_array( $booking->status, $from, true ) ) { return false; } // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql is the plugin's own table name ($wpdb->prefix) plus placeholders only: %i for the whitelisted column names, %s / %d for the values. $result = $wpdb->query( $wpdb->prepare( $sql, ...array_merge( $args, [ $id, (string) $booking->status ] ) ) ); // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter if ( 1 === $result ) { $previous = (string) $booking->status; } } if ( null === $previous ) { return false; } if ( isset( $extra['cancel_reason'] ) ) { $meta = [ 'reason' => (string) $extra['cancel_reason'] ] + $meta; } BookingEvent::add( $id, $to, $meta, $actor ); /** * A booking changed status (admin action, bulk action or the customer's cancel link). * * @param int $id * @param string $from previous status * @param string $to new status */ do_action( 'vizyt_booking_status_changed', $id, $previous, $to ); return true; } public static function delete( int $id ): bool { global $wpdb; if ( false === $wpdb->delete( self::table(), [ 'id' => $id ] ) ) { return false; } BookingEvent::deleteForBooking( $id ); return true; } public static function count( array $args = [] ): int { global $wpdb; $table = self::table(); if ( ! isset( $args['status'] ) ) { return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) ); } return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE status = %s', $table, $args['status'] ) ); } private static function generateToken(): string { return bin2hex( random_bytes( 32 ) ); } }