Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e6d984d95 | |||
| 9af41447b7 | |||
| f8b5902aa1 | |||
| 5f425adc1d | |||
| b0bf2cb083 | |||
| a5727a84fc | |||
| 817d7876db | |||
| 00034c0a26 | |||
| 907d759a23 | |||
| 7421f9c788 | |||
| dc31b65be8 | |||
| fa281124bb | |||
| 9491359b52 | |||
| 0ec4fd017f |
+3
-5
@@ -255,8 +255,6 @@ paket-files/
|
||||
# Ionide VSCode extension
|
||||
.ionide
|
||||
|
||||
src/environment.txt
|
||||
|
||||
# PHP ignore files
|
||||
src/vendor
|
||||
src/.env
|
||||
# in-progress: PHP version
|
||||
src/app/vendor
|
||||
**/.env
|
||||
|
||||
@@ -8,4 +8,9 @@ myPrayerJournal was borne of out of a personal desire [Daniel](https://github.co
|
||||
|
||||
## Further Reading
|
||||
|
||||
The documentation for the site is at <https://prayerjournal.me/docs>.
|
||||
The documentation for the site is at <https://bit-badger.github.io/myPrayerJournal/>.
|
||||
|
||||
---
|
||||
_Thanks to [JetBrains](https://jb.gg/OpenSource) for licensing their awesome toolset to this project._
|
||||
|
||||
[<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.png" alt="JetBrains Logo (Main) logo" width="100" height="100">](https://jb.gg/OpenSource)
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
frankenphp
|
||||
order php_server before file_server
|
||||
}
|
||||
http://localhost:3000 {
|
||||
root * ./public
|
||||
try_files {path} {path}.php
|
||||
php_server
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build
|
||||
WORKDIR /mpj
|
||||
COPY ./MyPrayerJournal/MyPrayerJournal.fsproj ./
|
||||
RUN dotnet restore
|
||||
|
||||
COPY ./MyPrayerJournal ./
|
||||
RUN dotnet publish -c Release -r linux-x64
|
||||
RUN rm bin/Release/net8.0/linux-x64/publish/appsettings.*.json || true
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine as final
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache icu-libs
|
||||
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
|
||||
COPY --from=build /mpj/bin/Release/net8.0/linux-x64/publish/ ./
|
||||
|
||||
EXPOSE 80
|
||||
CMD [ "dotnet", "/app/MyPrayerJournal.dll" ]
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="Program.fs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MyPrayerJournal\MyPrayerJournal.fsproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,114 @@
|
||||
open MyPrayerJournal.Domain
|
||||
open NodaTime
|
||||
|
||||
/// The old definition of the history entry
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type OldHistory =
|
||||
{ /// The time when this history entry was made
|
||||
asOf : int64
|
||||
/// The status for this history entry
|
||||
status : RequestAction
|
||||
/// The text of the update, if applicable
|
||||
text : string option
|
||||
}
|
||||
|
||||
/// The old definition of of the note entry
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type OldNote =
|
||||
{ /// The time when this note was made
|
||||
asOf : int64
|
||||
|
||||
/// The text of the notes
|
||||
notes : string
|
||||
}
|
||||
|
||||
/// Request is the identifying record for a prayer request
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type OldRequest =
|
||||
{ /// The ID of the request
|
||||
id : RequestId
|
||||
|
||||
/// The time this request was initially entered
|
||||
enteredOn : int64
|
||||
|
||||
/// The ID of the user to whom this request belongs ("sub" from the JWT)
|
||||
userId : UserId
|
||||
|
||||
/// The time at which this request should reappear in the user's journal by manual user choice
|
||||
snoozedUntil : int64
|
||||
|
||||
/// The time at which this request should reappear in the user's journal by recurrence
|
||||
showAfter : int64
|
||||
|
||||
/// The type of recurrence for this request
|
||||
recurType : string
|
||||
|
||||
/// How many of the recurrence intervals should occur between appearances in the journal
|
||||
recurCount : int16
|
||||
|
||||
/// The history entries for this request
|
||||
history : OldHistory[]
|
||||
|
||||
/// The notes for this request
|
||||
notes : OldNote[]
|
||||
}
|
||||
|
||||
|
||||
open LiteDB
|
||||
open MyPrayerJournal.Data
|
||||
|
||||
let db = new LiteDatabase ("Filename=./mpj.db")
|
||||
Startup.ensureDb db
|
||||
|
||||
/// Map the old recurrence to the new style
|
||||
let mapRecurrence old =
|
||||
match old.recurType with
|
||||
| "Days" -> Days old.recurCount
|
||||
| "Hours" -> Hours old.recurCount
|
||||
| "Weeks" -> Weeks old.recurCount
|
||||
| _ -> Immediate
|
||||
|
||||
/// Convert an old history entry to the new form
|
||||
let convertHistory (old : OldHistory) =
|
||||
{ AsOf = Instant.FromUnixTimeMilliseconds old.asOf
|
||||
Status = old.status
|
||||
Text = old.text
|
||||
}
|
||||
|
||||
/// Convert an old note to the new form
|
||||
let convertNote (old : OldNote) =
|
||||
{ AsOf = Instant.FromUnixTimeMilliseconds old.asOf
|
||||
Notes = old.notes
|
||||
}
|
||||
|
||||
/// Convert items that may be Instant.MinValue or Instant(0) to None
|
||||
let noneIfOld ms =
|
||||
match Instant.FromUnixTimeMilliseconds ms with
|
||||
| instant when instant > Instant.FromUnixTimeMilliseconds 0 -> Some instant
|
||||
| _ -> None
|
||||
|
||||
/// Map the old request to the new request
|
||||
let convert old =
|
||||
{ Id = old.id
|
||||
EnteredOn = Instant.FromUnixTimeMilliseconds old.enteredOn
|
||||
UserId = old.userId
|
||||
SnoozedUntil = noneIfOld old.snoozedUntil
|
||||
ShowAfter = noneIfOld old.showAfter
|
||||
Recurrence = mapRecurrence old
|
||||
History = old.history |> Array.map convertHistory |> List.ofArray
|
||||
Notes = old.notes |> Array.map convertNote |> List.ofArray
|
||||
}
|
||||
|
||||
/// Remove the old request, add the converted one (removes recurType / recurCount fields)
|
||||
let replace (req : Request) =
|
||||
db.Requests.Delete (Mapping.RequestId.toBson req.Id) |> ignore
|
||||
db.Requests.Insert req |> ignore
|
||||
db.Checkpoint ()
|
||||
|
||||
db.GetCollection<OldRequest>("request").FindAll ()
|
||||
|> Seq.map convert
|
||||
|> List.ofSeq
|
||||
|> List.iter replace
|
||||
|
||||
// For more information see https://aka.ms/fsharp-console-apps
|
||||
printfn "Done"
|
||||
@@ -1,10 +1,12 @@
|
||||
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 16
|
||||
VisualStudioVersion = 16.0.30114.105
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "MyPrayerJournal", "MyPrayerJournal\MyPrayerJournal.fsproj", "{6BD5A3C8-F859-42A0-ACD7-A5819385E828}"
|
||||
EndProject
|
||||
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "MyPrayerJournal.ConvertRecurrence", "MyPrayerJournal.ConvertRecurrence\MyPrayerJournal.ConvertRecurrence.fsproj", "{72B57736-8721-4636-A309-49FA4222416E}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
## LiteDB database file
|
||||
*.db
|
||||
|
||||
## Development settings
|
||||
appsettings.Development.json
|
||||
|
||||
+160
-163
@@ -1,202 +1,199 @@
|
||||
module MyPrayerJournal.Data
|
||||
module MyPrayerJournal.Data
|
||||
|
||||
/// Table(!) used by myPrayerJournal
|
||||
module Table =
|
||||
open LiteDB
|
||||
open MyPrayerJournal
|
||||
open System.Threading.Tasks
|
||||
|
||||
/// Requests
|
||||
[<Literal>]
|
||||
let Request = "mpj.request"
|
||||
/// LiteDB extensions
|
||||
[<AutoOpen>]
|
||||
module Extensions =
|
||||
|
||||
/// Extensions on the LiteDatabase class
|
||||
type LiteDatabase with
|
||||
|
||||
/// The Request collection
|
||||
member this.Requests = this.GetCollection<Request> "request"
|
||||
|
||||
/// Async version of the checkpoint command (flushes log)
|
||||
member this.SaveChanges () =
|
||||
this.Checkpoint ()
|
||||
Task.CompletedTask
|
||||
|
||||
|
||||
/// JSON serialization customizations
|
||||
/// Map domain to LiteDB
|
||||
// It does mapping, but since we're so DU-heavy, this gives us control over the JSON representation
|
||||
[<RequireQualifiedAccess>]
|
||||
module Json =
|
||||
|
||||
open System.Text.Json.Serialization
|
||||
|
||||
/// Convert a wrapped DU to/from its string representation
|
||||
type WrappedJsonConverter<'T>(wrap : string -> 'T, unwrap : 'T -> string) =
|
||||
inherit JsonConverter<'T>()
|
||||
override _.Read(reader, _, _) =
|
||||
wrap (reader.GetString())
|
||||
override _.Write(writer, value, _) =
|
||||
writer.WriteStringValue(unwrap value)
|
||||
|
||||
open System.Text.Json
|
||||
open NodaTime.Serialization.SystemTextJson
|
||||
|
||||
/// JSON serializer options to support the target domain
|
||||
let options =
|
||||
let opts = JsonSerializerOptions()
|
||||
[ WrappedJsonConverter(Recurrence.ofString, Recurrence.toString) :> JsonConverter
|
||||
WrappedJsonConverter(RequestAction.ofString, RequestAction.toString)
|
||||
WrappedJsonConverter(RequestId.ofString, RequestId.toString)
|
||||
WrappedJsonConverter(UserId, UserId.toString)
|
||||
JsonFSharpConverter() ]
|
||||
|> List.iter opts.Converters.Add
|
||||
let _ = opts.ConfigureForNodaTime NodaTime.DateTimeZoneProviders.Tzdb
|
||||
opts.PropertyNamingPolicy <- JsonNamingPolicy.CamelCase
|
||||
opts.DefaultIgnoreCondition <- JsonIgnoreCondition.WhenWritingNull
|
||||
opts
|
||||
|
||||
|
||||
open BitBadger.Documents.Postgres
|
||||
|
||||
/// Connection
|
||||
[<RequireQualifiedAccess>]
|
||||
module Connection =
|
||||
|
||||
open BitBadger.Documents
|
||||
open Microsoft.Extensions.Configuration
|
||||
open Npgsql
|
||||
open System.Text.Json
|
||||
|
||||
/// Ensure the database is ready to use
|
||||
let private ensureDb () = backgroundTask {
|
||||
do! Custom.nonQuery "CREATE SCHEMA IF NOT EXISTS mpj" []
|
||||
do! Definition.ensureTable Table.Request
|
||||
do! Definition.ensureDocumentIndex Table.Request Optimized
|
||||
}
|
||||
|
||||
/// Set up the data environment
|
||||
let setUp (cfg : IConfiguration) = backgroundTask {
|
||||
let builder = NpgsqlDataSourceBuilder (cfg.GetConnectionString "mpj")
|
||||
let _ = builder.UseNodaTime()
|
||||
Configuration.useDataSource (builder.Build())
|
||||
Configuration.useIdField "id"
|
||||
Configuration.useSerializer
|
||||
{ new IDocumentSerializer with
|
||||
member _.Serialize<'T>(it : 'T) = JsonSerializer.Serialize(it, Json.options)
|
||||
member _.Deserialize<'T>(it : string) = JsonSerializer.Deserialize<'T>(it, Json.options)
|
||||
}
|
||||
do! ensureDb ()
|
||||
}
|
||||
|
||||
|
||||
/// Data access functions for requests
|
||||
[<RequireQualifiedAccess>]
|
||||
module Request =
|
||||
module Mapping =
|
||||
|
||||
open NodaTime
|
||||
open NodaTime.Text
|
||||
|
||||
/// Add a request
|
||||
let add req =
|
||||
insert<Request> Table.Request req
|
||||
/// A NodaTime instant pattern to use for parsing instants from the database
|
||||
let instantPattern = InstantPattern.CreateWithInvariantCulture "g"
|
||||
|
||||
/// Does a request exist for the given request ID and user ID?
|
||||
let existsById (reqId : RequestId) (userId : UserId) =
|
||||
Exists.byContains Table.Request {| Id = reqId; UserId = userId |}
|
||||
/// Mapping for NodaTime's Instant type
|
||||
module Instant =
|
||||
let fromBson (value : BsonValue) = (instantPattern.Parse value.AsString).Value
|
||||
let toBson (value : Instant) : BsonValue = value.ToString ("g", null)
|
||||
|
||||
/// Retrieve a request by its ID and user ID
|
||||
let tryById reqId userId = backgroundTask {
|
||||
match! Find.byId<string, Request> Table.Request (RequestId.toString reqId) with
|
||||
| Some req when req.UserId = userId -> return Some req
|
||||
| _ -> return None
|
||||
/// Mapping for option types
|
||||
module Option =
|
||||
let instantFromBson (value : BsonValue) = if value.IsNull then None else Some (Instant.fromBson value)
|
||||
let instantToBson (value : Instant option) = match value with Some it -> Instant.toBson it | None -> null
|
||||
|
||||
let stringFromBson (value : BsonValue) = match value.AsString with "" -> None | x -> Some x
|
||||
let stringToBson (value : string option) : BsonValue = match value with Some txt -> txt | None -> ""
|
||||
|
||||
/// Mapping for Recurrence
|
||||
module Recurrence =
|
||||
let fromBson (value : BsonValue) = Recurrence.ofString value
|
||||
let toBson (value : Recurrence) : BsonValue = Recurrence.toString value
|
||||
|
||||
/// Mapping for RequestAction
|
||||
module RequestAction =
|
||||
let fromBson (value : BsonValue) = RequestAction.ofString value.AsString
|
||||
let toBson (value : RequestAction) : BsonValue = RequestAction.toString value
|
||||
|
||||
/// Mapping for RequestId
|
||||
module RequestId =
|
||||
let fromBson (value : BsonValue) = RequestId.ofString value.AsString
|
||||
let toBson (value : RequestId) : BsonValue = RequestId.toString value
|
||||
|
||||
/// Mapping for UserId
|
||||
module UserId =
|
||||
let fromBson (value : BsonValue) = UserId value.AsString
|
||||
let toBson (value : UserId) : BsonValue = UserId.toString value
|
||||
|
||||
/// Set up the mapping
|
||||
let register () =
|
||||
BsonMapper.Global.RegisterType<Instant>(Instant.toBson, Instant.fromBson)
|
||||
BsonMapper.Global.RegisterType<Instant option>(Option.instantToBson, Option.instantFromBson)
|
||||
BsonMapper.Global.RegisterType<Recurrence>(Recurrence.toBson, Recurrence.fromBson)
|
||||
BsonMapper.Global.RegisterType<RequestAction>(RequestAction.toBson, RequestAction.fromBson)
|
||||
BsonMapper.Global.RegisterType<RequestId>(RequestId.toBson, RequestId.fromBson)
|
||||
BsonMapper.Global.RegisterType<string option>(Option.stringToBson, Option.stringFromBson)
|
||||
BsonMapper.Global.RegisterType<UserId>(UserId.toBson, UserId.fromBson)
|
||||
|
||||
/// Code to be run at startup
|
||||
module Startup =
|
||||
|
||||
/// Ensure the database is set up
|
||||
let ensureDb (db : LiteDatabase) =
|
||||
db.Requests.EnsureIndex (fun it -> it.UserId) |> ignore
|
||||
Mapping.register ()
|
||||
|
||||
|
||||
/// Async wrappers for LiteDB, and request -> journal mappings
|
||||
[<AutoOpen>]
|
||||
module private Helpers =
|
||||
|
||||
open System.Linq
|
||||
|
||||
/// Convert a sequence to a list asynchronously (used for LiteDB IO)
|
||||
let toListAsync<'T> (q : 'T seq) =
|
||||
(q.ToList >> Task.FromResult) ()
|
||||
|
||||
/// Convert a sequence to a list asynchronously (used for LiteDB IO)
|
||||
let firstAsync<'T> (q : 'T seq) =
|
||||
q.FirstOrDefault () |> Task.FromResult
|
||||
|
||||
/// Async wrapper around a request update
|
||||
let doUpdate (db : LiteDatabase) (req : Request) =
|
||||
db.Requests.Update req |> ignore
|
||||
Task.CompletedTask
|
||||
|
||||
|
||||
/// Retrieve a request, including its history and notes, by its ID and user ID
|
||||
let tryFullRequestById reqId userId (db : LiteDatabase) = backgroundTask {
|
||||
let! req = db.Requests.Find (Query.EQ ("_id", RequestId.toString reqId)) |> firstAsync
|
||||
return match box req with null -> None | _ when req.UserId = userId -> Some req | _ -> None
|
||||
}
|
||||
|
||||
/// Update recurrence for a request
|
||||
let updateRecurrence reqId userId (recurType : Recurrence) = backgroundTask {
|
||||
let dbId = RequestId.toString reqId
|
||||
match! existsById reqId userId with
|
||||
| true -> do! Patch.byId Table.Request dbId {| Recurrence = recurType |}
|
||||
| false -> invalidOp $"Request ID {dbId} not found"
|
||||
}
|
||||
|
||||
/// Update the show-after time for a request
|
||||
let updateShowAfter reqId userId (showAfter : Instant option) = backgroundTask {
|
||||
let dbId = RequestId.toString reqId
|
||||
match! existsById reqId userId with
|
||||
| true -> do! Patch.byId Table.Request dbId {| ShowAfter = showAfter |}
|
||||
| false -> invalidOp $"Request ID {dbId} not found"
|
||||
}
|
||||
|
||||
/// Update the snoozed and show-after values for a request
|
||||
let updateSnoozed reqId userId (until : Instant option) = backgroundTask {
|
||||
let dbId = RequestId.toString reqId
|
||||
match! existsById reqId userId with
|
||||
| true -> do! Patch.byId Table.Request dbId {| SnoozedUntil = until; ShowAfter = until |}
|
||||
| false -> invalidOp $"Request ID {dbId} not found"
|
||||
}
|
||||
|
||||
|
||||
/// Specific manipulation of history entries
|
||||
[<RequireQualifiedAccess>]
|
||||
module History =
|
||||
|
||||
/// Add a history entry
|
||||
let add reqId userId hist = backgroundTask {
|
||||
let dbId = RequestId.toString reqId
|
||||
match! Request.tryById reqId userId with
|
||||
| Some req ->
|
||||
do! Patch.byId Table.Request dbId {| History = (hist :: req.History) |> List.sortByDescending (_.AsOf) |}
|
||||
| None -> invalidOp $"Request ID {dbId} not found"
|
||||
let addHistory reqId userId hist db = backgroundTask {
|
||||
match! tryFullRequestById reqId userId db with
|
||||
| Some req -> do! doUpdate db { req with History = Array.append [| hist |] req.History }
|
||||
| None -> invalidOp $"{RequestId.toString reqId} not found"
|
||||
}
|
||||
|
||||
/// Add a note
|
||||
let addNote reqId userId note db = backgroundTask {
|
||||
match! tryFullRequestById reqId userId db with
|
||||
| Some req -> do! doUpdate db { req with Notes = Array.append [| note |] req.Notes }
|
||||
| None -> invalidOp $"{RequestId.toString reqId} not found"
|
||||
}
|
||||
|
||||
/// Data access functions for journal-style requests
|
||||
[<RequireQualifiedAccess>]
|
||||
module Journal =
|
||||
/// Add a request
|
||||
let addRequest (req : Request) (db : LiteDatabase) =
|
||||
db.Requests.Insert req |> ignore
|
||||
|
||||
/// Retrieve a user's answered requests
|
||||
let answered (userId : UserId) = backgroundTask {
|
||||
let! reqs =
|
||||
Custom.list
|
||||
$"""{Query.Find.byContains Table.Request} AND {Query.whereJsonPathMatches "@stat"}"""
|
||||
[ jsonParam "@criteria" {| UserId = userId |}
|
||||
"@stat", Sql.string """$.history[0].status ? (@ == "Answered")""" ]
|
||||
fromData<Request>
|
||||
/// Find all requests for the given user
|
||||
let private getRequestsForUser (userId : UserId) (db : LiteDatabase) = backgroundTask {
|
||||
return! db.Requests.Find (Query.EQ (nameof Request.empty.UserId, Mapping.UserId.toBson userId)) |> toListAsync
|
||||
}
|
||||
|
||||
/// Retrieve all answered requests for the given user
|
||||
let answeredRequests userId db = backgroundTask {
|
||||
let! reqs = getRequestsForUser userId db
|
||||
return
|
||||
reqs
|
||||
|> Seq.ofList
|
||||
|> Seq.map JournalRequest.ofRequestLite
|
||||
|> Seq.map JournalRequest.ofRequestFull
|
||||
|> Seq.filter (fun it -> it.LastStatus = Answered)
|
||||
|> Seq.sortByDescending (_.AsOf)
|
||||
|> Seq.sortByDescending (fun it -> it.AsOf)
|
||||
|> List.ofSeq
|
||||
}
|
||||
|
||||
/// Retrieve a user's current prayer journal (includes snoozed and non-immediate recurrence)
|
||||
let forUser (userId : UserId) = backgroundTask {
|
||||
let! reqs =
|
||||
Custom.list
|
||||
$"""{Query.Find.byContains Table.Request} AND {Query.whereJsonPathMatches "@stat"}"""
|
||||
[ jsonParam "@criteria" {| UserId = userId |}
|
||||
"@stat", Sql.string """$.history[0].status ? (@ <> "Answered")""" ]
|
||||
fromData<Request>
|
||||
/// Retrieve the user's current journal
|
||||
let journalByUserId userId db = backgroundTask {
|
||||
let! reqs = getRequestsForUser userId db
|
||||
return
|
||||
reqs
|
||||
|> Seq.ofList
|
||||
|> Seq.map JournalRequest.ofRequestLite
|
||||
|> Seq.filter (fun it -> it.LastStatus <> Answered)
|
||||
|> Seq.sortBy (_.AsOf)
|
||||
|> Seq.sortBy (fun it -> it.AsOf)
|
||||
|> List.ofSeq
|
||||
}
|
||||
|
||||
/// Does the user's journal have any snoozed requests?
|
||||
let hasSnoozed userId now = backgroundTask {
|
||||
let! jrnl = forUser userId
|
||||
/// Does the user have any snoozed requests?
|
||||
let hasSnoozed userId now (db : LiteDatabase) = backgroundTask {
|
||||
let! jrnl = journalByUserId userId db
|
||||
return jrnl |> List.exists (fun r -> defaultArg (r.SnoozedUntil |> Option.map (fun it -> it > now)) false)
|
||||
}
|
||||
|
||||
let tryById reqId userId = backgroundTask {
|
||||
let! req = Request.tryById reqId userId
|
||||
/// Retrieve a request by its ID and user ID (without notes and history)
|
||||
let tryRequestById reqId userId db = backgroundTask {
|
||||
let! req = tryFullRequestById reqId userId db
|
||||
return req |> Option.map (fun r -> { r with History = [||]; Notes = [||] })
|
||||
}
|
||||
|
||||
/// Retrieve notes for a request by its ID and user ID
|
||||
let notesById reqId userId (db : LiteDatabase) = backgroundTask {
|
||||
match! tryFullRequestById reqId userId db with | Some req -> return req.Notes | None -> return [||]
|
||||
}
|
||||
|
||||
/// Retrieve a journal request by its ID and user ID
|
||||
let tryJournalById reqId userId (db : LiteDatabase) = backgroundTask {
|
||||
let! req = tryFullRequestById reqId userId db
|
||||
return req |> Option.map JournalRequest.ofRequestLite
|
||||
}
|
||||
|
||||
|
||||
/// Specific manipulation of note entries
|
||||
[<RequireQualifiedAccess>]
|
||||
module Note =
|
||||
|
||||
/// Add a note
|
||||
let add reqId userId note = backgroundTask {
|
||||
let dbId = RequestId.toString reqId
|
||||
match! Request.tryById reqId userId with
|
||||
| Some req ->
|
||||
do! Patch.byId Table.Request dbId {| Notes = (note :: req.Notes) |> List.sortByDescending (_.AsOf) |}
|
||||
| None -> invalidOp $"Request ID {dbId} not found"
|
||||
/// Update the recurrence for a request
|
||||
let updateRecurrence reqId userId recurType db = backgroundTask {
|
||||
match! tryFullRequestById reqId userId db with
|
||||
| Some req -> do! doUpdate db { req with Recurrence = recurType }
|
||||
| None -> invalidOp $"{RequestId.toString reqId} not found"
|
||||
}
|
||||
|
||||
/// Retrieve notes for a request by the request ID
|
||||
let byRequestId reqId userId = backgroundTask {
|
||||
match! Request.tryById reqId userId with Some req -> return req.Notes | None -> return []
|
||||
/// Update a snoozed request
|
||||
let updateSnoozed reqId userId until db = backgroundTask {
|
||||
match! tryFullRequestById reqId userId db with
|
||||
| Some req -> do! doUpdate db { req with SnoozedUntil = until; ShowAfter = until }
|
||||
| None -> invalidOp $"{RequestId.toString reqId} not found"
|
||||
}
|
||||
|
||||
/// Update the "show after" timestamp for a request
|
||||
let updateShowAfter reqId userId showAfter db = backgroundTask {
|
||||
match! tryFullRequestById reqId userId db with
|
||||
| Some req -> do! doUpdate db { req with ShowAfter = showAfter }
|
||||
| None -> invalidOp $"{RequestId.toString reqId} not found"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/// The data model for myPrayerJournal
|
||||
/// The data model for myPrayerJournal
|
||||
[<AutoOpen>]
|
||||
module MyPrayerJournal.Domain
|
||||
|
||||
@@ -169,10 +169,10 @@ type Request =
|
||||
Recurrence : Recurrence
|
||||
|
||||
/// The history entries for this request
|
||||
History : History list
|
||||
History : History[]
|
||||
|
||||
/// The notes for this request
|
||||
Notes : Note list
|
||||
Notes : Note[]
|
||||
}
|
||||
|
||||
/// Functions to support requests
|
||||
@@ -186,8 +186,8 @@ module Request =
|
||||
SnoozedUntil = None
|
||||
ShowAfter = None
|
||||
Recurrence = Immediate
|
||||
History = []
|
||||
Notes = []
|
||||
History = [||]
|
||||
Notes = [||]
|
||||
}
|
||||
|
||||
|
||||
@@ -234,8 +234,7 @@ module JournalRequest =
|
||||
|
||||
/// Convert a request to the form used for the journal (precomputed values, no notes or history)
|
||||
let ofRequestLite (req : Request) =
|
||||
let history = Seq.ofList req.History
|
||||
let lastHistory = Seq.tryHead history
|
||||
let lastHistory = req.History |> Array.sortByDescending (fun it -> it.AsOf) |> Array.tryHead
|
||||
// Requests are sorted by the "as of" field in this record; for sorting to work properly, we will put the
|
||||
// largest of the last prayed date, the "snoozed until". or the "show after" date; if none of those are filled,
|
||||
// we will use the last activity date. This will mean that:
|
||||
@@ -244,21 +243,23 @@ module JournalRequest =
|
||||
// them at the bottom of the list.
|
||||
// - Snoozed requests will reappear at the bottom of the list when they return.
|
||||
// - New requests will go to the bottom of the list, but will rise as others are marked as prayed.
|
||||
let lastActivity = lastHistory |> Option.map (_.AsOf) |> Option.defaultValue Instant.MinValue
|
||||
let lastActivity = lastHistory |> Option.map (fun it -> it.AsOf) |> Option.defaultValue Instant.MinValue
|
||||
let showAfter = defaultArg req.ShowAfter Instant.MinValue
|
||||
let snoozedUntil = defaultArg req.SnoozedUntil Instant.MinValue
|
||||
let lastPrayed =
|
||||
history
|
||||
|> Seq.filter History.isPrayed
|
||||
|> Seq.tryHead
|
||||
|> Option.map (_.AsOf)
|
||||
req.History
|
||||
|> Array.sortByDescending (fun it -> it.AsOf)
|
||||
|> Array.filter History.isPrayed
|
||||
|> Array.tryHead
|
||||
|> Option.map (fun it -> it.AsOf)
|
||||
|> Option.defaultValue Instant.MinValue
|
||||
let asOf = List.max [ lastPrayed; showAfter; snoozedUntil ]
|
||||
{ RequestId = req.Id
|
||||
UserId = req.UserId
|
||||
Text = history
|
||||
|> Seq.filter (fun it -> Option.isSome it.Text)
|
||||
|> Seq.tryHead
|
||||
Text = req.History
|
||||
|> Array.filter (fun it -> Option.isSome it.Text)
|
||||
|> Array.sortByDescending (fun it -> it.AsOf)
|
||||
|> Array.tryHead
|
||||
|> Option.map (fun h -> Option.get h.Text)
|
||||
|> Option.defaultValue ""
|
||||
AsOf = if asOf > Instant.MinValue then asOf else lastActivity
|
||||
@@ -274,6 +275,6 @@ module JournalRequest =
|
||||
/// Same as `ofRequestLite`, but with notes and history
|
||||
let ofRequestFull req =
|
||||
{ ofRequestLite req with
|
||||
History = req.History
|
||||
Notes = req.Notes
|
||||
History = List.ofArray req.History
|
||||
Notes = List.ofArray req.Notes
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/// HTTP handlers for the myPrayerJournal API
|
||||
/// HTTP handlers for the myPrayerJournal API
|
||||
[<RequireQualifiedAccess>]
|
||||
module MyPrayerJournal.Handlers
|
||||
|
||||
@@ -45,19 +45,23 @@ module Error =
|
||||
|
||||
|
||||
open System.Security.Claims
|
||||
open LiteDB
|
||||
open Microsoft.AspNetCore.Http
|
||||
open NodaTime
|
||||
|
||||
/// Extensions on the HTTP context
|
||||
type HttpContext with
|
||||
|
||||
/// The LiteDB database
|
||||
member this.Db = this.GetService<LiteDatabase> ()
|
||||
|
||||
/// The "sub" for the current user (None if no user is authenticated)
|
||||
member this.CurrentUser =
|
||||
this.User
|
||||
|> Option.ofObj
|
||||
|> Option.map (fun user -> user.Claims |> Seq.tryFind (fun u -> u.Type = ClaimTypes.NameIdentifier))
|
||||
|> Option.flatten
|
||||
|> Option.map (_.Value)
|
||||
|> Option.map (fun claim -> claim.Value)
|
||||
|
||||
/// The current user's ID
|
||||
// NOTE: this may raise if you don't run the request through the requireUser handler first
|
||||
@@ -79,8 +83,6 @@ type HttpContext with
|
||||
| None -> DateTimeZone.Utc
|
||||
|
||||
|
||||
open MyPrayerJournal.Data
|
||||
|
||||
/// Handler helpers
|
||||
[<AutoOpen>]
|
||||
module private Helpers =
|
||||
@@ -125,7 +127,7 @@ module private Helpers =
|
||||
let pageContext (ctx : HttpContext) pageTitle content = backgroundTask {
|
||||
let! hasSnoozed =
|
||||
match ctx.CurrentUser with
|
||||
| Some _ -> Journal.hasSnoozed ctx.UserId (ctx.Now())
|
||||
| Some _ -> Data.hasSnoozed ctx.UserId (ctx.Now ()) ctx.Db
|
||||
| None -> Task.FromResult false
|
||||
return
|
||||
{ IsAuthenticated = Option.isSome ctx.CurrentUser
|
||||
@@ -236,6 +238,7 @@ module Models =
|
||||
}
|
||||
|
||||
|
||||
open MyPrayerJournal.Data.Extensions
|
||||
open NodaTime.Text
|
||||
|
||||
/// Handlers for less-than-full-page HTML requests
|
||||
@@ -251,14 +254,14 @@ module Components =
|
||||
| Some snooze, _ when snooze < now -> true
|
||||
| _, Some hide when hide < now -> true
|
||||
| _, _ -> false
|
||||
let! journal = Journal.forUser ctx.UserId
|
||||
let! journal = Data.journalByUserId ctx.UserId ctx.Db
|
||||
let shown = journal |> List.filter shouldBeShown
|
||||
return! renderComponent [ Views.Journal.journalItems now ctx.TimeZone shown ] next ctx
|
||||
}
|
||||
|
||||
// GET /components/request-item/[req-id]
|
||||
let requestItem reqId : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
match! Journal.tryById (RequestId.ofString reqId) ctx.UserId with
|
||||
match! Data.tryJournalById (RequestId.ofString reqId) ctx.UserId ctx.Db with
|
||||
| Some req -> return! renderComponent [ Views.Request.reqListItem (ctx.Now ()) ctx.TimeZone req ] next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
@@ -269,8 +272,8 @@ module Components =
|
||||
|
||||
// GET /components/request/[req-id]/notes
|
||||
let notes requestId : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let! notes = Note.byRequestId (RequestId.ofString requestId) ctx.UserId
|
||||
return! renderComponent (Views.Request.notes (ctx.Now()) ctx.TimeZone notes) next ctx
|
||||
let! notes = Data.notesById (RequestId.ofString requestId) ctx.UserId ctx.Db
|
||||
return! renderComponent (Views.Request.notes (ctx.Now ()) ctx.TimeZone (List.ofArray notes)) next ctx
|
||||
}
|
||||
|
||||
// GET /components/request/[req-id]/snooze
|
||||
@@ -278,16 +281,13 @@ module Components =
|
||||
requireUser >=> renderComponent [ RequestId.ofString requestId |> Views.Journal.snooze ]
|
||||
|
||||
|
||||
/// / URL and documentation
|
||||
/// / URL
|
||||
module Home =
|
||||
|
||||
// GET /
|
||||
let home : HttpHandler =
|
||||
partialStatic "Welcome!" Views.Layout.home
|
||||
|
||||
// GET /docs
|
||||
let docs : HttpHandler =
|
||||
partialStatic "Documentation" Views.Docs.index
|
||||
|
||||
/// /journal URL
|
||||
module Journal =
|
||||
@@ -297,9 +297,9 @@ module Journal =
|
||||
let usr =
|
||||
ctx.User.Claims
|
||||
|> Seq.tryFind (fun c -> c.Type = ClaimTypes.GivenName)
|
||||
|> Option.map (_.Value)
|
||||
|> Option.map (fun c -> c.Value)
|
||||
|> Option.defaultValue "Your"
|
||||
let title = usr |> match usr with "Your" -> sprintf "%s" | _ -> sprintf "%s’s"
|
||||
let title = usr |> match usr with "Your" -> sprintf "%s" | _ -> sprintf "%s's"
|
||||
return! partial $"{title} Prayer Journal" (Views.Journal.journal usr) next ctx
|
||||
}
|
||||
|
||||
@@ -333,7 +333,7 @@ module Request =
|
||||
return! partial "Add Prayer Request"
|
||||
(Views.Request.edit (JournalRequest.ofRequestLite Request.empty) returnTo true) next ctx
|
||||
| _ ->
|
||||
match! Journal.tryById (RequestId.ofString requestId) ctx.UserId with
|
||||
match! Data.tryJournalById (RequestId.ofString requestId) ctx.UserId ctx.Db with
|
||||
| Some req ->
|
||||
debug ctx "Found - sending view"
|
||||
return! partial "Edit Prayer Request" (Views.Request.edit req returnTo false) next ctx
|
||||
@@ -344,42 +344,46 @@ module Request =
|
||||
|
||||
// PATCH /request/[req-id]/prayed
|
||||
let prayed requestId : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let db = ctx.Db
|
||||
let userId = ctx.UserId
|
||||
let reqId = RequestId.ofString requestId
|
||||
match! Journal.tryById reqId userId with
|
||||
match! Data.tryRequestById reqId userId db with
|
||||
| Some req ->
|
||||
let now = ctx.Now ()
|
||||
do! History.add reqId userId { AsOf = now; Status = Prayed; Text = None }
|
||||
do! Data.addHistory reqId userId { AsOf = now; Status = Prayed; Text = None } db
|
||||
let nextShow =
|
||||
match Recurrence.duration req.Recurrence with
|
||||
| 0L -> None
|
||||
| duration -> Some <| now.Plus (Duration.FromSeconds duration)
|
||||
do! Request.updateShowAfter reqId userId nextShow
|
||||
do! Data.updateShowAfter reqId userId nextShow db
|
||||
do! db.SaveChanges ()
|
||||
return! (withSuccessMessage "Request marked as prayed" >=> Components.journalItems) next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// POST /request/[req-id]/note
|
||||
/// POST /request/[req-id]/note
|
||||
let addNote requestId : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let db = ctx.Db
|
||||
let userId = ctx.UserId
|
||||
let reqId = RequestId.ofString requestId
|
||||
match! Request.existsById reqId userId with
|
||||
| true ->
|
||||
match! Data.tryRequestById reqId userId db with
|
||||
| Some _ ->
|
||||
let! notes = ctx.BindFormAsync<Models.NoteEntry> ()
|
||||
do! Note.add reqId userId { AsOf = ctx.Now(); Notes = notes.notes }
|
||||
do! Data.addNote reqId userId { AsOf = ctx.Now (); Notes = notes.notes } db
|
||||
do! db.SaveChanges ()
|
||||
return! (withSuccessMessage "Added Notes" >=> hideModal "notes" >=> created) next ctx
|
||||
| false -> return! Error.notFound next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// GET /requests/active
|
||||
let active : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let! reqs = Journal.forUser ctx.UserId
|
||||
let! reqs = Data.journalByUserId ctx.UserId ctx.Db
|
||||
return! partial "Active Requests" (Views.Request.active (ctx.Now ()) ctx.TimeZone reqs) next ctx
|
||||
}
|
||||
|
||||
// GET /requests/snoozed
|
||||
let snoozed : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let! reqs = Journal.forUser ctx.UserId
|
||||
let! reqs = Data.journalByUserId ctx.UserId ctx.Db
|
||||
let now = ctx.Now ()
|
||||
let snoozed = reqs
|
||||
|> List.filter (fun it -> defaultArg (it.SnoozedUntil |> Option.map (fun it -> it > now)) false)
|
||||
@@ -388,56 +392,62 @@ module Request =
|
||||
|
||||
// GET /requests/answered
|
||||
let answered : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let! reqs = Journal.answered ctx.UserId
|
||||
let! reqs = Data.answeredRequests ctx.UserId ctx.Db
|
||||
return! partial "Answered Requests" (Views.Request.answered (ctx.Now ()) ctx.TimeZone reqs) next ctx
|
||||
}
|
||||
|
||||
// GET /request/[req-id]/full
|
||||
let getFull requestId : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
match! Request.tryById (RequestId.ofString requestId) ctx.UserId with
|
||||
match! Data.tryFullRequestById (RequestId.ofString requestId) ctx.UserId ctx.Db with
|
||||
| Some req -> return! partial "Prayer Request" (Views.Request.full ctx.Clock ctx.TimeZone req) next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// PATCH /request/[req-id]/show
|
||||
let show requestId : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let db = ctx.Db
|
||||
let userId = ctx.UserId
|
||||
let reqId = RequestId.ofString requestId
|
||||
match! Request.existsById reqId userId with
|
||||
| true ->
|
||||
do! Request.updateShowAfter reqId userId None
|
||||
match! Data.tryRequestById reqId userId db with
|
||||
| Some _ ->
|
||||
do! Data.updateShowAfter reqId userId None db
|
||||
do! db.SaveChanges ()
|
||||
return! (withSuccessMessage "Request now shown" >=> Components.requestItem requestId) next ctx
|
||||
| false -> return! Error.notFound next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// PATCH /request/[req-id]/snooze
|
||||
let snooze requestId : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let db = ctx.Db
|
||||
let userId = ctx.UserId
|
||||
let reqId = RequestId.ofString requestId
|
||||
match! Request.existsById reqId userId with
|
||||
| true ->
|
||||
match! Data.tryRequestById reqId userId db with
|
||||
| Some _ ->
|
||||
let! until = ctx.BindFormAsync<Models.SnoozeUntil> ()
|
||||
let date =
|
||||
LocalDatePattern.CreateWithInvariantCulture("yyyy-MM-dd").Parse(until.until).Value
|
||||
.AtStartOfDayInZone(DateTimeZone.Utc)
|
||||
.ToInstant ()
|
||||
do! Request.updateSnoozed reqId userId (Some date)
|
||||
do! Data.updateSnoozed reqId userId (Some date) db
|
||||
do! db.SaveChanges ()
|
||||
return!
|
||||
(withSuccessMessage $"Request snoozed until {until.until}"
|
||||
>=> hideModal "snooze"
|
||||
>=> Components.journalItems) next ctx
|
||||
| false -> return! Error.notFound next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// PATCH /request/[req-id]/cancel-snooze
|
||||
let cancelSnooze requestId : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let db = ctx.Db
|
||||
let userId = ctx.UserId
|
||||
let reqId = RequestId.ofString requestId
|
||||
match! Request.existsById reqId userId with
|
||||
| true ->
|
||||
do! Request.updateSnoozed reqId userId None
|
||||
match! Data.tryRequestById reqId userId db with
|
||||
| Some _ ->
|
||||
do! Data.updateSnoozed reqId userId None db
|
||||
do! db.SaveChanges ()
|
||||
return! (withSuccessMessage "Request unsnoozed" >=> Components.requestItem requestId) next ctx
|
||||
| false -> return! Error.notFound next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
/// Derive a recurrence from its representation in the form
|
||||
@@ -448,6 +458,7 @@ module Request =
|
||||
// POST /request
|
||||
let add : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let! form = ctx.BindModelAsync<Models.Request> ()
|
||||
let db = ctx.Db
|
||||
let userId = ctx.UserId
|
||||
let now = ctx.Now ()
|
||||
let req =
|
||||
@@ -457,14 +468,15 @@ module Request =
|
||||
EnteredOn = now
|
||||
ShowAfter = None
|
||||
Recurrence = parseRecurrence form
|
||||
History = [
|
||||
History = [|
|
||||
{ AsOf = now
|
||||
Status = Created
|
||||
Text = Some form.requestText
|
||||
}
|
||||
]
|
||||
|]
|
||||
}
|
||||
do! Request.add req
|
||||
Data.addRequest req db
|
||||
do! db.SaveChanges ()
|
||||
Messages.pushSuccess ctx "Added prayer request" "/journal"
|
||||
return! seeOther "/journal" next ctx
|
||||
}
|
||||
@@ -472,24 +484,25 @@ module Request =
|
||||
// PATCH /request
|
||||
let update : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let! form = ctx.BindModelAsync<Models.Request> ()
|
||||
let db = ctx.Db
|
||||
let userId = ctx.UserId
|
||||
// TODO: update the instance and save rather than all these little updates
|
||||
match! Journal.tryById (RequestId.ofString form.requestId) userId with
|
||||
match! Data.tryJournalById (RequestId.ofString form.requestId) userId db with
|
||||
| Some req ->
|
||||
// update recurrence if changed
|
||||
let recur = parseRecurrence form
|
||||
match recur = req.Recurrence with
|
||||
| true -> ()
|
||||
| false ->
|
||||
do! Request.updateRecurrence req.RequestId userId recur
|
||||
do! Data.updateRecurrence req.RequestId userId recur db
|
||||
match recur with
|
||||
| Immediate -> do! Request.updateShowAfter req.RequestId userId None
|
||||
| Immediate -> do! Data.updateShowAfter req.RequestId userId None db
|
||||
| _ -> ()
|
||||
// append history
|
||||
let upd8Text = form.requestText.Trim ()
|
||||
let text = if upd8Text = req.Text then None else Some upd8Text
|
||||
do! History.add req.RequestId userId
|
||||
{ AsOf = ctx.Now(); Status = (Option.get >> RequestAction.ofString) form.status; Text = text }
|
||||
do! Data.addHistory req.RequestId userId
|
||||
{ AsOf = ctx.Now (); Status = (Option.get >> RequestAction.ofString) form.status; Text = text } db
|
||||
do! db.SaveChanges ()
|
||||
let nextUrl =
|
||||
match form.returnTo with
|
||||
| "active" -> "/requests/active"
|
||||
@@ -526,45 +539,44 @@ let routes = [
|
||||
GET_HEAD [ route "/" Home.home ]
|
||||
subRoute "/components/" [
|
||||
GET_HEAD [
|
||||
route "journal-items" Components.journalItems // done
|
||||
routef "request/%s/add-notes" Components.addNotes // done
|
||||
route "journal-items" Components.journalItems
|
||||
routef "request/%s/add-notes" Components.addNotes
|
||||
routef "request/%s/item" Components.requestItem
|
||||
routef "request/%s/notes" Components.notes // done
|
||||
routef "request/%s/snooze" Components.snooze // done
|
||||
routef "request/%s/notes" Components.notes
|
||||
routef "request/%s/snooze" Components.snooze
|
||||
]
|
||||
]
|
||||
GET_HEAD [ route "/docs" Home.docs ] // done
|
||||
GET_HEAD [ route "/journal" Journal.journal ] // done
|
||||
GET_HEAD [ route "/journal" Journal.journal ]
|
||||
subRoute "/legal/" [
|
||||
GET_HEAD [
|
||||
route "privacy-policy" Legal.privacyPolicy // done
|
||||
route "terms-of-service" Legal.termsOfService // done
|
||||
route "privacy-policy" Legal.privacyPolicy
|
||||
route "terms-of-service" Legal.termsOfService
|
||||
]
|
||||
]
|
||||
subRoute "/request" [
|
||||
GET_HEAD [
|
||||
routef "/%s/edit" Request.edit // done
|
||||
routef "/%s/full" Request.getFull // done
|
||||
route "s/active" Request.active // done
|
||||
route "s/answered" Request.answered // done
|
||||
route "s/snoozed" Request.snoozed // done
|
||||
routef "/%s/edit" Request.edit
|
||||
routef "/%s/full" Request.getFull
|
||||
route "s/active" Request.active
|
||||
route "s/answered" Request.answered
|
||||
route "s/snoozed" Request.snoozed
|
||||
]
|
||||
PATCH [
|
||||
route "" Request.update // done
|
||||
route "" Request.update
|
||||
routef "/%s/cancel-snooze" Request.cancelSnooze
|
||||
routef "/%s/prayed" Request.prayed // done
|
||||
routef "/%s/prayed" Request.prayed
|
||||
routef "/%s/show" Request.show
|
||||
routef "/%s/snooze" Request.snooze // done
|
||||
routef "/%s/snooze" Request.snooze
|
||||
]
|
||||
POST [
|
||||
route "" Request.add // done
|
||||
routef "/%s/note" Request.addNote // done
|
||||
route "" Request.add
|
||||
routef "/%s/note" Request.addNote
|
||||
]
|
||||
]
|
||||
subRoute "/user/" [
|
||||
GET_HEAD [
|
||||
route "log-off" User.logOff // done
|
||||
route "log-on" User.logOn // done
|
||||
route "log-off" User.logOff
|
||||
route "log-on" User.logOn
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Version>3.4</Version>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<Version>3.2</Version>
|
||||
<DebugType>embedded</DebugType>
|
||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||
<PublishSingleFile>false</PublishSingleFile>
|
||||
<SelfContained>false</SelfContained>
|
||||
<NoWarn>3391</NoWarn>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="Domain.fs" />
|
||||
@@ -16,21 +15,19 @@
|
||||
<Compile Include="Views/Layout.fs" />
|
||||
<Compile Include="Views/Legal.fs" />
|
||||
<Compile Include="Views/Request.fs" />
|
||||
<Compile Include="Views\Docs.fs" />
|
||||
<Compile Include="Handlers.fs" />
|
||||
<Compile Include="Program.fs" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BitBadger.Documents.Postgres" Version="3.1.0" />
|
||||
<PackageReference Include="FSharp.SystemTextJson" Version="1.3.13" />
|
||||
<PackageReference Include="FSharp.SystemTextJson" Version="1.1.23" />
|
||||
<PackageReference Include="FunctionalCuid" Version="1.0.0" />
|
||||
<PackageReference Include="Giraffe" Version="6.4.0" />
|
||||
<PackageReference Include="Giraffe.Htmx" Version="1.9.12" />
|
||||
<PackageReference Include="Giraffe.ViewEngine.Htmx" Version="1.9.12" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.6" />
|
||||
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.2.0" />
|
||||
<PackageReference Include="Npgsql.NodaTime" Version="8.0.3" />
|
||||
<PackageReference Update="FSharp.Core" Version="8.0.300" />
|
||||
<PackageReference Include="Giraffe" Version="6.0.0" />
|
||||
<PackageReference Include="Giraffe.Htmx" Version="1.9.2" />
|
||||
<PackageReference Include="Giraffe.ViewEngine.Htmx" Version="1.9.2" />
|
||||
<PackageReference Include="LiteDB" Version="5.0.16" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="7.0.5" />
|
||||
<PackageReference Include="NodaTime" Version="3.1.2" />
|
||||
<PackageReference Update="FSharp.Core" Version="7.0.300" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="wwwroot\" />
|
||||
|
||||
+109
-51
@@ -1,62 +1,99 @@
|
||||
module MyPrayerJournal.Api
|
||||
|
||||
open Microsoft.AspNetCore.Http
|
||||
open Microsoft.AspNetCore.Builder
|
||||
open Microsoft.AspNetCore.Hosting
|
||||
open System.IO
|
||||
|
||||
/// Configuration functions for the application
|
||||
module Configure =
|
||||
|
||||
/// Configure the content root
|
||||
let contentRoot root =
|
||||
WebApplicationOptions (ContentRootPath = root) |> WebApplication.CreateBuilder
|
||||
|
||||
|
||||
open Microsoft.Extensions.Configuration
|
||||
|
||||
/// Configure the application configuration
|
||||
let appConfiguration (bldr : WebApplicationBuilder) =
|
||||
bldr.Configuration
|
||||
.SetBasePath(bldr.Environment.ContentRootPath)
|
||||
.AddJsonFile("appsettings.json", optional = false, reloadOnChange = true)
|
||||
.AddJsonFile($"appsettings.{bldr.Environment.EnvironmentName}.json", optional = true, reloadOnChange = true)
|
||||
.AddEnvironmentVariables ()
|
||||
|> ignore
|
||||
bldr
|
||||
|
||||
|
||||
open Microsoft.AspNetCore.Server.Kestrel.Core
|
||||
|
||||
/// Configure Kestrel from appsettings.json
|
||||
let kestrel (bldr : WebApplicationBuilder) =
|
||||
let kestrelOpts (ctx : WebHostBuilderContext) (opts : KestrelServerOptions) =
|
||||
(ctx.Configuration.GetSection >> opts.Configure >> ignore) "Kestrel"
|
||||
bldr.WebHost.UseKestrel().ConfigureKestrel kestrelOpts |> ignore
|
||||
bldr
|
||||
|
||||
|
||||
/// Configure the web root directory
|
||||
let webRoot pathSegments (bldr : WebApplicationBuilder) =
|
||||
Array.concat [ [| bldr.Environment.ContentRootPath |]; pathSegments ]
|
||||
|> (Path.Combine >> bldr.WebHost.UseWebRoot >> ignore)
|
||||
bldr
|
||||
|
||||
|
||||
open Microsoft.Extensions.Logging
|
||||
open Microsoft.Extensions.Hosting
|
||||
|
||||
/// Configure logging
|
||||
let logging (bldr : WebApplicationBuilder) =
|
||||
if bldr.Environment.IsDevelopment () then bldr.Logging.AddFilter (fun l -> l > LogLevel.Information) |> ignore
|
||||
bldr.Logging.AddConsole().AddDebug() |> ignore
|
||||
bldr
|
||||
|
||||
|
||||
open Giraffe
|
||||
open LiteDB
|
||||
open Microsoft.AspNetCore.Authentication.Cookies
|
||||
open Microsoft.AspNetCore.Authentication.OpenIdConnect
|
||||
open Microsoft.AspNetCore.Http
|
||||
open Microsoft.Extensions.DependencyInjection
|
||||
open Microsoft.IdentityModel.Protocols.OpenIdConnect
|
||||
open NodaTime
|
||||
open System
|
||||
open System.Text.Json
|
||||
open System.Text.Json.Serialization
|
||||
open System.Threading.Tasks
|
||||
|
||||
/// Configure dependency injection
|
||||
let services (bldr : WebApplicationBuilder) =
|
||||
let sameSite (opts : CookieOptions) =
|
||||
match opts.SameSite, opts.Secure with
|
||||
| SameSiteMode.None, false -> opts.SameSite <- SameSiteMode.Unspecified
|
||||
| _, _ -> ()
|
||||
|
||||
open Giraffe
|
||||
open Giraffe.EndpointRouting
|
||||
open Microsoft.AspNetCore.Authentication.Cookies
|
||||
open Microsoft.AspNetCore.Authentication.OpenIdConnect
|
||||
open Microsoft.AspNetCore.Builder
|
||||
open Microsoft.AspNetCore.HttpOverrides
|
||||
open Microsoft.Extensions.Configuration
|
||||
open Microsoft.Extensions.DependencyInjection
|
||||
open Microsoft.Extensions.Hosting
|
||||
open Microsoft.Extensions.Logging
|
||||
open Microsoft.IdentityModel.Protocols.OpenIdConnect
|
||||
open MyPrayerJournal.Data
|
||||
open NodaTime
|
||||
open System
|
||||
open System.Text.Json
|
||||
open System.Threading.Tasks
|
||||
|
||||
[<EntryPoint>]
|
||||
let main args =
|
||||
//use host = Configure.webHost [| "wwwroot" |] (Directory.GetCurrentDirectory ())
|
||||
//host.Run ()
|
||||
let builder = WebApplication.CreateBuilder args
|
||||
let _ = builder.Configuration.AddEnvironmentVariables "MPJ_"
|
||||
let svc = builder.Services
|
||||
let cfg = svc.BuildServiceProvider().GetRequiredService<IConfiguration>()
|
||||
|
||||
let _ = svc.AddRouting()
|
||||
let _ = svc.AddGiraffe()
|
||||
let _ = svc.AddSingleton<IClock> SystemClock.Instance
|
||||
let _ = svc.AddSingleton<IDateTimeZoneProvider> DateTimeZoneProviders.Tzdb
|
||||
let _ = svc.Configure<ForwardedHeadersOptions>(fun (opts : ForwardedHeadersOptions) ->
|
||||
opts.ForwardedHeaders <- ForwardedHeaders.XForwardedFor ||| ForwardedHeaders.XForwardedProto)
|
||||
let _ = bldr.Services.AddRouting ()
|
||||
let _ = bldr.Services.AddGiraffe ()
|
||||
let _ = bldr.Services.AddSingleton<IClock> SystemClock.Instance
|
||||
let _ = bldr.Services.AddSingleton<IDateTimeZoneProvider> DateTimeZoneProviders.Tzdb
|
||||
|
||||
let _ =
|
||||
svc.Configure<CookiePolicyOptions>(fun (opts : CookiePolicyOptions) ->
|
||||
bldr.Services.Configure<CookiePolicyOptions>(fun (opts : CookiePolicyOptions) ->
|
||||
opts.MinimumSameSitePolicy <- SameSiteMode.Unspecified
|
||||
opts.OnAppendCookie <- fun ctx -> sameSite ctx.CookieOptions
|
||||
opts.OnDeleteCookie <- fun ctx -> sameSite ctx.CookieOptions)
|
||||
let _ =
|
||||
svc.AddAuthentication(fun opts ->
|
||||
bldr.Services.AddAuthentication(fun opts ->
|
||||
opts.DefaultAuthenticateScheme <- CookieAuthenticationDefaults.AuthenticationScheme
|
||||
opts.DefaultSignInScheme <- CookieAuthenticationDefaults.AuthenticationScheme
|
||||
opts.DefaultChallengeScheme <- CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie()
|
||||
.AddOpenIdConnect("Auth0", fun opts ->
|
||||
// Configure OIDC with Auth0 options from configuration
|
||||
let auth0 = cfg.GetSection "Auth0"
|
||||
opts.Authority <- $"""https://{auth0["Domain"]}/"""
|
||||
opts.ClientId <- auth0["Id"]
|
||||
opts.ClientSecret <- auth0["Secret"]
|
||||
let cfg = bldr.Configuration.GetSection "Auth0"
|
||||
opts.Authority <- $"""https://{cfg["Domain"]}/"""
|
||||
opts.ClientId <- cfg["Id"]
|
||||
opts.ClientSecret <- cfg["Secret"]
|
||||
opts.ResponseType <- OpenIdConnectResponseType.Code
|
||||
|
||||
opts.Scope.Clear ()
|
||||
@@ -81,31 +118,52 @@ let main args =
|
||||
$"{request.Scheme}://{request.Host.Value}{request.PathBase.Value}{redirUri}"
|
||||
| false -> redirUri
|
||||
Uri.EscapeDataString $"&returnTo={finalRedirUri}"
|
||||
ctx.Response.Redirect $"""https://{auth0["Domain"]}/v2/logout?client_id={auth0["Id"]}{returnTo}"""
|
||||
ctx.Response.Redirect $"""https://{cfg["Domain"]}/v2/logout?client_id={cfg["Id"]}{returnTo}"""
|
||||
ctx.HandleResponse ()
|
||||
Task.CompletedTask
|
||||
opts.Events.OnRedirectToIdentityProvider <- fun ctx ->
|
||||
let uri = UriBuilder ctx.ProtocolMessage.RedirectUri
|
||||
uri.Scheme <- auth0["Scheme"]
|
||||
uri.Port <- int auth0["Port"]
|
||||
ctx.ProtocolMessage.RedirectUri <- string uri
|
||||
let bldr = UriBuilder ctx.ProtocolMessage.RedirectUri
|
||||
bldr.Scheme <- cfg["Scheme"]
|
||||
bldr.Port <- int cfg["Port"]
|
||||
ctx.ProtocolMessage.RedirectUri <- string bldr
|
||||
Task.CompletedTask)
|
||||
|
||||
let _ = svc.AddSingleton<JsonSerializerOptions> Json.options
|
||||
let _ = svc.AddSingleton<Json.ISerializer>(SystemTextJson.Serializer Json.options)
|
||||
let _ = Connection.setUp cfg |> Async.AwaitTask |> Async.RunSynchronously
|
||||
let jsonOptions = JsonSerializerOptions ()
|
||||
jsonOptions.Converters.Add (JsonFSharpConverter ())
|
||||
let db = new LiteDatabase (bldr.Configuration.GetConnectionString "db")
|
||||
Data.Startup.ensureDb db
|
||||
let _ = bldr.Services.AddSingleton jsonOptions
|
||||
let _ = bldr.Services.AddSingleton<Json.ISerializer, SystemTextJson.Serializer> ()
|
||||
let _ = bldr.Services.AddSingleton<LiteDatabase> db
|
||||
|
||||
if builder.Environment.IsDevelopment() then builder.Logging.AddFilter(fun l -> l > LogLevel.Information) |> ignore
|
||||
let _ = builder.Logging.AddConsole().AddDebug() |> ignore
|
||||
bldr.Build ()
|
||||
|
||||
use app = builder.Build()
|
||||
|
||||
open Giraffe.EndpointRouting
|
||||
|
||||
/// Configure the web application
|
||||
let application (app : WebApplication) =
|
||||
let _ = app.UseStaticFiles ()
|
||||
let _ = app.UseCookiePolicy ()
|
||||
let _ = app.UseRouting ()
|
||||
let _ = app.UseAuthentication ()
|
||||
let _ = app.UseGiraffeErrorHandler Handlers.Error.error
|
||||
let _ = app.UseEndpoints (fun e -> e.MapGiraffeEndpoints Handlers.routes)
|
||||
app
|
||||
|
||||
app.Run()
|
||||
/// Compose all the configurations into one
|
||||
let webHost pathSegments =
|
||||
contentRoot
|
||||
>> appConfiguration
|
||||
>> kestrel
|
||||
>> webRoot pathSegments
|
||||
>> logging
|
||||
>> services
|
||||
>> application
|
||||
|
||||
|
||||
[<EntryPoint>]
|
||||
let main _ =
|
||||
use host = Configure.webHost [| "wwwroot" |] (Directory.GetCurrentDirectory ())
|
||||
host.Run ()
|
||||
0
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
module MyPrayerJournal.Views.Docs
|
||||
|
||||
open Giraffe.ViewEngine
|
||||
|
||||
/// The "About myPrayerJournal" section
|
||||
let private about = [
|
||||
h3 [ _class "mb-3 mt-4" ] [ rawText "About myPrayerJournal" ]
|
||||
p [] [
|
||||
rawText "Journaling has a long history; it helps people remember what happened, and the act of writing helps "
|
||||
rawText "people think about what happened and process it. A prayer journal is not a new concept; it helps you "
|
||||
rawText "keep track of the requests for which you've prayed, you can use it to pray over things repeatedly, "
|
||||
rawText "and you can write the result when the answer comes "; em [] [ rawText "(or it was “no”)" ]
|
||||
rawText "."
|
||||
]
|
||||
p [] [
|
||||
rawText "myPrayerJournal was borne of out of a personal desire "
|
||||
a [ _href "https://daniel.summershome.org"; _target "_blank"; _rel "noopener" ] [ rawText "Daniel" ]
|
||||
rawText " had to have something that would help him with his prayer life. When it’s time to pray, "
|
||||
rawText "it’s not really time to use an app, so the design goal here is to keep it simple and "
|
||||
rawText "unobtrusive. It will also help eliminate some of the downsides to a paper prayer journal, like not "
|
||||
rawText "remembering whether you’ve prayed for a request, or running out of room to write another update "
|
||||
rawText "on one."
|
||||
]
|
||||
]
|
||||
|
||||
/// The "Signing Up" section
|
||||
let private signUp = [
|
||||
h3 [ _class "mb-3 mt-4" ] [ rawText "Signing Up" ]
|
||||
p [] [
|
||||
rawText "myPrayerJournal uses login services using Google or Microsoft accounts. The only information the "
|
||||
rawText "application stores in its database is your user Id token it receives from these services, so there "
|
||||
rawText "are no permissions you should have to accept from these provider other than establishing that you can "
|
||||
rawText "log on with that account. Because of this, you’ll want to pick the same one each time; the "
|
||||
rawText "tokens between the two accounts are different, even if you use the same e-mail address to log on to "
|
||||
rawText "both."
|
||||
]
|
||||
]
|
||||
|
||||
/// The "Your Prayer Journal" section
|
||||
let private yourJournal = [
|
||||
h3 [ _class "mb-3 mt-4" ] [ rawText "Your Prayer Journal" ]
|
||||
p [] [
|
||||
rawText "Your current requests will be presented in columns (usually three, but it could be more or less, "
|
||||
rawText "depending on the size of your screen or device). Each request is in its own card, and the buttons at "
|
||||
rawText "the top of each card apply to that request. The last line of each request also tells you how long it "
|
||||
rawText "has been since anything has been done on that request. Any time you see something like “a few "
|
||||
rawText "minutes ago,” you can hover over that to see the actual date/time the action was taken."
|
||||
]
|
||||
]
|
||||
|
||||
/// The "Adding a Request" section
|
||||
let private addRequest = [
|
||||
h3 [ _class "mb-3 mt-4" ] [ rawText "Adding a Request" ]
|
||||
p [] [
|
||||
rawText "To add a request, click the “Add a New Request” button at the top of your journal. Then, "
|
||||
rawText "enter the text of the request as you see fit; there is no right or wrong way, and you are the only "
|
||||
rawText "person who will see the text you enter. When you save the request, it will go to the bottom of the "
|
||||
rawText "list of requests."
|
||||
]
|
||||
]
|
||||
|
||||
/// The "Setting Request Recurrence" section
|
||||
let private setRecurrence = [
|
||||
h3 [ _class "mb-3 mt-4" ] [ rawText "Setting Request Recurrence" ]
|
||||
p [] [
|
||||
rawText "When you add or update a request, you can choose whether requests go to the bottom of the journal "
|
||||
rawText "once they have been marked “Prayed” or whether they will reappear after a delay. You can "
|
||||
rawText "set recurrence in terms of hours, days, or weeks, but it cannot be longer than 365 days. If you "
|
||||
rawText "decide you want a request to reappear sooner, you can skip the current delay; click the "
|
||||
rawText "“Active” menu link, find the request in the list (likely near the bottom), and click the "
|
||||
rawText "“Show Now” button."
|
||||
]
|
||||
]
|
||||
|
||||
/// The "Praying for Requests" section
|
||||
let private praying = [
|
||||
h3 [ _class "mb-3 mt-4" ] [ rawText "Praying for Requests" ]
|
||||
p [] [
|
||||
rawText "The first button for each request has a checkmark icon; clicking this button will mark the request as "
|
||||
rawText "“Prayed” and move it to the bottom of the list (or off, if you’ve set a recurrence "
|
||||
rawText "period for the request). This allows you, if you’re praying through your requests, to start at "
|
||||
rawText "the top left (with the request that it’s been the longest since you’ve prayed) and click "
|
||||
rawText "the button as you pray; when the request move below or away, the next-least-recently-prayed request "
|
||||
rawText "will take the top spot."
|
||||
]
|
||||
]
|
||||
|
||||
/// The "Editing Requests" section
|
||||
let private editing = [
|
||||
h3 [ _class "mb-3 mt-4" ] [ rawText "Editing Requests" ]
|
||||
p [] [
|
||||
rawText "The second button for each request has a pencil icon. This allows you to edit the text of the "
|
||||
rawText "request, pretty much the same way you entered it; it starts with the current text, and you can add to "
|
||||
rawText "it, modify it, or completely replace it. By default, updates will go in with an “Updated” "
|
||||
rawText "status; you have the option to also mark this update as “Prayed” or "
|
||||
rawText "“Answered”. Answered requests will drop off the journal list."
|
||||
]
|
||||
]
|
||||
|
||||
/// The "Adding Notes" section
|
||||
let private addNotes = [
|
||||
h3 [ _class "mb-3 mt-4" ] [ rawText "Adding Notes" ]
|
||||
p [] [
|
||||
rawText "The third button for each request has an icon that looks like a speech bubble with lines on it; this "
|
||||
rawText "lets you record notes about the request. If there is something you want to record that doesn’t "
|
||||
rawText "change the text of the request, this is the place to do it. For example, you may be praying for a "
|
||||
rawText "long-term health issue, and that person tells you that their status is the same; or, you may want to "
|
||||
rawText "record something God said to you while you were praying for that request."
|
||||
]
|
||||
]
|
||||
|
||||
/// The "Snoozing Requests" section
|
||||
let private snoozing = [
|
||||
h3 [ _class "mb-3 mt-4" ] [ rawText "Snoozing Requests" ]
|
||||
p [] [
|
||||
rawText "There may be a time where a request does not need to appear. The fourth button, with the clock icon, "
|
||||
rawText "allows you to snooze requests until the day you specify. Additionally, if you have any snoozed "
|
||||
rawText "requests, a “Snoozed” menu item will appear next to the “Journal” one; this "
|
||||
rawText "page allows you to see what requests are snoozed, and return them to your journal by canceling the "
|
||||
rawText "snooze."
|
||||
]
|
||||
]
|
||||
|
||||
/// The "Viewing a Request and Its History" section
|
||||
let private viewing = [
|
||||
h3 [ _class "mb-3 mt-4" ] [ rawText "Viewing a Request and Its History" ]
|
||||
p [] [
|
||||
rawText "myPrayerJournal tracks all of the actions related to a request; from the “Active” and "
|
||||
rawText "“Answered” menu links (and “Snoozed”, if it’s showing), there is a "
|
||||
rawText "“View Full Request” button. That page will show the current text of the request; how many "
|
||||
rawText "times it has been marked as prayed; how long it has been an active request; and a log of all updates, "
|
||||
rawText "prayers, and notes you have recorded. That log is listed from most recent to least recent; if you "
|
||||
rawText "want to read it chronologically, press the “End” key on your keyboard and read it from "
|
||||
rawText "the bottom up."
|
||||
]
|
||||
p [] [
|
||||
rawText "The “Active” link will show all requests that have not yet been marked answered, "
|
||||
rawText "including snoozed and recurring requests. If requests are snoozed, or in a recurrence period off the "
|
||||
rawText "journal, there will be a button where you can return the request to the list (either “Cancel "
|
||||
rawText "Snooze” or “Show Now”). The “Answered” link shows all requests that "
|
||||
rawText "have been marked answered. The “Snoozed” link only shows snoozed requests."
|
||||
]
|
||||
]
|
||||
|
||||
/// The "Final Notes" section
|
||||
let private finalNotes = [
|
||||
h3 [ _class "mb-3 mt-4" ] [ rawText "Final Notes" ]
|
||||
ul [] [
|
||||
li [] [
|
||||
rawText "If you encounter errors, please "
|
||||
a [ _href "https://git.bitbadger.solutions/bit-badger/myPrayerJournal/issues"; _target "_blank" ] [
|
||||
rawText "file an issue"
|
||||
]; rawText " (or "
|
||||
a [ _href "mailto:daniel@bitbadger.solutions?subject=myPrayerJournal+Issue" ] [ rawText "e-mail Daniel" ]
|
||||
rawText " if you do not have an account on that server) with as much detail as possible. You can also "
|
||||
rawText "provide suggestions, or browse the list of currently open issues."
|
||||
]
|
||||
li [] [
|
||||
rawText "Prayer requests and their history are securely backed up nightly along with other Bit Badger "
|
||||
rawText "Solutions data."
|
||||
]
|
||||
li [] [
|
||||
rawText "Prayer changes things - most of all, the one doing the praying. I pray that this tool enables you "
|
||||
rawText "to deepen and strengthen your prayer life."
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
/// The documentation page
|
||||
let index =
|
||||
article [ _class "container mt-3" ] [
|
||||
h2 [ _class "mb-3" ] [ rawText "Documentation" ]
|
||||
yield! about
|
||||
yield! signUp
|
||||
yield! yourJournal
|
||||
yield! addRequest
|
||||
yield! setRecurrence
|
||||
yield! praying
|
||||
yield! editing
|
||||
yield! addNotes
|
||||
yield! snoozing
|
||||
yield! viewing
|
||||
yield! finalNotes
|
||||
]
|
||||
@@ -19,30 +19,34 @@ type PageRenderContext =
|
||||
PageTitle : string
|
||||
|
||||
/// The content of the page
|
||||
Content: XmlNode }
|
||||
Content : XmlNode
|
||||
}
|
||||
|
||||
/// The home page
|
||||
let home =
|
||||
article [ _class "container mt-3" ]
|
||||
[ p [] [ rawText " " ]
|
||||
p []
|
||||
[ str "myPrayerJournal is a place where individuals can record their prayer requests, record that they "
|
||||
article [ _class "container mt-3" ] [
|
||||
p [] [ rawText " " ]
|
||||
p [] [
|
||||
str "myPrayerJournal is a place where individuals can record their prayer requests, record that they "
|
||||
str "prayed for them, update them as God moves in the situation, and record a final answer received on "
|
||||
str "that request. It also allows individuals to review their answered prayers." ]
|
||||
p []
|
||||
[ str "This site is open and available to the general public. To get started, simply click the "
|
||||
str "that request. It also allows individuals to review their answered prayers."
|
||||
]
|
||||
p [] [
|
||||
str "This site is open and available to the general public. To get started, simply click the "
|
||||
rawText "“Log On” link above, and log on with either a Microsoft or Google account. You can "
|
||||
rawText "also learn more about the site at the “Docs” link, also above." ] ]
|
||||
rawText "also learn more about the site at the “Docs” link, also above."
|
||||
]
|
||||
]
|
||||
|
||||
/// The default navigation bar, which will load the items on page load, and whenever a refresh event occurs
|
||||
let private navBar ctx =
|
||||
nav [ _class "navbar navbar-dark"; _roleNavigation ]
|
||||
[ div [ _class "container-fluid" ]
|
||||
[ pageLink
|
||||
"/" [ _class "navbar-brand" ]
|
||||
[ span [ _class "m" ] [ str "my" ]
|
||||
nav [ _class "navbar navbar-dark"; _roleNavigation ] [
|
||||
div [ _class "container-fluid" ] [
|
||||
pageLink "/" [ _class "navbar-brand" ] [
|
||||
span [ _class "m" ] [ str "my" ]
|
||||
span [ _class "p" ] [ str "Prayer" ]
|
||||
span [ _class "j" ] [ str "Journal" ] ]
|
||||
span [ _class "j" ] [ str "Journal" ]
|
||||
]
|
||||
seq {
|
||||
let navLink (matchUrl : string) =
|
||||
match ctx.CurrentUrl.StartsWith matchUrl with true -> [ _class "is-active-route" ] | false -> []
|
||||
@@ -54,72 +58,91 @@ let private navBar ctx =
|
||||
li [ _class "nav-item" ] [ navLink "/requests/answered" [ str "Answered" ] ]
|
||||
li [ _class "nav-item" ] [ a [ _href "/user/log-off" ] [ str "Log Off" ] ]
|
||||
else li [ _class "nav-item"] [ a [ _href "/user/log-on" ] [ str "Log On" ] ]
|
||||
li [ _class "nav-item" ] [ navLink "/docs" [ str "Docs" ] ]
|
||||
li [ _class "nav-item" ] [
|
||||
a [ _href "https://docs.prayerjournal.me"; _target "_blank"; _rel "noopener" ] [ str "Docs" ]
|
||||
]
|
||||
}
|
||||
|> List.ofSeq
|
||||
|> ul [ _class "navbar-nav me-auto d-flex flex-row" ] ] ]
|
||||
|> ul [ _class "navbar-nav me-auto d-flex flex-row" ]
|
||||
]
|
||||
]
|
||||
|
||||
/// The title tag with the application name appended
|
||||
let titleTag ctx =
|
||||
title [] [ rawText ctx.PageTitle; rawText " « myPrayerJournal" ]
|
||||
title [] [ str ctx.PageTitle; rawText " « myPrayerJournal" ]
|
||||
|
||||
/// The HTML `head` element
|
||||
let htmlHead ctx =
|
||||
head [ _lang "en" ]
|
||||
[ meta [ _name "viewport"; _content "width=device-width, initial-scale=1" ]
|
||||
head [ _lang "en" ] [
|
||||
meta [ _name "viewport"; _content "width=device-width, initial-scale=1" ]
|
||||
meta [ _name "description"; _content "Online prayer journal - free w/Google or Microsoft account" ]
|
||||
titleTag ctx
|
||||
link [ _href "https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
|
||||
link [ _href "https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/css/bootstrap.min.css"
|
||||
_rel "stylesheet"
|
||||
_integrity "sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN"
|
||||
_integrity "sha384-gH2yIJqKdNHPEq0n4Mqa/HGKIhSkIHeL5AyhkYV8i59U5AR6csBvApHHNl/vI1Bx"
|
||||
_crossorigin "anonymous" ]
|
||||
link [ _href "https://fonts.googleapis.com/icon?family=Material+Icons"; _rel "stylesheet" ]
|
||||
link [ _href "/style/style.css"; _rel "stylesheet" ] ]
|
||||
link [ _href "/style/style.css"; _rel "stylesheet" ]
|
||||
]
|
||||
|
||||
/// Element used to display toasts
|
||||
let toaster =
|
||||
div [ _ariaLive "polite"; _ariaAtomic "true"; _id "toastHost" ]
|
||||
[ div [ _class "toast-container position-absolute p-3 bottom-0 end-0"; _id "toasts" ] [] ]
|
||||
div [ _ariaLive "polite"; _ariaAtomic "true"; _id "toastHost" ] [
|
||||
div [ _class "toast-container position-absolute p-3 bottom-0 end-0"; _id "toasts" ] []
|
||||
]
|
||||
|
||||
/// The page's `footer` element
|
||||
let htmlFoot =
|
||||
footer [ _class "container-fluid" ]
|
||||
[ p [ _class "text-muted text-end" ]
|
||||
[ str $"myPrayerJournal {version}"
|
||||
footer [ _class "container-fluid" ] [
|
||||
p [ _class "text-muted text-end" ] [
|
||||
str $"myPrayerJournal {version}"
|
||||
br []
|
||||
em []
|
||||
[ small []
|
||||
[ pageLink "/legal/privacy-policy" [] [ str "Privacy Policy" ]
|
||||
em [] [
|
||||
small [] [
|
||||
pageLink "/legal/privacy-policy" [] [ str "Privacy Policy" ]
|
||||
rawText " • "
|
||||
pageLink "/legal/terms-of-service" [] [ str "Terms of Service" ]
|
||||
rawText " • "
|
||||
a [ _href "https://git.bitbadger.solutions/bit-badger/myPrayerJournal"
|
||||
_target "_blank"
|
||||
_rel "noopener" ] [ str "Developed" ]
|
||||
a [ _href "https://github.com/bit-badger/myprayerjournal"; _target "_blank"; _rel "noopener" ] [
|
||||
str "Developed"
|
||||
]
|
||||
str " and hosted by "
|
||||
a [ _href "https://bitbadger.solutions"; _target "_blank"; _rel "noopener" ]
|
||||
[ str "Bit Badger Solutions" ] ] ] ]
|
||||
a [ _href "https://bitbadger.solutions"; _target "_blank"; _rel "noopener" ] [
|
||||
str "Bit Badger Solutions"
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
Htmx.Script.minified
|
||||
script [] [ rawText "if (!htmx) document.write('<script src=\"/script/htmx.min.js\"><\/script>')" ]
|
||||
script [] [
|
||||
rawText "if (!htmx) document.write('<script src=\"/script/htmx.min.js\"><\/script>')"
|
||||
]
|
||||
script [ _async
|
||||
_src "https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"
|
||||
_integrity "sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL"
|
||||
_src "https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/js/bootstrap.bundle.min.js"
|
||||
_integrity "sha384-A3rJD856KowSb7dwlZdYEkO39Gagi7vIsF0jrRAoQmDKKtQBHUuLZ9AsSv4jD4Xa"
|
||||
_crossorigin "anonymous" ] []
|
||||
script []
|
||||
[ rawText "setTimeout(function () { "
|
||||
script [] [
|
||||
rawText "setTimeout(function () { "
|
||||
rawText "if (!bootstrap) document.write('<script src=\"/script/bootstrap.bundle.min.js\"><\/script>') "
|
||||
rawText "}, 2000)" ]
|
||||
script [ _src "/script/mpj.js" ] [] ]
|
||||
rawText "}, 2000)"
|
||||
]
|
||||
script [ _src "/script/mpj.js" ] []
|
||||
]
|
||||
|
||||
/// Create the full view of the page
|
||||
let view ctx =
|
||||
html [ _lang "en" ]
|
||||
[ htmlHead ctx
|
||||
body []
|
||||
[ section [ _id "top"; _ariaLabel "Top navigation" ] [ navBar ctx; main [ _roleMain ] [ ctx.Content ] ]
|
||||
html [ _lang "en" ] [
|
||||
htmlHead ctx
|
||||
body [] [
|
||||
section [ _id "top"; _ariaLabel "Top navigation" ] [ navBar ctx; main [ _roleMain ] [ ctx.Content ] ]
|
||||
toaster
|
||||
htmlFoot ] ]
|
||||
htmlFoot
|
||||
]
|
||||
]
|
||||
|
||||
/// Create a partial view
|
||||
let partial ctx =
|
||||
html [ _lang "en" ] [ head [] [ titleTag ctx ]; body [] [ navBar ctx; main [ _roleMain ] [ ctx.Content ] ] ]
|
||||
html [ _lang "en" ] [
|
||||
head [] [ titleTag ctx ]
|
||||
body [] [ navBar ctx; main [ _roleMain ] [ ctx.Content ] ]
|
||||
]
|
||||
|
||||
@@ -77,31 +77,28 @@ let full (clock : IClock) tz (req : Request) =
|
||||
let now = clock.GetCurrentInstant ()
|
||||
let answered =
|
||||
req.History
|
||||
|> Seq.ofList
|
||||
|> Seq.filter History.isAnswered
|
||||
|> Seq.tryHead
|
||||
|> Option.map (_.AsOf)
|
||||
let prayed = (req.History |> List.filter History.isPrayed |> List.length).ToString "N0"
|
||||
|> Array.filter History.isAnswered
|
||||
|> Array.tryHead
|
||||
|> Option.map (fun x -> x.AsOf)
|
||||
let prayed = (req.History |> Array.filter History.isPrayed |> Array.length).ToString "N0"
|
||||
let daysOpen =
|
||||
let asOf = defaultArg answered now
|
||||
((asOf - (req.History |> List.filter History.isCreated |> List.head).AsOf).TotalDays |> int).ToString "N0"
|
||||
((asOf - (req.History |> Array.filter History.isCreated |> Array.head).AsOf).TotalDays |> int).ToString "N0"
|
||||
let lastText =
|
||||
req.History
|
||||
|> Seq.ofList
|
||||
|> Seq.filter (fun h -> Option.isSome h.Text)
|
||||
|> Seq.sortByDescending (_.AsOf)
|
||||
|> Seq.map (fun h -> Option.get h.Text)
|
||||
|> Seq.head
|
||||
|> Array.filter (fun h -> Option.isSome h.Text)
|
||||
|> Array.sortByDescending (fun h -> h.AsOf)
|
||||
|> Array.map (fun h -> Option.get h.Text)
|
||||
|> Array.head
|
||||
// The history log including notes (and excluding the final entry for answered requests)
|
||||
let log =
|
||||
let toDisp (h : History) = {| asOf = h.AsOf; text = h.Text; status = RequestAction.toString h.Status |}
|
||||
let all =
|
||||
req.Notes
|
||||
|> Seq.ofList
|
||||
|> Seq.map (fun n -> {| asOf = n.AsOf; text = Some n.Notes; status = "Notes" |})
|
||||
|> Seq.append (req.History |> List.map toDisp)
|
||||
|> Seq.sortByDescending (_.asOf)
|
||||
|> List.ofSeq
|
||||
|> Array.map (fun n -> {| asOf = n.AsOf; text = Some n.Notes; status = "Notes" |})
|
||||
|> Array.append (req.History |> Array.map toDisp)
|
||||
|> Array.sortByDescending (fun it -> it.asOf)
|
||||
|> List.ofArray
|
||||
// Skip the first entry for answered requests; that info is already displayed
|
||||
match answered with Some _ -> all.Tail | None -> all
|
||||
article [ _class "container mt-3" ] [
|
||||
|
||||
@@ -1,2 +1,12 @@
|
||||
{
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"db": "Filename=./mpj.db"
|
||||
},
|
||||
"Kestrel": {
|
||||
"EndPoints": {
|
||||
"Http": {
|
||||
"Url": "http://localhost:3000"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+4
-3
File diff suppressed because one or more lines are too long
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+198
-236
File diff suppressed because it is too large
Load Diff
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"name": "bit-badger/my-prayer-journal",
|
||||
"minimum-stability": "beta",
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"ext-pdo": "*",
|
||||
"ext-sqlite3": "*",
|
||||
"bit-badger/pdo-document": "^1",
|
||||
"visus/cuid2": "^4",
|
||||
"guzzlehttp/guzzle": "^7.8",
|
||||
"guzzlehttp/psr7": "^2.6",
|
||||
"http-interop/http-factory-guzzle": "^1.2",
|
||||
"auth0/auth0-php": "^8.11",
|
||||
"vlucas/phpdotenv": "^5.6"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"MyPrayerJournal\\": "lib/"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
"php-http/discovery": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
use BitBadger\PDODocument\{Configuration, Custom, Definition, Document, Mode};
|
||||
use BitBadger\PDODocument\Mapper\ArrayMapper;
|
||||
use MyPrayerJournal\{History, Note, Recurrence, RecurrencePeriod, Request, RequestAction, Table};
|
||||
|
||||
require 'start.php';
|
||||
|
||||
echo 'Retrieving v3 requests...' . PHP_EOL;
|
||||
|
||||
Configuration::resetPDO();
|
||||
Configuration::$pdoDSN = 'pgsql:host=localhost;user=mpj;password=devpassword;dbname=mpj';
|
||||
$reqs = Custom::array('SELECT data FROM mpj.request', [], new ArrayMapper());
|
||||
|
||||
echo 'Found ' . sizeof($reqs) . ' requests; migrating to v4...' . PHP_EOL;
|
||||
|
||||
Configuration::resetPDO();
|
||||
Configuration::$mode = Mode::SQLite;
|
||||
Configuration::$pdoDSN = 'sqlite:./data/mpj.db';
|
||||
|
||||
Definition::ensureTable(Table::REQUEST);
|
||||
|
||||
/** Convert dates to the same format */
|
||||
function convertDate(string $date): string
|
||||
{
|
||||
return (new DateTimeImmutable($date))->format('c');
|
||||
}
|
||||
|
||||
foreach ($reqs as $reqJson) {
|
||||
$req = json_decode($reqJson['data']);
|
||||
$notes = array_map(fn(stdClass $note) => new Note(convertDate($note->asOf), $note->notes), $req->notes ?? []);
|
||||
$history = array_map(fn(stdClass $hist) =>
|
||||
new History(
|
||||
asOf: convertDate($hist->asOf),
|
||||
action: RequestAction::from($hist->status),
|
||||
text: property_exists($hist, 'text') ? $hist->text : null),
|
||||
$req->history);
|
||||
$recurParts = explode(' ', $req->recurrence);
|
||||
$recurPeriod = RecurrencePeriod::from(end($recurParts));
|
||||
$recur = match ($recurPeriod) {
|
||||
RecurrencePeriod::Immediate => new Recurrence(RecurrencePeriod::Immediate),
|
||||
default => new Recurrence($recurPeriod, (int)$recurParts[0])
|
||||
};
|
||||
$v4Req = new Request(
|
||||
id: $req->id,
|
||||
enteredOn: convertDate($req->enteredOn),
|
||||
userId: $req->userId,
|
||||
snoozedUntil: property_exists($req, 'snoozedUntil') ? convertDate($req->snoozedUntil) : null,
|
||||
showAfter: property_exists($req, 'showAfter') ? convertDate($req->showAfter) : null,
|
||||
recurrence: $recur,
|
||||
history: $history,
|
||||
notes: $notes);
|
||||
Document::insert(Table::REQUEST, $v4Req);
|
||||
}
|
||||
|
||||
echo PHP_EOL . 'done' . PHP_EOL;
|
||||
@@ -1,79 +0,0 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace MyPrayerJournal;
|
||||
|
||||
use Auth0\SDK\Auth0;
|
||||
use Auth0\SDK\Exception\ConfigurationException;
|
||||
|
||||
class Auth
|
||||
{
|
||||
private static ?Auth0 $auth0 = null;
|
||||
|
||||
public static function client(): Auth0
|
||||
{
|
||||
if (is_null(self::$auth0)) {
|
||||
self::$auth0 = new Auth0([
|
||||
'domain' => $_ENV['AUTH0_DOMAIN'],
|
||||
'clientId' => $_ENV['AUTH0_CLIENT_ID'],
|
||||
'clientSecret' => $_ENV['AUTH0_CLIENT_SECRET'],
|
||||
'cookieSecret' => $_ENV['AUTH0_COOKIE_SECRET']
|
||||
]);
|
||||
}
|
||||
return self::$auth0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the logged on user information
|
||||
*
|
||||
* @return array|null The user information (null if no user is logged on)
|
||||
*/
|
||||
public static function user(): ?array
|
||||
{
|
||||
return self::client()->getUser();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate a log on with Auth0
|
||||
*
|
||||
* @throws ConfigurationException If the Auth0 client is not configured correctly
|
||||
*/
|
||||
public static function logOn(): never
|
||||
{
|
||||
$params = match (true) {
|
||||
$_SERVER['PHP_SELF'] <> '/user/log-on.php' => ['redirectUri' => $_SERVER['PHP_SELF']],
|
||||
default => []
|
||||
};
|
||||
|
||||
self::client()->clear();
|
||||
header('Location: ' . self::client()->login($_ENV['AUTH0_BASE_URL'] . '/user/log-on/success', $params));
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log off from this application and Auth0
|
||||
*
|
||||
* @throws ConfigurationException If the Auth0 client is not configured correctly
|
||||
*/
|
||||
public static function logOff(): never
|
||||
{
|
||||
session_destroy();
|
||||
header('Location: ' . self::client()->logout($_ENV['AUTH0_BASE_URL']));
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Require a user be logged on
|
||||
*
|
||||
* @param bool $redirect Whether to redirect to log on if there is not a user logged on
|
||||
* @return void If it returns, there is a user logged on; if not, we will be redirected to log on
|
||||
* @throws ConfigurationException If the Auth0 client is not configured correctly
|
||||
*/
|
||||
public static function requireUser(bool $redirect = true): void
|
||||
{
|
||||
if (is_null(self::user())) {
|
||||
if ($redirect) self::logOn();
|
||||
http_response_code(403);
|
||||
die('Not Authorized');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace MyPrayerJournal;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
/**
|
||||
* A record of an action taken on a request
|
||||
*/
|
||||
class History implements JsonSerializable
|
||||
{
|
||||
/**
|
||||
* @param string $asOf The date/time this entry was made
|
||||
* @param RequestAction $action The action taken for this history entry
|
||||
* @param string|null $text The text for this history entry (optional)
|
||||
*/
|
||||
public function __construct(public string $asOf, public RequestAction $action, public ?string $text = null) { }
|
||||
|
||||
public function jsonSerialize(): mixed
|
||||
{
|
||||
$values = ['asOf' => $this->asOf, 'action' => $this->action->value];
|
||||
if (isset($this->text)) $values['text'] = $this->text;
|
||||
return $values;
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace MyPrayerJournal;
|
||||
|
||||
use BitBadger\PDODocument\Custom;
|
||||
use BitBadger\PDODocument\Mapper\ExistsMapper;
|
||||
|
||||
class Layout
|
||||
{
|
||||
/**
|
||||
* Generate the heading for a bare result
|
||||
*/
|
||||
public static function bareHead(): void
|
||||
{
|
||||
echo '<!DOCTYPE html><html lang=en><head><title></title></head><body>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the end of a bare result
|
||||
*/
|
||||
public static function bareFoot(): void
|
||||
{
|
||||
echo '</body></html>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this an htmx request?
|
||||
*
|
||||
* @return bool True if this is an htmx request, false if not
|
||||
*/
|
||||
private static function isHtmx(): bool
|
||||
{
|
||||
return key_exists('HTTP_HX_REQUEST', $_SERVER) && !key_exists('HTTP_HX_HISTORY_RESTORE_REQUEST', $_SERVER);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the `DOCTYPE` declaration, `html`, and `head` tags for the page
|
||||
*
|
||||
* @param string $title The title of the page
|
||||
*/
|
||||
public static function htmlHead(string $title): void
|
||||
{
|
||||
if (self::isHtmx()) {
|
||||
echo "<!DOCTYPE html><html lang=en><head lang=en><title>$title « myPrayerJournal</title></head>";
|
||||
} else {
|
||||
echo <<<HEAD
|
||||
<!DOCTYPE html>
|
||||
<html lang=en>
|
||||
<head>
|
||||
<meta name=viewport content="width=device-width, initial-scale=1">
|
||||
<meta name=description content="Online prayer journal - free w/Google or Microsoft account">
|
||||
<meta name=htmx-config content='{"historyCacheSize":0}'>
|
||||
<title>$title « myPrayerJournal</title>
|
||||
<link href=https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css rel=stylesheet
|
||||
integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN"
|
||||
crossorigin=anonymous>
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel=stylesheet>
|
||||
<link href=/style/style.css rel=stylesheet>
|
||||
</head>
|
||||
HEAD;
|
||||
}
|
||||
}
|
||||
|
||||
private static function navLink(string $url, string $text): void
|
||||
{
|
||||
$classAttr = match (true) {
|
||||
str_starts_with($_SERVER['PHP_SELF'], $url) => ['class' => 'is-active-route'],
|
||||
default => []
|
||||
};
|
||||
echo '<li class=nav-item>';
|
||||
UI::pageLink($url, $text, $classAttr);
|
||||
}
|
||||
|
||||
/**
|
||||
* The default navigation bar, which will load the items on page load, and whenever a refresh event occurs
|
||||
*/
|
||||
public static function navBar(): void
|
||||
{
|
||||
$table = Table::REQUEST;
|
||||
$hasSnoozed = key_exists('user_id', $_SESSION)
|
||||
? Custom::scalar(<<<SQL
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM $table
|
||||
WHERE data->>'userId' = :userId AND datetime(data->>'snoozedUntil') > datetime('now'))
|
||||
SQL, [':userId' => $_SESSION['user_id']], new ExistsMapper())
|
||||
: false; ?>
|
||||
<nav class="navbar navbar-dark" role="navigation">
|
||||
<div class=container-fluid><?php
|
||||
UI::pageLink('/', '<span class=m>my</span><span class=p>Prayer</span><span class=j>Journal</span>',
|
||||
['class' => 'navbar-brand']); ?>
|
||||
<ul class="navbar-nav me-auto d-flex flex-row"><?php
|
||||
if (key_exists('user_id', $_SESSION)) {
|
||||
self::navLink('/journal', 'Journal');
|
||||
self::navLink('/requests/active', 'Active');
|
||||
if ($hasSnoozed) self::navLink('/requests/snoozed', 'Snoozed');
|
||||
self::navLink('/requests/answered', 'Answered'); ?>
|
||||
<li class=nav-item><a href=/user/log-off>Log Off</a><?php
|
||||
} else { ?>
|
||||
<li class=nav-item><a href=/user/log-on>Log On</a><?php
|
||||
}
|
||||
self::navLink('/docs', 'Docs'); ?>
|
||||
</ul>
|
||||
</div>
|
||||
</nav><?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop .0 or .0.0 from the end of the version to format it for display
|
||||
*
|
||||
* @return string The version of the application for user display
|
||||
*/
|
||||
private static function displayVersion(): string {
|
||||
[$major, $minor, $rev] = explode('.', MPJ_VERSION);
|
||||
$minor = $minor == '0' ? '' : ".$minor";
|
||||
$rev = match (true) {
|
||||
$rev == '0' => '',
|
||||
str_starts_with($rev, '0-') => substr($rev, 1),
|
||||
default => ".$rev"
|
||||
};
|
||||
return "v$major$minor$rev";
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the footer
|
||||
*/
|
||||
public static function htmlFoot(): void
|
||||
{ ?>
|
||||
<footer class=container-fluid>
|
||||
<p class="text-muted text-end">
|
||||
myPrayerJournal <?=self::displayVersion();?><br>
|
||||
<em><small><?php
|
||||
UI::pageLink('/legal/privacy-policy', 'Privacy Policy');
|
||||
echo ' • ';
|
||||
UI::pageLink('/legal/terms-of-service', 'Terms of Service');
|
||||
echo ' • '; ?>
|
||||
<a href=https://git.bitbadger.solutions/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>
|
||||
<script src=https://unpkg.com/htmx.org@2.0.0 crossorigin=anonymous
|
||||
integrity="sha384-wS5l5IKJBvK6sPTKa2WZ1js3d947pvWXbPJ1OmWfEuxLgeHcEbjUUA5i9V5ZkpCw"></script>
|
||||
<script>if (!htmx) document.write('<script src=\"/script/htmx.min.js\"><\/script>')</script>
|
||||
<script async src=https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js
|
||||
integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL"
|
||||
crossorigin=anonymous></script>
|
||||
<script>
|
||||
setTimeout(function () {
|
||||
if (!bootstrap) document.write('<script src=\"/script/bootstrap.bundle.min.js\"><\/script>')
|
||||
}, 2000)
|
||||
</script>
|
||||
<script src=/script/mpj.js></script>
|
||||
</footer><?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the heading for a full or partial page result
|
||||
*
|
||||
* @param string $title The title of the page
|
||||
*/
|
||||
public static function pageHead(string $title): void
|
||||
{
|
||||
self::htmlHead($title);
|
||||
echo '<body>';
|
||||
if (!self::isHtmx()) echo '<section id=top aria-label="Top navigation">';
|
||||
self::navBar();
|
||||
echo '<main role=main>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the end of the page for a full or partial page result
|
||||
*/
|
||||
public static function pageFoot(): void
|
||||
{
|
||||
echo '</main>';
|
||||
if (!self::isHtmx()) {
|
||||
echo '</section>';
|
||||
self::htmlFoot();
|
||||
}
|
||||
echo '</body></html>';
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace MyPrayerJournal;
|
||||
|
||||
use BitBadger\PDODocument\DocumentException;
|
||||
|
||||
/**
|
||||
* A note entered on a prayer request
|
||||
*/
|
||||
class Note
|
||||
{
|
||||
/**
|
||||
* @param string $asOf The date/time this note was recorded
|
||||
* @param string $text The text of the note
|
||||
*/
|
||||
public function __construct(public string $asOf, public string $text) { }
|
||||
|
||||
/**
|
||||
* Retrieve notes for a given request
|
||||
*
|
||||
* @param string $id The ID of the request for which notes should be retrieved
|
||||
* @return array|Note[] The notes for the request, or an empty array if the request was not found
|
||||
* @throws DocumentException If any is encountered
|
||||
*/
|
||||
public static function byRequestId(string $id): array
|
||||
{
|
||||
$req = Request::byId($id);
|
||||
return $req ? $req->notes : [];
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace MyPrayerJournal;
|
||||
|
||||
use DateInterval;
|
||||
use JsonSerializable;
|
||||
|
||||
/**
|
||||
* The recurrence for a prayer request
|
||||
*/
|
||||
class Recurrence implements JsonSerializable
|
||||
{
|
||||
/**
|
||||
* @param RecurrencePeriod $period The recurrence period
|
||||
* @param int|null $interval How many of the periods will pass before the request is visible again
|
||||
*/
|
||||
public function __construct(public RecurrencePeriod $period, public ?int $interval = null) { }
|
||||
|
||||
/**
|
||||
* Get the date/time interval for this recurrence
|
||||
*
|
||||
* @return DateInterval The interval matching the recurrence
|
||||
*/
|
||||
public function interval(): DateInterval
|
||||
{
|
||||
$period = match ($this->period) {
|
||||
RecurrencePeriod::Immediate => 'T0S',
|
||||
RecurrencePeriod::Hours => "T{$this->interval}H",
|
||||
RecurrencePeriod::Days => "{$this->interval}D",
|
||||
RecurrencePeriod::Weeks => ($this->interval * 7) . 'D'
|
||||
};
|
||||
return new DateInterval("P$period");
|
||||
}
|
||||
|
||||
public function jsonSerialize(): mixed
|
||||
{
|
||||
$values = ['period' => $this->period->value];
|
||||
if (isset($this->interval)) $values['interval'] = $this->interval;
|
||||
return $values;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace MyPrayerJournal;
|
||||
|
||||
/**
|
||||
* The type of recurrence a request can have
|
||||
*/
|
||||
enum RecurrencePeriod: string
|
||||
{
|
||||
/** Requests, once prayed, are available again immediately */
|
||||
case Immediate = 'Immediate';
|
||||
|
||||
/** Requests, once prayed, appear again in a number of hours */
|
||||
case Hours = 'Hours';
|
||||
|
||||
/** Requests, once prayed, appear again in a number of days */
|
||||
case Days = 'Days';
|
||||
|
||||
/** Requests, once prayed, appear again in a number of weeks */
|
||||
case Weeks = 'Weeks';
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace MyPrayerJournal;
|
||||
|
||||
use BitBadger\PDODocument\{Custom, DocumentException, DocumentList, Find, Mapper\DocumentMapper};
|
||||
use DateTimeImmutable;
|
||||
use Exception;
|
||||
use JsonSerializable;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
/**
|
||||
* A prayer request
|
||||
*/
|
||||
class Request implements JsonSerializable
|
||||
{
|
||||
/**
|
||||
* @param string $id The ID for the request
|
||||
* @param string $enteredOn The date/time this request was originally entered
|
||||
* @param string $userId The ID of the user to whom this request belongs
|
||||
* @param string|null $snoozedUntil The date/time the snooze expires for this request (null = not snoozed)
|
||||
* @param string|null $showAfter The date/time the current recurrence period is over (null = immediate)
|
||||
* @param Recurrence $recurrence The recurrence for this request
|
||||
* @param History[] $history The history of this request
|
||||
* @param Note[] $notes Notes regarding this request
|
||||
* @throws Exception If the ID generation fails
|
||||
*/
|
||||
public function __construct(public string $id = '', public string $enteredOn = '', public string $userId = '',
|
||||
public ?string $snoozedUntil = null, public ?string $showAfter = null,
|
||||
public Recurrence $recurrence = new Recurrence(RecurrencePeriod::Immediate),
|
||||
public array $history = [], public array $notes = [])
|
||||
{
|
||||
if ($id == '') {
|
||||
$this->id = (new Cuid2())->toString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current text for this request
|
||||
*
|
||||
* @return string The most recent text for the request
|
||||
*/
|
||||
public function currentText(): string
|
||||
{
|
||||
foreach ($this->history as $hist) if (isset($hist->text)) return $hist->text;
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the date/time this request was last marked as prayed
|
||||
*
|
||||
* @return string|null The date/time this request was last marked as prayed
|
||||
*/
|
||||
public function lastPrayed(): ?string
|
||||
{
|
||||
foreach ($this->history as $hist) if ($hist->action == RequestAction::Prayed) return $hist->asOf;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Has this request been answered?
|
||||
*
|
||||
* @return bool True if the request is answered, false if not
|
||||
*/
|
||||
public function isAnswered(): bool
|
||||
{
|
||||
return $this->history[0]->action == RequestAction::Answered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this request currently snoozed?
|
||||
*
|
||||
* @return bool True if the request is snoozed, false if not
|
||||
* @throws Exception If the snoozed until date/time is not valid
|
||||
*/
|
||||
public function isSnoozed(): bool
|
||||
{
|
||||
return isset($this->snoozedUntil) && new DateTimeImmutable($this->snoozedUntil) > new DateTimeImmutable('now');
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this request currently not shown due to recurrence?
|
||||
*
|
||||
* @return bool True if the request is pending, false if not
|
||||
* @throws Exception If the snoozed or show-after date/times are not valid
|
||||
*/
|
||||
public function isPending(): bool
|
||||
{
|
||||
return !$this->isSnoozed()
|
||||
&& isset($this->showAfter)
|
||||
&& new DateTimeImmutable($this->showAfter) > new DateTimeImmutable('now');
|
||||
}
|
||||
|
||||
public function jsonSerialize(): mixed
|
||||
{
|
||||
$values = [
|
||||
'id' => $this->id,
|
||||
'enteredOn' => $this->enteredOn,
|
||||
'userId' => $this->userId,
|
||||
'recurrence' => $this->recurrence,
|
||||
'history' => $this->history,
|
||||
'notes' => $this->notes
|
||||
];
|
||||
if (isset($this->snoozedUntil)) $values['snoozedUntil'] = $this->snoozedUntil;
|
||||
if (isset($this->showAfter)) $values['showAfter'] = $this->showAfter;
|
||||
return $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a request by its ID
|
||||
*
|
||||
* @param string $id The ID of the request
|
||||
* @return Request|false The request if it is found and belongs to the current user, false if not
|
||||
* @throws DocumentException If any is encountered
|
||||
*/
|
||||
public static function byId(string $id): Request|false
|
||||
{
|
||||
$req = Find::byId(Table::REQUEST, $id, self::class);
|
||||
return ($req && $req->userId == $_SESSION['user_id']) ? $req : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's active journal requests
|
||||
*
|
||||
* @return DocumentList<Request> The requests for the user's journal
|
||||
* @throws DocumentException If any is encountered
|
||||
*/
|
||||
public static function forJournal(): DocumentList
|
||||
{
|
||||
$table = Table::REQUEST;
|
||||
return Custom::list(<<<SQL
|
||||
SELECT data, (
|
||||
SELECT h.value->>'asOf' as_of
|
||||
FROM $table i LEFT JOIN json_each(i.data, '$.history') h
|
||||
WHERE r.data->>'id' = i.data->>'id' AND h.value->>'action' = 'Prayed'
|
||||
LIMIT 1) last_prayed
|
||||
FROM $table r
|
||||
WHERE data->>'userId' = :userId
|
||||
AND data->>'$.history[0].action' <> 'Answered'
|
||||
AND (data->>'snoozedUntil' IS NULL OR data->>'snoozedUntil' < datetime('now'))
|
||||
AND (data->>'showAfter' IS NULL OR data->>'showAfter' < datetime('now'))
|
||||
ORDER BY coalesce(last_prayed, data->>'snoozedUntil', data->>'showAfter', data->>'$.history[0].asOf')
|
||||
SQL, [':userId' => $_SESSION['user_id']], new DocumentMapper(self::class));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get either the user's active or answered requests
|
||||
*
|
||||
* @param bool $active True to retrieve active requests, false to retrieve answered requests
|
||||
* @param bool $snoozed True to retrieve only snoozed requests
|
||||
* @return DocumentList<Request> The requests matching the criteria
|
||||
* @throws DocumentException If any is encountered
|
||||
*/
|
||||
private static function forUser(bool $active = true, bool $snoozed = false): DocumentList
|
||||
{
|
||||
$table = Table::REQUEST;
|
||||
$op = $active ? '<>' : '=';
|
||||
$extra = $snoozed ? "AND datetime(data->>'snoozedUntil') > datetime('now')" : '';
|
||||
$order = $active
|
||||
? "coalesce(data->>'snoozedUntil', data->>'showAfter', last_prayed, data->>'$.history[0].asOf')"
|
||||
: "data->>'$.history[0].asOf' DESC";
|
||||
return Custom::list(<<<SQL
|
||||
SELECT data, (
|
||||
SELECT h.value->>'asOf' as_of
|
||||
FROM $table i LEFT JOIN json_each(i.data, '$.history') h
|
||||
WHERE r.data->>'id' = i.data->>'id' AND h.value->>'action' = 'Prayed'
|
||||
LIMIT 1) last_prayed
|
||||
FROM $table r
|
||||
WHERE data->>'userId' = :userId
|
||||
AND data->>'$.history[0].action' $op 'Answered' $extra
|
||||
ORDER BY $order
|
||||
SQL, [':userId' => $_SESSION['user_id']], new DocumentMapper(self::class));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of active requests for a user
|
||||
*
|
||||
* @return DocumentList<Request> The user's active requests
|
||||
* @throws DocumentException If any is encountered
|
||||
*/
|
||||
public static function active(): DocumentList
|
||||
{
|
||||
return self::forUser();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of answered requests for a user
|
||||
*
|
||||
* @return DocumentList<Request> The user's answered requests
|
||||
* @throws DocumentException If any is encountered
|
||||
*/
|
||||
public static function answered(): DocumentList
|
||||
{
|
||||
return self::forUser(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of snoozed requests for a user
|
||||
*
|
||||
* @return DocumentList<Request> The user's snoozed requests
|
||||
* @throws DocumentException If any is encountered
|
||||
*/
|
||||
public static function snoozed(): DocumentList
|
||||
{
|
||||
return self::forUser(snoozed: true);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace MyPrayerJournal;
|
||||
|
||||
/**
|
||||
* An action taken on a prayer request
|
||||
*/
|
||||
enum RequestAction: string
|
||||
{
|
||||
/** The request was created */
|
||||
case Created = 'Created';
|
||||
|
||||
/** The request was marked as having been prayed for */
|
||||
case Prayed = 'Prayed';
|
||||
|
||||
/** The request was updated */
|
||||
case Updated = 'Updated';
|
||||
|
||||
/** The request was marked as answered */
|
||||
case Answered = 'Answered';
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace MyPrayerJournal;
|
||||
|
||||
/**
|
||||
* Constants for table names
|
||||
*/
|
||||
class Table
|
||||
{
|
||||
/** @var string The prayer request table used by myPrayerJournal */
|
||||
const REQUEST = 'request';
|
||||
}
|
||||
-238
@@ -1,238 +0,0 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace MyPrayerJournal;
|
||||
|
||||
use BitBadger\PDODocument\{DocumentException, DocumentList};
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* User interface building blocks
|
||||
*/
|
||||
class UI
|
||||
{
|
||||
/**
|
||||
* Generate a material icon
|
||||
*
|
||||
* @param string $name The name of the material icon
|
||||
* @return string The material icon wrapped in a `span` tag
|
||||
*/
|
||||
public static function icon(string $name): string {
|
||||
return "<span class=material-icons>$name</span>";
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the journal items for the current user
|
||||
*
|
||||
* @throws DocumentException If any is encountered
|
||||
*/
|
||||
public static function journal(): void
|
||||
{
|
||||
Layout::bareHead();
|
||||
$reqs = Request::forJournal();
|
||||
if ($reqs->hasItems()) { ?>
|
||||
<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"><?php
|
||||
$spacer = '<span> </span>';
|
||||
foreach ($reqs->items() as /** @var Request $req */ $req) { ?>
|
||||
<div class=col>
|
||||
<div class="card h-100">
|
||||
<div class="card-header p-0 d-flex" role=toolbar><?php
|
||||
self::pageLink("/request/edit?id=$req->id", self::icon('edit'),
|
||||
['class' => 'btn btn-secondary', 'title' => 'Edit Request']); ?>
|
||||
<?=$spacer?>
|
||||
<button type=button class="btn btn-secondary" title="Add Notes" data-bs-toggle=modal
|
||||
data-bs-target=#notesModal hx-get="/components/request/add-note?id=<?=$req->id?>"
|
||||
hx-target=#notesBody hx-swap=innerHTML><?=self::icon('comment');?></button>
|
||||
<?=$spacer?>
|
||||
<button type=button class="btn btn-secondary" title="Snooze Request" data-bs-toggle=modal
|
||||
data-bs-target=#snoozeModal hx-get="/components/request/snooze?id=<?=$req->id?>"
|
||||
hx-target=#snoozeBody hx-swap=innerHTML><?=self::icon('schedule');?></button>
|
||||
<div class=flex-grow-1></div>
|
||||
<button type=button class="btn btn-success w-25" hx-patch="/request/prayed?id=<?=$req->id?>"
|
||||
title="Mark as Prayed"><?=self::icon('done');?></button>
|
||||
</div>
|
||||
<div class=card-body>
|
||||
<p class=request-text><?=htmlentities($req->currentText());?>
|
||||
</div>
|
||||
<div class="card-footer text-end text-muted px-1 py-0">
|
||||
<em><?php
|
||||
$lastPrayed = $req->lastPrayed();
|
||||
echo 'last ' . (is_null($lastPrayed) ? 'activity': 'prayed') . ' ';
|
||||
self::relativeDate($lastPrayed ?? $req->history[0]->asOf); ?>
|
||||
</em>
|
||||
</div>
|
||||
</div>
|
||||
</div><?php
|
||||
} ?>
|
||||
</section><?php
|
||||
} else {
|
||||
UI::noResults('No Active Requests', '/request/edit?id=new', 'Add a Request', <<<'TEXT'
|
||||
You have no requests to be shown; see the “Active” link above for snoozed or deferred
|
||||
requests, and the “Answered” link for answered requests
|
||||
TEXT);
|
||||
}
|
||||
Layout::bareFoot();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a card when there are no results found
|
||||
*/
|
||||
public static function noResults(string $heading, string $link, string $buttonText, string $text): void
|
||||
{ ?>
|
||||
<div class=card>
|
||||
<h5 class=card-header><?=$heading?></h5>
|
||||
<div class="card-body text-center">
|
||||
<p class=card-text><?=$text?></p><?php
|
||||
self::pageLink($link, $buttonText, ['class' => 'btn btn-primary']); ?>
|
||||
</div>
|
||||
</div><?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a link to a page within myPrayerJournal
|
||||
*
|
||||
* @param string $href The URL for the link
|
||||
* @param string $text The text for the link
|
||||
* @param array $attrs Any additional attributes that should be placed on the `a` tag
|
||||
*/
|
||||
public static function pageLink(string $href, string $text, array $attrs = []): void
|
||||
{ ?>
|
||||
<a href="<?=$href?>" hx-get="<?=$href?>" hx-target=#top hx-swap=innerHTML hx-push-url=true<?php
|
||||
foreach ($attrs as $key => $value) echo " $key=\"" . htmlspecialchars($value) . "\""; ?>><?=$text?></a><?php
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public static function relativeDate(string $date): void
|
||||
{
|
||||
$parsed = new DateTimeImmutable($date);
|
||||
$inZone = $parsed->setTimezone(new DateTimeZone($_REQUEST['time_zone']));
|
||||
echo '<span title="' . date_format($inZone, 'l, F j, Y \a\t g:ia T') . '">'
|
||||
. self::formatDistance('now', $parsed) . '</span>';
|
||||
}
|
||||
|
||||
// Many thanks to date-fns (https://date-fns.org) for this logic
|
||||
/**
|
||||
* Format the distance between two dates
|
||||
*
|
||||
* @param string|DateTimeImmutable $from The starting date/time
|
||||
* @param string|DateTimeImmutable $to The ending date/time
|
||||
* @return string The distance between two dates
|
||||
* @throws Exception If date/time objects cannot be created
|
||||
*/
|
||||
public static function formatDistance(string|DateTimeImmutable $from, string|DateTimeImmutable $to): string
|
||||
{
|
||||
$aDay = 1_440.0;
|
||||
$almost2Days = 2_520.0;
|
||||
$aMonth = 43_200.0;
|
||||
$twoMonths = 86_400.0;
|
||||
|
||||
$dtFrom = is_string($from) ? new DateTimeImmutable($from) : $from;
|
||||
$dtTo = is_string($to) ? new DateTimeImmutable($to) : $to;
|
||||
$minutes = abs($dtFrom->getTimestamp() - $dtTo->getTimestamp()) / 60;
|
||||
$months = round($minutes / $aMonth);
|
||||
$years = round($months / 12);
|
||||
|
||||
$typeAndNumber = match (true) {
|
||||
$minutes < 1.0 => [FormatDistanceToken::LessThanXMinutes, 1],
|
||||
$minutes < 45.0 => [FormatDistanceToken::XMinutes, round($minutes)],
|
||||
$minutes < 90.0 => [FormatDistanceToken::AboutXHours, 1],
|
||||
$minutes < $aDay => [FormatDistanceToken::AboutXHours, round($minutes / 60)],
|
||||
$minutes < $almost2Days => [FormatDistanceToken::XDays, 1],
|
||||
$minutes < $aMonth => [FormatDistanceToken::XDays, round($minutes / $aDay)],
|
||||
$minutes < $twoMonths => [FormatDistanceToken::AboutXMonths, round($minutes / $aMonth)],
|
||||
$months < 12 => [FormatDistanceToken::XMonths, round($minutes / $aMonth)],
|
||||
$months % 12 < 3 => [FormatDistanceToken::AboutXYears, $years],
|
||||
$months % 12 < 9 => [FormatDistanceToken::OverXYears, $years],
|
||||
default => [FormatDistanceToken::AlmostXYears, $years]
|
||||
};
|
||||
$format = match ($typeAndNumber[0]) {
|
||||
FormatDistanceToken::LessThanXMinutes => ['less than a minute', 'less than %d minutes'],
|
||||
FormatDistanceToken::XMinutes => ['a minute', '%d minutes'],
|
||||
FormatDistanceToken::AboutXHours => ['about an hour', 'about %d hours'],
|
||||
FormatDistanceToken::XHours => ['an hour', '%d hours'],
|
||||
FormatDistanceToken::XDays => ['a day', '%d days'],
|
||||
FormatDistanceToken::AboutXWeeks => ['about a week', 'about %d weeks'],
|
||||
FormatDistanceToken::XWeeks => ['a week', '%d weeks'],
|
||||
FormatDistanceToken::AboutXMonths => ['about a month', 'about %d months'],
|
||||
FormatDistanceToken::XMonths => ['a month', '%d months'],
|
||||
FormatDistanceToken::AboutXYears => ['about a year', 'about %d years'],
|
||||
FormatDistanceToken::XYears => ['a year', '%d years'],
|
||||
FormatDistanceToken::OverXYears => ['over a year', 'over %d years'],
|
||||
FormatDistanceToken::AlmostXYears => ['almost a year', 'almost %d years']
|
||||
};
|
||||
$value = $typeAndNumber[1] == 1 ? $format[0] : sprintf($format[1], $typeAndNumber[1]);
|
||||
return $dtFrom > $dtTo ? "$value ago" : "in $value";
|
||||
}
|
||||
|
||||
public static function requestItem(Request $req): void
|
||||
{
|
||||
$btnClass = "btn btn-light mx-2";
|
||||
$restoreBtn = fn(string $id, string $link, string $title) =>
|
||||
'<button class="' . $btnClass. '" hx-patch="/request/' . $link . '?id=' . $id
|
||||
. '" title="' . htmlspecialchars($title) . '">' . self::icon('restore') . '</button>'; ?>
|
||||
<div class="list-group-item px-0 d-flex flex-row align-items-start" hx-target=this
|
||||
hx-swap=outerHTML><?php
|
||||
self::pageLink("/request/full?id=$req->id", self::icon('description'),
|
||||
['class' => $btnClass, 'title' => 'View Full Request']);
|
||||
if (!$req->isAnswered()) {
|
||||
self::pageLink("/request/edit?id=$req->id", self::icon('edit'),
|
||||
['class' => $btnClass, 'title' => 'Edit Request']);
|
||||
}
|
||||
if ($req->isSnoozed()) {
|
||||
echo $restoreBtn($req->id, 'cancel-snooze', 'Cancel Snooze');
|
||||
} elseif ($req->isPending()) {
|
||||
echo $restoreBtn($req->id, 'show', 'Show Now');
|
||||
}
|
||||
echo '<p class="request-text mb-0">' . $req->currentText();
|
||||
if ($req->isSnoozed() || $req->isPending() || $req->isAnswered()) { ?>
|
||||
<br>
|
||||
<small class=text-muted><em><?php
|
||||
switch (true) {
|
||||
case $req->isSnoozed():
|
||||
echo 'Snooze expires '; self::relativeDate($req->snoozedUntil);
|
||||
break;
|
||||
case $req->isPending():
|
||||
echo 'Request appears next '; self::relativeDate($req->showAfter);
|
||||
break;
|
||||
default:
|
||||
echo 'Answered '; self::relativeDate($req->history[0]->asOf);
|
||||
} ?>
|
||||
</em></small><?php
|
||||
} ?>
|
||||
</div><?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the given list of requests
|
||||
*
|
||||
* @param DocumentList<Request> $reqs The list of requests to render
|
||||
* @throws Exception If date/time instances are not valid
|
||||
*/
|
||||
public static function requestList(DocumentList $reqs): void
|
||||
{
|
||||
echo '<div class=list-group>';
|
||||
foreach ($reqs->items() as $req) self::requestItem($req);
|
||||
echo '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
enum FormatDistanceToken
|
||||
{
|
||||
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;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
use MyPrayerJournal\{Auth, UI};
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] <> 'GET') not_found();
|
||||
require '../../start.php';
|
||||
|
||||
Auth::requireUser(false);
|
||||
|
||||
UI::journal();
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
use MyPrayerJournal\{Auth, Layout, Request};
|
||||
|
||||
require '../../../start.php';
|
||||
|
||||
$req = validate_request($_GET['id'], ['GET'], false);
|
||||
|
||||
Layout::bareHead(); ?>
|
||||
<form hx-post="/request/note?id=<?=$req->id?>">
|
||||
<div class="form-floating pb-3">
|
||||
<textarea id=notes name=notes class=form-control style="min-height: 8rem;" placeholder=Notes autofocus
|
||||
required></textarea>
|
||||
<label for=notes>Notes</label>
|
||||
</div>
|
||||
<p class=text-end><button type=submit class="btn btn-primary">Add Notes</button>
|
||||
</form>
|
||||
<hr style="margin: .5rem -1rem">
|
||||
<div id=priorNotes>
|
||||
<p class="text-center pt-3">
|
||||
<button type=button class="btn btn-secondary" hx-get="/components/request/notes?id=<?=$req->id?>"
|
||||
hx-swap=outerHTML hx-target=#priorNotes>Load Prior Notes</button>
|
||||
</div><?php
|
||||
Layout::bareFoot();
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
use MyPrayerJournal\{Layout, UI};
|
||||
|
||||
require '../../../start.php';
|
||||
|
||||
$req = validate_request($_GET['id'], ['GET'], false);
|
||||
|
||||
Layout::bareHead();?>
|
||||
<p class=text-center><strong>Prior Notes for This Request</strong><?php
|
||||
if (sizeof($req->notes) > 0) {
|
||||
foreach ($req->notes as $note) { ?>
|
||||
<p><small class=text-muted><?php UI::relativeDate($note->asOf); ?></small><br>
|
||||
<?=htmlentities($note->text)?><?php
|
||||
}
|
||||
} else { ?>
|
||||
<p class="text-center text-muted">There are no prior notes for this request<?php
|
||||
}
|
||||
Layout::bareFoot();
|
||||
@@ -1,18 +0,0 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
use MyPrayerJournal\Layout;
|
||||
|
||||
require '../../../start.php';
|
||||
|
||||
$req = validate_request($_GET['id'], ['GET'], false);
|
||||
|
||||
Layout::bareHead(); ?>
|
||||
<form hx-patch="/request/snooze?id=<?=$req->id?>" hx-target=#journalItems hx-swap=outerHTML>
|
||||
<div class="form-floating pb-3">
|
||||
<input type=date id=until name=until class=form-control
|
||||
min="<?=(new DateTimeImmutable('now'))->format('Y-m-d')?>" required>
|
||||
<label for=until>Until</label>
|
||||
</div>
|
||||
<p class="text-end mb-0"><button type=submit class="btn btn-primary">Snooze</button>
|
||||
</form><?php
|
||||
Layout::bareFoot();
|
||||
@@ -1,102 +0,0 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
use MyPrayerJournal\Layout;
|
||||
|
||||
require '../start.php';
|
||||
if ($_SERVER['REQUEST_METHOD'] <> 'GET') not_found();
|
||||
|
||||
Layout::pageHead('Documentation'); ?>
|
||||
<article class="container mt-3">
|
||||
<h2 class=mb-3>Documentation</h2>
|
||||
|
||||
<h3 class="mb-3 mt-4">About myPrayerJournal</h3>
|
||||
<p>Journaling has a long history; it helps people remember what happened, and the act of writing helps people think
|
||||
about what happened and process it. A prayer journal is not a new concept; it helps you keep track of the
|
||||
requests for which you’ve prayed, you can use it to pray over things repeatedly, and you can write the
|
||||
result when the answer comes <em>(or it was “no”)</em>.
|
||||
<p>myPrayerJournal was borne of out of a personal desire
|
||||
<a href=https://daniel.summershome.org target=_blank rel=noopener>Daniel</a> had to have something that would
|
||||
help him with his prayer life. When it’s time to pray, it’s not really time to use an app, so the
|
||||
design goal here is to keep it simple and unobtrusive. It will also help eliminate some of the downsides to a
|
||||
paper prayer journal, like not remembering whether you’ve prayed for a request, or running out of room to
|
||||
write another update on one.
|
||||
|
||||
<h3 class="mb-3 mt-4">Signing Up</h3>
|
||||
<p>myPrayerJournal uses login services using Google or Microsoft accounts. The only information the application
|
||||
stores in its database is your user ID token it receives from these services, so there are no permissions you
|
||||
should have to accept from these provider other than establishing that you can log on with that account. Because
|
||||
of this, you’ll want to pick the same one each time; the tokens between the two accounts are different,
|
||||
even if you use the same e-mail address to log on to both.
|
||||
|
||||
<h3 class="mb-3 mt-4">Your Prayer Journal</h3>
|
||||
<p>Your current requests will be presented in columns (usually three, but it could be more or less, depending on the
|
||||
size of your screen or device). Each request is in its own card, and the buttons at the top of each card apply
|
||||
to that request. The last line of each request also tells you how long it has been since anything has been done
|
||||
on that request. Any time you see something like “a few minutes ago,” you can hover over that to see
|
||||
the actual date/time the action was taken.
|
||||
|
||||
<h3 class="mb-3 mt-4">Adding a Request</h3>
|
||||
<p>To add a request, click the “Add a New Request” button at the top of your journal. Then, enter the
|
||||
text of the request as you see fit; there is no right or wrong way, and you are the only person who will see the
|
||||
text you enter. When you save the request, it will go to the bottom of the list of requests.
|
||||
|
||||
<h3 class="mb-3 mt-4">Setting Request Recurrence</h3>
|
||||
<p>When you add or update a request, you can choose whether requests go to the bottom of the journal once they have
|
||||
been marked “Prayed” or whether they will reappear after a delay. You can set recurrence in terms of
|
||||
hours, days, or weeks, but it cannot be longer than 365 days. If you decide you want a request to reappear
|
||||
sooner, you can skip the current delay; click the “Active” menu link, find the request in the list
|
||||
(likely near the bottom), and click the “Show Now” button.
|
||||
|
||||
<h3 class="mb-3 mt-4">Praying for Requests</h3>
|
||||
<p>The first button for each request has a checkmark icon; clicking this button will mark the request as
|
||||
“Prayed” and move it to the bottom of the list (or off, if you’ve set a recurrence period for
|
||||
the request). This allows you, if you’re praying through your requests, to start at the top left (with the
|
||||
request that it’s been the longest since you’ve prayed) and click the button as you pray; when the
|
||||
request move below or away, the next-least-recently-prayed request will take the top spot.
|
||||
|
||||
<h3 class="mb-3 mt-4">Editing Requests</h3>
|
||||
<p>The second button for each request has a pencil icon. This allows you to edit the text of the request, pretty
|
||||
much the same way you entered it; it starts with the current text, and you can add to it, modify it, or
|
||||
completely replace it. By default, updates will go in with an “Updated” status; you have the option
|
||||
to also mark this update as “Prayed” or “Answered”. Answered requests will drop off the
|
||||
journal list.
|
||||
|
||||
<h3 class="mb-3 mt-4">Adding Notes</h3>
|
||||
<p>The third button for each request has an icon that looks like a speech bubble with lines on it; this lets you
|
||||
record notes about the request. If there is something you want to record that doesn’t change the text of
|
||||
the request, this is the place to do it. For example, you may be praying for a long-term health issue, and that
|
||||
person tells you that their status is the same; or, you may want to record something God said to you while you
|
||||
were praying for that request."
|
||||
|
||||
<h3 class="mb-3 mt-4">Snoozing Requests</h3>
|
||||
<p>There may be a time when a request does not need to appear. The fourth button, with the clock icon, allows you to
|
||||
snooze requests until the day you specify. Additionally, if you have any snoozed requests, a
|
||||
“Snoozed” menu item will appear next to the “Journal” one; this page allows you to see
|
||||
what requests are snoozed, and return them to your journal by canceling the snooze.
|
||||
|
||||
<h3 class="mb-3 mt-4">Viewing a Request and Its History</h3>
|
||||
<p>myPrayerJournal tracks all actions related to a request; from the “Active” and “Answered”
|
||||
menu links (and “Snoozed”, if it’s showing), there is a “View Full Request”
|
||||
button. That page will show the current text of the request; how many times it has been marked as prayed; how
|
||||
long it has been an active request; and a log of all updates, prayers, and notes you have recorded. That log is
|
||||
listed from most recent to least recent; if you want to read it chronologically, press the “End” key
|
||||
on your keyboard and read it from the bottom up.
|
||||
<p>The “Active” link will show all requests that have not yet been marked answered, including snoozed
|
||||
and recurring requests. If requests are snoozed, or in a recurrence period off the journal, there will be a
|
||||
button where you can return the request to the list (either “Cancel Snooze” or “Show
|
||||
Now”). The “Answered” link shows all requests that have been marked answered. The
|
||||
“Snoozed” link only shows snoozed requests.
|
||||
|
||||
<h3 class="mb-3 mt-4">Final Notes</h3>
|
||||
<ul>
|
||||
<li>If you encounter errors, please
|
||||
<a href=https://git.bitbadger.solutions/bit-badger/myPrayerJournal/issues target=_blank rel=noopener>file an
|
||||
issue</a> (or <a href="mailto:daniel@bitbadger.solutions?subject=myPrayerJournal+Issue">e-mail Daniel</a> if
|
||||
you do not have an account on that server) with as much detail as possible. You can also provide
|
||||
suggestions, or browse the list of currently open issues.
|
||||
<li>Prayer requests and their history are securely backed up nightly along with other Bit Badger Solutions data.
|
||||
<li>Prayer changes things - most of all, the one doing the praying. I pray that this tool enables you to deepen
|
||||
and strengthen your prayer life.
|
||||
</ul>
|
||||
</article><?php
|
||||
Layout::pageFoot();
|
||||
@@ -1,18 +0,0 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
use MyPrayerJournal\Layout;
|
||||
|
||||
require '../start.php';
|
||||
if ($_SERVER['REQUEST_METHOD'] <> 'GET') not_found();
|
||||
|
||||
Layout::pageHead('Welcome'); ?>
|
||||
<article class="container mt-3">
|
||||
<p>
|
||||
<p>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>This site is open and available for anyone who wants to use it. 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.
|
||||
</article><?php
|
||||
Layout::pageFoot();
|
||||
@@ -1,50 +0,0 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
use MyPrayerJournal\{Auth, Layout, UI};
|
||||
|
||||
require '../start.php';
|
||||
if ($_SERVER['REQUEST_METHOD'] <> 'GET') not_found();
|
||||
|
||||
Auth::requireUser();
|
||||
|
||||
$user = Auth::user();
|
||||
$name = $user['given_name'] ?? 'Your';
|
||||
Layout::pageHead('Journal'); ?>
|
||||
<article class="container-fluid mt-3">
|
||||
<h2 class=pb-3><?=$name?><?=$name == 'Your' ? '' : '’s'?> Prayer Journal</h2>
|
||||
<p class="pb-3 text-center"><?php
|
||||
UI::pageLink('/request/edit?id=new', UI::icon('add_box') . ' Add a Prayer Request',
|
||||
['class' => 'btn btn-primary']); ?>
|
||||
<p hx-get=/components/journal-items hx-swap=outerHTML hx-trigger=load hx-target=this>
|
||||
Loading your prayer journal…
|
||||
<div id=notesModal class="modal fade" tabindex=-1 aria-labelledby=nodesModalLabel aria-hidden=true>
|
||||
<div class="modal-dialog modal-dialog-scrollable">
|
||||
<div class=modal-content>
|
||||
<div class=modal-header>
|
||||
<h5 class=modal-title id=nodesModalLabel>Add Notes to Prayer Request</h5>
|
||||
<button type=button class=btn-close data-bs-dismiss=modal aria-label=Close></button>
|
||||
</div>
|
||||
<div class=modal-body id=notesBody></div>
|
||||
<div class=modal-footer>
|
||||
<button type=button id=notesDismiss class="btn btn-secondary" data-bs-dismiss=modal>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id=snoozeModal class="modal fade" tabindex=-1 aria-labelledby=snoozeModalLabel aria-hidden=true>
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class=modal-content>
|
||||
<div class=modal-header>
|
||||
<h5 class=modal-title id=snoozeModalLabel>Snooze Prayer Request</h5>
|
||||
<button type=button class=btn-close data-bs-dismiss=modal aria-label=Close></button>
|
||||
</div>
|
||||
<div class=modal-body id=snoozeBody></div>
|
||||
<div class=modal-footer>
|
||||
<button type=button id=snoozeDismiss class="btn btn-secondary" data-bs-dismiss=modal>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article><?php
|
||||
Layout::pageFoot();
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
use MyPrayerJournal\Layout;
|
||||
|
||||
require '../../start.php';
|
||||
if ($_SERVER['REQUEST_METHOD'] <> 'GET') not_found();
|
||||
|
||||
Layout::pageHead('Privacy Policy'); ?>
|
||||
<article class="container mt-3">
|
||||
<h2 class=mb-2>Privacy Policy</h2>
|
||||
<h6 class="text-muted pb-3">as of May 21<sup>st</sup>, 2018</h6>
|
||||
<p>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.
|
||||
<div class=card>
|
||||
<div class="list-group list-group-flush">
|
||||
<div class=list-group-item>
|
||||
<h3>Third Party Services</h3>
|
||||
<p class=card-text>
|
||||
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>).
|
||||
</div>
|
||||
<div class=list-group-item>
|
||||
<h3>What We Collect</h3>
|
||||
<h4>Identifying Data</h4>
|
||||
<ul>
|
||||
<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>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.
|
||||
</ul>
|
||||
<h4>User Provided Data</h4>
|
||||
<ul class=mb-0>
|
||||
<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.
|
||||
</ul>
|
||||
</div>
|
||||
<div class=list-group-item>
|
||||
<h3>How Your Data Is Accessed / Secured</h3>
|
||||
<ul class=mb-0>
|
||||
<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>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>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>Access to servers and backups is strictly controlled and monitored for unauthorized access
|
||||
attempts.
|
||||
</ul>
|
||||
</div>
|
||||
<div class=list-group-item>
|
||||
<h3>Removing Your Data</h3>
|
||||
<p class=card-text>
|
||||
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.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article><?php
|
||||
Layout::pageFoot();
|
||||
@@ -1,59 +0,0 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
use MyPrayerJournal\{Layout, UI};
|
||||
|
||||
require '../../start.php';
|
||||
if ($_SERVER['REQUEST_METHOD'] <> 'GET') not_found();
|
||||
|
||||
Layout::pageHead('Terms of Service'); ?>
|
||||
<article class="container mt-3">
|
||||
<h2 class=mb-2>Terms of Service</h2>
|
||||
<h6 class="text-muted pb-3">as of May 21<sup>st</sup>, 2018</h6>
|
||||
<div class=card>
|
||||
<div class="list-group list-group-flush">
|
||||
<div class=list-group-item>
|
||||
<h3>1. Acceptance of Terms</h3>
|
||||
<p class=card-text>
|
||||
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.
|
||||
</div>
|
||||
<div class=list-group-item>
|
||||
<h3>2. Description of Service and Registration</h3>
|
||||
<p class=card-text>
|
||||
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
|
||||
<?php UI::pageLink('/legal/privacy-policy', 'our privacy policy'); ?> for details on how that
|
||||
information is accessed and stored.
|
||||
</div>
|
||||
<div class=list-group-item>
|
||||
<h3>3. Third Party Services</h3>
|
||||
<p class=card-text>
|
||||
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>).
|
||||
</div>
|
||||
<div class=list-group-item>
|
||||
<h3>4. Liability</h3>
|
||||
<p class=card-text>
|
||||
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.
|
||||
</div>
|
||||
<div class=list-group-item>
|
||||
<h3>5. Updates to Terms</h3>
|
||||
<p class=card-text>
|
||||
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."
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class=pt-3>
|
||||
You may also wish to review our <?php UI::pageLink('/legal/privacy-policy', 'privacy policy'); ?> to learn how
|
||||
we handle your data.
|
||||
</article><?php
|
||||
Layout::pageFoot();
|
||||
@@ -1,15 +0,0 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
use MyPrayerJournal\{Layout, Table, UI};
|
||||
use BitBadger\PDODocument\RemoveFields;
|
||||
|
||||
require '../../../start.php';
|
||||
|
||||
$req = validate_request($_GET['id'], ['GET'], false);
|
||||
|
||||
RemoveFields::byId(Table::REQUEST, $req->id, ['snoozedUntil']);
|
||||
|
||||
// TODO: message
|
||||
Layout::bareHead();
|
||||
UI::requestItem($req);
|
||||
Layout::bareFoot();
|
||||
@@ -1,105 +0,0 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
use MyPrayerJournal\{Auth, Layout, RecurrencePeriod, Request, RequestAction, UI};
|
||||
|
||||
require '../../start.php';
|
||||
if ($_SERVER['REQUEST_METHOD'] <> 'GET') not_found();
|
||||
|
||||
Auth::requireUser();
|
||||
|
||||
$isNew = $_GET['id'] == 'new';
|
||||
|
||||
$req = match ($isNew) {
|
||||
true => new Request('new'),
|
||||
false => Request::byId($_GET['id'])
|
||||
};
|
||||
if (!$req) not_found();
|
||||
|
||||
$cancelLink = match (true) {
|
||||
str_ends_with($_SERVER['HTTP_REFERER'] ?? '', 'active.php') => '/requests/active',
|
||||
str_ends_with($_SERVER['HTTP_REFERER'] ?? '', 'snoozed.php') => '/requests/snoozed',
|
||||
default => '/journal'
|
||||
};
|
||||
$action = $_GET['id'] == 'new' ? 'Add' : 'Edit';
|
||||
|
||||
Layout::pageHead("$action Prayer Request");?>
|
||||
<article class=container>
|
||||
<h2 class=pb-3><?=$action?> Prayer Request</h2>
|
||||
<form <?=$isNew ? 'hx-post' : 'hx-patch'?>=/request/save hx-target=#top hx-push-url=true>
|
||||
<input type=hidden name=requestId value=<?=$req->id?>>
|
||||
<input type=hidden name=returnTo value=<?=$cancelLink?>>
|
||||
<div class="form-floating pb-3">
|
||||
<textarea id=requestText name=requestText class=form-control style="min-height: 8rem;"
|
||||
placeholder="Enter the text of the request" autofocus required><?=$req->currentText()?></textarea>
|
||||
<label for=requestText>Prayer Request</label>
|
||||
</div><br><?php
|
||||
if (!$isNew) { ?>
|
||||
<div class=pb-3>
|
||||
<label>Also Mark As</label><br>
|
||||
<div class="form-check form-check-inline">
|
||||
<input type=radio class=form-check-input id=sU name=status value=<?=RequestAction::Updated->value?>
|
||||
checked>
|
||||
<label for=sU>Updated</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input type=radio class=form-check-input id=sP name=status value=<?=RequestAction::Prayed->value?>>
|
||||
<label for=sP>Prayed</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input type=radio class=form-check-input id=sA name=status
|
||||
value=<?=RequestAction::Answered->value?>>
|
||||
<label for=sA>Answered</label>
|
||||
</div>
|
||||
</div><?php
|
||||
} ?>
|
||||
<div class=row">
|
||||
<div class="col-12 offset-md-2 col-md-8 offset-lg-3 col-lg-6">
|
||||
<p><strong>Recurrence </strong> <em class=text-muted>After prayer, request reappears…</em>
|
||||
<div class="d-flex flex-row flex-wrap justify-content-center align-items-center">
|
||||
<div class="form-check mx-2">
|
||||
<input type=radio class=form-check-input id=rI name=recurType
|
||||
value=<?=RecurrencePeriod::Immediate->value?>
|
||||
onclick="mpj.edit.toggleRecurrence(event)"<?php
|
||||
if ($req->recurrence->period == RecurrencePeriod::Immediate) echo ' checked'; ?>>
|
||||
<label for=rI>Immediately</label>
|
||||
</div>
|
||||
<div class="form-check mx-2">
|
||||
<input type=radio class=form-check-input id=rO name=recurType value=Other
|
||||
onclick="mpj.edit.toggleRecurrence(event)"<?php
|
||||
if ($req->recurrence->period <> RecurrencePeriod::Immediate) echo ' checked'; ?>>
|
||||
<label for=rO>Every…</label>
|
||||
</div>
|
||||
<div class="form-floating mx-2">
|
||||
<input type=number class=form-control id=recurCount name=recurCount placeholder=0 required
|
||||
value=<?=$req->recurrence->interval ?? 0?> style="width:6rem;"<?php
|
||||
if ($req->recurrence->period == RecurrencePeriod::Immediate) echo ' disabled'; ?>>
|
||||
<label for=recurCount>Count</label>
|
||||
</div>
|
||||
<div class="form-floating mx-2">
|
||||
<select class=form-control id=recurInterval name=recurInterval style="width:6rem;" required<?php
|
||||
if ($req->recurrence->period == RecurrencePeriod::Immediate) echo ' disabled'; ?>>
|
||||
<option value=<?=RecurrencePeriod::Hours->value?><?php
|
||||
if ($req->recurrence->period == RecurrencePeriod::Hours) echo ' selected'; ?>>
|
||||
hours
|
||||
</option>
|
||||
<option value=<?=RecurrencePeriod::Days->value?><?php
|
||||
if ($req->recurrence->period == RecurrencePeriod::Days) echo ' selected'; ?>>
|
||||
days
|
||||
</option>
|
||||
<option value=<?=RecurrencePeriod::Weeks->value?><?php
|
||||
if ($req->recurrence->period == RecurrencePeriod::Weeks) echo ' selected'; ?>>
|
||||
weeks
|
||||
</option>
|
||||
</select>
|
||||
<label for=recurInterval>Interval</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-end pt-3">
|
||||
<button class="btn btn-primary me-2" type=submit><?=UI::icon('save');?> Save</button><?php
|
||||
UI::pageLink($cancelLink, UI::icon('arrow_back') . ' Cancel', ['class' => 'btn btn-secondary ms-2']); ?>
|
||||
</div>
|
||||
</form>
|
||||
</article><?php
|
||||
Layout::pageFoot();
|
||||
@@ -1,45 +0,0 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
use MyPrayerJournal\{History, Layout, Note, RequestAction, UI};
|
||||
|
||||
require '../../start.php';
|
||||
|
||||
$req = validate_request($_GET['id'], ['GET']);
|
||||
|
||||
$answered = $req->isAnswered() ? new DateTimeImmutable($req->history[0]->asOf) : null;
|
||||
$prayed = sizeof(array_filter($req->history, fn(History $hist) => $hist->action == RequestAction::Prayed));
|
||||
$daysOpen =
|
||||
(($answered ?? new DateTimeImmutable('now'))->getTimestamp()
|
||||
- (new DateTimeImmutable(end($req->history)->asOf))->getTimestamp()) / 86400;
|
||||
|
||||
$logs = array_merge(
|
||||
array_map(fn(Note $note) => [new DateTimeImmutable($note->asOf), 'Notes', $note->text], $req->notes),
|
||||
array_map(fn(History $hist) => [new DateTimeImmutable($hist->asOf), $hist->action->value, $hist->text ?? ''],
|
||||
$req->history));
|
||||
usort($logs, fn($a, $b) => $a[0] > $b[0] ? -1 : 1);
|
||||
if ($req->isAnswered()) array_shift($logs);
|
||||
|
||||
Layout::pageHead('Full Request');?>
|
||||
<article class="container mt-3">
|
||||
<div class=card>
|
||||
<h5 class=card-header>Full Prayer Request</h5>
|
||||
<div class=card-body>
|
||||
<h6 class="card-subtitle text-muted mb-2"><?php
|
||||
if (!is_null($answered)) { ?>
|
||||
Answered <?=$answered->format('F j, Y')?>
|
||||
(<?=UI::formatDistance('now', $req->history[0]->asOf);?>) •<?php
|
||||
} ?>
|
||||
Prayed <?=number_format($prayed)?> times • Open <?=number_format($daysOpen)?> days
|
||||
</h6>
|
||||
<p class=card-text><?=htmlentities($req->currentText())?>
|
||||
</div>
|
||||
<ul class="list-group list-group-flush"><?php
|
||||
foreach ($logs as $log) { ?>
|
||||
<li class=list-group-item>
|
||||
<p class=m-0><?=$log[1]?> <small><em><?=$log[0]->format('F j, Y')?></em></small><?php
|
||||
if ($log[2] <> '') echo '<p class="mt-2 mb-0">' . htmlentities($log[2]);
|
||||
} ?>
|
||||
</ul>
|
||||
</div>
|
||||
</article><?php
|
||||
Layout::pageFoot();
|
||||
@@ -1,14 +0,0 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
use MyPrayerJournal\{Note, Table};
|
||||
use BitBadger\PDODocument\Patch;
|
||||
|
||||
require '../../start.php';
|
||||
|
||||
$req = validate_request($_GET['id'], ['POST'], false);
|
||||
|
||||
array_unshift($req->notes, new Note((new DateTimeImmutable('now'))->format('c'), $_POST['notes']));
|
||||
Patch::byId(Table::REQUEST, $req->id, ['notes' => $req->notes]);
|
||||
|
||||
hide_modal('notes');
|
||||
http_response_code(202);
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
use MyPrayerJournal\{History, RecurrencePeriod, RequestAction, Table, UI};
|
||||
use BitBadger\PDODocument\Patch;
|
||||
|
||||
require '../../start.php';
|
||||
|
||||
$req = validate_request($_GET['id'], ['PATCH'], false);
|
||||
$now = new DateTimeImmutable('now');
|
||||
|
||||
array_unshift($req->history, new History($now->format('c'), RequestAction::Prayed));
|
||||
$patch = ['history' => $req->history];
|
||||
|
||||
if ($req->recurrence->period <> RecurrencePeriod::Immediate) {
|
||||
$patch['showAfter'] = $now->add($req->recurrence->interval())->format('c');
|
||||
}
|
||||
Patch::byId(Table::REQUEST, $req->id, $patch);
|
||||
|
||||
UI::journal();
|
||||
@@ -1,46 +0,0 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
use MyPrayerJournal\{Auth, History, Recurrence, RecurrencePeriod, Request, RequestAction, Table};
|
||||
use BitBadger\PDODocument\{Document, Patch, RemoveFields};
|
||||
|
||||
require '../../start.php';
|
||||
if ($_SERVER['REQUEST_METHOD'] <> 'POST' && $_SERVER['REQUEST_METHOD'] <> 'PATCH') not_found();
|
||||
|
||||
Auth::requireUser(false);
|
||||
|
||||
$now = new DateTimeImmutable('now');
|
||||
$recurrence = new Recurrence(RecurrencePeriod::from($_POST['recurType'] ?? $_PATCH['recurType']));
|
||||
if ($recurrence->period <> RecurrencePeriod::Immediate) {
|
||||
$recurrence->interval = (int)($_POST['recurCount'] ?? $_PATCH['recurCount']);
|
||||
}
|
||||
|
||||
switch ($_SERVER['REQUEST_METHOD']) {
|
||||
case 'POST':
|
||||
Document::insert(Table::REQUEST, new Request(
|
||||
enteredOn: $now->format('c'),
|
||||
userId: $_SESSION['user_id'],
|
||||
recurrence: $recurrence,
|
||||
history: [new History($now->format('c'), RequestAction::Created, $_POST['requestText'])]));
|
||||
//Messages.pushSuccess ctx "Added prayer request" "/journal"
|
||||
see_other('/journal');
|
||||
|
||||
case 'PATCH':
|
||||
$req = Request::byId($_PATCH['requestId']);
|
||||
if (!$req) not_found();
|
||||
$patch = [];
|
||||
// update recurrence if changed
|
||||
if ($recurrence != $req->recurrence) {
|
||||
$patch['recurrence'] = $recurrence;
|
||||
if ($recurrence->period == RecurrencePeriod::Immediate) {
|
||||
RemoveFields::byId(Table::REQUEST, $req->id, ['showAfter']);
|
||||
}
|
||||
}
|
||||
// append history
|
||||
$upd8Text = trim($_PATCH['requestText']);
|
||||
$text = $upd8Text == '' || $upd8Text == $req->currentText() ? null : $upd8Text;
|
||||
array_unshift($req->history, new History($now->format('c'), RequestAction::from($_PATCH['status']), $text));
|
||||
$patch['history'] = $req->history;
|
||||
Patch::byId(Table::REQUEST, $req->id, $patch);
|
||||
//Messages.pushSuccess ctx "Prayer request updated successfully" nextUrl
|
||||
see_other($_PATCH['returnTo']);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
use MyPrayerJournal\{Table, UI};
|
||||
use BitBadger\PDODocument\Patch;
|
||||
|
||||
require '../../start.php';
|
||||
|
||||
$req = validate_request($_GET['id'], ['PATCH'], false);
|
||||
|
||||
$until = (new DateTimeImmutable($_PATCH['until'] . 'T00:00:00', new DateTimeZone($_REQUEST['time_zone'])))
|
||||
->setTimezone(new DateTimeZone('Etc/UTC'));
|
||||
Patch::byId(Table::REQUEST, $req->id, ['snoozedUntil' => $until->format('c')]);
|
||||
|
||||
// TODO: message
|
||||
|
||||
hide_modal('snooze');
|
||||
UI::journal();
|
||||
@@ -1,22 +0,0 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
use MyPrayerJournal\{Auth, Layout, Request, UI};
|
||||
|
||||
require '../../start.php';
|
||||
if ($_SERVER['REQUEST_METHOD'] <> 'GET') not_found();
|
||||
|
||||
Auth::requireUser();
|
||||
|
||||
$reqs = Request::active();
|
||||
|
||||
Layout::pageHead('Active Requests'); ?>
|
||||
<article class="container mt-3">
|
||||
<h2 class=pb-3>Active Requests</h2><?php
|
||||
if ($reqs->hasItems()) {
|
||||
UI::requestList($reqs);
|
||||
} else {
|
||||
UI::noResults('No Active Requests', '/journal', 'Return to your journal',
|
||||
'Your prayer journal has no active requests');
|
||||
} ?>
|
||||
</article><?php
|
||||
Layout::pageFoot();
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
use MyPrayerJournal\{Auth, Layout, Request, UI};
|
||||
|
||||
require '../../start.php';
|
||||
if ($_SERVER['REQUEST_METHOD'] <> 'GET') not_found();
|
||||
|
||||
Auth::requireUser();
|
||||
|
||||
$reqs = Request::answered();
|
||||
|
||||
Layout::pageHead('Answered Requests'); ?>
|
||||
<article class="container mt-3">
|
||||
<h2 class=pb-3>Answered Requests</h2><?php
|
||||
if ($reqs->hasItems()) {
|
||||
UI::requestList($reqs);
|
||||
} else {
|
||||
UI::noResults('No Answered Requests', '/journal', 'Return to your journal', <<<'TEXT'
|
||||
Your prayer journal has no answered requests; once you have marked one as “Answered”, it will
|
||||
appear here
|
||||
TEXT);
|
||||
} ?>
|
||||
</article><?php
|
||||
Layout::pageFoot();
|
||||
@@ -1,22 +0,0 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
use MyPrayerJournal\{Auth, Layout, Request, UI};
|
||||
|
||||
require '../../start.php';
|
||||
if ($_SERVER['REQUEST_METHOD'] <> 'GET') not_found();
|
||||
|
||||
Auth::requireUser();
|
||||
|
||||
$reqs = Request::snoozed();
|
||||
|
||||
Layout::pageHead('Snoozed Requests'); ?>
|
||||
<article class="container mt-3">
|
||||
<h2 class=pb-3>Snoozed Requests</h2><?php
|
||||
if ($reqs->hasItems()) {
|
||||
UI::requestList($reqs);
|
||||
} else {
|
||||
UI::noResults('No Snoozed Requests', '/journal', 'Return to your journal',
|
||||
'Your prayer journal has no snoozed requests');
|
||||
} ?>
|
||||
</article><?php
|
||||
Layout::pageFoot();
|
||||
-7
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
@@ -1,57 +0,0 @@
|
||||
|
||||
nav {
|
||||
background-color: green;
|
||||
}
|
||||
nav .m {
|
||||
font-weight: 100;
|
||||
}
|
||||
nav .p {
|
||||
font-weight: 400;
|
||||
}
|
||||
nav .j {
|
||||
font-weight: 700;
|
||||
}
|
||||
.nav-item a:link,
|
||||
.nav-item a:visited {
|
||||
padding: .5rem 1rem;
|
||||
margin: 0 .5rem;
|
||||
border-radius: .5rem;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav-item a:hover {
|
||||
cursor: pointer;
|
||||
background-color: rgba(255, 255, 255, .2);
|
||||
}
|
||||
.nav-item 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;
|
||||
}
|
||||
footer p {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
use MyPrayerJournal\Auth;
|
||||
|
||||
require '../../start.php';
|
||||
|
||||
Auth::logOff();
|
||||
@@ -1,8 +0,0 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
use MyPrayerJournal\Auth;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] <> 'GET') not_found();
|
||||
require '../../start.php';
|
||||
|
||||
Auth::logOn();
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
use MyPrayerJournal\Auth;
|
||||
|
||||
require '../../../start.php';
|
||||
|
||||
Auth::client()->exchange($_ENV['AUTH0_BASE_URL'] . '/user/log-on/success');
|
||||
|
||||
// TODO: get the possible redirect URL
|
||||
header('Location: /journal');
|
||||
exit();
|
||||
@@ -1,85 +0,0 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
use Auth0\SDK\Exception\ConfigurationException;
|
||||
use BitBadger\PDODocument\{Configuration, Definition, DocumentException, Mode};
|
||||
use Dotenv\Dotenv;
|
||||
use MyPrayerJournal\{Auth, Request, Table};
|
||||
|
||||
require __DIR__ . '/vendor/autoload.php';
|
||||
|
||||
/** The version of this application */
|
||||
const MPJ_VERSION = '4.0.0-alpha1';
|
||||
|
||||
(Dotenv::createImmutable(__DIR__))->load();
|
||||
|
||||
if (php_sapi_name() != 'cli') {
|
||||
session_start();
|
||||
|
||||
$auth0_user = Auth::user();
|
||||
if (!is_null($auth0_user)) {
|
||||
$_SESSION['user_id'] = $auth0_user['sub'];
|
||||
}
|
||||
|
||||
$_REQUEST['time_zone'] = $_SERVER['HTTP_X_TIME_ZONE'] ?? 'Etc/UTC';
|
||||
}
|
||||
|
||||
Configuration::$pdoDSN = 'sqlite:' . implode(DIRECTORY_SEPARATOR, [__DIR__, 'data', 'mpj.db']);
|
||||
Configuration::$mode = Mode::SQLite;
|
||||
Definition::ensureTable(Table::REQUEST);
|
||||
Definition::ensureFieldIndex(Table::REQUEST, 'user', ['userId']);
|
||||
|
||||
$_PATCH = [];
|
||||
if ($_SERVER['REQUEST_METHOD'] ?? '' == 'PATCH') parse_str(file_get_contents('php://input'), $_PATCH);
|
||||
|
||||
/**
|
||||
* Return a 404 and exit
|
||||
*/
|
||||
function not_found(): never
|
||||
{
|
||||
http_response_code(404);
|
||||
die('Not found');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a 303 redirect ("see other" - redirects to a GET)
|
||||
*
|
||||
* @param string $url The URL to which the browser should be redirected
|
||||
*/
|
||||
function see_other(string $url): never
|
||||
{
|
||||
header('Location: ' . (str_starts_with($url, 'http') ? '/' : $url));
|
||||
http_response_code(303);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a header that instructs the browser to close an open modal dialog
|
||||
*
|
||||
* @param string $name The name of the dialog to be closed
|
||||
*/
|
||||
function hide_modal(string $name): void
|
||||
{
|
||||
header("X-Hide-Modal: $name");
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the user, HTTP method, and request
|
||||
*
|
||||
* @param string $id The ID of the prayer request to retrieve
|
||||
* @param array $methods The allowable HTTP methods
|
||||
* @param bool $redirect Whether to redirect not-logged-on users (optional, defaults to true)
|
||||
* @return Request The request (failures will not return)
|
||||
* @throws ConfigurationException If any is encountered
|
||||
* @throws DocumentException If any is encountered
|
||||
*/
|
||||
function validate_request(string $id, array $methods, bool $redirect = true): Request
|
||||
{
|
||||
if (sizeof(array_filter($methods, fn($it) => $_SERVER['REQUEST_METHOD'] == $it)) == 0) not_found();
|
||||
|
||||
Auth::requireUser($redirect);
|
||||
|
||||
$req = Request::byId($id);
|
||||
if (!$req) not_found();
|
||||
|
||||
return $req;
|
||||
}
|
||||
Reference in New Issue
Block a user