initial
This commit is contained in:
@@ -0,0 +1,371 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user