From e0f2d95a8ef29e00d8798f76d47972b2f430143c Mon Sep 17 00:00:00 2001 From: Joniras Date: Tue, 24 Jun 2025 21:44:08 +0200 Subject: [PATCH 1/5] feat: introduced block_servers --- CHANGELOG.md | 1 + application/controllers/Caldav.php | 162 ++++++++++++++++++ application/controllers/Calendar.php | 18 ++ .../language/german/translations_lang.php | 11 ++ application/libraries/Caldav_sync.php | 26 ++- .../migrations/061_add_block_servers.php | 101 +++++++++++ application/models/Appointments_model.php | 38 +++- application/views/pages/calendar.php | 27 +++ assets/css/general.scss | 4 + assets/js/http/caldav_http_client.js | 40 +++++ assets/js/utils/calendar_default_view.js | 13 +- assets/js/utils/calendar_sync.js | 142 +++++++++++++++ 12 files changed, 575 insertions(+), 8 deletions(-) create mode 100644 application/migrations/061_add_block_servers.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cc09ea251..5cd259190d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ developers to maintain and readjust their custom modifications on the main proje - Update unavailable dates after applying appointment data while rescheduling (#1662) - Make sure that any-provider does not include hidden providers while generating availability (#1733) - Provide "text" version of the emails in addition to HTML (#1711) +- Allow several "block"-servers to be defined for each provider (to allow multiple calendars to be synced) ## [1.5.1] - 2025-01-20 diff --git a/application/controllers/Caldav.php b/application/controllers/Caldav.php index 905aa9f136..f2ebca300a 100644 --- a/application/controllers/Caldav.php +++ b/application/controllers/Caldav.php @@ -81,6 +81,76 @@ public function connect_to_server(): void } } + + /** + * Connect to the target CalDAV server + * + * @return void + */ + public function add_block_server(): void + { + try { + $provider_id = request('provider_id'); + $user_id = session('user_id'); + + if (cannot('edit', PRIV_USERS) && (int) $user_id !== (int) $provider_id) { + throw new RuntimeException('You do not have the required permissions for this task.'); + } + + $caldav_url = request('caldav_url'); + $caldav_username = request('caldav_username'); + $caldav_password = request('caldav_password'); + + $this->caldav_sync->test_connection($caldav_url, $caldav_username, $caldav_password); + + // Insert into caldav_block_servers + $this->db->insert('ea_caldav_block_servers', [ + 'user_id' => $provider_id, + 'caldav_url' => $caldav_url, + 'caldav_username' => $caldav_username, + 'caldav_password' => $caldav_password, + ]); + + json_response([ + 'success' => true, + ]); + } catch (GuzzleException | InvalidArgumentException $e) { + json_response([ + 'success' => false, + 'message' => $e->getMessage(), + ]); + } catch (Throwable $e) { + json_exception($e); + } + } + + public function delete_block_server(): void{ + try{ + + $provider_id = request('provider_id'); + $block_server_id = request('block_server_id'); + $user_id = session('user_id'); + if (cannot('edit', PRIV_USERS) && (int) $user_id !== (int) $provider_id) { + throw new RuntimeException('You do not have the required permissions for this task.'); + } + + // Delete all appointments associated with this block server and provider + $this->db->where('id_caldav_block_server', $block_server_id) + ->where('id_users_provider', $provider_id) + ->delete('ea_appointments'); + + // Delete the block server itself + $this->db->where('id', $block_server_id) + ->delete('ea_caldav_block_servers'); + + json_response([ + 'success' => true, + ]); + }catch (Throwable $e) { + json_exception($e); + } + } + /** * Sync the provider events with the remote CalDAV calendar. * @@ -143,6 +213,7 @@ public static function sync(string $provider_id): void $where = [ 'start_datetime >=' => $start_date_time, 'end_datetime <=' => $end_date_time, + 'read_only =' => '0', 'id_users_provider' => $provider['id'], ]; @@ -334,4 +405,95 @@ public function disable_provider_sync(): void json_exception($e); } } + + /** + * Sync events from block servers + * + * @return void + */ + public function sync_block_servers(): void + { + $provider_id = request('provider_id'); + $user_id = session('user_id'); + if (cannot('edit', PRIV_USERS) && (int) $user_id !== (int) $provider_id) { + throw new RuntimeException('You do not have the required permissions for this task.'); + } + + $block_servers = $this->db->get_where('caldav_block_servers', ['user_id' => $provider_id])->result_array(); + + foreach ($block_servers as $block_server) { + $this->load->library('caldav_sync'); + $start_date_time = date('Y-m-d 00:00:00'); + $end_date_time = date('Y-m-d 23:59:59', strtotime('+30 days')); + + $provider = $this->providers_model->find($provider_id); + + // Use block server credentials for sync + $events = $this->caldav_sync->get_sync_events([ + 'settings' => [ + 'caldav_url' => $block_server['caldav_url'], + 'caldav_username' => $block_server['caldav_username'], + 'caldav_password' => $block_server['caldav_password'], + 'caldav_sync' => true, + ], + 'timezone' => $provider['timezone'], + 'id' => $provider_id, + ], $start_date_time, $end_date_time); + + // Collect current CalDAV event IDs + $current_event_ids = array_column($events, 'id'); + + // Fetch all read-only appointments for this block server and provider + $existing_appointments = $this->appointments_model->get_blocker([ + 'id_caldav_block_server' => $block_server['id'], + 'id_users_provider' => $provider_id, + ]); + + // Delete appointments not present in CalDAV anymore + foreach ($existing_appointments as $appointment) { + if (!in_array($appointment['id_caldav_calendar'], $current_event_ids)) { + $this->appointments_model->delete($appointment['id']); + } + } + + // Add or update current events + foreach ($events as $event) { + $exists = $this->appointments_model->get_blocker([ + 'id_caldav_calendar' => $event['id'], + 'id_caldav_block_server' => $block_server['id'], + ]); + if ($exists ) { + + $is_different = + $exists[0]['start_datetime'] !== $event['start_datetime'] || + $exists[0]['end_datetime'] !== $event['end_datetime']; + if ($is_different) { + $exists[0]['start_datetime'] = $event['start_datetime']; + $exists[0]['end_datetime'] = $event['end_datetime']; + $exists[0]['is_blocker'] = true; + $this->appointments_model->save($exists[0]); + } + continue; // Event already exists, skip to the next one. + } + + $this->appointments_model->save([ + 'start_datetime' => $event['start_datetime'], + 'end_datetime' => $event['end_datetime'], + 'location' => $event['location'], + 'notes' => $event['summary'] . ';$;' . $event['description'], + 'id_users_provider' => $provider_id, + 'id_services' => null, + 'id_users_customer' => null, + 'id_caldav_calendar' => $event['id'], + 'is_unavailability' => true, + 'read_only' => true, + 'id_caldav_block_server' => $block_server['id'], + 'is_blocker' => true, + 'status' => 'CONFIRMED', + ]); + } + } + + json_response(['success' => true]); + } } diff --git a/application/controllers/Calendar.php b/application/controllers/Calendar.php index d55a2a64ee..19a4b57648 100644 --- a/application/controllers/Calendar.php +++ b/application/controllers/Calendar.php @@ -174,6 +174,23 @@ public function index(string $appointment_hash = ''): void $appointment_status_options = setting('appointment_status_options'); + // Query all block_servers for this provider + $block_servers = []; + if ($role_slug === DB_SLUG_PROVIDER) { + $block_servers = $this->blocked_periods_model->get(['user_id' => $user_id]); + } elseif ($role_slug === DB_SLUG_SECRETARY || $role_slug === DB_SLUG_ADMIN) { + // For secretaries, get blocked periods for all their providers + foreach ($available_providers as $provider_id) { + $block_servers = array_merge( + $block_servers, + $block_servers = $this->db->get_where('caldav_block_servers', ['user_id' => $provider_id['id']])->result_array() + ); + } + } else { + // For admins or others, get all blocked periods + $block_servers = $this->blocked_periods_model->get(); + } + script_vars([ 'user_id' => $user_id, 'role_slug' => $role_slug, @@ -188,6 +205,7 @@ public function index(string $appointment_hash = ''): void 'available_services' => $available_services, 'secretary_providers' => $secretary_providers, 'edit_appointment' => $edit_appointment, + 'block_servers' => $block_servers, 'google_sync_feature' => config('google_sync_feature'), 'customers' => $this->customers_model->get(null, 50, null, 'update_datetime DESC'), 'default_language' => setting('default_language'), diff --git a/application/language/german/translations_lang.php b/application/language/german/translations_lang.php index ea756a2cc4..dcf32b16cc 100755 --- a/application/language/german/translations_lang.php +++ b/application/language/german/translations_lang.php @@ -63,10 +63,17 @@ $lang['synchronize'] = 'Synchronisieren'; $lang['enable_sync'] = 'Synchronisation einschalten'; $lang['disable_sync'] = 'Synchronisation ausschalten'; +$lang['add_block_sync'] = 'Neue Block-Synchronisation hinzufügen'; +$lang['sync_block_servers'] = 'Block-Kalender synchronisieren'; +$lang['blocker_server_list'] = 'Block-Server Liste'; +$lang['delete_block_server'] = 'Willst du den Block-Server löschen?'; +$lang['block_server_deletion_failed'] = 'Block Server konnte nicht gelöscht werden.'; +$lang['block_server_deletion_success'] = 'Block Server erfolgreich gelöscht.'; $lang['disable_sync_prompt'] = 'Sind Sie sicher, dass Sie die Kalendersynchronisation abschalten wollen?'; $lang['reload'] = 'Neu laden'; $lang['appointment'] = 'Termin'; $lang['unavailability'] = 'Nichtverfügbarkeit'; +$lang['blocker'] = 'Blocker'; $lang['week'] = 'Woche'; $lang['month'] = 'Monat'; $lang['today'] = 'Heute'; @@ -230,6 +237,8 @@ $lang['unexpected_issues_occurred'] = 'Unerwartete Probleme aufgetreten.'; $lang['service_communication_error'] = 'Während der Kommunikation mit dem Server ist ein Fehler aufgetreten, bitte versuchen Sie es erneut.'; $lang['no_privileges_edit_appointments'] = 'Sie haben nicht die Berechtigung, um Termine zu bearbeiten.'; +$lang['blocker_cannot_be_edited'] = 'Termine aus Blocker-Kalender können nicht bearbeitet werden.'; +$lang['blocker_cannot_be_moved'] = 'Termine aus Block-Kalender sind nur zum ansehen, nicht zum bearbeiten.'; $lang['unavailability_updated'] = 'Nichtverfügbarkeit erfolgreich erneuert worden.'; $lang['appointments'] = 'Termine'; $lang['unexpected_warnings'] = 'Unerwartete Warnungen'; @@ -268,6 +277,7 @@ $lang['select_sync_calendar'] = 'Wählen Sie externe Kalender'; $lang['select_sync_calendar_prompt'] = 'Wählen Sie den Kalender, mit dem Sie Ihre Termine synchronisieren möchten. Wenn Sie das nicht wollen, wird ein Standard-Kalender verwendet.'; $lang['sync_calendar_selected'] = 'Externe-Kalender wurde erfolgreich ausgewählt.'; +$lang['sync_block_calendar_selected'] = 'Block Kalender wurden erfolgreich synchronisiert.'; $lang['oops_something_went_wrong'] = 'Oops! Etwas ist schiefgelaufen.'; $lang['ea_update_success'] = 'Easy!Appointments wurde erfolgreich aktualisiert.'; $lang['require_captcha'] = 'CAPTCHA erfordern'; @@ -460,6 +470,7 @@ $lang['sync_method_prompt'] = 'Welche Synchronisierungsmethode möchten Sie verwenden?'; $lang['caldav_server'] = 'CalDAV-Server'; $lang['caldav_connection_info_prompt'] = 'Bitte geben Sie die Verbindungsinformationen des CalDAV-Zielservers ein.'; +$lang['caldav_connection_info_prompt_block_only'] = 'Bitte geben Sie die Verbindungsinformationen des CalDAV-Servers ein, von dem nur Termine runtersynchronisiert werden ein (read-only).'; $lang['connect'] = 'Verbinden'; $lang['ldap'] = 'LDAP'; $lang['ldap_info'] = 'Diese Integration ermöglicht es Ihnen, sich mit einem bestehenden LDAP-Server zu verbinden und Benutzer automatisch in Easy!Appointments zu importieren und ihnen ein SSO mit ihrem Verzeichnispasswort zu ermöglichen (der Benutzername muss übereinstimmen).'; diff --git a/application/libraries/Caldav_sync.php b/application/libraries/Caldav_sync.php index 34fe9e9380..9fee1caa18 100644 --- a/application/libraries/Caldav_sync.php +++ b/application/libraries/Caldav_sync.php @@ -194,8 +194,30 @@ public function get_event(array $provider, string $caldav_event_id): ?array public function get_sync_events(array $provider, string $start_date_time, string $end_date_time): array { try { - $client = $this->get_http_client_by_provider_id($provider['id']); - $provider_timezone_object = new DateTimeZone($provider['timezone']); + // Support both provider and block server parameter structures + if (isset($provider['settings'])) { + $settings = $provider['settings']; + $caldav_url = $settings['caldav_url'] ?? null; + $caldav_username = $settings['caldav_username'] ?? null; + $caldav_password = $settings['caldav_password'] ?? null; + } else { + $caldav_url = $provider['caldav_url'] ?? null; + $caldav_username = $provider['caldav_username'] ?? null; + $caldav_password = $provider['caldav_password'] ?? null; + } + + // Timezone + $timezone = $provider['timezone'] ?? ($provider['settings']['timezone'] ?? 'UTC'); + $provider_timezone_object = new DateTimeZone($timezone); + + // Use credentials directly if present, otherwise fallback to provider ID + if ($caldav_url && $caldav_username && $caldav_password) { + $client = $this->get_http_client($caldav_url, $caldav_username, $caldav_password); + } elseif (isset($provider['id'])) { + $client = $this->get_http_client_by_provider_id($provider['id']); + } else { + throw new InvalidArgumentException('Missing CalDAV credentials or provider ID.'); + } $response = $this->fetch_events($client, $start_date_time, $end_date_time); diff --git a/application/migrations/061_add_block_servers.php b/application/migrations/061_add_block_servers.php new file mode 100644 index 0000000000..117e523710 --- /dev/null +++ b/application/migrations/061_add_block_servers.php @@ -0,0 +1,101 @@ + + * @copyright Copyright (c) Alex Tselegidis + * @license https://opensource.org/licenses/GPL-3.0 - GPLv3 + * @link https://easyappointments.org + * @since v1.4.0 + * ---------------------------------------------------------------------------- */ + + +class Migration_Add_caldav_block_servers_table_and_appointment_columns extends EA_Migration +{ + /** + * Upgrade method. + */ + public function up(): void + { + if (!$this->db->table_exists('caldav_block_servers')) { + $this->dbforge->add_field( [ + 'id' => [ + 'type' => 'INT', + 'auto_increment' => true + ], + 'user_id' => [ + 'type' => 'INT', + 'null' => false + ], + 'caldav_url' => [ + 'type' => 'VARCHAR', + 'constraint' => '255', + 'null' => false + ], + 'caldav_username' => [ + 'type' => 'VARCHAR', + 'constraint' => '255', + 'null' => false + ], + 'caldav_password' => [ + 'type' => 'VARCHAR', + 'constraint' => '255', + 'null' => false + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => false, + 'default' => 'CURRENT_TIMESTAMP' + ] + ]); + + $this->dbforge->add_key('id', true); + $this->dbforge->add_key('user_id'); + $this->dbforge->create_table('caldav_block_servers', true,['engine' => 'InnoDB']); + + $this->db->query('ALTER TABLE ' . $this->db->dbprefix('caldav_block_servers') . ' ADD CONSTRAINT fk_caldav_block_servers_user_id FOREIGN KEY (user_id) REFERENCES ' . $this->db->dbprefix('users') . '(id) ON DELETE CASCADE'); + } + + if (!$this->db->field_exists('read_only', 'appointments')) { + $fields = [ + 'read_only' => [ + 'type' => 'BOOLEAN', + 'null' => false, + 'default' => 0 + ] + ]; + $this->dbforge->add_column('appointments', $fields); + } + + if (!$this->db->field_exists('id_caldav_block_server', 'appointments')) { + $fields = [ + 'id_caldav_block_server' => [ + 'type' => 'INT', + 'null' => true, + 'after' => 'read_only' + ] + ]; + $this->dbforge->add_column('appointments', $fields); + } + } + + /** + * Downgrade method. + */ + public function down(): void + { + if ($this->db->field_exists('id_caldav_block_server', 'appointments')) { + $this->dbforge->drop_column('appointments', 'id_caldav_block_server'); + } + + if ($this->db->field_exists('read_only', 'appointments')) { + $this->dbforge->drop_column('appointments', 'read_only'); + } + + if ($this->db->table_exists('caldav_block_servers')) { + $this->dbforge->drop_table('caldav_block_servers'); + } + } +} \ No newline at end of file diff --git a/application/models/Appointments_model.php b/application/models/Appointments_model.php index f040e49d9c..5663526e8d 100644 --- a/application/models/Appointments_model.php +++ b/application/models/Appointments_model.php @@ -27,6 +27,8 @@ class Appointments_model extends EA_Model 'id_users_provider' => 'integer', 'id_users_customer' => 'integer', 'id_services' => 'integer', + 'read_only' => 'boolean', + 'id_caldav_block_server' => 'integer', // <-- Add this line ]; /** @@ -61,7 +63,8 @@ class Appointments_model extends EA_Model public function save(array $appointment): int { $this->validate($appointment); - + //remove is_blocker from appointment data + unset($appointment['is_blocker']); if (empty($appointment['id'])) { return $this->insert($appointment); } else { @@ -92,14 +95,14 @@ public function validate(array $appointment): void // Make sure all required fields are provided. $require_notes = filter_var(setting('require_notes'), FILTER_VALIDATE_BOOLEAN); - - if ( + $isBlocker = isset($appointment['is_blocker'])?(bool)$appointment['is_blocker']:false; + if ( !$isBlocker && ( empty($appointment['start_datetime']) || empty($appointment['end_datetime']) || empty($appointment['id_services']) || empty($appointment['id_users_provider']) || empty($appointment['id_users_customer']) || - (empty($appointment['notes']) && $require_notes) + (empty($appointment['notes']) && $require_notes)) ) { throw new InvalidArgumentException('Not all required fields are provided: ' . print_r($appointment, true)); } @@ -132,7 +135,7 @@ public function validate(array $appointment): void ->get() ->num_rows(); - if (!$count) { + if (!$count && !$isBlocker) { throw new InvalidArgumentException( 'The appointment provider ID was not found in the database: ' . $appointment['id_users_provider'], ); @@ -199,6 +202,31 @@ public function get( return $appointments; } + public function get_blocker( + array|string|null $where = null, + ?int $limit = null, + ?int $offset = null, + ?string $order_by = null, + ): array { + if ($where !== null) { + $this->db->where($where); + } + + if ($order_by) { + $this->db->order_by($this->db->escape($order_by)); + } + + $appointments = $this->db + ->get_where('appointments', null, $limit, $offset) + ->result_array(); + + foreach ($appointments as &$appointment) { + $this->cast($appointment); + } + + return $appointments; + } + /** * Insert a new appointment into the database. * diff --git a/application/views/pages/calendar.php b/application/views/pages/calendar.php index a5aaab164c..6b16851b15 100755 --- a/application/views/pages/calendar.php +++ b/application/views/pages/calendar.php @@ -49,6 +49,33 @@ class="btn btn-light" +