myPrayerJournal v2 #27
							
								
								
									
										12
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										12
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -256,11 +256,11 @@ paket-files/ | ||||
| .ionide | ||||
| 
 | ||||
| # Compiled files / application | ||||
| src/api/build | ||||
| src/api/MyPrayerJournal.Api/wwwroot/favicon.ico | ||||
| src/api/MyPrayerJournal.Api/wwwroot/index.html | ||||
| src/api/MyPrayerJournal.Api/wwwroot/css | ||||
| src/api/MyPrayerJournal.Api/wwwroot/js | ||||
| src/api/MyPrayerJournal.Api/appsettings.development.json | ||||
| 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 | ||||
|  | ||||
							
								
								
									
										184
									
								
								src/MyPrayerJournal.Api/Data.fs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										184
									
								
								src/MyPrayerJournal.Api/Data.fs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,184 @@ | ||||
| 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 | ||||
|         |> List.ofSeq | ||||
|         |> List.map (fun r -> r.history <- []; r.notes <- []; r) | ||||
|       } | ||||
| 
 | ||||
|   /// 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 | ||||
|       } | ||||
|        | ||||
|   /// 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) | ||||
							
								
								
									
										169
									
								
								src/MyPrayerJournal.Api/Domain.fs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										169
									
								
								src/MyPrayerJournal.Api/Domain.fs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,169 @@ | ||||
