diff --git a/src/app/.htaccess b/src/app/.htaccess deleted file mode 100644 index 48a7a3d..0000000 --- a/src/app/.htaccess +++ /dev/null @@ -1,4 +0,0 @@ -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/AppUser.php b/src/app/AppUser.php deleted file mode 100644 index d674573..0000000 --- a/src/app/AppUser.php +++ /dev/null @@ -1,121 +0,0 @@ - $_ENV['AUTH0_DOMAIN'], - 'clientId' => $_ENV['AUTH0_CLIENT_ID'], - 'clientSecret' => $_ENV['AUTH0_CLIENT_SECRET'], - 'cookieSecret' => $_ENV['AUTH0_COOKIE_SECRET'] - ]); - } - return self::$auth0; - } - - /** - * Determine the host to use for return URLs - * - * @return string The host for return URLs - */ - private static function host() - { - return 'http' . ($_SERVER['SERVER_PORT'] == 443 ? 's' : '' ) . "://{$_SERVER['HTTP_HOST']}"; - } - - /** - * Generate the log on callback URL - * - * @return string The log on callback URL - */ - private static function logOnCallback() - { - return self::host() . '/user/log-on/success'; - } - - /** - * Initiate a redirect to the Auth0 log on page - * - * @param string $nextUrl The URL (other than /journal) to which the user should be redirected - * @return never This function exits the currently running script - */ - public static function logOn(?string $nextUrl = null): never - { - // TODO: pass the next URL in the Auth0 callback - self::auth0Instance()->clear(); - header('Location: ' . self::auth0Instance()->login(self::logOnCallback())); - exit; - } - - /** - * Process the log on response from Auth0 - * - * @return never This function exits the currently running script - */ - public static function processLogOn(): never - { - self::auth0Instance()->exchange(self::logOnCallback()); - // TODO: check for next URL and redirect if present - header('Location: /journal'); - exit; - } - - /** - * Log off the current user - * - * @return never This function exits the currently running script - */ - public static function logOff(): never - { - header('Location: ' . self::auth0Instance()->logout(self::host() . '/')); - exit; - } - - /** - * Get the current user - * - * @return ?object The current user, or null if one is not signed in - */ - public static function current(): ?object - { - return self::auth0Instance()->getCredentials(); - } - - /** - * Require that there be a user logged on - * - * @return void This will not return if there is not a user logged on - */ - public static function require() - { - if (is_null(self::current())) { - // TODO: get the current URL to specify for redirection - self::logOn(); - } - } - - /** - * Get the ID (`sub`) for the current user - * - * @return string The ID of the user (blank string if there is no current user) - */ - public static function currentId(): string - { - return self::auth0Instance()->getCredentials()?->user['sub'] ?? ''; - } -} diff --git a/src/app/Data.php b/src/app/Data.php deleted file mode 100644 index 7515054..0000000 --- a/src/app/Data.php +++ /dev/null @@ -1,153 +0,0 @@ -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 = self::findFullRequestById($reqId, $userId); - if (is_null($req)) throw new \InvalidArgumentException("$reqId not found"); - array_unshift($req->history, $history); - Document::updateFull(self::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 = self::findFullRequestById($reqId, $userId); - if (is_null($req)) throw new \InvalidArgumentException("$reqId not found"); - array_unshift($req->notes, $note); - Document::updateFull(self::REQ_TABLE, $reqId, $req); - } - - /** - * Add a new request - * - * @param Request $req The request to be added - */ - public static function addRequest(Request $req) - { - Document::insert(self::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 - { - $isAnswered = str_replace(':path', - "'$.history[*].action ? (@ $op \"" . RequestAction::Answered->name . "\")'", - Query::whereJsonPathMatches(':path')); - $sql = sprintf("%s WHERE %s AND $isAnswered", Query::selectFromTable(self::REQ_TABLE), - Query::whereDataContains(':criteria')); - $params = [ ':criteria' => Query::jsonbDocParam([ 'userId' => $userId ]) ]; - return self::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 = self::getJournalByAnswered($userId, '=='); - usort($answered, AsOf::newestToOldest(...)); - 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 = self::getJournalByAnswered($userId, '<>'); - usort($reqs, AsOf::oldestToNewest(...)); - return $reqs; - } - - /** - * Try to obtain a journal request by its ID - * - * @param string $reqId The request ID - * @param string $userId The ID of the currently logged-on user - * @return ?JournalRequest The request, or null if it is not found - */ - public static function tryJournalById(string $reqId, string $userId): ?JournalRequest - { - $req = self::findFullRequestById($reqId, $userId); - return is_null($req) ? null : new JournalRequest($req); - } -} diff --git a/src/app/Dates.php b/src/app/Dates.php deleted file mode 100644 index 4448f8b..0000000 --- a/src/app/Dates.php +++ /dev/null @@ -1,107 +0,0 @@ - $singular ? 'less than a minute' : 'less than %i minutes', - DistanceFormat::XMinutes => $singular ? 'a minute' : '%i minutes', - DistanceFormat::AboutXHours => $singular ? 'about an hour' : 'about %i hours', - DistanceFormat::XHours => $singular ? 'an hour' : '%i hours', - DistanceFormat::XDays => $singular ? 'a day' : '%i days', - DistanceFormat::AboutXWeeks => $singular ? 'about a week' : 'about %i weeks', - DistanceFormat::XWeeks => $singular ? 'a week' : '%i weeks', - DistanceFormat::AboutXMonths => $singular ? 'about a month' : 'about %i months', - DistanceFormat::XMonths => $singular ? 'a month' : '%i months', - DistanceFormat::AboutXYears => $singular ? 'about a year' : 'about %i years', - DistanceFormat::XYears => $singular ? 'a year' : '%i years', - DistanceFormat::OverXYears => $singular ? 'over a year' : 'over %i years', - DistanceFormat::AlmostXYears => $singular ? 'almost a year' : 'almost %i years', - }; - } -} - -class Dates -{ - /** Minutes in a day */ - private const A_DAY = 1_440; - - /** Minutes in two days(-ish) */ - private const ALMOST_2_DAYS = 2_520; - - /** Minutes in a month */ - private const A_MONTH = 43_200; - - /** Minutes in two months */ - private const TWO_MONTHS = 86_400; - - /** - * Get a UTC-referenced current date/time - * - * @return \DateTimeImmutable The current date/time with UTC reference - */ - public static function now(): \DateTimeImmutable - { - return new \DateTimeImmutable(timezone: new \DateTimeZone('Etc/UTC')); - } - - /** - * Format the distance between two instants in approximate English terms - * - * @param \DateTimeInterface $startOn The starting date/time for the comparison - * @param \DateTimeInterface $endOn THe ending date/time for the comparison - * @return string The formatted interval - */ - public static function formatDistance(\DateTimeInterface $startOn, \DateTimeInterface $endOn): string - { - $diff = $startOn->diff($endOn); - $minutes = - $diff->i + ($diff->h * 60) + ($diff->d * 60 * 24) + ($diff->m * 60 * 24 * 30) + ($diff->y * 60 * 24 * 365); - $months = round($minutes / self::A_MONTH); - $years = $months / 12; - [ $format, $number ] = match (true) { - $minutes < 1 => [ DistanceFormat::LessThanXMinutes, 1 ], - $minutes < 45 => [ DistanceFormat::XMinutes, $minutes ], - $minutes < 90 => [ DistanceFormat::AboutXHours, 1 ], - $minutes < self::A_DAY => [ DistanceFormat::AboutXHours, round($minutes / 60) ], - $minutes < self::ALMOST_2_DAYS => [ DistanceFormat::XDays, 1 ], - $minutes < self::A_MONTH => [ DistanceFormat::XDays, round($minutes / self::A_DAY) ], - $minutes < self::TWO_MONTHS => [ DistanceFormat::AboutXMonths, round($minutes / self::A_MONTH) ], - $months < 12 => [ DistanceFormat::XMonths, round($minutes / self::A_MONTH) ], - $months % 12 < 3 => [ DistanceFormat::AboutXYears, $years ], - $months % 12 < 9 => [ DistanceFormat::OverXYears, $years ], - default => [ DistanceFormat::AlmostXYears, $years + 1 ], - }; - - $relativeWords = sprintf(DistanceFormat::format($format, $number == 1), $number); - return $startOn > $endOn ? "$relativeWords ago" : "in $relativeWords"; - } -} diff --git a/src/app/Handlers.php b/src/app/Handlers.php deleted file mode 100644 index e209b46..0000000 --- a/src/app/Handlers.php +++ /dev/null @@ -1,130 +0,0 @@ - $pageTitle, - 'isHtmx' => - array_key_exists('HTTP_HX_REQUEST', $_SERVER) - && (!array_key_exists('HTTP_HX_HISTORY_RESTORE_REQUEST', $_SERVER)), - 'user' => AppUser::current(), - 'hasSnoozed' => false, - ]); - $params['pageContent'] = app()->template->render($template, $params); - $layout = $params['isHtmx'] ? 'layout/partial' : 'layout/full'; - response()->markup(app()->template->render($layout, $params)); - } - - /** - * Render a BareUI component template - * - * @param string $template The template name to render - * @param ?array $params Parameter to use to render the component (optional) - */ - private static function renderComponent(string $template, ?array $params = null) - { - $params = $params ?? []; - $params['pageContent'] = app()->template->render($template, $params); - header('Cache-Control: no-cache, max-age=-1'); - response()->markup(app()->template->render('layout/component', $params)); - } - - /** - * Send a 404 Not Found response - */ - private static function notFound() - { - response()->plain('Not found', 404); - } - - /** GET: /journal */ - public static function journal() - { - AppUser::require(); - - $user = AppUser::current()->user; - $firstName = (array_key_exists('given_name', $user) ? $user['given_name'] : null) ?? 'Your'; - self::render('journal', $firstName . ($firstName == 'Your' ? '' : '’s') . ' Prayer Journal'); - } - - /** GET: /components/journal-items */ - public static function journalItems() - { - AppUser::require(); - - $reqs = Data::getJournal(AppUser::currentId()); - $utc = new \DateTimeZone('Etc/UTC'); - $now = date_create_immutable(timezone: $utc); - $epoch = date_create_immutable('1970-01-01', $utc); - array_filter($reqs, - fn (JournalRequest $req) => $req->snoozedUntil ?? $epoch < $now && $req->showAfter ?? $epoch < $now); - - self::renderComponent('components/journal_items', [ 'requests' => $reqs ]); - } - - /** GET /request/[req-id]/edit */ - public static function requestEdit(string $reqId) - { - AppUser::require(); - - $returnTo = array_key_exists('HTTP_REFERER', $_SERVER) - ? match (true) { - str_ends_with($_SERVER['HTTP_REFERER'], '/active') => 'active', - str_ends_with($_SERVER['HTTP_REFERER'], '/snoozed') => 'snoozed', - default => 'journal' - } - : 'journal'; - if ($reqId == 'new') { - self::render('requests/edit', 'Add Prayer Request', [ - 'request' => new JournalRequest(), - 'isNew' => true, - 'returnTo' => $returnTo, - ]); - } else { - $req = Data::tryJournalById($reqId, AppUser::currentId()); - if (is_null($req)) { - self::notFound(); - } else { - self::render('requests/edit', 'Edit Prayer Request', [ - 'request' => $req, - 'isNew' => false, - 'returnTo' => $returnTo, - ]); - } - } - } - - /** POST|PATCH /request */ - public static function requestSave() - { - AppUser::require(); - - $form = request()->validate([ - 'requestId' => ['required', 'text'], - 'requestText' => ['required', 'text'], - 'status' => ['required', 'textOnly'], - 'recurType' => ['required', 'textOnly'], - 'recurCount' => 'number', - 'recurInterval' => 'textOnly', - ]); - if ($form) { - // valid - } else { - // errors - } - } -} diff --git a/src/app/composer.json b/src/app/composer.json index d2d0b0a..d394686 100644 --- a/src/app/composer.json +++ b/src/app/composer.json @@ -1,23 +1,29 @@ { + "name": "bitbadger/my-prayer-journal", + "description": "Minimalist prayer journal to enhance your prayer life", + "type": "project", + "license": "MIT", + "autoload": { + "psr-4": { + "MyPrayerJournal\\": "lib/", + "MyPrayerJournal\\Domain\\": "lib/domain/", + "BitBadger\\PgDocuments\\": "lib/documents/" + } + }, + "authors": [ + { + "name": "Daniel J. Summers", + "email": "daniel@bitbadger.solutions" + } + ], "require": { - "leafs/leaf": "^3.0", - "leafs/bareui": "^1.1", - "leafs/db": "^2.1", "netresearch/jsonmapper": "^4.2", - "visus/cuid2": "^3.0", "guzzlehttp/guzzle": "^7.8", "guzzlehttp/psr7": "^2.6", "http-interop/http-factory-guzzle": "^1.2", - "auth0/auth0-php": "^8.7", + "auth0/auth0-php": "^8.8", "vlucas/phpdotenv": "^5.5", - "leafs/form": "^2.0" - }, - "autoload": { - "psr-4": { - "BitBadger\\PgSQL\\Documents\\": [ "./documents" ], - "MyPrayerJournal\\": [ "." ], - "MyPrayerJournal\\Domain\\": [ "./domain" ] - } + "visus/cuid2": "^4.0" }, "config": { "allow-plugins": { diff --git a/src/app/composer.lock b/src/app/composer.lock index f5f8833..c9b1088 100644 --- a/src/app/composer.lock +++ b/src/app/composer.lock @@ -4,24 +4,25 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5cf05fa628183ad173a48e697e23513f", + "content-hash": "ff3f9ac8d14771409438ac21fd6919e7", "packages": [ { "name": "auth0/auth0-php", - "version": "8.7.1", + "version": "8.8.0", "source": { "type": "git", "url": "https://github.com/auth0/auth0-PHP.git", - "reference": "00202f130364add3e3c5708a235ac4a8c4b239bf" + "reference": "ef7634a50857598de44c04692bdc9b23ad201e10" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/auth0/auth0-PHP/zipball/00202f130364add3e3c5708a235ac4a8c4b239bf", - "reference": "00202f130364add3e3c5708a235ac4a8c4b239bf", + "url": "https://api.github.com/repos/auth0/auth0-PHP/zipball/ef7634a50857598de44c04692bdc9b23ad201e10", + "reference": "ef7634a50857598de44c04692bdc9b23ad201e10", "shasum": "" }, "require": { "ext-json": "*", + "ext-mbstring": "*", "ext-openssl": "*", "php": "^8.0", "php-http/multipart-stream-builder": "^1", @@ -31,6 +32,7 @@ "psr/http-message-implementation": "^1" }, "require-dev": { + "ergebnis/composer-normalize": "^2", "friendsofphp/php-cs-fixer": "^3", "mockery/mockery": "^1", "pestphp/pest": "^2", @@ -99,22 +101,22 @@ ], "support": { "issues": "https://github.com/auth0/auth0-PHP/issues", - "source": "https://github.com/auth0/auth0-PHP/tree/8.7.1" + "source": "https://github.com/auth0/auth0-PHP/tree/8.8.0" }, - "time": "2023-08-07T04:45:57+00:00" + "time": "2023-10-18T22:54:14+00:00" }, { "name": "composer/semver", - "version": "3.3.2", + "version": "3.4.0", "source": { "type": "git", "url": "https://github.com/composer/semver.git", - "reference": "3953f23262f2bff1919fc82183ad9acb13ff62c9" + "reference": "35e8d0af4486141bc745f23a29cc2091eb624a32" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/3953f23262f2bff1919fc82183ad9acb13ff62c9", - "reference": "3953f23262f2bff1919fc82183ad9acb13ff62c9", + "url": "https://api.github.com/repos/composer/semver/zipball/35e8d0af4486141bc745f23a29cc2091eb624a32", + "reference": "35e8d0af4486141bc745f23a29cc2091eb624a32", "shasum": "" }, "require": { @@ -164,9 +166,9 @@ "versioning" ], "support": { - "irc": "irc://irc.freenode.org/composer", + "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/semver/issues", - "source": "https://github.com/composer/semver/tree/3.3.2" + "source": "https://github.com/composer/semver/tree/3.4.0" }, "funding": [ { @@ -182,7 +184,7 @@ "type": "tidelift" } ], - "time": "2022-04-01T19:23:25+00:00" + "time": "2023-08-31T09:50:34+00:00" }, { "name": "graham-campbell/result-type", @@ -629,529 +631,6 @@ }, "time": "2021-07-21T13:50:14+00:00" }, - { - "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/form", - "version": "v2.0", - "source": { - "type": "git", - "url": "https://github.com/leafsphp/form.git", - "reference": "b724596da4f52b9dc7fe1ec0cdf12b4325f369c6" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/leafsphp/form/zipball/b724596da4f52b9dc7fe1ec0cdf12b4325f369c6", - "reference": "b724596da4f52b9dc7fe1ec0cdf12b4325f369c6", - "shasum": "" - }, - "require": { - "ext-json": "*" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "^3.0", - "pestphp/pest": "^1.22" - }, - "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 straightup data validation", - "homepage": "https://leafphp.dev/modules/forms/", - "keywords": [ - "form", - "framework", - "leaf", - "php", - "validation" - ], - "support": { - "issues": "https://github.com/leafsphp/form/issues", - "source": "https://github.com/leafsphp/form/tree/v2.0" - }, - "funding": [ - { - "url": "https://github.com/leafsphp", - "type": "github" - }, - { - "url": "https://opencollective.com/leaf", - "type": "open_collective" - } - ], - "time": "2023-08-19T01:07:37+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", @@ -2163,16 +1642,16 @@ }, { "name": "psr/http-client", - "version": "1.0.2", + "version": "1.0.3", "source": { "type": "git", "url": "https://github.com/php-fig/http-client.git", - "reference": "0955afe48220520692d2d09f7ab7e0f93ffd6a31" + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-client/zipball/0955afe48220520692d2d09f7ab7e0f93ffd6a31", - "reference": "0955afe48220520692d2d09f7ab7e0f93ffd6a31", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", "shasum": "" }, "require": { @@ -2209,9 +1688,9 @@ "psr-18" ], "support": { - "source": "https://github.com/php-fig/http-client/tree/1.0.2" + "source": "https://github.com/php-fig/http-client" }, - "time": "2023-04-10T20:12:12+00:00" + "time": "2023-09-23T14:17:50+00:00" }, { "name": "psr/http-factory", @@ -2484,16 +1963,16 @@ }, { "name": "symfony/polyfill-ctype", - "version": "v1.27.0", + "version": "v1.28.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "5bbc823adecdae860bb64756d639ecfec17b050a" + "reference": "ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/5bbc823adecdae860bb64756d639ecfec17b050a", - "reference": "5bbc823adecdae860bb64756d639ecfec17b050a", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb", + "reference": "ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb", "shasum": "" }, "require": { @@ -2508,7 +1987,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "1.27-dev" + "dev-main": "1.28-dev" }, "thanks": { "name": "symfony/polyfill", @@ -2546,7 +2025,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.27.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.28.0" }, "funding": [ { @@ -2562,20 +2041,20 @@ "type": "tidelift" } ], - "time": "2022-11-03T14:55:06+00:00" + "time": "2023-01-26T09:26:14+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.27.0", + "version": "v1.28.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "8ad114f6b39e2c98a8b0e3bd907732c207c2b534" + "reference": "42292d99c55abe617799667f454222c54c60e229" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/8ad114f6b39e2c98a8b0e3bd907732c207c2b534", - "reference": "8ad114f6b39e2c98a8b0e3bd907732c207c2b534", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/42292d99c55abe617799667f454222c54c60e229", + "reference": "42292d99c55abe617799667f454222c54c60e229", "shasum": "" }, "require": { @@ -2590,7 +2069,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "1.27-dev" + "dev-main": "1.28-dev" }, "thanks": { "name": "symfony/polyfill", @@ -2629,7 +2108,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.27.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.28.0" }, "funding": [ { @@ -2645,20 +2124,20 @@ "type": "tidelift" } ], - "time": "2022-11-03T14:55:06+00:00" + "time": "2023-07-28T09:04:16+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.27.0", + "version": "v1.28.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "7a6ff3f1959bb01aefccb463a0f2cd3d3d2fd936" + "reference": "6caa57379c4aec19c0a12a38b59b26487dcfe4b5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/7a6ff3f1959bb01aefccb463a0f2cd3d3d2fd936", - "reference": "7a6ff3f1959bb01aefccb463a0f2cd3d3d2fd936", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/6caa57379c4aec19c0a12a38b59b26487dcfe4b5", + "reference": "6caa57379c4aec19c0a12a38b59b26487dcfe4b5", "shasum": "" }, "require": { @@ -2667,7 +2146,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "1.27-dev" + "dev-main": "1.28-dev" }, "thanks": { "name": "symfony/polyfill", @@ -2712,7 +2191,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.27.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.28.0" }, "funding": [ { @@ -2728,20 +2207,20 @@ "type": "tidelift" } ], - "time": "2022-11-03T14:55:06+00:00" + "time": "2023-01-26T09:26:14+00:00" }, { "name": "visus/cuid2", - "version": "3.0.0", + "version": "4.0.0", "source": { "type": "git", "url": "https://github.com/visus-io/php-cuid2.git", - "reference": "0a422fa4785c3ce1f01f60cec35684ed31f46860" + "reference": "624e982aa908231f6690738d8553a3ad693b0560" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/visus-io/php-cuid2/zipball/0a422fa4785c3ce1f01f60cec35684ed31f46860", - "reference": "0a422fa4785c3ce1f01f60cec35684ed31f46860", + "url": "https://api.github.com/repos/visus-io/php-cuid2/zipball/624e982aa908231f6690738d8553a3ad693b0560", + "reference": "624e982aa908231f6690738d8553a3ad693b0560", "shasum": "" }, "require": { @@ -2770,7 +2249,7 @@ }, "notification-url": "https://packagist.org/downloads/", "license": [ - "Apache-2.0" + "MIT" ], "authors": [ { @@ -2785,9 +2264,9 @@ ], "support": { "issues": "https://github.com/visus-io/php-cuid2/issues", - "source": "https://github.com/visus-io/php-cuid2/tree/3.0.0" + "source": "https://github.com/visus-io/php-cuid2/tree/4.0.0" }, - "time": "2023-08-11T16:22:53+00:00" + "time": "2023-08-19T21:14:46+00:00" }, { "name": "vlucas/phpdotenv", diff --git a/src/app/documents/Configuration.php b/src/app/documents/Configuration.php deleted file mode 100644 index 9daad55..0000000 --- a/src/app/documents/Configuration.php +++ /dev/null @@ -1,54 +0,0 @@ -load(); - -use MyPrayerJournal\{ AppUser, Data, Handlers }; - -Data::configure(); - -app()->template->config('path', './pages'); -app()->template->config('params', [ - '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', -]); - -app()->get('/', fn () => Handlers::render('home', 'Welcome')); - -app()->group('/components', function () { - app()->get('/journal-items', Handlers::journalItems(...)); -}); -app()->get('/journal', Handlers::journal(...)); -app()->group('/legal', function () { - app()->get('/privacy-policy', fn () => Handlers::render('legal/privacy-policy', 'Privacy Policy')); - app()->get('/terms-of-service', fn () => Handlers::render('legal/terms-of-service', 'Terms of Service')); -}); -app()->group('/request', function () { - app()->get( '/{reqId}/edit', Handlers::requestEdit(...)); - app()->post( '/request', Handlers::requestSave(...)); - app()->patch('/request', Handlers::requestSave(...)); -}); -app()->group('/user', function () { - app()->get('/log-on', AppUser::logOn(...)); - app()->get('/log-on/success', AppUser::processLogOn(...)); - app()->get('/log-off', AppUser::logOff(...)); -}); - -// Extract the user's time zone from the request, if present -app()->use(new class extends \Leaf\Middleware { - public function call() - { - $_REQUEST['USER_TIME_ZONE'] = new \DateTimeZone( - array_key_exists('HTTP_X_TIME_ZONE', $_SERVER) ? $_SERVER['HTTP_X_TIME_ZONE'] : 'Etc/UTC'); - $this->next(); - } -}); - -// TODO: remove before go-live -$stdOut = fopen('php://stdout', 'w'); -function stdout(string $msg) -{ - global $stdOut; - fwrite($stdOut, $msg . "\n"); -} - -app()->run(); diff --git a/src/app/lib/Constants.php b/src/app/lib/Constants.php new file mode 100644 index 0000000..63fe4df --- /dev/null +++ b/src/app/lib/Constants.php @@ -0,0 +1,67 @@ +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 = self::findFullRequestById($reqId, $userId); + // if (is_null($req)) throw new \InvalidArgumentException("$reqId not found"); + // array_unshift($req->history, $history); + // Document::updateFull(self::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 = self::findFullRequestById($reqId, $userId); + // if (is_null($req)) throw new \InvalidArgumentException("$reqId not found"); + // array_unshift($req->notes, $note); + // Document::updateFull(self::REQ_TABLE, $reqId, $req); + // } + + // /** + // * Add a new request + // * + // * @param Request $req The request to be added + // */ + // public static function addRequest(Request $req) + // { + // Document::insert(self::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(self::REQ_TABLE) + . ' WHERE ' . Query::whereDataContains('$1') . ' AND ' . Query::whereJsonPathMatches('$2'); + $params = [ + Query::jsonbDocParam([ 'userId' => $userId ]), + sprintf("$.history[*].action ? (@ $op \"%s\")", RequestAction::Answered->name) + ]; + return self::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 = self::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 = self::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/lib/Dates.php b/src/app/lib/Dates.php new file mode 100644 index 0000000..3b93b8b --- /dev/null +++ b/src/app/lib/Dates.php @@ -0,0 +1,63 @@ +diff($endOn); + $minutes = + $diff->i + ($diff->h * 60) + ($diff->d * 60 * 24) + ($diff->m * 60 * 24 * 30) + ($diff->y * 60 * 24 * 365); + $months = round($minutes / self::A_MONTH); + $years = $months / 12; + [ $format, $number ] = match (true) { + $minutes < 1 => [ DistanceFormat::LessThanXMinutes, 1 ], + $minutes < 45 => [ DistanceFormat::XMinutes, $minutes ], + $minutes < 90 => [ DistanceFormat::AboutXHours, 1 ], + $minutes < self::A_DAY => [ DistanceFormat::AboutXHours, round($minutes / 60) ], + $minutes < self::ALMOST_2_DAYS => [ DistanceFormat::XDays, 1 ], + $minutes < self::A_MONTH => [ DistanceFormat::XDays, round($minutes / self::A_DAY) ], + $minutes < self::TWO_MONTHS => [ DistanceFormat::AboutXMonths, round($minutes / self::A_MONTH) ], + $months < 12 => [ DistanceFormat::XMonths, round($minutes / self::A_MONTH) ], + $months % 12 < 3 => [ DistanceFormat::AboutXYears, $years ], + $months % 12 < 9 => [ DistanceFormat::OverXYears, $years ], + default => [ DistanceFormat::AlmostXYears, $years + 1 ], + }; + + $relativeWords = sprintf(DistanceFormat::format($format, $number == 1), $number); + return $startOn > $endOn ? "$relativeWords ago" : "in $relativeWords"; + } +} diff --git a/src/app/lib/DistanceFormat.php b/src/app/lib/DistanceFormat.php new file mode 100644 index 0000000..9173cb1 --- /dev/null +++ b/src/app/lib/DistanceFormat.php @@ -0,0 +1,50 @@ + $singular ? 'less than a minute' : 'less than %i minutes', + self::XMinutes => $singular ? 'a minute' : '%i minutes', + self::AboutXHours => $singular ? 'about an hour' : 'about %i hours', + self::XHours => $singular ? 'an hour' : '%i hours', + self::XDays => $singular ? 'a day' : '%i days', + self::AboutXWeeks => $singular ? 'about a week' : 'about %i weeks', + self::XWeeks => $singular ? 'a week' : '%i weeks', + self::AboutXMonths => $singular ? 'about a month' : 'about %i months', + self::XMonths => $singular ? 'a month' : '%i months', + self::AboutXYears => $singular ? 'about a year' : 'about %i years', + self::XYears => $singular ? 'a year' : '%i years', + self::OverXYears => $singular ? 'over a year' : 'over %i years', + self::AlmostXYears => $singular ? 'almost a year' : 'almost %i years', + }; + } +} diff --git a/src/app/lib/documents/Configuration.php b/src/app/lib/documents/Configuration.php new file mode 100644 index 0000000..01cb0e2 --- /dev/null +++ b/src/app/lib/documents/Configuration.php @@ -0,0 +1,70 @@ +query(self::createTable($name))->execute(); + /** @var Result|bool */ + $result = pg_query(pg_conn(), self::createTable($name)); + if ($result) pg_free_result($result); } /** @@ -52,6 +56,8 @@ class Definition */ public static function ensureIndex(string $name, DocumentIndex $type) { - pdo()->query(self::createIndex($name, $type))->execute(); + /** @var Result|bool */ + $result = pg_query(pg_conn(), self::createIndex($name, $type)); + if ($result) pg_free_result($result); } } diff --git a/src/app/documents/Document.php b/src/app/lib/documents/Document.php similarity index 65% rename from src/app/documents/Document.php rename to src/app/lib/documents/Document.php index fb2e54a..668554b 100644 --- a/src/app/documents/Document.php +++ b/src/app/lib/documents/Document.php @@ -1,18 +1,16 @@ false ]; + private static ?JsonMapper $mapper = null; /** * Map a domain type from the JSON document retrieved @@ -25,7 +23,7 @@ class Document public static function mapDocFromJson(string $columnName, array $result, string $className): mixed { if (is_null(self::$mapper)) { - self::$mapper = new \JsonMapper(); + self::$mapper = new JsonMapper(); } $mapped = new $className(); @@ -54,10 +52,9 @@ class Document */ private static function executeNonQuery(string $query, string $docId, array|object $document) { - $nonQuery = pdo()->prepare($query, self::NO_PREPARE); - $nonQuery->bindParam(':id', $docId); - $nonQuery->bindParam(':data', Query::jsonbDocParam($document)); - $nonQuery->execute(); + /** @var Result|bool */ + $result = pg_query_params(pg_conn(), $query, [ $docId, Query::jsonbDocParam($document) ]); + if ($result) pg_free_result($result); } /** @@ -84,6 +81,23 @@ class Document self::executeNonQuery(Query::save($tableName), $docId, $document); } + /** + * Run a count query, returning the `it` parameter of that query as an integer + * + * @param string $sql The SQL query that will return a count + * @param array $params Parameters needed for that query + * @return int The count of matching rows for the query + */ + private static function runCount(string $sql, array $params): int + { + /** @var Result|bool */ + $result = pg_query_params(pg_conn(), $sql, $params); + if (!$result) return -1; + $count = intval(pg_fetch_assoc($result)['it']); + pg_free_result($result); + return $count; + } + /** * Count all documents in a table * @@ -92,8 +106,7 @@ class Document */ public static function countAll(string $tableName): int { - $result = pdo()->query(Query::countAll($tableName))->fetch(\PDO::FETCH_ASSOC); - return intval($result['it']); + return self::runCount(Query::countAll($tableName), []); } /** @@ -105,11 +118,7 @@ class Document */ public static function countByContains(string $tableName, array|object $criteria): int { - $query = pdo()->prepare(Query::countByContains($tableName), self::NO_PREPARE); - $query->bindParam(':criteria', Query::jsonbDocParam($criteria)); - $query->execute(); - $result = $query->fetch(\PDO::FETCH_ASSOC); - return intval($result['it']); + return self::runCount(Query::countByContains($tableName), [ Query::jsonbDocParam($criteria) ]); } /** @@ -121,11 +130,24 @@ class Document */ public static function countByJsonPath(string $tableName, string $jsonPath): int { - $query = pdo()->prepare(Query::countByContains($tableName), self::NO_PREPARE); - $query->bindParam(':path', $jsonPath); - $query->execute(); - $result = $query->fetch(\PDO::FETCH_ASSOC); - return intval($result['it']); + return self::runCount(Query::countByJsonPath($tableName), [ $jsonPath ]); + } + + /** + * Run an existence query (returning the `it` parameter of that query) + * + * @param string $sql The SQL query that will return existence + * @param array $params Parameters needed for that query + * @return bool The result of the existence query + */ + private static function runExists(string $sql, array $params): bool + { + /** @var Result|bool */ + $result = pg_query_params(pg_conn(), $sql, $params); + if (!$result) return -1; + $exists = boolval(pg_fetch_assoc($result)['it']); + pg_free_result($result); + return $exists; } /** @@ -137,11 +159,7 @@ class Document */ public static function existsById(string $tableName, string $docId): bool { - $query = pdo()->prepare(Query::existsById($tableName), self::NO_PREPARE); - $query->bindParam(':id', $docId); - $query->execute(); - $result = $query->fetch(\PDO::FETCH_ASSOC); - return boolval($result['it']); + return self::runExists(Query::existsById($tableName), [ $docId ]); } /** @@ -153,11 +171,7 @@ class Document */ public static function existsByContains(string $tableName, array|object $criteria): bool { - $query = pdo()->prepare(Query::existsByContains($tableName), self::NO_PREPARE); - $query->bindParam(':criteria', Query::jsonbDocParam($criteria)); - $query->execute(); - $result = $query->fetch(\PDO::FETCH_ASSOC); - return boolval($result['it']); + return self::runExists(Query::existsByContains($tableName), [ Query::jsonbDocParam($criteria) ]); } /** @@ -169,23 +183,27 @@ class Document */ public static function existsByJsonPath(string $tableName, string $jsonPath): bool { - $query = pdo()->prepare(Query::existsByJsonPath($tableName), self::NO_PREPARE); - $query->bindParam(':path', $jsonPath); - $query->execute(); - $result = $query->fetch(\PDO::FETCH_ASSOC); - return boolval($result['it']); + return self::runExists(Query::existsByJsonPath($tableName), [ $jsonPath ]); } /** - * Map the results of a query to domain type objects + * Run a query, mapping the results to an array of domain type objects * - * @param \PDOStatement $stmt The statement with the query to be run + * @param string $sql The query to be run + * @param array $params The parameters for the query * @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 + private static function runListQuery(string $sql, array $params, string $className): array { - return array_map(fn ($it) => self::mapFromJson($it, $className), $stmt->fetchAll(\PDO::FETCH_ASSOC)); + /** @var Result|bool */ + $result = pg_query_params(pg_conn(), $sql, $params); + try { + if (!$result || pg_result_status($result) == PGSQL_EMPTY_QUERY) return []; + return array_map(fn ($it) => self::mapFromJson($it, $className), pg_fetch_all($result)); + } finally { + if ($result) pg_free_result($result); + } } /** @@ -197,7 +215,7 @@ class Document */ public static function findAll(string $tableName, string $className): array { - return self::mapResults(pdo()->query(Query::selectFromTable($tableName)), $className); + return self::runListQuery(Query::selectFromTable($tableName), [], $className); } /** @@ -210,26 +228,8 @@ class Document */ public static function findById(string $tableName, string $docId, string $className): mixed { - $query = pdo()->prepare(Query::findById($tableName), self::NO_PREPARE); - $query->bindParam(':id', $docId); - $query->execute(); - $result = $query->fetch(\PDO::FETCH_ASSOC); - return $result ? self::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), self::NO_PREPARE); - $query->bindParam(':criteria', Query::jsonbDocParam($criteria)); - $query->execute(); - return $query; + $results = self::runListQuery(Query::findById($tableName), [ $docId ], $className); + return $results ? $results[0] : null; } /** @@ -242,7 +242,7 @@ class Document */ public static function findByContains(string $tableName, array|object $criteria, string $className): array { - return self::mapResults(self::queryByContains($tableName, $criteria), $className); + return self::runListQuery(Query::findByContains($tableName), [ Query::jsonbDocParam($criteria) ], $className); } /** @@ -255,24 +255,9 @@ class Document */ public static function findFirstByContains(string $tableName, array|object $criteria, string $className): mixed { - $query = self::queryByContains($tableName, $criteria); - $result = $query->fetch(\PDO::FETCH_ASSOC); - return $result ? self::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), self::NO_PREPARE); - $query->bindParam(':path', $jsonPath); - $query->execute(); - return $query; + $results = self::runListQuery(Query::findByContains($tableName) . ' LIMIT 1', + [ Query::jsonbDocParam($criteria) ], $className); + return $results ? $results[0] : null; } /** @@ -285,7 +270,7 @@ class Document */ public static function findByJsonPath(string $tableName, string $jsonPath, string $className): array { - return self::mapResults(self::queryByJsonPath($tableName, $jsonPath), $className); + return self::runListQuery(Query::findByJsonPath($tableName), [ $jsonPath ], $className); } /** @@ -298,9 +283,8 @@ class Document */ public static function findFirstByJsonPath(string $tableName, string $jsonPath, string $className): mixed { - $query = self::queryByJsonPath($tableName, $jsonPath); - $result = $query->fetch(\PDO::FETCH_ASSOC); - return $result ? self::mapFromJson($result, $className) : null; + $results = self::runListQuery(Query::findByJsonPath($tableName) . ' LIMIT 1', [ $jsonPath ], $className); + return $results ? $results[0] : null; } /** @@ -336,10 +320,10 @@ class Document */ public static function updatePartialByContains(string $tableName, array|object $criteria, array|object $document) { - $query = pdo()->prepare(Query::updatePartialByContains($tableName), self::NO_PREPARE); - $query->bindParam(':data', Query::jsonbDocParam($document)); - $query->bindParam(':criteria', Query::jsonbDocParam($criteria)); - $query->execute(); + /** @var Result|bool */ + $result = pg_query_params(pg_conn(), Query::updatePartialByContains($tableName), + [ Query::jsonbDocParam($criteria), Query::jsonbDocParam($document) ]); + if ($result) pg_free_result($result); } /** @@ -351,10 +335,10 @@ class Document */ public static function updatePartialByJsonPath(string $tableName, string $jsonPath, array|object $document) { - $query = pdo()->prepare(Query::updatePartialByContains($tableName), self::NO_PREPARE); - $query->bindParam(':data', Query::jsonbDocParam($document)); - $query->bindParam(':path', $jsonPath); - $query->execute(); + /** @var Result|bool */ + $result = pg_query_params(pg_conn(), Query::updatePartialByContains($tableName), + [ $jsonPath, Query::jsonbDocParam($document) ]); + if ($result) pg_free_result($result); } /** @@ -376,9 +360,9 @@ class Document */ public static function deleteByContains(string $tableName, array|object $criteria) { - $query = pdo()->prepare(Query::deleteByContains($tableName), self::NO_PREPARE); - $query->bindParam(':criteria', Query::jsonbDocParam($criteria)); - $query->execute(); + /** @var Result|bool */ + $result = pg_query_params(pg_conn(), Query::deleteByContains($tableName), [ Query::jsonbDocParam($criteria) ]); + if ($result) pg_free_result($result); } /** @@ -389,67 +373,57 @@ class Document */ public static function deleteByJsonPath(string $tableName, string $jsonPath) { - $query = pdo()->prepare(Query::deleteByJsonPath($tableName), self::NO_PREPARE); - $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, [ \PDO::ATTR_EMULATE_PREPARES => false ]); - array_walk($params, fn ($value, $name) => $query->bindParam($name, $value)); - $query->execute(); - return $query; + /** @var Result|bool */ + $result = pg_query_params(pg_conn(), Query::deleteByJsonPath($tableName), [ $jsonPath ]); + if ($result) pg_free_result($result); } /** * 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 array $params A positional 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)); + /** @var Result|bool */ + $result = pg_query_params(pg_conn(), $sql, $params); + try { + if (!$result || pg_result_status($result) == PGSQL_EMPTY_QUERY) return []; + return array_map(fn ($it) => $mapFunc($it, $className), pg_fetch_all($result)); + } finally { + if ($result) pg_free_result($result); + } } /** * 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 string $sql The SQL query to execute ("LIMIT 1" will be appended) + * @param array $params A positional 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 = self::createCustomQuery($sql, $params)->fetch(\PDO::FETCH_ASSOC); - return $result ? $mapFunc($result, $className) : null; + $results = self::customList("$sql LIMIT 1", $params, $className, $mapFunc); + return $results ? $results[0] : 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 + * @param array $params A positional array of parameters for the SQL query */ public static function customNonQuery(string $sql, array $params) { - self::createCustomQuery($sql, $params); + /** @var Result|bool */ + $result = pg_query_params(pg_conn(), $sql, $params); + if ($result) pg_free_result($result); } } diff --git a/src/app/documents/DocumentIndex.php b/src/app/lib/documents/DocumentIndex.php similarity index 89% rename from src/app/documents/DocumentIndex.php rename to src/app/lib/documents/DocumentIndex.php index 7a61d56..5c0f40a 100644 --- a/src/app/documents/DocumentIndex.php +++ b/src/app/lib/documents/DocumentIndex.php @@ -1,7 +1,7 @@ $paramName::jsonb"; + return "data @> $paramName"; } /** @@ -36,7 +36,7 @@ class Query */ public static function whereJsonPathMatches(string $paramName): string { - return "data @?? {$paramName}::jsonpath"; + return "data @? $paramName::jsonpath"; } /** @@ -62,7 +62,7 @@ class Query */ public static function insert(string $tableName): string { - return "INSERT INTO $tableName (id, data) VALUES (:id, :data)"; + return "INSERT INTO $tableName (id, data) VALUES ($1, $2)"; } /** @@ -73,7 +73,7 @@ class Query */ public static function save(string $tableName): string { - return "INSERT INTO $tableName (id, data) VALUES (:id, :data) + return "INSERT INTO $tableName (id, data) VALUES ($1, $2) ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data"; } @@ -85,7 +85,7 @@ class Query */ public static function countAll(string $tableName): string { - return "SELECT COUNT(*) AS it FROM $tableName"; + return "SELECT COUNT(id) AS it FROM $tableName"; } /** @@ -96,7 +96,7 @@ class Query */ public static function countByContains(string $tableName): string { - return sprintf("SELECT COUNT(*) AS it FROM $tableName WHERE %s", self::whereDataContains(':criteria')); + return "SELECT COUNT(id) AS it FROM $tableName WHERE " . self::whereDataContains('$1'); } /** @@ -107,7 +107,7 @@ class Query */ public static function countByJsonPath(string $tableName): string { - return sprintf("SELECT COUNT(*) AS it FROM $tableName WHERE %s", self::whereJsonPathMatches(':path')); + return "SELECT COUNT(id) AS it FROM $tableName WHERE " . self::whereJsonPathMatches('$1'); } /** @@ -118,7 +118,7 @@ class Query */ public static function existsById(string $tableName): string { - return "SELECT EXISTS (SELECT 1 FROM $tableName WHERE id = :id) AS it"; + return "SELECT EXISTS (SELECT 1 FROM $tableName WHERE id = $1) AS it"; } /** @@ -129,7 +129,7 @@ class Query */ public static function existsByContains(string $tableName): string { - return sprintf("SELECT EXISTS (SELECT 1 FROM $tableName WHERE %s AS it", self::whereDataContains(':criteria')); + return "SELECT EXISTS (SELECT 1 FROM $tableName WHERE " . self::whereDataContains('$1') . ' AS it'; } /** @@ -140,7 +140,7 @@ class Query */ public static function existsByJsonPath(string $tableName): string { - return sprintf("SELECT EXISTS (SELECT 1 FROM $tableName WHERE %s AS it", self::whereJsonPathMatches(':path')); + return "SELECT EXISTS (SELECT 1 FROM $tableName WHERE " . self::whereJsonPathMatches('$1') . ' AS it'; } /** @@ -151,7 +151,7 @@ class Query */ public static function findById(string $tableName): string { - return sprintf('%s WHERE id = :id', self::selectFromTable($tableName)); + return self::selectFromTable($tableName) . ' WHERE id = $1'; } /** @@ -162,7 +162,7 @@ class Query */ public static function findByContains(string $tableName): string { - return sprintf('%s WHERE %s', self::selectFromTable($tableName), self::whereDataContains(':criteria')); + return self::selectFromTable($tableName) . ' WHERE ' . self::whereDataContains('$1'); } /** @@ -173,7 +173,7 @@ class Query */ public static function findByJsonPath(string $tableName): string { - return sprintf('%s WHERE %s', self::selectFromTable($tableName), self::whereJsonPathMatches(':path')); + return self::selectFromTable($tableName) . ' WHERE ' . self::whereJsonPathMatches('$1'); } /** @@ -184,7 +184,7 @@ class Query */ public static function updateFull(string $tableName): string { - return "UPDATE $tableName SET data = :data WHERE id = :id"; + return "UPDATE $tableName SET data = $2 WHERE id = $1"; } /** @@ -195,7 +195,7 @@ class Query */ public static function updatePartialById(string $tableName): string { - return "UPDATE $tableName SET data = data || :data WHERE id = :id"; + return "UPDATE $tableName SET data = data || $2 WHERE id = $1"; } /** @@ -206,7 +206,7 @@ class Query */ public static function updatePartialByContains(string $tableName): string { - return sprintf("UPDATE $tableName SET data = data || :data WHERE %s", self::whereDataContains(':criteria')); + return "UPDATE $tableName SET data = data || $2 WHERE " . self::whereDataContains('$1'); } /** @@ -217,7 +217,7 @@ class Query */ public static function updatePartialByJsonPath(string $tableName): string { - return sprintf("UPDATE $tableName SET data = data || :data WHERE %s", self::whereJsonPathMatches(':path')); + return "UPDATE $tableName SET data = data || $2 WHERE " . self::whereJsonPathMatches('$1'); } /** @@ -228,7 +228,7 @@ class Query */ public static function deleteById(string $tableName): string { - return "DELETE FROM $tableName WHERE id = :id"; + return "DELETE FROM $tableName WHERE id = $1"; } /** @@ -239,7 +239,7 @@ class Query */ public static function deleteByContains(string $tableName): string { - return sprintf("DELETE FROM $tableName WHERE %s", self::whereDataContains(':criteria')); + return "DELETE FROM $tableName WHERE " . self::whereDataContains('$1'); } /** @@ -250,6 +250,6 @@ class Query */ public static function deleteByJsonPath(string $tableName): string { - return sprintf("DELETE FROM $tableName WHERE %s", self::whereJsonPathMatches(':path')); + return "DELETE FROM $tableName WHERE " . self::whereJsonPathMatches('$1'); } } diff --git a/src/app/lib/documents/functions.php b/src/app/lib/documents/functions.php new file mode 100644 index 0000000..d254b51 --- /dev/null +++ b/src/app/lib/documents/functions.php @@ -0,0 +1,16 @@ +asOf = new \DateTimeImmutable('1/1/1970', new \DateTimeZone('Etc/UTC')); + $this->asOf = new DateTimeImmutable('1/1/1970', new DateTimeZone('Etc/UTC')); } public function isCreated(): bool diff --git a/src/app/domain/JournalRequest.php b/src/app/lib/domain/JournalRequest.php similarity index 86% rename from src/app/domain/JournalRequest.php rename to src/app/lib/domain/JournalRequest.php index 4e3f901..cc95efb 100644 --- a/src/app/domain/JournalRequest.php +++ b/src/app/lib/domain/JournalRequest.php @@ -3,6 +3,8 @@ declare(strict_types=1); namespace MyPrayerJournal\Domain; +use DateTimeImmutable, DateTimeZone; + /** * A prayer request, along with calculated fields, for use in displaying journal lists */ @@ -20,16 +22,16 @@ class JournalRequest public string $text = ''; /** The date/time this request was last marked as prayed */ - public \DateTimeImmutable $lastPrayed; + public DateTimeImmutable $lastPrayed; /** The last action taken on this request */ public RequestAction $lastAction = RequestAction::Created; /** When this request will be shown again after having been snoozed */ - public ?\DateTimeImmutable $snoozedUntil = null; + public ?DateTimeImmutable $snoozedUntil = null; /** When this request will be show agains after a non-immediate recurrence */ - public ?\DateTimeImmutable $showAfter = null; + public ?DateTimeImmutable $showAfter = null; /** The type of recurrence for this request */ public RecurrenceType $recurrenceType = RecurrenceType::Immediate; @@ -58,8 +60,8 @@ class JournalRequest public function __construct(?Request $req = null, bool $full = false) { if (is_null($req)) { - $this->asOf = new \DateTimeImmutable('1/1/1970', new \DateTimeZone('Etc/UTC')); - $this->lastPrayed = new \DateTimeImmutable('1/1/1970', new \DateTimeZone('Etc/UTC')); + $this->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; diff --git a/src/app/domain/Note.php b/src/app/lib/domain/Note.php similarity index 66% rename from src/app/domain/Note.php rename to src/app/lib/domain/Note.php index 811534a..fbbbf4c 100644 --- a/src/app/domain/Note.php +++ b/src/app/lib/domain/Note.php @@ -3,6 +3,8 @@ declare(strict_types=1); namespace MyPrayerJournal\Domain; +use DateTimeImmutable, DateTimeZone; + /** * A note entered on a prayer request */ @@ -15,6 +17,6 @@ class Note public function __construct() { - $this->asOf = new \DateTimeImmutable('1/1/1970', new \DateTimeZone('Etc/UTC')); + $this->asOf = new DateTimeImmutable('1/1/1970', new DateTimeZone('Etc/UTC')); } } diff --git a/src/app/domain/RecurrenceType.php b/src/app/lib/domain/RecurrenceType.php similarity index 89% rename from src/app/domain/RecurrenceType.php rename to src/app/lib/domain/RecurrenceType.php index ea0091e..b9d410c 100644 --- a/src/app/domain/RecurrenceType.php +++ b/src/app/lib/domain/RecurrenceType.php @@ -3,10 +3,12 @@ declare(strict_types=1); namespace MyPrayerJournal\Domain; +use JsonSerializable; + /** * The unit to use when determining when to show a recurring request */ -enum RecurrenceType implements \JsonSerializable +enum RecurrenceType implements JsonSerializable { /** The request should reappear immediately */ case Immediate; diff --git a/src/app/domain/Request.php b/src/app/lib/domain/Request.php similarity index 79% rename from src/app/domain/Request.php rename to src/app/lib/domain/Request.php index 1cd304e..ac6652c 100644 --- a/src/app/domain/Request.php +++ b/src/app/lib/domain/Request.php @@ -3,6 +3,7 @@ declare(strict_types=1); namespace MyPrayerJournal\Domain; +use DateTimeImmutable, DateTimeZone; use Visus\Cuid2\Cuid2; /** @@ -14,16 +15,16 @@ class Request public string $id; /** The date/time the request was originally entered */ - public \DateTimeImmutable $enteredOn; + public DateTimeImmutable $enteredOn; /** The ID of the user to whom this request belongs */ public string $userId = ''; /** The date/time the snooze expires for this request */ - public ?\DateTimeImmutable $snoozedUntil = null; + public ?DateTimeImmutable $snoozedUntil = null; /** The date/time this request should once again show as defined by recurrence */ - public ?\DateTimeImmutable $showAfter = null; + public ?DateTimeImmutable $showAfter = null; /** The type of recurrence for this request */ public RecurrenceType $recurrenceType = RecurrenceType::Immediate; @@ -46,6 +47,6 @@ class Request public function __construct() { $this->id = new Cuid2(); - $this->enteredOn = new \DateTimeImmutable('1/1/1970', new \DateTimeZone('Etc/UTC')); + $this->enteredOn = new DateTimeImmutable('1/1/1970', new DateTimeZone('Etc/UTC')); } } diff --git a/src/app/domain/RequestAction.php b/src/app/lib/domain/RequestAction.php similarity index 87% rename from src/app/domain/RequestAction.php rename to src/app/lib/domain/RequestAction.php index 2484f6c..8222c0e 100644 --- a/src/app/domain/RequestAction.php +++ b/src/app/lib/domain/RequestAction.php @@ -3,10 +3,12 @@ declare(strict_types=1); namespace MyPrayerJournal\Domain; +use JsonSerializable; + /** * An action that was taken on a request */ -enum RequestAction implements \JsonSerializable +enum RequestAction implements JsonSerializable { /** The request was entered */ case Created; diff --git a/src/app/lib/start.php b/src/app/lib/start.php new file mode 100644 index 0000000..40dabab --- /dev/null +++ b/src/app/lib/start.php @@ -0,0 +1,98 @@ +load(); + +/** @var Auth0 The Auth0 instance to use for the request */ +$auth0 = new Auth0([ + 'domain' => $_ENV[Constants::AUTH0_DOMAIN], + 'clientId' => $_ENV[Constants::AUTH0_CLIENT_ID], + 'clientSecret' => $_ENV[Constants::AUTH0_CLIENT_SECRET], + 'cookieSecret' => $_ENV[Constants::AUTH0_COOKIE_SECRET] +]); + +/** @var ?object The Auth0 session for the current user */ +$session = $auth0->getCredentials(); +if (!is_null($session)) $_REQUEST[Constants::USER_ID] = $session->user[Constants::CLAIM_SUB]; + +$_REQUEST[Constants::IS_HTMX] = array_key_exists(Constants::HEADER_HX_REQUEST, $_SERVER) + && (!array_key_exists(Constants::HEADER_HX_HIST_REQ, $_SERVER)); + +$_REQUEST[Constants::TIME_ZONE] = new DateTimeZone( + array_key_exists(Constants::HEADER_USER_TZ, $_SERVER) ? $_SERVER[Constants::HEADER_USER_TZ] : 'Etc/UTC'); + +$_REQUEST[Constants::VERSION] = 4; + +Configuration::$startUp = '\MyPrayerJournal\Data::startUp'; + +/** + * Bring in a template + */ +function template(string $name) +{ + require_once __DIR__ . "/../templates/$name.php"; +} + +/** + * If a user is not found, either redirect them or fail the request + * + * @param bool $fail Whether to fail the request (true) or redirect to log on (false - optional, default) + */ +function require_user(bool $fail = false) +{ + if (!array_key_exists(Constants::USER_ID, $_REQUEST)) { + if ($fail) { + http_response_code(403); + } else { + header("Location: /user/log-on?{${Constants::RETURN_URL}}={$_SERVER[Constants::REQUEST_URI]}"); + } + exit; + } +} + +/** + * Write a bare header for a component result + */ +function bare_header() +{ + echo ''; +} + +/** + * Create a traditional and htmx link, and apply an active class if the link is active + * + * @param string $url The URL of the page to be linked + * @param array $classNames CSS class names to be applied to the link (optional, default none) + * @param bool $checkActive Whether to apply an active class if the route matches (optional, default false) + */ +function page_link(string $url, array $classNames = [], bool $checkActive = false) +{ + echo 'href="'. $url . '" hx-get="' . $url . '"'; + if ($checkActive && str_starts_with($_SERVER[Constants::REQUEST_URI], $url)) { + array_push($classNames, 'is-active-route'); + } + if (!empty($classNames)) { + echo ' class="' . implode(' ', $classNames) . '"'; + } + echo ' hx-target="#top" hx-swap="innerHTML" hx-push-url="true"'; +} + +/** + * Close any open database connection; close the `body` and `html` tags + */ +function end_request() +{ + Configuration::closeConn(); + echo ''; +} diff --git a/src/app/pages/components/journal_card.view.php b/src/app/pages/components/journal_card.view.php deleted file mode 100644 index 9458802..0000000 --- a/src/app/pages/components/journal_card.view.php +++ /dev/null @@ -1,50 +0,0 @@ - '; -/** - * Format the activity and relative time - * - * @param string $activity The activity performed (activity or prayed) - * @param \DateTimeImmutable $asOf The date/time the activity was performed - */ -function formatActivity(string $activity, \DateTimeImmutable $asOf) -{ - echo "last $activity setTimezone($_REQUEST['USER_TIME_ZONE'])->format('l, F jS, Y/g:ia T') - . '">' . Dates::formatDistance(Dates::now(), $asOf) . ''; -} ?> -
-
- -
-

text); ?>

