Compare commits
14 Commits
Author | SHA1 | Date | |
---|---|---|---|
1e6d984d95 | |||
9af41447b7 | |||
f8b5902aa1 | |||
5f425adc1d | |||
b0bf2cb083 | |||
a5727a84fc | |||
817d7876db | |||
00034c0a26 | |||
907d759a23 | |||
7421f9c788 | |||
dc31b65be8 | |||
fa281124bb | |||
9491359b52 | |||
0ec4fd017f |
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -254,3 +254,7 @@ paket-files/
|
||||||
|
|
||||||
# Ionide VSCode extension
|
# Ionide VSCode extension
|
||||||
.ionide
|
.ionide
|
||||||
|
|
||||||
|
# in-progress: PHP version
|
||||||
|
src/app/vendor
|
||||||
|
**/.env
|
||||||
|
|
33
src/app/composer.json
Normal file
33
src/app/composer.json
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
{
|
||||||
|
"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": {
|
||||||
|
"netresearch/jsonmapper": "^4.2",
|
||||||
|
"guzzlehttp/guzzle": "^7.8",
|
||||||
|
"guzzlehttp/psr7": "^2.6",
|
||||||
|
"http-interop/http-factory-guzzle": "^1.2",
|
||||||
|
"auth0/auth0-php": "^8.8",
|
||||||
|
"vlucas/phpdotenv": "^5.5",
|
||||||
|
"visus/cuid2": "^4.0"
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"allow-plugins": {
|
||||||
|
"php-http/discovery": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
2365
src/app/composer.lock
generated
Normal file
2365
src/app/composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
67
src/app/lib/Constants.php
Normal file
67
src/app/lib/Constants.php
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MyPrayerJournal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constants for use throughout the application
|
||||||
|
*/
|
||||||
|
class Constants
|
||||||
|
{
|
||||||
|
/** @var string The `$_ENV` key for the Auth0 domain configured for myPrayerJournal */
|
||||||
|
const AUTH0_DOMAIN = 'AUTH0_DOMAIN';
|
||||||
|
|
||||||
|
/** @var string The `$_ENV` key for the Auth0 client ID for myPrayerJournal */
|
||||||
|
const AUTH0_CLIENT_ID = 'AUTH0_CLIENT_ID';
|
||||||
|
|
||||||
|
/** @var string The `$_ENV` key for the Auth0 client secret */
|
||||||
|
const AUTH0_CLIENT_SECRET = 'AUTH0_CLIENT_SECRET';
|
||||||
|
|
||||||
|
/** @var string The `$_ENV` key for the Auth0 cookie secret */
|
||||||
|
const AUTH0_COOKIE_SECRET = 'AUTH0_COOKIE_SECRET';
|
||||||
|
|
||||||
|
/** @var string The `$_ENV` key for the base URL for this instance of myPrayerJournal */
|
||||||
|
const BASE_URL = 'AUTH0_BASE_URL';
|
||||||
|
|
||||||
|
/** @var string The Auth0 given name (first name) claim */
|
||||||
|
const CLAIM_GIVEN_NAME = 'given_name';
|
||||||
|
|
||||||
|
/** @var string The Auth0 subscriber (sub) claim */
|
||||||
|
const CLAIM_SUB = 'sub';
|
||||||
|
|
||||||
|
/** @var string The name of the cookie used to persist redirection after Auth0 authentication */
|
||||||
|
const COOKIE_REDIRECT = 'mpjredirect';
|
||||||
|
|
||||||
|
/** @var string the `$_SERVER` key for the HX-Request header */
|
||||||
|
const HEADER_HX_REQUEST = 'HTTP_HX_REQUEST';
|
||||||
|
|
||||||
|
/** @var string The `$_SERVER` key for the HX-History-Restore-Request header */
|
||||||
|
const HEADER_HX_HIST_REQ = 'HTTP_HX_HISTORY_RESTORE_REQUEST';
|
||||||
|
|
||||||
|
/** @var string The `$_SERVER` key for the X-Time-Zone header */
|
||||||
|
const HEADER_USER_TZ = 'HTTP_X_TIME_ZONE';
|
||||||
|
|
||||||
|
/** @var string The `$_REQUEST` key for whether the request was initiated by htmx */
|
||||||
|
const IS_HTMX = 'MPJ_IS_HTMX';
|
||||||
|
|
||||||
|
/** @var string The `$_GET` key for state passed to Auth0 if redirection is required once authenticated */
|
||||||
|
const LOG_ON_STATE = 'state';
|
||||||
|
|
||||||
|
/** @var string The `$_REQUEST` key for the page title for this request */
|
||||||
|
const PAGE_TITLE = 'MPJ_PAGE_TITLE';
|
||||||
|
|
||||||
|
/** @var string The `$_SERVER` key for the current page's relative URI */
|
||||||
|
const REQUEST_URI = 'REQUEST_URI';
|
||||||
|
|
||||||
|
/** @var string The `$_GET` key sent to the log on page if redirection is required once authenticated */
|
||||||
|
const RETURN_URL = 'return_url';
|
||||||
|
|
||||||
|
/** @var string The `$_REQUEST` key for the timezone reference to use for this request */
|
||||||
|
const TIME_ZONE = 'MPJ_TIME_ZONE';
|
||||||
|
|
||||||
|
/** @var string The `$_REQUEST` key for the current user's ID */
|
||||||
|
const USER_ID = 'MPJ_USER_ID';
|
||||||
|
|
||||||
|
/** @var string The `$_REQUEST` key for the current version of myPrayerJournal */
|
||||||
|
const VERSION = 'MPJ_VERSION';
|
||||||
|
}
|
134
src/app/lib/Data.php
Normal file
134
src/app/lib/Data.php
Normal file
|
@ -0,0 +1,134 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MyPrayerJournal;
|
||||||
|
|
||||||
|
use BitBadger\PgDocuments\{ Definition, Document, DocumentIndex, Query };
|
||||||
|
use MyPrayerJournal\Domain\{ History, JournalRequest, Note, Request, RequestAction };
|
||||||
|
|
||||||
|
class Data
|
||||||
|
{
|
||||||
|
/** The prayer request table */
|
||||||
|
const REQ_TABLE = 'mpj.request';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the table and index exist
|
||||||
|
*/
|
||||||
|
public static function startUp(): void
|
||||||
|
{
|
||||||
|
Definition::ensureTable(self::REQ_TABLE);
|
||||||
|
Definition::ensureIndex(self::REQ_TABLE, DocumentIndex::Optimized);
|
||||||
|
}
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * Find a full prayer request by its ID
|
||||||
|
// *
|
||||||
|
// * @param string $reqId The request ID
|
||||||
|
// * @param string $userId The ID of the currently logged-on user
|
||||||
|
// * @return ?Request The request, or null if it is not found
|
||||||
|
// */
|
||||||
|
// public static function findFullRequestById(string $reqId, string $userId): ?Request
|
||||||
|
// {
|
||||||
|
// $req = Document::findById(self::REQ_TABLE, $reqId, Request::class);
|
||||||
|
// return is_null($req) || $req->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 = sprintf('%s WHERE %s AND %s', Query::selectFromTable(self::REQ_TABLE), Query::whereDataContains('$1'),
|
||||||
|
Query::whereJsonPathMatches('$2'));
|
||||||
|
$params = [
|
||||||
|
Query::jsonbDocParam([ 'userId' => $userId ]),
|
||||||
|
sprintf('$.history[0].status ? (@ %s "%s")', $op, 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;
|
||||||
|
}
|
||||||
|
}
|
63
src/app/lib/Dates.php
Normal file
63
src/app/lib/Dates.php
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MyPrayerJournal;
|
||||||
|
|
||||||
|
use DateTimeImmutable, DateTimeInterface, DateTimeZone;
|
||||||
|
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
}
|
50
src/app/lib/DistanceFormat.php
Normal file
50
src/app/lib/DistanceFormat.php
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MyPrayerJournal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The different distance formats supported
|
||||||
|
*/
|
||||||
|
enum DistanceFormat
|
||||||
|
{
|
||||||
|
case LessThanXMinutes;
|
||||||
|
case XMinutes;
|
||||||
|
case AboutXHours;
|
||||||
|
case XHours;
|
||||||
|
case XDays;
|
||||||
|
case AboutXWeeks;
|
||||||
|
case XWeeks;
|
||||||
|
case AboutXMonths;
|
||||||
|
case XMonths;
|
||||||
|
case AboutXYears;
|
||||||
|
case XYears;
|
||||||
|
case OverXYears;
|
||||||
|
case AlmostXYears;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the formatting string for the given format and number
|
||||||
|
*
|
||||||
|
* @param DistanceFormat $it The distance format
|
||||||
|
* @param bool $singular If true, returns the singular version; if false (default), returns the plural version
|
||||||
|
* @return string The format string
|
||||||
|
*/
|
||||||
|
public static function format(DistanceFormat $it, bool $singular = false): string
|
||||||
|
{
|
||||||
|
return match ($it) {
|
||||||
|
self::LessThanXMinutes => $singular ? 'less than a minute' : 'less than %d minutes',
|
||||||
|
self::XMinutes => $singular ? 'a minute' : '%d minutes',
|
||||||
|
self::AboutXHours => $singular ? 'about an hour' : 'about %d hours',
|
||||||
|
self::XHours => $singular ? 'an hour' : '%d hours',
|
||||||
|
self::XDays => $singular ? 'a day' : '%d days',
|
||||||
|
self::AboutXWeeks => $singular ? 'about a week' : 'about %d weeks',
|
||||||
|
self::XWeeks => $singular ? 'a week' : '%d weeks',
|
||||||
|
self::AboutXMonths => $singular ? 'about a month' : 'about %d months',
|
||||||
|
self::XMonths => $singular ? 'a month' : '%d months',
|
||||||
|
self::AboutXYears => $singular ? 'about a year' : 'about %d years',
|
||||||
|
self::XYears => $singular ? 'a year' : '%d years',
|
||||||
|
self::OverXYears => $singular ? 'over a year' : 'over %d years',
|
||||||
|
self::AlmostXYears => $singular ? 'almost a year' : 'almost %d years',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
73
src/app/lib/documents/Configuration.php
Normal file
73
src/app/lib/documents/Configuration.php
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BitBadger\PgDocuments;
|
||||||
|
|
||||||
|
use PgSql\Connection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Document table configuration
|
||||||
|
*/
|
||||||
|
class Configuration
|
||||||
|
{
|
||||||
|
/** @var string $connectionString The connection string to use when establishing a database connection */
|
||||||
|
public static string $connectionString = '';
|
||||||
|
|
||||||
|
/** @var ?Connection $pgConn The active connection */
|
||||||
|
private static ?Connection $pgConn = null;
|
||||||
|
|
||||||
|
/** @var ?string $startUp The name of a function to run on first connection to the database */
|
||||||
|
public static ?string $startUp = null;
|
||||||
|
|
||||||
|
/** @var string $keyName The key name for document IDs (default "id") */
|
||||||
|
public static string $keyName = 'id';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure that the connection string is set, either explicitly, by environment variables, or with defaults
|
||||||
|
*/
|
||||||
|
private static function ensureConnectionString(): void
|
||||||
|
{
|
||||||
|
if (self::$connectionString == "") {
|
||||||
|
$host = $_ENV['PGDOC_HOST'] ?? 'localhost';
|
||||||
|
$port = $_ENV['PGDOC_PORT'] ?? 5432;
|
||||||
|
$db = $_ENV['PGDOC_DB'] ?? 'postgres';
|
||||||
|
$user = $_ENV['PGDOC_USER'] ?? 'postgres';
|
||||||
|
$pass = $_ENV['PGDOC_PASS'] ?? 'postgres';
|
||||||
|
$opts = $_ENV['PGDOC_OPTS'] ?? '';
|
||||||
|
self::$connectionString = "host=$host port=$port dbname=$db user=$user password=$pass"
|
||||||
|
. ($opts ? " options='$opts'" : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A no-op function to force this file to be autoloaded if no explicit configuration is required
|
||||||
|
*/
|
||||||
|
public static function init() { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the PostgreSQL connection, connecting on first request
|
||||||
|
*
|
||||||
|
* @return Connection The open PostgreSQL connection
|
||||||
|
*/
|
||||||
|
public static function getPgConn(): Connection
|
||||||
|
{
|
||||||
|
if (is_null(self::$pgConn)) {
|
||||||
|
self::ensureConnectionString();
|
||||||
|
self::$pgConn = pg_connect(self::$connectionString);
|
||||||
|
}
|
||||||
|
return self::$pgConn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the PostgreSQL connection if it is open
|
||||||
|
*/
|
||||||
|
public static function closeConn(): void
|
||||||
|
{
|
||||||
|
if (!is_null(self::$pgConn)) {
|
||||||
|
pg_close(self::$pgConn);
|
||||||
|
self::$pgConn = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require('functions.php');
|
87
src/app/lib/documents/Definition.php
Normal file
87
src/app/lib/documents/Definition.php
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BitBadger\PgDocuments;
|
||||||
|
|
||||||
|
use PgSql\Result;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Methods to define tables and indexes for document tables
|
||||||
|
*/
|
||||||
|
class Definition
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Create a statement to create a document table
|
||||||
|
*
|
||||||
|
* @param string $name The name of the table to create
|
||||||
|
* @return string A `CREATE TABLE` statement for the document table
|
||||||
|
*/
|
||||||
|
public static function createTable(string $name): string
|
||||||
|
{
|
||||||
|
return "CREATE TABLE IF NOT EXISTS $name (data JSONB NOT NULL)";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a statement to create a key for a document table
|
||||||
|
*
|
||||||
|
* @param string $tableName The table (or schema/table) for which a key should be created
|
||||||
|
* @return string A `CREATE INDEX` statement for a unique key for the document table
|
||||||
|
*/
|
||||||
|
public static function createKey(string $tableName): string
|
||||||
|
{
|
||||||
|
return sprintf('CREATE UNIQUE INDEX IF NOT EXISTS idx_%s_key ON %s ((data -> \'%s\'))',
|
||||||
|
self::extractTable($tableName), $tableName, Configuration::$keyName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a statement to create an index on a document table
|
||||||
|
*
|
||||||
|
* @param string $name The name of the table for which the index should be created
|
||||||
|
* @param DocumentIndex $type The type of index to create
|
||||||
|
* @return string A `CREATE INDEX` statement for the given table
|
||||||
|
*/
|
||||||
|
public static function createIndex(string $name, DocumentIndex $type): string
|
||||||
|
{
|
||||||
|
return sprintf('CREATE INDEX IF NOT EXISTS idx_%s ON %s USING GIN (data%s)',
|
||||||
|
self::extractTable($name), $name, $type == DocumentIndex::Full ? '' : ' jsonb_path_ops');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the given document table exists
|
||||||
|
*
|
||||||
|
* @param string $tableName The name of the table
|
||||||
|
*/
|
||||||
|
public static function ensureTable(string $tableName): void
|
||||||
|
{
|
||||||
|
/** @var Result|bool $result */
|
||||||
|
$result = pg_query(pg_conn(), self::createTable($tableName));
|
||||||
|
if ($result) pg_free_result($result);
|
||||||
|
$result = pg_query(pg_conn(), self::createKey($tableName));
|
||||||
|
if ($result) pg_free_result($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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): void
|
||||||
|
{
|
||||||
|
/** @var Result|bool $result */
|
||||||
|
$result = pg_query(pg_conn(), self::createIndex($name, $type));
|
||||||
|
if ($result) pg_free_result($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract just the table name from a possible `schema.table` name
|
||||||
|
*
|
||||||
|
* @param string $name The name of the table, possibly including the schema
|
||||||
|
* @return string The table name
|
||||||
|
*/
|
||||||
|
private static function extractTable(string $name): string
|
||||||
|
{
|
||||||
|
$schemaAndTable = explode('.', $name);
|
||||||
|
return end($schemaAndTable);
|
||||||
|
}
|
||||||
|
}
|
431
src/app/lib/documents/Document.php
Normal file
431
src/app/lib/documents/Document.php
Normal file
|
@ -0,0 +1,431 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BitBadger\PgDocuments;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
use JsonMapper;
|
||||||
|
use PgSql\Result;
|
||||||
|
|
||||||
|
/** Document manipulation functions */
|
||||||
|
class Document
|
||||||
|
{
|
||||||
|
/** JSON Mapper instance to use for creating a domain type instance from a document */
|
||||||
|
private static ?JsonMapper $mapper = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a domain type from the JSON document retrieved
|
||||||
|
*
|
||||||
|
* @param string $columnName The name of the column from the database
|
||||||
|
* @param array $result An associative array with a single result to be mapped
|
||||||
|
* @param class-string<Type> $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(self::$mapper)) {
|
||||||
|
self::$mapper = new JsonMapper();
|
||||||
|
}
|
||||||
|
|
||||||
|
$mapped = new $className();
|
||||||
|
self::$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<Type> $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 self::mapDocFromJson('data', $result, $className);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a document-focused statement that does not return results
|
||||||
|
*
|
||||||
|
* @param string $query The query to be executed
|
||||||
|
* @param array|object $document The array or object representing the document
|
||||||
|
* @throws Exception If the document's ID is null
|
||||||
|
*/
|
||||||
|
private static function executeNonQuery(string $query, array|object $document): void
|
||||||
|
{
|
||||||
|
$docId = is_array($document)
|
||||||
|
? $document[Configuration::$keyName]
|
||||||
|
: get_object_vars($document)[Configuration::$keyName];
|
||||||
|
if (is_null($docId)) throw new Exception('PgDocument: ID cannot be NULL');
|
||||||
|
/** @var Result|bool $result */
|
||||||
|
$result = pg_query_params(pg_conn(), $query, [ $docId, Query::jsonbDocParam($document) ]);
|
||||||
|
if ($result) pg_free_result($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a document
|
||||||
|
*
|
||||||
|
* @param string $tableName The name of the table into which a document should be inserted
|
||||||
|
* @param array|object $document The array or object representing the document
|
||||||
|
*/
|
||||||
|
public static function insert(string $tableName, array|object $document): void
|
||||||
|
{
|
||||||
|
self::executeNonQuery(Query::insert($tableName), $document);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save (upsert) a document
|
||||||
|
*
|
||||||
|
* @param string $tableName The name of the table into which a document should be inserted
|
||||||
|
* @param array|object $document The array or object representing the document
|
||||||
|
*/
|
||||||
|
public static function save(string $tableName, array|object $document): void
|
||||||
|
{
|
||||||
|
self::executeNonQuery(Query::save($tableName), $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 */
|
||||||
|
$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
|
||||||
|
*
|
||||||
|
* @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
|
||||||
|
{
|
||||||
|
return self::runCount(Query::countAll($tableName), []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
{
|
||||||
|
return self::runCount(Query::countByContains($tableName), [ Query::jsonbDocParam($criteria) ]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
{
|
||||||
|
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 */
|
||||||
|
$result = pg_query_params(pg_conn(), $sql, $params);
|
||||||
|
if (!$result) return false;
|
||||||
|
$exists = boolval(pg_fetch_assoc($result)['it']);
|
||||||
|
pg_free_result($result);
|
||||||
|
return $exists;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
{
|
||||||
|
return self::runExists(Query::existsById($tableName), [ $docId ]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 bool 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
|
||||||
|
{
|
||||||
|
return self::runExists(Query::existsByContains($tableName), [ Query::jsonbDocParam($criteria) ]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 bool True if any documents in the table match the JSON Path, false if not
|
||||||
|
*/
|
||||||
|
public static function existsByJsonPath(string $tableName, string $jsonPath): bool
|
||||||
|
{
|
||||||
|
return self::runExists(Query::existsByJsonPath($tableName), [ $jsonPath ]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a query, mapping the results to an array of domain type objects
|
||||||
|
*
|
||||||
|
* @param string $sql The query to be run
|
||||||
|
* @param array $params The parameters for the query
|
||||||
|
* @param class-string<Type> $className The type of document to be mapped
|
||||||
|
* @return array<Type> The documents matching the query
|
||||||
|
*/
|
||||||
|
private static function runListQuery(string $sql, array $params, string $className): array
|
||||||
|
{
|
||||||
|
/** @var Result|bool $result */
|
||||||
|
$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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve all documents in a table
|
||||||
|
*
|
||||||
|
* @param string $tableName The table from which all documents should be retrieved
|
||||||
|
* @param class-string<Type> $className The type of document to be retrieved
|
||||||
|
* @return array<Type> An array of documents
|
||||||
|
*/
|
||||||
|
public static function findAll(string $tableName, string $className): array
|
||||||
|
{
|
||||||
|
return self::runListQuery(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<Type> $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
|
||||||
|
{
|
||||||
|
$results = self::runListQuery(Query::findById($tableName), [ $docId ], $className);
|
||||||
|
return $results ? $results[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<Type> $className The type of document to be retrieved
|
||||||
|
* @return array<Type> Documents matching the JSON containment query
|
||||||
|
*/
|
||||||
|
public static function findByContains(string $tableName, array|object $criteria, string $className): array
|
||||||
|
{
|
||||||
|
return self::runListQuery(Query::findByContains($tableName), [ Query::jsonbDocParam($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<Type> $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
|
||||||
|
{
|
||||||
|
$results = self::runListQuery(Query::findByContains($tableName) . ' LIMIT 1',
|
||||||
|
[ Query::jsonbDocParam($criteria) ], $className);
|
||||||
|
return $results ? $results[0] : 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
|
||||||
|
* @param class-string<Type> $className The type of document to be retrieved
|
||||||
|
* @return array<Type> Documents matching the JSON Path
|
||||||
|
*/
|
||||||
|
public static function findByJsonPath(string $tableName, string $jsonPath, string $className): array
|
||||||
|
{
|
||||||
|
return self::runListQuery(Query::findByJsonPath($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<Type> $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
|
||||||
|
{
|
||||||
|
$results = self::runListQuery(Query::findByJsonPath($tableName) . ' LIMIT 1', [ $jsonPath ], $className);
|
||||||
|
return $results ? $results[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a full document
|
||||||
|
*
|
||||||
|
* @param string $tableName The table in which the document should be updated
|
||||||
|
* @param array|object $document The document to be updated
|
||||||
|
*/
|
||||||
|
public static function updateFull(string $tableName, array|object $document): void
|
||||||
|
{
|
||||||
|
self::executeNonQuery(Query::updateFull($tableName), $document);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a partial document by its ID
|
||||||
|
*
|
||||||
|
* @param string $tableName The table in which the document should be updated
|
||||||
|
* @param array|object $document The partial document to be updated
|
||||||
|
*/
|
||||||
|
public static function updatePartialById(string $tableName, array|object $document): void
|
||||||
|
{
|
||||||
|
self::executeNonQuery(Query::updatePartialById($tableName), $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): void
|
||||||
|
{
|
||||||
|
/** @var Result|bool $result */
|
||||||
|
$result = pg_query_params(pg_conn(), Query::updatePartialByContains($tableName),
|
||||||
|
[ Query::jsonbDocParam($criteria), Query::jsonbDocParam($document) ]);
|
||||||
|
if ($result) pg_free_result($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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): void
|
||||||
|
{
|
||||||
|
/** @var Result|bool $result */
|
||||||
|
$result = pg_query_params(pg_conn(), Query::updatePartialByJsonPath($tableName),
|
||||||
|
[ $jsonPath, Query::jsonbDocParam($document) ]);
|
||||||
|
if ($result) pg_free_result($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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): void
|
||||||
|
{
|
||||||
|
self::executeNonQuery(Query::deleteById($tableName), [ Configuration::$keyName => $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): void
|
||||||
|
{
|
||||||
|
/** @var Result|bool $result */
|
||||||
|
$result = pg_query_params(pg_conn(), Query::deleteByContains($tableName), [ Query::jsonbDocParam($criteria) ]);
|
||||||
|
if ($result) pg_free_result($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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): void
|
||||||
|
{
|
||||||
|
/** @var Result|bool $result */
|
||||||
|
$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 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<Type> $className The type of document to be mapped
|
||||||
|
* @return array<Type> The documents matching the query
|
||||||
|
*/
|
||||||
|
public static function customList(string $sql, array $params, string $className, callable $mapFunc): array
|
||||||
|
{
|
||||||
|
/** @var Result|bool $result */
|
||||||
|
$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 ("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<Type> $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
|
||||||
|
{
|
||||||
|
$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 A positional array of parameters for the SQL query
|
||||||
|
*/
|
||||||
|
public static function customNonQuery(string $sql, array $params): void
|
||||||
|
{
|
||||||
|
/** @var Result|bool $result */
|
||||||
|
$result = pg_query_params(pg_conn(), $sql, $params);
|
||||||
|
if ($result) pg_free_result($result);
|
||||||
|
}
|
||||||
|
}
|
14
src/app/lib/documents/DocumentIndex.php
Normal file
14
src/app/lib/documents/DocumentIndex.php
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BitBadger\PgDocuments;
|
||||||
|
|
||||||
|
/** The type of index to generate for the document */
|
||||||
|
enum DocumentIndex
|
||||||
|
{
|
||||||
|
/** A GIN index with standard operations (all operators supported) */
|
||||||
|
case Full;
|
||||||
|
|
||||||
|
/** A GIN index with JSONPath operations (optimized for `@>`, `@?`, `@@` operators) */
|
||||||
|
case Optimized;
|
||||||
|
}
|
309
src/app/lib/documents/Query.php
Normal file
309
src/app/lib/documents/Query.php
Normal file
|
@ -0,0 +1,309 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BitBadger\PgDocuments;
|
||||||
|
|
||||||
|
/** Query construction functions */
|
||||||
|
class Query
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Create a `SELECT` clause to retrieve the document data from the given table
|
||||||
|
*
|
||||||
|
* @param string $tableName The name of the table from which documents should be selected
|
||||||
|
* @return string A `SELECT` clause for the given table
|
||||||
|
*/
|
||||||
|
public static function selectFromTable(string $tableName): string
|
||||||
|
{
|
||||||
|
return "SELECT data FROM $tableName";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a `WHERE` clause fragment to implement a key check condition
|
||||||
|
*
|
||||||
|
* @param string $paramName The name of the parameter to be replaced when the query is executed
|
||||||
|
* @return string A `WHERE` clause fragment with the named key and parameter
|
||||||
|
*/
|
||||||
|
public static function whereById(string $paramName): string
|
||||||
|
{
|
||||||
|
return sprintf("data -> '%s' = %s", Configuration::$keyName, $paramName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a `WHERE` clause fragment to implement a @> (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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 `$1` parameter defined for the document)
|
||||||
|
*/
|
||||||
|
public static function insert(string $tableName): string
|
||||||
|
{
|
||||||
|
return sprintf('INSERT INTO %s (data) VALUES ($1)', $tableName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 `$1` parameter defined for the document)
|
||||||
|
*/
|
||||||
|
public static function save(string $tableName): string
|
||||||
|
{
|
||||||
|
return sprintf('INSERT INTO %s (data) VALUES ($1) ON CONFLICT (data) DO UPDATE SET data = EXCLUDED.data',
|
||||||
|
$tableName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query to count documents in a table
|
||||||
|
*
|
||||||
|
* @param string $tableName The name of the table for which documents should be counted
|
||||||
|
* @param string $where The condition for which documents should be counted
|
||||||
|
* @return string A `SELECT` statement to obtain the count of documents for the given table
|
||||||
|
*/
|
||||||
|
private static function countQuery(string $tableName, string $where): string
|
||||||
|
{
|
||||||
|
return "SELECT COUNT(*) AS it FROM $tableName WHERE $where";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 self::countQuery($tableName, '1 = 1');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 self::countQuery($tableName, self::whereDataContains('$1'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 self::countQuery($tableName, self::whereJsonPathMatches('$1'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query to check document existence
|
||||||
|
*
|
||||||
|
* @param string $tableName The name of the table in which document existence should be checked
|
||||||
|
* @param string $where The criteria for which document existence should be checked
|
||||||
|
* @return string A `SELECT` statement to check document existence for the given criteria
|
||||||
|
*/
|
||||||
|
private static function existsQuery(string $tableName, string $where): string
|
||||||
|
{
|
||||||
|
return "SELECT EXISTS (SELECT 1 FROM $tableName WHERE $where) AS it";
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 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 self::existsQuery($tableName, self::whereById('$1'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 self::existsQuery($tableName, self::whereDataContains('$1'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 self::existsQuery($tableName, self::whereJsonPathMatches('$1'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 sprintf('%s WHERE %s', self::selectFromTable($tableName), self::whereById('$1'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 sprintf('%s WHERE %s', self::selectFromTable($tableName), self::whereDataContains('$1'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 sprintf('%s WHERE %s', self::selectFromTable($tableName), self::whereJsonPathMatches('$1'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 sprintf('UPDATE %s SET data = $2 WHERE %s', $tableName, self::whereById('$1'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query to apply a partial update to a document
|
||||||
|
*
|
||||||
|
* @param string $tableName The name of the table in which documents should be updated
|
||||||
|
* @param string $where The `WHERE` clause specifying which documents should be updated
|
||||||
|
* @return string An `UPDATE` statement to update a partial document ($1 is ID, $2 is document)
|
||||||
|
*/
|
||||||
|
private static function updatePartial(string $tableName, string $where): string
|
||||||
|
{
|
||||||
|
return sprintf('UPDATE %s SET data = data || $2 WHERE %s', $tableName, $where);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 self::updatePartial($tableName, self::whereById('$1'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 self::updatePartial($tableName, self::whereDataContains('$1'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 self::updatePartial($tableName, self::whereJsonPathMatches('$1'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query to delete documents
|
||||||
|
*
|
||||||
|
* @param string $tableName The name of the table from which documents should be deleted
|
||||||
|
* @param string $where The criteria by which documents should be deleted
|
||||||
|
* @return string A `DELETE` statement to delete documents in the specified table
|
||||||
|
*/
|
||||||
|
private static function deleteQuery(string $tableName, string $where): string
|
||||||
|
{
|
||||||
|
return "DELETE FROM $tableName WHERE $where";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 self::deleteQuery($tableName, self::whereById('$1'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 self::deleteQuery($tableName, self::whereDataContains('$1'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 self::deleteQuery($tableName, self::whereJsonPathMatches('$1'));
|
||||||
|
}
|
||||||
|
}
|
16
src/app/lib/documents/functions.php
Normal file
16
src/app/lib/documents/functions.php
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use BitBadger\PgDocuments\Configuration;
|
||||||
|
use PgSql\Connection;
|
||||||
|
|
||||||
|
if (!function_exists('pg_conn')) {
|
||||||
|
/**
|
||||||
|
* Return the active PostgreSQL connection
|
||||||
|
*
|
||||||
|
* @return Connection The data connection from the configuration
|
||||||
|
*/
|
||||||
|
function pg_conn(): Connection
|
||||||
|
{
|
||||||
|
return Configuration::getPgConn();
|
||||||
|
}
|
||||||
|
}
|
36
src/app/lib/domain/AsOf.php
Normal file
36
src/app/lib/domain/AsOf.php
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MyPrayerJournal\Domain;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
class AsOf
|
||||||
|
{
|
||||||
|
/** The "as of" date/time */
|
||||||
|
public DateTimeImmutable $asOf;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort an as-of item from oldest to newest
|
||||||
|
*
|
||||||
|
* @param AsOf $a The first item to compare
|
||||||
|
* @param AsOf $b The second item to compare
|
||||||
|
* @return int 0 if they are equal, -1 if A is earlier than B, or 1 if B is earlier than A
|
||||||
|
*/
|
||||||
|
public static function oldestToNewest(AsOf $a, AsOf $b): int
|
||||||
|
{
|
||||||
|
return $a->asOf == $b->asOf ? 0 : ($a->asOf < $b->asOf ? -1 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort an as-of item from newest to oldest
|
||||||
|
*
|
||||||
|
* @param AsOf $a The first item to compare
|
||||||
|
* @param AsOf $b The second item to compare
|
||||||
|
* @return int 0 if they are equal, -1 if B is earlier than A, or 1 if A is earlier than B
|
||||||
|
*/
|
||||||
|
public static function newestToOldest(AsOf $a, AsOf $b): int
|
||||||
|
{
|
||||||
|
return $a->asOf == $b->asOf ? 0 : ($a->asOf > $b->asOf ? -1 : 1);
|
||||||
|
}
|
||||||
|
}
|
36
src/app/lib/domain/History.php
Normal file
36
src/app/lib/domain/History.php
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MyPrayerJournal\Domain;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A record of action taken on a prayer request, including updates to its text
|
||||||
|
*/
|
||||||
|
class History extends AsOf
|
||||||
|
{
|
||||||
|
/** The action taken that generated this history entry */
|
||||||
|
public RequestAction $status = RequestAction::Created;
|
||||||
|
|
||||||
|
/** The text of the update, if applicable */
|
||||||
|
public ?string $text = null;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->asOf = unix_epoch();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isCreated(): bool
|
||||||
|
{
|
||||||
|
return $this->status == RequestAction::Created;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isPrayed(): bool
|
||||||
|
{
|
||||||
|
return $this->status == RequestAction::Prayed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isAnswered(): bool
|
||||||
|
{
|
||||||
|
return $this->status == RequestAction::Answered;
|
||||||
|
}
|
||||||
|
}
|
85
src/app/lib/domain/JournalRequest.php
Normal file
85
src/app/lib/domain/JournalRequest.php
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MyPrayerJournal\Domain;
|
||||||
|
|
||||||
|
use DateTimeImmutable, DateTimeZone;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A prayer request, along with calculated fields, for use in displaying journal lists
|
||||||
|
*/
|
||||||
|
class JournalRequest extends AsOf
|
||||||
|
{
|
||||||
|
/** The ID of the prayer request */
|
||||||
|
public string $id = '';
|
||||||
|
|
||||||
|
/** The ID of the user to whom the prayer request belongs */
|
||||||
|
public string $userId = '';
|
||||||
|
|
||||||
|
/** The current text of the request */
|
||||||
|
public string $text = '';
|
||||||
|
|
||||||
|
/** The date/time this request was last marked as prayed */
|
||||||
|
public ?DateTimeImmutable $lastPrayed = null;
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
|
||||||
|
/** When this request will be show again after a non-immediate recurrence */
|
||||||
|
public ?DateTimeImmutable $showAfter = null;
|
||||||
|
|
||||||
|
/** The type of recurrence for this request */
|
||||||
|
public RecurrenceType $recurrenceType = RecurrenceType::Immediate;
|
||||||
|
|
||||||
|
/** The units for non-immediate recurrence */
|
||||||
|
public ?int $recurrence = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The history for this request
|
||||||
|
* @var History[] $history
|
||||||
|
*/
|
||||||
|
public array $history = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The notes for this request
|
||||||
|
* @var Note[] $notes
|
||||||
|
*/
|
||||||
|
public array $notes = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*
|
||||||
|
* @param ?Request $req The request off which this journal request should be populated
|
||||||
|
* @param bool $full Whether to include history and notes (true) or exclude them (false)
|
||||||
|
*/
|
||||||
|
public function __construct(?Request $req = null, bool $full = false)
|
||||||
|
{
|
||||||
|
if (is_null($req)) {
|
||||||
|
$this->asOf = unix_epoch();
|
||||||
|
$this->lastPrayed = null;
|
||||||
|
} 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, AsOf::newestToOldest(...));
|
||||||
|
$this->asOf = $req->history[array_key_first($req->history)]->asOf;
|
||||||
|
$lastText = array_filter($req->history, fn (History $it) => !is_null($it->text));
|
||||||
|
$this->text = $lastText[array_key_first($lastText)]->text;
|
||||||
|
$lastPrayed = array_filter($req->history, fn (History $it) => $it->isPrayed());
|
||||||
|
if ($lastPrayed) $this->lastPrayed = $lastPrayed[array_key_first($lastPrayed)]->asOf;
|
||||||
|
|
||||||
|
if ($full) {
|
||||||
|
usort($req->notes, AsOf::newestToOldest(...));
|
||||||
|
$this->history = $req->history;
|
||||||
|
$this->notes = $req->notes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
20
src/app/lib/domain/Note.php
Normal file
20
src/app/lib/domain/Note.php
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MyPrayerJournal\Domain;
|
||||||
|
|
||||||
|
use DateTimeImmutable, DateTimeZone;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A note entered on a prayer request
|
||||||
|
*/
|
||||||
|
class Note extends AsOf
|
||||||
|
{
|
||||||
|
/** The note */
|
||||||
|
public string $notes = '';
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->asOf = unix_epoch();
|
||||||
|
}
|
||||||
|
}
|
32
src/app/lib/domain/RecurrenceType.php
Normal file
32
src/app/lib/domain/RecurrenceType.php
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
<?php
|
||||||
|
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
|
||||||
|
{
|
||||||
|
/** The request should reappear immediately */
|
||||||
|
case Immediate;
|
||||||
|
|
||||||
|
/** The request should reappear after the specified number of hours */
|
||||||
|
case Hours;
|
||||||
|
|
||||||
|
/** The request should reappear after the specified number of days */
|
||||||
|
case Days;
|
||||||
|
|
||||||
|
/** The request should reappear after the specified number of weeks */
|
||||||
|
case Weeks;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize this enum using its name
|
||||||
|
*/
|
||||||
|
public function jsonSerialize(): mixed
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
}
|
52
src/app/lib/domain/Request.php
Normal file
52
src/app/lib/domain/Request.php
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MyPrayerJournal\Domain;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Visus\Cuid2\Cuid2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A prayer request
|
||||||
|
*/
|
||||||
|
class Request
|
||||||
|
{
|
||||||
|
/** The ID for the request */
|
||||||
|
public string $id;
|
||||||
|
|
||||||
|
/** The date/time the request was originally entered */
|
||||||
|
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;
|
||||||
|
|
||||||
|
/** The date/time this request should once again show as defined by recurrence */
|
||||||
|
public ?DateTimeImmutable $showAfter = null;
|
||||||
|
|
||||||
|
/** The type of recurrence for this request */
|
||||||
|
public RecurrenceType $recurrenceType = RecurrenceType::Immediate;
|
||||||
|
|
||||||
|
/** The units which apply to recurrences other than Immediate */
|
||||||
|
public ?int $recurrence = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The history for this request
|
||||||
|
* @var History[] $history
|
||||||
|
*/
|
||||||
|
public array $history = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The notes for this request
|
||||||
|
* @var Note[] $notes
|
||||||
|
*/
|
||||||
|
public array $notes = [];
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->id = (new Cuid2())->toString();
|
||||||
|
$this->enteredOn = unix_epoch();
|
||||||
|
}
|
||||||
|
}
|
32
src/app/lib/domain/RequestAction.php
Normal file
32
src/app/lib/domain/RequestAction.php
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MyPrayerJournal\Domain;
|
||||||
|
|
||||||
|
use JsonSerializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An action that was taken on a request
|
||||||
|
*/
|
||||||
|
enum RequestAction: string implements JsonSerializable
|
||||||
|
{
|
||||||
|
/** The request was entered */
|
||||||
|
case Created = 'Created';
|
||||||
|
|
||||||
|
/** Prayer was recorded for the request */
|
||||||
|
case Prayed = 'Prayed';
|
||||||
|
|
||||||
|
/** The request was updated */
|
||||||
|
case Updated = 'Updated';
|
||||||
|
|
||||||
|
/** The request was marked as answered */
|
||||||
|
case Answered = 'Answered';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize this enum using its name
|
||||||
|
*/
|
||||||
|
public function jsonSerialize(): mixed
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
}
|
108
src/app/lib/start.php
Normal file
108
src/app/lib/start.php
Normal file
|
@ -0,0 +1,108 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', 'On');
|
||||||
|
|
||||||
|
use Auth0\SDK\Auth0;
|
||||||
|
use BitBadger\PgDocuments\Configuration;
|
||||||
|
use DateTimeZone;
|
||||||
|
use Dotenv\Dotenv;
|
||||||
|
use MyPrayerJournal\Constants;
|
||||||
|
|
||||||
|
Dotenv::createImmutable(__DIR__)->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): void
|
||||||
|
{
|
||||||
|
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): void
|
||||||
|
{
|
||||||
|
if (!array_key_exists(Constants::USER_ID, $_REQUEST)) {
|
||||||
|
if ($fail) {
|
||||||
|
http_response_code(403);
|
||||||
|
} else {
|
||||||
|
header(sprintf('Location: /user/log-on?%s=%s', Constants::RETURN_URL, $_SERVER[Constants::REQUEST_URI]));
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a bare header for a component result
|
||||||
|
*/
|
||||||
|
function bare_header(): void
|
||||||
|
{
|
||||||
|
echo '<!DOCTYPE html><html lang="en"><head><meta charset="utf8"><title></title></head><body>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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): void
|
||||||
|
{
|
||||||
|
echo 'href="'. $url . '" hx-get="' . $url . '"';
|
||||||
|
if ($checkActive && str_starts_with($_SERVER[Constants::REQUEST_URI], $url)) {
|
||||||
|
$classNames[] = 'is-active-route';
|
||||||
|
}
|
||||||
|
if (!empty($classNames)) {
|
||||||
|
echo sprintf(' class="%s"', 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(): void
|
||||||
|
{
|
||||||
|
Configuration::closeConn();
|
||||||
|
echo '</body></html>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new instance of the Unix epoch
|
||||||
|
*
|
||||||
|
* @return DateTimeImmutable An immutable date/time as of the Unix epoch
|
||||||
|
*/
|
||||||
|
function unix_epoch(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return new DateTimeImmutable('1/1/1970', new DateTimeZone('Etc/UTC'));
|
||||||
|
}
|
90
src/app/public/components/journal-items.php
Normal file
90
src/app/public/components/journal-items.php
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once '../../lib/start.php';
|
||||||
|
|
||||||
|
use MyPrayerJournal\{ Constants, Data, Dates };
|
||||||
|
use MyPrayerJournal\Domain\JournalRequest;
|
||||||
|
|
||||||
|
require_user(true);
|
||||||
|
|
||||||
|
$requests = Data::getJournal($_REQUEST[Constants::USER_ID]);
|
||||||
|
|
||||||
|
bare_header();
|
||||||
|
if ($requests) { ?>
|
||||||
|
<section id="journalItems" class="row row-cols-1 row-cols-md-2 row-cols-lg-3 row-cols-xl-4 g-3" hx-target="this"
|
||||||
|
hx-swap="outerHTML" aria-label="Prayer Requests">
|
||||||
|
<p class="mb-3 has-text-centered">
|
||||||
|
<a <?php page_link('/request/edit?new'); ?> class="button is-light">
|
||||||
|
<span class="material-icons">add_box</span> Add a Prayer Request
|
||||||
|
</a>
|
||||||
|
</p><?php
|
||||||
|
array_walk($requests, journal_card(...)); ?>
|
||||||
|
</section><?php
|
||||||
|
} else {
|
||||||
|
$_REQUEST['EMPTY_HEADING'] = 'No Active Requests';
|
||||||
|
$_REQUEST['EMPTY_LINK'] = '/request/edit?new';
|
||||||
|
$_REQUEST['EMPTY_BTN_TXT'] = 'Add a Request';
|
||||||
|
$_REQUEST['EMPTY_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';
|
||||||
|
template('no_content');
|
||||||
|
}
|
||||||
|
end_request();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 format_activity(string $activity, DateTimeImmutable $asOf): void
|
||||||
|
{
|
||||||
|
echo sprintf('last %s <span title="%s">%s</span>', $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): void
|
||||||
|
{
|
||||||
|
$spacer = '<span> </span>'; ?>
|
||||||
|
<div class="col">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header p-0 d-flex" role="toolbar">
|
||||||
|
<a <?php page_link("/request/edit?{$req->id}"); ?> class="button btn-secondary" title="Edit Request">
|
||||||
|
<span class="material-icons">edit</span>
|
||||||
|
</a><?php echo $spacer; ?>
|
||||||
|
<button type="button" class="btn btn-secondary" title="Add Notes" data-bs-toggle="modal"
|
||||||
|
data-bs-target="#notesModal" hx-get="/components/request/<?php echo $req->id; ?>/add-notes"
|
||||||
|
hx-target="#notesBody" hx-swap="innerHTML">
|
||||||
|
<span class="material-icons">comment</span>
|
||||||
|
</button><?php echo $spacer; ?>
|
||||||
|
<button type="button" class="btn btn-secondary" title="Snooze Request" data-bs-toggle="modal"
|
||||||
|
data-bs-target="#snoozeModal" hx-get="/components/request/<?php echo $req->id; ?>/snooze"
|
||||||
|
hx-target="#snoozeBody" hx-swap="innerHTML">
|
||||||
|
<span class="material-icons">schedule</span>
|
||||||
|
</button>
|
||||||
|
<div class="flex-grow-1"></div>
|
||||||
|
<a href="/request/prayed?<?php echo $req->id; ?>" class="button btn-success w-25"
|
||||||
|
hx-patch="/request/prayed?<?php echo $req->id; ?>" title="Mark as Prayed">
|
||||||
|
<span class="material-icons">done</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="request-text"><?php echo htmlentities($req->text); ?></p>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer text-end text-muted px-1 py-0">
|
||||||
|
<em><?php
|
||||||
|
[ $activity, $asOf ] = is_null($req->lastPrayed)
|
||||||
|
? [ 'activity', $req->asOf ]
|
||||||
|
: [ 'prayed', $req->lastPrayed ];
|
||||||
|
format_activity($activity, $asOf); ?>
|
||||||
|
</em>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div><?php
|
||||||
|
}
|
25
src/app/public/index.php
Normal file
25
src/app/public/index.php
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once '../lib/start.php';
|
||||||
|
|
||||||
|
use MyPrayerJournal\Constants;
|
||||||
|
|
||||||
|
$_REQUEST[Constants::PAGE_TITLE] = 'Welcome';
|
||||||
|
|
||||||
|
template('layout/page_header'); ?>
|
||||||
|
<main class="container">
|
||||||
|
<p class="block"> </p>
|
||||||
|
<p class="block">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<p class="block">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</main><?php
|
||||||
|
template('layout/page_footer');
|
||||||
|
end_request();
|
20
src/app/public/journal.php
Normal file
20
src/app/public/journal.php
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once '../lib/start.php';
|
||||||
|
|
||||||
|
use MyPrayerJournal\Constants;
|
||||||
|
|
||||||
|
require_user();
|
||||||
|
|
||||||
|
$_REQUEST[Constants::PAGE_TITLE] = "{$session->user[Constants::CLAIM_GIVEN_NAME]}’s Prayer Journal";
|
||||||
|
|
||||||
|
template('layout/page_header'); ?>
|
||||||
|
<main class="container">
|
||||||
|
<h2 class="title"><?php echo $_REQUEST[Constants::PAGE_TITLE]; ?></h2>
|
||||||
|
<p hx-get="/components/journal-items" hx-swap="outerHTML" hx-trigger="load delay:.25s">
|
||||||
|
Loading your prayer journal…
|
||||||
|
</p>
|
||||||
|
</main><?php
|
||||||
|
template('layout/page_footer');
|
||||||
|
end_request();
|
98
src/app/public/legal/privacy-policy.php
Normal file
98
src/app/public/legal/privacy-policy.php
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once '../../lib/start.php';
|
||||||
|
|
||||||
|
use MyPrayerJournal\Constants;
|
||||||
|
|
||||||
|
$_REQUEST[Constants::PAGE_TITLE] = 'Privacy Policy';
|
||||||
|
|
||||||
|
template('layout/page_header'); ?>
|
||||||
|
<main class="container">
|
||||||
|
<h2 class="title">Privacy Policy</h2>
|
||||||
|
<h6 class="subtitle">as of May 21<sup>st</sup>, 2018</h6>
|
||||||
|
<p class="mb-3">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header has-background-light">
|
||||||
|
<h3 class="card-header-title">Third Party Services</h3>
|
||||||
|
</div>
|
||||||
|
<p class="card-content">
|
||||||
|
myPrayerJournal utilizes a third-party authentication and identity provider. You should familiarize yourself
|
||||||
|
with the privacy policy for
|
||||||
|
<a href="https://auth0.com/privacy" target="_blank" rel="noopener">Auth0</a>, as well as your chosen
|
||||||
|
provider
|
||||||
|
(<a href="https://privacy.microsoft.com/en-us/privacystatement" target="_blank"
|
||||||
|
rel="noopener">Microsoft</a> or
|
||||||
|
<a href="https://policies.google.com/privacy" target="_blank" rel="noopener">Google</a>).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header has-background-light">
|
||||||
|
<h3 class="card-header-title">What We Collect</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<h4 class="subtitle mb-3">Identifying Data</h4>
|
||||||
|
<ul class="mb-3 mx-5">
|
||||||
|
<li>
|
||||||
|
• 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.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
• 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.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<h4 class="subtitle mb-3">User Provided Data</h4>
|
||||||
|
<ul class="mx-5">
|
||||||
|
<li>
|
||||||
|
• myPrayerJournal stores the information you provide, including the text of prayer requests,
|
||||||
|
updates, and notes; and the date/time when certain actions are taken.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header has-background-light">
|
||||||
|
<h3 class="card-header-title">How Your Data Is Accessed / Secured</h3>
|
||||||
|
</div>
|
||||||
|
<ul class="card-content">
|
||||||
|
<li>
|
||||||
|
• 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.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
• 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 1<sup>st</sup> and
|
||||||
|
15<sup>th</sup> are preserved for 3 months. These backups are stored in a private cloud data repository.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
• 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).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
• Access to servers and backups is strictly controlled and monitored for unauthorized access
|
||||||
|
attempts.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header has-background-light">
|
||||||
|
<h3 class="card-header-title">Removing Your Data</h3>
|
||||||
|
</div>
|
||||||
|
<p class="card-content">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main><?php
|
||||||
|
template('layout/page_footer');
|
||||||
|
end_request();
|
73
src/app/public/legal/terms-of-service.php
Normal file
73
src/app/public/legal/terms-of-service.php
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once '../../lib/start.php';
|
||||||
|
|
||||||
|
use MyPrayerJournal\Constants;
|
||||||
|
|
||||||
|
$_REQUEST[Constants::PAGE_TITLE] = 'Terms of Service';
|
||||||
|
|
||||||
|
template('layout/page_header'); ?>
|
||||||
|
<main class="container">
|
||||||
|
<h2 class="title">Terms of Service</h2>
|
||||||
|
<h6 class="subtitle">as of May 21<sup>st</sup>, 2018</h6>
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header has-background-light">
|
||||||
|
<h3 class="card-header-title">1. Acceptance of Terms</h3>
|
||||||
|
</div>
|
||||||
|
<p class="card-content">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header has-background-light">
|
||||||
|
<h3 class="card-header-title">2. Description of Service and Registration</h3>
|
||||||
|
</div>
|
||||||
|
<p class="card-content">
|
||||||
|
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 <a <?php page_link('/legal/privacy-policy'); ?>>our privacy policy</a> for details on how that
|
||||||
|
information is accessed and stored.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header has-background-light">
|
||||||
|
<h3 class="card-header-title">3. Third Party Services</h3>
|
||||||
|
</div>
|
||||||
|
<p class="card-content">
|
||||||
|
This service utilizes a third-party service provider for identity management. Review the terms of service
|
||||||
|
for <a href="https://auth0.com/terms" target="_blank" rel="noopener">Auth0</a>, as well as those for the
|
||||||
|
selected authorization provider
|
||||||
|
(<a href="https://www.microsoft.com/en-us/servicesagreement" target="_blank"
|
||||||
|
rel="noopener">Microsoft</a> or
|
||||||
|
<a href="https://policies.google.com/terms" target="_blank" rel="noopener">Google</a>).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header has-background-light">
|
||||||
|
<h3 class="card-header-title">4. Liability</h3>
|
||||||
|
</div>
|
||||||
|
<p class="card-content">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header has-background-light">
|
||||||
|
<h3 class="card-header-title">5. Updates to Terms</h3>
|
||||||
|
</div>
|
||||||
|
<p class="card-content">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
You may also wish to review our <a <?php page_link('/legal/privacy-policy'); ?>>privacy policy</a> to learn how
|
||||||
|
we handle your data.
|
||||||
|
</p>
|
||||||
|
</main><?php
|
||||||
|
template('layout/page_footer');
|
||||||
|
end_request();
|
104
src/app/public/script/mpj.js
Normal file
104
src/app/public/script/mpj.js
Normal file
|
@ -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 => `<span class="me-auto"><strong>${typ.toUpperCase()}</strong></span>`
|
||||||
|
|
||||||
|
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()
|
60
src/app/public/style/style.css
Normal file
60
src/app/public/style/style.css
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
|
||||||
|
nav.navbar.is-dark {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
9
src/app/public/user/log-off.php
Normal file
9
src/app/public/user/log-off.php
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once '../../lib/start.php';
|
||||||
|
|
||||||
|
use MyPrayerJournal\Constants;
|
||||||
|
|
||||||
|
header("Location: {$auth0->logout($_ENV[Constants::BASE_URL])}");
|
||||||
|
exit;
|
24
src/app/public/user/log-on.php
Normal file
24
src/app/public/user/log-on.php
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once '../../lib/start.php';
|
||||||
|
|
||||||
|
use MyPrayerJournal\Constants;
|
||||||
|
|
||||||
|
$auth0->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;
|
26
src/app/public/user/logged-on.php
Normal file
26
src/app/public/user/logged-on.php
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once '../../lib/start.php';
|
||||||
|
|
||||||
|
use MyPrayerJournal\Constants;
|
||||||
|
|
||||||
|
$auth0->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;
|
21
src/app/templates/layout/page_footer.php
Normal file
21
src/app/templates/layout/page_footer.php
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
<?php
|
||||||
|
use MyPrayerJournal\Constants; ?>
|
||||||
|
</section><?php
|
||||||
|
if (!$_REQUEST[Constants::IS_HTMX]) { ?>
|
||||||
|
<footer class="container-fluid mx-1">
|
||||||
|
<p class="text-muted has-text-right">
|
||||||
|
myPrayerJournal <?php echo $_REQUEST[Constants::VERSION]; ?><br>
|
||||||
|
<em><small>
|
||||||
|
<a <?php page_link('/legal/privacy-policy'); ?>>Privacy Policy</a> •
|
||||||
|
<a <?php page_link('/legal/terms-of-service'); ?>>Terms of Service</a> •
|
||||||
|
<a href="https://github.com/bit-badger/myprayerjournal" target="_blank" rel="noopener">Developed</a>
|
||||||
|
and hosted by
|
||||||
|
<a href="https://bitbadger.solutions" target="_blank" rel="noopener">Bit Badger Solutions</a>
|
||||||
|
</small></em>
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.4"
|
||||||
|
integrity="sha384-zUfuhFKKZCbHTY6aRR46gxiqszMk5tcHjsVFxnUo8VMus4kHGVdIYVbOYYNlKmHV"
|
||||||
|
crossorigin="anonymous"></script>
|
||||||
|
<script src="/script/mpj.js"></script><?php
|
||||||
|
}
|
50
src/app/templates/layout/page_header.php
Normal file
50
src/app/templates/layout/page_header.php
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
<?php
|
||||||
|
use BitBadger\PgDocuments\Document;
|
||||||
|
use MyPrayerJournal\{ Constants, Data };
|
||||||
|
|
||||||
|
$isLoggedOn = array_key_exists('MPJ_USER_ID', $_REQUEST);
|
||||||
|
$hasSnoozed = false;
|
||||||
|
if ($isLoggedOn) {
|
||||||
|
$hasSnoozed = Document::countByJsonPath(Data::REQ_TABLE, '$.snoozedUntil ? (@ == "0")') > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$theTitle = array_key_exists(Constants::PAGE_TITLE, $_REQUEST) ? "{$_REQUEST[Constants::PAGE_TITLE]} « " : ''; ?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf8">
|
||||||
|
<title><?php echo $theTitle; ?>myPrayerJournal</title><?php
|
||||||
|
if (!$_REQUEST[Constants::IS_HTMX]) { ?>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bulma/0.9.4/css/bulma.min.css"
|
||||||
|
integrity="sha512-HqxHUkJM0SYcbvxUw5P60SzdOTy/QVwA1JJrvaXJv4q7lmbDZCmZaqz01UPOaQveoxfYRv1tHozWGPMcuTBuvQ=="
|
||||||
|
crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||||
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||||
|
<link href="/style/style.css" rel="stylesheet"><?php
|
||||||
|
} ?>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<section id="top" aria-label="top navigation">
|
||||||
|
<nav class="navbar is-dark has-shadow" role="navigation" aria-label="main navigation">
|
||||||
|
<div class="navbar-brand">
|
||||||
|
<a <?php page_link('/'); ?> class="navbar-item">
|
||||||
|
<span class="m">my</span><span class="p">Prayer</span><span class="j">Journal</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="navbar-menu">
|
||||||
|
<div class="navbar-start"><?php
|
||||||
|
if ($isLoggedOn) { ?>
|
||||||
|
<a <?php page_link('/journal', ['navbar-item'], true); ?>>Journal</a>
|
||||||
|
<a <?php page_link('/requests/active', ['navbar-item'], true); ?>>Active</a><?php
|
||||||
|
if ($hasSnoozed) { ?>
|
||||||
|
<a <?php page_link('/requests/snoozed', ['navbar-item'], true); ?>>Snoozed</a><?php
|
||||||
|
} ?>
|
||||||
|
<a <?php page_link('/requests/answered', ['navbar-item'], true); ?>>Answered</a>
|
||||||
|
<a href="/user/log-off" class="navbar-item">Log Off</a><?php
|
||||||
|
} else { ?>
|
||||||
|
<a href="/user/log-on" class="navbar-item">Log On</a><?php
|
||||||
|
} ?>
|
||||||
|
<a href="https://docs.prayerjournal.me" class="navbar-item" target="_blank" rel="noopener">Docs</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
10
src/app/templates/no_content.php
Normal file
10
src/app/templates/no_content.php
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header has-background-light">
|
||||||
|
<h5 class="card-header-title"><?php echo $_REQUEST['EMPTY_HEADING']; ?></h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-content has-text-centered">
|
||||||
|
<p class="mb-5"><?php echo $_REQUEST['EMPTY_TEXT']; ?></p>
|
||||||
|
<a <?php page_link($_REQUEST['EMPTY_LINK']); ?>
|
||||||
|
class="button is-link"><?php echo $_REQUEST['EMPTY_BTN_TXT']; ?></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
Loading…
Reference in New Issue
Block a user