get_secret('rsv_google_client_secret') ?? ''); } public function get_calendar_id(): string { return (string) get_option('rsv_google_calendar_id', 'primary'); } public function is_webhook_registered(): bool { return (bool) get_option('rsv_google_webhook_channel_id'); } // ------------------------------------------------------------------------- // Encrypted option storage for secrets (tokens, client secret) // ------------------------------------------------------------------------- /** Decrypt a secret stored in wp_options; null if unset or undecryptable. */ private function get_secret(string $option): ?string { $stored = get_option($option); if (!is_string($stored) || $stored === '') { return null; } return RsvCrypto::decrypt($stored); } /** Store a secret in wp_options, encrypted at rest. */ private function set_secret(string $option, string $value): void { update_option($option, RsvCrypto::encrypt($value)); } /** Persist the OAuth client secret (encrypted). */ public function set_client_secret(string $secret): void { $this->set_secret('rsv_google_client_secret', $secret); } // ------------------------------------------------------------------------- // OAuth // ------------------------------------------------------------------------- public function get_oauth_url(): string { return 'https://accounts.google.com/o/oauth2/v2/auth?' . http_build_query([ 'client_id' => $this->get_client_id(), 'redirect_uri' => site_url('/wp-json/reservations/v1/google-callback'), 'response_type' => 'code', 'scope' => 'https://www.googleapis.com/auth/calendar', 'access_type' => 'offline', 'prompt' => 'consent', ]); } public function exchange_code(string $code): bool { $response = wp_remote_post('https://oauth2.googleapis.com/token', [ 'body' => [ 'code' => $code, 'client_id' => $this->get_client_id(), 'client_secret' => $this->get_client_secret(), 'redirect_uri' => site_url('/wp-json/reservations/v1/google-callback'), 'grant_type' => 'authorization_code', ], ]); $data = json_decode(wp_remote_retrieve_body($response), true); if (!isset($data['access_token'])) { Logger::error('Google Calendar token exchange failed: ' . json_encode($data)); return false; } $this->set_secret('rsv_google_access_token', $data['access_token']); $this->set_secret('rsv_google_refresh_token', $data['refresh_token']); update_option('rsv_google_token_expires', time() + $data['expires_in']); return true; } public function disconnect(): void { $this->stop_webhook(); delete_option('rsv_google_access_token'); delete_option('rsv_google_refresh_token'); delete_option('rsv_google_token_expires'); delete_option('rsv_google_sync_token'); } public function get_access_token(): ?string { $access_token = $this->get_secret('rsv_google_access_token'); $refresh_token = $this->get_secret('rsv_google_refresh_token'); $expires_at = (int) get_option('rsv_google_token_expires', 0); if (!$access_token) { return null; } if (time() > $expires_at - 60) { $response = wp_remote_post('https://oauth2.googleapis.com/token', [ 'body' => [ 'client_id' => $this->get_client_id(), 'client_secret' => $this->get_client_secret(), 'refresh_token' => $refresh_token, 'grant_type' => 'refresh_token', ], ]); $data = json_decode(wp_remote_retrieve_body($response), true); if (isset($data['access_token'])) { $this->set_secret('rsv_google_access_token', $data['access_token']); update_option('rsv_google_token_expires', time() + $data['expires_in']); $access_token = $data['access_token']; } else { Logger::error('Google Calendar token refresh failed: ' . json_encode($data)); // Refresh token is permanently invalid — clear credentials so the // admin UI shows "reconnect needed" instead of silently failing. delete_option('rsv_google_access_token'); delete_option('rsv_google_refresh_token'); delete_option('rsv_google_token_expires'); return null; } } return $access_token; } // ------------------------------------------------------------------------- // Calendar list // ------------------------------------------------------------------------- public function list_calendars(): array { $access_token = $this->get_access_token(); if (!$access_token) { return []; } $response = wp_remote_get( 'https://www.googleapis.com/calendar/v3/users/me/calendarList', ['headers' => ['Authorization' => 'Bearer ' . $access_token]] ); if (is_wp_error($response)) { return []; } $data = json_decode(wp_remote_retrieve_body($response), true); return array_map(fn($c) => ['id' => $c['id'], 'summary' => $c['summary']], $data['items'] ?? []); } // ------------------------------------------------------------------------- // Calendar events // ------------------------------------------------------------------------- public function add_event( string $calendar_id, string $summary, string $start_utc, string $end_utc, ?string $attendee_email = null, ?int $reservation_id = null, string $status = 'confirmed' ): void { $access_token = $this->get_access_token(); if (!$access_token) { throw new \RuntimeException('Not connected to Google Calendar.'); } $tz = wp_timezone()->getName(); $start = (new \DateTime($start_utc, new \DateTimeZone('UTC')))->setTimezone(wp_timezone()); $end = (new \DateTime($end_utc, new \DateTimeZone('UTC')))->setTimezone(wp_timezone()); $event = [ 'summary' => $summary, 'status' => $status, 'start' => ['dateTime' => $start->format(\DateTimeInterface::RFC3339), 'timeZone' => $tz], 'end' => ['dateTime' => $end->format(\DateTimeInterface::RFC3339), 'timeZone' => $tz], ]; if ($attendee_email) { $event['attendees'] = [['email' => $attendee_email]]; } if ($reservation_id !== null) { $event['extendedProperties'] = [ 'private' => ['rsv_reservation_id' => (string) $reservation_id], ]; } $response = wp_remote_post( "https://www.googleapis.com/calendar/v3/calendars/{$calendar_id}/events?sendUpdates=all", [ 'headers' => [ 'Authorization' => 'Bearer ' . $access_token, 'Content-Type' => 'application/json', ], 'body' => json_encode($event), ] ); if (is_wp_error($response)) { throw new \RuntimeException('Google Calendar request failed: ' . $response->get_error_message()); } $body = json_decode(wp_remote_retrieve_body($response), true); if (!isset($body['id'])) { throw new \RuntimeException('Google Calendar event creation failed: ' . json_encode($body)); } } // ------------------------------------------------------------------------- // Push notification webhook // ------------------------------------------------------------------------- public function register_webhook(): array { $this->stop_webhook(); $access_token = $this->get_access_token(); if (!$access_token) { return ['error' => 'Not connected to Google Calendar.']; } // Get an initial sync token so the first process_changes() call works. $this->initialize_sync_token(); $channel_id = uniqid('rsv_gcal_', true); $response = wp_remote_post( "https://www.googleapis.com/calendar/v3/calendars/{$this->get_calendar_id()}/events/watch", [ 'headers' => [ 'Authorization' => 'Bearer ' . $access_token, 'Content-Type' => 'application/json', ], 'body' => json_encode([ 'id' => $channel_id, 'type' => 'web_hook', 'address' => site_url('/wp-json/reservations/v1/google-calendar-hook'), ]), ] ); $data = json_decode(wp_remote_retrieve_body($response), true); if (isset($data['id'])) { update_option('rsv_google_webhook_channel_id', $data['id']); update_option('rsv_google_webhook_resource_id', $data['resourceId']); update_option('rsv_google_webhook_expiration', $data['expiration'] / 1000); // ms → s } else { Logger::error('Google Calendar webhook registration failed: ' . json_encode($data)); } return $data; } public function stop_webhook(): void { $channel_id = get_option('rsv_google_webhook_channel_id'); $resource_id = get_option('rsv_google_webhook_resource_id'); if (!$channel_id || !$resource_id) { return; } $access_token = $this->get_access_token(); if ($access_token) { wp_remote_post('https://www.googleapis.com/calendar/v3/channels/stop', [ 'headers' => [ 'Authorization' => 'Bearer ' . $access_token, 'Content-Type' => 'application/json', ], 'body' => json_encode([ 'id' => $channel_id, 'resourceId' => $resource_id, ]), ]); } delete_option('rsv_google_webhook_channel_id'); delete_option('rsv_google_webhook_resource_id'); delete_option('rsv_google_webhook_expiration'); } // ------------------------------------------------------------------------- // Change sync // ------------------------------------------------------------------------- public function initialize_sync_token(): void { $access_token = $this->get_access_token(); if (!$access_token) { return; } $response = wp_remote_get( 'https://www.googleapis.com/calendar/v3/calendars/' . rawurlencode($this->get_calendar_id()) . '/events?' . http_build_query([ 'fields' => 'nextSyncToken', 'showDeleted' => 'true', 'singleEvents' => 'true', ]), ['headers' => ['Authorization' => 'Bearer ' . $access_token]] ); $data = json_decode(wp_remote_retrieve_body($response), true); if (isset($data['nextSyncToken'])) { update_option('rsv_google_sync_token', $data['nextSyncToken']); } } /** * Fetch events changed since the last sync and return actions to take. * * @return array */ public function process_changes(): array { $access_token = $this->get_access_token(); if (!$access_token) { return []; } $sync_token = get_option('rsv_google_sync_token'); if (!$sync_token) { $this->initialize_sync_token(); return []; } $response = wp_remote_get( 'https://www.googleapis.com/calendar/v3/calendars/' . rawurlencode($this->get_calendar_id()) . '/events?' . http_build_query([ 'syncToken' => $sync_token, 'showDeleted' => 'true', 'fields' => 'nextSyncToken,items(id,status,extendedProperties)', ]), ['headers' => ['Authorization' => 'Bearer ' . $access_token]] ); if (wp_remote_retrieve_response_code($response) === 410) { // Sync token expired — reinitialise and process next time. delete_option('rsv_google_sync_token'); $this->initialize_sync_token(); return []; } $data = json_decode(wp_remote_retrieve_body($response), true); if (isset($data['nextSyncToken'])) { update_option('rsv_google_sync_token', $data['nextSyncToken']); } $actions = []; foreach ($data['items'] ?? [] as $event) { $reservation_id = (int) ($event['extendedProperties']['private']['rsv_reservation_id'] ?? 0); if (!$reservation_id) { continue; } $status = $event['status'] ?? ''; if ($status === 'confirmed') { $actions[] = ['reservation_id' => $reservation_id, 'action' => 'accept']; } elseif ($status === 'cancelled') { $actions[] = ['reservation_id' => $reservation_id, 'action' => 'refuse']; } } return $actions; } }