-
- -
-
diff --git a/src/app/pages/components/journal_items.view.php b/src/app/pages/components/journal_items.view.php deleted file mode 100644 index c12b5f2..0000000 --- a/src/app/pages/components/journal_items.view.php +++ /dev/null @@ -1,17 +0,0 @@ -template->render('components/no_results', [ - 'heading' => 'No Active Requests', - 'link' => '/request/new/edit', - 'buttonText' => 'Add a Request', - 'text' => 'You have no requests to be shown; see the “Active” link above for snoozed or ' - . 'deferred requests, and the “Answered” link for answered requests' - ]); -} else { ?> -
template->render('components/journal_card', [ 'request' => $request ]); - } ?> -
-
- - diff --git a/src/app/pages/journal.view.php b/src/app/pages/journal.view.php deleted file mode 100644 index ebb3280..0000000 --- a/src/app/pages/journal.view.php +++ /dev/null @@ -1,43 +0,0 @@ - diff --git a/src/app/pages/layout/_foot.view.php b/src/app/pages/layout/_foot.view.php deleted file mode 100644 index 2e53de7..0000000 --- a/src/app/pages/layout/_foot.view.php +++ /dev/null @@ -1,30 +0,0 @@ - - - <?php echo $pageTitle; ?> « myPrayerJournal - - - - - - diff --git a/src/app/pages/layout/_nav.view.php b/src/app/pages/layout/_nav.view.php deleted file mode 100644 index 6ce97da..0000000 --- a/src/app/pages/layout/_nav.view.php +++ /dev/null @@ -1,21 +0,0 @@ - diff --git a/src/app/pages/layout/component.view.php b/src/app/pages/layout/component.view.php deleted file mode 100644 index 6f5e540..0000000 --- a/src/app/pages/layout/component.view.php +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/src/app/pages/layout/full.view.php b/src/app/pages/layout/full.view.php deleted file mode 100644 index 50b69bd..0000000 --- a/src/app/pages/layout/full.view.php +++ /dev/null @@ -1,11 +0,0 @@ - - -template->render('layout/_head', [ 'pageTitle' => $pageTitle, 'isHtmx' => $isHtmx ]); ?> - -
- template->render('layout/_nav', [ 'user' => $user, 'hasSnoozed' => $hasSnoozed ]); ?> -
-
- template->render('layout/_foot', [ 'isHtmx' => $isHtmx ]); ?> - - diff --git a/src/app/pages/layout/partial.view.php b/src/app/pages/layout/partial.view.php deleted file mode 100644 index 192daaa..0000000 --- a/src/app/pages/layout/partial.view.php +++ /dev/null @@ -1,12 +0,0 @@ - - - - <?php echo $pageTitle; ?> « myPrayerJournal - - -
- template->render('layout/_nav', [ 'user' => $user, 'hasSnoozed' => $hasSnoozed ]); ?> -
-
- - diff --git a/src/app/pages/legal/privacy-policy.view.php b/src/app/pages/legal/privacy-policy.view.php deleted file mode 100644 index 9bcc589..0000000 --- a/src/app/pages/legal/privacy-policy.view.php +++ /dev/null @@ -1,82 +0,0 @@ -
-

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 deleted file mode 100644 index 7a1d54c..0000000 --- a/src/app/pages/legal/terms-of-service.view.php +++ /dev/null @@ -1,57 +0,0 @@ -
-

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/pages/requests/edit.view.php b/src/app/pages/requests/edit.view.php deleted file mode 100644 index 42ac60b..0000000 --- a/src/app/pages/requests/edit.view.php +++ /dev/null @@ -1,85 +0,0 @@ - '/requests/active', - 'snoozed' => '/requests/snoozed', - default => '/journal', -}; -$isImmediate = $request->recurrenceType == RecurrenceType::Immediate; -$isHours = $request->recurrenceType == RecurrenceType::Hours; -$isDays = $request->recurrenceType == RecurrenceType::Days; -$isWeeks = $request->recurrenceType == RecurrenceType::Weeks; ?> -
-

