diff --git a/.editorconfig b/.editorconfig index c48b9159..d014440d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,5 +7,4 @@ indent_style = space indent_size = 4 [{package.json,.travis.yml}] -indent_style = space indent_size = 2 diff --git a/.env b/.env deleted file mode 100644 index d38b7b00..00000000 --- a/.env +++ /dev/null @@ -1,19 +0,0 @@ -DB_USERNAME=easyapp -DB_NAME=easyapp - -# before deploying to production change to harder password, and don't commit it to git -DB_PASSWORD=veryhardpassword - -# change to your installation address -APP_URL=localhost -APP_HOST=0.0.0.0 -APP_PORT=80 - -# email settings - set to 'smtp' and provide SMTP settings if you want to send emails -EMAIL_PROTOCOL=mail -SMTP_HOST=smtp.gmail.com -SMTP_USER=user -SMTP_PASS=password -SMTP_CRYPTO=ssl -SMTP_PORT=25 - diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..176a458f --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..cb58ca69 --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,58 @@ +# Code of Conduct + +### Our Pledge + +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making +participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, +disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, +religion, or sexual identity and orientation. + +### Our Standards + +Examples of behavior that contributes to creating a positive environment include: + +* Using welcoming and inclusive language. +* Being respectful of differing viewpoints and experiences. +* Gracefully accepting constructive criticism. +* Focusing on what is best for the community. +* Showing empathy towards other community members. + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or advances. +* Trolling, insulting/derogatory comments, and personal or political attacks. +* Public or private harassment. +* Publishing others' private information, such as a physical or electronic address, without explicit permission. +* Other conduct which could reasonably be considered inappropriate in a professional setting. + +### Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take +appropriate and fair corrective action in response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, +issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any +contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +### Scope + +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the +project or its community. Examples of representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed representative at an online or offline +event. Representation of a project may be further defined and clarified by project maintainers. + +### Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project owner at +[alextselegidis@gmail.com](alextselegidis@gmail.com). All complaints will be reviewed and investigated and will result +in a response that is deemed necessary and appropriate to the circumstances. The project owner is obligated to maintain +confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be +posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent +repercussions as determined by other members of the project's leadership. + +### Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.4, +available at http://contributor-covenant.org/version/1/4. diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 00000000..f1baecf0 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,17 @@ +# Contributing + +When contributing to this repository, please first discuss the change you wish to make via issue, email, or any other +method with the owner of this repository before making a change. + +Please note we have a code of conduct, please follow it in all your interactions with the project. + +## Pull Request Process + +1. Ensure any install or build dependencies are removed before the end of the layer when doing a build. +2. Ensure that your changes comply with the project's coding guidelines and that it's sufficiently documented. +3. Update the README.md with details of changes to the interface, this includes new environment variables, exposed + ports, useful file locations and container parameters. +4. Target the develop branch for your Pull Requests as this is were new changes are introduced. +4. After being successfully reviewed pull requests will be merged to develop branch and will finally be included in an + upcoming release. + diff --git a/.gitignore b/.gitignore index a55b9963..e0de0c53 100644 --- a/.gitignore +++ b/.gitignore @@ -1,21 +1,28 @@ /*.zip /.idea/ /.tmp-package/ -/doc/apigen/ -/doc/jsdoc/ -/doc/plato/ +/build/ +/docs/apigen/html +/docs/jsdoc/html +/docs/plato/html +/docker/mysql/* +!/docker/mysql/.gitkeep /node_modules/ /npm-debug.log -/src/config.php -/src/storage/cache/* -!/src/storage/cache/.htaccess -!/src/storage/cache/index.html -/src/storage/logs/* -!/src/storage/logs/.htaccess -!/src/storage/logs/index.html -/src/storage/sessions/* -!/src/storage/sessions/.htaccess -!/src/storage/sessions/index.html -/src/storage/uploads/* -/!src/storage/uploads/index.html +/assets/js/**/*.min.js +/assets/css/**/*.min.css +/config.php +/storage/cache/* +!/storage/cache/.htaccess +!/storage/cache/index.html +/storage/logs/* +!/storage/logs/.htaccess +!/storage/logs/index.html +/storage/sessions/* +!/storage/sessions/.htaccess +!/storage/sessions/index.html +/storage/uploads/* +/!storage/uploads/index.html /vendor/ +/metafile +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 18f56fe2..1a66ca83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,9 @@ developers to maintain and readjust their custom modifications on the main proje The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). -## [1.3.2] +## [1.3.2] - 2018-07-29 -## Fixed +### Fixed - #480: Make the app GDPR - new EU privacy regulations compliant. - #485: Make REST API search check with "q" parameter case insensitive. @@ -17,7 +17,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - #510: Providers should not be able to create appointments for other providers in the backend calendar page. - #512: Only show appointments of the currently logged in provider. -## [1.3.1] +## [1.3.1] - 2018-06-03 ### Added @@ -25,11 +25,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - #441: Added time format selection - #452: Provide more information when errors occur during the installation. -## Changed +### Changed - #494: French translation corrections/improvements. -## Fixed +### Fixed - #433: Selected date when editing an appointment - #436: All days unavailable in agendaDay view @@ -45,7 +45,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - #495: Database migration fixes (from 1.2.1 to 1.3.x). - #497: Backend settings are not being displayed on page load when the user is not an admin. -## [1.3.0] +## [1.3.0] - 2018-02-28 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 9cdfc159..00000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,75 +0,0 @@ -# Contributing - -When contributing to this repository, please first discuss the change you wish to make via issue, email, or any other -method with the owner of this repository before making a change. - -Please note we have a code of conduct, please follow it in all your interactions with the project. - -## Pull Request Process - -1. Ensure any install or build dependencies are removed before the end of the layer when doing a build. -2. Ensure that your changes comply with the project's coding guidelines and that it's sufficiently documented. -3. Update the README.md with details of changes to the interface, this includes new environment variables, exposed - ports, useful file locations and container parameters. -4. Target the develop branch for your Pull Requests as this is were new changes are introduced. -4. After being successfully reviewed pull requests will be merged to develop branch and will finally be included in an - upcoming release. - -## Code of Conduct - -### Our Pledge - -In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making -participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, -disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, -religion, or sexual identity and orientation. - -### Our Standards - -Examples of behavior that contributes to creating a positive environment include: - -* Using welcoming and inclusive language. -* Being respectful of differing viewpoints and experiences. -* Gracefully accepting constructive criticism. -* Focusing on what is best for the community. -* Showing empathy towards other community members. - -Examples of unacceptable behavior by participants include: - -* The use of sexualized language or imagery and unwelcome sexual attention or advances. -* Trolling, insulting/derogatory comments, and personal or political attacks. -* Public or private harassment. -* Publishing others' private information, such as a physical or electronic address, without explicit permission. -* Other conduct which could reasonably be considered inappropriate in a professional setting. - -### Our Responsibilities - -Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take -appropriate and fair corrective action in response to any instances of unacceptable behavior. - -Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, -issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any -contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. - -### Scope - -This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the -project or its community. Examples of representing a project or community include using an official project e-mail -address, posting via an official social media account, or acting as an appointed representative at an online or offline -event. Representation of a project may be further defined and clarified by project maintainers. - -### Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project owner at -[alextselegidis@gmail.com](alextselegidis@gmail.com). All complaints will be reviewed and investigated and will result -in a response that is deemed necessary and appropriate to the circumstances. The project owner is obligated to maintain -confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be -posted separately. - -Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent -repercussions as determined by other members of the project's leadership. - -### Attribution - -This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.4, -available at http://contributor-covenant.org/version/1/4. diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index fd721d9e..00000000 --- a/Dockerfile +++ /dev/null @@ -1,16 +0,0 @@ -FROM php:7.0-apache - -ENV PROJECT_DIR=/var/www/html \ - APP_URL=localhost - -RUN docker-php-ext-install mysqli gettext - -COPY ./src $PROJECT_DIR -COPY docker-entrypoint.sh /entrypoint.sh - -RUN sed -i 's/\r//' /entrypoint.sh - -VOLUME $PROJECT_DIR/storage - -ENTRYPOINT ["/bin/bash", "/entrypoint.sh"] -CMD ["run"] diff --git a/README.md b/README.md index b3851f03..e028d322 100644 --- a/README.md +++ b/README.md @@ -1,79 +1,108 @@ -Easy!Appointments -================ +
+ + + +
-### Features ++ About • + Features • + Setup • + Installation • + License +
-The project was designed to be flexible and reliable so as to be able to meet the needs of any -kind of enterprise. You can read the main features of the system below: +![screenshot](screenshot.png) -* Full customers and appointments management. -* Services and service providers organization. -* Workflow and booking rules. +## About + +**Easy!Appointments** is a highly customizable web application that allows customers to book appointments with you +via a sophisticated web interface. Moreover, it provides the ability to sync your data with Google Calendar so you can +use them with other services. It is an open source project that you can download and install **even for commercial use**. +Easy!Appointments will run smoothly with your existing website as it can be installed in a single folder of the +server and of course share an existing database. + +## Features + +The application is designed to be flexible enough so that it can handle any enterprise work flow. + +* Customers and appointments management. +* Services and providers organization. +* Working plan and booking rules. * Google Calendar synchronization. * Email notifications system. -* Standalone installation (like WordPress, Drupal, Joomla and other web systems). +* Self hosted installation. * Translated user interface. -* User community support. +* User community support. -### Installation +## Setup -Since Easy!Appointments is a web application, it runs on a web server and thus you will need to -perform the following steps in order to install the system on your server: +To clone and run this application, you'll need [Git](https://git-scm.com), [Node.js](https://nodejs.org/en/download/) (which comes with [npm](http://npmjs.com)) and [Composer](https://getcomposer.org) installed on your computer. From your command line: + +```bash +# Clone this repository +$ git clone https://github.com/alextselegidis/easyappointments.git + +# Go into the repository +$ cd easyappointments + +# Install dependencies +$ npm install && composer install + +# Start the file watcher +$ npm start +``` + +Note: If you're using Linux Bash for Windows, [see this guide](https://www.howtogeek.com/261575/how-to-run-graphical-linux-desktop-applications-from-windows-10s-bash-shell/) or use `node` from the command prompt. + +You can build the files by running `npm run build`. This command will bundle everything to a `build` directory. + +## Installation + +You will need to perform the following steps to install the application on your server: * Make sure that your server has Apache/Nginx, PHP and MySQL installed. -* Create a new database (or use an existing). +* Create a new database (or use an existing one). * Copy the "easyappointments" source folder on your server. * Make sure that the "storage" directory is writable. -* Rename the "config-sample.php" file to "config.php" and set your server properties. -* Open your browser on the Easy!Appointments URL and follow the installation guide. -* That's it! You can now use Easy!Appointments at your will. +* Rename the "config-sample.php" file to "config.php" and update its contents based on your environment. +* Open the browser on the Easy!Appointments URL and follow the installation guide. -You will find the latest release at [easyappointments.org](http://easyappointments.org). -If you have problems installing or configuring the application take a look on the -[wiki pages](https://github.com/alextselegidis/easyappointments/wiki) or visit the +That's it! You can now use Easy!Appointments at your will. + +You will find the latest release at [easyappointments.org](https://easyappointments.org). +If you have problems installing or configuring the application visit the [official support group](https://groups.google.com/forum/#!forum/easy-appointments). You can also report problems on the [issues page](https://github.com/alextselegidis/easyappointments/issues) and help the development progress. -### Docker -To start Easy!Appointments using Docker in development configuration, with source files mounted into container, run: -``` -docker-compose up -``` +## License -Production deployment can be made by changing required values in .env file (DB_PASSWORD, APP_URL, APP_PORT) and running: -``` -docker-compose -f docker-compose.prod.yml up -d -``` +Code Licensed Under [GPL v3.0](https://www.gnu.org/licenses/gpl-3.0.en.html) | Content Under [CC BY 3.0](https://creativecommons.org/licenses/by/3.0/) -Database data will be stored in named volume `easyappointments_easy-appointments-data`, and app storage (logs, cache, uploads) in `easyappointments_easy-appointments-storage`. -To find where exactly they are stored, you can run -``` -docker volume inspect easyappointments_easy-appointments-storage -``` +--- -Production containers will automatically be restarted in case of crash / server reboot. For more info, take a look into `docker-compose.prod.yml` file. +Website [alextselegidis.com](https://alextselegidis.com) · +GitHub [alextselegidis](https://github.com/alextselegidis) · +Twitter [@alextselegidis](https://twitter.com/AlexTselegidis) -### User Feedback - -Whether it is new ideas or defects, your feedback is highly appreciated and will be taken into -consideration for the following releases of the project. Share your experience and discuss your -thoughts with other users through communities. Create issues with suggestions on new features or -bug reports. - -### Translate Easy!Appointments - -As of version 1.0 Easy!Appointments supports translated user interface. If you want to contribute to the -translation process read the [get involved](https://github.com/alextselegidis/easyappointments/blob/master/doc/get-involved.md) -page for additional information. +###### More Projects On Github +###### ⇾ [Plainpad · Self Hosted Note Taking App](https://github.com/alextselegidis/plainpad) +###### ⇾ [Questionful · Web Questionnaires Made Easy](https://github.com/alextselegidis/questionful) +###### ⇾ [Integravy · Service Orchestration At Your Fingertips](https://github.com/alextselegidis/integravy) diff --git a/src/application/.htaccess b/application/.htaccess similarity index 100% rename from src/application/.htaccess rename to application/.htaccess diff --git a/application/config/autoload.php b/application/config/autoload.php new file mode 100644 index 00000000..1d600e7d --- /dev/null +++ b/application/config/autoload.php @@ -0,0 +1,117 @@ + 'arabic', + 'bu' => 'bulgarian', + 'zh' => 'chinese', + 'da' => 'danish', + 'nl' => 'dutch', + 'en' => 'english', + 'fi' => 'finnish', + 'fr' => 'french', + 'de' => 'german', + 'el' => 'greek', + 'hi' => 'hindi', + 'hu' => 'hungarian', + 'it' => 'italian', + 'ja' => 'japanese', + 'pl' => 'polish', + 'pt' => 'portuguese', + 'ro' => 'romanian', + 'ru' => 'russian', + 'sk' => 'slovak', + 'es' => 'spanish', + 'tr' => 'turkish', + 'sv' => 'swedish' + ][substr($_SERVER['HTTP_ACCEPT_LANGUAGE'], 0, 2)] + : Config::LANGUAGE; + +/* +|-------------------------------------------------------------------------- +| Available Languages +|-------------------------------------------------------------------------- +| +| Each item of this array must be a directory with the translation files in +| the /application/language directory. The users will be able to select one +| of these languages. +| +*/ +$config['available_languages'] = [ + 'arabic', + 'bulgarian', + 'catalan', + 'chinese', + 'czech', + 'danish', + 'dutch', + 'english', + 'finnish', + 'french', + 'german', + 'greek', + 'hindi', + 'hungarian', + 'italian', + 'japanese', + 'marathi', + 'luxembourgish', + 'polish', + 'portuguese', + 'portuguese-br', + 'romanian', + 'russian', + 'slovak', + 'spanish', + 'turkish' +]; + +/* +|-------------------------------------------------------------------------- +| Default Character Set +|-------------------------------------------------------------------------- +| +| This determines which character set is used by default in various methods +| that require a character set to be provided. +| +*/ +$config['charset'] = 'UTF-8'; + +/* +|-------------------------------------------------------------------------- +| Enable/Disable System Hooks +|-------------------------------------------------------------------------- +| +| If you would like to use the 'hooks' feature you must enable it by +| setting this variable to TRUE (boolean). See the user guide for details. +| +*/ +$config['enable_hooks'] = TRUE; + + +/* +|-------------------------------------------------------------------------- +| Class Extension Prefix +|-------------------------------------------------------------------------- +| +| This item allows you to set the filename/classname prefix when extending +| native libraries. For more information please see the user guide: +| +| http://codeigniter.com/user_guide/general/core_classes.html +| http://codeigniter.com/user_guide/general/creating_libraries.html +| +*/ +$config['subclass_prefix'] = 'MY_'; + + +/* +|-------------------------------------------------------------------------- +| Allowed URL Characters +|-------------------------------------------------------------------------- +| +| This lets you specify with a regular expression which characters are permitted +| within your URLs. When someone tries to submit a URL with disallowed +| characters they will get a warning message. +| +| As a security measure you are STRONGLY encouraged to restrict URLs to +| as few characters as possible. By default only these are allowed: a-z 0-9~%.:_- +| +| Leave blank to allow all characters -- but only if you are insane. +| +| DO NOT CHANGE THIS UNLESS YOU FULLY UNDERSTAND THE REPERCUSSIONS!! +| +*/ +$config['permitted_uri_chars'] = 'a-z 0-9~%.:_\-'; + + +/* +|-------------------------------------------------------------------------- +| Enable Query Strings +|-------------------------------------------------------------------------- +| +| By default CodeIgniter uses search-engine friendly segment based URLs: +| example.com/who/what/where/ +| +| By default CodeIgniter enables access to the $_GET array. If for some +| reason you would like to disable it, set 'allow_get_array' to FALSE. +| +| You can optionally enable standard query string based URLs: +| example.com?who=me&what=something&where=here +| +| Options are: TRUE or FALSE (boolean) +| +| The other items let you set the query string 'words' that will +| invoke your controllers and its functions: +| example.com/index.php?c=controller&m=function +| +| Please note that some of the helpers won't work as expected when +| this feature is enabled, since CodeIgniter is designed primarily to +| use segment based URLs. +| +*/ +$config['allow_get_array'] = TRUE; +$config['enable_query_strings'] = FALSE; +$config['controller_trigger'] = 'c'; +$config['function_trigger'] = 'm'; +$config['directory_trigger'] = 'd'; // experimental not currently in use + +/* +|-------------------------------------------------------------------------- +| Error Logging Threshold +|-------------------------------------------------------------------------- +| +| If you have enabled error logging, you can set an error threshold to +| determine what gets logged. Threshold options are: +| You can enable error logging by setting a threshold over zero. The +| threshold determines what gets logged. Threshold options are: +| +| 0 = Disables logging, Error logging TURNED OFF +| 1 = Error Messages (including PHP errors) +| 2 = Debug Messages +| 3 = Informational Messages +| 4 = All Messages +| +| For a live site you'll usually only enable Errors (1) to be logged otherwise +| your log files will fill up very fast. +| +*/ +$config['log_threshold'] = 1; + +/* +|-------------------------------------------------------------------------- +| Error Logging Directory Path +|-------------------------------------------------------------------------- +| +| Leave this BLANK unless you would like to set something other than the default +| application/logs/ folder. Use a full server path with trailing slash. +| +*/ +$config['log_path'] = __DIR__ . '/../../storage/logs/'; + +/* +|-------------------------------------------------------------------------- +| Date Format for Logs +|-------------------------------------------------------------------------- +| +| Each item that is logged has an associated date. You can use PHP date +| codes to set your own date formatting +| +*/ +$config['log_date_format'] = 'Y-m-d H:i:s'; + +/* +|-------------------------------------------------------------------------- +| Cache Directory Path +|-------------------------------------------------------------------------- +| +| Leave this BLANK unless you would like to set something other than the default +| system/cache/ folder. Use a full server path with trailing slash. +| +*/ +$config['cache_path'] = __DIR__ . '/../../storage/cache/'; + +/* +|-------------------------------------------------------------------------- +| Cache Busting Token +|-------------------------------------------------------------------------- +| +| This token will be appending to asset URLs in order to invalidate the browser +| cache and enforce end clients to fetch new files. Update the token with each +| new release. +| +*/ +$config['cache_busting_token'] = '93GX4'; + +/* +|-------------------------------------------------------------------------- +| Encryption Key +|-------------------------------------------------------------------------- +| +| If you use the Encryption class or the Session class you +| MUST set an encryption key. See the user guide for info. +| +*/ +$config['encryption_key'] = Config::BASE_URL; + +/* +|-------------------------------------------------------------------------- +| Session Variables +|-------------------------------------------------------------------------- +| +| 'sess_cookie_name' = the name you want for the cookie +| 'sess_expiration' = the number of SECONDS you want the session to last. +| by default sessions last 7200 seconds (two hours). Set to zero for no expiration. +| 'sess_expire_on_close' = Whether to cause the session to expire automatically +| when the browser window is closed +| 'sess_encrypt_cookie' = Whether to encrypt the cookie +| 'sess_use_database' = Whether to save the session data to a database +| 'sess_table_name' = The name of the session database table +| 'sess_match_ip' = Whether to match the user's IP address when reading the session data +| 'sess_match_useragent' = Whether to match the User Agent when reading the session data +| 'sess_time_to_update' = how many seconds between CI refreshing Session Information +| +*/ +$config['sess_driver'] = 'files'; +$config['sess_cookie_name'] = 'ea_session'; +$config['sess_expiration'] = 7200; +$config['sess_save_path'] = __DIR__ . '/../../storage/sessions'; +$config['sess_match_ip'] = FALSE; +$config['sess_time_to_update'] = 300; +$config['sess_regenerate_destroy'] = FALSE; + +/* +|-------------------------------------------------------------------------- +| Cookie Related Variables +|-------------------------------------------------------------------------- +| +| 'cookie_prefix' = Set a prefix if you need to avoid collisions +| 'cookie_domain' = Set to .your-domain.com for site-wide cookies +| 'cookie_path' = Typically will be a forward slash +| 'cookie_secure' = Cookies will only be set if a secure HTTPS connection exists. +| +*/ +$config['cookie_prefix'] = ""; +$config['cookie_domain'] = ""; +$config['cookie_path'] = "/"; +$config['cookie_secure'] = FALSE; + +/* +|-------------------------------------------------------------------------- +| Global XSS Filtering +|-------------------------------------------------------------------------- +| +| Determines whether the XSS filter is always active when GET, POST or +| COOKIE data is encountered +| +*/ +$config['global_xss_filtering'] = TRUE; + +/* +|-------------------------------------------------------------------------- +| Cross Site Request Forgery +|-------------------------------------------------------------------------- +| Enables a CSRF cookie token to be set. When set to TRUE, token will be +| checked on a submitted form. If you are accepting user data, it is strongly +| recommended CSRF protection be enabled. +| +| 'csrf_token_name' = The token name +| 'csrf_cookie_name' = The cookie name +| 'csrf_expire' = The number in seconds the token should expire. +*/ +$config['csrf_protection'] = TRUE; +$config['csrf_token_name'] = 'csrfToken'; +$config['csrf_cookie_name'] = 'csrfCookie'; +$config['csrf_expire'] = 7200; +$config['csrf_exclude_uris'] = ['api/v1/.*']; + +/* +|-------------------------------------------------------------------------- +| Output Compression +|-------------------------------------------------------------------------- +| +| Enables Gzip output compression for faster page loads. When enabled, +| the output class will test whether your server supports Gzip. +| Even if it does, however, not all browsers support compression +| so enable only if you are reasonably sure your visitors can handle it. +| +| VERY IMPORTANT: If you are getting a blank page when compression is enabled it +| means you are prematurely outputting something to your browser. It could +| even be a line of whitespace at the end of one of your scripts. For +| compression to work, nothing can be sent before the output buffer is called +| by the output class. Do not 'echo' any values with compression enabled. +| +*/ +$config['compress_output'] = FALSE; + +/* +|-------------------------------------------------------------------------- +| Master Time Reference +|-------------------------------------------------------------------------- +| +| Options are 'local' or 'gmt'. This pref tells the system whether to use +| your server's local time as the master 'now' reference, or convert it to +| GMT. See the 'date helper' page of the user guide for information +| regarding date handling. +| +*/ +$config['time_reference'] = 'local'; + + +/* +|-------------------------------------------------------------------------- +| Rewrite PHP Short Tags +|-------------------------------------------------------------------------- +| +| If your PHP installation does not have short tag support enabled CI +| can rewrite the tags on-the-fly, enabling you to utilize that syntax +| in your view files. Options are TRUE or FALSE (boolean) +| +*/ +$config['rewrite_short_tags'] = FALSE; + + +/* +|-------------------------------------------------------------------------- +| Reverse Proxy IPs +|-------------------------------------------------------------------------- +| +| If your server is behind a reverse proxy, you must whitelist the proxy IP +| addresses from which CodeIgniter should trust the HTTP_X_FORWARDED_FOR +| header in order to properly identify the visitor's IP address. +| Comma-delimited, e.g. '10.0.1.200,10.0.1.201' +| +*/ +$config['proxy_ips'] = ''; + + +/* End of file config.php */ +/* Location: ./application/config/config.php */ diff --git a/application/config/constants.php b/application/config/constants.php new file mode 100644 index 00000000..6e42bd86 --- /dev/null +++ b/application/config/constants.php @@ -0,0 +1,93 @@ +migration->latest() this is the version that schema will +| be upgraded / downgraded to. +| +*/ +$config['migration_version'] = 20; + + +/* +|-------------------------------------------------------------------------- +| Migrations Path +|-------------------------------------------------------------------------- +| +| Path to your migrations folder. +| Typically, it will be within your application path. +| Also, writing permission is required within the migrations path. +| +*/ +$config['migration_path'] = APPPATH . 'migrations/'; + + +/* End of file migration.php */ +/* Location: ./application/config/migration.php */ diff --git a/application/config/mimes.php b/application/config/mimes.php new file mode 100644 index 00000000..0ec9db0a --- /dev/null +++ b/application/config/mimes.php @@ -0,0 +1,184 @@ + array('application/mac-binhex40', 'application/mac-binhex', 'application/x-binhex40', 'application/x-mac-binhex40'), + 'cpt' => 'application/mac-compactpro', + 'csv' => array('text/x-comma-separated-values', 'text/comma-separated-values', 'application/octet-stream', 'application/vnd.ms-excel', 'application/x-csv', 'text/x-csv', 'text/csv', 'application/csv', 'application/excel', 'application/vnd.msexcel', 'text/plain'), + 'bin' => array('application/macbinary', 'application/mac-binary', 'application/octet-stream', 'application/x-binary', 'application/x-macbinary'), + 'dms' => 'application/octet-stream', + 'lha' => 'application/octet-stream', + 'lzh' => 'application/octet-stream', + 'exe' => array('application/octet-stream', 'application/x-msdownload'), + 'class' => 'application/octet-stream', + 'psd' => array('application/x-photoshop', 'image/vnd.adobe.photoshop'), + 'so' => 'application/octet-stream', + 'sea' => 'application/octet-stream', + 'dll' => 'application/octet-stream', + 'oda' => 'application/oda', + 'pdf' => array('application/pdf', 'application/force-download', 'application/x-download', 'binary/octet-stream'), + 'ai' => array('application/pdf', 'application/postscript'), + 'eps' => 'application/postscript', + 'ps' => 'application/postscript', + 'smi' => 'application/smil', + 'smil' => 'application/smil', + 'mif' => 'application/vnd.mif', + 'xls' => array('application/vnd.ms-excel', 'application/msexcel', 'application/x-msexcel', 'application/x-ms-excel', 'application/x-excel', 'application/x-dos_ms_excel', 'application/xls', 'application/x-xls', 'application/excel', 'application/download', 'application/vnd.ms-office', 'application/msword'), + 'ppt' => array('application/powerpoint', 'application/vnd.ms-powerpoint', 'application/vnd.ms-office', 'application/msword'), + 'pptx' => array('application/vnd.openxmlformats-officedocument.presentationml.presentation', 'application/x-zip', 'application/zip'), + 'wbxml' => 'application/wbxml', + 'wmlc' => 'application/wmlc', + 'dcr' => 'application/x-director', + 'dir' => 'application/x-director', + 'dxr' => 'application/x-director', + 'dvi' => 'application/x-dvi', + 'gtar' => 'application/x-gtar', + 'gz' => 'application/x-gzip', + 'gzip' => 'application/x-gzip', + 'php' => array('application/x-httpd-php', 'application/php', 'application/x-php', 'text/php', 'text/x-php', 'application/x-httpd-php-source'), + 'php4' => 'application/x-httpd-php', + 'php3' => 'application/x-httpd-php', + 'phtml' => 'application/x-httpd-php', + 'phps' => 'application/x-httpd-php-source', + 'js' => array('application/x-javascript', 'text/plain'), + 'swf' => 'application/x-shockwave-flash', + 'sit' => 'application/x-stuffit', + 'tar' => 'application/x-tar', + 'tgz' => array('application/x-tar', 'application/x-gzip-compressed'), + 'z' => 'application/x-compress', + 'xhtml' => 'application/xhtml+xml', + 'xht' => 'application/xhtml+xml', + 'zip' => array('application/x-zip', 'application/zip', 'application/x-zip-compressed', 'application/s-compressed', 'multipart/x-zip'), + 'rar' => array('application/x-rar', 'application/rar', 'application/x-rar-compressed'), + 'mid' => 'audio/midi', + 'midi' => 'audio/midi', + 'mpga' => 'audio/mpeg', + 'mp2' => 'audio/mpeg', + 'mp3' => array('audio/mpeg', 'audio/mpg', 'audio/mpeg3', 'audio/mp3'), + 'aif' => array('audio/x-aiff', 'audio/aiff'), + 'aiff' => array('audio/x-aiff', 'audio/aiff'), + 'aifc' => 'audio/x-aiff', + 'ram' => 'audio/x-pn-realaudio', + 'rm' => 'audio/x-pn-realaudio', + 'rpm' => 'audio/x-pn-realaudio-plugin', + 'ra' => 'audio/x-realaudio', + 'rv' => 'video/vnd.rn-realvideo', + 'wav' => array('audio/x-wav', 'audio/wave', 'audio/wav'), + 'bmp' => array('image/bmp', 'image/x-bmp', 'image/x-bitmap', 'image/x-xbitmap', 'image/x-win-bitmap', 'image/x-windows-bmp', 'image/ms-bmp', 'image/x-ms-bmp', 'application/bmp', 'application/x-bmp', 'application/x-win-bitmap'), + 'gif' => 'image/gif', + 'jpeg' => array('image/jpeg', 'image/pjpeg'), + 'jpg' => array('image/jpeg', 'image/pjpeg'), + 'jpe' => array('image/jpeg', 'image/pjpeg'), + 'jp2' => array('image/jp2', 'video/mj2', 'image/jpx', 'image/jpm'), + 'j2k' => array('image/jp2', 'video/mj2', 'image/jpx', 'image/jpm'), + 'jpf' => array('image/jp2', 'video/mj2', 'image/jpx', 'image/jpm'), + 'jpg2' => array('image/jp2', 'video/mj2', 'image/jpx', 'image/jpm'), + 'jpx' => array('image/jp2', 'video/mj2', 'image/jpx', 'image/jpm'), + 'jpm' => array('image/jp2', 'video/mj2', 'image/jpx', 'image/jpm'), + 'mj2' => array('image/jp2', 'video/mj2', 'image/jpx', 'image/jpm'), + 'mjp2' => array('image/jp2', 'video/mj2', 'image/jpx', 'image/jpm'), + 'png' => array('image/png', 'image/x-png'), + 'tiff' => 'image/tiff', + 'tif' => 'image/tiff', + 'css' => array('text/css', 'text/plain'), + 'html' => array('text/html', 'text/plain'), + 'htm' => array('text/html', 'text/plain'), + 'shtml' => array('text/html', 'text/plain'), + 'txt' => 'text/plain', + 'text' => 'text/plain', + 'log' => array('text/plain', 'text/x-log'), + 'rtx' => 'text/richtext', + 'rtf' => 'text/rtf', + 'xml' => array('application/xml', 'text/xml', 'text/plain'), + 'xsl' => array('application/xml', 'text/xsl', 'text/xml'), + 'mpeg' => 'video/mpeg', + 'mpg' => 'video/mpeg', + 'mpe' => 'video/mpeg', + 'qt' => 'video/quicktime', + 'mov' => 'video/quicktime', + 'avi' => array('video/x-msvideo', 'video/msvideo', 'video/avi', 'application/x-troff-msvideo'), + 'movie' => 'video/x-sgi-movie', + 'doc' => array('application/msword', 'application/vnd.ms-office'), + 'docx' => array('application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/zip', 'application/msword', 'application/x-zip'), + 'dot' => array('application/msword', 'application/vnd.ms-office'), + 'dotx' => array('application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/zip', 'application/msword'), + 'xlsx' => array('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/zip', 'application/vnd.ms-excel', 'application/msword', 'application/x-zip'), + 'word' => array('application/msword', 'application/octet-stream'), + 'xl' => 'application/excel', + 'eml' => 'message/rfc822', + 'json' => array('application/json', 'text/json'), + 'pem' => array('application/x-x509-user-cert', 'application/x-pem-file', 'application/octet-stream'), + 'p10' => array('application/x-pkcs10', 'application/pkcs10'), + 'p12' => 'application/x-pkcs12', + 'p7a' => 'application/x-pkcs7-signature', + 'p7c' => array('application/pkcs7-mime', 'application/x-pkcs7-mime'), + 'p7m' => array('application/pkcs7-mime', 'application/x-pkcs7-mime'), + 'p7r' => 'application/x-pkcs7-certreqresp', + 'p7s' => 'application/pkcs7-signature', + 'crt' => array('application/x-x509-ca-cert', 'application/x-x509-user-cert', 'application/pkix-cert'), + 'crl' => array('application/pkix-crl', 'application/pkcs-crl'), + 'der' => 'application/x-x509-ca-cert', + 'kdb' => 'application/octet-stream', + 'pgp' => 'application/pgp', + 'gpg' => 'application/gpg-keys', + 'sst' => 'application/octet-stream', + 'csr' => 'application/octet-stream', + 'rsa' => 'application/x-pkcs7', + 'cer' => array('application/pkix-cert', 'application/x-x509-ca-cert'), + '3g2' => 'video/3gpp2', + '3gp' => array('video/3gp', 'video/3gpp'), + 'mp4' => 'video/mp4', + 'm4a' => 'audio/x-m4a', + 'f4v' => array('video/mp4', 'video/x-f4v'), + 'flv' => 'video/x-flv', + 'webm' => 'video/webm', + 'aac' => 'audio/x-acc', + 'm4u' => 'application/vnd.mpegurl', + 'm3u' => 'text/plain', + 'xspf' => 'application/xspf+xml', + 'vlc' => 'application/videolan', + 'wmv' => array('video/x-ms-wmv', 'video/x-ms-asf'), + 'au' => 'audio/x-au', + 'ac3' => 'audio/ac3', + 'flac' => 'audio/x-flac', + 'ogg' => array('audio/ogg', 'video/ogg', 'application/ogg'), + 'kmz' => array('application/vnd.google-earth.kmz', 'application/zip', 'application/x-zip'), + 'kml' => array('application/vnd.google-earth.kml+xml', 'application/xml', 'text/xml'), + 'ics' => 'text/calendar', + 'ical' => 'text/calendar', + 'zsh' => 'text/x-scriptzsh', + '7z' => array('application/x-7z-compressed', 'application/x-compressed', 'application/x-zip-compressed', 'application/zip', 'multipart/x-zip'), + '7zip' => array('application/x-7z-compressed', 'application/x-compressed', 'application/x-zip-compressed', 'application/zip', 'multipart/x-zip'), + 'cdr' => array('application/cdr', 'application/coreldraw', 'application/x-cdr', 'application/x-coreldraw', 'image/cdr', 'image/x-cdr', 'zz-application/zz-winassoc-cdr'), + 'wma' => array('audio/x-ms-wma', 'video/x-ms-asf'), + 'jar' => array('application/java-archive', 'application/x-java-application', 'application/x-jar', 'application/x-compressed'), + 'svg' => array('image/svg+xml', 'application/xml', 'text/xml'), + 'vcf' => 'text/x-vcard', + 'srt' => array('text/srt', 'text/plain'), + 'vtt' => array('text/vtt', 'text/plain'), + 'ico' => array('image/x-icon', 'image/x-ico', 'image/vnd.microsoft.icon'), + 'odc' => 'application/vnd.oasis.opendocument.chart', + 'otc' => 'application/vnd.oasis.opendocument.chart-template', + 'odf' => 'application/vnd.oasis.opendocument.formula', + 'otf' => 'application/vnd.oasis.opendocument.formula-template', + 'odg' => 'application/vnd.oasis.opendocument.graphics', + 'otg' => 'application/vnd.oasis.opendocument.graphics-template', + 'odi' => 'application/vnd.oasis.opendocument.image', + 'oti' => 'application/vnd.oasis.opendocument.image-template', + 'odp' => 'application/vnd.oasis.opendocument.presentation', + 'otp' => 'application/vnd.oasis.opendocument.presentation-template', + 'ods' => 'application/vnd.oasis.opendocument.spreadsheet', + 'ots' => 'application/vnd.oasis.opendocument.spreadsheet-template', + 'odt' => 'application/vnd.oasis.opendocument.text', + 'odm' => 'application/vnd.oasis.opendocument.text-master', + 'ott' => 'application/vnd.oasis.opendocument.text-template', + 'oth' => 'application/vnd.oasis.opendocument.text-web' +); diff --git a/src/application/config/profiler.php b/application/config/profiler.php similarity index 100% rename from src/application/config/profiler.php rename to application/config/profiler.php diff --git a/application/config/routes.php b/application/config/routes.php new file mode 100644 index 00000000..59c824cf --- /dev/null +++ b/application/config/routes.php @@ -0,0 +1,84 @@ + + * @copyright Copyright (c) 2013 - 2020, Alex Tselegidis + * @license http://opensource.org/licenses/GPL-3.0 - GPLv3 + * @link http://easyappointments.org + * @since v1.0.0 + * ---------------------------------------------------------------------------- */ + +use EA\Engine\Notifications\Email as EmailClient; +use EA\Engine\Types\Email; +use EA\Engine\Types\Text; +use EA\Engine\Types\Url; + +/** + * Appointments Controller + * + * @property CI_Session $session + * @property CI_Loader $load + * @property CI_Input $input + * @property CI_Output $output + * @property CI_Config $config + * @property CI_Lang $lang + * @property CI_Cache $cache + * @property CI_DB_query_builder $db + * @property CI_Security $security + * @property Google_Sync $google_sync + * @property Ics_file $ics_file + * @property Appointments_Model $appointments_model + * @property Providers_Model $providers_model + * @property Services_Model $services_model + * @property Customers_Model $customers_model + * @property Settings_Model $settings_model + * @property Timezones $timezones + * @property Roles_Model $roles_model + * @property Secretaries_Model $secretaries_model + * @property Admins_Model $admins_model + * @property User_Model $user_model + * + * @package Controllers + */ +class Appointments extends CI_Controller { + /** + * Class Constructor + */ + public function __construct() + { + parent::__construct(); + + $this->load->library('session'); + $this->load->helper('installation'); + $this->load->helper('google_analytics'); + $this->load->model('appointments_model'); + $this->load->model('providers_model'); + $this->load->model('admins_model'); + $this->load->model('secretaries_model'); + $this->load->model('services_model'); + $this->load->model('customers_model'); + $this->load->model('settings_model'); + $this->load->library('timezones'); + + if ($this->session->userdata('language')) + { + // Set the user's selected language. + $this->config->set_item('language', $this->session->userdata('language')); + $this->lang->load('translations', $this->session->userdata('language')); + } + else + { + // Set the default language. + $this->lang->load('translations', $this->config->item('language')); + } + } + + /** + * Default callback method of the application. + * + * This method creates the appointment book wizard. If an appointment hash is provided then it means that the + * customer followed the appointment manage link that was send with the book success email. + * + * @param string $appointment_hash The appointment hash identifier. + */ + public function index($appointment_hash = '') + { + try + { + if ( ! is_app_installed()) + { + redirect('installation/index'); + return; + } + + $available_services = $this->services_model->get_available_services(); + $available_providers = $this->providers_model->get_available_providers(); + $company_name = $this->settings_model->get_setting('company_name'); + $book_advance_timeout = $this->settings_model->get_setting('book_advance_timeout'); + $date_format = $this->settings_model->get_setting('date_format'); + $time_format = $this->settings_model->get_setting('time_format'); + $first_weekday = $this->settings_model->get_setting('first_weekday'); + $require_phone_number = $this->settings_model->get_setting('require_phone_number'); + $display_cookie_notice = $this->settings_model->get_setting('display_cookie_notice'); + $cookie_notice_content = $this->settings_model->get_setting('cookie_notice_content'); + $display_terms_and_conditions = $this->settings_model->get_setting('display_terms_and_conditions'); + $terms_and_conditions_content = $this->settings_model->get_setting('terms_and_conditions_content'); + $display_privacy_policy = $this->settings_model->get_setting('display_privacy_policy'); + $privacy_policy_content = $this->settings_model->get_setting('privacy_policy_content'); + $display_any_provider = $this->settings_model->get_setting('display_any_provider'); + $timezones = $this->timezones->to_array(); + + // Remove the data that are not needed inside the $available_providers array. + foreach ($available_providers as $index => $provider) + { + $stripped_data = [ + 'id' => $provider['id'], + 'first_name' => $provider['first_name'], + 'last_name' => $provider['last_name'], + 'services' => $provider['services'], + 'timezone' => $provider['timezone'] + ]; + $available_providers[$index] = $stripped_data; + } + + // If an appointment hash is provided then it means that the customer is trying to edit a registered + // appointment record. + if ($appointment_hash !== '') + { + // Load the appointments data and enable the manage mode of the page. + $manage_mode = TRUE; + + $results = $this->appointments_model->get_batch(['hash' => $appointment_hash]); + + if (empty($results)) + { + // The requested appointment doesn't exist in the database. Display a message to the customer. + $variables = [ + 'message_title' => lang('appointment_not_found'), + 'message_text' => lang('appointment_does_not_exist_in_db'), + 'message_icon' => base_url('assets/img/error.png') + ]; + + $this->load->view('appointments/message', $variables); + + return; + } + + // If the requested apppointment begin date is lower than book_advance_timeout. Display + // a message to the customer. + $startDate = strtotime($results[0]['start_datetime']); + $limit = strtotime('+' . $book_advance_timeout . ' minutes', strtotime('now')); + + if ($startDate < $limit) + { + $hours = floor($book_advance_timeout / 60); + $minutes = ($book_advance_timeout % 60); + + $view = [ + 'message_title' => $this->lang->line('appointment_locked'), + 'message_text' => strtr($this->lang->line('appointment_locked_message'), [ + '{$limit}' => sprintf('%02d:%02d', $hours, $minutes) + ]), + 'message_icon' => base_url('assets/img/error.png') + ]; + $this->load->view('appointments/message', $view); + return; + } + + $appointment = $results[0]; + $provider = $this->providers_model->get_row($appointment['id_users_provider']); + $customer = $this->customers_model->get_row($appointment['id_users_customer']); + + $customer_token = md5(uniqid(mt_rand(), TRUE)); + + $this->load->driver('cache', ['adapter' => 'file']); + // Save the token for 10 minutes. + $this->cache->save('customer-token-' . $customer_token, $customer['id'], 600); + } + else + { + // The customer is going to book a new appointment so there is no need for the manage functionality to + // be initialized. + $manage_mode = FALSE; + $customer_token = FALSE; + $appointment = []; + $provider = []; + $customer = []; + } + + // Load the book appointment view. + $variables = [ + 'available_services' => $available_services, + 'available_providers' => $available_providers, + 'company_name' => $company_name, + 'manage_mode' => $manage_mode, + 'customer_token' => $customer_token, + 'date_format' => $date_format, + 'time_format' => $time_format, + 'first_weekday' => $first_weekday, + 'require_phone_number' => $require_phone_number, + 'appointment_data' => $appointment, + 'provider_data' => $provider, + 'customer_data' => $customer, + 'display_cookie_notice' => $display_cookie_notice, + 'cookie_notice_content' => $cookie_notice_content, + 'display_terms_and_conditions' => $display_terms_and_conditions, + 'terms_and_conditions_content' => $terms_and_conditions_content, + 'display_privacy_policy' => $display_privacy_policy, + 'privacy_policy_content' => $privacy_policy_content, + 'timezones' => $timezones, + 'display_any_provider' => $display_any_provider, + ]; + } + catch (Exception $exception) + { + $variables['exceptions'][] = $exception; + } + + $this->load->view('appointments/book', $variables); + } + + /** + * Cancel an existing appointment. + * + * This method removes an appointment from the company's schedule. In order for the appointment to be deleted, the + * hash string must be provided. The customer can only cancel the appointment if the edit time period is not over + * yet. + * + * @param string $appointment_hash This appointment hash identifier. + */ + public function cancel($appointment_hash) + { + try + { + // Check whether the appointment hash exists in the database. + $appointments = $this->appointments_model->get_batch(['hash' => $appointment_hash]); + + if (empty($appointments)) + { + throw new Exception('No record matches the provided hash.'); + } + + $appointment = $appointments[0]; + $provider = $this->providers_model->get_row($appointment['id_users_provider']); + $customer = $this->customers_model->get_row($appointment['id_users_customer']); + $service = $this->services_model->get_row($appointment['id_services']); + + $settings = [ + 'company_name' => $this->settings_model->get_setting('company_name'), + 'company_email' => $this->settings_model->get_setting('company_email'), + 'company_link' => $this->settings_model->get_setting('company_link'), + 'date_format' => $this->settings_model->get_setting('date_format'), + 'time_format' => $this->settings_model->get_setting('time_format') + ]; + + // Remove the appointment record from the data. + if ( ! $this->appointments_model->delete($appointment['id'])) + { + throw new Exception('Appointment could not be deleted from the database.'); + } + + // Remove the appointment from Google Calendar if needed. + if ($appointment['id_google_calendar'] != NULL) + { + try + { + $google_sync = filter_var( + $this->providers_model->get_setting('google_sync', $appointment['id_users_provider']), + FILTER_VALIDATE_BOOLEAN); + + if ($google_sync === TRUE) + { + $google_token = json_decode($this->providers_model + ->get_setting('google_token', $provider['id'])); + $this->load->library('Google_sync'); + $this->google_sync->refresh_token($google_token->refresh_token); + $this->google_sync->delete_appointment($provider, $appointment['id_google_calendar']); + } + } + catch (Exception $exception) + { + $exceptions[] = $exception; + } + } + + // Send email notification to customer and provider. + try + { + $email = new EmailClient($this, $this->config->config); + + $send_provider = filter_var($this->providers_model + ->get_setting('notifications', $provider['id']), + FILTER_VALIDATE_BOOLEAN); + + if ($send_provider === TRUE) + { + $email->sendDeleteAppointment($appointment, $provider, + $service, $customer, $settings, new Email($provider['email']), + new Text($this->input->post('cancel_reason'))); + } + + $send_customer = filter_var( + $this->settings_model->get_setting('customer_notifications'), + FILTER_VALIDATE_BOOLEAN); + + if ($send_customer === TRUE) + { + $email->sendDeleteAppointment($appointment, $provider, + $service, $customer, $settings, new Email($customer['email']), + new Text($this->input->post('cancel_reason'))); + } + + // Notify admins + $admins = $this->admins_model->get_batch(); + + foreach($admins as $admin) + { + if (!$admin['settings']['notifications'] === '0') + { + continue; + } + + $email->sendDeleteAppointment($appointment, $provider, + $service, $customer, $settings, new Email($admin['email']), + new Text($this->input->post('cancel_reason'))); + } + + // Notify secretaries + $secretaries = $this->secretaries_model->get_batch(); + + foreach($secretaries as $secretary) + { + if (!$secretary['settings']['notifications'] === '0') + { + continue; + } + + if (in_array($provider['id'], $secretary['providers'])) + { + continue; + } + + $email->sendDeleteAppointment($appointment, $provider, + $service, $customer, $settings, new Email($secretary['email']), + new Text($this->input->post('cancel_reason'))); + } + } + catch (Exception $exception) + { + $exceptions[] = $exception; + } + } + catch (Exception $exception) + { + // Display the error message to the customer. + $exceptions[] = $exception; + } + + $view = [ + 'message_title' => lang('appointment_cancelled_title'), + 'message_text' => lang('appointment_cancelled'), + 'message_icon' => base_url('assets/img/success.png') + ]; + + if (isset($exceptions)) + { + $view['exceptions'] = $exceptions; + } + + $this->load->view('appointments/message', $view); + } + + /** + * GET an specific appointment book and redirect to the success screen. + * + * @param string $appointment_hash The appointment hash identifier. + * + * @throws Exception + */ + public function book_success($appointment_hash) + { + $appointments = $this->appointments_model->get_batch(['hash' => $appointment_hash]); + + if (empty($appointments)) + { + redirect('appointments'); // The appointment does not exist. + return; + } + + $appointment = $appointments[0]; + unset($appointment['notes']); + + $provider = $this->providers_model->get_row($appointment['id_users_provider']); + unset($provider['settings'], $provider['notes']); + + $service = $this->services_model->get_row($appointment['id_services']); + + $company_name = $this->settings_model->get_setting('company_name'); + + // Get any pending exceptions. + $exceptions = $this->session->flashdata('book_success'); + + $view = [ + 'appointment_data' => $appointment, + 'provider_data' => $provider, + 'service_data' => $service, + 'company_name' => $company_name, + ]; + + if ($exceptions) + { + $view['exceptions'] = $exceptions; + } + + $this->load->view('appointments/book_success', $view); + } + + /** + * [AJAX] Get the available appointment hours for the given date. + * + * This method answers to an AJAX request. It calculates the available hours for the given service, provider and + * date. + * + * Outputs a JSON string with the availabilities. + */ + public function ajax_get_available_hours() + { + try + { + $provider_id = $this->input->post('provider_id'); + $service_id = $this->input->post('service_id'); + $selected_date = $this->input->post('selected_date'); + + // Do not continue if there was no provider selected (more likely there is no provider in the system). + if (empty($provider_id)) + { + $this->output + ->set_content_type('application/json') + ->set_output(json_encode([])); + + return; + } + + // If manage mode is TRUE then the following we should not consider the selected appointment when + // calculating the available time periods of the provider. + $exclude_appointments = []; + + if ($this->input->post('manage_mode') === 'true') + { + $exclude_appointments[] = $this->input->post('appointment_id'); + } + + // If the user has selected the "any-provider" option then we will need to search for an available provider + // that will provide the requested service. + if ($provider_id === ANY_PROVIDER) + { + $provider_id = $this->search_any_provider($service_id, $selected_date); + + if ($provider_id === NULL) + { + $this->output + ->set_content_type('application/json') + ->set_output(json_encode([])); + + return; + } + } + + $service = $this->services_model->get_row($service_id); + $provider = $this->providers_model->get_row($provider_id); + + $empty_periods = $this->get_provider_available_time_periods($provider_id, + $selected_date, $exclude_appointments); + + $available_hours = $this->calculate_available_hours($empty_periods, $selected_date, + $service['duration'], $service['availabilities_type']); + + if ($service['attendants_number'] > 1) + { + $available_hours = $this->get_multiple_attendants_hours($selected_date, $service, + $provider); + } + + $response = $this->consider_book_advance_timeout($selected_date, $available_hours, $provider); + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + + /** + * Search for any provider that can handle the requested service. + * + * This method will return the database ID of the provider with the most available periods. + * + * @param int $service_id The requested service ID. + * @param string $selected_date The date to be searched. + * + * @return int Returns the ID of the provider that can provide the service at the selected date. + * + * @throws Exception + */ + protected function search_any_provider($service_id, $selected_date) + { + $available_providers = $this->providers_model->get_available_providers(); + + $service = $this->services_model->get_row($service_id); + + $provider_id = NULL; + + $max_hours_count = 0; + + foreach ($available_providers as $provider) + { + foreach ($provider['services'] as $provider_service_id) + { + if ($provider_service_id == $service_id) + { + // Check if the provider is available for the requested date. + $empty_periods = $this->get_provider_available_time_periods($provider['id'], $selected_date); + + $available_hours = $this->calculate_available_hours($empty_periods, $selected_date, + $service['duration'], $service['availabilities_type']); + + if ($service['attendants_number'] > 1) + { + $available_hours = $this->get_multiple_attendants_hours($selected_date, $service, + $provider); + } + + if (count($available_hours) > $max_hours_count) + { + $provider_id = $provider['id']; + $max_hours_count = count($available_hours); + } + } + } + } + + return $provider_id; + } + + /** + * Get an array containing the free time periods (start - end) of a selected date. + * + * This method is very important because there are many cases where the system needs to know when a provider is + * available for an appointment. This method will return an array that belongs to the selected date and contains + * values that have the start and the end time of an available time period. + * + * @param int $provider_id Provider record ID. + * @param string $selected_date Date to be checked (MySQL formatted string). + * @param array $excluded_appointment_ids Array containing the IDs of the appointments that will not be taken into + * consideration when the available time periods are calculated. + * + * @return array Returns an array with the available time periods of the provider. + * + * @throws Exception + */ + protected function get_provider_available_time_periods( + $provider_id, + $selected_date, + $excluded_appointment_ids = [] + ) + { + // Get the service, provider's working plan and provider appointments. + $working_plan = json_decode($this->providers_model->get_setting('working_plan', $provider_id), TRUE); + + // Get the provider's working plan exceptions. + $working_plan_exceptions = json_decode($this->providers_model->get_setting('working_plan_exceptions', $provider_id), TRUE); + + $provider_appointments = $this->appointments_model->get_batch([ + 'id_users_provider' => $provider_id, + ]); + + // Sometimes it might be necessary to not take into account some appointment records in order to display what + // the providers' available time periods would be without them. + foreach ($excluded_appointment_ids as $excluded_appointment_id) + { + foreach ($provider_appointments as $index => $reserved) + { + if ($reserved['id'] == $excluded_appointment_id) + { + unset($provider_appointments[$index]); + } + } + } + + // Find the empty spaces on the plan. The first split between the plan is due to a break (if any). After that + // every reserved appointment is considered to be a taken space in the plan. + $selected_date_working_plan = $working_plan[strtolower(date('l', strtotime($selected_date)))]; + + // Search if the $selected_date is an custom availability period added outside the normal working plan. + if (isset($working_plan_exceptions[$selected_date])) + { + $selected_date_working_plan = $working_plan_exceptions[$selected_date]; + } + + $periods = []; + + if (isset($selected_date_working_plan['breaks'])) + { + $periods[] = [ + 'start' => $selected_date_working_plan['start'], + 'end' => $selected_date_working_plan['end'] + ]; + + $day_start = new DateTime($selected_date_working_plan['start']); + $day_end = new DateTime($selected_date_working_plan['end']); + + // Split the working plan to available time periods that do not contain the breaks in them. + foreach ($selected_date_working_plan['breaks'] as $index => $break) + { + $break_start = new DateTime($break['start']); + $break_end = new DateTime($break['end']); + + if ($break_start < $day_start) + { + $break_start = $day_start; + } + + if ($break_end > $day_end) + { + $break_end = $day_end; + } + + if ($break_start >= $break_end) + { + continue; + } + + foreach ($periods as $key => $period) + { + $period_start = new DateTime($period['start']); + $period_end = new DateTime($period['end']); + + $remove_current_period = FALSE; + + if ($break_start > $period_start && $break_start < $period_end && $break_end > $period_start) + { + $periods[] = [ + 'start' => $period_start->format('H:i'), + 'end' => $break_start->format('H:i') + ]; + + $remove_current_period = TRUE; + } + + if ($break_start < $period_end && $break_end > $period_start && $break_end < $period_end) + { + $periods[] = [ + 'start' => $break_end->format('H:i'), + 'end' => $period_end->format('H:i') + ]; + + $remove_current_period = TRUE; + } + + if ($break_start == $period_start && $break_end == $period_end) + { + $remove_current_period = TRUE; + } + + if ($remove_current_period) + { + unset($periods[$key]); + } + } + } + } + + // Break the empty periods with the reserved appointments. + foreach ($provider_appointments as $provider_appointment) + { + foreach ($periods as $index => &$period) + { + $appointment_start = new DateTime($provider_appointment['start_datetime']); + $appointment_end = new DateTime($provider_appointment['end_datetime']); + + if ($appointment_start >= $appointment_end) + { + continue; + } + + $period_start = new DateTime($selected_date . ' ' . $period['start']); + $period_end = new DateTime($selected_date . ' ' . $period['end']); + + if ($appointment_start <= $period_start && $appointment_end <= $period_end && $appointment_end <= $period_start) + { + // The appointment does not belong in this time period, so we will not change anything. + continue; + } + else + { + if ($appointment_start <= $period_start && $appointment_end <= $period_end && $appointment_end >= $period_start) + { + // The appointment starts before the period and finishes somewhere inside. We will need to break + // this period and leave the available part. + $period['start'] = $appointment_end->format('H:i'); + } + else + { + if ($appointment_start >= $period_start && $appointment_end < $period_end) + { + // The appointment is inside the time period, so we will split the period into two new + // others. + unset($periods[$index]); + + $periods[] = [ + 'start' => $period_start->format('H:i'), + 'end' => $appointment_start->format('H:i') + ]; + + $periods[] = [ + 'start' => $appointment_end->format('H:i'), + 'end' => $period_end->format('H:i') + ]; + } + else if ($appointment_start == $period_start && $appointment_end == $period_end) + { + unset($periods[$index]); // The whole period is blocked so remove it from the available periods array. + } + else + { + if ($appointment_start >= $period_start && $appointment_end >= $period_start && $appointment_start <= $period_end) + { + // The appointment starts in the period and finishes out of it. We will need to remove + // the time that is taken from the appointment. + $period['end'] = $appointment_start->format('H:i'); + } + else + { + if ($appointment_start >= $period_start && $appointment_end >= $period_end && $appointment_start >= $period_end) + { + // The appointment does not belong in the period so do not change anything. + continue; + } + else + { + if ($appointment_start <= $period_start && $appointment_end >= $period_end && $appointment_start <= $period_end) + { + // The appointment is bigger than the period, so this period needs to be removed. + unset($periods[$index]); + } + } + } + } + } + } + } + } + + return array_values($periods); + } + + /** + * Calculate the available appointment hours. + * + * Calculate the available appointment hours for the given date. The empty spaces + * are broken down to 15 min and if the service fit in each quarter then a new + * available hour is added to the "$available_hours" array. + * + * @param array $empty_periods Contains the empty periods as generated by the "get_provider_available_time_periods" + * method. + * @param string $selected_date The selected date to be search (format ) + * @param int $service_duration The service duration is required for the hour calculation. + * @param string $availabilities_type Optional ('flexible'), the service availabilities type. + * + * @return array Returns an array with the available hours for the appointment. + * @throws Exception + */ + protected function calculate_available_hours( + array $empty_periods, + $selected_date, + $service_duration, + $availabilities_type = 'flexible' + ) + { + $available_hours = []; + + foreach ($empty_periods as $period) + { + $start_hour = new DateTime($selected_date . ' ' . $period['start']); + $end_hour = new DateTime($selected_date . ' ' . $period['end']); + $interval = $availabilities_type === AVAILABILITIES_TYPE_FIXED ? (int)$service_duration : 15; + + $current_hour = $start_hour; + $diff = $current_hour->diff($end_hour); + + while (($diff->h * 60 + $diff->i) >= intval($service_duration)) + { + $available_hours[] = $current_hour->format('H:i'); + $current_hour->add(new DateInterval('PT' . $interval . 'M')); + $diff = $current_hour->diff($end_hour); + } + } + + return $available_hours; + } + + /** + * Get multiple attendants hours. + * + * This method will add the additional appointment hours whenever a service accepts multiple attendants. + * + * @param string $selected_date The selected appointment date. + * @param array $service Selected service data. + * @param array $provider Selected provider data. + * + * @return array Returns the available hours array. + * @throws Exception + */ + protected function get_multiple_attendants_hours( + $selected_date, + $service, + $provider + ) + { + $unavailability_events = $this->appointments_model->get_batch([ + 'is_unavailable' => TRUE, + 'DATE(start_datetime)' => $selected_date, + 'id_users_provider' => $provider['id'] + ]); + + $working_plan = json_decode($provider['settings']['working_plan'], TRUE); + $working_day = strtolower(date('l', strtotime($selected_date))); + $working_hours = $working_plan[$working_day]; + + $periods = [ + [ + 'start' => new DateTime($selected_date . ' ' . $working_hours['start']), + 'end' => new DateTime($selected_date . ' ' . $working_hours['end']) + ] + ]; + + $periods = $this->remove_breaks($selected_date, $periods, $working_hours['breaks']); + $periods = $this->remove_unavailability_events($periods, $unavailability_events); + + $hours = []; + + $interval_value = $service['availabilities_type'] == AVAILABILITIES_TYPE_FIXED ? $service['duration'] : '15'; + $interval = new DateInterval('PT' . (int)$interval_value . 'M'); + $duration = new DateInterval('PT' . (int)$service['duration'] . 'M'); + + foreach ($periods as $period) + { + $slot_start = clone $period['start']; + $slot_end = clone $slot_start; + $slot_end->add($duration); + + while ($slot_end <= $period['end']) + { + // Check reserved attendants for this time slot and see if current attendants fit. + $appointment_attendants_number = $this->appointments_model->get_attendants_number_for_period($slot_start, + $slot_end, $service['id']); + + if ($appointment_attendants_number < $service['attendants_number']) + { + $hours[] = $slot_start->format('H:i'); + } + + $slot_start->add($interval); + $slot_end->add($interval); + } + } + + return $hours; + } + + /** + * Remove breaks from available time periods. + * + * @param string $selected_date Selected data (Y-m-d format). + * @param array $periods Time periods of the current date. + * @param array $breaks Breaks array for the current date. + * + * @return array Returns the available time periods without the breaks. + * @throws Exception + */ + public function remove_breaks($selected_date, $periods, $breaks) + { + if ( ! $breaks) + { + return $periods; + } + + foreach ($breaks as $break) + { + $break_start = new DateTime($selected_date . ' ' . $break['start']); + $break_end = new DateTime($selected_date . ' ' . $break['end']); + + foreach ($periods as &$period) + { + $period_start = $period['start']; + $period_end = $period['end']; + + if ($break_start <= $period_start && $break_end >= $period_start && $break_end <= $period_end) + { + // left + $period['start'] = $break_end; + continue; + } + + if ($break_start >= $period_start && $break_start <= $period_end && $break_end >= $period_start && $break_end <= $period_end) + { + // middle + $period['end'] = $break_start; + $periods[] = [ + 'start' => $break_end, + 'end' => $period_end + ]; + continue; + } + + if ($break_start >= $period_start && $break_start <= $period_end && $break_end >= $period_end) + { + // right + $period['end'] = $break_start; + continue; + } + + if ($break_start <= $period_start && $break_end >= $period_end) + { + // break contains period + $period['start'] = $break_end; + continue; + } + } + } + + return $periods; + } + + /** + * Remove the unavailability entries from the available time periods of the selected date. + * + * @param array $periods Available time periods. + * @param array $unavailability_events Unavailability events of the current date. + * + * @return array Returns the available time periods without the unavailability events. + * + * @throws Exception + */ + public function remove_unavailability_events($periods, $unavailability_events) + { + foreach ($unavailability_events as $unavailability_event) + { + $unavailability_start = new DateTime($unavailability_event['start_datetime']); + $unavailability_end = new DateTime($unavailability_event['end_datetime']); + + foreach ($periods as &$period) + { + $period_start = $period['start']; + $period_end = $period['end']; + + if ($unavailability_start <= $period_start && $unavailability_end >= $period_start && $unavailability_end <= $period_end) + { + // left + $period['start'] = $unavailability_end; + continue; + } + + if ($unavailability_start >= $period_start && $unavailability_start <= $period_end && $unavailability_end >= $period_start && $unavailability_end <= $period_end) + { + // middle + $period['end'] = $unavailability_start; + $periods[] = [ + 'start' => $unavailability_end, + 'end' => $period_end + ]; + continue; + } + + if ($unavailability_start >= $period_start && $unavailability_start <= $period_end && $unavailability_end >= $period_end) + { + // right + $period['end'] = $unavailability_start; + continue; + } + + if ($unavailability_start <= $period_start && $unavailability_end >= $period_end) + { + // Unavailability contains period + $period['start'] = $unavailability_end; + continue; + } + } + } + + return $periods; + } + + /** + * [AJAX] Register the appointment to the database. + * + * Outputs a JSON string with the appointment ID. + */ + public function ajax_register_appointment() + { + try + { + $this->load->model('appointments_model'); + $this->load->model('providers_model'); + $this->load->model('admins_model'); + $this->load->model('secretaries_model'); + $this->load->model('services_model'); + $this->load->model('customers_model'); + $this->load->model('settings_model'); + + $post_data = $this->input->post('post_data'); + $captcha = $this->input->post('captcha'); + $manage_mode = filter_var($post_data['manage_mode'], FILTER_VALIDATE_BOOLEAN); + $appointment = $post_data['appointment']; + $customer = $post_data['customer']; + + // Check appointment availability before registering it to the database. + $appointment['id_users_provider'] = $this->check_datetime_availability(); + + if ( ! $appointment['id_users_provider']) + { + throw new Exception(lang('requested_hour_is_unavailable')); + } + + $provider = $this->providers_model->get_row($appointment['id_users_provider']); + $service = $this->services_model->get_row($appointment['id_services']); + + $require_captcha = $this->settings_model->get_setting('require_captcha'); + $captcha_phrase = $this->session->userdata('captcha_phrase'); + + // Validate the CAPTCHA string. + if ($require_captcha === '1' && $captcha_phrase !== $captcha) + { + $this->output + ->set_content_type('application/json') + ->set_output(json_encode([ + 'captcha_verification' => FALSE + ])); + + return; + } + + if ($this->customers_model->exists($customer)) + { + $customer['id'] = $this->customers_model->find_record_id($customer); + } + + if (empty($appointment['location']) && ! empty($service['location'])) + { + $appointment['location'] = $service['location']; + } + + // Save customer language (the language which is used to render the booking page). + $customer['language'] = $this->config->item('language'); + $customer_id = $this->customers_model->add($customer); + + $appointment['id_users_customer'] = $customer_id; + $appointment['is_unavailable'] = (int)$appointment['is_unavailable']; // needs to be type casted + $appointment['id'] = $this->appointments_model->add($appointment); + $appointment['hash'] = $this->appointments_model->get_value('hash', $appointment['id']); + + $settings = [ + 'company_name' => $this->settings_model->get_setting('company_name'), + 'company_link' => $this->settings_model->get_setting('company_link'), + 'company_email' => $this->settings_model->get_setting('company_email'), + 'date_format' => $this->settings_model->get_setting('date_format'), + 'time_format' => $this->settings_model->get_setting('time_format') + ]; + + // Synchronize the appointment with the provider's Google Calendar. + try + { + $google_sync = filter_var( + $this->providers_model->get_setting('google_sync', $appointment['id_users_provider']), + FILTER_VALIDATE_BOOLEAN); + + if ($google_sync === TRUE) + { + $google_token = json_decode( + $this->providers_model->get_setting('google_token', $appointment['id_users_provider'])); + + $this->load->library('google_sync'); + + $this->google_sync->refresh_token($google_token->refresh_token); + + if ($manage_mode === FALSE) + { + // Add appointment to Google Calendar. + $google_event = $this->google_sync->add_appointment($appointment, $provider, + $service, $customer, $settings); + $appointment['id_google_calendar'] = $google_event->id; + $this->appointments_model->add($appointment); + } + else + { + // Update appointment to Google Calendar. + $appointment['id_google_calendar'] = $this->appointments_model + ->get_value('id_google_calendar', $appointment['id']); + + $this->google_sync->update_appointment($appointment, $provider, + $service, $customer, $settings); + } + } + } + catch (Exception $exception) + { + log_message('error', $exception->getMessage()); + log_message('error', $exception->getTraceAsString()); + } + + // Send email notifications to customer and provider. + try + { + $this->config->load('email'); + + $email = new EmailClient($this, $this->config->config); + + if ($manage_mode === FALSE) + { + $customer_title = new Text(lang('appointment_booked')); + $customer_message = new Text(lang('thank_you_for_appointment')); + $provider_title = new Text(lang('appointment_added_to_your_plan')); + $provider_message = new Text(lang('appointment_link_description')); + + } + else + { + $customer_title = new Text(lang('appointment_changes_saved')); + $customer_message = new Text(''); + $provider_title = new Text(lang('appointment_details_changed')); + $provider_message = new Text(''); + } + + $customer_link = new Url(site_url('appointments/index/' . $appointment['hash'])); + $provider_link = new Url(site_url('backend/index/' . $appointment['hash'])); + + $send_customer = filter_var( + $this->settings_model->get_setting('customer_notifications'), + FILTER_VALIDATE_BOOLEAN); + + $this->load->library('ics_file'); + + $ics_stream = $this->ics_file->get_stream($appointment, $service, $provider, $customer); + + if ($send_customer === TRUE) + { + $email->sendAppointmentDetails($appointment, $provider, + $service, $customer, $settings, $customer_title, + $customer_message, $customer_link, new Email($customer['email']), new Text($ics_stream)); + } + + $send_provider = filter_var( + $this->providers_model->get_setting('notifications', $provider['id']), + FILTER_VALIDATE_BOOLEAN); + + if ($send_provider === TRUE) + { + $email->sendAppointmentDetails($appointment, $provider, + $service, $customer, $settings, $provider_title, + $provider_message, $provider_link, new Email($provider['email']), new Text($ics_stream)); + } + + // Notify admins + $admins = $this->admins_model->get_batch(); + + foreach($admins as $admin) + { + if (!$admin['settings']['notifications'] === '0') + { + continue; + } + + $email->sendAppointmentDetails($appointment, $provider, + $service, $customer, $settings, $provider_title, + $provider_message, $provider_link, new Email($admin['email']), new Text($ics_stream)); + } + + // Notify secretaries + $secretaries = $this->secretaries_model->get_batch(); + + foreach($secretaries as $secretary) + { + if (!$secretary['settings']['notifications'] === '0') + { + continue; + } + + if (in_array($provider['id'], $secretary['providers'])) + { + continue; + } + + $email->sendAppointmentDetails($appointment, $provider, + $service, $customer, $settings, $provider_title, + $provider_message, $provider_link, new Email($secretary['email']), new Text($ics_stream)); + } + } + catch (Exception $exception) + { + log_message('error', $exception->getMessage()); + log_message('error', $exception->getTraceAsString()); + } + + $response = [ + 'appointment_id' => $appointment['id'], + 'appointment_hash' => $appointment['hash'] + ]; + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + + /** + * Check whether the provider is still available in the selected appointment date. + * + * It might be times where two or more customers select the same appointment date and time. This shouldn't be + * allowed to happen, so one of the two customers will eventually get the preferred date and the other one will have + * to choose for another date. Use this method just before the customer confirms the appointment details. If the + * selected date was taken in the mean time, the customer must be prompted to select another time for his + * appointment. + * + * @return int Returns the ID of the provider that is available for the appointment. + * + * @throws Exception + */ + protected function check_datetime_availability() + { + $this->load->model('services_model'); + $this->load->model('appointments_model'); + + $post_data = $this->input->post('post_data'); + + $appointment = $post_data['appointment']; + + $service_duration = $this->services_model->get_value('duration', $appointment['id_services']); + + $exclude_appointments = []; + + if (isset($appointment['id'])) + { + $exclude_appointments[] = $appointment['id']; + } + + $attendants_number = $this->services_model->get_value('attendants_number', $appointment['id_services']); + + if ($attendants_number > 1) + { + // Exclude all the appointments that are currently registered. + $existing_appointments = $this->appointments_model->get_batch([ + 'id_services' => $appointment['id_services'], + 'start_datetime' => $appointment['start_datetime'] + ]); + + if ( ! empty($existing_appointments) && count($existing_appointments) < $attendants_number) + { + foreach ($existing_appointments as $existing_appointment) + { + $exclude_appointments[] = $existing_appointment['id']; + } + } + } + + if ($appointment['id_users_provider'] === ANY_PROVIDER) + { + $appointment['id_users_provider'] = $this->search_any_provider($appointment['id_services'], + date('Y-m-d', strtotime($appointment['start_datetime']))); + + return $appointment['id_users_provider']; + } + + $available_periods = $this->get_provider_available_time_periods( + $appointment['id_users_provider'], + date('Y-m-d', strtotime($appointment['start_datetime'])), + $exclude_appointments); + + $is_still_available = FALSE; + + foreach ($available_periods as $period) + { + $appt_start = new DateTime($appointment['start_datetime']); + $appt_start = $appt_start->format('H:i'); + + $appt_end = new DateTime($appointment['start_datetime']); + $appt_end->add(new DateInterval('PT' . $service_duration . 'M')); + $appt_end = $appt_end->format('H:i'); + + $period_start = date('H:i', strtotime($period['start'])); + $period_end = date('H:i', strtotime($period['end'])); + + if ($period_start <= $appt_start && $period_end >= $appt_end) + { + $is_still_available = TRUE; + break; + } + } + + return $is_still_available ? $appointment['id_users_provider'] : NULL; + } + + /** + * [AJAX] Get Unavailable Dates + * + * Get an array with the available dates of a specific provider, service and month of the year. Provide the + * "provider_id", "service_id" and "selected_date" as GET parameters to the request. The "selected_date" parameter + * must have the Y-m-d format. + * + * Outputs a JSON string with the unavailable dates. that are unavailable. + */ + public function ajax_get_unavailable_dates() + { + try + { + $this->load->model('providers_model'); + $this->load->model('services_model'); + + $provider_id = $this->input->get('provider_id'); + $service_id = $this->input->get('service_id'); + $appointment_id = $this->input->get_post('appointment_id'); + $manage_mode = $this->input->get_post('manage_mode'); + $selected_date_string = $this->input->get('selected_date'); + $selected_date = new DateTime($selected_date_string); + $number_of_days_in_month = (int)$selected_date->format('t'); + $unavailable_dates = []; + + $exclude_appointments = []; + + if ($manage_mode === 'true') + { + $exclude_appointments[] = $appointment_id; + } + + $provider_list = $provider_id === ANY_PROVIDER + ? $this->search_providers_by_service($service_id) + : [$provider_id]; + + // Get the service record. + $service = $this->services_model->get_row($service_id); + + for ($i = 1; $i <= $number_of_days_in_month; $i++) + { + $current_date = new DateTime($selected_date->format('Y-m') . '-' . $i); + + if ($current_date < new DateTime(date('Y-m-d 00:00:00'))) + { + // Past dates become immediately unavailable. + $unavailable_dates[] = $current_date->format('Y-m-d'); + continue; + } + + // Finding at least one slot of availability. + foreach ($provider_list as $current_provider_id) + { + // Get the provider record. + $current_provider = $this->providers_model->get_row($current_provider_id); + + $empty_periods = $this->get_provider_available_time_periods($current_provider_id, + $current_date->format('Y-m-d'), $exclude_appointments); + + $available_hours = $this->calculate_available_hours($empty_periods, $current_date->format('Y-m-d'), + $service['duration'], $service['availabilities_type']); + + if ( ! empty($available_hours)) + { + break; + } + + if ($service['attendants_number'] > 1) + { + $available_hours = $this->get_multiple_attendants_hours($current_date->format('Y-m-d'), + $service, $current_provider); + + if ( ! empty($available_hours)) + { + break; + } + } + + $available_hours = $this->consider_book_advance_timeout($current_date->format('Y-m-d'), + $available_hours, $current_provider); + } + + // No availability amongst all the provider. + if (empty($available_hours)) + { + $unavailable_dates[] = $current_date->format('Y-m-d'); + } + } + + $response = $unavailable_dates; + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + + /** + * Search for any provider that can handle the requested service. + * + * This method will return the database ID of the providers affected to the requested service. + * + * @param int $service_id The requested service ID. + * + * @return array Returns the ID of the provider that can provide the requested service. + */ + protected function search_providers_by_service($service_id) + { + $this->load->model('providers_model'); + $available_providers = $this->providers_model->get_available_providers(); + $provider_list = []; + + foreach ($available_providers as $provider) + { + foreach ($provider['services'] as $provider_service_id) + { + if ($provider_service_id === $service_id) + { + // Check if the provider is affected to the selected service. + $provider_list[] = $provider['id']; + } + } + } + + return $provider_list; + } + + /** + * Consider the book advance timeout and remove available hours that have passed the threshold. + * + * If the selected date is today, remove past hours. It is important include the timeout before booking + * that is set in the back-office the system. Normally we might want the customer to book an appointment + * that is at least half or one hour from now. The setting is stored in minutes. + * + * @param string $selected_date The selected date. + * @param array $available_hours Already generated available hours. + * @param array $provider Provider information. + * + * @return array Returns the updated available hours. + * + * @throws Exception + */ + protected function consider_book_advance_timeout($selected_date, $available_hours, $provider) + { + $provider_timezone = new DateTimeZone($provider['timezone']); + + $book_advance_timeout = $this->settings_model->get_setting('book_advance_timeout'); + + $threshold = new DateTime('+' . $book_advance_timeout . ' minutes', $provider_timezone); + + foreach ($available_hours as $index => $value) + { + $available_hour = new DateTime($selected_date . ' ' . $value, $provider_timezone); + + if ($available_hour->getTimestamp() <= $threshold->getTimestamp()) + { + unset($available_hours[$index]); + } + } + + $available_hours = array_values($available_hours); + sort($available_hours, SORT_STRING); + return array_values($available_hours); + } +} diff --git a/application/controllers/Backend.php b/application/controllers/Backend.php new file mode 100755 index 00000000..10036304 --- /dev/null +++ b/application/controllers/Backend.php @@ -0,0 +1,431 @@ + + * @copyright Copyright (c) 2013 - 2020, Alex Tselegidis + * @license http://opensource.org/licenses/GPL-3.0 - GPLv3 + * @link http://easyappointments.org + * @since v1.0.0 + * ---------------------------------------------------------------------------- */ + +/** + * Backend Controller + * + * @property CI_Session $session + * @property CI_Loader $load + * @property CI_Input $input + * @property CI_Output $output + * @property CI_Config $config + * @property CI_Lang $lang + * @property CI_Cache $cache + * @property CI_DB_query_builder $db + * @property CI_Security $security + * @property Google_Sync $google_sync + * @property Ics_file $ics_file + * @property Appointments_Model $appointments_model + * @property Providers_Model $providers_model + * @property Services_Model $services_model + * @property Customers_Model $customers_model + * @property Settings_Model $settings_model + * @property Timezones $timezones + * @property Roles_Model $roles_model + * @property Secretaries_Model $secretaries_model + * @property Admins_Model $admins_model + * @property User_Model $user_model + * + * @package Controllers + */ +class Backend extends CI_Controller { + /** + * Class Constructor + */ + public function __construct() + { + parent::__construct(); + $this->load->library('session'); + + if ($this->session->userdata('language')) + { + // Set user's selected language. + $this->config->set_item('language', $this->session->userdata('language')); + $this->lang->load('translations', $this->session->userdata('language')); + } + else + { + // Set the default language. + $this->lang->load('translations', $this->config->item('language')); + } + } + + /** + * Display the main backend page. + * + * This method displays the main backend page. All users login permission can view this page which displays a + * calendar with the events of the selected provider or service. If a user has more privileges he will see more + * menus at the top of the page. + * + * @param string $appointment_hash Appointment edit dialog will appear when the page loads (default ''). + * + * @throws Exception + */ + public function index($appointment_hash = '') + { + $this->session->set_userdata('dest_url', site_url('backend/index' . (!empty($appointment_hash) ? '/' . $appointment_hash : ''))); + + if ( ! $this->has_privileges(PRIV_APPOINTMENTS)) + { + return; + } + + $this->load->model('appointments_model'); + $this->load->model('providers_model'); + $this->load->model('services_model'); + $this->load->model('customers_model'); + $this->load->model('settings_model'); + $this->load->model('roles_model'); + $this->load->model('user_model'); + $this->load->model('secretaries_model'); + $this->load->library('timezones'); + + $calendar_view_query_param = $this->input->get('view'); + + $user_id = $this->session->userdata('user_id'); + + $user = $this->user_model->get_user($user_id); + + $view['base_url'] = $this->config->item('base_url'); + $view['page_title'] = lang('calendar'); + $view['user_display_name'] = $this->user_model->get_user_display_name($this->session->userdata('user_id')); + $view['active_menu'] = PRIV_APPOINTMENTS; + $view['date_format'] = $this->settings_model->get_setting('date_format'); + $view['time_format'] = $this->settings_model->get_setting('time_format'); + $view['first_weekday'] = $this->settings_model->get_setting('first_weekday'); + $view['company_name'] = $this->settings_model->get_setting('company_name'); + $view['require_phone_number'] = $this->settings_model->get_setting('require_phone_number'); + $view['available_providers'] = $this->providers_model->get_available_providers(); + $view['available_services'] = $this->services_model->get_available_services(); + $view['customers'] = $this->customers_model->get_batch(); + $view['calendar_view'] = !empty($calendar_view_query_param) ? $calendar_view_query_param : $user['settings']['calendar_view']; + $view['timezones'] = $this->timezones->to_array(); + $this->set_user_data($view); + + if ($this->session->userdata('role_slug') === DB_SLUG_SECRETARY) + { + $secretary = $this->secretaries_model->get_row($this->session->userdata('user_id')); + $view['secretary_providers'] = $secretary['providers']; + } + else + { + $view['secretary_providers'] = []; + } + + $results = $this->appointments_model->get_batch(['hash' => $appointment_hash]); + + if ($appointment_hash !== '' && count($results) > 0) + { + $appointment = $results[0]; + $appointment['customer'] = $this->customers_model->get_row($appointment['id_users_customer']); + $view['edit_appointment'] = $appointment; // This will display the appointment edit dialog on page load. + } + else + { + $view['edit_appointment'] = NULL; + } + + $this->load->view('backend/header', $view); + $this->load->view('backend/calendar', $view); + $this->load->view('backend/footer', $view); + } + + /** + * Check whether current user is logged in and has the required privileges to view a page. + * + * The backend page requires different privileges from the users to display pages. Not all pages are available to + * all users. For example secretaries should not be able to edit the system users. + * + * @param string $page This argument must match the roles field names of each section (eg "appointments", "users" + * ...). + * @param bool $redirect If the user has not the required privileges (either not logged in or insufficient role + * privileges) then the user will be redirected to another page. Set this argument to FALSE when using ajax (default + * true). + * + * @return bool Returns whether the user has the required privileges to view the page or not. If the user is not + * logged in then he will be prompted to log in. If he hasn't the required privileges then an info message will be + * displayed. + */ + protected function has_privileges($page, $redirect = TRUE) + { + // Check if user is logged in. + $user_id = $this->session->userdata('user_id'); + + if ($user_id == FALSE) + { + // User not logged in, display the login view. + if ($redirect) + { + header('Location: ' . site_url('user/login')); + } + return FALSE; + } + + // Check if the user has the required privileges for viewing the selected page. + $role_slug = $this->session->userdata('role_slug'); + + $role_privileges = $this->db->get_where('roles', ['slug' => $role_slug])->row_array(); + + if ($role_privileges[$page] < PRIV_VIEW) + { + // User does not have the permission to view the page. + if ($redirect) + { + header('Location: ' . site_url('user/no_privileges')); + } + return FALSE; + } + + return TRUE; + } + + /** + * Set the user data in order to be available at the view and js code. + * + * @param array $view Contains the view data. + */ + protected function set_user_data(&$view) + { + $this->load->model('roles_model'); + + // Get privileges + $view['user_id'] = $this->session->userdata('user_id'); + $view['user_email'] = $this->session->userdata('user_email'); + $view['timezone'] = $this->session->userdata('timezone'); + $view['role_slug'] = $this->session->userdata('role_slug'); + $view['privileges'] = $this->roles_model->get_privileges($this->session->userdata('role_slug')); + } + + /** + * Display the backend customers page. + * + * In this page the user can manage all the customer records of the system. + */ + public function customers() + { + $this->session->set_userdata('dest_url', site_url('backend/customers')); + + if ( ! $this->has_privileges(PRIV_CUSTOMERS)) + { + return; + } + + $this->load->model('providers_model'); + $this->load->model('customers_model'); + $this->load->model('secretaries_model'); + $this->load->model('services_model'); + $this->load->model('settings_model'); + $this->load->model('user_model'); + $this->load->library('timezones'); + + $view['base_url'] = $this->config->item('base_url'); + $view['page_title'] = lang('customers'); + $view['user_display_name'] = $this->user_model->get_user_display_name($this->session->userdata('user_id')); + $view['active_menu'] = PRIV_CUSTOMERS; + $view['company_name'] = $this->settings_model->get_setting('company_name'); + $view['date_format'] = $this->settings_model->get_setting('date_format'); + $view['time_format'] = $this->settings_model->get_setting('time_format'); + $view['first_weekday'] = $this->settings_model->get_setting('first_weekday'); + $view['require_phone_number'] = $this->settings_model->get_setting('require_phone_number'); + $view['customers'] = $this->customers_model->get_batch(); + $view['available_providers'] = $this->providers_model->get_available_providers(); + $view['available_services'] = $this->services_model->get_available_services(); + $view['timezones'] = $this->timezones->to_array(); + + if ($this->session->userdata('role_slug') === DB_SLUG_SECRETARY) + { + $secretary = $this->secretaries_model->get_row($this->session->userdata('user_id')); + $view['secretary_providers'] = $secretary['providers']; + } + else + { + $view['secretary_providers'] = []; + } + + $this->set_user_data($view); + + $this->load->view('backend/header', $view); + $this->load->view('backend/customers', $view); + $this->load->view('backend/footer', $view); + } + + /** + * Displays the backend services page. + * + * Here the admin user will be able to organize and create the services that the user will be able to book + * appointments in frontend. + * + * NOTICE: The services that each provider is able to service is managed from the backend services page. + */ + public function services() + { + $this->session->set_userdata('dest_url', site_url('backend/services')); + + if ( ! $this->has_privileges(PRIV_SERVICES)) + { + return; + } + + $this->load->model('customers_model'); + $this->load->model('services_model'); + $this->load->model('settings_model'); + $this->load->model('user_model'); + $this->load->library('timezones'); + + $view['base_url'] = $this->config->item('base_url'); + $view['page_title'] = lang('services'); + $view['user_display_name'] = $this->user_model->get_user_display_name($this->session->userdata('user_id')); + $view['active_menu'] = PRIV_SERVICES; + $view['company_name'] = $this->settings_model->get_setting('company_name'); + $view['date_format'] = $this->settings_model->get_setting('date_format'); + $view['time_format'] = $this->settings_model->get_setting('time_format'); + $view['first_weekday'] = $this->settings_model->get_setting('first_weekday'); + $view['services'] = $this->services_model->get_batch(); + $view['categories'] = $this->services_model->get_all_categories(); + $view['timezones'] = $this->timezones->to_array(); + $this->set_user_data($view); + + $this->load->view('backend/header', $view); + $this->load->view('backend/services', $view); + $this->load->view('backend/footer', $view); + } + + /** + * Display the backend users page. + * + * In this page the admin user will be able to manage the system users. By this, we mean the provider, secretary and + * admin users. This is also the page where the admin defines which service can each provider provide. + */ + public function users() + { + $this->session->set_userdata('dest_url', site_url('backend/users')); + + if ( ! $this->has_privileges(PRIV_USERS)) + { + return; + } + + $this->load->model('providers_model'); + $this->load->model('secretaries_model'); + $this->load->model('admins_model'); + $this->load->model('services_model'); + $this->load->model('settings_model'); + $this->load->model('user_model'); + $this->load->library('timezones'); + + $view['base_url'] = $this->config->item('base_url'); + $view['page_title'] = lang('users'); + $view['user_display_name'] = $this->user_model->get_user_display_name($this->session->userdata('user_id')); + $view['active_menu'] = PRIV_USERS; + $view['company_name'] = $this->settings_model->get_setting('company_name'); + $view['date_format'] = $this->settings_model->get_setting('date_format'); + $view['time_format'] = $this->settings_model->get_setting('time_format'); + $view['first_weekday'] = $this->settings_model->get_setting('first_weekday'); + $view['admins'] = $this->admins_model->get_batch(); + $view['providers'] = $this->providers_model->get_batch(); + $view['secretaries'] = $this->secretaries_model->get_batch(); + $view['services'] = $this->services_model->get_batch(); + $view['working_plan'] = $this->settings_model->get_setting('company_working_plan'); + $view['timezones'] = $this->timezones->to_array(); + $view['working_plan_exceptions'] = '{}'; + $this->set_user_data($view); + + $this->load->view('backend/header', $view); + $this->load->view('backend/users', $view); + $this->load->view('backend/footer', $view); + } + + /** + * Display the user/system settings. + * + * This page will display the user settings (name, password etc). If current user is an administrator, then he will + * be able to make change to the current Easy!Appointment installation (core settings like company name, book + * timeout etc). + */ + public function settings() + { + $this->session->set_userdata('dest_url', site_url('backend/settings')); + if ( ! $this->has_privileges(PRIV_SYSTEM_SETTINGS, FALSE) + && ! $this->has_privileges(PRIV_USER_SETTINGS)) + { + return; + } + + $this->load->model('settings_model'); + $this->load->model('user_model'); + $this->load->library('timezones'); + + $this->load->library('session'); + $user_id = $this->session->userdata('user_id'); + + $view['base_url'] = $this->config->item('base_url'); + $view['page_title'] = lang('settings'); + $view['user_display_name'] = $this->user_model->get_user_display_name($user_id); + $view['active_menu'] = PRIV_SYSTEM_SETTINGS; + $view['company_name'] = $this->settings_model->get_setting('company_name'); + $view['date_format'] = $this->settings_model->get_setting('date_format'); + $view['first_weekday'] = $this->settings_model->get_setting('first_weekday'); + $view['time_format'] = $this->settings_model->get_setting('time_format'); + $view['role_slug'] = $this->session->userdata('role_slug'); + $view['system_settings'] = $this->settings_model->get_settings(); + $view['user_settings'] = $this->user_model->get_user($user_id); + $view['timezones'] = $this->timezones->to_array(); + + // book_advance_timeout preview + $book_advance_timeout = $this->settings_model->get_setting('book_advance_timeout'); + $hours = floor($book_advance_timeout / 60); + $minutes = $book_advance_timeout % 60; + $view['book_advance_timeout_preview'] = sprintf('%02d:%02d', $hours, $minutes); + + $this->set_user_data($view); + + $this->load->view('backend/header', $view); + $this->load->view('backend/settings', $view); + $this->load->view('backend/footer', $view); + } + + /** + * This method will update the installation to the latest available version in the server. + * + * IMPORTANT: The code files must exist in the server, this method will not fetch any new files but will update + * the database schema. + * + * This method can be used either by loading the page in the browser or by an ajax request. But it will answer with + * JSON encoded data. + */ + public function update() + { + try + { + if ( ! $this->has_privileges(PRIV_SYSTEM_SETTINGS, TRUE)) + { + throw new Exception('You do not have the required privileges for this task!'); + } + + $this->load->library('migration'); + + if ( ! $this->migration->current()) + { + throw new Exception($this->migration->error_string()); + } + + $view = ['success' => TRUE]; + } + catch (Exception $exception) + { + $view = ['success' => FALSE, 'exception' => $exception->getMessage()]; + } + + $this->load->view('general/update', $view); + } +} diff --git a/application/controllers/Backend_api.php b/application/controllers/Backend_api.php new file mode 100755 index 00000000..eb34d50f --- /dev/null +++ b/application/controllers/Backend_api.php @@ -0,0 +1,1919 @@ + + * @copyright Copyright (c) 2013 - 2020, Alex Tselegidis + * @license http://opensource.org/licenses/GPL-3.0 - GPLv3 + * @link http://easyappointments.org + * @since v1.0.0 + * ---------------------------------------------------------------------------- */ + +use EA\Engine\Notifications\Email as EmailClient; +use EA\Engine\Types\Email; +use EA\Engine\Types\Text; +use EA\Engine\Types\Url; + +/** + * Backend API Controller + * + * Contains all the backend AJAX callbacks. + * + * @property CI_Session $session + * @property CI_Loader $load + * @property CI_Input $input + * @property CI_Output $output + * @property CI_Config $config + * @property CI_Lang $lang + * @property CI_Cache $cache + * @property CI_DB_query_builder $db + * @property CI_Security $security + * @property Google_Sync $google_sync + * @property Ics_file $ics_file + * @property Appointments_Model $appointments_model + * @property Providers_Model $providers_model + * @property Services_Model $services_model + * @property Customers_Model $customers_model + * @property Settings_Model $settings_model + * @property Timezones $timezones + * @property Roles_Model $roles_model + * @property Secretaries_Model $secretaries_model + * @property Admins_Model $admins_model + * @property User_Model $user_model + * + * @package Controllers + */ +class Backend_api extends CI_Controller { + /** + * @var array + */ + protected $privileges; + + /** + * Class Constructor + */ + public function __construct() + { + parent::__construct(); + + $this->load->library('session'); + $this->load->model('roles_model'); + + if ($this->session->userdata('role_slug')) + { + $this->privileges = $this->roles_model->get_privileges($this->session->userdata('role_slug')); + } + + // Set user's selected language. + if ($this->session->userdata('language')) + { + $this->config->set_item('language', $this->session->userdata('language')); + $this->lang->load('translations', $this->session->userdata('language')); + } + else + { + $this->lang->load('translations', $this->config->item('language')); // default + } + } + + /** + * Get Calendar Events + * + * This method will return all the calendar events within a specified period. + */ + public function ajax_get_calendar_events() + { + try + { + $this->output->set_content_type('application/json'); + $this->load->model('appointments_model'); + $this->load->model('customers_model'); + $this->load->model('services_model'); + $this->load->model('providers_model'); + + $startDate = $this->input->post('startDate') . ' 00:00:00'; + $endDate = $this->input->post('endDate') . ' 23:59:59'; + + $response = [ + 'appointments' => $this->appointments_model->get_batch([ + 'is_unavailable' => FALSE, + 'start_datetime >=' => $startDate, + 'end_datetime <=' => $endDate + ]), + 'unavailabilities' => $this->appointments_model->get_batch([ + 'is_unavailable' => TRUE, + 'start_datetime >=' => $startDate, + 'end_datetime <=' => $endDate + ]) + ]; + + foreach ($response['appointments'] as &$appointment) + { + $appointment['provider'] = $this->providers_model->get_row($appointment['id_users_provider']); + $appointment['service'] = $this->services_model->get_row($appointment['id_services']); + $appointment['customer'] = $this->customers_model->get_row($appointment['id_users_customer']); + } + + $userId = $this->session->userdata('user_id'); + $roleSlug = $this->session->userdata('role_slug'); + + // If the current user is a provider he must only see his own appointments. + if ($roleSlug === DB_SLUG_PROVIDER) + { + foreach ($response['appointments'] as $index => $appointment) + { + if ((int)$appointment['id_users_provider'] !== (int)$userId) + { + unset($response['appointments'][$index]); + } + } + + foreach ($response['unavailabilities'] as $index => $unavailability) + { + if ((int)$unavailability['id_users_provider'] !== (int)$userId) + { + unset($response['unavailabilities'][$index]); + } + } + } + + // If the current user is a secretary he must only see the appointments of his providers. + if ($roleSlug === DB_SLUG_SECRETARY) + { + $this->load->model('secretaries_model'); + $providers = $this->secretaries_model->get_row($userId)['providers']; + foreach ($response['appointments'] as $index => $appointment) + { + if ( ! in_array((int)$appointment['id_users_provider'], $providers)) + { + unset($response['appointments'][$index]); + } + } + + foreach ($response['unavailabilities'] as $index => $unavailability) + { + if ( ! in_array((int)$unavailability['id_users_provider'], $providers)) + { + unset($response['unavailabilities'][$index]); + } + } + } + + $this->output->set_output(json_encode($response)); + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + + /** + * Get the registered appointments for the given date period and record. + * + * This method returns the database appointments and unavailable periods for the + * user selected date period and record type (provider or service). + */ + public function ajax_get_calendar_appointments() + { + try + { + if ($this->privileges[PRIV_APPOINTMENTS]['view'] == FALSE) + { + throw new Exception('You do not have the required privileges for this task.'); + } + + if ( ! $this->input->post('filter_type')) + { + $this->output + ->set_content_type('application/json') + ->set_output(json_encode(['appointments' => []])); + return; + } + + $this->load->model('appointments_model'); + $this->load->model('providers_model'); + $this->load->model('services_model'); + $this->load->model('customers_model'); + + if ($this->input->post('filter_type') == FILTER_TYPE_PROVIDER) + { + $where_id = 'id_users_provider'; + } + else + { + $where_id = 'id_services'; + } + + // Get appointments + $record_id = $this->db->escape($this->input->post('record_id')); + $start_date = $this->db->escape($this->input->post('start_date')); + $end_date = $this->db->escape(date('Y-m-d', strtotime($this->input->post('end_date') . ' +1 day'))); + + $where_clause = $where_id . ' = ' . $record_id . ' + AND ((start_datetime > ' . $start_date . ' AND start_datetime < ' . $end_date . ') + or (end_datetime > ' . $start_date . ' AND end_datetime < ' . $end_date . ') + or (start_datetime <= ' . $start_date . ' AND end_datetime >= ' . $end_date . ')) + AND is_unavailable = 0 + '; + + $response['appointments'] = $this->appointments_model->get_batch($where_clause); + + foreach ($response['appointments'] as &$appointment) + { + $appointment['provider'] = $this->providers_model->get_row($appointment['id_users_provider']); + $appointment['service'] = $this->services_model->get_row($appointment['id_services']); + $appointment['customer'] = $this->customers_model->get_row($appointment['id_users_customer']); + } + + // Get unavailable periods (only for provider). + if ($this->input->post('filter_type') == FILTER_TYPE_PROVIDER) + { + $where_clause = $where_id . ' = ' . $record_id . ' + AND ((start_datetime > ' . $start_date . ' AND start_datetime < ' . $end_date . ') + or (end_datetime > ' . $start_date . ' AND end_datetime < ' . $end_date . ') + or (start_datetime <= ' . $start_date . ' AND end_datetime >= ' . $end_date . ')) + AND is_unavailable = 1 + '; + + $response['unavailables'] = $this->appointments_model->get_batch($where_clause); + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + + /** + * Save appointment changes that are made from the backend calendar page. + */ + public function ajax_save_appointment() + { + try + { + $this->load->model('appointments_model'); + $this->load->model('providers_model'); + $this->load->model('admins_model'); + $this->load->model('secretaries_model'); + $this->load->model('services_model'); + $this->load->model('customers_model'); + $this->load->model('settings_model'); + $this->load->library('timezones'); + $this->load->model('user_model'); + + // Save customer changes to the database. + if ($this->input->post('customer_data')) + { + $customer = json_decode($this->input->post('customer_data'), TRUE); + + $required_privilegesileges = ( ! isset($customer['id'])) + ? $this->privileges[PRIV_CUSTOMERS]['add'] + : $this->privileges[PRIV_CUSTOMERS]['edit']; + if ($required_privilegesileges == FALSE) + { + throw new Exception('You do not have the required privileges for this task.'); + } + + $customer['id'] = $this->customers_model->add($customer); + } + + // Save appointment changes to the database. + if ($this->input->post('appointment_data')) + { + $appointment = json_decode($this->input->post('appointment_data'), TRUE); + + $required_privilegesileges = ( ! isset($appointment['id'])) + ? $this->privileges[PRIV_APPOINTMENTS]['add'] + : $this->privileges[PRIV_APPOINTMENTS]['edit']; + if ($required_privilegesileges == FALSE) + { + throw new Exception('You do not have the required privileges for this task.'); + } + + $manage_mode = isset($appointment['id']); + // If the appointment does not contain the customer record id, then it + // means that is is going to be inserted. Get the customer's record id. + if ( ! isset($appointment['id_users_customer'])) + { + $appointment['id_users_customer'] = $customer['id']; + } + + $provider_timezone = $this->user_model->get_user_timezone($appointment['id_users_provider']); + + $session_timezone = $this->timezones->get_session_timezone(); + + $appointment['start_datetime'] = $this->timezones->convert($appointment['start_datetime'], + $session_timezone, $provider_timezone); + + $appointment['end_datetime'] = $this->timezones->convert($appointment['end_datetime'], + $session_timezone, $provider_timezone); + + $appointment['id'] = $this->appointments_model->add($appointment); + } + + $appointment = $this->appointments_model->get_row($appointment['id']); + $provider = $this->providers_model->get_row($appointment['id_users_provider']); + $customer = $this->customers_model->get_row($appointment['id_users_customer']); + $service = $this->services_model->get_row($appointment['id_services']); + + $settings = [ + 'company_name' => $this->settings_model->get_setting('company_name'), + 'company_link' => $this->settings_model->get_setting('company_link'), + 'company_email' => $this->settings_model->get_setting('company_email'), + 'date_format' => $this->settings_model->get_setting('date_format'), + 'time_format' => $this->settings_model->get_setting('time_format') + ]; + + // Sync appointment changes with Google Calendar. + try + { + $google_sync = $this->providers_model->get_setting('google_sync', + $appointment['id_users_provider']); + + if ($google_sync == TRUE) + { + $google_token = json_decode($this->providers_model->get_setting('google_token', + $appointment['id_users_provider'])); + + $this->load->library('Google_sync'); + $this->google_sync->refresh_token($google_token->refresh_token); + + if ($appointment['id_google_calendar'] == NULL) + { + $google_event = $this->google_sync->add_appointment($appointment, $provider, + $service, $customer, $settings); + $appointment['id_google_calendar'] = $google_event->id; + $this->appointments_model->add($appointment); // Store google calendar id. + } + else + { + $this->google_sync->update_appointment($appointment, $provider, + $service, $customer, $settings); + } + } + } + catch (Exception $exception) + { + $warnings[] = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + // Send email notifications to provider and customer. + try + { + $this->config->load('email'); + $email = new EmailClient($this, $this->config->config); + + $send_provider = $this->providers_model + ->get_setting('notifications', $provider['id']); + + if ( ! $manage_mode) + { + $customer_title = new Text(lang('appointment_booked')); + $customer_message = new Text(lang('thank_you_for_appointment')); + $provider_title = new Text(lang('appointment_added_to_your_plan')); + $provider_message = new Text(lang('appointment_link_description')); + } + else + { + $customer_title = new Text(lang('appointment_details_changed')); + $customer_message = new Text(''); + $provider_title = new Text(lang('appointment_changes_saved')); + $provider_message = new Text(''); + } + + $customer_link = new Url(site_url('appointments/index/' . $appointment['hash'])); + $provider_link = new Url(site_url('backend/index/' . $appointment['hash'])); + + $send_customer = $this->settings_model->get_setting('customer_notifications'); + + $this->load->library('ics_file'); + $ics_stream = $this->ics_file->get_stream($appointment, $service, $provider, $customer); + + if ((bool)$send_customer === TRUE) + { + $email->sendAppointmentDetails($appointment, $provider, + $service, $customer, $settings, $customer_title, + $customer_message, $customer_link, new Email($customer['email']), new Text($ics_stream)); + } + + if ($send_provider == TRUE) + { + $email->sendAppointmentDetails($appointment, $provider, + $service, $customer, $settings, $provider_title, + $provider_message, $provider_link, new Email($provider['email']), new Text($ics_stream)); + } + + // Notify admins + $admins = $this->admins_model->get_batch(); + + foreach($admins as $admin) + { + if (!$admin['settings']['notifications'] === '0') + { + continue; + } + + $email->sendAppointmentDetails($appointment, $provider, + $service, $customer, $settings, $provider_title, + $provider_message, $provider_link, new Email($admin['email']), new Text($ics_stream)); + } + + // Notify secretaries + $secretaries = $this->secretaries_model->get_batch(); + + foreach($secretaries as $secretary) + { + if (!$secretary['settings']['notifications'] === '0') + { + continue; + } + + if (in_array($provider['id'], $secretary['providers'])) + { + continue; + } + + $email->sendAppointmentDetails($appointment, $provider, + $service, $customer, $settings, $provider_title, + $provider_message, $provider_link, new Email($secretary['email']), new Text($ics_stream)); + } + } + catch (Exception $exception) + { + $warnings[] = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + if (empty($warnings)) + { + $response = AJAX_SUCCESS; + } + else + { + $response = ['warnings' => $warnings]; + } + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + + /** + * Delete appointment from the database. + * + * This method deletes an existing appointment from the database. Once this action is finished it cannot be undone. + * Notification emails are send to both provider and customer and the delete action is executed to the Google + * Calendar account of the provider, if the "google_sync" setting is enabled. + */ + public function ajax_delete_appointment() + { + try + { + if ($this->privileges[PRIV_APPOINTMENTS]['delete'] == FALSE) + { + throw new Exception('You do not have the required privileges for this task.'); + } + + if ( ! $this->input->post('appointment_id')) + { + throw new Exception('No appointment id provided.'); + } + + // Store appointment data for later use in this method. + $this->load->model('appointments_model'); + $this->load->model('providers_model'); + $this->load->model('admins_model'); + $this->load->model('secretaries_model'); + $this->load->model('customers_model'); + $this->load->model('services_model'); + $this->load->model('settings_model'); + + $appointment = $this->appointments_model->get_row($this->input->post('appointment_id')); + $provider = $this->providers_model->get_row($appointment['id_users_provider']); + $customer = $this->customers_model->get_row($appointment['id_users_customer']); + $service = $this->services_model->get_row($appointment['id_services']); + + $settings = [ + 'company_name' => $this->settings_model->get_setting('company_name'), + 'company_email' => $this->settings_model->get_setting('company_email'), + 'company_link' => $this->settings_model->get_setting('company_link'), + 'date_format' => $this->settings_model->get_setting('date_format'), + 'time_format' => $this->settings_model->get_setting('time_format') + ]; + + // Delete appointment record from the database. + $this->appointments_model->delete($this->input->post('appointment_id')); + + // Sync removal with Google Calendar. + if ($appointment['id_google_calendar'] != NULL) + { + try + { + $google_sync = $this->providers_model->get_setting('google_sync', $provider['id']); + + if ($google_sync == TRUE) + { + $google_token = json_decode($this->providers_model + ->get_setting('google_token', $provider['id'])); + $this->load->library('Google_sync'); + $this->google_sync->refresh_token($google_token->refresh_token); + $this->google_sync->delete_appointment($provider, $appointment['id_google_calendar']); + } + } + catch (Exception $exception) + { + $warnings[] = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + } + + // Send notification emails to provider and customer. + try + { + $this->config->load('email'); + + $email = new EmailClient($this, $this->config->config); + + $send_provider = $this->providers_model + ->get_setting('notifications', $provider['id']); + + if ((bool)$send_provider === TRUE) + { + $email->sendDeleteAppointment($appointment, $provider, + $service, $customer, $settings, new Email($provider['email']), + new Text($this->input->post('delete_reason'))); + } + + $send_customer = $this->settings_model->get_setting('customer_notifications'); + + if ((bool)$send_customer === TRUE) + { + $email->sendDeleteAppointment($appointment, $provider, + $service, $customer, $settings, new Email($customer['email']), + new Text($this->input->post('delete_reason'))); + } + + // Notify admins + $admins = $this->admins_model->get_batch(); + + foreach($admins as $admin) + { + if (!$admin['settings']['notifications'] === '0') + { + continue; + } + + $email->sendDeleteAppointment($appointment, $provider, + $service, $customer, $settings, new Email($admin['email']), + new Text($this->input->post('cancel_reason'))); + } + + // Notify secretaries + $secretaries = $this->secretaries_model->get_batch(); + + foreach($secretaries as $secretary) + { + if (!$secretary['settings']['notifications'] === '0') + { + continue; + } + + if (in_array($provider['id'], $secretary['providers'])) + { + continue; + } + + $email->sendDeleteAppointment($appointment, $provider, + $service, $customer, $settings, new Email($secretary['email']), + new Text($this->input->post('cancel_reason'))); + } + } + catch (Exception $exception) + { + $warnings[] = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + if (empty($warnings)) + { + $response = AJAX_SUCCESS; + } + else + { + $response = ['warnings' => $warnings]; + } + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + + /** + * Disable a providers sync setting. + * + * This method deletes the "google_sync" and "google_token" settings from the database. After that the provider's + * appointments will be no longer synced with google calendar. + */ + public function ajax_disable_provider_sync() + { + try + { + if ( ! $this->input->post('provider_id')) + { + throw new Exception('Provider id not specified.'); + } + + if ($this->privileges[PRIV_USERS]['edit'] == FALSE + && $this->session->userdata('user_id') != $this->input->post('provider_id')) + { + throw new Exception('You do not have the required privileges for this task.'); + } + + $this->load->model('providers_model'); + $this->load->model('appointments_model'); + $this->providers_model->set_setting('google_sync', FALSE, $this->input->post('provider_id')); + $this->providers_model->set_setting('google_token', NULL, $this->input->post('provider_id')); + $this->appointments_model->clear_google_sync_ids($this->input->post('provider_id')); + + $response = AJAX_SUCCESS; + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + + /** + * Filter the customer records with the given key string. + * + * Outputs the search results. + */ + public function ajax_filter_customers() + { + try + { + if ($this->privileges[PRIV_CUSTOMERS]['view'] == FALSE) + { + throw new Exception('You do not have the required privileges for this task.'); + } + + $this->load->model('appointments_model'); + $this->load->model('services_model'); + $this->load->model('providers_model'); + $this->load->model('customers_model'); + + $key = $this->db->escape_str($this->input->post('key')); + $key = strtoupper($key); + + $where = + '(first_name LIKE upper("%' . $key . '%") OR ' . + 'last_name LIKE upper("%' . $key . '%") OR ' . + 'email LIKE upper("%' . $key . '%") OR ' . + 'phone_number LIKE upper("%' . $key . '%") OR ' . + 'address LIKE upper("%' . $key . '%") OR ' . + 'city LIKE upper("%' . $key . '%") OR ' . + 'zip_code LIKE upper("%' . $key . '%") OR ' . + 'notes LIKE upper("%' . $key . '%"))'; + + $order_by = 'first_name ASC, last_name ASC'; + + $limit = $this->input->post('limit'); + + if ($limit === NULL) + { + $limit = 1000; + } + + $customers = $this->customers_model->get_batch($where, $order_by, $limit); + + foreach ($customers as &$customer) + { + $appointments = $this->appointments_model + ->get_batch(['id_users_customer' => $customer['id']]); + + foreach ($appointments as &$appointment) + { + $appointment['service'] = $this->services_model->get_row($appointment['id_services']); + $appointment['provider'] = $this->providers_model->get_row($appointment['id_users_provider']); + } + + $customer['appointments'] = $appointments; + } + + $response = $customers; + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + + /** + * Insert of update unavailable time period to database. + */ + public function ajax_save_unavailable() + { + try + { + // Check privileges + $unavailable = json_decode($this->input->post('unavailable'), TRUE); + + $required_privileges = ( ! isset($unavailable['id'])) + ? $this->privileges[PRIV_APPOINTMENTS]['add'] + : $this->privileges[PRIV_APPOINTMENTS]['edit']; + if ($required_privileges == FALSE) + { + throw new Exception('You do not have the required privileges for this task.'); + } + + $this->load->model('appointments_model'); + $this->load->model('providers_model'); + + $provider = $this->providers_model->get_row($unavailable['id_users_provider']); + + // Add appointment + $unavailable['id'] = $this->appointments_model->add_unavailable($unavailable); + $unavailable = $this->appointments_model->get_row($unavailable['id']); // fetch all inserted data + + // Google Sync + try + { + $google_sync = $this->providers_model->get_setting('google_sync', + $unavailable['id_users_provider']); + + if ($google_sync) + { + $google_token = json_decode($this->providers_model->get_setting('google_token', + $unavailable['id_users_provider'])); + + $this->load->library('google_sync'); + $this->google_sync->refresh_token($google_token->refresh_token); + + if ($unavailable['id_google_calendar'] == NULL) + { + $google_event = $this->google_sync->add_unavailable($provider, $unavailable); + $unavailable['id_google_calendar'] = $google_event->id; + $this->appointments_model->add_unavailable($unavailable); + } + else + { + $this->google_sync->update_unavailable($provider, $unavailable); + } + } + } + catch (Exception $exception) + { + $warnings[] = $exception; + } + + if (isset($warnings)) + { + $this->output + ->set_content_type('application/json') + ->set_output(json_encode(['warnings' => $warnings])); + } + else + { + $this->output + ->set_content_type('application/json') + ->set_output(json_encode(AJAX_SUCCESS)); + } + + $response = AJAX_SUCCESS; + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + + /** + * Delete an unavailable time period from database. + */ + public function ajax_delete_unavailable() + { + try + { + if ($this->privileges[PRIV_APPOINTMENTS]['delete'] == FALSE) + { + throw new Exception('You do not have the required privileges for this task.'); + } + + $this->load->model('appointments_model'); + $this->load->model('providers_model'); + + $unavailable = $this->appointments_model->get_row($this->input->post('unavailable_id')); + $provider = $this->providers_model->get_row($unavailable['id_users_provider']); + + // Delete unavailable + $this->appointments_model->delete_unavailable($unavailable['id']); + + // Google Sync + try + { + $google_sync = $this->providers_model->get_setting('google_sync', $provider['id']); + if ($google_sync == TRUE) + { + $google_token = json_decode($this->providers_model->get_setting('google_token', $provider['id'])); + $this->load->library('google_sync'); + $this->google_sync->refresh_token($google_token->refresh_token); + $this->google_sync->delete_unavailable($provider, $unavailable['id_google_calendar']); + } + } + catch (Exception $exception) + { + $warnings[] = $exception; + } + + if (empty($warnings)) + { + $response = AJAX_SUCCESS; + } + else + { + $response = ['warnings' => $warnings]; + } + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + + /** + * Insert of update working plan exceptions to database. + */ + public function ajax_save_working_plan_exception() + { + try + { + // Check privileges + $required_privileges = $this->privileges[PRIV_USERS]['edit']; + + if ($required_privileges == FALSE) + { + throw new Exception('You do not have the required privileges for this task.'); + } + + $date = $this->input->post('date'); + $working_plan_exception = $this->input->post('working_plan_exception'); + $provider_id = $this->input->post('provider_id'); + + $this->load->model('providers_model'); + + $success = $this->providers_model->save_working_plan_exception($date, $working_plan_exception, $provider_id); + + if ($success) + { + $response = AJAX_SUCCESS; + } + else + { + $response = ['warnings' => 'Error on saving working plan exception.']; + } + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + + /** + * Delete an working plan exceptions time period to database. + */ + public function ajax_delete_working_plan_exception() + { + try + { + // Check privileges + $required_privileges = $this->privileges[PRIV_USERS]['edit']; + + if ($required_privileges == FALSE) + { + throw new Exception('You do not have the required privileges for this task.'); + } + + $date = $this->input->post('date'); + $provider_id = $this->input->post('provider_id'); + + $this->load->model('providers_model'); + + $success = $this->providers_model->delete_working_plan_exception($date, $provider_id); + + if ($success) + { + $response = AJAX_SUCCESS; + } + else + { + $response = ['warnings' => 'Error on deleting working plan exception.']; + } + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + + /** + * Save (insert or update) a customer record. + */ + public function ajax_save_customer() + { + try + { + $this->load->model('customers_model'); + $customer = json_decode($this->input->post('customer'), TRUE); + + $required_privilegesileges = ( ! isset($customer['id'])) + ? $this->privileges[PRIV_CUSTOMERS]['add'] + : $this->privileges[PRIV_CUSTOMERS]['edit']; + if ($required_privilegesileges == FALSE) + { + throw new Exception('You do not have the required privileges for this task.'); + } + + $customer_id = $this->customers_model->add($customer); + + $response = [ + 'status' => AJAX_SUCCESS, + 'id' => $customer_id + ]; + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + + /** + * Delete customer from database. + */ + public function ajax_delete_customer() + { + try + { + if ($this->privileges[PRIV_CUSTOMERS]['delete'] == FALSE) + { + throw new Exception('You do not have the required privileges for this task.'); + } + + $this->load->model('customers_model'); + + $this->customers_model->delete($this->input->post('customer_id')); + + $response = AJAX_SUCCESS; + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + + /** + * Save (insert or update) service record. + */ + public function ajax_save_service() + { + try + { + $this->load->model('services_model'); + $service = json_decode($this->input->post('service'), TRUE); + + $required_privilegesileges = ( ! isset($service['id'])) + ? $this->privileges[PRIV_SERVICES]['add'] + : $this->privileges[PRIV_SERVICES]['edit']; + if ($required_privilegesileges == FALSE) + { + throw new Exception('You do not have the required privileges for this task.'); + } + + $service_id = $this->services_model->add($service); + $response = [ + 'status' => AJAX_SUCCESS, + 'id' => $service_id + ]; + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + + /** + * Delete service record from database. + */ + public function ajax_delete_service() + { + try + { + if ($this->privileges[PRIV_SERVICES]['delete'] == FALSE) + { + throw new Exception('You do not have the required privileges for this task.'); + } + + $this->load->model('services_model'); + + $result = $this->services_model->delete($this->input->post('service_id')); + + $response = $result ? AJAX_SUCCESS : AJAX_FAILURE; + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + + /** + * Filter service records by given key string. + */ + public function ajax_filter_services() + { + try + { + if ($this->privileges[PRIV_SERVICES]['view'] == FALSE) + { + throw new Exception('You do not have the required privileges for this task.'); + } + + $this->load->model('services_model'); + + $key = $this->db->escape_str($this->input->post('key')); + + $where = + '(name LIKE "%' . $key . '%" OR duration LIKE "%' . $key . '%" OR ' . + 'price LIKE "%' . $key . '%" OR currency LIKE "%' . $key . '%" OR ' . + 'description LIKE "%' . $key . '%")'; + + $response = $this->services_model->get_batch($where); + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + + /** + * Save (insert or update) category record. + */ + public function ajax_save_service_category() + { + try + { + $this->load->model('services_model'); + $category = json_decode($this->input->post('category'), TRUE); + + $required_privilegesileges = ( ! isset($category['id'])) + ? $this->privileges[PRIV_SERVICES]['add'] + : $this->privileges[PRIV_SERVICES]['edit']; + if ($required_privilegesileges == FALSE) + { + throw new Exception('You do not have the required privileges for this task.'); + } + + $category_id = $this->services_model->add_category($category); + + $response = [ + 'status' => AJAX_SUCCESS, + 'id' => $category_id + ]; + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + + /** + * Delete category record from database. + */ + public function ajax_delete_service_category() + { + try + { + if ($this->privileges[PRIV_SERVICES]['delete'] == FALSE) + { + throw new Exception('You do not have the required privileges for this task.'); + } + + $this->load->model('services_model'); + + $result = $this->services_model->delete_category($this->input->post('category_id')); + + $response = $result ? AJAX_SUCCESS : AJAX_FAILURE; + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + + /** + * Filter services categories with key string. + */ + public function ajax_filter_service_categories() + { + try + { + if ($this->privileges[PRIV_SERVICES]['view'] == FALSE) + { + throw new Exception('You do not have the required privileges for this task.'); + } + + $this->load->model('services_model'); + + $key = $this->db->escape_str($this->input->post('key')); + + $where = '(name LIKE "%' . $key . '%" OR description LIKE "%' . $key . '%")'; + + $response = $this->services_model->get_all_categories($where); + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + + /** + * Filter admin records with string key. + */ + public function ajax_filter_admins() + { + try + { + if ($this->privileges[PRIV_USERS]['view'] == FALSE) + { + throw new Exception('You do not have the required privileges for this task.'); + } + + $this->load->model('admins_model'); + + $key = $this->db->escape_str($this->input->post('key')); + + $where = + '(first_name LIKE "%' . $key . '%" OR last_name LIKE "%' . $key . '%" ' . + 'OR email LIKE "%' . $key . '%" OR mobile_number LIKE "%' . $key . '%" ' . + 'OR phone_number LIKE "%' . $key . '%" OR address LIKE "%' . $key . '%" ' . + 'OR city LIKE "%' . $key . '%" OR state LIKE "%' . $key . '%" ' . + 'OR zip_code LIKE "%' . $key . '%" OR notes LIKE "%' . $key . '%")'; + + $response = $this->admins_model->get_batch($where); + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + + /** + * Save (insert or update) admin record into database. + */ + public function ajax_save_admin() + { + try + { + $this->load->model('admins_model'); + $admin = json_decode($this->input->post('admin'), TRUE); + + $required_privileges = ( ! isset($admin['id'])) + ? $this->privileges[PRIV_USERS]['add'] + : $this->privileges[PRIV_USERS]['edit']; + if ($required_privileges == FALSE) + { + throw new Exception('You do not have the required privileges for this task.'); + } + + $admin_id = $this->admins_model->add($admin); + + $response = [ + 'status' => AJAX_SUCCESS, + 'id' => $admin_id + ]; + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + + /** + * Delete an admin record from the database. + */ + public function ajax_delete_admin() + { + try + { + if ($this->privileges[PRIV_USERS]['delete'] == FALSE) + { + throw new Exception('You do not have the required privileges for this task.'); + } + + $this->load->model('admins_model'); + + $result = $this->admins_model->delete($this->input->post('admin_id')); + + $response = $result ? AJAX_SUCCESS : AJAX_FAILURE; + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + + /** + * Filter provider records with string key. + */ + public function ajax_filter_providers() + { + try + { + if ($this->privileges[PRIV_USERS]['view'] == FALSE) + { + throw new Exception('You do not have the required privileges for this task.'); + } + + $this->load->model('providers_model'); + + $key = $this->db->escape_str($this->input->post('key')); + + $where = + '(first_name LIKE "%' . $key . '%" OR last_name LIKE "%' . $key . '%" ' . + 'OR email LIKE "%' . $key . '%" OR mobile_number LIKE "%' . $key . '%" ' . + 'OR phone_number LIKE "%' . $key . '%" OR address LIKE "%' . $key . '%" ' . + 'OR city LIKE "%' . $key . '%" OR state LIKE "%' . $key . '%" ' . + 'OR zip_code LIKE "%' . $key . '%" OR notes LIKE "%' . $key . '%")'; + + $response = $this->providers_model->get_batch($where); + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + + /** + * Save (insert or update) a provider record into database. + */ + public function ajax_save_provider() + { + try + { + $this->load->model('providers_model'); + $provider = json_decode($this->input->post('provider'), TRUE); + + $required_privileges = ( ! isset($provider['id'])) + ? $this->privileges[PRIV_USERS]['add'] + : $this->privileges[PRIV_USERS]['edit']; + if ($required_privileges == FALSE) + { + throw new Exception('You do not have the required privileges for this task.'); + } + + if ( ! isset($provider['settings']['working_plan'])) + { + $this->load->model('settings_model'); + $provider['settings']['working_plan'] = $this->settings_model + ->get_setting('company_working_plan'); + } + + $provider_id = $this->providers_model->add($provider); + + $response = [ + 'status' => AJAX_SUCCESS, + 'id' => $provider_id + ]; + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + + /** + * Delete a provider record from the database. + */ + public function ajax_delete_provider() + { + try + { + if ($this->privileges[PRIV_USERS]['delete'] == FALSE) + { + throw new Exception('You do not have the required privileges for this task.'); + } + + $this->load->model('providers_model'); + + $result = $this->providers_model->delete($this->input->post('provider_id')); + + $response =$result ? AJAX_SUCCESS : AJAX_FAILURE; + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + + /** + * Filter secretary records with string key. + */ + public function ajax_filter_secretaries() + { + try + { + if ($this->privileges[PRIV_USERS]['view'] == FALSE) + { + throw new Exception('You do not have the required privileges for this task.'); + } + + $this->load->model('secretaries_model'); + + $key = $this->db->escape_str($this->input->post('key')); + + $where = + '(first_name LIKE "%' . $key . '%" OR last_name LIKE "%' . $key . '%" ' . + 'OR email LIKE "%' . $key . '%" OR mobile_number LIKE "%' . $key . '%" ' . + 'OR phone_number LIKE "%' . $key . '%" OR address LIKE "%' . $key . '%" ' . + 'OR city LIKE "%' . $key . '%" OR state LIKE "%' . $key . '%" ' . + 'OR zip_code LIKE "%' . $key . '%" OR notes LIKE "%' . $key . '%")'; + + $response = $this->secretaries_model->get_batch($where); + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + + /** + * Save (insert or update) a secretary record into database. + */ + public function ajax_save_secretary() + { + try + { + $this->load->model('secretaries_model'); + $secretary = json_decode($this->input->post('secretary'), TRUE); + + $required_privileges = ( ! isset($secretary['id'])) + ? $this->privileges[PRIV_USERS]['add'] + : $this->privileges[PRIV_USERS]['edit']; + if ($required_privileges == FALSE) + { + throw new Exception('You do not have the required privileges for this task.'); + } + + $secretary_id = $this->secretaries_model->add($secretary); + + $response =[ + 'status' => AJAX_SUCCESS, + 'id' => $secretary_id + ]; + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + + /** + * Delete a secretary record from the database. + */ + public function ajax_delete_secretary() + { + try + { + if ($this->privileges[PRIV_USERS]['delete'] == FALSE) + { + throw new Exception('You do not have the required privileges for this task.'); + } + + $this->load->model('secretaries_model'); + + $result = $this->secretaries_model->delete($this->input->post('secretary_id')); + + $response =$result ? AJAX_SUCCESS : AJAX_FAILURE; + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + + /** + * Save a setting or multiple settings in the database. + */ + public function ajax_save_settings() + { + try + { + if ($this->input->post('type') == SETTINGS_SYSTEM) + { + if ($this->privileges[PRIV_SYSTEM_SETTINGS]['edit'] == FALSE) + { + throw new Exception('You do not have the required privileges for this task.'); + } + + $this->load->model('settings_model'); + + $settings = json_decode($this->input->post('settings', FALSE), TRUE); + + $this->settings_model->save_settings($settings); + } + else + { + if ($this->input->post('type') == SETTINGS_USER) + { + if ($this->privileges[PRIV_USER_SETTINGS]['edit'] == FALSE) + { + throw new Exception('You do not have the required privileges for this task.'); + } + + $this->load->model('user_model'); + + $this->user_model->save_user(json_decode($this->input->post('settings'), TRUE)); + } + } + + $response = AJAX_SUCCESS; + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + + /** + * This method checks whether the username already exists in the database. + */ + public function ajax_validate_username() + { + try + { + // We will only use the function in the admins_model because it is sufficient for the rest user types for + // now (providers, secretaries). + + $this->load->model('admins_model'); + + $is_valid = $this->admins_model->validate_username($this->input->post('username'), + $this->input->post('user_id')); + + $response = $is_valid; + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + + /** + * Change system language for current user. + * + * The language setting is stored in session data and retrieved every time the user visits any of the system pages. + */ + public function ajax_change_language() + { + try + { + // Check if language exists in the available languages. + $found = FALSE; + + foreach ($this->config->item('available_languages') as $lang) + { + if ($lang == $this->input->post('language')) + { + $found = TRUE; + break; + } + } + + if ( ! $found) + { + throw new Exception('Translations for the given language does not exist (' . $this->input->post('language') . ').'); + } + + $this->session->set_userdata('language', $this->input->post('language')); + $this->config->set_item('language', $this->input->post('language')); + + $response = AJAX_SUCCESS; + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + + /** + * This method will return a list of the available google calendars. + * + * The user will need to select a specific calendar from this list to sync his appointments with. Google access must + * be already granted for the specific provider. + */ + public function ajax_get_google_calendars() + { + try + { + $this->load->library('google_sync'); + $this->load->model('providers_model'); + + if ( ! $this->input->post('provider_id')) + { + throw new Exception('Provider id is required in order to fetch the google calendars.'); + } + + // Check if selected provider has sync enabled. + $google_sync = $this->providers_model->get_setting('google_sync', $this->input->post('provider_id')); + + if ($google_sync) + { + $google_token = json_decode($this->providers_model->get_setting('google_token', + $this->input->post('provider_id'))); + $this->google_sync->refresh_token($google_token->refresh_token); + + $calendars = $this->google_sync->get_google_calendars(); + + $response = $calendars; + } + else + { + $response =AJAX_FAILURE; + } + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + + /** + * Select a specific google calendar for a provider. + * + * All the appointments will be synced with this particular calendar. + */ + public function ajax_select_google_calendar() + { + try + { + if ($this->privileges[PRIV_USERS]['edit'] == FALSE + && $this->session->userdata('user_id') != $this->input->post('provider_id')) + { + throw new Exception('You do not have the required privileges for this task.'); + } + + $this->load->model('providers_model'); + + $result = $this->providers_model->set_setting('google_calendar', $this->input->post('calendar_id'), + $this->input->post('provider_id')); + + $response = $result ? AJAX_SUCCESS : AJAX_FAILURE; + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + + /** + * Apply global working plan to all providers. + */ + public function ajax_apply_global_working_plan() + { + try + { + if ($this->privileges[PRIV_SYSTEM_SETTINGS]['edit'] == FALSE) + { + throw new Exception('You do not have the required privileges for this task.'); + } + + $working_plan = $this->input->post('working_plan'); + + $this->load->model('providers_model'); + + $providers = $this->providers_model->get_batch(); + + foreach ($providers as $provider) + { + $this->providers_model->set_setting('working_plan', $working_plan, $provider['id']); + } + + $response =AJAX_SUCCESS; + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } +} diff --git a/application/controllers/Captcha.php b/application/controllers/Captcha.php new file mode 100644 index 00000000..897aa0fa --- /dev/null +++ b/application/controllers/Captcha.php @@ -0,0 +1,62 @@ + + * @copyright Copyright (c) 2013 - 2020, Alex Tselegidis + * @license http://opensource.org/licenses/GPL-3.0 - GPLv3 + * @link http://easyappointments.org + * @since v1.0.0 + * ---------------------------------------------------------------------------- */ + +/** + * Captcha Controller + * + * @property CI_Session $session + * @property CI_Loader $load + * @property CI_Input $input + * @property CI_Output $output + * @property CI_Config $config + * @property CI_Lang $lang + * @property CI_Cache $cache + * @property CI_DB_query_builder $db + * @property CI_Security $security + * @property Google_Sync $google_sync + * @property Ics_file $ics_file + * @property Appointments_Model $appointments_model + * @property Providers_Model $providers_model + * @property Services_Model $services_model + * @property Customers_Model $customers_model + * @property Settings_Model $settings_model + * @property Timezones $timezones + * @property Roles_Model $roles_model + * @property Secretaries_Model $secretaries_model + * @property Admins_Model $admins_model + * @property User_Model $user_model + * + * @package Controllers + */ +class Captcha extends CI_Controller { + /** + * Class Constructor + */ + public function __construct() + { + parent::__construct(); + $this->load->library('session'); + } + + /** + * Make a request to this method to get a captcha image. + */ + public function index() + { + header('Content-type: image/jpeg'); + $builder = new Gregwar\Captcha\CaptchaBuilder; + $builder->build(); + $this->session->set_userdata('captcha_phrase', $builder->getPhrase()); + $builder->output(); + } +} diff --git a/application/controllers/Consents.php b/application/controllers/Consents.php new file mode 100644 index 00000000..bd8e4fb2 --- /dev/null +++ b/application/controllers/Consents.php @@ -0,0 +1,79 @@ + + * @copyright Copyright (c) 2013 - 2020, Alex Tselegidis + * @license http://opensource.org/licenses/GPL-3.0 - GPLv3 + * @link http://easyappointments.org + * @since v1.3.2 + * ---------------------------------------------------------------------------- */ + +/** + * Class Consent + * + * Handles user consent related operations. + * + * @property CI_Session $session + * @property CI_Loader $load + * @property CI_Input $input + * @property CI_Output $output + * @property CI_Config $config + * @property CI_Lang $lang + * @property CI_Cache $cache + * @property CI_DB_query_builder $db + * @property CI_Security $security + * @property Google_Sync $google_sync + * @property Ics_file $ics_file + * @property Appointments_Model $appointments_model + * @property Providers_Model $providers_model + * @property Services_Model $services_model + * @property Customers_Model $customers_model + * @property Consents_Model consents_model + * @property Settings_Model $settings_model + * @property Timezones $timezones + * @property Roles_Model $roles_model + * @property Secretaries_Model $secretaries_model + * @property Admins_Model $admins_model + * @property User_Model $user_model + * + * @package Controllers + */ +class Consents extends CI_Controller { + /** + * Save the user's consent. + */ + public function ajax_save_consent() + { + try + { + $this->load->model('consents_model'); + + $consent = $this->input->post('consent'); + + $consent['ip'] = $this->input->ip_address(); + + $consent['id'] = $this->consents_model->add($consent); + + $response = [ + 'success' => TRUE, + 'id' => $consent['id'] + ]; + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } +} diff --git a/application/controllers/Console.php b/application/controllers/Console.php new file mode 100644 index 00000000..afea3f9c --- /dev/null +++ b/application/controllers/Console.php @@ -0,0 +1,90 @@ + + * @copyright Copyright (c) 2013 - 2020, Alex Tselegidis + * @license http://opensource.org/licenses/GPL-3.0 - GPLv3 + * @link http://easyappointments.org + * @since v1.3.2 + * ---------------------------------------------------------------------------- */ + +require_once __DIR__ .'/Google.php'; + +/** + * Class Console + * + * CLI commands of Easy!Appointments, can only be executed from a terminal and not with a direct request. + * + * @property CI_Migration $migration + * @property Providers_model $providers_model + */ +class Console extends CI_Controller { + /** + * Console constructor. + */ + public function __construct() + { + if ( ! is_cli()) + { + exit('No direct script access allowed'); + } + + parent::__construct(); + + $this->load->library('migration'); + $this->load->model('providers_model'); + } + + /** + * Migrate the database to the latest state. + * + * Use this method to upgrade an existing installation to the latest database state. + * + * Notice: + * + * Do not use this method to install the app as it will not seed the database with the initial entries (admin, + * provider, service, settings etc). Use the UI installation page for this. + * + * Usage: + * + * php index.php console migrate + */ + public function migrate() + { + if ($this->migration->current() === FALSE) + { + show_error($this->migration->error_string()); + } + } + + /** + * Trigger the synchronization of all provider calendars with Google Calendar. + * + * Use this method in a cronjob to automatically sync events between Easy!Appointments and Google Calendar. + * + * Notice: + * + * Google syncing must first be enabled for each individual provider from inside the backend calendar page. + * + * Usage: + * + * php index.php console sync + */ + public function sync() + { + $providers = $this->providers_model->get_batch(); + + foreach ($providers as $provider) + { + if ( ! filter_var($provider['settings']['google_sync'], FILTER_VALIDATE_BOOLEAN)) + { + continue; + } + + Google::sync($provider['id']); + } + } +} diff --git a/application/controllers/Errors.php b/application/controllers/Errors.php new file mode 100644 index 00000000..425a35ed --- /dev/null +++ b/application/controllers/Errors.php @@ -0,0 +1,84 @@ + + * @copyright Copyright (c) 2013 - 2020, Alex Tselegidis + * @license http://opensource.org/licenses/GPL-3.0 - GPLv3 + * @link http://easyappointments.org + * @since v1.0.0 + * ---------------------------------------------------------------------------- */ + +/** + * Errors Controller + * + * @property CI_Session $session + * @property CI_Loader $load + * @property CI_Input $input + * @property CI_Output $output + * @property CI_Config $config + * @property CI_Lang $lang + * @property CI_Cache $cache + * @property CI_DB_query_builder $db + * @property CI_Security $security + * @property Google_Sync $google_sync + * @property Ics_file $ics_file + * @property Appointments_Model $appointments_model + * @property Providers_Model $providers_model + * @property Services_Model $services_model + * @property Customers_Model $customers_model + * @property Settings_Model $settings_model + * @property Timezones $timezones + * @property Roles_Model $roles_model + * @property Secretaries_Model $secretaries_model + * @property Admins_Model $admins_model + * @property User_Model $user_model + * + * @package Controllers + */ +class Errors extends CI_Controller { + /** + * Class Constructor + */ + public function __construct() + { + parent::__construct(); + + $this->load->library('session'); + + if ($this->session->userdata('language')) + { + // Set user's selected language. + $this->config->set_item('language', $this->session->userdata('language')); + $this->lang->load('translations', $this->session->userdata('language')); + } + else + { + // Set the default language. + $this->lang->load('translations', $this->config->item('language')); + } + } + + /** + * Display the 404 error page. + */ + public function index() + { + $this->error404(); + } + + /** + * Display the 404 error page. + */ + public function error404() + { + $this->load->helper('google_analytics'); + $this->load->model('settings_model'); + + $view['company_name'] = $this->settings_model->get_setting('company_name'); + + $this->load->view('general/error404', $view); + } +} diff --git a/application/controllers/Google.php b/application/controllers/Google.php new file mode 100644 index 00000000..455d8908 --- /dev/null +++ b/application/controllers/Google.php @@ -0,0 +1,320 @@ + + * @copyright Copyright (c) 2013 - 2020, Alex Tselegidis + * @license http://opensource.org/licenses/GPL-3.0 - GPLv3 + * @link http://easyappointments.org + * @since v1.0.0 + * ---------------------------------------------------------------------------- */ + +/** + * Google Controller + * + * This controller handles the Google Calendar synchronization operations. + * + * @property CI_Session $session + * @property CI_Loader $load + * @property CI_Input $input + * @property CI_Output $output + * @property CI_Config $config + * @property CI_Lang $lang + * @property CI_Cache $cache + * @property CI_DB_query_builder $db + * @property CI_Security $security + * @property Google_Sync $google_sync + * @property Ics_file $ics_file + * @property Appointments_Model $appointments_model + * @property Providers_Model $providers_model + * @property Services_Model $services_model + * @property Customers_Model $customers_model + * @property Settings_Model $settings_model + * @property Timezones $timezones + * @property Roles_Model $roles_model + * @property Secretaries_Model $secretaries_model + * @property Admins_Model $admins_model + * @property User_Model $user_model + * + * @package Controllers + */ +class Google extends CI_Controller { + /** + * Class Constructor + */ + public function __construct() + { + parent::__construct(); + + $this->load->library('session'); + } + + /** + * Authorize Google Calendar API usage for a specific provider. + * + * Since it is required to follow the web application flow, in order to retrieve a refresh token from the Google API + * service, this method is going to authorize the given provider. + * + * @param int $provider_id The provider id, for whom the sync authorization is made. + */ + public function oauth($provider_id) + { + // Store the provider id for use on the callback function. + $this->session->set_userdata('oauth_provider_id', $provider_id); + + // Redirect browser to google user content page. + $this->load->library('google_sync'); + header('Location: ' . $this->google_sync->get_auth_url()); + } + + /** + * Callback method for the Google Calendar API authorization process. + * + * Once the user grants consent with his Google Calendar data usage, the Google OAuth service will redirect him back + * in this page. Here we are going to store the refresh token, because this is what will be used to generate access + * tokens in the future. + * + * IMPORTANT: Because it is necessary to authorize the application using the web server flow (see official + * documentation of OAuth), every Easy!Appointments installation should use its own calendar api key. So in every + * api console account, the "http://path-to-Easy!Appointments/google/oauth_callback" should be included in an allowed redirect URL. + */ + public function oauth_callback() + { + $code = $this->input->get('code'); + + if (empty($code)) + { + $this->output->set_output('Code authorization failed.'); + return; + } + + $this->load->library('Google_sync'); + + $token = $this->google_sync->authenticate($code); + + if (empty($token)) + { + $this->output->set_output('Token authorization failed.'); + return; + } + + // Store the token into the database for future reference. + $oauth_provider_id = $this->session->userdata('oauth_provider_id'); + + if ($oauth_provider_id) + { + $this->load->model('providers_model'); + $this->providers_model->set_setting('google_sync', TRUE, $oauth_provider_id); + $this->providers_model->set_setting('google_token', json_encode($token), $oauth_provider_id); + $this->providers_model->set_setting('google_calendar', 'primary', $oauth_provider_id); + } + else + { + $this->output->set_output('Sync provider id not specified.'); + } + } + + /** + * Complete synchronization of appointments between Google Calendar and Easy!Appointments. + * + * This method will completely sync the appointments of a provider with his Google Calendar account. The sync period + * needs to be relatively small, because a lot of API calls might be necessary and this will lead to consuming the + * Google limit for the Calendar API usage. + * + * @param int $provider_id Provider record to be synced. + */ + public static function sync($provider_id = NULL) + { + try + { + $framework = get_instance(); + + // The user must be logged in. + $framework->load->library('session'); + + if ($framework->session->userdata('user_id') == FALSE && is_cli() === FALSE) + { + return; + } + + if ($provider_id === NULL) + { + throw new Exception('Provider id not specified.'); + } + + $framework->load->model('appointments_model'); + $framework->load->model('providers_model'); + $framework->load->model('services_model'); + $framework->load->model('customers_model'); + $framework->load->model('settings_model'); + + $provider = $framework->providers_model->get_row($provider_id); + + // Check whether the selected provider has google sync enabled. + $google_sync = $framework->providers_model->get_setting('google_sync', $provider['id']); + + if ( ! $google_sync) + { + throw new Exception('The selected provider has not the google synchronization setting enabled.'); + } + + $google_token = json_decode($framework->providers_model->get_setting('google_token', $provider['id'])); + $framework->load->library('google_sync'); + $framework->google_sync->refresh_token($google_token->refresh_token); + + // Fetch provider's appointments that belong to the sync time period. + $sync_past_days = $framework->providers_model->get_setting('sync_past_days', $provider['id']); + $sync_future_days = $framework->providers_model->get_setting('sync_future_days', $provider['id']); + $start = strtotime('-' . $sync_past_days . ' days', strtotime(date('Y-m-d'))); + $end = strtotime('+' . $sync_future_days . ' days', strtotime(date('Y-m-d'))); + + $where_clause = [ + 'start_datetime >=' => date('Y-m-d H:i:s', $start), + 'end_datetime <=' => date('Y-m-d H:i:s', $end), + 'id_users_provider' => $provider['id'] + ]; + + $appointments = $framework->appointments_model->get_batch($where_clause); + + $company_settings = [ + 'company_name' => $framework->settings_model->get_setting('company_name'), + 'company_link' => $framework->settings_model->get_setting('company_link'), + 'company_email' => $framework->settings_model->get_setting('company_email') + ]; + + $provider_timezone = new DateTimeZone($provider['timezone']); + + // Sync each appointment with Google Calendar by following the project's sync protocol (see documentation). + foreach ($appointments as $appointment) + { + if ($appointment['is_unavailable'] == FALSE) + { + $service = $framework->services_model->get_row($appointment['id_services']); + $customer = $framework->customers_model->get_row($appointment['id_users_customer']); + } + else + { + $service = NULL; + $customer = NULL; + } + + // If current appointment not synced yet, add to Google Calendar. + if ($appointment['id_google_calendar'] == NULL) + { + $google_event = $framework->google_sync->add_appointment($appointment, $provider, + $service, $customer, $company_settings); + $appointment['id_google_calendar'] = $google_event->id; + $framework->appointments_model->add($appointment); // Save the Google Calendar ID. + } + else + { + // Appointment is synced with google calendar. + try + { + $google_event = $framework->google_sync->get_event($provider, $appointment['id_google_calendar']); + + if ($google_event->status == 'cancelled') + { + throw new Exception('Event is cancelled, remove the record from Easy!Appointments.'); + } + + // If Google Calendar event is different from Easy!Appointments appointment then update + // Easy!Appointments record. + $is_different = FALSE; + $appt_start = strtotime($appointment['start_datetime']); + $appt_end = strtotime($appointment['end_datetime']); + $event_start = new DateTime($google_event->getStart()->getDateTime()); + $event_start->setTimezone($provider_timezone); + $event_end = new DateTime($google_event->getEnd()->getDateTime()); + $event_end->setTimezone($provider_timezone); + + + if ($appt_start != $event_start->getTimestamp() || $appt_end != $event_end->getTimestamp() + || $appointment['notes'] !== $google_event->getDescription()) + { + $is_different = TRUE; + } + + if ($is_different) + { + $appointment['start_datetime'] = $event_start->format('Y-m-d H:i:s'); + $appointment['end_datetime'] = $event_end->format('Y-m-d H:i:s'); + $appointment['notes'] = $google_event->getDescription(); + $framework->appointments_model->add($appointment); + } + + } + catch (Exception $exception) + { + // Appointment not found on Google Calendar, delete from Easy!Appoinmtents. + $framework->appointments_model->delete($appointment['id']); + $appointment['id_google_calendar'] = NULL; + } + } + } + + // Add Google Calendar events that do not exist in Easy!Appointments. + $google_calendar = $provider['settings']['google_calendar']; + $google_events = $framework->google_sync->get_sync_events($google_calendar, $start, $end); + + foreach ($google_events->getItems() as $google_event) + { + if ($google_event->getStatus() === 'cancelled') + { + continue; + } + + if ($google_event->getStart() === NULL || $google_event->getEnd() === NULL) + { + continue; + } + + $results = $framework->appointments_model->get_batch(['id_google_calendar' => $google_event->getId()]); + + if ( ! empty($results)) + { + continue; + } + + $event_start = new DateTime($google_event->getStart()->getDateTime()); + $event_start->setTimezone($provider_timezone); + $event_end = new DateTime($google_event->getEnd()->getDateTime()); + $event_end->setTimezone($provider_timezone); + + // Record doesn't exist in the Easy!Appointments, so add the event now. + $appointment = [ + 'start_datetime' => $event_start->format('Y-m-d H:i:s'), + 'end_datetime' => $event_end->format('Y-m-d H:i:s'), + 'is_unavailable' => TRUE, + 'location' => $google_event->getLocation(), + 'notes' => $google_event->getSummary() . ' ' . $google_event->getDescription(), + 'id_users_provider' => $provider_id, + 'id_google_calendar' => $google_event->getId(), + 'id_users_customer' => NULL, + 'id_services' => NULL, + ]; + + $framework->appointments_model->add($appointment); + } + + $response = AJAX_SUCCESS; + } + catch (Exception $exception) + { + $framework->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $framework->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + + +} diff --git a/application/controllers/Installation.php b/application/controllers/Installation.php new file mode 100644 index 00000000..bfb2c5a6 --- /dev/null +++ b/application/controllers/Installation.php @@ -0,0 +1,187 @@ + + * @copyright Copyright (c) 2013 - 2020, Alex Tselegidis + * @license http://opensource.org/licenses/GPL-3.0 - GPLv3 + * @link http://easyappointments.org + * @since v1.1.0 + * ---------------------------------------------------------------------------- */ + +/** + * Installation Controller + * + * This controller will handle the installation procedure of Easy!Appointments. + * + * @property CI_Session $session + * @property CI_Loader $load + * @property CI_Input $input + * @property CI_Output $output + * @property CI_Config $config + * @property CI_Lang $lang + * @property CI_Cache $cache + * @property CI_DB_query_builder $db + * @property CI_Security $security + * @property CI_Migration migration + * @property Google_Sync $google_sync + * @property Ics_file $ics_file + * @property Appointments_Model $appointments_model + * @property Providers_Model $providers_model + * @property Services_Model $services_model + * @property Customers_Model $customers_model + * @property Settings_Model $settings_model + * @property Timezones $timezones + * @property Roles_Model $roles_model + * @property Secretaries_Model $secretaries_model + * @property Admins_Model $admins_model + * @property User_Model $user_model + * + * @package Controllers + */ +class Installation extends CI_Controller { + /** + * Class Constructor + */ + public function __construct() + { + parent::__construct(); + + $this->load->helper('installation'); + $this->load->library('session'); + + if ($this->session->userdata('language')) + { + // Set user's selected language. + $this->config->set_item('language', $this->session->userdata('language')); + $this->lang->load('translations', $this->session->userdata('language')); + } + else + { + // Set the default language. + $this->lang->load('translations', $this->config->item('language')); + } + } + + /** + * Display the installation page. + */ + public function index() + { + if (is_app_installed()) + { + redirect('appointments/index'); + return; + } + + $this->load->view('general/installation', [ + 'base_url' => $this->config->item('base_url') + ]); + } + + /** + * [AJAX] Installs Easy!Appointments on the server. + */ + public function ajax_install() + { + try + { + if (is_app_installed()) + { + return; + } + + $this->load->model('admins_model'); + $this->load->model('settings_model'); + $this->load->model('services_model'); + $this->load->model('providers_model'); + $this->load->library('session'); + $this->load->library('migration'); + $this->load->helper('string'); + + $admin = $this->input->post('admin'); + $company = $this->input->post('company'); + + if ( ! $this->migration->current()) + { + throw new Exception($this->migration->error_string()); + } + + // Insert admin + $admin['timezone'] = 'UTC'; + $admin['settings']['username'] = $admin['username']; + $admin['settings']['password'] = $admin['password']; + $admin['settings']['notifications'] = true; + $admin['settings']['calendar_view'] = CALENDAR_VIEW_DEFAULT; + unset($admin['username'], $admin['password']); + $admin['id'] = $this->admins_model->add($admin); + + $this->session->set_userdata('user_id', $admin['id']); + $this->session->set_userdata('user_email', $admin['email']); + $this->session->set_userdata('role_slug', DB_SLUG_ADMIN); + $this->session->set_userdata('timezone', $admin['timezone']); + $this->session->set_userdata('username', $admin['settings']['username']); + + // Save company settings + $this->settings_model->set_setting('company_name', $company['company_name']); + $this->settings_model->set_setting('company_email', $company['company_email']); + $this->settings_model->set_setting('company_link', $company['company_link']); + + // Create sample records. + $services = [ + 'name' => 'Test Service', + 'duration' => 30, + 'price' => 50.0, + 'currency' => 'EUR', + 'description' => 'This is a test service automatically inserted by the installer.', + 'availabilities_type' => 'flexible', + 'attendants_number' => 1, + 'id_service_categories' => NULL + ]; + $services['id'] = $this->services_model->add($services); + + $salt = generate_salt(); + $password = random_string('alnum', 12); + + $sample_provider = [ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'email' => 'john@example.org', + 'phone_number' => '0123456789', + 'services' => [ + $services['id'] + ], + 'settings' => [ + 'username' => 'johndoe', + 'password' => hash_password($salt, $password), + 'salt' => $salt, + 'working_plan' => '{"monday":{"start":"09:00","end":"18:00","breaks":[{"start":"14:30","end":"15:00"}]},"tuesday":{"start":"09:00","end":"18:00","breaks":[{"start":"14:30","end":"15:00"}]},"wednesday":{"start":"09:00","end":"18:00","breaks":[{"start":"14:30","end":"15:00"}]},"thursday":{"start":"09:00","end":"18:00","breaks":[{"start":"14:30","end":"15:00"}]},"friday":{"start":"09:00","end":"18:00","breaks":[{"start":"14:30","end":"15:00"}]},"saturday":null,"sunday":null}', + 'notifications' => FALSE, + 'google_sync' => FALSE, + 'sync_past_days' => 5, + 'sync_future_days' => 5, + 'calendar_view' => CALENDAR_VIEW_DEFAULT + ] + ]; + + $this->providers_model->add($sample_provider); + + $response = AJAX_SUCCESS; + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } +} diff --git a/application/controllers/Privacy.php b/application/controllers/Privacy.php new file mode 100644 index 00000000..cf8647f3 --- /dev/null +++ b/application/controllers/Privacy.php @@ -0,0 +1,88 @@ + + * @copyright Copyright (c) 2013 - 2020, Alex Tselegidis + * @license http://opensource.org/licenses/GPL-3.0 - GPLv3 + * @link http://easyappointments.org + * @since v1.3.2 + * ---------------------------------------------------------------------------- */ + +/** + * Class Privacy + * + * @property CI_Session $session + * @property CI_Loader $load + * @property CI_Input $input + * @property CI_Output $output + * @property CI_Config $config + * @property CI_Lang $lang + * @property CI_Cache $cache + * @property CI_DB_query_builder $db + * @property CI_Security $security + * @property Google_Sync $google_sync + * @property Ics_file $ics_file + * @property Appointments_Model $appointments_model + * @property Providers_Model $providers_model + * @property Services_Model $services_model + * @property Customers_Model $customers_model + * @property Settings_Model $settings_model + * @property Timezones $timezones + * @property Roles_Model $roles_model + * @property Secretaries_Model $secretaries_model + * @property Admins_Model $admins_model + * @property User_Model $user_model + * + * @package Controllers + */ +class Privacy extends CI_Controller { + /** + * Remove all customer data (including appointments from the system). + */ + public function ajax_delete_personal_information() + { + try + { + $customer_token = $this->input->post('customer_token'); + + if (empty($customer_token)) + { + throw new InvalidArgumentException('Invalid customer token value provided.'); + } + + $this->load->driver('cache', ['adapter' => 'file']); + + $customer_id = $this->cache->get('customer-token-' . $customer_token); + + if (empty($customer_id)) + { + throw new InvalidArgumentException('Customer ID could not be found, please reload the page ' + . 'and try again.'); + } + + $this->load->model('customers_model'); + + $this->customers_model->delete($customer_id); + + $response = [ + 'success' => TRUE + ]; + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } +} diff --git a/application/controllers/User.php b/application/controllers/User.php new file mode 100644 index 00000000..4223773e --- /dev/null +++ b/application/controllers/User.php @@ -0,0 +1,249 @@ + + * @copyright Copyright (c) 2013 - 2020, Alex Tselegidis + * @license http://opensource.org/licenses/GPL-3.0 - GPLv3 + * @link http://easyappointments.org + * @since v1.0.0 + * ---------------------------------------------------------------------------- */ + +use EA\Engine\Notifications\Email as EmailClient; +use EA\Engine\Types\Email; +use EA\Engine\Types\NonEmptyText; + +/** + * User Controller + * + * @property CI_Session $session + * @property CI_Loader $load + * @property CI_Input $input + * @property CI_Output $output + * @property CI_Config $config + * @property CI_Lang $lang + * @property CI_Cache $cache + * @property CI_DB_query_builder $db + * @property CI_Security $security + * @property Google_Sync $google_sync + * @property Ics_file $ics_file + * @property Appointments_Model $appointments_model + * @property Providers_Model $providers_model + * @property Services_Model $services_model + * @property Customers_Model $customers_model + * @property Settings_Model $settings_model + * @property Timezones $timezones + * @property Roles_Model $roles_model + * @property Secretaries_Model $secretaries_model + * @property Admins_Model $admins_model + * @property User_Model $user_model + * + * @package Controllers + */ +class User extends CI_Controller { + /** + * Class Constructor + */ + public function __construct() + { + parent::__construct(); + + $this->load->library('session'); + + if ($this->session->userdata('language')) + { + // Set user's selected language. + $this->config->set_item('language', $this->session->userdata('language')); + $this->lang->load('translations', $this->session->userdata('language')); + } + else + { + // Set the default language. + $this->lang->load('translations', $this->config->item('language')); // default + } + } + + /** + * Default Method + * + * The default method will redirect the browser to the user/login URL. + */ + public function index() + { + header('Location: ' . site_url('user/login')); + } + + /** + * Display the login page. + * + * @throws Exception + */ + public function login() + { + $this->load->model('settings_model'); + + $view['base_url'] = $this->config->item('base_url'); + $view['dest_url'] = $this->session->userdata('dest_url'); + + if ( ! $view['dest_url']) + { + $view['dest_url'] = site_url('backend'); + } + + $view['company_name'] = $this->settings_model->get_setting('company_name'); + + $this->load->view('user/login', $view); + } + + /** + * Display the logout page. + */ + public function logout() + { + $this->load->model('settings_model'); + + $this->session->unset_userdata('user_id'); + $this->session->unset_userdata('user_email'); + $this->session->unset_userdata('role_slug'); + $this->session->unset_userdata('username'); + $this->session->unset_userdata('dest_url'); + + $view['base_url'] = $this->config->item('base_url'); + $view['company_name'] = $this->settings_model->get_setting('company_name'); + $this->load->view('user/logout', $view); + } + + /** + * Display the "forgot password" page. + * @throws Exception + */ + public function forgot_password() + { + $this->load->model('settings_model'); + $view['base_url'] = $this->config->item('base_url'); + $view['company_name'] = $this->settings_model->get_setting('company_name'); + $this->load->view('user/forgot_password', $view); + } + + /** + * Display the "not authorized" page. + * @throws Exception + */ + public function no_privileges() + { + $this->load->model('settings_model'); + $view['base_url'] = $this->config->item('base_url'); + $view['company_name'] = $this->settings_model->get_setting('company_name'); + $this->load->view('user/no_privileges', $view); + } + + /** + * [AJAX] Check whether the user has entered the correct login credentials. + * + * The session data of a logged in user are the following: + * - 'user_id' + * - 'user_email' + * - 'role_slug' + * - 'dest_url' + */ + public function ajax_check_login() + { + try + { + if ( ! $this->input->post('username') || ! $this->input->post('password')) + { + throw new Exception('Invalid credentials given!'); + } + + $this->load->model('user_model'); + + $user_data = $this->user_model->check_login($this->input->post('username'), $this->input->post('password')); + + if ($user_data) + { + $this->session->set_userdata($user_data); // Save data on user's session. + + $response = AJAX_SUCCESS; + } + else + { + $response = AJAX_FAILURE; + } + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } + + /** + * Regenerate a new password for the current user, only if the username and + * email address given correspond to an existing user in db. + * + * Required POST Parameters: + * + * - string $_POST['username'] Username to be validated. + * - string $_POST['email'] Email to be validated. + */ + public function ajax_forgot_password() + { + try + { + if ( ! $this->input->post('username') || ! $this->input->post('email')) + { + throw new Exception('You must enter a valid username and email address in ' + . 'order to get a new password!'); + } + + $this->load->model('user_model'); + $this->load->model('settings_model'); + + $new_password = $this->user_model->regenerate_password( + $this->input->post('username'), + $this->input->post('email') + ); + + if ($new_password != FALSE) + { + $this->config->load('email'); + + $email = new EmailClient($this, $this->config->config); + + $company_settings = [ + 'company_name' => $this->settings_model->get_setting('company_name'), + 'company_link' => $this->settings_model->get_setting('company_link'), + 'company_email' => $this->settings_model->get_setting('company_email') + ]; + + $email->sendPassword(new NonEmptyText($new_password), new Email($this->input->post('email')), + $company_settings); + } + + $response = $new_password != FALSE ? AJAX_SUCCESS : AJAX_FAILURE; + } + catch (Exception $exception) + { + $this->output->set_status_header(500); + + $response = [ + 'message' => $exception->getMessage(), + 'trace' => config('debug') ? $exception->getTrace() : [] + ]; + } + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($response)); + } +} diff --git a/src/application/controllers/api/index.html b/application/controllers/api/index.html similarity index 100% rename from src/application/controllers/api/index.html rename to application/controllers/api/index.html diff --git a/application/controllers/api/v1/API_V1_Controller.php b/application/controllers/api/v1/API_V1_Controller.php new file mode 100644 index 00000000..b27e529b --- /dev/null +++ b/application/controllers/api/v1/API_V1_Controller.php @@ -0,0 +1,173 @@ + + * @copyright Copyright (c) 2013 - 2020, Alex Tselegidis + * @license http://opensource.org/licenses/GPL-3.0 - GPLv3 + * @link http://easyappointments.org + * @since v1.2.0 + * ---------------------------------------------------------------------------- */ + +use EA\Engine\Api\V1\Authorization; +use EA\Engine\Types\NonEmptyText; + +/** + * API V1 Controller + * + * Parent controller class for the API v1 resources. Extend this class instead of the CI_Controller + * and call the parent constructor. + * + * @package Controllers + */ +class API_V1_Controller extends CI_Controller { + /** + * Class Constructor + * + * This constructor will handle the common operations of each API call. + * + * Important: Do not forget to call the this constructor from the child classes. + * + * Notice: At the time being only the basic authentication is supported. Make sure + * that you use the API through SSL/TLS for security. + */ + public function __construct() + { + try + { + parent::__construct(); + + $this->load->model('settings_model'); + + $api_token = $this->settings_model->get_setting('api_token'); + + $authorization = new Authorization($this); + + if ( ! empty($api_token) && $api_token === $this->_getBearerToken()) + { + return; + } + + if ( ! isset($_SERVER['PHP_AUTH_USER'])) + { + $this->_requestAuthentication(); + return; + } + + $username = new NonEmptyText($_SERVER['PHP_AUTH_USER']); + $password = new NonEmptyText($_SERVER['PHP_AUTH_PW']); + $authorization->basic($username, $password); + } + catch (Exception $exception) + { + exit($this->_handleException($exception)); + } + } + + /** + * Returns the bearer token value. + * + * @return string + */ + protected function _getBearerToken() + { + $headers = $this->_getAuthorizationHeader(); + + // HEADER: Get the access token from the header + + if ( ! empty($headers)) + { + if (preg_match('/Bearer\s(\S+)/', $headers, $matches)) + { + return $matches[1]; + } + } + return NULL; + } + + /** + * Returns the authorization header. + * + * @return string + */ + protected function _getAuthorizationHeader() + { + $headers = NULL; + + if (isset($_SERVER['Authorization'])) + { + $headers = trim($_SERVER['Authorization']); + } + else + { + if (isset($_SERVER['HTTP_AUTHORIZATION'])) + { + //Nginx or fast CGI + $headers = trim($_SERVER['HTTP_AUTHORIZATION']); + } + elseif (function_exists('apache_request_headers')) + { + $requestHeaders = apache_request_headers(); + + // Server-side fix for bug in old Android versions (a nice side-effect of this fix means we don't care + // about capitalization for Authorization). + $requestHeaders = array_combine(array_map('ucwords', array_keys($requestHeaders)), array_values($requestHeaders)); + + if (isset($requestHeaders['Authorization'])) + { + $headers = trim($requestHeaders['Authorization']); + } + } + } + + return $headers; + } + + /** + * Sets request authentication headers. + */ + protected function _requestAuthentication() + { + header('WWW-Authenticate: Basic realm="Easy!Appointments"'); + header('HTTP/1.0 401 Unauthorized'); + exit('You are not authorized to use the API.'); + } + + /** + * Outputs the required headers and messages for exception handling. + * + * Call this method from catch blocks of child controller callbacks. + * + * @param Exception $exception Thrown exception to be outputted. + */ + protected function _handleException(Exception $exception) + { + $error = [ + 'code' => $exception->getCode() ?: 500, + 'message' => $exception->getMessage(), + ]; + + $header = $exception instanceof \EA\Engine\Api\V1\Exception + ? $exception->getCode() . ' ' . $exception->getHeader() + : '500 Internal Server Error'; + + header('HTTP/1.0 ' . $header); + header('Content-Type: application/json'); + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($error, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); + } + + /** + * Throw an API exception stating that the requested record was not found. + * + * @throws \EA\Engine\Api\V1\Exception + */ + protected function _throwRecordNotFound() + { + throw new \EA\Engine\Api\V1\Exception('The requested record was not found!', 404, 'Not Found'); + } +} diff --git a/application/controllers/api/v1/Admins.php b/application/controllers/api/v1/Admins.php new file mode 100644 index 00000000..3c4cea7c --- /dev/null +++ b/application/controllers/api/v1/Admins.php @@ -0,0 +1,188 @@ + + * @copyright Copyright (c) 2013 - 2020, Alex Tselegidis + * @license http://opensource.org/licenses/GPL-3.0 - GPLv3 + * @link http://easyappointments.org + * @since v1.2.0 + * ---------------------------------------------------------------------------- */ + +require_once __DIR__ . '/API_V1_Controller.php'; + +use EA\Engine\Api\V1\Request; +use EA\Engine\Api\V1\Response; +use EA\Engine\Types\NonEmptyText; + +/** + * Admins Controller + * + * @property CI_Session $session + * @property CI_Loader $load + * @property CI_Input $input + * @property CI_Output $output + * @property CI_Config $config + * @property CI_Lang $lang + * @property CI_Cache $cache + * @property CI_DB_query_builder $db + * @property CI_Security $security + * @property Google_Sync $google_sync + * @property Ics_file $ics_file + * @property Appointments_Model $appointments_model + * @property Providers_Model $providers_model + * @property Services_Model $services_model + * @property Customers_Model $customers_model + * @property Settings_Model $settings_model + * @property Timezones $timezones + * @property Roles_Model $roles_model + * @property Secretaries_Model $secretaries_model + * @property Admins_Model $admins_model + * @property User_Model $user_model + * + * @package Controllers + */ +class Admins extends API_V1_Controller { + /** + * Admins Resource Parser + * + * @var \EA\Engine\Api\V1\Parsers\Admins + */ + protected $parser; + + /** + * Class Constructor + */ + public function __construct() + { + parent::__construct(); + $this->load->model('admins_model'); + $this->parser = new \EA\Engine\Api\V1\Parsers\Admins; + } + + /** + * GET API Method + * + * @param int $id Optional (null), the record ID to be returned. + */ + public function get($id = NULL) + { + try + { + $condition = $id !== NULL ? 'id = ' . $id : NULL; + $admins = $this->admins_model->get_batch($condition); + + if ($id !== NULL && count($admins) === 0) + { + $this->_throwRecordNotFound(); + } + + $response = new Response($admins); + + $response->encode($this->parser) + ->search() + ->sort() + ->paginate() + ->minimize() + ->singleEntry($id) + ->output(); + + } + catch (Exception $exception) + { + $this->_handleException($exception); + } + } + + /** + * POST API Method + */ + public function post() + { + try + { + // Insert the admin to the database. + $request = new Request(); + $admin = $request->getBody(); + $this->parser->decode($admin); + + if (isset($admin['id'])) + { + unset($admin['id']); + } + + $id = $this->admins_model->add($admin); + + // Fetch the new object from the database and return it to the client. + $batch = $this->admins_model->get_batch('id = ' . $id); + $response = new Response($batch); + $status = new NonEmptyText('201 Created'); + $response->encode($this->parser)->singleEntry(TRUE)->output($status); + } + catch (Exception $exception) + { + $this->_handleException($exception); + } + } + + /** + * PUT API Method + * + * @param int $id The record ID to be updated. + */ + public function put($id) + { + try + { + // Update the admin record. + $batch = $this->admins_model->get_batch('id = ' . $id); + + if ($id !== NULL && count($batch) === 0) + { + $this->_throwRecordNotFound(); + } + + $request = new Request(); + $updatedAdmin = $request->getBody(); + $baseAdmin = $batch[0]; + $this->parser->decode($updatedAdmin, $baseAdmin); + $updatedAdmin['id'] = $id; + $id = $this->admins_model->add($updatedAdmin); + + // Fetch the updated object from the database and return it to the client. + $batch = $this->admins_model->get_batch('id = ' . $id); + $response = new Response($batch); + $response->encode($this->parser)->singleEntry($id)->output(); + } + catch (Exception $exception) + { + $this->_handleException($exception); + } + } + + /** + * DELETE API Method + * + * @param int $id The record ID to be deleted. + */ + public function delete($id) + { + try + { + $this->admins_model->delete($id); + + $response = new Response([ + 'code' => 200, + 'message' => 'Record was deleted successfully!' + ]); + + $response->output(); + } + catch (Exception $exception) + { + $this->_handleException($exception); + } + } +} diff --git a/application/controllers/api/v1/Appointments.php b/application/controllers/api/v1/Appointments.php new file mode 100644 index 00000000..d479dab8 --- /dev/null +++ b/application/controllers/api/v1/Appointments.php @@ -0,0 +1,211 @@ + + * @copyright Copyright (c) 2013 - 2020, Alex Tselegidis + * @license http://opensource.org/licenses/GPL-3.0 - GPLv3 + * @link http://easyappointments.org + * @since v1.2.0 + * ---------------------------------------------------------------------------- */ + +require_once __DIR__ . '/API_V1_Controller.php'; + +use EA\Engine\Api\V1\Request; +use EA\Engine\Api\V1\Response; +use EA\Engine\Types\NonEmptyText; + +/** + * Appointments Controller + * + * @property CI_Session $session + * @property CI_Loader $load + * @property CI_Input $input + * @property CI_Output $output + * @property CI_Config $config + * @property CI_Lang $lang + * @property CI_Cache $cache + * @property CI_DB_query_builder $db + * @property CI_Security $security + * @property Google_Sync $google_sync + * @property Ics_file $ics_file + * @property Appointments_Model $appointments_model + * @property Providers_Model $providers_model + * @property Services_Model $services_model + * @property Customers_Model $customers_model + * @property Settings_Model $settings_model + * @property Timezones $timezones + * @property Roles_Model $roles_model + * @property Secretaries_Model $secretaries_model + * @property Admins_Model $admins_model + * @property User_Model $user_model + * + * @package Controllers + */ +class Appointments extends API_V1_Controller { + /** + * Appointments Resource Parser + * + * @var \EA\Engine\Api\V1\Parsers\Appointments + */ + protected $parser; + + /** + * Class Constructor + */ + public function __construct() + { + parent::__construct(); + $this->load->model('appointments_model'); + $this->parser = new \EA\Engine\Api\V1\Parsers\Appointments; + } + + /** + * GET API Method + * + * @param int $id Optional (null), the record ID to be returned. + */ + public function get($id = NULL) + { + try + { + $conditions = [ + 'is_unavailable' => FALSE + ]; + + if ($id !== NULL) + { + $conditions['id'] = $id; + } + + $appointments = $this->appointments_model->get_batch($conditions, NULL, NULL, NULL, array_key_exists('aggregates', $_GET)); + + if ($id !== NULL && count($appointments) === 0) + { + $this->_throwRecordNotFound(); + } + + $response = new Response($appointments); + + $response->encode($this->parser) + ->search() + ->sort() + ->paginate() + ->minimize() + ->singleEntry($id) + ->output(); + + } + catch (Exception $exception) + { + exit($this->_handleException($exception)); + } + } + + /** + * POST API Method + */ + public function post() + { + $this->load->model('services_model'); + + try + { + // Insert the appointment to the database. + $request = new Request(); + $appointment = $request->getBody(); + $this->parser->decode($appointment); + + if (isset($appointment['id'])) + { + unset($appointment['id']); + } + + // Generate end_datetime based on service duration if this field is not defined + if ( ! isset($appointment['end_datetime'])) + { + $service = $this->services_model->get_row($appointment['id_services']); + + if (isset($service['duration'])) + { + $end_datetime = new DateTime($appointment['start_datetime']); + $end_datetime->add(new DateInterval('PT' . $service['duration'] . 'M')); + $appointment['end_datetime'] = $end_datetime->format('Y-m-d H:i:s'); + } + } + + $id = $this->appointments_model->add($appointment); + + // Fetch the new object from the database and return it to the client. + $batch = $this->appointments_model->get_batch('id = ' . $id); + $response = new Response($batch); + $status = new NonEmptyText('201 Created'); + $response->encode($this->parser)->singleEntry(TRUE)->output($status); + } + catch (Exception $exception) + { + exit($this->_handleException($exception)); + } + } + + /** + * PUT API Method + * + * @param int $id The record ID to be updated. + */ + public function put($id) + { + try + { + // Update the appointment record. + $batch = $this->appointments_model->get_batch('id = ' . $id); + + if ($id !== NULL && count($batch) === 0) + { + $this->_throwRecordNotFound(); + } + + $request = new Request(); + $updatedAppointment = $request->getBody(); + $baseAppointment = $batch[0]; + $this->parser->decode($updatedAppointment, $baseAppointment); + $updatedAppointment['id'] = $id; + $id = $this->appointments_model->add($updatedAppointment); + + // Fetch the updated object from the database and return it to the client. + $batch = $this->appointments_model->get_batch('id = ' . $id); + $response = new Response($batch); + $response->encode($this->parser)->singleEntry($id)->output(); + } + catch (Exception $exception) + { + exit($this->_handleException($exception)); + } + } + + /** + * DELETE API Method + * + * @param int $id The record ID to be deleted. + */ + public function delete($id) + { + try + { + $this->appointments_model->delete($id); + + $response = new Response([ + 'code' => 200, + 'message' => 'Record was deleted successfully!' + ]); + + $response->output(); + } + catch (Exception $exception) + { + exit($this->_handleException($exception)); + } + } +} diff --git a/application/controllers/api/v1/Availabilities.php b/application/controllers/api/v1/Availabilities.php new file mode 100644 index 00000000..a3501637 --- /dev/null +++ b/application/controllers/api/v1/Availabilities.php @@ -0,0 +1,568 @@ + + * @copyright Copyright (c) 2013 - 2020, Alex Tselegidis + * @license http://opensource.org/licenses/GPL-3.0 - GPLv3 + * @link http://easyappointments.org + * @since v1.2.0 + * ---------------------------------------------------------------------------- */ + +require_once __DIR__ . '/API_V1_Controller.php'; +require_once __DIR__ . '/../../Appointments.php'; + +use EA\Engine\Types\UnsignedInteger; + +/** + * Availabilities Controller + * + * @property CI_Session $session + * @property CI_Loader $load + * @property CI_Input $input + * @property CI_Output $output + * @property CI_Config $config + * @property CI_Lang $lang + * @property CI_Cache $cache + * @property CI_DB_query_builder $db + * @property CI_Security $security + * @property Google_Sync $google_sync + * @property Ics_file $ics_file + * @property Appointments_Model $appointments_model + * @property Providers_Model $providers_model + * @property Services_Model $services_model + * @property Customers_Model $customers_model + * @property Settings_Model $settings_model + * @property Timezones $timezones + * @property Roles_Model $roles_model + * @property Secretaries_Model $secretaries_model + * @property Admins_Model $admins_model + * @property User_Model $user_model + * + * @package Controllers + */ +class Availabilities extends API_V1_Controller { + /** + * Class Constructor + */ + public function __construct() + { + parent::__construct(); + $this->load->model('appointments_model'); + $this->load->model('providers_model'); + $this->load->model('services_model'); + $this->load->model('settings_model'); + } + + /** + * GET API Method + * + * Provide the "providerId", "serviceId" and "date" GET parameters to get the availabilities for a specific date. + * If no "date" was provided then the current date will be used. + */ + public function get() + { + try + { + $providerId = new UnsignedInteger($this->input->get('providerId')); + $serviceId = new UnsignedInteger($this->input->get('serviceId')); + + if ($this->input->get('date')) + { + $date = new DateTime($this->input->get('date')); + } + else + { + $date = new DateTime(); + } + + $provider = $this->providers_model->get_row($providerId->get()); + $service = $this->services_model->get_row($serviceId->get()); + + $emptyPeriods = $this->_getProviderAvailableTimePeriods($providerId->get(), + $date->format('Y-m-d'), []); + + $availableHours = $this->_calculateAvailableHours($emptyPeriods, + $date->format('Y-m-d'), $service['duration'], FALSE, $service['availabilities_type']); + + if ($service['attendants_number'] > 1) + { + $availableHours = $this->_getMultipleAttendantsHours($date->format('Y-m-d'), $service, $provider); + } + + // If the selected date is today, remove past hours. It is important include the timeout before + // booking that is set in the back-office the system. Normally we might want the customer to book + // an appointment that is at least half or one hour from now. The setting is stored in minutes. + if ($date->format('Y-m-d') === date('Y-m-d')) + { + $bookAdvanceTimeout = $this->settings_model->get_setting('book_advance_timeout'); + + foreach ($availableHours as $index => $value) + { + $availableHour = strtotime($value); + $currentHour = strtotime('+' . $bookAdvanceTimeout . ' minutes', strtotime('now')); + if ($availableHour <= $currentHour) + { + unset($availableHours[$index]); + } + } + } + + $availableHours = array_values($availableHours); + sort($availableHours, SORT_STRING); + $availableHours = array_values($availableHours); + + $this->output + ->set_content_type('application/json') + ->set_output(json_encode($availableHours)); + } + catch (Exception $exception) + { + exit($this->_handleException($exception)); + } + } + + /** + * Get an array containing the free time periods (start - end) of a selected date. + * + * This method is very important because there are many cases where the system needs to + * know when a provider is available for an appointment. This method will return an array + * that belongs to the selected date and contains values that have the start and the end + * time of an available time period. + * + * @param int $provider_id The provider's record id. + * @param string $selected_date The date to be checked (MySQL formatted string). + * @param array $exclude_appointments This array contains the ids of the appointments that + * will not be taken into consideration when the available time periods are calculated. + * + * @return array Returns an array with the available time periods of the provider. + */ + protected function _getProviderAvailableTimePeriods( + $provider_id, + $selected_date, + $exclude_appointments = [] + ) + { + $this->load->model('appointments_model'); + $this->load->model('providers_model'); + + // Get the provider's working plan and reserved appointments. + $working_plan = json_decode($this->providers_model->get_setting('working_plan', $provider_id), TRUE); + + // Get the provider's working plan exceptions. + $working_plan_exceptions = json_decode($this->providers_model->get_setting('working_plan_exceptions', $provider_id), TRUE); + + $where_clause = [ + 'id_users_provider' => $provider_id + ]; + + $reserved_appointments = $this->appointments_model->get_batch($where_clause); + + // Sometimes it might be necessary to not take into account some appointment records + // in order to display what the providers' available time periods would be without them. + foreach ($exclude_appointments as $excluded_id) + { + foreach ($reserved_appointments as $index => $reserved) + { + if ($reserved['id'] == $excluded_id) + { + unset($reserved_appointments[$index]); + } + } + } + + // Find the empty spaces on the plan. The first split between the plan is due to a break (if exist). After that + // every reserved appointment is considered to be a taken space in the plan. + $selected_date_working_plan = $working_plan[strtolower(date('l', strtotime($selected_date)))]; + + if (isset($working_plan_exceptions[$selected_date])) + { + $selected_date_working_plan = $working_plan_exceptions[$selected_date]; + } + + $available_periods_with_breaks = []; + + if (isset($selected_date_working_plan['breaks'])) + { + $start = new DateTime($selected_date_working_plan['start']); + $end = new DateTime($selected_date_working_plan['end']); + $available_periods_with_breaks[] = [ + 'start' => $selected_date_working_plan['start'], + 'end' => $selected_date_working_plan['end'] + ]; + + // Split the working plan to available time periods that do not contain the breaks in them. + foreach ($selected_date_working_plan['breaks'] as $index => $break) + { + $break_start = new DateTime($break['start']); + $break_end = new DateTime($break['end']); + + if ($break_start < $start) + { + $break_start = $start; + } + + if ($break_end > $end) + { + $break_end = $end; + } + + if ($break_start >= $break_end) + { + continue; + } + + foreach ($available_periods_with_breaks as $key => $open_period) + { + $s = new DateTime($open_period['start']); + $e = new DateTime($open_period['end']); + + if ($s < $break_end && $break_start < $e) + { // check for overlap + $changed = FALSE; + if ($s < $break_start) + { + $open_start = $s; + $open_end = $break_start; + $available_periods_with_breaks[] = [ + 'start' => $open_start->format('H:i'), + 'end' => $open_end->format('H:i') + ]; + $changed = TRUE; + } + + if ($break_end < $e) + { + $open_start = $break_end; + $open_end = $e; + $available_periods_with_breaks[] = [ + 'start' => $open_start->format('H:i'), + 'end' => $open_end->format('H:i') + ]; + $changed = TRUE; + } + + if ($changed) + { + unset($available_periods_with_breaks[$key]); + } + } + } + } + } + + // Break the empty periods with the reserved appointments. + $available_periods_with_appointments = $available_periods_with_breaks; + + foreach ($reserved_appointments as $appointment) + { + foreach ($available_periods_with_appointments as $index => &$period) + { + $a_start = strtotime($appointment['start_datetime']); + $a_end = strtotime($appointment['end_datetime']); + $p_start = strtotime($selected_date . ' ' . $period['start']); + $p_end = strtotime($selected_date . ' ' . $period['end']); + + if ($a_start <= $p_start && $a_end <= $p_end && $a_end <= $p_start) + { + // The appointment does not belong in this time period, so we + // will not change anything. + } + else + { + if ($a_start <= $p_start && $a_end <= $p_end && $a_end >= $p_start) + { + // The appointment starts before the period and finishes somewhere inside. + // We will need to break this period and leave the available part. + $period['start'] = date('H:i', $a_end); + } + else + { + if ($a_start >= $p_start && $a_end <= $p_end) + { + // The appointment is inside the time period, so we will split the period + // into two new others. + unset($available_periods_with_appointments[$index]); + $available_periods_with_appointments[] = [ + 'start' => date('H:i', $p_start), + 'end' => date('H:i', $a_start) + ]; + $available_periods_with_appointments[] = [ + 'start' => date('H:i', $a_end), + 'end' => date('H:i', $p_end) + ]; + } + else + { + if ($a_start >= $p_start && $a_end >= $p_start && $a_start <= $p_end) + { + // The appointment starts in the period and finishes out of it. We will + // need to remove the time that is taken from the appointment. + $period['end'] = date('H:i', $a_start); + } + else + { + if ($a_start >= $p_start && $a_end >= $p_end && $a_start >= $p_end) + { + // The appointment does not belong in the period so do not change anything. + } + else + { + if ($a_start <= $p_start && $a_end >= $p_end && $a_start <= $p_end) + { + // The appointment is bigger than the period, so this period needs to be removed. + unset($available_periods_with_appointments[$index]); + } + } + } + } + } + } + } + } + + return array_values($available_periods_with_appointments); + } + + /** + * Calculate the available appointment hours. + * + * Calculate the available appointment hours for the given date. The empty spaces + * are broken down to 15 min and if the service fit in each quarter then a new + * available hour is added to the "$available_hours" array. + * + * @param array $empty_periods Contains the empty periods as generated by the + * "_getProviderAvailableTimePeriods" method. + * @param string $selected_date The selected date to be search (format ) + * @param int $service_duration The service duration is required for the hour calculation. + * @param bool $manage_mode (optional) Whether we are currently on manage mode (editing an existing appointment). + * @param string $availabilities_type Optional ('flexible'), the service availabilities type. + * + * @return array Returns an array with the available hours for the appointment. + */ + protected function _calculateAvailableHours( + array $empty_periods, + $selected_date, + $service_duration, + $manage_mode = FALSE, + $availabilities_type = 'flexible' + ) + { + $this->load->model('settings_model'); + + $available_hours = []; + + foreach ($empty_periods as $period) + { + $start_hour = new DateTime($selected_date . ' ' . $period['start']); + $end_hour = new DateTime($selected_date . ' ' . $period['end']); + $interval = $availabilities_type === AVAILABILITIES_TYPE_FIXED ? (int)$service_duration : 15; + + $current_hour = $start_hour; + $diff = $current_hour->diff($end_hour); + + while (($diff->h * 60 + $diff->i) >= intval($service_duration)) + { + $available_hours[] = $current_hour->format('H:i'); + $current_hour->add(new DateInterval('PT' . $interval . 'M')); + $diff = $current_hour->diff($end_hour); + } + } + + return $available_hours; + } + + /** + * Get multiple attendants hours. + * + * This method will add the additional appointment hours whenever a service accepts multiple attendants. + * + * @param string $selected_date The selected appointment date. + * @param array $service Selected service data. + * @param array $provider Selected provider data. + * + * @return array Returns the available hours array. + */ + protected function _getMultipleAttendantsHours( + $selected_date, + $service, + $provider + ) + { + $this->load->model('appointments_model'); + $this->load->model('services_model'); + $this->load->model('providers_model'); + + $unavailabilities = $this->appointments_model->get_batch([ + 'is_unavailable' => TRUE, + 'DATE(start_datetime)' => $selected_date, + 'id_users_provider' => $provider['id'] + ]); + + $working_plan = json_decode($provider['settings']['working_plan'], TRUE); + $working_day = strtolower(date('l', strtotime($selected_date))); + $working_hours = $working_plan[$working_day]; + + $periods = [ + [ + 'start' => new DateTime($selected_date . ' ' . $working_hours['start']), + 'end' => new DateTime($selected_date . ' ' . $working_hours['end']) + ] + ]; + + $periods = $this->_removeBreaks($selected_date, $periods, $working_hours['breaks']); + $periods = $this->_removeUnavailabilities($periods, $unavailabilities); + + $hours = []; + + $interval_value = $service['availabilities_type'] == AVAILABILITIES_TYPE_FIXED ? $service['duration'] : '15'; + $interval = new DateInterval('PT' . (int)$interval_value . 'M'); + $duration = new DateInterval('PT' . (int)$service['duration'] . 'M'); + + foreach ($periods as $period) + { + $slot_start = clone $period['start']; + $slot_end = clone $slot_start; + $slot_end->add($duration); + + while ($slot_end <= $period['end']) + { + // Check reserved attendants for this time slot and see if current attendants fit. + $appointment_attendants_number = $this->appointments_model->get_attendants_number_for_period($slot_start, + $slot_end, $service['id']); + + if ($appointment_attendants_number < $service['attendants_number']) + { + $hours[] = $slot_start->format('H:i'); + } + + $slot_start->add($interval); + $slot_end->add($interval); + } + } + + return $hours; + } + + /** + * Remove breaks from available time periods. + * + * @param string $selected_date Selected data (Y-m-d format). + * @param array $periods Time periods of the current date. + * @param array $breaks Breaks array for the current date. + * + * @return array Returns the available time periods without the breaks. + */ + public function _removeBreaks($selected_date, $periods, $breaks) + { + if ( ! $breaks) + { + return $periods; + } + + foreach ($breaks as $break) + { + $break_start = new DateTime($selected_date . ' ' . $break['start']); + $break_end = new DateTime($selected_date . ' ' . $break['end']); + + foreach ($periods as &$period) + { + $period_start = $period['start']; + $period_end = $period['end']; + + if ($break_start <= $period_start && $break_end >= $period_start && $break_end <= $period_end) + { + // left + $period['start'] = $break_end; + continue; + } + + if ($break_start >= $period_start && $break_start <= $period_end && $break_end >= $period_start && $break_end <= $period_end) + { + // middle + $period['end'] = $break_start; + $periods[] = [ + 'start' => $break_end, + 'end' => $period_end + ]; + continue; + } + + if ($break_start >= $period_start && $break_start <= $period_end && $break_end >= $period_end) + { + // right + $period['end'] = $break_start; + continue; + } + + if ($break_start <= $period_start && $break_end >= $period_end) + { + // break contains period + $period['start'] = $break_end; + continue; + } + } + } + + return $periods; + } + + /** + * Remove the unavailabilities from the available time periods of the selected date. + * + * @param array $periods Available time periods. + * @param array $unavailabilities Unavailabilities of the current date. + * + * @return array Returns the available time periods without the unavailabilities. + */ + public function _removeUnavailabilities($periods, $unavailabilities) + { + foreach ($unavailabilities as $unavailability) + { + $unavailability_start = new DateTime($unavailability['start_datetime']); + $unavailability_end = new DateTime($unavailability['end_datetime']); + + foreach ($periods as &$period) + { + $period_start = $period['start']; + $period_end = $period['end']; + + if ($unavailability_start <= $period_start && $unavailability_end >= $period_start && $unavailability_end <= $period_end) + { + // left + $period['start'] = $unavailability_end; + continue; + } + + if ($unavailability_start >= $period_start && $unavailability_start <= $period_end && $unavailability_end >= $period_start && $unavailability_end <= $period_end) + { + // middle + $period['end'] = $unavailability_start; + $periods[] = [ + 'start' => $unavailability_end, + 'end' => $period_end + ]; + continue; + } + + if ($unavailability_start >= $period_start && $unavailability_start <= $period_end && $unavailability_end >= $period_end) + { + // right + $period['end'] = $unavailability_start; + continue; + } + + if ($unavailability_start <= $period_start && $unavailability_end >= $period_end) + { + // Unavaibility contains period + $period['start'] = $unavailability_end; + continue; + } + } + } + + return $periods; + } +} diff --git a/application/controllers/api/v1/Categories.php b/application/controllers/api/v1/Categories.php new file mode 100644 index 00000000..eb5a6649 --- /dev/null +++ b/application/controllers/api/v1/Categories.php @@ -0,0 +1,188 @@ + + * @copyright Copyright (c) 2013 - 2020, Alex Tselegidis + * @license http://opensource.org/licenses/GPL-3.0 - GPLv3 + * @link http://easyappointments.org + * @since v1.2.0 + * ---------------------------------------------------------------------------- */ + +require_once __DIR__ . '/API_V1_Controller.php'; + +use EA\Engine\Api\V1\Request; +use EA\Engine\Api\V1\Response; +use EA\Engine\Types\NonEmptyText; + +/** + * Categories Controller + * + * @property CI_Session $session + * @property CI_Loader $load + * @property CI_Input $input + * @property CI_Output $output + * @property CI_Config $config + * @property CI_Lang $lang + * @property CI_Cache $cache + * @property CI_DB_query_builder $db + * @property CI_Security $security + * @property Google_Sync $google_sync + * @property Ics_file $ics_file + * @property Appointments_Model $appointments_model + * @property Providers_Model $providers_model + * @property Services_Model $services_model + * @property Customers_Model $customers_model + * @property Settings_Model $settings_model + * @property Timezones $timezones + * @property Roles_Model $roles_model + * @property Secretaries_Model $secretaries_model + * @property Admins_Model $admins_model + * @property User_Model $user_model + * + * @package Controllers + */ +class Categories extends API_V1_Controller { + /** + * Categories Resource Parser + * + * @var \EA\Engine\Api\V1\Parsers\Categories + */ + protected $parser; + + /** + * Class Constructor + */ + public function __construct() + { + parent::__construct(); + $this->load->model('services_model'); + $this->parser = new \EA\Engine\Api\V1\Parsers\Categories; + } + + /** + * GET API Method + * + * @param int $id Optional (null), the record ID to be returned. + */ + public function get($id = NULL) + { + try + { + $condition = $id !== NULL ? 'id = ' . $id : ''; + $categories = $this->services_model->get_all_categories($condition); + + if ($id !== NULL && count($categories) === 0) + { + $this->_throwRecordNotFound(); + } + + $response = new Response($categories); + + $response->encode($this->parser) + ->search() + ->sort() + ->paginate() + ->minimize() + ->singleEntry($id) + ->output(); + + } + catch (Exception $exception) + { + $this->_handleException($exception); + } + } + + /** + * POST API Method + */ + public function post() + { + try + { + // Insert the category to the database. + $request = new Request(); + $category = $request->getBody(); + $this->parser->decode($category); + + if (isset($category['id'])) + { + unset($category['id']); + } + + $id = $this->services_model->add_category($category); + + // Fetch the new object from the database and return it to the client. + $batch = $this->services_model->get_all_categories('id = ' . $id); + $response = new Response($batch); + $status = new NonEmptyText('201 Created'); + $response->encode($this->parser)->singleEntry(TRUE)->output($status); + } + catch (Exception $exception) + { + $this->_handleException($exception); + } + } + + /** + * PUT API Method + * + * @param int $id The record ID to be updated. + */ + public function put($id) + { + try + { + // Update the category record. + $batch = $this->services_model->get_all_categories('id = ' . $id); + + if ($id !== NULL && count($batch) === 0) + { + $this->_throwRecordNotFound(); + } + + $request = new Request(); + $updatedCategory = $request->getBody(); + $baseCategory = $batch[0]; + $this->parser->decode($updatedCategory, $baseCategory); + $updatedCategory['id'] = $id; + $id = $this->services_model->add_category($updatedCategory); + + // Fetch the updated object from the database and return it to the client. + $batch = $this->services_model->get_all_categories('id = ' . $id); + $response = new Response($batch); + $response->encode($this->parser)->singleEntry($id)->output(); + } + catch (Exception $exception) + { + $this->_handleException($exception); + } + } + + /** + * DELETE API Method + * + * @param int $id The record ID to be deleted. + */ + public function delete($id) + { + try + { + $result = $this->services_model->delete_category($id); + + $response = new Response([ + 'code' => 200, + 'message' => 'Record was deleted successfully!' + ]); + + $response->output(); + } + catch (Exception $exception) + { + $this->_handleException($exception); + } + } +} diff --git a/application/controllers/api/v1/Customers.php b/application/controllers/api/v1/Customers.php new file mode 100644 index 00000000..e2d9a332 --- /dev/null +++ b/application/controllers/api/v1/Customers.php @@ -0,0 +1,188 @@ + + * @copyright Copyright (c) 2013 - 2020, Alex Tselegidis + * @license http://opensource.org/licenses/GPL-3.0 - GPLv3 + * @link http://easyappointments.org + * @since v1.2.0 + * ---------------------------------------------------------------------------- */ + +require_once __DIR__ . '/API_V1_Controller.php'; + +use EA\Engine\Api\V1\Request; +use EA\Engine\Api\V1\Response; +use EA\Engine\Types\NonEmptyText; + +/** + * Customers Controller + * + * @property CI_Session $session + * @property CI_Loader $load + * @property CI_Input $input + * @property CI_Output $output + * @property CI_Config $config + * @property CI_Lang $lang + * @property CI_Cache $cache + * @property CI_DB_query_builder $db + * @property CI_Security $security + * @property Google_Sync $google_sync + * @property Ics_file $ics_file + * @property Appointments_Model $appointments_model + * @property Providers_Model $providers_model + * @property Services_Model $services_model + * @property Customers_Model $customers_model + * @property Settings_Model $settings_model + * @property Timezones $timezones + * @property Roles_Model $roles_model + * @property Secretaries_Model $secretaries_model + * @property Admins_Model $admins_model + * @property User_Model $user_model + * + * @package Controllers + */ +class Customers extends API_V1_Controller { + /** + * Customers Resource Parser + * + * @var \EA\Engine\Api\V1\Parsers\Customers + */ + protected $parser; + + /** + * Class Constructor + */ + public function __construct() + { + parent::__construct(); + $this->load->model('customers_model'); + $this->parser = new \EA\Engine\Api\V1\Parsers\Customers; + } + + /** + * GET API Method + * + * @param int $id Optional (null), the record ID to be returned. + */ + public function get($id = NULL) + { + try + { + $condition = $id !== NULL ? 'id = ' . $id : NULL; + $customers = $this->customers_model->get_batch($condition); + + if ($id !== NULL && count($customers) === 0) + { + $this->_throwRecordNotFound(); + } + + $response = new Response($customers); + + $response->encode($this->parser) + ->search() + ->sort() + ->paginate() + ->minimize() + ->singleEntry($id) + ->output(); + + } + catch (Exception $exception) + { + $this->_handleException($exception); + } + } + + /** + * POST API Method + */ + public function post() + { + try + { + // Insert the customer to the database. + $request = new Request(); + $customer = $request->getBody(); + $this->parser->decode($customer); + + if (isset($customer['id'])) + { + unset($customer['id']); + } + + $id = $this->customers_model->add($customer); + + // Fetch the new object from the database and return it to the client. + $batch = $this->customers_model->get_batch('id = ' . $id); + $response = new Response($batch); + $status = new NonEmptyText('201 Created'); + $response->encode($this->parser)->singleEntry(TRUE)->output($status); + } + catch (Exception $exception) + { + $this->_handleException($exception); + } + } + + /** + * PUT API Method + * + * @param int $id The record ID to be updated. + */ + public function put($id) + { + try + { + // Update the customer record. + $batch = $this->customers_model->get_batch('id = ' . $id); + + if ($id !== NULL && count($batch) === 0) + { + $this->_throwRecordNotFound(); + } + + $request = new Request(); + $updatedCustomer = $request->getBody(); + $baseCustomer = $batch[0]; + $this->parser->decode($updatedCustomer, $baseCustomer); + $updatedCustomer['id'] = $id; + $id = $this->customers_model->add($updatedCustomer); + + // Fetch the updated object from the database and return it to the client. + $batch = $this->customers_model->get_batch('id = ' . $id); + $response = new Response($batch); + $response->encode($this->parser)->singleEntry($id)->output(); + } + catch (Exception $exception) + { + $this->_handleException($exception); + } + } + + /** + * DELETE API Method + * + * @param int $id The record ID to be deleted. + */ + public function delete($id) + { + try + { + $result = $this->customers_model->delete($id); + + $response = new Response([ + 'code' => 200, + 'message' => 'Record was deleted successfully!' + ]); + + $response->output(); + } + catch (Exception $exception) + { + $this->_handleException($exception); + } + } +} diff --git a/application/controllers/api/v1/Providers.php b/application/controllers/api/v1/Providers.php new file mode 100644 index 00000000..2918e053 --- /dev/null +++ b/application/controllers/api/v1/Providers.php @@ -0,0 +1,188 @@ + + * @copyright Copyright (c) 2013 - 2020, Alex Tselegidis + * @license http://opensource.org/licenses/GPL-3.0 - GPLv3 + * @link http://easyappointments.org + * @since v1.2.0 + * ---------------------------------------------------------------------------- */ + +require_once __DIR__ . '/API_V1_Controller.php'; + +use EA\Engine\Api\V1\Request; +use EA\Engine\Api\V1\Response; +use EA\Engine\Types\NonEmptyText; + +/** + * Providers Controller + * + * @property CI_Session $session + * @property CI_Loader $load + * @property CI_Input $input + * @property CI_Output $output + * @property CI_Config $config + * @property CI_Lang $lang + * @property CI_Cache $cache + * @property CI_DB_query_builder $db + * @property CI_Security $security + * @property Google_Sync $google_sync + * @property Ics_file $ics_file + * @property Appointments_Model $appointments_model + * @property Providers_Model $providers_model + * @property Services_Model $services_model + * @property Customers_Model $customers_model + * @property Settings_Model $settings_model + * @property Timezones $timezones + * @property Roles_Model $roles_model + * @property Secretaries_Model $secretaries_model + * @property Admins_Model $admins_model + * @property User_Model $user_model + * + * @package Controllers + */ +class Providers extends API_V1_Controller { + /** + * Providers Resource Parser + * + * @var \EA\Engine\Api\V1\Parsers\Providers + */ + protected $parser; + + /** + * Class Constructor + */ + public function __construct() + { + parent::__construct(); + $this->load->model('providers_model'); + $this->parser = new \EA\Engine\Api\V1\Parsers\Providers; + } + + /** + * GET API Method + * + * @param int $id Optional (null), the record ID to be returned. + */ + public function get($id = NULL) + { + try + { + $condition = $id !== NULL ? 'id = ' . $id : NULL; + $providers = $this->providers_model->get_batch($condition); + + if ($id !== NULL && count($providers) === 0) + { + $this->_throwRecordNotFound(); + } + + $response = new Response($providers); + + $response->encode($this->parser) + ->search() + ->sort() + ->paginate() + ->minimize() + ->singleEntry($id) + ->output(); + + } + catch (Exception $exception) + { + $this->_handleException($exception); + } + } + + /** + * POST API Method + */ + public function post() + { + try + { + // Insert the provider to the database. + $request = new Request(); + $provider = $request->getBody(); + $this->parser->decode($provider); + + if (isset($provider['id'])) + { + unset($provider['id']); + } + + $id = $this->providers_model->add($provider); + + // Fetch the new object from the database and return it to the client. + $batch = $this->providers_model->get_batch('id = ' . $id); + $response = new Response($batch); + $status = new NonEmptyText('201 Created'); + $response->encode($this->parser)->singleEntry(TRUE)->output($status); + } + catch (Exception $exception) + { + $this->_handleException($exception); + } + } + + /** + * PUT API Method + * + * @param int $id The record ID to be updated. + */ + public function put($id) + { + try + { + // Update the provider record. + $batch = $this->providers_model->get_batch('id = ' . $id); + + if ($id !== NULL && count($batch) === 0) + { + $this->_throwRecordNotFound(); + } + + $request = new Request(); + $updatedProvider = $request->getBody(); + $baseProvider = $batch[0]; + $this->parser->decode($updatedProvider, $baseProvider); + $updatedProvider['id'] = $id; + $id = $this->providers_model->add($updatedProvider); + + // Fetch the updated object from the database and return it to the client. + $batch = $this->providers_model->get_batch('id = ' . $id); + $response = new Response($batch); + $response->encode($this->parser)->singleEntry($id)->output(); + } + catch (Exception $exception) + { + $this->_handleException($exception); + } + } + + /** + * DELETE API Method + * + * @param int $id The record ID to be deleted. + */ + public function delete($id) + { + try + { + $result = $this->providers_model->delete($id); + + $response = new Response([ + 'code' => 200, + 'message' => 'Record was deleted successfully!' + ]); + + $response->output(); + } + catch (Exception $exception) + { + $this->_handleException($exception); + } + } +} diff --git a/application/controllers/api/v1/Secretaries.php b/application/controllers/api/v1/Secretaries.php new file mode 100644 index 00000000..605fd711 --- /dev/null +++ b/application/controllers/api/v1/Secretaries.php @@ -0,0 +1,188 @@ + + * @copyright Copyright (c) 2013 - 2020, Alex Tselegidis + * @license http://opensource.org/licenses/GPL-3.0 - GPLv3 + * @link http://easyappointments.org + * @since v1.2.0 + * ---------------------------------------------------------------------------- */ + +require_once __DIR__ . '/API_V1_Controller.php'; + +use EA\Engine\Api\V1\Request; +use EA\Engine\Api\V1\Response; +use EA\Engine\Types\NonEmptyText; + +/** + * Secretaries Controller + * + * @property CI_Session $session + * @property CI_Loader $load + * @property CI_Input $input + * @property CI_Output $output + * @property CI_Config $config + * @property CI_Lang $lang + * @property CI_Cache $cache + * @property CI_DB_query_builder $db + * @property CI_Security $security + * @property Google_Sync $google_sync + * @property Ics_file $ics_file + * @property Appointments_Model $appointments_model + * @property Providers_Model $providers_model + * @property Services_Model $services_model + * @property Customers_Model $customers_model + * @property Settings_Model $settings_model + * @property Timezones $timezones + * @property Roles_Model $roles_model + * @property Secretaries_Model $secretaries_model + * @property Admins_Model $admins_model + * @property User_Model $user_model + * + * @package Controllers + */ +class Secretaries extends API_V1_Controller { + /** + * Secretaries Resource Parser + * + * @var \EA\Engine\Api\V1\Parsers\Secretaries + */ + protected $parser; + + /** + * Class Constructor + */ + public function __construct() + { + parent::__construct(); + $this->load->model('secretaries_model'); + $this->parser = new \EA\Engine\Api\V1\Parsers\Secretaries; + } + + /** + * GET API Method + * + * @param int $id Optional (null), the record ID to be returned. + */ + public function get($id = NULL) + { + try + { + $condition = $id !== NULL ? 'id = ' . $id : NULL; + $secretaries = $this->secretaries_model->get_batch($condition); + + if ($id !== NULL && count($secretaries) === 0) + { + $this->_throwRecordNotFound(); + } + + $response = new Response($secretaries); + + $response->encode($this->parser) + ->search() + ->sort() + ->paginate() + ->minimize() + ->singleEntry($id) + ->output(); + + } + catch (Exception $exception) + { + $this->_handleException($exception); + } + } + + /** + * POST API Method + */ + public function post() + { + try + { + // Insert the secretary to the database. + $request = new Request(); + $secretary = $request->getBody(); + $this->parser->decode($secretary); + + if (isset($secretary['id'])) + { + unset($secretary['id']); + } + + $id = $this->secretaries_model->add($secretary); + + // Fetch the new object from the database and return it to the client. + $batch = $this->secretaries_model->get_batch('id = ' . $id); + $response = new Response($batch); + $status = new NonEmptyText('201 Created'); + $response->encode($this->parser)->singleEntry(TRUE)->output($status); + } + catch (Exception $exception) + { + $this->_handleException($exception); + } + } + + /** + * PUT API Method + * + * @param int $id The record ID to be updated. + */ + public function put($id) + { + try + { + // Update the secretary record. + $batch = $this->secretaries_model->get_batch('id = ' . $id); + + if ($id !== NULL && count($batch) === 0) + { + $this->_throwRecordNotFound(); + } + + $request = new Request(); + $updatedSecretary = $request->getBody(); + $baseSecretary = $batch[0]; + $this->parser->decode($updatedSecretary, $baseSecretary); + $updatedSecretary['id'] = $id; + $id = $this->secretaries_model->add($updatedSecretary); + + // Fetch the updated object from the database and return it to the client. + $batch = $this->secretaries_model->get_batch('id = ' . $id); + $response = new Response($batch); + $response->encode($this->parser)->singleEntry($id)->output(); + } + catch (Exception $exception) + { + $this->_handleException($exception); + } + } + + /** + * DELETE API Method + * + * @param int $id The record ID to be deleted. + */ + public function delete($id) + { + try + { + $result = $this->secretaries_model->delete($id); + + $response = new Response([ + 'code' => 200, + 'message' => 'Record was deleted successfully!' + ]); + + $response->output(); + } + catch (Exception $exception) + { + $this->_handleException($exception); + } + } +} diff --git a/application/controllers/api/v1/Services.php b/application/controllers/api/v1/Services.php new file mode 100644 index 00000000..6aa9ccf1 --- /dev/null +++ b/application/controllers/api/v1/Services.php @@ -0,0 +1,188 @@ + + * @copyright Copyright (c) 2013 - 2020, Alex Tselegidis + * @license http://opensource.org/licenses/GPL-3.0 - GPLv3 + * @link http://easyappointments.org + * @since v1.2.0 + * ---------------------------------------------------------------------------- */ + +require_once __DIR__ . '/API_V1_Controller.php'; + +use EA\Engine\Api\V1\Request; +use EA\Engine\Api\V1\Response; +use EA\Engine\Types\NonEmptyText; + +/** + * Services Controller + * + * @property CI_Session $session + * @property CI_Loader $load + * @property CI_Input $input + * @property CI_Output $output + * @property CI_Config $config + * @property CI_Lang $lang + * @property CI_Cache $cache + * @property CI_DB_query_builder $db + * @property CI_Security $security + * @property Google_Sync $google_sync + * @property Ics_file $ics_file + * @property Appointments_Model $appointments_model + * @property Providers_Model $providers_model + * @property Services_Model $services_model + * @property Customers_Model $customers_model + * @property Settings_Model $settings_model + * @property Timezones $timezones + * @property Roles_Model $roles_model + * @property Secretaries_Model $secretaries_model + * @property Admins_Model $admins_model + * @property User_Model $user_model + * + * @package Controllers + */ +class Services extends API_V1_Controller { + /** + * Services Resource Parser + * + * @var \EA\Engine\Api\V1\Parsers\Services + */ + protected $parser; + + /** + * Class Constructor + */ + public function __construct() + { + parent::__construct(); + $this->load->model('services_model'); + $this->parser = new \EA\Engine\Api\V1\Parsers\Services; + } + + /** + * GET API Method + * + * @param int $id Optional (null), the record ID to be returned. + */ + public function get($id = NULL) + { + try + { + $condition = $id !== NULL ? 'id = ' . $id : NULL; + $services = $this->services_model->get_batch($condition); + + if ($id !== NULL && count($services) === 0) + { + $this->_throwRecordNotFound(); + } + + $response = new Response($services); + + $response->encode($this->parser) + ->search() + ->sort() + ->paginate() + ->minimize() + ->singleEntry($id) + ->output(); + + } + catch (Exception $exception) + { + $this->_handleException($exception); + } + } + + /** + * POST API Method + */ + public function post() + { + try + { + // Insert the service to the database. + $request = new Request(); + $service = $request->getBody(); + $this->parser->decode($service); + + if (isset($service['id'])) + { + unset($service['id']); + } + + $id = $this->services_model->add($service); + + // Fetch the new object from the database and return it to the client. + $batch = $this->services_model->get_batch('id = ' . $id); + $response = new Response($batch); + $status = new NonEmptyText('201 Created'); + $response->encode($this->parser)->singleEntry(TRUE)->output($status); + } + catch (Exception $exception) + { + $this->_handleException($exception); + } + } + + /** + * PUT API Method + * + * @param int $id The record ID to be updated. + */ + public function put($id) + { + try + { + // Update the service record. + $batch = $this->services_model->get_batch('id = ' . $id); + + if ($id !== NULL && count($batch) === 0) + { + $this->_throwRecordNotFound(); + } + + $request = new Request(); + $updatedService = $request->getBody(); + $baseService = $batch[0]; + $this->parser->decode($updatedService, $baseService); + $updatedService['id'] = $id; + $id = $this->services_model->add($updatedService); + + // Fetch the updated object from the database and return it to the client. + $batch = $this->services_model->get_batch('id = ' . $id); + $response = new Response($batch); + $response->encode($this->parser)->singleEntry($id)->output(); + } + catch (Exception $exception) + { + $this->_handleException($exception); + } + } + + /** + * DELETE API Method + * + * @param int $id The record ID to be deleted. + */ + public function delete($id) + { + try + { + $result = $this->services_model->delete($id); + + $response = new Response([ + 'code' => 200, + 'message' => 'Record was deleted successfully!' + ]); + + $response->output(); + } + catch (Exception $exception) + { + $this->_handleException($exception); + } + } +} diff --git a/application/controllers/api/v1/Settings.php b/application/controllers/api/v1/Settings.php new file mode 100644 index 00000000..19a82f28 --- /dev/null +++ b/application/controllers/api/v1/Settings.php @@ -0,0 +1,168 @@ + + * @copyright Copyright (c) 2013 - 2020, Alex Tselegidis + * @license http://opensource.org/licenses/GPL-3.0 - GPLv3 + * @link http://easyappointments.org + * @since v1.2.0 + * ---------------------------------------------------------------------------- */ + +require_once __DIR__ . '/API_V1_Controller.php'; + +use EA\Engine\Api\V1\Request; +use EA\Engine\Api\V1\Response; + +/** + * Settings Controller + * + * @property CI_Session $session + * @property CI_Loader $load + * @property CI_Input $input + * @property CI_Output $output + * @property CI_Config $config + * @property CI_Lang $lang + * @property CI_Cache $cache + * @property CI_DB_query_builder $db + * @property CI_Security $security + * @property Google_Sync $google_sync + * @property Ics_file $ics_file + * @property Appointments_Model $appointments_model + * @property Providers_Model $providers_model + * @property Services_Model $services_model + * @property Customers_Model $customers_model + * @property Settings_Model $settings_model + * @property Timezones $timezones + * @property Roles_Model $roles_model + * @property Secretaries_Model $secretaries_model + * @property Admins_Model $admins_model + * @property User_Model $user_model + * + * @package Controllers + */ +class Settings extends API_V1_Controller { + /** + * Settings Resource Parser + * + * @var \EA\Engine\Api\V1\Parsers\Settings + */ + protected $parser; + + /** + * Class Constructor + */ + public function __construct() + { + parent::__construct(); + $this->load->model('settings_model'); + $this->parser = new \EA\Engine\Api\V1\Parsers\Settings; + } + + /** + * GET API Method + * + * @param string $name Optional (null), the setting name to be returned. + */ + public function get($name = NULL) + { + try + { + $settings = $this->settings_model->get_settings(); + + if ($name !== NULL) + { + $setting = NULL; + + foreach ($settings as $entry) + { + if ($entry['name'] === $name) + { + $setting = $entry; + break; + } + } + + if (empty($setting)) + { + $this->_throwRecordNotFound(); + } + + unset($setting['id']); + + $settings = [ + $setting + ]; + } + + $response = new Response($settings); + + $response->encode($this->parser) + ->search() + ->sort() + ->paginate() + ->minimize() + ->singleEntry($name) + ->output(); + + } + catch (Exception $exception) + { + exit($this->_handleException($exception)); + } + } + + /** + * PUT API Method + * + * @param string $name The setting name to be inserted/updated. + */ + public function put($name) + { + try + { + $request = new Request(); + $value = $request->getBody()['value']; + $this->settings_model->set_setting($name, $value); + + // Fetch the updated object from the database and return it to the client. + $response = new Response([ + [ + 'name' => $name, + 'value' => $value + ] + ]); + $response->encode($this->parser)->singleEntry($name)->output(); + } + catch (Exception $exception) + { + exit($this->_handleException($exception)); + } + } + + /** + * DELETE API Method + * + * @param string $name The setting name to be deleted. + */ + public function delete($name) + { + try + { + $result = $this->settings_model->remove_setting($name); + + $response = new Response([ + 'code' => 200, + 'message' => 'Record was deleted successfully!' + ]); + + $response->output(); + } + catch (Exception $exception) + { + exit($this->_handleException($exception)); + } + } +} diff --git a/application/controllers/api/v1/Unavailabilities.php b/application/controllers/api/v1/Unavailabilities.php new file mode 100644 index 00000000..bc2cc2c9 --- /dev/null +++ b/application/controllers/api/v1/Unavailabilities.php @@ -0,0 +1,188 @@ + + * @copyright Copyright (c) 2013 - 2020, Alex Tselegidis + * @license http://opensource.org/licenses/GPL-3.0 - GPLv3 + * @link http://easyappointments.org + * @since v1.2.0 + * ---------------------------------------------------------------------------- */ + +require_once __DIR__ . '/API_V1_Controller.php'; + +use EA\Engine\Api\V1\Request; +use EA\Engine\Api\V1\Response; +use EA\Engine\Types\NonEmptyText; + +/** + * Unavailabilities Controller + * + * @property CI_Session $session + * @property CI_Loader $load + * @property CI_Input $input + * @property CI_Output $output + * @property CI_Config $config + * @property CI_Lang $lang + * @property CI_Cache $cache + * @property CI_DB_query_builder $db + * @property CI_Security $security + * @property Google_Sync $google_sync + * @property Ics_file $ics_file + * @property Appointments_Model $appointments_model + * @property Providers_Model $providers_model + * @property Services_Model $services_model + * @property Customers_Model $customers_model + * @property Settings_Model $settings_model + * @property Timezones $timezones + * @property Roles_Model $roles_model + * @property Secretaries_Model $secretaries_model + * @property Admins_Model $admins_model + * @property User_Model $user_model + * + * @package Controllers + */ +class Unavailabilities extends API_V1_Controller { + /** + * Unavailabilities Resource Parser + * + * @var \EA\Engine\Api\V1\Parsers\Unavailabilities + */ + protected $parser; + + /** + * Class Constructor + */ + public function __construct() + { + parent::__construct(); + $this->load->model('appointments_model'); + $this->parser = new \EA\Engine\Api\V1\Parsers\Unavailabilities; + } + + /** + * GET API Method + * + * @param int $id Optional (null), the record ID to be returned. + */ + public function get($id = NULL) + { + try + { + $condition = $id !== NULL ? 'id = ' . $id : 'is_unavailable = 1'; + $unavailabilities = $this->appointments_model->get_batch($condition); + + if ($id !== NULL && count($unavailabilities) === 0) + { + $this->_throwRecordNotFound(); + } + + $response = new Response($unavailabilities); + + $response->encode($this->parser) + ->search() + ->sort() + ->paginate() + ->minimize() + ->singleEntry($id) + ->output(); + + } + catch (Exception $exception) + { + exit($this->_handleException($exception)); + } + } + + /** + * POST API Method + */ + public function post() + { + try + { + // Insert the appointment to the database. + $request = new Request(); + $unavailability = $request->getBody(); + $this->parser->decode($unavailability); + + if (isset($unavailability['id'])) + { + unset($unavailability['id']); + } + + $id = $this->appointments_model->add_unavailable($unavailability); + + // Fetch the new object from the database and return it to the client. + $batch = $this->appointments_model->get_batch('id = ' . $id); + $response = new Response($batch); + $status = new NonEmptyText('201 Created'); + $response->encode($this->parser)->singleEntry(TRUE)->output($status); + } + catch (Exception $exception) + { + exit($this->_handleException($exception)); + } + } + + /** + * PUT API Method + * + * @param int $id The record ID to be updated. + */ + public function put($id) + { + try + { + // Update the appointment record. + $batch = $this->appointments_model->get_batch('id = ' . $id); + + if ($id !== NULL && count($batch) === 0) + { + $this->_throwRecordNotFound(); + } + + $request = new Request(); + $updatedUnavailability = $request->getBody(); + $baseUnavailability = $batch[0]; + $this->parser->decode($updatedUnavailability, $baseUnavailability); + $updatedUnavailability['id'] = $id; + $id = $this->appointments_model->add_unavailable($updatedUnavailability); + + // Fetch the updated object from the database and return it to the client. + $batch = $this->appointments_model->get_batch('id = ' . $id); + $response = new Response($batch); + $response->encode($this->parser)->singleEntry($id)->output(); + } + catch (Exception $exception) + { + exit($this->_handleException($exception)); + } + } + + /** + * DELETE API Method + * + * @param int $id The record ID to be deleted. + */ + public function delete($id) + { + try + { + $result = $this->appointments_model->delete_unavailable($id); + + $response = new Response([ + 'code' => 200, + 'message' => 'Record was deleted successfully!' + ]); + + $response->output(); + } + catch (Exception $exception) + { + exit($this->_handleException($exception)); + } + } +} diff --git a/src/application/controllers/api/v1/index.html b/application/controllers/api/v1/index.html similarity index 100% rename from src/application/controllers/api/v1/index.html rename to application/controllers/api/v1/index.html diff --git a/src/application/controllers/index.html b/application/controllers/index.html similarity index 100% rename from src/application/controllers/index.html rename to application/controllers/index.html diff --git a/application/helpers/asset_helper.php b/application/helpers/asset_helper.php new file mode 100644 index 00000000..b16af5dd --- /dev/null +++ b/application/helpers/asset_helper.php @@ -0,0 +1,44 @@ + + * @copyright Copyright (c) 2013 - 2020, Alex Tselegidis + * @license http://opensource.org/licenses/GPL-3.0 - GPLv3 + * @link http://easyappointments.org + * @since v1.3.0 + * ---------------------------------------------------------------------------- */ + +/** + * Assets URL helper function. + * + * This function will create an asset file URL that includes a cache busting parameter in order + * to invalidate the browser cache in case of an update. + * + * @param string $uri Relative URI (just like the one used in the base_url helper). + * @param string|null $protocol Valid URI protocol. + * + * @return string Returns the final asset URL. + */ +function asset_url($uri = '', $protocol = NULL) +{ + $framework =& get_instance(); + + $debug = $framework->config->item('debug'); + + $cache_busting_token = ! $debug ? '?' . $framework->config->item('cache_busting_token') : ''; + + if (strpos(basename($uri), '.js') !== FALSE && strpos(basename($uri), '.min.js') === FALSE && ! $debug) + { + $uri = str_replace('.js', '.min.js', $uri); + } + + if (strpos(basename($uri), '.css') !== FALSE && strpos(basename($uri), '.min.css') === FALSE && ! $debug) + { + $uri = str_replace('.css', '.min.css', $uri); + } + + return base_url($uri . $cache_busting_token, $protocol); +} diff --git a/application/helpers/config_helper.php b/application/helpers/config_helper.php new file mode 100644 index 00000000..3776e0de --- /dev/null +++ b/application/helpers/config_helper.php @@ -0,0 +1,26 @@ + + * @copyright Copyright (c) 2013 - 2020, Alex Tselegidis + * @license http://opensource.org/licenses/GPL-3.0 - GPLv3 + * @link http://easyappointments.org + * @since v1.3.0 + * ---------------------------------------------------------------------------- */ + +/** + * Quickly fetch the value of a framework configuration. + * + * @param string $key Configuration key. + * + * @return mixed Returns the configuration value. + */ +function config($key) +{ + $framework = &get_instance(); + + return $framework->config->item($key); +} diff --git a/application/helpers/custom_exceptions_helper.php b/application/helpers/custom_exceptions_helper.php new file mode 100644 index 00000000..64865197 --- /dev/null +++ b/application/helpers/custom_exceptions_helper.php @@ -0,0 +1,44 @@ + + * @copyright Copyright (c) 2013 - 2020, Alex Tselegidis + * @license http://opensource.org/licenses/GPL-3.0 - GPLv3 + * @link http://easyappointments.org + * @since v1.0.0 + * ---------------------------------------------------------------------------- */ + +/** + * Print an exception to an HTML text. + * + * This method is used to display exceptions in a way that is useful and easy + * for the user to see. It uses the Bootstrap CSS accordion markup to display + * the message and when the user clicks on it the exception trace will be revealed. + * We display only one exceptions at a time because the user needs to be able + * to display the details of each exception seperately. (In contrast with js). + * + * @param Exception $exception The exception to be displayed. + * @return string Returns the html markup of the exception. + */ +function exceptionToHtml($exception) +{ + return + '' . $exception->getTraceAsString() . '+
+ = lang('appointment_details_was_sent_to_you') ?> +
+ ++ + = lang('check_spam_folder') ?> + +
+ + + + = lang('go_to_booking_page') ?> + + + + + + + +Directory access is forbidden.
+ + + diff --git a/application/views/appointments/message.php b/application/views/appointments/message.php new file mode 100644 index 00000000..819b0a17 --- /dev/null +++ b/application/views/appointments/message.php @@ -0,0 +1,62 @@ + + + + + + + + += $message_text ?>
+ + +