Files
Reservair/includes/Services/RsvGoogleCalendarService.php
Martin Slachta 0d829845c4 initial
2026-06-11 19:03:29 +02:00

372 lines
14 KiB
PHP

<?php
use Reservair\Logger\Logger;
class RsvGoogleCalendarService {
public function is_google_connected(): bool {
return (bool) get_option('rsv_google_access_token');
}
private function get_client_id(): string {
return (string) get_option('rsv_google_client_id', '');
}
private function get_client_secret(): string {
return (string) ($this->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<array{reservation_id: int, action: 'accept'|'refuse'}>
*/
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;
}
}