Prayer Request

-
="/request"> - - -
- - -

-
-
-
- - -
-
- - -
-
- - -
-
-
-
-

- Recurrence   - After prayer, request reappears… -

-
-
- > - -
-
- > - -
-
- > - -
-
- - -
-
-
-
- -
-
diff --git a/src/app/public/components/journal-items.php b/src/app/public/components/journal-items.php new file mode 100644 index 0000000..2b55165 --- /dev/null +++ b/src/app/public/components/journal-items.php @@ -0,0 +1,93 @@ + +
+

+ class="button is-light"> + add_box Add a Prayer Request + +

+
%s', [ + $activity, + $asOf->setTimezone($_REQUEST[Constants::TIME_ZONE])->format('l, F jS, Y/g:ia T'), + Dates::formatDistance(Dates::now(), $asOf) + ]); +} + +/** + * Create a card for a prayer request + * + * @param JournalRequest $req The request for which a card should be generated + */ +function journal_card(JournalRequest $req) +{ + $spacer = ' '; ?> +
+
+ +
+

text); ?>

+
+ +
+
-

 

-

+ +

+

 

+

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.

- +
user[Constants::CLAIM_GIVEN_NAME]}’s Prayer Journal"; + +template('layout/page_header'); ?> +
+

’s Prayer Journal

