From 0ec4fd017fe803e1c7ea309eb862eef68addd1ef Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sun, 20 Aug 2023 17:27:02 -0400 Subject: [PATCH] WIP on PHP (Leaf) version --- .gitignore | 3 + src/app/.htaccess | 4 + src/app/Data.php | 135 ++++ src/app/composer.json | 16 + src/app/composer.lock | 635 ++++++++++++++++++ src/app/documents/Configuration.php | 31 + src/app/documents/Definition.php | 57 ++ src/app/documents/Document.php | 454 +++++++++++++ src/app/documents/DocumentIndex.php | 14 + src/app/documents/Query.php | 255 +++++++ src/app/documents/functions.php | 15 + src/app/domain/History.php | 41 ++ src/app/domain/JournalRequest.php | 87 +++ src/app/domain/Note.php | 21 + src/app/domain/RecurrenceType.php | 30 + src/app/domain/Request.php | 51 ++ src/app/domain/RequestAction.php | 30 + src/app/index.php | 49 ++ src/app/pages/home.view.php | 13 + src/app/pages/layout/_foot.view.php | 30 + src/app/pages/layout/_head.view.php | 12 + src/app/pages/layout/_nav.view.php | 21 + src/app/pages/layout/full.view.php | 11 + src/app/pages/legal/privacy-policy.view.php | 82 +++ src/app/pages/legal/terms-of-service.view.php | 57 ++ src/app/script/mpj.js | 104 +++ src/app/style/style.css | 60 ++ 27 files changed, 2318 insertions(+) create mode 100644 src/app/.htaccess create mode 100644 src/app/Data.php create mode 100644 src/app/composer.json create mode 100644 src/app/composer.lock create mode 100644 src/app/documents/Configuration.php create mode 100644 src/app/documents/Definition.php create mode 100644 src/app/documents/Document.php create mode 100644 src/app/documents/DocumentIndex.php create mode 100644 src/app/documents/Query.php create mode 100644 src/app/documents/functions.php create mode 100644 src/app/domain/History.php create mode 100644 src/app/domain/JournalRequest.php create mode 100644 src/app/domain/Note.php create mode 100644 src/app/domain/RecurrenceType.php create mode 100644 src/app/domain/Request.php create mode 100644 src/app/domain/RequestAction.php create mode 100644 src/app/index.php create mode 100644 src/app/pages/home.view.php create mode 100644 src/app/pages/layout/_foot.view.php create mode 100644 src/app/pages/layout/_head.view.php create mode 100644 src/app/pages/layout/_nav.view.php create mode 100644 src/app/pages/layout/full.view.php create mode 100644 src/app/pages/legal/privacy-policy.view.php create mode 100644 src/app/pages/legal/terms-of-service.view.php create mode 100644 src/app/script/mpj.js create mode 100644 src/app/style/style.css diff --git a/.gitignore b/.gitignore index 37f26ff..21047c2 100644 --- a/.gitignore +++ b/.gitignore @@ -254,3 +254,6 @@ paket-files/ # Ionide VSCode extension .ionide + +# in-progress: PHP version +src/app/vendor diff --git a/src/app/.htaccess b/src/app/.htaccess new file mode 100644 index 0000000..48a7a3d --- /dev/null +++ b/src/app/.htaccess @@ -0,0 +1,4 @@ +RewriteEngine on +RewriteCond %{REQUEST_FILENAME} !-d +RewriteCond %{REQUEST_FILENAME} !-f +RewriteRule . index.php [L] \ No newline at end of file diff --git a/src/app/Data.php b/src/app/Data.php new file mode 100644 index 0000000..ab7e206 --- /dev/null +++ b/src/app/Data.php @@ -0,0 +1,135 @@ +userId != $userId ? null : $req; + } + + /** + * Add a history entry to the specified request + * + * @param string $reqId The request ID + * @param string $userId The ID of the currently logged-on user + * @param History $history The history entry to be added + */ + public static function addHistory(string $reqId, string $userId, History $history) + { + $req = Data::findFullRequestById($reqId, $userId); + if (is_null($req)) throw new \InvalidArgumentException("$reqId not found"); + array_unshift($req->history, $history); + Document::updateFull(Data::REQ_TABLE, $reqId, $req); + } + + /** + * Add a note to the specified request + * + * @param string $reqId The request ID + * @param string $userId The ID of the currently logged-on user + * @param Note $note The note to be added + */ + public static function addNote(string $reqId, string $userId, Note $note) + { + $req = Data::findFullRequestById($reqId, $userId); + if (is_null($req)) throw new \InvalidArgumentException("$reqId not found"); + array_unshift($req->notes, $note); + Document::updateFull(Data::REQ_TABLE, $reqId, $req); + } + + /** + * Add a new request + * + * @param Request $req The request to be added + */ + public static function addRequest(Request $req) + { + Document::insert(Data::REQ_TABLE, $req->id, $req); + } + + /** + * Map an array of `Request`s to an array of `JournalRequest`s + * + * @param Request[] $reqs The requests to map + * @param bool $full Whether to include history and notes (true) or not (false) + * @return JournalRequest[] The journal request objects + */ + private static function mapToJournalRequest(array $reqs, bool $full): array + { + return array_map(fn (Request $req) => new JournalRequest($req, $full), $reqs); + } + + /** + * Get journal requests for the given user by "answered" status + * + * @param string $userId The ID of the user for whom requests should be retrieved + * @param string $op The JSON Path operator to use for comparison (`==` or `<>`) + * @return JournalRequest[] The journal request objects + */ + private static function getJournalByAnswered(string $userId, string $op): array + { + $sql = Query::selectFromTable(Data::REQ_TABLE) + . ' WHERE ' . Query::whereDataContains(':criteria') . ' AND ' . Query::whereJsonPathMatches(':path'); + $params = [ + ':criteria' => Query::jsonbDocParam([ 'userId' => $userId ]), + ':path' => '$.history[*].action (@ ' . $op . ' "' . RequestAction::Answered->name . '")' + ]; + return Data::mapToJournalRequest( + Document::customList($sql, $params, Request::class, Document::mapFromJson(...)), true); + } + + /** + * Retrieve all answered requests for this user + * + * @param string $userId The ID of the user for whom answered requests should be retrieved + * @return JournalRequest[] The answered requests + */ + public static function getAnsweredRequests(string $userId): array + { + $answered = Data::getJournalByAnswered($userId, '=='); + usort($answered, + fn (JournalRequest $a, JournalRequest $b) => $a->asOf == $b->asOf ? 0 : ($a->asOf > $b->asOf ? -1 : 1)); + return $answered; + } + + /** + * Get the user's current prayer request journal + * + * @param string $userId The ID of the user whose journal should be retrieved + * @return JournalRequest[] The journal request objects + */ + public static function getJournal(string $userId): array + { + $reqs = data::getJournalByAnswered($userId, '<>'); + usort($reqs, + fn (JournalRequest $a, JournalRequest $b) => $a->asOf == $b->asOf ? 0 : ($a->asOf < $b->asOf ? -1 : 1)); + return $reqs; + } +} diff --git a/src/app/composer.json b/src/app/composer.json new file mode 100644 index 0000000..0995f5e --- /dev/null +++ b/src/app/composer.json @@ -0,0 +1,16 @@ +{ + "require": { + "leafs/leaf": "^3.0", + "leafs/bareui": "^1.1", + "leafs/db": "^2.1", + "netresearch/jsonmapper": "^4.2", + "visus/cuid2": "^3.0" + }, + "autoload": { + "psr-4": { + "BitBadger\\PgSQL\\Documents\\": [ "./documents" ], + "MyPrayerJournal\\": [ "." ], + "MyPrayerJournal\\Domain\\": [ "./domain" ] + } + } +} diff --git a/src/app/composer.lock b/src/app/composer.lock new file mode 100644 index 0000000..5d425bb --- /dev/null +++ b/src/app/composer.lock @@ -0,0 +1,635 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "3e1594cc7c6f8fe6041e1c22822e0ab2", + "packages": [ + { + "name": "leafs/anchor", + "version": "v1.5.0", + "source": { + "type": "git", + "url": "https://github.com/leafsphp/anchor.git", + "reference": "debc228afd63d46d94d0c1d02629c2d912ecb4ee" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/leafsphp/anchor/zipball/debc228afd63d46d94d0c1d02629c2d912ecb4ee", + "reference": "debc228afd63d46d94d0c1d02629c2d912ecb4ee", + "shasum": "" + }, + "require-dev": { + "pestphp/pest": "^1.21" + }, + "type": "library", + "autoload": { + "psr-4": { + "Leaf\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Darko", + "email": "mickdd22@gmail.com", + "homepage": "https://mychi.netlify.app", + "role": "Developer" + } + ], + "description": "Leaf PHP util module", + "homepage": "https://leafphp.netlify.app/#/", + "keywords": [ + "framework", + "leaf", + "php", + "util" + ], + "support": { + "issues": "https://github.com/leafsphp/anchor/issues", + "source": "https://github.com/leafsphp/anchor/tree/v1.5.0" + }, + "funding": [ + { + "url": "https://github.com/leafsphp", + "type": "github" + }, + { + "url": "https://opencollective.com/leaf", + "type": "open_collective" + } + ], + "time": "2023-07-09T00:51:55+00:00" + }, + { + "name": "leafs/bareui", + "version": "v1.1.1", + "source": { + "type": "git", + "url": "https://github.com/leafsphp/bareui.git", + "reference": "a84d855be5ba319a9c0c695d65d25aabf959b23b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/leafsphp/bareui/zipball/a84d855be5ba319a9c0c695d65d25aabf959b23b", + "reference": "a84d855be5ba319a9c0c695d65d25aabf959b23b", + "shasum": "" + }, + "type": "library", + "autoload": { + "files": [ + "src/scripts.php" + ], + "psr-4": { + "Leaf\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Darko", + "email": "mickdd22@gmail.com", + "homepage": "https://mychi.netlify.app", + "role": "Developer" + } + ], + "description": "Leaf PHP bareui templating engine", + "homepage": "https://leafphp.netlify.app/#/", + "keywords": [ + "framework", + "leaf", + "php", + "simple templating", + "template", + "view" + ], + "support": { + "issues": "https://github.com/leafsphp/bareui/issues", + "source": "https://github.com/leafsphp/bareui/tree/v1.1.1" + }, + "funding": [ + { + "url": "https://github.com/leafsphp", + "type": "github" + }, + { + "url": "https://opencollective.com/leaf", + "type": "open_collective" + } + ], + "time": "2023-05-17T08:42:17+00:00" + }, + { + "name": "leafs/db", + "version": "v2.1.0", + "source": { + "type": "git", + "url": "https://github.com/leafsphp/db.git", + "reference": "5b663754b552dcc4dde3314e425afe75084a02f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/leafsphp/db/zipball/5b663754b552dcc4dde3314e425afe75084a02f3", + "reference": "5b663754b552dcc4dde3314e425afe75084a02f3", + "shasum": "" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.14", + "leafs/alchemy": "^1.0", + "pestphp/pest": "^1.21" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Leaf\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Darko", + "email": "mickdd22@gmail.com", + "homepage": "https://mychi.netlify.app", + "role": "Developer" + } + ], + "description": "Leaf PHP db module.", + "homepage": "https://leafphp.netlify.app/#/", + "keywords": [ + "database", + "framework", + "leaf", + "orm", + "php" + ], + "support": { + "issues": "https://github.com/leafsphp/db/issues", + "source": "https://github.com/leafsphp/db/tree/v2.1.0" + }, + "funding": [ + { + "url": "https://github.com/leafsphp", + "type": "github" + }, + { + "url": "https://opencollective.com/leaf", + "type": "open_collective" + } + ], + "time": "2023-02-27T17:16:54+00:00" + }, + { + "name": "leafs/exception", + "version": "v3.2.1", + "source": { + "type": "git", + "url": "https://github.com/leafsphp/exceptions.git", + "reference": "064a24f34c719a8121da2d737eddc9917ddca263" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/leafsphp/exceptions/zipball/064a24f34c719a8121da2d737eddc9917ddca263", + "reference": "064a24f34c719a8121da2d737eddc9917ddca263", + "shasum": "" + }, + "require": { + "php": "^5.5.9 || ^7.0 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "mockery/mockery": "^0.9 || ^1.0", + "phpunit/phpunit": "^4.8.36 || ^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.8 || ^9.3.3", + "symfony/var-dumper": "^2.6 || ^3.0 || ^4.0 || ^5.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "autoload": { + "psr-4": { + "Leaf\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + }, + { + "name": "Michael Darko", + "email": "mickdd22@gmail.com", + "homepage": "https://mychi.netlify.app", + "role": "Developer" + } + ], + "description": "Error handler for leaf (fork of whoops)", + "homepage": "https://github.com/leafsphp/exception", + "keywords": [ + "error", + "exception", + "handling", + "library", + "throwable", + "whoops" + ], + "support": { + "source": "https://github.com/leafsphp/exceptions/tree/v3.2.1" + }, + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2023-07-08T12:03:30+00:00" + }, + { + "name": "leafs/http", + "version": "v2.2.3", + "source": { + "type": "git", + "url": "https://github.com/leafsphp/http.git", + "reference": "77eebb3db4c722f04f9ca53ee28e9c62a5294505" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/leafsphp/http/zipball/77eebb3db4c722f04f9ca53ee28e9c62a5294505", + "reference": "77eebb3db4c722f04f9ca53ee28e9c62a5294505", + "shasum": "" + }, + "require": { + "leafs/anchor": "*" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Leaf\\Http\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Darko", + "email": "mickdd22@gmail.com", + "homepage": "https://mychi.netlify.app", + "role": "Developer" + } + ], + "description": "Leaf PHP HTTP module.", + "homepage": "https://leafphp.dev/modules/http/v/2/request.html", + "keywords": [ + "framework", + "headers", + "http", + "leaf", + "php", + "request", + "response" + ], + "support": { + "issues": "https://github.com/leafsphp/http/issues", + "source": "https://github.com/leafsphp/http/tree/v2.2.3" + }, + "funding": [ + { + "url": "https://github.com/leafsphp", + "type": "github" + }, + { + "url": "https://opencollective.com/leaf", + "type": "open_collective" + } + ], + "time": "2023-04-20T20:43:16+00:00" + }, + { + "name": "leafs/leaf", + "version": "v3.4.1", + "source": { + "type": "git", + "url": "https://github.com/leafsphp/leaf.git", + "reference": "8964d19c3c129721d1baa403141499ae59c10c7f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/leafsphp/leaf/zipball/8964d19c3c129721d1baa403141499ae59c10c7f", + "reference": "8964d19c3c129721d1baa403141499ae59c10c7f", + "shasum": "" + }, + "require": { + "leafs/anchor": "*", + "leafs/exception": "*", + "leafs/http": "*", + "leafs/router": "*" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.0", + "pestphp/pest": "^1.21" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Leaf\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Darko", + "email": "mickdd22@gmail.com", + "homepage": "https://mychi.netlify.app", + "role": "Developer" + } + ], + "description": "Simple, performant and powerful PHP micro-framework for rapid web app & API development", + "homepage": "https://leafphp.dev", + "keywords": [ + "framework", + "leaf", + "microframework", + "php", + "rest", + "router" + ], + "support": { + "issues": "https://github.com/leafsphp/leaf/issues", + "source": "https://github.com/leafsphp/leaf/tree/v3.4.1" + }, + "funding": [ + { + "url": "https://github.com/leafsphp", + "type": "github" + }, + { + "url": "https://opencollective.com/leaf", + "type": "open_collective" + } + ], + "time": "2023-07-08T12:13:03+00:00" + }, + { + "name": "leafs/router", + "version": "v0.2.3", + "source": { + "type": "git", + "url": "https://github.com/leafsphp/router.git", + "reference": "d7d66f7e76714885d878e70c7cc2c117d5c998ad" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/leafsphp/router/zipball/d7d66f7e76714885d878e70c7cc2c117d5c998ad", + "reference": "d7d66f7e76714885d878e70c7cc2c117d5c998ad", + "shasum": "" + }, + "require": { + "leafs/anchor": "*", + "leafs/http": "*" + }, + "require-dev": { + "pestphp/pest": "^1.21" + }, + "type": "library", + "autoload": { + "psr-4": { + "Leaf\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Darko", + "email": "mickdd22@gmail.com", + "homepage": "https://mychi.netlify.app", + "role": "Developer" + } + ], + "description": "Leaf router module for Leaf PHP.", + "homepage": "https://leafphp.netlify.app/#/modules/router", + "keywords": [ + "framework", + "leaf", + "php", + "rest", + "router" + ], + "support": { + "issues": "https://github.com/leafsphp/router/issues", + "source": "https://github.com/leafsphp/router/tree/v0.2.3" + }, + "funding": [ + { + "url": "https://github.com/leafsphp", + "type": "github" + }, + { + "url": "https://opencollective.com/leaf", + "type": "open_collective" + } + ], + "time": "2023-06-30T09:40:09+00:00" + }, + { + "name": "netresearch/jsonmapper", + "version": "v4.2.0", + "source": { + "type": "git", + "url": "https://github.com/cweiske/jsonmapper.git", + "reference": "f60565f8c0566a31acf06884cdaa591867ecc956" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cweiske/jsonmapper/zipball/f60565f8c0566a31acf06884cdaa591867ecc956", + "reference": "f60565f8c0566a31acf06884cdaa591867ecc956", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-pcre": "*", + "ext-reflection": "*", + "ext-spl": "*", + "php": ">=7.1" + }, + "require-dev": { + "phpunit/phpunit": "~7.5 || ~8.0 || ~9.0", + "squizlabs/php_codesniffer": "~3.5" + }, + "type": "library", + "autoload": { + "psr-0": { + "JsonMapper": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "OSL-3.0" + ], + "authors": [ + { + "name": "Christian Weiske", + "email": "cweiske@cweiske.de", + "homepage": "http://github.com/cweiske/jsonmapper/", + "role": "Developer" + } + ], + "description": "Map nested JSON structures onto PHP classes", + "support": { + "email": "cweiske@cweiske.de", + "issues": "https://github.com/cweiske/jsonmapper/issues", + "source": "https://github.com/cweiske/jsonmapper/tree/v4.2.0" + }, + "time": "2023-04-09T17:37:40+00:00" + }, + { + "name": "psr/log", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "fe5ea303b0887d5caefd3d431c3e61ad47037001" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/fe5ea303b0887d5caefd3d431c3e61ad47037001", + "reference": "fe5ea303b0887d5caefd3d431c3e61ad47037001", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.0" + }, + "time": "2021-07-14T16:46:02+00:00" + }, + { + "name": "visus/cuid2", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/visus-io/php-cuid2.git", + "reference": "0a422fa4785c3ce1f01f60cec35684ed31f46860" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/visus-io/php-cuid2/zipball/0a422fa4785c3ce1f01f60cec35684ed31f46860", + "reference": "0a422fa4785c3ce1f01f60cec35684ed31f46860", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.29", + "ext-ctype": "*", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpstan/phpstan": "^1.9", + "phpunit/phpunit": "^10.0", + "squizlabs/php_codesniffer": "^3.7", + "vimeo/psalm": "^5.4" + }, + "suggest": { + "ext-gmp": "*" + }, + "type": "library", + "autoload": { + "files": [ + "src/compat.php" + ], + "psr-4": { + "Visus\\Cuid2\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Alan Brault", + "email": "alan.brault@visus.io" + } + ], + "description": "A PHP library for generating collision-resistant ids (CUIDs).", + "keywords": [ + "cuid", + "identifier" + ], + "support": { + "issues": "https://github.com/visus-io/php-cuid2/issues", + "source": "https://github.com/visus-io/php-cuid2/tree/3.0.0" + }, + "time": "2023-08-11T16:22:53+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [], + "plugin-api-version": "2.3.0" +} diff --git a/src/app/documents/Configuration.php b/src/app/documents/Configuration.php new file mode 100644 index 0000000..09d1623 --- /dev/null +++ b/src/app/documents/Configuration.php @@ -0,0 +1,31 @@ +query(Definition::createTable($name))->execute(); + } + + /** + * Ensure an index on the given document table exists + * + * @param string $name The name of the table for which the index should be created + * @param DocumentIndex $type The type of index to create + */ + public static function ensureIndex(string $name, DocumentIndex $type) + { + pdo()->query(Definition::createIndex($name, $type))->execute(); + } +} diff --git a/src/app/documents/Document.php b/src/app/documents/Document.php new file mode 100644 index 0000000..13361a1 --- /dev/null +++ b/src/app/documents/Document.php @@ -0,0 +1,454 @@ + $className The name of the class onto which the JSON will be mapped + * @return Type The domain type + */ + public static function mapDocFromJson(string $columnName, array $result, string $className): mixed + { + if (is_null(Document::$mapper)) { + Document::$mapper = new \JsonMapper(); + } + + $mapped = new $className(); + Document::$mapper->map(json_decode($result[$columnName]), $mapped); + return $mapped; + } + + /** + * Map a domain type from the JSON document retrieved + * + * @param array $result An associative array with a single result to be mapped + * @param class-string $className The name of the class onto which the JSON will be mapped + * @return Type The domain type + */ + public static function mapFromJson(array $result, string $className): mixed + { + return Document::mapDocFromJson('data', $result, $className); + } + + /** + * Execute a document-focused statement that does not return results + * + * @param string $query The query to be executed + * @param string $docId The ID of the document on which action should be taken + * @param array|object $document The array or object representing the document + */ + private static function executeNonQuery(string $query, string $docId, array|object $document) + { + $nonQuery = pdo()->prepare($query); + $nonQuery->bindParam('@id', $docId); + $nonQuery->bindParam('@data', Query::jsonbDocParam($document)); + $nonQuery->execute(); + } + + /** + * Insert a document + * + * @param string $tableName The name of the table into which a document should be inserted + * @param string $docId The ID of the document to be inserted + * @param array|object $document The array or object representing the document + */ + public static function insert(string $tableName, string $docId, array|object $document) + { + Document::executeNonQuery(Query::insert($tableName), $docId, $document); + } + + /** + * Save (upsert) a document + * + * @param string $tableName The name of the table into which a document should be inserted + * @param string $docId The ID of the document to be inserted + * @param array|object $document The array or object representing the document + */ + public static function save(string $tableName, string $docId, array|object $document) + { + Document::executeNonQuery(Query::save($tableName), $docId, $document); + } + + /** + * Count all documents in a table + * + * @param string $tableName The name of the table in which documents should be counted + * @return int The number of documents in the table + */ + public static function countAll(string $tableName): int + { + $result = pdo()->query(Query::countAll($tableName))->fetch(\PDO::FETCH_ASSOC); + return intval($result['it']); + } + + /** + * Count documents in a table by JSON containment `@>` + * + * @param string $tableName The name of the table in which documents should be counted + * @param array|object $criteria The criteria for the JSON containment query + * @return int The number of documents in the table matching the JSON containment query + */ + public static function countByContains(string $tableName, array|object $criteria): int + { + $query = pdo()->prepare(Query::countByContains($tableName)); + $query->bindParam('@criteria', Query::jsonbDocParam($criteria)); + $query->execute(); + $result = $query->fetch(\PDO::FETCH_ASSOC); + return intval($result['it']); + } + + /** + * Count documents in a table by JSON Path match `@?` + * + * @param string $tableName The name of the table in which documents should be counted + * @param string $jsonPath The JSON Path to be matched + * @return int The number of documents in the table matching the JSON Path + */ + public static function countByJsonPath(string $tableName, string $jsonPath): int + { + $query = pdo()->prepare(Query::countByContains($tableName)); + $query->bindParam('@path', $jsonPath); + $query->execute(); + $result = $query->fetch(\PDO::FETCH_ASSOC); + return intval($result['it']); + } + + /** + * Determine if a document exists for the given ID + * + * @param string $tableName The name of the table in which existence should be checked + * @param string $docId The ID of the document whose existence should be checked + * @return bool True if the document exists, false if not + */ + public static function existsById(string $tableName, string $docId): bool + { + $query = pdo()->prepare(Query::existsById($tableName)); + $query->bindParam('@id', $docId); + $query->execute(); + $result = $query->fetch(\PDO::FETCH_ASSOC); + return boolval($result['it']); + } + + /** + * Determine if documents exist by JSON containment `@>` + * + * @param string $tableName The name of the table in which existence should be checked + * @param array|object $criteria The criteria for the JSON containment query + * @return int True if any documents in the table match the JSON containment query, false if not + */ + public static function existsByContains(string $tableName, array|object $criteria): bool + { + $query = pdo()->prepare(Query::existsByContains($tableName)); + $query->bindParam('@criteria', Query::jsonbDocParam($criteria)); + $query->execute(); + $result = $query->fetch(\PDO::FETCH_ASSOC); + return boolval($result['it']); + } + + /** + * Determine if documents exist by JSON Path match `@?` + * + * @param string $tableName The name of the table in which existence should be checked + * @param string $jsonPath The JSON Path to be matched + * @return int True if any documents in the table match the JSON Path, false if not + */ + public static function existsByJsonPath(string $tableName, string $jsonPath): bool + { + $query = pdo()->prepare(Query::existsByJsonPath($tableName)); + $query->bindParam('@path', $jsonPath); + $query->execute(); + $result = $query->fetch(\PDO::FETCH_ASSOC); + return boolval($result['it']); + } + + /** + * Map the results of a query to domain type objects + * + * @param \PDOStatement $stmt The statement with the query to be run + * @param class-string $className The type of document to be mapped + * @return array The documents matching the query + */ + private static function mapResults(\PDOStatement $stmt, string $className): array + { + return array_map(fn ($it) => Document::mapFromJson($it, $className), $stmt->fetchAll(\PDO::FETCH_ASSOC)); + } + + /** + * Retrieve all documents in a table + * + * @param string $tableName The table from which all documents should be retrieved + * @param class-string $className The type of document to be retrieved + * @return array An array of documents + */ + public static function findAll(string $tableName, string $className): array + { + return Document::mapResults(pdo()->query(Query::selectFromTable($tableName)), $className); + } + + /** + * Retrieve a document by its ID + * + * @param string $tableName The table from which a document should be retrieved + * @param string $docId The ID of the document to retrieve + * @param class-string $className The type of document to retrieve + * @return Type|null The document, or null if it is not found + */ + public static function findById(string $tableName, string $docId, string $className): mixed + { + $query = pdo()->prepare(Query::findById($tableName)); + $query->bindParam(':id', $docId); + $query->execute(); + $result = $query->fetch(\PDO::FETCH_ASSOC); + return $result ? Document::mapFromJson($result, $className) : null; + } + + /** + * Create a JSON containment query + * + * @param string $tableName The table from which documents should be retrieved + * @param array|object $criteria The criteria for the JSON containment query + * @return \PDOStatement An executed query ready to be fetched + */ + private static function queryByContains(string $tableName, array|object $criteria): \PDOStatement + { + $query = pdo()->prepare(Query::findByContains($tableName)); + $query->bindParam('@criteria', Query::jsonbDocParam($criteria)); + $query->execute(); + return $query; + } + + /** + * Retrieve documents in a table via JSON containment `@>` + * + * @param string $tableName The table from which documents should be retrieved + * @param array|object $criteria The criteria for the JSON containment query + * @param class-string $className The type of document to be retrieved + * @return array Documents matching the JSON containment query + */ + public static function findByContains(string $tableName, array|object $criteria, string $className): array + { + return Document::mapResults(Document::queryByContains($tableName, $criteria), $className); + } + + /** + * Retrieve the first matching document via JSON containment `@>` + * + * @param string $tableName The table from which documents should be retrieved + * @param array|object $criteria The criteria for the JSON containment query + * @param class-string $className The type of document to be retrieved + * @return Type|null The document, or null if none match + */ + public static function findFirstByContains(string $tableName, array|object $criteria, string $className): mixed + { + $query = Document::queryByContains($tableName, $criteria); + $result = $query->fetch(\PDO::FETCH_ASSOC); + return $result ? Document::mapFromJson($result, $className) : null; + } + + /** + * Retrieve documents in a table via JSON Path match `@?` + * + * @param string $tableName The table from which documents should be retrieved + * @param string $jsonPath The JSON Path to be matched + * @return \PDOStatement An executed query ready to be fetched + */ + private static function queryByJsonPath(string $tableName, string $jsonPath): \PDOStatement + { + $query = pdo()->prepare(Query::findByJsonPath($tableName)); + $query->bindParam('@path', $jsonPath); + $query->execute(); + return $query; + } + + /** + * Retrieve documents in a table via JSON Path match `@?` + * + * @param string $tableName The table from which documents should be retrieved + * @param string $jsonPath The JSON Path to be matched + * @param class-string $className The type of document to be retrieved + * @return array Documents matching the JSON Path + */ + public static function findByJsonPath(string $tableName, string $jsonPath, string $className): array + { + return Document::mapResults(Document::queryByJsonPath($tableName, $jsonPath), $className); + } + + /** + * Retrieve the first matching document via JSON Path match `@?` + * + * @param string $tableName The table from which documents should be retrieved + * @param string $jsonPath The JSON Path to be matched + * @param class-string $className The type of document to be retrieved + * @return Type|null The document, or null if none match + */ + public static function findFirstByJsonPath(string $tableName, string $jsonPath, string $className): mixed + { + $query = Document::queryByJsonPath($tableName, $jsonPath); + $result = $query->fetch(\PDO::FETCH_ASSOC); + return $result ? Document::mapFromJson($result, $className) : null; + } + + /** + * Update a full document + * + * @param string $tableName The table in which the document should be updated + * @param string $docId The ID of the document to be updated + * @param array|object $document The document to be updated + */ + public static function updateFull(string $tableName, string $docId, array|object $document) + { + Document::executeNonQuery(Query::updateFull($tableName), $docId, $document); + } + + /** + * Update a partial document by its ID + * + * @param string $tableName The table in which the document should be updated + * @param string $docId The ID of the document to be updated + * @param array|object $document The partial document to be updated + */ + public static function updatePartialById(string $tableName, string $docId, array|object $document) + { + Document::executeNonQuery(Query::updatePartialById($tableName), $docId, $document); + } + + /** + * Update partial documents by JSON containment `@>` + * + * @param string $tableName The table in which documents should be updated + * @param array|object $criteria The JSON containment criteria + * @param array|object $document The document to be updated + */ + public static function updatePartialByContains(string $tableName, array|object $criteria, array|object $document) + { + $query = pdo()->prepare(Query::updatePartialByContains($tableName)); + $query->bindParam('@data', Query::jsonbDocParam($document)); + $query->bindParam('@criteria', Query::jsonbDocParam($criteria)); + $query->execute(); + } + + /** + * Update partial documents by JSON Path match `@?` + * + * @param string $tableName The table in which documents should be updated + * @param string $jsonPath The JSON Path to be matched + * @param array|object $document The document to be updated + */ + public static function updatePartialByJsonPath(string $tableName, string $jsonPath, array|object $document) + { + $query = pdo()->prepare(Query::updatePartialByContains($tableName)); + $query->bindParam('@data', Query::jsonbDocParam($document)); + $query->bindParam('@path', $jsonPath); + $query->execute(); + } + + /** + * Delete a document by its ID + * + * @param string $tableName The table from which a document should be deleted + * @param string $docId The ID of the document to be deleted + */ + public static function deleteById(string $tableName, string $docId) + { + Document::executeNonQuery(Query::deleteById($tableName), $docId, []); + } + + /** + * Delete documents by JSON containment `@>` + * + * @param string $tableName The table from which documents should be deleted + * @param array|object $criteria The criteria for the JSON containment query + */ + public static function deleteByContains(string $tableName, array|object $criteria) + { + $query = pdo()->prepare(Query::deleteByContains($tableName)); + $query->bindParam('@criteria', Query::jsonbDocParam($criteria)); + $query->execute(); + } + + /** + * Delete documents by JSON Path match `@?` + * + * @param string $tableName The table from which documents should be deleted + * @param string $jsonPath The JSON Path expression to be matched + */ + public static function deleteByJsonPath(string $tableName, string $jsonPath) + { + $query = pdo()->prepare(Query::deleteByJsonPath($tableName)); + $query->bindParam('@path', $jsonPath); + $query->execute(); + } + + // TODO: custom + + /** + * Create and execute a custom query + * + * @param string $sql The SQL query to execute + * @param array $params An associative array of parameters for the SQL query + * @return PDOStatement The query, executed and ready to be fetched + */ + private static function createCustomQuery(string $sql, array $params): PDOStatement + { + $query = pdo()->prepare($sql); + foreach ($params as $name => $value) { + $query->bindParam($name, $value); + } + $query->execute(); + return $query; + } + + /** + * Retrieve documents via a custom query and mapping + * + * @param string $sql The SQL query to execute + * @param array $params An associative array of parameters for the SQL query + * @param callable $mapFunc A function that expects an associative array and returns a value of the desired type + * @param class-string $className The type of document to be mapped + * @return array The documents matching the query + */ + public static function customList(string $sql, array $params, string $className, callable $mapFunc): array + { + return array_map( + fn ($it) => $mapFunc($it, $className), + Document::createCustomQuery($sql, $params)->fetchAll(\PDO::FETCH_ASSOC)); + } + + /** + * Retrieve a document via a custom query and mapping + * + * @param string $sql The SQL query to execute + * @param array $params An associative array of parameters for the SQL query + * @param callable $mapFunc A function that expects an associative array and returns a value of the desired type + * @param class-string $className The type of document to be mapped + * @return ?Type The document matching the query, or null if none is found + */ + public static function customSingle(string $sql, array $params, string $className, callable $mapFunc): mixed + { + $result = Document::createCustomQuery($sql, $params)->fetch(\PDO::FETCH_ASSOC); + return $result ? $mapFunc($result, $className) : null; + } + + /** + * Execute a custom query that does not return a result + * + * @param string $sql The SQL query to execute + * @param array $params An associative array of parameters for the SQL query + */ + public static function customNonQuery(string $sql, array $params) + { + Document::createCustomQuery($sql, $params); + } +} diff --git a/src/app/documents/DocumentIndex.php b/src/app/documents/DocumentIndex.php new file mode 100644 index 0000000..7a61d56 --- /dev/null +++ b/src/app/documents/DocumentIndex.php @@ -0,0 +1,14 @@ +`, `@?`, `@@` operators) */ + case Optimized; +} diff --git a/src/app/documents/Query.php b/src/app/documents/Query.php new file mode 100644 index 0000000..d608f6d --- /dev/null +++ b/src/app/documents/Query.php @@ -0,0 +1,255 @@ + (JSON contains) condition + * + * @param string $paramName The name of the parameter for the contains clause + * @return string A `WHERE` clause fragment with the named parameter + */ + public static function whereDataContains(string $paramName): string + { + return "data @> $paramName"; + } + + /** + * Create a `WHERE` clause fragment to implement a @? (JSON Path match) condition + * + * @param string $paramName THe name of the parameter for the JSON Path match + * @return string A `WHERE` clause fragment with the named parameter + */ + public static function whereJsonPathMatches(string $paramName): string + { + return "data @? {$paramName}::jsonpath"; + } + + /** + * Create a JSONB document parameter + * + * @param array|object $it The array or object to become a JSONB parameter + * @return string The encoded JSON + */ + public static function jsonbDocParam(array|object $it): string + { + return json_encode($it); + } + + /// Create ID and data parameters for a query + /* let docParameters<'T> docId (doc : 'T) = + [ "@id", Sql.string docId; "@data", jsonbDocParam doc ] + */ + /** + * Query to insert a document + * + * @param string $tableName The name of the table into which the document will be inserted + * @return string The `INSERT` statement (with `@id` and `@data` parameters defined) + */ + public static function insert(string $tableName): string + { + return "INSERT INTO $tableName (id, data) VALUES (@id, @data)"; + } + + /** + * Query to save a document, inserting it if it does not exist and updating it if it does (AKA "upsert") + * + * @param string $tableName The name of the table into which the document will be saved + * @return string The `INSERT`/`ON CONFLICT DO UPDATE` statement (with `@id` and `@data` parameters defined) + */ + public static function save(string $tableName): string + { + return "INSERT INTO $tableName (id, data) VALUES (@id, @data) + ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data"; + } + + /** + * Query to count all documents in a table + * + * @param string $tableName The name of the table whose rows will be counted + * @return string A `SELECT` statement to obtain the count of all documents in the given table + */ + public static function countAll(string $tableName): string + { + return "SELECT COUNT(id) AS it FROM $tableName"; + } + + /** + * Query to count matching documents using a JSON containment query `@>` + * + * @param string $tableName The name of the table from which the count should be obtained + * @return string A `SELECT` statement to obtain the count of documents via JSON containment + */ + public static function countByContains(string $tableName): string + { + return "SELECT COUNT(id) AS it FROM $tableName WHERE " . Query::whereDataContains('@criteria'); + } + + /** + * Query to count matching documents using a JSON Path match `@?` + * + * @param string $tableName The name of the table from which the count should be obtained + * @return string A `SELECT` statement to obtain the count of documents via JSON Path match + */ + public static function countByJsonPath(string $tableName): string + { + return "SELECT COUNT(id) AS it FROM $tableName WHERE " . Query::whereJsonPathMatches('@path'); + } + + /** + * Query to determine if a document exists for the given ID + * + * @param string $tableName The name of the table in which existence should be checked + * @return string A `SELECT` statement to check existence of a document by its ID + */ + public static function existsById(string $tableName): string + { + return "SELECT EXISTS (SELECT 1 FROM $tableName WHERE id = @id) AS it"; + } + + /** + * Query to determine if documents exist using a JSON containment query `@>` + * + * @param string $tableName The name of the table in which existence should be checked + * @return string A `SELECT` statement to check existence of a document by JSON containment + */ + public static function existsByContains(string $tableName): string + { + return "SELECT EXISTS (SELECT 1 FROM $tableName WHERE " . Query::whereDataContains('@criteria') . ' AS it'; + } + + /** + * Query to determine if documents exist using a JSON Path match `@?` + * + * @param string $tableName The name of the table in which existence should be checked + * @return string A `SELECT` statement to check existence of a document by JSON Path match + */ + public static function existsByJsonPath(string $tableName): string + { + return "SELECT EXISTS (SELECT 1 FROM $tableName WHERE " . Query::whereJsonPathMatches('@path') . ' AS it'; + } + + /** + * Query to retrieve a document by its ID + * + * @param string $tableName The name of the table from which a document should be retrieved + * @return string A `SELECT` statement to retrieve a document by its ID + */ + public static function findById(string $tableName): string + { + return Query::selectFromTable($tableName) . ' WHERE id = :id'; + } + + /** + * Query to retrieve documents using a JSON containment query `@>` + * + * @param string $tableName The name of the table from which a document should be retrieved + * @return string A `SELECT` statement to retrieve documents by JSON containment + */ + public static function findByContains(string $tableName): string + { + return Query::selectFromTable($tableName) . ' WHERE ' . Query::whereDataContains('@criteria'); + } + + /** + * Query to retrieve documents using a JSON Path match `@?` + * + * @param string $tableName The name of the table from which a document should be retrieved + * @return string A `SELECT` statement to retrieve a documents by JSON Path match + */ + public static function findByJsonPath(string $tableName): string + { + return Query::selectFromTable($tableName) . ' WHERE ' . Query::whereJsonPathMatches('@path'); + } + + /** + * Query to update a document, replacing the existing document + * + * @param string $tableName The name of the table in which a document should be updated + * @return string An `UPDATE` statement to update a document by its ID + */ + public static function updateFull(string $tableName): string + { + return "UPDATE $tableName SET data = @data WHERE id = @id"; + } + + /** + * Query to update a document, merging the existing document with the one provided + * + * @param string $tableName The name of the table in which a document should be updated + * @return string An `UPDATE` statement to update a document by its ID + */ + public static function updatePartialById(string $tableName): string + { + return "UPDATE $tableName SET data = data || @data WHERE id = @id"; + } + + /** + * Query to update partial documents matching a JSON containment query `@>` + * + * @param string $tableName The name of the table in which documents should be updated + * @return string An `UPDATE` statement to update documents by JSON containment + */ + public static function updatePartialByContains(string $tableName): string + { + return "UPDATE $tableName SET data = data || @data WHERE " . Query::whereDataContains('@criteria'); + } + + /** + * Query to update partial documents matching a JSON containment query `@>` + * + * @param string $tableName The name of the table in which documents should be updated + * @return string An `UPDATE` statement to update documents by JSON Path match + */ + public static function updatePartialByJsonPath(string $tableName): string + { + return "UPDATE $tableName SET data = data || @data WHERE " . Query::whereJsonPathMatches('@path'); + } + + /** + * Query to delete a document by its ID + * + * @param string $tableName The name of the table from which a document should be deleted + * @return string A `DELETE` statement to delete a document by its ID + */ + public static function deleteById(string $tableName): string + { + return "DELETE FROM $tableName WHERE id = @id"; + } + + /** + * Query to delete documents using a JSON containment query `@>` + * + * @param string $tableName The name of the table from which documents should be deleted + * @return string A `DELETE` statement to delete documents by JSON containment + */ + public static function deleteByContains(string $tableName): string + { + return "DELETE FROM $tableName WHERE " . Query::whereDataContains('@criteria'); + } + + /** + * Query to delete documents using a JSON Path match `@?` + * + * @param string $tableName The name of the table from which documents should be deleted + * @return string A `DELETE` statement to delete documents by JSON Path match + */ + public static function deleteByJsonPath(string $tableName): string + { + return "DELETE FROM $tableName WHERE " . Query::whereJsonPathMatches('@path'); + } +} diff --git a/src/app/documents/functions.php b/src/app/documents/functions.php new file mode 100644 index 0000000..a302a0f --- /dev/null +++ b/src/app/documents/functions.php @@ -0,0 +1,15 @@ +asOf = new DateTimeImmutable('1/1/1970', new \DateTimeZone('Etc/UTC')); + } + + public function isCreated(): bool + { + return $this->action == RequestAction::Created; + } + + public function isPrayed(): bool + { + return $this->action == RequestAction::Prayed; + } + + public function isAnswered(): bool + { + return $this->action == RequestAction::Answered; + } +} diff --git a/src/app/domain/JournalRequest.php b/src/app/domain/JournalRequest.php new file mode 100644 index 0000000..3dbcb59 --- /dev/null +++ b/src/app/domain/JournalRequest.php @@ -0,0 +1,87 @@ +asOf = new \DateTimeImmutable('1/1/1970', new \DateTimeZone('Etc/UTC')); + $this->lastPrayed = new \DateTimeImmutable('1/1/1970', new \DateTimeZone('Etc/UTC')); + } else { + $this->id = $req->id; + $this->userId = $req->userId; + $this->snoozedUntil = $req->snoozedUntil; + $this->showAfter = $req->showAfter; + $this->recurrenceType = $req->recurrenceType; + $this->recurrence = $req->recurrence; + + usort($req->history, + fn (History $a, History $b) => $a->asOf == $b->asOf ? 0 : ($a->asOf > $b->asOf ? -1 : 1)); + $this->asOf = $req->history[0]->asOf; + $this->lastPrayed = + array_values(array_filter($req->history, fn (History $it) => $it->action == RequestAction::Prayed))[0] + ?->asOf; + + if ($full) { + usort($req->notes, + fn (Note $a, Note $b) => $a->asOf == $b->asOf ? 0 : ($a->asOf > $b->asOf ? -1 : 1)); + $this->history = $req->history; + $this->notes = $req->notes; + } + } + } +} diff --git a/src/app/domain/Note.php b/src/app/domain/Note.php new file mode 100644 index 0000000..17e3861 --- /dev/null +++ b/src/app/domain/Note.php @@ -0,0 +1,21 @@ +asOf = new \DateTimeImmutable('1/1/1970', new \DateTimeZone('Etc/UTC')); + } +} diff --git a/src/app/domain/RecurrenceType.php b/src/app/domain/RecurrenceType.php new file mode 100644 index 0000000..ea0091e --- /dev/null +++ b/src/app/domain/RecurrenceType.php @@ -0,0 +1,30 @@ +name; + } +} diff --git a/src/app/domain/Request.php b/src/app/domain/Request.php new file mode 100644 index 0000000..1cd304e --- /dev/null +++ b/src/app/domain/Request.php @@ -0,0 +1,51 @@ +id = new Cuid2(); + $this->enteredOn = new \DateTimeImmutable('1/1/1970', new \DateTimeZone('Etc/UTC')); + } +} diff --git a/src/app/domain/RequestAction.php b/src/app/domain/RequestAction.php new file mode 100644 index 0000000..2484f6c --- /dev/null +++ b/src/app/domain/RequestAction.php @@ -0,0 +1,30 @@ +name; + } +} diff --git a/src/app/index.php b/src/app/index.php new file mode 100644 index 0000000..e929bcb --- /dev/null +++ b/src/app/index.php @@ -0,0 +1,49 @@ +template->config('path', './pages'); +app()->template->config('params', [ + // 'app' => function () { return app(); }, + 'page_link' => function (string $url, bool $checkActive = false) { + echo 'href="'. $url . '" hx-get="' . $url . '"'; + if ($checkActive && str_starts_with($_SERVER['REQUEST_URI'], $url)) { + echo ' class="is-active-route"'; + } + echo 'hx-target="#top" hx-swap="innerHTML" hx-push-url="true"'; + }, + 'version' => 'v4', +]); + +function renderPage(string $template, array $params, string $pageTitle) +{ + if (is_null($params)) { + $params = []; + } + $params['pageTitle'] = $pageTitle; + $params['isHtmx'] = + array_key_exists('HTTP_HX_REQUEST', $_SERVER) + && (!array_key_exists('HTTP_HX_HISTORY_RESTORE_REQUEST', $_SERVER)); + $params['userId'] = false; + $params['pageContent'] = app()->template->render($template, $params); + // TODO: make the htmx distinction here + response()->markup(app()->template->render('layout/full', $params)); +} + +app()->get('/', function () { + renderPage('home', [], 'Welcome'); +}); + +app()->get('/legal/privacy-policy', function () { + renderPage('legal/privacy-policy', [], 'Privacy Policy'); +}); + +app()->get('/legal/terms-of-service', function () { + renderPage('legal/terms-of-service', [], 'Terms of Service'); +}); + +app()->run(); diff --git a/src/app/pages/home.view.php b/src/app/pages/home.view.php new file mode 100644 index 0000000..5b58762 --- /dev/null +++ b/src/app/pages/home.view.php @@ -0,0 +1,13 @@ +
+

 

