Version 3 #67
10
.gitignore
vendored
10
.gitignore
vendored
@ -254,13 +254,3 @@ paket-files/
|
|||||||
|
|
||||||
# Ionide VSCode extension
|
# Ionide VSCode extension
|
||||||
.ionide
|
.ionide
|
||||||
|
|
||||||
# Compiled files / application
|
|
||||||
src/build
|
|
||||||
src/MyPrayerJournal.Api/wwwroot/favicon.ico
|
|
||||||
src/MyPrayerJournal.Api/wwwroot/index.html
|
|
||||||
src/MyPrayerJournal.Api/wwwroot/css
|
|
||||||
src/MyPrayerJournal.Api/wwwroot/js
|
|
||||||
src/MyPrayerJournal.Api/appsettings.development.json
|
|
||||||
/build
|
|
||||||
src/*.exe
|
|
||||||
|
3
publish.ps1
Executable file
3
publish.ps1
Executable file
@ -0,0 +1,3 @@
|
|||||||
|
#!/snap/bin/pwsh
|
||||||
|
Set-Location src/MyPrayerJournal
|
||||||
|
dotnet publish -c Release -r linux-x64 -p:PublishSingleFile=true --self-contained false
|
@ -1,186 +0,0 @@
|
|||||||
namespace MyPrayerJournal
|
|
||||||
|
|
||||||
open System
|
|
||||||
open System.Collections.Generic
|
|
||||||
|
|
||||||
/// JSON converters for various DUs
|
|
||||||
module Converters =
|
|
||||||
|
|
||||||
open Microsoft.FSharpLu.Json
|
|
||||||
open Newtonsoft.Json
|
|
||||||
|
|
||||||
/// JSON converter for request IDs
|
|
||||||
type RequestIdJsonConverter () =
|
|
||||||
inherit JsonConverter<RequestId> ()
|
|
||||||
override __.WriteJson(writer : JsonWriter, value : RequestId, _ : JsonSerializer) =
|
|
||||||
(RequestId.toString >> writer.WriteValue) value
|
|
||||||
override __.ReadJson(reader: JsonReader, _ : Type, _ : RequestId, _ : bool, _ : JsonSerializer) =
|
|
||||||
(string >> RequestId.fromIdString) reader.Value
|
|
||||||
|
|
||||||
/// JSON converter for user IDs
|
|
||||||
type UserIdJsonConverter () =
|
|
||||||
inherit JsonConverter<UserId> ()
|
|
||||||
override __.WriteJson(writer : JsonWriter, value : UserId, _ : JsonSerializer) =
|
|
||||||
(UserId.toString >> writer.WriteValue) value
|
|
||||||
override __.ReadJson(reader: JsonReader, _ : Type, _ : UserId, _ : bool, _ : JsonSerializer) =
|
|
||||||
(string >> UserId) reader.Value
|
|
||||||
|
|
||||||
/// JSON converter for Ticks
|
|
||||||
type TicksJsonConverter () =
|
|
||||||
inherit JsonConverter<Ticks> ()
|
|
||||||
override __.WriteJson(writer : JsonWriter, value : Ticks, _ : JsonSerializer) =
|
|
||||||
(Ticks.toLong >> writer.WriteValue) value
|
|
||||||
override __.ReadJson(reader: JsonReader, _ : Type, _ : Ticks, _ : bool, _ : JsonSerializer) =
|
|
||||||
(string >> int64 >> Ticks) reader.Value
|
|
||||||
|
|
||||||
/// A sequence of all custom converters needed for myPrayerJournal
|
|
||||||
let all : JsonConverter seq =
|
|
||||||
seq {
|
|
||||||
yield RequestIdJsonConverter ()
|
|
||||||
yield UserIdJsonConverter ()
|
|
||||||
yield TicksJsonConverter ()
|
|
||||||
yield CompactUnionJsonConverter true
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// RavenDB index declarations
|
|
||||||
module Indexes =
|
|
||||||
|
|
||||||
open Raven.Client.Documents.Indexes
|
|
||||||
|
|
||||||
/// Index requests for a journal view
|
|
||||||
type Requests_AsJournal () as this =
|
|
||||||
inherit AbstractJavaScriptIndexCreationTask ()
|
|
||||||
do
|
|
||||||
this.Maps <- HashSet<string> [
|
|
||||||
"""docs.Requests.Select(req => new {
|
|
||||||
requestId = req.Id.Replace("Requests/", ""),
|
|
||||||
userId = req.userId,
|
|
||||||
text = req.history.Where(hist => hist.text != null).OrderByDescending(hist => hist.asOf).First().text,
|
|
||||||
asOf = req.history.OrderByDescending(hist => hist.asOf).First().asOf,
|
|
||||||
lastStatus = req.history.OrderByDescending(hist => hist.asOf).First().status,
|
|
||||||
snoozedUntil = req.snoozedUntil,
|
|
||||||
showAfter = req.showAfter,
|
|
||||||
recurType = req.recurType,
|
|
||||||
recurCount = req.recurCount
|
|
||||||
})"""
|
|
||||||
]
|
|
||||||
this.Fields <-
|
|
||||||
[ "requestId", IndexFieldOptions (Storage = Nullable FieldStorage.Yes)
|
|
||||||
"text", IndexFieldOptions (Storage = Nullable FieldStorage.Yes)
|
|
||||||
"asOf", IndexFieldOptions (Storage = Nullable FieldStorage.Yes)
|
|
||||||
"lastStatus", IndexFieldOptions (Storage = Nullable FieldStorage.Yes)
|
|
||||||
]
|
|
||||||
|> dict
|
|
||||||
|> Dictionary<string, IndexFieldOptions>
|
|
||||||
|
|
||||||
|
|
||||||
/// All data manipulations within myPrayerJournal
|
|
||||||
module Data =
|
|
||||||
|
|
||||||
open FSharp.Control.Tasks.V2.ContextInsensitive
|
|
||||||
open Indexes
|
|
||||||
open Microsoft.FSharpLu
|
|
||||||
open Raven.Client.Documents
|
|
||||||
open Raven.Client.Documents.Linq
|
|
||||||
open Raven.Client.Documents.Session
|
|
||||||
|
|
||||||
/// Add a history entry
|
|
||||||
let addHistory reqId (hist : History) (sess : IAsyncDocumentSession) =
|
|
||||||
sess.Advanced.Patch<Request, History> (
|
|
||||||
RequestId.toString reqId,
|
|
||||||
(fun r -> r.history :> IEnumerable<History>),
|
|
||||||
fun (h : JavaScriptArray<History>) -> h.Add (hist) :> obj)
|
|
||||||
|
|
||||||
/// Add a note
|
|
||||||
let addNote reqId (note : Note) (sess : IAsyncDocumentSession) =
|
|
||||||
sess.Advanced.Patch<Request, Note> (
|
|
||||||
RequestId.toString reqId,
|
|
||||||
(fun r -> r.notes :> IEnumerable<Note>),
|
|
||||||
fun (h : JavaScriptArray<Note>) -> h.Add (note) :> obj)
|
|
||||||
|
|
||||||
/// Add a request
|
|
||||||
let addRequest req (sess : IAsyncDocumentSession) =
|
|
||||||
sess.StoreAsync (req, req.Id)
|
|
||||||
|
|
||||||
/// Retrieve all answered requests for the given user
|
|
||||||
let answeredRequests userId (sess : IAsyncDocumentSession) =
|
|
||||||
task {
|
|
||||||
let! reqs =
|
|
||||||
sess.Query<JournalRequest, Requests_AsJournal>()
|
|
||||||
.Where(fun r -> r.userId = userId && r.lastStatus = "Answered")
|
|
||||||
.OrderByDescending(fun r -> r.asOf)
|
|
||||||
.ProjectInto<JournalRequest>()
|
|
||||||
.ToListAsync ()
|
|
||||||
return List.ofSeq reqs
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retrieve the user's current journal
|
|
||||||
let journalByUserId userId (sess : IAsyncDocumentSession) =
|
|
||||||
task {
|
|
||||||
let! jrnl =
|
|
||||||
sess.Query<JournalRequest, Requests_AsJournal>()
|
|
||||||
.Where(fun r -> r.userId = userId && r.lastStatus <> "Answered")
|
|
||||||
.OrderBy(fun r -> r.asOf)
|
|
||||||
.ProjectInto<JournalRequest>()
|
|
||||||
.ToListAsync()
|
|
||||||
return
|
|
||||||
jrnl
|
|
||||||
|> Seq.map (fun r -> r.history <- []; r.notes <- []; r)
|
|
||||||
|> List.ofSeq
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Save changes in the current document session
|
|
||||||
let saveChanges (sess : IAsyncDocumentSession) =
|
|
||||||
sess.SaveChangesAsync ()
|
|
||||||
|
|
||||||
/// Retrieve a request, including its history and notes, by its ID and user ID
|
|
||||||
let tryFullRequestById reqId userId (sess : IAsyncDocumentSession) =
|
|
||||||
task {
|
|
||||||
let! req = RequestId.toString reqId |> sess.LoadAsync
|
|
||||||
return match Option.fromObject req with Some r when r.userId = userId -> Some r | _ -> None
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// Retrieve a request by its ID and user ID (without notes and history)
|
|
||||||
let tryRequestById reqId userId (sess : IAsyncDocumentSession) =
|
|
||||||
task {
|
|
||||||
match! tryFullRequestById reqId userId sess with
|
|
||||||
| Some r -> return Some { r with history = []; notes = [] }
|
|
||||||
| _ -> return None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retrieve notes for a request by its ID and user ID
|
|
||||||
let notesById reqId userId (sess : IAsyncDocumentSession) =
|
|
||||||
task {
|
|
||||||
match! tryFullRequestById reqId userId sess with
|
|
||||||
| Some req -> return req.notes
|
|
||||||
| None -> return []
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retrieve a journal request by its ID and user ID
|
|
||||||
let tryJournalById reqId userId (sess : IAsyncDocumentSession) =
|
|
||||||
task {
|
|
||||||
let! req =
|
|
||||||
sess.Query<Request, Requests_AsJournal>()
|
|
||||||
.Where(fun x -> x.Id = (RequestId.toString reqId) && x.userId = userId)
|
|
||||||
.ProjectInto<JournalRequest>()
|
|
||||||
.FirstOrDefaultAsync ()
|
|
||||||
return
|
|
||||||
Option.fromObject req
|
|
||||||
|> Option.map (fun r -> r.history <- []; r.notes <- []; r)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update the recurrence for a request
|
|
||||||
let updateRecurrence reqId recurType recurCount (sess : IAsyncDocumentSession) =
|
|
||||||
sess.Advanced.Patch<Request, Recurrence> (RequestId.toString reqId, (fun r -> r.recurType), recurType)
|
|
||||||
sess.Advanced.Patch<Request, int16> (RequestId.toString reqId, (fun r -> r.recurCount), recurCount)
|
|
||||||
|
|
||||||
/// Update a snoozed request
|
|
||||||
let updateSnoozed reqId until (sess : IAsyncDocumentSession) =
|
|
||||||
sess.Advanced.Patch<Request, Ticks> (RequestId.toString reqId, (fun r -> r.snoozedUntil), until)
|
|
||||||
sess.Advanced.Patch<Request, Ticks> (RequestId.toString reqId, (fun r -> r.showAfter), until)
|
|
||||||
|
|
||||||
/// Update the "show after" timestamp for a request
|
|
||||||
let updateShowAfter reqId showAfter (sess : IAsyncDocumentSession) =
|
|
||||||
sess.Advanced.Patch<Request, Ticks> (RequestId.toString reqId, (fun r -> r.showAfter), showAfter)
|
|
@ -1,169 +0,0 @@
|
|||||||
[<AutoOpen>]
|
|
||||||
/// The data model for myPrayerJournal
|
|
||||||
module MyPrayerJournal.Domain
|
|
||||||
|
|
||||||
open Cuid
|
|
||||||
|
|
||||||
/// Request ID is a CUID
|
|
||||||
type RequestId =
|
|
||||||
| RequestId of Cuid
|
|
||||||
module RequestId =
|
|
||||||
/// The string representation of the request ID
|
|
||||||
let toString x = match x with RequestId y -> (Cuid.toString >> sprintf "Requests/%s") y
|
|
||||||
/// Create a request ID from a string representation
|
|
||||||
let fromIdString (y : string) = (Cuid >> RequestId) <| y.Replace("Requests/", "")
|
|
||||||
|
|
||||||
|
|
||||||
/// User ID is a string (the "sub" part of the JWT)
|
|
||||||
type UserId =
|
|
||||||
| UserId of string
|
|
||||||
module UserId =
|
|
||||||
/// The string representation of the user ID
|
|
||||||
let toString x = match x with UserId y -> y
|
|
||||||
|
|
||||||
|
|
||||||
/// A long integer representing seconds since the epoch
|
|
||||||
type Ticks =
|
|
||||||
| Ticks of int64
|
|
||||||
module Ticks =
|
|
||||||
/// The int64 (long) representation of ticks
|
|
||||||
let toLong x = match x with Ticks y -> y
|
|
||||||
|
|
||||||
|
|
||||||
/// How frequently a request should reappear after it is marked "Prayed"
|
|
||||||
type Recurrence =
|
|
||||||
| Immediate
|
|
||||||
| Hours
|
|
||||||
| Days
|
|
||||||
| Weeks
|
|
||||||
module Recurrence =
|
|
||||||
/// Create a recurrence value from a string
|
|
||||||
let fromString x =
|
|
||||||
match x with
|
|
||||||
| "Immediate" -> Immediate
|
|
||||||
| "Hours" -> Hours
|
|
||||||
| "Days" -> Days
|
|
||||||
| "Weeks" -> Weeks
|
|
||||||
| _ -> invalidOp (sprintf "%s is not a valid recurrence" x)
|
|
||||||
/// The duration of the recurrence
|
|
||||||
let duration x =
|
|
||||||
match x with
|
|
||||||
| Immediate -> 0L
|
|
||||||
| Hours -> 3600000L
|
|
||||||
| Days -> 86400000L
|
|
||||||
| Weeks -> 604800000L
|
|
||||||
|
|
||||||
|
|
||||||
/// The action taken on a request as part of a history entry
|
|
||||||
type RequestAction =
|
|
||||||
| Created
|
|
||||||
| Prayed
|
|
||||||
| Updated
|
|
||||||
| Answered
|
|
||||||
module RequestAction =
|
|
||||||
/// Create a RequestAction from a string
|
|
||||||
let fromString x =
|
|
||||||
match x with
|
|
||||||
| "Created" -> Created
|
|
||||||
| "Prayed" -> Prayed
|
|
||||||
| "Updated" -> Updated
|
|
||||||
| "Answered" -> Answered
|
|
||||||
| _ -> (sprintf "Bad request action %s" >> invalidOp) x
|
|
||||||
|
|
||||||
|
|
||||||
/// History is a record of action taken on a prayer request, including updates to its text
|
|
||||||
[<CLIMutable; NoComparison; NoEquality>]
|
|
||||||
type History =
|
|
||||||
{ /// The time when this history entry was made
|
|
||||||
asOf : Ticks
|
|
||||||
/// The status for this history entry
|
|
||||||
status : RequestAction
|
|
||||||
/// The text of the update, if applicable
|
|
||||||
text : string option
|
|
||||||
}
|
|
||||||
with
|
|
||||||
/// An empty history entry
|
|
||||||
static member empty =
|
|
||||||
{ asOf = Ticks 0L
|
|
||||||
status = Created
|
|
||||||
text = None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Note is a note regarding a prayer request that does not result in an update to its text
|
|
||||||
[<CLIMutable; NoComparison; NoEquality>]
|
|
||||||
type Note =
|
|
||||||
{ /// The time when this note was made
|
|
||||||
asOf : Ticks
|
|
||||||
/// The text of the notes
|
|
||||||
notes : string
|
|
||||||
}
|
|
||||||
with
|
|
||||||
/// An empty note
|
|
||||||
static member empty =
|
|
||||||
{ asOf = Ticks 0L
|
|
||||||
notes = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Request is the identifying record for a prayer request
|
|
||||||
[<CLIMutable; NoComparison; NoEquality>]
|
|
||||||
type Request =
|
|
||||||
{ /// The ID of the request
|
|
||||||
Id : string
|
|
||||||
/// The time this request was initially entered
|
|
||||||
enteredOn : Ticks
|
|
||||||
/// 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 : Ticks
|
|
||||||
/// The time at which this request should reappear in the user's journal by recurrence
|
|
||||||
showAfter : Ticks
|
|
||||||
/// The type of recurrence for this request
|
|
||||||
recurType : Recurrence
|
|
||||||
/// How many of the recurrence intervals should occur between appearances in the journal
|
|
||||||
recurCount : int16
|
|
||||||
/// The history entries for this request
|
|
||||||
history : History list
|
|
||||||
/// The notes for this request
|
|
||||||
notes : Note list
|
|
||||||
}
|
|
||||||
with
|
|
||||||
/// An empty request
|
|
||||||
static member empty =
|
|
||||||
{ Id = ""
|
|
||||||
enteredOn = Ticks 0L
|
|
||||||
userId = UserId ""
|
|
||||||
snoozedUntil = Ticks 0L
|
|
||||||
showAfter = Ticks 0L
|
|
||||||
recurType = Immediate
|
|
||||||
recurCount = 0s
|
|
||||||
history = []
|
|
||||||
notes = []
|
|
||||||
}
|
|
||||||
|
|
||||||
/// JournalRequest is the form of a prayer request returned for the request journal display. It also contains
|
|
||||||
/// properties that may be filled for history and notes.
|
|
||||||
// RavenDB doesn't like the "@"-suffixed properties from record types in a ProjectInto clause
|
|
||||||
[<NoComparison; NoEquality>]
|
|
||||||
type JournalRequest () =
|
|
||||||
/// The ID of the request (just the CUID part)
|
|
||||||
[<DefaultValue>] val mutable requestId : string
|
|
||||||
/// The ID of the user to whom the request belongs
|
|
||||||
[<DefaultValue>] val mutable userId : UserId
|
|
||||||
/// The current text of the request
|
|
||||||
[<DefaultValue>] val mutable text : string
|
|
||||||
/// The last time action was taken on the request
|
|
||||||
[<DefaultValue>] val mutable asOf : Ticks
|
|
||||||
/// The last status for the request
|
|
||||||
[<DefaultValue>] val mutable lastStatus : string
|
|
||||||
/// The time that this request should reappear in the user's journal
|
|
||||||
[<DefaultValue>] val mutable snoozedUntil : Ticks
|
|
||||||
/// The time after which this request should reappear in the user's journal by configured recurrence
|
|
||||||
[<DefaultValue>] val mutable showAfter : Ticks
|
|
||||||
/// The type of recurrence for this request
|
|
||||||
[<DefaultValue>] val mutable recurType : Recurrence
|
|
||||||
/// How many of the recurrence intervals should occur between appearances in the journal
|
|
||||||
[<DefaultValue>] val mutable recurCount : int16
|
|
||||||
/// History entries for the request
|
|
||||||
[<DefaultValue>] val mutable history : History list
|
|
||||||
/// Note entries for the request
|
|
||||||
[<DefaultValue>] val mutable notes : Note list
|
|
@ -1,378 +0,0 @@
|
|||||||
/// HTTP handlers for the myPrayerJournal API
|
|
||||||
[<RequireQualifiedAccess>]
|
|
||||||
module MyPrayerJournal.Handlers
|
|
||||||
|
|
||||||
open Giraffe
|
|
||||||
|
|
||||||
/// Handler to return Vue files
|
|
||||||
module Vue =
|
|
||||||
|
|
||||||
/// The application index page
|
|
||||||
let app : HttpHandler = htmlFile "wwwroot/index.html"
|
|
||||||
|
|
||||||
open System
|
|
||||||
|
|
||||||
/// Handlers for error conditions
|
|
||||||
module Error =
|
|
||||||
|
|
||||||
open Microsoft.Extensions.Logging
|
|
||||||
|
|
||||||
/// Handle errors
|
|
||||||
let error (ex : Exception) (log : ILogger) =
|
|
||||||
log.LogError (EventId(), ex, "An unhandled exception has occurred while executing the request.")
|
|
||||||
clearResponse >=> setStatusCode 500 >=> json ex.Message
|
|
||||||
|
|
||||||
/// Handle 404s from the API, sending known URL paths to the Vue app so that they can be handled there
|
|
||||||
let notFound : HttpHandler =
|
|
||||||
fun next ctx ->
|
|
||||||
[ "/journal"; "/legal"; "/request"; "/user" ]
|
|
||||||
|> List.filter ctx.Request.Path.Value.StartsWith
|
|
||||||
|> List.length
|
|
||||||
|> function
|
|
||||||
| 0 -> (setStatusCode 404 >=> json ([ "error", "not found" ] |> dict)) next ctx
|
|
||||||
| _ -> Vue.app next ctx
|
|
||||||
|
|
||||||
open Cuid
|
|
||||||
|
|
||||||
/// Handler helpers
|
|
||||||
[<AutoOpen>]
|
|
||||||
module private Helpers =
|
|
||||||
|
|
||||||
open Microsoft.AspNetCore.Http
|
|
||||||
open Raven.Client.Documents
|
|
||||||
open System.Threading.Tasks
|
|
||||||
open System.Security.Claims
|
|
||||||
|
|
||||||
/// Create a RavenDB session
|
|
||||||
let session (ctx : HttpContext) =
|
|
||||||
let sess = ctx.GetService<IDocumentStore>().OpenAsyncSession ()
|
|
||||||
sess.Advanced.WaitForIndexesAfterSaveChanges ()
|
|
||||||
sess
|
|
||||||
|
|
||||||
/// Get the user's "sub" claim
|
|
||||||
let user (ctx : HttpContext) =
|
|
||||||
ctx.User.Claims |> Seq.tryFind (fun u -> u.Type = ClaimTypes.NameIdentifier)
|
|
||||||
|
|
||||||
/// Get the current user's ID
|
|
||||||
// NOTE: this may raise if you don't run the request through the authorize handler first
|
|
||||||
let userId ctx =
|
|
||||||
((user >> Option.get) ctx).Value |> UserId
|
|
||||||
|
|
||||||
/// Create a request ID from a string
|
|
||||||
let toReqId x =
|
|
||||||
let reqId =
|
|
||||||
match Cuid.ofString x with
|
|
||||||
| Ok cuid -> cuid
|
|
||||||
| Error msg -> invalidOp msg
|
|
||||||
RequestId reqId
|
|
||||||
|
|
||||||
/// Return a 201 CREATED response
|
|
||||||
let created next ctx =
|
|
||||||
setStatusCode 201 next ctx
|
|
||||||
|
|
||||||
/// The "now" time in JavaScript as Ticks
|
|
||||||
let jsNow () =
|
|
||||||
(int64 >> (*) 1000L >> Ticks) <| DateTime.UtcNow.Subtract(DateTime (1970, 1, 1, 0, 0, 0)).TotalSeconds
|
|
||||||
|
|
||||||
/// Handler to return a 403 Not Authorized reponse
|
|
||||||
let notAuthorized : HttpHandler =
|
|
||||||
setStatusCode 403 >=> fun _ _ -> Task.FromResult<HttpContext option> None
|
|
||||||
|
|
||||||
/// Handler to require authorization
|
|
||||||
let authorize : HttpHandler =
|
|
||||||
fun next ctx -> match user ctx with Some _ -> next ctx | None -> notAuthorized next ctx
|
|
||||||
|
|
||||||
/// Flip JSON result so we can pipe into it
|
|
||||||
let asJson<'T> next ctx (o : 'T) =
|
|
||||||
json o next ctx
|
|
||||||
|
|
||||||
/// Work-around to let the Json.NET serializer synchronously deserialize from the request stream
|
|
||||||
// TODO: Remove this once there is an async serializer
|
|
||||||
let allowSyncIO : HttpHandler =
|
|
||||||
fun next ctx ->
|
|
||||||
match ctx.Features.Get<Features.IHttpBodyControlFeature>() with
|
|
||||||
| null -> ()
|
|
||||||
| f -> f.AllowSynchronousIO <- true
|
|
||||||
next ctx
|
|
||||||
|
|
||||||
|
|
||||||
/// Strongly-typed models for post requests
|
|
||||||
module Models =
|
|
||||||
|
|
||||||
/// A history entry addition (AKA request update)
|
|
||||||
[<CLIMutable>]
|
|
||||||
type HistoryEntry =
|
|
||||||
{ /// The status of the history update
|
|
||||||
status : string
|
|
||||||
/// The text of the update
|
|
||||||
updateText : string
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An additional note
|
|
||||||
[<CLIMutable>]
|
|
||||||
type NoteEntry =
|
|
||||||
{ /// The notes being added
|
|
||||||
notes : string
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Recurrence update
|
|
||||||
[<CLIMutable>]
|
|
||||||
type Recurrence =
|
|
||||||
{ /// The recurrence type
|
|
||||||
recurType : string
|
|
||||||
/// The recurrence cound
|
|
||||||
recurCount : int16
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A prayer request
|
|
||||||
[<CLIMutable>]
|
|
||||||
type Request =
|
|
||||||
{ /// The text of the request
|
|
||||||
requestText : string
|
|
||||||
/// The recurrence type
|
|
||||||
recurType : string
|
|
||||||
/// The recurrence count
|
|
||||||
recurCount : int16
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The time until which a request should not appear in the journal
|
|
||||||
[<CLIMutable>]
|
|
||||||
type SnoozeUntil =
|
|
||||||
{ /// The time at which the request should reappear
|
|
||||||
until : int64
|
|
||||||
}
|
|
||||||
|
|
||||||
open FSharp.Control.Tasks.V2.ContextInsensitive
|
|
||||||
|
|
||||||
/// /api/journal URLs
|
|
||||||
module Journal =
|
|
||||||
|
|
||||||
/// GET /api/journal
|
|
||||||
let journal : HttpHandler =
|
|
||||||
authorize
|
|
||||||
>=> fun next ctx ->
|
|
||||||
task {
|
|
||||||
use sess = session ctx
|
|
||||||
let usrId = userId ctx
|
|
||||||
let! jrnl = Data.journalByUserId usrId sess
|
|
||||||
return! json jrnl next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// /api/request URLs
|
|
||||||
module Request =
|
|
||||||
|
|
||||||
/// POST /api/request
|
|
||||||
let add : HttpHandler =
|
|
||||||
authorize
|
|
||||||
>=> allowSyncIO
|
|
||||||
>=> fun next ctx ->
|
|
||||||
task {
|
|
||||||
let! r = ctx.BindJsonAsync<Models.Request> ()
|
|
||||||
use sess = session ctx
|
|
||||||
let reqId = (Cuid.generate >> RequestId) ()
|
|
||||||
let usrId = userId ctx
|
|
||||||
let now = jsNow ()
|
|
||||||
do! Data.addRequest
|
|
||||||
{ Request.empty with
|
|
||||||
Id = RequestId.toString reqId
|
|
||||||
userId = usrId
|
|
||||||
enteredOn = now
|
|
||||||
showAfter = Ticks 0L
|
|
||||||
recurType = Recurrence.fromString r.recurType
|
|
||||||
recurCount = r.recurCount
|
|
||||||
history = [
|
|
||||||
{ asOf = now
|
|
||||||
status = Created
|
|
||||||
text = Some r.requestText
|
|
||||||
}
|
|
||||||
]
|
|
||||||
} sess
|
|
||||||
do! Data.saveChanges sess
|
|
||||||
match! Data.tryJournalById reqId usrId sess with
|
|
||||||
| Some req -> return! (setStatusCode 201 >=> json req) next ctx
|
|
||||||
| None -> return! Error.notFound next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST /api/request/[req-id]/history
|
|
||||||
let addHistory requestId : HttpHandler =
|
|
||||||
authorize
|
|
||||||
>=> allowSyncIO
|
|
||||||
>=> fun next ctx ->
|
|
||||||
task {
|
|
||||||
use sess = session ctx
|
|
||||||
let usrId = userId ctx
|
|
||||||
let reqId = toReqId requestId
|
|
||||||
match! Data.tryRequestById reqId usrId sess with
|
|
||||||
| Some req ->
|
|
||||||
let! hist = ctx.BindJsonAsync<Models.HistoryEntry> ()
|
|
||||||
let now = jsNow ()
|
|
||||||
let act = RequestAction.fromString hist.status
|
|
||||||
Data.addHistory reqId
|
|
||||||
{ asOf = now
|
|
||||||
status = act
|
|
||||||
text = match hist.updateText with null | "" -> None | x -> Some x
|
|
||||||
} sess
|
|
||||||
match act with
|
|
||||||
| Prayed ->
|
|
||||||
let nextShow =
|
|
||||||
match Recurrence.duration req.recurType with
|
|
||||||
| 0L -> 0L
|
|
||||||
| duration -> (Ticks.toLong now) + (duration * int64 req.recurCount)
|
|
||||||
Data.updateShowAfter reqId (Ticks nextShow) sess
|
|
||||||
| _ -> ()
|
|
||||||
do! Data.saveChanges sess
|
|
||||||
return! created next ctx
|
|
||||||
| None -> return! Error.notFound next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST /api/request/[req-id]/note
|
|
||||||
let addNote requestId : HttpHandler =
|
|
||||||
authorize
|
|
||||||
>=> allowSyncIO
|
|
||||||
>=> fun next ctx ->
|
|
||||||
task {
|
|
||||||
use sess = session ctx
|
|
||||||
let usrId = userId ctx
|
|
||||||
let reqId = toReqId requestId
|
|
||||||
match! Data.tryRequestById reqId usrId sess with
|
|
||||||
| Some _ ->
|
|
||||||
let! notes = ctx.BindJsonAsync<Models.NoteEntry> ()
|
|
||||||
Data.addNote reqId { asOf = jsNow (); notes = notes.notes } sess
|
|
||||||
do! Data.saveChanges sess
|
|
||||||
return! created next ctx
|
|
||||||
| None -> return! Error.notFound next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /api/requests/answered
|
|
||||||
let answered : HttpHandler =
|
|
||||||
authorize
|
|
||||||
>=> fun next ctx ->
|
|
||||||
task {
|
|
||||||
use sess = session ctx
|
|
||||||
let usrId = userId ctx
|
|
||||||
let! reqs = Data.answeredRequests usrId sess
|
|
||||||
return! json reqs next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /api/request/[req-id]
|
|
||||||
let get requestId : HttpHandler =
|
|
||||||
authorize
|
|
||||||
>=> fun next ctx ->
|
|
||||||
task {
|
|
||||||
use sess = session ctx
|
|
||||||
let usrId = userId ctx
|
|
||||||
match! Data.tryJournalById (toReqId requestId) usrId sess with
|
|
||||||
| Some req -> return! json req next ctx
|
|
||||||
| None -> return! Error.notFound next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /api/request/[req-id]/full
|
|
||||||
let getFull requestId : HttpHandler =
|
|
||||||
authorize
|
|
||||||
>=> fun next ctx ->
|
|
||||||
task {
|
|
||||||
use sess = session ctx
|
|
||||||
let usrId = userId ctx
|
|
||||||
match! Data.tryFullRequestById (toReqId requestId) usrId sess with
|
|
||||||
| Some req -> return! json req next ctx
|
|
||||||
| None -> return! Error.notFound next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /api/request/[req-id]/notes
|
|
||||||
let getNotes requestId : HttpHandler =
|
|
||||||
authorize
|
|
||||||
>=> fun next ctx ->
|
|
||||||
task {
|
|
||||||
use sess = session ctx
|
|
||||||
let usrId = userId ctx
|
|
||||||
let! notes = Data.notesById (toReqId requestId) usrId sess
|
|
||||||
return! json notes next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
/// PATCH /api/request/[req-id]/show
|
|
||||||
let show requestId : HttpHandler =
|
|
||||||
authorize
|
|
||||||
>=> fun next ctx ->
|
|
||||||
task {
|
|
||||||
use sess = session ctx
|
|
||||||
let usrId = userId ctx
|
|
||||||
let reqId = toReqId requestId
|
|
||||||
match! Data.tryRequestById reqId usrId sess with
|
|
||||||
| Some _ ->
|
|
||||||
Data.updateShowAfter reqId (Ticks 0L) sess
|
|
||||||
do! Data.saveChanges sess
|
|
||||||
return! setStatusCode 204 next ctx
|
|
||||||
| None -> return! Error.notFound next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
/// PATCH /api/request/[req-id]/snooze
|
|
||||||
let snooze requestId : HttpHandler =
|
|
||||||
authorize
|
|
||||||
>=> allowSyncIO
|
|
||||||
>=> fun next ctx ->
|
|
||||||
task {
|
|
||||||
use sess = session ctx
|
|
||||||
let usrId = userId ctx
|
|
||||||
let reqId = toReqId requestId
|
|
||||||
match! Data.tryRequestById reqId usrId sess with
|
|
||||||
| Some _ ->
|
|
||||||
let! until = ctx.BindJsonAsync<Models.SnoozeUntil> ()
|
|
||||||
Data.updateSnoozed reqId (Ticks until.until) sess
|
|
||||||
do! Data.saveChanges sess
|
|
||||||
return! setStatusCode 204 next ctx
|
|
||||||
| None -> return! Error.notFound next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
/// PATCH /api/request/[req-id]/recurrence
|
|
||||||
let updateRecurrence requestId : HttpHandler =
|
|
||||||
authorize
|
|
||||||
>=> allowSyncIO
|
|
||||||
>=> fun next ctx ->
|
|
||||||
task {
|
|
||||||
use sess = session ctx
|
|
||||||
let usrId = userId ctx
|
|
||||||
let reqId = toReqId requestId
|
|
||||||
match! Data.tryRequestById reqId usrId sess with
|
|
||||||
| Some _ ->
|
|
||||||
let! recur = ctx.BindJsonAsync<Models.Recurrence> ()
|
|
||||||
let recurrence = Recurrence.fromString recur.recurType
|
|
||||||
Data.updateRecurrence reqId recurrence recur.recurCount sess
|
|
||||||
match recurrence with Immediate -> Data.updateShowAfter reqId (Ticks 0L) sess | _ -> ()
|
|
||||||
do! Data.saveChanges sess
|
|
||||||
return! setStatusCode 204 next ctx
|
|
||||||
| None -> return! Error.notFound next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
open Giraffe.TokenRouter
|
|
||||||
|
|
||||||
/// The routes for myPrayerJournal
|
|
||||||
let webApp : HttpHandler =
|
|
||||||
router Error.notFound [
|
|
||||||
route "/" Vue.app
|
|
||||||
subRoute "/api/" [
|
|
||||||
GET [
|
|
||||||
route "journal" Journal.journal
|
|
||||||
subRoute "request" [
|
|
||||||
route "s/answered" Request.answered
|
|
||||||
routef "/%s/full" Request.getFull
|
|
||||||
routef "/%s/notes" Request.getNotes
|
|
||||||
routef "/%s" Request.get
|
|
||||||
]
|
|
||||||
]
|
|
||||||
PATCH [
|
|
||||||
subRoute "request" [
|
|
||||||
routef "/%s/recurrence" Request.updateRecurrence
|
|
||||||
routef "/%s/show" Request.show
|
|
||||||
routef "/%s/snooze" Request.snooze
|
|
||||||
]
|
|
||||||
]
|
|
||||||
POST [
|
|
||||||
subRoute "request" [
|
|
||||||
route "" Request.add
|
|
||||||
routef "/%s/history" Request.addHistory
|
|
||||||
routef "/%s/note" Request.addNote
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
@ -1,34 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>netcoreapp3.1</TargetFramework>
|
|
||||||
<Version>2.1.4.0</Version>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Compile Include="Domain.fs" />
|
|
||||||
<Compile Include="Data.fs" />
|
|
||||||
<Compile Include="Handlers.fs" />
|
|
||||||
<Compile Include="Program.fs" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="FunctionalCuid" Version="1.0.0" />
|
|
||||||
<PackageReference Include="Giraffe" Version="4.1.0" />
|
|
||||||
<PackageReference Include="Giraffe.TokenRouter" Version="1.0.0" />
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="3.1.3" />
|
|
||||||
<PackageReference Include="Microsoft.FSharpLu" Version="0.11.6" />
|
|
||||||
<PackageReference Include="Microsoft.FSharpLu.Json" Version="0.11.6" />
|
|
||||||
<PackageReference Include="RavenDb.Client" Version="4.2.102" />
|
|
||||||
<PackageReference Include="TaskBuilder.fs" Version="2.1.0" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Update="FSharp.Core" Version="4.7.1" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Folder Include="wwwroot\" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
@ -1,145 +0,0 @@
|
|||||||
module MyPrayerJournal.Api
|
|
||||||
|
|
||||||
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 (bldr : IWebHostBuilder) =
|
|
||||||
bldr.UseContentRoot root
|
|
||||||
|
|
||||||
open Microsoft.Extensions.Configuration
|
|
||||||
|
|
||||||
/// Configure the application configuration
|
|
||||||
let appConfiguration (bldr : IWebHostBuilder) =
|
|
||||||
let configuration (ctx : WebHostBuilderContext) (cfg : IConfigurationBuilder) =
|
|
||||||
cfg.SetBasePath(ctx.HostingEnvironment.ContentRootPath)
|
|
||||||
.AddJsonFile("appsettings.json", optional = true, reloadOnChange = true)
|
|
||||||
.AddJsonFile(sprintf "appsettings.%s.json" ctx.HostingEnvironment.EnvironmentName)
|
|
||||||
.AddEnvironmentVariables ()
|
|
||||||
|> ignore
|
|
||||||
bldr.ConfigureAppConfiguration configuration
|
|
||||||
|
|
||||||
open Microsoft.AspNetCore.Server.Kestrel.Core
|
|
||||||
|
|
||||||
/// Configure Kestrel from appsettings.json
|
|
||||||
let kestrel (bldr : IWebHostBuilder) =
|
|
||||||
let kestrelOpts (ctx : WebHostBuilderContext) (opts : KestrelServerOptions) =
|
|
||||||
(ctx.Configuration.GetSection >> opts.Configure >> ignore) "Kestrel"
|
|
||||||
bldr.UseKestrel().ConfigureKestrel kestrelOpts
|
|
||||||
|
|
||||||
/// Configure the web root directory
|
|
||||||
let webRoot pathSegments (bldr : IWebHostBuilder) =
|
|
||||||
(Path.Combine >> bldr.UseWebRoot) pathSegments
|
|
||||||
|
|
||||||
open Giraffe
|
|
||||||
open Giraffe.Serialization
|
|
||||||
open Microsoft.AspNetCore.Authentication.JwtBearer
|
|
||||||
open Microsoft.Extensions.DependencyInjection
|
|
||||||
open MyPrayerJournal.Indexes
|
|
||||||
open Newtonsoft.Json
|
|
||||||
open Newtonsoft.Json.Serialization
|
|
||||||
open Raven.Client.Documents
|
|
||||||
open Raven.Client.Documents.Indexes
|
|
||||||
open System.Security.Cryptography.X509Certificates
|
|
||||||
|
|
||||||
/// Configure dependency injection
|
|
||||||
let services (bldr : IWebHostBuilder) =
|
|
||||||
let svcs (sc : IServiceCollection) =
|
|
||||||
/// Custom settings for the JSON serializer (uses compact representation for options and DUs)
|
|
||||||
let jsonSettings =
|
|
||||||
let x = NewtonsoftJsonSerializer.DefaultSettings
|
|
||||||
Converters.all |> List.ofSeq |> List.iter x.Converters.Add
|
|
||||||
x.NullValueHandling <- NullValueHandling.Ignore
|
|
||||||
x.MissingMemberHandling <- MissingMemberHandling.Error
|
|
||||||
x.Formatting <- Formatting.Indented
|
|
||||||
x.ContractResolver <- DefaultContractResolver ()
|
|
||||||
x
|
|
||||||
|
|
||||||
use sp = sc.BuildServiceProvider ()
|
|
||||||
let cfg = sp.GetRequiredService<IConfiguration> ()
|
|
||||||
sc.AddGiraffe()
|
|
||||||
.AddAuthentication(
|
|
||||||
/// Use HTTP "Bearer" authentication with JWTs
|
|
||||||
fun opts ->
|
|
||||||
opts.DefaultAuthenticateScheme <- JwtBearerDefaults.AuthenticationScheme
|
|
||||||
opts.DefaultChallengeScheme <- JwtBearerDefaults.AuthenticationScheme)
|
|
||||||
.AddJwtBearer(
|
|
||||||
/// Configure JWT options with Auth0 options from configuration
|
|
||||||
fun opts ->
|
|
||||||
let jwtCfg = cfg.GetSection "Auth0"
|
|
||||||
opts.Authority <- sprintf "https://%s/" jwtCfg.["Domain"]
|
|
||||||
opts.Audience <- jwtCfg.["Id"]
|
|
||||||
)
|
|
||||||
|> ignore
|
|
||||||
sc.AddSingleton<IJsonSerializer> (NewtonsoftJsonSerializer jsonSettings)
|
|
||||||
|> ignore
|
|
||||||
let config = sc.BuildServiceProvider().GetRequiredService<IConfiguration>().GetSection "RavenDB"
|
|
||||||
let store = new DocumentStore ()
|
|
||||||
store.Urls <- [| config.["URL"] |]
|
|
||||||
store.Database <- config.["Database"]
|
|
||||||
match isNull config.["Certificate"] with
|
|
||||||
| true -> ()
|
|
||||||
| false -> store.Certificate <- new X509Certificate2 (config.["Certificate"], config.["Password"])
|
|
||||||
store.Conventions.CustomizeJsonSerializer <- fun x -> Converters.all |> List.ofSeq |> List.iter x.Converters.Add
|
|
||||||
store.Initialize () |> (sc.AddSingleton >> ignore)
|
|
||||||
IndexCreation.CreateIndexes (typeof<Requests_AsJournal>.Assembly, store)
|
|
||||||
bldr.ConfigureServices svcs
|
|
||||||
|
|
||||||
open Microsoft.Extensions.Logging
|
|
||||||
open Microsoft.Extensions.Hosting
|
|
||||||
|
|
||||||
/// Configure logging
|
|
||||||
let logging (bldr : IWebHostBuilder) =
|
|
||||||
let logz (log : ILoggingBuilder) =
|
|
||||||
let env = log.Services.BuildServiceProvider().GetService<IWebHostEnvironment> ()
|
|
||||||
match env.IsDevelopment () with
|
|
||||||
| true -> log
|
|
||||||
| false -> log.AddFilter(fun l -> l > LogLevel.Information)
|
|
||||||
|> function l -> l.AddConsole().AddDebug()
|
|
||||||
|> ignore
|
|
||||||
bldr.ConfigureLogging logz
|
|
||||||
|
|
||||||
open System
|
|
||||||
|
|
||||||
/// Configure the web application
|
|
||||||
let application (bldr : IWebHostBuilder) =
|
|
||||||
let appConfig =
|
|
||||||
Action<IApplicationBuilder> (
|
|
||||||
fun (app : IApplicationBuilder) ->
|
|
||||||
let env = app.ApplicationServices.GetService<IWebHostEnvironment> ()
|
|
||||||
match env.IsDevelopment () with
|
|
||||||
| true -> app.UseDeveloperExceptionPage ()
|
|
||||||
| false -> app.UseGiraffeErrorHandler Handlers.Error.error
|
|
||||||
|> function
|
|
||||||
| a ->
|
|
||||||
a.UseAuthentication()
|
|
||||||
.UseStaticFiles()
|
|
||||||
.UseGiraffe Handlers.webApp
|
|
||||||
|> ignore)
|
|
||||||
bldr.Configure appConfig
|
|
||||||
|
|
||||||
/// Compose all the configurations into one
|
|
||||||
let webHost appRoot pathSegments =
|
|
||||||
contentRoot appRoot
|
|
||||||
>> appConfiguration
|
|
||||||
>> kestrel
|
|
||||||
>> webRoot (Array.concat [ [| appRoot |]; pathSegments ])
|
|
||||||
>> services
|
|
||||||
>> logging
|
|
||||||
>> application
|
|
||||||
|
|
||||||
/// Build the web host from the given configuration
|
|
||||||
let buildHost (bldr : IWebHostBuilder) = bldr.Build ()
|
|
||||||
|
|
||||||
let exitCode = 0
|
|
||||||
|
|
||||||
[<EntryPoint>]
|
|
||||||
let main _ =
|
|
||||||
let appRoot = Directory.GetCurrentDirectory ()
|
|
||||||
use host = WebHostBuilder() |> (Configure.webHost appRoot [| "wwwroot" |] >> Configure.buildHost)
|
|
||||||
host.Run ()
|
|
||||||
exitCode
|
|
22
src/MyPrayerJournal.Migrate/Migrate.fsproj
Normal file
22
src/MyPrayerJournal.Migrate/Migrate.fsproj
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net6.0</TargetFramework>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Include="Program.fs" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="FSharp.Data" Version="4.2.3" />
|
||||||
|
<PackageReference Include="LiteDB" Version="5.0.11" />
|
||||||
|
<PackageReference Include="NodaTime" Version="3.0.9" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\MyPrayerJournal\MyPrayerJournal.fsproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
57
src/MyPrayerJournal.Migrate/Program.fs
Normal file
57
src/MyPrayerJournal.Migrate/Program.fs
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
open FSharp.Data
|
||||||
|
open FSharp.Data.CsvExtensions
|
||||||
|
open LiteDB
|
||||||
|
open MyPrayerJournal.Domain
|
||||||
|
open NodaTime
|
||||||
|
|
||||||
|
module Subdocs =
|
||||||
|
|
||||||
|
open FSharp.Data.JsonExtensions
|
||||||
|
|
||||||
|
let history json =
|
||||||
|
match JsonValue.Parse json with
|
||||||
|
| JsonValue.Array hist ->
|
||||||
|
hist
|
||||||
|
|> Array.map (fun h ->
|
||||||
|
{ asOf = (h?asOf.AsInteger64 >> Instant.FromUnixTimeMilliseconds) ()
|
||||||
|
status = h?status.AsString () |> RequestAction.ofString
|
||||||
|
text = match h?text.AsString () with "" -> None | txt -> Some txt
|
||||||
|
})
|
||||||
|
|> List.ofArray
|
||||||
|
| _ -> []
|
||||||
|
|
||||||
|
let notes json =
|
||||||
|
match JsonValue.Parse json with
|
||||||
|
| JsonValue.Array notes ->
|
||||||
|
notes
|
||||||
|
|> Array.map (fun n ->
|
||||||
|
{ asOf = (n?asOf.AsInteger64 >> Instant.FromUnixTimeMilliseconds) ()
|
||||||
|
notes = n?notes.AsString ()
|
||||||
|
})
|
||||||
|
|> List.ofArray
|
||||||
|
| _ -> []
|
||||||
|
|
||||||
|
let oldData = CsvFile.Load("data.csv")
|
||||||
|
|
||||||
|
let db = new LiteDatabase("Filename=./mpj.db")
|
||||||
|
|
||||||
|
MyPrayerJournal.Data.Startup.ensureDb db
|
||||||
|
|
||||||
|
let migrated =
|
||||||
|
oldData.Rows
|
||||||
|
|> Seq.map (fun r ->
|
||||||
|
{ id = r["@id"].Replace ("Requests/", "") |> RequestId.ofString
|
||||||
|
enteredOn = (r?enteredOn.AsInteger64 >> Instant.FromUnixTimeMilliseconds) ()
|
||||||
|
userId = UserId r?userId
|
||||||
|
snoozedUntil = (r?snoozedUntil.AsInteger64 >> Instant.FromUnixTimeMilliseconds) ()
|
||||||
|
showAfter = (r?showAfter.AsInteger64 >> Instant.FromUnixTimeMilliseconds) ()
|
||||||
|
recurType = r?recurType |> Recurrence.ofString
|
||||||
|
recurCount = (r?recurCount.AsInteger >> int16) ()
|
||||||
|
history = Subdocs.history r?history
|
||||||
|
notes = Subdocs.notes r?notes
|
||||||
|
})
|
||||||
|
|> db.GetCollection<Request>("request").Insert
|
||||||
|
|
||||||
|
db.Checkpoint ()
|
||||||
|
|
||||||
|
printfn $"Migrated {migrated} requests"
|
@ -1,37 +0,0 @@
|
|||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
|
||||||
# Visual Studio Version 16
|
|
||||||
VisualStudioVersion = 16.0.28721.148
|
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
|
||||||
Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "MyPrayerJournal.Api", "MyPrayerJournal.Api\MyPrayerJournal.Api.fsproj", "{1887D1E1-544A-4F54-B266-38E7867DC842}"
|
|
||||||
EndProject
|
|
||||||
Global
|
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
|
||||||
Debug|Any CPU = Debug|Any CPU
|
|
||||||
Debug|iPhone = Debug|iPhone
|
|
||||||
Debug|iPhoneSimulator = Debug|iPhoneSimulator
|
|
||||||
Release|Any CPU = Release|Any CPU
|
|
||||||
Release|iPhone = Release|iPhone
|
|
||||||
Release|iPhoneSimulator = Release|iPhoneSimulator
|
|
||||||
EndGlobalSection
|
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
|
||||||
{1887D1E1-544A-4F54-B266-38E7867DC842}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{1887D1E1-544A-4F54-B266-38E7867DC842}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{1887D1E1-544A-4F54-B266-38E7867DC842}.Debug|iPhone.ActiveCfg = Debug|Any CPU
|
|
||||||
{1887D1E1-544A-4F54-B266-38E7867DC842}.Debug|iPhone.Build.0 = Debug|Any CPU
|
|
||||||
{1887D1E1-544A-4F54-B266-38E7867DC842}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU
|
|
||||||
{1887D1E1-544A-4F54-B266-38E7867DC842}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU
|
|
||||||
{1887D1E1-544A-4F54-B266-38E7867DC842}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{1887D1E1-544A-4F54-B266-38E7867DC842}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{1887D1E1-544A-4F54-B266-38E7867DC842}.Release|iPhone.ActiveCfg = Release|Any CPU
|
|
||||||
{1887D1E1-544A-4F54-B266-38E7867DC842}.Release|iPhone.Build.0 = Release|Any CPU
|
|
||||||
{1887D1E1-544A-4F54-B266-38E7867DC842}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU
|
|
||||||
{1887D1E1-544A-4F54-B266-38E7867DC842}.Release|iPhoneSimulator.Build.0 = Release|Any CPU
|
|
||||||
EndGlobalSection
|
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
|
||||||
HideSolutionNode = FALSE
|
|
||||||
EndGlobalSection
|
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
|
||||||
SolutionGuid = {8E2447D9-52F0-4A0D-BB61-A83C19353D7C}
|
|
||||||
EndGlobalSection
|
|
||||||
EndGlobal
|
|
5
src/MyPrayerJournal/.gitignore
vendored
Normal file
5
src/MyPrayerJournal/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
## LiteDB database file
|
||||||
|
*.db
|
||||||
|
|
||||||
|
## Development settings
|
||||||
|
appsettings.Development.json
|
209
src/MyPrayerJournal/Data.fs
Normal file
209
src/MyPrayerJournal/Data.fs
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
module MyPrayerJournal.Data
|
||||||
|
|
||||||
|
open LiteDB
|
||||||
|
open NodaTime
|
||||||
|
open System
|
||||||
|
open System.Threading.Tasks
|
||||||
|
|
||||||
|
// fsharplint:disable MemberNames
|
||||||
|
|
||||||
|
/// LiteDB extensions
|
||||||
|
[<AutoOpen>]
|
||||||
|
module Extensions =
|
||||||
|
|
||||||
|
/// Extensions on the LiteDatabase class
|
||||||
|
type LiteDatabase with
|
||||||
|
/// The Request collection
|
||||||
|
member this.requests
|
||||||
|
with get () = this.GetCollection<Request> "request"
|
||||||
|
/// Async version of the checkpoint command (flushes log)
|
||||||
|
member this.saveChanges () =
|
||||||
|
this.Checkpoint ()
|
||||||
|
Task.CompletedTask
|
||||||
|
|
||||||
|
|
||||||
|
/// Map domain to LiteDB
|
||||||
|
// It does mapping, but since we're so DU-heavy, this gives us control over the JSON representation
|
||||||
|
[<RequireQualifiedAccess>]
|
||||||
|
module Mapping =
|
||||||
|
|
||||||
|
/// Map a history entry to BSON
|
||||||
|
let historyToBson (hist : History) : BsonValue =
|
||||||
|
let doc = BsonDocument ()
|
||||||
|
doc["asOf"] <- hist.asOf.ToUnixTimeMilliseconds ()
|
||||||
|
doc["status"] <- RequestAction.toString hist.status
|
||||||
|
doc["text"] <- match hist.text with Some t -> t | None -> ""
|
||||||
|
upcast doc
|
||||||
|
|
||||||
|
/// Map a BSON document to a history entry
|
||||||
|
let historyFromBson (doc : BsonValue) =
|
||||||
|
{ asOf = Instant.FromUnixTimeMilliseconds doc["asOf"].AsInt64
|
||||||
|
status = RequestAction.ofString doc["status"].AsString
|
||||||
|
text = match doc["text"].AsString with "" -> None | txt -> Some txt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a note entry to BSON
|
||||||
|
let noteToBson (note : Note) : BsonValue =
|
||||||
|
let doc = BsonDocument ()
|
||||||
|
doc["asOf"] <- note.asOf.ToUnixTimeMilliseconds ()
|
||||||
|
doc["notes"] <- note.notes
|
||||||
|
upcast doc
|
||||||
|
|
||||||
|
/// Map a BSON document to a note entry
|
||||||
|
let noteFromBson (doc : BsonValue) =
|
||||||
|
{ asOf = Instant.FromUnixTimeMilliseconds doc["asOf"].AsInt64
|
||||||
|
notes = doc["notes"].AsString
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a request to its BSON representation
|
||||||
|
let requestToBson req : BsonValue =
|
||||||
|
let doc = BsonDocument ()
|
||||||
|
doc["_id"] <- RequestId.toString req.id
|
||||||
|
doc["enteredOn"] <- req.enteredOn.ToUnixTimeMilliseconds ()
|
||||||
|
doc["userId"] <- UserId.toString req.userId
|
||||||
|
doc["snoozedUntil"] <- req.snoozedUntil.ToUnixTimeMilliseconds ()
|
||||||
|
doc["showAfter"] <- req.showAfter.ToUnixTimeMilliseconds ()
|
||||||
|
doc["recurType"] <- Recurrence.toString req.recurType
|
||||||
|
doc["recurCount"] <- BsonValue req.recurCount
|
||||||
|
doc["history"] <- BsonArray (req.history |> List.map historyToBson |> Seq.ofList)
|
||||||
|
doc["notes"] <- BsonArray (req.notes |> List.map noteToBson |> Seq.ofList)
|
||||||
|
upcast doc
|
||||||
|
|
||||||
|
/// Map a BSON document to a request
|
||||||
|
let requestFromBson (doc : BsonValue) =
|
||||||
|
{ id = RequestId.ofString doc["_id"].AsString
|
||||||
|
enteredOn = Instant.FromUnixTimeMilliseconds doc["enteredOn"].AsInt64
|
||||||
|
userId = UserId doc["userId"].AsString
|
||||||
|
snoozedUntil = Instant.FromUnixTimeMilliseconds doc["snoozedUntil"].AsInt64
|
||||||
|
showAfter = Instant.FromUnixTimeMilliseconds doc["showAfter"].AsInt64
|
||||||
|
recurType = Recurrence.ofString doc["recurType"].AsString
|
||||||
|
recurCount = int16 doc["recurCount"].AsInt32
|
||||||
|
history = doc["history"].AsArray |> Seq.map historyFromBson |> List.ofSeq
|
||||||
|
notes = doc["notes"].AsArray |> Seq.map noteFromBson |> List.ofSeq
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set up the mapping
|
||||||
|
let register () =
|
||||||
|
BsonMapper.Global.RegisterType<Request>(
|
||||||
|
Func<Request, BsonValue> requestToBson, Func<BsonValue, Request> requestFromBson)
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a history entry
|
||||||
|
let addHistory reqId userId hist db = backgroundTask {
|
||||||
|
match! tryFullRequestById reqId userId db with
|
||||||
|
| Some req -> do! doUpdate db { req with history = 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 = note :: req.notes }
|
||||||
|
| None -> invalidOp $"{RequestId.toString reqId} not found"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a request
|
||||||
|
let addRequest (req : Request) (db : LiteDatabase) =
|
||||||
|
db.requests.Insert req |> ignore
|
||||||
|
|
||||||
|
// FIXME: make a common function here
|
||||||
|
|
||||||
|
/// Retrieve all answered requests for the given user
|
||||||
|
let answeredRequests userId (db : LiteDatabase) = backgroundTask {
|
||||||
|
let! reqs = db.requests.Find (Query.EQ ("userId", UserId.toString userId)) |> toListAsync
|
||||||
|
return
|
||||||
|
reqs
|
||||||
|
|> Seq.map JournalRequest.ofRequestFull
|
||||||
|
|> Seq.filter (fun it -> it.lastStatus = Answered)
|
||||||
|
|> Seq.sortByDescending (fun it -> it.asOf)
|
||||||
|
|> List.ofSeq
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve the user's current journal
|
||||||
|
let journalByUserId userId (db : LiteDatabase) = backgroundTask {
|
||||||
|
let! jrnl = db.requests.Find (Query.EQ ("userId", UserId.toString userId)) |> toListAsync
|
||||||
|
return
|
||||||
|
jrnl
|
||||||
|
|> Seq.map JournalRequest.ofRequestLite
|
||||||
|
|> Seq.filter (fun it -> it.lastStatus <> Answered)
|
||||||
|
|> Seq.sortBy (fun it -> it.asOf)
|
||||||
|
|> List.ofSeq
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 -> r.snoozedUntil > now)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the recurrence for a request
|
||||||
|
let updateRecurrence reqId userId recurType recurCount db = backgroundTask {
|
||||||
|
match! tryFullRequestById reqId userId db with
|
||||||
|
| Some req -> do! doUpdate db { req with recurType = recurType; recurCount = recurCount }
|
||||||
|
| None -> invalidOp $"{RequestId.toString reqId} not found"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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"
|
||||||
|
}
|
78
src/MyPrayerJournal/Dates.fs
Normal file
78
src/MyPrayerJournal/Dates.fs
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
/// Date formatting helpers
|
||||||
|
// Many thanks to date-fns (https://date-fns.org) for this logic
|
||||||
|
module MyPrayerJournal.Dates
|
||||||
|
|
||||||
|
open NodaTime
|
||||||
|
|
||||||
|
type internal FormatDistanceToken =
|
||||||
|
| LessThanXMinutes
|
||||||
|
| XMinutes
|
||||||
|
| AboutXHours
|
||||||
|
| XHours
|
||||||
|
| XDays
|
||||||
|
| AboutXWeeks
|
||||||
|
| XWeeks
|
||||||
|
| AboutXMonths
|
||||||
|
| XMonths
|
||||||
|
| AboutXYears
|
||||||
|
| XYears
|
||||||
|
| OverXYears
|
||||||
|
| AlmostXYears
|
||||||
|
|
||||||
|
let internal locales =
|
||||||
|
let format = PrintfFormat<int -> string, unit, string, string>
|
||||||
|
Map.ofList [
|
||||||
|
"en-US", Map.ofList [
|
||||||
|
LessThanXMinutes, ("less than a minute", format "less than %i minutes")
|
||||||
|
XMinutes, ("a minute", format "%i minutes")
|
||||||
|
AboutXHours, ("about an hour", format "about %i hours")
|
||||||
|
XHours, ("an hour", format "%i hours")
|
||||||
|
XDays, ("a day", format "%i days")
|
||||||
|
AboutXWeeks, ("about a week", format "about %i weeks")
|
||||||
|
XWeeks, ("a week", format "%i weeks")
|
||||||
|
AboutXMonths, ("about a month", format "about %i months")
|
||||||
|
XMonths, ("a month", format "%i months")
|
||||||
|
AboutXYears, ("about a year", format "about %i years")
|
||||||
|
XYears, ("a year", format "%i years")
|
||||||
|
OverXYears, ("over a year", format "over %i years")
|
||||||
|
AlmostXYears, ("almost a year", format "almost %i years")
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
let aDay = 1_440.
|
||||||
|
let almost2Days = 2_520.
|
||||||
|
let aMonth = 43_200.
|
||||||
|
let twoMonths = 86_400.
|
||||||
|
|
||||||
|
open System
|
||||||
|
|
||||||
|
/// Convert from a JavaScript "ticks" value to a date/time
|
||||||
|
let fromJs ticks = DateTime.UnixEpoch + TimeSpan.FromTicks (ticks * 10_000L)
|
||||||
|
|
||||||
|
let formatDistance (startDate : Instant) (endDate : Instant) =
|
||||||
|
let format (token, number) locale =
|
||||||
|
let labels = locales |> Map.find locale
|
||||||
|
match number with 1 -> fst labels[token] | _ -> sprintf (snd labels[token]) number
|
||||||
|
let round (it : float) = Math.Round it |> int
|
||||||
|
|
||||||
|
let diff = startDate - endDate
|
||||||
|
let minutes = Math.Abs diff.TotalMinutes
|
||||||
|
let formatToken =
|
||||||
|
let months = minutes / aMonth |> round
|
||||||
|
let years = months / 12
|
||||||
|
match true with
|
||||||
|
| _ when minutes < 1. -> LessThanXMinutes, 1
|
||||||
|
| _ when minutes < 45. -> XMinutes, round minutes
|
||||||
|
| _ when minutes < 90. -> AboutXHours, 1
|
||||||
|
| _ when minutes < aDay -> AboutXHours, round (minutes / 60.)
|
||||||
|
| _ when minutes < almost2Days -> XDays, 1
|
||||||
|
| _ when minutes < aMonth -> XDays, round (minutes / aDay)
|
||||||
|
| _ when minutes < twoMonths -> AboutXMonths, round (minutes / aMonth)
|
||||||
|
| _ when months < 12 -> XMonths, round (minutes / aMonth)
|
||||||
|
| _ when months % 12 < 3 -> AboutXYears, years
|
||||||
|
| _ when months % 12 < 9 -> OverXYears, years
|
||||||
|
| _ -> AlmostXYears, years + 1
|
||||||
|
|
||||||
|
format formatToken "en-US"
|
||||||
|
|> match startDate > endDate with true -> sprintf "%s ago" | false -> sprintf "in %s"
|
||||||
|
|
213
src/MyPrayerJournal/Domain.fs
Normal file
213
src/MyPrayerJournal/Domain.fs
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
[<AutoOpen>]
|
||||||
|
/// The data model for myPrayerJournal
|
||||||
|
module MyPrayerJournal.Domain
|
||||||
|
|
||||||
|
// fsharplint:disable RecordFieldNames
|
||||||
|
|
||||||
|
open Cuid
|
||||||
|
open NodaTime
|
||||||
|
|
||||||
|
/// An identifier for a request
|
||||||
|
type RequestId =
|
||||||
|
| RequestId of Cuid
|
||||||
|
|
||||||
|
/// Functions to manipulate request IDs
|
||||||
|
module RequestId =
|
||||||
|
/// The string representation of the request ID
|
||||||
|
let toString = function RequestId x -> Cuid.toString x
|
||||||
|
/// Create a request ID from a string representation
|
||||||
|
let ofString = Cuid >> RequestId
|
||||||
|
|
||||||
|
|
||||||
|
/// The identifier of a user (the "sub" part of the JWT)
|
||||||
|
type UserId =
|
||||||
|
| UserId of string
|
||||||
|
|
||||||
|
/// Functions to manipulate user IDs
|
||||||
|
module UserId =
|
||||||
|
/// The string representation of the user ID
|
||||||
|
let toString = function UserId x -> x
|
||||||
|
|
||||||
|
|
||||||
|
/// How frequently a request should reappear after it is marked "Prayed"
|
||||||
|
type Recurrence =
|
||||||
|
| Immediate
|
||||||
|
| Hours
|
||||||
|
| Days
|
||||||
|
| Weeks
|
||||||
|
|
||||||
|
/// Functions to manipulate recurrences
|
||||||
|
module Recurrence =
|
||||||
|
/// Create a string representation of a recurrence
|
||||||
|
let toString =
|
||||||
|
function
|
||||||
|
| Immediate -> "Immediate"
|
||||||
|
| Hours -> "Hours"
|
||||||
|
| Days -> "Days"
|
||||||
|
| Weeks -> "Weeks"
|
||||||
|
/// Create a recurrence value from a string
|
||||||
|
let ofString =
|
||||||
|
function
|
||||||
|
| "Immediate" -> Immediate
|
||||||
|
| "Hours" -> Hours
|
||||||
|
| "Days" -> Days
|
||||||
|
| "Weeks" -> Weeks
|
||||||
|
| it -> invalidOp $"{it} is not a valid recurrence"
|
||||||
|
/// An hour's worth of seconds
|
||||||
|
let private oneHour = 3_600L
|
||||||
|
/// The duration of the recurrence (in milliseconds)
|
||||||
|
let duration x =
|
||||||
|
(match x with
|
||||||
|
| Immediate -> 0L
|
||||||
|
| Hours -> oneHour
|
||||||
|
| Days -> oneHour * 24L
|
||||||
|
| Weeks -> oneHour * 24L * 7L)
|
||||||
|
|
||||||
|
|
||||||
|
/// The action taken on a request as part of a history entry
|
||||||
|
type RequestAction =
|
||||||
|
| Created
|
||||||
|
| Prayed
|
||||||
|
| Updated
|
||||||
|
| Answered
|
||||||
|
|
||||||
|
|
||||||
|
/// History is a record of action taken on a prayer request, including updates to its text
|
||||||
|
[<CLIMutable; NoComparison; NoEquality>]
|
||||||
|
type History = {
|
||||||
|
/// The time when this history entry was made
|
||||||
|
asOf : Instant
|
||||||
|
/// The status for this history entry
|
||||||
|
status : RequestAction
|
||||||
|
/// The text of the update, if applicable
|
||||||
|
text : string option
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Note is a note regarding a prayer request that does not result in an update to its text
|
||||||
|
[<CLIMutable; NoComparison; NoEquality>]
|
||||||
|
type Note = {
|
||||||
|
/// The time when this note was made
|
||||||
|
asOf : Instant
|
||||||
|
/// The text of the notes
|
||||||
|
notes : string
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request is the identifying record for a prayer request
|
||||||
|
[<CLIMutable; NoComparison; NoEquality>]
|
||||||
|
type Request = {
|
||||||
|
/// The ID of the request
|
||||||
|
id : RequestId
|
||||||
|
/// The time this request was initially entered
|
||||||
|
enteredOn : Instant
|
||||||
|
/// 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 : Instant
|
||||||
|
/// The time at which this request should reappear in the user's journal by recurrence
|
||||||
|
showAfter : Instant
|
||||||
|
/// The type of recurrence for this request
|
||||||
|
recurType : Recurrence
|
||||||
|
/// How many of the recurrence intervals should occur between appearances in the journal
|
||||||
|
recurCount : int16
|
||||||
|
/// The history entries for this request
|
||||||
|
history : History list
|
||||||
|
/// The notes for this request
|
||||||
|
notes : Note list
|
||||||
|
}
|
||||||
|
with
|
||||||
|
/// An empty request
|
||||||
|
static member empty =
|
||||||
|
{ id = Cuid.generate () |> RequestId
|
||||||
|
enteredOn = Instant.MinValue
|
||||||
|
userId = UserId ""
|
||||||
|
snoozedUntil = Instant.MinValue
|
||||||
|
showAfter = Instant.MinValue
|
||||||
|
recurType = Immediate
|
||||||
|
recurCount = 0s
|
||||||
|
history = []
|
||||||
|
notes = []
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JournalRequest is the form of a prayer request returned for the request journal display. It also contains
|
||||||
|
/// properties that may be filled for history and notes.
|
||||||
|
[<NoComparison; NoEquality>]
|
||||||
|
type JournalRequest = {
|
||||||
|
/// The ID of the request (just the CUID part)
|
||||||
|
requestId : RequestId
|
||||||
|
/// The ID of the user to whom the request belongs
|
||||||
|
userId : UserId
|
||||||
|
/// The current text of the request
|
||||||
|
text : string
|
||||||
|
/// The last time action was taken on the request
|
||||||
|
asOf : Instant
|
||||||
|
/// The last status for the request
|
||||||
|
lastStatus : RequestAction
|
||||||
|
/// The time that this request should reappear in the user's journal
|
||||||
|
snoozedUntil : Instant
|
||||||
|
/// The time after which this request should reappear in the user's journal by configured recurrence
|
||||||
|
showAfter : Instant
|
||||||
|
/// The type of recurrence for this request
|
||||||
|
recurType : Recurrence
|
||||||
|
/// How many of the recurrence intervals should occur between appearances in the journal
|
||||||
|
recurCount : int16
|
||||||
|
/// History entries for the request
|
||||||
|
history : History list
|
||||||
|
/// Note entries for the request
|
||||||
|
notes : Note list
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Functions to manipulate journal requests
|
||||||
|
module JournalRequest =
|
||||||
|
|
||||||
|
/// Convert a request to the form used for the journal (precomputed values, no notes or history)
|
||||||
|
let ofRequestLite (req : Request) =
|
||||||
|
let hist = req.history |> List.sortByDescending (fun it -> it.asOf) |> List.tryHead
|
||||||
|
{ requestId = req.id
|
||||||
|
userId = req.userId
|
||||||
|
text = req.history
|
||||||
|
|> List.filter (fun it -> Option.isSome it.text)
|
||||||
|
|> List.sortByDescending (fun it -> it.asOf)
|
||||||
|
|> List.tryHead
|
||||||
|
|> Option.map (fun h -> Option.get h.text)
|
||||||
|
|> Option.defaultValue ""
|
||||||
|
asOf = match hist with Some h -> h.asOf | None -> Instant.MinValue
|
||||||
|
lastStatus = match hist with Some h -> h.status | None -> Created
|
||||||
|
snoozedUntil = req.snoozedUntil
|
||||||
|
showAfter = req.showAfter
|
||||||
|
recurType = req.recurType
|
||||||
|
recurCount = req.recurCount
|
||||||
|
history = []
|
||||||
|
notes = []
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same as `ofRequestLite`, but with notes and history
|
||||||
|
let ofRequestFull req =
|
||||||
|
{ ofRequestLite req with
|
||||||
|
history = req.history
|
||||||
|
notes = req.notes
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Functions to manipulate request actions
|
||||||
|
module RequestAction =
|
||||||
|
/// Create a string representation of an action
|
||||||
|
let toString =
|
||||||
|
function
|
||||||
|
| Created -> "Created"
|
||||||
|
| Prayed -> "Prayed"
|
||||||
|
| Updated -> "Updated"
|
||||||
|
| Answered -> "Answered"
|
||||||
|
/// Create a RequestAction from a string
|
||||||
|
let ofString =
|
||||||
|
function
|
||||||
|
| "Created" -> Created
|
||||||
|
| "Prayed" -> Prayed
|
||||||
|
| "Updated" -> Updated
|
||||||
|
| "Answered" -> Answered
|
||||||
|
| it -> invalidOp $"Bad request action {it}"
|
||||||
|
/// Determine if a history's status is `Created`
|
||||||
|
let isCreated hist = hist.status = Created
|
||||||
|
/// Determine if a history's status is `Prayed`
|
||||||
|
let isPrayed hist = hist.status = Prayed
|
||||||
|
/// Determine if a history's status is `Answered`
|
||||||
|
let isAnswered hist = hist.status = Answered
|
603
src/MyPrayerJournal/Handlers.fs
Normal file
603
src/MyPrayerJournal/Handlers.fs
Normal file
@ -0,0 +1,603 @@
|
|||||||
|
/// HTTP handlers for the myPrayerJournal API
|
||||||
|
[<RequireQualifiedAccess>]
|
||||||
|
module MyPrayerJournal.Handlers
|
||||||
|
|
||||||
|
// fsharplint:disable RecordFieldNames
|
||||||
|
|
||||||
|
open Giraffe
|
||||||
|
open Giraffe.Htmx
|
||||||
|
open Microsoft.AspNetCore.Authentication
|
||||||
|
open Microsoft.AspNetCore.Http
|
||||||
|
open System
|
||||||
|
open System.Security.Claims
|
||||||
|
open NodaTime
|
||||||
|
|
||||||
|
/// Helper function to be able to split out log on
|
||||||
|
[<AutoOpen>]
|
||||||
|
module private LogOnHelpers =
|
||||||
|
|
||||||
|
/// Log on, optionally specifying a redirected URL once authentication is complete
|
||||||
|
let logOn url : HttpHandler =
|
||||||
|
fun next ctx -> backgroundTask {
|
||||||
|
match url with
|
||||||
|
| Some it ->
|
||||||
|
do! ctx.ChallengeAsync ("Auth0", AuthenticationProperties (RedirectUri = it))
|
||||||
|
return! next ctx
|
||||||
|
| None -> return! challenge "Auth0" next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handlers for error conditions
|
||||||
|
module Error =
|
||||||
|
|
||||||
|
open Microsoft.Extensions.Logging
|
||||||
|
open System.Threading.Tasks
|
||||||
|
|
||||||
|
/// Handle errors
|
||||||
|
let error (ex : Exception) (log : ILogger) =
|
||||||
|
log.LogError (EventId(), ex, "An unhandled exception has occurred while executing the request.")
|
||||||
|
clearResponse
|
||||||
|
>=> setStatusCode 500
|
||||||
|
>=> setHttpHeader "X-Toast" (sprintf "error|||%s: %s" (ex.GetType().Name) ex.Message)
|
||||||
|
>=> text ex.Message
|
||||||
|
|
||||||
|
/// Handle unauthorized actions, redirecting to log on for GETs, otherwise returning a 401 Not Authorized reponse
|
||||||
|
let notAuthorized : HttpHandler =
|
||||||
|
fun next ctx ->
|
||||||
|
(next, ctx)
|
||||||
|
||> match ctx.Request.Method with
|
||||||
|
| "GET" -> logOn None
|
||||||
|
| _ -> setStatusCode 401 >=> fun _ _ -> Task.FromResult<HttpContext option> None
|
||||||
|
|
||||||
|
/// Handle 404s from the API, sending known URL paths to the Vue app so that they can be handled there
|
||||||
|
let notFound : HttpHandler =
|
||||||
|
setStatusCode 404 >=> text "Not found"
|
||||||
|
|
||||||
|
|
||||||
|
/// Handler helpers
|
||||||
|
[<AutoOpen>]
|
||||||
|
module private Helpers =
|
||||||
|
|
||||||
|
open LiteDB
|
||||||
|
open Microsoft.Extensions.Logging
|
||||||
|
open Microsoft.Net.Http.Headers
|
||||||
|
|
||||||
|
let debug (ctx : HttpContext) message =
|
||||||
|
let fac = ctx.GetService<ILoggerFactory>()
|
||||||
|
let log = fac.CreateLogger "Debug"
|
||||||
|
log.LogInformation message
|
||||||
|
|
||||||
|
/// Get the LiteDB database
|
||||||
|
let db (ctx : HttpContext) = ctx.GetService<LiteDatabase>()
|
||||||
|
|
||||||
|
/// Get the user's "sub" claim
|
||||||
|
let user (ctx : HttpContext) =
|
||||||
|
ctx.User
|
||||||
|
|> Option.ofObj
|
||||||
|
|> Option.map (fun user -> user.Claims |> Seq.tryFind (fun u -> u.Type = ClaimTypes.NameIdentifier))
|
||||||
|
|> Option.flatten
|
||||||
|
|> Option.map (fun claim -> claim.Value)
|
||||||
|
|
||||||
|
/// Get the current user's ID
|
||||||
|
// NOTE: this may raise if you don't run the request through the requiresAuthentication handler first
|
||||||
|
let userId ctx =
|
||||||
|
(user >> Option.get) ctx |> UserId
|
||||||
|
|
||||||
|
/// Get the system clock
|
||||||
|
let clock (ctx : HttpContext) =
|
||||||
|
ctx.GetService<IClock> ()
|
||||||
|
|
||||||
|
/// Get the current instant
|
||||||
|
let now ctx =
|
||||||
|
(clock ctx).GetCurrentInstant ()
|
||||||
|
|
||||||
|
/// Return a 201 CREATED response
|
||||||
|
let created =
|
||||||
|
setStatusCode 201
|
||||||
|
|
||||||
|
/// Return a 201 CREATED response with the location header set for the created resource
|
||||||
|
let createdAt url : HttpHandler =
|
||||||
|
fun next ctx ->
|
||||||
|
(sprintf "%s://%s%s" ctx.Request.Scheme ctx.Request.Host.Value url |> setHttpHeader HeaderNames.Location
|
||||||
|
>=> created) next ctx
|
||||||
|
|
||||||
|
/// Return a 303 SEE OTHER response (forces a GET on the redirected URL)
|
||||||
|
let seeOther (url : string) =
|
||||||
|
noResponseCaching >=> setStatusCode 303 >=> setHttpHeader "Location" url
|
||||||
|
|
||||||
|
/// Render a component result
|
||||||
|
let renderComponent nodes : HttpHandler =
|
||||||
|
noResponseCaching
|
||||||
|
>=> fun next ctx -> backgroundTask {
|
||||||
|
return! ctx.WriteHtmlStringAsync (ViewEngine.RenderView.AsString.htmlNodes nodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
open Views.Layout
|
||||||
|
|
||||||
|
/// Create a page rendering context
|
||||||
|
let pageContext (ctx : HttpContext) pageTitle content = backgroundTask {
|
||||||
|
let! hasSnoozed = backgroundTask {
|
||||||
|
match user ctx with
|
||||||
|
| Some _ -> return! Data.hasSnoozed (userId ctx) (now ctx) (db ctx)
|
||||||
|
| None -> return false
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
isAuthenticated = (user >> Option.isSome) ctx
|
||||||
|
hasSnoozed = hasSnoozed
|
||||||
|
currentUrl = ctx.Request.Path.Value
|
||||||
|
pageTitle = pageTitle
|
||||||
|
content = content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Composable handler to write a view to the output
|
||||||
|
let writeView view : HttpHandler =
|
||||||
|
fun next ctx -> backgroundTask {
|
||||||
|
return! ctx.WriteHtmlViewAsync view
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hold messages across redirects
|
||||||
|
module Messages =
|
||||||
|
|
||||||
|
/// The messages being held
|
||||||
|
let mutable private messages : Map<string, (string * string)> = Map.empty
|
||||||
|
|
||||||
|
/// Locked update to prevent updates by multiple threads
|
||||||
|
let private upd8 = obj ()
|
||||||
|
|
||||||
|
/// Push a new message into the list
|
||||||
|
let push ctx message url = lock upd8 (fun () ->
|
||||||
|
messages <- messages.Add (ctx |> (user >> Option.get), (message, url)))
|
||||||
|
|
||||||
|
/// Add a success message header to the response
|
||||||
|
let pushSuccess ctx message url =
|
||||||
|
push ctx (sprintf "success|||%s" message) url
|
||||||
|
|
||||||
|
/// Pop the messages for the given user
|
||||||
|
let pop userId = lock upd8 (fun () ->
|
||||||
|
let msg = messages.TryFind userId
|
||||||
|
msg |> Option.iter (fun _ -> messages <- messages.Remove userId)
|
||||||
|
msg)
|
||||||
|
|
||||||
|
/// Send a partial result if this is not a full page load (does not append no-cache headers)
|
||||||
|
let partialStatic (pageTitle : string) content : HttpHandler =
|
||||||
|
fun next ctx -> backgroundTask {
|
||||||
|
let isPartial = ctx.Request.IsHtmx && not ctx.Request.IsHtmxRefresh
|
||||||
|
let! pageCtx = pageContext ctx pageTitle content
|
||||||
|
let view = (match isPartial with true -> partial | false -> view) pageCtx
|
||||||
|
return!
|
||||||
|
(next, ctx)
|
||||||
|
||> match user ctx with
|
||||||
|
| Some u ->
|
||||||
|
match Messages.pop u with
|
||||||
|
| Some (msg, url) -> setHttpHeader "X-Toast" msg >=> withHxPush url >=> writeView view
|
||||||
|
| None -> writeView view
|
||||||
|
| None -> writeView view
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send an explicitly non-cached result, rendering as a partial if this is not a full page load
|
||||||
|
let partial pageTitle content =
|
||||||
|
noResponseCaching >=> partialStatic pageTitle content
|
||||||
|
|
||||||
|
/// Add a success message header to the response
|
||||||
|
let withSuccessMessage : string -> HttpHandler =
|
||||||
|
sprintf "success|||%s" >> setHttpHeader "X-Toast"
|
||||||
|
|
||||||
|
/// Hide a modal window when the response is sent
|
||||||
|
let hideModal (name : string) : HttpHandler =
|
||||||
|
setHttpHeader "X-Hide-Modal" name
|
||||||
|
|
||||||
|
|
||||||
|
/// Strongly-typed models for post requests
|
||||||
|
module Models =
|
||||||
|
|
||||||
|
/// An additional note
|
||||||
|
[<CLIMutable; NoComparison; NoEquality>]
|
||||||
|
type NoteEntry = {
|
||||||
|
/// The notes being added
|
||||||
|
notes : string
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A prayer request
|
||||||
|
[<CLIMutable; NoComparison; NoEquality>]
|
||||||
|
type Request = {
|
||||||
|
/// The ID of the request
|
||||||
|
requestId : string
|
||||||
|
/// Where to redirect after saving
|
||||||
|
returnTo : string
|
||||||
|
/// The text of the request
|
||||||
|
requestText : string
|
||||||
|
/// The additional status to record
|
||||||
|
status : string option
|
||||||
|
/// The recurrence type
|
||||||
|
recurType : string
|
||||||
|
/// The recurrence count
|
||||||
|
recurCount : int16 option
|
||||||
|
/// The recurrence interval
|
||||||
|
recurInterval : string option
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The date until which a request should not appear in the journal
|
||||||
|
[<CLIMutable; NoComparison; NoEquality>]
|
||||||
|
type SnoozeUntil = {
|
||||||
|
/// The date (YYYY-MM-DD) at which the request should reappear
|
||||||
|
until : string
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
open MyPrayerJournal.Data.Extensions
|
||||||
|
open NodaTime.Text
|
||||||
|
|
||||||
|
/// Handlers for less-than-full-page HTML requests
|
||||||
|
module Components =
|
||||||
|
|
||||||
|
// GET /components/journal-items
|
||||||
|
let journalItems : HttpHandler =
|
||||||
|
requiresAuthentication Error.notAuthorized
|
||||||
|
>=> fun next ctx -> backgroundTask {
|
||||||
|
let now = now ctx
|
||||||
|
let! jrnl = Data.journalByUserId (userId ctx) (db ctx)
|
||||||
|
let shown = jrnl |> List.filter (fun it -> now > it.snoozedUntil && now > it.showAfter)
|
||||||
|
return! renderComponent [ Views.Journal.journalItems now shown ] next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /components/request-item/[req-id]
|
||||||
|
let requestItem reqId : HttpHandler =
|
||||||
|
requiresAuthentication Error.notAuthorized
|
||||||
|
>=> fun next ctx -> backgroundTask {
|
||||||
|
match! Data.tryJournalById (RequestId.ofString reqId) (userId ctx) (db ctx) with
|
||||||
|
| Some req -> return! renderComponent [ Views.Request.reqListItem (now ctx) req ] next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /components/request/[req-id]/add-notes
|
||||||
|
let addNotes requestId : HttpHandler =
|
||||||
|
requiresAuthentication Error.notAuthorized
|
||||||
|
>=> renderComponent (Views.Journal.notesEdit (RequestId.ofString requestId))
|
||||||
|
|
||||||
|
// GET /components/request/[req-id]/notes
|
||||||
|
let notes requestId : HttpHandler =
|
||||||
|
requiresAuthentication Error.notAuthorized
|
||||||
|
>=> fun next ctx -> backgroundTask {
|
||||||
|
let! notes = Data.notesById (RequestId.ofString requestId) (userId ctx) (db ctx)
|
||||||
|
return! renderComponent (Views.Request.notes (now ctx) notes) next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /components/request/[req-id]/snooze
|
||||||
|
let snooze requestId : HttpHandler =
|
||||||
|
requiresAuthentication Error.notAuthorized
|
||||||
|
>=> renderComponent [ RequestId.ofString requestId |> Views.Journal.snooze ]
|
||||||
|
|
||||||
|
|
||||||
|
/// / URL
|
||||||
|
module Home =
|
||||||
|
|
||||||
|
// GET /
|
||||||
|
let home : HttpHandler =
|
||||||
|
partialStatic "Welcome!" Views.Layout.home
|
||||||
|
|
||||||
|
|
||||||
|
/// /journal URL
|
||||||
|
module Journal =
|
||||||
|
|
||||||
|
// GET /journal
|
||||||
|
let journal : HttpHandler =
|
||||||
|
requiresAuthentication Error.notAuthorized
|
||||||
|
>=> fun next ctx -> backgroundTask {
|
||||||
|
let usr =
|
||||||
|
ctx.User.Claims
|
||||||
|
|> Seq.tryFind (fun c -> c.Type = ClaimTypes.GivenName)
|
||||||
|
|> Option.map (fun c -> c.Value)
|
||||||
|
|> Option.defaultValue "Your"
|
||||||
|
let title = usr |> match usr with "Your" -> sprintf "%s" | _ -> sprintf "%s's"
|
||||||
|
return! partial (sprintf "%s Prayer Journal" title) (Views.Journal.journal usr) next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// /legal URLs
|
||||||
|
module Legal =
|
||||||
|
|
||||||
|
// GET /legal/privacy-policy
|
||||||
|
let privacyPolicy : HttpHandler =
|
||||||
|
partialStatic "Privacy Policy" Views.Legal.privacyPolicy
|
||||||
|
|
||||||
|
// GET /legal/terms-of-service
|
||||||
|
let termsOfService : HttpHandler =
|
||||||
|
partialStatic "Terms of Service" Views.Legal.termsOfService
|
||||||
|
|
||||||
|
|
||||||
|
/// /api/request and /request(s) URLs
|
||||||
|
module Request =
|
||||||
|
|
||||||
|
// GET /request/[req-id]/edit
|
||||||
|
let edit requestId : HttpHandler =
|
||||||
|
requiresAuthentication Error.notAuthorized
|
||||||
|
>=> fun next ctx -> backgroundTask {
|
||||||
|
let returnTo =
|
||||||
|
match ctx.Request.Headers.Referer.[0] with
|
||||||
|
| it when it.EndsWith "/active" -> "active"
|
||||||
|
| it when it.EndsWith "/snoozed" -> "snoozed"
|
||||||
|
| _ -> "journal"
|
||||||
|
match requestId with
|
||||||
|
| "new" ->
|
||||||
|
return! partial "Add Prayer Request"
|
||||||
|
(Views.Request.edit (JournalRequest.ofRequestLite Request.empty) returnTo true) next ctx
|
||||||
|
| _ ->
|
||||||
|
match! Data.tryJournalById (RequestId.ofString requestId) (userId ctx) (db ctx) with
|
||||||
|
| Some req ->
|
||||||
|
debug ctx "Found - sending view"
|
||||||
|
return! partial "Edit Prayer Request" (Views.Request.edit req returnTo false) next ctx
|
||||||
|
| None ->
|
||||||
|
debug ctx "Not found - uh oh..."
|
||||||
|
return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH /request/[req-id]/prayed
|
||||||
|
let prayed requestId : HttpHandler =
|
||||||
|
requiresAuthentication Error.notAuthorized
|
||||||
|
>=> fun next ctx -> backgroundTask {
|
||||||
|
let db = db ctx
|
||||||
|
let usrId = userId ctx
|
||||||
|
let reqId = RequestId.ofString requestId
|
||||||
|
match! Data.tryRequestById reqId usrId db with
|
||||||
|
| Some req ->
|
||||||
|
let now = now ctx
|
||||||
|
do! Data.addHistory reqId usrId { asOf = now; status = Prayed; text = None } db
|
||||||
|
let nextShow =
|
||||||
|
match Recurrence.duration req.recurType with
|
||||||
|
| 0L -> Instant.MinValue
|
||||||
|
| duration -> now.Plus (Duration.FromSeconds (duration * int64 req.recurCount))
|
||||||
|
do! Data.updateShowAfter reqId usrId 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
|
||||||
|
let addNote requestId : HttpHandler =
|
||||||
|
requiresAuthentication Error.notAuthorized
|
||||||
|
>=> fun next ctx -> backgroundTask {
|
||||||
|
let db = db ctx
|
||||||
|
let usrId = userId ctx
|
||||||
|
let reqId = RequestId.ofString requestId
|
||||||
|
match! Data.tryRequestById reqId usrId db with
|
||||||
|
| Some _ ->
|
||||||
|
let! notes = ctx.BindFormAsync<Models.NoteEntry> ()
|
||||||
|
do! Data.addNote reqId usrId { asOf = now ctx; notes = notes.notes } db
|
||||||
|
do! db.saveChanges ()
|
||||||
|
return! (withSuccessMessage "Added Notes" >=> hideModal "notes" >=> created) next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /requests/active
|
||||||
|
let active : HttpHandler =
|
||||||
|
requiresAuthentication Error.notAuthorized
|
||||||
|
>=> fun next ctx -> backgroundTask {
|
||||||
|
let! reqs = Data.journalByUserId (userId ctx) (db ctx)
|
||||||
|
return! partial "Active Requests" (Views.Request.active (now ctx) reqs) next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /requests/snoozed
|
||||||
|
let snoozed : HttpHandler =
|
||||||
|
requiresAuthentication Error.notAuthorized
|
||||||
|
>=> fun next ctx -> backgroundTask {
|
||||||
|
let! reqs = Data.journalByUserId (userId ctx) (db ctx)
|
||||||
|
let now = now ctx
|
||||||
|
let snoozed = reqs |> List.filter (fun it -> it.snoozedUntil > now)
|
||||||
|
return! partial "Active Requests" (Views.Request.snoozed now snoozed) next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /requests/answered
|
||||||
|
let answered : HttpHandler =
|
||||||
|
requiresAuthentication Error.notAuthorized
|
||||||
|
>=> fun next ctx -> backgroundTask {
|
||||||
|
let! reqs = Data.answeredRequests (userId ctx) (db ctx)
|
||||||
|
return! partial "Answered Requests" (Views.Request.answered (now ctx) reqs) next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/request/[req-id]
|
||||||
|
let get requestId : HttpHandler =
|
||||||
|
requiresAuthentication Error.notAuthorized
|
||||||
|
>=> fun next ctx -> backgroundTask {
|
||||||
|
match! Data.tryJournalById (RequestId.ofString requestId) (userId ctx) (db ctx) with
|
||||||
|
| Some req -> return! json req next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /request/[req-id]/full
|
||||||
|
let getFull requestId : HttpHandler =
|
||||||
|
requiresAuthentication Error.notAuthorized
|
||||||
|
>=> fun next ctx -> backgroundTask {
|
||||||
|
match! Data.tryFullRequestById (RequestId.ofString requestId) (userId ctx) (db ctx) with
|
||||||
|
| Some req -> return! partial "Prayer Request" (Views.Request.full (clock ctx) req) next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH /request/[req-id]/show
|
||||||
|
let show requestId : HttpHandler =
|
||||||
|
requiresAuthentication Error.notAuthorized
|
||||||
|
>=> fun next ctx -> backgroundTask {
|
||||||
|
let db = db ctx
|
||||||
|
let usrId = userId ctx
|
||||||
|
let reqId = RequestId.ofString requestId
|
||||||
|
match! Data.tryRequestById reqId usrId db with
|
||||||
|
| Some _ ->
|
||||||
|
do! Data.updateShowAfter reqId usrId Instant.MinValue db
|
||||||
|
do! db.saveChanges ()
|
||||||
|
return! (withSuccessMessage "Request now shown" >=> Components.requestItem requestId) next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH /request/[req-id]/snooze
|
||||||
|
let snooze requestId : HttpHandler =
|
||||||
|
requiresAuthentication Error.notAuthorized
|
||||||
|
>=> fun next ctx -> backgroundTask {
|
||||||
|
let db = db ctx
|
||||||
|
let usrId = userId ctx
|
||||||
|
let reqId = RequestId.ofString requestId
|
||||||
|
match! Data.tryRequestById reqId usrId 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! Data.updateSnoozed reqId usrId date db
|
||||||
|
do! db.saveChanges ()
|
||||||
|
return!
|
||||||
|
(withSuccessMessage $"Request snoozed until {until.until}"
|
||||||
|
>=> hideModal "snooze"
|
||||||
|
>=> Components.journalItems) next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH /request/[req-id]/cancel-snooze
|
||||||
|
let cancelSnooze requestId : HttpHandler =
|
||||||
|
requiresAuthentication Error.notAuthorized
|
||||||
|
>=> fun next ctx -> backgroundTask {
|
||||||
|
let db = db ctx
|
||||||
|
let usrId = userId ctx
|
||||||
|
let reqId = RequestId.ofString requestId
|
||||||
|
match! Data.tryRequestById reqId usrId db with
|
||||||
|
| Some _ ->
|
||||||
|
do! Data.updateSnoozed reqId usrId Instant.MinValue db
|
||||||
|
do! db.saveChanges ()
|
||||||
|
return! (withSuccessMessage "Request unsnoozed" >=> Components.requestItem requestId) next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive a recurrence and interval from its primitive representation in the form
|
||||||
|
let private parseRecurrence (form : Models.Request) =
|
||||||
|
(Recurrence.ofString (match form.recurInterval with Some x -> x | _ -> "Immediate"),
|
||||||
|
defaultArg form.recurCount (int16 0))
|
||||||
|
|
||||||
|
// POST /request
|
||||||
|
let add : HttpHandler =
|
||||||
|
requiresAuthentication Error.notAuthorized
|
||||||
|
>=> fun next ctx -> backgroundTask {
|
||||||
|
let! form = ctx.BindModelAsync<Models.Request> ()
|
||||||
|
let db = db ctx
|
||||||
|
let usrId = userId ctx
|
||||||
|
let now = now ctx
|
||||||
|
let (recur, interval) = parseRecurrence form
|
||||||
|
let req =
|
||||||
|
{ Request.empty with
|
||||||
|
userId = usrId
|
||||||
|
enteredOn = now
|
||||||
|
showAfter = Instant.MinValue
|
||||||
|
recurType = recur
|
||||||
|
recurCount = interval
|
||||||
|
history = [
|
||||||
|
{ asOf = now
|
||||||
|
status = Created
|
||||||
|
text = Some form.requestText
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
Data.addRequest req db
|
||||||
|
do! db.saveChanges ()
|
||||||
|
Messages.pushSuccess ctx "Added prayer request" "/journal"
|
||||||
|
return! seeOther "/journal" next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH /request
|
||||||
|
let update : HttpHandler =
|
||||||
|
requiresAuthentication Error.notAuthorized
|
||||||
|
>=> fun next ctx -> backgroundTask {
|
||||||
|
let! form = ctx.BindModelAsync<Models.Request> ()
|
||||||
|
let db = db ctx
|
||||||
|
let usrId = userId ctx
|
||||||
|
match! Data.tryJournalById (RequestId.ofString form.requestId) usrId db with
|
||||||
|
| Some req ->
|
||||||
|
// update recurrence if changed
|
||||||
|
let (recur, interval) = parseRecurrence form
|
||||||
|
match recur = req.recurType && interval = req.recurCount with
|
||||||
|
| true -> ()
|
||||||
|
| false ->
|
||||||
|
do! Data.updateRecurrence req.requestId usrId recur interval db
|
||||||
|
match recur with
|
||||||
|
| Immediate -> do! Data.updateShowAfter req.requestId usrId Instant.MinValue db
|
||||||
|
| _ -> ()
|
||||||
|
// append history
|
||||||
|
let upd8Text = form.requestText.Trim ()
|
||||||
|
let text = match upd8Text = req.text with true -> None | false -> Some upd8Text
|
||||||
|
do! Data.addHistory req.requestId usrId
|
||||||
|
{ asOf = now ctx; status = (Option.get >> RequestAction.ofString) form.status; text = text } db
|
||||||
|
do! db.saveChanges ()
|
||||||
|
let nextUrl =
|
||||||
|
match form.returnTo with
|
||||||
|
| "active" -> "/requests/active"
|
||||||
|
| "snoozed" -> "/requests/snoozed"
|
||||||
|
| _ (* "journal" *) -> "/journal"
|
||||||
|
Messages.pushSuccess ctx "Prayer request updated successfully" nextUrl
|
||||||
|
return! seeOther nextUrl next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Handlers for /user URLs
|
||||||
|
module User =
|
||||||
|
|
||||||
|
open Microsoft.AspNetCore.Authentication.Cookies
|
||||||
|
|
||||||
|
// GET /user/log-on
|
||||||
|
let logOn : HttpHandler =
|
||||||
|
logOn (Some "/journal")
|
||||||
|
|
||||||
|
// GET /user/log-off
|
||||||
|
let logOff : HttpHandler =
|
||||||
|
requiresAuthentication Error.notAuthorized
|
||||||
|
>=> fun next ctx -> task {
|
||||||
|
do! ctx.SignOutAsync ("Auth0", AuthenticationProperties (RedirectUri = "/"))
|
||||||
|
do! ctx.SignOutAsync CookieAuthenticationDefaults.AuthenticationScheme
|
||||||
|
return! next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
open Giraffe.EndpointRouting
|
||||||
|
|
||||||
|
/// The routes for myPrayerJournal
|
||||||
|
let routes =
|
||||||
|
[ GET_HEAD [ route "/" Home.home ]
|
||||||
|
subRoute "/components/" [
|
||||||
|
GET_HEAD [
|
||||||
|
route "journal-items" Components.journalItems
|
||||||
|
routef "request/%s/add-notes" Components.addNotes
|
||||||
|
routef "request/%s/item" Components.requestItem
|
||||||
|
routef "request/%s/notes" Components.notes
|
||||||
|
routef "request/%s/snooze" Components.snooze
|
||||||
|
]
|
||||||
|
]
|
||||||
|
GET_HEAD [ route "/journal" Journal.journal ]
|
||||||
|
subRoute "/legal/" [
|
||||||
|
GET_HEAD [
|
||||||
|
route "privacy-policy" Legal.privacyPolicy
|
||||||
|
route "terms-of-service" Legal.termsOfService
|
||||||
|
]
|
||||||
|
]
|
||||||
|
subRoute "/request" [
|
||||||
|
GET_HEAD [
|
||||||
|
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
|
||||||
|
routef "/%s/cancel-snooze" Request.cancelSnooze
|
||||||
|
routef "/%s/prayed" Request.prayed
|
||||||
|
routef "/%s/show" Request.show
|
||||||
|
routef "/%s/snooze" Request.snooze
|
||||||
|
]
|
||||||
|
POST [
|
||||||
|
route "" Request.add
|
||||||
|
routef "/%s/note" Request.addNote
|
||||||
|
]
|
||||||
|
]
|
||||||
|
subRoute "/user/" [
|
||||||
|
GET_HEAD [
|
||||||
|
route "log-off" User.logOff
|
||||||
|
route "log-on" User.logOn
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
33
src/MyPrayerJournal/MyPrayerJournal.fsproj
Normal file
33
src/MyPrayerJournal/MyPrayerJournal.fsproj
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net6.0</TargetFramework>
|
||||||
|
<Version>3.0.0.0</Version>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Include="Domain.fs" />
|
||||||
|
<Compile Include="Data.fs" />
|
||||||
|
<Compile Include="Dates.fs" />
|
||||||
|
<Compile Include="Views/Helpers.fs" />
|
||||||
|
<Compile Include="Views/Journal.fs" />
|
||||||
|
<Compile Include="Views/Layout.fs" />
|
||||||
|
<Compile Include="Views/Legal.fs" />
|
||||||
|
<Compile Include="Views/Request.fs" />
|
||||||
|
<Compile Include="Handlers.fs" />
|
||||||
|
<Compile Include="Program.fs" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="FSharp.SystemTextJson" Version="0.17.4" />
|
||||||
|
<PackageReference Include="FunctionalCuid" Version="1.0.0" />
|
||||||
|
<PackageReference Include="Giraffe" Version="5.0.0" />
|
||||||
|
<PackageReference Include="LiteDB" Version="5.0.11" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="5.0.10" />
|
||||||
|
<PackageReference Include="NodaTime" Version="3.0.9" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../../../Giraffe.Htmx/src/Htmx/Giraffe.Htmx.fsproj" />
|
||||||
|
<ProjectReference Include="../../../Giraffe.Htmx/src/ViewEngine.Htmx/Giraffe.ViewEngine.Htmx.fsproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Folder Include="wwwroot\" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
181
src/MyPrayerJournal/Program.fs
Normal file
181
src/MyPrayerJournal/Program.fs
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
module MyPrayerJournal.Api
|
||||||
|
|
||||||
|
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) =
|
||||||
|
match bldr.Environment.IsDevelopment () with
|
||||||
|
| true -> ()
|
||||||
|
| false -> 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
|
||||||
|
| _, _ -> ()
|
||||||
|
|
||||||
|
bldr.Services
|
||||||
|
.AddRouting()
|
||||||
|
.AddGiraffe()
|
||||||
|
.AddSingleton<IClock>(SystemClock.Instance)
|
||||||
|
.Configure<CookiePolicyOptions>(
|
||||||
|
fun (opts : CookiePolicyOptions) ->
|
||||||
|
opts.MinimumSameSitePolicy <- SameSiteMode.Unspecified
|
||||||
|
opts.OnAppendCookie <- fun ctx -> sameSite ctx.CookieOptions
|
||||||
|
opts.OnDeleteCookie <- fun ctx -> sameSite ctx.CookieOptions)
|
||||||
|
.AddAuthentication(
|
||||||
|
/// Use HTTP "Bearer" authentication with JWTs
|
||||||
|
fun opts ->
|
||||||
|
opts.DefaultAuthenticateScheme <- CookieAuthenticationDefaults.AuthenticationScheme
|
||||||
|
opts.DefaultSignInScheme <- CookieAuthenticationDefaults.AuthenticationScheme
|
||||||
|
opts.DefaultChallengeScheme <- CookieAuthenticationDefaults.AuthenticationScheme)
|
||||||
|
.AddCookie()
|
||||||
|
.AddOpenIdConnect("Auth0",
|
||||||
|
/// Configure OIDC with Auth0 options from configuration
|
||||||
|
fun opts ->
|
||||||
|
let cfg = bldr.Configuration.GetSection "Auth0"
|
||||||
|
opts.Authority <- sprintf "https://%s/" cfg["Domain"]
|
||||||
|
opts.ClientId <- cfg["Id"]
|
||||||
|
opts.ClientSecret <- cfg["Secret"]
|
||||||
|
opts.ResponseType <- OpenIdConnectResponseType.Code
|
||||||
|
|
||||||
|
opts.Scope.Clear ()
|
||||||
|
opts.Scope.Add "openid"
|
||||||
|
opts.Scope.Add "profile"
|
||||||
|
|
||||||
|
opts.CallbackPath <- PathString "/user/log-on/success"
|
||||||
|
opts.ClaimsIssuer <- "Auth0"
|
||||||
|
opts.SaveTokens <- true
|
||||||
|
|
||||||
|
opts.Events <- OpenIdConnectEvents ()
|
||||||
|
opts.Events.OnRedirectToIdentityProviderForSignOut <- fun ctx ->
|
||||||
|
let returnTo =
|
||||||
|
match ctx.Properties.RedirectUri with
|
||||||
|
| it when isNull it || it = "" -> ""
|
||||||
|
| redirUri ->
|
||||||
|
let finalRedirUri =
|
||||||
|
match redirUri.StartsWith "/" with
|
||||||
|
| true ->
|
||||||
|
// transform to absolute
|
||||||
|
let request = ctx.Request
|
||||||
|
sprintf "%s://%s%s%s" request.Scheme request.Host.Value request.PathBase.Value redirUri
|
||||||
|
| false -> redirUri
|
||||||
|
Uri.EscapeDataString finalRedirUri |> sprintf "&returnTo=%s"
|
||||||
|
sprintf "https://%s/v2/logout?client_id=%s%s" cfg["Domain"] cfg["Id"] returnTo
|
||||||
|
|> ctx.Response.Redirect
|
||||||
|
ctx.HandleResponse ()
|
||||||
|
|
||||||
|
Task.CompletedTask
|
||||||
|
opts.Events.OnRedirectToIdentityProvider <- fun ctx ->
|
||||||
|
let bldr = UriBuilder ctx.ProtocolMessage.RedirectUri
|
||||||
|
bldr.Scheme <- cfg["Scheme"]
|
||||||
|
bldr.Port <- int cfg["Port"]
|
||||||
|
ctx.ProtocolMessage.RedirectUri <- string bldr
|
||||||
|
Task.CompletedTask
|
||||||
|
)
|
||||||
|
|> ignore
|
||||||
|
let jsonOptions = JsonSerializerOptions ()
|
||||||
|
jsonOptions.Converters.Add (JsonFSharpConverter ())
|
||||||
|
let db = new LiteDatabase (bldr.Configuration.GetConnectionString "db")
|
||||||
|
Data.Startup.ensureDb db
|
||||||
|
bldr.Services.AddSingleton(jsonOptions)
|
||||||
|
.AddSingleton<Json.ISerializer, SystemTextJson.Serializer>()
|
||||||
|
.AddSingleton<LiteDatabase> db
|
||||||
|
|> ignore
|
||||||
|
bldr.Build ()
|
||||||
|
|
||||||
|
|
||||||
|
open Giraffe.EndpointRouting
|
||||||
|
|
||||||
|
/// Configure the web application
|
||||||
|
let application (app : WebApplication) =
|
||||||
|
// match app.Environment.IsDevelopment () with
|
||||||
|
// | true -> app.UseDeveloperExceptionPage ()
|
||||||
|
// | false -> app.UseGiraffeErrorHandler Handlers.Error.error
|
||||||
|
// |> ignore
|
||||||
|
app
|
||||||
|
.UseStaticFiles()
|
||||||
|
.UseCookiePolicy()
|
||||||
|
.UseRouting()
|
||||||
|
.UseAuthentication()
|
||||||
|
.UseGiraffeErrorHandler(Handlers.Error.error)
|
||||||
|
.UseEndpoints (fun e -> e.MapGiraffeEndpoints Handlers.routes |> ignore)
|
||||||
|
|> ignore
|
||||||
|
app
|
||||||
|
|
||||||
|
/// 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
|
31
src/MyPrayerJournal/Views/Helpers.fs
Normal file
31
src/MyPrayerJournal/Views/Helpers.fs
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
/// Internal partial views
|
||||||
|
[<AutoOpen>]
|
||||||
|
module private MyPrayerJournal.Views.Helpers
|
||||||
|
|
||||||
|
open Giraffe.ViewEngine
|
||||||
|
open Giraffe.ViewEngine.Htmx
|
||||||
|
open MyPrayerJournal
|
||||||
|
open NodaTime
|
||||||
|
|
||||||
|
/// Create a link that targets the `#top` element and pushes a URL to history
|
||||||
|
let pageLink href attrs =
|
||||||
|
attrs
|
||||||
|
|> List.append [ _href href; _hxBoost; _hxTarget "#top"; _hxSwap HxSwap.InnerHtml; _hxPushUrl ]
|
||||||
|
|> a
|
||||||
|
|
||||||
|
/// Create a Material icon
|
||||||
|
let icon name = span [ _class "material-icons" ] [ str name ]
|
||||||
|
|
||||||
|
/// Create a card when there are no results found
|
||||||
|
let noResults heading link buttonText text =
|
||||||
|
div [ _class "card" ] [
|
||||||
|
h5 [ _class "card-header"] [ str heading ]
|
||||||
|
div [ _class "card-body text-center" ] [
|
||||||
|
p [ _class "card-text" ] text
|
||||||
|
pageLink link [ _class "btn btn-primary" ] [ str buttonText ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Create a date with a span tag, displaying the relative date with the full date/time in the tooltip
|
||||||
|
let relativeDate (date : Instant) now =
|
||||||
|
span [ _title (date.ToDateTimeOffset().ToString ("f", null)) ] [ Dates.formatDistance now date |> str ]
|
177
src/MyPrayerJournal/Views/Journal.fs
Normal file
177
src/MyPrayerJournal/Views/Journal.fs
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
/// Views for journal pages and components
|
||||||
|
module MyPrayerJournal.Views.Journal
|
||||||
|
|
||||||
|
open Giraffe.ViewEngine
|
||||||
|
open Giraffe.ViewEngine.Accessibility
|
||||||
|
open Giraffe.ViewEngine.Htmx
|
||||||
|
open MyPrayerJournal
|
||||||
|
|
||||||
|
/// Display a card for this prayer request
|
||||||
|
let journalCard now req =
|
||||||
|
let reqId = RequestId.toString req.requestId
|
||||||
|
let spacer = span [] [ rawText " " ]
|
||||||
|
div [ _class "col" ] [
|
||||||
|
div [ _class "card h-100" ] [
|
||||||
|
div [ _class "card-header p-0 d-flex"; _roleToolBar ] [
|
||||||
|
pageLink $"/request/{reqId}/edit" [ _class "btn btn-secondary"; _title "Edit Request" ] [ icon "edit" ]
|
||||||
|
spacer
|
||||||
|
button [
|
||||||
|
_type "button"
|
||||||
|
_class "btn btn-secondary"
|
||||||
|
_title "Add Notes"
|
||||||
|
_data "bs-toggle" "modal"
|
||||||
|
_data "bs-target" "#notesModal"
|
||||||
|
_hxGet $"/components/request/{reqId}/add-notes"
|
||||||
|
_hxTarget "#notesBody"
|
||||||
|
_hxSwap HxSwap.InnerHtml
|
||||||
|
] [ icon "comment" ]
|
||||||
|
spacer
|
||||||
|
button [
|
||||||
|
_type "button"
|
||||||
|
_class "btn btn-secondary"
|
||||||
|
_title "Snooze Request"
|
||||||
|
_data "bs-toggle" "modal"
|
||||||
|
_data "bs-target" "#snoozeModal"
|
||||||
|
_hxGet $"/components/request/{reqId}/snooze"
|
||||||
|
_hxTarget "#snoozeBody"
|
||||||
|
_hxSwap HxSwap.InnerHtml
|
||||||
|
] [ icon "schedule" ]
|
||||||
|
div [ _class "flex-grow-1" ] []
|
||||||
|
button [
|
||||||
|
_type "button"
|
||||||
|
_class "btn btn-success w-25"
|
||||||
|
_hxPatch $"/request/{reqId}/prayed"
|
||||||
|
_title "Mark as Prayed"
|
||||||
|
] [ icon "done" ]
|
||||||
|
]
|
||||||
|
div [ _class "card-body" ] [
|
||||||
|
p [ _class "request-text" ] [ str req.text ]
|
||||||
|
]
|
||||||
|
div [ _class "card-footer text-end text-muted px-1 py-0" ] [
|
||||||
|
em [] [ str "last activity "; relativeDate req.asOf now ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
/// The journal loading page
|
||||||
|
let journal user = article [ _class "container-fluid mt-3" ] [
|
||||||
|
h2 [ _class "pb-3" ] [
|
||||||
|
str user
|
||||||
|
match user with "Your" -> () | _ -> rawText "’s"
|
||||||
|
str " Prayer Journal"
|
||||||
|
]
|
||||||
|
p [ _class "pb-3 text-center" ] [
|
||||||
|
pageLink "/request/new/edit" [ _class "btn btn-primary "] [ icon "add_box"; str " Add a Prayer Request" ]
|
||||||
|
]
|
||||||
|
p [ _hxGet "/components/journal-items"; _hxSwap HxSwap.OuterHtml; _hxTrigger HxTrigger.Load ] [
|
||||||
|
rawText "Loading your prayer journal…"
|
||||||
|
]
|
||||||
|
div [
|
||||||
|
_id "notesModal"
|
||||||
|
_class "modal fade"
|
||||||
|
_tabindex "-1"
|
||||||
|
_ariaLabelledBy "nodesModalLabel"
|
||||||
|
_ariaHidden "true"
|
||||||
|
] [
|
||||||
|
div [ _class "modal-dialog modal-dialog-scrollable" ] [
|
||||||
|
div [ _class "modal-content" ] [
|
||||||
|
div [ _class "modal-header" ] [
|
||||||
|
h5 [ _class "modal-title"; _id "nodesModalLabel" ] [ str "Add Notes to Prayer Request" ]
|
||||||
|
button [ _type "button"; _class "btn-close"; _data "bs-dismiss" "modal"; _ariaLabel "Close" ] []
|
||||||
|
]
|
||||||
|
div [ _class "modal-body"; _id "notesBody" ] [ ]
|
||||||
|
div [ _class "modal-footer" ] [
|
||||||
|
button [ _type "button"; _id "notesDismiss"; _class "btn btn-secondary"; _data "bs-dismiss" "modal" ] [
|
||||||
|
str "Close"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
div [
|
||||||
|
_id "snoozeModal"
|
||||||
|
_class "modal fade"
|
||||||
|
_tabindex "-1"
|
||||||
|
_ariaLabelledBy "snoozeModalLabel"
|
||||||
|
_ariaHidden "true"
|
||||||
|
] [
|
||||||
|
div [ _class "modal-dialog modal-sm" ] [
|
||||||
|
div [ _class "modal-content" ] [
|
||||||
|
div [ _class "modal-header" ] [
|
||||||
|
h5 [ _class "modal-title"; _id "snoozeModalLabel" ] [ str "Snooze Prayer Request" ]
|
||||||
|
button [ _type "button"; _class "btn-close"; _data "bs-dismiss" "modal"; _ariaLabel "Close" ] []
|
||||||
|
]
|
||||||
|
div [ _class "modal-body"; _id "snoozeBody" ] [ ]
|
||||||
|
div [ _class "modal-footer" ] [
|
||||||
|
button [ _type "button"; _id "snoozeDismiss"; _class "btn btn-secondary"; _data "bs-dismiss" "modal" ] [
|
||||||
|
str "Close"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
/// The journal items
|
||||||
|
let journalItems now items =
|
||||||
|
match items |> List.isEmpty with
|
||||||
|
| true ->
|
||||||
|
noResults "No Active Requests" "/request/new/edit" "Add a Request" [
|
||||||
|
rawText "You have no requests to be shown; see the “Active” link above for snoozed or deferred "
|
||||||
|
rawText "requests, and the “Answered” link for answered requests"
|
||||||
|
]
|
||||||
|
| false ->
|
||||||
|
items
|
||||||
|
|> List.map (journalCard now)
|
||||||
|
|> section [
|
||||||
|
_id "journalItems"
|
||||||
|
_class "row row-cols-1 row-cols-md-2 row-cols-lg-3 row-cols-xl-4 g-3"
|
||||||
|
_hxTarget "this"
|
||||||
|
_hxSwap HxSwap.OuterHtml
|
||||||
|
]
|
||||||
|
|
||||||
|
/// The notes edit modal body
|
||||||
|
let notesEdit requestId =
|
||||||
|
let reqId = RequestId.toString requestId
|
||||||
|
[ form [ _hxPost $"/request/{reqId}/note" ] [
|
||||||
|
div [ _class "form-floating pb-3" ] [
|
||||||
|
textarea [
|
||||||
|
_id "notes"
|
||||||
|
_name "notes"
|
||||||
|
_class "form-control"
|
||||||
|
_style "min-height: 8rem;"
|
||||||
|
_placeholder "Notes"
|
||||||
|
_autofocus; _required
|
||||||
|
] [ ]
|
||||||
|
label [ _for "notes" ] [ str "Notes" ]
|
||||||
|
]
|
||||||
|
p [ _class "text-end" ] [ button [ _type "submit"; _class "btn btn-primary" ] [ str "Add Notes" ] ]
|
||||||
|
]
|
||||||
|
hr [ _style "margin: .5rem -1rem" ]
|
||||||
|
div [ _id "priorNotes" ] [
|
||||||
|
p [ _class "text-center pt-3" ] [
|
||||||
|
button [
|
||||||
|
_type "button"
|
||||||
|
_class "btn btn-secondary"
|
||||||
|
_hxGet $"/components/request/{reqId}/notes"
|
||||||
|
_hxSwap HxSwap.OuterHtml
|
||||||
|
_hxTarget "#priorNotes"
|
||||||
|
] [str "Load Prior Notes" ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
/// The snooze edit form
|
||||||
|
let snooze requestId =
|
||||||
|
let today = System.DateTime.Today.ToString "yyyy-MM-dd"
|
||||||
|
form [
|
||||||
|
_hxPatch $"/request/{RequestId.toString requestId}/snooze"
|
||||||
|
_hxTarget "#journalItems"
|
||||||
|
_hxSwap HxSwap.OuterHtml
|
||||||
|
] [
|
||||||
|
div [ _class "form-floating pb-3" ] [
|
||||||
|
input [ _type "date"; _id "until"; _name "until"; _class "form-control"; _min today; _required ]
|
||||||
|
label [ _for "until" ] [ str "Until" ]
|
||||||
|
]
|
||||||
|
p [ _class "text-end mb-0" ] [ button [ _type "submit"; _class "btn btn-primary" ] [ str "Snooze" ] ]
|
||||||
|
]
|
151
src/MyPrayerJournal/Views/Layout.fs
Normal file
151
src/MyPrayerJournal/Views/Layout.fs
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
/// Layout / home views
|
||||||
|
module MyPrayerJournal.Views.Layout
|
||||||
|
|
||||||
|
// fsharplint:disable RecordFieldNames
|
||||||
|
|
||||||
|
open Giraffe.ViewEngine
|
||||||
|
open Giraffe.ViewEngine.Accessibility
|
||||||
|
|
||||||
|
/// The data needed to render a page-level view
|
||||||
|
type PageRenderContext = {
|
||||||
|
/// Whether the user is authenticated
|
||||||
|
isAuthenticated : bool
|
||||||
|
/// Whether the user has snoozed requests
|
||||||
|
hasSnoozed : bool
|
||||||
|
/// The current URL
|
||||||
|
currentUrl : string
|
||||||
|
/// The title for the page to be rendered
|
||||||
|
pageTitle : string
|
||||||
|
/// The content of the page
|
||||||
|
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 prayed for "
|
||||||
|
str "them, update them as God moves in the situation, and record a final answer received on that request. It also "
|
||||||
|
str "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 also "
|
||||||
|
rawText "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" ]
|
||||||
|
span [ _class "p" ] [ str "Prayer" ]
|
||||||
|
span [ _class "j" ] [ str "Journal" ]
|
||||||
|
]
|
||||||
|
seq {
|
||||||
|
let navLink (matchUrl : string) =
|
||||||
|
match ctx.currentUrl.StartsWith matchUrl with true -> [ _class "is-active-route" ] | false -> []
|
||||||
|
|> pageLink matchUrl
|
||||||
|
match ctx.isAuthenticated with
|
||||||
|
| true ->
|
||||||
|
li [ _class "nav-item" ] [ navLink "/journal" [ str "Journal" ] ]
|
||||||
|
li [ _class "nav-item" ] [ navLink "/requests/active" [ str "Active" ] ]
|
||||||
|
if ctx.hasSnoozed then li [ _class "nav-item" ] [ navLink "/requests/snoozed" [ str "Snoozed" ] ]
|
||||||
|
li [ _class "nav-item" ] [ navLink "/requests/answered" [ str "Answered" ] ]
|
||||||
|
li [ _class "nav-item" ] [ a [ _href "/user/log-off" ] [ str "Log Off" ] ]
|
||||||
|
| false -> li [ _class "nav-item"] [ a [ _href "/user/log-on" ] [ str "Log On" ] ]
|
||||||
|
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" ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
/// The title tag with the application name appended
|
||||||
|
let titleTag ctx = 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" ]
|
||||||
|
meta [ _name "description"; _content "Online prayer journal - free w/Google or Microsoft account" ]
|
||||||
|
titleTag ctx
|
||||||
|
link [
|
||||||
|
_href "https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
|
||||||
|
_rel "stylesheet"
|
||||||
|
_integrity "sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC"
|
||||||
|
_crossorigin "anonymous"
|
||||||
|
]
|
||||||
|
link [ _href "https://fonts.googleapis.com/icon?family=Material+Icons"; _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" ] []
|
||||||
|
]
|
||||||
|
|
||||||
|
/// The page's `footer` element
|
||||||
|
let htmlFoot =
|
||||||
|
footer [ _class "container-fluid" ] [
|
||||||
|
p [ _class "text-muted text-end" ] [
|
||||||
|
str "myPrayerJournal v3"
|
||||||
|
br []
|
||||||
|
em [] [
|
||||||
|
small [] [
|
||||||
|
pageLink "/legal/privacy-policy" [] [ str "Privacy Policy" ]
|
||||||
|
rawText " • "
|
||||||
|
pageLink "/legal/terms-of-service" [] [ str "Terms of Service" ]
|
||||||
|
rawText " • "
|
||||||
|
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" ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
script [
|
||||||
|
_src "https://unpkg.com/htmx.org@1.5.0"
|
||||||
|
_integrity "sha384-oGA+prIp5Vchu6we2YkI51UtVzN9Jpx2Z7PnR1I78PnZlN8LkrCT4lqqqmDkyrvI"
|
||||||
|
_crossorigin "anonymous"
|
||||||
|
] []
|
||||||
|
script [] [
|
||||||
|
rawText "if (!htmx) document.write('<script src=\"/script/htmx-1.5.0.min.js\"><\/script>')"
|
||||||
|
]
|
||||||
|
script [
|
||||||
|
_async
|
||||||
|
_src "https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"
|
||||||
|
_integrity "sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
|
||||||
|
_crossorigin "anonymous"
|
||||||
|
] []
|
||||||
|
script [] [
|
||||||
|
rawText "setTimeout(function () { "
|
||||||
|
rawText "if (!bootstrap) document.write('<script src=\"/script/bootstrap.bundle.min.js\"><\/script>') "
|
||||||
|
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" ] [ navBar ctx; main [ _roleMain ] [ ctx.content ] ]
|
||||||
|
toaster
|
||||||
|
htmlFoot
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Create a partial view
|
||||||
|
let partial ctx =
|
||||||
|
html [ _lang "en" ] [
|
||||||
|
head [] [ titleTag ctx ]
|
||||||
|
body [] [ navBar ctx; main [ _roleMain ] [ ctx.content ] ]
|
||||||
|
]
|
153
src/MyPrayerJournal/Views/Legal.fs
Normal file
153
src/MyPrayerJournal/Views/Legal.fs
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
/// Views for legal pages
|
||||||
|
module MyPrayerJournal.Views.Legal
|
||||||
|
|
||||||
|
open Giraffe.ViewEngine
|
||||||
|
|
||||||
|
/// View for the "Privacy Policy" page
|
||||||
|
let privacyPolicy = article [ _class "container mt-3" ] [
|
||||||
|
h2 [ _class "mb-2" ] [ str "Privacy Policy" ]
|
||||||
|
h6 [ _class "text-muted pb-3" ] [ str "as of May 21"; sup [] [ str "st"]; str ", 2018" ]
|
||||||
|
p [] [
|
||||||
|
str "The nature of the service is one where privacy is a must. The items below will help you understand the data "
|
||||||
|
str "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 [] [ str "Third Party Services" ]
|
||||||
|
p [ _class "card-text" ] [
|
||||||
|
str "myPrayerJournal utilizes a third-party authentication and identity provider. You should familiarize "
|
||||||
|
str "yourself with the privacy policy for "
|
||||||
|
a [ _href "https://auth0.com/privacy"; _target "_blank" ] [ str "Auth0" ]
|
||||||
|
str ", as well as your chosen provider ("
|
||||||
|
a [ _href "https://privacy.microsoft.com/en-us/privacystatement"; _target "_blank" ] [ str "Microsoft"]
|
||||||
|
str " or "
|
||||||
|
a [ _href "https://policies.google.com/privacy"; _target "_blank" ] [ str "Google" ]
|
||||||
|
str ")."
|
||||||
|
]
|
||||||
|
]
|
||||||
|
div [ _class "list-group-item" ] [
|
||||||
|
h3 [] [ str "What We Collect" ]
|
||||||
|
h4 [] [ str "Identifying Data" ]
|
||||||
|
ul [] [
|
||||||
|
li [] [
|
||||||
|
rawText "The only identifying data myPrayerJournal stores is the subscriber (“sub”) field from "
|
||||||
|
str "the token we receive from Auth0, once you have signed in through their hosted service. All "
|
||||||
|
str "information is associated with you via this field."
|
||||||
|
]
|
||||||
|
li [] [
|
||||||
|
str "While you are signed in, within your browser, the service has access to your first and last names, "
|
||||||
|
str "along with a URL to the profile picture (provided by your selected identity provider). This "
|
||||||
|
rawText "information is not transmitted to the server, and is removed when “Log Off” is "
|
||||||
|
str "clicked."
|
||||||
|
]
|
||||||
|
]
|
||||||
|
h4 [] [ str "User Provided Data" ]
|
||||||
|
ul [ _class "mb-0" ] [
|
||||||
|
li [] [
|
||||||
|
str "myPrayerJournal stores the information you provide, including the text of prayer requests, updates, "
|
||||||
|
str "and notes; and the date/time when certain actions are taken."
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
div [ _class "list-group-item" ] [
|
||||||
|
h3 [] [ str "How Your Data Is Accessed / Secured" ]
|
||||||
|
ul [ _class "mb-0" ] [
|
||||||
|
li [] [
|
||||||
|
str "Your provided data is returned to you, as required, to display your journal or your answered "
|
||||||
|
str "requests. On the server, it is stored in a controlled-access database."
|
||||||
|
]
|
||||||
|
li [] [
|
||||||
|
str "Your data is backed up, along with other Bit Badger Solutions hosted systems, in a rolling manner; "
|
||||||
|
str "backups are preserved for the prior 7 days, and backups from the 1"
|
||||||
|
sup [] [ str "st" ]
|
||||||
|
str " and 15"
|
||||||
|
sup [] [ str "th" ]
|
||||||
|
str " are preserved for 3 months. These backups are stored in a private cloud data repository."
|
||||||
|
]
|
||||||
|
li [] [
|
||||||
|
str "The data collected and stored is the absolute minimum necessary for the functionality of the service. "
|
||||||
|
rawText "There are no plans to “monetize” this service, and storing the minimum amount of "
|
||||||
|
str "information means that the data we have is not interesting to purchasers (or those who may have more "
|
||||||
|
str "nefarious purposes)."
|
||||||
|
]
|
||||||
|
li [] [
|
||||||
|
str "Access to servers and backups is strictly controlled and monitored for unauthorized access attempts."
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
div [ _class "list-group-item" ] [
|
||||||
|
h3 [] [ str "Removing Your Data" ]
|
||||||
|
p [ _class "card-text" ] [
|
||||||
|
str "At any time, you may choose to discontinue using this service. Both Microsoft and Google provide ways "
|
||||||
|
str "to revoke access from this application. However, if you want your data removed from the database, "
|
||||||
|
str "please contact daniel at bitbadger.solutions (via e-mail, replacing at with @) prior to doing so, to "
|
||||||
|
str "ensure we can determine which subscriber ID belongs to you."
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
/// View for the "Terms of Service" page
|
||||||
|
let termsOfService = article [ _class "container mt-3" ] [
|
||||||
|
h2 [ _class "mb-2" ] [ str "Terms of Service" ]
|
||||||
|
h6 [ _class "text-muted pb-3"] [ str "as of May 21"; sup [] [ str "st" ]; str ", 2018" ]
|
||||||
|
div [ _class "card" ] [
|
||||||
|
div [ _class "list-group list-group-flush" ] [
|
||||||
|
div [ _class "list-group-item" ] [
|
||||||
|
h3 [] [ str "1. Acceptance of Terms" ]
|
||||||
|
p [ _class "card-text" ] [
|
||||||
|
str "By accessing this web site, you are agreeing to be bound by these Terms and Conditions, and that you "
|
||||||
|
str "are responsible to ensure that your use of this site complies with all applicable laws. Your continued "
|
||||||
|
str "use of this site implies your acceptance of these terms."
|
||||||
|
]
|
||||||
|
]
|
||||||
|
div [ _class "list-group-item" ] [
|
||||||
|
h3 [] [ str "2. Description of Service and Registration" ]
|
||||||
|
p [ _class "card-text" ] [
|
||||||
|
str "myPrayerJournal is a service that allows individuals to enter and amend their prayer requests. It "
|
||||||
|
str "requires no registration by itself, but access is granted based on a successful login with an external "
|
||||||
|
str "identity provider. See "
|
||||||
|
pageLink "/legal/privacy-policy" [] [ str "our privacy policy" ]
|
||||||
|
str " for details on how that information is accessed and stored."
|
||||||
|
]
|
||||||
|
]
|
||||||
|
div [ _class "list-group-item" ] [
|
||||||
|
h3 [] [ str "3. Third Party Services" ]
|
||||||
|
p [ _class "card-text" ] [
|
||||||
|
str "This service utilizes a third-party service provider for identity management. Review the terms of "
|
||||||
|
str "service for "
|
||||||
|
a [ _href "https://auth0.com/terms"; _target "_blank" ] [ str "Auth0"]
|
||||||
|
str ", as well as those for the selected authorization provider ("
|
||||||
|
a [ _href "https://www.microsoft.com/en-us/servicesagreement"; _target "_blank" ] [ str "Microsoft"]
|
||||||
|
str " or "
|
||||||
|
a [ _href "https://policies.google.com/terms"; _target "_blank" ] [ str "Google" ]
|
||||||
|
str ")."
|
||||||
|
]
|
||||||
|
]
|
||||||
|
div [ _class "list-group-item" ] [
|
||||||
|
h3 [] [ str "4. Liability" ]
|
||||||
|
p [ _class "card-text" ] [
|
||||||
|
rawText "This service is provided “as is”, and no warranty (express or implied) exists. The "
|
||||||
|
str "service and its developers may not be held liable for any damages that may arise through the use of "
|
||||||
|
str "this service."
|
||||||
|
]
|
||||||
|
]
|
||||||
|
div [ _class "list-group-item" ] [
|
||||||
|
h3 [] [ str "5. Updates to Terms" ]
|
||||||
|
p [ _class "card-text" ] [
|
||||||
|
str "These terms and conditions may be updated at any time, and this service does not have the capability to "
|
||||||
|
str "notify users when these change. The date at the top of the page will be updated when any of the text of "
|
||||||
|
str "these terms is updated."
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
p [ _class "pt-3" ] [
|
||||||
|
str "You may also wish to review our "
|
||||||
|
pageLink "/legal/privacy-policy" [] [ str "privacy policy" ]
|
||||||
|
str " to learn how we handle your data."
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
268
src/MyPrayerJournal/Views/Request.fs
Normal file
268
src/MyPrayerJournal/Views/Request.fs
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
/// Views for request pages and components
|
||||||
|
module MyPrayerJournal.Views.Request
|
||||||
|
|
||||||
|
open Giraffe.ViewEngine
|
||||||
|
open Giraffe.ViewEngine.Htmx
|
||||||
|
open MyPrayerJournal
|
||||||
|
open NodaTime
|
||||||
|
open System
|
||||||
|
|
||||||
|
/// Create a request within the list
|
||||||
|
let reqListItem now req =
|
||||||
|
let reqId = RequestId.toString req.requestId
|
||||||
|
let isAnswered = req.lastStatus = Answered
|
||||||
|
let isSnoozed = req.snoozedUntil > now
|
||||||
|
let isPending = (not isSnoozed) && req.showAfter > now
|
||||||
|
let btnClass = _class "btn btn-light mx-2"
|
||||||
|
let restoreBtn (link : string) title =
|
||||||
|
button [ btnClass; _hxPatch $"/request/{reqId}/{link}"; _title title ] [ icon "restore" ]
|
||||||
|
div [ _class "list-group-item px-0 d-flex flex-row align-items-start"; _hxTarget "this"; _hxSwap HxSwap.OuterHtml ] [
|
||||||
|
pageLink $"/request/{reqId}/full" [ btnClass; _title "View Full Request" ] [ icon "description" ]
|
||||||
|
match isAnswered with
|
||||||
|
| true -> ()
|
||||||
|
| false -> pageLink $"/request/{reqId}/edit" [ btnClass; _title "Edit Request" ] [ icon "edit" ]
|
||||||
|
match true with
|
||||||
|
| _ when isSnoozed -> restoreBtn "cancel-snooze" "Cancel Snooze"
|
||||||
|
| _ when isPending -> restoreBtn "show" "Show Now"
|
||||||
|
| _ -> ()
|
||||||
|
p [ _class "request-text mb-0" ] [
|
||||||
|
str req.text
|
||||||
|
match isSnoozed || isPending || isAnswered with
|
||||||
|
| true ->
|
||||||
|
br []
|
||||||
|
small [ _class "text-muted" ] [
|
||||||
|
match () with
|
||||||
|
| _ when isSnoozed -> [ str "Snooze expires "; relativeDate req.snoozedUntil now ]
|
||||||
|
| _ when isPending -> [ str "Request appears next "; relativeDate req.showAfter now ]
|
||||||
|
| _ (* isAnswered *) -> [ str "Answered "; relativeDate req.asOf now ]
|
||||||
|
|> em []
|
||||||
|
]
|
||||||
|
| false -> ()
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Create a list of requests
|
||||||
|
let reqList now reqs =
|
||||||
|
reqs
|
||||||
|
|> List.map (reqListItem now)
|
||||||
|
|> div [ _class "list-group" ]
|
||||||
|
|
||||||
|
/// View for Active Requests page
|
||||||
|
let active now reqs = article [ _class "container mt-3" ] [
|
||||||
|
h2 [ _class "pb-3" ] [ str "Active Requests" ]
|
||||||
|
match reqs |> List.isEmpty with
|
||||||
|
| true ->
|
||||||
|
noResults "No Active Requests" "/journal" "Return to your journal"
|
||||||
|
[ str "Your prayer journal has no active requests" ]
|
||||||
|
| false -> reqList now reqs
|
||||||
|
]
|
||||||
|
|
||||||
|
/// View for Answered Requests page
|
||||||
|
let answered now reqs = article [ _class "container mt-3" ] [
|
||||||
|
h2 [ _class "pb-3" ] [ str "Answered Requests" ]
|
||||||
|
match reqs |> List.isEmpty with
|
||||||
|
| true ->
|
||||||
|
noResults "No Active Requests" "/journal" "Return to your journal" [
|
||||||
|
rawText "Your prayer journal has no answered requests; once you have marked one as “Answered”, "
|
||||||
|
str "it will appear here"
|
||||||
|
]
|
||||||
|
| false -> reqList now reqs
|
||||||
|
]
|
||||||
|
|
||||||
|
/// View for Snoozed Requests page
|
||||||
|
let snoozed now reqs = article [ _class "container mt-3" ] [
|
||||||
|
h2 [ _class "pb-3" ] [ str "Snoozed Requests" ]
|
||||||
|
reqList now reqs
|
||||||
|
]
|
||||||
|
|
||||||
|
/// View for Full Request page
|
||||||
|
let full (clock : IClock) (req : Request) =
|
||||||
|
let now = clock.GetCurrentInstant ()
|
||||||
|
let answered =
|
||||||
|
req.history
|
||||||
|
|> List.filter RequestAction.isAnswered
|
||||||
|
|> List.tryHead
|
||||||
|
|> Option.map (fun x -> x.asOf)
|
||||||
|
let prayed = (req.history |> List.filter RequestAction.isPrayed |> List.length).ToString "N0"
|
||||||
|
let daysOpen =
|
||||||
|
let asOf = defaultArg answered now
|
||||||
|
((asOf - (req.history |> List.filter RequestAction.isCreated |> List.head).asOf).TotalDays |> int).ToString "N0"
|
||||||
|
let lastText =
|
||||||
|
req.history
|
||||||
|
|> List.filter (fun h -> Option.isSome h.text)
|
||||||
|
|> List.sortByDescending (fun h -> h.asOf)
|
||||||
|
|> List.map (fun h -> Option.get h.text)
|
||||||
|
|> List.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
|
||||||
|
|> List.map (fun n -> {| asOf = n.asOf; text = Some n.notes; status = "Notes" |})
|
||||||
|
|> List.append (req.history |> List.map toDisp)
|
||||||
|
|> List.sortByDescending (fun it -> it.asOf)
|
||||||
|
// Skip the first entry for answered requests; that info is already displayed
|
||||||
|
match answered with Some _ -> all |> List.skip 1 | None -> all
|
||||||
|
article [ _class "container mt-3" ] [
|
||||||
|
div [_class "card" ] [
|
||||||
|
h5 [ _class "card-header" ] [ str "Full Prayer Request" ]
|
||||||
|
div [ _class "card-body" ] [
|
||||||
|
h6 [ _class "card-subtitle text-muted mb-2"] [
|
||||||
|
match answered with
|
||||||
|
| Some date ->
|
||||||
|
str "Answered "
|
||||||
|
date.ToDateTimeOffset().ToString ("D", null) |> str
|
||||||
|
str " ("
|
||||||
|
relativeDate date now
|
||||||
|
rawText ") • "
|
||||||
|
| None -> ()
|
||||||
|
sprintf "Prayed %s times • Open %s days" prayed daysOpen |> rawText
|
||||||
|
]
|
||||||
|
p [ _class "card-text" ] [ str lastText ]
|
||||||
|
]
|
||||||
|
log
|
||||||
|
|> List.map (fun it -> li [ _class "list-group-item" ] [
|
||||||
|
p [ _class "m-0" ] [
|
||||||
|
str it.status
|
||||||
|
rawText " "
|
||||||
|
small [] [ em [] [ it.asOf.ToDateTimeOffset().ToString ("D", null) |> str ] ]
|
||||||
|
]
|
||||||
|
match it.text with
|
||||||
|
| Some txt -> p [ _class "mt-2 mb-0" ] [ str txt ]
|
||||||
|
| None -> ()
|
||||||
|
])
|
||||||
|
|> ul [ _class "list-group list-group-flush" ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
/// View for the edit request component
|
||||||
|
let edit (req : JournalRequest) returnTo isNew =
|
||||||
|
let cancelLink =
|
||||||
|
match returnTo with
|
||||||
|
| "active" -> "/requests/active"
|
||||||
|
| "snoozed" -> "/requests/snoozed"
|
||||||
|
| _ (* "journal" *) -> "/journal"
|
||||||
|
article [ _class "container" ] [
|
||||||
|
h2 [ _class "pb-3" ] [ (match isNew with true -> "Add" | false -> "Edit") |> strf "%s Prayer Request" ]
|
||||||
|
form [
|
||||||
|
_hxBoost
|
||||||
|
_hxTarget "#top"
|
||||||
|
_hxPushUrl
|
||||||
|
"/request" |> match isNew with true -> _hxPost | false -> _hxPatch
|
||||||
|
] [
|
||||||
|
input [
|
||||||
|
_type "hidden"
|
||||||
|
_name "requestId"
|
||||||
|
_value (match isNew with true -> "new" | false -> RequestId.toString req.requestId)
|
||||||
|
]
|
||||||
|
input [ _type "hidden"; _name "returnTo"; _value returnTo ]
|
||||||
|
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
|
||||||
|
] [ str req.text ]
|
||||||
|
label [ _for "requestText" ] [ str "Prayer Request" ]
|
||||||
|
]
|
||||||
|
br []
|
||||||
|
match isNew with
|
||||||
|
| true -> ()
|
||||||
|
| false ->
|
||||||
|
div [ _class "pb-3" ] [
|
||||||
|
label [] [ str "Also Mark As" ]
|
||||||
|
br []
|
||||||
|
div [ _class "form-check form-check-inline" ] [
|
||||||
|
input [ _type "radio"; _class "form-check-input"; _id "sU"; _name "status"; _value "Updated"; _checked ]
|
||||||
|
label [ _for "sU" ] [ str "Updated" ]
|
||||||
|
]
|
||||||
|
div [ _class "form-check form-check-inline" ] [
|
||||||
|
input [ _type "radio"; _class "form-check-input"; _id "sP"; _name "status"; _value "Prayed" ]
|
||||||
|
label [ _for "sP" ] [ str "Prayed" ]
|
||||||
|
]
|
||||||
|
div [ _class "form-check form-check-inline" ] [
|
||||||
|
input [ _type "radio"; _class "form-check-input"; _id "sA"; _name "status"; _value "Answered" ]
|
||||||
|
label [ _for "sA" ] [ str "Answered" ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
div [ _class "row" ] [
|
||||||
|
div [ _class "col-12 offset-md-2 col-md-8 offset-lg-3 col-lg-6" ] [
|
||||||
|
p [] [
|
||||||
|
strong [] [ rawText "Recurrence " ]
|
||||||
|
em [ _class "text-muted" ] [ rawText "After prayer, request reappears…" ]
|
||||||
|
]
|
||||||
|
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 "Immediate"
|
||||||
|
_onclick "mpj.edit.toggleRecurrence(event)"
|
||||||
|
match req.recurType with Immediate -> _checked | _ -> ()
|
||||||
|
]
|
||||||
|
label [ _for "rI" ] [ str "Immediately" ]
|
||||||
|
]
|
||||||
|
div [ _class "form-check mx-2"] [
|
||||||
|
input [
|
||||||
|
_type "radio"
|
||||||
|
_class "form-check-input"
|
||||||
|
_id "rO"
|
||||||
|
_name "recurType"
|
||||||
|
_value "Other"
|
||||||
|
_onclick "mpj.edit.toggleRecurrence(event)"
|
||||||
|
match req.recurType with Immediate -> () | _ -> _checked
|
||||||
|
]
|
||||||
|
label [ _for "rO" ] [ rawText "Every…" ]
|
||||||
|
]
|
||||||
|
div [ _class "form-floating mx-2"] [
|
||||||
|
input [
|
||||||
|
_type "number"
|
||||||
|
_class "form-control"
|
||||||
|
_id "recurCount"
|
||||||
|
_name "recurCount"
|
||||||
|
_placeholder "0"
|
||||||
|
_value (string req.recurCount)
|
||||||
|
_style "width:6rem;"
|
||||||
|
_required
|
||||||
|
match req.recurType with Immediate -> _disabled | _ -> ()
|
||||||
|
]
|
||||||
|
label [ _for "recurCount" ] [ str "Count" ]
|
||||||
|
]
|
||||||
|
div [ _class "form-floating mx-2" ] [
|
||||||
|
select [
|
||||||
|
_class "form-control"
|
||||||
|
_id "recurInterval"
|
||||||
|
_name "recurInterval"
|
||||||
|
_style "width:6rem;"
|
||||||
|
_required
|
||||||
|
match req.recurType with Immediate -> _disabled | _ -> ()
|
||||||
|
] [
|
||||||
|
option [ _value "Hours"; match req.recurType with Hours -> _selected | _ -> () ] [ str "hours" ]
|
||||||
|
option [ _value "Days"; match req.recurType with Days -> _selected | _ -> () ] [ str "days" ]
|
||||||
|
option [ _value "Weeks"; match req.recurType with Weeks -> _selected | _ -> () ] [ str "weeks" ]
|
||||||
|
]
|
||||||
|
label [ _form "recurInterval" ] [ str "Interval" ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
div [ _class "text-end pt-3" ] [
|
||||||
|
button [ _class "btn btn-primary me-2"; _type "submit" ] [ icon "save"; str " Save" ]
|
||||||
|
pageLink cancelLink [ _class "btn btn-secondary ms-2" ] [ icon "arrow_back"; str " Cancel" ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Display a list of notes for a request
|
||||||
|
let notes now notes =
|
||||||
|
let toItem (note : Note) =
|
||||||
|
p [] [ small [ _class "text-muted" ] [ relativeDate note.asOf now ]; br []; str note.notes ]
|
||||||
|
[ p [ _class "text-center" ] [ strong [] [ str "Prior Notes for This Request" ] ]
|
||||||
|
match notes with
|
||||||
|
| [] -> p [ _class "text-center text-muted" ] [ str "There are no prior notes for this request" ]
|
||||||
|
| _ -> yield! notes |> List.map toItem
|
||||||
|
]
|
@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"db": "Filename=./mpj.db"
|
||||||
|
},
|
||||||
"Kestrel": {
|
"Kestrel": {
|
||||||
"EndPoints": {
|
"EndPoints": {
|
||||||
"Http": {
|
"Http": {
|
7
src/MyPrayerJournal/wwwroot/script/bootstrap.bundle.min.js
vendored
Normal file
7
src/MyPrayerJournal/wwwroot/script/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
src/MyPrayerJournal/wwwroot/script/htmx-1.5.0.min.js
vendored
Normal file
1
src/MyPrayerJournal/wwwroot/script/htmx-1.5.0.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
82
src/MyPrayerJournal/wwwroot/script/mpj.js
Normal file
82
src/MyPrayerJournal/wwwroot/script/mpj.js
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
"use strict"
|
||||||
|
|
||||||
|
/** myPrayerJournal script */
|
||||||
|
const mpj = {
|
||||||
|
/**
|
||||||
|
* Show a message via toast
|
||||||
|
* @param {string} message The message to show
|
||||||
|
*/
|
||||||
|
showToast (message) {
|
||||||
|
const [level, msg] = message.split("|||")
|
||||||
|
|
||||||
|
let header
|
||||||
|
if (level !== "success") {
|
||||||
|
const heading = typ => `<span class="me-auto"><strong>${typ.toUpperCase()}</strong></span>`
|
||||||
|
|
||||||
|
header = document.createElement("div")
|
||||||
|
header.className = "toast-header"
|
||||||
|
header.innerHTML = heading(level === "warning" ? level : "error")
|
||||||
|
|
||||||
|
const close = document.createElement("button")
|
||||||
|
close.type = "button"
|
||||||
|
close.className = "btn-close"
|
||||||
|
close.setAttribute("data-bs-dismiss", "toast")
|
||||||
|
close.setAttribute("aria-label", "Close")
|
||||||
|
header.appendChild(close)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = document.createElement("div")
|
||||||
|
body.className = "toast-body"
|
||||||
|
body.innerText = msg
|
||||||
|
|
||||||
|
const toastEl = document.createElement("div")
|
||||||
|
toastEl.className = `toast bg-${level === "error" ? "danger" : level} text-white`
|
||||||
|
toastEl.setAttribute("role", "alert")
|
||||||
|
toastEl.setAttribute("aria-live", "assertlive")
|
||||||
|
toastEl.setAttribute("aria-atomic", "true")
|
||||||
|
toastEl.addEventListener("hidden.bs.toast", e => e.target.remove())
|
||||||
|
if (header) toastEl.appendChild(header)
|
||||||
|
|
||||||
|
toastEl.appendChild(body)
|
||||||
|
document.getElementById("toasts").appendChild(toastEl)
|
||||||
|
new bootstrap.Toast(toastEl, { autohide: level === "success" }).show()
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Load local version of Bootstrap CSS if the CDN load failed
|
||||||
|
*/
|
||||||
|
ensureCss () {
|
||||||
|
let loaded = false
|
||||||
|
for (let i = 0; !loaded && i < document.styleSheets.length; i++) {
|
||||||
|
loaded = document.styleSheets[i].href.endsWith("bootstrap.min.css")
|
||||||
|
}
|
||||||
|
if (!loaded) {
|
||||||
|
const css = document.createElement("link")
|
||||||
|
css.rel = "stylesheet"
|
||||||
|
css.href = "/style/bootstrap.min.css"
|
||||||
|
document.getElementsByTagName("head")[0].appendChild(css)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/** Script for the request edit component */
|
||||||
|
edit: {
|
||||||
|
/**
|
||||||
|
* Toggle the recurrence input fields
|
||||||
|
* @param {Event} e The click event
|
||||||
|
*/
|
||||||
|
toggleRecurrence ({ target }) {
|
||||||
|
const isDisabled = target.value === "Immediate"
|
||||||
|
;["recurCount", "recurInterval"].forEach(it => document.getElementById(it).disabled = isDisabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
htmx.on("htmx:afterOnLoad", function (evt) {
|
||||||
|
const hdrs = evt.detail.xhr.getAllResponseHeaders()
|
||||||
|
// Show a message if there was one in the response
|
||||||
|
if (hdrs.indexOf("x-toast") >= 0) {
|
||||||
|
mpj.showToast(evt.detail.xhr.getResponseHeader("x-toast"))
|
||||||
|
}
|
||||||
|
// Hide a modal window if requested
|
||||||
|
if (hdrs.indexOf("x-hide-modal") >= 0) {
|
||||||
|
document.getElementById(evt.detail.xhr.getResponseHeader("x-hide-modal") + "Dismiss").click()
|
||||||
|
}
|
||||||
|
})
|
7
src/MyPrayerJournal/wwwroot/style/bootstrap.min.css
vendored
Normal file
7
src/MyPrayerJournal/wwwroot/style/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
57
src/MyPrayerJournal/wwwroot/style/style.css
Normal file
57
src/MyPrayerJournal/wwwroot/style/style.css
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
|
||||||
|
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,3 +0,0 @@
|
|||||||
> 1%
|
|
||||||
last 2 versions
|
|
||||||
not ie <= 8
|
|
@ -1,17 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
root: true,
|
|
||||||
env: {
|
|
||||||
node: true
|
|
||||||
},
|
|
||||||
'extends': [
|
|
||||||
'plugin:vue/essential',
|
|
||||||
'@vue/standard'
|
|
||||||
],
|
|
||||||
rules: {
|
|
||||||
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
|
|
||||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off'
|
|
||||||
},
|
|
||||||
parserOptions: {
|
|
||||||
parser: 'babel-eslint'
|
|
||||||
}
|
|
||||||
}
|
|
24
src/app/.gitignore
vendored
24
src/app/.gitignore
vendored
@ -1,24 +0,0 @@
|
|||||||
.DS_Store
|
|
||||||
node_modules
|
|
||||||
/dist
|
|
||||||
|
|
||||||
# local env files
|
|
||||||
.env.local
|
|
||||||
.env.*.local
|
|
||||||
|
|
||||||
# Log files
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.idea
|
|
||||||
.vscode
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw*
|
|
||||||
|
|
||||||
# Auth0 settings
|
|
||||||
src/auth/auth0-variables.*
|
|
@ -1,5 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
autoprefixer: {}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2017 Daniel J. Summers
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
@ -1,5 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
presets: [
|
|
||||||
'@vue/app'
|
|
||||||
]
|
|
||||||
}
|
|
@ -1,36 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "my-prayer-journal",
|
|
||||||
"version": "2.1.5",
|
|
||||||
"description": "myPrayerJournal - Front End",
|
|
||||||
"author": "Daniel J. Summers <daniel@bitbadger.solutions>",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"serve": "vue-cli-service serve --port 8081",
|
|
||||||
"build": "vue-cli-service build --modern",
|
|
||||||
"lint": "vue-cli-service lint",
|
|
||||||
"apistart": "cd ../MyPrayerJournal.Api && dotnet run",
|
|
||||||
"vue": "vue-cli-service build --modern && cd ../MyPrayerJournal.Api && dotnet run",
|
|
||||||
"publish": "vue-cli-service build --modern && cd ../MyPrayerJournal.Api && dotnet publish -c Release -r linux-x64 --self-contained false"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"auth0-js": "^9.13.2",
|
|
||||||
"axios": "^0.21.1",
|
|
||||||
"moment": "^2.18.1",
|
|
||||||
"vue": "^2.5.15",
|
|
||||||
"vue-material": "^1.0.0-beta-13",
|
|
||||||
"vue-router": "^3.0.0",
|
|
||||||
"vuex": "^3.0.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@vue/cli-plugin-babel": "^3.0.0",
|
|
||||||
"@vue/cli-plugin-eslint": "^3.0.0",
|
|
||||||
"@vue/cli-service": "^3.0.0",
|
|
||||||
"@vue/eslint-config-standard": "^4.0.0",
|
|
||||||
"node-sass": "^4.12.0",
|
|
||||||
"pug": "^3.0.1",
|
|
||||||
"pug-plain-loader": "^1.0.0",
|
|
||||||
"sass-loader": "^7.3.1",
|
|
||||||
"vue-template-compiler": "^2.5.17",
|
|
||||||
"webpack-bundle-analyzer": "^3.4.1"
|
|
||||||
}
|
|
||||||
}
|
|
Binary file not shown.
Before Width: | Height: | Size: 1.1 KiB |
@ -1,19 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
||||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
|
||||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="preload" as="style">
|
|
||||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
|
||||||
<title>myPrayerJournal</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<noscript>
|
|
||||||
<strong>We're sorry but newapp doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
|
||||||
</noscript>
|
|
||||||
<div id="app"></div>
|
|
||||||
<!-- built files will be auto injected -->
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,165 +0,0 @@
|
|||||||
<template lang="pug">
|
|
||||||
#app.page-container
|
|
||||||
md-app(md-waterfall md-mode='fixed-last' role='application')
|
|
||||||
md-app-toolbar.md-large.md-dense.md-primary
|
|
||||||
md-progress-bar(v-if='progress.visible'
|
|
||||||
:md-mode='progress.mode')
|
|
||||||
.md-no-progress-bar(v-if='!progress.visible')
|
|
||||||
.md-toolbar-row
|
|
||||||
.md-toolbar-section-start
|
|
||||||
router-link(to='/').md-title
|
|
||||||
span(style='font-weight:100;') my
|
|
||||||
span(style='font-weight:400;') Prayer
|
|
||||||
span(style='font-weight:700;') Journal
|
|
||||||
navigation
|
|
||||||
md-app-content
|
|
||||||
router-view
|
|
||||||
md-snackbar(:md-active.sync='snackbar.visible'
|
|
||||||
md-position='center'
|
|
||||||
:md-duration='snackbar.interval'
|
|
||||||
ref='snackbar') {{ snackbar.message }}
|
|
||||||
footer
|
|
||||||
p.mpj-muted-text.mpj-text-right
|
|
||||||
| myPrayerJournal v{{ version }}
|
|
||||||
br
|
|
||||||
em: small.
|
|
||||||
#[router-link(to='/legal/privacy-policy') Privacy Policy] •
|
|
||||||
#[router-link(to='/legal/terms-of-service') Terms of Service] •
|
|
||||||
#[a(href='https://github.com/bit-badger/myprayerjournal' target='_blank') Developed] and hosted by
|
|
||||||
#[a(href='https://bitbadger.solutions' target='_blank') Bit Badger Solutions]
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
'use strict'
|
|
||||||
|
|
||||||
import Vue from 'vue'
|
|
||||||
|
|
||||||
import Navigation from '@/components/common/Navigation'
|
|
||||||
|
|
||||||
import actions from '@/store/action-types'
|
|
||||||
import { version } from '../package.json'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'app',
|
|
||||||
components: {
|
|
||||||
Navigation
|
|
||||||
},
|
|
||||||
data () {
|
|
||||||
return {
|
|
||||||
progress: {
|
|
||||||
events: new Vue(),
|
|
||||||
visible: false,
|
|
||||||
mode: 'query'
|
|
||||||
},
|
|
||||||
snackbar: {
|
|
||||||
events: new Vue(),
|
|
||||||
visible: false,
|
|
||||||
message: '',
|
|
||||||
interval: 4000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async mounted () {
|
|
||||||
this.progress.events.$on('show', this.showProgress)
|
|
||||||
this.progress.events.$on('done', this.hideProgress)
|
|
||||||
this.snackbar.events.$on('info', this.showInfo)
|
|
||||||
this.snackbar.events.$on('error', this.showError)
|
|
||||||
await this.$store.dispatch(actions.CHECK_AUTHENTICATION)
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
version () {
|
|
||||||
return version.endsWith('.0')
|
|
||||||
? version.endsWith('.0.0')
|
|
||||||
? version.substr(0, version.length - 4)
|
|
||||||
: version.substr(0, version.length - 2)
|
|
||||||
: version
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
showSnackbar (message) {
|
|
||||||
this.snackbar.message = message
|
|
||||||
this.snackbar.visible = true
|
|
||||||
},
|
|
||||||
showInfo (message) {
|
|
||||||
this.snackbar.interval = 4000
|
|
||||||
this.showSnackbar(message)
|
|
||||||
},
|
|
||||||
showError (message) {
|
|
||||||
this.snackbar.interval = Infinity
|
|
||||||
this.showSnackbar(message)
|
|
||||||
},
|
|
||||||
showProgress (mode) {
|
|
||||||
this.progress.mode = mode
|
|
||||||
this.progress.visible = true
|
|
||||||
},
|
|
||||||
hideProgress () {
|
|
||||||
this.progress.visible = false
|
|
||||||
},
|
|
||||||
handleLoginEvent (data) {
|
|
||||||
if (!data.loggedIn) {
|
|
||||||
this.showInfo('Logged out successfully')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
provide () {
|
|
||||||
return {
|
|
||||||
messages: this.snackbar.events,
|
|
||||||
progress: this.progress.events
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="sass">
|
|
||||||
@import "~vue-material/dist/theme/engine"
|
|
||||||
@include md-register-theme("default", (primary: md-get-palette-color(green, 800), accent: md-get-palette-color(gray, 700)))
|
|
||||||
@import "~vue-material/dist/theme/all"
|
|
||||||
|
|
||||||
html, body
|
|
||||||
font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;
|
|
||||||
font-size: 1rem
|
|
||||||
p
|
|
||||||
margin-bottom: 0
|
|
||||||
footer
|
|
||||||
border-top: solid 1px lightgray
|
|
||||||
margin: 1rem -1rem 0
|
|
||||||
padding: 0 1rem
|
|
||||||
footer p
|
|
||||||
margin: 0
|
|
||||||
.mpj-full-page-card
|
|
||||||
font-size: 1rem
|
|
||||||
line-height: 1.25rem
|
|
||||||
.mpj-main-content
|
|
||||||
max-width: 60rem
|
|
||||||
margin: auto
|
|
||||||
.mpj-request-text
|
|
||||||
white-space: pre-line
|
|
||||||
p.mpj-request-text
|
|
||||||
margin-top: 0
|
|
||||||
.mpj-text-center
|
|
||||||
text-align: center
|
|
||||||
.mpj-text-nowrap
|
|
||||||
white-space: nowrap
|
|
||||||
.mpj-text-right
|
|
||||||
text-align: right
|
|
||||||
.mpj-muted-text
|
|
||||||
color: rgba(0, 0, 0, .6)
|
|
||||||
.mpj-valign-top
|
|
||||||
vertical-align: top
|
|
||||||
.mpj-narrow
|
|
||||||
max-width: 40rem
|
|
||||||
margin: auto
|
|
||||||
.mpj-skinny
|
|
||||||
max-width: 20rem
|
|
||||||
margin: auto
|
|
||||||
.mpj-full-width
|
|
||||||
width: 100%
|
|
||||||
.md-toolbar > .md-progress-bar
|
|
||||||
height: 2px
|
|
||||||
width: 100%
|
|
||||||
background-color: rgba(255, 255, 255, .8) !important
|
|
||||||
margin: 0
|
|
||||||
.md-toolbar > .md-no-progress-bar
|
|
||||||
height: 2px
|
|
||||||
width: 100%
|
|
||||||
</style>
|
|
@ -1,98 +0,0 @@
|
|||||||
'use strict'
|
|
||||||
|
|
||||||
import axios from 'axios'
|
|
||||||
|
|
||||||
const http = axios.create({
|
|
||||||
baseURL: `${location.protocol}//${location.host}/api/`
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* API access for myPrayerJournal
|
|
||||||
*/
|
|
||||||
export default {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the bearer token for all future requests
|
|
||||||
* @param {string} token The token to use to identify the user to the server
|
|
||||||
*/
|
|
||||||
setBearer: token => { http.defaults.headers.common['Authorization'] = `Bearer ${token}` },
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove the bearer token
|
|
||||||
*/
|
|
||||||
removeBearer: () => delete http.defaults.headers.common['Authorization'],
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a note for a prayer request
|
|
||||||
* @param {string} requestId The Id of the request to which the note applies
|
|
||||||
* @param {string} notes The notes to be added
|
|
||||||
*/
|
|
||||||
addNote: (requestId, notes) => http.post(`request/${requestId}/note`, { notes }),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a new prayer request
|
|
||||||
* @param {string} requestText The text of the request to be added
|
|
||||||
* @param {string} recurType The type of recurrence for this request
|
|
||||||
* @param {number} recurCount The number of intervals of recurrence
|
|
||||||
*/
|
|
||||||
addRequest: (requestText, recurType, recurCount) => http.post('request', { requestText, recurType, recurCount }),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all answered requests, along with the text they had when it was answered
|
|
||||||
*/
|
|
||||||
getAnsweredRequests: () => http.get('requests/answered'),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a prayer request (full; includes all history and notes)
|
|
||||||
* @param {string} requestId The Id of the request to retrieve
|
|
||||||
*/
|
|
||||||
getFullRequest: requestId => http.get(`request/${requestId}/full`),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get past notes for a prayer request
|
|
||||||
* @param {string} requestId The Id of the request for which notes should be retrieved
|
|
||||||
*/
|
|
||||||
getNotes: requestId => http.get(`request/${requestId}/notes`),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a prayer request (journal-style; only latest update)
|
|
||||||
* @param {string} requestId The Id of the request to retrieve
|
|
||||||
*/
|
|
||||||
getRequest: requestId => http.get(`request/${requestId}`),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all prayer requests and their most recent updates
|
|
||||||
*/
|
|
||||||
journal: () => http.get('journal'),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show a request after the given date (used for "show now")
|
|
||||||
* @param {string} requestId The ID of the request which should be shown
|
|
||||||
* @param {number} showAfter The ticks after which the request should be shown
|
|
||||||
*/
|
|
||||||
showRequest: (requestId, showAfter) => http.patch(`request/${requestId}/show`, { showAfter }),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Snooze a request until the given time
|
|
||||||
* @param {string} requestId The ID of the prayer request to be snoozed
|
|
||||||
* @param {number} until The ticks until which the request should be snoozed
|
|
||||||
*/
|
|
||||||
snoozeRequest: (requestId, until) => http.patch(`request/${requestId}/snooze`, { until }),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update recurrence for a prayer request
|
|
||||||
* @param {string} requestId The ID of the prayer request for which recurrence is being updated
|
|
||||||
* @param {string} recurType The type of recurrence to set
|
|
||||||
* @param {number} recurCount The number of recurrence intervals to set
|
|
||||||
*/
|
|
||||||
updateRecurrence: (requestId, recurType, recurCount) =>
|
|
||||||
http.patch(`request/${requestId}/recurrence`, { recurType, recurCount }),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update a prayer request
|
|
||||||
* @param {string} requestId The ID of the request to be updated
|
|
||||||
* @param {string} status The status of the update
|
|
||||||
* @param {string} updateText The text of the update (optional)
|
|
||||||
*/
|
|
||||||
updateRequest: (requestId, status, updateText) => http.post(`request/${requestId}/history`, { status, updateText })
|
|
||||||
}
|
|
@ -1,194 +0,0 @@
|
|||||||
'use strict'
|
|
||||||
/* eslint-disable */
|
|
||||||
import auth0 from 'auth0-js'
|
|
||||||
import EventEmitter from 'events'
|
|
||||||
|
|
||||||
import AUTH_CONFIG from './auth0-variables'
|
|
||||||
import mutations from '@/store/mutation-types'
|
|
||||||
/* es-lint-enable*/
|
|
||||||
|
|
||||||
// Auth0 web authentication instance to use for our calls
|
|
||||||
const webAuth = new auth0.WebAuth({
|
|
||||||
domain: AUTH_CONFIG.domain,
|
|
||||||
clientID: AUTH_CONFIG.clientId,
|
|
||||||
redirectUri: AUTH_CONFIG.appDomain + AUTH_CONFIG.callbackUrl,
|
|
||||||
audience: `https://${AUTH_CONFIG.domain}/userinfo`,
|
|
||||||
responseType: 'token id_token',
|
|
||||||
scope: 'openid profile email'
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A class to handle all authentication calls and determinations
|
|
||||||
*/
|
|
||||||
class AuthService extends EventEmitter {
|
|
||||||
|
|
||||||
// Local storage key for our session data
|
|
||||||
AUTH_SESSION = 'auth-session'
|
|
||||||
|
|
||||||
// Received and calculated values for our ssesion (initially loaded from local storage if present)
|
|
||||||
session = {}
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super()
|
|
||||||
this.refreshSession()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts the user log in flow
|
|
||||||
*/
|
|
||||||
login (customState) {
|
|
||||||
webAuth.authorize({
|
|
||||||
appState: customState
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Promisified parseHash function
|
|
||||||
*/
|
|
||||||
parseHash () {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
webAuth.parseHash((err, authResult) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err)
|
|
||||||
} else {
|
|
||||||
resolve(authResult)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle authentication replies from Auth0
|
|
||||||
*
|
|
||||||
* @param store The Vuex store
|
|
||||||
*/
|
|
||||||
async handleAuthentication (store) {
|
|
||||||
try {
|
|
||||||
const authResult = await this.parseHash()
|
|
||||||
if (authResult && authResult.accessToken && authResult.idToken) {
|
|
||||||
this.setSession(authResult)
|
|
||||||
store.commit(mutations.USER_LOGGED_ON, this.session.profile)
|
|
||||||
}
|
|
||||||
} catch(err) {
|
|
||||||
console.error(err)
|
|
||||||
alert(`Error: ${err.error}. Check the console for further details.`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set up the session and commit it to local storage
|
|
||||||
*
|
|
||||||
* @param authResult The authorization result
|
|
||||||
*/
|
|
||||||
setSession (authResult) {
|
|
||||||
this.session.profile = authResult.idTokenPayload
|
|
||||||
this.session.id.token = authResult.idToken
|
|
||||||
this.session.id.expiry = this.session.profile.exp * 1000
|
|
||||||
this.session.access.token = authResult.accessToken
|
|
||||||
this.session.access.expiry = authResult.expiresIn * 1000 + Date.now()
|
|
||||||
|
|
||||||
localStorage.setItem(this.AUTH_SESSION, JSON.stringify(this.session))
|
|
||||||
|
|
||||||
this.emit('loginEvent', {
|
|
||||||
loggedIn: true,
|
|
||||||
profile: authResult.idTokenPayload,
|
|
||||||
state: authResult.appState || {}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh this instance's session from the one in local storage
|
|
||||||
*/
|
|
||||||
refreshSession () {
|
|
||||||
this.session =
|
|
||||||
localStorage.getItem(this.AUTH_SESSION)
|
|
||||||
? JSON.parse(localStorage.getItem(this.AUTH_SESSION))
|
|
||||||
: { profile: {},
|
|
||||||
id: {
|
|
||||||
token: null,
|
|
||||||
expiry: null
|
|
||||||
},
|
|
||||||
access: {
|
|
||||||
token: null,
|
|
||||||
expiry: null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renew authorzation tokens with Auth0
|
|
||||||
*/
|
|
||||||
renewTokens () {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this.refreshSession()
|
|
||||||
if (this.session.id.token !== null) {
|
|
||||||
webAuth.checkSession({}, (err, authResult) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err)
|
|
||||||
} else {
|
|
||||||
this.setSession(authResult)
|
|
||||||
resolve(authResult)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
reject('Not logged in')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log out of myPrayerJournal
|
|
||||||
*
|
|
||||||
* @param store The Vuex store
|
|
||||||
*/
|
|
||||||
logout (store) {
|
|
||||||
// Clear access token and ID token from local storage
|
|
||||||
localStorage.removeItem(this.AUTH_SESSION)
|
|
||||||
this.refreshSession()
|
|
||||||
|
|
||||||
store.commit(mutations.USER_LOGGED_OFF)
|
|
||||||
|
|
||||||
webAuth.logout({
|
|
||||||
returnTo: `${AUTH_CONFIG.appDomain}/`,
|
|
||||||
clientID: AUTH_CONFIG.clientId
|
|
||||||
})
|
|
||||||
this.emit('loginEvent', { loggedIn: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check expiration for a token (the way it's stored in the session)
|
|
||||||
*/
|
|
||||||
checkExpiry = (it) => it.token && it.expiry && Date.now() < it.expiry
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Is there a user authenticated?
|
|
||||||
*/
|
|
||||||
isAuthenticated () {
|
|
||||||
return this.checkExpiry(this.session.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Is the current access token valid?
|
|
||||||
*/
|
|
||||||
isAccessTokenValid () {
|
|
||||||
return this.checkExpiry(this.session.access)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the user's access token, renewing it if required
|
|
||||||
*/
|
|
||||||
async getAccessToken () {
|
|
||||||
if (this.isAccessTokenValid()) {
|
|
||||||
return this.session.access.token
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
const authResult = await this.renewTokens()
|
|
||||||
return authResult.accessToken
|
|
||||||
} catch (reject) {
|
|
||||||
throw reject
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default new AuthService()
|
|
@ -1,22 +0,0 @@
|
|||||||
<template lang="pug">
|
|
||||||
md-content(role='main').mpj-main-content
|
|
||||||
page-title(title='Welcome!'
|
|
||||||
hideOnPage=true)
|
|
||||||
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 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.
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
'use strict'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'home'
|
|
||||||
}
|
|
||||||
</script>
|
|
@ -1,81 +0,0 @@
|
|||||||
<template lang="pug">
|
|
||||||
md-content(role='main').mpj-main-content-wide
|
|
||||||
page-title(:title='title')
|
|
||||||
p(v-if='isLoadingJournal') Loading your prayer journal...
|
|
||||||
template(v-else)
|
|
||||||
md-empty-state(v-if='journal.length === 0'
|
|
||||||
md-icon='done_all'
|
|
||||||
md-label='No Requests to Show'
|
|
||||||
md-description='You have no requests to be shown; see the “Active” link above for snoozed/deferred requests, and the “Answered” link for answered requests')
|
|
||||||
md-button(:to="{ name: 'EditRequest', params: { id: 'new' } }").md-primary.md-raised Add a New Request
|
|
||||||
template(v-else)
|
|
||||||
.mpj-text-center
|
|
||||||
md-button(:to="{ name: 'EditRequest', params: { id: 'new' } }"
|
|
||||||
role='button').md-raised.md-accent #[md-icon add_box] Add a New Request
|
|
||||||
br
|
|
||||||
.mpj-journal
|
|
||||||
request-card(v-for='request in journal'
|
|
||||||
:key='request.requestId'
|
|
||||||
:request='request')
|
|
||||||
notes-edit
|
|
||||||
snooze-request
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
'use strict'
|
|
||||||
|
|
||||||
import Vue from 'vue'
|
|
||||||
import { mapState } from 'vuex'
|
|
||||||
|
|
||||||
import NotesEdit from './request/NotesEdit'
|
|
||||||
import RequestCard from './request/RequestCard'
|
|
||||||
import SnoozeRequest from './request/SnoozeRequest'
|
|
||||||
|
|
||||||
import actions from '@/store/action-types'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'journal',
|
|
||||||
inject: [
|
|
||||||
'messages',
|
|
||||||
'progress'
|
|
||||||
],
|
|
||||||
components: {
|
|
||||||
NotesEdit,
|
|
||||||
RequestCard,
|
|
||||||
SnoozeRequest
|
|
||||||
},
|
|
||||||
data () {
|
|
||||||
return {
|
|
||||||
eventBus: new Vue()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
title () {
|
|
||||||
return `${this.user.given_name}’s Prayer Journal`
|
|
||||||
},
|
|
||||||
snackbar () {
|
|
||||||
return this.$parent.$refs.snackbar
|
|
||||||
},
|
|
||||||
...mapState(['user', 'journal', 'isLoadingJournal'])
|
|
||||||
},
|
|
||||||
async created () {
|
|
||||||
await this.$store.dispatch(actions.LOAD_JOURNAL, this.progress)
|
|
||||||
this.messages.$emit('info', `Loaded ${this.journal.length} prayer requests`)
|
|
||||||
},
|
|
||||||
provide () {
|
|
||||||
return {
|
|
||||||
journalEvents: this.eventBus
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="sass">
|
|
||||||
.mpj-journal
|
|
||||||
display: flex
|
|
||||||
flex-flow: row wrap
|
|
||||||
justify-content: center
|
|
||||||
align-items: flex-start
|
|
||||||
.mpj-dialog-content
|
|
||||||
padding: 0 1rem
|
|
||||||
</style>
|
|
@ -1,55 +0,0 @@
|
|||||||
<script>
|
|
||||||
'use strict'
|
|
||||||
|
|
||||||
import moment from 'moment'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'date-from-now',
|
|
||||||
props: {
|
|
||||||
tag: {
|
|
||||||
type: String,
|
|
||||||
default: 'span'
|
|
||||||
},
|
|
||||||
value: {
|
|
||||||
type: Number,
|
|
||||||
default: 0
|
|
||||||
},
|
|
||||||
interval: {
|
|
||||||
type: Number,
|
|
||||||
default: 10000
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data () {
|
|
||||||
return {
|
|
||||||
fromNow: moment(this.value).fromNow(),
|
|
||||||
intervalId: null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
actual () {
|
|
||||||
return moment(this.value).format('LLLL')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted () {
|
|
||||||
this.intervalId = setInterval(this.updateFromNow, this.interval)
|
|
||||||
this.$watch('value', this.updateFromNow)
|
|
||||||
},
|
|
||||||
beforeDestroy () {
|
|
||||||
clearInterval(this.intervalId)
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
updateFromNow () {
|
|
||||||
let newFromNow = moment(this.value).fromNow()
|
|
||||||
if (newFromNow !== this.fromNow) this.fromNow = newFromNow
|
|
||||||
}
|
|
||||||
},
|
|
||||||
render (createElement) {
|
|
||||||
return createElement(this.tag, {
|
|
||||||
domProps: {
|
|
||||||
title: this.actual,
|
|
||||||
innerText: this.fromNow
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
@ -1,59 +0,0 @@
|
|||||||
<template lang="pug">
|
|
||||||
.md-toolbar-row
|
|
||||||
md-tabs(md-sync-route).md-primary
|
|
||||||
template(v-if='isAuthenticated')
|
|
||||||
md-tab(md-label='Journal'
|
|
||||||
to='/journal')
|
|
||||||
md-tab(md-label='Active'
|
|
||||||
to='/requests/active')
|
|
||||||
md-tab(v-if='hasSnoozed'
|
|
||||||
md-label='Snoozed'
|
|
||||||
to='/requests/snoozed')
|
|
||||||
md-tab(md-label='Answered'
|
|
||||||
to='/requests/answered')
|
|
||||||
md-tab(md-label='Log Off'
|
|
||||||
href='/user/log-off'
|
|
||||||
@click.prevent='logOff()')
|
|
||||||
md-tab(md-label='Docs'
|
|
||||||
href='https://docs.prayerjournal.me'
|
|
||||||
@click.prevent='showHelp()')
|
|
||||||
template(v-else)
|
|
||||||
md-tab(md-label='Log On'
|
|
||||||
href='/user/log-on'
|
|
||||||
@click.prevent='logOn()')
|
|
||||||
md-tab(md-label='Docs'
|
|
||||||
href='https://docs.prayerjournal.me'
|
|
||||||
@click.prevent='showHelp()')
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
'use strict'
|
|
||||||
|
|
||||||
import { mapState } from 'vuex'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'navigation',
|
|
||||||
data () {
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
hasSnoozed () {
|
|
||||||
return this.isAuthenticated &&
|
|
||||||
Array.isArray(this.journal) &&
|
|
||||||
this.journal.filter(req => req.snoozedUntil > Date.now()).length > 0
|
|
||||||
},
|
|
||||||
...mapState([ 'isAuthenticated', 'journal' ])
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
logOn () {
|
|
||||||
this.$auth.login()
|
|
||||||
},
|
|
||||||
logOff () {
|
|
||||||
this.$auth.logout(this.$store, this.$router)
|
|
||||||
},
|
|
||||||
showHelp () {
|
|
||||||
window.open('https://docs.prayerjournal.me', '_blank')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
@ -1,28 +0,0 @@
|
|||||||
<template lang="pug">
|
|
||||||
h1(v-if='!hideOnPage'
|
|
||||||
v-html='title').md-title
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
name: 'page-title',
|
|
||||||
props: {
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
hideOnPage: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
title () {
|
|
||||||
document.title = `${this.title.replace('’', "'")} « myPrayerJournal`
|
|
||||||
}
|
|
||||||
},
|
|
||||||
created () {
|
|
||||||
document.title = `${this.title.replace('’', "'")} « myPrayerJournal`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
@ -1,59 +0,0 @@
|
|||||||
<template lang="pug">
|
|
||||||
md-content(role='main').mpj-main-content
|
|
||||||
page-title(title='Privacy Policy'
|
|
||||||
hide-on-page=true)
|
|
||||||
md-card
|
|
||||||
md-card-header
|
|
||||||
.md-title Privacy Policy
|
|
||||||
.md-subhead as of May 21, 2018
|
|
||||||
md-card-content.mpj-full-page-card
|
|
||||||
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.
|
|
||||||
hr
|
|
||||||
h3 Third Party Services
|
|
||||||
p.
|
|
||||||
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') Auth0], as well as your
|
|
||||||
chosen provider (#[a(href='https://privacy.microsoft.com/en-us/privacystatement' target='_blank') Microsoft] or
|
|
||||||
#[a(href='https://policies.google.com/privacy' target='_blank') Google]).
|
|
||||||
hr
|
|
||||||
h3 What We Collect
|
|
||||||
h4 Identifying Data
|
|
||||||
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.
|
|
||||||
h4 User Provided Data
|
|
||||||
ul
|
|
||||||
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.
|
|
||||||
hr
|
|
||||||
h3 How Your Data Is Accessed / Secured
|
|
||||||
ul
|
|
||||||
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 1st and 15th 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.
|
|
||||||
hr
|
|
||||||
h3 Removing Your Data
|
|
||||||
p.
|
|
||||||
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.
|
|
||||||
</template>
|
|
@ -1,40 +0,0 @@
|
|||||||
<template lang="pug">
|
|
||||||
md-content(role='main').mpj-main-content
|
|
||||||
page-title(title='Terms of Service'
|
|
||||||
hide-on-page=true)
|
|
||||||
md-card
|
|
||||||
md-card-header
|
|
||||||
.md-title Terms of Service
|
|
||||||
.md-subhead as of May 21, 2018
|
|
||||||
md-card-content.mpj-full-page-card
|
|
||||||
h3 1. Acceptance of Terms
|
|
||||||
p.
|
|
||||||
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.
|
|
||||||
h3 2. Description of Service and Registration
|
|
||||||
p.
|
|
||||||
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 #[router-link(:to="{ name: 'PrivacyPolicy' }") our privacy policy] for details on how that information is
|
|
||||||
accessed and stored.
|
|
||||||
h3 3. Third Party Services
|
|
||||||
p.
|
|
||||||
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') Auth0], as well as those for the selected authorization
|
|
||||||
provider (#[a(href='https://www.microsoft.com/en-us/servicesagreement' target='_blank') Microsoft] or
|
|
||||||
#[a(href='https://policies.google.com/terms' target='_blank') Google]).
|
|
||||||
h3 4. Liability
|
|
||||||
p.
|
|
||||||
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.
|
|
||||||
h3 5. Updates to Terms
|
|
||||||
p.
|
|
||||||
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.
|
|
||||||
hr
|
|
||||||
p.
|
|
||||||
You may also wish to review our #[router-link(:to="{ name: 'PrivacyPolicy' }") privacy policy] to learn how we
|
|
||||||
handle your data.
|
|
||||||
</template>
|
|
@ -1,60 +0,0 @@
|
|||||||
<template lang="pug">
|
|
||||||
md-content(role='main').mpj-main-content
|
|
||||||
page-title(title='Active Requests'
|
|
||||||
hide-on-page=true)
|
|
||||||
template(v-if='loaded')
|
|
||||||
md-empty-state(v-if='requests.length === 0'
|
|
||||||
md-icon='sentiment_dissatisfied'
|
|
||||||
md-label='No Active Requests'
|
|
||||||
md-description='Your prayer journal has no active requests')
|
|
||||||
md-button(to='/journal').md-primary.md-raised Return to your journal
|
|
||||||
request-list(v-if='requests.length !== 0'
|
|
||||||
title='Active Requests'
|
|
||||||
:requests='requests')
|
|
||||||
p(v-else) Loading journal...
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
'use strict'
|
|
||||||
|
|
||||||
import { mapState } from 'vuex'
|
|
||||||
|
|
||||||
import RequestList from '@/components/request/RequestList'
|
|
||||||
|
|
||||||
import actions from '@/store/action-types'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'active-requests',
|
|
||||||
inject: ['progress'],
|
|
||||||
components: {
|
|
||||||
RequestList
|
|
||||||
},
|
|
||||||
data () {
|
|
||||||
return {
|
|
||||||
requests: [],
|
|
||||||
loaded: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
...mapState(['journal', 'isLoadingJournal'])
|
|
||||||
},
|
|
||||||
created () {
|
|
||||||
this.$on('requestUnsnoozed', this.ensureJournal)
|
|
||||||
this.$on('requestNowShown', this.ensureJournal)
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
async ensureJournal () {
|
|
||||||
if (!Array.isArray(this.journal)) {
|
|
||||||
this.loaded = false
|
|
||||||
await this.$store.dispatch(actions.LOAD_JOURNAL, this.progress)
|
|
||||||
}
|
|
||||||
this.requests = this.journal
|
|
||||||
.sort((a, b) => a.showAfter - b.showAfter)
|
|
||||||
this.loaded = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async mounted () {
|
|
||||||
await this.ensureJournal()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
@ -1,53 +0,0 @@
|
|||||||
<template lang="pug">
|
|
||||||
md-content(role='main').mpj-main-content
|
|
||||||
page-title(title='Answered Requests'
|
|
||||||
hide-on-page=true)
|
|
||||||
template(v-if='loaded')
|
|
||||||
md-empty-state(v-if='requests.length === 0'
|
|
||||||
md-icon='sentiment_dissatisfied'
|
|
||||||
md-label='No Answered Requests'
|
|
||||||
md-description='Your prayer journal has no answered requests; once you have marked one as “Answered”, it will appear here')
|
|
||||||
request-list(v-if='requests.length !== 0'
|
|
||||||
title='Answered Requests'
|
|
||||||
:requests='requests')
|
|
||||||
p(v-else) Loading answered requests...
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
'use strict'
|
|
||||||
|
|
||||||
import api from '@/api'
|
|
||||||
|
|
||||||
import RequestList from '@/components/request/RequestList'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'answered-requests',
|
|
||||||
inject: [
|
|
||||||
'messages',
|
|
||||||
'progress'
|
|
||||||
],
|
|
||||||
components: {
|
|
||||||
RequestList
|
|
||||||
},
|
|
||||||
data () {
|
|
||||||
return {
|
|
||||||
requests: [],
|
|
||||||
loaded: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async mounted () {
|
|
||||||
this.progress.$emit('show', 'query')
|
|
||||||
try {
|
|
||||||
const reqs = await api.getAnsweredRequests()
|
|
||||||
this.requests = reqs.data
|
|
||||||
this.progress.$emit('done')
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
this.messages.$emit('error', 'Error loading requests; check console for details')
|
|
||||||
this.progress.$emit('done')
|
|
||||||
} finally {
|
|
||||||
this.loaded = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
@ -1,174 +0,0 @@
|
|||||||
<template lang="pug">
|
|
||||||
md-content(role='main').mpj-narrow
|
|
||||||
page-title(:title='title')
|
|
||||||
md-field
|
|
||||||
label(for='request_text') Prayer Request
|
|
||||||
md-textarea(v-model='form.requestText'
|
|
||||||
@blur='trimText()'
|
|
||||||
md-autogrow
|
|
||||||
autofocus).mpj-full-width
|
|
||||||
br
|
|
||||||
template(v-if='!isNew')
|
|
||||||
label Also Mark As
|
|
||||||
br
|
|
||||||
md-radio(v-model='form.status'
|
|
||||||
value='Updated') Updated
|
|
||||||
md-radio(v-model='form.status'
|
|
||||||
value='Prayed') Prayed
|
|
||||||
md-radio(v-model='form.status'
|
|
||||||
value='Answered') Answered
|
|
||||||
br
|
|
||||||
label Recurrence
|
|
||||||
|
|
|
||||||
em.mpj-muted-text After prayer, request reappears...
|
|
||||||
br
|
|
||||||
.md-layout
|
|
||||||
.md-layout-item.md-size-30
|
|
||||||
md-radio(v-model='form.recur.typ'
|
|
||||||
value='Immediate') Immediately
|
|
||||||
.md-layout-item.md-size-20
|
|
||||||
md-radio(v-model='form.recur.typ'
|
|
||||||
value='other') Every...
|
|
||||||
.md-layout-item.md-size-10
|
|
||||||
md-field(md-inline)
|
|
||||||
label Count
|
|
||||||
md-input(v-model='form.recur.count'
|
|
||||||
type='number'
|
|
||||||
:disabled='!showRecurrence')
|
|
||||||
.md-layout-item.md-size-20
|
|
||||||
md-field
|
|
||||||
label Interval
|
|
||||||
md-select(v-model='form.recur.other'
|
|
||||||
:disabled='!showRecurrence')
|
|
||||||
md-option(value='Hours') hours
|
|
||||||
md-option(value='Days') days
|
|
||||||
md-option(value='Weeks') weeks
|
|
||||||
.mpj-text-right
|
|
||||||
md-button(:disabled='!isValidRecurrence'
|
|
||||||
@click.stop='saveRequest()').md-primary.md-raised #[md-icon save] Save
|
|
||||||
md-button(@click.stop='goBack()').md-raised #[md-icon arrow_back] Cancel
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
'use strict'
|
|
||||||
|
|
||||||
import { mapState } from 'vuex'
|
|
||||||
|
|
||||||
import actions from '@/store/action-types'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'edit-request',
|
|
||||||
inject: [
|
|
||||||
'messages',
|
|
||||||
'progress'
|
|
||||||
],
|
|
||||||
props: {
|
|
||||||
id: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data () {
|
|
||||||
return {
|
|
||||||
title: 'Edit Prayer Request',
|
|
||||||
isNew: false,
|
|
||||||
form: {
|
|
||||||
requestId: '',
|
|
||||||
requestText: '',
|
|
||||||
status: 'Updated',
|
|
||||||
recur: {
|
|
||||||
typ: 'Immediate',
|
|
||||||
other: '',
|
|
||||||
count: ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
isValidRecurrence () {
|
|
||||||
if (this.form.recur.typ === 'Immediate') return true
|
|
||||||
const count = Number.parseInt(this.form.recur.count)
|
|
||||||
if (isNaN(count) || this.form.recur.other === '') return false
|
|
||||||
if (this.form.recur.other === 'Hours' && count > (365 * 24)) return false
|
|
||||||
if (this.form.recur.other === 'Days' && count > 365) return false
|
|
||||||
if (this.form.recur.other === 'Weeks' && count > 52) return false
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
showRecurrence () {
|
|
||||||
return this.form.recur.typ !== 'Immediate'
|
|
||||||
},
|
|
||||||
...mapState(['journal'])
|
|
||||||
},
|
|
||||||
async mounted () {
|
|
||||||
await this.ensureJournal()
|
|
||||||
if (this.id === 'new') {
|
|
||||||
this.title = 'Add Prayer Request'
|
|
||||||
this.isNew = true
|
|
||||||
this.form.requestId = ''
|
|
||||||
this.form.requestText = ''
|
|
||||||
this.form.status = 'Created'
|
|
||||||
this.form.recur.typ = 'Immediate'
|
|
||||||
this.form.recur.other = ''
|
|
||||||
this.form.recur.count = ''
|
|
||||||
} else {
|
|
||||||
this.title = 'Edit Prayer Request'
|
|
||||||
this.isNew = false
|
|
||||||
if (this.journal.length === 0) {
|
|
||||||
await this.$store.dispatch(actions.LOAD_JOURNAL, this.progress)
|
|
||||||
}
|
|
||||||
const req = this.journal.filter(r => r.requestId === this.id)[0]
|
|
||||||
this.form.requestId = this.id
|
|
||||||
this.form.requestText = req.text
|
|
||||||
this.form.status = 'Updated'
|
|
||||||
if (req.recurType === 'Immediate') {
|
|
||||||
this.form.recur.typ = 'Immediate'
|
|
||||||
this.form.recur.other = ''
|
|
||||||
this.form.recur.count = ''
|
|
||||||
} else {
|
|
||||||
this.form.recur.typ = 'other'
|
|
||||||
this.form.recur.other = req.recurType
|
|
||||||
this.form.recur.count = req.recurCount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
goBack () {
|
|
||||||
this.$router.go(-1)
|
|
||||||
},
|
|
||||||
trimText () {
|
|
||||||
this.form.requestText = this.form.requestText.trim()
|
|
||||||
},
|
|
||||||
async ensureJournal () {
|
|
||||||
if (!Array.isArray(this.journal)) {
|
|
||||||
await this.$store.dispatch(actions.LOAD_JOURNAL, this.progress)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async saveRequest () {
|
|
||||||
if (this.isNew) {
|
|
||||||
await this.$store.dispatch(actions.ADD_REQUEST, {
|
|
||||||
progress: this.progress,
|
|
||||||
requestText: this.form.requestText,
|
|
||||||
recurType: this.form.recur.typ === 'Immediate' ? 'Immediate' : this.form.recur.other,
|
|
||||||
recurCount: this.form.recur.typ === 'Immediate' ? 0 : Number.parseInt(this.form.recur.count)
|
|
||||||
})
|
|
||||||
this.messages.$emit('info', 'New prayer request added')
|
|
||||||
} else {
|
|
||||||
await this.$store.dispatch(actions.UPDATE_REQUEST, {
|
|
||||||
progress: this.progress,
|
|
||||||
requestId: this.form.requestId,
|
|
||||||
updateText: this.form.requestText,
|
|
||||||
status: this.form.status,
|
|
||||||
recurType: this.form.recur.typ === 'Immediate' ? 'Immediate' : this.form.recur.other,
|
|
||||||
recurCount: this.form.recur.typ === 'Immediate' ? 0 : Number.parseInt(this.form.recur.count)
|
|
||||||
})
|
|
||||||
if (this.form.status === 'Answered') {
|
|
||||||
this.messages.$emit('info', 'Request updated and removed from active journal')
|
|
||||||
} else {
|
|
||||||
this.messages.$emit('info', 'Request updated')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.goBack()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
@ -1,94 +0,0 @@
|
|||||||
<template lang="pug">
|
|
||||||
md-content(role='main').mpj-main-content
|
|
||||||
page-title(title='Full Prayer Request'
|
|
||||||
hide-on-page=true)
|
|
||||||
md-card(v-if='request')
|
|
||||||
md-card-header
|
|
||||||
.md-title Full Prayer Request
|
|
||||||
.md-subhead
|
|
||||||
span(v-if='isAnswered') Answered {{ formatDate(answered) }} (#[date-from-now(:value='answered')]) !{' • '}
|
|
||||||
| Prayed {{ prayedCount }} times • Open {{ openDays }} days
|
|
||||||
md-card-content.mpj-full-page-card
|
|
||||||
p.mpj-request-text {{ lastText }}
|
|
||||||
md-table
|
|
||||||
md-table-row
|
|
||||||
md-table-head Action
|
|
||||||
md-table-head Update / Notes
|
|
||||||
md-table-row(v-for='item in log'
|
|
||||||
:key='item.asOf')
|
|
||||||
md-table-cell.mpj-valign-top {{ item.status }} on #[span.mpj-text-nowrap {{ formatDate(item.asOf) }}]
|
|
||||||
md-table-cell(v-if='item.text').mpj-request-text.mpj-valign-top {{ item.text }}
|
|
||||||
md-table-cell(v-else)
|
|
||||||
p(v-else) Loading request...
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
'use strict'
|
|
||||||
|
|
||||||
import moment from 'moment'
|
|
||||||
|
|
||||||
import api from '@/api'
|
|
||||||
|
|
||||||
const asOfDesc = (a, b) => b.asOf - a.asOf
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'full-request',
|
|
||||||
inject: ['progress'],
|
|
||||||
props: {
|
|
||||||
id: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data () {
|
|
||||||
return {
|
|
||||||
request: null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
answered () {
|
|
||||||
return this.request.history.find(hist => hist.status === 'Answered').asOf
|
|
||||||
},
|
|
||||||
isAnswered () {
|
|
||||||
return this.request.history.filter(hist => hist.status === 'Answered').length > 0
|
|
||||||
},
|
|
||||||
lastText () {
|
|
||||||
return this.request.history
|
|
||||||
.filter(hist => hist.text)
|
|
||||||
.sort(asOfDesc)[0].text
|
|
||||||
},
|
|
||||||
log () {
|
|
||||||
const allHistory = (this.request.notes || [])
|
|
||||||
.map(note => ({ asOf: note.asOf, text: note.notes, status: 'Notes' }))
|
|
||||||
.concat(this.request.history)
|
|
||||||
.sort(asOfDesc)
|
|
||||||
// Skip the first entry for answered requests; that info is already displayed
|
|
||||||
return this.isAnswered ? allHistory.slice(1) : allHistory
|
|
||||||
},
|
|
||||||
openDays () {
|
|
||||||
const asOf = this.isAnswered ? this.answered : Date.now()
|
|
||||||
return Math.floor(
|
|
||||||
(asOf - this.request.history.find(hist => hist.status === 'Created').asOf) / 1000 / 60 / 60 / 24)
|
|
||||||
},
|
|
||||||
prayedCount () {
|
|
||||||
return this.request.history.filter(hist => hist.status === 'Prayed').length
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async mounted () {
|
|
||||||
this.progress.$emit('show', 'indeterminate')
|
|
||||||
try {
|
|
||||||
const req = await api.getFullRequest(this.id)
|
|
||||||
this.request = req.data
|
|
||||||
this.progress.$emit('done')
|
|
||||||
} catch (e) {
|
|
||||||
console.log(e)
|
|
||||||
this.progress.$emit('done')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
formatDate (asOf) {
|
|
||||||
return moment(asOf).format('LL')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
@ -1,118 +0,0 @@
|
|||||||
<template lang="pug">
|
|
||||||
md-dialog(:md-active.sync='notesVisible').mpj-note-dialog
|
|
||||||
md-dialog-title Add Notes to Prayer Request
|
|
||||||
md-content.mpj-dialog-content
|
|
||||||
md-field
|
|
||||||
label Notes
|
|
||||||
md-textarea(v-model='form.notes'
|
|
||||||
md-autogrow
|
|
||||||
@blur='trimText()')
|
|
||||||
md-dialog-actions
|
|
||||||
md-button(@click='saveNotes()').md-primary #[md-icon save] Save
|
|
||||||
md-button(@click='closeDialog()') #[md-icon undo] Cancel
|
|
||||||
md-dialog-content(md-scrollbar='true').mpj-dialog-content
|
|
||||||
div(v-if='hasPriorNotes')
|
|
||||||
p.mpj-text-center: strong Prior Notes for This Request
|
|
||||||
.mpj-note-list
|
|
||||||
p(v-for='note in priorNotes'
|
|
||||||
:key='note.asOf')
|
|
||||||
small.mpj-muted-text: date-from-now(:value='note.asOf')
|
|
||||||
br
|
|
||||||
span.mpj-request-text {{ note.notes }}
|
|
||||||
div(v-else-if='noPriorNotes').mpj-text-center.mpj-muted-text There are no prior notes for this request
|
|
||||||
div(v-else).mpj-text-center
|
|
||||||
hr
|
|
||||||
md-button(@click='loadNotes()') #[md-icon cloud_download] Load Prior Notes
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
'use strict'
|
|
||||||
|
|
||||||
import api from '@/api'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'notes-edit',
|
|
||||||
inject: [
|
|
||||||
'journalEvents',
|
|
||||||
'messages',
|
|
||||||
'progress'
|
|
||||||
],
|
|
||||||
data () {
|
|
||||||
return {
|
|
||||||
notesVisible: false,
|
|
||||||
form: {
|
|
||||||
requestId: '',
|
|
||||||
notes: ''
|
|
||||||
},
|
|
||||||
priorNotes: [],
|
|
||||||
priorNotesLoaded: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
hasPriorNotes () {
|
|
||||||
return this.priorNotesLoaded && this.priorNotes.length > 0
|
|
||||||
},
|
|
||||||
noPriorNotes () {
|
|
||||||
return this.priorNotesLoaded && this.priorNotes.length === 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
created () {
|
|
||||||
this.journalEvents.$on('notes', this.openDialog)
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
closeDialog () {
|
|
||||||
this.form.requestId = ''
|
|
||||||
this.form.notes = ''
|
|
||||||
this.priorNotes = []
|
|
||||||
this.priorNotesLoaded = false
|
|
||||||
this.notesVisible = false
|
|
||||||
},
|
|
||||||
async loadNotes () {
|
|
||||||
this.progress.$emit('show', 'indeterminate')
|
|
||||||
try {
|
|
||||||
const notes = await api.getNotes(this.form.requestId)
|
|
||||||
this.priorNotes = notes.data.sort((a, b) => b.asOf - a.asOf)
|
|
||||||
this.progress.$emit('done')
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
this.progress.$emit('done')
|
|
||||||
} finally {
|
|
||||||
this.priorNotesLoaded = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
openDialog (request) {
|
|
||||||
this.form.requestId = request.requestId
|
|
||||||
this.notesVisible = true
|
|
||||||
},
|
|
||||||
async saveNotes () {
|
|
||||||
this.progress.$emit('show', 'indeterminate')
|
|
||||||
try {
|
|
||||||
await api.addNote(this.form.requestId, this.form.notes)
|
|
||||||
this.progress.$emit('done')
|
|
||||||
this.messages.$emit('info', 'Added notes')
|
|
||||||
this.closeDialog()
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
this.progress.$emit('done')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
trimText () {
|
|
||||||
this.form.notes = this.form.notes.trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="sass">
|
|
||||||
.mpj-note-dialog
|
|
||||||
width: 40rem
|
|
||||||
padding-bottom: 1.5rem
|
|
||||||
@media screen and (max-width: 40rem)
|
|
||||||
@media screen and (max-width: 20rem)
|
|
||||||
.mpj-note-dialog
|
|
||||||
width: 100%
|
|
||||||
.mpj-note-dialog
|
|
||||||
width: 20rem
|
|
||||||
.mpj-note-list p
|
|
||||||
border-top: dotted 1px lightgray
|
|
||||||
</style>
|
|
@ -1,78 +0,0 @@
|
|||||||
<template lang="pug">
|
|
||||||
md-card(v-if='shouldDisplay'
|
|
||||||
md-with-hover).mpj-request-card
|
|
||||||
md-card-actions(md-alignment='space-between')
|
|
||||||
md-button(@click='markPrayed()').md-icon-button.md-raised.md-primary
|
|
||||||
md-icon done
|
|
||||||
md-tooltip(md-direction='top'
|
|
||||||
md-delay=1000) Mark as Prayed
|
|
||||||
span
|
|
||||||
md-button(@click.stop='showEdit()').md-icon-button.md-raised
|
|
||||||
md-icon edit
|
|
||||||
md-tooltip(md-direction='top'
|
|
||||||
md-delay=1000) Edit Request
|
|
||||||
md-button(@click.stop='showNotes()').md-icon-button.md-raised
|
|
||||||
md-icon comment
|
|
||||||
md-tooltip(md-direction='top'
|
|
||||||
md-delay=1000) Add Notes
|
|
||||||
md-button(@click.stop='snooze()').md-icon-button.md-raised
|
|
||||||
md-icon schedule
|
|
||||||
md-tooltip(md-direction='top'
|
|
||||||
md-delay=1000) Snooze Request
|
|
||||||
md-card-content
|
|
||||||
p.mpj-request-text {{ request.text }}
|
|
||||||
p.mpj-text-right: small.mpj-muted-text: em (last activity #[date-from-now(:value='request.asOf')])
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
'use strict'
|
|
||||||
|
|
||||||
import actions from '@/store/action-types'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'request-card',
|
|
||||||
inject: [
|
|
||||||
'journalEvents',
|
|
||||||
'messages',
|
|
||||||
'progress'
|
|
||||||
],
|
|
||||||
props: {
|
|
||||||
request: { required: true }
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
shouldDisplay () {
|
|
||||||
const now = Date.now()
|
|
||||||
return Math.max(now, this.request.showAfter, this.request.snoozedUntil) === now
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
async markPrayed () {
|
|
||||||
await this.$store.dispatch(actions.UPDATE_REQUEST, {
|
|
||||||
progress: this.progress,
|
|
||||||
requestId: this.request.requestId,
|
|
||||||
status: 'Prayed',
|
|
||||||
updateText: ''
|
|
||||||
})
|
|
||||||
this.messages.$emit('info', 'Request marked as prayed')
|
|
||||||
},
|
|
||||||
showEdit () {
|
|
||||||
this.$router.push({ name: 'EditRequest', params: { id: this.request.requestId } })
|
|
||||||
},
|
|
||||||
showNotes () {
|
|
||||||
this.journalEvents.$emit('notes', this.request)
|
|
||||||
},
|
|
||||||
snooze () {
|
|
||||||
this.journalEvents.$emit('snooze', this.request.requestId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="sass">
|
|
||||||
.mpj-request-card
|
|
||||||
width: 20rem
|
|
||||||
margin-bottom: 1rem
|
|
||||||
@media screen and (max-width: 20rem)
|
|
||||||
.mpj-request-card
|
|
||||||
width: 100%
|
|
||||||
</style>
|
|
@ -1,40 +0,0 @@
|
|||||||
<template lang="pug">
|
|
||||||
md-table(md-card)
|
|
||||||
md-table-toolbar
|
|
||||||
h1.md-title {{ title }}
|
|
||||||
md-table-row
|
|
||||||
md-table-head Actions
|
|
||||||
md-table-head Request
|
|
||||||
request-list-item(v-for='req in requests'
|
|
||||||
:key='req.requestId'
|
|
||||||
:request='req')
|
|
||||||
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
'use strict'
|
|
||||||
|
|
||||||
import RequestListItem from '@/components/request/RequestListItem'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'request-list',
|
|
||||||
components: { RequestListItem },
|
|
||||||
props: {
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
requests: {
|
|
||||||
type: Array,
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data () {
|
|
||||||
return { }
|
|
||||||
},
|
|
||||||
created () {
|
|
||||||
this.$on('requestUnsnoozed', this.$parent.$emit('requestUnsnoozed'))
|
|
||||||
this.$on('requestNowShown', this.$parent.$emit('requestNowShown'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
@ -1,95 +0,0 @@
|
|||||||
<template lang="pug">
|
|
||||||
md-table-row
|
|
||||||
md-table-cell.mpj-action-cell.mpj-valign-top
|
|
||||||
md-button(@click='viewFull').md-icon-button.md-raised
|
|
||||||
md-icon description
|
|
||||||
md-tooltip(md-direction='top'
|
|
||||||
md-delay=250) View Full Request
|
|
||||||
template(v-if='!isAnswered')
|
|
||||||
md-button(@click='editRequest').md-icon-button.md-raised
|
|
||||||
md-icon edit
|
|
||||||
md-tooltip(md-direction='top'
|
|
||||||
md-delay=250) Edit Request
|
|
||||||
template(v-if='isSnoozed')
|
|
||||||
md-button(@click='cancelSnooze()').md-icon-button.md-raised
|
|
||||||
md-icon restore
|
|
||||||
md-tooltip(md-direction='top'
|
|
||||||
md-delay=250) Cancel Snooze
|
|
||||||
template(v-if='isPending')
|
|
||||||
md-button(@click='showNow()').md-icon-button.md-raised
|
|
||||||
md-icon restore
|
|
||||||
md-tooltip(md-direction='top'
|
|
||||||
md-delay=250) Show Now
|
|
||||||
md-table-cell.mpj-valign-top
|
|
||||||
p.mpj-request-text {{ request.text }}
|
|
||||||
br(v-if='isSnoozed || isPending || isAnswered')
|
|
||||||
small(v-if='isSnoozed').mpj-muted-text: em Snooze expires #[date-from-now(:value='request.snoozedUntil')]
|
|
||||||
small(v-if='isPending').mpj-muted-text: em Request appears next #[date-from-now(:value='request.showAfter')]
|
|
||||||
small(v-if='isAnswered').mpj-muted-text: em Answered #[date-from-now(:value='request.asOf')]
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
'use strict'
|
|
||||||
|
|
||||||
import actions from '@/store/action-types'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'request-list-item',
|
|
||||||
inject: [
|
|
||||||
'messages',
|
|
||||||
'progress'
|
|
||||||
],
|
|
||||||
props: {
|
|
||||||
request: { required: true }
|
|
||||||
},
|
|
||||||
data () {
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
answered () {
|
|
||||||
return this.request.history.find(hist => hist.status === 'Answered').asOf
|
|
||||||
},
|
|
||||||
isAnswered () {
|
|
||||||
return this.request.lastStatus === 'Answered'
|
|
||||||
},
|
|
||||||
isPending () {
|
|
||||||
return !this.isSnoozed && this.request.showAfter > Date.now()
|
|
||||||
},
|
|
||||||
isSnoozed () {
|
|
||||||
return this.request.snoozedUntil > Date.now()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
async cancelSnooze () {
|
|
||||||
await this.$store.dispatch(actions.SNOOZE_REQUEST, {
|
|
||||||
progress: this.progress,
|
|
||||||
requestId: this.request.requestId,
|
|
||||||
until: 0
|
|
||||||
})
|
|
||||||
this.messages.$emit('info', 'Request un-snoozed')
|
|
||||||
this.$parent.$emit('requestUnsnoozed')
|
|
||||||
},
|
|
||||||
editRequest () {
|
|
||||||
this.$router.push({ name: 'EditRequest', params: { id: this.request.requestId } })
|
|
||||||
},
|
|
||||||
async showNow () {
|
|
||||||
await this.$store.dispatch(actions.SHOW_REQUEST_NOW, {
|
|
||||||
progress: this.progress,
|
|
||||||
requestId: this.request.requestId,
|
|
||||||
showAfter: 0
|
|
||||||
})
|
|
||||||
this.messages.$emit('info', 'Recurrence skipped; request now shows in journal')
|
|
||||||
this.$parent.$emit('requestNowShown')
|
|
||||||
},
|
|
||||||
viewFull () {
|
|
||||||
this.$router.push({ name: 'FullRequest', params: { id: this.request.requestId } })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="sass">
|
|
||||||
.mpj-action-cell
|
|
||||||
width: 1%
|
|
||||||
white-space: nowrap
|
|
||||||
</style>
|
|
@ -1,69 +0,0 @@
|
|||||||
<template lang="pug">
|
|
||||||
md-dialog(:md-active.sync='snoozeVisible').mpj-skinny
|
|
||||||
md-dialog-title Snooze Prayer Request
|
|
||||||
md-content.mpj-dialog-content
|
|
||||||
span.mpj-text-muted Until
|
|
||||||
md-datepicker(v-model='form.snoozedUntil'
|
|
||||||
:md-disabled-dates='datesInPast'
|
|
||||||
md-immediately)
|
|
||||||
md-dialog-actions
|
|
||||||
md-button(:disabled='!isValid'
|
|
||||||
@click='snoozeRequest()').md-primary #[md-icon snooze] Snooze
|
|
||||||
md-button(@click='closeDialog()') #[md-icon undo] Cancel
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
'use strict'
|
|
||||||
|
|
||||||
import actions from '@/store/action-types'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'snooze-request',
|
|
||||||
inject: [
|
|
||||||
'journalEvents',
|
|
||||||
'messages',
|
|
||||||
'progress'
|
|
||||||
],
|
|
||||||
props: {
|
|
||||||
events: { required: true }
|
|
||||||
},
|
|
||||||
data () {
|
|
||||||
return {
|
|
||||||
snoozeVisible: false,
|
|
||||||
datesInPast: date => date < new Date(),
|
|
||||||
form: {
|
|
||||||
requestId: '',
|
|
||||||
snoozedUntil: ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
created () {
|
|
||||||
this.journalEvents.$on('snooze', this.openDialog)
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
isValid () {
|
|
||||||
return !isNaN(Date.parse(this.form.snoozedUntil))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
closeDialog () {
|
|
||||||
this.form.requestId = ''
|
|
||||||
this.form.snoozedUntil = ''
|
|
||||||
this.snoozeVisible = false
|
|
||||||
},
|
|
||||||
openDialog (requestId) {
|
|
||||||
this.form.requestId = requestId
|
|
||||||
this.snoozeVisible = true
|
|
||||||
},
|
|
||||||
async snoozeRequest () {
|
|
||||||
await this.$store.dispatch(actions.SNOOZE_REQUEST, {
|
|
||||||
progress: this.progress,
|
|
||||||
requestId: this.form.requestId,
|
|
||||||
until: Date.parse(this.form.snoozedUntil)
|
|
||||||
})
|
|
||||||
this.messages.$emit('info', `Request snoozed until ${this.form.snoozedUntil}`)
|
|
||||||
this.closeDialog()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
@ -1,60 +0,0 @@
|
|||||||
<template lang="pug">
|
|
||||||
article.mpj-main-content(role='main')
|
|
||||||
page-title(title='Snoozed Requests'
|
|
||||||
hide-on-page=true)
|
|
||||||
template(v-if='loaded')
|
|
||||||
md-empty-state(v-if='requests.length === 0'
|
|
||||||
md-icon='sentiment_dissatisfied'
|
|
||||||
md-label='No Snoozed Requests'
|
|
||||||
md-description='Your prayer journal has no snoozed requests')
|
|
||||||
md-button(to='/journal').md-primary.md-raised Return to your journal
|
|
||||||
request-list(v-if='requests.length !== 0'
|
|
||||||
title='Snoozed Requests'
|
|
||||||
:requests='requests')
|
|
||||||
p(v-else) Loading journal...
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
'use strict'
|
|
||||||
|
|
||||||
import { mapState } from 'vuex'
|
|
||||||
|
|
||||||
import actions from '@/store/action-types'
|
|
||||||
|
|
||||||
import RequestList from '@/components/request/RequestList'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'snoozed-requests',
|
|
||||||
inject: ['progress'],
|
|
||||||
components: {
|
|
||||||
RequestList
|
|
||||||
},
|
|
||||||
data () {
|
|
||||||
return {
|
|
||||||
requests: [],
|
|
||||||
loaded: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
...mapState(['journal', 'isLoadingJournal'])
|
|
||||||
},
|
|
||||||
created () {
|
|
||||||
this.$on('requestUnsnoozed', this.ensureJournal)
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
async ensureJournal () {
|
|
||||||
if (!Array.isArray(this.journal)) {
|
|
||||||
this.loaded = false
|
|
||||||
await this.$store.dispatch(actions.LOAD_JOURNAL, this.progress)
|
|
||||||
}
|
|
||||||
this.requests = this.journal
|
|
||||||
.filter(req => req.snoozedUntil > Date.now())
|
|
||||||
.sort((a, b) => a.snoozedUntil - b.snoozedUntil)
|
|
||||||
this.loaded = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async mounted () {
|
|
||||||
await this.ensureJournal()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
@ -1,23 +0,0 @@
|
|||||||
<template lang="pug">
|
|
||||||
article.mpj-main-content(role='main')
|
|
||||||
pageTitle(title='Logging On')
|
|
||||||
p Logging you on...
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
'use strict'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'log-on',
|
|
||||||
inject: ['progress'],
|
|
||||||
async created () {
|
|
||||||
this.progress.$emit('show', 'indeterminate')
|
|
||||||
await this.$auth.handleAuthentication(this.$store)
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
handleLoginEvent (data) {
|
|
||||||
this.$router.push(data.state.target || '/journal')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
@ -1,64 +0,0 @@
|
|||||||
/* eslint-disable */
|
|
||||||
|
|
||||||
// Vue packages and components
|
|
||||||
import Vue from 'vue'
|
|
||||||
import { MdApp,
|
|
||||||
MdButton,
|
|
||||||
MdCard,
|
|
||||||
MdContent,
|
|
||||||
MdDatepicker,
|
|
||||||
MdDialog,
|
|
||||||
MdEmptyState,
|
|
||||||
MdField,
|
|
||||||
MdIcon,
|
|
||||||
MdLayout,
|
|
||||||
MdProgress,
|
|
||||||
MdRadio,
|
|
||||||
MdSnackbar,
|
|
||||||
MdTable,
|
|
||||||
MdTabs,
|
|
||||||
MdToolbar,
|
|
||||||
MdTooltip } from 'vue-material/dist/components'
|
|
||||||
|
|
||||||
// myPrayerJournal components
|
|
||||||
import App from './App'
|
|
||||||
import router from './router'
|
|
||||||
import store from './store'
|
|
||||||
import DateFromNow from './components/common/DateFromNow'
|
|
||||||
import PageTitle from './components/common/PageTitle'
|
|
||||||
import AuthPlugin from './plugins/auth'
|
|
||||||
|
|
||||||
/* eslint-enable */
|
|
||||||
|
|
||||||
// Styles
|
|
||||||
import 'vue-material/dist/vue-material.min.css'
|
|
||||||
import 'vue-material/dist/theme/default.css'
|
|
||||||
|
|
||||||
Vue.config.productionTip = false
|
|
||||||
|
|
||||||
Vue.use(MdApp)
|
|
||||||
Vue.use(MdButton)
|
|
||||||
Vue.use(MdCard)
|
|
||||||
Vue.use(MdContent)
|
|
||||||
Vue.use(MdDatepicker)
|
|
||||||
Vue.use(MdDialog)
|
|
||||||
Vue.use(MdEmptyState)
|
|
||||||
Vue.use(MdField)
|
|
||||||
Vue.use(MdIcon)
|
|
||||||
Vue.use(MdLayout)
|
|
||||||
Vue.use(MdProgress)
|
|
||||||
Vue.use(MdRadio)
|
|
||||||
Vue.use(MdSnackbar)
|
|
||||||
Vue.use(MdTable)
|
|
||||||
Vue.use(MdTabs)
|
|
||||||
Vue.use(MdToolbar)
|
|
||||||
Vue.use(MdTooltip)
|
|
||||||
Vue.use(AuthPlugin)
|
|
||||||
Vue.component('date-from-now', DateFromNow)
|
|
||||||
Vue.component('page-title', PageTitle)
|
|
||||||
|
|
||||||
new Vue({
|
|
||||||
router,
|
|
||||||
store,
|
|
||||||
render: h => h(App)
|
|
||||||
}).$mount('#app')
|
|
@ -1,22 +0,0 @@
|
|||||||
'use strict'
|
|
||||||
|
|
||||||
import authService from '../auth/AuthService'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
install (Vue) {
|
|
||||||
Vue.prototype.$auth = authService
|
|
||||||
|
|
||||||
Vue.mixin({
|
|
||||||
created () {
|
|
||||||
if (this.handleLoginEvent) {
|
|
||||||
authService.addListener('loginEvent', this.handleLoginEvent)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
destroyed () {
|
|
||||||
if (this.handleLoginEvent) {
|
|
||||||
authService.removeListener('loginEvent', this.handleLoginEvent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,83 +0,0 @@
|
|||||||
'use strict'
|
|
||||||
|
|
||||||
/* eslint-disable */
|
|
||||||
import Vue from 'vue'
|
|
||||||
import Router from 'vue-router'
|
|
||||||
|
|
||||||
import auth from './auth/AuthService'
|
|
||||||
import Home from '@/components/Home'
|
|
||||||
/* eslint-enable */
|
|
||||||
|
|
||||||
Vue.use(Router)
|
|
||||||
|
|
||||||
export default new Router({
|
|
||||||
mode: 'history',
|
|
||||||
base: process.env.BASE_URL,
|
|
||||||
scrollBehavior (to, from, savedPosition) {
|
|
||||||
if (savedPosition) {
|
|
||||||
return savedPosition
|
|
||||||
} else {
|
|
||||||
return { x: 0, y: 0 }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
beforeEach (to, from, next) {
|
|
||||||
if (to.path === '/' || to.path === '/user/log-on' || auth.isAuthenticated()) {
|
|
||||||
return next()
|
|
||||||
}
|
|
||||||
auth.login({ target: to.path })
|
|
||||||
},
|
|
||||||
routes: [
|
|
||||||
{
|
|
||||||
path: '/',
|
|
||||||
name: 'Home',
|
|
||||||
component: Home
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/journal',
|
|
||||||
name: 'Journal',
|
|
||||||
component: () => import('@/components/Journal')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/legal/privacy-policy',
|
|
||||||
name: 'PrivacyPolicy',
|
|
||||||
component: () => import('@/components/legal/PrivacyPolicy')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/legal/terms-of-service',
|
|
||||||
name: 'TermsOfService',
|
|
||||||
component: () => import('@/components/legal/TermsOfService')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/request/:id/edit',
|
|
||||||
name: 'EditRequest',
|
|
||||||
component: () => import('@/components/request/EditRequest'),
|
|
||||||
props: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/request/:id/full',
|
|
||||||
name: 'FullRequest',
|
|
||||||
component: () => import('@/components/request/FullRequest'),
|
|
||||||
props: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/requests/active',
|
|
||||||
name: 'ActiveRequests',
|
|
||||||
component: () => import('@/components/request/ActiveRequests')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/requests/answered',
|
|
||||||
name: 'AnsweredRequests',
|
|
||||||
component: () => import('@/components/request/AnsweredRequests')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/requests/snoozed',
|
|
||||||
name: 'SnoozedRequests',
|
|
||||||
component: () => import('@/components/request/SnoozedRequests')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/user/log-on',
|
|
||||||
name: 'LogOn',
|
|
||||||
component: () => import('@/components/user/LogOn')
|
|
||||||
}
|
|
||||||
]
|
|
||||||
})
|
|
@ -1,16 +0,0 @@
|
|||||||
'use strict'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
/** Action to add a prayer request (pass request text) */
|
|
||||||
ADD_REQUEST: 'add-request',
|
|
||||||
/** Action to check if a user is authenticated, refreshing the session first if it exists */
|
|
||||||
CHECK_AUTHENTICATION: 'check-authentication',
|
|
||||||
/** Action to load the user's prayer journal */
|
|
||||||
LOAD_JOURNAL: 'load-journal',
|
|
||||||
/** Action to update a request */
|
|
||||||
UPDATE_REQUEST: 'update-request',
|
|
||||||
/** Action to skip the remaining recurrence period */
|
|
||||||
SHOW_REQUEST_NOW: 'show-request-now',
|
|
||||||
/** Action to snooze a request */
|
|
||||||
SNOOZE_REQUEST: 'snooze-request'
|
|
||||||
}
|
|
@ -1,186 +0,0 @@
|
|||||||
'use strict'
|
|
||||||
|
|
||||||
/* eslint-disable no-multi-spaces */
|
|
||||||
import Vue from 'vue'
|
|
||||||
import Vuex from 'vuex'
|
|
||||||
|
|
||||||
import api from '@/api'
|
|
||||||
import auth from '@/auth/AuthService'
|
|
||||||
|
|
||||||
import mutations from './mutation-types'
|
|
||||||
import actions from './action-types'
|
|
||||||
/* eslint-enable no-multi-spaces */
|
|
||||||
|
|
||||||
Vue.use(Vuex)
|
|
||||||
|
|
||||||
/* eslint-disable no-console */
|
|
||||||
const logError = function (error) {
|
|
||||||
if (error.response) {
|
|
||||||
// The request was made and the server responded with a status code
|
|
||||||
// that falls out of the range of 2xx
|
|
||||||
console.error(error.response.data)
|
|
||||||
console.error(error.response.status)
|
|
||||||
console.error(error.response.headers)
|
|
||||||
} else if (error.request) {
|
|
||||||
// The request was made but no response was received
|
|
||||||
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
|
|
||||||
// http.ClientRequest in node.js
|
|
||||||
console.error(error.request)
|
|
||||||
} else {
|
|
||||||
// Something happened in setting up the request that triggered an Error
|
|
||||||
console.error('Error', error.message)
|
|
||||||
}
|
|
||||||
console.error(`config: ${error.config}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the "Bearer" authorization header with the current access token
|
|
||||||
*/
|
|
||||||
const setBearer = async function () {
|
|
||||||
try {
|
|
||||||
await auth.getAccessToken()
|
|
||||||
api.setBearer(auth.session.id.token)
|
|
||||||
} catch (err) {
|
|
||||||
if (err === 'Not logged in') {
|
|
||||||
console.warn('API request attempted when user was not logged in')
|
|
||||||
} else {
|
|
||||||
console.error(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/* eslint-enable no-console */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the sort value for a prayer request
|
|
||||||
* @param x The prayer request
|
|
||||||
*/
|
|
||||||
const sortValue = x => x.showAfter === 0 ? x.asOf : x.showAfter
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sort journal requests either by asOf or showAfter
|
|
||||||
*/
|
|
||||||
const journalSort = (a, b) => sortValue(a) - sortValue(b)
|
|
||||||
|
|
||||||
export default new Vuex.Store({
|
|
||||||
state: {
|
|
||||||
user: auth.session.profile,
|
|
||||||
isAuthenticated: auth.isAuthenticated(),
|
|
||||||
journal: [],
|
|
||||||
isLoadingJournal: false
|
|
||||||
},
|
|
||||||
mutations: {
|
|
||||||
[mutations.LOADING_JOURNAL] (state, flag) {
|
|
||||||
state.isLoadingJournal = flag
|
|
||||||
},
|
|
||||||
[mutations.LOADED_JOURNAL] (state, journal) {
|
|
||||||
state.journal = journal.sort(journalSort)
|
|
||||||
},
|
|
||||||
[mutations.REQUEST_ADDED] (state, newRequest) {
|
|
||||||
state.journal.push(newRequest)
|
|
||||||
},
|
|
||||||
[mutations.REQUEST_UPDATED] (state, request) {
|
|
||||||
let jrnl = state.journal.filter(it => it.requestId !== request.requestId)
|
|
||||||
if (request.lastStatus !== 'Answered') jrnl.push(request)
|
|
||||||
state.journal = jrnl
|
|
||||||
},
|
|
||||||
[mutations.SET_AUTHENTICATION] (state, value) {
|
|
||||||
state.isAuthenticated = value
|
|
||||||
},
|
|
||||||
[mutations.USER_LOGGED_OFF] (state) {
|
|
||||||
state.user = {}
|
|
||||||
api.removeBearer()
|
|
||||||
state.isAuthenticated = false
|
|
||||||
},
|
|
||||||
[mutations.USER_LOGGED_ON] (state, user) {
|
|
||||||
state.user = user
|
|
||||||
state.isAuthenticated = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions: {
|
|
||||||
async [actions.ADD_REQUEST] ({ commit }, { progress, requestText, recurType, recurCount }) {
|
|
||||||
progress.$emit('show', 'indeterminate')
|
|
||||||
try {
|
|
||||||
await setBearer()
|
|
||||||
const newRequest = await api.addRequest(requestText, recurType, recurCount)
|
|
||||||
commit(mutations.REQUEST_ADDED, newRequest.data)
|
|
||||||
progress.$emit('done')
|
|
||||||
} catch (err) {
|
|
||||||
logError(err)
|
|
||||||
progress.$emit('done')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async [actions.CHECK_AUTHENTICATION] ({ commit }) {
|
|
||||||
try {
|
|
||||||
await auth.getAccessToken()
|
|
||||||
commit(mutations.SET_AUTHENTICATION, auth.isAuthenticated())
|
|
||||||
} catch (_) {
|
|
||||||
commit(mutations.SET_AUTHENTICATION, false)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async [actions.LOAD_JOURNAL] ({ commit }, progress) {
|
|
||||||
commit(mutations.LOADED_JOURNAL, [])
|
|
||||||
progress.$emit('show', 'query')
|
|
||||||
commit(mutations.LOADING_JOURNAL, true)
|
|
||||||
await setBearer()
|
|
||||||
try {
|
|
||||||
const jrnl = await api.journal()
|
|
||||||
commit(mutations.LOADED_JOURNAL, jrnl.data)
|
|
||||||
progress.$emit('done')
|
|
||||||
} catch (err) {
|
|
||||||
logError(err)
|
|
||||||
progress.$emit('done')
|
|
||||||
} finally {
|
|
||||||
commit(mutations.LOADING_JOURNAL, false)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async [actions.UPDATE_REQUEST] ({ commit, state }, { progress, requestId, status, updateText, recurType, recurCount }) {
|
|
||||||
progress.$emit('show', 'indeterminate')
|
|
||||||
try {
|
|
||||||
await setBearer()
|
|
||||||
let oldReq = (state.journal.filter(req => req.requestId === requestId) || [])[0] || {}
|
|
||||||
if (!(status === 'Prayed' && updateText === '')) {
|
|
||||||
if (status !== 'Answered' && (oldReq.recurType !== recurType || oldReq.recurCount !== recurCount)) {
|
|
||||||
await api.updateRecurrence(requestId, recurType, recurCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (status !== 'Updated' || oldReq.text !== updateText) {
|
|
||||||
await api.updateRequest(requestId, status, oldReq.text !== updateText ? updateText : '')
|
|
||||||
}
|
|
||||||
const request = await api.getRequest(requestId)
|
|
||||||
commit(mutations.REQUEST_UPDATED, request.data)
|
|
||||||
progress.$emit('done')
|
|
||||||
} catch (err) {
|
|
||||||
logError(err)
|
|
||||||
progress.$emit('done')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async [actions.SHOW_REQUEST_NOW] ({ commit }, { progress, requestId, showAfter }) {
|
|
||||||
progress.$emit('show', 'indeterminate')
|
|
||||||
try {
|
|
||||||
await setBearer()
|
|
||||||
await api.showRequest(requestId, showAfter)
|
|
||||||
const request = await api.getRequest(requestId)
|
|
||||||
commit(mutations.REQUEST_UPDATED, request.data)
|
|
||||||
progress.$emit('done')
|
|
||||||
} catch (err) {
|
|
||||||
logError(err)
|
|
||||||
progress.$emit('done')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async [actions.SNOOZE_REQUEST] ({ commit }, { progress, requestId, until }) {
|
|
||||||
progress.$emit('show', 'indeterminate')
|
|
||||||
try {
|
|
||||||
await setBearer()
|
|
||||||
await api.snoozeRequest(requestId, until)
|
|
||||||
const request = await api.getRequest(requestId)
|
|
||||||
commit(mutations.REQUEST_UPDATED, request.data)
|
|
||||||
progress.$emit('done')
|
|
||||||
} catch (err) {
|
|
||||||
logError(err)
|
|
||||||
progress.$emit('done')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
getters: {},
|
|
||||||
modules: {}
|
|
||||||
})
|
|
@ -1,18 +0,0 @@
|
|||||||
'use strict'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
/** Mutation for when the user's prayer journal is being loaded */
|
|
||||||
LOADING_JOURNAL: 'loading-journal',
|
|
||||||
/** Mutation for when the user's prayer journal has been loaded */
|
|
||||||
LOADED_JOURNAL: 'journal-loaded',
|
|
||||||
/** Mutation for adding a new prayer request (pass text) */
|
|
||||||
REQUEST_ADDED: 'request-added',
|
|
||||||
/** Mutation to replace a prayer request at the top of the current journal */
|
|
||||||
REQUEST_UPDATED: 'request-updated',
|
|
||||||
/** Mutation for setting the authentication state */
|
|
||||||
SET_AUTHENTICATION: 'set-authentication',
|
|
||||||
/** Mutation for logging a user off */
|
|
||||||
USER_LOGGED_OFF: 'user-logged-off',
|
|
||||||
/** Mutation for logging a user on (pass user) */
|
|
||||||
USER_LOGGED_ON: 'user-logged-on'
|
|
||||||
}
|
|
@ -1,22 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<script src="https://cdn.auth0.com/js/auth0/8.9/auth0.min.js"></script>
|
|
||||||
<script>
|
|
||||||
var webAuth = new auth0.WebAuth({
|
|
||||||
domain: 'djs-consulting.auth0.com',
|
|
||||||
clientID: 'Of2s0RQCQ3mt3dwIkOBY5h85J9sXbF2n',
|
|
||||||
scope: 'openid profile email',
|
|
||||||
responseType: 'token id_token',
|
|
||||||
redirectUri: location.protocol + '//' + location.host + '/static/silent.html'
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
<script>
|
|
||||||
webAuth.parseHash(window.location.hash, function (err, response) {
|
|
||||||
parent.postMessage(err || response, location.protocol + '//' + location.host);
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
<body></body>
|
|
||||||
</html>
|
|
@ -1,16 +0,0 @@
|
|||||||
const webpack = require('webpack')
|
|
||||||
// const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
|
|
||||||
module.exports = {
|
|
||||||
outputDir: '../MyPrayerJournal.Api/wwwroot',
|
|
||||||
configureWebpack: {
|
|
||||||
plugins: [
|
|
||||||
// new BundleAnalyzerPlugin(),
|
|
||||||
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/)
|
|
||||||
],
|
|
||||||
optimization: {
|
|
||||||
splitChunks: {
|
|
||||||
chunks: 'all'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
9124
src/app/yarn.lock
9124
src/app/yarn.lock
File diff suppressed because it is too large
Load Diff
@ -1,31 +0,0 @@
|
|||||||
ALTER TABLE mpj.request
|
|
||||||
ADD COLUMN "showAfter" BIGINT NOT NULL DEFAULT 0;
|
|
||||||
ALTER TABLE mpj.request
|
|
||||||
ADD COLUMN "recurType" VARCHAR(10) NOT NULL DEFAULT 'immediate';
|
|
||||||
ALTER TABLE mpj.request
|
|
||||||
ADD COLUMN "recurCount" SMALLINT NOT NULL DEFAULT 0;
|
|
||||||
CREATE OR REPLACE VIEW mpj.journal AS
|
|
||||||
SELECT
|
|
||||||
request."requestId",
|
|
||||||
request."userId",
|
|
||||||
(SELECT "text"
|
|
||||||
FROM mpj.history
|
|
||||||
WHERE history."requestId" = request."requestId"
|
|
||||||
AND "text" IS NOT NULL
|
|
||||||
ORDER BY "asOf" DESC
|
|
||||||
LIMIT 1) AS "text",
|
|
||||||
(SELECT "asOf"
|
|
||||||
FROM mpj.history
|
|
||||||
WHERE history."requestId" = request."requestId"
|
|
||||||
ORDER BY "asOf" DESC
|
|
||||||
LIMIT 1) AS "asOf",
|
|
||||||
(SELECT "status"
|
|
||||||
FROM mpj.history
|
|
||||||
WHERE history."requestId" = request."requestId"
|
|
||||||
ORDER BY "asOf" DESC
|
|
||||||
LIMIT 1) AS "lastStatus",
|
|
||||||
request."snoozedUntil",
|
|
||||||
request."showAfter",
|
|
||||||
request."recurType",
|
|
||||||
request."recurCount"
|
|
||||||
FROM mpj.request;
|
|
Loading…
Reference in New Issue
Block a user