+

+ Loading your prayer journal… +

+
+
+
+

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. +

+
+
+
+

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. +

+
logout($_ENV[Constants::BASE_URL])}"); +exit; diff --git a/src/app/public/user/log-on.php b/src/app/public/user/log-on.php new file mode 100644 index 0000000..97cecf2 --- /dev/null +++ b/src/app/public/user/log-on.php @@ -0,0 +1,24 @@ +clear(); + +// Check for return URL; if present, store it in a cookie we'll retrieve when we're logged in +$nonce = ''; +if (array_key_exists(Constants::RETURN_URL, $_GET)) { + $nonce = urlencode(base64_encode(openssl_random_pseudo_bytes(8))); + setcookie(Constants::COOKIE_REDIRECT, "$nonce|{$_GET[Constants::RETURN_URL]}", [ + 'expires' => -1, + 'secure' => true, + 'httponly' => true, + 'samesite' => 'Strict' + ]); +} +$params = $nonce ? [ Constants::LOG_ON_STATE => $nonce ] : []; + +header('Location: ' . $auth0->login("{$_ENV[Constants::BASE_URL]}/user/logged-on", $params)); +exit; diff --git a/src/app/public/user/logged-on.php b/src/app/public/user/logged-on.php new file mode 100644 index 0000000..a3db73c --- /dev/null +++ b/src/app/public/user/logged-on.php @@ -0,0 +1,26 @@ +exchange("{$_ENV[Constants::BASE_URL]}/user/logged-on"); + +$nextUrl = '/journal'; +if (array_key_exists(Constants::LOG_ON_STATE, $_GET)) { + $nonce = base64_decode(urldecode($_GET[Constants::LOG_ON_STATE])); + [$verify, $newNext] = explode('|', $_COOKIE[Constants::COOKIE_REDIRECT]); + if ($verify == $nonce && $newNext && str_starts_with($newNext, '/') && !str_starts_with($newNext, '//')) { + $nextUrl = $newNext; + } +} + +setcookie(Constants::COOKIE_REDIRECT, '', [ + 'expires' => -1, + 'secure' => true, + 'httponly' => true, + 'samesite' => 'Strict' +]); +header("Location: $nextUrl"); +exit; diff --git a/src/app/templates/layout/page_footer.php b/src/app/templates/layout/page_footer.php new file mode 100644 index 0000000..f14b08b --- /dev/null +++ b/src/app/templates/layout/page_footer.php @@ -0,0 +1,21 @@ + + + + + 0; +} + +$theTitle = array_key_exists(Constants::PAGE_TITLE, $_REQUEST) ? "{$_REQUEST[Constants::PAGE_TITLE]} « " : ''; ?> + + + + + <?php echo $theTitle; ?>myPrayerJournal + + + + + + +
+ diff --git a/src/app/templates/no_content.php b/src/app/templates/no_content.php new file mode 100644 index 0000000..be80a6d --- /dev/null +++ b/src/app/templates/no_content.php @@ -0,0 +1,10 @@ +