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