Version 3 #67
.gitignorepublish.ps1
src
MyPrayerJournal.Api
MyPrayerJournal.Migrate
MyPrayerJournal.slnMyPrayerJournal
.gitignoreData.fsDates.fsDomain.fsHandlers.fsMyPrayerJournal.fsprojProgram.fs
Properties
Views
appsettings.jsonwwwroot
app
.browserslistrc.eslintrc.js.gitignore.postcssrc.jsLICENSEbabel.config.jspackage.json
public
src
App.vue
api
auth
components
Home.vueJournal.vue
main.jscommon
legal
request
ActiveRequests.vueAnsweredRequests.vueEditRequest.vueFullRequest.vueNotesEdit.vueRequestCard.vueRequestList.vueRequestListItem.vueSnoozeRequest.vueSnoozedRequests.vue
user
plugins
router.jsstore
static
vue.config.jsyarn.locksql
10
.gitignore
vendored
10
.gitignore
vendored
@ -254,13 +254,3 @@ paket-files/
|
||||
|
||||
# Ionide VSCode extension
|
||||
.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": {
|
||||
"EndPoints": {
|
||||
"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: 16px | Height: 16px | 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