| [<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,11 +1,8 @@ | ||||
| /// HTTP handlers for the myPrayerJournal API | ||||
| [<RequireQualifiedAccess>] | ||||
| module MyPrayerJournal.Api.Handlers | ||||
| module MyPrayerJournal.Handlers | ||||
| 
 | ||||
| open FSharp.Control.Tasks.V2.ContextInsensitive | ||||
| open Giraffe | ||||
| open MyPrayerJournal | ||||
| open System | ||||
| 
 | ||||
| /// Handler to return Vue files | ||||
| module Vue = | ||||
| @ -13,6 +10,7 @@ module Vue = | ||||
|   /// The application index page | ||||
|   let app : HttpHandler = htmlFile "wwwroot/index.html" | ||||
| 
 | ||||
| open System | ||||
| 
 | ||||
| /// Handlers for error conditions | ||||
| module Error = | ||||
| @ -34,18 +32,22 @@ module Error = | ||||
|       | 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 | ||||
| 
 | ||||
|   /// Get the database context from DI | ||||
|   let db (ctx : HttpContext) = | ||||
|     ctx.GetService<AppDbContext> () | ||||
|   /// 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) = | ||||
| @ -54,15 +56,23 @@ module private Helpers = | ||||
|   /// 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 | ||||
|     ((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 | ||||
|   /// The "now" time in JavaScript as Ticks | ||||
|   let jsNow () = | ||||
|     DateTime.UtcNow.Subtract(DateTime (1970, 1, 1, 0, 0, 0)).TotalSeconds |> int64 |> (*) 1000L | ||||
|     (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 = | ||||
| @ -116,13 +126,6 @@ module Models = | ||||
|       recurCount  : int16 | ||||
|       } | ||||
|    | ||||
|   /// Reset the "showAfter" property on a request | ||||
|   [<CLIMutable>] | ||||
|   type Show = | ||||
|     { /// The time after which the request should appear | ||||
|       showAfter : int64 | ||||
|       } | ||||
| 
 | ||||
|   /// The time until which a request should not appear in the journal | ||||
|   [<CLIMutable>] | ||||
|   type SnoozeUntil = | ||||
| @ -130,6 +133,7 @@ module Models = | ||||
|       until : int64 | ||||
|       } | ||||
| 
 | ||||
| open FSharp.Control.Tasks.V2.ContextInsensitive | ||||
| 
 | ||||
| /// /api/journal URLs | ||||
| module Journal = | ||||
| @ -138,99 +142,92 @@ module Journal = | ||||
|   let journal : HttpHandler = | ||||
|     authorize | ||||
|     >=> fun next ctx -> | ||||
|       userId ctx | ||||
|       |> (db ctx).JournalByUserId | ||||
|       |> asJson 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 = | ||||
|    | ||||
|   open NCuid | ||||
|    | ||||
|   /// Ticks per recurrence | ||||
|   let private recurrence = | ||||
|     [ "immediate",         0L | ||||
|       "hours",       3600000L | ||||
|       "days",       86400000L | ||||
|       "weeks",     604800000L | ||||
|       ] | ||||
|     |> Map.ofList | ||||
| 
 | ||||
|   /// POST /api/request | ||||
|   let add : HttpHandler = | ||||
|     authorize | ||||
|     >=> fun next ctx -> | ||||
|       task { | ||||
|         let! r     = ctx.BindJsonAsync<Models.Request> () | ||||
|         let  db    = db ctx | ||||
|         let  reqId = Cuid.Generate () | ||||
|         use  sess  = session ctx | ||||
|         let  reqId = (Cuid.generate >> RequestId) () | ||||
|         let  usrId = userId ctx | ||||
|         let  now   = jsNow () | ||||
|         { Request.empty with | ||||
|             requestId  = reqId | ||||
|             userId     = usrId | ||||
|             enteredOn  = now | ||||
|             showAfter  = now | ||||
|             recurType  = r.recurType | ||||
|             recurCount = r.recurCount | ||||
|           } | ||||
|         |> db.AddEntry | ||||
|         { History.empty with | ||||
|             requestId = reqId | ||||
|             asOf      = now | ||||
|             status    = "Created" | ||||
|             text      = Some r.requestText | ||||
|             } | ||||
|         |> db.AddEntry | ||||
|         let! _   = db.SaveChangesAsync () | ||||
|         match! db.TryJournalById reqId usrId with | ||||
|         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 reqId : HttpHandler = | ||||
|   let addHistory requestId : HttpHandler = | ||||
|     authorize | ||||
|     >=> fun next ctx -> | ||||
|       task { | ||||
|         let db = db ctx | ||||
|         match! db.TryRequestById reqId (userId ctx) with | ||||
|         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 () | ||||
|             { History.empty with | ||||
|                 requestId = reqId | ||||
|                 asOf      = now | ||||
|                 status    = hist.status | ||||
|                 text      = match hist.updateText with null | "" -> None | x -> Some x | ||||
|               } | ||||
|             |> db.AddEntry | ||||
|             match hist.status with | ||||
|             | "Prayed" -> | ||||
|                 db.UpdateEntry { req with showAfter = now + (recurrence.[req.recurType] * int64 req.recurCount) } | ||||
|             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 | ||||
|             | _ -> () | ||||
|             let! _ = db.SaveChangesAsync () | ||||
|             do! Data.saveChanges sess | ||||
|             return! created next ctx | ||||
|         | None -> return! Error.notFound next ctx | ||||
|         } | ||||
|    | ||||
|   /// POST /api/request/[req-id]/note | ||||
|   let addNote reqId : HttpHandler = | ||||
|   let addNote requestId : HttpHandler = | ||||
|     authorize | ||||
|     >=> fun next ctx -> | ||||
|       task { | ||||
|         let db = db ctx | ||||
|         match! db.TryRequestById reqId (userId ctx) with | ||||
|         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> () | ||||
|             { Note.empty with | ||||
|                 requestId = reqId | ||||
|                 asOf      = jsNow () | ||||
|                 notes     = notes.notes | ||||
|               } | ||||
|             |> db.AddEntry | ||||
|             let! _ = db.SaveChangesAsync () | ||||
|             Data.addNote reqId { asOf = jsNow (); notes = notes.notes } sess | ||||
|             do! Data.saveChanges sess | ||||
|             return! created next ctx | ||||
|         | None -> return! Error.notFound next ctx | ||||
|         } | ||||
| @ -239,83 +236,129 @@ module Request = | ||||
|   let answered : HttpHandler = | ||||
|     authorize | ||||
|     >=> fun next ctx -> | ||||
|       userId ctx | ||||
|       |> (db ctx).AnsweredRequests | ||||
|       |> asJson 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 reqId : HttpHandler = | ||||
|   let get requestId : HttpHandler = | ||||
|     authorize | ||||
|     >=> fun next ctx -> | ||||
|       task { | ||||
|         match! (db ctx).TryJournalById reqId (userId ctx) with | ||||
|         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 reqId : HttpHandler = | ||||
|   let getFull requestId : HttpHandler = | ||||
|     authorize | ||||
|     >=> fun next ctx -> | ||||
|       task { | ||||
|         match! (db ctx).TryFullRequestById reqId (userId ctx) with | ||||
|         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 reqId : HttpHandler = | ||||
|   let getNotes requestId : HttpHandler = | ||||
|     authorize | ||||
|     >=> fun next ctx -> | ||||
|       task { | ||||
|         let! notes = (db ctx).NotesById reqId (userId ctx) | ||||
|         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 reqId : HttpHandler = | ||||
|   let show requestId : HttpHandler = | ||||
|     authorize | ||||
|     >=> fun next ctx -> | ||||
|       task { | ||||
|         let db = db ctx | ||||
|         match! db.TryRequestById reqId (userId ctx) with | ||||
|         | Some req -> | ||||
|             let! show = ctx.BindJsonAsync<Models.Show> () | ||||
|             { req with showAfter = show.showAfter } | ||||
|             |> db.UpdateEntry | ||||
|             let! _ = db.SaveChangesAsync () | ||||
|         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 reqId : HttpHandler = | ||||
|   let snooze requestId : HttpHandler = | ||||
|     authorize | ||||
|     >=> fun next ctx -> | ||||
|       task { | ||||
|         let db = db ctx | ||||
|         match! db.TryRequestById reqId (userId ctx) with | ||||
|         | Some req -> | ||||
|         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> () | ||||
|             { req with snoozedUntil = until.until; showAfter = until.until } | ||||
|             |> db.UpdateEntry | ||||
|             let! _ = db.SaveChangesAsync () | ||||
|             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 reqId : HttpHandler = | ||||
|   let updateRecurrence requestId : HttpHandler = | ||||
|     authorize | ||||
|     >=> fun next ctx -> | ||||
|       task { | ||||
|         let db = db ctx | ||||
|         match! db.TryRequestById reqId (userId ctx) with | ||||
|         | Some req -> | ||||
|         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> () | ||||
|             { req with recurType = recur.recurType; recurCount = recur.recurCount } | ||||
|             |> db.UpdateEntry | ||||
|             let! _ = db.SaveChangesAsync () | ||||
|             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 +1,30 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk.Web"> | ||||
| <Project Sdk="Microsoft.NET.Sdk.Web"> | ||||
| 
 | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>netcoreapp2.2</TargetFramework> | ||||
|     <Version>1.2.2.0</Version> | ||||
|     <Version>2.0.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="FSharp.EFCore.OptionConverter" Version="1.0.0" /> | ||||
|     <PackageReference Include="FunctionalCuid" Version="1.0.0" /> | ||||
|     <PackageReference Include="Giraffe" Version="3.6.0" /> | ||||
|     <PackageReference Include="Giraffe.TokenRouter" Version="1.0.0" /> | ||||
|     <PackageReference Include="Microsoft.AspNetCore.App" /> | ||||
|     <PackageReference Include="Microsoft.FSharpLu" Version="0.10.29" /> | ||||
|     <PackageReference Include="Microsoft.FSharpLu.Json" Version="0.10.29" /> | ||||
|     <PackageReference Include="NCuid.NetCore" Version="1.0.1" /> | ||||
|     <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="2.2.0" /> | ||||
|     <PackageReference Include="RavenDb.Client" Version="4.2.1" /> | ||||
|     <PackageReference Include="TaskBuilder.fs" Version="2.1.0" /> | ||||
|   </ItemGroup> | ||||
| 
 | ||||
|   <ItemGroup> | ||||
|     <PackageReference Update="FSharp.Core" Version="4.6.2" /> | ||||
|     <PackageReference Update="FSharp.Core" Version="4.7.0" /> | ||||
|   </ItemGroup> | ||||
| 
 | ||||
|   <ItemGroup> | ||||
							
								
								
									
										144
									
								
								src/MyPrayerJournal.Api/Program.fs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										144
									
								
								src/MyPrayerJournal.Api/Program.fs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,144 @@ | ||||
| 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 | ||||
| 
 | ||||
|   /// Configure logging | ||||
|   let logging (bldr : IWebHostBuilder) = | ||||
|     let logz (log : ILoggingBuilder) = | ||||
|       let env = log.Services.BuildServiceProvider().GetService<IHostingEnvironment> () | ||||
|       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<IHostingEnvironment> () | ||||
|             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 | ||||
							
								
								
									
										37
									
								
								src/MyPrayerJournal.sln
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/MyPrayerJournal.sln
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,37 @@ | ||||
|  | ||||
| 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 | ||||
| @ -1,275 +0,0 @@ | ||||
| namespace MyPrayerJournal | ||||
| 
 | ||||
| open FSharp.Control.Tasks.V2.ContextInsensitive | ||||
| open Microsoft.EntityFrameworkCore | ||||
| open Microsoft.FSharpLu | ||||
| 
 | ||||
| /// Entities for use in the data model for myPrayerJournal | ||||
| [<AutoOpen>] | ||||
| module Entities = | ||||
|    | ||||
|   open FSharp.EFCore.OptionConverter | ||||
|   open System.Collections.Generic | ||||
| 
 | ||||
|   /// Type alias for a Collision-resistant Unique IDentifier | ||||
|   type Cuid = string | ||||
| 
 | ||||
|   /// Request ID is a CUID | ||||
|   type RequestId = Cuid | ||||
| 
 | ||||
|   /// User ID is a string (the "sub" part of the JWT) | ||||
|   type UserId = string | ||||
| 
 | ||||
|   /// History is a record of action taken on a prayer request, including updates to its text | ||||
|   type [<CLIMutable; NoComparison; NoEquality>] History = | ||||
|     { /// The ID of the request to which this history entry applies | ||||
|       requestId : RequestId | ||||
|       /// The time when this history entry was made | ||||
|       asOf      : int64 | ||||
|       /// The status for this history entry | ||||
|       status    : string | ||||
|       /// The text of the update, if applicable | ||||
|       text      : string option | ||||
|       } | ||||
|   with | ||||
|     /// An empty history entry | ||||
|     static member empty = | ||||
|       { requestId = "" | ||||
|         asOf      = 0L | ||||
|         status    = "" | ||||
|         text      = None | ||||
|         } | ||||
| 
 | ||||
|     static member configureEF (mb : ModelBuilder) = | ||||
|       mb.Entity<History> ( | ||||
|         fun m -> | ||||
|           m.ToTable "history" |> ignore | ||||
|           m.HasKey ("requestId", "asOf") |> ignore | ||||
|           m.Property(fun e -> e.requestId).IsRequired () |> ignore | ||||
|           m.Property(fun e -> e.asOf).IsRequired () |> ignore | ||||
|           m.Property(fun e -> e.status).IsRequired() |> ignore | ||||
|           m.Property(fun e -> e.text) |> ignore) | ||||
|       |> ignore | ||||
|       let typ = mb.Model.FindEntityType(typeof<History>) | ||||
|       let prop = typ.FindProperty("text") | ||||
|       mb.Model.FindEntityType(typeof<History>).FindProperty("text").SetValueConverter (OptionConverter<string> ()) | ||||
| 
 | ||||
|   /// Note is a note regarding a prayer request that does not result in an update to its text | ||||
|   and [<CLIMutable; NoComparison; NoEquality>] Note = | ||||
|     { /// The ID of the request to which this note applies | ||||
|       requestId : RequestId | ||||
|       /// The time when this note was made | ||||
|       asOf      : int64 | ||||
|       /// The text of the notes | ||||
|       notes     : string | ||||
|       } | ||||
|   with | ||||
|     /// An empty note | ||||
|     static member empty = | ||||
|       { requestId = "" | ||||
|         asOf      = 0L | ||||
|         notes     = "" | ||||
|         } | ||||
| 
 | ||||
|     static member configureEF (mb : ModelBuilder) = | ||||
|       mb.Entity<Note> ( | ||||
|         fun m -> | ||||
|           m.ToTable "note" |> ignore | ||||
|           m.HasKey ("requestId", "asOf") |> ignore | ||||
|           m.Property(fun e -> e.requestId).IsRequired () |> ignore | ||||
|           m.Property(fun e -> e.asOf).IsRequired () |> ignore | ||||
|           m.Property(fun e -> e.notes).IsRequired () |> ignore) | ||||
|       |> ignore | ||||
| 
 | ||||
|   /// Request is the identifying record for a prayer request | ||||
|   and [<CLIMutable; NoComparison; NoEquality>] Request = | ||||
|     { /// The ID of the request | ||||
|       requestId    : RequestId | ||||
|       /// The time this request was initially entered | ||||
|       enteredOn    : int64 | ||||
|       /// The ID of the user to whom this request belongs ("sub" from the JWT) | ||||
|       userId       : string | ||||
|       /// The time at which this request should reappear in the user's journal by manual user choice | ||||
|       snoozedUntil : int64 | ||||
|       /// The time at which this request should reappear in the user's journal by recurrence | ||||
|       showAfter    : int64 | ||||
|       /// The type of recurrence for this request | ||||
|       recurType    : string | ||||
|       /// How many of the recurrence intervals should occur between appearances in the journal | ||||
|       recurCount   : int16 | ||||
|       /// The history entries for this request | ||||
|       history      : ICollection<History> | ||||
|       /// The notes for this request | ||||
|       notes        : ICollection<Note> | ||||
|       } | ||||
|   with | ||||
|     /// An empty request | ||||
|     static member empty = | ||||
|       { requestId    = "" | ||||
|         enteredOn    = 0L | ||||
|         userId       = "" | ||||
|         snoozedUntil = 0L | ||||
|         showAfter    = 0L | ||||
|         recurType    = "immediate" | ||||
|         recurCount   = 0s | ||||
|         history      = List<History> () | ||||
|         notes        = List<Note> () | ||||
|         } | ||||
| 
 | ||||
|     static member configureEF (mb : ModelBuilder) = | ||||
|       mb.Entity<Request> ( | ||||
|         fun m -> | ||||
|           m.ToTable "request" |> ignore | ||||
|           m.HasKey(fun e -> e.requestId :> obj) |> ignore | ||||
|           m.Property(fun e -> e.requestId).IsRequired () |> ignore | ||||
|           m.Property(fun e -> e.enteredOn).IsRequired () |> ignore | ||||
|           m.Property(fun e -> e.userId).IsRequired () |> ignore | ||||
|           m.Property(fun e -> e.snoozedUntil).IsRequired () |> ignore | ||||
|           m.Property(fun e -> e.showAfter).IsRequired () |> ignore | ||||
|           m.Property(fun e -> e.recurType).IsRequired() |> ignore | ||||
|           m.Property(fun e -> e.recurCount).IsRequired() |> ignore | ||||
|           m.HasMany(fun e -> e.history :> IEnumerable<History>) | ||||
|             .WithOne() | ||||
|             .HasForeignKey(fun e -> e.requestId :> obj) | ||||
|           |> ignore | ||||
|           m.HasMany(fun e -> e.notes :> IEnumerable<Note>) | ||||
|             .WithOne() | ||||
|             .HasForeignKey(fun e -> e.requestId :> obj) | ||||
|           |> ignore) | ||||
|       |> ignore | ||||
| 
 | ||||
|   /// 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 | ||||
|   [<CLIMutable; NoComparison; NoEquality>] | ||||
|   type JournalRequest = | ||||
|     { /// The ID of the request | ||||
|       requestId    : RequestId | ||||
|       /// The ID of the user to whom the request belongs | ||||
|       userId       : string | ||||
|       /// The current text of the request | ||||
|       text         : string | ||||
|       /// The last time action was taken on the request | ||||
|       asOf         : int64 | ||||
|       /// The last status for the request | ||||
|       lastStatus   : string | ||||
|       /// The time that this request should reappear in the user's journal | ||||
|       snoozedUntil : int64 | ||||
|       /// The time after which this request should reappear in the user's journal by configured recurrence | ||||
|       showAfter    : int64 | ||||
|       /// The type of recurrence for this request | ||||
|       recurType    : string | ||||
|       /// How many of the recurrence intervals should occur between appearances in the journal | ||||
|       recurCount   : int16 | ||||
|       /// History entries for the request | ||||
|       history      : History list | ||||
|       /// Note entries for the request | ||||
|       notes        : Note list | ||||
|       } | ||||
|   with | ||||
|     static member configureEF (mb : ModelBuilder) = | ||||
|       mb.Query<JournalRequest> ( | ||||
|         fun m -> | ||||
|           m.ToView "journal" |> ignore | ||||
|           m.Ignore(fun e -> e.history :> obj) |> ignore | ||||
|           m.Ignore(fun e -> e.notes :> obj) |> ignore) | ||||
|       |> ignore | ||||
| 
 | ||||
| 
 | ||||
| open System.Linq | ||||
| 
 | ||||
| /// Data context | ||||
| type AppDbContext (opts : DbContextOptions<AppDbContext>) = | ||||
|   inherit DbContext (opts) | ||||
| 
 | ||||
|   [<DefaultValue>] | ||||
|   val mutable private history  : DbSet<History> | ||||
|   [<DefaultValue>] | ||||
|   val mutable private notes    : DbSet<Note> | ||||
|   [<DefaultValue>] | ||||
|   val mutable private requests : DbSet<Request> | ||||
|   [<DefaultValue>] | ||||
|   val mutable private journal  : DbQuery<JournalRequest> | ||||
| 
 | ||||
|   member this.History | ||||
|     with get () = this.history | ||||
|       and set v = this.history <- v | ||||
|   member this.Notes | ||||
|     with get () = this.notes | ||||
|       and set v = this.notes <- v | ||||
|   member this.Requests | ||||
|     with get () = this.requests | ||||
|       and set v = this.requests <- v | ||||
|   member this.Journal | ||||
|     with get () = this.journal | ||||
|       and set v = this.journal <- v | ||||
|    | ||||
|   override __.OnModelCreating (mb : ModelBuilder) = | ||||
|     base.OnModelCreating mb | ||||
|     [ History.configureEF | ||||
|       Note.configureEF | ||||
|       Request.configureEF | ||||
|       JournalRequest.configureEF | ||||
|       ] | ||||
|     |> List.iter (fun x -> x mb) | ||||
|    | ||||
|   /// Register a disconnected entity with the context, having the given state | ||||
|   member private this.RegisterAs<'TEntity when 'TEntity : not struct> state e = | ||||
|     this.Entry<'TEntity>(e).State <- state | ||||
| 
 | ||||
|   /// Add an entity instance to the context | ||||
|   member this.AddEntry e = | ||||
|     this.RegisterAs EntityState.Added e | ||||
| 
 | ||||
|   /// Update the entity instance's values | ||||
|   member this.UpdateEntry e = | ||||
|     this.RegisterAs EntityState.Modified e | ||||
| 
 | ||||
|   /// Retrieve all answered requests for the given user | ||||
|   member this.AnsweredRequests userId : JournalRequest seq = | ||||
|     upcast this.Journal | ||||
|       .Where(fun r -> r.userId = userId && r.lastStatus = "Answered") | ||||
|       .OrderByDescending(fun r -> r.asOf) | ||||
|    | ||||
|   /// Retrieve the user's current journal | ||||
|   member this.JournalByUserId userId : JournalRequest seq = | ||||
|     upcast this.Journal | ||||
|       .Where(fun r -> r.userId = userId && r.lastStatus <> "Answered") | ||||
|       .OrderBy(fun r -> r.showAfter) | ||||
|    | ||||
|   /// Retrieve a request by its ID and user ID | ||||
|   member this.TryRequestById reqId userId = | ||||
|     task { | ||||
|       let! req = this.Requests.AsNoTracking().FirstOrDefaultAsync(fun r -> r.requestId = reqId && r.userId = userId) | ||||
|       return Option.fromObject req | ||||
|       } | ||||
| 
 | ||||
|   /// Retrieve notes for a request by its ID and user ID | ||||
|   member this.NotesById reqId userId = | ||||
|     task { | ||||
|       match! this.TryRequestById reqId userId with | ||||
|       | Some _ -> return this.Notes.AsNoTracking().Where(fun n -> n.requestId = reqId) |> List.ofSeq | ||||
|       | None -> return [] | ||||
|       } | ||||
| 
 | ||||
|   /// Retrieve a journal request by its ID and user ID | ||||
|   member this.TryJournalById reqId userId = | ||||
|     task { | ||||
|       let! req = this.Journal.FirstOrDefaultAsync(fun r -> r.requestId = reqId && r.userId = userId) | ||||
|       return Option.fromObject req | ||||
|       } | ||||
|    | ||||
|   /// Retrieve a request, including its history and notes, by its ID and user ID | ||||
|   member this.TryFullRequestById requestId userId = | ||||
|     task { | ||||
|       match! this.TryJournalById requestId userId with | ||||
|       | Some req -> | ||||
|           let! fullReq = | ||||
|             this.Requests.AsNoTracking() | ||||
|               .Include(fun r -> r.history) | ||||
|               .Include(fun r -> r.notes) | ||||
|               .FirstOrDefaultAsync(fun r -> r.requestId = requestId && r.userId = userId) | ||||
|           match Option.fromObject fullReq with | ||||
|           | Some _ -> return Some { req with history = List.ofSeq fullReq.history; notes = List.ofSeq fullReq.notes } | ||||
|           | None -> return None | ||||
|       | None -> return None | ||||
|       } | ||||
| @ -1,139 +0,0 @@ | ||||
| namespace MyPrayerJournal.Api | ||||
| 
 | ||||
| open Microsoft.AspNetCore.Builder | ||||
| open Microsoft.AspNetCore.Hosting | ||||
| open System | ||||
| 
 | ||||
| /// Configuration functions for the application | ||||
| module Configure = | ||||
|    | ||||
|   open Giraffe | ||||
|   open Giraffe.Serialization | ||||
|   open Giraffe.TokenRouter | ||||
|   open Microsoft.AspNetCore.Authentication.JwtBearer | ||||
|   open Microsoft.AspNetCore.Server.Kestrel.Core | ||||
|   open Microsoft.EntityFrameworkCore | ||||
|   open Microsoft.Extensions.Configuration | ||||
|   open Microsoft.Extensions.DependencyInjection | ||||
|   open Microsoft.Extensions.Logging | ||||
|   open Microsoft.FSharpLu.Json | ||||
|   open MyPrayerJournal | ||||
|   open Newtonsoft.Json | ||||
| 
 | ||||
|   /// Set up the configuration for the app | ||||
|   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 | ||||
|      | ||||
|   /// Configure Kestrel from appsettings.json | ||||
|   let kestrel (ctx : WebHostBuilderContext) (opts : KestrelServerOptions) = | ||||
|     (ctx.Configuration.GetSection >> opts.Configure >> ignore) "Kestrel" | ||||
| 
 | ||||
|   /// Custom settings for the JSON serializer (uses compact representation for options and DUs) | ||||
|   let jsonSettings = | ||||
|     let x = NewtonsoftJsonSerializer.DefaultSettings | ||||
|     x.Converters.Add (CompactUnionJsonConverter (true)) | ||||
|     x.NullValueHandling     <- NullValueHandling.Ignore | ||||
|     x.MissingMemberHandling <- MissingMemberHandling.Error | ||||
|     x.Formatting            <- Formatting.Indented | ||||
|     x | ||||
| 
 | ||||
|   /// Configure dependency injection | ||||
|   let services (sc : IServiceCollection) = | ||||
|     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.AddDbContext<AppDbContext>(fun opts -> opts.UseNpgsql(cfg.GetConnectionString "mpj") |> ignore) | ||||
|       .AddSingleton<IJsonSerializer>(NewtonsoftJsonSerializer jsonSettings) | ||||
|     |> ignore | ||||
|    | ||||
|   /// Routes for the available URLs within myPrayerJournal | ||||
|   let webApp = | ||||
|     router Handlers.Error.notFound [ | ||||
|       route "/" Handlers.Vue.app | ||||
|       subRoute "/api/" [ | ||||
|         GET [ | ||||
|           route    "journal" Handlers.Journal.journal | ||||
|           subRoute "request" [ | ||||
|             route  "s/answered" Handlers.Request.answered | ||||
|             routef "/%s/full"   Handlers.Request.getFull | ||||
|             routef "/%s/notes"  Handlers.Request.getNotes | ||||
|             routef "/%s"        Handlers.Request.get | ||||
|             ] | ||||
|           ] | ||||
|         PATCH [ | ||||
|           subRoute "request" [ | ||||
|             routef "/%s/recurrence" Handlers.Request.updateRecurrence | ||||
|             routef "/%s/show"       Handlers.Request.show | ||||
|             routef "/%s/snooze"     Handlers.Request.snooze | ||||
|             ] | ||||
|           ] | ||||
|         POST [ | ||||
|           subRoute "request" [ | ||||
|             route  ""            Handlers.Request.add | ||||
|             routef "/%s/history" Handlers.Request.addHistory | ||||
|             routef "/%s/note"    Handlers.Request.addNote | ||||
|             ] | ||||
|           ] | ||||
|         ] | ||||
|       ] | ||||
| 
 | ||||
|   /// Configure the web application | ||||
|   let application (app : IApplicationBuilder) = | ||||
|     let env = app.ApplicationServices.GetService<IHostingEnvironment> () | ||||
|     match env.IsDevelopment () with | ||||
|     | true -> app.UseDeveloperExceptionPage () | ||||
|     | false -> app.UseGiraffeErrorHandler Handlers.Error.error | ||||
|     |> function | ||||
|     | a -> | ||||
|         a.UseAuthentication() | ||||
|           .UseStaticFiles() | ||||
|           .UseGiraffe webApp | ||||
|     |> ignore | ||||
| 
 | ||||
|   /// Configure logging | ||||
|   let logging (log : ILoggingBuilder) = | ||||
|     let env = log.Services.BuildServiceProvider().GetService<IHostingEnvironment> () | ||||
|     match env.IsDevelopment () with | ||||
|     | true -> log | ||||
|     | false -> log.AddFilter(fun l -> l > LogLevel.Information) | ||||
|     |> function l -> l.AddConsole().AddDebug() | ||||
|     |> ignore | ||||
| 
 | ||||
| 
 | ||||
| module Program = | ||||
|    | ||||
|   open System.IO | ||||
| 
 | ||||
|   let exitCode = 0 | ||||
| 
 | ||||
|   let CreateWebHostBuilder _ = | ||||
|     let contentRoot = Directory.GetCurrentDirectory () | ||||
|     WebHostBuilder() | ||||
|       .UseContentRoot(contentRoot) | ||||
|       .ConfigureAppConfiguration(Configure.configuration) | ||||
|       .UseKestrel(Configure.kestrel) | ||||
|       .UseWebRoot(Path.Combine (contentRoot, "wwwroot")) | ||||
|       .ConfigureServices(Configure.services) | ||||
|       .ConfigureLogging(Configure.logging) | ||||
|       .Configure(Action<IApplicationBuilder> Configure.application) | ||||
| 
 | ||||
|   [<EntryPoint>] | ||||
|   let main args = | ||||
|     CreateWebHostBuilder(args).Build().Run() | ||||
|     exitCode | ||||
| @ -1,25 +0,0 @@ | ||||
|  | ||||
| Microsoft Visual Studio Solution File, Format Version 12.00 | ||||
| # Visual Studio 15 | ||||
| VisualStudioVersion = 15.0.27703.2035 | ||||
| MinimumVisualStudioVersion = 10.0.40219.1 | ||||
| Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "MyPrayerJournal.Api", "MyPrayerJournal.Api\MyPrayerJournal.Api.fsproj", "{E0E5240C-00DC-428A-899A-DA4F06625B8A}" | ||||
| EndProject | ||||
| Global | ||||
| 	GlobalSection(SolutionConfigurationPlatforms) = preSolution | ||||
| 		Debug|Any CPU = Debug|Any CPU | ||||
| 		Release|Any CPU = Release|Any CPU | ||||
| 	EndGlobalSection | ||||
| 	GlobalSection(ProjectConfigurationPlatforms) = postSolution | ||||
| 		{E0E5240C-00DC-428A-899A-DA4F06625B8A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | ||||
| 		{E0E5240C-00DC-428A-899A-DA4F06625B8A}.Debug|Any CPU.Build.0 = Debug|Any CPU | ||||
| 		{E0E5240C-00DC-428A-899A-DA4F06625B8A}.Release|Any CPU.ActiveCfg = Release|Any CPU | ||||
| 		{E0E5240C-00DC-428A-899A-DA4F06625B8A}.Release|Any CPU.Build.0 = Release|Any CPU | ||||
| 	EndGlobalSection | ||||
| 	GlobalSection(SolutionProperties) = preSolution | ||||
| 		HideSolutionNode = FALSE | ||||
| 	EndGlobalSection | ||||
| 	GlobalSection(ExtensibilityGlobals) = postSolution | ||||
| 		SolutionGuid = {7EAB6243-94B3-49A5-BA64-7F01B8BE7CB9} | ||||
| 	EndGlobalSection | ||||
| EndGlobal | ||||
| @ -1,25 +1,24 @@ | ||||
| { | ||||
|   "name": "my-prayer-journal", | ||||
|   "version": "1.2.2", | ||||
|   "version": "2.0.0", | ||||
|   "description": "myPrayerJournal - Front End", | ||||
|   "author": "Daniel J. Summers <daniel@bitbadger.solutions>", | ||||
|   "private": true, | ||||
|   "scripts": { | ||||
|     "serve": "vue-cli-service serve", | ||||
|     "serve": "vue-cli-service serve --port 8081", | ||||
|     "build": "vue-cli-service build --modern", | ||||
|     "lint": "vue-cli-service lint", | ||||
|     "apistart": "cd ../api/MyPrayerJournal.Api && dotnet run", | ||||
|     "vue": "vue-cli-service build --modern && cd ../api/MyPrayerJournal.Api && dotnet run", | ||||
|     "publish": "vue-cli-service build --modern && cd ../api/MyPrayerJournal.Api && dotnet publish -c Release" | ||||
|     "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" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "auth0-js": "^9.7.3", | ||||
|     "axios": "^0.19.0", | ||||
|     "moment": "^2.18.1", | ||||
|     "vue": "^2.5.15", | ||||
|     "vue-progressbar": "^0.7.3", | ||||
|     "vue-material": "^1.0.0-beta-11", | ||||
|     "vue-router": "^3.0.0", | ||||
|     "vue-toast": "^3.1.0", | ||||
|     "vuex": "^3.0.1" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
| @ -27,8 +26,11 @@ | ||||
|     "@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": "^2.0.1", | ||||
|     "pug-plain-loader": "^1.0.0", | ||||
|     "vue-template-compiler": "^2.5.17" | ||||
|     "sass-loader": "^7.3.1", | ||||
|     "vue-template-compiler": "^2.5.17", | ||||
|     "webpack-bundle-analyzer": "^3.4.1" | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -1,26 +1,41 @@ | ||||
| <template lang="pug"> | ||||
| #app(role='application') | ||||
|   navigation | ||||
|   #content | ||||
|     router-view | ||||
|     vue-progress-bar | ||||
|     toast(ref='toast') | ||||
|   footer.mpj-text-right.mpj-muted-text | ||||
|     p | ||||
|       | myPrayerJournal v{{ version }} | ||||
|       br | ||||
|       em: small. | ||||
|         #[router-link(:to="{ name: 'PrivacyPolicy' }") Privacy Policy] • | ||||
|         #[router-link(:to="{ name: 'TermsOfService' }") 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] | ||||
| #app.page-container | ||||
|   md-app(md-waterfall md-mode='fixed-last' role='application') | ||||
|     md-app-toolbar.md-large.md-dense.md-primary | ||||
|       .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 | ||||
|       md-progress-bar(v-if='progress.visible' | ||||
|                       :md-mode='progress.mode') | ||||
|       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 Navigation from './components/common/Navigation.vue' | ||||
| import Vue from 'vue' | ||||
| 
 | ||||
| import Navigation from '@/components/common/Navigation' | ||||
| 
 | ||||
| import actions from '@/store/action-types' | ||||
| import { version } from '../package.json' | ||||
| 
 | ||||
| export default { | ||||
| @ -29,216 +44,115 @@ export default { | ||||
|     Navigation | ||||
|   }, | ||||
|   data () { | ||||
|     return {} | ||||
|     return { | ||||
|       progress: { | ||||
|         events: new Vue(), | ||||
|         visible: false, | ||||
|         mode: 'query' | ||||
|       }, | ||||
|       snackbar: { | ||||
|         events: new Vue(), | ||||
|         visible: false, | ||||
|         message: '', | ||||
|         interval: 4000 | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   mounted () { | ||||
|     this.$refs.toast.setOptions({ position: 'bottom right' }) | ||||
|   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: { | ||||
|     toast () { | ||||
|       return this.$refs.toast | ||||
|     }, | ||||
|     version () { | ||||
|       return version.endsWith('.0') ? version.substr(0, version.length - 2) : 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> | ||||
| html, body { | ||||
|   background-color: whitesmoke; | ||||
| <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; | ||||
| } | ||||
| body { | ||||
|   padding-top: 50px; | ||||
|   margin: 0; | ||||
| } | ||||
| h1, h2, h3, h4, h5 { | ||||
|   font-weight: 500; | ||||
|   margin-top: 0; | ||||
| } | ||||
| h1 { | ||||
|   font-size: 2.5rem; | ||||
| } | ||||
| h2 { | ||||
|   font-size: 2rem; | ||||
| } | ||||
| h3 { | ||||
|   font-size: 1.75rem; | ||||
| } | ||||
| h4 { | ||||
|   font-size: 1.5rem; | ||||
| } | ||||
| h5 { | ||||
|   font-size: 1.25rem; | ||||
| } | ||||
| p { | ||||
|   margin-bottom: 0; | ||||
| } | ||||
| input, textarea, select { | ||||
|   border-radius: .25rem; | ||||
|   font-size: 1rem; | ||||
| } | ||||
| textarea { | ||||
|   font-family: "SFMono-Regular",Consolas,"Liberation Mono",Menlo,Courier,monospace; | ||||
| } | ||||
| input, select { | ||||
|   font-family: inherit; | ||||
| } | ||||
| button, | ||||
| a[role="button"] { | ||||
|   border: solid 1px #050; | ||||
|   border-radius: .5rem; | ||||
|   background-color: rgb(235, 235, 235); | ||||
|   padding: .25rem; | ||||
|   font-size: 1rem; | ||||
| } | ||||
| a[role="button"]:link, | ||||
| a[role="button"]:visited { | ||||
|   color: black; | ||||
| } | ||||
| button.primary, | ||||
| a[role="button"].primary { | ||||
|   background-color: white; | ||||
|   border-width: 3px; | ||||
| } | ||||
| button:hover, | ||||
| a[role="button"]:hover { | ||||
|   cursor: pointer; | ||||
|   background-color: #050; | ||||
|   color: white; | ||||
|   text-decoration: none; | ||||
| } | ||||
| label { | ||||
|   font-variant: small-caps; | ||||
|   font-size: 1.1rem; | ||||
| } | ||||
| label.normal { | ||||
|   font-variant: unset; | ||||
|   font-size: unset; | ||||
| } | ||||
| footer { | ||||
|   border-top: solid 1px lightgray; | ||||
|   margin-top: 1rem; | ||||
|   padding: 0 1rem; | ||||
| } | ||||
| footer p { | ||||
|   margin: 0; | ||||
| } | ||||
| a:link, a:visited { | ||||
|   color: #050; | ||||
|   text-decoration: none; | ||||
| } | ||||
| a:hover { | ||||
|   text-decoration: underline; | ||||
| } | ||||
| .mpj-main-content { | ||||
|   max-width: 60rem; | ||||
|   margin: auto; | ||||
| } | ||||
| .mpj-main-content-wide { | ||||
|   margin: .5rem; | ||||
| } | ||||
| @media screen and (max-width: 21rem) { | ||||
|   .mpj-main-content-wide { | ||||
|     margin: 0; | ||||
|   } | ||||
| } | ||||
| .mpj-request-text { | ||||
|   white-space: pre-line; | ||||
| } | ||||
| .mpj-request-list p { | ||||
|   border-top: solid 1px lightgray; | ||||
| } | ||||
| .mpj-request-list p:first-child { | ||||
|   border-top: none; | ||||
| } | ||||
| .mpj-request-log { | ||||
|   width: 100%; | ||||
| } | ||||
| .mpj-request-log thead th { | ||||
|   border-top: solid 1px lightgray; | ||||
|   border-bottom: solid 2px lightgray; | ||||
|   text-align: left; | ||||
| } | ||||
| .mpj-request-log tbody td { | ||||
|   border-bottom: dotted 1px lightgray; | ||||
|   vertical-align: top; | ||||
| } | ||||
| .mpj-bg { | ||||
|   background-image: -webkit-gradient(linear, left top, left bottom, from(#050), to(whitesmoke)); | ||||
|   background-image: -webkit-linear-gradient(top, #050, whitesmoke); | ||||
|   background-image: -moz-linear-gradient(top, #050, whitesmoke); | ||||
|   background-image: linear-gradient(to bottom, #050, whitesmoke); | ||||
| } | ||||
| .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-narrow { | ||||
|   max-width: 40rem; | ||||
|   margin: auto; | ||||
| } | ||||
| .mpj-skinny { | ||||
|   max-width: 20rem; | ||||
|   margin: auto; | ||||
| } | ||||
| .mpj-full-width { | ||||
|   width: 100%; | ||||
| } | ||||
| .mpj-modal { | ||||
|   position: fixed; | ||||
|   z-index: 8; | ||||
|   left: 0; | ||||
|   top: 0; | ||||
|   width: 100%; | ||||
|   height: 100%; | ||||
|   overflow: auto; | ||||
|   background-color: rgba(0, 0, 0, .4); | ||||
| } | ||||
| .mpj-modal-content { | ||||
|   background-color: whitesmoke; | ||||
|   border: solid 1px #050; | ||||
|   border-radius: .5rem; | ||||
|   animation-name: animatetop; | ||||
|   animation-duration: 0.4s; | ||||
|   padding: 1rem; | ||||
|   margin-top: 4rem; | ||||
| } | ||||
| @keyframes animatetop { | ||||
|   from { | ||||
|     top: -300px; | ||||
|     opacity: 0; | ||||
|   } | ||||
|   to { | ||||
|     top: 0; | ||||
|     opacity: 1; | ||||
|   } | ||||
| } | ||||
| .mpj-modal-content header { | ||||
|   margin: -1rem -1rem .5rem; | ||||
|   border-radius: .4rem; | ||||
| } | ||||
| .mpj-modal-content header h5 { | ||||
|   color: white; | ||||
|   margin: 0; | ||||
|   padding: 1rem; | ||||
| } | ||||
| .mpj-margin { | ||||
|   margin-left: 1rem; | ||||
|   margin-right: 1rem; | ||||
| } | ||||
| .material-icons { | ||||
|   vertical-align: middle; | ||||
| } | ||||
|   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-progress-bar | ||||
|   margin: 24px | ||||
| </style> | ||||
|  | ||||
| @ -15,12 +15,12 @@ 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}` }, | ||||
|   setBearer: token => { http.defaults.headers.common['Authorization'] = `Bearer ${token}` }, | ||||
| 
 | ||||
|   /** | ||||
|    * Remove the bearer token | ||||
|    */ | ||||
|   removeBearer: () => delete http.defaults.headers.common['authorization'], | ||||
|   removeBearer: () => delete http.defaults.headers.common['Authorization'], | ||||
| 
 | ||||
|   /** | ||||
|    * Add a note for a prayer request | ||||
|  | ||||
| @ -1,31 +1,45 @@ | ||||
| 'use strict' | ||||
| 
 | ||||
| import auth0 from 'auth0-js' | ||||
| /* eslint-disable */ | ||||
| import auth0        from 'auth0-js' | ||||
| import EventEmitter from 'events' | ||||
| 
 | ||||
| import AUTH_CONFIG from './auth0-variables' | ||||
| import mutations from '@/store/mutation-types' | ||||
| import mutations   from '@/store/mutation-types' | ||||
| /* es-lint-enable*/ | ||||
| 
 | ||||
| var tokenRenewalTimeout | ||||
| // 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' | ||||
| }) | ||||
| 
 | ||||
| export default class AuthService { | ||||
|   constructor () { | ||||
|     this.login = this.login.bind(this) | ||||
|     this.setSession = this.setSession.bind(this) | ||||
|     this.logout = this.logout.bind(this) | ||||
|     this.isAuthenticated = this.isAuthenticated.bind(this) | ||||
| /** | ||||
|  * 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() | ||||
|   } | ||||
| 
 | ||||
|   auth0 = 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' | ||||
|   }) | ||||
| 
 | ||||
|   login () { | ||||
|     this.auth0.authorize() | ||||
|   /** | ||||
|    * Starts the user log in flow | ||||
|    */ | ||||
|   login (customState) { | ||||
|     webAuth.authorize({ | ||||
|       appState: customState | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
| @ -33,7 +47,7 @@ export default class AuthService { | ||||
|    */ | ||||
|   parseHash () { | ||||
|     return new Promise((resolve, reject) => { | ||||
|       this.auth0.parseHash((err, authResult) => { | ||||
|       webAuth.parseHash((err, authResult) => { | ||||
|         if (err) { | ||||
|           reject(err) | ||||
|         } else { | ||||
| @ -44,95 +58,137 @@ export default class AuthService { | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Promisified userInfo function | ||||
|    * | ||||
|    * @param token The auth token from the login result | ||||
|    * Handle authentication replies from Auth0 | ||||
|    *  | ||||
|    * @param store The Vuex store | ||||
|    */ | ||||
|   userInfo (token) { | ||||
|     return new Promise((resolve, reject) => { | ||||
|       this.auth0.client.userInfo(token, (err, user) => { | ||||
|         if (err) { | ||||
|           reject(err) | ||||
|         } else { | ||||
|           resolve(user) | ||||
|         } | ||||
|       }) | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   handleAuthentication (store, router) { | ||||
|     this.parseHash() | ||||
|       .then(authResult => { | ||||
|         if (authResult && authResult.accessToken && authResult.idToken) { | ||||
|           this.setSession(authResult) | ||||
|           this.userInfo(authResult.accessToken) | ||||
|             .then(user => { | ||||
|               store.commit(mutations.USER_LOGGED_ON, user) | ||||
|               router.replace('/journal') | ||||
|             }) | ||||
|         } | ||||
|       }) | ||||
|       .catch(err => { | ||||
|         router.replace('/') | ||||
|         console.log(err) | ||||
|         alert(`Error: ${err.error}. Check the console for further details.`) | ||||
|       }) | ||||
|   } | ||||
| 
 | ||||
|   scheduleRenewal () { | ||||
|     let expiresAt = JSON.parse(localStorage.getItem('expires_at')) | ||||
|     let delay = expiresAt - Date.now() | ||||
|     if (delay > 0) { | ||||
|       tokenRenewalTimeout = setTimeout(() => { | ||||
|         this.renewToken() | ||||
|       }, delay) | ||||
|   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) { | ||||
|     // Set the time that the access token will expire at
 | ||||
|     let expiresAt = JSON.stringify( | ||||
|       authResult.expiresIn * 1000 + new Date().getTime() | ||||
|     ) | ||||
|     localStorage.setItem('access_token', authResult.accessToken) | ||||
|     localStorage.setItem('id_token', authResult.idToken) | ||||
|     localStorage.setItem('expires_at', expiresAt) | ||||
|     this.scheduleRenewal() | ||||
|     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 || {} | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   renewToken () { | ||||
|     console.log('attempting renewal...') | ||||
|     this.auth0.renewAuth( | ||||
|       { | ||||
|         audience: `https://${AUTH_CONFIG.domain}/userinfo`, | ||||
|         redirectUri: `${AUTH_CONFIG.appDomain}/static/silent.html`, | ||||
|         usePostMessage: true | ||||
|       }, | ||||
|       (err, result) => { | ||||
|         if (err) { | ||||
|           console.log(err) | ||||
|         } else { | ||||
|           this.setSession(result) | ||||
|   /** | ||||
|    * 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') | ||||
|       } | ||||
|     ) | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   logout (store, router) { | ||||
|   /** | ||||
|    * Log out of myPrayerJournal | ||||
|    *  | ||||
|    * @param store The Vuex store | ||||
|    */ | ||||
|   logout (store) { | ||||
|     // Clear access token and ID token from local storage
 | ||||
|     clearTimeout(tokenRenewalTimeout) | ||||
|     localStorage.removeItem('access_token') | ||||
|     localStorage.removeItem('id_token') | ||||
|     localStorage.removeItem('expires_at') | ||||
|     localStorage.setItem('user_profile', JSON.stringify({})) | ||||
|     // navigate to the home route
 | ||||
|     localStorage.removeItem(this.AUTH_SESSION) | ||||
|     this.refreshSession() | ||||
| 
 | ||||
|     store.commit(mutations.USER_LOGGED_OFF) | ||||
|     router.replace('/') | ||||
| 
 | ||||
|     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 () { | ||||
|     // Check whether the current time is past the access token's expiry time
 | ||||
|     let expiresAt = JSON.parse(localStorage.getItem('expires_at')) | ||||
|     return new Date().getTime() < expiresAt | ||||
|     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,16 +1,16 @@ | ||||
| <template lang="pug"> | ||||
| article.mpj-main-content(role='main') | ||||
| md-content(role='main').mpj-main-content | ||||
|   page-title(title='Welcome!' | ||||
|              hideOnPage='true') | ||||
|              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 will also allow | ||||
|     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 currently in beta, but it 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. | ||||
|     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> | ||||
|  | ||||
| @ -1,25 +1,24 @@ | ||||
| <template lang="pug"> | ||||
| article.mpj-main-content-wide(role='main') | ||||
| md-content(role='main').mpj-main-content-wide | ||||
|   page-title(:title='title') | ||||
|   p(v-if='isLoadingJournal') Loading your prayer journal... | ||||
|   template(v-else) | ||||
|     .mpj-text-center | ||||
|       router-link(:to="{ name: 'EditRequest', params: { id: 'new' } }" | ||||
|                   role='button'). | ||||
|         #[md-icon(icon='add_box')] Add a New Request | ||||
|     br | ||||
|     .mpj-journal(v-if='journal.length > 0') | ||||
|       request-card(v-for='request in journal' | ||||
|                    :key='request.requestId' | ||||
|                    :request='request' | ||||
|                    :events='eventBus' | ||||
|                    :toast='toast') | ||||
|     p.text-center(v-else): em. | ||||
|       No requests found; click the “Add a New Request” button to add one | ||||
|     notes-edit(:events='eventBus' | ||||
|                :toast='toast') | ||||
|     snooze-request(:events='eventBus' | ||||
|                    :toast='toast') | ||||
|     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> | ||||
| @ -36,6 +35,10 @@ import actions from '@/store/action-types' | ||||
| 
 | ||||
| export default { | ||||
|   name: 'journal', | ||||
|   inject: [ | ||||
|     'messages', | ||||
|     'progress' | ||||
|   ], | ||||
|   components: { | ||||
|     NotesEdit, | ||||
|     RequestCard, | ||||
| @ -50,23 +53,29 @@ export default { | ||||
|     title () { | ||||
|       return `${this.user.given_name}’s Prayer Journal` | ||||
|     }, | ||||
|     toast () { | ||||
|       return this.$parent.$refs.toast | ||||
|     snackbar () { | ||||
|       return this.$parent.$refs.snackbar | ||||
|     }, | ||||
|     ...mapState(['user', 'journal', 'isLoadingJournal']) | ||||
|   }, | ||||
|   async created () { | ||||
|     await this.$store.dispatch(actions.LOAD_JOURNAL, this.$Progress) | ||||
|     this.toast.showToast(`Loaded ${this.journal.length} prayer requests`, { theme: 'success' }) | ||||
|     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> | ||||
| .mpj-journal { | ||||
|   display: flex; | ||||
|   flex-flow: row wrap; | ||||
|   justify-content: center; | ||||
|   align-items: flex-start; | ||||
| } | ||||
| <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,15 +0,0 @@ | ||||
| <template lang="pug"> | ||||
| i.material-icons(v-html='icon') | ||||
| </template> | ||||
| 
 | ||||
| <script> | ||||
| export default { | ||||
|   name: 'md-icon', | ||||
|   props: { | ||||
|     icon: { | ||||
|       type: String, | ||||
|       required: true | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| @ -1,34 +1,29 @@ | ||||
| <template lang="pug"> | ||||
| nav.mpj-top-nav.mpj-bg(role='menubar') | ||||
|   router-link.title(:to="{ name: 'Home' }" | ||||
|                     role='menuitem') | ||||
|     span(style='font-weight:100;') my | ||||
|     span(style='font-weight:600;') Prayer | ||||
|     span(style='font-weight:700;') Journal | ||||
|   router-link(v-if='isAuthenticated' | ||||
|               :to="{ name: 'Journal' }" | ||||
|               role='menuitem') Journal | ||||
|   router-link(v-if='isAuthenticated' | ||||
|               :to="{ name: 'ActiveRequests' }" | ||||
|               role='menuitem') Active | ||||
|   router-link(v-if='hasSnoozed' | ||||
|               :to="{ name: 'SnoozedRequests' }" | ||||
|               role='menuitem') Snoozed | ||||
|   router-link(v-if='isAuthenticated' | ||||
|               :to="{ name: 'AnsweredRequests' }" | ||||
|               role='menuitem') Answered | ||||
|   a(v-if='isAuthenticated' | ||||
|     href='#' | ||||
|     role='menuitem' | ||||
|     @click.stop='logOff()') Log Off | ||||
|   a(v-if='!isAuthenticated' | ||||
|     href='#' | ||||
|     role='menuitem' | ||||
|     @click.stop='logOn()') Log On | ||||
|   a(href='https://docs.prayerjournal.me' | ||||
|     target='_blank' | ||||
|     role='menuitem' | ||||
|     @click.stop='') Docs | ||||
| .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> | ||||
| @ -36,14 +31,10 @@ nav.mpj-top-nav.mpj-bg(role='menubar') | ||||
| 
 | ||||
| import { mapState } from 'vuex' | ||||
| 
 | ||||
| import AuthService from '@/auth/AuthService' | ||||
| 
 | ||||
| export default { | ||||
|   name: 'navigation', | ||||
|   data () { | ||||
|     return { | ||||
|       auth0: new AuthService() | ||||
|     } | ||||
|     return {} | ||||
|   }, | ||||
|   computed: { | ||||
|     hasSnoozed () { | ||||
| @ -51,46 +42,18 @@ export default { | ||||
|         Array.isArray(this.journal) && | ||||
|         this.journal.filter(req => req.snoozedUntil > Date.now()).length > 0 | ||||
|     }, | ||||
|     ...mapState([ 'journal', 'isAuthenticated' ]) | ||||
|     ...mapState([ 'isAuthenticated', 'journal' ]) | ||||
|   }, | ||||
|   methods: { | ||||
|     logOn () { | ||||
|       this.auth0.login() | ||||
|       this.$auth.login() | ||||
|     }, | ||||
|     logOff () { | ||||
|       this.auth0.logout(this.$store, this.$router) | ||||
|       this.$auth.logout(this.$store, this.$router) | ||||
|     }, | ||||
|     showHelp () { | ||||
|       window.open('https://docs.prayerjournal.me', '_blank') | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| 
 | ||||
| <style> | ||||
| .mpj-top-nav { | ||||
|   position: fixed; | ||||
|   display: flex; | ||||
|   flex-flow: row wrap; | ||||
|   align-items: center; | ||||
|   top: 0; | ||||
|   left: 0; | ||||
|   width: 100%; | ||||
|   padding-left: .5rem; | ||||
|   min-height: 50px; | ||||
| } | ||||
| .mpj-top-nav a:link, | ||||
| .mpj-top-nav a:visited { | ||||
|   text-decoration: none; | ||||
|   color: rgba(255, 255, 255, .75); | ||||
|   padding-left: 1rem; | ||||
| } | ||||
| .mpj-top-nav a:link.router-link-active, | ||||
| .mpj-top-nav a:visited.router-link-active, | ||||
| .mpj-top-nav a:hover { | ||||
|   color: white; | ||||
| } | ||||
| .mpj-top-nav .title { | ||||
|   font-size: 1.25rem; | ||||
|   color: white; | ||||
|   padding-left: 1.25rem; | ||||
|   padding-right: 1.25rem; | ||||
| } | ||||
| </style> | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| <template lang="pug"> | ||||
| h2.mpj-page-title(v-if='!hideOnPage' | ||||
|                   v-html='title') | ||||
| h1(v-if='!hideOnPage' | ||||
|    v-html='title').md-title | ||||
| </template> | ||||
| 
 | ||||
| <script> | ||||
| @ -26,10 +26,3 @@ export default { | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| 
 | ||||
| <style scoped> | ||||
| .mpj-page-title { | ||||
|   border-bottom: solid 1px lightgray; | ||||
|   margin-bottom: 20px; | ||||
| } | ||||
| </style> | ||||
|  | ||||
| @ -1,54 +1,59 @@ | ||||
| <template lang="pug"> | ||||
| article | ||||
|   page-title(title='Privacy Policy') | ||||
|   p: small: em (as of May 21, 2018) | ||||
|   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. | ||||
| 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,35 +1,40 @@ | ||||
| <template lang="pug"> | ||||
| article | ||||
|   page-title(title='Terms of Service') | ||||
|   p: small: em (as of May 21, 2018) | ||||
|   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. | ||||
| 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,13 +1,16 @@ | ||||
| <template lang="pug"> | ||||
| article.mpj-main-content(role='main') | ||||
|   page-title(title='Active Requests') | ||||
|   div(v-if='loaded').mpj-request-list | ||||
|     p.mpj-text-center(v-if='requests.length === 0'): em. | ||||
|       No active requests found; return to #[router-link(:to='{ name: "Journal" } ') your journal] | ||||
|     request-list-item(v-for='req in requests' | ||||
|                       :key='req.requestId' | ||||
|                       :request='req' | ||||
|                       :toast='toast') | ||||
| 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> | ||||
| 
 | ||||
| @ -16,14 +19,15 @@ article.mpj-main-content(role='main') | ||||
| 
 | ||||
| import { mapState } from 'vuex' | ||||
| 
 | ||||
| import RequestListItem from '@/components/request/RequestListItem' | ||||
| import RequestList from '@/components/request/RequestList' | ||||
| 
 | ||||
| import actions from '@/store/action-types' | ||||
| 
 | ||||
| export default { | ||||
|   name: 'active-requests', | ||||
|   inject: ['progress'], | ||||
|   components: { | ||||
|     RequestListItem | ||||
|     RequestList | ||||
|   }, | ||||
|   data () { | ||||
|     return { | ||||
| @ -32,9 +36,6 @@ export default { | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     toast () { | ||||
|       return this.$parent.$refs.toast | ||||
|     }, | ||||
|     ...mapState(['journal', 'isLoadingJournal']) | ||||
|   }, | ||||
|   created () { | ||||
| @ -45,7 +46,7 @@ export default { | ||||
|     async ensureJournal () { | ||||
|       if (!Array.isArray(this.journal)) { | ||||
|         this.loaded = false | ||||
|         await this.$store.dispatch(actions.LOAD_JOURNAL, this.$Progress) | ||||
|         await this.$store.dispatch(actions.LOAD_JOURNAL, this.progress) | ||||
|       } | ||||
|       this.requests = this.journal | ||||
|         .sort((a, b) => a.showAfter - b.showAfter) | ||||
|  | ||||
| @ -1,13 +1,15 @@ | ||||
| <template lang="pug"> | ||||
| article.mpj-main-content(role='main') | ||||
|   page-title(title='Answered Requests') | ||||
|   div(v-if='loaded').mpj-request-list | ||||
|     p.text-center(v-if='requests.length === 0'): em. | ||||
|       No answered requests found; once you have marked one as “Answered”, it will appear here | ||||
|     request-list-item(v-for='req in requests' | ||||
|                       :key='req.requestId' | ||||
|                       :request='req' | ||||
|                       :toast='toast') | ||||
| 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> | ||||
| 
 | ||||
| @ -16,12 +18,16 @@ article.mpj-main-content(role='main') | ||||
| 
 | ||||
| import api from '@/api' | ||||
| 
 | ||||
| import RequestListItem from '@/components/request/RequestListItem' | ||||
| import RequestList from '@/components/request/RequestList' | ||||
| 
 | ||||
| export default { | ||||
|   name: 'answered-requests', | ||||
|   inject: [ | ||||
|     'messages', | ||||
|     'progress' | ||||
|   ], | ||||
|   components: { | ||||
|     RequestListItem | ||||
|     RequestList | ||||
|   }, | ||||
|   data () { | ||||
|     return { | ||||
| @ -29,21 +35,16 @@ export default { | ||||
|       loaded: false | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     toast () { | ||||
|       return this.$parent.$refs.toast | ||||
|     } | ||||
|   }, | ||||
|   async mounted () { | ||||
|     this.$Progress.start() | ||||
|     this.progress.$emit('show', 'query') | ||||
|     try { | ||||
|       const reqs = await api.getAnsweredRequests() | ||||
|       this.requests = reqs.data | ||||
|       this.$Progress.finish() | ||||
|       this.progress.$emit('done') | ||||
|     } catch (err) { | ||||
|       console.error(err) | ||||
|       this.toast.showToast('Error loading requests; check console for details', { theme: 'danger' }) | ||||
|       this.$Progress.fail() | ||||
|       this.messages.$emit('error', 'Error loading requests; check console for details') | ||||
|       this.progress.$emit('done') | ||||
|     } finally { | ||||
|       this.loaded = true | ||||
|     } | ||||
|  | ||||
| @ -1,71 +1,52 @@ | ||||
| <template lang="pug"> | ||||
| article.mpj-main-content(role='main') | ||||
| md-content(role='main').mpj-narrow | ||||
|   page-title(:title='title') | ||||
|   .mpj-narrow | ||||
|     label(for='request_text') | ||||
|       | Prayer Request | ||||
|       br | ||||
|       textarea(v-model='form.requestText' | ||||
|                :rows='10' | ||||
|                @blur='trimText()' | ||||
|                autofocus).mpj-full-width | ||||
|   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 | ||||
|     template(v-if='!isNew') | ||||
|       label Also Mark As | ||||
|       br | ||||
|       label.normal | ||||
|         input(v-model='form.status' | ||||
|               type='radio' | ||||
|               name='status' | ||||
|               value='Updated') | ||||
|         | Updated | ||||
|       |     | ||||
|       label.normal | ||||
|         input(v-model='form.status' | ||||
|               type='radio' | ||||
|               name='status' | ||||
|               value='Prayed') | ||||
|         | Prayed | ||||
|       |     | ||||
|       label.normal | ||||
|         input(v-model='form.status' | ||||
|               type='radio' | ||||
|               name='status' | ||||
|               value='Answered') | ||||
|         | Answered | ||||
|       br | ||||
|     label Recurrence | ||||
|     |     | ||||
|     em.mpj-muted-text After prayer, request reappears... | ||||
|     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.normal | ||||
|       input(v-model='form.recur.typ' | ||||
|             type='radio' | ||||
|             name='recur' | ||||
|             value='immediate') | ||||
|       | Immediately | ||||
|     |     | ||||
|     label.normal | ||||
|       input(v-model='form.recur.typ' | ||||
|             type='radio' | ||||
|             name='recur' | ||||
|             value='other') | ||||
|       | Every... | ||||
|     input(v-model='form.recur.count' | ||||
|           type='number' | ||||
|           :disabled='!showRecurrence').mpj-recur-count | ||||
|     select(v-model='form.recur.other' | ||||
|            :disabled='!showRecurrence').mpj-recur-type | ||||
|       option(value='hours') hours | ||||
|       option(value='days') days | ||||
|       option(value='weeks') weeks | ||||
|     .mpj-text-right | ||||
|       button(:disabled='!isValidRecurrence' | ||||
|              @click.stop='saveRequest()').primary. | ||||
|         #[md-icon(icon='save')] Save | ||||
|       |     | ||||
|       button(@click.stop='goBack()'). | ||||
|         #[md-icon(icon='arrow_back')] Cancel | ||||
|   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> | ||||
| @ -77,6 +58,10 @@ import actions from '@/store/action-types' | ||||
| 
 | ||||
| export default { | ||||
|   name: 'edit-request', | ||||
|   inject: [ | ||||
|     'messages', | ||||
|     'progress' | ||||
|   ], | ||||
|   props: { | ||||
|     id: { | ||||
|       type: String, | ||||
| @ -92,7 +77,7 @@ export default { | ||||
|         requestText: '', | ||||
|         status: 'Updated', | ||||
|         recur: { | ||||
|           typ: 'immediate', | ||||
|           typ: 'Immediate', | ||||
|           other: '', | ||||
|           count: '' | ||||
|         } | ||||
| @ -101,19 +86,16 @@ export default { | ||||
|   }, | ||||
|   computed: { | ||||
|     isValidRecurrence () { | ||||
|       if (this.form.recur.typ === 'immediate') return true | ||||
|       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 | ||||
|       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' | ||||
|     }, | ||||
|     toast () { | ||||
|       return this.$parent.$refs.toast | ||||
|       return this.form.recur.typ !== 'Immediate' | ||||
|     }, | ||||
|     ...mapState(['journal']) | ||||
|   }, | ||||
| @ -125,21 +107,21 @@ export default { | ||||
|       this.form.requestId = '' | ||||
|       this.form.requestText = '' | ||||
|       this.form.status = 'Created' | ||||
|       this.form.recur.typ = 'immediate' | ||||
|       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) | ||||
|         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' | ||||
|       if (req.recurType === 'Immediate') { | ||||
|         this.form.recur.typ = 'Immediate' | ||||
|         this.form.recur.other = '' | ||||
|         this.form.recur.count = '' | ||||
|       } else { | ||||
| @ -158,31 +140,31 @@ export default { | ||||
|     }, | ||||
|     async ensureJournal () { | ||||
|       if (!Array.isArray(this.journal)) { | ||||
|         await this.$store.dispatch(actions.LOAD_JOURNAL, this.$Progress) | ||||
|         await this.$store.dispatch(actions.LOAD_JOURNAL, this.progress) | ||||
|       } | ||||
|     }, | ||||
|     async saveRequest () { | ||||
|       if (this.isNew) { | ||||
|         await this.$store.dispatch(actions.ADD_REQUEST, { | ||||
|           progress: this.$Progress, | ||||
|           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) | ||||
|           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.toast.showToast('New prayer request added', { theme: 'success' }) | ||||
|         this.messages.$emit('info', 'New prayer request added') | ||||
|       } else { | ||||
|         await this.$store.dispatch(actions.UPDATE_REQUEST, { | ||||
|           progress: this.$Progress, | ||||
|           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) | ||||
|           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.toast.showToast('Request updated and removed from active journal', { theme: 'success' }) | ||||
|           this.messages.$emit('info', 'Request updated and removed from active journal') | ||||
|         } else { | ||||
|           this.toast.showToast('Request updated', { theme: 'success' }) | ||||
|           this.messages.$emit('info', 'Request updated') | ||||
|         } | ||||
|       } | ||||
|       this.goBack() | ||||
| @ -190,15 +172,3 @@ export default { | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| 
 | ||||
| <style> | ||||
| .mpj-recur-count { | ||||
|   width: 3rem; | ||||
|   border-top-right-radius: 0; | ||||
|   border-bottom-right-radius: 0; | ||||
| } | ||||
| .mpj-recur-type { | ||||
|   border-top-left-radius: 0; | ||||
|   border-bottom-left-radius: 0; | ||||
| } | ||||
| </style> | ||||
|  | ||||
| @ -1,22 +1,24 @@ | ||||
| <template lang="pug"> | ||||
| article.mpj-main-content(role='main') | ||||
|   page-title(title='Full Prayer Request') | ||||
|   template(v-if='request') | ||||
|     p | ||||
|       span(v-if='isAnswered') Answered {{ formatDate(answered) }} (#[date-from-now(:value='answered')])   | ||||
|       small: em.mpj-muted-text prayed {{ prayedCount }} times, open {{ openDays }} days | ||||
|     p.mpj-request-text {{ lastText }} | ||||
|     br | ||||
|     table.mpj-request-log | ||||
|       thead | ||||
|         tr | ||||
|           th Action | ||||
|           th Update / Notes | ||||
|       tbody | ||||
|         tr(v-for='item in log' :key='item.asOf') | ||||
|           td {{ item.status }} on #[span.mpj-text-nowrap {{ formatDate(item.asOf) }}] | ||||
|           td(v-if='item.text').mpj-request-text {{ item.text }} | ||||
|           td(v-else)   | ||||
| 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> | ||||
| 
 | ||||
| @ -31,6 +33,7 @@ const asOfDesc = (a, b) => b.asOf - a.asOf | ||||
| 
 | ||||
| export default { | ||||
|   name: 'full-request', | ||||
|   inject: ['progress'], | ||||
|   props: { | ||||
|     id: { | ||||
|       type: String, | ||||
| @ -72,14 +75,14 @@ export default { | ||||
|     } | ||||
|   }, | ||||
|   async mounted () { | ||||
|     this.$Progress.start() | ||||
|     this.progress.$emit('show', 'indeterminate') | ||||
|     try { | ||||
|       const req = await api.getFullRequest(this.id) | ||||
|       this.request = req.data | ||||
|       this.$Progress.finish() | ||||
|       this.progress.$emit('done') | ||||
|     } catch (e) { | ||||
|       console.log(e) | ||||
|       this.$Progress.fail() | ||||
|       this.progress.$emit('done') | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|  | ||||
| @ -1,21 +1,16 @@ | ||||
| <template lang="pug"> | ||||
| .mpj-modal(v-show='notesVisible') | ||||
|   .mpj-modal-content.mpj-narrow | ||||
|     header.mpj-bg | ||||
|       h5 Add Notes to Prayer Request | ||||
|     label | ||||
|       | Notes | ||||
|       br | ||||
|       textarea(v-model='form.notes' | ||||
|                :rows='10' | ||||
|                @blur='trimText()').mpj-full-width | ||||
|     .mpj-text-right | ||||
|       button(@click='saveNotes()').primary. | ||||
|         #[md-icon(icon='save')] Save | ||||
|       |     | ||||
|       button(@click='closeDialog()'). | ||||
|         #[md-icon(icon='undo')] Cancel | ||||
|     hr | ||||
| 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 | ||||
|   .mpj-dialog-content | ||||
|     div(v-if='hasPriorNotes') | ||||
|       p.mpj-text-center: strong Prior Notes for This Request | ||||
|       .mpj-note-list | ||||
| @ -26,8 +21,8 @@ | ||||
|           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 | ||||
|       button(@click='loadNotes()'). | ||||
|         #[md-icon(icon='cloud_download')] Load Prior Notes | ||||
|       hr | ||||
|       md-button(@click='loadNotes()') #[md-icon cloud_download] Load Prior Notes | ||||
| </template> | ||||
| 
 | ||||
| <script> | ||||
| @ -37,10 +32,11 @@ import api from '@/api' | ||||
| 
 | ||||
| export default { | ||||
|   name: 'notes-edit', | ||||
|   props: { | ||||
|     toast: { required: true }, | ||||
|     events: { required: true } | ||||
|   }, | ||||
|   inject: [ | ||||
|     'journalEvents', | ||||
|     'messages', | ||||
|     'progress' | ||||
|   ], | ||||
|   data () { | ||||
|     return { | ||||
|       notesVisible: false, | ||||
| @ -61,7 +57,7 @@ export default { | ||||
|     } | ||||
|   }, | ||||
|   created () { | ||||
|     this.events.$on('notes', this.openDialog) | ||||
|     this.journalEvents.$on('notes', this.openDialog) | ||||
|   }, | ||||
|   methods: { | ||||
|     closeDialog () { | ||||
| @ -72,14 +68,14 @@ export default { | ||||
|       this.notesVisible = false | ||||
|     }, | ||||
|     async loadNotes () { | ||||
|       this.$Progress.start() | ||||
|       this.progress.$emit('show', 'indeterminate') | ||||
|       try { | ||||
|         const notes = await api.getNotes(this.form.requestId) | ||||
|         this.priorNotes = notes.data | ||||
|         this.$Progress.finish() | ||||
|         this.progress.$emit('done') | ||||
|       } catch (e) { | ||||
|         console.error(e) | ||||
|         this.$Progress.fail() | ||||
|         this.progress.$emit('done') | ||||
|       } finally { | ||||
|         this.priorNotesLoaded = true | ||||
|       } | ||||
| @ -89,15 +85,15 @@ export default { | ||||
|       this.notesVisible = true | ||||
|     }, | ||||
|     async saveNotes () { | ||||
|       this.$Progress.start() | ||||
|       this.progress.$emit('show', 'indeterminate') | ||||
|       try { | ||||
|         await api.addNote(this.form.requestId, this.form.notes) | ||||
|         this.$Progress.finish() | ||||
|         this.toast.showToast('Added notes', { theme: 'success' }) | ||||
|         this.progress.$emit('done') | ||||
|         this.messages.$emit('info', 'Added notes') | ||||
|         this.closeDialog() | ||||
|       } catch (e) { | ||||
|         console.error(e) | ||||
|         this.$Progress.fail() | ||||
|         this.progress.$emit('done') | ||||
|       } | ||||
|     }, | ||||
|     trimText () { | ||||
| @ -107,8 +103,16 @@ export default { | ||||
| } | ||||
| </script> | ||||
| 
 | ||||
| <style> | ||||
| .mpj-note-list p { | ||||
|   border-top: dotted 1px lightgray; | ||||
| } | ||||
| <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,17 +1,27 @@ | ||||
| <template lang="pug"> | ||||
| .mpj-request-card(v-if='shouldDisplay') | ||||
|   header.mpj-card-header(role='toolbar'). | ||||
|     #[button(@click='markPrayed()' title='Pray').primary: md-icon(icon='done')] | ||||
|     #[button(@click.stop='showEdit()' title='Edit'): md-icon(icon='edit')] | ||||
|     #[button(@click.stop='showNotes()' title='Add Notes'): md-icon(icon='comment')] | ||||
|     #[button(@click.stop='snooze()' title='Snooze Request'): md-icon(icon='schedule')] | ||||
|   div | ||||
|     p.card-text.mpj-request-text | ||||
|       | {{ request.text }} | ||||
|     p.as-of.mpj-text-right: small.mpj-muted-text: em | ||||
|       = '(last activity ' | ||||
|       date-from-now(:value='request.asOf') | ||||
|       | ) | ||||
| 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> | ||||
| @ -21,10 +31,13 @@ import actions from '@/store/action-types' | ||||
| 
 | ||||
| export default { | ||||
|   name: 'request-card', | ||||
|   inject: [ | ||||
|     'journalEvents', | ||||
|     'messages', | ||||
|     'progress' | ||||
|   ], | ||||
|   props: { | ||||
|     request: { required: true }, | ||||
|     toast: { required: true }, | ||||
|     events: { required: true } | ||||
|     request: { required: true } | ||||
|   }, | ||||
|   computed: { | ||||
|     shouldDisplay () { | ||||
| @ -35,59 +48,31 @@ export default { | ||||
|   methods: { | ||||
|     async markPrayed () { | ||||
|       await this.$store.dispatch(actions.UPDATE_REQUEST, { | ||||
|         progress: this.$Progress, | ||||
|         progress: this.progress, | ||||
|         requestId: this.request.requestId, | ||||
|         status: 'Prayed', | ||||
|         updateText: '' | ||||
|       }) | ||||
|       this.toast.showToast('Request marked as prayed', { theme: 'success' }) | ||||
|       this.messages.$emit('info', 'Request marked as prayed') | ||||
|     }, | ||||
|     showEdit () { | ||||
|       this.$router.push({ name: 'EditRequest', params: { id: this.request.requestId } }) | ||||
|     }, | ||||
|     showNotes () { | ||||
|       this.events.$emit('notes', this.request) | ||||
|       this.journalEvents.$emit('notes', this.request) | ||||
|     }, | ||||
|     snooze () { | ||||
|       this.events.$emit('snooze', this.request.requestId) | ||||
|       this.journalEvents.$emit('snooze', this.request.requestId) | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| 
 | ||||
| <style> | ||||
| .mpj-request-card { | ||||
|   border: solid 1px darkgray; | ||||
|   border-radius: 5px; | ||||
|   width: 20rem; | ||||
|   margin: .5rem; | ||||
| } | ||||
| @media screen and (max-width: 20rem) { | ||||
|   .mpj-request-card { | ||||
|     width: 100%; | ||||
|   } | ||||
| } | ||||
| .mpj-card-header { | ||||
|   display: flex; | ||||
|   flex-flow: row; | ||||
|   justify-content: center; | ||||
|   background-image: -webkit-gradient(linear, left top, left bottom, from(lightgray), to(whitesmoke)); | ||||
|   background-image: -webkit-linear-gradient(top, lightgray, whitesmoke); | ||||
|   background-image: -moz-linear-gradient(top, lightgray, whitesmoke); | ||||
|   background-image: linear-gradient(to bottom, lightgray, whitesmoke); | ||||
| } | ||||
| .mpj-card-header button { | ||||
|   margin: .25rem; | ||||
|   padding: 0 .25rem; | ||||
| } | ||||
| .mpj-card-header button .material-icons { | ||||
|   font-size: 1.3rem; | ||||
| } | ||||
| .mpj-request-card .card-text { | ||||
|   margin-left: 1rem; | ||||
|   margin-right: 1rem; | ||||
| } | ||||
| .mpj-request-card .as-of { | ||||
|   margin-right: .25rem; | ||||
| } | ||||
| <style lang="sass"> | ||||
| .mpj-request-card | ||||
|   width: 20rem | ||||
|   margin-bottom: 1rem | ||||
| @media screen and (max-width: 20rem) | ||||
|   .mpj-request-card | ||||
|     width: 100% | ||||
| </style> | ||||
|  | ||||
							
								
								
									
										40
									
								
								src/app/src/components/request/RequestList.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/app/src/components/request/RequestList.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,40 @@ | ||||
| <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,31 +1,31 @@ | ||||
| <template lang="pug"> | ||||
| p.mpj-request-text | ||||
|   | {{ request.text }} | ||||
|   br | ||||
|   br | ||||
|   button(@click='viewFull' | ||||
|          title='View Full Request'). | ||||
|     #[md-icon(icon='description')] View Full Request | ||||
|   |     | ||||
|   template(v-if='!isAnswered') | ||||
|     button(@click='editRequest' | ||||
|            title='Edit Request'). | ||||
|       #[md-icon(icon='edit')] Edit Request | ||||
|     |     | ||||
|   template(v-if='isSnoozed') | ||||
|     button(@click='cancelSnooze()'). | ||||
|       #[md-icon(icon='restore')] Cancel Snooze | ||||
|     |     | ||||
|   template(v-if='isPending') | ||||
|     button(@click='showNow()'). | ||||
|       #[md-icon(icon='restore')] Show Now | ||||
|   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 scheduled to reappear #[date-from-now(:value='request.showAfter')] | ||||
|   small(v-if='isAnswered').mpj-muted-text: em. | ||||
|       Answered #[date-from-now(:value='request.asOf')] | ||||
| 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> | ||||
| @ -35,9 +35,12 @@ import actions from '@/store/action-types' | ||||
| 
 | ||||
| export default { | ||||
|   name: 'request-list-item', | ||||
|   inject: [ | ||||
|     'messages', | ||||
|     'progress' | ||||
|   ], | ||||
|   props: { | ||||
|     request: { required: true }, | ||||
|     toast: { required: true } | ||||
|     request: { required: true } | ||||
|   }, | ||||
|   data () { | ||||
|     return {} | ||||
| @ -59,11 +62,11 @@ export default { | ||||
|   methods: { | ||||
|     async cancelSnooze () { | ||||
|       await this.$store.dispatch(actions.SNOOZE_REQUEST, { | ||||
|         progress: this.$Progress, | ||||
|         progress: this.progress, | ||||
|         requestId: this.request.requestId, | ||||
|         until: 0 | ||||
|       }) | ||||
|       this.toast.showToast('Request un-snoozed', { theme: 'success' }) | ||||
|       this.messages.$emit('info', 'Request un-snoozed') | ||||
|       this.$parent.$emit('requestUnsnoozed') | ||||
|     }, | ||||
|     editRequest () { | ||||
| @ -71,11 +74,11 @@ export default { | ||||
|     }, | ||||
|     async showNow () { | ||||
|       await this.$store.dispatch(actions.SHOW_REQUEST_NOW, { | ||||
|         progress: this.$Progress, | ||||
|         progress: this.progress, | ||||
|         requestId: this.request.requestId, | ||||
|         showAfter: Date.now() | ||||
|         showAfter: 0 | ||||
|       }) | ||||
|       this.toast.showToast('Recurrence skipped; request now shows in journal', { theme: 'success' }) | ||||
|       this.messages.$emit('info', 'Recurrence skipped; request now shows in journal') | ||||
|       this.$parent.$emit('requestNowShown') | ||||
|     }, | ||||
|     viewFull () { | ||||
| @ -84,3 +87,9 @@ export default { | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| 
 | ||||
| <style lang="sass"> | ||||
| .mpj-action-cell | ||||
|   width: 1% | ||||
|   white-space: nowrap | ||||
| </style> | ||||
|  | ||||
| @ -1,22 +1,15 @@ | ||||
| <template lang="pug"> | ||||
| .mpj-modal(v-show='snoozeVisible') | ||||
|   .mpj-modal-content.mpj-skinny | ||||
|     header.mpj-bg | ||||
|       h5 Snooze Prayer Request | ||||
|     p.mpj-text-center | ||||
|       label | ||||
|         = 'Until ' | ||||
|         input(v-model='form.snoozedUntil' | ||||
|               type='date' | ||||
|               autofocus) | ||||
|     br | ||||
|     .mpj-text-right | ||||
|       button.primary(:disabled='!isValid' | ||||
|                      @click='snoozeRequest()'). | ||||
|         #[md-icon(icon='snooze')] Snooze | ||||
|       |     | ||||
|       button(@click='closeDialog()'). | ||||
|         #[md-icon(icon='undo')] Cancel | ||||
| 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> | ||||
| @ -26,13 +19,18 @@ import actions from '@/store/action-types' | ||||
| 
 | ||||
| export default { | ||||
|   name: 'snooze-request', | ||||
|   inject: [ | ||||
|     'journalEvents', | ||||
|     'messages', | ||||
|     'progress' | ||||
|   ], | ||||
|   props: { | ||||
|     toast: { required: true }, | ||||
|     events: { required: true } | ||||
|   }, | ||||
|   data () { | ||||
|     return { | ||||
|       snoozeVisible: false, | ||||
|       datesInPast: date => date < new Date(), | ||||
|       form: { | ||||
|         requestId: '', | ||||
|         snoozedUntil: '' | ||||
| @ -40,7 +38,7 @@ export default { | ||||
|     } | ||||
|   }, | ||||
|   created () { | ||||
|     this.events.$on('snooze', this.openDialog) | ||||
|     this.journalEvents.$on('snooze', this.openDialog) | ||||
|   }, | ||||
|   computed: { | ||||
|     isValid () { | ||||
| @ -59,11 +57,11 @@ export default { | ||||
|     }, | ||||
|     async snoozeRequest () { | ||||
|       await this.$store.dispatch(actions.SNOOZE_REQUEST, { | ||||
|         progress: this.$Progress, | ||||
|         progress: this.progress, | ||||
|         requestId: this.form.requestId, | ||||
|         until: Date.parse(this.form.snoozedUntil) | ||||
|       }) | ||||
|       this.toast.showToast(`Request snoozed until ${this.form.snoozedUntil}`, { theme: 'success' }) | ||||
|       this.messages.$emit('info', `Request snoozed until ${this.form.snoozedUntil}`) | ||||
|       this.closeDialog() | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @ -1,13 +1,16 @@ | ||||
| <template lang="pug"> | ||||
| article.mpj-main-content(role='main') | ||||
|   page-title(title='Snoozed Requests') | ||||
|   div(v-if='loaded').mpj-request-list | ||||
|     p.mpj-text-center(v-if='requests.length === 0'): em. | ||||
|       No snoozed requests found; return to #[router-link(:to='{ name: "Journal" } ') your journal] | ||||
|     request-list-item(v-for='req in requests' | ||||
|                       :key='req.requestId' | ||||
|                       :request='req' | ||||
|                       :toast='toast') | ||||
|   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> | ||||
| 
 | ||||
| @ -18,12 +21,13 @@ import { mapState } from 'vuex' | ||||
| 
 | ||||
| import actions from '@/store/action-types' | ||||
| 
 | ||||
| import RequestListItem from '@/components/request/RequestListItem' | ||||
| import RequestList from '@/components/request/RequestList' | ||||
| 
 | ||||
| export default { | ||||
|   name: 'snoozed-requests', | ||||
|   inject: ['progress'], | ||||
|   components: { | ||||
|     RequestListItem | ||||
|     RequestList | ||||
|   }, | ||||
|   data () { | ||||
|     return { | ||||
| @ -32,9 +36,6 @@ export default { | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     toast () { | ||||
|       return this.$parent.$refs.toast | ||||
|     }, | ||||
|     ...mapState(['journal', 'isLoadingJournal']) | ||||
|   }, | ||||
|   created () { | ||||
| @ -44,7 +45,7 @@ export default { | ||||
|     async ensureJournal () { | ||||
|       if (!Array.isArray(this.journal)) { | ||||
|         this.loaded = false | ||||
|         await this.$store.dispatch(actions.LOAD_JOURNAL, this.$Progress) | ||||
|         await this.$store.dispatch(actions.LOAD_JOURNAL, this.progress) | ||||
|       } | ||||
|       this.requests = this.journal | ||||
|         .filter(req => req.snoozedUntil > Date.now()) | ||||
|  | ||||
| @ -7,14 +7,17 @@ article.mpj-main-content(role='main') | ||||
| <script> | ||||
| 'use strict' | ||||
| 
 | ||||
| import AuthService from '@/auth/AuthService' | ||||
| 
 | ||||
| export default { | ||||
|   name: 'log-on', | ||||
|   created () { | ||||
|     this.$Progress.start() | ||||
|     new AuthService().handleAuthentication(this.$store, this.$router) | ||||
|     // Auth service redirects to dashboard, which restarts the progress bar | ||||
|   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,33 +1,61 @@ | ||||
| /* eslint-disable */ | ||||
| 
 | ||||
| // Vue packages and components
 | ||||
| import Vue from 'vue' | ||||
| import VueProgressBar from 'vue-progressbar' | ||||
| import VueToast from 'vue-toast' | ||||
| import { MdApp, | ||||
|          MdButton, | ||||
|          MdCard, | ||||
|          MdContent, | ||||
|          MdDatepicker, | ||||
|          MdDialog, | ||||
|          MdEmptyState, | ||||
|          MdField, | ||||
|          MdIcon, | ||||
|          MdLayout, | ||||
|          MdProgress, | ||||
|          MdRadio, | ||||
|          MdSnackbar, | ||||
|          MdTable, | ||||
|          MdTabs, | ||||
|          MdToolbar, | ||||
|          MdTooltip } from 'vue-material/dist/components' | ||||
| 
 | ||||
| import 'vue-toast/dist/vue-toast.min.css' | ||||
| 
 | ||||
| import App from './App' | ||||
| import router from './router' | ||||
| import store from './store' | ||||
| // myPrayerJournal components
 | ||||
| import App         from './App' | ||||
| import router      from './router' | ||||
| import store       from './store' | ||||
| import DateFromNow from './components/common/DateFromNow' | ||||
| import MaterialDesignIcon from './components/common/MaterialDesignIcon' | ||||
| import PageTitle from './components/common/PageTitle' | ||||
| 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(VueProgressBar, { | ||||
|   color: 'yellow', | ||||
|   failedColor: 'red', | ||||
|   height: '5px', | ||||
|   transition: { | ||||
|     speed: '0.2s', | ||||
|     opacity: '0.6s', | ||||
|     termination: 1000 | ||||
|   } | ||||
| }) | ||||
| 
 | ||||
| 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('md-icon', MaterialDesignIcon) | ||||
| Vue.component('page-title', PageTitle) | ||||
| Vue.component('toast', VueToast) | ||||
| 
 | ||||
| new Vue({ | ||||
|   router, | ||||
|  | ||||
							
								
								
									
										22
									
								
								src/app/src/plugins/auth.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src/app/src/plugins/auth.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,22 @@ | ||||
| '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,18 +1,12 @@ | ||||
| 'use strict' | ||||
| 
 | ||||
| import Vue from 'vue' | ||||
| /* eslint-disable */ | ||||
| import Vue    from 'vue' | ||||
| import Router from 'vue-router' | ||||
| 
 | ||||
| import ActiveRequests from '@/components/request/ActiveRequests' | ||||
| import AnsweredRequests from '@/components/request/AnsweredRequests' | ||||
| import EditRequest from '@/components/request/EditRequest' | ||||
| import FullRequest from '@/components/request/FullRequest' | ||||
| import auth from './auth/AuthService' | ||||
| import Home from '@/components/Home' | ||||
| import Journal from '@/components/Journal' | ||||
| import LogOn from '@/components/user/LogOn' | ||||
| import PrivacyPolicy from '@/components/legal/PrivacyPolicy' | ||||
| import SnoozedRequests from '@/components/request/SnoozedRequests' | ||||
| import TermsOfService from '@/components/legal/TermsOfService' | ||||
| /* eslint-enable */ | ||||
| 
 | ||||
| Vue.use(Router) | ||||
| 
 | ||||
| @ -26,6 +20,12 @@ export default new Router({ | ||||
|       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: '/', | ||||
| @ -35,49 +35,49 @@ export default new Router({ | ||||
|     { | ||||
|       path: '/journal', | ||||
|       name: 'Journal', | ||||
|       component: Journal | ||||
|       component: () => import('@/components/Journal') | ||||
|     }, | ||||
|     { | ||||
|       path: '/legal/privacy-policy', | ||||
|       name: 'PrivacyPolicy', | ||||
|       component: PrivacyPolicy | ||||
|       component: () => import('@/components/legal/PrivacyPolicy') | ||||
|     }, | ||||
|     { | ||||
|       path: '/legal/terms-of-service', | ||||
|       name: 'TermsOfService', | ||||
|       component: TermsOfService | ||||
|       component: () => import('@/components/legal/TermsOfService') | ||||
|     }, | ||||
|     { | ||||
|       path: '/request/:id/edit', | ||||
|       name: 'EditRequest', | ||||
|       component: EditRequest, | ||||
|       component: () => import('@/components/request/EditRequest'), | ||||
|       props: true | ||||
|     }, | ||||
|     { | ||||
|       path: '/request/:id/full', | ||||
|       name: 'FullRequest', | ||||
|       component: FullRequest, | ||||
|       component: () => import('@/components/request/FullRequest'), | ||||
|       props: true | ||||
|     }, | ||||
|     { | ||||
|       path: '/requests/active', | ||||
|       name: 'ActiveRequests', | ||||
|       component: ActiveRequests | ||||
|       component: () => import('@/components/request/ActiveRequests') | ||||
|     }, | ||||
|     { | ||||
|       path: '/requests/answered', | ||||
|       name: 'AnsweredRequests', | ||||
|       component: AnsweredRequests | ||||
|       component: () => import('@/components/request/AnsweredRequests') | ||||
|     }, | ||||
|     { | ||||
|       path: '/requests/snoozed', | ||||
|       name: 'SnoozedRequests', | ||||
|       component: SnoozedRequests | ||||
|       component: () => import('@/components/request/SnoozedRequests') | ||||
|     }, | ||||
|     { | ||||
|       path: '/user/log-on', | ||||
|       name: 'LogOn', | ||||
|       component: LogOn | ||||
|       component: () => import('@/components/user/LogOn') | ||||
|     } | ||||
|   ] | ||||
| }) | ||||
|  | ||||
| @ -3,6 +3,8 @@ | ||||
| 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 */ | ||||
|  | ||||
| @ -1,47 +1,59 @@ | ||||
| 'use strict' | ||||
| 
 | ||||
| import Vue from 'vue' | ||||
| /* eslint-disable no-multi-spaces */ | ||||
| import Vue  from 'vue' | ||||
| import Vuex from 'vuex' | ||||
| 
 | ||||
| import api from '@/api' | ||||
| import AuthService from '@/auth/AuthService' | ||||
| import api  from '@/api' | ||||
| import auth from '@/auth/AuthService' | ||||
| 
 | ||||
| import mutations from './mutation-types' | ||||
| import actions from './action-types' | ||||
| import actions   from './action-types' | ||||
| /* eslint-enable no-multi-spaces */ | ||||
| 
 | ||||
| Vue.use(Vuex) | ||||
| 
 | ||||
| const auth0 = new AuthService() | ||||
| 
 | ||||
| /* 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.log(error.response.data) | ||||
|     console.log(error.response.status) | ||||
|     console.log(error.response.headers) | ||||
|     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.log(error.request) | ||||
|     console.error(error.request) | ||||
|   } else { | ||||
|     // Something happened in setting up the request that triggered an Error
 | ||||
|     console.log('Error', error.message) | ||||
|     console.error('Error', error.message) | ||||
|   } | ||||
|   console.log(error.config) | ||||
|   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 */ | ||||
| 
 | ||||
| export default new Vuex.Store({ | ||||
|   state: { | ||||
|     user: JSON.parse(localStorage.getItem('user_profile') || '{}'), | ||||
|     isAuthenticated: (() => { | ||||
|       auth0.scheduleRenewal() | ||||
|       if (auth0.isAuthenticated()) { | ||||
|         api.setBearer(localStorage.getItem('id_token')) | ||||
|       } | ||||
|       return auth0.isAuthenticated() | ||||
|     })(), | ||||
|     user: auth.session.profile, | ||||
|     isAuthenticated: auth.isAuthenticated(), | ||||
|     journal: {}, | ||||
|     isLoadingJournal: false | ||||
|   }, | ||||
| @ -60,49 +72,60 @@ export default new Vuex.Store({ | ||||
|       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) { | ||||
|       localStorage.setItem('user_profile', JSON.stringify(user)) | ||||
|       state.user = user | ||||
|       api.setBearer(localStorage.getItem('id_token')) | ||||
|       state.isAuthenticated = true | ||||
|     } | ||||
|   }, | ||||
|   actions: { | ||||
|     async [actions.ADD_REQUEST] ({ commit }, { progress, requestText, recurType, recurCount }) { | ||||
|       progress.start() | ||||
|       progress.$emit('show', 'indeterminate') | ||||
|       try { | ||||
|         await setBearer() | ||||
|         const newRequest = await api.addRequest(requestText, recurType, recurCount) | ||||
|         commit(mutations.REQUEST_ADDED, newRequest.data) | ||||
|         progress.finish() | ||||
|         progress.$emit('done') | ||||
|       } catch (err) { | ||||
|         logError(err) | ||||
|         progress.fail() | ||||
|         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.start() | ||||
|       progress.$emit('show', 'query') | ||||
|       commit(mutations.LOADING_JOURNAL, true) | ||||
|       api.setBearer(localStorage.getItem('id_token')) | ||||
|       await setBearer() | ||||
|       try { | ||||
|         const jrnl = await api.journal() | ||||
|         commit(mutations.LOADED_JOURNAL, jrnl.data) | ||||
|         progress.finish() | ||||
|         progress.$emit('done') | ||||
|       } catch (err) { | ||||
|         logError(err) | ||||
|         progress.fail() | ||||
|         progress.$emit('done') | ||||
|       } finally { | ||||
|         commit(mutations.LOADING_JOURNAL, false) | ||||
|       } | ||||
|     }, | ||||
|     async [actions.UPDATE_REQUEST] ({ commit, state }, { progress, requestId, status, updateText, recurType, recurCount }) { | ||||
|       progress.start() | ||||
|       progress.$emit('show', 'indeterminate') | ||||
|       try { | ||||
|         await setBearer() | ||||
|         let oldReq = (state.journal.filter(req => req.requestId === requestId) || [])[0] || {} | ||||
|         if (!(status === 'Prayed' && updateText === '')) { | ||||
|           if (status !== 'Answered' && (oldReq.recurType !== recurType || oldReq.recurCount !== recurCount)) { | ||||
| @ -114,34 +137,36 @@ export default new Vuex.Store({ | ||||
|         } | ||||
|         const request = await api.getRequest(requestId) | ||||
|         commit(mutations.REQUEST_UPDATED, request.data) | ||||
|         progress.finish() | ||||
|         progress.$emit('done') | ||||
|       } catch (err) { | ||||
|         logError(err) | ||||
|         progress.fail() | ||||
|         progress.$emit('done') | ||||
|       } | ||||
|     }, | ||||
|     async [actions.SHOW_REQUEST_NOW] ({ commit }, { progress, requestId, showAfter }) { | ||||
|       progress.start() | ||||
|       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.finish() | ||||
|         progress.$emit('done') | ||||
|       } catch (err) { | ||||
|         logError(err) | ||||
|         progress.fail() | ||||
|         progress.$emit('done') | ||||
|       } | ||||
|     }, | ||||
|     async [actions.SNOOZE_REQUEST] ({ commit }, { progress, requestId, until }) { | ||||
|       progress.start() | ||||
|       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.finish() | ||||
|         progress.$emit('done') | ||||
|       } catch (err) { | ||||
|         logError(err) | ||||
|         progress.fail() | ||||
|         progress.$emit('done') | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|  | ||||
| @ -9,6 +9,8 @@ export default { | ||||
|   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) */ | ||||
|  | ||||
| @ -1,9 +1,16 @@ | ||||
| const webpack = require('webpack') | ||||
| // const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
 | ||||
| module.exports = { | ||||
|   outputDir: '../api/MyPrayerJournal.Api/wwwroot', | ||||
|   outputDir: '../MyPrayerJournal.Api/wwwroot', | ||||
|   configureWebpack: { | ||||
|     plugins: [ | ||||
|       // new BundleAnalyzerPlugin(),
 | ||||
|       new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/) | ||||
|     ] | ||||
|     ], | ||||
|     optimization: { | ||||
|       splitChunks: { | ||||
|         chunks: 'all' | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
							
								
								
									
										1372
									
								
								src/app/yarn.lock
									
									
									
									
									
								
							
							
						
						
									
										1372
									
								
								src/app/yarn.lock
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										110
									
								
								src/migrate/Program.fs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										110
									
								
								src/migrate/Program.fs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,110 @@ | ||||
|  | ||||
| open Microsoft.FSharpLu.Json | ||||
| open MyPrayerJournal | ||||
| open Npgsql | ||||
| open Raven.Client.Documents | ||||
| 
 | ||||
| type NpgsqlDataReader with | ||||
|   member this.getShort = this.GetOrdinal >> this.GetInt16 | ||||
|   member this.getString = this.GetOrdinal >> this.GetString | ||||
|   member this.getTicks = this.GetOrdinal >> this.GetInt64 >> Ticks | ||||
|   member this.isNull = this.GetOrdinal >> this.IsDBNull | ||||
| 
 | ||||
| let pgConn connStr = | ||||
|   let c = new NpgsqlConnection (connStr) | ||||
|   c.Open () | ||||
|   c | ||||
| 
 | ||||
| let isValidStatus stat = | ||||
|   try | ||||
|     (RequestAction.fromString >> ignore) stat | ||||
|     true | ||||
|   with _ -> false | ||||
| 
 | ||||
| let getHistory reqId connStr = | ||||
|   use conn = pgConn connStr | ||||
|   use cmd = conn.CreateCommand () | ||||
|   cmd.CommandText <- """SELECT "asOf", status, text FROM mpj.history WHERE "requestId" = @reqId ORDER BY "asOf" """ | ||||
|   (cmd.Parameters.Add >> ignore) (NpgsqlParameter ("@reqId", reqId :> obj)) | ||||
|   use rdr = cmd.ExecuteReader () | ||||
|   seq { | ||||
|     while rdr.Read () do | ||||
|       match (rdr.getString >> isValidStatus) "status" with | ||||
|       | true -> | ||||
|           yield | ||||
|             { asOf   = rdr.getTicks "asOf" | ||||
|               status = (rdr.getString >> RequestAction.fromString) "status" | ||||
|               text   = match rdr.isNull "text" with true -> None | false -> (rdr.getString >> Some) "text" | ||||
|               } | ||||
|       | false -> | ||||
|           printf "Invalid status %s; skipped history entry %s/%i\n" (rdr.getString "status") reqId | ||||
|             ((rdr.getTicks >> Ticks.toLong) "asOf") | ||||
|     } | ||||
|   |> List.ofSeq | ||||
| 
 | ||||
| let getNotes reqId connStr = | ||||
|   use conn = pgConn connStr | ||||
|   use cmd = conn.CreateCommand () | ||||
|   cmd.CommandText <- """SELECT "asOf", notes FROM mpj.note WHERE "requestId" = @reqId""" | ||||
|   (cmd.Parameters.Add >> ignore) (NpgsqlParameter ("@reqId", reqId :> obj)) | ||||
|   use rdr = cmd.ExecuteReader () | ||||
|   seq { | ||||
|     while rdr.Read () do | ||||
|       yield | ||||
|         { asOf  = rdr.getTicks "asOf" | ||||
|           notes = rdr.getString "notes" | ||||
|           } | ||||
|     } | ||||
|   |> List.ofSeq | ||||
| 
 | ||||
| let migrateRequests (store : IDocumentStore) connStr = | ||||
|   use sess = store.OpenSession () | ||||
|   use conn = pgConn connStr | ||||
|   use cmd = conn.CreateCommand () | ||||
|   cmd.CommandText <- | ||||
|     """SELECT "requestId", "enteredOn", "userId", "snoozedUntil", "showAfter", "recurType", "recurCount" FROM mpj.request""" | ||||
|   use rdr = cmd.ExecuteReader () | ||||
|   while rdr.Read () do | ||||
|     let reqId      = rdr.getString "requestId" | ||||
|     let recurrence = | ||||
|       match rdr.getString "recurType" with | ||||
|       | "immediate" -> Immediate | ||||
|       | "hours" -> Hours | ||||
|       | "days" -> Days | ||||
|       | "weeks" -> Weeks | ||||
|       | x -> invalidOp (sprintf "%s is not a valid recurrence" x) | ||||
|     sess.Store ( | ||||
|       { Id           = (RequestId.fromIdString >> RequestId.toString) reqId | ||||
|         enteredOn    = rdr.getTicks "enteredOn" | ||||
|         userId       = (rdr.getString >> UserId) "userId" | ||||
|         snoozedUntil = rdr.getTicks "snoozedUntil" | ||||
|         showAfter    = match recurrence with Immediate -> Ticks 0L | _ -> rdr.getTicks "showAfter" | ||||
|         recurType    = recurrence | ||||
|         recurCount   = rdr.getShort "recurCount" | ||||
|         history      = getHistory reqId connStr | ||||
|         notes        = getNotes   reqId connStr | ||||
|         }) | ||||
|   sess.SaveChanges () | ||||
| 
 | ||||
| open Converters | ||||
| open System | ||||
| open System.Security.Cryptography.X509Certificates | ||||
| 
 | ||||
| [<EntryPoint>] | ||||
| let main argv = | ||||
|   match argv.Length with | ||||
|   | 4 -> | ||||
|       let clientCert = new X509Certificate2 (argv.[1], argv.[2]) | ||||
|       let raven = new DocumentStore (Urls = [| argv.[0] |], Database = "myPrayerJournal", Certificate = clientCert) | ||||
|       raven.Conventions.CustomizeJsonSerializer <- | ||||
|         fun x -> | ||||
|             x.Converters.Add (RequestIdJsonConverter ()) | ||||
|             x.Converters.Add (TicksJsonConverter ()) | ||||
|             x.Converters.Add (UserIdJsonConverter ()) | ||||
|             x.Converters.Add (CompactUnionJsonConverter ()) | ||||
|       let store = raven.Initialize () | ||||
|       migrateRequests store argv.[3] | ||||
|       printfn "fin" | ||||
|   | _ -> | ||||
|       Console.WriteLine "Usage: dotnet migrate.dll [raven-url] [raven-cert-file] [raven-cert-pw] [postgres-conn-str]" | ||||
|   0 | ||||
							
								
								
									
										23
									
								
								src/migrate/migrate.fsproj
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/migrate/migrate.fsproj
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,23 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
| 
 | ||||
|   <PropertyGroup> | ||||
|     <OutputType>Exe</OutputType> | ||||
|     <TargetFramework>netcoreapp2.2</TargetFramework> | ||||
|   </PropertyGroup> | ||||
| 
 | ||||
|   <ItemGroup> | ||||
|     <Compile Include="Program.fs" /> | ||||
|   </ItemGroup> | ||||
| 
 | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Microsoft.FSharpLu.Json" Version="0.11.2" /> | ||||
|     <PackageReference Include="Npgsql" Version="4.0.8" /> | ||||
|     <PackageReference Include="RavenDb.Client" Version="4.2.2" /> | ||||
|     <ProjectReference Include="../MyPrayerJournal.Api/MyPrayerJournal.Api.fsproj" /> | ||||
|   </ItemGroup> | ||||
| 
 | ||||
|   <ItemGroup> | ||||
|     <PackageReference Update="FSharp.Core" Version="4.7.0" /> | ||||
|   </ItemGroup> | ||||
| 
 | ||||
| </Project> | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user