diff --git a/.editorconfig b/.editorconfig index d51908caf..a3e5a0fc1 100644 --- a/.editorconfig +++ b/.editorconfig @@ -18,10 +18,8 @@ max_line_length = 80 [COMMIT_EDITMSG] max_line_length = 0 -<<<<<<< Updated upstream -======= [*.{js, vue, ts}] indent_size = 2 indent_style = space ->>>>>>> Stashed changes + diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d9a0da1c..2ed9cdebf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,6 @@ and this project adheres to * [person-thirdparty]: fix quick-add of names that consist of multiple parts (eg. De Vlieger) within onthefly modal person/thirdparty * [search]: Order of birthdate fields changed in advanced search to avoid confusion. * [workflow]: Constraint added to workflow (https://gitlab.com/champs-libres/departement-de-la-vendee/accent-suivi-developpement/-/issues/675) -* [action]: Agents traitants should be prefilled with referrer of the parcours or left empty if there is no referrer (https://gitlab.com/champs-libres/departement-de-la-vendee/accent-suivi-developpement/-/issues/696) ## Test releases diff --git a/composer.json b/composer.json index b815e58b0..45d7ca832 100644 --- a/composer.json +++ b/composer.json @@ -19,10 +19,12 @@ "graylog2/gelf-php": "^1.5", "knplabs/knp-menu-bundle": "^3.0", "knplabs/knp-time-bundle": "^1.12", + "knpuniversity/oauth2-client-bundle": "^2.10", "league/csv": "^9.7.1", "nyholm/psr7": "^1.4", "ocramius/package-versions": "^1.10 || ^2", "odolbeau/phone-number-bundle": "^3.6", + "ovh/ovh": "^3.0", "phpoffice/phpspreadsheet": "^1.16", "ramsey/uuid-doctrine": "^1.7", "sensio/framework-extra-bundle": "^5.5", @@ -36,6 +38,7 @@ "symfony/http-foundation": "^4.4", "symfony/intl": "^4.4", "symfony/mailer": "^5.4", + "symfony/messenger": "^5.4", "symfony/mime": "^5.4", "symfony/monolog-bundle": "^3.5", "symfony/security-bundle": "^4.4", @@ -49,6 +52,7 @@ "symfony/webpack-encore-bundle": "^1.11", "symfony/workflow": "^4.4", "symfony/yaml": "^4.4", + "thenetworg/oauth2-azure": "^2.0", "twig/extra-bundle": "^3.0", "twig/intl-extra": "^3.0", "twig/markdown-extra": "^3.3", @@ -96,7 +100,8 @@ "autoload-dev": { "psr-4": { "App\\": "tests/app/src/", - "Chill\\DocGeneratorBundle\\Tests\\": "src/Bundle/ChillDocGeneratorBundle/tests" + "Chill\\DocGeneratorBundle\\Tests\\": "src/Bundle/ChillDocGeneratorBundle/tests", + "Chill\\WopiBundle\\Tests\\": "src/Bundle/ChillDocGeneratorBundle/tests" } }, "config": { diff --git a/docs/source/installation/index.rst b/docs/source/installation/index.rst index 1312480e3..8acfc6174 100644 --- a/docs/source/installation/index.rst +++ b/docs/source/installation/index.rst @@ -14,6 +14,13 @@ Installation & Usage #################### +.. toctree:: + :maxdepth: 2 + + prod.rst + prod-calendar-sms-sending.rst + msgraph-configure.rst + Requirements ************ @@ -38,13 +45,36 @@ Clone or download the chill-app project and `cd` into the main directory. As a developer, the code will stay on your computer and will be executed in docker container. To avoid permission problem, the code should be run with the same uid/gid from your current user. This is why we get your current user id with the command ``id -u`` in each following scripts. -2. Prepare your variables -========================= +2. Prepare composer download of sources +======================================= -Have a look at the variable in ``.env.dist`` and in ``app/config/parameters.yml.dist`` and check if you need to adapt them. If they do not adapt with your need, or if some are missing: +As you are running in dev, you must configure an auth token for getting the source code. -1. copy the file as ``.env``: ``cp .env.dist .env`` -2. you may replace some variables inside ``.env`` +.. warning + + If you skip this part, the code will be downloaded from dist instead of source (with git repository). You will probably replace the source manually, but the next time you will run ```composer update```, your repository will be replaced and you might loose something. + +1. Create a personal access token from https://gitlab.com/-/profile/personal_access_tokens, with the `read_api` scope. +2. add a file called ```.composer/auth.json```: + + .. code-block:: json + + { + "gitlab-token": { + "gitlab.com": "glXXX-XXXXXXXXXXXXXXXXXXXX" + } + } + +2. Prepare your variables and environment +========================================= + +Copy ```docker-compose.override.dev.yml``` into ```docker-compose.override.yml``` + +.. code-block:: bash + + cp docker-compose.override.dev.template.yml docker-compose.override.yml + +Configure your environment variables, by creating a .env.local file and override the desired variables. **Note**: If you intend to use the bundle ``Chill-Doc-Store``, you will need to configure and install an openstack object storage container with temporary url middleware. You will have to configure `secret keys `_. @@ -65,6 +95,17 @@ This script will : 4. build assets +.. note:: + + In some cases it can happen that an old image (chill_base_php or chill_php) stored in the docker cache will make the script fail. To solve this problem you have to delete the image and the container, before the make init : + + .. code-block:: bash + + docker-compose images php + docker rmi -f chill_php:prod + docker-compose rm php + + 4. Start the project ==================== diff --git a/docs/source/installation/msgraph-configure.rst b/docs/source/installation/msgraph-configure.rst new file mode 100644 index 000000000..2a0a17882 --- /dev/null +++ b/docs/source/installation/msgraph-configure.rst @@ -0,0 +1,320 @@ + +Configure Chill for calendar sync and SSO with Microsoft Graph (Outlook) +======================================================================== + +Chill offers the possibility to: + +* authenticate users using Microsoft Graph, with relatively small adaptations; +* synchronize calendar in both ways (`see the user manual for a large description of the feature `_). + +Both can be configured separately (synchronising calendars without SSO, or SSO without calendar). When calendar sync is configured without SSL, the user's email address is the key to associate Chill's users with Microsoft's ones. + +Configure SSO +------------- + +On Azure side +************* + +Configure an app with the Azure interface, and give it the name of your choice. + +Grab the tenant's ID for your app, which is visible on the main tab "Vue d'ensemble": + +.. figure:: ./saml_login_id_general.png + +This the variable which will be named :code:`SAML_IDP_APP_UUID`. + +Go to the "Single sign-on" ("Authentication unique") section. Choose "SAML" as protocol, and fill those values: + +.. figure:: ./saml_login_1.png + +1. The :code:`entityId` seems to be arbitrary. This will be your variable :code:`SAML_ENTITY_ID`; +2. The url response must be your Chill's URL appended by :code:`/saml/acs` +3. The only used attributes is :code:`emailaddress`, which must match the user's email one. + +.. figure:: ./saml_login_2.png + +You must download the certificate, as base64. The format for the download is :code:`cer`: you will remove the first and last line (the ones with :code:`-----BEGIN CERTIFICATE-----` and :code:`-----END CERTIFICATE-----`), and remove all the return line. The final result should be something as :code:`MIIAbcdef...XyZA=`. + +This certificat will be your :code:`SAML_IDP_X509_CERT` variable. + +The url login will be filled automatically with your tenant id. + +Do not forget to provider user's accesses to your app, using the "Utilisateurs et groupes" tab: + +.. figure:: ./saml_login_appro.png + + +You must know have gathered all the required variables for SSO: + +.. code-block:: + + SAML_BASE_URL=https://test.chill.be # must be + SAML_ENTITY_ID=https://test.chill.be # must match the one entered + SAML_IDP_APP_UUID=42XXXXXX-xxxx-xxxx-xxxx-xxxxxxxxxxxx + SAML_IDP_X509_CERT: MIIC...E8u3bk # truncated + +Configure chill app +******************* + +* add the bundle :code:`hslavich/oneloginsaml-bundle` +* add the configuration file (see example above) +* configure the security part (see example above) +* add a user SAML factory into your src, and register it + + +.. code-block:: yaml + + # config/packages/hslavich_onelogin.yaml + + parameters: + saml_base_url: '%env(resolve:SAML_BASE_URL)%' + saml_entity_id: '%env(resolve:SAML_ENTITY_ID)%' + saml_idp_x509cert: '%env(resolve:SAML_IDP_X509_CERT)%' + saml_idp_app_uuid: '%env(resolve:SAML_IDP_APP_UUID)%' + + + hslavich_onelogin_saml: + # Basic settings + idp: + entityId: 'https://sts.windows.net/%saml_idp_app_uuid%/' + singleSignOnService: + url: 'https://login.microsoftonline.com/%saml_idp_app_uuid%/saml2' + binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' + singleLogoutService: + url: 'https://login.microsoftonline.com/%saml_idp_app_uuid%/saml2' + binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' + x509cert: '%saml_idp_x509cert%' + sp: + entityId: '%saml_entity_id%' + assertionConsumerService: + url: '%saml_base_url%/saml/acs' + binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' + singleLogoutService: + url: '%saml_base_url%/saml/' + binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' + privateKey: '' + # Optional settings. + baseurl: '%saml_base_url%/saml' + strict: true + debug: true + security: + nameIdEncrypted: false + authnRequestsSigned: false + logoutRequestSigned: false + logoutResponseSigned: false + wantMessagesSigned: false + wantAssertionsSigned: false + wantNameIdEncrypted: false + requestedAuthnContext: true + signMetadata: false + wantXMLValidation: true + signatureAlgorithm: 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256' + digestAlgorithm: 'http://www.w3.org/2001/04/xmlenc#sha256' + contactPerson: + technical: + givenName: 'Tech User' + emailAddress: 'techuser@example.com' + support: + givenName: 'Support User' + emailAddress: 'supportuser@example.com' + organization: + en: + name: 'Example' + displayname: 'Example' + url: 'http://example.com' + + +.. code-block:: yaml + + # config/security.yaml + # merge this with other existing configurations + + security: + + + providers: + saml_provider: + # Loads user from user repository + entity: + class: Chill\MainBundle\Entity\User + property: username + + firewalls: + + + default: + # saml part: + saml: + username_attribute: http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress + # weird behaviour in dev environment... configuration seems different + # username_attribute: http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name + # Use the attribute's friendlyName instead of the name + use_attribute_friendly_name: false + user_factory: user_from_saml_factory + persist_user: true + check_path: saml_acs + login_path: saml_login + logout: + path: /saml/logout + + +.. code-block:: php + + // src/Security/SamlFactory.php + + namespace App\Security; + + use Chill\MainBundle\Entity\User; + use Hslavich\OneloginSamlBundle\Security\Authentication\Token\SamlTokenInterface; + use Hslavich\OneloginSamlBundle\Security\User\SamlUserFactoryInterface; + + class UserSamlFactory implements SamlUserFactoryInterface + { + public function createUser(SamlTokenInterface $token) + { + $attributes = $token->getAttributes(); + $user = new User(); + $user->setUsername($attributes['http://schemas.microsoft.com/identity/claims/displayname'][0]); + $user->setLabel($attributes['http://schemas.microsoft.com/identity/claims/displayname'][0]); + $user->setPassword(''); + $user->setEmail($attributes['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'][0]); + $user->setAttributes($attributes); + + return $user; + } + } + + + +Configure sync +-------------- + +The sync processe might be configured in the same app, or into a different app. + +The synchronization processes use Oauth2.0 for authentication and authorization. + +.. note:: + + Two flows are in use: + + * we authenticate "on behalf of a user", to allow users to see their own calendar or other user's calendar into the web interface. + + Typically, when the page is loaded, Chill first check that an authorization token exists. If not, the user is redirected to Microsoft Azure for authentification and a new token is grabbed (most of the times, this is transparent for users). + + * Chill also acts "as a machine", to synchronize calendars with a daemon background. + +One can access the configuration using this screen (it is quite well hidden into the multiple of tabs): + +.. figure:: ./oauth_app_registration.png + + You can find the oauth configuration on the "Securité > Autorisations" tab, and click on "application registration" (not translated). + +Add a redirection URI for you authentification: + +.. figure:: ./oauth_api_authentification.png + + The URI must be "your chill public url" with :code:`/connect/azure/check` at the end. + +Allow some authorizations for your app: + +.. figure:: ./oauth_api_autorisees.png + +Take care of the separation between autorization "on behalf of a user" (déléguée), or "for a machine" (application). + +Some explanation: + +* Users must be allowed to read their user profile (:code:`User.Read`), and the profile of other users (:code:`User.ReadBasicAll`); +* They must be allowed to read their calendar (:code:`Calendars.Read`), and the calendars shared with them (:code:`Calendars.Read.Shared`); + +The sync daemon must have write access: + +* the daemon must be allowed to read all users and their profile, to establish a link between them and the Chill's users: (:code:`Users.Read.All`); +* it must also be allowed to read and write into the calendars (:code:`Calendars.ReadWrite.All`) +* for sending invitation to other users, the permission (:code:`Mail.Send`) must be granted. + +At this step, you might choose to accept those permissions for all users, or let them do it by yourself. + +Grab your client id: + +.. figure:: ./oauth_api_client_id.png + +This will be your :code:`OAUTH_AZURE_CLIENT_ID` variable. + + +Generate a secret: + +.. figure:: ./oauth_api_secret.png + +This will be your :code:`OAUTH_AZURE_CLIENT_SECRET` variable. + +And get you azure's tenant id, which is the same as the :code:`SAML_IDP_APP_UUID` (see above). + +Your variables will be: + +.. code-block:: + + OAUTH_AZURE_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + OAUTH_AZURE_CLIENT_TENANT=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + OAUTH_AZURE_CLIENT_SECRET: 3-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +Then, configure chill: + +Enable the calendar sync with microsoft azure: + +.. code-block:: yaml + + # config/packages/chill_calendar.yaml + + chill_calendar: + remote_calendars_sync: + microsoft_graph: + enabled: true + +and configure the oauth client: + +.. code-block:: yaml + + # config/packages/knp_oauth2_client.yaml + knpu_oauth2_client: + clients: + azure: + type: azure + client_id: '%env(OAUTH_AZURE_CLIENT_ID)%' + client_secret: '%env(OAUTH_AZURE_CLIENT_SECRET)%' + redirect_route: chill_calendar_remote_connect_azure_check + redirect_params: { } + tenant: '%env(OAUTH_AZURE_CLIENT_TENANT)%' + url_api: 'https://graph.microsoft.com/' + default_end_point_version: '2.0' + + +You can now process for the first api authorization on the application side, (unless you did it in the Azure interface), and get a first token, by using : + +:code:`bin/console chill:calendar:msgraph-grant-admin-consent` + +This will generate a url that you can use to grant your app for your tenant. The redirection may fails in the browser, but this is not relevant: if you get an authorization token in the CLI, the authentication works. + +Run the processes to synchronize +-------------------------------- + +The calendar synchronization is processed using symfony messenger. It seems to be intersting to configure a queue (in the postgresql database it is the most simple way), and to run a worker for synchronization, at least in production. + +The association between chill's users and Microsoft's users is done by this cli command: + +.. code-block:: + + bin/console chill:calendar:msgraph-user-map-subscribe + +This command: + +* will associate the Microsoft's user metadata in our database; +* and, most important, create a subscription to get notification when the user alter his calendar, to sync chill's event and ranges in sync. + +The subscription least at most 3 days. This command should be runned: + +* at least each time a user is added; +* and, at least, every three days. + +In production, we advise to run it at least every day to get the sync working. + + diff --git a/docs/source/installation/oauth_api_authentification.png b/docs/source/installation/oauth_api_authentification.png new file mode 100644 index 000000000..f6ecad32b Binary files /dev/null and b/docs/source/installation/oauth_api_authentification.png differ diff --git a/docs/source/installation/oauth_api_autorisees.png b/docs/source/installation/oauth_api_autorisees.png new file mode 100644 index 000000000..41222f971 Binary files /dev/null and b/docs/source/installation/oauth_api_autorisees.png differ diff --git a/docs/source/installation/oauth_api_client_id.png b/docs/source/installation/oauth_api_client_id.png new file mode 100644 index 000000000..7aa620ba7 Binary files /dev/null and b/docs/source/installation/oauth_api_client_id.png differ diff --git a/docs/source/installation/oauth_api_secret.png b/docs/source/installation/oauth_api_secret.png new file mode 100644 index 000000000..7e2fc1c28 Binary files /dev/null and b/docs/source/installation/oauth_api_secret.png differ diff --git a/docs/source/installation/oauth_app_registration.png b/docs/source/installation/oauth_app_registration.png new file mode 100644 index 000000000..757d34f00 Binary files /dev/null and b/docs/source/installation/oauth_app_registration.png differ diff --git a/docs/source/installation/prod-calendar-sms-sending.rst b/docs/source/installation/prod-calendar-sms-sending.rst new file mode 100644 index 000000000..6529b95d9 --- /dev/null +++ b/docs/source/installation/prod-calendar-sms-sending.rst @@ -0,0 +1,27 @@ + +Send short messages (SMS) with calendar bundle +============================================== + +To activate the sending of messages, you should run this command on a regularly basis (using, for instance, a cronjob): + +.. code-block:: bash + + bin/console chill:calendar:send-short-messages + +A transporter must be configured for the message to be effectively sent. + +Configure OVH Transporter +------------------------- + +Currently, this is the only one transporter available. + +For configuring this, simply add this config variable in your environment: + +```env +SHORT_MESSAGE_DSN=ovh://applicationKey:applicationSecret@endpoint?consumerKey=xxxx&sender=yyyy&service_name=zzzz +``` + +In order to generate the application key, secret, and consumerKey, refers to their `documentation `_. + +Before to be able to send your first sms, you must enable your account, grab some credits, and configure a sender. The service_name is an internal configuration generated by OVH. + diff --git a/docs/source/installation/prod.rst b/docs/source/installation/prod.rst new file mode 100644 index 000000000..4e670b8f8 --- /dev/null +++ b/docs/source/installation/prod.rst @@ -0,0 +1,48 @@ +.. Copyright (C) 2014-2019 Champs Libres Cooperative SCRLFS +Permission is granted to copy, distribute and/or modify this document +under the terms of the GNU Free Documentation License, Version 1.3 +or any later version published by the Free Software Foundation; +with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. +A copy of the license is included in the section entitled "GNU +Free Documentation License". + +Installation for production +########################### + +An installation use these services, which are deployed using docker containers: + +* a php-fpm image, which run the Php and Symfony code for Chill; +* a nginx image, which serves the assets, and usually proxy the php requests to the fpm image; +* a redis server, which stores the cache, sessions (this is currently hardcoded in the php image), and some useful keys (like wopi locks); +* a postgresql database. The use of postgresql is mandatory; +* a relatorio service, which transform odt templates to full documents (replacing the placeholders); + +Some external services: + +* (required) an openstack object store, configured with `temporary url ` configured (no openstack users is required). This is currently the only way to store documents from chill; +* a mailer service (SMTP) +* (optional) a service for verifying phone number. Currently, only Twilio is possible; +* (optional) a service for sending Short Messages (SMS). Currently, only Ovh is possible; + +The `docker-compose.yaml` file of chill app is a basis for a production install. The environment variable in the ```.env``` and ```.env.prod``` should be overriden by environment variables, or ```.env.local``` files. + +This should be adapted to your needs: + +* The image for php and nginx apps are pre-compiled images, with the default configuration and bundle. If they do not fullfill your needs, you should compile your own images. + + .. TODO: + + As the time of writing (2022-07-03) those images are not published yet. + +* Think about how you will backup your database. Some adminsys find easier to store database outside of docker, which might be easier to administrate or replicate. + +Tweak symfony messenger +======================= + +Calendar sync is processed using symfony messenger. + +You can tweak the configuration + +Going further: + +* Configure the saml login and synchronisation with Outlook api diff --git a/docs/source/installation/saml_login_1.png b/docs/source/installation/saml_login_1.png new file mode 100644 index 000000000..ec62a7acb Binary files /dev/null and b/docs/source/installation/saml_login_1.png differ diff --git a/docs/source/installation/saml_login_2.png b/docs/source/installation/saml_login_2.png new file mode 100644 index 000000000..736b74579 Binary files /dev/null and b/docs/source/installation/saml_login_2.png differ diff --git a/docs/source/installation/saml_login_appro.png b/docs/source/installation/saml_login_appro.png new file mode 100644 index 000000000..526eff63d Binary files /dev/null and b/docs/source/installation/saml_login_appro.png differ diff --git a/docs/source/installation/saml_login_id_general.png b/docs/source/installation/saml_login_id_general.png new file mode 100644 index 000000000..3062e6515 Binary files /dev/null and b/docs/source/installation/saml_login_id_general.png differ diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 89cb80635..8f157fcc5 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -40,6 +40,9 @@ src/Bundle/ChillDocGeneratorBundle/tests/ + + src/Bundle/ChillWopiBundle/tests/ + diff --git a/src/Bundle/ChillActivityBundle/Controller/ActivityController.php b/src/Bundle/ChillActivityBundle/Controller/ActivityController.php index a89b1d67c..ddbaf0f7f 100644 --- a/src/Bundle/ChillActivityBundle/Controller/ActivityController.php +++ b/src/Bundle/ChillActivityBundle/Controller/ActivityController.php @@ -21,6 +21,7 @@ use Chill\ActivityBundle\Repository\ActivityTypeRepository; use Chill\ActivityBundle\Security\Authorization\ActivityVoter; use Chill\MainBundle\Entity\Embeddable\CommentEmbeddable; use Chill\MainBundle\Repository\LocationRepository; +use Chill\MainBundle\Repository\UserRepositoryInterface; use Chill\PersonBundle\Entity\AccompanyingPeriod; use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Privacy\PrivacyEvent; @@ -70,6 +71,8 @@ final class ActivityController extends AbstractController private ThirdPartyRepository $thirdPartyRepository; + private UserRepositoryInterface $userRepository; + public function __construct( ActivityACLAwareRepositoryInterface $activityACLAwareRepository, ActivityTypeRepository $activityTypeRepository, @@ -82,7 +85,8 @@ final class ActivityController extends AbstractController EntityManagerInterface $entityManager, EventDispatcherInterface $eventDispatcher, LoggerInterface $logger, - SerializerInterface $serializer + SerializerInterface $serializer, + UserRepositoryInterface $userRepository ) { $this->activityACLAwareRepository = $activityACLAwareRepository; $this->activityTypeRepository = $activityTypeRepository; @@ -96,6 +100,7 @@ final class ActivityController extends AbstractController $this->eventDispatcher = $eventDispatcher; $this->logger = $logger; $this->serializer = $serializer; + $this->userRepository = $userRepository; } /** @@ -366,7 +371,7 @@ final class ActivityController extends AbstractController if ($request->query->has('activityData')) { $activityData = $request->query->get('activityData'); - if (array_key_exists('durationTime', $activityData)) { + if (array_key_exists('durationTime', $activityData) && $activityType->getDurationTimeVisible() > 0) { $durationTimeInMinutes = $activityData['durationTime']; $hours = floor($durationTimeInMinutes / 60); $minutes = $durationTimeInMinutes % 60; @@ -385,26 +390,36 @@ final class ActivityController extends AbstractController } } - if (array_key_exists('personsId', $activityData)) { + if (array_key_exists('personsId', $activityData) && $activityType->getPersonsVisible() > 0) { foreach ($activityData['personsId'] as $personId) { $concernedPerson = $this->personRepository->find($personId); $entity->addPerson($concernedPerson); } } - if (array_key_exists('professionalsId', $activityData)) { + if (array_key_exists('professionalsId', $activityData) && $activityType->getThirdPartiesVisible() > 0) { foreach ($activityData['professionalsId'] as $professionalsId) { $professional = $this->thirdPartyRepository->find($professionalsId); $entity->addThirdParty($professional); } } - if (array_key_exists('location', $activityData)) { + if (array_key_exists('usersId', $activityData) && $activityType->getUsersVisible() > 0) { + foreach ($activityData['usersId'] as $userId) { + $user = $this->userRepository->find($userId); + + if (null !== $user) { + $entity->addUser($user); + } + } + } + + if (array_key_exists('location', $activityData) && $activityType->getLocationVisible() > 0) { $location = $this->locationRepository->find($activityData['location']); $entity->setLocation($location); } - if (array_key_exists('comment', $activityData)) { + if (array_key_exists('comment', $activityData) && $activityType->getCommentVisible() > 0) { $comment = new CommentEmbeddable(); $comment->setComment($activityData['comment']); $comment->setUserId($this->getUser()->getid()); diff --git a/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/api.js b/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/api.js index edc0a616c..a520f22c4 100644 --- a/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/api.js +++ b/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/api.js @@ -17,7 +17,7 @@ const getLocations = () => fetchResults('/api/1.0/main/location.json'); const getLocationTypes = () => fetchResults('/api/1.0/main/location-type.json'); -const getUserCurrentLocation = +const getUserCurrentLocation = () => fetch('/api/1.0/main/user-current-location.json') .then(response => { if (response.ok) { return response.json(); } @@ -35,6 +35,13 @@ const getLocationTypeByDefaultFor = (entity) => { ); }; +/** + * Post a location + * + * **NOTE**: also in use for Calendar + * @param body + * @returns {Promise} + */ const postLocation = (body) => { const url = `/api/1.0/main/location.json`; return fetch(url, { diff --git a/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/store.locations.js b/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/store.locations.js index 6125140a5..056dc129d 100644 --- a/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/store.locations.js +++ b/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/store.locations.js @@ -55,7 +55,7 @@ const makeAccompanyingPeriodLocation = (locationType, store) => { export default function prepareLocations(store) { -// find the locations + // find the locations let allLocations = getLocations().then( (results) => { store.commit('addAvailableLocationGroup', { @@ -111,7 +111,7 @@ export default function prepareLocations(store) { if (window.default_location_id) { for (let group of store.state.availableLocations) { let location = group.locations.find((l) => l.id === window.default_location_id); - if (location !== undefined & store.state.activity.location === null) { + if (location !== undefined && store.state.activity.location === null) { store.dispatch('updateLocation', location); break; } diff --git a/src/Bundle/ChillActivityBundle/Resources/views/Activity/concernedGroups.html.twig b/src/Bundle/ChillActivityBundle/Resources/views/Activity/concernedGroups.html.twig index d9d72845a..9b64403b1 100644 --- a/src/Bundle/ChillActivityBundle/Resources/views/Activity/concernedGroups.html.twig +++ b/src/Bundle/ChillActivityBundle/Resources/views/Activity/concernedGroups.html.twig @@ -1,3 +1,14 @@ +{# + WARNING: this file is in use in both ActivityBundle and CalendarBundle. + + Take care when editing this file. + + Maybe should we think about abstracting this file a bit more ? Moving it to PersonBundle ? +#} +{% if context == 'calendar_accompanyingCourse' %} + {% import "@ChillCalendar/_invite.html.twig" as invite %} +{% endif %} + {% macro href(pathname, key, value) %} {% set parms = { (key): value } %} {{ path(pathname, parms) }} @@ -18,7 +29,7 @@ {% endmacro %} {% set blocks = [] %} -{% if entity.activityType.personsVisible %} +{% if context == 'calendar_accompanyingCourse' or entity.activityType.personsVisible %} {% if context == 'person' %} {% set blocks = blocks|merge([{ 'title': 'Others persons'|trans, @@ -43,7 +54,7 @@ }]) %} {% endif %} {% endif %} -{% if entity.activityType.thirdPartiesVisible %} +{% if context == 'calendar_accompanyingCourse' or entity.activityType.thirdPartiesVisible %} {% set blocks = blocks|merge([{ 'title': 'Third parties'|trans, 'items': entity.thirdParties, @@ -52,7 +63,7 @@ 'key' : 'id', }]) %} {% endif %} -{% if entity.activityType.usersVisible %} +{% if context == 'calendar_accompanyingCourse' or entity.activityType.usersVisible %} {% set blocks = blocks|merge([{ 'title': 'Users concerned'|trans, 'items': entity.users, @@ -132,6 +143,12 @@ {% if bloc.type == 'user' %} {{ item|chill_entity_render_box({'render': 'raw', 'addAltNames': false }) }} + {%- if context == 'calendar_accompanyingCourse' %} + {% set invite = entity.inviteForUser(item) %} + {% if invite is not null %} + {{ invite.invite_span(invite) }} + {% endif %} + {%- endif -%} {% else %} {{ _self.insert_onthefly(bloc.type, item) }} diff --git a/src/Bundle/ChillCalendarBundle/ChillCalendarBundle.php b/src/Bundle/ChillCalendarBundle/ChillCalendarBundle.php index 11b985224..3bf7b5e44 100644 --- a/src/Bundle/ChillCalendarBundle/ChillCalendarBundle.php +++ b/src/Bundle/ChillCalendarBundle/ChillCalendarBundle.php @@ -11,8 +11,16 @@ declare(strict_types=1); namespace Chill\CalendarBundle; +use Chill\CalendarBundle\RemoteCalendar\DependencyInjection\RemoteCalendarCompilerPass; +use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; class ChillCalendarBundle extends Bundle { + public function build(ContainerBuilder $container) + { + parent::build($container); + + $container->addCompilerPass(new RemoteCalendarCompilerPass()); + } } diff --git a/src/Bundle/ChillCalendarBundle/Command/AzureGrantAdminConsentAndAcquireToken.php b/src/Bundle/ChillCalendarBundle/Command/AzureGrantAdminConsentAndAcquireToken.php new file mode 100644 index 000000000..87a00d0b4 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Command/AzureGrantAdminConsentAndAcquireToken.php @@ -0,0 +1,77 @@ +azure = $azure; + $this->clientRegistry = $clientRegistry; + $this->machineTokenStorage = $machineTokenStorage; + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + /** @var FormatterHelper $formatter */ + $formatter = $this->getHelper('formatter'); + $this->azure->scope = ['https://graph.microsoft.com/.default']; + $authorizationUrl = explode('?', $this->azure->getAuthorizationUrl(['prompt' => 'admin_consent'])); + + // replace the first part by the admin consent authorization url + $authorizationUrl[0] = strtr('https://login.microsoftonline.com/{tenant}/adminconsent', ['{tenant}' => $this->azure->tenant]); + + $output->writeln('Go to the url'); + $output->writeln(implode('?', $authorizationUrl)); + $output->writeln('Authenticate as admin, and grant admin consent'); + + // not necessary ? + $helper = $this->getHelper('question'); + $question = new ConfirmationQuestion('Access granted ?'); + + if (!$helper->ask($input, $output, $question)) { + $messages = ['No problem, we will wait for you', 'Grant access and come back here']; + $output->writeln($formatter->formatBlock($messages, 'warning')); + + return 0; + } + + $token = $this->machineTokenStorage->getToken(); + + $messages = ['Token acquired!', 'We could acquire a machine token successfully']; + $output->writeln($formatter->formatBlock($messages, 'success')); + + $output->writeln('Token information:'); + $output->writeln($token->getToken()); + $output->writeln('Expires at: ' . $token->getExpires()); + $output->writeln('To inspect the token content, go to https://jwt.ms/#access_token=' . urlencode($token->getToken())); + + return 0; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Command/MapAndSubscribeUserCalendarCommand.php b/src/Bundle/ChillCalendarBundle/Command/MapAndSubscribeUserCalendarCommand.php new file mode 100644 index 000000000..7d9eec59a --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Command/MapAndSubscribeUserCalendarCommand.php @@ -0,0 +1,164 @@ +em = $em; + $this->eventsOnUserSubscriptionCreator = $eventsOnUserSubscriptionCreator; + $this->logger = $logger; + $this->mapCalendarToUser = $mapCalendarToUser; + $this->userRepository = $userRepository; + } + + public function execute(InputInterface $input, OutputInterface $output): int + { + $this->logger->info(__CLASS__ . ' execute command'); + + $limit = 50; + $offset = 0; + /** @var DateInterval $interval the interval before the end of the expiration */ + $interval = new DateInterval('P1D'); + $expiration = (new DateTimeImmutable('now'))->add(new DateInterval($input->getOption('subscription-duration'))); + $total = $this->userRepository->countByMostOldSubscriptionOrWithoutSubscriptionOrData($interval); + $created = 0; + $renewed = 0; + + $this->logger->info(__CLASS__ . ' the number of user to get - renew', [ + 'total' => $total, + 'expiration' => $expiration->format(DateTimeImmutable::ATOM), + ]); + + while ($offset < ($total - 1)) { + $users = $this->userRepository->findByMostOldSubscriptionOrWithoutSubscriptionOrData( + $interval, + $limit, + $offset + ); + + foreach ($users as $user) { + if (!$this->mapCalendarToUser->hasUserId($user)) { + $this->mapCalendarToUser->writeMetadata($user); + } + + if ($this->mapCalendarToUser->hasUserId($user)) { + // we first try to renew an existing subscription, if any. + // if not, or if it fails, we try to create a new one + if ($this->mapCalendarToUser->hasActiveSubscription($user)) { + $this->logger->debug(__CLASS__ . ' renew a subscription for', [ + 'userId' => $user->getId(), + 'username' => $user->getUsernameCanonical(), + ]); + + ['secret' => $secret, 'id' => $id, 'expiration' => $expirationTs] + = $this->eventsOnUserSubscriptionCreator->renewSubscriptionForUser($user, $expiration); + $this->mapCalendarToUser->writeSubscriptionMetadata($user, $expirationTs, $id, $secret); + + if (0 !== $expirationTs) { + ++$renewed; + } else { + $this->logger->warning(__CLASS__ . ' could not renew subscription for a user', [ + 'userId' => $user->getId(), + 'username' => $user->getUsernameCanonical(), + ]); + } + } + + if (!$this->mapCalendarToUser->hasActiveSubscription($user)) { + $this->logger->debug(__CLASS__ . ' create a subscription for', [ + 'userId' => $user->getId(), + 'username' => $user->getUsernameCanonical(), + ]); + + ['secret' => $secret, 'id' => $id, 'expiration' => $expirationTs] + = $this->eventsOnUserSubscriptionCreator->createSubscriptionForUser($user, $expiration); + $this->mapCalendarToUser->writeSubscriptionMetadata($user, $expirationTs, $id, $secret); + + if (0 !== $expirationTs) { + ++$created; + } else { + $this->logger->warning(__CLASS__ . ' could not create subscription for a user', [ + 'userId' => $user->getId(), + 'username' => $user->getUsernameCanonical(), + ]); + } + } + } + + ++$offset; + } + + $this->em->flush(); + $this->em->clear(); + } + + $this->logger->warning(__CLASS__ . ' process executed', [ + 'created' => $created, + 'renewed' => $renewed, + ]); + + return 0; + } + + protected function configure() + { + parent::configure(); + + $this + ->setDescription('MSGraph: collect user metadata and create subscription on events for users') + ->addOption( + 'renew-before-end-interval', + 'r', + InputOption::VALUE_OPTIONAL, + 'delay before renewing subscription', + 'P1D' + ) + ->addOption( + 'subscription-duration', + 's', + InputOption::VALUE_OPTIONAL, + 'duration for the subscription', + 'PT4230M' + ); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Command/SendShortMessageOnEligibleCalendar.php b/src/Bundle/ChillCalendarBundle/Command/SendShortMessageOnEligibleCalendar.php new file mode 100644 index 000000000..091436561 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Command/SendShortMessageOnEligibleCalendar.php @@ -0,0 +1,41 @@ +messageSender = $messageSender; + } + + public function getName() + { + return 'chill:calendar:send-short-messages'; + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $this->messageSender->sendBulkMessageToEligibleCalendars(); + + return 0; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Command/SendTestShortMessageOnCalendarCommand.php b/src/Bundle/ChillCalendarBundle/Command/SendTestShortMessageOnCalendarCommand.php new file mode 100644 index 000000000..9d79f5654 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Command/SendTestShortMessageOnCalendarCommand.php @@ -0,0 +1,200 @@ +personRepository = $personRepository; + $this->phoneNumberUtil = $phoneNumberUtil; + $this->phoneNumberHelper = $phoneNumberHelper; + $this->messageForCalendarBuilder = $messageForCalendarBuilder; + $this->transporter = $transporter; + $this->userRepository = $userRepository; + } + + public function getName() + { + return 'chill:calendar:test-send-short-message'; + } + + protected function configure() + { + $this->setDescription('Test sending a SMS for a dummy calendar appointment'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $calendar = new Calendar(); + $calendar->setSendSMS(true); + + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + + // start date + $question = new Question('When will start the appointment ? (default: "1 hour") ', '1 hour'); + $startDate = new DateTimeImmutable($helper->ask($input, $output, $question)); + + if (false === $startDate) { + throw new UnexpectedValueException('could not create a date with this date and time'); + } + + $calendar->setStartDate($startDate); + + // end date + $question = new Question('How long will last the appointment ? (default: "PT30M") ', 'PT30M'); + $interval = new DateInterval($helper->ask($input, $output, $question)); + + if (false === $interval) { + throw new UnexpectedValueException('could not create the interval'); + } + + $calendar->setEndDate($calendar->getStartDate()->add($interval)); + + // a person + $question = new Question('Who will participate ? Give an id for a person. '); + $question + ->setValidator(function ($answer): Person { + if (!is_numeric($answer)) { + throw new UnexpectedValueException('the answer must be numeric'); + } + + if (0 >= (int) $answer) { + throw new UnexpectedValueException('the answer must be greater than zero'); + } + + $person = $this->personRepository->find((int) $answer); + + if (null === $person) { + throw new UnexpectedValueException('The person is not found'); + } + + return $person; + }); + + $person = $helper->ask($input, $output, $question); + $calendar->addPerson($person); + + // a main user + $question = new Question('Who will be the main user ? Give an id for a user. '); + $question + ->setValidator(function ($answer): User { + if (!is_numeric($answer)) { + throw new UnexpectedValueException('the answer must be numeric'); + } + + if (0 >= (int) $answer) { + throw new UnexpectedValueException('the answer must be greater than zero'); + } + + $user = $this->userRepository->find((int) $answer); + + if (null === $user) { + throw new UnexpectedValueException('The user is not found'); + } + + return $user; + }); + + $user = $helper->ask($input, $output, $question); + $calendar->setMainUser($user); + + // phonenumber + $phonenumberFormatted = null !== $person->getMobilenumber() ? + $this->phoneNumberUtil->format($person->getMobilenumber(), PhoneNumberFormat::E164) : ''; + $question = new Question( + sprintf('To which number are we going to send this fake message ? (default to: %s)', $phonenumberFormatted), + $phonenumberFormatted + ); + + $question->setNormalizer(function ($answer): PhoneNumber { + if (null === $answer) { + throw new UnexpectedValueException('The person is not found'); + } + + $phone = $this->phoneNumberUtil->parse($answer, 'BE'); + + if (!$this->phoneNumberUtil->isPossibleNumberForType($phone, PhoneNumberType::MOBILE)) { + throw new UnexpectedValueException('Phone number si not a mobile'); + } + + return $phone; + }); + + $phone = $helper->ask($input, $output, $question); + + $question = new ConfirmationQuestion('really send the message to the phone ?'); + $reallySend = (bool) $helper->ask($input, $output, $question); + + $messages = $this->messageForCalendarBuilder->buildMessageForCalendar($calendar); + + if (0 === count($messages)) { + $output->writeln('no message to send to this user'); + } + + foreach ($messages as $key => $message) { + $output->writeln("The short message for SMS {$key} will be: "); + $output->writeln($message->getContent()); + $message->setPhoneNumber($phone); + + if ($reallySend) { + $this->transporter->send($message); + } + } + + return 0; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Controller/CalendarAPIController.php b/src/Bundle/ChillCalendarBundle/Controller/CalendarAPIController.php index 70cf34cd4..6589b30f1 100644 --- a/src/Bundle/ChillCalendarBundle/Controller/CalendarAPIController.php +++ b/src/Bundle/ChillCalendarBundle/Controller/CalendarAPIController.php @@ -11,11 +11,73 @@ declare(strict_types=1); namespace Chill\CalendarBundle\Controller; +use Chill\CalendarBundle\Repository\CalendarRepository; use Chill\MainBundle\CRUD\Controller\ApiController; +use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Serializer\Model\Collection; +use DateTimeImmutable; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\Routing\Annotation\Route; class CalendarAPIController extends ApiController { + private CalendarRepository $calendarRepository; + + public function __construct(CalendarRepository $calendarRepository) + { + $this->calendarRepository = $calendarRepository; + } + + /** + * @Route("/api/1.0/calendar/calendar/by-user/{id}.{_format}", + * name="chill_api_single_calendar_list_by-user", + * requirements={"_format": "json"} + * ) + */ + public function listByUser(User $user, Request $request, string $_format): JsonResponse + { + $this->denyAccessUnlessGranted('ROLE_USER'); + + if (!$request->query->has('dateFrom')) { + throw new BadRequestHttpException('You must provide a dateFrom parameter'); + } + + if (false === $dateFrom = DateTimeImmutable::createFromFormat( + DateTimeImmutable::ATOM, + $request->query->get('dateFrom') + )) { + throw new BadRequestHttpException('dateFrom not parsable'); + } + + if (!$request->query->has('dateTo')) { + throw new BadRequestHttpException('You must provide a dateTo parameter'); + } + + if (false === $dateTo = DateTimeImmutable::createFromFormat( + DateTimeImmutable::ATOM, + $request->query->get('dateTo') + )) { + throw new BadRequestHttpException('dateTo not parsable'); + } + + $total = $this->calendarRepository->countByUser($user, $dateFrom, $dateTo); + $paginator = $this->getPaginatorFactory()->create($total); + $ranges = $this->calendarRepository->findByUser( + $user, + $dateFrom, + $dateTo, + $paginator->getItemsPerPage(), + $paginator->getCurrentPageFirstItemNumber() + ); + + $collection = new Collection($ranges, $paginator); + + return $this->json($collection, Response::HTTP_OK, [], ['groups' => ['calendar:light']]); + } + protected function customizeQuery(string $action, Request $request, $qb): void { if ($request->query->has('main_user')) { diff --git a/src/Bundle/ChillCalendarBundle/Controller/CalendarController.php b/src/Bundle/ChillCalendarBundle/Controller/CalendarController.php index ff34aaf45..7feedaa01 100644 --- a/src/Bundle/ChillCalendarBundle/Controller/CalendarController.php +++ b/src/Bundle/ChillCalendarBundle/Controller/CalendarController.php @@ -13,53 +13,67 @@ namespace Chill\CalendarBundle\Controller; use Chill\CalendarBundle\Entity\Calendar; use Chill\CalendarBundle\Form\CalendarType; +use Chill\CalendarBundle\RemoteCalendar\Connector\RemoteCalendarConnectorInterface; +use Chill\CalendarBundle\Repository\CalendarACLAwareRepositoryInterface; use Chill\CalendarBundle\Repository\CalendarRepository; use Chill\MainBundle\Entity\User; use Chill\MainBundle\Pagination\PaginatorFactory; -use Chill\MainBundle\Security\Authorization\AuthorizationHelper; +use Chill\MainBundle\Repository\UserRepository; +use Chill\MainBundle\Templating\Listing\FilterOrderHelper; +use Chill\MainBundle\Templating\Listing\FilterOrderHelperFactoryInterface; use Chill\PersonBundle\Entity\AccompanyingPeriod; use Chill\PersonBundle\Entity\Person; use Chill\ThirdPartyBundle\Entity\ThirdParty; +use DateTimeImmutable; use Exception; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\Form; use Symfony\Component\Form\FormInterface; +use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Serializer\SerializerInterface; class CalendarController extends AbstractController { - protected AuthorizationHelper $authorizationHelper; - - protected EventDispatcherInterface $eventDispatcher; - - protected LoggerInterface $logger; - - protected PaginatorFactory $paginator; - - protected SerializerInterface $serializer; + private CalendarACLAwareRepositoryInterface $calendarACLAwareRepository; private CalendarRepository $calendarRepository; + private FilterOrderHelperFactoryInterface $filterOrderHelperFactory; + + private LoggerInterface $logger; + + private PaginatorFactory $paginator; + + private RemoteCalendarConnectorInterface $remoteCalendarConnector; + + private SerializerInterface $serializer; + + private UserRepository $userRepository; + public function __construct( - EventDispatcherInterface $eventDispatcher, - AuthorizationHelper $authorizationHelper, + CalendarRepository $calendarRepository, + CalendarACLAwareRepositoryInterface $calendarACLAwareRepository, + FilterOrderHelperFactoryInterface $filterOrderHelperFactory, LoggerInterface $logger, - SerializerInterface $serializer, PaginatorFactory $paginator, - CalendarRepository $calendarRepository + RemoteCalendarConnectorInterface $remoteCalendarConnector, + SerializerInterface $serializer, + UserRepository $userRepository ) { - $this->eventDispatcher = $eventDispatcher; - $this->authorizationHelper = $authorizationHelper; - $this->logger = $logger; - $this->serializer = $serializer; - $this->paginator = $paginator; $this->calendarRepository = $calendarRepository; + $this->calendarACLAwareRepository = $calendarACLAwareRepository; + $this->filterOrderHelperFactory = $filterOrderHelperFactory; + $this->logger = $logger; + $this->paginator = $paginator; + $this->remoteCalendarConnector = $remoteCalendarConnector; + $this->serializer = $serializer; + $this->userRepository = $userRepository; } /** @@ -67,12 +81,13 @@ class CalendarController extends AbstractController * * @Route("/{_locale}/calendar/{id}/delete", name="chill_calendar_calendar_delete") */ - public function deleteAction(Request $request, int $id) + public function deleteAction(Request $request, Calendar $entity) { $view = null; $em = $this->getDoctrine()->getManager(); - [$user, $accompanyingPeriod] = $this->getEntity($request); + $accompanyingPeriod = $entity->getAccompanyingPeriod(); + $user = null; // TODO legacy code ? remove it ? if ($accompanyingPeriod instanceof AccompanyingPeriod) { $view = '@ChillCalendar/Calendar/confirm_deleteByAccompanyingCourse.html.twig'; @@ -80,14 +95,7 @@ class CalendarController extends AbstractController $view = '@ChillCalendar/Calendar/confirm_deleteByUser.html.twig'; } - /** @var Calendar $entity */ - $entity = $em->getRepository(\Chill\CalendarBundle\Entity\Calendar::class)->find($id); - - if (!$entity) { - throw $this->createNotFoundException('Unable to find Calendar entity.'); - } - - $form = $this->createDeleteForm($id, $user, $accompanyingPeriod); + $form = $this->createDeleteForm($entity->getId(), $user, $accompanyingPeriod); if ($request->getMethod() === Request::METHOD_DELETE) { $form->handleRequest($request); @@ -106,7 +114,7 @@ class CalendarController extends AbstractController $params = $this->buildParamsToUrl($user, $accompanyingPeriod); - return $this->redirectToRoute('chill_calendar_calendar_list', $params); + return $this->redirectToRoute('chill_calendar_calendar_list_by_period', $params); } } @@ -126,8 +134,12 @@ class CalendarController extends AbstractController * * @Route("/{_locale}/calendar/calendar/{id}/edit", name="chill_calendar_calendar_edit") */ - public function editAction(int $id, Request $request): Response + public function editAction(Calendar $entity, Request $request): Response { + if (!$this->remoteCalendarConnector->isReady()) { + return $this->remoteCalendarConnector->getMakeReadyResponse($request->getUri()); + } + $view = null; $em = $this->getDoctrine()->getManager(); @@ -136,35 +148,28 @@ class CalendarController extends AbstractController if ($accompanyingPeriod instanceof AccompanyingPeriod) { $view = '@ChillCalendar/Calendar/editByAccompanyingCourse.html.twig'; } elseif ($user instanceof User) { + throw new Exception('to analyze'); $view = '@ChillCalendar/Calendar/editByUser.html.twig'; } - $entity = $em->getRepository(\Chill\CalendarBundle\Entity\Calendar::class)->find($id); - - if (!$entity) { - throw $this->createNotFoundException('Unable to find Calendar entity.'); - } - - $form = $this->createForm(CalendarType::class, $entity, [ - 'accompanyingPeriod' => $accompanyingPeriod, - ])->handleRequest($request); + $form = $this->createForm(CalendarType::class, $entity); + $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { - $em->persist($entity); $em->flush(); $this->addFlash('success', $this->get('translator')->trans('Success : calendar item updated!')); $params = $this->buildParamsToUrl($user, $accompanyingPeriod); - return $this->redirectToRoute('chill_calendar_calendar_list', $params); + return $this->redirectToRoute('chill_calendar_calendar_list_by_period', $params); } if ($form->isSubmitted() && !$form->isValid()) { $this->addFlash('error', $this->get('translator')->trans('This form contains errors')); } - $deleteForm = $this->createDeleteForm($id, $user, $accompanyingPeriod); + $deleteForm = $this->createDeleteForm($entity->getId(), $user, $accompanyingPeriod); if (null === $view) { throw $this->createNotFoundException('Template not found'); @@ -177,7 +182,7 @@ class CalendarController extends AbstractController 'form' => $form->createView(), 'delete_form' => $deleteForm->createView(), 'accompanyingCourse' => $accompanyingPeriod, - 'user' => $user, + // 'user' => $user, 'entity_json' => $entity_array, ]); } @@ -185,45 +190,53 @@ class CalendarController extends AbstractController /** * Lists all Calendar entities. * - * @Route("/{_locale}/calendar/calendar/", name="chill_calendar_calendar_list") + * @Route("/{_locale}/calendar/calendar/by-period/{id}", name="chill_calendar_calendar_list_by_period") */ - public function listAction(Request $request): Response + public function listActionByCourse(AccompanyingPeriod $accompanyingPeriod): Response { - $view = null; + $filterOrder = $this->buildListFilterOrder(); + ['from' => $from, 'to' => $to] = $filterOrder->getDateRangeData('startDate'); - [$user, $accompanyingPeriod] = $this->getEntity($request); + $total = $this->calendarACLAwareRepository + ->countByAccompanyingPeriod($accompanyingPeriod, $from, $to); + $paginator = $this->paginator->create($total); + $calendarItems = $this->calendarACLAwareRepository->findByAccompanyingPeriod( + $accompanyingPeriod, + $from, + $to, + ['startDate' => 'DESC'], + $paginator->getCurrentPageFirstItemNumber(), + $paginator->getItemsPerPage() + ); - if ($user instanceof User) { - $calendarItems = $this->calendarRepository->findByUser($user); + return $this->render('@ChillCalendar/Calendar/listByAccompanyingCourse.html.twig', [ + 'calendarItems' => $calendarItems, + 'accompanyingCourse' => $accompanyingPeriod, + 'paginator' => $paginator, + 'filterOrder' => $filterOrder, + ]); + } - $view = '@ChillCalendar/Calendar/listByUser.html.twig'; + /** + * @Route("/{_locale}/calendar/calendar/my", name="chill_calendar_calendar_list_my") + */ + public function myCalendar(Request $request): Response + { + $this->denyAccessUnlessGranted('ROLE_USER'); - return $this->render($view, [ - 'calendarItems' => $calendarItems, - 'user' => $user, - ]); + if (!$this->remoteCalendarConnector->isReady()) { + return $this->remoteCalendarConnector->getMakeReadyResponse($request->getUri()); } - if ($accompanyingPeriod instanceof AccompanyingPeriod) { - $total = $this->calendarRepository->countByAccompanyingPeriod($accompanyingPeriod); - $paginator = $this->paginator->create($total); - $calendarItems = $this->calendarRepository->findBy( - ['accompanyingPeriod' => $accompanyingPeriod], - ['startDate' => 'DESC'], - $paginator->getItemsPerPage(), - $paginator->getCurrentPageFirstItemNumber() - ); - - $view = '@ChillCalendar/Calendar/listByAccompanyingCourse.html.twig'; - - return $this->render($view, [ - 'calendarItems' => $calendarItems, - 'accompanyingCourse' => $accompanyingPeriod, - 'paginator' => $paginator, - ]); + if (!$this->getUser() instanceof User) { + throw new UnauthorizedHttpException('you are not an user'); } - throw new Exception('Unable to list actions.'); + $view = '@ChillCalendar/Calendar/listByUser.html.twig'; + + return $this->render($view, [ + 'user' => $this->getUser(), + ]); } /** @@ -233,6 +246,10 @@ class CalendarController extends AbstractController */ public function newAction(Request $request): Response { + if (!$this->remoteCalendarConnector->isReady()) { + return $this->remoteCalendarConnector->getMakeReadyResponse($request->getUri()); + } + $view = null; $em = $this->getDoctrine()->getManager(); @@ -246,8 +263,10 @@ class CalendarController extends AbstractController // } $entity = new Calendar(); - $entity->setUser($this->getUser()); - $entity->setStatus($entity::STATUS_VALID); + + if ($request->query->has('mainUser')) { + $entity->setMainUser($this->userRepository->find($request->query->getInt('mainUser'))); + } // if ($user instanceof User) { // $entity->setPerson($user); @@ -257,9 +276,8 @@ class CalendarController extends AbstractController $entity->setAccompanyingPeriod($accompanyingPeriod); } - $form = $this->createForm(CalendarType::class, $entity, [ - 'accompanyingPeriod' => $accompanyingPeriod, - ])->handleRequest($request); + $form = $this->createForm(CalendarType::class, $entity); + $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $em->persist($entity); @@ -269,7 +287,7 @@ class CalendarController extends AbstractController $params = $this->buildParamsToUrl($user, $accompanyingPeriod); - return $this->redirectToRoute('chill_calendar_calendar_list', $params); + return $this->redirectToRoute('chill_calendar_calendar_list_by_period', $params); } if ($form->isSubmitted() && !$form->isValid()) { @@ -349,7 +367,7 @@ class CalendarController extends AbstractController 'professionalsId' => $professionalsId, 'date' => $entity->getStartDate()->format('Y-m-d'), 'durationTime' => $durationTimeInMinutes, - 'location' => $entity->getLocation()->getId(), + 'location' => $entity->getLocation() ? $entity->getLocation()->getId() : null, 'comment' => $entity->getComment()->getComment(), ]; @@ -362,6 +380,58 @@ class CalendarController extends AbstractController ]); } + /** + * @Route("/{_locale}/calendar/calendar/{id}/to-activity", name="chill_calendar_calendar_to_activity") + */ + public function toActivity(Request $request, Calendar $calendar): RedirectResponse + { + $personsId = array_map( + static fn (Person $p): int => $p->getId(), + $calendar->getPersons()->toArray() + ); + + $professionalsId = array_map( + static fn (ThirdParty $thirdParty): ?int => $thirdParty->getId(), + $calendar->getProfessionals()->toArray() + ); + + $usersId = array_map( + static fn (User $user): ?int => $user->getId(), + array_merge($calendar->getUsers()->toArray(), [$calendar->getMainUser()]) + ); + + $durationTime = $calendar->getEndDate()->diff($calendar->getStartDate()); + $durationTimeInMinutes = $durationTime->days * 1440 + $durationTime->h * 60 + $durationTime->i; + + $activityData = [ + 'calendarId' => $calendar->getId(), + 'personsId' => $personsId, + 'professionalsId' => $professionalsId, + 'usersId' => $usersId, + 'date' => $calendar->getStartDate()->format('Y-m-d'), + 'durationTime' => $durationTimeInMinutes, + 'location' => $calendar->getLocation() ? $calendar->getLocation()->getId() : null, + 'comment' => $calendar->getComment()->getComment(), + ]; + + return $this->redirectToRoute( + 'chill_activity_activity_new', + [ + 'accompanying_period_id' => $calendar->getAccompanyingPeriod()->getId(), + 'activityData' => $activityData, + 'returnPath' => $request->query->get('returnPath', null), + ] + ); + } + + private function buildListFilterOrder(): FilterOrderHelper + { + $filterOrder = $this->filterOrderHelperFactory->create(self::class); + $filterOrder->addDateRange('startDate', null, new DateTimeImmutable('3 days ago'), null); + + return $filterOrder->build(); + } + private function buildParamsToUrl(?User $user, ?AccompanyingPeriod $accompanyingPeriod): array { $params = []; @@ -371,7 +441,7 @@ class CalendarController extends AbstractController } if (null !== $accompanyingPeriod) { - $params['accompanying_period_id'] = $accompanyingPeriod->getId(); + $params['id'] = $accompanyingPeriod->getId(); } return $params; diff --git a/src/Bundle/ChillCalendarBundle/Controller/CalendarRangeAPIController.php b/src/Bundle/ChillCalendarBundle/Controller/CalendarRangeAPIController.php index 018d23426..da063ea02 100644 --- a/src/Bundle/ChillCalendarBundle/Controller/CalendarRangeAPIController.php +++ b/src/Bundle/ChillCalendarBundle/Controller/CalendarRangeAPIController.php @@ -11,38 +11,72 @@ declare(strict_types=1); namespace Chill\CalendarBundle\Controller; +use Chill\CalendarBundle\Repository\CalendarRangeRepository; use Chill\MainBundle\CRUD\Controller\ApiController; +use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Serializer\Model\Collection; +use DateTimeImmutable; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; -use function count; +use Symfony\Component\Routing\Annotation\Route; class CalendarRangeAPIController extends ApiController { - /** - * @Route("/api/1.0/calendar/calendar-range-available.{_format}", name="chill_api_single_calendar_range_available") - */ - public function availableRanges(Request $request, string $_format): JsonResponse + private CalendarRangeRepository $calendarRangeRepository; + + public function __construct(CalendarRangeRepository $calendarRangeRepository) { - $em = $this->getDoctrine()->getManager(); + $this->calendarRangeRepository = $calendarRangeRepository; + } - $sql = 'SELECT c FROM ChillCalendarBundle:CalendarRange c - WHERE NOT EXISTS (SELECT cal.id FROM ChillCalendarBundle:Calendar cal WHERE cal.calendarRange = c.id)'; + /** + * @Route("/api/1.0/calendar/calendar-range-available/{id}.{_format}", + * name="chill_api_single_calendar_range_available", + * requirements={"_format": "json"} + * ) + */ + public function availableRanges(User $user, Request $request, string $_format): JsonResponse + { + //return new JsonResponse(['ok' => true], 200, [], false); + $this->denyAccessUnlessGranted('ROLE_USER'); - if ($request->query->has('user')) { - $user = $request->query->get('user'); - $sql = $sql . ' AND c.user = :user'; - $query = $em->createQuery($sql) - ->setParameter('user', $user); - } else { - $query = $em->createQuery($sql); + if (!$request->query->has('dateFrom')) { + throw new BadRequestHttpException('You must provide a dateFrom parameter'); } - $results = $query->getResult(); + if (false === $dateFrom = DateTimeImmutable::createFromFormat( + DateTimeImmutable::ATOM, + $request->query->get('dateFrom') + )) { + throw new BadRequestHttpException('dateFrom not parsable'); + } - return $this->json(['count' => count($results), 'results' => $results], Response::HTTP_OK, [], ['groups' => ['read']]); - //TODO use also the paginator, eg return $this->serializeCollection('get', $request, $_format, $paginator, $results); + if (!$request->query->has('dateTo')) { + throw new BadRequestHttpException('You must provide a dateTo parameter'); + } + + if (false === $dateTo = DateTimeImmutable::createFromFormat( + DateTimeImmutable::ATOM, + $request->query->get('dateTo') + )) { + throw new BadRequestHttpException('dateTo not parsable'); + } + + $total = $this->calendarRangeRepository->countByAvailableRangesForUser($user, $dateFrom, $dateTo); + $paginator = $this->getPaginatorFactory()->create($total); + $ranges = $this->calendarRangeRepository->findByAvailableRangesForUser( + $user, + $dateFrom, + $dateTo, + $paginator->getItemsPerPage(), + $paginator->getCurrentPageFirstItemNumber() + ); + + $collection = new Collection($ranges, $paginator); + + return $this->json($collection, Response::HTTP_OK, [], ['groups' => ['read']]); } } diff --git a/src/Bundle/ChillCalendarBundle/Controller/InviteApiController.php b/src/Bundle/ChillCalendarBundle/Controller/InviteApiController.php new file mode 100644 index 000000000..05ab52718 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Controller/InviteApiController.php @@ -0,0 +1,76 @@ +entityManager = $entityManager; + $this->messageBus = $messageBus; + $this->security = $security; + } + + /** + * Give an answer to a calendar invite. + * + * @Route("/api/1.0/calendar/calendar/{id}/answer/{answer}.json", methods={"post"}) + */ + public function answer(Calendar $calendar, string $answer): Response + { + $user = $this->security->getUser(); + + if (!$user instanceof User) { + throw new AccessDeniedHttpException('not a regular user'); + } + + if (null === $invite = $calendar->getInviteForUser($user)) { + throw new AccessDeniedHttpException('not invited to this calendar'); + } + + if (!$this->security->isGranted(InviteVoter::ANSWER, $invite)) { + throw new AccessDeniedHttpException('not allowed to answer on this invitation'); + } + + if (!in_array($answer, Invite::STATUSES, true)) { + throw new BadRequestHttpException('answer not valid'); + } + + $invite->setStatus($answer); + $this->entityManager->flush(); + + $this->messageBus->dispatch(new InviteUpdateMessage($invite, $this->security->getUser())); + + return new JsonResponse(null, Response::HTTP_ACCEPTED, [], false); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Controller/RemoteCalendarConnectAzureController.php b/src/Bundle/ChillCalendarBundle/Controller/RemoteCalendarConnectAzureController.php new file mode 100644 index 000000000..cd220972f --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Controller/RemoteCalendarConnectAzureController.php @@ -0,0 +1,69 @@ +clientRegistry = $clientRegistry; + $this->MSGraphTokenStorage = $MSGraphTokenStorage; + } + + /** + * @Route("/{_locale}/connect/azure", name="chill_calendar_remote_connect_azure") + */ + public function connectAzure(Request $request): Response + { + $request->getSession()->set('azure_return_path', $request->query->get('returnPath', '/')); + + return $this->clientRegistry + ->getClient('azure') // key used in config/packages/knpu_oauth2_client.yaml + ->redirect(['https://graph.microsoft.com/.default', 'offline_access'], []); + } + + /** + * @Route("/connect/azure/check", name="chill_calendar_remote_connect_azure_check") + */ + public function connectAzureCheck(Request $request): Response + { + /** @var Azure $client */ + $client = $this->clientRegistry->getClient('azure'); + + try { + /** @var AccessToken $token */ + $token = $client->getAccessToken([]); + + $this->MSGraphTokenStorage->setToken($token); + } catch (IdentityProviderException $e) { + throw $e; + } + + return new RedirectResponse($request->getSession()->remove('azure_return_path', '/')); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Controller/RemoteCalendarMSGraphSyncController.php b/src/Bundle/ChillCalendarBundle/Controller/RemoteCalendarMSGraphSyncController.php new file mode 100644 index 000000000..e07895782 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Controller/RemoteCalendarMSGraphSyncController.php @@ -0,0 +1,54 @@ +messageBus = $messageBus; + } + + /** + * @Route("/public/incoming-hook/calendar/msgraph/events/{userId}", name="chill_calendar_remote_msgraph_incoming_webhook_events", + * methods={"POST"}) + */ + public function webhookCalendarReceiver(int $userId, Request $request): Response + { + if ($request->query->has('validationToken')) { + return new Response($request->query->get('validationToken'), Response::HTTP_OK, [ + 'content-type' => 'text/plain', + ]); + } + + try { + $body = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new BadRequestHttpException('could not decode json', $e); + } + + $this->messageBus->dispatch(new MSGraphChangeNotificationMessage($body, $userId)); + + return new Response('', Response::HTTP_ACCEPTED); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Controller/RemoteCalendarProxyController.php b/src/Bundle/ChillCalendarBundle/Controller/RemoteCalendarProxyController.php new file mode 100644 index 000000000..0523bbd19 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Controller/RemoteCalendarProxyController.php @@ -0,0 +1,107 @@ +paginatorFactory = $paginatorFactory; + $this->remoteCalendarConnector = $remoteCalendarConnector; + $this->serializer = $serializer; + } + + /** + * @Route("api/1.0/calendar/proxy/calendar/by-user/{id}/events") + */ + public function listEventForCalendar(User $user, Request $request): Response + { + if (!$request->query->has('dateFrom')) { + throw new BadRequestHttpException('You must provide a dateFrom parameter'); + } + + if (false === $dateFrom = DateTimeImmutable::createFromFormat( + DateTimeImmutable::ATOM, + $request->query->get('dateFrom') + )) { + throw new BadRequestHttpException('dateFrom not parsable'); + } + + if (!$request->query->has('dateTo')) { + throw new BadRequestHttpException('You must provide a dateTo parameter'); + } + + if (false === $dateTo = DateTimeImmutable::createFromFormat( + DateTimeImmutable::ATOM, + $request->query->get('dateTo') + )) { + throw new BadRequestHttpException('dateTo not parsable'); + } + + $total = $this->remoteCalendarConnector->countEventsForUser($user, $dateFrom, $dateTo); + $paginator = $this->paginatorFactory->create($total); + + if (0 === $total) { + return new JsonResponse( + $this->serializer->serialize(new Collection([], $paginator), 'json'), + JsonResponse::HTTP_OK, + [], + true + ); + } + + $events = $this->remoteCalendarConnector->listEventsForUser( + $user, + $dateFrom, + $dateTo, + $paginator->getCurrentPageFirstItemNumber(), + $paginator->getItemsPerPage() + ); + + // in some case, we cannot paginate: we have to fetch all the items at once. We must avoid + // further requests by forcing the number of items returned. + if (count($events) > $paginator->getItemsPerPage()) { + $paginator->setItemsPerPage(count($events)); + } + + $collection = new Collection($events, $paginator); + + return new JsonResponse( + $this->serializer->serialize($collection, 'json', ['groups' => ['read']]), + JsonResponse::HTTP_OK, + [], + true + ); + } +} diff --git a/src/Bundle/ChillCalendarBundle/DataFixtures/ORM/LoadCalendarRange.php b/src/Bundle/ChillCalendarBundle/DataFixtures/ORM/LoadCalendarRange.php index e708672a9..4db0f6d57 100644 --- a/src/Bundle/ChillCalendarBundle/DataFixtures/ORM/LoadCalendarRange.php +++ b/src/Bundle/ChillCalendarBundle/DataFixtures/ORM/LoadCalendarRange.php @@ -12,12 +12,18 @@ declare(strict_types=1); namespace Chill\CalendarBundle\DataFixtures\ORM; use Chill\CalendarBundle\Entity\CalendarRange; +use Chill\MainBundle\Entity\Address; +use Chill\MainBundle\Entity\Country; +use Chill\MainBundle\Entity\Location; +use Chill\MainBundle\Entity\LocationType; +use Chill\MainBundle\Entity\PostalCode; use Chill\MainBundle\Repository\UserRepository; use DateTimeImmutable; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface; use Doctrine\Common\DataFixtures\OrderedFixtureInterface; use Doctrine\Persistence\ObjectManager; +use libphonenumber\PhoneNumberUtil; class LoadCalendarRange extends Fixture implements FixtureGroupInterface, OrderedFixtureInterface { @@ -49,6 +55,24 @@ class LoadCalendarRange extends Fixture implements FixtureGroupInterface, Ordere $users = $this->userRepository->findAll(); + $location = (new Location()) + ->setAddress($address = new Address()) + ->setName('Centre A') + ->setEmail('centreA@test.chill.social') + ->setLocationType($type = new LocationType()) + ->setPhonenumber1(PhoneNumberUtil::getInstance()->parse('+3287653812')); + $type->setTitle('Service'); + $address->setStreet('Rue des Épaules')->setStreetNumber('14') + ->setPostcode($postCode = new PostalCode()); + $postCode->setCode('4145')->setName('Houte-Si-Plout')->setCountry( + ($country = new Country())->setName(['fr' => 'Pays'])->setCountryCode('ZZ') + ); + $manager->persist($country); + $manager->persist($postCode); + $manager->persist($address); + $manager->persist($type); + $manager->persist($location); + $days = [ '2021-08-23', '2021-08-24', @@ -58,6 +82,8 @@ class LoadCalendarRange extends Fixture implements FixtureGroupInterface, Ordere '2021-08-31', '2021-09-01', '2021-09-02', + (new DateTimeImmutable('tomorrow'))->format('Y-m-d'), + (new DateTimeImmutable('today'))->format('Y-m-d'), ]; $hours = [ @@ -76,7 +102,8 @@ class LoadCalendarRange extends Fixture implements FixtureGroupInterface, Ordere $calendarRange = (new CalendarRange()) ->setUser($u) ->setStartDate($startEvent) - ->setEndDate($endEvent); + ->setEndDate($endEvent) + ->setLocation($location); $manager->persist($calendarRange); } diff --git a/src/Bundle/ChillCalendarBundle/DependencyInjection/ChillCalendarExtension.php b/src/Bundle/ChillCalendarBundle/DependencyInjection/ChillCalendarExtension.php index 165365597..9d556e6d8 100644 --- a/src/Bundle/ChillCalendarBundle/DependencyInjection/ChillCalendarExtension.php +++ b/src/Bundle/ChillCalendarBundle/DependencyInjection/ChillCalendarExtension.php @@ -36,6 +36,15 @@ class ChillCalendarExtension extends Extension implements PrependExtensionInterf $loader->load('services/fixtures.yml'); $loader->load('services/form.yml'); $loader->load('services/event.yml'); + $loader->load('services/remote_calendar.yaml'); + + $container->setParameter('chill_calendar', $config); + + if ($config['short_messages']['enabled']) { + $container->setParameter('chill_calendar.short_messages', $config['short_messages']); + } else { + $container->setParameter('chill_calendar.short_messages', null); + } } public function prepend(ContainerBuilder $container) diff --git a/src/Bundle/ChillCalendarBundle/DependencyInjection/Configuration.php b/src/Bundle/ChillCalendarBundle/DependencyInjection/Configuration.php index f1e744cb8..0d295ccbb 100644 --- a/src/Bundle/ChillCalendarBundle/DependencyInjection/Configuration.php +++ b/src/Bundle/ChillCalendarBundle/DependencyInjection/Configuration.php @@ -26,9 +26,22 @@ class Configuration implements ConfigurationInterface $treeBuilder = new TreeBuilder('chill_calendar'); $rootNode = $treeBuilder->getRootNode('chill_calendar'); - // Here you should define the parameters that are allowed to - // configure your bundle. See the documentation linked above for - // more information on that topic. + $rootNode + ->children() + ->arrayNode('short_messages') + ->canBeDisabled() + ->children()->end() + ->end() // end for short_messages + ->arrayNode('remote_calendars_sync')->canBeEnabled() + ->children() + ->arrayNode('microsoft_graph')->canBeEnabled() + ->children() + ->end() // end of machine_access_token + ->end() // end of microsoft_graph children + ->end() // end of array microsoft_graph + ->end() // end of children's remote_calendars_sync + ->end() // end of array remote_calendars_sync + ->end(); return $treeBuilder; } diff --git a/src/Bundle/ChillCalendarBundle/Entity/Calendar.php b/src/Bundle/ChillCalendarBundle/Entity/Calendar.php index c40bddb44..018f073bd 100644 --- a/src/Bundle/ChillCalendarBundle/Entity/Calendar.php +++ b/src/Bundle/ChillCalendarBundle/Entity/Calendar.php @@ -12,7 +12,10 @@ declare(strict_types=1); namespace Chill\CalendarBundle\Entity; use Chill\ActivityBundle\Entity\Activity; -use Chill\CalendarBundle\Repository\CalendarRepository; +use Chill\MainBundle\Doctrine\Model\TrackCreationInterface; +use Chill\MainBundle\Doctrine\Model\TrackCreationTrait; +use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface; +use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait; use Chill\MainBundle\Entity\Embeddable\CommentEmbeddable; use Chill\MainBundle\Entity\Embeddable\PrivateCommentEmbeddable; use Chill\MainBundle\Entity\Location; @@ -20,33 +23,71 @@ use Chill\MainBundle\Entity\User; use Chill\PersonBundle\Entity\AccompanyingPeriod; use Chill\PersonBundle\Entity\Person; use Chill\ThirdPartyBundle\Entity\ThirdParty; +use DateInterval; use DateTimeImmutable; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; +use Doctrine\Common\Collections\Criteria; use Doctrine\ORM\Mapping as ORM; +use LogicException; use Symfony\Component\Serializer\Annotation as Serializer; -use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints\NotBlank; + use Symfony\Component\Validator\Constraints\Range; use Symfony\Component\Validator\Mapping\ClassMetadata; - use function in_array; /** - * @ORM\Table(name="chill_calendar.calendar") - * @ORM\Entity(repositoryClass=CalendarRepository::class) + * @ORM\Table( + * name="chill_calendar.calendar", + * uniqueConstraints={@ORM\UniqueConstraint(name="idx_calendar_remote", columns={"remoteId"}, options={"where": "remoteId <> ''"})} + * ) + * @ORM\Entity */ -class Calendar +class Calendar implements TrackCreationInterface, TrackUpdateInterface { + use RemoteCalendarTrait; + + use TrackCreationTrait; + + use TrackUpdateTrait; + + public const SMS_CANCEL_PENDING = 'sms_cancel_pending'; + + public const SMS_PENDING = 'sms_pending'; + + public const SMS_SENT = 'sms_sent'; + public const STATUS_CANCELED = 'canceled'; + /** + * @deprecated + */ public const STATUS_MOVED = 'moved'; public const STATUS_VALID = 'valid'; /** - * @ORM\ManyToOne(targetEntity="Chill\PersonBundle\Entity\AccompanyingPeriod") - * @Groups({"read"}) + * a list of invite which have been added during this session. + * + * @var array|Invite[] + */ + public array $newInvites = []; + + /** + * a list of invite which have been removed during this session. + * + * @var array|Invite[] + */ + public array $oldInvites = []; + + public ?CalendarRange $previousCalendarRange = null; + + public ?User $previousMainUser = null; + + /** + * @ORM\ManyToOne(targetEntity="Chill\PersonBundle\Entity\AccompanyingPeriod", inversedBy="calendars") */ private AccompanyingPeriod $accompanyingPeriod; @@ -56,7 +97,8 @@ class Calendar private ?Activity $activity = null; /** - * @ORM\ManyToOne(targetEntity="CalendarRange", inversedBy="calendars") + * @ORM\OneToOne(targetEntity="CalendarRange", inversedBy="calendar") + * @Serializer\Groups({"calendar:read", "read"}) */ private ?CalendarRange $calendarRange = null; @@ -67,13 +109,14 @@ class Calendar /** * @ORM\Embedded(class=CommentEmbeddable::class, columnPrefix="comment_") - * @Serializer\Groups({"calendar:read"}) + * @Serializer\Groups({"calendar:read", "read"}) */ private CommentEmbeddable $comment; /** - * @ORM\Column(type="datetimetz_immutable") - * @Serializer\Groups({"calendar:read"}) + * @ORM\Column(type="datetime_immutable", nullable=false) + * @Serializer\Groups({"calendar:read", "read", "calendar:light"}) + * @Assert\NotNull(message="calendar.An end date is required") */ private ?DateTimeImmutable $endDate = null; @@ -81,38 +124,43 @@ class Calendar * @ORM\Id * @ORM\GeneratedValue * @ORM\Column(type="integer") - * @Serializer\Groups({"calendar:read"}) + * @Serializer\Groups({"calendar:read", "read", "calendar:light"}) */ - private ?int $id; + private ?int $id = null; /** - * @ORM\ManyToMany( - * targetEntity="Invite", - * cascade={"persist", "remove", "merge", "detach"}) + * @ORM\OneToMany( + * targetEntity=Invite::class, + * mappedBy="calendar", + * orphanRemoval=true, + * cascade={"persist", "remove", "merge", "detach"} + * ) * @ORM\JoinTable(name="chill_calendar.calendar_to_invites") - * @Groups({"read"}) + * @Serializer\Groups({"read"}) */ private Collection $invites; /** * @ORM\ManyToOne(targetEntity="Chill\MainBundle\Entity\Location") - * @groups({"read"}) + * @Serializer\Groups({"read"}) + * @Assert\NotNull(message="calendar.A location is required") */ private ?Location $location = null; /** * @ORM\ManyToOne(targetEntity="Chill\MainBundle\Entity\User") - * @Serializer\Groups({"calendar:read"}) + * @Serializer\Groups({"calendar:read", "read", "calendar:light"}) + * @Serializer\Context(normalizationContext={"read"}, groups={"calendar:light"}) + * @Assert\NotNull(message="calendar.A main user is mandatory") */ - private ?User $mainUser; + private ?User $mainUser = null; /** - * @ORM\ManyToMany( - * targetEntity="Chill\PersonBundle\Entity\Person", - * cascade={"persist", "remove", "merge", "detach"}) + * @ORM\ManyToMany(targetEntity="Chill\PersonBundle\Entity\Person", inversedBy="calendars") * @ORM\JoinTable(name="chill_calendar.calendar_to_persons") - * @Groups({"read"}) - * @Serializer\Groups({"calendar:read"}) + * @Serializer\Groups({"calendar:read", "read", "calendar:light"}) + * @Serializer\Context(normalizationContext={"read"}, groups={"calendar:light"}) + * @Assert\Count(min=1, minMessage="calendar.At least {{ limit }} person is required.") */ private Collection $persons; @@ -123,37 +171,37 @@ class Calendar private PrivateCommentEmbeddable $privateComment; /** - * @ORM\ManyToMany( - * targetEntity="Chill\ThirdPartyBundle\Entity\ThirdParty", - * cascade={"persist", "remove", "merge", "detach"}) + * @ORM\ManyToMany(targetEntity="Chill\ThirdPartyBundle\Entity\ThirdParty") * @ORM\JoinTable(name="chill_calendar.calendar_to_thirdparties") - * @Groups({"read"}) - * @Serializer\Groups({"calendar:read"}) + * @Serializer\Groups({"calendar:read", "read", "calendar:light"}) + * @Serializer\Context(normalizationContext={"read"}, groups={"calendar:light"}) */ private Collection $professionals; /** * @ORM\Column(type="boolean", nullable=true) */ - private ?bool $sendSMS; + private ?bool $sendSMS = false; /** - * @ORM\Column(type="datetimetz_immutable") - * @Serializer\Groups({"calendar:read"}) + * @ORM\Column(type="text", nullable=false, options={"default": Calendar::SMS_PENDING}) + */ + private string $smsStatus = self::SMS_PENDING; + + /** + * @ORM\Column(type="datetime_immutable", nullable=false) + * @Serializer\Groups({"calendar:read", "read", "calendar:light"}) + * @Serializer\Context(normalizationContext={"read"}, groups={"calendar:light"}) + * @Assert\NotNull(message="calendar.A start date is required") */ private ?DateTimeImmutable $startDate = null; /** - * @ORM\Column(type="string", length=255) + * @ORM\Column(type="string", length=255, nullable=false, options={"default": "valid"}) + * @Serializer\Groups({"calendar:read", "read", "calendar:light"}) + * @Serializer\Context(normalizationContext={"read"}, groups={"calendar:light"}) */ - private ?string $status = null; - - /** - * @ORM\ManyToOne(targetEntity="Chill\MainBundle\Entity\User") - * @Groups({"read"}) - * @Serializer\Groups({"calendar:read"}) - */ - private ?User $user = null; + private string $status = self::STATUS_VALID; public function __construct() { @@ -164,28 +212,41 @@ class Calendar $this->invites = new ArrayCollection(); } - public function addInvite(?Invite $invite): self + /** + * @internal Use {@link (Calendar::addUser)} instead + */ + public function addInvite(Invite $invite): self { - if (null !== $invite) { - $this->invites[] = $invite; + if ($invite->getCalendar() instanceof Calendar && $invite->getCalendar() !== $this) { + throw new LogicException('Not allowed to move an invitation to another Calendar'); } + $this->invites[] = $invite; + $this->newInvites[] = $invite; + + $invite->setCalendar($this); + return $this; } - public function addPerson(?Person $person): self + public function addPerson(Person $person): self { - if (null !== $person) { - $this->persons[] = $person; - } + $this->persons[] = $person; return $this; } - public function addProfessional(?ThirdParty $professional): self + public function addProfessional(ThirdParty $professional): self { - if (null !== $professional) { - $this->professionals[] = $professional; + $this->professionals[] = $professional; + + return $this; + } + + public function addUser(User $user): self + { + if (!$this->getUsers()->contains($user) && $this->getMainUser() !== $user) { + $this->addInvite((new Invite())->setUser($user)); } return $this; @@ -216,6 +277,15 @@ class Calendar return $this->comment; } + public function getDuration(): ?DateInterval + { + if ($this->getStartDate() === null || $this->getEndDate() === null) { + return null; + } + + return $this->getStartDate()->diff($this->getEndDate()); + } + public function getEndDate(): ?DateTimeImmutable { return $this->endDate; @@ -226,6 +296,21 @@ class Calendar return $this->id; } + public function getInviteForUser(User $user): ?Invite + { + $criteria = Criteria::create(); + $criteria->where(Criteria::expr()->eq('user', $user)); + + $matchings = $this->invites + ->matching($criteria); + + if (1 === $matchings->count()) { + return $matchings->first(); + } + + return null; + } + /** * @return Collection|Invite[] */ @@ -304,6 +389,11 @@ class Calendar return $this->sendSMS; } + public function getSmsStatus(): string + { + return $this->smsStatus; + } + public function getStartDate(): ?DateTimeImmutable { return $this->startDate; @@ -319,14 +409,35 @@ class Calendar return $this->getProfessionals(); } - public function getUser(): ?User + /** + * @return Collection|User[] + * @Serializer\Groups({"calendar:read", "read"}) + */ + public function getUsers(): Collection { - return $this->user; + return $this->getInvites()->map(static function (Invite $i) { return $i->getUser(); }); } - public function getusers(): Collection + public function hasCalendarRange(): bool { - return $this->getInvites(); //TODO get users of the invite + return null !== $this->calendarRange; + } + + public function hasLocation(): bool + { + return null !== $this->getLocation(); + } + + /** + * return true if the user is invited. + */ + public function isInvited(User $user): bool + { + if ($this->getMainUser() === $user) { + return false; + } + + return $this->getUsers()->contains($user); } public static function loadValidatorMetadata(ClassMetadata $metadata): void @@ -343,9 +454,15 @@ class Calendar ])); } + /** + * @internal Use {@link (Calendar::removeUser)} instead + */ public function removeInvite(Invite $invite): self { - $this->invites->removeElement($invite); + if ($this->invites->removeElement($invite)) { + $invite->setCalendar(null); + $this->oldInvites[] = $invite; + } return $this; } @@ -364,6 +481,20 @@ class Calendar return $this; } + public function removeUser(User $user): self + { + if (!$this->getUsers()->contains($user)) { + return $this; + } + + $invite = $this->invites + ->filter(static function (Invite $invite) use ($user) { return $invite->getUser() === $user; }) + ->first(); + $this->removeInvite($invite); + + return $this; + } + public function setAccompanyingPeriod(?AccompanyingPeriod $accompanyingPeriod): self { $this->accompanyingPeriod = $accompanyingPeriod; @@ -380,8 +511,20 @@ class Calendar public function setCalendarRange(?CalendarRange $calendarRange): self { + if ($this->calendarRange !== $calendarRange) { + $this->previousCalendarRange = $this->calendarRange; + + if (null !== $this->previousCalendarRange) { + $this->previousCalendarRange->setCalendar(null); + } + } + $this->calendarRange = $calendarRange; + if ($this->calendarRange instanceof CalendarRange) { + $this->calendarRange->setCalendar($this); + } + return $this; } @@ -415,7 +558,12 @@ class Calendar public function setMainUser(?User $mainUser): self { + if ($this->mainUser !== $mainUser) { + $this->previousMainUser = $this->mainUser; + } + $this->mainUser = $mainUser; + $this->removeUser($mainUser); return $this; } @@ -434,6 +582,13 @@ class Calendar return $this; } + public function setSmsStatus(string $smsStatus): self + { + $this->smsStatus = $smsStatus; + + return $this; + } + public function setStartDate(DateTimeImmutable $startDate): self { $this->startDate = $startDate; @@ -445,12 +600,9 @@ class Calendar { $this->status = $status; - return $this; - } - - public function setUser(?User $user): self - { - $this->user = $user; + if (self::STATUS_CANCELED === $status && $this->getSmsStatus() === self::SMS_SENT) { + $this->setSmsStatus(self::SMS_CANCEL_PENDING); + } return $this; } diff --git a/src/Bundle/ChillCalendarBundle/Entity/CalendarRange.php b/src/Bundle/ChillCalendarBundle/Entity/CalendarRange.php index b29a9db08..013b465d5 100644 --- a/src/Bundle/ChillCalendarBundle/Entity/CalendarRange.php +++ b/src/Bundle/ChillCalendarBundle/Entity/CalendarRange.php @@ -11,29 +11,41 @@ declare(strict_types=1); namespace Chill\CalendarBundle\Entity; -use Chill\CalendarBundle\Repository\CalendarRangeRepository; +use Chill\MainBundle\Doctrine\Model\TrackCreationInterface; +use Chill\MainBundle\Doctrine\Model\TrackCreationTrait; +use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface; +use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait; +use Chill\MainBundle\Entity\Location; use Chill\MainBundle\Entity\User; use DateTimeImmutable; -use Doctrine\Common\Collections\ArrayCollection; -use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Validator\Constraints as Assert; /** - * @ORM\Table(name="chill_calendar.calendar_range") - * @ORM\Entity(repositoryClass=CalendarRangeRepository::class) + * @ORM\Table( + * name="chill_calendar.calendar_range", + * uniqueConstraints={@ORM\UniqueConstraint(name="idx_calendar_range_remote", columns={"remoteId"}, options={"where": "remoteId <> ''"})} + * ) + * @ORM\Entity */ -class CalendarRange +class CalendarRange implements TrackCreationInterface, TrackUpdateInterface { - /** - * @ORM\OneToMany(targetEntity=Calendar::class, - * mappedBy="calendarRange") - */ - private Collection $calendars; + use RemoteCalendarTrait; + + use TrackCreationTrait; + + use TrackUpdateTrait; /** - * @ORM\Column(type="datetimetz_immutable") - * @groups({"read", "write"}) + * @ORM\OneToOne(targetEntity=Calendar::class, mappedBy="calendarRange") + */ + private ?Calendar $calendar = null; + + /** + * @ORM\Column(type="datetime_immutable", nullable=false) + * @Groups({"read", "write", "calendar:read"}) + * @Assert\NotNull */ private ?DateTimeImmutable $endDate = null; @@ -41,27 +53,35 @@ class CalendarRange * @ORM\Id * @ORM\GeneratedValue * @ORM\Column(type="integer") - * @groups({"read"}) + * @Groups({"read"}) */ private $id; /** - * @ORM\Column(type="datetimetz_immutable") - * @groups({"read", "write"}) + * @ORM\ManyToOne(targetEntity=Location::class) + * @ORM\JoinColumn(nullable=false) + * @Groups({"read", "write", "calendar:read"}) + * @Assert\NotNull + */ + private ?Location $location; + + /** + * @ORM\Column(type="datetime_immutable", nullable=false) + * @groups({"read", "write", "calendar:read"}) + * @Assert\NotNull */ private ?DateTimeImmutable $startDate = null; /** * @ORM\ManyToOne(targetEntity="Chill\MainBundle\Entity\User") - * @groups({"read", "write"}) + * @Groups({"read", "write", "calendar:read"}) + * @Assert\NotNull */ private ?User $user = null; - //TODO Lieu - - public function __construct() + public function getCalendar(): ?Calendar { - $this->calendars = new ArrayCollection(); + return $this->calendar; } public function getEndDate(): ?DateTimeImmutable @@ -74,6 +94,11 @@ class CalendarRange return $this->id; } + public function getLocation(): ?Location + { + return $this->location; + } + public function getStartDate(): ?DateTimeImmutable { return $this->startDate; @@ -84,6 +109,14 @@ class CalendarRange return $this->user; } + /** + * @internal use {@link (Calendar::setCalendarRange)} instead + */ + public function setCalendar(?Calendar $calendar): void + { + $this->calendar = $calendar; + } + public function setEndDate(DateTimeImmutable $endDate): self { $this->endDate = $endDate; @@ -91,6 +124,13 @@ class CalendarRange return $this; } + public function setLocation(?Location $location): self + { + $this->location = $location; + + return $this; + } + public function setStartDate(DateTimeImmutable $startDate): self { $this->startDate = $startDate; diff --git a/src/Bundle/ChillCalendarBundle/Entity/Invite.php b/src/Bundle/ChillCalendarBundle/Entity/Invite.php index 464c5485a..e53ca1ca1 100644 --- a/src/Bundle/ChillCalendarBundle/Entity/Invite.php +++ b/src/Bundle/ChillCalendarBundle/Entity/Invite.php @@ -11,39 +11,90 @@ declare(strict_types=1); namespace Chill\CalendarBundle\Entity; -use Chill\CalendarBundle\Repository\InviteRepository; +use Chill\MainBundle\Doctrine\Model\TrackCreationInterface; +use Chill\MainBundle\Doctrine\Model\TrackCreationTrait; +use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface; +use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait; use Chill\MainBundle\Entity\User; use Doctrine\ORM\Mapping as ORM; +use LogicException; +use Symfony\Component\Serializer\Annotation as Serializer; /** - * @ORM\Table(name="chill_calendar.invite") - * @ORM\Entity(repositoryClass=InviteRepository::class) + * An invitation for another user to a Calendar. + * + * The event/calendar in the user may have a different id than the mainUser. We add then fields to store the + * remote id of this event in the remote calendar. + * + * @ORM\Table( + * name="chill_calendar.invite", + * uniqueConstraints={@ORM\UniqueConstraint(name="idx_calendar_invite_remote", columns={"remoteId"}, options={"where": "remoteId <> ''"})} + * ) + * @ORM\Entity */ -class Invite +class Invite implements TrackUpdateInterface, TrackCreationInterface { + use RemoteCalendarTrait; + + use TrackCreationTrait; + + use TrackUpdateTrait; + + public const ACCEPTED = 'accepted'; + + public const DECLINED = 'declined'; + + public const PENDING = 'pending'; + + /** + * all statuses in one const. + */ + public const STATUSES = [ + self::ACCEPTED, + self::DECLINED, + self::PENDING, + self::TENTATIVELY_ACCEPTED, + ]; + + public const TENTATIVELY_ACCEPTED = 'tentative'; + + /** + * @ORM\ManyToOne(targetEntity=Calendar::class, inversedBy="invites") + */ + private ?Calendar $calendar = null; + /** * @ORM\Id * @ORM\GeneratedValue * @ORM\Column(type="integer") + * @Serializer\Groups(groups={"calendar:read", "read"}) */ - private $id; + private ?int $id = null; /** - * @ORM\Column(type="json") + * @ORM\Column(type="text", nullable=false, options={"default": "pending"}) + * @Serializer\Groups(groups={"calendar:read", "read"}) */ - private array $status = []; + private string $status = self::PENDING; /** * @ORM\ManyToOne(targetEntity="Chill\MainBundle\Entity\User") + * @ORM\JoinColumn(nullable=false) + * @Serializer\Groups(groups={"calendar:read", "read"}) */ - private User $user; + private ?User $user = null; + + public function getCalendar(): ?Calendar + { + return $this->calendar; + } public function getId(): ?int { return $this->id; } - public function getStatus(): ?array + public function getStatus(): string { return $this->status; } @@ -53,7 +104,15 @@ class Invite return $this->user; } - public function setStatus(array $status): self + /** + * @internal use Calendar::addInvite instead + */ + public function setCalendar(?Calendar $calendar): void + { + $this->calendar = $calendar; + } + + public function setStatus(string $status): self { $this->status = $status; @@ -62,6 +121,10 @@ class Invite public function setUser(?User $user): self { + if ($user instanceof User && $this->user instanceof User && $user !== $this->user) { + throw new LogicException('Not allowed to associate an invite to a different user'); + } + $this->user = $user; return $this; diff --git a/src/Bundle/ChillCalendarBundle/Entity/RemoteCalendarTrait.php b/src/Bundle/ChillCalendarBundle/Entity/RemoteCalendarTrait.php new file mode 100644 index 000000000..9e162541d --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Entity/RemoteCalendarTrait.php @@ -0,0 +1,64 @@ +remoteAttributes = array_merge($this->remoteAttributes, $remoteAttributes); + + return $this; + } + + public function getRemoteAttributes(): array + { + return $this->remoteAttributes; + } + + public function getRemoteId(): string + { + return $this->remoteId; + } + + public function hasRemoteId(): bool + { + return '' !== $this->remoteId; + } + + public function setRemoteId(string $remoteId): self + { + $this->remoteId = $remoteId; + + return $this; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Form/CalendarType.php b/src/Bundle/ChillCalendarBundle/Form/CalendarType.php index b1dccb5fd..1a38c1ba6 100644 --- a/src/Bundle/ChillCalendarBundle/Form/CalendarType.php +++ b/src/Bundle/ChillCalendarBundle/Form/CalendarType.php @@ -12,18 +12,16 @@ declare(strict_types=1); namespace Chill\CalendarBundle\Form; use Chill\CalendarBundle\Entity\Calendar; -use Chill\CalendarBundle\Entity\CalendarRange; use Chill\CalendarBundle\Entity\CancelReason; -use Chill\CalendarBundle\Entity\Invite; -use Chill\MainBundle\Entity\Location; -use Chill\MainBundle\Entity\User; +use Chill\CalendarBundle\Form\DataTransformer\IdToCalendarRangeDataTransformer; +use Chill\MainBundle\Form\DataTransformer\IdToLocationDataTransformer; +use Chill\MainBundle\Form\DataTransformer\IdToUserDataTransformer; +use Chill\MainBundle\Form\DataTransformer\IdToUsersDataTransformer; use Chill\MainBundle\Form\Type\CommentType; use Chill\MainBundle\Form\Type\PrivateCommentType; -use Chill\MainBundle\Templating\TranslatableStringHelper; -use Chill\PersonBundle\Entity\Person; -use Chill\ThirdPartyBundle\Entity\ThirdParty; +use Chill\PersonBundle\Form\DataTransformer\PersonsToIdDataTransformer; +use Chill\ThirdPartyBundle\Form\DataTransformer\ThirdPartiesToIdDataTransformer; use DateTimeImmutable; -use Doctrine\Persistence\ObjectManager; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\CallbackTransformer; @@ -34,16 +32,32 @@ use Symfony\Component\OptionsResolver\OptionsResolver; class CalendarType extends AbstractType { - protected ObjectManager $om; + private IdToCalendarRangeDataTransformer $calendarRangeDataTransformer; - protected TranslatableStringHelper $translatableStringHelper; + private IdToLocationDataTransformer $idToLocationDataTransformer; + + private IdToUserDataTransformer $idToUserDataTransformer; + + private IdToUsersDataTransformer $idToUsersDataTransformer; + + private ThirdPartiesToIdDataTransformer $partiesToIdDataTransformer; + + private PersonsToIdDataTransformer $personsToIdDataTransformer; public function __construct( - TranslatableStringHelper $translatableStringHelper, - ObjectManager $om + PersonsToIdDataTransformer $personsToIdDataTransformer, + IdToUserDataTransformer $idToUserDataTransformer, + IdToUsersDataTransformer $idToUsersDataTransformer, + IdToLocationDataTransformer $idToLocationDataTransformer, + ThirdPartiesToIdDataTransformer $partiesToIdDataTransformer, + IdToCalendarRangeDataTransformer $idToCalendarRangeDataTransformer ) { - $this->translatableStringHelper = $translatableStringHelper; - $this->om = $om; + $this->personsToIdDataTransformer = $personsToIdDataTransformer; + $this->idToUserDataTransformer = $idToUserDataTransformer; + $this->idToUsersDataTransformer = $idToUsersDataTransformer; + $this->idToLocationDataTransformer = $idToLocationDataTransformer; + $this->partiesToIdDataTransformer = $partiesToIdDataTransformer; + $this->calendarRangeDataTransformer = $idToCalendarRangeDataTransformer; } public function buildForm(FormBuilderInterface $builder, array $options) @@ -64,7 +78,6 @@ class CalendarType extends AbstractType // }, // ]) ->add('sendSMS', ChoiceType::class, [ - 'required' => false, 'choices' => [ 'Oui' => true, 'Non' => false, @@ -73,36 +86,26 @@ class CalendarType extends AbstractType ]); $builder->add('mainUser', HiddenType::class); - $builder->get('mainUser') - ->addModelTransformer(new CallbackTransformer( - static function (?User $user): int { - if (null !== $user) { - $res = $user->getId(); - } else { - $res = -1; //TODO cannot be null in any ways... - } - - return $res; - }, - function (?int $userId): User { - return $this->om->getRepository(user::class)->findOneBy(['id' => (int) $userId]); - } - )); + $builder->get('mainUser')->addModelTransformer($this->idToUserDataTransformer); $builder->add('startDate', HiddenType::class); $builder->get('startDate') ->addModelTransformer(new CallbackTransformer( static function (?DateTimeImmutable $dateTimeImmutable): string { if (null !== $dateTimeImmutable) { - $res = date_format($dateTimeImmutable, 'Y-m-d H:i:s'); + $res = date_format($dateTimeImmutable, DateTimeImmutable::ATOM); } else { $res = ''; } return $res; }, - static function (?string $dateAsString): DateTimeImmutable { - return new DateTimeImmutable($dateAsString); + static function (?string $dateAsString): ?DateTimeImmutable { + if ('' === $dateAsString || null === $dateAsString) { + return null; + } + + return DateTimeImmutable::createFromFormat(DateTimeImmutable::ATOM, $dateAsString); } )); @@ -111,115 +114,41 @@ class CalendarType extends AbstractType ->addModelTransformer(new CallbackTransformer( static function (?DateTimeImmutable $dateTimeImmutable): string { if (null !== $dateTimeImmutable) { - $res = date_format($dateTimeImmutable, 'Y-m-d H:i:s'); + $res = date_format($dateTimeImmutable, DateTimeImmutable::ATOM); } else { $res = ''; } return $res; }, - static function (?string $dateAsString): DateTimeImmutable { - return new DateTimeImmutable($dateAsString); + static function (?string $dateAsString): ?DateTimeImmutable { + if ('' === $dateAsString || null === $dateAsString) { + return null; + } + + return DateTimeImmutable::createFromFormat(DateTimeImmutable::ATOM, $dateAsString); } )); $builder->add('persons', HiddenType::class); $builder->get('persons') - ->addModelTransformer(new CallbackTransformer( - static function (iterable $personsAsIterable): string { - $personIds = []; - - foreach ($personsAsIterable as $value) { - $personIds[] = $value->getId(); - } - - return implode(',', $personIds); - }, - function (?string $personsAsString): array { - return array_map( - fn (string $id): ?Person => $this->om->getRepository(Person::class)->findOneBy(['id' => (int) $id]), - explode(',', $personsAsString) - ); - } - )); + ->addModelTransformer($this->personsToIdDataTransformer); $builder->add('professionals', HiddenType::class); $builder->get('professionals') - ->addModelTransformer(new CallbackTransformer( - static function (iterable $thirdpartyAsIterable): string { - $thirdpartyIds = []; + ->addModelTransformer($this->partiesToIdDataTransformer); - foreach ($thirdpartyAsIterable as $value) { - $thirdpartyIds[] = $value->getId(); - } - - return implode(',', $thirdpartyIds); - }, - function (?string $thirdpartyAsString): array { - return array_map( - fn (string $id): ?ThirdParty => $this->om->getRepository(ThirdParty::class)->findOneBy(['id' => (int) $id]), - explode(',', $thirdpartyAsString) - ); - } - )); + $builder->add('users', HiddenType::class); + $builder->get('users') + ->addModelTransformer($this->idToUsersDataTransformer); $builder->add('calendarRange', HiddenType::class); $builder->get('calendarRange') - ->addModelTransformer(new CallbackTransformer( - static function (?CalendarRange $calendarRange): ?int { - if (null !== $calendarRange) { - $res = $calendarRange->getId(); - } else { - $res = -1; - } - - return $res; - }, - function (?string $calendarRangeId): ?CalendarRange { - if (null !== $calendarRangeId) { - $res = $this->om->getRepository(CalendarRange::class)->findOneBy(['id' => (int) $calendarRangeId]); - } else { - $res = null; - } - - return $res; - } - )); + ->addModelTransformer($this->calendarRangeDataTransformer); $builder->add('location', HiddenType::class) ->get('location') - ->addModelTransformer(new CallbackTransformer( - static function (?Location $location): string { - if (null === $location) { - return ''; - } - - return $location->getId(); - }, - function (?string $id): ?Location { - return $this->om->getRepository(Location::class)->findOneBy(['id' => (int) $id]); - } - )); - - $builder->add('invites', HiddenType::class); - $builder->get('invites') - ->addModelTransformer(new CallbackTransformer( - static function (iterable $usersAsIterable): string { - $userIds = []; - - foreach ($usersAsIterable as $value) { - $userIds[] = $value->getId(); - } - - return implode(',', $userIds); - }, - function (?string $usersAsString): array { - return array_map( - fn (string $id): ?Invite => $this->om->getRepository(Invite::class)->findOneBy(['id' => (int) $id]), - explode(',', $usersAsString) - ); - } - )); + ->addModelTransformer($this->idToLocationDataTransformer); } public function configureOptions(OptionsResolver $resolver) @@ -227,14 +156,11 @@ class CalendarType extends AbstractType $resolver->setDefaults([ 'data_class' => Calendar::class, ]); - - $resolver - ->setRequired(['accompanyingPeriod']) - ->setAllowedTypes('accompanyingPeriod', [\Chill\PersonBundle\Entity\AccompanyingPeriod::class, 'null']); } public function getBlockPrefix() { - return 'chill_calendarbundle_calendar'; + // as the js share some hardcoded items from activity, we have to rewrite block prefix + return 'chill_activitybundle_activity'; } } diff --git a/src/Bundle/ChillCalendarBundle/Form/DataTransformer/IdToCalendarRangeDataTransformer.php b/src/Bundle/ChillCalendarBundle/Form/DataTransformer/IdToCalendarRangeDataTransformer.php new file mode 100644 index 000000000..69bb9eca1 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Form/DataTransformer/IdToCalendarRangeDataTransformer.php @@ -0,0 +1,23 @@ +security = $security; $this->translator = $translator; - $this->authorizationHelper = $authorizationHelper; - $this->tokenStorage = $tokenStorage; } public function buildMenu($menuId, MenuItem $menu, array $parameters) { $period = $parameters['accompanyingCourse']; - if (AccompanyingPeriod::STEP_DRAFT !== $period->getStep()) { - /* + if ($this->security->isGranted(CalendarVoter::SEE, $period)) { $menu->addChild($this->translator->trans('Calendar'), [ - 'route' => 'chill_calendar_calendar_list', + 'route' => 'chill_calendar_calendar_list_by_period', 'routeParameters' => [ - 'accompanying_period_id' => $period->getId(), + 'id' => $period->getId(), ], ]) ->setExtras(['order' => 35]); - */ } } diff --git a/src/Bundle/ChillCalendarBundle/Menu/UserMenuBuilder.php b/src/Bundle/ChillCalendarBundle/Menu/UserMenuBuilder.php index 74512eda5..4049e7c55 100644 --- a/src/Bundle/ChillCalendarBundle/Menu/UserMenuBuilder.php +++ b/src/Bundle/ChillCalendarBundle/Menu/UserMenuBuilder.php @@ -58,7 +58,7 @@ class UserMenuBuilder implements LocalMenuBuilderInterface if ($this->authorizationChecker->isGranted('ROLE_USER')) { $menu->addChild('My calendar list', [ - 'route' => 'chill_calendar_calendar_list', + 'route' => 'chill_calendar_calendar_list_my', ]) ->setExtras([ 'order' => 9, diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Doctrine/CalendarEntityListener.php b/src/Bundle/ChillCalendarBundle/Messenger/Doctrine/CalendarEntityListener.php new file mode 100644 index 000000000..cbb765fba --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Messenger/Doctrine/CalendarEntityListener.php @@ -0,0 +1,70 @@ +messageBus = $messageBus; + $this->security = $security; + } + + public function postPersist(Calendar $calendar, LifecycleEventArgs $args): void + { + if (!$calendar->preventEnqueueChanges) { + $this->messageBus->dispatch( + new CalendarMessage( + $calendar, + CalendarMessage::CALENDAR_PERSIST, + $this->security->getUser() + ) + ); + } + } + + public function postRemove(Calendar $calendar, LifecycleEventArgs $args): void + { + if (!$calendar->preventEnqueueChanges) { + $this->messageBus->dispatch( + new CalendarRemovedMessage( + $calendar, + $this->security->getUser() + ) + ); + } + } + + public function postUpdate(Calendar $calendar, LifecycleEventArgs $args): void + { + if (!$calendar->preventEnqueueChanges) { + $this->messageBus->dispatch( + new CalendarMessage( + $calendar, + CalendarMessage::CALENDAR_UPDATE, + $this->security->getUser() + ) + ); + } + } +} diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Doctrine/CalendarRangeEntityListener.php b/src/Bundle/ChillCalendarBundle/Messenger/Doctrine/CalendarRangeEntityListener.php new file mode 100644 index 000000000..7d85a7537 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Messenger/Doctrine/CalendarRangeEntityListener.php @@ -0,0 +1,70 @@ +messageBus = $messageBus; + $this->security = $security; + } + + public function postPersist(CalendarRange $calendarRange, LifecycleEventArgs $eventArgs): void + { + if (!$calendarRange->preventEnqueueChanges) { + $this->messageBus->dispatch( + new CalendarRangeMessage( + $calendarRange, + CalendarRangeMessage::CALENDAR_RANGE_PERSIST, + $this->security->getUser() + ) + ); + } + } + + public function postRemove(CalendarRange $calendarRange, LifecycleEventArgs $eventArgs): void + { + if (!$calendarRange->preventEnqueueChanges) { + $this->messageBus->dispatch( + new CalendarRangeRemovedMessage( + $calendarRange, + $this->security->getUser() + ) + ); + } + } + + public function postUpdate(CalendarRange $calendarRange, LifecycleEventArgs $eventArgs): void + { + if (!$calendarRange->preventEnqueueChanges) { + $this->messageBus->dispatch( + new CalendarRangeMessage( + $calendarRange, + CalendarRangeMessage::CALENDAR_RANGE_UPDATE, + $this->security->getUser() + ) + ); + } + } +} diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Handler/CalendarRangeRemoveToRemoteHandler.php b/src/Bundle/ChillCalendarBundle/Messenger/Handler/CalendarRangeRemoveToRemoteHandler.php new file mode 100644 index 000000000..8f8b724a5 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Messenger/Handler/CalendarRangeRemoveToRemoteHandler.php @@ -0,0 +1,45 @@ +remoteCalendarConnector = $remoteCalendarConnector; + $this->userRepository = $userRepository; + } + + public function __invoke(CalendarRangeRemovedMessage $calendarRangeRemovedMessage) + { + $this->remoteCalendarConnector->removeCalendarRange( + $calendarRangeRemovedMessage->getRemoteId(), + $calendarRangeRemovedMessage->getRemoteAttributes(), + $this->userRepository->find($calendarRangeRemovedMessage->getCalendarRangeUserId()) + ); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Handler/CalendarRangeToRemoteHandler.php b/src/Bundle/ChillCalendarBundle/Messenger/Handler/CalendarRangeToRemoteHandler.php new file mode 100644 index 000000000..60eb2ae23 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Messenger/Handler/CalendarRangeToRemoteHandler.php @@ -0,0 +1,57 @@ +calendarRangeRepository = $calendarRangeRepository; + $this->remoteCalendarConnector = $remoteCalendarConnector; + $this->entityManager = $entityManager; + } + + public function __invoke(CalendarRangeMessage $calendarRangeMessage): void + { + $range = $this->calendarRangeRepository->find($calendarRangeMessage->getCalendarRangeId()); + + if (null === $range) { + return; + } + + $this->remoteCalendarConnector->syncCalendarRange($range); + $range->preventEnqueueChanges = true; + + $this->entityManager->flush(); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Handler/CalendarRemoveHandler.php b/src/Bundle/ChillCalendarBundle/Messenger/Handler/CalendarRemoveHandler.php new file mode 100644 index 000000000..81e23e745 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Messenger/Handler/CalendarRemoveHandler.php @@ -0,0 +1,55 @@ +remoteCalendarConnector = $remoteCalendarConnector; + $this->calendarRangeRepository = $calendarRangeRepository; + $this->userRepository = $userRepository; + } + + public function __invoke(CalendarRemovedMessage $message) + { + if (null !== $message->getAssociatedCalendarRangeId()) { + $associatedRange = $this->calendarRangeRepository->find($message->getAssociatedCalendarRangeId()); + } else { + $associatedRange = null; + } + + $this->remoteCalendarConnector->removeCalendar( + $message->getRemoteId(), + $message->getRemoteAttributes(), + $this->userRepository->find($message->getCalendarUserId()), + $associatedRange + ); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Handler/CalendarToRemoteHandler.php b/src/Bundle/ChillCalendarBundle/Messenger/Handler/CalendarToRemoteHandler.php new file mode 100644 index 000000000..e0a4c30ee --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Messenger/Handler/CalendarToRemoteHandler.php @@ -0,0 +1,112 @@ +calendarConnector = $calendarConnector; + $this->calendarRepository = $calendarRepository; + $this->calendarRangeRepository = $calendarRangeRepository; + $this->entityManager = $entityManager; + $this->userRepository = $userRepository; + $this->inviteRepository = $inviteRepository; + } + + public function __invoke(CalendarMessage $calendarMessage) + { + $calendar = $this->calendarRepository->find($calendarMessage->getCalendarId()); + + if (null === $calendar) { + return; + } + + if (null !== $calendarMessage->getPreviousCalendarRangeId()) { + $previousCalendarRange = $this->calendarRangeRepository + ->find($calendarMessage->getPreviousCalendarRangeId()); + } else { + $previousCalendarRange = null; + } + + if (null !== $calendarMessage->getPreviousMainUserId()) { + $previousMainUser = $this->userRepository + ->find($calendarMessage->getPreviousMainUserId()); + } else { + $previousMainUser = null; + } + + $newInvites = array_filter( + array_map( + function ($id) { return $this->inviteRepository->find($id); }, + $calendarMessage->getNewInvitesIds(), + ), + static function (?Invite $invite) { return null !== $invite; } + ); + + $this->calendarConnector->syncCalendar( + $calendar, + $calendarMessage->getAction(), + $previousCalendarRange, + $previousMainUser, + $calendarMessage->getOldInvites(), + $newInvites + ); + + $calendar->preventEnqueueChanges = true; + + if ($calendar->hasCalendarRange()) { + $calendar->getCalendarRange()->preventEnqueueChanges = true; + } + + if ($previousCalendarRange instanceof CalendarRange) { + $previousCalendarRange->preventEnqueueChanges = true; + } + + $this->entityManager->flush(); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Handler/InviteUpdateHandler.php b/src/Bundle/ChillCalendarBundle/Messenger/Handler/InviteUpdateHandler.php new file mode 100644 index 000000000..30888a05b --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Messenger/Handler/InviteUpdateHandler.php @@ -0,0 +1,50 @@ +em = $em; + $this->inviteRepository = $inviteRepository; + $this->remoteCalendarConnector = $remoteCalendarConnector; + } + + public function __invoke(InviteUpdateMessage $inviteUpdateMessage): void + { + if (null === $invite = $this->inviteRepository->find($inviteUpdateMessage->getInviteId())) { + return; + } + + $this->remoteCalendarConnector->syncInvite($invite); + + $this->em->flush(); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Handler/MSGraphChangeNotificationHandler.php b/src/Bundle/ChillCalendarBundle/Messenger/Handler/MSGraphChangeNotificationHandler.php new file mode 100644 index 000000000..744b1a301 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Messenger/Handler/MSGraphChangeNotificationHandler.php @@ -0,0 +1,103 @@ +calendarRangeRepository = $calendarRangeRepository; + $this->calendarRangeSyncer = $calendarRangeSyncer; + $this->calendarRepository = $calendarRepository; + $this->calendarSyncer = $calendarSyncer; + $this->em = $em; + $this->logger = $logger; + $this->mapCalendarToUser = $mapCalendarToUser; + $this->userRepository = $userRepository; + } + + public function __invoke(MSGraphChangeNotificationMessage $changeNotificationMessage): void + { + $user = $this->userRepository->find($changeNotificationMessage->getUserId()); + + if (null === $user) { + $this->logger->warning(__CLASS__ . ' notification concern non-existent user, skipping'); + + return; + } + + foreach ($changeNotificationMessage->getContent()['value'] as $notification) { + $secret = $this->mapCalendarToUser->getSubscriptionSecret($user); + + if ($secret !== ($notification['clientState'] ?? -1)) { + $this->logger->warning(__CLASS__ . ' could not validate secret, skipping'); + + continue; + } + + $remoteId = $notification['resourceData']['id']; + + // is this a calendar range ? + if (null !== $calendarRange = $this->calendarRangeRepository->findOneBy(['remoteId' => $remoteId])) { + $this->calendarRangeSyncer->handleCalendarRangeSync($calendarRange, $notification, $user); + $this->em->flush(); + } elseif (null !== $calendar = $this->calendarRepository->findOneBy(['remoteId' => $remoteId])) { + $this->calendarSyncer->handleCalendarSync($calendar, $notification, $user); + $this->em->flush(); + } else { + $this->logger->info(__CLASS__ . ' id not found in any calendar nor calendar range'); + } + } + + $this->em->flush(); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Message/CalendarMessage.php b/src/Bundle/ChillCalendarBundle/Messenger/Message/CalendarMessage.php new file mode 100644 index 000000000..44af1e6a7 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Messenger/Message/CalendarMessage.php @@ -0,0 +1,104 @@ + + */ + private array $oldInvites = []; + + private ?int $previousCalendarRangeId = null; + + private ?int $previousMainUserId = null; + + public function __construct( + Calendar $calendar, + string $action, + User $byUser + ) { + $this->calendarId = $calendar->getId(); + $this->byUserId = $byUser->getId(); + $this->action = $action; + $this->previousCalendarRangeId = null !== $calendar->previousCalendarRange ? + $calendar->previousCalendarRange->getId() : null; + $this->previousMainUserId = null !== $calendar->previousMainUser ? + $calendar->previousMainUser->getId() : null; + $this->newInvitesIds = array_map(static fn (Invite $i) => $i->getId(), $calendar->newInvites); + $this->oldInvites = array_map(static function (Invite $i) { + return [ + 'inviteId' => $i->getId(), + 'userId' => $i->getUser()->getId(), + 'userEmail' => $i->getUser()->getEmail(), + 'userLabel' => $i->getUser()->getLabel(), + ]; + }, $calendar->oldInvites); + } + + public function getAction(): string + { + return $this->action; + } + + public function getByUserId(): ?int + { + return $this->byUserId; + } + + public function getCalendarId(): ?int + { + return $this->calendarId; + } + + /** + * @return array|int[]|null[] + */ + public function getNewInvitesIds(): array + { + return $this->newInvitesIds; + } + + /** + * @return array + */ + public function getOldInvites(): array + { + return $this->oldInvites; + } + + public function getPreviousCalendarRangeId(): ?int + { + return $this->previousCalendarRangeId; + } + + public function getPreviousMainUserId(): ?int + { + return $this->previousMainUserId; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Message/CalendarRangeMessage.php b/src/Bundle/ChillCalendarBundle/Messenger/Message/CalendarRangeMessage.php new file mode 100644 index 000000000..b02697147 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Messenger/Message/CalendarRangeMessage.php @@ -0,0 +1,53 @@ +action = $action; + $this->calendarRangeId = $calendarRange->getId(); + + if (null !== $byUser) { + $this->byUserId = $byUser->getId(); + } + } + + public function getAction(): string + { + return $this->action; + } + + public function getByUserId(): ?int + { + return $this->byUserId; + } + + public function getCalendarRangeId(): ?int + { + return $this->calendarRangeId; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Message/CalendarRangeRemovedMessage.php b/src/Bundle/ChillCalendarBundle/Messenger/Message/CalendarRangeRemovedMessage.php new file mode 100644 index 000000000..e855f91a3 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Messenger/Message/CalendarRangeRemovedMessage.php @@ -0,0 +1,57 @@ +remoteId = $calendarRange->getRemoteId(); + $this->remoteAttributes = $calendarRange->getRemoteAttributes(); + $this->calendarRangeUserId = $calendarRange->getUser()->getId(); + + if (null !== $byUser) { + $this->byUserId = $byUser->getId(); + } + } + + public function getByUserId(): ?int + { + return $this->byUserId; + } + + public function getCalendarRangeUserId(): ?int + { + return $this->calendarRangeUserId; + } + + public function getRemoteAttributes(): array + { + return $this->remoteAttributes; + } + + public function getRemoteId(): string + { + return $this->remoteId; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Message/CalendarRemovedMessage.php b/src/Bundle/ChillCalendarBundle/Messenger/Message/CalendarRemovedMessage.php new file mode 100644 index 000000000..2c3db661b --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Messenger/Message/CalendarRemovedMessage.php @@ -0,0 +1,68 @@ +remoteId = $calendar->getRemoteId(); + $this->remoteAttributes = $calendar->getRemoteAttributes(); + $this->calendarUserId = $calendar->getMainUser()->getId(); + + if ($calendar->hasCalendarRange()) { + $this->associatedCalendarRangeId = $calendar->getCalendarRange()->getId(); + } + + if (null !== $byUser) { + $this->byUserId = $byUser->getId(); + } + } + + public function getAssociatedCalendarRangeId(): ?int + { + return $this->associatedCalendarRangeId; + } + + public function getByUserId(): ?int + { + return $this->byUserId; + } + + public function getCalendarUserId(): ?int + { + return $this->calendarUserId; + } + + public function getRemoteAttributes(): array + { + return $this->remoteAttributes; + } + + public function getRemoteId(): string + { + return $this->remoteId; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Message/InviteUpdateMessage.php b/src/Bundle/ChillCalendarBundle/Messenger/Message/InviteUpdateMessage.php new file mode 100644 index 000000000..ddb5d9028 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Messenger/Message/InviteUpdateMessage.php @@ -0,0 +1,38 @@ +inviteId = $invite->getId(); + $this->byUserId = $byUser->getId(); + } + + public function getByUserId(): int + { + return $this->byUserId; + } + + public function getInviteId(): int + { + return $this->inviteId; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Message/MSGraphChangeNotificationMessage.php b/src/Bundle/ChillCalendarBundle/Messenger/Message/MSGraphChangeNotificationMessage.php new file mode 100644 index 000000000..f5e0f98cb --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Messenger/Message/MSGraphChangeNotificationMessage.php @@ -0,0 +1,35 @@ +content = $content; + $this->userId = $userId; + } + + public function getContent(): array + { + return $this->content; + } + + public function getUserId(): int + { + return $this->userId; + } +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/AddressConverter.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/AddressConverter.php new file mode 100644 index 000000000..3d283ed9d --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/AddressConverter.php @@ -0,0 +1,41 @@ +addressRender = $addressRender; + $this->translatableStringHelper = $translatableStringHelper; + } + + public function addressToRemote(Address $address): array + { + return [ + 'city' => $address->getPostcode()->getName(), + 'postalCode' => $address->getPostcode()->getCode(), + 'countryOrRegion' => $this->translatableStringHelper->localize($address->getPostcode()->getCountry()->getName()), + 'street' => $address->isNoAddress() ? '' : + implode(', ', $this->addressRender->renderLines($address, false, false)), + 'state' => '', + ]; + } +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/EventsOnUserSubscriptionCreator.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/EventsOnUserSubscriptionCreator.php new file mode 100644 index 000000000..de80aa3fd --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/EventsOnUserSubscriptionCreator.php @@ -0,0 +1,133 @@ +logger = $logger; + $this->machineHttpClient = $machineHttpClient; + $this->mapCalendarToUser = $mapCalendarToUser; + $this->urlGenerator = $urlGenerator; + } + + /** + * @throws ClientExceptionInterface + * @throws \Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface + * @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface + * @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface + * @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface + * + * @return array + */ + public function createSubscriptionForUser(User $user, DateTimeImmutable $expiration): array + { + if (null === $userId = $this->mapCalendarToUser->getUserId($user)) { + throw new LogicException('no user id'); + } + + $subscription = [ + 'changeType' => 'deleted,updated', + 'notificationUrl' => $this->urlGenerator->generate( + 'chill_calendar_remote_msgraph_incoming_webhook_events', + ['userId' => $user->getId()], + UrlGeneratorInterface::ABSOLUTE_URL + ), + 'resource' => "/users/{$userId}/calendar/events", + 'clientState' => $secret = base64_encode(openssl_random_pseudo_bytes(92, $cstrong)), + 'expirationDateTime' => $expiration->format(DateTimeImmutable::ATOM), + ]; + + try { + $subs = $this->machineHttpClient->request( + 'POST', + '/v1.0/subscriptions', + [ + 'json' => $subscription, + ] + )->toArray(); + } catch (ClientExceptionInterface $e) { + $this->logger->error('could not create subscription for user events', [ + 'body' => $e->getResponse()->getContent(false), + ]); + + return ['secret' => '', 'id' => '', 'expiration' => 0]; + } + + return ['secret' => $secret, 'id' => $subs['id'], 'expiration' => $expiration->getTimestamp()]; + } + + /** + * @throws ClientExceptionInterface + * @throws \Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface + * @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface + * @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface + * @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface + * + * @return array + */ + public function renewSubscriptionForUser(User $user, DateTimeImmutable $expiration): array + { + if (null === $userId = $this->mapCalendarToUser->getUserId($user)) { + throw new LogicException('no user id'); + } + + if (null === $subscriptionId = $this->mapCalendarToUser->getActiveSubscriptionId($user)) { + throw new LogicException('no user id'); + } + + $subscription = [ + 'expirationDateTime' => $expiration->format(DateTimeImmutable::ATOM), + ]; + + try { + $subs = $this->machineHttpClient->request( + 'PATCH', + "/v1.0/subscriptions/{$subscriptionId}", + [ + 'json' => $subscription, + ] + )->toArray(); + } catch (ClientExceptionInterface $e) { + $this->logger->error('could not patch subscription for user events', [ + 'body' => $e->getResponse()->getContent(false), + ]); + + return ['secret' => '', 'id' => '', 'expiration' => 0]; + } + + return ['secret' => $subs['clientState'], 'id' => $subs['id'], 'expiration' => $expiration->getTimestamp()]; + } +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/LocationConverter.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/LocationConverter.php new file mode 100644 index 000000000..bf34ff8bb --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/LocationConverter.php @@ -0,0 +1,46 @@ +addressConverter = $addressConverter; + } + + public function locationToRemote(Location $location): array + { + $results = []; + + if ($location->hasAddress()) { + $results['address'] = $this->addressConverter->addressToRemote($location->getAddress()); + + if ($location->getAddress()->hasAddressReference() && $location->getAddress()->getAddressReference()->hasPoint()) { + $results['coordinates'] = [ + 'latitude' => $location->getAddress()->getAddressReference()->getPoint()->getLat(), + 'longitude' => $location->getAddress()->getAddressReference()->getPoint()->getLon(), + ]; + } + } + + if (null !== $location->getName()) { + $results['displayName'] = $location->getName(); + } + + return $results; + } +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MSGraphUserRepository.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MSGraphUserRepository.php new file mode 100644 index 000000000..d3caea942 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MSGraphUserRepository.php @@ -0,0 +1,77 @@ +'msgraph' ?? 'subscription_events_expiration' + OR (attributes->'msgraph' ?? 'subscription_events_expiration' AND (attributes->'msgraph'->>'subscription_events_expiration')::int < EXTRACT(EPOCH FROM (NOW() + :interval::interval))) + LIMIT :limit OFFSET :offset + ; + SQL; + + private EntityManagerInterface $entityManager; + + public function __construct(EntityManagerInterface $entityManager) + { + $this->entityManager = $entityManager; + } + + public function countByMostOldSubscriptionOrWithoutSubscriptionOrData(DateInterval $interval): int + { + $rsm = new ResultSetMapping(); + $rsm->addScalarResult('c', 'c'); + + $sql = strtr(self::MOST_OLD_SUBSCRIPTION_OR_ANY_MS_GRAPH, [ + '{select}' => 'COUNT(u) AS c', + 'LIMIT :limit OFFSET :offset' => '', + ]); + + return $this->entityManager->createNativeQuery($sql, $rsm)->setParameters([ + 'interval' => $interval, + ])->getSingleScalarResult(); + } + + /** + * @return array|User[] + */ + public function findByMostOldSubscriptionOrWithoutSubscriptionOrData(DateInterval $interval, int $limit = 50, int $offset = 0): array + { + $rsm = new ResultSetMappingBuilder($this->entityManager); + $rsm->addRootEntityFromClassMetadata(User::class, 'u'); + + return $this->entityManager->createNativeQuery( + strtr(self::MOST_OLD_SUBSCRIPTION_OR_ANY_MS_GRAPH, ['{select}' => $rsm->generateSelectClause()]), + $rsm + )->setParameters([ + 'interval' => $interval, + 'limit' => $limit, + 'offset' => $offset, + ])->getResult(); + } +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MachineHttpClient.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MachineHttpClient.php new file mode 100644 index 000000000..32a4cdf01 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MachineHttpClient.php @@ -0,0 +1,75 @@ +decoratedClient = $decoratedClient ?? \Symfony\Component\HttpClient\HttpClient::create(); + $this->machineTokenStorage = $machineTokenStorage; + } + + /** + * @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface + * @throws LogicException if method is not supported + */ + public function request(string $method, string $url, array $options = []): ResponseInterface + { + $options['headers'] = array_merge( + $options['headers'] ?? [], + $this->getAuthorizationHeaders($this->machineTokenStorage->getToken()) + ); + $options['base_uri'] = 'https://graph.microsoft.com/v1.0/'; + + switch ($method) { + case 'GET': + case 'HEAD': + case 'DELETE': + $options['headers']['Accept'] = 'application/json'; + + break; + + case 'POST': + case 'PUT': + case 'PATCH': + $options['headers']['Content-Type'] = 'application/json'; + + break; + + default: + throw new LogicException("Method not supported: {$method}"); + } + + return $this->decoratedClient->request($method, $url, $options); + } + + public function stream($responses, ?float $timeout = null): ResponseStreamInterface + { + return $this->decoratedClient->stream($responses, $timeout); + } +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MachineTokenStorage.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MachineTokenStorage.php new file mode 100644 index 000000000..31533722e --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MachineTokenStorage.php @@ -0,0 +1,50 @@ +azure = $azure; + $this->chillRedis = $chillRedis; + } + + public function getToken(): AccessTokenInterface + { + if (null === $this->accessToken || $this->accessToken->hasExpired()) { + $this->accessToken = $this->azure->getAccessToken('client_credentials', [ + 'scope' => 'https://graph.microsoft.com/.default', + ]); + } + + return $this->accessToken; + } + + public function storeToken(AccessToken $token): void + { + $this->chillRedis->set(self::KEY, serialize($token)); + } +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MapCalendarToUser.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MapCalendarToUser.php new file mode 100644 index 000000000..7e7d55739 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MapCalendarToUser.php @@ -0,0 +1,193 @@ +machineHttpClient = $machineHttpClient; + $this->logger = $logger; + } + + public function getActiveSubscriptionId(User $user): string + { + if (!array_key_exists(self::METADATA_KEY, $user->getAttributes())) { + throw new LogicException('do not contains msgraph metadata'); + } + + if (!array_key_exists(self::ID_SUBSCRIPTION_EVENT, $user->getAttributes()[self::METADATA_KEY])) { + throw new LogicException('do not contains metadata for subscription id'); + } + + return $user->getAttributes()[self::METADATA_KEY][self::ID_SUBSCRIPTION_EVENT]; + } + + public function getCalendarId(User $user): ?string + { + if (null === $msKey = ($user->getAttributes()[self::METADATA_KEY] ?? null)) { + return null; + } + + return $msKey['defaultCalendarId'] ?? null; + } + + public function getDefaultUserCalendar(string $idOrUserPrincipalName): ?array + { + $value = $this->machineHttpClient->request('GET', "users/{$idOrUserPrincipalName}/calendars", [ + 'query' => ['$filter' => 'isDefaultCalendar eq true'], + ])->toArray()['value']; + + return $value[0] ?? null; + } + + public function getSubscriptionSecret(User $user): string + { + if (!array_key_exists(self::METADATA_KEY, $user->getAttributes())) { + throw new LogicException('do not contains msgraph metadata'); + } + + if (!array_key_exists(self::SECRET_SUBSCRIPTION_EVENT, $user->getAttributes()[self::METADATA_KEY])) { + throw new LogicException('do not contains secret in msgraph'); + } + + return $user->getAttributes()[self::METADATA_KEY][self::SECRET_SUBSCRIPTION_EVENT]; + } + + public function getUserByEmail(string $email): ?array + { + $value = $this->machineHttpClient->request('GET', 'users', [ + 'query' => ['$filter' => "mail eq '{$email}'"], + ])->toArray()['value']; + + return $value[0] ?? null; + } + + public function getUserId(User $user): ?string + { + if (null === $msKey = ($user->getAttributes()[self::METADATA_KEY] ?? null)) { + return null; + } + + return $msKey['id'] ?? null; + } + + public function hasActiveSubscription(User $user): bool + { + if (!array_key_exists(self::METADATA_KEY, $user->getAttributes())) { + return false; + } + + if (!array_key_exists(self::EXPIRATION_SUBSCRIPTION_EVENT, $user->getAttributes()[self::METADATA_KEY])) { + return false; + } + + return $user->getAttributes()[self::METADATA_KEY][self::EXPIRATION_SUBSCRIPTION_EVENT] + >= (new DateTimeImmutable('now'))->getTimestamp(); + } + + public function hasSubscriptionSecret(User $user): bool + { + if (!array_key_exists(self::METADATA_KEY, $user->getAttributes())) { + return false; + } + + return array_key_exists(self::SECRET_SUBSCRIPTION_EVENT, $user->getAttributes()[self::METADATA_KEY]); + } + + public function hasUserId(User $user): bool + { + if (null === $user->getEmail() || '' === $user->getEmail()) { + return false; + } + + if (!array_key_exists(self::METADATA_KEY, $user->getAttributes())) { + return false; + } + + return array_key_exists('id', $user->getAttributes()[self::METADATA_KEY]); + } + + public function writeMetadata(User $user): User + { + if (null === $user->getEmail() OR '' === $user->getEmail()) { + return $user; + } + + if (null === $userData = $this->getUserByEmail($user->getEmailCanonical())) { + $this->logger->warning('[MapCalendarToUser] could not find user on msgraph', ['userId' => $user->getId(), 'email' => $user->getEmailCanonical()]); + + return $this->writeNullData($user); + } + + if (null === $defaultCalendar = $this->getDefaultUserCalendar($userData['id'])) { + $this->logger->warning('[MapCalendarToUser] could not find default calendar', ['userId' => $user->getId(), 'email' => $user->getEmailCanonical()]); + + return $this->writeNullData($user); + } + + return $user->setAttributes([self::METADATA_KEY => [ + 'id' => $userData['id'], + 'userPrincipalName' => $userData['userPrincipalName'], + 'defaultCalendarId' => $defaultCalendar['id'], + ]]); + } + + /** + * @param int $expiration the expiration time as unix timestamp + */ + public function writeSubscriptionMetadata( + User $user, + int $expiration, + ?string $id = null, + ?string $secret = null + ): void { + $user->setAttributeByDomain(self::METADATA_KEY, self::EXPIRATION_SUBSCRIPTION_EVENT, $expiration); + + if (null !== $id) { + $user->setAttributeByDomain(self::METADATA_KEY, self::ID_SUBSCRIPTION_EVENT, $id); + } + + if (null !== $secret) { + $user->setAttributeByDomain(self::METADATA_KEY, self::SECRET_SUBSCRIPTION_EVENT, $secret); + } + } + + private function writeNullData(User $user): User + { + return $user->unsetAttribute(self::METADATA_KEY); + } +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/OnBehalfOfUserHttpClient.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/OnBehalfOfUserHttpClient.php new file mode 100644 index 000000000..9ca988287 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/OnBehalfOfUserHttpClient.php @@ -0,0 +1,70 @@ +decoratedClient = $decoratedClient ?? \Symfony\Component\HttpClient\HttpClient::create(); + $this->tokenStorage = $tokenStorage; + } + + public function request(string $method, string $url, array $options = []): ResponseInterface + { + $options['headers'] = array_merge( + $options['headers'] ?? [], + $this->getAuthorizationHeaders($this->tokenStorage->getToken()) + ); + $options['base_uri'] = 'https://graph.microsoft.com/v1.0/'; + + switch ($method) { + case 'GET': + case 'HEAD': + $options['headers']['Accept'] = 'application/json'; + + break; + + case 'POST': + case 'PUT': + case 'PATCH': + $options['headers']['Content-Type'] = 'application/json'; + + break; + + default: + throw new LogicException("Method not supported: {$method}"); + } + + return $this->decoratedClient->request($method, $url, $options); + } + + public function stream($responses, ?float $timeout = null): ResponseStreamInterface + { + return $this->decoratedClient->stream($responses, $timeout); + } +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/OnBehalfOfUserTokenStorage.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/OnBehalfOfUserTokenStorage.php new file mode 100644 index 000000000..580c95888 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/OnBehalfOfUserTokenStorage.php @@ -0,0 +1,65 @@ +azure = $azure; + $this->session = $session; + } + + public function getToken(): AccessToken + { + /** @var ?AccessToken $token */ + $token = $this->session->get(self::MS_GRAPH_ACCESS_TOKEN, null); + + if (null === $token) { + throw new LogicException('unexisting token'); + } + + if ($token->hasExpired()) { + $token = $this->azure->getAccessToken('refresh_token', [ + 'refresh_token' => $token->getRefreshToken(), + ]); + + $this->setToken($token); + } + + return $token; + } + + public function hasToken(): bool + { + return $this->session->has(self::MS_GRAPH_ACCESS_TOKEN); + } + + public function setToken(AccessToken $token): void + { + $this->session->set(self::MS_GRAPH_ACCESS_TOKEN, $token); + } +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteEventConverter.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteEventConverter.php new file mode 100644 index 000000000..15b77a297 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteEventConverter.php @@ -0,0 +1,277 @@ +engine = $engine; + $this->locationConverter = $locationConverter; + $this->logger = $logger; + $this->translator = $translator; + $this->personRender = $personRender; + $this->defaultDateTimeZone = (new DateTimeImmutable())->getTimezone(); + $this->remoteDateTimeZone = self::getRemoteTimeZone(); + } + + /** + * Transform a CalendarRange into a representation suitable for storing into MSGraph. + * + * @return array an array representation for event in MS Graph + */ + public function calendarRangeToEvent(CalendarRange $calendarRange): array + { + return [ + 'subject' => $this->translator->trans('remote_calendar.calendar_range_title'), + 'start' => [ + 'dateTime' => $calendarRange->getStartDate()->setTimezone($this->remoteDateTimeZone) + ->format(self::REMOTE_DATE_FORMAT), + 'timeZone' => 'UTC', + ], + 'end' => [ + 'dateTime' => $calendarRange->getEndDate()->setTimezone($this->remoteDateTimeZone) + ->format(self::REMOTE_DATE_FORMAT), + 'timeZone' => 'UTC', + ], + 'attendees' => [ + [ + 'emailAddress' => [ + 'address' => $calendarRange->getUser()->getEmailCanonical(), + 'name' => $calendarRange->getUser()->getLabel(), + ], + ], + ], + 'isReminderOn' => false, + 'location' => $this->locationConverter->locationToRemote($calendarRange->getLocation()), + ]; + } + + public function calendarToEvent(Calendar $calendar): array + { + $result = array_merge( + [ + 'subject' => '[Chill] ' . + implode( + ', ', + $calendar->getPersons()->map(function (Person $p) { + return $this->personRender->renderString($p, []); + })->toArray() + ), + 'start' => [ + 'dateTime' => $calendar->getStartDate()->setTimezone($this->remoteDateTimeZone) + ->format(self::REMOTE_DATE_FORMAT), + 'timeZone' => 'UTC', + ], + 'end' => [ + 'dateTime' => $calendar->getEndDate()->setTimezone($this->remoteDateTimeZone) + ->format(self::REMOTE_DATE_FORMAT), + 'timeZone' => 'UTC', + ], + 'allowNewTimeProposals' => false, + 'transactionId' => 'calendar_' . $calendar->getId(), + 'body' => [ + 'contentType' => 'text', + 'content' => $this->engine->render( + '@ChillCalendar/MSGraph/calendar_event_body.html.twig', + ['calendar' => $calendar] + ), + ], + 'responseRequested' => true, + 'isReminderOn' => false, + ], + $this->calendarToEventAttendeesOnly($calendar) + ); + + if ($calendar->hasLocation()) { + $result['location'] = $this->locationConverter->locationToRemote($calendar->getLocation()); + } + + return $result; + } + + public function calendarToEventAttendeesOnly(Calendar $calendar): array + { + return [ + 'attendees' => $calendar->getInvites()->map( + function (Invite $i) { + return $this->buildInviteToAttendee($i); + } + )->toArray(), + ]; + } + + public function convertAvailabilityToRemoteEvent(array $event): RemoteEvent + { + $startDate = + DateTimeImmutable::createFromFormat(self::REMOTE_DATE_FORMAT, $event['start']['dateTime'], $this->remoteDateTimeZone) + ->setTimezone($this->defaultDateTimeZone); + $endDate = + DateTimeImmutable::createFromFormat(self::REMOTE_DATE_FORMAT, $event['end']['dateTime'], $this->remoteDateTimeZone) + ->setTimezone($this->defaultDateTimeZone); + + return new RemoteEvent( + uniqid('generated_'), + $this->translator->trans('remote_ms_graph.freebusy_statuses.' . $event['status']), + '', + $startDate, + $endDate + ); + } + + public static function convertStringDateWithoutTimezone(string $date): DateTimeImmutable + { + $d = DateTimeImmutable::createFromFormat( + self::REMOTE_DATETIME_WITHOUT_TZ_FORMAT, + $date, + self::getRemoteTimeZone() + ); + + if (false === $d) { + throw new RuntimeException("could not convert string date to datetime: {$date}"); + } + + return $d->setTimezone((new DateTimeImmutable())->getTimezone()); + } + + public static function convertStringDateWithTimezone(string $date): DateTimeImmutable + { + $d = DateTimeImmutable::createFromFormat(self::REMOTE_DATETIMEZONE_FORMAT, $date); + + if (false === $d) { + throw new RuntimeException("could not convert string date to datetime: {$date}"); + } + + $d->setTimezone((new DateTimeImmutable())->getTimezone()); + + return $d; + } + + public function convertToRemote(array $event): RemoteEvent + { + $startDate = + DateTimeImmutable::createFromFormat(self::REMOTE_DATE_FORMAT, $event['start']['dateTime'], $this->remoteDateTimeZone) + ->setTimezone($this->defaultDateTimeZone); + $endDate = + DateTimeImmutable::createFromFormat(self::REMOTE_DATE_FORMAT, $event['end']['dateTime'], $this->remoteDateTimeZone) + ->setTimezone($this->defaultDateTimeZone); + + return new RemoteEvent( + $event['id'], + $event['subject'], + '', + $startDate, + $endDate, + $event['isAllDay'] + ); + } + + public function getLastModifiedDate(array $event): DateTimeImmutable + { + $date = DateTimeImmutable::createFromFormat(self::REMOTE_DATETIMEZONE_FORMAT, $event['lastModifiedDateTime']); + + if (false === $date) { + $date = DateTimeImmutable::createFromFormat(self::REMOTE_DATETIMEZONE_FORMAT_ALT, $event['lastModifiedDateTime']); + } + + if (false === $date) { + $this->logger->error(self::class . ' Could not convert lastModifiedDate', [ + 'actual' => $event['lastModifiedDateTime'], + 'format' => self::REMOTE_DATETIMEZONE_FORMAT, + 'format_alt' => self::REMOTE_DATETIMEZONE_FORMAT_ALT, + ]); + + throw new RuntimeException(sprintf( + 'could not convert lastModifiedDate: %s, expected format: %s', + $event['lastModifiedDateTime'], + self::REMOTE_DATETIMEZONE_FORMAT . ' and ' . self::REMOTE_DATETIMEZONE_FORMAT_ALT + )); + } + + return $date; + } + + /** + * Return a string which format a DateTime to string. To be used in POST requests,. + */ + public static function getRemoteDateTimeSimpleFormat(): string + { + return 'Y-m-d\TH:i:s'; + } + + public static function getRemoteTimeZone(): DateTimeZone + { + return new DateTimeZone('UTC'); + } + + private function buildInviteToAttendee(Invite $invite): array + { + return [ + 'emailAddress' => [ + 'address' => $invite->getUser()->getEmail(), + 'name' => $invite->getUser()->getLabel(), + ], + 'type' => 'Required', + ]; + } +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteToLocalSync/CalendarRangeSyncer.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteToLocalSync/CalendarRangeSyncer.php new file mode 100644 index 000000000..18d1e4632 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteToLocalSync/CalendarRangeSyncer.php @@ -0,0 +1,103 @@ +em = $em; + $this->logger = $logger; + $this->machineHttpClient = $machineHttpClient; + } + + public function handleCalendarRangeSync(CalendarRange $calendarRange, array $notification, User $user): void + { + switch ($notification['changeType']) { + case 'deleted': + // test if the notification is not linked to a Calendar + if (null !== $calendarRange->getCalendar()) { + return; + } + $calendarRange->preventEnqueueChanges = true; + + $this->logger->info(__CLASS__ . ' remove a calendar range because deleted on remote calendar'); + $this->em->remove($calendarRange); + + break; + + case 'updated': + try { + $new = $this->machineHttpClient->request( + 'GET', + $notification['resource'] + )->toArray(); + } catch (ClientExceptionInterface $clientException) { + $this->logger->warning(__CLASS__ . ' could not retrieve event from ms graph. Already deleted ?', [ + 'calendarRangeId' => $calendarRange->getId(), + 'remoteEventId' => $notification['resource'], + ]); + + throw $clientException; + } + + $lastModified = RemoteEventConverter::convertStringDateWithTimezone($new['lastModifiedDateTime']); + + if ($calendarRange->getRemoteAttributes()['lastModifiedDateTime'] === $lastModified->getTimestamp()) { + $this->logger->info(__CLASS__ . ' change key is equals. Source is probably a local update', [ + 'calendarRangeId' => $calendarRange->getId(), + 'remoteEventId' => $notification['resource'], + ]); + + return; + } + + $startDate = RemoteEventConverter::convertStringDateWithoutTimezone($new['start']['dateTime']); + $endDate = RemoteEventConverter::convertStringDateWithoutTimezone($new['end']['dateTime']); + + $calendarRange + ->setStartDate($startDate)->setEndDate($endDate) + ->addRemoteAttributes([ + 'lastModifiedDateTime' => $lastModified->getTimestamp(), + 'changeKey' => $new['changeKey'], + ]) + ->preventEnqueueChanges = true; + + break; + + default: + throw new RuntimeException('This changeType is not suppored: ' . $notification['changeType']); + } + } +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteToLocalSync/CalendarSyncer.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteToLocalSync/CalendarSyncer.php new file mode 100644 index 000000000..db947e039 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteToLocalSync/CalendarSyncer.php @@ -0,0 +1,182 @@ +logger = $logger; + $this->machineHttpClient = $machineHttpClient; + $this->userRepository = $userRepository; + } + + public function handleCalendarSync(Calendar $calendar, array $notification, User $user): void + { + switch ($notification['changeType']) { + case 'deleted': + $this->handleDeleteCalendar($calendar, $notification, $user); + + break; + + case 'updated': + $this->handleUpdateCalendar($calendar, $notification, $user); + + break; + + default: + throw new RuntimeException('this change type is not supported: ' . $notification['changeType']); + } + } + + private function handleDeleteCalendar(Calendar $calendar, array $notification, User $user): void + { + $calendar + ->setStatus(Calendar::STATUS_CANCELED) + ->setCalendarRange(null); + $calendar->preventEnqueueChanges = true; + } + + private function handleUpdateCalendar(Calendar $calendar, array $notification, User $user): void + { + try { + $new = $this->machineHttpClient->request( + 'GET', + $notification['resource'] + )->toArray(); + } catch (ClientExceptionInterface $clientException) { + $this->logger->warning(__CLASS__ . ' could not retrieve event from ms graph. Already deleted ?', [ + 'calendarId' => $calendar->getId(), + 'remoteEventId' => $notification['resource'], + ]); + + throw $clientException; + } + + if (false === $new['isOrganizer']) { + return; + } + + $lastModified = RemoteEventConverter::convertStringDateWithTimezone( + $new['lastModifiedDateTime'] + ); + + if ($calendar->getRemoteAttributes()['lastModifiedDateTime'] === $lastModified->getTimestamp()) { + $this->logger->info(__CLASS__ . ' change key is equals. Source is probably a local update', [ + 'calendarRangeId' => $calendar->getId(), + 'remoteEventId' => $notification['resource'], + ]); + + return; + } + + $this->syncAttendees($calendar, $new['attendees']); + + $startDate = RemoteEventConverter::convertStringDateWithoutTimezone($new['start']['dateTime']); + $endDate = RemoteEventConverter::convertStringDateWithoutTimezone($new['end']['dateTime']); + + if ($startDate->getTimestamp() !== $calendar->getStartDate()->getTimestamp()) { + $calendar->setStartDate($startDate)->setStatus(Calendar::STATUS_MOVED); + } + + if ($endDate->getTimestamp() !== $calendar->getEndDate()->getTimestamp()) { + $calendar->setEndDate($endDate)->setStatus(Calendar::STATUS_MOVED); + } + + $calendar + ->addRemoteAttributes([ + 'lastModifiedDateTime' => $lastModified->getTimestamp(), + 'changeKey' => $new['changeKey'], + ]) + ->preventEnqueueChanges = true; + } + + private function syncAttendees(Calendar $calendar, array $attendees): void + { + $emails = []; + + foreach ($attendees as $attendee) { + $status = $attendee['status']['response']; + + if ('organizer' === $status) { + continue; + } + + $email = $attendee['emailAddress']['address']; + $emails[] = strtolower($email); + $user = $this->userRepository->findOneByUsernameOrEmail($email); + + if (null === $user) { + continue; + } + + if (!$calendar->isInvited($user)) { + $calendar->addUser($user); + } + + $invite = $calendar->getInviteForUser($user); + + switch ($status) { + // possible cases: none, organizer, tentativelyAccepted, accepted, declined, notResponded. + case 'none': + case 'notResponded': + $invite->setStatus(Invite::PENDING); + + break; + + case 'tentativelyAccepted': + $invite->setStatus(Invite::TENTATIVELY_ACCEPTED); + + break; + + case 'accepted': + $invite->setStatus(Invite::ACCEPTED); + + break; + + case 'declined': + $invite->setStatus(Invite::DECLINED); + + break; + + default: + throw new LogicException('should not happens, not implemented: ' . $status); + + break; + } + } + + foreach ($calendar->getUsers() as $user) { + if (!in_array(strtolower($user->getEmailCanonical()), $emails, true)) { + $calendar->removeUser($user); + } + } + } +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraphRemoteCalendarConnector.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraphRemoteCalendarConnector.php new file mode 100644 index 000000000..8d36a8f8e --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraphRemoteCalendarConnector.php @@ -0,0 +1,733 @@ +calendarRepository = $calendarRepository; + $this->calendarRangeRepository = $calendarRangeRepository; + $this->machineHttpClient = $machineHttpClient; + $this->mapCalendarToUser = $mapCalendarToUser; + $this->logger = $logger; + $this->remoteEventConverter = $remoteEventConverter; + $this->tokenStorage = $tokenStorage; + $this->translator = $translator; + $this->urlGenerator = $urlGenerator; + $this->userHttpClient = $userHttpClient; + } + + public function countEventsForUser(User $user, DateTimeImmutable $startDate, DateTimeImmutable $endDate): int + { + $userId = $this->mapCalendarToUser->getUserId($user); + + if (null === $userId) { + return 0; + } + + try { + $data = $this->userHttpClient->request( + 'GET', + 'users/' . $userId . '/calendarView', + [ + 'query' => [ + 'startDateTime' => $startDate->format(DateTimeImmutable::ATOM), + 'endDateTime' => $endDate->format(DateTimeImmutable::ATOM), + '$count' => 'true', + '$top' => 0, + ], + ] + )->toArray(); + } catch (ClientExceptionInterface $e) { + if (403 === $e->getResponse()->getStatusCode()) { + return count($this->getScheduleTimesForUser($user, $startDate, $endDate)); + } + + $this->logger->error('Could not get list of event on MSGraph', [ + 'error_code' => $e->getResponse()->getStatusCode(), + 'error' => $e->getResponse()->getInfo(), + ]); + + return 0; + } + + return $data['@odata.count']; + } + + public function getMakeReadyResponse(string $returnPath): Response + { + return new RedirectResponse($this->urlGenerator + ->generate('chill_calendar_remote_connect_azure', ['returnPath' => $returnPath])); + } + + public function isReady(): bool + { + return $this->tokenStorage->hasToken(); + } + + /** + * @throws \Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface + * @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface + * @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface + * @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface + * + * @return array|\Chill\CalendarBundle\RemoteCalendar\Model\RemoteEvent[] + */ + public function listEventsForUser(User $user, DateTimeImmutable $startDate, DateTimeImmutable $endDate, ?int $offset = 0, ?int $limit = 50): array + { + $userId = $this->mapCalendarToUser->getUserId($user); + + if (null === $userId) { + return []; + } + + try { + $bareEvents = $this->userHttpClient->request( + 'GET', + 'users/' . $userId . '/calendarView', + [ + 'query' => [ + 'startDateTime' => $startDate->format(DateTimeImmutable::ATOM), + 'endDateTime' => $endDate->format(DateTimeImmutable::ATOM), + '$select' => 'id,subject,start,end,isAllDay', + '$top' => $limit, + '$skip' => $offset, + ], + ] + )->toArray(); + + $ids = array_map(static function ($item) { return $item['id']; }, $bareEvents['value']); + $existingIdsInRange = $this->calendarRangeRepository->findRemoteIdsPresent($ids); + $existingIdsInCalendar = $this->calendarRepository->findRemoteIdsPresent($ids); + + return array_values( + array_map( + function ($item) { + return $this->remoteEventConverter->convertToRemote($item); + }, + // filter all event to keep only the one not in range + array_filter( + $bareEvents['value'], + static function ($item) use ($existingIdsInRange, $existingIdsInCalendar) { + return ((!$existingIdsInRange[$item['id']]) ?? true) && ((!$existingIdsInCalendar[$item['id']]) ?? true); + } + ) + ) + ); + } catch (ClientExceptionInterface $e) { + if (403 === $e->getResponse()->getStatusCode()) { + return $this->getScheduleTimesForUser($user, $startDate, $endDate); + } + + $this->logger->error('Could not get list of event on MSGraph', [ + 'error_code' => $e->getResponse()->getStatusCode(), + 'error' => $e->getResponse()->getInfo(), + ]); + + return []; + } + } + + public function removeCalendar(string $remoteId, array $remoteAttributes, User $user, ?CalendarRange $associatedCalendarRange = null): void + { + if ('' === $remoteId) { + return; + } + + $this->removeEvent($remoteId, $user); + + if (null !== $associatedCalendarRange) { + $this->syncCalendarRange($associatedCalendarRange); + } + } + + public function removeCalendarRange(string $remoteId, array $remoteAttributes, User $user): void + { + if ('' === $remoteId) { + return; + } + + $this->removeEvent($remoteId, $user); + } + + public function syncCalendar(Calendar $calendar, string $action, ?CalendarRange $previousCalendarRange, ?User $previousMainUser, ?array $oldInvites, ?array $newInvites): void + { + /* + * cases to support: + * + * * a calendar range is created: + * * create on remote + * * if calendar range is associated: remove the range + * * a Calendar change the CalendarRange: + * * re-create the previous calendar range; + * * remove the current calendar range + * * a calendar change the mainUser + * * cancel the calendar in the previous mainUser + * * recreate the previous calendar range in the previousMainUser, if any + * * delete the current calendar range in the current mainUser, if any + * * create the calendar in the current mainUser + * + */ + + if (!$calendar->hasRemoteId()) { + $this->createCalendarOnRemote($calendar); + } else { + if (null !== $previousMainUser) { + // cancel event in previousMainUserCalendar + $this->cancelOnRemote( + $calendar->getRemoteId(), + $this->translator->trans('remote_ms_graph.cancel_event_because_main_user_is_%label%', ['%label%' => $calendar->getMainUser()]), + $previousMainUser, + 'calendar_' . $calendar->getRemoteId() + ); + $this->createCalendarOnRemote($calendar); + } else { + $this->patchCalendarOnRemote($calendar, $newInvites); + } + } + + if ($calendar->hasCalendarRange() && $calendar->getCalendarRange()->hasRemoteId()) { + $this->removeEvent( + $calendar->getCalendarRange()->getRemoteId(), + $calendar->getCalendarRange()->getUser() + ); + + $calendar->getCalendarRange() + ->addRemoteAttributes([ + 'lastModifiedDateTime' => null, + 'changeKey' => null, + 'previousId' => $calendar->getCalendarRange()->getRemoteId(), + ]) + ->setRemoteId(''); + } + + if (null !== $previousCalendarRange) { + $this->createRemoteCalendarRange($previousCalendarRange); + } + } + + public function syncCalendarRange(CalendarRange $calendarRange): void + { + if ($calendarRange->hasRemoteId()) { + $this->updateRemoteCalendarRange($calendarRange); + } else { + $this->createRemoteCalendarRange($calendarRange); + } + } + + public function syncInvite(Invite $invite): void + { + if ('' === $remoteId = $invite->getCalendar()->getRemoteId()) { + return; + } + + if (null === $invite->getUser()) { + return; + } + + if (null === $userId = $this->mapCalendarToUser->getUserId($invite->getUser())) { + return; + } + + if ($invite->hasRemoteId()) { + $remoteIdAttendeeCalendar = $invite->getRemoteId(); + } else { + $remoteIdAttendeeCalendar = $this->findRemoteIdOnUserCalendar($invite->getCalendar(), $invite->getUser()); + $invite->setRemoteId($remoteIdAttendeeCalendar); + } + + switch ($invite->getStatus()) { + case Invite::PENDING: + return; + + case Invite::ACCEPTED: + $url = "/v1.0/users/{$userId}/calendar/events/{$remoteIdAttendeeCalendar}/accept"; + + break; + + case Invite::TENTATIVELY_ACCEPTED: + $url = "/v1.0/users/{$userId}/calendar/events/{$remoteIdAttendeeCalendar}/tentativelyAccept"; + + break; + + case Invite::DECLINED: + $url = "/v1.0/users/{$userId}/calendar/events/{$remoteIdAttendeeCalendar}/decline"; + + break; + + default: + throw new Exception('not supported'); + } + + try { + $this->machineHttpClient->request( + 'POST', + $url, + ['json' => ['sendResponse' => true]] + ); + } catch (ClientExceptionInterface $e) { + $this->logger->warning('could not update calendar range to remote', [ + 'exception' => $e->getTraceAsString(), + 'content' => $e->getResponse()->getContent(), + 'calendarRangeId' => 'invite_' . $invite->getId(), + ]); + + throw $e; + } + } + + private function cancelOnRemote(string $remoteId, string $comment, User $user, string $identifier): void + { + $userId = $this->mapCalendarToUser->getUserId($user); + + if (null === $userId) { + return; + } + + try { + $this->machineHttpClient->request( + 'POST', + "users/{$userId}/calendar/events/{$remoteId}/cancel", + [ + 'json' => ['Comment' => $comment], + ] + ); + } catch (ClientExceptionInterface $e) { + $this->logger->warning('could not update calendar range to remote', [ + 'exception' => $e->getTraceAsString(), + 'content' => $e->getResponse()->getContent(), + 'calendarRangeId' => $identifier, + ]); + + throw $e; + } + } + + private function createCalendarOnRemote(Calendar $calendar): void + { + $eventData = $this->remoteEventConverter->calendarToEvent($calendar); + + [ + 'id' => $id, + 'lastModifiedDateTime' => $lastModified, + 'changeKey' => $changeKey + ] = $this->createOnRemote($eventData, $calendar->getMainUser(), 'calendar_' . $calendar->getId()); + + if (null === $id) { + return; + } + + $calendar + ->setRemoteId($id) + ->addRemoteAttributes([ + 'lastModifiedDateTime' => $lastModified, + 'changeKey' => $changeKey, + ]); + } + + /** + * @param string $identifier an identifier for logging in case of something does not work + * + * @return array{?id: string, ?lastModifiedDateTime: int, ?changeKey: string} + */ + private function createOnRemote(array $eventData, User $user, string $identifier): array + { + $userId = $this->mapCalendarToUser->getUserId($user); + + if (null === $userId) { + $this->logger->warning('user does not have userId nor calendarId', [ + 'user_id' => $user->getId(), + 'calendar_identifier' => $identifier, + ]); + + return ['id' => null, 'lastModifiedDateTime' => null, 'changeKey' => null]; + } + + try { + $event = $this->machineHttpClient->request( + 'POST', + 'users/' . $userId . '/calendar/events', + [ + 'json' => $eventData, + ] + )->toArray(); + } catch (ClientExceptionInterface $e) { + $this->logger->warning('could not save calendar range to remote', [ + 'exception' => $e->getTraceAsString(), + 'content' => $e->getResponse()->getContent(), + 'calendar_identifier' => $identifier, + ]); + + throw $e; + } + + return [ + 'id' => $event['id'], + 'lastModifiedDateTime' => $this->remoteEventConverter->getLastModifiedDate($event)->getTimestamp(), + 'changeKey' => $event['changeKey'], + ]; + } + + private function createRemoteCalendarRange(CalendarRange $calendarRange): void + { + $userId = $this->mapCalendarToUser->getUserId($calendarRange->getUser()); + + if (null === $userId) { + $this->logger->warning('user does not have userId nor calendarId', [ + 'user_id' => $calendarRange->getUser()->getId(), + 'calendar_range_id' => $calendarRange->getId(), + ]); + + return; + } + + $eventData = $this->remoteEventConverter->calendarRangeToEvent($calendarRange); + + [ + 'id' => $id, + 'lastModifiedDateTime' => $lastModified, + 'changeKey' => $changeKey + ] = $this->createOnRemote( + $eventData, + $calendarRange->getUser(), + 'calendar_range_' . $calendarRange->getId() + ); + + $calendarRange->setRemoteId($id) + ->addRemoteAttributes([ + 'lastModifiedDateTime' => $lastModified, + 'changeKey' => $changeKey, + ]); + } + + /** + * the remoteId is not the same across different user calendars. This method allow to find + * the correct remoteId in another calendar. + * + * For achieving this, the iCalUid is used. + */ + private function findRemoteIdOnUserCalendar(Calendar $calendar, User $user): ?string + { + // find the icalUid on original user + $event = $this->getOnRemote($calendar->getMainUser(), $calendar->getRemoteId()); + $userId = $this->mapCalendarToUser->getUserId($user); + + if ('' === $iCalUid = ($event['iCalUId'] ?? '')) { + throw new Exception('no iCalUid for this event'); + } + + try { + $events = $this->machineHttpClient->request( + 'GET', + "/v1.0/users/{$userId}/calendar/events", + [ + 'query' => [ + '$select' => 'id', + '$filter' => "iCalUId eq '{$iCalUid}'", + ], + ] + )->toArray(); + } catch (ClientExceptionInterface $clientException) { + throw $clientException; + } + + if (1 !== count($events['value'])) { + throw new Exception('multiple events found with same iCalUid'); + } + + return $events['value'][0]['id']; + } + + private function getOnRemote(User $user, string $remoteId): array + { + $userId = $this->mapCalendarToUser->getUserId($user); + + if (null === $userId) { + throw new Exception('no remote calendar for this user', [ + 'user' => $user->getId(), + 'remoteId' => $remoteId, + ]); + } + + try { + return $this->machineHttpClient->request( + 'GET', + 'users/' . $userId . '/calendar/events/' . $remoteId + )->toArray(); + } catch (ClientExceptionInterface $e) { + $this->logger->warning('Could not get event from calendar', [ + 'remoteId' => $remoteId, + ]); + + throw $e; + } + } + + private function getScheduleTimesForUser(User $user, DateTimeImmutable $startDate, DateTimeImmutable $endDate): array + { + $userId = $this->mapCalendarToUser->getUserId($user); + + if (array_key_exists($userId, $this->cacheScheduleTimeForUser)) { + return $this->cacheScheduleTimeForUser[$userId]; + } + + if (null === $userId) { + return []; + } + + if (null === $user->getEmailCanonical() || '' === $user->getEmailCanonical()) { + return []; + } + + $body = [ + 'schedules' => [$user->getEmailCanonical()], + 'startTime' => [ + 'dateTime' => ($startDate->setTimezone(RemoteEventConverter::getRemoteTimeZone())->format(RemoteEventConverter::getRemoteDateTimeSimpleFormat())), + 'timeZone' => 'UTC', + ], + 'endTime' => [ + 'dateTime' => ($endDate->setTimezone(RemoteEventConverter::getRemoteTimeZone())->format(RemoteEventConverter::getRemoteDateTimeSimpleFormat())), + 'timeZone' => 'UTC', + ], + ]; + + try { + $response = $this->userHttpClient->request('POST', 'users/' . $userId . '/calendar/getSchedule', [ + 'json' => $body, + ])->toArray(); + } catch (ClientExceptionInterface $e) { + $this->logger->debug('Could not get schedule on MSGraph', [ + 'error_code' => $e->getResponse()->getStatusCode(), + 'error' => $e->getResponse()->getInfo(), + ]); + + return []; + } + + $this->cacheScheduleTimeForUser[$userId] = array_map( + function ($item) { + return $this->remoteEventConverter->convertAvailabilityToRemoteEvent($item); + }, + $response['value'][0]['scheduleItems'] + ); + + return $this->cacheScheduleTimeForUser[$userId]; + } + + private function patchCalendarOnRemote(Calendar $calendar, array $newInvites): void + { + $eventDatas = []; + $eventDatas[] = $this->remoteEventConverter->calendarToEvent($calendar); + + if (0 < count($newInvites)) { + // it seems that invitaiton are always send, even if attendee changes are mixed with other datas + // $eventDatas[] = $this->remoteEventConverter->calendarToEventAttendeesOnly($calendar); + } + + foreach ($eventDatas as $eventData) { + [ + 'id' => $id, + 'lastModifiedDateTime' => $lastModified, + 'changeKey' => $changeKey + ] = $this->patchOnRemote( + $calendar->getRemoteId(), + $eventData, + $calendar->getMainUser(), + 'calendar_' . $calendar->getId() + ); + + $calendar->addRemoteAttributes([ + 'lastModifiedDateTime' => $lastModified, + 'changeKey' => $changeKey, + ]); + } + } + + /** + * @param string $identifier an identifier for logging in case of something does not work + * + * @return array{?id: string, ?lastModifiedDateTime: int, ?changeKey: string} + */ + private function patchOnRemote(string $remoteId, array $eventData, User $user, string $identifier): array + { + $userId = $this->mapCalendarToUser->getUserId($user); + + if (null === $userId) { + $this->logger->warning('user does not have userId nor calendarId', [ + 'user_id' => $user->getId(), + 'calendar_identifier' => $identifier, + ]); + + return ['id' => null, 'lastModifiedDateTime' => null, 'changeKey' => null]; + } + + try { + $event = $this->machineHttpClient->request( + 'PATCH', + 'users/' . $userId . '/calendar/events/' . $remoteId, + [ + 'json' => $eventData, + ] + )->toArray(); + } catch (ClientExceptionInterface $e) { + $this->logger->warning('could not update calendar range to remote', [ + 'exception' => $e->getTraceAsString(), + 'calendarRangeId' => $identifier, + ]); + + throw $e; + } + + return [ + 'id' => $event['id'], + 'lastModifiedDateTime' => $this->remoteEventConverter->getLastModifiedDate($event)->getTimestamp(), + 'changeKey' => $event['changeKey'], + ]; + } + + private function removeEvent($remoteId, User $user): void + { + $userId = $this->mapCalendarToUser->getUserId($user); + + try { + $this->machineHttpClient->request( + 'DELETE', + 'users/' . $userId . '/calendar/events/' . $remoteId + ); + } catch (ClientExceptionInterface $e) { + $this->logger->warning('could not remove event from calendar', [ + 'event_remote_id' => $remoteId, + 'user_id' => $user->getId(), + ]); + } + } + + private function updateRemoteCalendarRange(CalendarRange $calendarRange): void + { + $userId = $this->mapCalendarToUser->getUserId($calendarRange->getUser()); + $calendarId = $this->mapCalendarToUser->getCalendarId($calendarRange->getUser()); + + if (null === $userId || null === $calendarId) { + $this->logger->warning('user does not have userId nor calendarId', [ + 'user_id' => $calendarRange->getUser()->getId(), + 'calendar_range_id' => $calendarRange->getId(), + ]); + + return; + } + + try { + $event = $this->machineHttpClient->request( + 'GET', + 'users/' . $userId . '/calendar/events/' . $calendarRange->getRemoteId() + )->toArray(); + } catch (ClientExceptionInterface $e) { + $this->logger->warning('Could not get event from calendar', [ + 'calendar_range_id' => $calendarRange->getId(), + 'calendar_range_remote_id' => $calendarRange->getRemoteId(), + ]); + + throw $e; + } + + if ($this->remoteEventConverter->getLastModifiedDate($event)->getTimestamp() > $calendarRange->getUpdatedAt()->getTimestamp()) { + $this->logger->info('Skip updating as the lastModified date seems more fresh than the database one', [ + 'calendar_range_id' => $calendarRange->getId(), + 'calendar_range_remote_id' => $calendarRange->getRemoteId(), + 'db_last_updated' => $calendarRange->getUpdatedAt()->getTimestamp(), + 'remote_last_updated' => $this->remoteEventConverter->getLastModifiedDate($event)->getTimestamp(), + ]); + + return; + } + + $eventData = $this->remoteEventConverter->calendarRangeToEvent($calendarRange); + + try { + $event = $this->machineHttpClient->request( + 'PATCH', + 'users/' . $userId . '/calendar/events/' . $calendarRange->getRemoteId(), + [ + 'json' => $eventData, + ] + )->toArray(); + } catch (ClientExceptionInterface $e) { + $this->logger->warning('could not update calendar range to remote', [ + 'exception' => $e->getTraceAsString(), + 'calendarRangeId' => $calendarRange->getId(), + ]); + + throw $e; + } + + $calendarRange + ->addRemoteAttributes([ + 'lastModifiedDateTime' => $this->remoteEventConverter->getLastModifiedDate($event)->getTimestamp(), + 'changeKey' => $event['changeKey'], + ]); + } +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/NullRemoteCalendarConnector.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/NullRemoteCalendarConnector.php new file mode 100644 index 000000000..54440053b --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/NullRemoteCalendarConnector.php @@ -0,0 +1,63 @@ + $oldInvites + */ + public function syncCalendar(Calendar $calendar, string $action, ?CalendarRange $previousCalendarRange, ?User $previousMainUser, ?array $oldInvites, ?array $newInvites): void; + + public function syncCalendarRange(CalendarRange $calendarRange): void; + + public function syncInvite(Invite $invite): void; +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/DependencyInjection/RemoteCalendarCompilerPass.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/DependencyInjection/RemoteCalendarCompilerPass.php new file mode 100644 index 000000000..f6631f0cc --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/DependencyInjection/RemoteCalendarCompilerPass.php @@ -0,0 +1,75 @@ +getParameter('chill_calendar'); + $connector = null; + + if (!$config['remote_calendars_sync']['enabled']) { + $connector = NullRemoteCalendarConnector::class; + } + + if ($config['remote_calendars_sync']['microsoft_graph']['enabled']) { + $connector = MSGraphRemoteCalendarConnector::class; + + $container->setAlias(HttpClientInterface::class . ' $machineHttpClient', MachineHttpClient::class); + } else { + // 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); + } + + if (!$container->hasAlias(Azure::class) && $container->hasDefinition('knpu.oauth2.client.azure')) { + $container->setAlias(Azure::class, 'knpu.oauth2.provider.azure'); + } + + if (null === $connector) { + throw new RuntimeException('Could not configure remote calendar'); + } + + foreach ([ + NullRemoteCalendarConnector::class, + MSGraphRemoteCalendarConnector::class, ] as $serviceId) { + if ($connector === $serviceId) { + $container->getDefinition($serviceId) + ->setDecoratedService(RemoteCalendarConnectorInterface::class); + } else { + // keep the container lighter by removing definitions + if ($container->hasDefinition($serviceId)) { + $container->removeDefinition($serviceId); + } + } + } + } +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Model/RemoteEvent.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Model/RemoteEvent.php new file mode 100644 index 000000000..b96fa0a3b --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Model/RemoteEvent.php @@ -0,0 +1,55 @@ +id = $id; + $this->title = $title; + $this->description = $description; + $this->startDate = $startDate; + $this->endDate = $endDate; + $this->isAllDay = $isAllDay; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Repository/CalendarACLAwareRepository.php b/src/Bundle/ChillCalendarBundle/Repository/CalendarACLAwareRepository.php new file mode 100644 index 000000000..4832d2735 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Repository/CalendarACLAwareRepository.php @@ -0,0 +1,80 @@ +em = $em; + } + + public function buildQueryByAccompanyingPeriod(AccompanyingPeriod $period, ?DateTimeImmutable $startDate, ?DateTimeImmutable $endDate): QueryBuilder + { + $qb = $this->em->createQueryBuilder(); + $qb->from(Calendar::class, 'c'); + + $andX = $qb->expr()->andX($qb->expr()->eq('c.accompanyingPeriod', ':period')); + $qb->setParameter('period', $period); + + if (null !== $startDate) { + $andX->add($qb->expr()->gte('c.startDate', ':startDate')); + $qb->setParameter('startDate', $startDate); + } + + if (null !== $endDate) { + $andX->add($qb->expr()->lte('c.endDate', ':endDate')); + $qb->setParameter('endDate', $endDate); + } + + $qb->where($andX); + + return $qb; + } + + public function countByAccompanyingPeriod(AccompanyingPeriod $period, ?DateTimeImmutable $startDate, ?DateTimeImmutable $endDate): int + { + $qb = $this->buildQueryByAccompanyingPeriod($period, $startDate, $endDate)->select('count(c)'); + + return $qb->getQuery()->getSingleScalarResult(); + } + + /** + * @return array|Calendar[] + */ + public function findByAccompanyingPeriod(AccompanyingPeriod $period, ?DateTimeImmutable $startDate, ?DateTimeImmutable $endDate, ?array $orderBy = [], ?int $offset = null, ?int $limit = null): array + { + $qb = $this->buildQueryByAccompanyingPeriod($period, $startDate, $endDate)->select('c'); + + foreach ($orderBy as $sort => $order) { + $qb->addOrderBy('c.' . $sort, $order); + } + + if (null !== $offset) { + $qb->setFirstResult($offset); + } + + if (null !== $limit) { + $qb->setMaxResults($limit); + } + + return $qb->getQuery()->getResult(); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Repository/CalendarACLAwareRepositoryInterface.php b/src/Bundle/ChillCalendarBundle/Repository/CalendarACLAwareRepositoryInterface.php new file mode 100644 index 000000000..bc62a6a65 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Repository/CalendarACLAwareRepositoryInterface.php @@ -0,0 +1,26 @@ +em = $entityManager; + $this->repository = $entityManager->getRepository(CalendarRange::class); } - // /** - // * @return CalendarRange[] Returns an array of CalendarRange objects - // */ - /* - public function findByExampleField($value) + public function countByAvailableRangesForUser(User $user, DateTimeImmutable $from, DateTimeImmutable $to): int { - return $this->createQueryBuilder('c') - ->andWhere('c.exampleField = :val') - ->setParameter('val', $value) - ->orderBy('c.id', 'ASC') - ->setMaxResults(10) - ->getQuery() - ->getResult() - ; + return $this->buildQueryAvailableRangesForUser($user, $from, $to) + ->select('COUNT(cr)') + ->getQuery()->getSingleScalarResult(); } - */ - /* - public function findOneBySomeField($value): ?CalendarRange + public function find($id): ?CalendarRange { - return $this->createQueryBuilder('c') - ->andWhere('c.exampleField = :val') - ->setParameter('val', $value) - ->getQuery() - ->getOneOrNullResult() - ; + return $this->repository->find($id); } + + /** + * @return array|CalendarRange[] */ + public function findAll(): array + { + return $this->repository->findAll(); + } + + /** + * @return array|CalendarRange[] + */ + public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null) + { + return $this->repository->findBy($criteria, $orderBy, $limit, $offset); + } + + /** + * @return array|CalendarRange[] + */ + public function findByAvailableRangesForUser( + User $user, + DateTimeImmutable $from, + DateTimeImmutable $to, + ?int $limit = null, + ?int $offset = null + ): array { + $qb = $this->buildQueryAvailableRangesForUser($user, $from, $to); + + if (null !== $limit) { + $qb->setMaxResults($limit); + } + + if (null !== $offset) { + $qb->setFirstResult($offset); + } + + return $qb->getQuery()->getResult(); + } + + public function findOneBy(array $criteria): ?CalendarRange + { + return $this->repository->findOneBy($criteria); + } + + /** + * Given a list of remote ids, return an array where + * keys are the remoteIds, and value is a boolean, true if the + * id is present in database. + * + * @param array|list $remoteIds + * + * @return array + */ + public function findRemoteIdsPresent(array $remoteIds): array + { + if (0 === count($remoteIds)) { + return []; + } + + $sql = 'SELECT + sq.remoteId as remoteid, + EXISTS (SELECT 1 FROM chill_calendar.calendar_range cr WHERE cr.remoteId = sq.remoteId) AS present + FROM + ( + VALUES %remoteIds% + ) AS sq(remoteId); + '; + + $remoteIdsStr = implode( + ', ', + array_fill(0, count($remoteIds), '((?))') + ); + + $rsm = new ResultSetMapping(); + $rsm + ->addScalarResult('remoteid', 'remoteId', Types::STRING) + ->addScalarResult('present', 'present', Types::BOOLEAN); + + $rows = $this->em + ->createNativeQuery( + strtr($sql, ['%remoteIds%' => $remoteIdsStr]), + $rsm + ) + ->setParameters(array_values($remoteIds)) + ->getResult(); + + $results = []; + + foreach ($rows as $r) { + $results[$r['remoteId']] = $r['present']; + } + + return $results; + } + + public function getClassName(): string + { + return CalendarRange::class; + } + + private function buildQueryAvailableRangesForUser(User $user, DateTimeImmutable $from, DateTimeImmutable $to): QueryBuilder + { + $qb = $this->repository->createQueryBuilder('cr'); + + $qb->leftJoin('cr.calendar', 'calendar'); + + return $qb + ->where( + $qb->expr()->andX( + $qb->expr()->eq('cr.user', ':user'), + $qb->expr()->gte('cr.startDate', ':startDate'), + $qb->expr()->lte('cr.endDate', ':endDate'), + $qb->expr()->isNull('calendar') + ) + ) + ->setParameters([ + 'user' => $user, + 'startDate' => $from, + 'endDate' => $to, + ]); + } } diff --git a/src/Bundle/ChillCalendarBundle/Repository/CalendarRepository.php b/src/Bundle/ChillCalendarBundle/Repository/CalendarRepository.php index 55fba5f80..ed4b7b22d 100644 --- a/src/Bundle/ChillCalendarBundle/Repository/CalendarRepository.php +++ b/src/Bundle/ChillCalendarBundle/Repository/CalendarRepository.php @@ -12,52 +12,215 @@ declare(strict_types=1); namespace Chill\CalendarBundle\Repository; use Chill\CalendarBundle\Entity\Calendar; -use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Chill\MainBundle\Entity\User; +use Chill\PersonBundle\Entity\AccompanyingPeriod; +use DateTimeImmutable; +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; -use Doctrine\Persistence\ManagerRegistry; +use Doctrine\ORM\Query\ResultSetMapping; +use Doctrine\ORM\QueryBuilder; +use Doctrine\Persistence\ObjectRepository; +use function count; -/** - * @method Calendar|null find($id, $lockMode = null, $lockVersion = null) - * @method Calendar|null findOneBy(array $criteria, array $orderBy = null) - * @method Calendar[] findAll() - * @method Calendar[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) - */ -class CalendarRepository extends ServiceEntityRepository +class CalendarRepository implements ObjectRepository { - // private EntityRepository $repository; + private EntityManagerInterface $em; - public function __construct(ManagerRegistry $registry) + private EntityRepository $repository; + + public function __construct(EntityManagerInterface $entityManager) { - parent::__construct($registry, Calendar::class); - // $this->repository = $entityManager->getRepository(AccompanyingPeriodWork::class); + $this->repository = $entityManager->getRepository(Calendar::class); + $this->em = $entityManager; } - // /** - // * @return Calendar[] Returns an array of Calendar objects - // */ - /* - public function findByExampleField($value) + public function countByAccompanyingPeriod(AccompanyingPeriod $period): int { - return $this->createQueryBuilder('c') - ->andWhere('c.exampleField = :val') - ->setParameter('val', $value) - ->orderBy('c.id', 'ASC') - ->setMaxResults(10) + return $this->repository->count(['accompanyingPeriod' => $period]); + } + + public function countByUser(User $user, DateTimeImmutable $from, DateTimeImmutable $to): int + { + return $this->buildQueryByUser($user, $from, $to) + ->select('COUNT(c)') ->getQuery() - ->getResult() - ; + ->getSingleScalarResult(); } - */ - /* - public function findOneBySomeField($value): ?Calendar + public function find($id): ?Calendar { - return $this->createQueryBuilder('c') - ->andWhere('c.exampleField = :val') - ->setParameter('val', $value) - ->getQuery() - ->getOneOrNullResult() - ; + return $this->repository->find($id); } + + /** + * @return array|Calendar[] */ + public function findAll(): array + { + return $this->repository->findAll(); + } + + /** + * @return array|Calendar[] + */ + public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array + { + return $this->repository->findBy($criteria, $orderBy, $limit, $offset); + } + + /** + * @return array|Calendar[] + */ + public function findByAccompanyingPeriod(AccompanyingPeriod $period, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array + { + return $this->findBy( + [ + 'accompanyingPeriod' => $period, + ], + $orderBy, + $limit, + $orderBy + ); + } + + public function findByNotificationAvailable(DateTimeImmutable $startDate, DateTimeImmutable $endDate, ?int $limit = null, ?int $offset = null): array + { + $qb = $this->queryByNotificationAvailable($startDate, $endDate)->select('c'); + + if (null !== $limit) { + $qb->setMaxResults($limit); + } + + if (null !== $offset) { + $qb->setFirstResult($offset); + } + + return $qb->getQuery()->getResult(); + } + + /** + * @return array|Calendar[] + */ + public function findByUser(User $user, DateTimeImmutable $from, DateTimeImmutable $to, ?int $limit = null, ?int $offset = null): array + { + $qb = $this->buildQueryByUser($user, $from, $to)->select('c'); + + if (null !== $limit) { + $qb->setMaxResults($limit); + } + + if (null !== $offset) { + $qb->setFirstResult($offset); + } + + return $qb->getQuery()->getResult(); + } + + public function findOneBy(array $criteria): ?Calendar + { + return $this->repository->findOneBy($criteria); + } + + /** + * Given a list of remote ids, return an array where + * keys are the remoteIds, and value is a boolean, true if the + * id is present in database. + * + * @param array|list $remoteIds + * + * @return array + */ + public function findRemoteIdsPresent(array $remoteIds): array + { + if (0 === count($remoteIds)) { + return []; + } + + $remoteIdsStr = implode( + ', ', + array_fill(0, count($remoteIds), '((?))') + ); + + $sql = "SELECT + sq.remoteId as remoteid, + EXISTS (SELECT 1 FROM chill_calendar.calendar c WHERE c.remoteId = sq.remoteId) AS present + FROM + ( + VALUES {$remoteIdsStr} + ) AS sq(remoteId); + "; + + $rsm = new ResultSetMapping(); + $rsm + ->addScalarResult('remoteid', 'remoteId', Types::STRING) + ->addScalarResult('present', 'present', Types::BOOLEAN); + + $rows = $this->em + ->createNativeQuery( + $sql, + $rsm + ) + ->setParameters(array_values($remoteIds)) + ->getResult(); + + $results = []; + + foreach ($rows as $r) { + $results[$r['remoteId']] = $r['present']; + } + + return $results; + } + + public function getClassName() + { + return Calendar::class; + } + + private function buildQueryByUser(User $user, DateTimeImmutable $from, DateTimeImmutable $to): QueryBuilder + { + $qb = $this->repository->createQueryBuilder('c'); + + return $qb + ->where( + $qb->expr()->andX( + $qb->expr()->eq('c.mainUser', ':user'), + $qb->expr()->gte('c.startDate', ':startDate'), + $qb->expr()->lte('c.endDate', ':endDate'), + ) + ) + ->setParameters([ + 'user' => $user, + 'startDate' => $from, + 'endDate' => $to, + ]); + } + + private function queryByNotificationAvailable(DateTimeImmutable $startDate, DateTimeImmutable $endDate): QueryBuilder + { + $qb = $this->repository->createQueryBuilder('c'); + + $qb->where( + $qb->expr()->andX( + $qb->expr()->eq('c.sendSMS', ':true'), + $qb->expr()->gte('c.startDate', ':startDate'), + $qb->expr()->lt('c.startDate', ':endDate'), + $qb->expr()->orX( + $qb->expr()->eq('c.smsStatus', ':pending'), + $qb->expr()->eq('c.smsStatus', ':cancel_pending') + ) + ) + ); + + $qb->setParameters([ + 'true' => true, + 'startDate' => $startDate, + 'endDate' => $endDate, + 'pending' => Calendar::SMS_PENDING, + 'cancel_pending' => Calendar::SMS_CANCEL_PENDING, + ]); + + return $qb; + } } diff --git a/src/Bundle/ChillCalendarBundle/Repository/InviteRepository.php b/src/Bundle/ChillCalendarBundle/Repository/InviteRepository.php index 53aa8b2a1..450626cef 100644 --- a/src/Bundle/ChillCalendarBundle/Repository/InviteRepository.php +++ b/src/Bundle/ChillCalendarBundle/Repository/InviteRepository.php @@ -12,48 +12,47 @@ declare(strict_types=1); namespace Chill\CalendarBundle\Repository; use Chill\CalendarBundle\Entity\Invite; -use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; -use Doctrine\Persistence\ManagerRegistry; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\EntityRepository; +use Doctrine\Persistence\ObjectRepository; -/** - * @method Invite|null find($id, $lockMode = null, $lockVersion = null) - * @method Invite|null findOneBy(array $criteria, array $orderBy = null) - * @method Invite[] findAll() - * @method Invite[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) - */ -class InviteRepository extends ServiceEntityRepository +class InviteRepository implements ObjectRepository { - public function __construct(ManagerRegistry $registry) + private EntityRepository $entityRepository; + + public function __construct(EntityManagerInterface $em) { - parent::__construct($registry, Invite::class); + $this->entityRepository = $em->getRepository(Invite::class); } - // /** - // * @return Invite[] Returns an array of Invite objects - // */ - /* - public function findByExampleField($value) + public function find($id): ?Invite { - return $this->createQueryBuilder('i') - ->andWhere('i.exampleField = :val') - ->setParameter('val', $value) - ->orderBy('i.id', 'ASC') - ->setMaxResults(10) - ->getQuery() - ->getResult() - ; + return $this->entityRepository->find($id); } - */ - /* - public function findOneBySomeField($value): ?Invite - { - return $this->createQueryBuilder('i') - ->andWhere('i.exampleField = :val') - ->setParameter('val', $value) - ->getQuery() - ->getOneOrNullResult() - ; - } + /** + * @return array|Invite[] */ + public function findAll(): array + { + return $this->entityRepository->findAll(); + } + + /** + * @return array|Invite[] + */ + public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null) + { + return $this->entityRepository->findBy($criteria, $orderBy, $limit, $offset); + } + + public function findOneBy(array $criteria): ?Invite + { + return $this->entityRepository->findOneBy($criteria); + } + + public function getClassName(): string + { + return Invite::class; + } } diff --git a/src/Bundle/ChillCalendarBundle/Resources/config/services.yml b/src/Bundle/ChillCalendarBundle/Resources/config/services.yml index c41c2ad93..7e316acf3 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/config/services.yml +++ b/src/Bundle/ChillCalendarBundle/Resources/config/services.yml @@ -3,12 +3,41 @@ services: Chill\CalendarBundle\Repository\: autowire: true + autoconfigure: true resource: '../../Repository/' - tags: - - { name: 'doctrine.repository_service' } Chill\CalendarBundle\Menu\: autowire: true autoconfigure: true resource: '../../Menu/' tags: ['chill.menu_builder'] + + Chill\CalendarBundle\Command\: + autowire: true + autoconfigure: true + resource: '../../Command/' + + Chill\CalendarBundle\Messenger\: + autowire: true + autoconfigure: true + resource: '../../Messenger/' + + Chill\CalendarBundle\Command\AzureGrantAdminConsentAndAcquireToken: + autoconfigure: true + autowire: true + arguments: + $azure: '@knpu.oauth2.provider.azure' + tags: ['console.command'] + + Chill\CalendarBundle\Security\: + autoconfigure: true + autowire: true + resource: '../../Security/' + + Chill\CalendarBundle\Service\: + autoconfigure: true + autowire: true + resource: '../../Service/' + + Chill\CalendarBundle\Service\ShortMessageForCalendarBuilderInterface: + alias: Chill\CalendarBundle\Service\DefaultShortMessageForCalendarBuider diff --git a/src/Bundle/ChillCalendarBundle/Resources/config/services/event.yml b/src/Bundle/ChillCalendarBundle/Resources/config/services/event.yml index 348677174..365d8c2da 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/config/services/event.yml +++ b/src/Bundle/ChillCalendarBundle/Resources/config/services/event.yml @@ -7,4 +7,37 @@ services: name: 'doctrine.orm.entity_listener' event: 'postPersist' entity: 'Chill\ActivityBundle\Entity\Activity' - \ No newline at end of file + + Chill\CalendarBundle\Messenger\Doctrine\CalendarRangeEntityListener: + autowire: true + autoconfigure: true + tags: + - + name: 'doctrine.orm.entity_listener' + event: 'postPersist' + entity: 'Chill\CalendarBundle\Entity\CalendarRange' + - + name: 'doctrine.orm.entity_listener' + event: 'postUpdate' + entity: 'Chill\CalendarBundle\Entity\CalendarRange' + - + name: 'doctrine.orm.entity_listener' + event: 'postRemove' + entity: 'Chill\CalendarBundle\Entity\CalendarRange' + + Chill\CalendarBundle\Messenger\Doctrine\CalendarEntityListener: + autowire: true + autoconfigure: true + tags: + - + name: 'doctrine.orm.entity_listener' + event: 'postPersist' + entity: 'Chill\CalendarBundle\Entity\Calendar' + - + name: 'doctrine.orm.entity_listener' + event: 'postUpdate' + entity: 'Chill\CalendarBundle\Entity\Calendar' + - + name: 'doctrine.orm.entity_listener' + event: 'postRemove' + entity: 'Chill\CalendarBundle\Entity\Calendar' diff --git a/src/Bundle/ChillCalendarBundle/Resources/config/services/form.yml b/src/Bundle/ChillCalendarBundle/Resources/config/services/form.yml index 85095dc9a..310fce05f 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/config/services/form.yml +++ b/src/Bundle/ChillCalendarBundle/Resources/config/services/form.yml @@ -1,10 +1,6 @@ --- -services: - chill.calendar.form.type.calendar: - class: Chill\CalendarBundle\Form\CalendarType - arguments: - - "@chill.main.helper.translatable_string" - - "@doctrine.orm.entity_manager" - - tags: - - { name: form.type, alias: chill_calendarbundle_calendar } \ No newline at end of file +services: + Chill\CalendarBundle\Form\: + resource: './../../Form' + autowire: true + autoconfigure: true diff --git a/src/Bundle/ChillCalendarBundle/Resources/config/services/remote_calendar.yaml b/src/Bundle/ChillCalendarBundle/Resources/config/services/remote_calendar.yaml new file mode 100644 index 000000000..af55692ca --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/config/services/remote_calendar.yaml @@ -0,0 +1,15 @@ +services: + _defaults: + autoconfigure: true + autowire: true + + Chill\CalendarBundle\RemoteCalendar\Connector\RemoteCalendarConnectorInterface: ~ + + Chill\CalendarBundle\RemoteCalendar\Connector\NullRemoteCalendarConnector: ~ + + Chill\CalendarBundle\RemoteCalendar\Connector\MSGraphRemoteCalendarConnector: ~ + + Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\: + resource: '../../RemoteCalendar/Connector/MSGraph/' + + diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/module/Invite/answer.js b/src/Bundle/ChillCalendarBundle/Resources/public/module/Invite/answer.js new file mode 100644 index 000000000..a97fe584d --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/module/Invite/answer.js @@ -0,0 +1,33 @@ + +import { createApp } from 'vue'; +import Answer from 'ChillCalendarAssets/vuejs/Invite/Answer'; +import { _createI18n } from 'ChillMainAssets/vuejs/_js/i18n'; + +const i18n = _createI18n({}); + +document.addEventListener('DOMContentLoaded', function (e) { + console.log('dom loaded answer'); + document.querySelectorAll('div[invite-answer]').forEach(function (el) { + console.log('element found', el); + + const app = createApp({ + components: { + Answer, + }, + data() { + return { + status: el.dataset.status, + calendarId: Number.parseInt(el.dataset.calendarId), + } + }, + template: '', + methods: { + onStatusChanged: function(newStatus) { + this.$data.status = newStatus; + }, + } + }); + + app.use(i18n).mount(el); + }); +}); diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/types.ts b/src/Bundle/ChillCalendarBundle/Resources/public/types.ts new file mode 100644 index 000000000..ebc7ab7be --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/types.ts @@ -0,0 +1,67 @@ +import {EventInput} from '@fullcalendar/vue3'; +import {DateTime, Location, User, UserAssociatedInterface} from '../../../ChillMainBundle/Resources/public/types' ; +import {Person} from "../../../ChillPersonBundle/Resources/public/types"; + +export interface CalendarRange { + id: number; + endDate: DateTime; + startDate: DateTime; + user: User; + location: Location; + createdAt: DateTime; + createdBy: User; + updatedAt: DateTime; + updatedBy: User; +} + +export interface CalendarRangeCreate { + user: UserAssociatedInterface; + startDate: DateTime; + endDate: DateTime; + location: Location; +} + +export interface CalendarRangeEdit { + startDate?: DateTime, + endDate?: DateTime + location?: Location; +} + +export interface Calendar { + id: number; +} + +export interface CalendarLight { + id: number; + endDate: DateTime; + startDate: DateTime; + mainUser: User; + persons: Person[]; + status: "valid" | "moved" | "canceled"; +} + +export interface CalendarRemote { + id: number; + endDate: DateTime; + startDate: DateTime; + title: string; + isAllDay: boolean; +} + +export type EventInputCalendarRange = EventInput & { + id: string, + userId: number, + userLabel: string, + calendarRangeId: number, + locationId: number, + locationName: string, + start: string, + end: string, + is: "range" +}; + +export function isEventInputCalendarRange(toBeDetermined: EventInputCalendarRange | EventInput): toBeDetermined is EventInputCalendarRange { + return typeof toBeDetermined.is === "string" && toBeDetermined.is === "range"; +} + +export {}; diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/App.vue b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/App.vue index 2741f8b8f..472bb525b 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/App.vue +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/App.vue @@ -1,156 +1,280 @@ + diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/Components/CalendarActive.vue b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/Components/CalendarActive.vue new file mode 100644 index 000000000..cd6e52815 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/Components/CalendarActive.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/api.ts b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/api.ts new file mode 100644 index 000000000..17ef256ad --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/api.ts @@ -0,0 +1,38 @@ +import {fetchResults} from '../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods'; +import {datetimeToISO} from '../../../../../ChillMainBundle/Resources/public/chill/js/date'; +import {User} from '../../../../../ChillMainBundle/Resources/public/types'; +import {CalendarLight, CalendarRange, CalendarRemote} from '../../types'; + +// re-export whoami +export {whoami} from "../../../../../ChillMainBundle/Resources/public/lib/api/user"; + +/** + * + * @param user + * @param Date start + * @param Date end + * @return Promise + */ +export const fetchCalendarRangeForUser = (user: User, start: Date, end: Date): Promise => { + const uri = `/api/1.0/calendar/calendar-range-available/${user.id}.json`; + const dateFrom = datetimeToISO(start); + const dateTo = datetimeToISO(end); + + return fetchResults(uri, {dateFrom, dateTo}); +} + +export const fetchCalendarRemoteForUser = (user: User, start: Date, end: Date): Promise => { + const uri = `/api/1.0/calendar/proxy/calendar/by-user/${user.id}/events`; + const dateFrom = datetimeToISO(start); + const dateTo = datetimeToISO(end); + + return fetchResults(uri, {dateFrom, dateTo}); +} + +export const fetchCalendarLocalForUser = (user: User, start: Date, end: Date): Promise => { + const uri = `/api/1.0/calendar/calendar/by-user/${user.id}.json`; + const dateFrom = datetimeToISO(start); + const dateTo = datetimeToISO(end); + + return fetchResults(uri, {dateFrom, dateTo}); +} diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/const.ts b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/const.ts new file mode 100644 index 000000000..05a0d5627 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/const.ts @@ -0,0 +1,19 @@ + +const COLORS = [ /* from https://colorbrewer2.org/#type=qualitative&scheme=Set3&n=12 */ + '#8dd3c7', + '#ffffb3', + '#bebada', + '#fb8072', + '#80b1d3', + '#fdb462', + '#b3de69', + '#fccde5', + '#d9d9d9', + '#bc80bd', + '#ccebc5', + '#ffed6f' +]; + +export { + COLORS, +}; diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/i18n.js b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/i18n.js index 3020502e9..502db50b2 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/i18n.js +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/i18n.js @@ -1,19 +1,25 @@ -import { personMessages } from 'ChillPersonAssets/vuejs/_js/i18n' -import { calendarUserSelectorMessages } from '../_components/CalendarUserSelector/js/i18n'; -import { activityMessages } from 'ChillActivityAssets/vuejs/Activity/i18n'; +import {personMessages} from 'ChillPersonAssets/vuejs/_js/i18n' +import {calendarUserSelectorMessages} from '../_components/CalendarUserSelector/js/i18n'; +import {activityMessages} from 'ChillActivityAssets/vuejs/Activity/i18n'; const appMessages = { - fr: { - choose_your_date: "Sélectionnez votre plage", - activity: { - add_persons: "Ajouter des personnes concernées", - bloc_persons: "Usagers", - bloc_persons_associated: "Usagers du parcours", - bloc_persons_not_associated: "Tiers non-pro.", - bloc_thirdparty: "Tiers professionnels", - bloc_users: "T(M)S", - } - } + fr: { + choose_your_date: "Sélectionnez votre plage", + activity: { + add_persons: "Ajouter des personnes concernées", + bloc_persons: "Usagers", + bloc_persons_associated: "Usagers du parcours", + bloc_persons_not_associated: "Tiers non-pro.", + bloc_thirdparty: "Tiers professionnels", + bloc_users: "T(M)S", + }, + this_calendar_range_will_change_main_user: "Cette plage de disponibilité n'est pas celle de l'utilisateur principal. Si vous continuez, l'utilisateur principal sera adapté. Êtes-vous sûr·e ?", + will_change_main_user_for_me: "Vous ne pouvez pas écrire dans le calendrier d'un autre utilisateur. Voulez-vous être l'utilisateur principal de ce rendez-vous ?", + main_user_is_mandatory: "L'utilisateur principal est requis. Vous pouvez le modifier, mais pas le supprimer", + change_main_user_will_reset_event_data: "Modifier l'utilisateur principal nécessite de choisir une autre plage de disponibilité ou un autre horaire. Ces informations seront perdues. Êtes-vous sûr·e de vouloir continuer ?", + list_three_days: 'Liste 3 jours', + current_selected: 'Rendez-vous fixé', + } } Object.assign(appMessages.fr, personMessages.fr); @@ -21,5 +27,5 @@ Object.assign(appMessages.fr, calendarUserSelectorMessages.fr); Object.assign(appMessages.fr, activityMessages.fr); export { - appMessages + appMessages }; diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store.js b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store.js deleted file mode 100644 index db2950bd4..000000000 --- a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store.js +++ /dev/null @@ -1,228 +0,0 @@ -import 'es6-promise/auto'; -import { createStore } from 'vuex'; -import { postLocation } from 'ChillActivityAssets/vuejs/Activity/api'; -import { - getLocations, getLocationTypeByDefaultFor, - getUserCurrentLocation -} from "../../../../../ChillActivityBundle/Resources/public/vuejs/Activity/api"; - -const debug = process.env.NODE_ENV !== 'production'; - -const addIdToValue = (string, id) => { - let array = string ? string.split(',') : []; - array.push(id.toString()); - let str = array.join(); - return str; -}; - -const removeIdFromValue = (string, id) => { - let array = string.split(','); - array = array.filter(el => el !== id.toString()); - let str = array.join(); - return str; -}; - -/* -* Assign missing keys for the ConcernedGroups component -*/ -const mapEntity = (entity) => { - Object.assign(entity, {thirdParties: entity.professionals, users: entity.invites}); - return entity; -}; - -const store = createStore({ - strict: debug, - state: { - activity: mapEntity(window.entity), // activity is the calendar entity actually - currentEvent: null - }, - getters: { - suggestedEntities(state) { - if (typeof(state.activity.accompanyingPeriod) === 'undefined') { - return []; - } - const allEntities = [ - ...store.getters.suggestedPersons, - ...store.getters.suggestedRequestor, - ...store.getters.suggestedUser, - ...store.getters.suggestedResources - ]; - const uniqueIds = [...new Set(allEntities.map(i => `${i.type}-${i.id}`))]; - return Array.from(uniqueIds, id => allEntities.filter(r => `${r.type}-${r.id}` === id)[0]); - }, - suggestedPersons(state) { - const existingPersonIds = state.activity.persons.map(p => p.id); - return state.activity.accompanyingPeriod.participations - .filter(p => p.endDate === null) - .map(p => p.person) - .filter(p => !existingPersonIds.includes(p.id)) - }, - suggestedRequestor(state) { - const existingPersonIds = state.activity.persons.map(p => p.id); - const existingThirdPartyIds = state.activity.thirdParties.map(p => p.id); - return [state.activity.accompanyingPeriod.requestor] - .filter(r => - (r.type === 'person' && !existingPersonIds.includes(r.id)) || - (r.type === 'thirdparty' && !existingThirdPartyIds.includes(r.id)) - ); - }, - suggestedUser(state) { - const existingUserIds = state.activity.users.map(p => p.id); - return [state.activity.accompanyingPeriod.user] - .filter( - u => !existingUserIds.includes(u.id) - ); - }, - suggestedResources(state) { - const resources = state.activity.accompanyingPeriod.resources; - const existingPersonIds = state.activity.persons.map(p => p.id); - const existingThirdPartyIds = state.activity.thirdParties.map(p => p.id); - return state.activity.accompanyingPeriod.resources - .map(r => r.resource) - .filter(r => - (r.type === 'person' && !existingPersonIds.includes(r.id)) || - (r.type === 'thirdparty' && !existingThirdPartyIds.includes(r.id)) - ); - } - }, - mutations: { - - // ConcernedGroups - addPersonsInvolved(state, payload) { - //console.log('### mutation addPersonsInvolved', payload.result.type); - switch (payload.result.type) { - case 'person': - state.activity.persons.push(payload.result); - break; - case 'thirdparty': - state.activity.thirdParties.push(payload.result); - break; - case 'user': - state.activity.users.push(payload.result); - break; - }; - }, - removePersonInvolved(state, payload) { - //console.log('### mutation removePersonInvolved', payload.type); - switch (payload.type) { - case 'person': - state.activity.persons = state.activity.persons.filter(person => person !== payload); - break; - case 'thirdparty': - state.activity.thirdParties = state.activity.thirdParties.filter(thirdparty => thirdparty !== payload); - break; - case 'user': - state.activity.users = state.activity.users.filter(user => user !== payload); - break; - }; - }, - // Calendar - setEvents(state, payload) { - console.log(payload) - state.currentEvent = {start: payload.start, end: payload.end} - }, - // Location - updateLocation(state, value) { - console.log('### mutation: updateLocation', value); - state.activity.location = value; - } - }, - actions: { - addPersonsInvolved({ commit }, payload) { - console.log('### action addPersonsInvolved', payload.result.type); - switch (payload.result.type) { - case 'person': - let aPersons = document.getElementById("chill_calendarbundle_calendar_persons"); - aPersons.value = addIdToValue(aPersons.value, payload.result.id); - break; - case 'thirdparty': - let aThirdParties = document.getElementById("chill_calendarbundle_calendar_professionals"); - aThirdParties.value = addIdToValue(aThirdParties.value, payload.result.id); - break; - case 'user': - let aUsers = document.getElementById("chill_calendarbundle_calendar_invites"); - aUsers.value = addIdToValue(aUsers.value, payload.result.id); - break; - }; - commit('addPersonsInvolved', payload); - }, - removePersonInvolved({ commit }, payload) { - //console.log('### action removePersonInvolved', payload); - switch (payload.type) { - case 'person': - let aPersons = document.getElementById("chill_calendarbundle_calendar_persons"); - aPersons.value = removeIdFromValue(aPersons.value, payload.id); - break; - case 'thirdparty': - let aThirdParties = document.getElementById("chill_calendarbundle_calendar_professionals"); - aThirdParties.value = removeIdFromValue(aThirdParties.value, payload.id); - break; - case 'user': - let aUsers = document.getElementById("chill_calendarbundle_calendar_invites"); - aUsers.value = removeIdFromValue(aUsers.value, payload.id); - break; - }; - commit('removePersonInvolved', payload); - }, - - // Calendar - createEvent({ commit }, payload) { - console.log('### action createEvent', payload); - let startDateInput = document.getElementById("chill_calendarbundle_calendar_startDate"); - startDateInput.value = payload.startStr; - let endDateInput = document.getElementById("chill_calendarbundle_calendar_endDate"); - endDateInput.value = payload.endStr; - let mainUserInput = document.getElementById("chill_calendarbundle_calendar_mainUser"); - mainUserInput.value = payload.users.logged.id; - commit('setEvents', payload); - }, - updateEvent({ commit }, payload) { - console.log('### action updateEvent', payload); - let startDateInput = document.getElementById("chill_calendarbundle_calendar_startDate"); - startDateInput.value = payload.event.start.toISOString(); - let endDateInput = document.getElementById("chill_calendarbundle_calendar_endDate"); - endDateInput.value = payload.event.end.toISOString(); - let calendarRangeInput = document.getElementById("chill_calendarbundle_calendar_calendarRange"); - calendarRangeInput.value = Number(payload.event.extendedProps.calendarRangeId); - let mainUserInput = document.getElementById("chill_calendarbundle_calendar_mainUser"); - mainUserInput.value = Number(payload.event.source.id); - commit('setEvents', payload); - }, - - // Location - updateLocation({ commit }, value) { - console.log('### action: updateLocation', value); - let hiddenLocation = document.getElementById("chill_calendarbundle_calendar_location"); - if (value.onthefly) { - const body = { - "type": "location", - "name": value.name === '__AccompanyingCourseLocation__' ? null : value.name, - "locationType": { - "id": value.locationType.id, - "type": "location-type" - } - }; - if (value.address.id) { - Object.assign(body, { - "address": { - "id": value.address.id - }, - }) - } - postLocation(body) - .then( - location => hiddenLocation.value = location.id - ).catch( - err => { - console.log(err.message); - } - ); - } else { - hiddenLocation.value = value.id; - } - commit("updateLocation", value); - } - } -}); - -export default store; diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/actions.js b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/actions.js new file mode 100644 index 000000000..1568944db --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/actions.js @@ -0,0 +1,241 @@ +import { + addIdToValue, + removeIdFromValue, +} from './utils'; +import { + fetchCalendarRangeForUser, + fetchCalendarRemoteForUser, + fetchCalendarLocalForUser, +} from './../api'; +import {datetimeToISO} from 'ChillMainAssets/chill/js/date'; +import {postLocation} from 'ChillActivityAssets/vuejs/Activity/api'; + +/** + * This will store a unique key for each value, and prevent to launch the same + * request multiple times, when fetching user calendars. + * + * Actually, each time a user is added or removed, the methods "dateSet" is executed and this + * sparkle a request by user to get the calendar data. When the calendar data is fetched, it is + * immediatly added to the calendar which, in turn , launch the event dateSet and re-launch fetch + * queries which has not yet ended. Storing the queries already executed prevent this loop. + * + * @type {Set} + */ +const fetchings = new Set(); + +export default { + setCurrentDatesView({commit, dispatch}, {start, end}) { + commit('setCurrentDatesView', {start, end}); + + return dispatch('fetchCalendarEvents'); + }, + fetchCalendarEvents({state, getters, dispatch}) { + if (state.currentView.start === null && state.currentView.end === null) { + return Promise.resolve(); + } + + let promises = []; + for (const uid of state.currentView.users.keys()) { + let unique = `${uid}, ${state.currentView.start.toISOString()}, ${state.currentView.end.toISOString()}`; + + if (fetchings.has(unique)) { + console.log('prevent from fetching for a user', unique); + continue; + } + + fetchings.add(unique); + + promises.push( + dispatch( + 'fetchCalendarRangeForUser', + {user: state.usersData.get(uid).user, start: state.currentView.start, end: state.currentView.end} + ) + ); + promises.push( + dispatch( + 'fetchCalendarRemotesForUser', + {user: state.usersData.get(uid).user, start: state.currentView.start, end: state.currentView.end} + ) + ); + promises.push( + dispatch( + 'fetchCalendarLocalsForUser', + {user: state.usersData.get(uid).user, start: state.currentView.start, end: state.currentView.end} + ) + ); + } + + return Promise.all(promises); + }, + fetchCalendarRangeForUser({commit, getters}, {user, start, end}) { + if (!getters.isCalendarRangeLoadedForUser({user, start, end})) { + return fetchCalendarRangeForUser(user, start, end).then((ranges) => { + commit('addCalendarRangesForUser', {user, ranges, start, end}); + + return Promise.resolve(); + }); + } + }, + fetchCalendarRemotesForUser({commit, getters}, {user, start, end}) { + if (!getters.isCalendarRemoteLoadedForUser({user, start, end})) { + return fetchCalendarRemoteForUser(user, start, end).then((remotes) => { + commit('addCalendarRemotesForUser', {user, remotes, start, end}); + + return Promise.resolve(); + }); + } + }, + fetchCalendarLocalsForUser({commit, getters}, {user, start, end}) { + if (!getters.isCalendarRemoteLoadedForUser({user, start, end})) { + return fetchCalendarLocalForUser(user, start, end).then((locals) => { + commit('addCalendarLocalsForUser', {user, locals, start, end}); + + return Promise.resolve(); + }); + } + }, + addPersonsInvolved({commit, dispatch}, payload) { + console.log('### action addPersonsInvolved', payload.result.type); + console.log('### action addPersonsInvolved payload result', payload.result); + switch (payload.result.type) { + case 'person': + let aPersons = document.getElementById("chill_activitybundle_activity_persons"); + aPersons.value = addIdToValue(aPersons.value, payload.result.id); + break; + case 'thirdparty': + let aThirdParties = document.getElementById("chill_activitybundle_activity_professionals"); + aThirdParties.value = addIdToValue(aThirdParties.value, payload.result.id); + break; + case 'user': + let aUsers = document.getElementById("chill_activitybundle_activity_users"); + aUsers.value = addIdToValue(aUsers.value, payload.result.id); + commit('showUserOnCalendar', {user: payload.result, ranges: false, remotes: true}); + dispatch('fetchCalendarEvents'); + break; + } + ; + commit('addPersonsInvolved', payload); + }, + removePersonInvolved({commit}, payload) { + //console.log('### action removePersonInvolved', payload); + switch (payload.type) { + case 'person': + let aPersons = document.getElementById("chill_activitybundle_activity_persons"); + aPersons.value = removeIdFromValue(aPersons.value, payload.id); + break; + case 'thirdparty': + let aThirdParties = document.getElementById("chill_activitybundle_activity_professionals"); + aThirdParties.value = removeIdFromValue(aThirdParties.value, payload.id); + break; + case 'user': + let aUsers = document.getElementById("chill_activitybundle_activity_users"); + aUsers.value = removeIdFromValue(aUsers.value, payload.id); + break; + } + ; + commit('removePersonInvolved', payload); + }, + + // Calendar + /** + * set event startDate and endDate. + * + * if the mainUser is different from "me", it will replace the mainUser + * + * @param commit + * @param state + * @param getters + * @param start + * @param end + */ + setEventTimes({commit, state, getters}, {start, end}) { + console.log('### action createEvent', {start, end}); + let startDateInput = document.getElementById("chill_activitybundle_activity_startDate"); + startDateInput.value = null !== start ? datetimeToISO(start) : ''; + let endDateInput = document.getElementById("chill_activitybundle_activity_endDate"); + endDateInput.value = null !== end ? datetimeToISO(end) : ''; + let calendarRangeInput = document.getElementById("chill_activitybundle_activity_calendarRange"); + calendarRangeInput.value = ""; + + if (getters.getMainUser === null || getters.getMainUser.id !== state.me.id) { + let mainUserInput = document.getElementById("chill_activitybundle_activity_mainUser"); + mainUserInput.value = state.me.id; + commit('setMainUser', state.me); + } + + commit('setEventTimes', {start, end}); + }, + associateCalendarToRange({state, commit, dispatch, getters}, {range}) { + console.log('### action associateCAlendarToRange', range); + let startDateInput = document.getElementById("chill_activitybundle_activity_startDate"); + startDateInput.value = null !== range ? datetimeToISO(range.start) : ""; + let endDateInput = document.getElementById("chill_activitybundle_activity_endDate"); + endDateInput.value = null !== range ? datetimeToISO(range.end) : ""; + let calendarRangeInput = document.getElementById("chill_activitybundle_activity_calendarRange"); + calendarRangeInput.value = null !== range ? Number(range.extendedProps.calendarRangeId) : ""; + + if (null !== range) { + let location = getters.getLocationById(range.extendedProps.locationId); + + if (null === location) { + console.error("location not found!", range.extendedProps.locationId); + } + + dispatch('updateLocation', location); + + const userId = range.extendedProps.userId; + if (state.activity.mainUser !== null && state.activity.mainUser.id !== userId) { + dispatch('setMainUser', state.usersData.get(userId).user); + + // TODO: remove persons involved with this user + } + } + + commit('associateCalendarToRange', {range}); + return Promise.resolve(); + }, + setMainUser({commit, dispatch, state}, mainUser) { + console.log('setMainUser', mainUser); + + let mainUserInput = document.getElementById("chill_activitybundle_activity_mainUser"); + mainUserInput.value = Number(mainUser.id); + + return dispatch('associateCalendarToRange', { range: null }).then(() => { + commit('setMainUser', mainUser); + }); + }, + + // Location + updateLocation({commit}, value) { + console.log('### action: updateLocation', value); + let hiddenLocation = document.getElementById("chill_activitybundle_activity_location"); + if (value.onthefly) { + const body = { + "type": "location", + "name": value.name === '__AccompanyingCourseLocation__' ? null : value.name, + "locationType": { + "id": value.locationType.id, + "type": "location-type" + } + }; + if (value.address.id) { + Object.assign(body, { + "address": { + "id": value.address.id + }, + }) + } + postLocation(body) + .then( + location => hiddenLocation.value = location.id + ).catch( + err => { + console.log(err.message); + } + ); + } else { + hiddenLocation.value = value.id; + } + commit("updateLocation", value); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/getters.js b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/getters.js new file mode 100644 index 000000000..b9d9b6a82 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/getters.js @@ -0,0 +1,272 @@ +export default { + /** + * get the main user of the event/Calendar + * + * @param state + * @returns {*|null} + */ + getMainUser(state) { + return state.activity.mainUser || null; + }, + /** + * return the date of the event/Calendar + * + * @param state + * @returns {Date} + */ + getEventDate(state) { + if (null === state.activity.start) { + return new Date(); + } + throw 'transform date to object ?'; + }, + /** + * Compute the event sources to show on the FullCalendar + * + * @param state + * @param getters + * @returns {[]} + */ + getEventSources(state, getters) { + let sources = []; + + // current calendar + if (state.activity.startDate !== null && state.activity.endDate !== null) { + const s = { + id: 'current', + events: [ + { + title: "Rendez-vous", + start: state.activity.startDate, + end: state.activity.endDate, + allDay: false, + is: "current", + classNames: ['iscurrent'], + } + ], + editable: state.activity.calendarRange === null, + }; + + sources.push(s); + } + + for (const [userId, kinds] of state.currentView.users.entries()) { + if (!state.usersData.has(userId)) { + console.log('try to get events on a user which not exists', userId); + continue; + } + + const userData = state.usersData.get(userId); + + if (kinds.ranges && userData.calendarRanges.length > 0) { + const s = { + id: `ranges_${userId}`, + events: userData.calendarRanges.filter(r => state.activity.calendarRange === null || r.calendarRangeId !== state.activity.calendarRange.calendarRangeId), + color: userData.mainColor, + classNames: ['isrange'], + backgroundColor: 'white', + textColor: 'black', + editable: false, + }; + + sources.push(s); + } + + if (kinds.remotes && userData.remotes.length > 0) { + const s = { + 'id': `remote_${userId}`, + events: userData.remotes, + color: userData.mainColor, + textColor: 'black', + editable: false, + }; + + sources.push(s); + } + + // if remotes is checked, we display also the locals calendars + if (kinds.remotes && userData.locals.length > 0) { + const s = { + 'id': `local_${userId}`, + events: userData.locals.filter(l => l.originId !== state.activity.id), + color: userData.mainColor, + textColor: 'black', + editable: false, + }; + + sources.push(s); + } + } + + return sources; + }, + getInitialDate(state) { + return state.activity.startDate; + }, + getInviteForUser: (state) => (user) => { + return state.activity.invites.find(i => i.user.id === user.id); + }, + /** + * get the user data for a specific user + * + * @param state + * @returns {function(*): unknown} + */ + getUserData: (state) => (user) => { + return state.usersData.get(user.id); + }, + getUserDataById: (state) => (userId) => { + return state.usersData.get(userId); + }, + /** + * return true if the user has an entry in userData + * + * @param state + * @returns {function(*): boolean} + */ + hasUserData: (state) => (user) => { + return state.usersData.has(user.id); + }, + hasUserDataById: (state) => (userId) => { + return state.usersData.has(userId); + }, + /** + * return true if there was a fetch query for event between this date (start and end), + * those date are included. + * + * @param state + * @param getters + * @returns {(function({user: *, start: *, end: *}): (boolean))|*} + */ + isCalendarRangeLoadedForUser: (state, getters) => ({user, start, end}) => { + if (!getters.hasUserData(user)) { + return false; + } + + for (let interval of getters.getUserData(user).calendarRangesLoaded) { + if (start >= interval.start && end <= interval.end) { + return true; + } + } + + return false; + }, + /** + * return true if there was a fetch query for event between this date (start and end), + * those date are included. + * + * @param state + * @param getters + * @returns {(function({user: *, start: *, end: *}): (boolean))|*} + */ + isCalendarRemoteLoadedForUser: (state, getters) => ({user, start, end}) => { + if (!getters.hasUserData(user)) { + return false; + } + + for (let interval of getters.getUserData(user).remotesLoaded) { + if (start >= interval.start && end <= interval.end) { + return true; + } + } + + return false; + }, + /** + * return true if the user ranges are shown on calendar + * + * @param state + * @returns boolean + */ + isRangeShownOnCalendarForUser: (state) => (user) => { + const k = state.currentView.users.get(user.id); + if (typeof k === 'undefined') { + console.error('try to determinate if calendar range is shown and user is not in currentView'); + return false; + } + + return k.ranges; + }, + + /** + * return true if the user remote is shown on calendar + * @param state + * @returns boolean + */ + isRemoteShownOnCalendarForUser: (state) => (user) => { + const k = state.currentView.users.get(user.id); + if (typeof k === 'undefined') { + console.error('try to determinate if calendar range is shown and user is not in currentView'); + return false; + } + + return k.remotes; + }, + + getLocationById: (state) => (id) => { + for (let group of state.availableLocations) { + console.log('group', group); + const found = group.locations.find(l => l.id === id); + if (typeof found !== "undefined") { + return found; + } + } + + return null; + }, + + suggestedEntities(state, getters) { + if (typeof (state.activity.accompanyingPeriod) === 'undefined') { + return []; + } + const allEntities = [ + ...getters.suggestedPersons, + ...getters.suggestedRequestor, + ...getters.suggestedUser, + ...getters.suggestedResources + ]; + const uniqueIds = [...new Set(allEntities.map(i => `${i.type}-${i.id}`))]; + return Array.from(uniqueIds, id => allEntities.filter(r => `${r.type}-${r.id}` === id)[0]); + }, + suggestedPersons(state) { + const existingPersonIds = state.activity.persons.map(p => p.id); + return state.activity.accompanyingPeriod.participations + .filter(p => p.endDate === null) + .map(p => p.person) + .filter(p => !existingPersonIds.includes(p.id)) + }, + suggestedRequestor(state) { + if (state.activity.accompanyingPeriod.requestor === null) { + return []; + } + + const existingPersonIds = state.activity.persons.map(p => p.id); + const existingThirdPartyIds = state.activity.thirdParties.map(p => p.id); + return [state.activity.accompanyingPeriod.requestor] + .filter(r => + (r.type === 'person' && !existingPersonIds.includes(r.id)) || + (r.type === 'thirdparty' && !existingThirdPartyIds.includes(r.id)) + ); + }, + suggestedUser(state) { + if (null === state.activity.users) { + return []; + } + const existingUserIds = state.activity.users.map(p => p.id); + return [state.activity.accompanyingPeriod.user] + .filter( + u => u !== null && !existingUserIds.includes(u.id) + ); + }, + suggestedResources(state) { + const resources = state.activity.accompanyingPeriod.resources; + const existingPersonIds = state.activity.persons.map(p => p.id); + const existingThirdPartyIds = state.activity.thirdParties.map(p => p.id); + return state.activity.accompanyingPeriod.resources + .map(r => r.resource) + .filter(r => + (r.type === 'person' && !existingPersonIds.includes(r.id)) || + (r.type === 'thirdparty' && !existingThirdPartyIds.includes(r.id)) + ); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/index.js b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/index.js new file mode 100644 index 000000000..ffb186cdf --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/index.js @@ -0,0 +1,59 @@ +import 'es6-promise/auto'; +import { createStore } from 'vuex'; +import { postLocation } from 'ChillActivityAssets/vuejs/Activity/api'; +import getters from './getters'; +import actions from './actions'; +import mutations from './mutations'; +import { mapEntity } from './utils'; +import { whoami } from '../api'; +import prepareLocations from "ChillActivityAssets/vuejs/Activity/store.locations"; + +const debug = process.env.NODE_ENV !== 'production'; + +const store = createStore({ + strict: debug, + state: { + activity: mapEntity(window.entity), // activity is the calendar entity actually + currentEvent: null, + availableLocations: [], + /** + * the current user + */ + me: null, + /** + * store information about current view + */ + currentView: { + start: null, + end: null, + users: new Map(), + }, + /** + * store a list of existing event, to avoid storing them twice + */ + existingEvents: new Set(), + /** + * store user data + */ + usersData: new Map(), + }, + getters, + mutations, + actions, +}); + +whoami().then(me => { + store.commit('setWhoAmiI', me); +}); + +if (null !== store.getters.getMainUser) { + store.commit('showUserOnCalendar', {ranges: true, remotes: true, user: store.getters.getMainUser}); +} + +for (let u of store.state.activity.users) { + store.commit('showUserOnCalendar', {ranges: false, remotes: false, user: u}); +} + +prepareLocations(store); + +export default store; diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/mutations.js b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/mutations.js new file mode 100644 index 000000000..c956b0d87 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/mutations.js @@ -0,0 +1,195 @@ +import { + createUserData, + calendarRangeToFullCalendarEvent, + remoteToFullCalendarEvent, + localsToFullCalendarEvent, +} from './utils'; + +export default { + setWhoAmiI(state, me) { + state.me = me; + }, + setCurrentDatesView(state, {start, end}) { + state.currentView.start = start; + state.currentView.end = end; + }, + showUserOnCalendar(state, {user, ranges, remotes}) { + if (!state.usersData.has(user.id)) { + state.usersData.set(user.id, createUserData(user, state.usersData.size)); + } + + const cur = state.currentView.users.get(user.id); + + state.currentView.users.set( + user.id, + { + ranges: typeof ranges !== 'undefined' ? ranges : cur.ranges, + remotes: typeof remotes !== 'undefined' ? remotes : cur.remotes, + } + ); + }, + /** + * Set the event start and end to the given start and end, + * and remove eventually the calendar range. + * + * @param state + * @param Date start + * @param Date end + */ + setEventTimes(state, {start, end}) { + state.activity.startDate = start; + state.activity.endDate = end; + state.activity.calendarRange = null; + }, + /** + * Set the event's start and end from the calendar range data, + * and associate event to calendar range. + * + * @param state + * @param range + */ + associateCalendarToRange(state, {range}) { + console.log('associateCalendarToRange', range); + + if (null === range) { + state.activity.calendarRange = null; + state.activity.startDate = null; + state.activity.endDate = null; + + return; + } + + console.log('userId', range.extendedProps.userId); + + const r = state.usersData.get(range.extendedProps.userId).calendarRanges + .find(r => r.calendarRangeId === range.extendedProps.calendarRangeId); + + if (typeof r === 'undefined') { + throw Error('Could not find managed calendar range'); + } + + console.log('range found', r); + + state.activity.startDate = range.start; + state.activity.endDate = range.end; + state.activity.calendarRange = r; + + console.log('activity', state.activity); + }, + + setMainUser(state, user) { + state.activity.mainUser = user; + }, + + // ConcernedGroups + addPersonsInvolved(state, payload) { + //console.log('### mutation addPersonsInvolved', payload.result.type); + switch (payload.result.type) { + case 'person': + state.activity.persons.push(payload.result); + break; + case 'thirdparty': + state.activity.thirdParties.push(payload.result); + break; + case 'user': + state.activity.users.push(payload.result); + break; + } + ; + }, + removePersonInvolved(state, payload) { + //console.log('### mutation removePersonInvolved', payload.type); + switch (payload.type) { + case 'person': + state.activity.persons = state.activity.persons.filter(person => person !== payload); + break; + case 'thirdparty': + state.activity.thirdParties = state.activity.thirdParties.filter(thirdparty => thirdparty !== payload); + break; + case 'user': + state.activity.users = state.activity.users.filter(user => user !== payload); + break; + } + ; + }, + /** + * Add CalendarRange object for an user + * + * @param state + * @param user + * @param ranges + * @param start + * @param end + */ + addCalendarRangesForUser(state, {user, ranges, start, end}) { + let userData; + if (state.usersData.has(user.id)) { + userData = state.usersData.get(user.id); + } else { + userData = createUserData(user, state.usersData.size); + state.usersData.set(user.id, userData); + } + + const eventRanges = ranges + .filter(r => !state.existingEvents.has(`range_${r.id}`)) + .map(r => { + // add to existing ids + state.existingEvents.add(`range_${r.id}`); + return r; + }) + .map(r => calendarRangeToFullCalendarEvent(r)); + + userData.calendarRanges = userData.calendarRanges.concat(eventRanges); + userData.calendarRangesLoaded.push({start, end}); + }, + addCalendarRemotesForUser(state, {user, remotes, start, end}) { + let userData; + if (state.usersData.has(user.id)) { + userData = state.usersData.get(user.id); + } else { + userData = createUserData(user, state.usersData.size); + state.usersData.set(user.id, userData); + } + + const eventRemotes = remotes + .filter(r => !state.existingEvents.has(`remote_${r.id}`)) + .map(r => { + // add to existing ids + state.existingEvents.add(`remote_${r.id}`); + return r; + }) + .map(r => remoteToFullCalendarEvent(r)); + + userData.remotes = userData.remotes.concat(eventRemotes); + userData.remotesLoaded.push({start, end}); + }, + addCalendarLocalsForUser(state, {user, locals, start, end}) { + let userData; + if (state.usersData.has(user.id)) { + userData = state.usersData.get(user.id); + } else { + userData = createUserData(user, state.usersData.size); + state.usersData.set(user.id, userData); + } + + const eventRemotes = locals + .filter(r => !state.existingEvents.has(`locals_${r.id}`)) + .map(r => { + // add to existing ids + state.existingEvents.add(`locals_${r.id}`); + return r; + }) + .map(r => localsToFullCalendarEvent(r)); + + userData.locals = userData.locals.concat(eventRemotes); + userData.localsLoaded.push({start, end}); + }, + // Location + updateLocation(state, value) { + console.log('### mutation: updateLocation', value); + state.activity.location = value; + }, + addAvailableLocationGroup(state, group) { + state.availableLocations.push(group); + }, +}; diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/utils.ts b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/utils.ts new file mode 100644 index 000000000..61259a984 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/utils.ts @@ -0,0 +1,108 @@ +import {COLORS} from '../const'; +import {ISOToDatetime} from '../../../../../../ChillMainBundle/Resources/public/chill/js/date'; +import {DateTime, User} from '../../../../../../ChillMainBundle/Resources/public/types'; +import {CalendarLight, CalendarRange, CalendarRemote} from '../../../types'; +import type {EventInputCalendarRange} from '../../../types'; +import {EventInput} from '@fullcalendar/vue3'; + +export interface UserData { + user: User, + calendarRanges: CalendarRange[], + calendarRangesLoaded: {}[], + remotes: CalendarRemote[], + remotesLoaded: {}[], + locals: CalendarRemote[], + localsLoaded: {}[], + mainColor: string, +} + +export const addIdToValue = (string: string, id: number): string => { + let array = string ? string.split(',') : []; + array.push(id.toString()); + let str = array.join(); + return str; +}; + +export const removeIdFromValue = (string: string, id: number) => { + let array = string.split(','); + array = array.filter(el => el !== id.toString()); + let str = array.join(); + return str; +}; + +/* +* Assign missing keys for the ConcernedGroups component +*/ +export const mapEntity = (entity: EventInput): EventInput => { + let calendar = { ...entity}; + Object.assign(calendar, {thirdParties: entity.professionals}); + + if (entity.startDate !== null ) { + calendar.startDate = ISOToDatetime(entity.startDate.datetime); + } + if (entity.endDate !== null) { + calendar.endDate = ISOToDatetime(entity.endDate.datetime); + } + + if (entity.calendarRange !== null) { + calendar.calendarRange.calendarRangeId = entity.calendarRange.id; + calendar.calendarRange.id = `range_${entity.calendarRange.id}`; + } + + return calendar; +}; + +export const createUserData = (user: User, colorIndex: number): UserData => { + const colorId = colorIndex % COLORS.length; + console.log('colorId', colorId); + return { + user: user, + calendarRanges: [], + calendarRangesLoaded: [], + remotes: [], + remotesLoaded: [], + locals: [], + localsLoaded: [], + mainColor: COLORS[colorId], + } +} + +// TODO move this function to a more global namespace, as it is also in use in MyCalendarRange app +export const calendarRangeToFullCalendarEvent = (entity: CalendarRange): EventInputCalendarRange => { + return { + id: `range_${entity.id}`, + title: "(" + entity.user.text + ")", + start: entity.startDate.datetime8601, + end: entity.endDate.datetime8601, + allDay: false, + userId: entity.user.id, + userLabel: entity.user.label, + calendarRangeId: entity.id, + locationId: entity.location.id, + locationName: entity.location.name, + is: 'range', + }; +} + +export const remoteToFullCalendarEvent = (entity: CalendarRemote): EventInput & {id: string} => { + return { + id: `range_${entity.id}`, + title: entity.title, + start: entity.startDate.datetime8601, + end: entity.endDate.datetime8601, + allDay: entity.isAllDay, + is: 'remote', + }; +} + +export const localsToFullCalendarEvent = (entity: CalendarLight): EventInput & {id: string; originId: number;} => { + return { + id: `local_${entity.id}`, + title: entity.persons.map(p => p.text).join(', '), + originId: entity.id, + start: entity.startDate.datetime8601, + end: entity.endDate.datetime8601, + allDay: false, + is: 'local', + }; +} diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Invite/Answer.vue b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Invite/Answer.vue new file mode 100644 index 000000000..4607af7fc --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Invite/Answer.vue @@ -0,0 +1,95 @@ + + + + + + diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/App.vue b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/App.vue deleted file mode 100644 index a30dd2000..000000000 --- a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/App.vue +++ /dev/null @@ -1,468 +0,0 @@ - - - diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/App2.vue b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/App2.vue new file mode 100644 index 000000000..5cbd17a76 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/App2.vue @@ -0,0 +1,304 @@ + + + + + diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/Components/EditLocation.vue b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/Components/EditLocation.vue new file mode 100644 index 000000000..3b605dec7 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/Components/EditLocation.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/i18n.js b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/i18n.ts similarity index 76% rename from src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/i18n.js rename to src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/i18n.ts index 2305b307b..383fff19f 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/i18n.js +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/i18n.ts @@ -3,6 +3,8 @@ const appMessages = { edit_your_calendar_range: "Planifiez vos plages de disponibilités", show_my_calendar: "Afficher mon calendrier", show_weekends: "Afficher les week-ends", + copy_range: "Copier", + copy_range_from_to: "Copier les plages d'un jour à l'autre", copy_range_to_next_day: "Copier les plages du jour au jour suivant", copy_range_from_day: "Copier les plages du ", to_the_next_day: " au jour suivant", @@ -12,7 +14,13 @@ const appMessages = { update_range_to_save: "Plages à modifier", delete_range_to_save: "Plages à supprimer", by: "Par", - main_user_concerned: "Utilisateur concerné" + main_user_concerned: "Utilisateur concerné", + dateFrom: "De", + dateTo: "à", + day: "Jour", + week: "Semaine", + month: "Mois", + today: "Aujourd'hui", } } diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/index.js b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/index.js deleted file mode 100644 index a10a16601..000000000 --- a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/index.js +++ /dev/null @@ -1,16 +0,0 @@ -import { createApp } from 'vue'; -import { _createI18n } from 'ChillMainAssets/vuejs/_js/i18n' -import { appMessages } from './i18n' -import store from './store' - -import App from './App.vue'; - -const i18n = _createI18n(appMessages); - -const app = createApp({ - template: ``, -}) -.use(store) -.use(i18n) -.component('app', App) -.mount('#myCalendar'); diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/index2.ts b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/index2.ts new file mode 100644 index 000000000..30a238255 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/index2.ts @@ -0,0 +1,19 @@ +import { createApp } from 'vue'; +import { _createI18n } from '../../../../../ChillMainBundle/Resources/public/vuejs/_js/i18n' +import { appMessages } from './i18n' +import futureStore, {key} from './store/index' + +import App2 from './App2.vue'; +import {useI18n} from "vue-i18n"; + +futureStore().then((store) => { + const i18n = _createI18n(appMessages, true); + + const app = createApp({ + template: ``, + }) + .use(store, key) + .use(i18n) + .component('app', App2) + .mount('#myCalendar'); +}); diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store.js b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store.js deleted file mode 100644 index 997f95b11..000000000 --- a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store.js +++ /dev/null @@ -1,89 +0,0 @@ -import 'es6-promise/auto'; -import { createStore } from 'vuex'; -import { postCalendarRange, patchCalendarRange, deleteCalendarRange } from '../_api/api'; - -const debug = process.env.NODE_ENV !== 'production'; - -const store = createStore({ - strict: debug, - state: { - newCalendarRanges: [], - updateCalendarRanges: [], - deleteCalendarRanges: [] - }, - mutations: { - updateRange(state, payload) { - state.updateCalendarRanges.push({ - id: payload.event.extendedProps.calendarRangeId, - start: payload.event.start, - end: payload.event.end - }); - }, - addRange(state, payload) { - state.newCalendarRanges.push({ - start: payload.start, - end: payload.end - }); - }, - deleteRange(state, payload) { - state.deleteCalendarRanges.push({ - id: payload.extendedProps.calendarRangeId, - start: payload.start, - end: payload.end - }); - }, - clearNewCalendarRanges(state) { - state.newCalendarRanges = []; - }, - clearUpdateCalendarRanges(state) { - state.updateCalendarRanges = []; - }, - clearDeleteCalendarRanges(state) { - state.deleteCalendarRanges = []; - }, - removeNewCalendarRanges(state, payload) { - let filteredCollection = state.newCalendarRanges.filter( - (e) => e.start.toString() !== payload.start.toString() && e.end.toString() !== payload.end.toString() - ) - state.newCalendarRanges = filteredCollection; - }, - removeFromDeleteRange(state, payload) { - let filteredCollection = state.deleteCalendarRanges.filter( - (e) => e.start.toString() !== payload.start.toString() && e.end.toString() !== payload.end.toString() - ) - state.deleteCalendarRanges = filteredCollection; - }, - }, - actions: { - createRange({ commit }, payload) { - console.log('### action createRange', payload); - commit('addRange', payload); - }, - updateRange({ commit }, payload) { - console.log('### action updateRange', payload); - commit('updateRange', payload); - }, - deleteRange({ commit }, payload) { - console.log('### action deleteRange', payload); - commit('deleteRange', payload); - }, - clearNewCalendarRanges({ commit }, payload) { - commit('clearNewCalendarRanges', payload); - }, - clearUpdateCalendarRanges({ commit }, payload) { - commit('clearUpdateCalendarRanges', payload); - }, - clearDeleteCalendarRanges({ commit }, payload) { - commit('clearDeleteCalendarRanges', payload); - }, - removeNewCalendarRanges({ commit }, payload) { - commit('removeNewCalendarRanges', payload); - }, - removeFromDeleteRange({ commit }, payload) { - commit('removeFromDeleteRange', payload); - }, - } -}); - - -export default store; diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/index.ts b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/index.ts new file mode 100644 index 000000000..f87832cb5 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/index.ts @@ -0,0 +1,50 @@ +import 'es6-promise/auto'; +import {Store, createStore} from 'vuex'; +import {InjectionKey} from "vue"; +import me, {MeState} from './modules/me'; +import fullCalendar, {FullCalendarState} from './modules/fullcalendar'; +import calendarRanges, {CalendarRangesState} from './modules/calendarRanges'; +import calendarRemotes, {CalendarRemotesState} from './modules/calendarRemotes'; +import {whoami} from "../../../../../../ChillMainBundle/Resources/public/lib/api/user"; +import {User} from '../../../../../../ChillMainBundle/Resources/public/types'; +import locations, {LocationState} from "./modules/location"; +import calendarLocals, {CalendarLocalsState} from "./modules/calendarLocals"; + +const debug = process.env.NODE_ENV !== 'production'; + +export interface State { + calendarRanges: CalendarRangesState, + calendarRemotes: CalendarRemotesState, + calendarLocals: CalendarLocalsState, + fullCalendar: FullCalendarState, + me: MeState, + locations: LocationState +} + +export const key: InjectionKey> = Symbol(); + +const futureStore = function(): Promise> { + return whoami().then((user: User) => { + const store = createStore({ + strict: debug, + modules: { + me, + fullCalendar, + calendarRanges, + calendarRemotes, + calendarLocals, + locations, + }, + mutations: {} + }); + + store.commit('me/setWhoAmi', user, {root: true}); + store.dispatch('locations/getLocations', null, {root: true}).then(_ => { + return store.dispatch('locations/getCurrentLocation', null, {root: true}); + }); + + return Promise.resolve(store); + }); +} + +export default futureStore; diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/calendarLocals.ts b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/calendarLocals.ts new file mode 100644 index 000000000..12df9663d --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/calendarLocals.ts @@ -0,0 +1,95 @@ +import {State} from './../index'; +import {ActionContext, Module} from 'vuex'; +import {CalendarLight} from '../../../../types'; +import {fetchCalendarLocalForUser} from '../../../Calendar/api'; +import {EventInput} from '@fullcalendar/vue3'; +import {localsToFullCalendarEvent} from "../../../Calendar/store/utils"; +import {TransportExceptionInterface} from "../../../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods"; +import {COLORS} from "../../../Calendar/const"; + +export interface CalendarLocalsState { + locals: EventInput[], + localsLoaded: {start: number, end: number}[], + localsIndex: Set, + key: number +} + +type Context = ActionContext; + +export default > { + namespaced: true, + state: (): CalendarLocalsState => ({ + locals: [], + localsLoaded: [], + localsIndex: new Set(), + key: 0 + }), + getters: { + isLocalsLoaded: (state: CalendarLocalsState) => ({start, end}: {start: Date, end: Date}): boolean => { + for (let range of state.localsLoaded) { + if (start.getTime() === range.start && end.getTime() === range.end) { + return true; + } + } + + return false; + }, + }, + mutations: { + addLocals(state: CalendarLocalsState, ranges: CalendarLight[]) { + console.log('addLocals', ranges); + + const toAdd = ranges + .map(cr => localsToFullCalendarEvent(cr)) + .filter(r => !state.localsIndex.has(r.id)); + + toAdd.forEach((r) => { + state.localsIndex.add(r.id); + state.locals.push(r); + }); + state.key = state.key + toAdd.length; + }, + addLoaded(state: CalendarLocalsState, payload: {start: Date, end: Date}) { + state.localsLoaded.push({start: payload.start.getTime(), end: payload.end.getTime()}); + }, + }, + actions: { + fetchLocals(ctx: Context, payload: {start: Date, end: Date}): Promise { + const start = payload.start; + const end = payload.end; + + if (ctx.rootGetters['me/getMe'] === null) { + return Promise.resolve(null); + } + + if (ctx.getters.isLocalsLoaded({start, end})) { + return Promise.resolve(ctx.getters.getRangeSource); + } + + ctx.commit('addLoaded', { + start: start, + end: end, + }); + + return fetchCalendarLocalForUser( + ctx.rootGetters['me/getMe'], + start, + end + ) + .then((remotes: CalendarLight[]) => { + // to be add when reactivity problem will be solve ? + //ctx.commit('addRemotes', remotes); + const inputs = remotes + .map(cr => localsToFullCalendarEvent(cr)) + .map(cr => ({...cr, backgroundColor: COLORS[0], textColor: 'black', editable: false})) + ctx.commit('calendarRanges/addExternals', inputs, {root: true}); + return Promise.resolve(null); + }) + .catch((e: TransportExceptionInterface) => { + console.error(e); + + return Promise.resolve(null); + }); + } + } +}; diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/calendarRanges.ts b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/calendarRanges.ts new file mode 100644 index 000000000..fd7e2d0c1 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/calendarRanges.ts @@ -0,0 +1,252 @@ +import {State} from './../index'; +import {ActionContext, Module} from 'vuex'; +import {CalendarRange, CalendarRangeCreate, CalendarRangeEdit, isEventInputCalendarRange} from "../../../../types"; +import {Location} from "../../../../../../../ChillMainBundle/Resources/public/types"; +import {fetchCalendarRangeForUser} from '../../../Calendar/api'; +import {calendarRangeToFullCalendarEvent} from '../../../Calendar/store/utils'; +import {EventInput} from '@fullcalendar/vue3'; +import {makeFetch} from "../../../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods"; +import { + datetimeToISO, + dateToISO, + ISOToDatetime +} from "../../../../../../../ChillMainBundle/Resources/public/chill/js/date"; +import type {EventInputCalendarRange} from "../../../../types"; + +export interface CalendarRangesState { + ranges: (EventInput | EventInputCalendarRange) [], + rangesLoaded: { start: number, end: number }[], + rangesIndex: Set, + key: number +} + +type Context = ActionContext; + +export default >{ + namespaced: true, + state: (): CalendarRangesState => ({ + ranges: [], + rangesLoaded: [], + rangesIndex: new Set(), + key: 0 + }), + getters: { + isRangeLoaded: (state: CalendarRangesState) => ({start, end}: { start: Date, end: Date }): boolean => { + for (let range of state.rangesLoaded) { + if (start.getTime() === range.start && end.getTime() === range.end) { + return true; + } + } + + return false; + }, + getRangesOnDate: (state: CalendarRangesState) => (date: Date): EventInputCalendarRange[] => { + const founds = []; + const dateStr = dateToISO(date); + + for (let range of state.ranges) { + if (isEventInputCalendarRange(range) + && range.start.startsWith(dateStr) + ) { + founds.push(range); + } + } + + return founds; + }, + }, + mutations: { + addRanges(state: CalendarRangesState, ranges: CalendarRange[]) { + const toAdd = ranges + .map(cr => calendarRangeToFullCalendarEvent(cr)) + .map(cr => ({ + ...cr, backgroundColor: 'white', borderColor: '#3788d8', + textColor: 'black' + })) + .filter(r => !state.rangesIndex.has(r.id)); + + toAdd.forEach((r) => { + state.rangesIndex.add(r.id); + state.ranges.push(r); + }); + state.key = state.key + toAdd.length; + }, + addExternals(state, externalEvents: (EventInput & { id: string })[]) { + const toAdd = externalEvents + .filter(r => !state.rangesIndex.has(r.id)); + + toAdd.forEach((r) => { + state.rangesIndex.add(r.id); + state.ranges.push(r); + }); + state.key = state.key + toAdd.length; + }, + addLoaded(state: CalendarRangesState, payload: { start: Date, end: Date }) { + state.rangesLoaded.push({start: payload.start.getTime(), end: payload.end.getTime()}); + }, + addRange(state: CalendarRangesState, payload: CalendarRange) { + const asEvent = calendarRangeToFullCalendarEvent(payload); + state.ranges.push({...asEvent, backgroundColor: 'white', borderColor: '#3788d8', textColor: 'black'}); + state.rangesIndex.add(asEvent.id); + state.key = state.key + 1; + }, + removeRange(state: CalendarRangesState, calendarRangeId: number) { + const found = state.ranges.find(r => r.calendarRangeId === calendarRangeId && r.is === "range"); + + if (found !== undefined) { + state.ranges = state.ranges.filter( + (r) => !(r.calendarRangeId === calendarRangeId && r.is === "range") + ); + + if (typeof found.id === "string") { // should always be true + state.rangesIndex.delete(found.id); + } + + state.key = state.key + 1; + } + }, + updateRange(state, range: CalendarRange) { + const found = state.ranges.find(r => r.calendarRangeId === range.id && r.is === "range"); + const newEvent = calendarRangeToFullCalendarEvent(range); + + if (found !== undefined) { + found.start = newEvent.start; + found.end = newEvent.end; + found.locationId = range.location.id; + found.locationName = range.location.name; + } + + state.key = state.key + 1; + } + }, + actions: { + fetchRanges(ctx: Context, payload: { start: Date, end: Date }): Promise { + const start = payload.start; + const end = payload.end; + + if (ctx.rootGetters['me/getMe'] === null) { + return Promise.resolve(ctx.getters.getRangeSource); + } + + if (ctx.getters.isRangeLoaded({start, end})) { + return Promise.resolve(ctx.getters.getRangeSource); + } + + ctx.commit('addLoaded', { + start: start, + end: end, + }); + + return fetchCalendarRangeForUser( + ctx.rootGetters['me/getMe'], + start, + end + ) + .then((ranges: CalendarRange[]) => { + ctx.commit('addRanges', ranges); + return Promise.resolve(null); + }); + }, + createRange(ctx, {start, end, location}: { start: Date, end: Date, location: Location }): Promise { + const url = `/api/1.0/calendar/calendar-range.json?`; + + if (ctx.rootState.me.me === null) { + throw new Error('user is currently null'); + } + + const body = { + user: { + id: ctx.rootState.me.me.id, + type: "user" + }, + startDate: { + datetime: datetimeToISO(start), + }, + endDate: { + datetime: datetimeToISO(end) + }, + location: { + id: location.id, + type: "location" + } + } as CalendarRangeCreate; + + return makeFetch('POST', url, body) + .then((newRange) => { + + ctx.commit('addRange', newRange); + + return Promise.resolve(null); + }) + .catch((error) => { + console.error(error); + + throw error; + }) + }, + deleteRange(ctx, calendarRangeId: number) { + const url = `/api/1.0/calendar/calendar-range/${calendarRangeId}.json`; + + makeFetch('DELETE', url) + .then((_) => { + ctx.commit('removeRange', calendarRangeId); + }); + }, + patchRangeTime(ctx, {calendarRangeId, start, end}: {calendarRangeId: number, start: Date, end: Date}): Promise { + const url = `/api/1.0/calendar/calendar-range/${calendarRangeId}.json`; + const body = { + startDate: { + datetime: datetimeToISO(start) + }, + endDate: { + datetime: datetimeToISO(end) + }, + } as CalendarRangeEdit; + + return makeFetch('PATCH', url, body) + .then((range) => { + ctx.commit('updateRange', range); + return Promise.resolve(null); + }) + .catch((error) => { + console.error(error); + return Promise.resolve(null); + }) + }, + patchRangeLocation(ctx, {location, calendarRangeId}: {location: Location, calendarRangeId: number}): Promise { + const url = `/api/1.0/calendar/calendar-range/${calendarRangeId}.json`; + const body = { + location: { + id: location.id, + type: "location" + }, + } as CalendarRangeEdit; + + return makeFetch('PATCH', url, body) + .then((range) => { + ctx.commit('updateRange', range); + return Promise.resolve(null); + }) + .catch((error) => { + console.error(error); + return Promise.resolve(null); + }) + }, + copyFromDayToAnotherDay(ctx, {from, to}: {from: Date, to: Date}): Promise { + const rangesToCopy: EventInputCalendarRange[] = ctx.getters['getRangesOnDate'](from); + const promises = []; + + for (let r of rangesToCopy) { + let start = new Date(ISOToDatetime(r.start)); + start.setFullYear(to.getFullYear(), to.getMonth(), to.getDate()) + let end = new Date(ISOToDatetime(r.end)); + end.setFullYear(to.getFullYear(), to.getMonth(), to.getDate()); + let location = ctx.rootGetters['locations/getLocationById'](r.locationId); + + promises.push(ctx.dispatch('createRange', {start, end, location})); + } + + return Promise.all(promises).then(_ => Promise.resolve(null)); + } + } +}; diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/calendarRemotes.ts b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/calendarRemotes.ts new file mode 100644 index 000000000..260f79a42 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/calendarRemotes.ts @@ -0,0 +1,95 @@ +import {State} from './../index'; +import {ActionContext, Module} from 'vuex'; +import {CalendarRemote} from '../../../../types'; +import {fetchCalendarRemoteForUser} from '../../../Calendar/api'; +import {EventInput, EventSource} from '@fullcalendar/vue3'; +import {remoteToFullCalendarEvent} from "../../../Calendar/store/utils"; +import {TransportExceptionInterface} from "../../../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods"; +import {COLORS} from "../../../Calendar/const"; + +export interface CalendarRemotesState { + remotes: EventInput[], + remotesLoaded: {start: number, end: number}[], + remotesIndex: Set, + key: number +} + +type Context = ActionContext; + +export default > { + namespaced: true, + state: (): CalendarRemotesState => ({ + remotes: [], + remotesLoaded: [], + remotesIndex: new Set(), + key: 0 + }), + getters: { + isRemotesLoaded: (state: CalendarRemotesState) => ({start, end}: {start: Date, end: Date}): boolean => { + for (let range of state.remotesLoaded) { + if (start.getTime() === range.start && end.getTime() === range.end) { + return true; + } + } + + return false; + }, + }, + mutations: { + addRemotes(state: CalendarRemotesState, ranges: CalendarRemote[]) { + console.log('addRemotes', ranges); + + const toAdd = ranges + .map(cr => remoteToFullCalendarEvent(cr)) + .filter(r => !state.remotesIndex.has(r.id)); + + toAdd.forEach((r) => { + state.remotesIndex.add(r.id); + state.remotes.push(r); + }); + state.key = state.key + toAdd.length; + }, + addLoaded(state: CalendarRemotesState, payload: {start: Date, end: Date}) { + state.remotesLoaded.push({start: payload.start.getTime(), end: payload.end.getTime()}); + }, + }, + actions: { + fetchRemotes(ctx: Context, payload: {start: Date, end: Date}): Promise { + const start = payload.start; + const end = payload.end; + + if (ctx.rootGetters['me/getMe'] === null) { + return Promise.resolve(null); + } + + if (ctx.getters.isRemotesLoaded({start, end})) { + return Promise.resolve(ctx.getters.getRangeSource); + } + + ctx.commit('addLoaded', { + start: start, + end: end, + }); + + return fetchCalendarRemoteForUser( + ctx.rootGetters['me/getMe'], + start, + end + ) + .then((remotes: CalendarRemote[]) => { + // to be add when reactivity problem will be solve ? + //ctx.commit('addRemotes', remotes); + const inputs = remotes + .map(cr => remoteToFullCalendarEvent(cr)) + .map(cr => ({...cr, backgroundColor: COLORS[0], textColor: 'black', editable: false})) + ctx.commit('calendarRanges/addExternals', inputs, {root: true}); + return Promise.resolve(null); + }) + .catch((e: TransportExceptionInterface) => { + console.error(e); + + return Promise.resolve(null); + }); + } + } +}; diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/fullcalendar.ts b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/fullcalendar.ts new file mode 100644 index 000000000..de379753f --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/fullcalendar.ts @@ -0,0 +1,56 @@ +import {State} from './../index'; +import {ActionContext} from 'vuex'; + +export interface FullCalendarState { + currentView: { + start: Date|null, + end: Date|null, + }, + key: number +} + +type Context = ActionContext; + +export default { + namespaced: true, + state: (): FullCalendarState => ({ + currentView: { + start: null, + end: null, + }, + key: 0, + }), + mutations: { + setCurrentDatesView: function(state: FullCalendarState, payload: {start: Date, end: Date}): void { + state.currentView.start = payload.start; + state.currentView.end = payload.end; + }, + increaseKey: function(state: FullCalendarState): void { + state.key = state.key + 1; + } + }, + actions: { + setCurrentDatesView(ctx: Context, {start, end}: {start: Date|null, end: Date|null}): Promise { + console.log('dispatch setCurrentDatesView', {start, end}); + + if (ctx.state.currentView.start !== start || ctx.state.currentView.end !== end) { + ctx.commit('setCurrentDatesView', {start, end}); + } + + if (start !== null && end !== null) { + + return Promise.all([ + ctx.dispatch('calendarRanges/fetchRanges', {start, end}, {root: true}).then(_ => Promise.resolve(null)), + ctx.dispatch('calendarRemotes/fetchRemotes', {start, end}, {root: true}).then(_ => Promise.resolve(null)), + ctx.dispatch('calendarLocals/fetchLocals', {start, end}, {root: true}).then(_ => Promise.resolve(null)) + ] + ).then(_ => Promise.resolve(null)); + + } else { + return Promise.resolve(null); + } + }, + } +} + + diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/location.ts b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/location.ts new file mode 100644 index 000000000..7fbfe7fce --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/location.ts @@ -0,0 +1,62 @@ +import {Location} from "../../../../../../../ChillMainBundle/Resources/public/types"; +import {State} from '../index'; +import {Module} from 'vuex'; +import {getLocations} from "../../../../../../../ChillMainBundle/Resources/public/lib/api/locations"; +import {whereami} from "../../../../../../../ChillMainBundle/Resources/public/lib/api/user"; + +export interface LocationState { + locations: Location[]; + locationPicked: Location | null; + currentLocation: Location | null; +} + +export default >{ + namespaced: true, + state: (): LocationState => { + return { + locations: [], + locationPicked: null, + currentLocation: null, + } + }, + getters: { + getLocationById: (state) => (id: number): Location|undefined => { + return state.locations.find(l => l.id === id); + }, + }, + mutations: { + setLocations(state, locations): void { + state.locations = locations; + }, + setLocationPicked(state, location: Location | null): void { + if (null === location) { + state.locationPicked = null; + return; + } + + state.locationPicked = state.locations.find(l => l.id === location.id) || null; + }, + setCurrentLocation(state, location: Location | null): void { + if (null === location) { + state.currentLocation = null; + return; + } + + state.currentLocation = state.locations.find(l => l.id === location.id) || null; + } + }, + actions: { + getLocations(ctx): Promise { + return getLocations().then(locations => { + ctx.commit('setLocations', locations); + return Promise.resolve(); + }); + }, + getCurrentLocation(ctx): Promise { + return whereami().then(location => { + ctx.commit('setCurrentLocation', location); + }) + } + } +} + diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/me.ts b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/me.ts new file mode 100644 index 000000000..8610b76d3 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/me.ts @@ -0,0 +1,29 @@ +import {State} from './../index'; +import {User} from '../../../../../../../ChillMainBundle/Resources/public/types'; +import {ActionContext} from 'vuex'; + +export interface MeState { + me: User|null, +} + +type Context = ActionContext; + +export default { + namespaced: true, + state: (): MeState => ({ + me: null, + }), + getters: { + getMe: function(state: MeState): User|null { + return state.me; + }, + }, + mutations: { + setWhoAmi(state: MeState, me: User) { + state.me = me; + }, + } +}; + + + diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/_api/api.js b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/_api/api.js index 7aad8ace2..b2bee8afe 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/_api/api.js +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/_api/api.js @@ -4,6 +4,7 @@ * @returns {Promise} a promise containing all Calendar ranges objects */ const fetchCalendarRanges = () => { + return Promise.resolve([]); const url = `/api/1.0/calendar/calendar-range-available.json`; return fetch(url) .then(response => { @@ -13,6 +14,7 @@ const fetchCalendarRanges = () => { }; const fetchCalendarRangesByUser = (userId) => { + return Promise.resolve([]); const url = `/api/1.0/calendar/calendar-range-available.json?user=${userId}`; return fetch(url) .then(response => { @@ -27,6 +29,7 @@ const fetchCalendarRangesByUser = (userId) => { * @returns {Promise} a promise containing all Calendar objects */ const fetchCalendar = (mainUserId) => { + return Promise.resolve([]); const url = `/api/1.0/calendar/calendar.json?main_user=${mainUserId}&item_per_page=1000`; return fetch(url) .then(response => { diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/confirm_deleteByAccompanyingCourse.html.twig b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/confirm_deleteByAccompanyingCourse.html.twig index 26818bb6c..161f4d8b4 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/confirm_deleteByAccompanyingCourse.html.twig +++ b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/confirm_deleteByAccompanyingCourse.html.twig @@ -9,8 +9,8 @@ { 'title' : 'Remove calendar item'|trans, 'confirm_question' : 'Are you sure you want to remove the calendar item?'|trans, - 'cancel_route' : 'chill_calendar_calendar_list', - 'cancel_parameters' : { 'accompanying_period_id' : accompanyingCourse.id, 'id' : calendar.id }, + 'cancel_route' : 'chill_calendar_calendar_list_by_period', + 'cancel_parameters' : { 'id' : accompanyingCourse.id }, 'form' : delete_form } ) }} {% endblock %} diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/edit.html.twig b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/edit.html.twig index b96d12c06..f609b9d44 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/edit.html.twig +++ b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/edit.html.twig @@ -3,9 +3,9 @@ {{ form_start(form) }} {{ form_errors(form) }} -{%- if form.mainUser is defined -%} - {{ form_row(form.mainUser) }} -{% endif %} +
{# <=== vue component #} + +
{# <=== vue component: mainUser #}

{{ 'Concerned groups'|trans }}

@@ -26,6 +26,13 @@

{{ 'Calendar data'|trans }}

+
+ +{%- if form.location is defined -%} + {{ form_row(form.location) }} +
+{% endif %} + {%- if form.startDate is defined -%} {{ form_row(form.startDate) }} {% endif %} @@ -38,10 +45,7 @@ {{ form_row(form.calendarRange) }} {% endif %} -{%- if form.location is defined -%} - {{ form_row(form.location) }} -
-{% endif %} +
{%- if form.comment is defined -%} {{ form_row(form.comment) }} @@ -59,7 +63,6 @@
{% endif %} -
  • @@ -68,7 +71,7 @@ {%- if context == 'user' -%} href="{{ chill_return_path_or('chill_calendar_calendar_list', { 'user_id': user.id } )}}" {%- elseif context == 'accompanyingCourse' -%} - href="{{ chill_return_path_or('chill_calendar_calendar_list', { 'accompanying_period_id': accompanyingCourse.id } )}}" + href="{{ chill_return_path_or('chill_calendar_calendar_list_by_period', { 'id': accompanyingCourse.id } )}}" {%- endif -%} > {{ 'Cancel'|trans|chill_return_path_label }} @@ -76,7 +79,7 @@
diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/editByAccompanyingCourse.html.twig b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/editByAccompanyingCourse.html.twig index 850bfbecb..a167d5c18 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/editByAccompanyingCourse.html.twig +++ b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/editByAccompanyingCourse.html.twig @@ -7,7 +7,6 @@ {% block content %}
-
{# <=== vue component #} {% include 'ChillCalendarBundle:Calendar:edit.html.twig' with {'context': 'accompanyingCourse'} %}
@@ -16,10 +15,6 @@ {% block js %} {{ parent() }} {{ encore_entry_script_tags('vue_calendar') }} {% endblock %} @@ -28,6 +26,7 @@ {% block css %} {{ parent() }} {{ encore_entry_link_tags('vue_calendar') }} + {{ encore_entry_link_tags('mod_pickentity_type') }} {% endblock %} {% block block_post_menu %} diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/show.html.twig b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/show.html.twig index 3a1bf03d5..bff4baa53 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/show.html.twig +++ b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/show.html.twig @@ -1,4 +1,13 @@

{{ "Calendar"|trans }}

+

+ {% if entity.endDate.diff(entity.startDate).days >= 1 %} + {{ "From the day"|trans }} {{ entity.startDate|format_datetime('medium', 'short') }} + {{ "to the day"|trans }} {{ entity.endDate|format_datetime('medium', 'short') }} + {% else %} + {{ entity.startDate|format_date('full') }}, + {{ entity.startDate|format_datetime('none', 'short', locale='fr') }} - {{ entity.endDate|format_datetime('none', 'short', locale='fr') }} + {% endif %} +

{{ 'main user concerned'|trans }}
@@ -6,7 +15,7 @@

{{ 'Concerned groups'|trans }}

-{% include 'ChillActivityBundle:Activity:concernedGroups.html.twig' with {'context': context, 'render': 'bloc' } %} +{% include 'ChillActivityBundle:Activity:concernedGroups.html.twig' with {'context': 'calendar_' ~ context, 'render': 'bloc' } %}

{{ 'Calendar data'|trans }}

@@ -79,8 +88,8 @@
  • - + {{ 'Back to the list'|trans }}
  • diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/CalendarShortMessage/short_message.txt.twig b/src/Bundle/ChillCalendarBundle/Resources/views/CalendarShortMessage/short_message.txt.twig new file mode 100644 index 000000000..d6c46a7e9 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/views/CalendarShortMessage/short_message.txt.twig @@ -0,0 +1 @@ +Votre travailleur social {{ calendar.mainUser.label }} vous rencontrera le {{ calendar.startDate|format_date('short', locale='fr') }} à {{ calendar.startDate|format_time('short', locale='fr') }} - LIEU.{% if calendar.mainUser.mainLocation is not null and calendar.mainUser.mainLocation.phonenumber1 is not null %} En cas d'indisponibilité, appelez-nous au {{ calendar.mainUser.mainLocation.phonenumber1|chill_format_phonenumber }}.{% endif %} diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/CalendarShortMessage/short_message_canceled.twig b/src/Bundle/ChillCalendarBundle/Resources/views/CalendarShortMessage/short_message_canceled.twig new file mode 100644 index 000000000..82453a7a9 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/views/CalendarShortMessage/short_message_canceled.twig @@ -0,0 +1 @@ +Votre RDV avec votre travailleur social {{ calendar.mainUser.label }} prévu le {{ calendar.startDate|format_date('short', locale='fr') }} à {{ calendar.startDate|format_time('short', locale='fr') }} est annulé. {% if calendar.mainUser.mainLocation is not null and calendar.mainUser.mainLocation.phonenumber1 is not null %} En cas de question, appelez-nous au {{ calendar.mainUser.mainLocation.phonenumber1|chill_format_phonenumber }}.{% endif %} diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/MSGraph/calendar_event_body.html.twig b/src/Bundle/ChillCalendarBundle/Resources/views/MSGraph/calendar_event_body.html.twig new file mode 100644 index 000000000..63a206ae0 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/views/MSGraph/calendar_event_body.html.twig @@ -0,0 +1,16 @@ +Ce rendez-vous a été généré automatiquement par Chill. + +Pour modifier ou supprimer ce rendez-vous, utilisez l’adresse suivante: + +{{ absolute_url(path('chill_calendar_calendar_edit', {'id': calendar.id, '_locale': 'fr'})) }} + +{{ calendar.comment.comment|nl2br }} + +Usagers et tiers concernés: + +{% for p in calendar.persons %} +- {{ p|chill_entity_render_string }} +{%- endfor -%} +{% for t in calendar.professionals %} +- {{ t|chill_entity_render_string }} +{% endfor %} diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/_invite.html.twig b/src/Bundle/ChillCalendarBundle/Resources/views/_invite.html.twig new file mode 100644 index 000000000..c3f2aef58 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/views/_invite.html.twig @@ -0,0 +1,14 @@ +{% macro invite_span(invite) %} + {% if invite.status == 'accepted' %} + {% set fa = 'check' %} + {% elseif invite.status == 'declined' %} + {% set fa = 'times' %} + {% elseif invite.status == 'pending' %} + {% set fa = 'hourglass' %} + {% elseif invite.status == 'tentative' %} + {% set fa = 'question' %} + {% else %} + {% set fa = invite.status %} + {% endif %} + +{% endmacro %} diff --git a/src/Bundle/ChillCalendarBundle/Security/Voter/CalendarVoter.php b/src/Bundle/ChillCalendarBundle/Security/Voter/CalendarVoter.php new file mode 100644 index 000000000..54bdcb5fb --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Security/Voter/CalendarVoter.php @@ -0,0 +1,104 @@ +security = $security; + $this->voterHelper = $voterHelperFactory + ->generate(self::class) + ->addCheckFor(AccompanyingPeriod::class, [self::SEE]) + ->addCheckFor(Calendar::class, [self::SEE, self::CREATE, self::EDIT, self::DELETE]) + ->build(); + } + + public function getRoles(): array + { + return [ + self::SEE, + ]; + } + + public function getRolesWithHierarchy(): array + { + return ['Calendar' => $this->getRoles()]; + } + + public function getRolesWithoutScope(): array + { + return []; + } + + protected function supports($attribute, $subject): bool + { + return $this->voterHelper->supports($attribute, $subject); + } + + protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool + { + if ($subject instanceof AccompanyingPeriod) { + switch ($attribute) { + case self::SEE: + if ($subject->getStep() === AccompanyingPeriod::STEP_DRAFT) { + return false; + } + + // we first check here that the user has read access to the period + return $this->security->isGranted(AccompanyingPeriodVoter::SEE, $subject); + + default: + throw new LogicException('subject not implemented'); + } + } elseif ($subject instanceof Calendar) { + if (null !== $period = $subject->getAccompanyingPeriod()) { + switch ($attribute) { + case self::SEE: + case self::EDIT: + case self::CREATE: + return $this->security->isGranted(AccompanyingPeriodVoter::SEE, $period); + + case self::DELETE: + return $this->security->isGranted(AccompanyingPeriodVoter::EDIT, $period); + } + } + } + + throw new LogicException('attribute not implemented'); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Security/Voter/InviteVoter.php b/src/Bundle/ChillCalendarBundle/Security/Voter/InviteVoter.php new file mode 100644 index 000000000..83caa5f64 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Security/Voter/InviteVoter.php @@ -0,0 +1,35 @@ +getUser() === $subject->getUser(); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/BulkCalendarShortMessageSender.php b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/BulkCalendarShortMessageSender.php new file mode 100644 index 000000000..06a67c1ce --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/BulkCalendarShortMessageSender.php @@ -0,0 +1,64 @@ +provider = $provider; + $this->em = $em; + $this->logger = $logger; + $this->messageBus = $messageBus; + $this->messageForCalendarBuilder = $messageForCalendarBuilder; + } + + public function sendBulkMessageToEligibleCalendars() + { + $countCalendars = 0; + $countSms = 0; + + foreach ($this->provider->getCalendars(new DateTimeImmutable('now')) as $calendar) { + $smses = $this->messageForCalendarBuilder->buildMessageForCalendar($calendar); + + foreach ($smses as $sms) { + $this->messageBus->dispatch($sms); + ++$countSms; + } + + $this->em + ->createQuery('UPDATE ' . Calendar::class . ' c SET c.smsStatus = :smsStatus WHERE c.id = :id') + ->setParameters(['smsStatus' => Calendar::SMS_SENT, 'id' => $calendar->getId()]) + ->execute(); + ++$countCalendars; + $this->em->refresh($calendar); + } + + $this->logger->info(__CLASS__ . 'a bulk of messages was sent', ['count_calendars' => $countCalendars, 'count_sms' => $countSms]); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/CalendarForShortMessageProvider.php b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/CalendarForShortMessageProvider.php new file mode 100644 index 000000000..feb3b698b --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/CalendarForShortMessageProvider.php @@ -0,0 +1,69 @@ +calendarRepository = $calendarRepository; + $this->em = $em; + $this->rangeGenerator = $rangeGenerator; + } + + /** + * Generate calendars instance. + * + * Warning: this method takes care of clearing the EntityManager at regular interval + * + * @return iterable|Calendar[] + */ + public function getCalendars(DateTimeImmutable $at): iterable + { + ['startDate' => $startDate, 'endDate' => $endDate] = $this->rangeGenerator + ->generateRange($at); + + $offset = 0; + $batchSize = 10; + + $calendars = $this->calendarRepository + ->findByNotificationAvailable($startDate, $endDate, $batchSize, $offset); + + do { + foreach ($calendars as $calendar) { + ++$offset; + + yield $calendar; + } + + $this->em->clear(); + + $calendars = $this->calendarRepository + ->findByNotificationAvailable($startDate, $endDate, $batchSize, $offset); + } while (count($calendars) === $batchSize); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/DefaultRangeGenerator.php b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/DefaultRangeGenerator.php new file mode 100644 index 000000000..0587296a5 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/DefaultRangeGenerator.php @@ -0,0 +1,68 @@ + Envoi des rdv du mardi et mercredi. + * * Mardi => Envoi des rdv du jeudi. + * * Mercredi => Envoi des rdv du vendredi + * * Jeudi => envoi des rdv du samedi et dimanche + * * Vendredi => Envoi des rdv du lundi. + */ +class DefaultRangeGenerator implements RangeGeneratorInterface +{ + public function generateRange(\DateTimeImmutable $date): array + { + $onMidnight = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $date->format('Y-m-d') . ' 00:00:00'); + + switch ($dow = (int) $onMidnight->format('w')) { + case 6: // Saturday + case 0: // Sunday + return ['startDate' => null, 'endDate' => null]; + + case 1: // Monday + // send for Tuesday and Wednesday + $startDate = $onMidnight->add(new DateInterval('P1D')); + $endDate = $startDate->add(new DateInterval('P2D')); + + break; + + case 2: // tuesday + case 3: // wednesday + $startDate = $onMidnight->add(new DateInterval('P2D')); + $endDate = $startDate->add(new DateInterval('P1D')); + + break; + + case 4: // thursday + $startDate = $onMidnight->add(new DateInterval('P2D')); + $endDate = $startDate->add(new DateInterval('P2D')); + + break; + + case 5: // friday + $startDate = $onMidnight->add(new DateInterval('P3D')); + $endDate = $startDate->add(new DateInterval('P1D')); + + break; + + default: + throw new UnexpectedValueException('a day of a week should not have the value: ' . $dow); + } + + return ['startDate' => $startDate, 'endDate' => $endDate]; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/DefaultShortMessageForCalendarBuilder.php b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/DefaultShortMessageForCalendarBuilder.php new file mode 100644 index 000000000..2abb3cb00 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/DefaultShortMessageForCalendarBuilder.php @@ -0,0 +1,58 @@ +engine = $engine; + } + + public function buildMessageForCalendar(Calendar $calendar): array + { + if (true !== $calendar->getSendSMS()) { + return []; + } + + $toUsers = []; + + foreach ($calendar->getPersons() as $person) { + if (false === $person->getAcceptSMS() || null === $person->getAcceptSMS() || null === $person->getMobilenumber()) { + continue; + } + + if (Calendar::SMS_PENDING === $calendar->getSmsStatus()) { + $toUsers[] = new ShortMessage( + $this->engine->render('@ChillCalendar/CalendarShortMessage/short_message.txt.twig', ['calendar' => $calendar]), + $person->getMobilenumber(), + ShortMessage::PRIORITY_LOW + ); + } elseif (Calendar::SMS_CANCEL_PENDING === $calendar->getSmsStatus()) { + $toUsers[] = new ShortMessage( + $this->engine->render('@ChillCalendar/CalendarShortMessage/short_message_canceled.txt.twig', ['calendar' => $calendar]), + $person->getMobilenumber(), + ShortMessage::PRIORITY_LOW + ); + } + } + + return $toUsers; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/RangeGeneratorInterface.php b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/RangeGeneratorInterface.php new file mode 100644 index 000000000..7117d982b --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/RangeGeneratorInterface.php @@ -0,0 +1,22 @@ + + */ + public function generateRange(DateTimeImmutable $date): array; +} diff --git a/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/ShortMessageForCalendarBuilderInterface.php b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/ShortMessageForCalendarBuilderInterface.php new file mode 100644 index 000000000..ffcc8fe5c --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/ShortMessageForCalendarBuilderInterface.php @@ -0,0 +1,23 @@ +request( + 'POST', + '/public/incoming-hook/calendar/msgraph/events/23', + [], + [], + [], + self::SAMPLE_BODY + ); + + $this->assertResponseIsSuccessful(); + $this->assertResponseStatusCodeSame(202); + + /** @var InMemoryTransport $transport */ + $transport = self::$container->get('messenger.transport.async'); + $this->assertCount(1, $transport->getSent()); + } + + public function testValidateSubscription(): void + { + $client = self::createClient(); + $client->request( + 'POST', + '/public/incoming-hook/calendar/msgraph/events/23?validationToken=something%20to%20decode' + ); + + $this->assertResponseIsSuccessful(); + + $response = $client->getResponse(); + + $this->assertResponseHasHeader('Content-Type'); + $this->assertStringContainsString('text/plain', $response->headers->get('Content-Type')); + $this->assertEquals('something to decode', $response->getContent()); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/Entity/CalendarTest.php b/src/Bundle/ChillCalendarBundle/Tests/Entity/CalendarTest.php new file mode 100644 index 000000000..348620faf --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/Entity/CalendarTest.php @@ -0,0 +1,56 @@ +assertCount(0, $calendar->getInvites()); + $this->assertCount(0, $calendar->getUsers()); + + $calendar->addUser($user0 = new User()); + + $this->assertCount(1, $calendar->getInvites()); + $this->assertCount(1, $calendar->getUsers()); + $this->assertSame($user0, $calendar->getUsers()->first()); + + $calendar->addUser($user1 = new User()); + + $this->assertCount(2, $calendar->getInvites()); + $this->assertCount(2, $calendar->getUsers()); + $this->assertContains($user0, $calendar->getUsers()); + $this->assertContains($user1, $calendar->getUsers()); + + $calendar->removeUser($user0); + + $this->assertCount(1, $calendar->getInvites()); + $this->assertCount(1, $calendar->getUsers()); + $this->assertNotSame($user0, $calendar->getUsers()->first()); + $this->assertSame($user1, $calendar->getUsers()->first()); + + $calendar->removeUser($user1); + + $this->assertCount(0, $calendar->getInvites()); + $this->assertCount(0, $calendar->getUsers()); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/Form/CalendarTypeTest.php b/src/Bundle/ChillCalendarBundle/Tests/Form/CalendarTypeTest.php new file mode 100644 index 000000000..8adc0599c --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/Form/CalendarTypeTest.php @@ -0,0 +1,203 @@ +personsToIdDataTransformer = $this->buildMultiToIdDataTransformer(PersonsToIdDataTransformer::class, Person::class); + $this->idToUserDataTransformer = $this->buildSingleToIdDataTransformer(IdToUserDataTransformer::class, User::class); + $this->idToUsersDataTransformer = $this->buildMultiToIdDataTransformer(IdToUsersDataTransformer::class, User::class); + $this->idToLocationDataTransformer = $this->buildSingleToIdDataTransformer(IdToLocationDataTransformer::class, Location::class); + $this->partiesToIdDataTransformer = $this->buildMultiToIdDataTransformer(ThirdPartiesToIdDataTransformer::class, ThirdParty::class); + $this->calendarRangeDataTransformer = $this->buildSingleToIdDataTransformer(IdToCalendarRangeDataTransformer::class, CalendarRange::class); + $tokenStorage = $this->prophesize(TokenStorageInterface::class); + $token = $this->prophesize(TokenInterface::class); + $token->getUser()->willReturn(new User()); + $tokenStorage->getToken()->willReturn($token->reveal()); + $this->tokenStorage = $tokenStorage->reveal(); + + parent::setUp(); + } + + public function testSubmitValidData() + { + $formData = [ + 'mainUser' => '1', + 'users' => '2,3', + 'professionnals' => '4,5', + 'startDate' => '2022-05-05 14:00:00', + 'endDate' => '2022-05-05 14:30:00', + 'persons' => '7', + 'calendarRange' => '8', + 'location' => '9', + 'sendSMS' => '1', + ]; + + $calendar = new Calendar(); + $calendar->setMainUser(new class() extends User { + public function getId() + { + return '1'; + } + }); + + $form = $this->factory->create(CalendarType::class, $calendar); + + $form->submit($formData); + + $this->assertTrue($form->isSynchronized()); + $this->assertEquals(DateTimeImmutable::createFromFormat('Y-m-d H:i:s', '2022-05-05 14:00:00'), $calendar->getStartDate()); + $this->assertEquals(DateTimeImmutable::createFromFormat('Y-m-d H:i:s', '2022-05-05 14:30:00'), $calendar->getEndDate()); + $this->assertEquals(7, $calendar->getPersons()->first()->getId()); + $this->assertEquals(8, $calendar->getCalendarRange()->getId()); + $this->assertEquals(9, $calendar->getLocation()->getId()); + $this->assertEquals(true, $calendar->getSendSMS()); + $this->assertContains(2, $calendar->getUsers()->map(static function (User $u) { return $u->getId(); })); + $this->assertContains(3, $calendar->getUsers()->map(static function (User $u) { return $u->getId(); })); + } + + protected function getExtensions() + { + $parents = parent::getExtensions(); + + $calendarType = new CalendarType( + $this->personsToIdDataTransformer, + $this->idToUserDataTransformer, + $this->idToUsersDataTransformer, + $this->idToLocationDataTransformer, + $this->partiesToIdDataTransformer, + $this->calendarRangeDataTransformer + ); + $commentType = new CommentType($this->tokenStorage); + + return array_merge( + parent::getExtensions(), + [new PreloadedExtension([$calendarType, $commentType], [])] + ); + } + + private function buildMultiToIdDataTransformer( + string $classTransformer, + string $objClass + ) { + $transformer = $this->prophesize($classTransformer); + $transformer->transform(Argument::type('array')) + ->will(static function ($args) { + return implode( + ',', + array_map(static function ($p) { return $p->getId(); }, $args[0]) + ); + }); + $transformer->transform(Argument::exact(null)) + ->willReturn([]); + $transformer->transform(Argument::type(Collection::class)) + ->will(static function ($args) { + return implode( + ',', + array_map(static function ($p) { return $p->getId(); }, $args[0]->toArray()) + ); + }); + $transformer->reverseTransform(Argument::type('string')) + ->will(static function ($args) use ($objClass) { + if (null === $args[0]) { + return []; + } + + return array_map( + static function ($id) use ($objClass) { + $obj = new $objClass(); + $reflectionProperty = new ReflectionProperty($objClass, 'id'); + $reflectionProperty->setAccessible(true); + $reflectionProperty->setValue($obj, (int) $id); + + return $obj; + }, + explode(',', $args[0]) + ); + }); + + return $transformer->reveal(); + } + + private function buildSingleToIdDataTransformer( + string $classTransformer, + string $class + ) { + $transformer = $this->prophesize($classTransformer); + $transformer->transform(Argument::type('object')) + ->will(static function ($args) { + return (string) $args[0]->getId(); + }); + $transformer->transform(Argument::exact(null)) + ->willReturn(''); + $transformer->reverseTransform(Argument::type('string')) + ->will(static function ($args) use ($class) { + if (null === $args[0]) { + return null; + } + $obj = new $class(); + $reflectionProperty = new ReflectionProperty($class, 'id'); + $reflectionProperty->setAccessible(true); + $reflectionProperty->setValue($obj, (int) $args[0]); + + return $obj; + }); + + return $transformer->reveal(); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/AddressConverterTest.php b/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/AddressConverterTest.php new file mode 100644 index 000000000..d4fcbf46c --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/AddressConverterTest.php @@ -0,0 +1,67 @@ +setName(['fr' => 'Belgique']); + $postalCode = (new PostalCode())->setName('Houte-Si-Plout')->setCode('4122') + ->setCountry($country); + $address = (new Address())->setPostcode($postalCode)->setStreet("Rue de l'Église") + ->setStreetNumber('15B')->setBuildingName('Résidence de la Truite'); + + $actual = $this->buildAddressConverter()->addressToRemote($address); + + $this->assertArrayHasKey('city', $actual); + $this->assertStringContainsString($actual['city'], 'Houte-Si-Plout'); + $this->assertArrayHasKey('postalCode', $actual); + $this->assertStringContainsString($actual['postalCode'], '4122'); + $this->assertArrayHasKey('countryOrRegion', $actual); + $this->assertStringContainsString('Belgique', $actual['countryOrRegion']); + $this->assertArrayHasKey('street', $actual); + $this->assertStringContainsString('Rue de l\'Église', $actual['street']); + $this->assertStringContainsString('15B', $actual['street']); + $this->assertStringContainsString('Résidence de la Truite', $actual['street']); + } + + private function buildAddressConverter(): AddressConverter + { + $engine = $this->prophesize(EngineInterface::class); + $translatableStringHelper = $this->prophesize(TranslatableStringHelperInterface::class); + $translatableStringHelper->localize(Argument::type('array'))->will(static function ($args): string { + return ($args[0] ?? ['fr' => 'not provided'])['fr'] ?? 'not provided'; + }); + + $addressRender = new AddressRender($engine->reveal(), $translatableStringHelper->reveal()); + + return new AddressConverter($addressRender, $translatableStringHelper->reveal()); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/CalendarRangeSyncerTest.php b/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/CalendarRangeSyncerTest.php new file mode 100644 index 000000000..6ff112956 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/CalendarRangeSyncerTest.php @@ -0,0 +1,248 @@ +prophesize(EntityManagerInterface::class); + $em->remove(Argument::type(CalendarRange::class))->shouldNotBeCalled(); + + $machineHttpClient = new MockHttpClient([ + new MockResponse(self::REMOTE_CALENDAR_RANGE, ['http_code' => 200]), + ]); + + $calendarRangeSyncer = new CalendarRangeSyncer( + $em->reveal(), + new NullLogger(), + $machineHttpClient + ); + + $calendarRange = new CalendarRange(); + $calendarRange + ->setUser($user = new User()); + $calendar = new Calendar(); + $calendar->setCalendarRange($calendarRange); + + $notification = json_decode(self::NOTIF_DELETE, true); + + $calendarRangeSyncer->handleCalendarRangeSync( + $calendarRange, + $notification['value'][0], + $user + ); + } + + public function testDeleteCalendarRangeWithoutAssociation(): void + { + $em = $this->prophesize(EntityManagerInterface::class); + $em->remove(Argument::type(CalendarRange::class))->shouldBeCalled(); + + $machineHttpClient = new MockHttpClient([ + new MockResponse(self::REMOTE_CALENDAR_RANGE, ['http_code' => 200]), + ]); + + $calendarRangeSyncer = new CalendarRangeSyncer( + $em->reveal(), + new NullLogger(), + $machineHttpClient + ); + + $calendarRange = new CalendarRange(); + $calendarRange + ->setUser($user = new User()); + $notification = json_decode(self::NOTIF_DELETE, true); + + $calendarRangeSyncer->handleCalendarRangeSync( + $calendarRange, + $notification['value'][0], + $user + ); + $this->assertTrue($calendarRange->preventEnqueueChanges); + } + + public function testUpdateCalendarRange(): void + { + $em = $this->prophesize(EntityManagerInterface::class); + $machineHttpClient = new MockHttpClient([ + new MockResponse(self::REMOTE_CALENDAR_RANGE, ['http_code' => 200]), + ]); + + $calendarRangeSyncer = new CalendarRangeSyncer( + $em->reveal(), + new NullLogger(), + $machineHttpClient + ); + + $calendarRange = new CalendarRange(); + $calendarRange + ->setUser($user = new User()) + ->setStartDate(new DateTimeImmutable('2020-01-01 15:00:00')) + ->setEndDate(new DateTimeImmutable('2020-01-01 15:30:00')) + ->addRemoteAttributes([ + 'lastModifiedDateTime' => 0, + 'changeKey' => 'abc', + ]); + $notification = json_decode(self::NOTIF_UPDATE, true); + + $calendarRangeSyncer->handleCalendarRangeSync( + $calendarRange, + $notification['value'][0], + $user + ); + + $this->assertStringContainsString( + '2022-06-10T15:30:00', + $calendarRange->getStartDate()->format(DateTimeImmutable::ATOM) + ); + $this->assertStringContainsString( + '2022-06-10T17:30:00', + $calendarRange->getEndDate()->format(DateTimeImmutable::ATOM) + ); + $this->assertTrue($calendarRange->preventEnqueueChanges); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/CalendarSyncerTest.php b/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/CalendarSyncerTest.php new file mode 100644 index 000000000..f7f658b92 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/CalendarSyncerTest.php @@ -0,0 +1,589 @@ +\r\n\r\n\r\n\r\n\r\n
    \r\n
    \r\n
    ________________________________________________________________________________\r\n
    \r\n
    \r\n
    Réunion Microsoft Teams\r\n
    \r\n
    \r\n
    Rejoindre sur votre ordinateur ou application mobile\r\n
    \r\nCliquez\r\n ici pour participer à la réunion
    \r\n\r\n
    \r\n
    \r\n
    \r\n
    \r\n
    \r\n
    ________________________________________________________________________________\r\n
    \r\n\r\n\r\n" + }, + "start": { + "dateTime": "2022-06-11T12:30:00.0000000", + "timeZone": "UTC" + }, + "end": { + "dateTime": "2022-06-11T13:30:00.0000000", + "timeZone": "UTC" + }, + "location": { + "displayName": "", + "locationType": "default", + "uniqueIdType": "unknown", + "address": {}, + "coordinates": {} + }, + "locations": [], + "recurrence": null, + "attendees": [ + { + "type": "required", + "status": { + "response": "accepted", + "time": "2022-06-08T16:22:15.1392583Z" + }, + "emailAddress": { + "name": "Alex Wilber", + "address": "AlexW@2zy74l.onmicrosoft.com" + } + }, + { + "type": "required", + "status": { + "response": "declined", + "time": "2022-06-08T16:22:15.1392583Z" + }, + "emailAddress": { + "name": "Alfred Nobel", + "address": "alfredN@2zy74l.onmicrosoft.com" + } + } + ], + "organizer": { + "emailAddress": { + "name": "Diego Siciliani", + "address": "DiegoS@2zy74l.onmicrosoft.com" + } + }, + "onlineMeeting": { + "joinUrl": "https://teams.microsoft.com/l/meetup-join/19%3ameeting_NTE3ODUxY2ItNGJhNi00Y2UwLTljN2QtMmQ3YjAxNWY1Nzk2%40thread.v2/0?context=%7b%22Tid%22%3a%22421bf216-3f48-47bd-a7cf-8b1995cb24bd%22%2c%22Oid%22%3a%224feb0ae3-7ffb-48dd-891e-c86b2cdeefd4%22%7d" + } + } + JSON; + + private const REMOTE_CALENDAR_WITH_ATTENDEES = <<<'JSON' + { + "@odata.etag": "W/\"B3bmsWoxX06b9JHIZPptRQAAJcrv7A==\"", + "id": "AAMkADM1MTdlMGIzLTZhZWUtNDQ0ZC05Y2M4LWViMjhmOWJlMDhhMQBGAAAAAAA5e3965gkBSLcU1p00sMSyBwAHduaxajFfTpv0kchk_m1FAAAAAAENAAAHduaxajFfTpv0kchk_m1FAAAl1BuqAAA=", + "createdDateTime": "2022-06-08T16:19:18.997293Z", + "lastModifiedDateTime": "2022-06-08T16:22:18.7276485Z", + "changeKey": "B3bmsWoxX06b9JHIZPptRQAAJcrv7A==", + "categories": [], + "transactionId": "42ac0b77-313e-20ca-2cac-1a3f58b3452d", + "originalStartTimeZone": "Romance Standard Time", + "originalEndTimeZone": "Romance Standard Time", + "iCalUId": "040000008200E00074C5B7101A82E00800000000A8955A81537BD801000000000000000010000000007EB443987CD641B6794C4BA48EE356", + "reminderMinutesBeforeStart": 15, + "isReminderOn": true, + "hasAttachments": false, + "subject": "test 2", + "bodyPreview": "________________________________________________________________________________\r\nRéunion Microsoft Teams\r\nRejoindre sur votre ordinateur ou application mobile\r\nCliquez ici pour participer à la réunion\r\nPour en savoir plus | Options de réunion\r\n________", + "importance": "normal", + "sensitivity": "normal", + "isAllDay": false, + "isCancelled": false, + "isOrganizer": true, + "responseRequested": true, + "seriesMasterId": null, + "showAs": "busy", + "type": "singleInstance", + "webLink": "https://outlook.office365.com/owa/?itemid=AAMkADM1MTdlMGIzLTZhZWUtNDQ0ZC05Y2M4LWViMjhmOWJlMDhhMQBGAAAAAAA5e3965gkBSLcU1p00sMSyBwAHduaxajFfTpv0kchk%2Bm1FAAAAAAENAAAHduaxajFfTpv0kchk%2Bm1FAAAl1BuqAAA%3D&exvsurl=1&path=/calendar/item", + "onlineMeetingUrl": null, + "isOnlineMeeting": true, + "onlineMeetingProvider": "teamsForBusiness", + "allowNewTimeProposals": true, + "occurrenceId": null, + "isDraft": false, + "hideAttendees": false, + "responseStatus": { + "response": "organizer", + "time": "0001-01-01T00:00:00Z" + }, + "body": { + "contentType": "html", + "content": "\r\n\r\n\r\n\r\n\r\n
    \r\n
    \r\n
    ________________________________________________________________________________\r\n
    \r\n
    \r\n
    Réunion Microsoft Teams\r\n
    \r\n
    \r\n
    Rejoindre sur votre ordinateur ou application mobile\r\n
    \r\nCliquez\r\n ici pour participer à la réunion
    \r\n\r\n
    \r\n
    \r\n
    \r\n
    \r\n
    \r\n
    ________________________________________________________________________________\r\n
    \r\n\r\n\r\n" + }, + "start": { + "dateTime": "2022-06-11T12:30:00.0000000", + "timeZone": "UTC" + }, + "end": { + "dateTime": "2022-06-11T13:30:00.0000000", + "timeZone": "UTC" + }, + "location": { + "displayName": "", + "locationType": "default", + "uniqueIdType": "unknown", + "address": {}, + "coordinates": {} + }, + "locations": [], + "recurrence": null, + "attendees": [ + { + "type": "required", + "status": { + "response": "accepted", + "time": "2022-06-08T16:22:15.1392583Z" + }, + "emailAddress": { + "name": "Alex Wilber", + "address": "AlexW@2zy74l.onmicrosoft.com" + } + }, + { + "type": "required", + "status": { + "response": "accepted", + "time": "2022-06-08T16:22:15.1392583Z" + }, + "emailAddress": { + "name": "External User", + "address": "external@example.com" + } + }, + { + "type": "required", + "status": { + "response": "declined", + "time": "2022-06-08T16:22:15.1392583Z" + }, + "emailAddress": { + "name": "Alfred Nobel", + "address": "alfredN@2zy74l.onmicrosoft.com" + } + } + ], + "organizer": { + "emailAddress": { + "name": "Diego Siciliani", + "address": "DiegoS@2zy74l.onmicrosoft.com" + } + }, + "onlineMeeting": { + "joinUrl": "https://teams.microsoft.com/l/meetup-join/19%3ameeting_NTE3ODUxY2ItNGJhNi00Y2UwLTljN2QtMmQ3YjAxNWY1Nzk2%40thread.v2/0?context=%7b%22Tid%22%3a%22421bf216-3f48-47bd-a7cf-8b1995cb24bd%22%2c%22Oid%22%3a%224feb0ae3-7ffb-48dd-891e-c86b2cdeefd4%22%7d" + } + } + JSON; + + protected function setUp(): void + { + parent::setUp(); + + // all tests should run when timezone = +02:00 + $brussels = new DateTimeZone('Europe/Brussels'); + + if (7200 === $brussels->getOffset(new DateTimeImmutable())) { + date_default_timezone_set('Europe/Brussels'); + } else { + date_default_timezone_set('Europe/Moscow'); + } + } + + public function testHandleAttendeesConfirmingCalendar(): void + { + $machineHttpClient = new MockHttpClient([ + new MockResponse(self::REMOTE_CALENDAR_WITH_ATTENDEES, ['http_code' => 200]), + ]); + + $userA = (new User())->setEmail('alexw@2zy74l.onmicrosoft.com') + ->setEmailCanonical('alexw@2zy74l.onmicrosoft.com'); + $userB = (new User())->setEmail('zzzzz@2zy74l.onmicrosoft.com') + ->setEmailCanonical('zzzzz@2zy74l.onmicrosoft.com'); + $userC = (new User())->setEmail('alfredN@2zy74l.onmicrosoft.com') + ->setEmailCanonical('alfredn@2zy74l.onmicrosoft.com'); + + $userRepository = $this->prophesize(UserRepositoryInterface::class); + $userRepository->findOneByUsernameOrEmail(Argument::exact('AlexW@2zy74l.onmicrosoft.com')) + ->willReturn($userA); + $userRepository->findOneByUsernameOrEmail(Argument::exact('zzzzz@2zy74l.onmicrosoft.com')) + ->willReturn($userB); + $userRepository->findOneByUsernameOrEmail(Argument::exact('alfredN@2zy74l.onmicrosoft.com')) + ->willReturn($userC); + $userRepository->findOneByUsernameOrEmail(Argument::exact('external@example.com')) + ->willReturn(null); + + $calendarSyncer = new CalendarSyncer( + new NullLogger(), + $machineHttpClient, + $userRepository->reveal() + ); + + $calendar = new Calendar(); + $calendar + ->setMainUser($user = new User()) + ->setStartDate(new DateTimeImmutable('2022-06-11 14:30:00')) + ->setEndDate(new DateTimeImmutable('2022-06-11 15:30:00')) + ->addUser($userA) + ->addUser($userB) + ->setCalendarRange(new CalendarRange()) + ->addRemoteAttributes([ + 'lastModifiedDateTime' => 0, + 'changeKey' => 'abcd', + ]); + + $notification = json_decode(self::NOTIF_UPDATE, true); + + $calendarSyncer->handleCalendarSync( + $calendar, + $notification['value'][0], + $user + ); + + $this->assertTrue($calendar->preventEnqueueChanges); + $this->assertEquals(Calendar::STATUS_VALID, $calendar->getStatus()); + // user A is invited, and accepted + $this->assertTrue($calendar->isInvited($userA)); + $this->assertEquals(Invite::ACCEPTED, $calendar->getInviteForUser($userA)->getStatus()); + $this->assertFalse($calendar->getInviteForUser($userA)->preventEnqueueChanges); + // user B is no more invited + $this->assertFalse($calendar->isInvited($userB)); + // user C is invited, but declined + $this->assertFalse($calendar->getInviteForUser($userC)->preventEnqueueChanges); + $this->assertTrue($calendar->isInvited($userC)); + $this->assertEquals(Invite::DECLINED, $calendar->getInviteForUser($userC)->getStatus()); + } + + public function testHandleDeleteCalendar(): void + { + $machineHttpClient = new MockHttpClient([]); + $userRepository = $this->prophesize(UserRepositoryInterface::class); + + $calendarSyncer = new CalendarSyncer( + new NullLogger(), + $machineHttpClient, + $userRepository->reveal() + ); + + $calendar = new Calendar(); + $calendar + ->setMainUser($user = new User()) + ->setCalendarRange($calendarRange = new CalendarRange()); + + $notification = json_decode(self::NOTIF_DELETE, true); + + $calendarSyncer->handleCalendarSync( + $calendar, + $notification['value'][0], + $user + ); + + $this->assertEquals(Calendar::STATUS_CANCELED, $calendar->getStatus()); + $this->assertNull($calendar->getCalendarRange()); + $this->assertTrue($calendar->preventEnqueueChanges); + } + + public function testHandleMoveCalendar(): void + { + $machineHttpClient = new MockHttpClient([ + new MockResponse(self::REMOTE_CALENDAR_NO_ATTENDEES, ['http_code' => 200]), + ]); + $userRepository = $this->prophesize(UserRepositoryInterface::class); + + $calendarSyncer = new CalendarSyncer( + new NullLogger(), + $machineHttpClient, + $userRepository->reveal() + ); + + $calendar = new Calendar(); + $calendar + ->setMainUser($user = new User()) + ->setStartDate(new DateTimeImmutable('2020-01-01 10:00:00')) + ->setEndDate(new DateTimeImmutable('2020-01-01 12:00:00')) + ->setCalendarRange(new CalendarRange()) + ->addRemoteAttributes([ + 'lastModifiedDateTime' => 0, + 'changeKey' => 'abcd', + ]); + $notification = json_decode(self::NOTIF_UPDATE, true); + + $calendarSyncer->handleCalendarSync( + $calendar, + $notification['value'][0], + $user + ); + + $this->assertStringContainsString( + '2022-06-10T15:30:00', + $calendar->getStartDate()->format(DateTimeImmutable::ATOM) + ); + $this->assertStringContainsString( + '2022-06-10T17:30:00', + $calendar->getEndDate()->format(DateTimeImmutable::ATOM) + ); + $this->assertTrue($calendar->preventEnqueueChanges); + $this->assertEquals(Calendar::STATUS_MOVED, $calendar->getStatus()); + } + + public function testHandleNotMovedCalendar(): void + { + $machineHttpClient = new MockHttpClient([ + new MockResponse(self::REMOTE_CALENDAR_NO_ATTENDEES, ['http_code' => 200]), + ]); + $userRepository = $this->prophesize(UserRepositoryInterface::class); + + $calendarSyncer = new CalendarSyncer( + new NullLogger(), + $machineHttpClient, + $userRepository->reveal() + ); + + $calendar = new Calendar(); + $calendar + ->setMainUser($user = new User()) + ->setStartDate(new DateTimeImmutable('2022-06-10 15:30:00')) + ->setEndDate(new DateTimeImmutable('2022-06-10 17:30:00')) + ->setCalendarRange(new CalendarRange()) + ->addRemoteAttributes([ + 'lastModifiedDateTime' => 0, + 'changeKey' => 'abcd', + ]); + $notification = json_decode(self::NOTIF_UPDATE, true); + + $calendarSyncer->handleCalendarSync( + $calendar, + $notification['value'][0], + $user + ); + + $this->assertStringContainsString( + '2022-06-10T15:30:00', + $calendar->getStartDate()->format(DateTimeImmutable::ATOM) + ); + $this->assertStringContainsString( + '2022-06-10T17:30:00', + $calendar->getEndDate()->format(DateTimeImmutable::ATOM) + ); + $this->assertTrue($calendar->preventEnqueueChanges); + $this->assertEquals(Calendar::STATUS_VALID, $calendar->getStatus()); + } + + public function testHandleNotOrganizer(): void + { + // when isOrganiser === false, nothing should happens + $machineHttpClient = new MockHttpClient([ + new MockResponse(self::REMOTE_CALENDAR_NOT_ORGANIZER, ['http_code' => 200]), + ]); + $userRepository = $this->prophesize(UserRepositoryInterface::class); + + $calendarSyncer = new CalendarSyncer( + new NullLogger(), + $machineHttpClient, + $userRepository->reveal() + ); + + $calendar = new Calendar(); + $calendar + ->setMainUser($user = new User()) + ->setStartDate(new DateTimeImmutable('2020-01-01 10:00:00')) + ->setEndDate(new DateTimeImmutable('2020-01-01 12:00:00')) + ->setCalendarRange(new CalendarRange()) + ->addRemoteAttributes([ + 'lastModifiedDateTime' => 0, + 'changeKey' => 'abcd', + ]); + $notification = json_decode(self::NOTIF_UPDATE, true); + + $calendarSyncer->handleCalendarSync( + $calendar, + $notification['value'][0], + $user + ); + + $this->assertStringContainsString( + '2020-01-01T10:00:00', + $calendar->getStartDate()->format(DateTimeImmutable::ATOM) + ); + $this->assertStringContainsString( + '2020-01-01T12:00:00', + $calendar->getEndDate()->format(DateTimeImmutable::ATOM) + ); + + $this->assertEquals(Calendar::STATUS_VALID, $calendar->getStatus()); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/LocationConverterTest.php b/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/LocationConverterTest.php new file mode 100644 index 000000000..3ea81afdc --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/LocationConverterTest.php @@ -0,0 +1,79 @@ +setName('display')->setAddress($address = new Address()); + $address->setAddressReference($reference = new AddressReference()); + + $actual = $this->buildLocationConverter()->locationToRemote($location); + + $this->assertArrayHasKey('address', $actual); + $this->assertArrayNotHasKey('coordinates', $actual); + $this->assertArrayHasKey('displayName', $actual); + $this->assertEquals('display', $actual['displayName']); + } + + public function testConvertToRemoteWithAddressWithPoint(): void + { + $location = (new Location())->setName('display')->setAddress($address = new Address()); + $address->setAddressReference($reference = new AddressReference()); + $reference->setPoint($point = Point::fromLonLat(5.3134, 50.3134)); + + $actual = $this->buildLocationConverter()->locationToRemote($location); + + $this->assertArrayHasKey('address', $actual); + $this->assertArrayHasKey('coordinates', $actual); + $this->assertEquals(['latitude' => 50.3134, 'longitude' => 5.3134], $actual['coordinates']); + $this->assertArrayHasKey('displayName', $actual); + $this->assertEquals('display', $actual['displayName']); + } + + public function testConvertToRemoteWithoutAddressWithoutPoint(): void + { + $location = (new Location())->setName('display'); + + $actual = $this->buildLocationConverter()->locationToRemote($location); + + $this->assertArrayNotHasKey('address', $actual); + $this->assertArrayNotHasKey('coordinates', $actual); + $this->assertArrayHasKey('displayName', $actual); + $this->assertEquals('display', $actual['displayName']); + } + + private function buildLocationConverter(): LocationConverter + { + $addressConverter = $this->prophesize(AddressConverter::class); + $addressConverter->addressToRemote(Argument::type(Address::class))->willReturn(['street' => 'dummy']); + + return new LocationConverter($addressConverter->reveal()); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/BulkCalendarShortMessageSenderTest.php b/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/BulkCalendarShortMessageSenderTest.php new file mode 100644 index 000000000..fc1262d64 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/BulkCalendarShortMessageSenderTest.php @@ -0,0 +1,128 @@ +get(EntityManagerInterface::class); + + foreach ($this->toDelete as [$entity, $id]) { + $entity = $em->find($entity, $id); + $em->remove($entity); + } + + $em->flush(); + } + + public function testSendBulkMessageToEligibleCalendar() + { + $em = self::$container->get(EntityManagerInterface::class); + $calendar = new Calendar(); + $calendar + ->addPerson($this->getRandomPerson($em)) + ->setMainUser($user = $this->prepareUser([])) + ->setStartDate(new DateTimeImmutable('now')) + ->setEndDate($calendar->getStartDate()->add(new DateInterval('PT30M'))) + ->setSendSMS(true); + + $user->setUsername(uniqid()); + $user->setEmail(uniqid() . '@gmail.com'); + $calendar->getPersons()->first()->setAcceptSMS(true); + + // hack to prevent side effect with messages + $calendar->preventEnqueueChanges = true; + + $em->persist($user); + //$this->toDelete[] = [User::class, $user->getId()]; + $em->persist($calendar); + //$this->toDelete[] = [Calendar::class, $calendar->getId()]; + $em->flush(); + + $provider = $this->prophesize(CalendarForShortMessageProvider::class); + $provider->getCalendars(Argument::type(DateTimeImmutable::class)) + ->willReturn(new ArrayIterator([$calendar])); + + $messageBuilder = $this->prophesize(ShortMessageForCalendarBuilderInterface::class); + $messageBuilder->buildMessageForCalendar(Argument::type(Calendar::class)) + ->willReturn( + [ + new ShortMessage( + 'content', + PhoneNumberUtil::getInstance()->parse('+32470123456', 'BE'), + ShortMessage::PRIORITY_MEDIUM + ), + ] + ); + + $bus = $this->prophesize(MessageBusInterface::class); + $bus->dispatch(Argument::type(ShortMessage::class)) + ->willReturn(new Envelope(new stdClass())) + ->shouldBeCalledTimes(1); + + $bulk = new BulkCalendarShortMessageSender( + $provider->reveal(), + $em, + new NullLogger(), + $bus->reveal(), + $messageBuilder->reveal() + ); + + $bulk->sendBulkMessageToEligibleCalendars(); + + $em->clear(); + $calendar = $em->find(Calendar::class, $calendar->getId()); + + $this->assertEquals(Calendar::SMS_SENT, $calendar->getSmsStatus()); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/CalendarForShortMessageProviderTest.php b/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/CalendarForShortMessageProviderTest.php new file mode 100644 index 000000000..ffeefa213 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/CalendarForShortMessageProviderTest.php @@ -0,0 +1,103 @@ +prophesize(CalendarRepository::class); + $calendarRepository->findByNotificationAvailable( + Argument::type(DateTimeImmutable::class), + Argument::type(DateTimeImmutable::class), + Argument::type('int'), + Argument::exact(0) + )->will(static function ($args) { + return array_fill(0, $args[2], new Calendar()); + })->shouldBeCalledTimes(1); + $calendarRepository->findByNotificationAvailable( + Argument::type(DateTimeImmutable::class), + Argument::type(DateTimeImmutable::class), + Argument::type('int'), + Argument::not(0) + )->will(static function ($args) { + return array_fill(0, $args[2] - 1, new Calendar()); + })->shouldBeCalledTimes(1); + + $em = $this->prophesize(EntityManagerInterface::class); + $em->clear()->shouldBeCalled(); + + $provider = new CalendarForShortMessageProvider( + $calendarRepository->reveal(), + $em->reveal(), + new DefaultRangeGenerator() + ); + + $calendars = iterator_to_array($provider->getCalendars(new DateTimeImmutable('now'))); + + $this->assertGreaterThan(1, count($calendars)); + $this->assertLessThan(100, count($calendars)); + $this->assertContainsOnly(Calendar::class, $calendars); + } + + public function testGetCalendarsWithOnlyOneCalendar() + { + $calendarRepository = $this->prophesize(CalendarRepository::class); + $calendarRepository->findByNotificationAvailable( + Argument::type(DateTimeImmutable::class), + Argument::type(DateTimeImmutable::class), + Argument::type('int'), + Argument::exact(0) + )->will(static function ($args) { + return array_fill(0, 1, new Calendar()); + })->shouldBeCalledTimes(1); + $calendarRepository->findByNotificationAvailable( + Argument::type(DateTimeImmutable::class), + Argument::type(DateTimeImmutable::class), + Argument::type('int'), + Argument::not(0) + )->will(static function ($args) { + return []; + })->shouldBeCalledTimes(1); + + $em = $this->prophesize(EntityManagerInterface::class); + $em->clear()->shouldBeCalled(); + + $provider = new CalendarForShortMessageProvider( + $calendarRepository->reveal(), + $em->reveal(), + new DefaultRangeGenerator() + ); + + $calendars = iterator_to_array($provider->getCalendars(new DateTimeImmutable('now'))); + + $this->assertEquals(1, count($calendars)); + $this->assertContainsOnly(Calendar::class, $calendars); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/DefaultRangeGeneratorTest.php b/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/DefaultRangeGeneratorTest.php new file mode 100644 index 000000000..7d873c0c1 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/DefaultRangeGeneratorTest.php @@ -0,0 +1,94 @@ + Envoi des rdv du mardi et mercredi. + * * Mardi => Envoi des rdv du jeudi. + * * Mercredi => Envoi des rdv du vendredi + * * Jeudi => envoi des rdv du samedi et dimanche + * * Vendredi => Envoi des rdv du lundi. + */ + public function generateData(): Iterator + { + yield [ + new DateTimeImmutable('2022-06-13 10:45:00'), + new DateTimeImmutable('2022-06-14 00:00:00'), + new DateTimeImmutable('2022-06-16 00:00:00'), + ]; + + yield [ + new DateTimeImmutable('2022-06-14 15:45:00'), + new DateTimeImmutable('2022-06-16 00:00:00'), + new DateTimeImmutable('2022-06-17 00:00:00'), + ]; + + yield [ + new DateTimeImmutable('2022-06-15 13:45:18'), + new DateTimeImmutable('2022-06-17 00:00:00'), + new DateTimeImmutable('2022-06-18 00:00:00'), + ]; + + yield [ + new DateTimeImmutable('2022-06-16 01:30:55'), + new DateTimeImmutable('2022-06-18 00:00:00'), + new DateTimeImmutable('2022-06-20 00:00:00'), + ]; + + yield [ + new DateTimeImmutable('2022-06-17 21:30:55'), + new DateTimeImmutable('2022-06-20 00:00:00'), + new DateTimeImmutable('2022-06-21 00:00:00'), + ]; + + yield [ + new DateTimeImmutable('2022-06-18 21:30:55'), + null, + null, + ]; + + yield [ + new DateTimeImmutable('2022-06-19 21:30:55'), + null, + null, + ]; + } + + /** + * @dataProvider generateData + */ + public function testGenerateRange(DateTimeImmutable $date, ?DateTimeImmutable $startDate, ?DateTimeImmutable $endDate) + { + $generator = new DefaultRangeGenerator(); + + ['startDate' => $actualStartDate, 'endDate' => $actualEndDate] = $generator->generateRange($date); + + if (null === $startDate) { + $this->assertNull($actualStartDate); + $this->assertNull($actualEndDate); + } else { + $this->assertEquals($startDate->format(DateTimeImmutable::ATOM), $actualStartDate->format(DateTimeImmutable::ATOM)); + $this->assertEquals($endDate->format(DateTimeImmutable::ATOM), $actualEndDate->format(DateTimeImmutable::ATOM)); + } + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/DefaultShortMessageForCalendarBuilderTest.php b/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/DefaultShortMessageForCalendarBuilderTest.php new file mode 100644 index 000000000..736bea81d --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/DefaultShortMessageForCalendarBuilderTest.php @@ -0,0 +1,108 @@ +phoneNumberUtil = PhoneNumberUtil::getInstance(); + } + + public function testBuildMessageForCalendar() + { + $calendar = new Calendar(); + $calendar + ->setStartDate(new DateTimeImmutable('now')) + ->setEndDate($calendar->getStartDate()->add(new DateInterval('PT30M'))) + ->setMainUser($user = new User()) + ->addPerson($person = new Person()) + ->setSendSMS(false); + $user + ->setLabel('Alex') + ->setMainLocation($location = new Location()); + $location->setName('LOCAMAT'); + $person + ->setMobilenumber($this->phoneNumberUtil->parse('+32470123456', 'BE')) + ->setAcceptSMS(false); + + $engine = $this->prophesize(EngineInterface::class); + $engine->render(Argument::exact('@ChillCalendar/CalendarShortMessage/short_message.txt.twig'), Argument::withKey('calendar')) + ->willReturn('message content') + ->shouldBeCalledTimes(1); + $engine->render(Argument::exact('@ChillCalendar/CalendarShortMessage/short_message_canceled.txt.twig'), Argument::withKey('calendar')) + ->willReturn('message canceled') + ->shouldBeCalledTimes(1); + + $builder = new DefaultShortMessageForCalendarBuilder( + $engine->reveal() + ); + + // if the calendar should not send sms + $sms = $builder->buildMessageForCalendar($calendar); + $this->assertCount(0, $sms); + + // if the person do not accept sms + $calendar->setSendSMS(true); + $sms = $builder->buildMessageForCalendar($calendar); + $this->assertCount(0, $sms); + + // person accepts sms + $person->setAcceptSMS(true); + $sms = $builder->buildMessageForCalendar($calendar); + + $this->assertCount(1, $sms); + $this->assertEquals( + '+32470123456', + $this->phoneNumberUtil->format($sms[0]->getPhoneNumber(), PhoneNumberFormat::E164) + ); + $this->assertEquals('message content', $sms[0]->getContent()); + $this->assertEquals('low', $sms[0]->getPriority()); + + // if the calendar is canceled + $calendar + ->setSmsStatus(Calendar::SMS_SENT) + ->setStatus(Calendar::STATUS_CANCELED); + + $sms = $builder->buildMessageForCalendar($calendar); + + $this->assertCount(1, $sms); + $this->assertEquals( + '+32470123456', + $this->phoneNumberUtil->format($sms[0]->getPhoneNumber(), PhoneNumberFormat::E164) + ); + $this->assertEquals('message canceled', $sms[0]->getContent()); + $this->assertEquals('low', $sms[0]->getPriority()); + } +} diff --git a/src/Bundle/ChillCalendarBundle/chill.api.specs.yaml b/src/Bundle/ChillCalendarBundle/chill.api.specs.yaml index ebe0de88d..8d8df2568 100644 --- a/src/Bundle/ChillCalendarBundle/chill.api.specs.yaml +++ b/src/Bundle/ChillCalendarBundle/chill.api.specs.yaml @@ -1,181 +1,276 @@ --- -openapi: "3.0.0" -info: - version: "1.0.0" - title: "Chill api" - description: "Api documentation for chill. Currently, work in progress" -servers: - - url: "/api" - description: "Your current dev server" +#openapi: "3.0.0" +#info: +# version: "1.0.0" +# title: "Chill api" +# description: "Api documentation for chill. Currently, work in progress" +#servers: +# - url: "/api" +# description: "Your current dev server" components: - schemas: - Date: - type: object - properties: - datetime: - type: string - format: date-time - User: - type: object - properties: - id: - type: integer - type: - type: string - enum: - - user - username: - type: string - text: - type: string + schemas: + Date: + type: object + properties: + datetime: + type: string + format: date-time + User: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - user + username: + type: string + text: + type: string paths: - /1.0/calendar/calendar.json: - get: - tags: - - calendar - summary: Return a list of all calendar items - responses: - 200: - description: "ok" + /1.0/calendar/calendar/{id}/answer/{answer}.json: + post: + tags: + - calendar + summary: Answer to a calendar's invite + parameters: + - + in: path + name: id + required: true + description: the calendar id + schema: + type: integer + format: integer + minimum: 0 + - + in: path + name: answer + required: true + description: the answer + schema: + type: string + enum: + - accepted + - declined + - tentative + responses: + 400: + description: bad answer + 403: + description: not invited + 404: + description: not found + 202: + description: accepted - /1.0/calendar/calendar/{id}.json: - get: - tags: - - calendar - summary: Return an calendar item by id - parameters: - - name: id - in: path - required: true - description: The calendar id - schema: - type: integer - format: integer - minimum: 1 - responses: - 200: - description: "ok" - 404: - description: "not found" - 401: - description: "Unauthorized" + /1.0/calendar/calendar.json: + get: + tags: + - calendar + summary: Return a list of all calendar items + responses: + 200: + description: "ok" - /1.0/calendar/calendar-range.json: - get: - tags: - - calendar - summary: Return a list of all calendar range items - responses: - 200: - description: "ok" - post: - tags: - - calendar - summary: create a new calendar range - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - user: - $ref: '#/components/schemas/User' - startDate: - $ref: '#/components/schemas/Date' - endDate: - $ref: '#/components/schemas/Date' - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "Unprocessable entity (validation errors)" - 400: - description: "transition cannot be applyed" + /1.0/calendar/calendar/{id}.json: + get: + tags: + - calendar + summary: Return an calendar item by id + parameters: + - name: id + in: path + required: true + description: The calendar id + schema: + type: integer + format: integer + minimum: 1 + responses: + 200: + description: "ok" + 404: + description: "not found" + 403: + description: "Unauthorized" - /1.0/calendar/calendar-range/{id}.json: - get: - tags: - - calendar - summary: Return an calendar-range item by id - parameters: - - name: id - in: path - required: true - description: The calendar-range id - schema: - type: integer - format: integer - minimum: 1 - responses: - 200: - description: "ok" - 404: - description: "not found" - 401: - description: "Unauthorized" - patch: - tags: - - calendar - summary: update a calendar range - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - user: - $ref: '#/components/schemas/User' - startDate: - $ref: '#/components/schemas/Date' - endDate: - $ref: '#/components/schemas/Date' - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "Unprocessable entity (validation errors)" - 400: - description: "transition cannot be applyed" - delete: - tags: - - calendar - summary: "Remove a calendar range" - parameters: - - name: id - in: path - required: true - description: The calendar range id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" - - /1.0/calendar/calendar-range-available.json: - get: - tags: - - calendar - summary: Return a list of available calendar range items. Available means calendar-range not being taken by a calendar entity - responses: - 200: - description: "ok" \ No newline at end of file + /1.0/calendar/calendar/by-user/{userId}.json: + get: + tags: + - calendar + summary: Return a list of calendars for a user + parameters: + - name: userId + in: path + required: true + description: The user id + schema: + type: integer + format: integer + minimum: 1 + - name: dateFrom + in: query + required: true + description: The date from, formatted as ISO8601 string + schema: + type: string + format: date-time + - name: dateTo + in: query + required: true + description: The date to, formatted as ISO8601 string + schema: + type: string + format: date-time + responses: + 200: + description: "ok" + 404: + description: "not found" + 403: + description: "Unauthorized" + + /1.0/calendar/calendar-range.json: + get: + tags: + - calendar + summary: Return a list of all calendar range items + responses: + 200: + description: "ok" + post: + tags: + - calendar + summary: create a new calendar range + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + user: + $ref: '#/components/schemas/User' + startDate: + $ref: '#/components/schemas/Date' + endDate: + $ref: '#/components/schemas/Date' + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "Unprocessable entity (validation errors)" + 400: + description: "transition cannot be applyed" + + /1.0/calendar/calendar-range/{id}.json: + get: + tags: + - calendar + summary: Return an calendar-range item by id + parameters: + - name: id + in: path + required: true + description: The calendar-range id + schema: + type: integer + format: integer + minimum: 1 + responses: + 200: + description: "ok" + 404: + description: "not found" + 401: + description: "Unauthorized" + patch: + tags: + - calendar + summary: update a calendar range + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + user: + $ref: '#/components/schemas/User' + startDate: + $ref: '#/components/schemas/Date' + endDate: + $ref: '#/components/schemas/Date' + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "Unprocessable entity (validation errors)" + 400: + description: "transition cannot be applyed" + delete: + tags: + - calendar + summary: "Remove a calendar range" + parameters: + - name: id + in: path + required: true + description: The calendar range id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" + + /1.0/calendar/calendar-range-available/{userId}.json: + get: + tags: + - calendar + summary: Return a list of available calendar range items. Available means calendar-range not being taken by a calendar entity + parameters: + - name: userId + in: path + required: true + description: The user id + schema: + type: integer + format: integer + minimum: 1 + - name: dateFrom + in: query + required: true + description: The date from, formatted as ISO8601 string + schema: + type: string + format: date-time + - name: dateTo + in: query + required: true + description: The date to, formatted as ISO8601 string + schema: + type: string + format: date-time + responses: + 200: + description: "ok" diff --git a/src/Bundle/ChillCalendarBundle/chill.webpack.config.js b/src/Bundle/ChillCalendarBundle/chill.webpack.config.js index f91b30268..e82210087 100644 --- a/src/Bundle/ChillCalendarBundle/chill.webpack.config.js +++ b/src/Bundle/ChillCalendarBundle/chill.webpack.config.js @@ -6,6 +6,7 @@ module.exports = function(encore, entries) { }); encore.addEntry('vue_calendar', __dirname + '/Resources/public/vuejs/Calendar/index.js'); - encore.addEntry('vue_mycalendarrange', __dirname + '/Resources/public/vuejs/MyCalendarRange/index.js'); + encore.addEntry('vue_mycalendarrange', __dirname + '/Resources/public/vuejs/MyCalendarRange/index2.ts'); encore.addEntry('page_calendar', __dirname + '/Resources/public/chill/index.js'); + encore.addEntry('mod_answer', __dirname + '/Resources/public/module/Invite/answer.js'); }; diff --git a/src/Bundle/ChillCalendarBundle/migrations/Version20220510155609.php b/src/Bundle/ChillCalendarBundle/migrations/Version20220510155609.php new file mode 100644 index 000000000..30927836e --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/migrations/Version20220510155609.php @@ -0,0 +1,55 @@ +addSql('ALTER TABLE chill_calendar.calendar_range DROP CONSTRAINT FK_38D57D0565FF1AEC'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range DROP CONSTRAINT FK_38D57D053174800F'); + $this->addSql('DROP INDEX chill_calendar.IDX_38D57D0565FF1AEC'); + $this->addSql('DROP INDEX chill_calendar.IDX_38D57D053174800F'); + $this->addSql('DROP INDEX chill_calendar.idx_calendar_range_remote'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range DROP remoteId'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range DROP remoteAttributes'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range DROP updatedAt'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range DROP createdAt'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range DROP updatedBy_id'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range DROP createdBy_id'); + } + + public function getDescription(): string + { + return 'Add columns on calendar range to handle remote'; + } + + public function up(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_calendar.calendar_range ADD remoteId TEXT DEFAULT \'\' NOT NULL'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range ADD remoteAttributes JSON DEFAULT \'[]\' NOT NULL'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range ADD updatedAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range ADD createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range ADD updatedBy_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range ADD createdBy_id INT DEFAULT NULL'); + $this->addSql('COMMENT ON COLUMN chill_calendar.calendar_range.updatedAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN chill_calendar.calendar_range.createdAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE chill_calendar.calendar_range ADD CONSTRAINT FK_38D57D0565FF1AEC FOREIGN KEY (updatedBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range ADD CONSTRAINT FK_38D57D053174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE INDEX IDX_38D57D0565FF1AEC ON chill_calendar.calendar_range (updatedBy_id)'); + $this->addSql('CREATE INDEX IDX_38D57D053174800F ON chill_calendar.calendar_range (createdBy_id)'); + $this->addSql('CREATE INDEX idx_calendar_range_remote ON chill_calendar.calendar_range (remoteId)'); + } +} diff --git a/src/Bundle/ChillCalendarBundle/migrations/Version20220511134619.php b/src/Bundle/ChillCalendarBundle/migrations/Version20220511134619.php new file mode 100644 index 000000000..1d3ed2784 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/migrations/Version20220511134619.php @@ -0,0 +1,87 @@ +addSql('CREATE TABLE chill_calendar.calendar_to_invites (calendar_id INT NOT NULL, invite_id INT NOT NULL, PRIMARY KEY(calendar_id, invite_id))'); + $this->addSql('CREATE INDEX idx_fcbeaaaea417747 ON chill_calendar.calendar_to_invites (invite_id)'); + $this->addSql('CREATE INDEX idx_fcbeaaaa40a2c8 ON chill_calendar.calendar_to_invites (calendar_id)'); + $this->addSql('ALTER TABLE chill_calendar.calendar_to_invites ADD CONSTRAINT fk_fcbeaaaa40a2c8 FOREIGN KEY (calendar_id) REFERENCES chill_calendar.calendar (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_calendar.calendar_to_invites ADD CONSTRAINT fk_fcbeaaaea417747 FOREIGN KEY (invite_id) REFERENCES chill_calendar.invite (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_calendar.invite DROP CONSTRAINT FK_F517FFA7A40A2C8'); + $this->addSql('ALTER TABLE chill_calendar.invite DROP CONSTRAINT FK_F517FFA73174800F'); + $this->addSql('ALTER TABLE chill_calendar.invite DROP CONSTRAINT FK_F517FFA765FF1AEC'); + $this->addSql('DROP INDEX chill_calendar.IDX_F517FFA7A40A2C8'); + $this->addSql('DROP INDEX chill_calendar.IDX_F517FFA73174800F'); + $this->addSql('DROP INDEX chill_calendar.IDX_F517FFA765FF1AEC'); + $this->addSql('ALTER TABLE chill_calendar.invite DROP calendar_id'); + $this->addSql('ALTER TABLE chill_calendar.invite DROP createdAt'); + $this->addSql('ALTER TABLE chill_calendar.invite DROP updatedAt'); + $this->addSql('ALTER TABLE chill_calendar.invite DROP createdBy_id'); + $this->addSql('ALTER TABLE chill_calendar.invite DROP updatedBy_id'); + $this->addSql('ALTER TABLE chill_calendar.invite ALTER user_id DROP NOT NULL'); + $this->addSql('ALTER TABLE chill_calendar.invite DROP COLUMN status'); + $this->addSql('ALTER TABLE chill_calendar.invite ADD COLUMN status JSON'); + $this->addSql('ALTER TABLE chill_calendar.invite ALTER status DROP DEFAULT'); + $this->addSql('ALTER TABLE chill_calendar.calendar DROP CONSTRAINT FK_712315AC3174800F'); + $this->addSql('ALTER TABLE chill_calendar.calendar DROP CONSTRAINT FK_712315AC65FF1AEC'); + $this->addSql('DROP INDEX chill_calendar.IDX_712315AC3174800F'); + $this->addSql('DROP INDEX chill_calendar.IDX_712315AC65FF1AEC'); + $this->addSql('ALTER TABLE chill_calendar.calendar DROP createdAt'); + $this->addSql('ALTER TABLE chill_calendar.calendar DROP updatedAt'); + $this->addSql('ALTER TABLE chill_calendar.calendar DROP createdBy_id'); + $this->addSql('ALTER TABLE chill_calendar.calendar DROP updatedBy_id'); + } + + public function getDescription(): string + { + return 'Prepare schema for handling calendar invites'; + } + + public function up(Schema $schema): void + { + $this->addSql('DROP TABLE chill_calendar.calendar_to_invites'); + $this->addSql('ALTER TABLE chill_calendar.calendar ADD createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + $this->addSql('ALTER TABLE chill_calendar.calendar ADD updatedAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + $this->addSql('ALTER TABLE chill_calendar.calendar ADD createdBy_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE chill_calendar.calendar ADD updatedBy_id INT DEFAULT NULL'); + $this->addSql('COMMENT ON COLUMN chill_calendar.calendar.createdAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN chill_calendar.calendar.updatedAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE chill_calendar.calendar ADD CONSTRAINT FK_712315AC3174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_calendar.calendar ADD CONSTRAINT FK_712315AC65FF1AEC FOREIGN KEY (updatedBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE INDEX IDX_712315AC3174800F ON chill_calendar.calendar (createdBy_id)'); + $this->addSql('CREATE INDEX IDX_712315AC65FF1AEC ON chill_calendar.calendar (updatedBy_id)'); + $this->addSql('ALTER TABLE chill_calendar.invite ADD calendar_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE chill_calendar.invite ADD createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + $this->addSql('ALTER TABLE chill_calendar.invite ADD updatedAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + $this->addSql('ALTER TABLE chill_calendar.invite ADD createdBy_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE chill_calendar.invite ADD updatedBy_id INT DEFAULT NULL'); + $this->addSql('DELETE FROM chill_calendar.invite WHERE user_id IS NULL'); + $this->addSql('ALTER TABLE chill_calendar.invite ALTER user_id SET NOT NULL'); + $this->addSql('ALTER TABLE chill_calendar.invite DROP COLUMN status'); + $this->addSql('ALTER TABLE chill_calendar.invite ADD COLUMN status TEXT DEFAULT \'pending\''); + $this->addSql('COMMENT ON COLUMN chill_calendar.invite.createdAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN chill_calendar.invite.updatedAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE chill_calendar.invite ADD CONSTRAINT FK_F517FFA7A40A2C8 FOREIGN KEY (calendar_id) REFERENCES chill_calendar.calendar (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_calendar.invite ADD CONSTRAINT FK_F517FFA73174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_calendar.invite ADD CONSTRAINT FK_F517FFA765FF1AEC FOREIGN KEY (updatedBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE INDEX IDX_F517FFA7A40A2C8 ON chill_calendar.invite (calendar_id)'); + $this->addSql('CREATE INDEX IDX_F517FFA73174800F ON chill_calendar.invite (createdBy_id)'); + $this->addSql('CREATE INDEX IDX_F517FFA765FF1AEC ON chill_calendar.invite (updatedBy_id)'); + } +} diff --git a/src/Bundle/ChillCalendarBundle/migrations/Version20220525080633.php b/src/Bundle/ChillCalendarBundle/migrations/Version20220525080633.php new file mode 100644 index 000000000..fe4c6baf1 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/migrations/Version20220525080633.php @@ -0,0 +1,52 @@ +addSql('ALTER TABLE chill_calendar.invite ALTER status DROP NOT NULL'); + $this->addSql('DROP INDEX chill_calendar.UNIQ_712315ACC5CB285D'); + $this->addSql('DROP INDEX chill_calendar.idx_calendar_remote'); + $this->addSql('ALTER TABLE chill_calendar.calendar ADD user_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE chill_calendar.calendar DROP remoteAttributes'); + $this->addSql('ALTER TABLE chill_calendar.calendar DROP remoteId'); + $this->addSql('ALTER TABLE chill_calendar.calendar ALTER status DROP DEFAULT'); + $this->addSql('ALTER TABLE chill_calendar.calendar ADD CONSTRAINT fk_712315aca76ed395 FOREIGN KEY (user_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE INDEX idx_712315acc5cb285d ON chill_calendar.calendar (calendarrange_id)'); + $this->addSql('CREATE INDEX idx_712315aca76ed395 ON chill_calendar.calendar (user_id)'); + } + + public function getDescription(): string + { + return 'Calendar: add remote infos and fix associations'; + } + + public function up(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_calendar.calendar DROP CONSTRAINT fk_712315aca76ed395'); + $this->addSql('DROP INDEX chill_calendar.idx_712315acc5cb285d'); + $this->addSql('DROP INDEX chill_calendar.idx_712315aca76ed395'); + $this->addSql('ALTER TABLE chill_calendar.calendar ADD remoteAttributes JSON DEFAULT \'[]\' NOT NULL'); + $this->addSql('ALTER TABLE chill_calendar.calendar ADD remoteId TEXT DEFAULT \'\' NOT NULL'); + $this->addSql('ALTER TABLE chill_calendar.calendar DROP user_id'); + $this->addSql('ALTER TABLE chill_calendar.calendar ALTER status SET DEFAULT \'valid\''); + $this->addSql('CREATE UNIQUE INDEX UNIQ_712315ACC5CB285D ON chill_calendar.calendar (calendarRange_id)'); + $this->addSql('CREATE INDEX idx_calendar_remote ON chill_calendar.calendar (remoteId)'); + $this->addSql('UPDATE chill_calendar.invite SET status=\'pending\' WHERE status IS NULL'); + $this->addSql('ALTER TABLE chill_calendar.invite ALTER status SET NOT NULL'); + } +} diff --git a/src/Bundle/ChillCalendarBundle/migrations/Version20220606153851.php b/src/Bundle/ChillCalendarBundle/migrations/Version20220606153851.php new file mode 100644 index 000000000..1ae43eb41 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/migrations/Version20220606153851.php @@ -0,0 +1,43 @@ +addSql('ALTER TABLE chill_calendar.calendar ALTER endDate TYPE TIMESTAMP(0) WITH TIME ZONE'); + $this->addSql('ALTER TABLE chill_calendar.calendar ALTER endDate DROP DEFAULT'); + $this->addSql('ALTER TABLE chill_calendar.calendar ALTER startDate TYPE TIMESTAMP(0) WITH TIME ZONE'); + $this->addSql('ALTER TABLE chill_calendar.calendar ALTER startDate DROP DEFAULT'); + $this->addSql('COMMENT ON COLUMN chill_calendar.calendar.enddate IS \'(DC2Type:datetimetz_immutable)\''); + $this->addSql('COMMENT ON COLUMN chill_calendar.calendar.startdate IS \'(DC2Type:datetimetz_immutable)\''); + } + + public function getDescription(): string + { + return 'remove timezone from dates in calendar entity'; + } + + public function up(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_calendar.calendar ALTER startdate TYPE TIMESTAMP(0) WITHOUT TIME ZONE'); + $this->addSql('ALTER TABLE chill_calendar.calendar ALTER startdate DROP DEFAULT'); + $this->addSql('ALTER TABLE chill_calendar.calendar ALTER enddate TYPE TIMESTAMP(0) WITHOUT TIME ZONE'); + $this->addSql('ALTER TABLE chill_calendar.calendar ALTER enddate DROP DEFAULT'); + $this->addSql('COMMENT ON COLUMN chill_calendar.calendar.startDate IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN chill_calendar.calendar.endDate IS \'(DC2Type:datetime_immutable)\''); + } +} diff --git a/src/Bundle/ChillCalendarBundle/migrations/Version20220606154119.php b/src/Bundle/ChillCalendarBundle/migrations/Version20220606154119.php new file mode 100644 index 000000000..990e5bcab --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/migrations/Version20220606154119.php @@ -0,0 +1,43 @@ +addSql('ALTER TABLE chill_calendar.calendar_range ALTER endDate TYPE TIMESTAMP(0) WITH TIME ZONE'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range ALTER endDate DROP DEFAULT'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range ALTER startDate TYPE TIMESTAMP(0) WITH TIME ZONE'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range ALTER startDate DROP DEFAULT'); + $this->addSql('COMMENT ON COLUMN chill_calendar.calendar_range.enddate IS \'(DC2Type:datetimetz_immutable)\''); + $this->addSql('COMMENT ON COLUMN chill_calendar.calendar_range.startdate IS \'(DC2Type:datetimetz_immutable)\''); + } + + public function getDescription(): string + { + return 'remove timezone from calendar range'; + } + + public function up(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_calendar.calendar_range ALTER startdate TYPE TIMESTAMP(0) WITHOUT TIME ZONE'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range ALTER startdate DROP DEFAULT'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range ALTER enddate TYPE TIMESTAMP(0) WITHOUT TIME ZONE'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range ALTER enddate DROP DEFAULT'); + $this->addSql('COMMENT ON COLUMN chill_calendar.calendar_range.startDate IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN chill_calendar.calendar_range.endDate IS \'(DC2Type:datetime_immutable)\''); + } +} diff --git a/src/Bundle/ChillCalendarBundle/migrations/Version20220608084052.php b/src/Bundle/ChillCalendarBundle/migrations/Version20220608084052.php new file mode 100644 index 000000000..390e1b743 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/migrations/Version20220608084052.php @@ -0,0 +1,36 @@ +addSql('ALTER TABLE chill_calendar.invite DROP remoteAttributes'); + $this->addSql('ALTER TABLE chill_calendar.invite DROP remoteId'); + } + + public function getDescription(): string + { + return 'Add remoteId for invitation'; + } + + public function up(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_calendar.invite ADD remoteAttributes JSON DEFAULT \'[]\' NOT NULL'); + $this->addSql('ALTER TABLE chill_calendar.invite ADD remoteId TEXT DEFAULT \'\' NOT NULL'); + $this->addSql('CREATE INDEX idx_calendar_invite_remote ON chill_calendar.invite (remoteId)'); + } +} diff --git a/src/Bundle/ChillCalendarBundle/migrations/Version20220609200857.php b/src/Bundle/ChillCalendarBundle/migrations/Version20220609200857.php new file mode 100644 index 000000000..8894da5d3 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/migrations/Version20220609200857.php @@ -0,0 +1,43 @@ +addSql('DROP INDEX chill_calendar.idx_calendar_range_remote'); + $this->addSql('CREATE INDEX idx_calendar_range_remote ON chill_calendar.calendar_range (remoteId)'); + $this->addSql('DROP INDEX chill_calendar.idx_calendar_remote'); + $this->addSql('CREATE INDEX idx_calendar_remote ON chill_calendar.calendar (remoteId)'); + $this->addSql('DROP INDEX chill_calendar.idx_calendar_invite_remote'); + $this->addSql('CREATE INDEX idx_calendar_invite_remote ON chill_calendar.invite (remoteId)'); + } + + public function getDescription(): string + { + return 'Set an unique contraint on remoteId on calendar object which are synced to a remote'; + } + + public function up(Schema $schema): void + { + $this->addSql('DROP INDEX chill_calendar.idx_calendar_range_remote'); + $this->addSql('CREATE UNIQUE INDEX idx_calendar_range_remote ON chill_calendar.calendar_range (remoteId) WHERE remoteId <> \'\''); + $this->addSql('DROP INDEX chill_calendar.idx_calendar_remote'); + $this->addSql('CREATE UNIQUE INDEX idx_calendar_remote ON chill_calendar.calendar (remoteId) WHERE remoteId <> \'\''); + $this->addSql('DROP INDEX chill_calendar.idx_calendar_invite_remote'); + $this->addSql('CREATE UNIQUE INDEX idx_calendar_invite_remote ON chill_calendar.invite (remoteId) WHERE remoteId <> \'\''); + } +} diff --git a/src/Bundle/ChillCalendarBundle/migrations/Version20220613202636.php b/src/Bundle/ChillCalendarBundle/migrations/Version20220613202636.php new file mode 100644 index 000000000..9e9099384 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/migrations/Version20220613202636.php @@ -0,0 +1,33 @@ +addSql('ALTER TABLE chill_calendar.calendar DROP smsStatus'); + } + + public function getDescription(): string + { + return 'Add sms status on calendars'; + } + + public function up(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_calendar.calendar ADD smsStatus TEXT DEFAULT \'sms_pending\' NOT NULL'); + } +} diff --git a/src/Bundle/ChillCalendarBundle/migrations/Version20220629095515.php b/src/Bundle/ChillCalendarBundle/migrations/Version20220629095515.php new file mode 100644 index 000000000..ebc677473 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/migrations/Version20220629095515.php @@ -0,0 +1,37 @@ +addSql('alter table chill_calendar.calendar_range DROP COLUMN location_id'); + } + + public function getDescription(): string + { + return 'Add location on calendar range'; + } + + public function up(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_calendar.calendar_range ADD location_id INT DEFAULT NULL'); + $this->addSql('UPDATE chill_calendar.calendar_range SET location_id = n.min FROM (SELECT min(id) FROM public.chill_main_location) AS n'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range ALTER COLUMN location_id SET NOT NULL'); + $this->addSql('ALTER TABLE chill_calendar.calendar_range ADD CONSTRAINT FK_38D57D0564D218E FOREIGN KEY (location_id) REFERENCES chill_main_location (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE INDEX IDX_38D57D0564D218E ON chill_calendar.calendar_range (location_id)'); + } +} diff --git a/src/Bundle/ChillCalendarBundle/translations/messages.fr.yml b/src/Bundle/ChillCalendarBundle/translations/messages.fr.yml index da244fd13..9efe99b79 100644 --- a/src/Bundle/ChillCalendarBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillCalendarBundle/translations/messages.fr.yml @@ -14,7 +14,7 @@ start date: début du rendez-vous end date: fin du rendez-vous cancel reason: motif d'annulation status: Statut du rendez-vous -calendar location: Localistion du rendez-vous +calendar location: Localisation du rendez-vous calendar comment: Remarque sur le rendez-vous sendSMS: Envoi d'un SMS Send s m s: Envoi d'un SMS ? @@ -26,6 +26,9 @@ The calendar item has been successfully removed.: Le rendez-vous a été supprim From the day: Du to the day: au Transform to activity: Transformer en échange +Will send SMS: Un SMS de rappel sera envoyé +Will not send SMS: Aucun SMS de rappel ne sera envoyé +SMS already sent: Un SMS a été envoyé canceledBy: supprimé par Canceled by: supprimé par @@ -38,3 +41,31 @@ crud: add_new: Ajouter un nouveau title_new: Nouveau motif d'annulation title_edit: Modifier le motif d'annulation + +chill_calendar: + form: + The main user is mandatory. He will organize the appointment.: L'utilisateur principal est obligatoire. Il est l'organisateur de l'événement. + Create for referrer: Créer pour le référent + start date filter: Début du rendez-vous + From: Du + To: Au + Next calendars: Prochains rendez-vous + +remote_ms_graph: + freebusy_statuses: + busy: Occupé + free: Libre + tentative: En attente de confirmation + oof: En dehors du bureau + workingElsewhere: Travaille à l'extérieur + unknown: Inconnu + cancel_event_because_main_user_is_%label%: L'événement est transféré à l'utilisateur %label% + +remote_calendar: + calendar_range_title: Plage de disponibilité Chill + +invite: + accepted: Accepté + declined: Refusé + pending: En attente + tentative: Accepté provisoirement diff --git a/src/Bundle/ChillCalendarBundle/translations/validators.fr.yml b/src/Bundle/ChillCalendarBundle/translations/validators.fr.yml new file mode 100644 index 000000000..7d9be7a91 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/translations/validators.fr.yml @@ -0,0 +1,7 @@ +calendar: + At least {{ limit }} person is required.: Au moins {{ limit }} personne doit être associée à ce rendez-vous + An end date is required: Indiquez une date et heure de fin + A start date is required: Indiquez une date et heure de début + A location is required: Indiquez un lieu + A main user is mandator: Indiquez un utilisateur principal + diff --git a/src/Bundle/ChillDocGeneratorBundle/Resources/public/api/pickTemplate.js b/src/Bundle/ChillDocGeneratorBundle/Resources/public/api/pickTemplate.js index a668fe29d..4c5673d05 100644 --- a/src/Bundle/ChillDocGeneratorBundle/Resources/public/api/pickTemplate.js +++ b/src/Bundle/ChillDocGeneratorBundle/Resources/public/api/pickTemplate.js @@ -1,4 +1,4 @@ -import { fetchResults } from "ChillMainAssets/lib/api/apiMethods.js"; +import { fetchResults } from "ChillMainAssets/lib/api/apiMethods.ts"; const fetchTemplates = (entityClass) => { let fqdnEntityClass = encodeURI(entityClass); diff --git a/src/Bundle/ChillDocStoreBundle/Resources/public/module/async_upload/downloader.js b/src/Bundle/ChillDocStoreBundle/Resources/public/module/async_upload/downloader.js index 0e889c373..2b6a1b8ae 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/public/module/async_upload/downloader.js +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/module/async_upload/downloader.js @@ -1,4 +1,4 @@ -var mime = require('mime-types'); +var mime = require('mime'); var algo = 'AES-CBC'; @@ -28,7 +28,7 @@ var download = (button) => { labelPreparing = button.dataset.labelPreparing, labelReady = button.dataset.labelReady, mimeType = button.dataset.mimeType, - extension = mime.extension(mimeType), + extension = mime.getExtension(mimeType), decryptError = "Error while decrypting file", fetchError = "Error while fetching file", key, url @@ -93,4 +93,4 @@ window.addEventListener('load', function(e) { initializeButtons(e.target); }); -export { initializeButtons, download }; \ No newline at end of file +export { initializeButtons, download }; diff --git a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManager.php b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManager.php index 7ff9b2ea8..0f867e0de 100644 --- a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManager.php +++ b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManager.php @@ -17,11 +17,12 @@ use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Exception\StoredObjectManagerException; use DateTimeImmutable; use DateTimeInterface; +use DateTimeZone; use RuntimeException; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\ResponseInterface; use Throwable; @@ -150,7 +151,8 @@ final class StoredObjectManager implements StoredObjectManagerInterface $date = DateTimeImmutable::createFromFormat( DateTimeImmutable::RFC7231, - $lastModifiedString + $lastModifiedString, + new DateTimeZone('GMT') ); if (false === $date) { diff --git a/src/Bundle/ChillEventBundle/Controller/AdminController.php b/src/Bundle/ChillEventBundle/Controller/AdminController.php index aca3b5a04..81918436e 100644 --- a/src/Bundle/ChillEventBundle/Controller/AdminController.php +++ b/src/Bundle/ChillEventBundle/Controller/AdminController.php @@ -12,6 +12,7 @@ declare(strict_types=1); namespace Chill\EventBundle\Controller; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\Routing\Annotation\Route; /** * Class AdminController @@ -20,18 +21,12 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; class AdminController extends AbstractController { /** - * @return \Symfony\Component\HttpFoundation\Response + * Event admin. + * + * @Route("/{_locale}/admin/event", name="chill_event_admin_index") */ - public function indexAction() + public function indexAdminAction() { - return $this->render('ChillEventBundle:Admin:layout.html.twig'); - } - - /** - * @return \Symfony\Component\HttpFoundation\RedirectResponse - */ - public function redirectToAdminIndexAction() - { - return $this->redirectToRoute('chill_main_admin_central'); + return $this->render('ChillEventBundle:Admin:index.html.twig'); } } diff --git a/src/Bundle/ChillEventBundle/Controller/EventController.php b/src/Bundle/ChillEventBundle/Controller/EventController.php index 0dcaf176a..5d00387c8 100644 --- a/src/Bundle/ChillEventBundle/Controller/EventController.php +++ b/src/Bundle/ChillEventBundle/Controller/EventController.php @@ -273,7 +273,7 @@ class EventController extends AbstractController /** * @var Center $centers */ - $centers = $this->authorizationHelper->getReachableCenters($this->getUser(), $role); + $centers = $this->authorizationHelper->getReachableCenters($this->getUser(), (string) $role); if (count($centers) === 1) { return $this->redirectToRoute('chill_event__event_new', [ diff --git a/src/Bundle/ChillEventBundle/Form/Type/PickEventType.php b/src/Bundle/ChillEventBundle/Form/Type/PickEventType.php index 6b0b2d1ab..68500e55e 100644 --- a/src/Bundle/ChillEventBundle/Form/Type/PickEventType.php +++ b/src/Bundle/ChillEventBundle/Form/Type/PickEventType.php @@ -151,7 +151,7 @@ class PickEventType extends AbstractType } else { $centers = $this->authorizationHelper->getReachableCenters( $this->user, - $options['role'] + (string) $options['role'] ); } diff --git a/src/Bundle/ChillEventBundle/Menu/AdminMenuBuilder.php b/src/Bundle/ChillEventBundle/Menu/AdminMenuBuilder.php new file mode 100644 index 000000000..cede2a5b8 --- /dev/null +++ b/src/Bundle/ChillEventBundle/Menu/AdminMenuBuilder.php @@ -0,0 +1,61 @@ +authorizationChecker = $authorizationChecker; + } + + public function buildMenu($menuId, MenuItem $menu, array $parameters) + { + if (!$this->authorizationChecker->isGranted('ROLE_ADMIN')) { + return; + } + + $menu->addChild('Events', [ + 'route' => 'chill_event_admin_index', + ]) + ->setAttribute('class', 'list-group-item-header') + ->setExtras([ + 'order' => 6500 + ]); + + $menu->addChild('Event type', [ + 'route' => 'chill_eventtype_admin', + ])->setExtras(['order' => 6510]); + + $menu->addChild('Event status', [ + 'route' => 'chill_event_admin_status', + ])->setExtras(['order' => 6520]); + + $menu->addChild('Role', [ + 'route' => 'chill_event_admin_role', + ])->setExtras(['order' => 6530]); + } + + public static function getMenuIds(): array + { + return ['admin_section', 'admin_event']; + } +} diff --git a/src/Bundle/ChillEventBundle/Resources/views/Admin/index.html.twig b/src/Bundle/ChillEventBundle/Resources/views/Admin/index.html.twig new file mode 100644 index 000000000..4ad95b49c --- /dev/null +++ b/src/Bundle/ChillEventBundle/Resources/views/Admin/index.html.twig @@ -0,0 +1,13 @@ +{% extends "@ChillMain/Admin/layoutWithVerticalMenu.html.twig" %} + +{% block vertical_menu_content %} + {{ chill_menu('admin_event', { + 'layout': '@ChillMain/Admin/menu_admin_section.html.twig', + }) }} +{% endblock %} + +{% block layout_wvm_content %} + {% block admin_content %} +

    {{ 'Events configuration' |trans }}

    + {% endblock %} +{% endblock %} \ No newline at end of file diff --git a/src/Bundle/ChillEventBundle/Resources/views/Admin/layout.html.twig b/src/Bundle/ChillEventBundle/Resources/views/Admin/layout.html.twig deleted file mode 100644 index 827f4610e..000000000 --- a/src/Bundle/ChillEventBundle/Resources/views/Admin/layout.html.twig +++ /dev/null @@ -1,31 +0,0 @@ -{# - * Copyright (C) 2014-2015, Champs Libres Cooperative SCRLFS, - / - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . -#} - -{% extends "@ChillMain/Admin/layoutWithVerticalMenu.html.twig" %} - -{% block vertical_menu_content %} - {{ chill_menu('admin_events', { - 'layout': '@ChillEvent/Admin/menu.html.twig', - }) }} -{% endblock %} - -{% block layout_wvm_content %} - {% block admin_content %} -

    {{ 'Events configuration' |trans }}

    - {% endblock %} -{% endblock %} \ No newline at end of file diff --git a/src/Bundle/ChillEventBundle/Resources/views/Admin/menu.html.twig b/src/Bundle/ChillEventBundle/Resources/views/Admin/menu.html.twig index 2e804277d..f587d97c4 100644 --- a/src/Bundle/ChillEventBundle/Resources/views/Admin/menu.html.twig +++ b/src/Bundle/ChillEventBundle/Resources/views/Admin/menu.html.twig @@ -1,21 +1,3 @@ -{# - * Copyright (C) 2014-2015, Champs Libres Cooperative SCRLFS, - / - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . -#} - {% extends "@ChillMain/Menu/verticalMenu.html.twig" %} {% block v_menu_title %}{{ 'Events configuration menu'|trans }}{% endblock %} diff --git a/src/Bundle/ChillEventBundle/Resources/views/EventType/edit.html.twig b/src/Bundle/ChillEventBundle/Resources/views/EventType/edit.html.twig index 3f2f5b146..205b8791b 100644 --- a/src/Bundle/ChillEventBundle/Resources/views/EventType/edit.html.twig +++ b/src/Bundle/ChillEventBundle/Resources/views/EventType/edit.html.twig @@ -1,4 +1,4 @@ -{% extends "ChillEventBundle:Admin:layout.html.twig" %} +{% extends "ChillEventBundle:Admin:index.html.twig" %} {% block admin_content -%} diff --git a/src/Bundle/ChillEventBundle/Resources/views/EventType/index.html.twig b/src/Bundle/ChillEventBundle/Resources/views/EventType/index.html.twig index 308974770..9c62697f3 100644 --- a/src/Bundle/ChillEventBundle/Resources/views/EventType/index.html.twig +++ b/src/Bundle/ChillEventBundle/Resources/views/EventType/index.html.twig @@ -1,4 +1,4 @@ -{% extends "ChillEventBundle:Admin:layout.html.twig" %} +{% extends "ChillEventBundle:Admin:index.html.twig" %} {% block admin_content -%} diff --git a/src/Bundle/ChillEventBundle/Resources/views/EventType/new.html.twig b/src/Bundle/ChillEventBundle/Resources/views/EventType/new.html.twig index e7d2f0d66..4c0823cdc 100644 --- a/src/Bundle/ChillEventBundle/Resources/views/EventType/new.html.twig +++ b/src/Bundle/ChillEventBundle/Resources/views/EventType/new.html.twig @@ -1,4 +1,4 @@ -{% extends "ChillEventBundle:Admin:layout.html.twig" %} +{% extends "ChillEventBundle:Admin:index.html.twig" %} {% block admin_content -%} diff --git a/src/Bundle/ChillEventBundle/Resources/views/EventType/show.html.twig b/src/Bundle/ChillEventBundle/Resources/views/EventType/show.html.twig index 3e0aaba03..0dbd66b17 100644 --- a/src/Bundle/ChillEventBundle/Resources/views/EventType/show.html.twig +++ b/src/Bundle/ChillEventBundle/Resources/views/EventType/show.html.twig @@ -1,4 +1,4 @@ -{% extends "ChillEventBundle:Admin:layout.html.twig" %} +{% extends "ChillEventBundle:Admin:index.html.twig" %} {% block admin_content -%} diff --git a/src/Bundle/ChillEventBundle/Resources/views/Role/edit.html.twig b/src/Bundle/ChillEventBundle/Resources/views/Role/edit.html.twig index a0b2d4f92..0c9d61795 100644 --- a/src/Bundle/ChillEventBundle/Resources/views/Role/edit.html.twig +++ b/src/Bundle/ChillEventBundle/Resources/views/Role/edit.html.twig @@ -1,4 +1,4 @@ -{% extends "ChillEventBundle:Admin:layout.html.twig" %} +{% extends "ChillEventBundle:Admin:index.html.twig" %} {% block admin_content -%}

    {{ 'Role edit'|trans }}

    diff --git a/src/Bundle/ChillEventBundle/Resources/views/Role/index.html.twig b/src/Bundle/ChillEventBundle/Resources/views/Role/index.html.twig index 4fb4f866f..091b2f6ac 100644 --- a/src/Bundle/ChillEventBundle/Resources/views/Role/index.html.twig +++ b/src/Bundle/ChillEventBundle/Resources/views/Role/index.html.twig @@ -1,4 +1,4 @@ -{% extends "ChillEventBundle:Admin:layout.html.twig" %} +{% extends "ChillEventBundle:Admin:index.html.twig" %} {% block admin_content -%} diff --git a/src/Bundle/ChillEventBundle/Resources/views/Role/new.html.twig b/src/Bundle/ChillEventBundle/Resources/views/Role/new.html.twig index da0a3e459..03681f30c 100644 --- a/src/Bundle/ChillEventBundle/Resources/views/Role/new.html.twig +++ b/src/Bundle/ChillEventBundle/Resources/views/Role/new.html.twig @@ -1,4 +1,4 @@ -{% extends "ChillEventBundle:Admin:layout.html.twig" %} +{% extends "ChillEventBundle:Admin:index.html.twig" %} {% block admin_content -%} diff --git a/src/Bundle/ChillEventBundle/Resources/views/Role/show.html.twig b/src/Bundle/ChillEventBundle/Resources/views/Role/show.html.twig index 2fb920eb2..68a15958f 100644 --- a/src/Bundle/ChillEventBundle/Resources/views/Role/show.html.twig +++ b/src/Bundle/ChillEventBundle/Resources/views/Role/show.html.twig @@ -1,4 +1,4 @@ -{% extends "ChillEventBundle:Admin:layout.html.twig" %} +{% extends "ChillEventBundle:Admin:index.html.twig" %} {% block admin_content -%} diff --git a/src/Bundle/ChillEventBundle/Resources/views/Status/edit.html.twig b/src/Bundle/ChillEventBundle/Resources/views/Status/edit.html.twig index e376e05e3..3d00a66fa 100644 --- a/src/Bundle/ChillEventBundle/Resources/views/Status/edit.html.twig +++ b/src/Bundle/ChillEventBundle/Resources/views/Status/edit.html.twig @@ -1,4 +1,4 @@ -{% extends "ChillEventBundle:Admin:layout.html.twig" %} +{% extends "ChillEventBundle:Admin:index.html.twig" %} {% block admin_content -%} diff --git a/src/Bundle/ChillEventBundle/Resources/views/Status/index.html.twig b/src/Bundle/ChillEventBundle/Resources/views/Status/index.html.twig index 7ac4ed37e..144a80e51 100644 --- a/src/Bundle/ChillEventBundle/Resources/views/Status/index.html.twig +++ b/src/Bundle/ChillEventBundle/Resources/views/Status/index.html.twig @@ -1,4 +1,4 @@ -{% extends "ChillEventBundle:Admin:layout.html.twig" %} +{% extends "ChillEventBundle:Admin:index.html.twig" %} {% block admin_content -%} diff --git a/src/Bundle/ChillEventBundle/Resources/views/Status/new.html.twig b/src/Bundle/ChillEventBundle/Resources/views/Status/new.html.twig index aa3ecc374..03ba1de3a 100644 --- a/src/Bundle/ChillEventBundle/Resources/views/Status/new.html.twig +++ b/src/Bundle/ChillEventBundle/Resources/views/Status/new.html.twig @@ -1,4 +1,4 @@ -{% extends "ChillEventBundle:Admin:layout.html.twig" %} +{% extends "ChillEventBundle:Admin:index.html.twig" %} {% block admin_content -%} diff --git a/src/Bundle/ChillEventBundle/Resources/views/Status/show.html.twig b/src/Bundle/ChillEventBundle/Resources/views/Status/show.html.twig index 624997026..0e0a75069 100644 --- a/src/Bundle/ChillEventBundle/Resources/views/Status/show.html.twig +++ b/src/Bundle/ChillEventBundle/Resources/views/Status/show.html.twig @@ -1,4 +1,4 @@ -{% extends "ChillEventBundle:Admin:layout.html.twig" %} +{% extends "ChillEventBundle:Admin:index.html.twig" %} {% block admin_content -%} diff --git a/src/Bundle/ChillEventBundle/config/routes.yaml b/src/Bundle/ChillEventBundle/config/routes.yaml index 2abb338bd..afa6bbe2b 100644 --- a/src/Bundle/ChillEventBundle/config/routes.yaml +++ b/src/Bundle/ChillEventBundle/config/routes.yaml @@ -9,24 +9,24 @@ chill_event_participation: ## ADMIN -chill_event_admin: +chill_event_admin_index: path: /{_locale}/admin/event - controller: Chill\EventBundle\Controller\AdminController::indexAction - options: - menus: - admin_section: - order: 2100 - label: "Events" - icons: ['calendar'] + controller: Chill\EventBundle\Controller\AdminController::indexAdminAction + # options: + # menus: + # admin_section: + # order: 2100 + # label: "Events" + # icons: ['calendar'] -chill_event_admin_redirect_to_admin_index: - path: /{_locale}/admin/event_redirect_to_main - controller: Chill\EventBundle\Controller\AdminController::redirectToAdminIndexAction - options: - menus: - admin_events: - order: 0 - label: Main admin menu +# chill_event_admin_redirect_to_admin_index: +# path: /{_locale}/admin/event_redirect_to_main +# controller: Chill\EventBundle\Controller\AdminController::redirectToAdminIndexAction +# options: +# menus: +# admin_events: +# order: 0 +# label: Main admin menu chill_event_admin_status: resource: "@ChillEventBundle/config/routes/status.yaml" @@ -39,4 +39,4 @@ chill_event_admin_role: chill_event_admin_event_type: resource: "@ChillEventBundle/config/routes/eventtype.yaml" prefix: /{_locale}/admin/event/event_type - + diff --git a/src/Bundle/ChillEventBundle/config/services.yaml b/src/Bundle/ChillEventBundle/config/services.yaml index 8287f32b5..cee12a024 100644 --- a/src/Bundle/ChillEventBundle/config/services.yaml +++ b/src/Bundle/ChillEventBundle/config/services.yaml @@ -1,2 +1,11 @@ services: + Chill\EventBundle\Controller\: + autowire: true + resource: '../Controller' + tags: ['controller.service_arguments'] + Chill\EventBundle\Menu\: + autowire: true + autoconfigure: true + resource: '../Menu/' + tags: ['chill.menu_builder'] \ No newline at end of file diff --git a/src/Bundle/ChillMainBundle/ChillMainBundle.php b/src/Bundle/ChillMainBundle/ChillMainBundle.php index 32e075b7f..54da6911a 100644 --- a/src/Bundle/ChillMainBundle/ChillMainBundle.php +++ b/src/Bundle/ChillMainBundle/ChillMainBundle.php @@ -18,6 +18,7 @@ use Chill\MainBundle\DependencyInjection\CompilerPass\GroupingCenterCompilerPass use Chill\MainBundle\DependencyInjection\CompilerPass\MenuCompilerPass; use Chill\MainBundle\DependencyInjection\CompilerPass\NotificationCounterCompilerPass; use Chill\MainBundle\DependencyInjection\CompilerPass\SearchableServicesCompilerPass; +use Chill\MainBundle\DependencyInjection\CompilerPass\ShortMessageCompilerPass; use Chill\MainBundle\DependencyInjection\CompilerPass\TimelineCompilerClass; use Chill\MainBundle\DependencyInjection\CompilerPass\WidgetsCompilerPass; use Chill\MainBundle\DependencyInjection\ConfigConsistencyCompilerPass; @@ -70,5 +71,6 @@ class ChillMainBundle extends Bundle $container->addCompilerPass(new ACLFlagsCompilerPass()); $container->addCompilerPass(new GroupingCenterCompilerPass()); $container->addCompilerPass(new CRUDControllerCompilerPass()); + $container->addCompilerPass(new ShortMessageCompilerPass()); } } diff --git a/src/Bundle/ChillMainBundle/Controller/ExportController.php b/src/Bundle/ChillMainBundle/Controller/ExportController.php index bc3f994a7..5dcd158f6 100644 --- a/src/Bundle/ChillMainBundle/Controller/ExportController.php +++ b/src/Bundle/ChillMainBundle/Controller/ExportController.php @@ -405,7 +405,7 @@ class ExportController extends AbstractController 'alias' => $alias, ]; unset($parameters['_token']); - $key = md5(uniqid(mt_rand(), false)); + $key = md5(uniqid((string) mt_rand(), false)); $this->redis->setEx($key, 3600, serialize($parameters)); diff --git a/src/Bundle/ChillMainBundle/DependencyInjection/ChillMainExtension.php b/src/Bundle/ChillMainBundle/DependencyInjection/ChillMainExtension.php index 929bffe14..7348d7648 100644 --- a/src/Bundle/ChillMainBundle/DependencyInjection/ChillMainExtension.php +++ b/src/Bundle/ChillMainBundle/DependencyInjection/ChillMainExtension.php @@ -196,8 +196,11 @@ class ChillMainExtension extends Extension implements $loader->load('services/search.yaml'); $loader->load('services/serializer.yaml'); $loader->load('services/mailer.yaml'); + $loader->load('services/short_message.yaml'); $this->configureCruds($container, $config['cruds'], $config['apis'], $loader); + $container->setParameter('chill_main.short_messages', $config['short_messages']); + //$this->configureSms($config['short_messages'], $container, $loader); } public function prepend(ContainerBuilder $container) diff --git a/src/Bundle/ChillMainBundle/DependencyInjection/CompilerPass/ShortMessageCompilerPass.php b/src/Bundle/ChillMainBundle/DependencyInjection/CompilerPass/ShortMessageCompilerPass.php new file mode 100644 index 000000000..fa9994408 --- /dev/null +++ b/src/Bundle/ChillMainBundle/DependencyInjection/CompilerPass/ShortMessageCompilerPass.php @@ -0,0 +1,87 @@ +resolveEnvPlaceholders($container->getParameter('chill_main.short_messages', null), true); + // weird fix for special characters + $config['dsn'] = str_replace(['%%'], ['%'], $config['dsn']); + $dsn = parse_url($config['dsn']); + parse_str($dsn['query'] ?? '', $dsn['queries']); + + if ('null' === $dsn['scheme'] || false === $config['enabled']) { + $defaultTransporter = new Reference(NullShortMessageSender::class); + } elseif ('ovh' === $dsn['scheme']) { + if (!class_exists('\Ovh\Api')) { + throw new RuntimeException('Class \\Ovh\\Api not found'); + } + + foreach (['user', 'host', 'pass'] as $component) { + if (!array_key_exists($component, $dsn)) { + throw new RuntimeException(sprintf('The component %s does not exist in dsn. Please provide a dsn ' . + 'like ovh://applicationKey:applicationSecret@endpoint?consumerKey=xxxx&sender=yyyy&service_name=zzzz', $component)); + } + + $container->setParameter('chill_main.short_messages.ovh_config_' . $component, $dsn[$component]); + } + + foreach (['consumer_key', 'sender', 'service_name'] as $param) { + if (!array_key_exists($param, $dsn['queries'])) { + throw new RuntimeException(sprintf('The parameter %s does not exist in dsn. Please provide a dsn ' . + 'like ovh://applicationKey:applicationSecret@endpoint?consumerKey=xxxx&sender=yyyy&service_name=zzzz', $param)); + } + $container->setParameter('chill_main.short_messages.ovh_config_' . $param, $dsn['queries'][$param]); + } + + $ovh = new Definition(); + $ovh + ->setClass('\Ovh\Api') + ->setArgument(0, $dsn['user']) + ->setArgument(1, $dsn['pass']) + ->setArgument(2, $dsn['host']) + ->setArgument(3, $dsn['queries']['consumer_key']); + $container->setDefinition('Ovh\Api', $ovh); + + $ovhSender = new Definition(); + $ovhSender + ->setClass(OvhShortMessageSender::class) + ->setArgument(0, new Reference('Ovh\Api')) + ->setArgument(1, $dsn['queries']['service_name']) + ->setArgument(2, $dsn['queries']['sender']) + ->setArgument(3, new Reference(LoggerInterface::class)) + ->setArgument(4, new Reference(PhoneNumberUtil::class)); + $container->setDefinition(OvhShortMessageSender::class, $ovhSender); + + $defaultTransporter = new Reference(OvhShortMessageSender::class); + } else { + throw new RuntimeException(sprintf('Cannot find a sender for this dsn: %s', $config['dsn'])); + } + + $container->getDefinition(ShortMessageTransporter::class) + ->setArgument(0, $defaultTransporter); + } +} diff --git a/src/Bundle/ChillMainBundle/DependencyInjection/Configuration.php b/src/Bundle/ChillMainBundle/DependencyInjection/Configuration.php index a9b3ffec0..19b43de43 100644 --- a/src/Bundle/ChillMainBundle/DependencyInjection/Configuration.php +++ b/src/Bundle/ChillMainBundle/DependencyInjection/Configuration.php @@ -102,6 +102,14 @@ class Configuration implements ConfigurationInterface ->end() ->end() ->end() + ->arrayNode('short_messages') + ->canBeEnabled() + ->children() + ->scalarNode('dsn')->cannotBeEmpty()->defaultValue('null://null') + ->info('the dsn for sending short message. Example: ovh://applicationKey:secret@endpoint') + ->end() + ->end() + ->end() // end for 'short_messages' ->arrayNode('acl') ->addDefaultsIfNotSet() ->children() diff --git a/src/Bundle/ChillMainBundle/Entity/Address.php b/src/Bundle/ChillMainBundle/Entity/Address.php index d3eee0b2a..d088a567d 100644 --- a/src/Bundle/ChillMainBundle/Entity/Address.php +++ b/src/Bundle/ChillMainBundle/Entity/Address.php @@ -359,6 +359,11 @@ class Address return $this->validTo; } + public function hasAddressReference(): bool + { + return null !== $this->getAddressReference(); + } + public function isNoAddress(): bool { return $this->getIsNoAddress(); diff --git a/src/Bundle/ChillMainBundle/Entity/AddressReference.php b/src/Bundle/ChillMainBundle/Entity/AddressReference.php index fc4339fe0..5d581efd4 100644 --- a/src/Bundle/ChillMainBundle/Entity/AddressReference.php +++ b/src/Bundle/ChillMainBundle/Entity/AddressReference.php @@ -168,6 +168,11 @@ class AddressReference return $this->updatedAt; } + public function hasPoint(): bool + { + return null !== $this->getPoint(); + } + public function setCreatedAt(?DateTimeImmutable $createdAt): self { $this->createdAt = $createdAt; diff --git a/src/Bundle/ChillMainBundle/Entity/Location.php b/src/Bundle/ChillMainBundle/Entity/Location.php index c1421586f..09f2f2140 100644 --- a/src/Bundle/ChillMainBundle/Entity/Location.php +++ b/src/Bundle/ChillMainBundle/Entity/Location.php @@ -180,6 +180,11 @@ class Location implements TrackCreationInterface, TrackUpdateInterface return $this->updatedBy; } + public function hasAddress(): bool + { + return null !== $this->getAddress(); + } + public function setActive(bool $active): self { $this->active = $active; diff --git a/src/Bundle/ChillMainBundle/Entity/User.php b/src/Bundle/ChillMainBundle/Entity/User.php index 80848e2cf..3c9bf7959 100644 --- a/src/Bundle/ChillMainBundle/Entity/User.php +++ b/src/Bundle/ChillMainBundle/Entity/User.php @@ -43,7 +43,7 @@ class User implements AdvancedUserInterface /** * Array where SAML attributes's data are stored. * - * @ORM\Column(type="json", nullable=true) + * @ORM\Column(type="json", nullable=false) */ private array $attributes = []; @@ -359,16 +359,21 @@ class User implements AdvancedUserInterface } } - /** - * Set attributes. - * - * @param array $attributes - * - * @return Report - */ - public function setAttributes($attributes) + public function setAttributeByDomain(string $domain, string $key, $value): self { - $this->attributes = $attributes; + $this->attributes[$domain][$key] = $value; + + return $this; + } + + /** + * Merge the attributes with existing attributes. + * + * Only the key provided will be created or updated. For a two-level array, use @see{User::setAttributeByDomain} + */ + public function setAttributes(array $attributes): self + { + $this->attributes = array_merge($this->attributes, $attributes); return $this; } @@ -506,4 +511,11 @@ class User implements AdvancedUserInterface return $this; } + + public function unsetAttribute($key): self + { + unset($this->attributes[$key]); + + return $this; + } } diff --git a/src/Bundle/ChillMainBundle/Form/DataTransformer/IdToEntityDataTransformer.php b/src/Bundle/ChillMainBundle/Form/DataTransformer/IdToEntityDataTransformer.php new file mode 100644 index 000000000..9df6d053d --- /dev/null +++ b/src/Bundle/ChillMainBundle/Form/DataTransformer/IdToEntityDataTransformer.php @@ -0,0 +1,103 @@ +repository = $repository; + $this->multiple = $multiple; + $this->getId = $getId ?? static function (object $o) { return $o->getId(); }; + } + + /** + * @param string $value + * + * @return array|object[]|T[]|T|object + */ + public function reverseTransform($value) + { + if ($this->multiple) { + if (null === $value | '' === $value) { + return []; + } + + return array_map( + fn (string $id): ?object => $this->repository->findOneBy(['id' => (int) $id]), + explode(',', $value) + ); + } + + if (null === $value | '' === $value) { + return null; + } + + $object = $this->repository->findOneBy(['id' => (int) $value]); + + if (null === $object) { + throw new TransformationFailedException('could not find any object by object id'); + } + + return $object; + } + + /** + * @param object|T|object[]|T[] $value + */ + public function transform($value): string + { + if ($this->multiple) { + $ids = []; + + foreach ($value as $v) { + $ids[] = $id = call_user_func($this->getId, $v); + + if (null === $id) { + throw new TransformationFailedException('id is null'); + } + } + + return implode(',', $ids); + } + + if (null === $value) { + return ''; + } + + $id = call_user_func($this->getId, $value); + + if (null === $id) { + throw new TransformationFailedException('id is null'); + } + + return (string) $id; + } +} diff --git a/src/Bundle/ChillMainBundle/Form/DataTransformer/IdToLocationDataTransformer.php b/src/Bundle/ChillMainBundle/Form/DataTransformer/IdToLocationDataTransformer.php new file mode 100644 index 000000000..f75316956 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Form/DataTransformer/IdToLocationDataTransformer.php @@ -0,0 +1,22 @@ +exportManager->getExport($options['export_alias']); $centers = $this->authorizationHelper->getReachableCenters( $this->user, - $export->requiredRole() + (string) $export->requiredRole() ); $builder->add(self::CENTERS_IDENTIFIERS, EntityType::class, [ - 'class' => 'ChillMainBundle:Center', + 'class' => Center::class, 'query_builder' => static function (EntityRepository $er) use ($centers) { $qb = $er->createQueryBuilder('c'); $ids = array_map( diff --git a/src/Bundle/ChillMainBundle/Form/Type/Listing/FilterOrderType.php b/src/Bundle/ChillMainBundle/Form/Type/Listing/FilterOrderType.php index 2b6cdc21d..6f62b831b 100644 --- a/src/Bundle/ChillMainBundle/Form/Type/Listing/FilterOrderType.php +++ b/src/Bundle/ChillMainBundle/Form/Type/Listing/FilterOrderType.php @@ -11,6 +11,7 @@ declare(strict_types=1); namespace Chill\MainBundle\Form\Type\Listing; +use Chill\MainBundle\Form\Type\ChillDateType; use Chill\MainBundle\Templating\Listing\FilterOrderHelper; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\HiddenType; @@ -70,10 +71,38 @@ final class FilterOrderType extends \Symfony\Component\Form\AbstractType $builder->add($checkboxesBuilder); } + if (0 < count($helper->getDateRanges())) { + $dateRangesBuilder = $builder->create('dateRanges', null, ['compound' => true]); + + foreach ($helper->getDateRanges() as $name => $opts) { + $rangeBuilder = $dateRangesBuilder->create($name, null, [ + 'compound' => true, + 'label' => null === $opts['label'] ? false : $opts['label'] ?? $name, + ]); + + $rangeBuilder->add( + 'from', + ChillDateType::class, + ['input' => 'datetime_immutable', 'required' => false] + ); + $rangeBuilder->add( + 'to', + ChillDateType::class, + ['input' => 'datetime_immutable', 'required' => false] + ); + + $dateRangesBuilder->add($rangeBuilder); + } + + $builder->add($dateRangesBuilder); + } + foreach ($this->requestStack->getCurrentRequest()->query->getIterator() as $key => $value) { switch ($key) { case 'q': case 'checkboxes' . $key: + case $key . '_from': + case $key . '_to': break; case 'page': diff --git a/src/Bundle/ChillMainBundle/Repository/PostalCodeRepository.php b/src/Bundle/ChillMainBundle/Repository/PostalCodeRepository.php index 02e63771b..1c4a69366 100644 --- a/src/Bundle/ChillMainBundle/Repository/PostalCodeRepository.php +++ b/src/Bundle/ChillMainBundle/Repository/PostalCodeRepository.php @@ -119,8 +119,8 @@ final class PostalCodeRepository implements ObjectRepository $pertinenceClause = ['STRICT_WORD_SIMILARITY(canonical, UNACCENT(?))']; $pertinenceArgs = [$pattern]; - $orWhere = ['canonical %>> UNACCENT(?)']; - $orWhereArgs = [$pattern]; + $andWhere = ['canonical %>> UNACCENT(?)']; + $andWhereArgs = [$pattern]; foreach (explode(' ', $pattern) as $part) { $part = trim($part); @@ -129,8 +129,8 @@ final class PostalCodeRepository implements ObjectRepository continue; } - $orWhere[] = "canonical LIKE '%' || UNACCENT(LOWER(?)) || '%'"; - $orWhereArgs[] = $part; + $andWhere[] = "canonical LIKE '%' || UNACCENT(LOWER(?)) || '%'"; + $andWhereArgs[] = $part; $pertinenceClause[] = "(EXISTS (SELECT 1 FROM unnest(string_to_array(canonical, ' ')) AS t WHERE starts_with(t, UNACCENT(LOWER(?)))))::int"; $pertinenceClause[] = @@ -139,7 +139,7 @@ final class PostalCodeRepository implements ObjectRepository } $query ->setSelectPertinence(implode(' + ', $pertinenceClause), $pertinenceArgs) - ->andWhereClause(implode(' OR ', $orWhere), $orWhereArgs); + ->andWhereClause(implode(' AND ', $andWhere), $andWhereArgs); return $query; } diff --git a/src/Bundle/ChillMainBundle/Repository/UserRepository.php b/src/Bundle/ChillMainBundle/Repository/UserRepository.php index 7b7d5d2ab..dfb5c2c5d 100644 --- a/src/Bundle/ChillMainBundle/Repository/UserRepository.php +++ b/src/Bundle/ChillMainBundle/Repository/UserRepository.php @@ -15,12 +15,14 @@ use Chill\MainBundle\Entity\GroupCenter; use Chill\MainBundle\Entity\User; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; +use Doctrine\ORM\NoResultException; +use Doctrine\ORM\Query\ResultSetMapping; +use Doctrine\ORM\Query\ResultSetMappingBuilder; use Doctrine\ORM\QueryBuilder; -use Doctrine\Persistence\ObjectRepository; use function count; -final class UserRepository implements ObjectRepository +final class UserRepository implements UserRepositoryInterface { private EntityManagerInterface $entityManager; @@ -42,6 +44,16 @@ final class UserRepository implements ObjectRepository return $this->countBy(['enabled' => true]); } + public function countByNotHavingAttribute(string $key): int + { + $rsm = new ResultSetMapping(); + $rsm->addScalarResult('count', 'count'); + + $sql = 'SELECT count(*) FROM users u WHERE NOT attributes ?? :key OR attributes IS NULL AND enabled IS TRUE'; + + return $this->entityManager->createNativeQuery($sql, $rsm)->setParameter(':key', $key)->getSingleScalarResult(); + } + public function countByUsernameOrEmail(string $pattern): int { $qb = $this->queryByUsernameOrEmail($pattern); @@ -83,6 +95,29 @@ final class UserRepository implements ObjectRepository return $this->findBy(['enabled' => true], $orderBy, $limit, $offset); } + /** + * Find users which does not have a key on attribute column. + * + * @return array|User[] + */ + public function findByNotHavingAttribute(string $key, ?int $limit = null, ?int $offset = null): array + { + $rsm = new ResultSetMappingBuilder($this->entityManager); + $rsm->addRootEntityFromClassMetadata(User::class, 'u'); + + $sql = 'SELECT ' . $rsm->generateSelectClause() . ' FROM users u WHERE NOT attributes ?? :key OR attributes IS NULL AND enabled IS TRUE'; + + if (null !== $limit) { + $sql .= " LIMIT {$limit}"; + } + + if (null !== $offset) { + $sql .= " OFFSET {$offset}"; + } + + return $this->entityManager->createNativeQuery($sql, $rsm)->setParameter(':key', $key)->getResult(); + } + public function findByUsernameOrEmail(string $pattern, ?array $orderBy = [], ?int $limit = null, ?int $offset = null): array { $qb = $this->queryByUsernameOrEmail($pattern); @@ -109,11 +144,15 @@ final class UserRepository implements ObjectRepository return $this->repository->findOneBy($criteria, $orderBy); } - public function findOneByUsernameOrEmail(string $pattern) + public function findOneByUsernameOrEmail(string $pattern): ?User { - $qb = $this->queryByUsernameOrEmail($pattern); + $qb = $this->queryByUsernameOrEmail($pattern)->select('u'); - return $qb->getQuery()->getSingleResult(); + try { + return $qb->getQuery()->getSingleResult(); + } catch (NoResultException $e) { + return null; + } } /** @@ -171,7 +210,7 @@ final class UserRepository implements ObjectRepository return $qb->getQuery()->getResult(); } - public function getClassName() + public function getClassName(): string { return User::class; } diff --git a/src/Bundle/ChillMainBundle/Repository/UserRepositoryInterface.php b/src/Bundle/ChillMainBundle/Repository/UserRepositoryInterface.php new file mode 100644 index 000000000..75e8b90b4 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Repository/UserRepositoryInterface.php @@ -0,0 +1,73 @@ + { +export const dateToISO = (date: Date|null): string|null => { if (null === date) { return null; } @@ -29,7 +29,7 @@ const dateToISO = (date) => { * * **Experimental** */ -const ISOToDate = (str) => { +export const ISOToDate = (str: string|null): Date|null => { if (null === str) { return null; } @@ -38,25 +38,25 @@ const ISOToDate = (str) => { } let - [year, month, day] = str.split('-'); + [year, month, day] = str.split('-').map(p => parseInt(p)); - return new Date(year, month-1, day); + return new Date(year, month-1, day, 0, 0, 0, 0); } /** * Return a date object from iso string formatted as YYYY-mm-dd:HH:MM:ss+01:00 * */ -const ISOToDatetime = (str) => { +export const ISOToDatetime = (str: string|null): Date|null => { if (null === str) { return null; } let [cal, times] = str.split('T'), - [year, month, date] = cal.split('-'), + [year, month, date] = cal.split('-').map(s => parseInt(s)), [time, timezone] = times.split(times.charAt(8)), - [hours, minutes, seconds] = time.split(':') + [hours, minutes, seconds] = time.split(':').map(s => parseInt(s)); ; return new Date(year, month-1, date, hours, minutes, seconds); @@ -66,7 +66,7 @@ const ISOToDatetime = (str) => { * Convert a date to ISO8601, valid for usage in api * */ -const datetimeToISO = (date) => { +export const datetimeToISO = (date: Date): string => { let cal, time, offset; cal = [ date.getFullYear(), @@ -92,7 +92,7 @@ const datetimeToISO = (date) => { return x; }; -const intervalDaysToISO = (days) => { +export const intervalDaysToISO = (days: number|string|null): string => { if (null === days) { return 'P0D'; } @@ -100,7 +100,7 @@ const intervalDaysToISO = (days) => { return `P${days}D`; } -const intervalISOToDays = (str) => { +export const intervalISOToDays = (str: string|null): number|null => { if (null === str) { return null } @@ -154,12 +154,3 @@ const intervalISOToDays = (str) => { return days; } - -export { - dateToISO, - ISOToDate, - ISOToDatetime, - datetimeToISO, - intervalISOToDays, - intervalDaysToISO, -}; diff --git a/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.d.ts.backup b/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.d.ts.backup new file mode 100644 index 000000000..07ce99e1a --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.d.ts.backup @@ -0,0 +1,4 @@ +export function fetchResults(uri: string, params: {item_per_page?: number}): Promise; + +export function makeFetch(method: "GET"|"POST"|"PATCH"|"DELETE", url: string, body: B, options: {[key: string]: string}): Promise; + diff --git a/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.js b/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.js deleted file mode 100644 index 80e59005f..000000000 --- a/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.js +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Generic api method that can be adapted to any fetch request - */ -const makeFetch = (method, url, body, options) => { - let opts = { - method: method, - headers: { - 'Content-Type': 'application/json;charset=utf-8' - }, - body: (body !== null) ? JSON.stringify(body) : null - }; - - if (typeof options !== 'undefined') { - opts = Object.assign(opts, options); - } - - return fetch(url, opts) - .then(response => { - if (response.ok) { - return response.json(); - } - - if (response.status === 422) { - return response.json().then(response => { - throw ValidationException(response) - }); - } - - if (response.status === 403) { - throw AccessException(response); - } - - throw { - name: 'Exception', - sta: response.status, - txt: response.statusText, - err: new Error(), - violations: response.body - }; - }); -} - -/** - * Fetch results with certain parameters - */ -const _fetchAction = (page, uri, params) => { - const item_per_page = 50; - if (params === undefined) { - params = {}; - } - let url = uri + '?' + new URLSearchParams({ item_per_page, page, ...params }); - - return fetch(url, { - method: 'GET', - headers: { - 'Content-Type': 'application/json;charset=utf-8' - }, - }).then(response => { - if (response.ok) { return response.json(); } - throw Error({ m: response.statusText }); - }); -}; - -const fetchResults = async (uri, params) => { - let promises = [], - page = 1; - let firstData = await _fetchAction(page, uri, params); - - promises.push(Promise.resolve(firstData.results)); - - if (firstData.pagination.more) { - do { - page = ++page; - promises.push(_fetchAction(page, uri, params).then(r => Promise.resolve(r.results))); - } while (page * firstData.pagination.items_per_page < firstData.count) - } - - return Promise.all(promises).then(values => values.flat()); -}; - -const fetchScopes = () => { - return fetchResults('/api/1.0/main/scope.json'); -}; - - -/** - * Error objects to be thrown - */ -const ValidationException = (response) => { - const error = {}; - error.name = 'ValidationException'; - error.violations = response.violations.map((violation) => `${violation.title}: ${violation.propertyPath}`); - error.titles = response.violations.map((violation) => violation.title); - error.propertyPaths = response.violations.map((violation) => violation.propertyPath); - return error; -} - -const AccessException = (response) => { - const error = {}; - error.name = 'AccessException'; - error.violations = ['You are not allowed to perform this action']; - - return error; -} - -export { - makeFetch, - fetchResults, - fetchScopes -} diff --git a/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.ts b/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.ts new file mode 100644 index 000000000..7ef909884 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.ts @@ -0,0 +1,220 @@ +import {Scope} from '../../types'; + +export type body = {[key: string]: boolean|string|number|null}; +export type fetchOption = {[key: string]: boolean|string|number|null}; + +export interface Params { + [key: string]: number|string +} + +export interface PaginationResponse { + pagination: { + more: boolean; + items_per_page: number; + }; + results: T[]; + count: number; +} + +export interface FetchParams { + [K: string]: string|number|null; +}; + +export interface TransportExceptionInterface { + name: string; +} + +export interface ValidationExceptionInterface extends TransportExceptionInterface { + name: 'ValidationException'; + error: object; + violations: string[]; + titles: string[]; + propertyPaths: string[]; +} + +export interface ValidationErrorResponse extends TransportExceptionInterface { + violations: { + title: string; + propertyPath: string; + }[]; +} + +export interface AccessExceptionInterface extends TransportExceptionInterface { + name: 'AccessException'; + violations: string[]; +} + +export interface NotFoundExceptionInterface extends TransportExceptionInterface { + name: 'NotFoundException'; +} + +export interface ServerExceptionInterface extends TransportExceptionInterface { + name: 'ServerException'; + message: string; + code: number; + body: string; +} + + +/** + * Generic api method that can be adapted to any fetch request + */ +export const makeFetch = (method: 'POST'|'GET'|'PUT'|'PATCH'|'DELETE', url: string, body?: body | Input | null, options?: FetchParams): Promise => { + let opts = { + method: method, + headers: { + 'Content-Type': 'application/json;charset=utf-8' + }, + body: (body !== null || typeof body !== 'undefined') ? JSON.stringify(body) : null + }; + + if (typeof options !== 'undefined') { + opts = Object.assign(opts, options); + } + + return fetch(url, opts) + .then(response => { + if (response.ok) { + return response.json(); + } + + if (response.status === 422) { + return response.json().then(response => { + throw ValidationException(response) + }); + } + + if (response.status === 403) { + throw AccessException(response); + } + + throw { + name: 'Exception', + sta: response.status, + txt: response.statusText, + err: new Error(), + violations: response.body + }; + }); +} + +/** + * Fetch results with certain parameters + */ +function _fetchAction(page: number, uri: string, params?: FetchParams): Promise> { + const item_per_page: number = 50; + + let searchParams = new URLSearchParams(); + searchParams.append('item_per_page', item_per_page.toString()); + searchParams.append('page', page.toString()); + + if (params !== undefined) { + Object.keys(params).forEach(key => { + let v = params[key]; + if (typeof v === 'string') { + searchParams.append(key, v); + } else if (typeof v === 'number') { + searchParams.append(key, v.toString()); + } else if (v === null) { + searchParams.append(key, ''); + } + }); + } + + let url = uri + '?' + searchParams.toString(); + + return fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json;charset=utf-8' + }, + }).then((response) => { + if (response.ok) { return response.json(); } + + if (response.status === 404) { + throw NotFoundException(response); + } + + if (response.status === 422) { + return response.json().then(response => { + throw ValidationException(response) + }); + } + + if (response.status === 403) { + throw AccessException(response); + } + + if (response.status >= 500) { + return response.text().then(body => { + throw ServerException(response.status, body); + }); + } + + throw new Error("other network error"); + }).catch((reason: any) => { + console.error(reason); + throw new Error(reason); + }); +}; + +export const fetchResults = async (uri: string, params?: FetchParams): Promise => { + let promises: Promise[] = [], + page = 1; + let firstData: PaginationResponse = await _fetchAction(page, uri, params) as PaginationResponse; + + promises.push(Promise.resolve(firstData.results)); + + if (firstData.pagination.more) { + do { + page = ++page; + promises.push( + _fetchAction(page, uri, params) + .then(r => Promise.resolve(r.results)) + ); + } while (page * firstData.pagination.items_per_page < firstData.count) + } + + return Promise.all(promises).then((values) => values.flat()); +}; + +export const fetchScopes = (): Promise => { + return fetchResults('/api/1.0/main/scope.json'); +}; + + +/** + * Error objects to be thrown + */ +const ValidationException = (response: ValidationErrorResponse): ValidationExceptionInterface => { + const error = {} as ValidationExceptionInterface; + error.name = 'ValidationException'; + error.violations = response.violations.map((violation) => `${violation.title}: ${violation.propertyPath}`); + error.titles = response.violations.map((violation) => violation.title); + error.propertyPaths = response.violations.map((violation) => violation.propertyPath); + return error; +} + +const AccessException = (response: Response): AccessExceptionInterface => { + const error = {} as AccessExceptionInterface; + error.name = 'AccessException'; + error.violations = ['You are not allowed to perform this action']; + + return error; +} + +const NotFoundException = (response: Response): NotFoundExceptionInterface => { + const error = {} as NotFoundExceptionInterface; + error.name = 'NotFoundException'; + + return error; +} + +const ServerException = (code: number, body: string): ServerExceptionInterface => { + const error = {} as ServerExceptionInterface; + error.name = 'ServerException'; + error.code = code; + error.body = body; + + return error; +} diff --git a/src/Bundle/ChillMainBundle/Resources/public/lib/api/locations.ts b/src/Bundle/ChillMainBundle/Resources/public/lib/api/locations.ts new file mode 100644 index 000000000..d5ab081c3 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/lib/api/locations.ts @@ -0,0 +1,6 @@ +import {fetchResults} from "./apiMethods"; +import {Location, LocationType} from "../../types"; + +export const getLocations = (): Promise => fetchResults('/api/1.0/main/location.json'); + +export const getLocationTypes = (): Promise => fetchResults('/api/1.0/main/location-type.json'); diff --git a/src/Bundle/ChillMainBundle/Resources/public/lib/api/user.ts b/src/Bundle/ChillMainBundle/Resources/public/lib/api/user.ts new file mode 100644 index 000000000..730e6de66 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/lib/api/user.ts @@ -0,0 +1,25 @@ +import {User} from "../../types"; +import {makeFetch} from "./apiMethods"; + +export const whoami = (): Promise => { + const url = `/api/1.0/main/whoami.json`; + return fetch(url) + .then(response => { + if (response.ok) { + return response.json(); + } + throw { + msg: 'Error while getting whoami.', + sta: response.status, + txt: response.statusText, + err: new Error(), + body: response.body + }; + }); +}; + +export const whereami = (): Promise => { + const url = `/api/1.0/main/user-current-location.json`; + + return makeFetch("GET", url); +} diff --git a/src/Bundle/ChillMainBundle/Resources/public/lib/download-report/download-report.js b/src/Bundle/ChillMainBundle/Resources/public/lib/download-report/download-report.js index c7b7bb59a..041e94c45 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/lib/download-report/download-report.js +++ b/src/Bundle/ChillMainBundle/Resources/public/lib/download-report/download-report.js @@ -1,4 +1,4 @@ -/* +/* * Copyright (C) 2018 Champs Libres Cooperative * * This program is free software: you can redistribute it and/or modify @@ -15,12 +15,12 @@ * along with this program. If not, see . */ -var mime = require('mime-types') +var mime = require('mime') var download_report = (url, container) => { var download_text = container.dataset.downloadText, alias = container.dataset.alias; - + window.fetch(url, { credentials: 'same-origin' }) .then(response => { if (!response.ok) { @@ -29,21 +29,21 @@ var download_report = (url, container) => { return response.blob(); }).then(blob => { - + var content = URL.createObjectURL(blob), link = document.createElement("a"), type = blob.type, hasForcedType = 'mimeType' in container.dataset, extension; - + if (hasForcedType) { // force a type type = container.dataset.mimeType; blob = new Blob([ blob ], { 'type': type }); content = URL.createObjectURL(blob); } - - extension = mime.extension(type); + + extension = mime.getExtension(type); link.appendChild(document.createTextNode(download_text)); link.classList.add("btn", "btn-action"); @@ -56,7 +56,7 @@ var download_report = (url, container) => { container.appendChild(link); }).catch(function(error) { console.log(error); - var problem_text = + var problem_text = document.createTextNode("Problem during download"); container @@ -64,4 +64,4 @@ var download_report = (url, container) => { }); }; -module.exports = download_report; \ No newline at end of file +module.exports = download_report; diff --git a/src/Bundle/ChillMainBundle/Resources/public/module/pick-entity/index.js b/src/Bundle/ChillMainBundle/Resources/public/module/pick-entity/index.js index 511d2126e..334230f20 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/module/pick-entity/index.js +++ b/src/Bundle/ChillMainBundle/Resources/public/module/pick-entity/index.js @@ -6,6 +6,7 @@ import { appMessages } from 'ChillMainAssets/vuejs/PickEntity/i18n'; const i18n = _createI18n(appMessages); let appsOnPage = new Map(); +let appsPerInput = new Map(); function loadDynamicPicker(element) { @@ -78,13 +79,14 @@ function loadDynamicPicker(element) { .mount(el); appsOnPage.set(uniqId, app); + appsPerInput.set(input.name, app); }); } document.addEventListener('show-hide-show', function(e) { loadDynamicPicker(e.detail.container) -}) +}); document.addEventListener('show-hide-hide', function(e) { console.log('hiding event caught') @@ -95,7 +97,23 @@ document.addEventListener('show-hide-hide', function(e) { appsOnPage.delete(uniqId); } }) -}) +}); + +document.addEventListener('pick-entity-type-action', function (e) { + console.log('pick entity event', e); + if (!appsPerInput.has(e.detail.name)) { + console.error('no app with this name'); + return; + } + const app = appsPerInput.get(e.detail.name); + if (e.detail.action === 'add') { + app.addNewEntity(e.detail.entity); + } else if (e.detail.action === 'remove') { + app.removeEntity(e.detail.entity); + } else { + console.error('action not supported: '+e.detail.action); + } +}); document.addEventListener('DOMContentLoaded', function(e) { loadDynamicPicker(document) diff --git a/src/Bundle/ChillMainBundle/Resources/public/types.ts b/src/Bundle/ChillMainBundle/Resources/public/types.ts new file mode 100644 index 000000000..142864393 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/types.ts @@ -0,0 +1,139 @@ +export interface DateTime { + datetime: string; + datetime8601: string +} + +export interface Civility { + id: number; + // TODO +} + +export interface Job { + id: number; + type: "user_job"; + label: { + "fr": string; // could have other key. How to do that in ts ? + } +} + +export interface Center { + id: number; + type: "center"; + name: string; +} + +export interface Scope { + id: number; + type: "scope"; + name: { + "fr": string + } +} + +export interface User { + type: "user"; + id: number; + username: string; + text: string; + email: string; + user_job: Job; + label: string; + // todo: mainCenter; mainJob; etc.. +} + +export interface UserAssociatedInterface { + type: "user"; + id: number; +}; + +export type TranslatableString = { + fr?: string; + nl?: string; +} + +export interface Postcode { + id: number; + name: string; + code: string; + center: Point; +} + +export type Point = { + type: "Point"; + coordinates: [lat: number, lon: number]; +} + +export interface Country { + id: number; + name: TranslatableString; + code: string; +} + +export interface Address { + type: "address"; + address_id: number; + text: string; + street: string; + streetNumber: string; + postcode: Postcode; + country: Country; + floor: string | null; + corridor: string | null; + steps: string | null; + flat: string | null; + buildingName: string | null; + distribution: string | null; + extra: string | null; + confidential: boolean; + lines: string[]; + addressReference: AddressReference | null; + validFrom: DateTime; + validTo: DateTime | null; +} + +export interface AddressReference { + id: number; + createdAt: DateTime | null; + deletedAt: DateTime | null; + municipalityCode: string; + point: Point; + postcode: Postcode; + refId: string; + source: string; + street: string; + streetNumber: string; + updatedAt: DateTime | null; +} + +export interface Location { + type: "location"; + id: number; + active: boolean; + address: Address | null; + availableForUsers: boolean; + createdAt: DateTime | null; + createdBy: User | null; + updatedAt: DateTime | null; + updatedBy: User | null; + email: string | null + name: string; + phonenumber1: string | null; + phonenumber2: string | null; + locationType: LocationType; +} + +export interface LocationAssociated { + type: "location"; + id: number; +} + +export interface LocationType { + type: "location-type"; + id: number; + active: boolean; + addressRequired: "optional" | "required"; + availableForUsers: boolean; + editableByUsers: boolean; + contactData: "optional" | "required"; + title: TranslatableString; +} diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/DatePane.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/DatePane.vue index 6a83cbfe2..eb76604d4 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/DatePane.vue +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/DatePane.vue @@ -54,7 +54,7 @@