,status?:string,source?:string,hold?:string} $data * Customer fields arrive already cleaned; this checks the time and the table. * `hold` is the key of a hold (`fxtr_table_holds`) the booking may take. * @param bool $staff Staff may seat any table, at any time. * @return int|WP_Error The new reservation's id. */ public function reserve( array $data, bool $staff = false ): int|WP_Error { $date = $data['date']; $time = substr( $data['time'], 0, 5 ); $party = $data['party_size']; $status = $data['status'] ?? 'pending'; $source = $data['source'] ?? 'online'; $hold = $data['hold'] ?? ''; if ( ! Planner::is_date( $date ) || 1 !== preg_match( '/^\d{2}:\d{2}$/', $time ) || $party < 1 || $party > Tables::MAX_PARTY || ! in_array( $status, self::STATUSES, true ) || ! in_array( $source, self::SOURCES, true ) ) { return new WP_Error( 'fxtr_invalid', __( 'The date, time or party size is not valid.', 'flexatech-table-reservations' ), [ 'status' => 400 ] ); } if ( ! $this->lock( $date ) ) { return new WP_Error( 'fxtr_busy', __( 'Many people are booking right now. Try again in a moment.', 'flexatech-table-reservations' ), [ 'status' => 503 ] ); } try { $slot = $this->slot( $date, $time, $party, $staff, 0, $hold ); if ( $slot instanceof WP_Error ) { return $slot; } $id = $this->insert( $data, $time, $slot ); } finally { $this->unlock( $date ); } if ( 0 === $id ) { return new WP_Error( 'fxtr_not_saved', __( 'The booking could not be saved. Try again.', 'flexatech-table-reservations' ), [ 'status' => 500 ] ); } do_action( 'fxtr_reservation_created', $id, $hold ); return $id; } /** * Keeps the table for a guest who picked a time online and is filling in * the form (CheckoutHolds). The time must be open for a guest now. * `$replace` is the guest's earlier hold, given up first, so going back * and picking another time does not keep two tables. With holds turned * off the time is still checked, and the key is ''. * * @return array{key:string,expires_in:int}|WP_Error */ public function hold( string $date, string $time, int $party, string $replace = '' ): array|WP_Error { if ( '' !== $replace ) { $this->release( $replace ); } $minutes = CheckoutHolds::minutes(); if ( ! $this->lock( $date ) ) { return new WP_Error( 'fxtr_busy', __( 'Many people are booking right now. Try again in a moment.', 'flexatech-table-reservations' ), [ 'status' => 503 ] ); } try { $slot = $this->slot( $date, $time, $party, false, 0 ); if ( $slot instanceof WP_Error ) { return $slot; } if ( 0 === $minutes ) { return [ 'key' => '', 'expires_in' => 0, ]; } $hold = CheckoutHolds::put( $date, $time, $party, $minutes ); } finally { $this->unlock( $date ); } return [ 'key' => $hold['key'], 'expires_in' => $minutes * MINUTE_IN_SECONDS, ]; } /** * Gives a hold up, under its date's lock. A key that is not one is ignored. */ public function release( string $key ): void { $date = CheckoutHolds::date( $key ); if ( null === $date || ! $this->lock( $date ) ) { return; } try { CheckoutHolds::remove( $key ); } finally { $this->unlock( $date ); } } /** * Moves a reservation to another date, time or party size. Its own table * is free for the check, so a party can stay put and only grow; the * table it gets is the best one for the new time, as for a new booking. * Fires `fxtr_reservation_moved` with the reservation as it was. * * @param string $status The status it has afterwards: a changed booking * may need confirming again. * @return Reservation|WP_Error The reservation at its new time. */ public function move( int $id, string $date, string $time, int $party, string $status, bool $staff = false ): Reservation|WP_Error { $time = substr( $time, 0, 5 ); $before = $this->reservations->find( $id ); if ( null === $before ) { return new WP_Error( 'fxtr_not_found', __( 'Not found. Someone may have deleted it.', 'flexatech-table-reservations' ), [ 'status' => 404 ] ); } if ( ! Planner::is_date( $date ) || 1 !== preg_match( '/^\d{2}:\d{2}$/', $time ) || $party < 1 || $party > Tables::MAX_PARTY || ! in_array( $status, self::STATUSES, true ) ) { return new WP_Error( 'fxtr_invalid', __( 'The date, time or party size is not valid.', 'flexatech-table-reservations' ), [ 'status' => 400 ] ); } if ( ! $this->lock( $date ) ) { return new WP_Error( 'fxtr_busy', __( 'Many people are booking right now. Try again in a moment.', 'flexatech-table-reservations' ), [ 'status' => 503 ] ); } try { $slot = $this->slot( $date, $time, $party, $staff, $id ); if ( $slot instanceof WP_Error ) { return $slot; } $saved = $this->relocate( $id, $date, $time, $party, $status, $slot ); } finally { $this->unlock( $date ); } $after = $saved ? $this->reservations->find( $id ) : null; if ( null === $after ) { return new WP_Error( 'fxtr_not_saved', __( 'The booking could not be saved. Try again.', 'flexatech-table-reservations' ), [ 'status' => 500 ] ); } do_action( 'fxtr_reservation_moved', $id, $after, $before ); return $after; } /** * The slot at the time, when it is open for the party. Runs under the lock. * * @return array{time:string,shift_id:int,duration:int,table_id:int,tables:list,reason:string}|WP_Error */ private function slot( string $date, string $time, int $party, bool $staff, int $except, string $hold = '' ): array|WP_Error { foreach ( $this->availability->slots( $date, $party, $staff, $except, $hold ) as $candidate ) { if ( $candidate['time'] !== $time ) { continue; } if ( Planner::OPEN === $candidate['reason'] ) { return $candidate; } return new WP_Error( 'fxtr_slot_taken', match ( $candidate['reason'] ) { Planner::TOO_SOON => __( 'That time is too soon to book. Pick a later one.', 'flexatech-table-reservations' ), Planner::BUSY => __( 'The restaurant is not taking bookings at that time. Pick another one.', 'flexatech-table-reservations' ), default => __( 'That time was just taken. Pick another one.', 'flexatech-table-reservations' ), }, [ 'status' => 409, 'reason' => $candidate['reason'], ] ); } return new WP_Error( 'fxtr_no_such_time', __( 'The restaurant does not take bookings at that time.', 'flexatech-table-reservations' ), [ 'status' => 400 ] ); } /** * @param array{time:string,shift_id:int,duration:int,table_id:int,tables:list,reason:string} $slot */ private function relocate( int $id, string $date, string $time, int $party, string $status, array $slot ): bool { global $wpdb; $ok = $wpdb->update( $wpdb->prefix . 'fxtr_reservations', [ 'reservation_date' => $date, 'reservation_time' => $time . ':00', 'party_size' => $party, 'duration' => $slot['duration'], 'shift_id' => $slot['shift_id'], 'status' => $status, 'reminded' => 0, ], [ 'id' => $id ], [ '%s', '%s', '%d', '%d', '%d', '%s', '%d' ], [ '%d' ] ); if ( false === $ok ) { return false; } $wpdb->delete( $wpdb->prefix . 'fxtr_reservation_tables', [ 'reservation_id' => $id ], [ '%d' ] ); self::seat( $id, $date, $slot['tables'] ); return true; } /** * @param array $data * @param array{time:string,shift_id:int,duration:int,table_id:int,tables:list,reason:string} $slot */ private function insert( array $data, string $time, array $slot ): int { global $wpdb; $ok = $wpdb->insert( $wpdb->prefix . 'fxtr_reservations', [ 'reservation_date' => (string) $data['date'], 'reservation_time' => $time . ':00', 'party_size' => (int) $data['party_size'], 'duration' => $slot['duration'], 'shift_id' => $slot['shift_id'], 'customer_name' => (string) ( $data['customer_name'] ?? '' ), 'phone' => (string) ( $data['phone'] ?? '' ), 'email' => (string) ( $data['email'] ?? '' ), 'notes' => (string) ( $data['notes'] ?? '' ), 'fields' => [] === ( $data['fields'] ?? [] ) ? null : (string) wp_json_encode( $data['fields'] ), 'status' => (string) ( $data['status'] ?? 'pending' ), 'source' => (string) ( $data['source'] ?? 'online' ), 'created_at' => current_time( 'mysql' ), ], [ '%s', '%s', '%d', '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ] ); if ( false === $ok ) { return 0; } $id = (int) $wpdb->insert_id; self::seat( $id, (string) $data['date'], $slot['tables'] ); return $id; } /** * One row per table the party sits at: two or more when tables are pushed together. * * @param list $tables */ private static function seat( int $id, string $date, array $tables ): void { global $wpdb; foreach ( $tables as $table_id ) { $wpdb->insert( $wpdb->prefix . 'fxtr_reservation_tables', [ 'reservation_id' => $id, 'table_id' => $table_id, 'reservation_date' => $date, ], [ '%d', '%d', '%s' ] ); } } /** * Lock names are server-wide, so the prefix keeps two sites on one * database server apart. */ private function name( string $date ): string { global $wpdb; return 'fxtr_' . md5( $wpdb->prefix . '|reservations|' . $date ); } private function lock( string $date ): bool { global $wpdb; return 1 === (int) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', $this->name( $date ), self::WAIT ) ); } private function unlock( string $date ): void { global $wpdb; $wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $this->name( $date ) ) ); } }