+

+ myPrayerJournal is a place where individuals can record their prayer requests, record that they prayed for them, + update them as God moves in the situation, and record a final answer received on that request. It also allows + individuals to review their answered prayers. +

+

+ This site is open and available to the general public. To get started, simply click the “Log On” + link above, and log on with either a Microsoft or Google account. You can also learn more about the site at the + “Docs” link, also above. +

+
diff --git a/src/app/pages/layout/_foot.view.php b/src/app/pages/layout/_foot.view.php new file mode 100644 index 0000000..2e53de7 --- /dev/null +++ b/src/app/pages/layout/_foot.view.php @@ -0,0 +1,30 @@ + + + <?php echo htmlentities($pageTitle); ?> « myPrayerJournal + + + + + + diff --git a/src/app/pages/layout/_nav.view.php b/src/app/pages/layout/_nav.view.php new file mode 100644 index 0000000..1046f39 --- /dev/null +++ b/src/app/pages/layout/_nav.view.php @@ -0,0 +1,21 @@ + diff --git a/src/app/pages/layout/full.view.php b/src/app/pages/layout/full.view.php new file mode 100644 index 0000000..375049f --- /dev/null +++ b/src/app/pages/layout/full.view.php @@ -0,0 +1,11 @@ + + +template->render('layout/_head', [ 'pageTitle' => $pageTitle, 'isHtmx' => $isHtmx ]); ?> + +
+ template->render('layout/_nav', [ 'userId' => $userId ]); ?> + +
+ template->render('layout/_foot', [ 'isHtmx' => $isHtmx ]); ?> + + diff --git a/src/app/pages/legal/privacy-policy.view.php b/src/app/pages/legal/privacy-policy.view.php new file mode 100644 index 0000000..9bcc589 --- /dev/null +++ b/src/app/pages/legal/privacy-policy.view.php @@ -0,0 +1,82 @@ +
+

