From 92d5fe154e84ffc92f0a546493263bade62989a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Fri, 5 Dec 2025 11:59:32 +0000 Subject: [PATCH] Create a connector to synchronize with zimbra calendars --- .gitlab-ci.yml | 52 +++++++ .php-cs-fixer.dist.php | 1 + composer.json | 8 + config/bundles.php | 1 + package.json | 1 + packages/ChillZimbraBundle/README.md | 40 +++++ packages/ChillZimbraBundle/composer.json | 22 +++ .../Calendar/Connector/ZimbraConnector.php | 147 ++++++++++++++++++ .../Connector/ZimbraConnector/CreateEvent.php | 96 ++++++++++++ .../ZimbraConnector/CreateZimbraComponent.php | 129 +++++++++++++++ .../ZimbraConnector/DateConverter.php | 37 +++++ .../Connector/ZimbraConnector/DeleteEvent.php | 51 ++++++ .../ZimbraConnector/SoapClientBuilder.php | 78 ++++++++++ .../Connector/ZimbraConnector/UpdateEvent.php | 97 ++++++++++++ .../ZimbraConnector/ZimbraIdSerializer.php | 67 ++++++++ .../src/ChillZimbraBundle.php | 16 ++ .../ChillZimbraExtension.php | 26 ++++ .../CalendarWithoutMainUserException.php | 14 ++ ...mbraCalendarIdNotDeserializedException.php | 14 ++ .../src/config/services.yaml | 7 + .../ZimbraComponent/calendar_content.txt.twig | 18 +++ .../invitation_content.txt.twig | 20 +++ .../src/translations/messages+intl-icu.fr.yml | 9 ++ phpstan.dist.neon | 1 + .../ChillCalendarExtension.php | 2 + .../DependencyInjection/Configuration.php | 3 +- .../Entity/CalendarRange.php | 5 + .../Handler/CalendarRemoveHandler.php | 10 +- .../RemoteCalendarConnectorInterface.php | 105 ++++++++++++- .../RemoteCalendarCompilerPass.php | 49 ++++-- .../translations/messages.fr.yml | 2 + .../Templating/Entity/AddressRender.php | 5 +- .../Templating/Entity/CommentRender.php | 2 +- symfony.lock | 3 + 34 files changed, 1116 insertions(+), 22 deletions(-) create mode 100644 packages/ChillZimbraBundle/README.md create mode 100644 packages/ChillZimbraBundle/composer.json create mode 100644 packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector.php create mode 100644 packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/CreateEvent.php create mode 100644 packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/CreateZimbraComponent.php create mode 100644 packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/DateConverter.php create mode 100644 packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/DeleteEvent.php create mode 100644 packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/SoapClientBuilder.php create mode 100644 packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/UpdateEvent.php create mode 100644 packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/ZimbraIdSerializer.php create mode 100644 packages/ChillZimbraBundle/src/ChillZimbraBundle.php create mode 100644 packages/ChillZimbraBundle/src/DependencyInjection/ChillZimbraExtension.php create mode 100644 packages/ChillZimbraBundle/src/Exception/CalendarWithoutMainUserException.php create mode 100644 packages/ChillZimbraBundle/src/Exception/ZimbraCalendarIdNotDeserializedException.php create mode 100644 packages/ChillZimbraBundle/src/config/services.yaml create mode 100644 packages/ChillZimbraBundle/src/templates/ZimbraComponent/calendar_content.txt.twig create mode 100644 packages/ChillZimbraBundle/src/templates/ZimbraComponent/invitation_content.txt.twig create mode 100644 packages/ChillZimbraBundle/src/translations/messages+intl-icu.fr.yml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 355524fa4..b9c532b29 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -38,15 +38,67 @@ variables: TZ: Europe/Brussels # avoid direct deprecations (using symfony phpunit bridge: https://symfony.com/doc/4.x/components/phpunit_bridge.html#internal-deprecations SYMFONY_DEPRECATIONS_HELPER: max[total]=99999999&max[self]=0&max[direct]=45&verbose=0 + # consider the root package at the dev-master version + # this is required to work with packages + # see https://getcomposer.org/doc/articles/troubleshooting.md#dependencies-on-the-root-package + COMPOSER_ROOT_VERSION: dev-master stages: + - mirror - Composer install - Tests - Deploy +mirror_chill_zimbra_bundle: + stage: mirror + image: alpine:latest + + variables: + GIT_DEPTH: 0 # <-- access to the full git history + + rules: + # 1) Allow manual run from GitLab UI, whatever the branch + - if: '$CI_PIPELINE_SOURCE == "web"' + + # 2) Auto-run on commits to master or 472-zimbra-connector + # but only if relevant files changed + - if: '$CI_COMMIT_BRANCH == "master" || $CI_COMMIT_BRANCH == "472-zimbra-connector"' + changes: + - packages/ChillZimbraBundle/**/* + - .gitlab-ci.yml + + # 3) Otherwise: never run + - when: never + + + before_script: + - apk add --no-cache git git-subtree openssh + # Config git + - git config --global user.email "ci@gitlab.com" + - git config --global user.name "GitLab CI" + # Préparation SSH + - mkdir -p ~/.ssh + - cp "$DEPLOY_KEY" ~/.ssh/id_ed25519 + - printf '\n' >> ~/.ssh/id_ed25519 + - chmod 600 ~/.ssh/id_ed25519 + - ssh-keyscan gitlab.com >> ~/.ssh/known_hosts + # Ajout du remote vers le repo dédié + - git remote add chill-zimbra-connector git@gitlab.com:Chill-Projet/chill-zimbra-connector.git || true + + script: + # On s'assure d'être sur la bonne branche (celle qui a déclenché le job, master) + - git checkout "$CI_COMMIT_REF_NAME" + + # Crée une branche temporaire qui contient uniquement l'historique de packages/ChillZimbraBundle + - git subtree split --prefix=packages/ChillZimbraBundle -b chill_zimbra_temp + + # Push vers le repo cible, branche master du repo chill-zimbra-connector + - git push --force chill-zimbra-connector chill_zimbra_temp:main + build: stage: Composer install image: chill/base-image:8.3-edge + variables: before_script: - composer config -g cache-dir "$(pwd)/.cache" script: diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 3360abe85..9e6e76238 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -14,6 +14,7 @@ $finder = PhpCsFixer\Finder::create(); $finder ->in(__DIR__.'/src') ->in(__DIR__.'/utils') + ->in(__DIR__.'/packages') ->append([__FILE__]) ->exclude(['docs/', 'tests/app']) ->notPath('tests/app') diff --git a/composer.json b/composer.json index c1e235670..2ccf95008 100644 --- a/composer.json +++ b/composer.json @@ -7,6 +7,13 @@ "chill", "social worker" ], + "repositories": [{ + "type": "path", + "url": "./packages/ChillZimbraBundle", + "options": { + "symlink": true + } + }], "require": { "php": "^8.2", "ext-dom": "*", @@ -14,6 +21,7 @@ "ext-openssl": "*", "ext-redis": "*", "ext-zlib": "*", + "chill-project/chill-zimbra-bundle": "@dev", "champs-libres/wopi-bundle": "dev-symfony-v5@dev", "champs-libres/wopi-lib": "dev-master@dev", "doctrine/data-fixtures": "^1.8", diff --git a/config/bundles.php b/config/bundles.php index 72b5e22f5..989338f3e 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -37,4 +37,5 @@ return [ Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], Symfony\UX\Translator\UxTranslatorBundle::class => ['all' => true], loophp\PsrHttpMessageBridgeBundle\PsrHttpMessageBridgeBundle::class => ['all' => true], + Chill\ZimbraBundle\ChillZimbraBundle::class => ['all' => true], ]; diff --git a/package.json b/package.json index a013df3da..1b4c7df13 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "@hotwired/stimulus": "^3.0.0", "@luminateone/eslint-baseline": "^1.0.9", "@symfony/stimulus-bridge": "^3.2.0", + "@symfony/ux-translator": "file:vendor/symfony/ux-translator/assets", "@symfony/webpack-encore": "^4.1.0", "@tsconfig/node20": "^20.1.4", "@types/dompurify": "^3.0.5", diff --git a/packages/ChillZimbraBundle/README.md b/packages/ChillZimbraBundle/README.md new file mode 100644 index 000000000..521b31fd2 --- /dev/null +++ b/packages/ChillZimbraBundle/README.md @@ -0,0 +1,40 @@ +# Chill Zimbra Bundle + +This bundle provides integration with Zimbra email server for Chill application. + +## Source code + +This bundle should be modified within the chill-bundles repository. The code at +https://gitlab.com/Chill-Projet/chill-zimbra-connector is a mirror of the main +repository, and is intended to serve packagist information. + +## Configuration + +This bundle should be configured using a dsn scheme with a `zimbra+http` or `zimbra+https` +scheme. + +```yaml +chill_calendar: + # remember to url-encode username and password + remote_calendar_dsn: zimbra+https://chill%40zimbra.example.com:password@zimbra.example.com +``` + +## Development + +This bundles should be developed from within the chill-bundles repository. + +During development, you must use an inline alias, or a branch alias to +be able to load the root package's master branch as a replacement for a version. + +Example of composer.json for chill-project/chill-zimbra-bundle: + +```json +{ + "require": { + "chill-project/chill-bundles": "dev-master as v4.6.1", + "zimbra-api/soap-api": "^3.2.2", + "psr/http-client": "^1.0", + "nyholm/psr7": "^1.0" + } +} +``` diff --git a/packages/ChillZimbraBundle/composer.json b/packages/ChillZimbraBundle/composer.json new file mode 100644 index 000000000..f62e21125 --- /dev/null +++ b/packages/ChillZimbraBundle/composer.json @@ -0,0 +1,22 @@ +{ + "name": "chill-project/chill-zimbra-bundle", + "description": "Provide connection between Zimbra agenda and Chill", + "minimum-stability": "stable", + "license": "AGPL-3.0", + "type": "library", + "keywords": [ + "chill", + "social worker" + ], + "require": { + "chill-project/chill-bundles": "dev-master as v4.6.1", + "zimbra-api/soap-api": "^3.2.2", + "psr/http-client": "^1.0", + "nyholm/psr7": "^1.0" + }, + "autoload": { + "psr-4": { + "Chill\\ZimbraBundle\\": "src/" + } + } +} diff --git a/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector.php b/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector.php new file mode 100644 index 000000000..acb6bdd84 --- /dev/null +++ b/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector.php @@ -0,0 +1,147 @@ +deleteEvent)($user, $remoteId); + + if (null !== $associatedCalendarRange) { + $this->logger->info(self::LOG_PREFIX.'Ask to re-create the previous calendar range', ['previous_calendar_range_id' => $associatedCalendarRange->getId()]); + $this->createCalendarRange($associatedCalendarRange); + } + } + + public function removeCalendarRange(string $remoteId, array $remoteAttributes, User $user): void + { + if ('' === $remoteId) { + return; + } + + ($this->deleteEvent)($user, $remoteId); + } + + public function syncCalendar(Calendar $calendar, string $action, ?CalendarRange $previousCalendarRange, ?User $previousMainUser, ?array $oldInvites, ?array $newInvites): void + { + if (null !== $previousMainUser && $previousMainUser !== $calendar->getMainUser()) { + $this->removeCalendar($calendar->getRemoteId(), [], $previousMainUser); + $calendar->setRemoteId(''); + } + + if (!$calendar->hasRemoteId()) { + $calItemId = ($this->createEvent)($calendar); + $this->logger->info(self::LOG_PREFIX.'Calendar synced with Zimbra', ['calendar_id' => $calendar->getId(), 'action' => $action, 'calItemId' => $calItemId]); + + $calendar->setRemoteId($calItemId); + } else { + ($this->updateEvent)($calendar); + $this->logger->info(self::LOG_PREFIX.'Calendar updated against zimbra', ['old_cal_remote_id' => $calendar->getRemoteId(), 'calendar_id' => $calendar->getId()]); + } + + if (null !== $calendar->getCalendarRange()) { + $range = $calendar->getCalendarRange(); + $this->removeCalendarRange($range->getRemoteId(), [], $range->getUser()); + $range->setRemoteId(''); + } + + if (null !== $previousCalendarRange) { + $this->syncCalendarRange($previousCalendarRange); + } + + foreach ($calendar->getInvites() as $invite) { + $this->syncInvite($invite); + } + } + + public function syncCalendarRange(CalendarRange $calendarRange): void + { + if (!$calendarRange->hasRemoteId()) { + $this->createCalendarRange($calendarRange); + } else { + ($this->updateEvent)($calendarRange); + $this->logger->info(self::LOG_PREFIX.'Calendar range updated against zimbra', ['old_cal_remote_id' => $calendarRange->getRemoteId(), 'calendar_range_id' => $calendarRange->getId()]); + } + } + + public function syncInvite(Invite $invite): void + { + if (Invite::ACCEPTED === $invite->getStatus()) { + if ($invite->hasRemoteId()) { + ($this->updateEvent)($invite); + $this->logger->info(self::LOG_PREFIX.'Invite range updated against zimbra', ['invite_id' => $invite->getId(), 'invite_remote_id', $invite->getRemoteId()]); + } else { + $remoteId = ($this->createEvent)($invite); + $invite->setRemoteId($remoteId); + $this->logger->info(self::LOG_PREFIX.'Invite range updated against zimbra', ['invite_id' => $invite->getId(), 'invite_remote_id', $invite->getRemoteId()]); + } + } elseif ($invite->hasRemoteId()) { + // case when the invite has been accepted in the past, and synchronized + ($this->deleteEvent)($invite->getUser(), $invite->getRemoteId()); + $this->logger->info(self::LOG_PREFIX.'Invite range removed in zimbra', ['invite_id' => $invite->getId(), 'invite_remote_id', $invite->getRemoteId()]); + $invite->setRemoteId(''); + } + } + + private function createCalendarRange(CalendarRange $calendarRange): void + { + $calItemId = ($this->createEvent)($calendarRange); + $this->logger->info(self::LOG_PREFIX.'Calendar range created with Zimbra', ['calendar_range_id' => $calendarRange->getId(), 'calItemId' => $calItemId]); + + $calendarRange->setRemoteId($calItemId); + } +} diff --git a/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/CreateEvent.php b/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/CreateEvent.php new file mode 100644 index 000000000..0ada55760 --- /dev/null +++ b/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/CreateEvent.php @@ -0,0 +1,96 @@ +getMainUser()->getEmail(); + $organizerLang = $calendar->getMainUser()->getLocale(); + } elseif ($calendar instanceof Invite) { + $organizerEmail = $calendar->getUser()->getEmail(); + $organizerLang = $calendar->getUser()->getLocale(); + } else { + $organizerEmail = $calendar->getUser()->getEmail(); + $organizerLang = $calendar->getUser()->getLocale(); + } + + if (null === $organizerEmail) { + throw new CalendarWithoutMainUserException(); + } + + $api = $this->soapClientBuilder->getApiForAccount($organizerEmail); + + $comp = $this->createEvent->createZimbraInviteComponentFromCalendar($calendar); + + $inv = new InvitationInfo(); + $inv->setInviteComponent($comp); + + $mp = new MimePartInfo(); + $mp->addMimePart(new MimePartInfo('text/plain', $this->translator->trans('zimbra.event_created_by_chill', locale: $organizerLang))); + + $msg = new Msg(); + $msg->setSubject($this->translator->trans('zimbra.event_created_trough_soap', locale: $organizerLang)) + ->setFolderId('10') + ->setInvite($inv) + ->setMimePart($mp); + + $response = $api->createAppointment($msg, echo: true); + + $echo = $response->getEcho(); + $invite = $echo->getInvite(); + $MPInviteInfo = $invite->getInvite(); + /** @var InviteComponent $firstInvite */ + $firstInvite = $MPInviteInfo->getInviteComponents()[0]; + + return $this->zimbraIdSerializer->serializeId( + $response->getCalItemId(), + $response->getCalInvId(), + $firstInvite->getUid(), + ); + } +} diff --git a/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/CreateZimbraComponent.php b/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/CreateZimbraComponent.php new file mode 100644 index 000000000..9fa14d75c --- /dev/null +++ b/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/CreateZimbraComponent.php @@ -0,0 +1,129 @@ +getPersons()->map(fn (Person $p) => $this->personRender->renderString($p, ['addAge' => false]))->toArray() + ); + $content = $this->twig->render('@ChillZimbra/ZimbraComponent/calendar_content.txt.twig', ['calendar' => $calendar]); + } elseif ($calendar instanceof Invite) { + $subject = '[Chill] '. + '('.$this->translator->trans('remote_calendar.calendar_invite_statement_in_calendar').') '. + implode( + ', ', + $calendar->getCalendar()->getPersons()->map(fn (Person $p) => $this->personRender->renderString($p, ['addAge' => false]))->toArray() + ); + $content = $this->twig->render('@ChillZimbra/ZimbraComponent/invitation_content.txt.twig', ['calendar' => $calendar->getCalendar()]); + } else { + // $calendar is an instanceof CalendarRange + $subject = $this->translator->trans('remote_calendar.calendar_range_title'); + $content = ''; + } + + if ($calendar instanceof Invite) { + $startDate = $calendar->getCalendar()->getStartDate(); + $endDate = $calendar->getCalendar()->getEndDate(); + $location = $calendar->getCalendar()->getLocation(); + $hasLocation = $calendar->getCalendar()->hasLocation(); + $isPrivate = $calendar->getCalendar()->getAccompanyingPeriod()?->isConfidential() ?? false; + } else { + $startDate = $calendar->getStartDate(); + $endDate = $calendar->getEndDate(); + $location = $calendar->getLocation(); + $hasLocation = $calendar->hasLocation(); + $isPrivate = $calendar->getAccompanyingPeriod()?->isConfidential() ?? false; + } + + $comp = new InviteComponent(); + $comp->setName($subject); + $comp->setDescription($content); + $comp->setFreeBusy(FreeBusyStatus::BUSY); + $comp->setStatus(InviteStatus::CONFIRMED); + $comp->setCalClass($isPrivate ? InviteClass::PRI : InviteClass::PUB); + $comp->setTransparency(Transparency::OPAQUE); + $comp->setIsAllDay(false); + $comp->setIsDraft(false); + $comp->setDtStart($this->dateConverter->phpToZimbraDateTime($startDate)); + $comp->setDtEnd($this->dateConverter->phpToZimbraDateTime($endDate)); + + if ($hasLocation) { + $comp + ->setLocation($this->createLocationString($location)); + } + + return $comp; + } + + private function createLocationString(Location $location): string + { + $str = ''; + + if ('' !== ($loc = (string) $location->getName())) { + $str .= $loc; + } + + if ($location->hasAddress()) { + if ('' !== $str) { + $str .= ', '; + } + + $str .= $this->addressRender->renderString($location->getAddress(), ['separator' => ', ']); + } + + return $str; + } +} diff --git a/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/DateConverter.php b/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/DateConverter.php new file mode 100644 index 000000000..1fdea5040 --- /dev/null +++ b/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/DateConverter.php @@ -0,0 +1,37 @@ +format(self::FORMAT_DATE_TIME), $date->getTimezone()->getName()); + } +} diff --git a/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/DeleteEvent.php b/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/DeleteEvent.php new file mode 100644 index 000000000..2e3ae5314 --- /dev/null +++ b/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/DeleteEvent.php @@ -0,0 +1,51 @@ +getEmail(); + + if (null === $organizerEmail) { + throw new CalendarWithoutMainUserException(); + } + + $api = $this->soapClientBuilder->getApiForAccount($organizerEmail); + ['calItemId' => $calItemId, 'calInvId' => $calInvId, 'inviteComponentCommonUid' => $inviteComponentCommonUid] + = $this->zimbraIdSerializer->deSerializeId($remoteId); + + $api->cancelAppointment( + id: $calInvId, + componentNum: 0, + ); + } +} diff --git a/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/SoapClientBuilder.php b/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/SoapClientBuilder.php new file mode 100644 index 000000000..e66c45e85 --- /dev/null +++ b/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/SoapClientBuilder.php @@ -0,0 +1,78 @@ +parameterBag->get('chill_calendar.remote_calendar_dsn'); + $url = parse_url($dsn); + + $this->username = urldecode($url['user']); + $this->password = urldecode($url['pass']); + if ('zimbra+http' === $url['scheme']) { + $scheme = 'http://'; + $port = $url['port'] ?? 80; + } elseif ('zimbra+https' === $url['scheme']) { + $scheme = 'https://'; + $port = $url['port'] ?? 443; + } else { + throw new \Symfony\Component\DependencyInjection\Exception\InvalidArgumentException('Unsupported remote calendar scheme: '.$url['scheme']); + } + + $this->url = $scheme.$url['host'].':'.$port; + } + + private function buildApi(): MailApi + { + $baseClient = $this->client->withOptions([ + 'base_uri' => $location = $this->url.'/service/soap', + 'verify_host' => false, + 'verify_peer' => false, + ]); + $psr18Client = new Psr18Client($baseClient); + $api = new MailApi(); + $client = ClientFactory::create($location, $psr18Client); + $api->setClient($client); + + return $api; + } + + public function getApiForAccount(string $accountName): MailApi + { + $api = $this->buildApi(); + $response = $api->authByAccountName($this->username, $this->password); + + $token = $response->getAuthToken(); + + $apiBy = $this->buildApi(); + $apiBy->setAuthToken($token); + $apiBy->setTargetAccount(new AccountInfo(AccountBy::NAME, $accountName)); + + return $apiBy; + } +} diff --git a/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/UpdateEvent.php b/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/UpdateEvent.php new file mode 100644 index 000000000..9bd42b016 --- /dev/null +++ b/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/UpdateEvent.php @@ -0,0 +1,97 @@ +getMainUser()->getEmail(); + $organizerLang = $calendar->getMainUser()->getLocale(); + } elseif ($calendar instanceof Invite) { + $organizerEmail = $calendar->getCalendar()->getMainUser()->getEmail(); + $organizerLang = $calendar->getCalendar()->getMainUser()->getLocale(); + } else { + $organizerEmail = $calendar->getUser()->getEmail(); + $organizerLang = $calendar->getUser()->getLocale(); + } + + if (null === $organizerEmail) { + throw new CalendarWithoutMainUserException(); + } + + $api = $this->soapClientBuilder->getApiForAccount($organizerEmail); + + ['calItemId' => $calItemId, 'calInvId' => $calInvId, 'inviteComponentCommonUid' => $inviteComponentCommonUid] + = $this->zimbraIdSerializer->deSerializeId($calendar->getRemoteId()); + + $existing = $api->getAppointment(sync: true, includeContent: true, includeInvites: true, id: $calItemId); + $appt = $existing->getApptItem(); + + $comp = $this->createZimbraComponent->createZimbraInviteComponentFromCalendar($calendar); + $comp->setUid($inviteComponentCommonUid); + + $inv = new InvitationInfo(); + $inv->setInviteComponent($comp) + ->setUid($calInvId); + + $mp = new MimePartInfo(); + $mp->addMimePart(new MimePartInfo('text/plain', $this->translator->trans('zimbra.event_created_by_chill', locale: $organizerLang))); + + $msg = new Msg(); + $msg->setSubject($this->translator->trans('zimbra.event_created_trough_soap', locale: $organizerLang)) + ->setFolderId('10') + ->setInvite($inv) + ->setMimePart($mp) + ; + + $response = $api->modifyAppointment( + id: $calInvId, + componentNum: 0, + modifiedSequence: $appt->getModifiedSequence(), + revision: $appt->getRevision(), + msg: $msg, + echo: true + ); + } +} diff --git a/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/ZimbraIdSerializer.php b/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/ZimbraIdSerializer.php new file mode 100644 index 000000000..ba9d9ab63 --- /dev/null +++ b/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/ZimbraIdSerializer.php @@ -0,0 +1,67 @@ + $exploded[0], + 'calInvId' => $exploded[1], + 'inviteComponentCommonUid' => $exploded[2], + ]; + } +} diff --git a/packages/ChillZimbraBundle/src/ChillZimbraBundle.php b/packages/ChillZimbraBundle/src/ChillZimbraBundle.php new file mode 100644 index 000000000..b49e7e8af --- /dev/null +++ b/packages/ChillZimbraBundle/src/ChillZimbraBundle.php @@ -0,0 +1,16 @@ +load('services.yaml'); + } +} diff --git a/packages/ChillZimbraBundle/src/Exception/CalendarWithoutMainUserException.php b/packages/ChillZimbraBundle/src/Exception/CalendarWithoutMainUserException.php new file mode 100644 index 000000000..01314a62b --- /dev/null +++ b/packages/ChillZimbraBundle/src/Exception/CalendarWithoutMainUserException.php @@ -0,0 +1,14 @@ +setParameter('chill_calendar.short_messages', null); } + + $container->setParameter('chill_calendar.remote_calendar_dsn', $config['remote_calendar_dsn']); } public function prepend(ContainerBuilder $container) diff --git a/src/Bundle/ChillCalendarBundle/DependencyInjection/Configuration.php b/src/Bundle/ChillCalendarBundle/DependencyInjection/Configuration.php index a3e4ae391..e4ca96ca2 100644 --- a/src/Bundle/ChillCalendarBundle/DependencyInjection/Configuration.php +++ b/src/Bundle/ChillCalendarBundle/DependencyInjection/Configuration.php @@ -32,9 +32,10 @@ class Configuration implements ConfigurationInterface ->canBeDisabled() ->children()->end() ->end() // end for short_messages + ->scalarNode('remote_calendar_dsn')->defaultValue('null://null')->cannotBeEmpty()->end() ->arrayNode('remote_calendars_sync')->canBeEnabled() ->children() - ->arrayNode('microsoft_graph')->canBeEnabled() + ->arrayNode('microsoft_graph')->canBeEnabled()->setDeprecated('chill-project/chill-bundles', '4.7.0', 'The child node %node% at path %path% is deprecated: use remote_calendar_dsn instead, with a "msgraph://default" value') ->children() ->end() // end of machine_access_token ->end() // end of microsoft_graph children diff --git a/src/Bundle/ChillCalendarBundle/Entity/CalendarRange.php b/src/Bundle/ChillCalendarBundle/Entity/CalendarRange.php index 12f3ed7e7..34cd5623a 100644 --- a/src/Bundle/ChillCalendarBundle/Entity/CalendarRange.php +++ b/src/Bundle/ChillCalendarBundle/Entity/CalendarRange.php @@ -107,6 +107,11 @@ class CalendarRange implements TrackCreationInterface, TrackUpdateInterface return $this; } + public function hasLocation(): bool + { + return null !== $this->location; + } + public function setLocation(?Location $location): self { $this->location = $location; diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Handler/CalendarRemoveHandler.php b/src/Bundle/ChillCalendarBundle/Messenger/Handler/CalendarRemoveHandler.php index 73e8a0c37..9b3a054a0 100644 --- a/src/Bundle/ChillCalendarBundle/Messenger/Handler/CalendarRemoveHandler.php +++ b/src/Bundle/ChillCalendarBundle/Messenger/Handler/CalendarRemoveHandler.php @@ -22,6 +22,7 @@ use Chill\CalendarBundle\Messenger\Message\CalendarRemovedMessage; use Chill\CalendarBundle\RemoteCalendar\Connector\RemoteCalendarConnectorInterface; use Chill\CalendarBundle\Repository\CalendarRangeRepository; use Chill\MainBundle\Repository\UserRepositoryInterface; +use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Messenger\Handler\MessageHandlerInterface; /** @@ -31,7 +32,12 @@ use Symfony\Component\Messenger\Handler\MessageHandlerInterface; */ class CalendarRemoveHandler implements MessageHandlerInterface { - public function __construct(private readonly RemoteCalendarConnectorInterface $remoteCalendarConnector, private readonly CalendarRangeRepository $calendarRangeRepository, private readonly UserRepositoryInterface $userRepository) {} + public function __construct( + private readonly RemoteCalendarConnectorInterface $remoteCalendarConnector, + private readonly CalendarRangeRepository $calendarRangeRepository, + private readonly UserRepositoryInterface $userRepository, + private readonly EntityManagerInterface $entityManager, + ) {} public function __invoke(CalendarRemovedMessage $message) { @@ -47,5 +53,7 @@ class CalendarRemoveHandler implements MessageHandlerInterface $this->userRepository->find($message->getCalendarUserId()), $associatedRange ); + + $this->entityManager->flush(); } } diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/RemoteCalendarConnectorInterface.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/RemoteCalendarConnectorInterface.php index 1e3c16845..644d22ae4 100644 --- a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/RemoteCalendarConnectorInterface.php +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/RemoteCalendarConnectorInterface.php @@ -21,42 +21,137 @@ namespace Chill\CalendarBundle\RemoteCalendar\Connector; use Chill\CalendarBundle\Entity\Calendar; use Chill\CalendarBundle\Entity\CalendarRange; use Chill\CalendarBundle\Entity\Invite; +use Chill\CalendarBundle\Messenger\Message\CalendarMessage; use Chill\CalendarBundle\RemoteCalendar\Model\RemoteEvent; use Chill\MainBundle\Entity\User; use Symfony\Component\HttpFoundation\Response; +/** + * Contract for connectors that synchronize Chill calendars with a remote + * calendar provider (for example Microsoft 365/Graph, Zimbra, ...). + * + * Implementations act as an adapter between Chill domain objects + * (Calendar, CalendarRange, Invite) and the remote provider API. They must: + * - expose a readiness flow for per-user authorization when applicable + * (see {@see getMakeReadyResponse()} and {@see isReady()}); + * - list and count remote events in a time range for a given user; + * - mirror local lifecycle changes to the remote provider for calendars, + * calendar ranges (availability/busy blocks) and invites/attendees. + * + * Use {@see MSGraphRemoteCalendarConnector} as a reference implementation for + * expected behaviours, error handling and parameter semantics. + */ interface RemoteCalendarConnectorInterface { public function countEventsForUser(User $user, \DateTimeImmutable $startDate, \DateTimeImmutable $endDate): int; /** - * Return a response, more probably a RedirectResponse, where the user - * will be able to fullfill requirements to prepare this connector and - * make it ready. + * Returns a Response (typically a RedirectResponse) that lets the current + * user perform the steps required to make the connector usable (for + * example, OAuth consent or account linking). After completion, the user + * should be redirected back to the given path. */ public function getMakeReadyResponse(string $returnPath): Response; /** - * Return true if the connector is ready to act as a proxy for reading - * remote calendars. + * Returns true when the connector is ready to access the remote provider + * on behalf of the current user (e.g. required tokens/consent exist). */ public function isReady(): bool; /** + * Lists events from the remote provider for the given user and time range. + * + * Implementations should map provider-specific payloads to instances of + * {@see RemoteEvent}. + * * @return array|RemoteEvent[] */ public function listEventsForUser(User $user, \DateTimeImmutable $startDate, \DateTimeImmutable $endDate, ?int $offset = 0, ?int $limit = 50): array; + /** + * Removes a calendar (single event) from the remote provider. + * + * **Note**: calendar (single event) which are canceled will appears in this + * method, and not in syncCalendar method. + * + * Parameters: + * - remoteId: the provider identifier of the remote event to delete. If + * empty, implementations should no-op. + * - remoteAttributes: provider-specific metadata previously stored with the + * local entity (e.g. change keys, etags) that can help perform safe + * concurrency checks when deleting. Implementations may ignore unknown + * keys. + * - user: the user in whose remote calendar the event lives and on whose + * behalf the deletion must be performed. + * - associatedCalendarRange: when provided, the implementation should + * update/synchronize the corresponding remote busy-time block after the + * event removal so that availability stays consistent. + */ public function removeCalendar(string $remoteId, array $remoteAttributes, User $user, ?CalendarRange $associatedCalendarRange = null): void; + /** + * Removes a remote busy-time block (calendar range) identified by + * provider-specific id and attributes for the given user. + * + * Implementations should no-op if the id is empty. + */ public function removeCalendarRange(string $remoteId, array $remoteAttributes, User $user): void; /** + * Synchronizes a Calendar entity to the remote provider. + * + * Typical cases to support (see MSGraph implementation): + * - Creating the event on the remote calendar when it has no remote id. + * - Updating the existing remote event when details or attendees change. + * - Handling main user changes: cancel on the previous user's calendar, + * (re)create associated ranges where needed, then create on the new + * main user's calendar. + * - If the Calendar uses a CalendarRange that already exists remotely, + * implementations should remove/update that remote range when the event + * becomes the source of truth for busy times. + * + * The implementation should not expects to receive calendar which are canceled + * here. + * + * Parameters: + * - calendar: the domain Calendar to mirror remotely. + * - action: a hint about what triggered the sync; implementations should not rely + * solely on this value and must base decisions on the Calendar state. + * - previousCalendarRange: if the Calendar was previously attached to a + * different range, this contains the former range so it can be recreated + * remotely to preserve availability history when applicable. + * - previousMainUser: the former main user, when the main user changed; + * used to cancel the event in the previous user's calendar. + * - oldInvites: the attendee snapshot before the change. Each item is an + * array with keys: inviteId, userId, userEmail, userLabel. + * - newInvites: the attendee snapshot after the change, same shape as + * oldInvites. Implementations can compute diffs to add/remove attendees. + * + * The $action argument is a string tag indicating what happened to the + * calendar. It MUST be one of the constants defined on + * {@see CalendarMessage}: + * - {@see CalendarMessage::CALENDAR_PERSIST} + * - {@see CalendarMessage::CALENDAR_UPDATE} + * * @param array $oldInvites + * + * @phpstan-param (CalendarMessage::CALENDAR_PERSIST|CalendarMessage::CALENDAR_UPDATE) $action */ public function syncCalendar(Calendar $calendar, string $action, ?CalendarRange $previousCalendarRange, ?User $previousMainUser, ?array $oldInvites, ?array $newInvites): void; + /** + * Creates or updates a remote busy-time block representing the provided + * CalendarRange. If the range has a remote id, it should be updated; + * otherwise it should be created remotely, and the range enriched with + * the new id/attributes by the caller. + */ public function syncCalendarRange(CalendarRange $calendarRange): void; + /** + * Synchronizes a single Invite (attendee) change to the remote provider. + * Implementations may need to lookup the attendee's personal calendar to + * find provider-specific identifiers before patching the main event. + */ public function syncInvite(Invite $invite): void; } diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/DependencyInjection/RemoteCalendarCompilerPass.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/DependencyInjection/RemoteCalendarCompilerPass.php index c52fc2866..3fb82ac6a 100644 --- a/src/Bundle/ChillCalendarBundle/RemoteCalendar/DependencyInjection/RemoteCalendarCompilerPass.php +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/DependencyInjection/RemoteCalendarCompilerPass.php @@ -35,25 +35,46 @@ use TheNetworg\OAuth2\Client\Provider\Azure; class RemoteCalendarCompilerPass implements CompilerPassInterface { + private const ZIMBRA_CONNECTOR = 'Chill\ZimbraBundle\Calendar\Connector\ZimbraConnector'; + + private const MS_GRAPH_SERVICES_TO_REMOVE = [ + MapAndSubscribeUserCalendarCommand::class, + AzureGrantAdminConsentAndAcquireToken::class, + RemoteCalendarConnectAzureController::class, + MachineTokenStorage::class, + MachineHttpClient::class, + MSGraphRemoteCalendarConnector::class, + MSUserAbsenceReaderInterface::class, + MSUserAbsenceSync::class, + ]; + public function process(ContainerBuilder $container) { - $config = $container->getParameter('chill_calendar'); + $config = $container->getParameter('chill_calendar.remote_calendar_dsn'); + if (true === $container->getParameter('chill_calendar')['remote_calendars_sync']['microsoft_graph']['enabled']) { + $dsn = 'msgraph://default'; + } else { + $dsn = $config; + } - if (true === $config['remote_calendars_sync']['microsoft_graph']['enabled']) { + $scheme = parse_url($dsn, PHP_URL_SCHEME); + + if ('msgraph' === $scheme) { $connector = MSGraphRemoteCalendarConnector::class; $container->setAlias(HttpClientInterface::class.' $machineHttpClient', MachineHttpClient::class); - } else { + } elseif ('zimbra+http' === $scheme || 'zimbra+https' === $scheme) { + $connector = self::ZIMBRA_CONNECTOR; + foreach (self::MS_GRAPH_SERVICES_TO_REMOVE as $serviceId) { + $container->removeDefinition($serviceId); + } + } elseif ('null' === $scheme) { $connector = NullRemoteCalendarConnector::class; - // remove services which cannot be loaded - $container->removeDefinition(MapAndSubscribeUserCalendarCommand::class); - $container->removeDefinition(AzureGrantAdminConsentAndAcquireToken::class); - $container->removeDefinition(RemoteCalendarConnectAzureController::class); - $container->removeDefinition(MachineTokenStorage::class); - $container->removeDefinition(MachineHttpClient::class); - $container->removeDefinition(MSGraphRemoteCalendarConnector::class); - $container->removeDefinition(MSUserAbsenceReaderInterface::class); - $container->removeDefinition(MSUserAbsenceSync::class); + foreach (self::MS_GRAPH_SERVICES_TO_REMOVE as $serviceId) { + $container->removeDefinition($serviceId); + } + } else { + throw new \Symfony\Component\DependencyInjection\Exception\InvalidArgumentException('Unsupported remote calendar scheme: '.$scheme); } if (!$container->hasAlias(Azure::class) && $container->hasDefinition('knpu.oauth2.client.azure')) { @@ -62,7 +83,9 @@ class RemoteCalendarCompilerPass implements CompilerPassInterface foreach ([ NullRemoteCalendarConnector::class, - MSGraphRemoteCalendarConnector::class, ] as $serviceId) { + MSGraphRemoteCalendarConnector::class, + self::ZIMBRA_CONNECTOR, + ] as $serviceId) { if ($connector === $serviceId) { $container->getDefinition($serviceId) ->setDecoratedService(RemoteCalendarConnectorInterface::class); diff --git a/src/Bundle/ChillCalendarBundle/translations/messages.fr.yml b/src/Bundle/ChillCalendarBundle/translations/messages.fr.yml index 8717dcd9f..b4bdd0679 100644 --- a/src/Bundle/ChillCalendarBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillCalendarBundle/translations/messages.fr.yml @@ -87,6 +87,8 @@ remote_ms_graph: remote_calendar: calendar_range_title: Plage de disponibilité Chill + # small type-hint in remote calendar to says that the appointment is created through an invitation, and not as main referrer + calendar_invite_statement_in_calendar: Par invitation invite: accepted: Accepté diff --git a/src/Bundle/ChillMainBundle/Templating/Entity/AddressRender.php b/src/Bundle/ChillMainBundle/Templating/Entity/AddressRender.php index f59b1cd66..33ce83a83 100644 --- a/src/Bundle/ChillMainBundle/Templating/Entity/AddressRender.php +++ b/src/Bundle/ChillMainBundle/Templating/Entity/AddressRender.php @@ -26,6 +26,7 @@ class AddressRender implements ChillEntityRenderInterface 'with_delimiter' => false, 'has_no_address' => false, 'multiline' => true, + 'separator' => ' — ', /* deprecated */ 'extended_infos' => false, ]; @@ -114,7 +115,9 @@ class AddressRender implements ChillEntityRenderInterface public function renderString($addr, array $options): string { - return implode(' — ', $this->renderLines($addr)); + $opts = [...self::DEFAULT_OPTIONS, ...$options]; + + return implode($opts['separator'], $this->renderLines($addr)); } public function supports($entity, array $options): bool diff --git a/src/Bundle/ChillMainBundle/Templating/Entity/CommentRender.php b/src/Bundle/ChillMainBundle/Templating/Entity/CommentRender.php index a4d0f48e6..9a808da77 100644 --- a/src/Bundle/ChillMainBundle/Templating/Entity/CommentRender.php +++ b/src/Bundle/ChillMainBundle/Templating/Entity/CommentRender.php @@ -52,7 +52,7 @@ class CommentRender implements ChillEntityRenderInterface public function renderString($entity, array $options): string { - return $entity->getComment(); + return (string) $entity->getComment(); } public function supports($entity, array $options): bool diff --git a/symfony.lock b/symfony.lock index 6409f0c1d..0feff9ebd 100644 --- a/symfony.lock +++ b/symfony.lock @@ -2,6 +2,9 @@ "champs-libres/wopi-bundle": { "version": "dev-master" }, + "chill-project/chill-zimbra-bundle": { + "version": "dev-472-zimbra-connector" + }, "doctrine/annotations": { "version": "1.14", "recipe": {