configured() && '' !== (string) ( self::auth()['refresh_token'] ?? '' ); } /** * The address the owner adds to their OAuth client as a redirect URI. */ public static function redirect_uri(): string { return admin_url( 'admin-post.php?action=fxtr_gcal' ); } /** * Google's consent page, with a one-time state tied to this user. */ public function auth_url(): string { $state = wp_generate_password( 32, false ); set_transient( 'fxtr_gcal_state_' . $state, get_current_user_id(), 15 * MINUTE_IN_SECONDS ); return add_query_arg( rawurlencode_deep( [ 'client_id' => (string) Settings::get( 'gcal_client_id', '' ), 'redirect_uri' => self::redirect_uri(), 'response_type' => 'code', 'scope' => self::SCOPE, 'access_type' => 'offline', 'prompt' => 'consent', 'state' => $state, ] ), self::AUTH_URL ); } /** * Google sends the owner back here with a code, which is swapped for * tokens. Then back to the Google Calendar tab, saying how it went. */ public function callback(): void { $result = $this->connect_from_request(); wp_safe_redirect( admin_url( 'admin.php?page=flexatech-table-reservations&fxtr_gcal=' . ( true === $result ? 'connected' : 'error' ) . '#/bookings/calendar' ) ); exit; } /** * @return true|WP_Error */ public function connect_from_request(): bool|WP_Error { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- the OAuth state below is the one-time check. $state = isset( $_GET['state'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['state'] ) ) : ''; $code = isset( $_GET['code'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['code'] ) ) : ''; // phpcs:enable $user = '' !== $state ? get_transient( 'fxtr_gcal_state_' . $state ) : false; if ( '' !== $state ) { delete_transient( 'fxtr_gcal_state_' . $state ); } if ( ! Access::can_manage_settings() || false === $user || get_current_user_id() !== (int) $user || '' === $code ) { return $this->fail( new WP_Error( 'fxtr_gcal_state', __( 'Google sign-in could not be checked. Please try again.', 'flexatech-table-reservations' ) ) ); } $tokens = $this->token_request( [ 'code' => $code, 'redirect_uri' => self::redirect_uri(), 'grant_type' => 'authorization_code', ] ); if ( $tokens instanceof WP_Error ) { return $this->fail( $tokens ); } if ( '' === (string) ( $tokens['refresh_token'] ?? '' ) ) { return $this->fail( new WP_Error( 'fxtr_gcal_no_refresh', __( 'Google gave no lasting access. Remove the app from your Google account and connect again.', 'flexatech-table-reservations' ) ) ); } self::save_auth( [ 'access_token' => (string) ( $tokens['access_token'] ?? '' ), 'expires_at' => time() + (int) ( $tokens['expires_in'] ?? 0 ), 'refresh_token' => (string) $tokens['refresh_token'], 'connected_at' => current_time( 'mysql' ), 'last_error' => '', ] ); delete_transient( 'fxtr_gcal_calendars' ); return true; } public function disconnect(): void { $token = (string) ( self::auth()['refresh_token'] ?? '' ); if ( '' !== $token ) { wp_remote_post( 'https://oauth2.googleapis.com/revoke', [ 'timeout' => 10, 'body' => [ 'token' => $token ], ] ); } delete_option( self::AUTH ); delete_transient( 'fxtr_gcal_calendars' ); self::unschedule(); } /** * What the Google Calendar tab shows. No token is in it. * * @return array */ public function status(): array { $auth = self::auth(); return [ 'configured' => $this->configured(), 'connected' => $this->connected(), 'redirect_uri' => self::redirect_uri(), 'connected_at' => (string) ( $auth['connected_at'] ?? '' ), 'last_error' => (string) ( $auth['last_error'] ?? '' ), 'calendars' => $this->connected() ? $this->calendars() : [], ]; } /** * The calendars of the connected account, for the owner to pick from. * [] when Google cannot be reached; the tab then takes an id typed in. * * @return list */ public function calendars(): array { $cached = get_transient( 'fxtr_gcal_calendars' ); if ( is_array( $cached ) ) { /** @var list $cached */ return $cached; } $data = $this->api( 'GET', '/users/me/calendarList?' . http_build_query( [ 'maxResults' => '250' ] ) ); if ( $data instanceof WP_Error ) { return []; } $out = []; foreach ( is_array( $data['items'] ?? null ) ? $data['items'] : [] as $item ) { if ( ! is_array( $item ) || ! is_string( $item['id'] ?? null ) ) { continue; } $out[] = [ 'id' => $item['id'], 'name' => (string) ( $item['summaryOverride'] ?? $item['summary'] ?? $item['id'] ), 'primary' => true === ( $item['primary'] ?? false ), 'writable' => in_array( $item['accessRole'] ?? '', [ 'owner', 'writer' ], true ), ]; } set_transient( 'fxtr_gcal_calendars', $out, 10 * MINUTE_IN_SECONDS ); return $out; } /** * A reservation changed: copy it out soon, from cron. */ public function queue( mixed $id ): void { if ( ! $this->connected() || ! (bool) Settings::get( 'gcal_push', true ) ) { return; } // A copy already queued sends the latest state, so a second is not needed. if ( false === wp_next_scheduled( self::PUSH, [ (int) $id ] ) ) { wp_schedule_single_event( time(), self::PUSH, [ (int) $id ] ); } } public function run_push( mixed $id ): void { $this->push( $id ); } /** * Write one reservation's event: create it, update it, move it to the * calendar now chosen, or delete it once the reservation is cancelled * or a no-show. * * @return true|WP_Error */ public function push( mixed $id ): bool|WP_Error { $repo = new Repository(); $reservation = $repo->find( (int) $id ); if ( null === $reservation || ! $this->connected() ) { return true; } $calendar = (string) Settings::get( 'gcal_calendar', 'primary' ); $synced = $repo->gcal( $reservation->id ); $old = $synced['event']; $old_cal = '' !== $synced['calendar'] ? $synced['calendar'] : $calendar; if ( in_array( $reservation->status, self::REMOVED, true ) ) { if ( '' !== $old ) { $gone = $this->api( 'DELETE', self::event_path( $old_cal, $old ) ); if ( $gone instanceof WP_Error && ! self::missing( $gone ) ) { return $this->fail( $gone ); } $repo->set_gcal( $reservation->id, '', '' ); } return true; } $body = $this->event( $reservation, $repo->table_names( $reservation->id ) ); if ( '' !== $old && $old_cal === $calendar ) { $event = $this->api( 'PATCH', self::event_path( $calendar, $old ), $body ); if ( ! $event instanceof WP_Error ) { return true; } if ( ! self::missing( $event ) ) { return $this->fail( $event ); } } elseif ( '' !== $old ) { // The owner picked another calendar since: the old copy goes. $this->api( 'DELETE', self::event_path( $old_cal, $old ) ); } $event = $this->api( 'POST', '/calendars/' . rawurlencode( $calendar ) . '/events', $body ); if ( $event instanceof WP_Error ) { return $this->fail( $event ); } $repo->set_gcal( $reservation->id, $calendar, (string) ( $event['id'] ?? '' ) ); return true; } /** * Copy the upcoming reservations that have no event yet, 50 at a time. * * @return array{pushed:int}|WP_Error */ public function sync_now(): array|WP_Error { if ( ! $this->connected() ) { return new WP_Error( 'fxtr_gcal_not_connected', __( 'Connect Google Calendar first.', 'flexatech-table-reservations' ), [ 'status' => 400 ] ); } $pushed = 0; if ( (bool) Settings::get( 'gcal_push', true ) ) { foreach ( ( new Repository() )->unsynced( current_time( 'Y-m-d' ), 50 ) as $reservation ) { $result = $this->push( $reservation->id ); if ( $result instanceof WP_Error ) { return $result; } ++$pushed; } } self::save_auth( [ 'last_error' => '' ] + self::auth() ); return [ 'pushed' => $pushed ]; } /** * Busy ranges on the busy calendar around one date, for Availability: * that day and the next morning, so a late party's stay is covered. * Google being unreachable blocks nothing. * * @param mixed $ranges What earlier filters found. * @return array */ public function busy( mixed $ranges, string $date ): array { $ranges = is_array( $ranges ) ? $ranges : []; $calendar = (string) Settings::get( 'gcal_busy_calendar', '' ); if ( '' === $calendar || ! $this->connected() ) { return $ranges; } $key = 'fxtr_gcal_busy_' . md5( $calendar . '|' . $date ); $cached = get_transient( $key ); if ( is_array( $cached ) ) { return array_merge( $ranges, $cached ); } $events = $this->day_events( $calendar, $date ); if ( $events instanceof WP_Error ) { set_transient( $key, [], MINUTE_IN_SECONDS ); return $ranges; } $busy = []; foreach ( $events as $event ) { if ( 'cancelled' === ( $event['status'] ?? '' ) || 'transparent' === ( $event['transparency'] ?? '' ) || null !== self::reservation_id( $event ) ) { continue; } $range = self::range( $event ); if ( null !== $range ) { $busy[] = $range; } } set_transient( $key, $busy, 5 * MINUTE_IN_SECONDS ); return array_merge( $ranges, $busy ); } /** * The event Google keeps for a reservation. * * @param list $tables The names of its tables. * @return array */ public function event( Reservation $reservation, array $tables = [] ): array { $tz = wp_timezone(); $start = new DateTimeImmutable( $reservation->date . ' ' . $reservation->time, $tz ); $end = $start->modify( '+' . max( 15, $reservation->duration ) . ' minutes' ); $lines = [ /* translators: %s: reservation status, such as Confirmed. */ sprintf( __( 'Status: %s', 'flexatech-table-reservations' ), self::status_label( $reservation->status ) ), ]; if ( [] !== $tables ) { /* translators: %s: table names, such as "T1, T2". */ $lines[] = sprintf( __( 'Table: %s', 'flexatech-table-reservations' ), implode( ', ', $tables ) ); } if ( '' !== $reservation->phone ) { /* translators: %s: the guest's phone. */ $lines[] = sprintf( __( 'Phone: %s', 'flexatech-table-reservations' ), $reservation->phone ); } if ( '' !== $reservation->email ) { /* translators: %s: the guest's email. */ $lines[] = sprintf( __( 'Email: %s', 'flexatech-table-reservations' ), $reservation->email ); } if ( '' !== $reservation->notes ) { /* translators: %s: the guest's notes. */ $lines[] = sprintf( __( 'Notes: %s', 'flexatech-table-reservations' ), $reservation->notes ); } $lines[] = admin_url( 'admin.php?page=flexatech-table-reservations#/bookings?date=' . $reservation->date ); return [ /* translators: 1: guest name, 2: number of guests. */ 'summary' => sprintf( _n( '%1$s (%2$d guest)', '%1$s (%2$d guests)', $reservation->party_size, 'flexatech-table-reservations' ), $reservation->customer_name, $reservation->party_size ), 'description' => implode( "\n", $lines ), 'start' => [ 'dateTime' => $start->format( DATE_RFC3339 ), 'timeZone' => $tz->getName(), ], 'end' => [ 'dateTime' => $end->format( DATE_RFC3339 ), 'timeZone' => $tz->getName(), ], 'extendedProperties' => [ 'private' => [ self::PROPERTY => (string) $reservation->id ] ], ]; } /** * @param array $event */ public static function reservation_id( array $event ): ?int { $id = $event['extendedProperties']['private'][ self::PROPERTY ] ?? null; return is_scalar( $id ) && (int) $id > 0 ? (int) $id : null; } /** * An event's time range as Unix timestamps. An all-day event covers its * days from the site's midnight. * * @param array $event * @return array{0:int,1:int}|null */ public static function range( array $event ): ?array { try { if ( isset( $event['start']['dateTime'], $event['end']['dateTime'] ) ) { return [ ( new DateTimeImmutable( (string) $event['start']['dateTime'] ) )->getTimestamp(), ( new DateTimeImmutable( (string) $event['end']['dateTime'] ) )->getTimestamp(), ]; } if ( isset( $event['start']['date'], $event['end']['date'] ) ) { $tz = wp_timezone(); return [ ( new DateTimeImmutable( $event['start']['date'] . ' 00:00:00', $tz ) )->getTimestamp(), ( new DateTimeImmutable( $event['end']['date'] . ' 00:00:00', $tz ) )->getTimestamp(), ]; } } catch ( Throwable ) { return null; } return null; } private static function status_label( string $status ): string { return match ( $status ) { 'pending' => __( 'Pending', 'flexatech-table-reservations' ), 'confirmed' => __( 'Confirmed', 'flexatech-table-reservations' ), 'seated' => __( 'Seated', 'flexatech-table-reservations' ), 'completed' => __( 'Completed', 'flexatech-table-reservations' ), default => $status, }; } private static function event_path( string $calendar, string $event ): string { return '/calendars/' . rawurlencode( $calendar ) . '/events/' . rawurlencode( $event ); } /** * @return list>|WP_Error */ private function day_events( string $calendar, string $date ): array|WP_Error { try { $from = new DateTimeImmutable( $date . ' 00:00:00', wp_timezone() ); } catch ( Throwable ) { return []; } return $this->list_events( $calendar, [ 'timeMin' => $from->format( DATE_RFC3339 ), 'timeMax' => $from->modify( '+36 hours' )->format( DATE_RFC3339 ), 'singleEvents' => 'true', ] ); } /** * Every page of events.list. * * @param array $query * @return list>|WP_Error */ private function list_events( string $calendar, array $query ): array|WP_Error { $items = []; $page = ''; for ( $i = 0; $i < 10; $i++ ) { $args = $query + [ 'maxResults' => '250' ]; if ( '' !== $page ) { $args['pageToken'] = $page; } $data = $this->api( 'GET', '/calendars/' . rawurlencode( $calendar ) . '/events?' . http_build_query( $args ) ); if ( $data instanceof WP_Error ) { return $data; } foreach ( is_array( $data['items'] ?? null ) ? $data['items'] : [] as $item ) { if ( is_array( $item ) ) { $items[] = $item; } } $page = (string) ( $data['nextPageToken'] ?? '' ); if ( '' === $page ) { break; } } return $items; } /** * One Calendar API call with a fresh access token. * * @param array|null $body * @return array|WP_Error */ private function api( string $method, string $path, ?array $body = null ): array|WP_Error { $token = $this->access_token(); if ( $token instanceof WP_Error ) { return $token; } $args = [ 'method' => $method, 'timeout' => 15, 'headers' => [ 'Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json', ], ]; if ( null !== $body ) { $args['body'] = (string) wp_json_encode( $body ); } $response = wp_remote_request( self::API . $path, $args ); if ( is_wp_error( $response ) ) { return $response; } $code = (int) wp_remote_retrieve_response_code( $response ); $data = json_decode( (string) wp_remote_retrieve_body( $response ), true ); if ( $code >= 400 ) { $message = is_array( $data ) && is_string( $data['error']['message'] ?? null ) ? $data['error']['message'] : 'HTTP ' . $code; return new WP_Error( 'fxtr_gcal_api', $message, [ 'status' => $code ] ); } return is_array( $data ) ? $data : []; } private function access_token(): string|WP_Error { $auth = self::auth(); if ( '' !== (string) ( $auth['access_token'] ?? '' ) && (int) ( $auth['expires_at'] ?? 0 ) > time() + 60 ) { return (string) $auth['access_token']; } if ( '' === (string) ( $auth['refresh_token'] ?? '' ) ) { return new WP_Error( 'fxtr_gcal_not_connected', __( 'Connect Google Calendar first.', 'flexatech-table-reservations' ), [ 'status' => 400 ] ); } $tokens = $this->token_request( [ 'refresh_token' => (string) $auth['refresh_token'], 'grant_type' => 'refresh_token', ] ); if ( $tokens instanceof WP_Error ) { return $tokens; } $auth['access_token'] = (string) ( $tokens['access_token'] ?? '' ); $auth['expires_at'] = time() + (int) ( $tokens['expires_in'] ?? 0 ); self::save_auth( $auth ); return $auth['access_token']; } /** * @param array $fields * @return array|WP_Error */ private function token_request( array $fields ): array|WP_Error { $response = wp_remote_post( self::TOKEN_URL, [ 'timeout' => 15, 'body' => $fields + [ 'client_id' => (string) Settings::get( 'gcal_client_id', '' ), 'client_secret' => (string) Settings::get( 'gcal_client_secret', '' ), ], ] ); if ( is_wp_error( $response ) ) { return $response; } $data = json_decode( (string) wp_remote_retrieve_body( $response ), true ); if ( (int) wp_remote_retrieve_response_code( $response ) >= 400 || ! is_array( $data ) || '' === (string) ( $data['access_token'] ?? '' ) ) { $message = is_array( $data ) && is_string( $data['error_description'] ?? null ) ? $data['error_description'] : __( 'Google refused the sign-in.', 'flexatech-table-reservations' ); return new WP_Error( 'fxtr_gcal_token', $message, [ 'status' => 400 ] ); } return $data; } /** * Keep the last error for the Google Calendar tab. */ private function fail( WP_Error $error ): WP_Error { $auth = self::auth(); if ( [] !== $auth ) { $auth['last_error'] = $error->get_error_message(); self::save_auth( $auth ); } return $error; } private static function missing( WP_Error $error ): bool { $data = $error->get_error_data(); return is_array( $data ) && in_array( (int) ( $data['status'] ?? 0 ), [ 404, 410 ], true ); } /** * @return array */ private static function auth(): array { $auth = get_option( self::AUTH, [] ); return is_array( $auth ) ? $auth : []; } /** * @param array $auth */ private static function save_auth( array $auth ): void { update_option( self::AUTH, $auth, false ); } }