Privacy Policy

+
as of May 21st, 2018
+

+ The nature of the service is one where privacy is a must. The items below will help you understand the data we + collect, access, and store on your behalf as you use this service. +

+
+
+
+

Third Party Services

+

+ myPrayerJournal utilizes a third-party authentication and identity provider. You should familiarize + yourself with the privacy policy for + Auth0, as well as + your chosen provider + (Microsoft or + Google). +

+
+
+

What We Collect

+

Identifying Data

+
    +
  • + The only identifying data myPrayerJournal stores is the subscriber (“sub”) field + from the token we receive from Auth0, once you have signed in through their hosted service. All + information is associated with you via this field. +
  • +
  • + While you are signed in, within your browser, the service has access to your first and last + names, along with a URL to the profile picture (provided by your selected identity provider). + This information is not transmitted to the server, and is removed when “Log Off” is + clicked. +
  • +
+

User Provided Data

+
    +
  • + myPrayerJournal stores the information you provide, including the text of prayer requests, + updates, and notes; and the date/time when certain actions are taken. +
  • +
+
+
+

How Your Data Is Accessed / Secured

+
    +
  • + Your provided data is returned to you, as required, to display your journal or your answered + requests. On the server, it is stored in a controlled-access database. +
  • +
  • + Your data is backed up, along with other Bit Badger Solutions hosted systems, in a rolling + manner; backups are preserved for the prior 7 days, and backups from the 1st and + 15th are preserved for 3 months. These backups are stored in a private cloud data + repository. +
  • +
  • + The data collected and stored is the absolute minimum necessary for the functionality of the + service. There are no plans to “monetize” this service, and storing the minimum + amount of information means that the data we have is not interesting to purchasers (or those who + may have more nefarious purposes). +
  • +
  • + Access to servers and backups is strictly controlled and monitored for unauthorized access + attempts. +
  • +
+
+
+

Removing Your Data

+

+ At any time, you may choose to discontinue using this service. Both Microsoft and Google provide + ways to revoke access from this application. However, if you want your data removed from the + database, please contact daniel at bitbadger.solutions (via e-mail, replacing at with @) prior to + doing so, to ensure we can determine which subscriber ID belongs to you. +

+
+
+
+
diff --git a/src/app/pages/legal/terms-of-service.view.php b/src/app/pages/legal/terms-of-service.view.php new file mode 100644 index 0000000..7a1d54c --- /dev/null +++ b/src/app/pages/legal/terms-of-service.view.php @@ -0,0 +1,57 @@ +
+

Terms of Service

+
as of May 21st, 2018
+
+
+
+

1. Acceptance of Terms

+

+ By accessing this web site, you are agreeing to be bound by these Terms and Conditions, and that you + are responsible to ensure that your use of this site complies with all applicable laws. Your + continued use of this site implies your acceptance of these terms. +

+
+
+

2. Description of Service and Registration

+

+ myPrayerJournal is a service that allows individuals to enter and amend their prayer requests. It + requires no registration by itself, but access is granted based on a successful login with an + external identity provider. See + >our privacy policy for details on how that + information is accessed and stored. +

+
+
+

3. Third Party Services

+

+ This service utilizes a third-party service provider for identity management. Review the terms of + service for Auth0, as well as + those for the selected authorization provider + (Microsoft or + Google). +

+
+
+

4. Liability

+

+ This service is provided “as is”, and no warranty (express or implied) exists. The + service and its developers may not be held liable for any damages that may arise through the use of + this service. +

+
+
+

5. Updates to Terms

+

+ These terms and conditions may be updated at any time, and this service does not have the capability + to notify users when these change. The date at the top of the page will be updated when any of the + text of these terms is updated. +

+
+
+
+

+ You may also wish to review our >privacy policy to learn how + we handle your data. +

+
diff --git a/src/app/script/mpj.js b/src/app/script/mpj.js new file mode 100644 index 0000000..946b751 --- /dev/null +++ b/src/app/script/mpj.js @@ -0,0 +1,104 @@ +"use strict" + +/** myPrayerJournal script */ +this.mpj = { + /** + * Show a message via toast + * @param {string} message The message to show + */ + showToast (message) { + const [level, msg] = message.split("|||") + + let header + if (level !== "success") { + const heading = typ => `${typ.toUpperCase()}` + + header = document.createElement("div") + header.className = "toast-header" + header.innerHTML = heading(level === "warning" ? level : "error") + + const close = document.createElement("button") + close.type = "button" + close.className = "btn-close" + close.setAttribute("data-bs-dismiss", "toast") + close.setAttribute("aria-label", "Close") + header.appendChild(close) + } + + const body = document.createElement("div") + body.className = "toast-body" + body.innerText = msg + + const toastEl = document.createElement("div") + toastEl.className = `toast bg-${level === "error" ? "danger" : level} text-white` + toastEl.setAttribute("role", "alert") + toastEl.setAttribute("aria-live", "assertlive") + toastEl.setAttribute("aria-atomic", "true") + toastEl.addEventListener("hidden.bs.toast", e => e.target.remove()) + if (header) toastEl.appendChild(header) + + toastEl.appendChild(body) + document.getElementById("toasts").appendChild(toastEl) + new bootstrap.Toast(toastEl, { autohide: level === "success" }).show() + }, + /** + * Load local version of Bootstrap CSS if the CDN load failed + */ + ensureCss () { + let loaded = false + for (let i = 0; !loaded && i < document.styleSheets.length; i++) { + loaded = document.styleSheets[i].href.endsWith("bootstrap.min.css") + } + if (!loaded) { + const css = document.createElement("link") + css.rel = "stylesheet" + css.href = "/style/bootstrap.min.css" + document.getElementsByTagName("head")[0].appendChild(css) + } + }, + /** Script for the request edit component */ + edit: { + /** + * Toggle the recurrence input fields + * @param {Event} e The click event + */ + toggleRecurrence ({ target }) { + const isDisabled = target.value === "Immediate" + ;["recurCount", "recurInterval"].forEach(it => document.getElementById(it).disabled = isDisabled) + } + }, + /** + * The time zone of the current browser + * @type {string} + **/ + timeZone: undefined, + /** + * Derive the time zone from the current browser + */ + deriveTimeZone () { + try { + this.timeZone = (new Intl.DateTimeFormat()).resolvedOptions().timeZone + } catch (_) { } + } +} + +htmx.on("htmx:afterOnLoad", function (evt) { + const hdrs = evt.detail.xhr.getAllResponseHeaders() + // Show a message if there was one in the response + if (hdrs.indexOf("x-toast") >= 0) { + mpj.showToast(evt.detail.xhr.getResponseHeader("x-toast")) + } + // Hide a modal window if requested + if (hdrs.indexOf("x-hide-modal") >= 0) { + document.getElementById(evt.detail.xhr.getResponseHeader("x-hide-modal") + "Dismiss").click() + } +}) + +htmx.on("htmx:configRequest", function (evt) { + // Send the user's current time zone so that we can display local time + if (mpj.timeZone) { + evt.detail.headers["X-Time-Zone"] = mpj.timeZone + } +}) + +mpj.deriveTimeZone() diff --git a/src/app/style/style.css b/src/app/style/style.css new file mode 100644 index 0000000..739dd70 --- /dev/null +++ b/src/app/style/style.css @@ -0,0 +1,60 @@ + +nav { + background-color: green; + + & .m { + font-weight: 100; + } + & .p { + font-weight: 400; + } + & .j { + font-weight: 700; + } +} +.nav-item { + & a:link, + & a:visited { + padding: .5rem 1rem; + margin: 0 .5rem; + border-radius: .5rem; + color: white; + text-decoration: none; + } + & a:hover { + cursor: pointer; + background-color: rgba(255, 255, 255, .2); + } + & a.is-active-route { + border-top-left-radius: 0; + border-top-right-radius: 0; + border-top: solid 4px rgba(255, 255, 255, .3); + } +} +form { + max-width: 60rem; + margin: auto; +} +.action-cell .material-icons { + font-size: 1.1rem ; +} +.material-icons { + vertical-align: bottom; +} +#toastHost { + position: sticky; + bottom: 0; +} +.request-text { + white-space: pre-line +} + +footer { + border-top: solid 1px lightgray; + margin: 1rem -1rem 0; + padding: 0 1rem; + + & p { + margin: 0; + } +} \ No newline at end of file