myPrayerJournal v2 #27
							
								
								
									
										12
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										12
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -256,11 +256,11 @@ paket-files/ | |||||||
| .ionide | .ionide | ||||||
| 
 | 
 | ||||||
| # Compiled files / application | # Compiled files / application | ||||||
| src/api/build | src/build | ||||||
| src/api/MyPrayerJournal.Api/wwwroot/favicon.ico | src/MyPrayerJournal.Api/wwwroot/favicon.ico | ||||||
| src/api/MyPrayerJournal.Api/wwwroot/index.html | src/MyPrayerJournal.Api/wwwroot/index.html | ||||||
| src/api/MyPrayerJournal.Api/wwwroot/css | src/MyPrayerJournal.Api/wwwroot/css | ||||||
| src/api/MyPrayerJournal.Api/wwwroot/js | src/MyPrayerJournal.Api/wwwroot/js | ||||||
| src/api/MyPrayerJournal.Api/appsettings.development.json | src/MyPrayerJournal.Api/appsettings.development.json | ||||||
| /build | /build | ||||||
| src/*.exe | 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 | /// HTTP handlers for the myPrayerJournal API | ||||||
| [<RequireQualifiedAccess>] | [<RequireQualifiedAccess>] | ||||||
| module MyPrayerJournal.Api.Handlers | module MyPrayerJournal.Handlers | ||||||
| 
 | 
 | ||||||
| open FSharp.Control.Tasks.V2.ContextInsensitive |  | ||||||
| open Giraffe | open Giraffe | ||||||
| open MyPrayerJournal |  | ||||||
| open System |  | ||||||
| 
 | 
 | ||||||
| /// Handler to return Vue files | /// Handler to return Vue files | ||||||
| module Vue = | module Vue = | ||||||
| @ -13,6 +10,7 @@ module Vue = | |||||||
|   /// The application index page |   /// The application index page | ||||||
|   let app : HttpHandler = htmlFile "wwwroot/index.html" |   let app : HttpHandler = htmlFile "wwwroot/index.html" | ||||||
| 
 | 
 | ||||||
|  | open System | ||||||
| 
 | 
 | ||||||
| /// Handlers for error conditions | /// Handlers for error conditions | ||||||
| module Error = | module Error = | ||||||
| @ -34,18 +32,22 @@ module Error = | |||||||
|       | 0 -> (setStatusCode 404 >=> json ([ "error", "not found" ] |> dict)) next ctx |       | 0 -> (setStatusCode 404 >=> json ([ "error", "not found" ] |> dict)) next ctx | ||||||
|       | _ -> Vue.app next ctx |       | _ -> Vue.app next ctx | ||||||
| 
 | 
 | ||||||
|  | open Cuid | ||||||
| 
 | 
 | ||||||
| /// Handler helpers | /// Handler helpers | ||||||
| [<AutoOpen>] | [<AutoOpen>] | ||||||
| module private Helpers = | module private Helpers = | ||||||
|    |    | ||||||
|   open Microsoft.AspNetCore.Http |   open Microsoft.AspNetCore.Http | ||||||
|  |   open Raven.Client.Documents | ||||||
|   open System.Threading.Tasks |   open System.Threading.Tasks | ||||||
|   open System.Security.Claims |   open System.Security.Claims | ||||||
| 
 | 
 | ||||||
|   /// Get the database context from DI |   /// Create a RavenDB session | ||||||
|   let db (ctx : HttpContext) = |   let session (ctx : HttpContext) = | ||||||
|     ctx.GetService<AppDbContext> () |     let sess = ctx.GetService<IDocumentStore>().OpenAsyncSession () | ||||||
|  |     sess.Advanced.WaitForIndexesAfterSaveChanges () | ||||||
|  |     sess | ||||||
| 
 | 
 | ||||||
|   /// Get the user's "sub" claim |   /// Get the user's "sub" claim | ||||||
|   let user (ctx : HttpContext) = |   let user (ctx : HttpContext) = | ||||||
| @ -54,15 +56,23 @@ module private Helpers = | |||||||
|   /// Get the current user's ID |   /// Get the current user's ID | ||||||
|   //  NOTE: this may raise if you don't run the request through the authorize handler first |   //  NOTE: this may raise if you don't run the request through the authorize handler first | ||||||
|   let userId ctx = |   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 |   /// Return a 201 CREATED response | ||||||
|   let created next ctx = |   let created next ctx = | ||||||
|     setStatusCode 201 next ctx |     setStatusCode 201 next ctx | ||||||
| 
 | 
 | ||||||
|   /// The "now" time in JavaScript |   /// The "now" time in JavaScript as Ticks | ||||||
|   let jsNow () = |   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 |   /// Handler to return a 403 Not Authorized reponse | ||||||
|   let notAuthorized : HttpHandler = |   let notAuthorized : HttpHandler = | ||||||
| @ -116,13 +126,6 @@ module Models = | |||||||
|       recurCount  : int16 |       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 |   /// The time until which a request should not appear in the journal | ||||||
|   [<CLIMutable>] |   [<CLIMutable>] | ||||||
|   type SnoozeUntil = |   type SnoozeUntil = | ||||||
| @ -130,6 +133,7 @@ module Models = | |||||||
|       until : int64 |       until : int64 | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|  | open FSharp.Control.Tasks.V2.ContextInsensitive | ||||||
| 
 | 
 | ||||||
| /// /api/journal URLs | /// /api/journal URLs | ||||||
| module Journal = | module Journal = | ||||||
| @ -138,99 +142,92 @@ module Journal = | |||||||
|   let journal : HttpHandler = |   let journal : HttpHandler = | ||||||
|     authorize |     authorize | ||||||
|     >=> fun next ctx -> |     >=> fun next ctx -> | ||||||
|       userId ctx |       task { | ||||||
|       |> (db ctx).JournalByUserId |         use  sess  = session ctx | ||||||
|       |> asJson next ctx |         let  usrId = userId  ctx | ||||||
|  |         let! jrnl  = Data.journalByUserId usrId sess | ||||||
|  |         return! json jrnl next ctx | ||||||
|  |         } | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| /// /api/request URLs | /// /api/request URLs | ||||||
| module Request = | module Request = | ||||||
|    |    | ||||||
|   open NCuid |  | ||||||
|    |  | ||||||
|   /// Ticks per recurrence |  | ||||||
|   let private recurrence = |  | ||||||
|     [ "immediate",         0L |  | ||||||
|       "hours",       3600000L |  | ||||||
|       "days",       86400000L |  | ||||||
|       "weeks",     604800000L |  | ||||||
|       ] |  | ||||||
|     |> Map.ofList |  | ||||||
| 
 |  | ||||||
|   /// POST /api/request |   /// POST /api/request | ||||||
|   let add : HttpHandler = |   let add : HttpHandler = | ||||||
|     authorize |     authorize | ||||||
|     >=> fun next ctx -> |     >=> fun next ctx -> | ||||||
|       task { |       task { | ||||||
|         let! r     = ctx.BindJsonAsync<Models.Request> () |         let! r     = ctx.BindJsonAsync<Models.Request> () | ||||||
|         let  db    = db ctx |         use  sess  = session ctx | ||||||
|         let  reqId = Cuid.Generate () |         let  reqId = (Cuid.generate >> RequestId) () | ||||||
|         let  usrId = userId ctx |         let  usrId = userId ctx | ||||||
|         let  now   = jsNow () |         let  now   = jsNow () | ||||||
|  |         do! Data.addRequest | ||||||
|               { Request.empty with |               { Request.empty with | ||||||
|             requestId  = reqId |                   Id         = RequestId.toString reqId | ||||||
|                   userId     = usrId |                   userId     = usrId | ||||||
|                   enteredOn  = now |                   enteredOn  = now | ||||||
|             showAfter  = now |                   showAfter  = Ticks 0L | ||||||
|             recurType  = r.recurType |                   recurType  = Recurrence.fromString r.recurType | ||||||
|                   recurCount = r.recurCount |                   recurCount = r.recurCount | ||||||
|           } |                   history    = [ | ||||||
|         |> db.AddEntry |                     { asOf   = now | ||||||
|         { History.empty with |                       status = Created | ||||||
|             requestId = reqId |  | ||||||
|             asOf      = now |  | ||||||
|             status    = "Created" |  | ||||||
|                       text   = Some r.requestText |                       text   = Some r.requestText | ||||||
|                       }       |                       }       | ||||||
|         |> db.AddEntry |                     ] | ||||||
|         let! _   = db.SaveChangesAsync () |                 } sess | ||||||
|         match! db.TryJournalById reqId usrId with |         do! Data.saveChanges sess | ||||||
|  |         match! Data.tryJournalById reqId usrId sess with | ||||||
|         | Some req -> return! (setStatusCode 201 >=> json req) next ctx |         | Some req -> return! (setStatusCode 201 >=> json req) next ctx | ||||||
|         | None -> return! Error.notFound next ctx |         | None -> return! Error.notFound next ctx | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|   /// POST /api/request/[req-id]/history |   /// POST /api/request/[req-id]/history | ||||||
|   let addHistory reqId : HttpHandler = |   let addHistory requestId : HttpHandler = | ||||||
|     authorize |     authorize | ||||||
|     >=> fun next ctx -> |     >=> fun next ctx -> | ||||||
|       task { |       task { | ||||||
|         let db = db ctx |         use sess  = session ctx | ||||||
|         match! db.TryRequestById reqId (userId ctx) with |         let usrId = userId ctx | ||||||
|  |         let reqId = toReqId requestId | ||||||
|  |         match! Data.tryRequestById reqId usrId sess with | ||||||
|         | Some req -> |         | Some req -> | ||||||
|             let! hist = ctx.BindJsonAsync<Models.HistoryEntry> () |             let! hist = ctx.BindJsonAsync<Models.HistoryEntry> () | ||||||
|             let  now  = jsNow () |             let  now  = jsNow () | ||||||
|             { History.empty with |             let  act  = RequestAction.fromString hist.status | ||||||
|                 requestId = reqId |             Data.addHistory reqId | ||||||
|                 asOf      = now |               { asOf   = now | ||||||
|                 status    = hist.status |                 status = act | ||||||
|                 text   = match hist.updateText with null | "" -> None | x -> Some x |                 text   = match hist.updateText with null | "" -> None | x -> Some x | ||||||
|               } |                 } sess | ||||||
|             |> db.AddEntry |             match act with | ||||||
|             match hist.status with |             | Prayed -> | ||||||
|             | "Prayed" -> |                 let nextShow = | ||||||
|                 db.UpdateEntry { req with showAfter = now + (recurrence.[req.recurType] * int64 req.recurCount) } |                   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 |             return! created next ctx | ||||||
|         | None -> return! Error.notFound next ctx |         | None -> return! Error.notFound next ctx | ||||||
|         } |         } | ||||||
|    |    | ||||||
|   /// POST /api/request/[req-id]/note |   /// POST /api/request/[req-id]/note | ||||||
|   let addNote reqId : HttpHandler = |   let addNote requestId : HttpHandler = | ||||||
|     authorize |     authorize | ||||||
|     >=> fun next ctx -> |     >=> fun next ctx -> | ||||||
|       task { |       task { | ||||||
|         let db = db ctx |         use sess  = session ctx | ||||||
|         match! db.TryRequestById reqId (userId ctx) with |         let usrId = userId ctx | ||||||
|  |         let reqId = toReqId requestId | ||||||
|  |         match! Data.tryRequestById reqId usrId sess with | ||||||
|         | Some _ -> |         | Some _ -> | ||||||
|             let! notes = ctx.BindJsonAsync<Models.NoteEntry> () |             let! notes = ctx.BindJsonAsync<Models.NoteEntry> () | ||||||
|             { Note.empty with |             Data.addNote reqId { asOf = jsNow (); notes = notes.notes } sess | ||||||
|                 requestId = reqId |             do! Data.saveChanges sess | ||||||
|                 asOf      = jsNow () |  | ||||||
|                 notes     = notes.notes |  | ||||||
|               } |  | ||||||
|             |> db.AddEntry |  | ||||||
|             let! _ = db.SaveChangesAsync () |  | ||||||
|             return! created next ctx |             return! created next ctx | ||||||
|         | None -> return! Error.notFound next ctx |         | None -> return! Error.notFound next ctx | ||||||
|         } |         } | ||||||
| @ -239,83 +236,129 @@ module Request = | |||||||
|   let answered : HttpHandler = |   let answered : HttpHandler = | ||||||
|     authorize |     authorize | ||||||
|     >=> fun next ctx -> |     >=> fun next ctx -> | ||||||
|       userId ctx |       task { | ||||||
|       |> (db ctx).AnsweredRequests |         use  sess  = session ctx | ||||||
|       |> asJson next ctx |         let  usrId = userId ctx | ||||||
|  |         let! reqs  = Data.answeredRequests usrId sess | ||||||
|  |         return! json reqs next ctx | ||||||
|  |         } | ||||||
|    |    | ||||||
|   /// GET /api/request/[req-id] |   /// GET /api/request/[req-id] | ||||||
|   let get reqId : HttpHandler = |   let get requestId : HttpHandler = | ||||||
|     authorize |     authorize | ||||||
|     >=> fun next ctx -> |     >=> fun next ctx -> | ||||||
|       task { |       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 |         | Some req -> return! json req next ctx | ||||||
|         | None -> return! Error.notFound next ctx |         | None -> return! Error.notFound next ctx | ||||||
|         } |         } | ||||||
|    |    | ||||||
|   /// GET /api/request/[req-id]/full |   /// GET /api/request/[req-id]/full | ||||||
|   let getFull reqId : HttpHandler = |   let getFull requestId : HttpHandler = | ||||||
|     authorize |     authorize | ||||||
|     >=> fun next ctx -> |     >=> fun next ctx -> | ||||||
|       task { |       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 |         | Some req -> return! json req next ctx | ||||||
|         | None -> return! Error.notFound next ctx |         | None -> return! Error.notFound next ctx | ||||||
|         } |         } | ||||||
|    |    | ||||||
|   /// GET /api/request/[req-id]/notes |   /// GET /api/request/[req-id]/notes | ||||||
|   let getNotes reqId : HttpHandler = |   let getNotes requestId : HttpHandler = | ||||||
|     authorize |     authorize | ||||||
|     >=> fun next ctx -> |     >=> fun next ctx -> | ||||||
|       task { |       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 |         return! json notes next ctx | ||||||
|         } |         } | ||||||
|    |    | ||||||
|   /// PATCH /api/request/[req-id]/show |   /// PATCH /api/request/[req-id]/show | ||||||
|   let show reqId : HttpHandler = |   let show requestId : HttpHandler = | ||||||
|     authorize |     authorize | ||||||
|     >=> fun next ctx -> |     >=> fun next ctx -> | ||||||
|       task { |       task { | ||||||
|         let db = db ctx |         use sess  = session ctx | ||||||
|         match! db.TryRequestById reqId (userId ctx) with |         let usrId = userId ctx | ||||||
|         | Some req -> |         let reqId = toReqId requestId | ||||||
|             let! show = ctx.BindJsonAsync<Models.Show> () |         match! Data.tryRequestById reqId usrId sess with | ||||||
|             { req with showAfter = show.showAfter } |         | Some _ -> | ||||||
|             |> db.UpdateEntry |             Data.updateShowAfter reqId (Ticks 0L) sess | ||||||
|             let! _ = db.SaveChangesAsync () |             do! Data.saveChanges sess | ||||||
|             return! setStatusCode 204 next ctx |             return! setStatusCode 204 next ctx | ||||||
|         | None -> return! Error.notFound next ctx |         | None -> return! Error.notFound next ctx | ||||||
|         } |         } | ||||||
|    |    | ||||||
|   /// PATCH /api/request/[req-id]/snooze |   /// PATCH /api/request/[req-id]/snooze | ||||||
|   let snooze reqId : HttpHandler = |   let snooze requestId : HttpHandler = | ||||||
|     authorize |     authorize | ||||||
|     >=> fun next ctx -> |     >=> fun next ctx -> | ||||||
|       task { |       task { | ||||||
|         let db = db ctx |         use sess  = session ctx | ||||||
|         match! db.TryRequestById reqId (userId ctx) with |         let usrId = userId ctx | ||||||
|         | Some req -> |         let reqId = toReqId requestId | ||||||
|  |         match! Data.tryRequestById reqId usrId sess with | ||||||
|  |         | Some _ -> | ||||||
|             let! until = ctx.BindJsonAsync<Models.SnoozeUntil> () |             let! until = ctx.BindJsonAsync<Models.SnoozeUntil> () | ||||||
|             { req with snoozedUntil = until.until; showAfter = until.until } |             Data.updateSnoozed reqId (Ticks until.until) sess | ||||||
|             |> db.UpdateEntry |             do! Data.saveChanges sess | ||||||
|             let! _ = db.SaveChangesAsync () |  | ||||||
|             return! setStatusCode 204 next ctx |             return! setStatusCode 204 next ctx | ||||||
|         | None -> return! Error.notFound next ctx |         | None -> return! Error.notFound next ctx | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|   /// PATCH /api/request/[req-id]/recurrence |   /// PATCH /api/request/[req-id]/recurrence | ||||||
|   let updateRecurrence reqId : HttpHandler = |   let updateRecurrence requestId : HttpHandler = | ||||||
|     authorize |     authorize | ||||||
|     >=> fun next ctx -> |     >=> fun next ctx -> | ||||||
|       task { |       task { | ||||||
|         let db = db ctx |         use sess  = session ctx | ||||||
|         match! db.TryRequestById reqId (userId ctx) with |         let usrId = userId ctx | ||||||
|         | Some req -> |         let reqId = toReqId requestId | ||||||
|  |         match! Data.tryRequestById reqId usrId sess with | ||||||
|  |         | Some _ -> | ||||||
|             let! recur = ctx.BindJsonAsync<Models.Recurrence> () |             let! recur = ctx.BindJsonAsync<Models.Recurrence> () | ||||||
|             { req with recurType = recur.recurType; recurCount = recur.recurCount } |             let recurrence = Recurrence.fromString recur.recurType | ||||||
|             |> db.UpdateEntry |             Data.updateRecurrence reqId recurrence recur.recurCount sess | ||||||
|             let! _ = db.SaveChangesAsync () |             match recurrence with Immediate -> Data.updateShowAfter reqId (Ticks 0L) sess | _ -> () | ||||||
|  |             do! Data.saveChanges sess | ||||||
|             return! setStatusCode 204 next ctx |             return! setStatusCode 204 next ctx | ||||||
|         | None -> return! Error.notFound 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> |   <PropertyGroup> | ||||||
|     <TargetFramework>netcoreapp2.2</TargetFramework> |     <TargetFramework>netcoreapp2.2</TargetFramework> | ||||||
|     <Version>1.2.2.0</Version> |     <Version>2.0.0.0</Version> | ||||||
|   </PropertyGroup> |   </PropertyGroup> | ||||||
| 
 | 
 | ||||||
|   <ItemGroup> |   <ItemGroup> | ||||||
|  |     <Compile Include="Domain.fs" /> | ||||||
|     <Compile Include="Data.fs" /> |     <Compile Include="Data.fs" /> | ||||||
|     <Compile Include="Handlers.fs" /> |     <Compile Include="Handlers.fs" /> | ||||||
|     <Compile Include="Program.fs" /> |     <Compile Include="Program.fs" /> | ||||||
|   </ItemGroup> |   </ItemGroup> | ||||||
| 
 | 
 | ||||||
|   <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" Version="3.6.0" /> | ||||||
|     <PackageReference Include="Giraffe.TokenRouter" Version="1.0.0" /> |     <PackageReference Include="Giraffe.TokenRouter" Version="1.0.0" /> | ||||||
|     <PackageReference Include="Microsoft.AspNetCore.App" /> |     <PackageReference Include="Microsoft.AspNetCore.App" /> | ||||||
|     <PackageReference Include="Microsoft.FSharpLu" Version="0.10.29" /> |     <PackageReference Include="Microsoft.FSharpLu" Version="0.10.29" /> | ||||||
|     <PackageReference Include="Microsoft.FSharpLu.Json" Version="0.10.29" /> |     <PackageReference Include="Microsoft.FSharpLu.Json" Version="0.10.29" /> | ||||||
|     <PackageReference Include="NCuid.NetCore" Version="1.0.1" /> |     <PackageReference Include="RavenDb.Client" Version="4.2.1" /> | ||||||
|     <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="2.2.0" /> |  | ||||||
|     <PackageReference Include="TaskBuilder.fs" Version="2.1.0" /> |     <PackageReference Include="TaskBuilder.fs" Version="2.1.0" /> | ||||||
|   </ItemGroup> |   </ItemGroup> | ||||||
| 
 | 
 | ||||||
|   <ItemGroup> |   <ItemGroup> | ||||||
|     <PackageReference Update="FSharp.Core" Version="4.6.2" /> |     <PackageReference Update="FSharp.Core" Version="4.7.0" /> | ||||||
|   </ItemGroup> |   </ItemGroup> | ||||||
| 
 | 
 | ||||||
|   <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", |   "name": "my-prayer-journal", | ||||||
|   "version": "1.2.2", |   "version": "2.0.0", | ||||||
|   "description": "myPrayerJournal - Front End", |   "description": "myPrayerJournal - Front End", | ||||||
|   "author": "Daniel J. Summers <daniel@bitbadger.solutions>", |   "author": "Daniel J. Summers <daniel@bitbadger.solutions>", | ||||||
|   "private": true, |   "private": true, | ||||||
|   "scripts": { |   "scripts": { | ||||||
|     "serve": "vue-cli-service serve", |     "serve": "vue-cli-service serve --port 8081", | ||||||
|     "build": "vue-cli-service build --modern", |     "build": "vue-cli-service build --modern", | ||||||
|     "lint": "vue-cli-service lint", |     "lint": "vue-cli-service lint", | ||||||
|     "apistart": "cd ../api/MyPrayerJournal.Api && dotnet run", |     "apistart": "cd ../MyPrayerJournal.Api && dotnet run", | ||||||
|     "vue": "vue-cli-service build --modern && cd ../api/MyPrayerJournal.Api && dotnet run", |     "vue": "vue-cli-service build --modern && cd ../MyPrayerJournal.Api && dotnet run", | ||||||
|     "publish": "vue-cli-service build --modern && cd ../api/MyPrayerJournal.Api && dotnet publish -c Release" |     "publish": "vue-cli-service build --modern && cd ../MyPrayerJournal.Api && dotnet publish -c Release" | ||||||
|   }, |   }, | ||||||
|   "dependencies": { |   "dependencies": { | ||||||
|     "auth0-js": "^9.7.3", |     "auth0-js": "^9.7.3", | ||||||
|     "axios": "^0.19.0", |     "axios": "^0.19.0", | ||||||
|     "moment": "^2.18.1", |     "moment": "^2.18.1", | ||||||
|     "vue": "^2.5.15", |     "vue": "^2.5.15", | ||||||
|     "vue-progressbar": "^0.7.3", |     "vue-material": "^1.0.0-beta-11", | ||||||
|     "vue-router": "^3.0.0", |     "vue-router": "^3.0.0", | ||||||
|     "vue-toast": "^3.1.0", |  | ||||||
|     "vuex": "^3.0.1" |     "vuex": "^3.0.1" | ||||||
|   }, |   }, | ||||||
|   "devDependencies": { |   "devDependencies": { | ||||||
| @ -27,8 +26,11 @@ | |||||||
|     "@vue/cli-plugin-eslint": "^3.0.0", |     "@vue/cli-plugin-eslint": "^3.0.0", | ||||||
|     "@vue/cli-service": "^3.0.0", |     "@vue/cli-service": "^3.0.0", | ||||||
|     "@vue/eslint-config-standard": "^4.0.0", |     "@vue/eslint-config-standard": "^4.0.0", | ||||||
|  |     "node-sass": "^4.12.0", | ||||||
|     "pug": "^2.0.1", |     "pug": "^2.0.1", | ||||||
|     "pug-plain-loader": "^1.0.0", |     "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,17 +1,29 @@ | |||||||
| <template lang="pug"> | <template lang="pug"> | ||||||
| #app(role='application') | #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 |       navigation | ||||||
|   #content |     md-app-content | ||||||
|  |       md-progress-bar(v-if='progress.visible' | ||||||
|  |                       :md-mode='progress.mode') | ||||||
|       router-view |       router-view | ||||||
|     vue-progress-bar |       md-snackbar(:md-active.sync='snackbar.visible' | ||||||
|     toast(ref='toast') |                   md-position='center' | ||||||
|   footer.mpj-text-right.mpj-muted-text |                   :md-duration='snackbar.interval' | ||||||
|     p |                   ref='snackbar') {{ snackbar.message }} | ||||||
|  |       footer | ||||||
|  |         p.mpj-muted-text.mpj-text-right | ||||||
|           | myPrayerJournal v{{ version }} |           | myPrayerJournal v{{ version }} | ||||||
|           br |           br | ||||||
|           em: small. |           em: small. | ||||||
|         #[router-link(:to="{ name: 'PrivacyPolicy' }") Privacy Policy] • |             #[router-link(to='/legal/privacy-policy') Privacy Policy] • | ||||||
|         #[router-link(:to="{ name: 'TermsOfService' }") Terms of Service] • |             #[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://github.com/bit-badger/myprayerjournal' target='_blank') Developed] and hosted by | ||||||
|             #[a(href='https://bitbadger.solutions' target='_blank') Bit Badger Solutions] |             #[a(href='https://bitbadger.solutions' target='_blank') Bit Badger Solutions] | ||||||
| </template> | </template> | ||||||
| @ -19,8 +31,11 @@ | |||||||
| <script> | <script> | ||||||
| 'use strict' | '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' | import { version } from '../package.json' | ||||||
| 
 | 
 | ||||||
| export default { | export default { | ||||||
| @ -29,216 +44,115 @@ export default { | |||||||
|     Navigation |     Navigation | ||||||
|   }, |   }, | ||||||
|   data () { |   data () { | ||||||
|     return {} |     return { | ||||||
|  |       progress: { | ||||||
|  |         events: new Vue(), | ||||||
|  |         visible: false, | ||||||
|  |         mode: 'query' | ||||||
|       }, |       }, | ||||||
|   mounted () { |       snackbar: { | ||||||
|     this.$refs.toast.setOptions({ position: 'bottom right' }) |         events: new Vue(), | ||||||
|  |         visible: false, | ||||||
|  |         message: '', | ||||||
|  |         interval: 4000 | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   async mounted () { | ||||||
|  |     this.progress.events.$on('show', this.showProgress) | ||||||
|  |     this.progress.events.$on('done', this.hideProgress) | ||||||
|  |     this.snackbar.events.$on('info', this.showInfo) | ||||||
|  |     this.snackbar.events.$on('error', this.showError) | ||||||
|  |     await this.$store.dispatch(actions.CHECK_AUTHENTICATION) | ||||||
|   }, |   }, | ||||||
|   computed: { |   computed: { | ||||||
|     toast () { |  | ||||||
|       return this.$refs.toast |  | ||||||
|     }, |  | ||||||
|     version () { |     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> | </script> | ||||||
| 
 | 
 | ||||||
| <style> | <style lang="sass"> | ||||||
| html, body { | @import "~vue-material/dist/theme/engine" | ||||||
|   background-color: whitesmoke; | @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-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif; | ||||||
|   font-size: 1rem; |   font-size: 1rem | ||||||
| } | p | ||||||
| body { |   margin-bottom: 0 | ||||||
|   padding-top: 50px; | footer | ||||||
|   margin: 0; |   border-top: solid 1px lightgray | ||||||
| } |   margin: 1rem -1rem 0 | ||||||
| h1, h2, h3, h4, h5 { |   padding: 0 1rem | ||||||
|   font-weight: 500; | footer p | ||||||
|   margin-top: 0; |   margin: 0 | ||||||
| } | .mpj-full-page-card | ||||||
| h1 { |   font-size: 1rem | ||||||
|   font-size: 2.5rem; |   line-height: 1.25rem | ||||||
| } | .mpj-main-content | ||||||
| h2 { |   max-width: 60rem | ||||||
|   font-size: 2rem; |   margin: auto | ||||||
| } | .mpj-request-text | ||||||
| h3 { |   white-space: pre-line | ||||||
|   font-size: 1.75rem; | p.mpj-request-text | ||||||
| } |   margin-top: 0 | ||||||
| h4 { | .mpj-text-center | ||||||
|   font-size: 1.5rem; |   text-align: center | ||||||
| } | .mpj-text-nowrap | ||||||
| h5 { |   white-space: nowrap | ||||||
|   font-size: 1.25rem; | .mpj-text-right | ||||||
| } |   text-align: right | ||||||
| p { | .mpj-muted-text | ||||||
|   margin-bottom: 0; |   color: rgba(0, 0, 0, .6) | ||||||
| } | .mpj-valign-top | ||||||
| input, textarea, select { |   vertical-align: top | ||||||
|   border-radius: .25rem; | .mpj-narrow | ||||||
|   font-size: 1rem; |   max-width: 40rem | ||||||
| } |   margin: auto | ||||||
| textarea { | .mpj-skinny | ||||||
|   font-family: "SFMono-Regular",Consolas,"Liberation Mono",Menlo,Courier,monospace; |   max-width: 20rem | ||||||
| } |   margin: auto | ||||||
| input, select { | .mpj-full-width | ||||||
|   font-family: inherit; |   width: 100% | ||||||
| } | .md-progress-bar | ||||||
| button, |   margin: 24px | ||||||
| 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; |  | ||||||
| } |  | ||||||
| </style> | </style> | ||||||
|  | |||||||
| @ -15,12 +15,12 @@ export default { | |||||||
|    * Set the bearer token for all future requests |    * Set the bearer token for all future requests | ||||||
|    * @param {string} token The token to use to identify the user to the server |    * @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 |    * 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 |    * Add a note for a prayer request | ||||||
|  | |||||||
| @ -1,31 +1,45 @@ | |||||||
| 'use strict' | 'use strict' | ||||||
| 
 | /* eslint-disable */ | ||||||
| import auth0        from 'auth0-js' | import auth0        from 'auth0-js' | ||||||
|  | import EventEmitter from 'events' | ||||||
| 
 | 
 | ||||||
| import AUTH_CONFIG from './auth0-variables' | 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({ | ||||||
| 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) |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   auth0 = new auth0.WebAuth({ |  | ||||||
|   domain: AUTH_CONFIG.domain, |   domain: AUTH_CONFIG.domain, | ||||||
|   clientID: AUTH_CONFIG.clientId, |   clientID: AUTH_CONFIG.clientId, | ||||||
|   redirectUri: AUTH_CONFIG.appDomain + AUTH_CONFIG.callbackUrl, |   redirectUri: AUTH_CONFIG.appDomain + AUTH_CONFIG.callbackUrl, | ||||||
|   audience: `https://${AUTH_CONFIG.domain}/userinfo`, |   audience: `https://${AUTH_CONFIG.domain}/userinfo`, | ||||||
|   responseType: 'token id_token', |   responseType: 'token id_token', | ||||||
|   scope: 'openid profile email' |   scope: 'openid profile email' | ||||||
|   }) | }) | ||||||
| 
 | 
 | ||||||
|   login () { | /** | ||||||
|     this.auth0.authorize() |  * A class to handle all authentication calls and determinations | ||||||
|  |  */ | ||||||
|  | class AuthService extends EventEmitter { | ||||||
|  |    | ||||||
|  |   // Local storage key for our session data
 | ||||||
|  |   AUTH_SESSION = 'auth-session' | ||||||
|  | 
 | ||||||
|  |   // Received and calculated values for our ssesion (initially loaded from local storage if present)
 | ||||||
|  |   session = {} | ||||||
|  | 
 | ||||||
|  |   constructor() { | ||||||
|  |     super() | ||||||
|  |     this.refreshSession() | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /** | ||||||
|  |    * Starts the user log in flow | ||||||
|  |    */ | ||||||
|  |   login (customState) { | ||||||
|  |     webAuth.authorize({ | ||||||
|  |       appState: customState | ||||||
|  |     }) | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   /** |   /** | ||||||
| @ -33,7 +47,7 @@ export default class AuthService { | |||||||
|    */ |    */ | ||||||
|   parseHash () { |   parseHash () { | ||||||
|     return new Promise((resolve, reject) => { |     return new Promise((resolve, reject) => { | ||||||
|       this.auth0.parseHash((err, authResult) => { |       webAuth.parseHash((err, authResult) => { | ||||||
|         if (err) { |         if (err) { | ||||||
|           reject(err) |           reject(err) | ||||||
|         } else { |         } else { | ||||||
| @ -44,95 +58,137 @@ export default class AuthService { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   /** |   /** | ||||||
|    * Promisified userInfo function |    * Handle authentication replies from Auth0 | ||||||
|    *  |    *  | ||||||
|    * @param token The auth token from the login result |    * @param store The Vuex store | ||||||
|    */ |    */ | ||||||
|   userInfo (token) { |   async handleAuthentication (store) { | ||||||
|  |     try { | ||||||
|  |       const authResult = await this.parseHash() | ||||||
|  |       if (authResult && authResult.accessToken && authResult.idToken) { | ||||||
|  |         this.setSession(authResult) | ||||||
|  |         store.commit(mutations.USER_LOGGED_ON, this.session.profile) | ||||||
|  |       } | ||||||
|  |     } catch(err) { | ||||||
|  |       console.error(err) | ||||||
|  |       alert(`Error: ${err.error}. Check the console for further details.`) | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /** | ||||||
|  |    * Set up the session and commit it to local storage | ||||||
|  |    *  | ||||||
|  |    * @param authResult The authorization result | ||||||
|  |    */ | ||||||
|  |   setSession (authResult) { | ||||||
|  |     this.session.profile = authResult.idTokenPayload | ||||||
|  |     this.session.id.token = authResult.idToken | ||||||
|  |     this.session.id.expiry = this.session.profile.exp * 1000 | ||||||
|  |     this.session.access.token = authResult.accessToken | ||||||
|  |     this.session.access.expiry = authResult.expiresIn * 1000 + Date.now() | ||||||
|  | 
 | ||||||
|  |     localStorage.setItem(this.AUTH_SESSION, JSON.stringify(this.session)) | ||||||
|  | 
 | ||||||
|  |     this.emit('loginEvent', { | ||||||
|  |       loggedIn: true, | ||||||
|  |       profile: authResult.idTokenPayload, | ||||||
|  |       state: authResult.appState || {} | ||||||
|  |     }) | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /** | ||||||
|  |    * Refresh this instance's session from the one in local storage | ||||||
|  |    */ | ||||||
|  |   refreshSession () { | ||||||
|  |     this.session =  | ||||||
|  |       localStorage.getItem(this.AUTH_SESSION) | ||||||
|  |       ? JSON.parse(localStorage.getItem(this.AUTH_SESSION)) | ||||||
|  |       : { profile: {}, | ||||||
|  |           id: { | ||||||
|  |             token: null, | ||||||
|  |             expiry: null | ||||||
|  |           }, | ||||||
|  |           access: { | ||||||
|  |             token: null, | ||||||
|  |             expiry: null | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /** | ||||||
|  |    * Renew authorzation tokens with Auth0 | ||||||
|  |    */ | ||||||
|  |   renewTokens () { | ||||||
|     return new Promise((resolve, reject) => { |     return new Promise((resolve, reject) => { | ||||||
|       this.auth0.client.userInfo(token, (err, user) => { |       this.refreshSession() | ||||||
|  |       if (this.session.id.token !== null) { | ||||||
|  |         webAuth.checkSession({}, (err, authResult) => { | ||||||
|           if (err) { |           if (err) { | ||||||
|             reject(err) |             reject(err) | ||||||
|           } else { |           } else { | ||||||
|           resolve(user) |  | ||||||
|         } |  | ||||||
|       }) |  | ||||||
|     }) |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   handleAuthentication (store, router) { |  | ||||||
|     this.parseHash() |  | ||||||
|       .then(authResult => { |  | ||||||
|         if (authResult && authResult.accessToken && authResult.idToken) { |  | ||||||
|             this.setSession(authResult) |             this.setSession(authResult) | ||||||
|           this.userInfo(authResult.accessToken) |             resolve(authResult) | ||||||
|             .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) |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   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() |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   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 { |       } else { | ||||||
|           this.setSession(result) |         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
 |     // Clear access token and ID token from local storage
 | ||||||
|     clearTimeout(tokenRenewalTimeout) |     localStorage.removeItem(this.AUTH_SESSION) | ||||||
|     localStorage.removeItem('access_token') |     this.refreshSession() | ||||||
|     localStorage.removeItem('id_token') | 
 | ||||||
|     localStorage.removeItem('expires_at') |  | ||||||
|     localStorage.setItem('user_profile', JSON.stringify({})) |  | ||||||
|     // navigate to the home route
 |  | ||||||
|     store.commit(mutations.USER_LOGGED_OFF) |     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 () { |   isAuthenticated () { | ||||||
|     // Check whether the current time is past the access token's expiry time
 |     return this.checkExpiry(this.session.id) | ||||||
|     let expiresAt = JSON.parse(localStorage.getItem('expires_at')) |   } | ||||||
|     return new Date().getTime() < expiresAt | 
 | ||||||
|  |   /** | ||||||
|  |    * 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"> | <template lang="pug"> | ||||||
| article.mpj-main-content(role='main') | md-content(role='main').mpj-main-content | ||||||
|   page-title(title='Welcome!' |   page-title(title='Welcome!' | ||||||
|              hideOnPage='true') |              hideOnPage=true) | ||||||
|   p   |   p   | ||||||
|   p. |   p. | ||||||
|     myPrayerJournal is a place where individuals can record their prayer requests, record that they prayed for them, |     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. |     individuals to review their answered prayers. | ||||||
|   p. |   p. | ||||||
|     This site is currently in beta, but it is open and available to the general public. To get started, simply click |     This site is open and available to the general public. To get started, simply click the “Log On” link | ||||||
|     the “Log On” link above, and log on with either a Microsoft or Google account. You can also learn more |     above, and log on with either a Microsoft or Google account. You can also learn more about the site at the | ||||||
|     about the site at the “Docs” link, also above. |     “Docs” link, also above. | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| <script> | <script> | ||||||
|  | |||||||
| @ -1,25 +1,24 @@ | |||||||
| <template lang="pug"> | <template lang="pug"> | ||||||
| article.mpj-main-content-wide(role='main') | md-content(role='main').mpj-main-content-wide | ||||||
|   page-title(:title='title') |   page-title(:title='title') | ||||||
|   p(v-if='isLoadingJournal') Loading your prayer journal... |   p(v-if='isLoadingJournal') Loading your prayer journal... | ||||||
|  |   template(v-else) | ||||||
|  |     md-empty-state(v-if='journal.length === 0' | ||||||
|  |                    md-icon='done_all' | ||||||
|  |                    md-label='No Requests to Show' | ||||||
|  |                    md-description='You have no requests to be shown; see the “Active” link above for snoozed/deferred requests, and the “Answered” link for answered requests') | ||||||
|  |       md-button(:to="{ name: 'EditRequest', params: { id: 'new' } }").md-primary.md-raised Add a New Request | ||||||
|     template(v-else) |     template(v-else) | ||||||
|       .mpj-text-center |       .mpj-text-center | ||||||
|       router-link(:to="{ name: 'EditRequest', params: { id: 'new' } }" |         md-button(:to="{ name: 'EditRequest', params: { id: 'new' } }" | ||||||
|                   role='button'). |                   role='button').md-raised.md-accent #[md-icon add_box] Add a New Request | ||||||
|         #[md-icon(icon='add_box')] Add a New Request |  | ||||||
|       br |       br | ||||||
|     .mpj-journal(v-if='journal.length > 0') |       .mpj-journal | ||||||
|         request-card(v-for='request in journal' |         request-card(v-for='request in journal' | ||||||
|                     :key='request.requestId' |                     :key='request.requestId' | ||||||
|                    :request='request' |                     :request='request') | ||||||
|                    :events='eventBus' |     notes-edit | ||||||
|                    :toast='toast') |     snooze-request | ||||||
|     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') |  | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| <script> | <script> | ||||||
| @ -36,6 +35,10 @@ import actions from '@/store/action-types' | |||||||
| 
 | 
 | ||||||
| export default { | export default { | ||||||
|   name: 'journal', |   name: 'journal', | ||||||
|  |   inject: [ | ||||||
|  |     'messages', | ||||||
|  |     'progress' | ||||||
|  |   ], | ||||||
|   components: { |   components: { | ||||||
|     NotesEdit, |     NotesEdit, | ||||||
|     RequestCard, |     RequestCard, | ||||||
| @ -50,23 +53,29 @@ export default { | |||||||
|     title () { |     title () { | ||||||
|       return `${this.user.given_name}’s Prayer Journal` |       return `${this.user.given_name}’s Prayer Journal` | ||||||
|     }, |     }, | ||||||
|     toast () { |     snackbar () { | ||||||
|       return this.$parent.$refs.toast |       return this.$parent.$refs.snackbar | ||||||
|     }, |     }, | ||||||
|     ...mapState(['user', 'journal', 'isLoadingJournal']) |     ...mapState(['user', 'journal', 'isLoadingJournal']) | ||||||
|   }, |   }, | ||||||
|   async created () { |   async created () { | ||||||
|     await this.$store.dispatch(actions.LOAD_JOURNAL, this.$Progress) |     await this.$store.dispatch(actions.LOAD_JOURNAL, this.progress) | ||||||
|     this.toast.showToast(`Loaded ${this.journal.length} prayer requests`, { theme: 'success' }) |     this.messages.$emit('info', `Loaded ${this.journal.length} prayer requests`) | ||||||
|  |   }, | ||||||
|  |   provide () { | ||||||
|  |     return { | ||||||
|  |       journalEvents: this.eventBus | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <style> | <style lang="sass"> | ||||||
| .mpj-journal { | .mpj-journal | ||||||
|   display: flex; |   display: flex | ||||||
|   flex-flow: row wrap; |   flex-flow: row wrap | ||||||
|   justify-content: center; |   justify-content: center | ||||||
|   align-items: flex-start; |   align-items: flex-start | ||||||
| } | .mpj-dialog-content | ||||||
|  |   padding: 0 1rem | ||||||
| </style> | </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"> | <template lang="pug"> | ||||||
| nav.mpj-top-nav.mpj-bg(role='menubar') | .md-toolbar-row | ||||||
|   router-link.title(:to="{ name: 'Home' }" |   md-tabs(md-sync-route).md-primary | ||||||
|                     role='menuitem') |     template(v-if='isAuthenticated') | ||||||
|     span(style='font-weight:100;') my |       md-tab(md-label='Journal' | ||||||
|     span(style='font-weight:600;') Prayer |              to='/journal') | ||||||
|     span(style='font-weight:700;') Journal |       md-tab(md-label='Active' | ||||||
|   router-link(v-if='isAuthenticated' |              to='/requests/active') | ||||||
|               :to="{ name: 'Journal' }" |       md-tab(v-if='hasSnoozed' | ||||||
|               role='menuitem') Journal |              md-label='Snoozed' | ||||||
|   router-link(v-if='isAuthenticated' |              to='/requests/snoozed') | ||||||
|               :to="{ name: 'ActiveRequests' }" |       md-tab(md-label='Answered' | ||||||
|               role='menuitem') Active |              to='/requests/answered') | ||||||
|   router-link(v-if='hasSnoozed' |       md-tab(md-label='Log Off' | ||||||
|               :to="{ name: 'SnoozedRequests' }" |              href='/user/log-off' | ||||||
|               role='menuitem') Snoozed |              @click.prevent='logOff()') | ||||||
|   router-link(v-if='isAuthenticated' |       md-tab(md-label='Docs' | ||||||
|               :to="{ name: 'AnsweredRequests' }" |              href='https://docs.prayerjournal.me' | ||||||
|               role='menuitem') Answered |              @click.prevent='showHelp()') | ||||||
|   a(v-if='isAuthenticated' |     template(v-else) | ||||||
|     href='#' |       md-tab(md-label='Log On' | ||||||
|     role='menuitem' |              href='/user/log-on' | ||||||
|     @click.stop='logOff()') Log Off |              @click.prevent='logOn()') | ||||||
|   a(v-if='!isAuthenticated' |       md-tab(md-label='Docs' | ||||||
|     href='#' |              href='https://docs.prayerjournal.me' | ||||||
|     role='menuitem' |              @click.prevent='showHelp()') | ||||||
|     @click.stop='logOn()') Log On |  | ||||||
|   a(href='https://docs.prayerjournal.me' |  | ||||||
|     target='_blank' |  | ||||||
|     role='menuitem' |  | ||||||
|     @click.stop='') Docs |  | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| <script> | <script> | ||||||
| @ -36,14 +31,10 @@ nav.mpj-top-nav.mpj-bg(role='menubar') | |||||||
| 
 | 
 | ||||||
| import { mapState } from 'vuex' | import { mapState } from 'vuex' | ||||||
| 
 | 
 | ||||||
| import AuthService from '@/auth/AuthService' |  | ||||||
| 
 |  | ||||||
| export default { | export default { | ||||||
|   name: 'navigation', |   name: 'navigation', | ||||||
|   data () { |   data () { | ||||||
|     return { |     return {} | ||||||
|       auth0: new AuthService() |  | ||||||
|     } |  | ||||||
|   }, |   }, | ||||||
|   computed: { |   computed: { | ||||||
|     hasSnoozed () { |     hasSnoozed () { | ||||||
| @ -51,46 +42,18 @@ export default { | |||||||
|         Array.isArray(this.journal) && |         Array.isArray(this.journal) && | ||||||
|         this.journal.filter(req => req.snoozedUntil > Date.now()).length > 0 |         this.journal.filter(req => req.snoozedUntil > Date.now()).length > 0 | ||||||
|     }, |     }, | ||||||
|     ...mapState([ 'journal', 'isAuthenticated' ]) |     ...mapState([ 'isAuthenticated', 'journal' ]) | ||||||
|   }, |   }, | ||||||
|   methods: { |   methods: { | ||||||
|     logOn () { |     logOn () { | ||||||
|       this.auth0.login() |       this.$auth.login() | ||||||
|     }, |     }, | ||||||
|     logOff () { |     logOff () { | ||||||
|       this.auth0.logout(this.$store, this.$router) |       this.$auth.logout(this.$store, this.$router) | ||||||
|  |     }, | ||||||
|  |     showHelp () { | ||||||
|  |       window.open('https://docs.prayerjournal.me', '_blank') | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
| </script> | </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"> | <template lang="pug"> | ||||||
| h2.mpj-page-title(v-if='!hideOnPage' | h1(v-if='!hideOnPage' | ||||||
|                   v-html='title') |    v-html='title').md-title | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| <script> | <script> | ||||||
| @ -26,10 +26,3 @@ export default { | |||||||
|   } |   } | ||||||
| } | } | ||||||
| </script> | </script> | ||||||
| 
 |  | ||||||
| <style scoped> |  | ||||||
| .mpj-page-title { |  | ||||||
|   border-bottom: solid 1px lightgray; |  | ||||||
|   margin-bottom: 20px; |  | ||||||
| } |  | ||||||
| </style> |  | ||||||
|  | |||||||
| @ -1,16 +1,21 @@ | |||||||
| <template lang="pug"> | <template lang="pug"> | ||||||
| article | md-content(role='main').mpj-main-content | ||||||
|   page-title(title='Privacy Policy') |   page-title(title='Privacy Policy' | ||||||
|   p: small: em (as of May 21, 2018) |              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. |       p. | ||||||
|         The nature of the service is one where privacy is a must. The items below will help you understand the data we |         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. |         collect, access, and store on your behalf as you use this service. | ||||||
|       hr |       hr | ||||||
|       h3 Third Party Services |       h3 Third Party Services | ||||||
|       p. |       p. | ||||||
|     myPrayerJournal utilizes a third-party authentication and identity provider. You should familiarize yourself with |         myPrayerJournal utilizes a third-party authentication and identity provider. You should familiarize yourself | ||||||
|     the privacy policy for #[a(href='https://auth0.com/privacy' target='_blank') Auth0], as well as your chosen provider |         with the privacy policy for #[a(href='https://auth0.com/privacy' target='_blank') Auth0], as well as your | ||||||
|     (#[a(href='https://privacy.microsoft.com/en-us/privacystatement' target='_blank') Microsoft] or |         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]). |         #[a(href='https://policies.google.com/privacy' target='_blank') Google]). | ||||||
|       hr |       hr | ||||||
|       h3 What We Collect |       h3 What We Collect | ||||||
| @ -18,12 +23,12 @@ article | |||||||
|       ul |       ul | ||||||
|         li. |         li. | ||||||
|           The only identifying data myPrayerJournal stores is the subscriber (“sub”) field from the token we |           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 |           receive from Auth0, once you have signed in through their hosted service. All information is associated with | ||||||
|       via this field. |           you via this field. | ||||||
|         li. |         li. | ||||||
|       While you are signed in, within your browser, the service has access to your first and last names, along with a |           While you are signed in, within your browser, the service has access to your first and last names, along with | ||||||
|       URL to the profile picture (provided by your selected identity provider). This information is not transmitted to |           a URL to the profile picture (provided by your selected identity provider). This information is not | ||||||
|       the server, and is removed when “Log Off” is clicked. |           transmitted to the server, and is removed when “Log Off” is clicked. | ||||||
|       h4 User Provided Data |       h4 User Provided Data | ||||||
|       ul |       ul | ||||||
|         li. |         li. | ||||||
| @ -33,16 +38,16 @@ article | |||||||
|       h3 How Your Data Is Accessed / Secured |       h3 How Your Data Is Accessed / Secured | ||||||
|       ul |       ul | ||||||
|         li. |         li. | ||||||
|       Your provided data is returned to you, as required, to display your journal or your answered requests. |           Your provided data is returned to you, as required, to display your journal or your answered requests. On the | ||||||
|       On the server, it is stored in a controlled-access database. |           server, it is stored in a controlled-access database. | ||||||
|         li. |         li. | ||||||
|           Your data is backed up, along with other Bit Badger Solutions hosted systems, in a rolling manner; backups are |           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 |           preserved for the prior 7 days, and backups from the 1st and 15th are preserved for 3 months. These backups | ||||||
|       stored in a private cloud data repository. |           are stored in a private cloud data repository. | ||||||
|         li. |         li. | ||||||
|       The data collected and stored is the absolute minimum necessary for the functionality of the service. There are |           The data collected and stored is the absolute minimum necessary for the functionality of the service. There | ||||||
|       no plans to “monetize” this service, and storing the minimum amount of information means that the |           are no plans to “monetize” this service, and storing the minimum amount of information means that | ||||||
|       data we have is not interesting to purchasers (or those who may have more nefarious purposes). |           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. |         li Access to servers and backups is strictly controlled and monitored for unauthorized access attempts. | ||||||
|       hr |       hr | ||||||
|       h3 Removing Your Data |       h3 Removing Your Data | ||||||
|  | |||||||
| @ -1,7 +1,12 @@ | |||||||
| <template lang="pug"> | <template lang="pug"> | ||||||
| article | md-content(role='main').mpj-main-content | ||||||
|   page-title(title='Terms of Service') |   page-title(title='Terms of Service' | ||||||
|   p: small: em (as of May 21, 2018) |              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 |       h3 1. Acceptance of Terms | ||||||
|       p. |       p. | ||||||
|         By accessing this web site, you are agreeing to be bound by these Terms and Conditions, and that you are |         By accessing this web site, you are agreeing to be bound by these Terms and Conditions, and that you are | ||||||
| @ -10,9 +15,9 @@ article | |||||||
|       h3 2. Description of Service and Registration |       h3 2. Description of Service and Registration | ||||||
|       p. |       p. | ||||||
|         myPrayerJournal is a service that allows individuals to enter and amend their prayer requests. It requires no |         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 |         registration by itself, but access is granted based on a successful login with an external identity provider. | ||||||
|     #[router-link(:to="{ name: 'PrivacyPolicy' }") our privacy policy] for details on how that information is accessed |         See #[router-link(:to="{ name: 'PrivacyPolicy' }") our privacy policy] for details on how that information is | ||||||
|     and stored. |         accessed and stored. | ||||||
|       h3 3. Third Party Services |       h3 3. Third Party Services | ||||||
|       p. |       p. | ||||||
|         This service utilizes a third-party service provider for identity management. Review the terms of service for |         This service utilizes a third-party service provider for identity management. Review the terms of service for | ||||||
| @ -21,8 +26,8 @@ article | |||||||
|         #[a(href='https://policies.google.com/terms' target='_blank') Google]). |         #[a(href='https://policies.google.com/terms' target='_blank') Google]). | ||||||
|       h3 4. Liability |       h3 4. Liability | ||||||
|       p. |       p. | ||||||
|     This service is provided "as is", and no warranty (express or implied) exists. The service and its developers may |         This service is provided "as is", and no warranty (express or implied) exists. The service and its developers | ||||||
|     not be held liable for any damages that may arise through the use of this service. |         may not be held liable for any damages that may arise through the use of this service. | ||||||
|       h3 5. Updates to Terms |       h3 5. Updates to Terms | ||||||
|       p. |       p. | ||||||
|         These terms and conditions may be updated at any time, and this service does not have the capability to notify |         These terms and conditions may be updated at any time, and this service does not have the capability to notify | ||||||
|  | |||||||
| @ -1,13 +1,16 @@ | |||||||
| <template lang="pug"> | <template lang="pug"> | ||||||
| article.mpj-main-content(role='main') | md-content(role='main').mpj-main-content | ||||||
|   page-title(title='Active Requests') |   page-title(title='Active Requests' | ||||||
|   div(v-if='loaded').mpj-request-list |              hide-on-page=true) | ||||||
|     p.mpj-text-center(v-if='requests.length === 0'): em. |   template(v-if='loaded') | ||||||
|       No active requests found; return to #[router-link(:to='{ name: "Journal" } ') your journal] |     md-empty-state(v-if='requests.length === 0' | ||||||
|     request-list-item(v-for='req in requests' |                    md-icon='sentiment_dissatisfied' | ||||||
|                       :key='req.requestId' |                    md-label='No Active Requests' | ||||||
|                       :request='req' |                    md-description='Your prayer journal has no active requests') | ||||||
|                       :toast='toast') |       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... |   p(v-else) Loading journal... | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| @ -16,14 +19,15 @@ article.mpj-main-content(role='main') | |||||||
| 
 | 
 | ||||||
| import { mapState } from 'vuex' | import { mapState } from 'vuex' | ||||||
| 
 | 
 | ||||||
| import RequestListItem from '@/components/request/RequestListItem' | import RequestList from '@/components/request/RequestList' | ||||||
| 
 | 
 | ||||||
| import actions from '@/store/action-types' | import actions from '@/store/action-types' | ||||||
| 
 | 
 | ||||||
| export default { | export default { | ||||||
|   name: 'active-requests', |   name: 'active-requests', | ||||||
|  |   inject: ['progress'], | ||||||
|   components: { |   components: { | ||||||
|     RequestListItem |     RequestList | ||||||
|   }, |   }, | ||||||
|   data () { |   data () { | ||||||
|     return { |     return { | ||||||
| @ -32,9 +36,6 @@ export default { | |||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   computed: { |   computed: { | ||||||
|     toast () { |  | ||||||
|       return this.$parent.$refs.toast |  | ||||||
|     }, |  | ||||||
|     ...mapState(['journal', 'isLoadingJournal']) |     ...mapState(['journal', 'isLoadingJournal']) | ||||||
|   }, |   }, | ||||||
|   created () { |   created () { | ||||||
| @ -45,7 +46,7 @@ export default { | |||||||
|     async ensureJournal () { |     async ensureJournal () { | ||||||
|       if (!Array.isArray(this.journal)) { |       if (!Array.isArray(this.journal)) { | ||||||
|         this.loaded = false |         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 |       this.requests = this.journal | ||||||
|         .sort((a, b) => a.showAfter - b.showAfter) |         .sort((a, b) => a.showAfter - b.showAfter) | ||||||
|  | |||||||
| @ -1,13 +1,15 @@ | |||||||
| <template lang="pug"> | <template lang="pug"> | ||||||
| article.mpj-main-content(role='main') | md-content(role='main').mpj-main-content | ||||||
|   page-title(title='Answered Requests') |   page-title(title='Answered Requests' | ||||||
|   div(v-if='loaded').mpj-request-list |              hide-on-page=true) | ||||||
|     p.text-center(v-if='requests.length === 0'): em. |   template(v-if='loaded') | ||||||
|       No answered requests found; once you have marked one as “Answered”, it will appear here |     md-empty-state(v-if='requests.length === 0' | ||||||
|     request-list-item(v-for='req in requests' |                    md-icon='sentiment_dissatisfied' | ||||||
|                       :key='req.requestId' |                    md-label='No Answered Requests' | ||||||
|                       :request='req' |                    md-description='Your prayer journal has no answered requests; once you have marked one as “Answered”, it will appear here') | ||||||
|                       :toast='toast') |     request-list(v-if='requests.length !== 0' | ||||||
|  |                  title='Answered Requests' | ||||||
|  |                  :requests='requests') | ||||||
|   p(v-else) Loading answered requests... |   p(v-else) Loading answered requests... | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| @ -16,12 +18,16 @@ article.mpj-main-content(role='main') | |||||||
| 
 | 
 | ||||||
| import api from '@/api' | import api from '@/api' | ||||||
| 
 | 
 | ||||||
| import RequestListItem from '@/components/request/RequestListItem' | import RequestList from '@/components/request/RequestList' | ||||||
| 
 | 
 | ||||||
| export default { | export default { | ||||||
|   name: 'answered-requests', |   name: 'answered-requests', | ||||||
|  |   inject: [ | ||||||
|  |     'messages', | ||||||
|  |     'progress' | ||||||
|  |   ], | ||||||
|   components: { |   components: { | ||||||
|     RequestListItem |     RequestList | ||||||
|   }, |   }, | ||||||
|   data () { |   data () { | ||||||
|     return { |     return { | ||||||
| @ -29,21 +35,16 @@ export default { | |||||||
|       loaded: false |       loaded: false | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   computed: { |  | ||||||
|     toast () { |  | ||||||
|       return this.$parent.$refs.toast |  | ||||||
|     } |  | ||||||
|   }, |  | ||||||
|   async mounted () { |   async mounted () { | ||||||
|     this.$Progress.start() |     this.progress.$emit('show', 'query') | ||||||
|     try { |     try { | ||||||
|       const reqs = await api.getAnsweredRequests() |       const reqs = await api.getAnsweredRequests() | ||||||
|       this.requests = reqs.data |       this.requests = reqs.data | ||||||
|       this.$Progress.finish() |       this.progress.$emit('done') | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       console.error(err) |       console.error(err) | ||||||
|       this.toast.showToast('Error loading requests; check console for details', { theme: 'danger' }) |       this.messages.$emit('error', 'Error loading requests; check console for details') | ||||||
|       this.$Progress.fail() |       this.progress.$emit('done') | ||||||
|     } finally { |     } finally { | ||||||
|       this.loaded = true |       this.loaded = true | ||||||
|     } |     } | ||||||
|  | |||||||
| @ -1,71 +1,52 @@ | |||||||
| <template lang="pug"> | <template lang="pug"> | ||||||
| article.mpj-main-content(role='main') | md-content(role='main').mpj-narrow | ||||||
|   page-title(:title='title') |   page-title(:title='title') | ||||||
|   .mpj-narrow |   md-field | ||||||
|     label(for='request_text') |     label(for='request_text') Prayer Request | ||||||
|       | Prayer Request |     md-textarea(v-model='form.requestText' | ||||||
|       br |  | ||||||
|       textarea(v-model='form.requestText' |  | ||||||
|                :rows='10' |  | ||||||
|                 @blur='trimText()' |                 @blur='trimText()' | ||||||
|  |                 md-autogrow | ||||||
|                 autofocus).mpj-full-width |                 autofocus).mpj-full-width | ||||||
|   br |   br | ||||||
|   template(v-if='!isNew') |   template(v-if='!isNew') | ||||||
|     label Also Mark As |     label Also Mark As | ||||||
|     br |     br | ||||||
|       label.normal |     md-radio(v-model='form.status' | ||||||
|         input(v-model='form.status' |              value='Updated') Updated | ||||||
|               type='radio' |     md-radio(v-model='form.status' | ||||||
|               name='status' |              value='Prayed') Prayed | ||||||
|               value='Updated') |     md-radio(v-model='form.status' | ||||||
|         | Updated |              value='Answered') Answered | ||||||
|       |     |  | ||||||
|       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 |     br | ||||||
|   label Recurrence |   label Recurrence | ||||||
|   |     |   |     | ||||||
|   em.mpj-muted-text After prayer, request reappears... |   em.mpj-muted-text After prayer, request reappears... | ||||||
|   br |   br | ||||||
|     label.normal |   .md-layout | ||||||
|       input(v-model='form.recur.typ' |     .md-layout-item.md-size-30 | ||||||
|             type='radio' |       md-radio(v-model='form.recur.typ' | ||||||
|             name='recur' |               value='Immediate') Immediately | ||||||
|             value='immediate') |     .md-layout-item.md-size-20 | ||||||
|       | Immediately |       md-radio(v-model='form.recur.typ' | ||||||
|     |     |               value='other') Every... | ||||||
|     label.normal |     .md-layout-item.md-size-10 | ||||||
|       input(v-model='form.recur.typ' |       md-field(md-inline) | ||||||
|             type='radio' |         label Count | ||||||
|             name='recur' |         md-input(v-model='form.recur.count' | ||||||
|             value='other') |  | ||||||
|       | Every... |  | ||||||
|     input(v-model='form.recur.count' |  | ||||||
|                 type='number' |                 type='number' | ||||||
|           :disabled='!showRecurrence').mpj-recur-count |                 :disabled='!showRecurrence') | ||||||
|     select(v-model='form.recur.other' |     .md-layout-item.md-size-20 | ||||||
|            :disabled='!showRecurrence').mpj-recur-type |       md-field | ||||||
|       option(value='hours') hours |         label Interval | ||||||
|       option(value='days') days |         md-select(v-model='form.recur.other' | ||||||
|       option(value='weeks') weeks |                   :disabled='!showRecurrence') | ||||||
|  |           md-option(value='Hours') hours | ||||||
|  |           md-option(value='Days') days | ||||||
|  |           md-option(value='Weeks') weeks | ||||||
|   .mpj-text-right |   .mpj-text-right | ||||||
|       button(:disabled='!isValidRecurrence' |     md-button(:disabled='!isValidRecurrence' | ||||||
|              @click.stop='saveRequest()').primary. |               @click.stop='saveRequest()').md-primary.md-raised #[md-icon save] Save | ||||||
|         #[md-icon(icon='save')] Save |     md-button(@click.stop='goBack()').md-raised #[md-icon arrow_back] Cancel | ||||||
|       |     |  | ||||||
|       button(@click.stop='goBack()'). |  | ||||||
|         #[md-icon(icon='arrow_back')] Cancel |  | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| <script> | <script> | ||||||
| @ -77,6 +58,10 @@ import actions from '@/store/action-types' | |||||||
| 
 | 
 | ||||||
| export default { | export default { | ||||||
|   name: 'edit-request', |   name: 'edit-request', | ||||||
|  |   inject: [ | ||||||
|  |     'messages', | ||||||
|  |     'progress' | ||||||
|  |   ], | ||||||
|   props: { |   props: { | ||||||
|     id: { |     id: { | ||||||
|       type: String, |       type: String, | ||||||
| @ -92,7 +77,7 @@ export default { | |||||||
|         requestText: '', |         requestText: '', | ||||||
|         status: 'Updated', |         status: 'Updated', | ||||||
|         recur: { |         recur: { | ||||||
|           typ: 'immediate', |           typ: 'Immediate', | ||||||
|           other: '', |           other: '', | ||||||
|           count: '' |           count: '' | ||||||
|         } |         } | ||||||
| @ -101,19 +86,16 @@ export default { | |||||||
|   }, |   }, | ||||||
|   computed: { |   computed: { | ||||||
|     isValidRecurrence () { |     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) |       const count = Number.parseInt(this.form.recur.count) | ||||||
|       if (isNaN(count) || this.form.recur.other === '') return false |       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 === 'Hours' && count > (365 * 24)) return false | ||||||
|       if (this.form.recur.other === 'days' && count > 365) 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 === 'Weeks' && count > 52) return false | ||||||
|       return true |       return true | ||||||
|     }, |     }, | ||||||
|     showRecurrence () { |     showRecurrence () { | ||||||
|       return this.form.recur.typ !== 'immediate' |       return this.form.recur.typ !== 'Immediate' | ||||||
|     }, |  | ||||||
|     toast () { |  | ||||||
|       return this.$parent.$refs.toast |  | ||||||
|     }, |     }, | ||||||
|     ...mapState(['journal']) |     ...mapState(['journal']) | ||||||
|   }, |   }, | ||||||
| @ -125,21 +107,21 @@ export default { | |||||||
|       this.form.requestId = '' |       this.form.requestId = '' | ||||||
|       this.form.requestText = '' |       this.form.requestText = '' | ||||||
|       this.form.status = 'Created' |       this.form.status = 'Created' | ||||||
|       this.form.recur.typ = 'immediate' |       this.form.recur.typ = 'Immediate' | ||||||
|       this.form.recur.other = '' |       this.form.recur.other = '' | ||||||
|       this.form.recur.count = '' |       this.form.recur.count = '' | ||||||
|     } else { |     } else { | ||||||
|       this.title = 'Edit Prayer Request' |       this.title = 'Edit Prayer Request' | ||||||
|       this.isNew = false |       this.isNew = false | ||||||
|       if (this.journal.length === 0) { |       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] |       const req = this.journal.filter(r => r.requestId === this.id)[0] | ||||||
|       this.form.requestId = this.id |       this.form.requestId = this.id | ||||||
|       this.form.requestText = req.text |       this.form.requestText = req.text | ||||||
|       this.form.status = 'Updated' |       this.form.status = 'Updated' | ||||||
|       if (req.recurType === 'immediate') { |       if (req.recurType === 'Immediate') { | ||||||
|         this.form.recur.typ = 'immediate' |         this.form.recur.typ = 'Immediate' | ||||||
|         this.form.recur.other = '' |         this.form.recur.other = '' | ||||||
|         this.form.recur.count = '' |         this.form.recur.count = '' | ||||||
|       } else { |       } else { | ||||||
| @ -158,31 +140,31 @@ export default { | |||||||
|     }, |     }, | ||||||
|     async ensureJournal () { |     async ensureJournal () { | ||||||
|       if (!Array.isArray(this.journal)) { |       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 () { |     async saveRequest () { | ||||||
|       if (this.isNew) { |       if (this.isNew) { | ||||||
|         await this.$store.dispatch(actions.ADD_REQUEST, { |         await this.$store.dispatch(actions.ADD_REQUEST, { | ||||||
|           progress: this.$Progress, |           progress: this.progress, | ||||||
|           requestText: this.form.requestText, |           requestText: this.form.requestText, | ||||||
|           recurType: this.form.recur.typ === 'immediate' ? 'immediate' : this.form.recur.other, |           recurType: this.form.recur.typ === 'Immediate' ? 'Immediate' : this.form.recur.other, | ||||||
|           recurCount: this.form.recur.typ === 'immediate' ? 0 : Number.parseInt(this.form.recur.count) |           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 { |       } else { | ||||||
|         await this.$store.dispatch(actions.UPDATE_REQUEST, { |         await this.$store.dispatch(actions.UPDATE_REQUEST, { | ||||||
|           progress: this.$Progress, |           progress: this.progress, | ||||||
|           requestId: this.form.requestId, |           requestId: this.form.requestId, | ||||||
|           updateText: this.form.requestText, |           updateText: this.form.requestText, | ||||||
|           status: this.form.status, |           status: this.form.status, | ||||||
|           recurType: this.form.recur.typ === 'immediate' ? 'immediate' : this.form.recur.other, |           recurType: this.form.recur.typ === 'Immediate' ? 'Immediate' : this.form.recur.other, | ||||||
|           recurCount: this.form.recur.typ === 'immediate' ? 0 : Number.parseInt(this.form.recur.count) |           recurCount: this.form.recur.typ === 'Immediate' ? 0 : Number.parseInt(this.form.recur.count) | ||||||
|         }) |         }) | ||||||
|         if (this.form.status === 'Answered') { |         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 { |         } else { | ||||||
|           this.toast.showToast('Request updated', { theme: 'success' }) |           this.messages.$emit('info', 'Request updated') | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|       this.goBack() |       this.goBack() | ||||||
| @ -190,15 +172,3 @@ export default { | |||||||
|   } |   } | ||||||
| } | } | ||||||
| </script> | </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"> | <template lang="pug"> | ||||||
| article.mpj-main-content(role='main') | md-content(role='main').mpj-main-content | ||||||
|   page-title(title='Full Prayer Request') |   page-title(title='Full Prayer Request' | ||||||
|   template(v-if='request') |              hide-on-page=true) | ||||||
|     p |   md-card(v-if='request') | ||||||
|       span(v-if='isAnswered') Answered {{ formatDate(answered) }} (#[date-from-now(:value='answered')])   |     md-card-header | ||||||
|       small: em.mpj-muted-text prayed {{ prayedCount }} times, open {{ openDays }} days |       .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 }} |       p.mpj-request-text {{ lastText }} | ||||||
|     br |       md-table | ||||||
|     table.mpj-request-log |         md-table-row | ||||||
|       thead |           md-table-head Action | ||||||
|         tr |           md-table-head Update / Notes | ||||||
|           th Action |         md-table-row(v-for='item in log' | ||||||
|           th Update / Notes |                      :key='item.asOf') | ||||||
|       tbody |           md-table-cell.mpj-valign-top {{ item.status }} on #[span.mpj-text-nowrap {{ formatDate(item.asOf) }}] | ||||||
|         tr(v-for='item in log' :key='item.asOf') |           md-table-cell(v-if='item.text').mpj-request-text.mpj-valign-top {{ item.text }} | ||||||
|           td {{ item.status }} on #[span.mpj-text-nowrap {{ formatDate(item.asOf) }}] |           md-table-cell(v-else)   | ||||||
|           td(v-if='item.text').mpj-request-text {{ item.text }} |  | ||||||
|           td(v-else)   |  | ||||||
|   p(v-else) Loading request... |   p(v-else) Loading request... | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| @ -31,6 +33,7 @@ const asOfDesc = (a, b) => b.asOf - a.asOf | |||||||
| 
 | 
 | ||||||
| export default { | export default { | ||||||
|   name: 'full-request', |   name: 'full-request', | ||||||
|  |   inject: ['progress'], | ||||||
|   props: { |   props: { | ||||||
|     id: { |     id: { | ||||||
|       type: String, |       type: String, | ||||||
| @ -72,14 +75,14 @@ export default { | |||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   async mounted () { |   async mounted () { | ||||||
|     this.$Progress.start() |     this.progress.$emit('show', 'indeterminate') | ||||||
|     try { |     try { | ||||||
|       const req = await api.getFullRequest(this.id) |       const req = await api.getFullRequest(this.id) | ||||||
|       this.request = req.data |       this.request = req.data | ||||||
|       this.$Progress.finish() |       this.progress.$emit('done') | ||||||
|     } catch (e) { |     } catch (e) { | ||||||
|       console.log(e) |       console.log(e) | ||||||
|       this.$Progress.fail() |       this.progress.$emit('done') | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   methods: { |   methods: { | ||||||
|  | |||||||
| @ -1,21 +1,16 @@ | |||||||
| <template lang="pug"> | <template lang="pug"> | ||||||
| .mpj-modal(v-show='notesVisible') | md-dialog(:md-active.sync='notesVisible').mpj-note-dialog | ||||||
|   .mpj-modal-content.mpj-narrow |   md-dialog-title Add Notes to Prayer Request | ||||||
|     header.mpj-bg |   md-content.mpj-dialog-content | ||||||
|       h5 Add Notes to Prayer Request |     md-field | ||||||
|     label |       label Notes | ||||||
|       | Notes |       md-textarea(v-model='form.notes' | ||||||
|       br |                   md-autogrow | ||||||
|       textarea(v-model='form.notes' |                   @blur='trimText()') | ||||||
|                :rows='10' |   md-dialog-actions | ||||||
|                @blur='trimText()').mpj-full-width |     md-button(@click='saveNotes()').md-primary #[md-icon save] Save | ||||||
|     .mpj-text-right |     md-button(@click='closeDialog()') #[md-icon undo] Cancel | ||||||
|       button(@click='saveNotes()').primary. |   .mpj-dialog-content | ||||||
|         #[md-icon(icon='save')] Save |  | ||||||
|       |     |  | ||||||
|       button(@click='closeDialog()'). |  | ||||||
|         #[md-icon(icon='undo')] Cancel |  | ||||||
|     hr |  | ||||||
|     div(v-if='hasPriorNotes') |     div(v-if='hasPriorNotes') | ||||||
|       p.mpj-text-center: strong Prior Notes for This Request |       p.mpj-text-center: strong Prior Notes for This Request | ||||||
|       .mpj-note-list |       .mpj-note-list | ||||||
| @ -26,8 +21,8 @@ | |||||||
|           span.mpj-request-text {{ note.notes }} |           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-if='noPriorNotes').mpj-text-center.mpj-muted-text There are no prior notes for this request | ||||||
|     div(v-else).mpj-text-center |     div(v-else).mpj-text-center | ||||||
|       button(@click='loadNotes()'). |       hr | ||||||
|         #[md-icon(icon='cloud_download')] Load Prior Notes |       md-button(@click='loadNotes()') #[md-icon cloud_download] Load Prior Notes | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| <script> | <script> | ||||||
| @ -37,10 +32,11 @@ import api from '@/api' | |||||||
| 
 | 
 | ||||||
| export default { | export default { | ||||||
|   name: 'notes-edit', |   name: 'notes-edit', | ||||||
|   props: { |   inject: [ | ||||||
|     toast: { required: true }, |     'journalEvents', | ||||||
|     events: { required: true } |     'messages', | ||||||
|   }, |     'progress' | ||||||
|  |   ], | ||||||
|   data () { |   data () { | ||||||
|     return { |     return { | ||||||
|       notesVisible: false, |       notesVisible: false, | ||||||
| @ -61,7 +57,7 @@ export default { | |||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   created () { |   created () { | ||||||
|     this.events.$on('notes', this.openDialog) |     this.journalEvents.$on('notes', this.openDialog) | ||||||
|   }, |   }, | ||||||
|   methods: { |   methods: { | ||||||
|     closeDialog () { |     closeDialog () { | ||||||
| @ -72,14 +68,14 @@ export default { | |||||||
|       this.notesVisible = false |       this.notesVisible = false | ||||||
|     }, |     }, | ||||||
|     async loadNotes () { |     async loadNotes () { | ||||||
|       this.$Progress.start() |       this.progress.$emit('show', 'indeterminate') | ||||||
|       try { |       try { | ||||||
|         const notes = await api.getNotes(this.form.requestId) |         const notes = await api.getNotes(this.form.requestId) | ||||||
|         this.priorNotes = notes.data |         this.priorNotes = notes.data | ||||||
|         this.$Progress.finish() |         this.progress.$emit('done') | ||||||
|       } catch (e) { |       } catch (e) { | ||||||
|         console.error(e) |         console.error(e) | ||||||
|         this.$Progress.fail() |         this.progress.$emit('done') | ||||||
|       } finally { |       } finally { | ||||||
|         this.priorNotesLoaded = true |         this.priorNotesLoaded = true | ||||||
|       } |       } | ||||||
| @ -89,15 +85,15 @@ export default { | |||||||
|       this.notesVisible = true |       this.notesVisible = true | ||||||
|     }, |     }, | ||||||
|     async saveNotes () { |     async saveNotes () { | ||||||
|       this.$Progress.start() |       this.progress.$emit('show', 'indeterminate') | ||||||
|       try { |       try { | ||||||
|         await api.addNote(this.form.requestId, this.form.notes) |         await api.addNote(this.form.requestId, this.form.notes) | ||||||
|         this.$Progress.finish() |         this.progress.$emit('done') | ||||||
|         this.toast.showToast('Added notes', { theme: 'success' }) |         this.messages.$emit('info', 'Added notes') | ||||||
|         this.closeDialog() |         this.closeDialog() | ||||||
|       } catch (e) { |       } catch (e) { | ||||||
|         console.error(e) |         console.error(e) | ||||||
|         this.$Progress.fail() |         this.progress.$emit('done') | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|     trimText () { |     trimText () { | ||||||
| @ -107,8 +103,16 @@ export default { | |||||||
| } | } | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <style> | <style lang="sass"> | ||||||
| .mpj-note-list p { | .mpj-note-dialog | ||||||
|   border-top: dotted 1px lightgray; |   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> | </style> | ||||||
|  | |||||||
| @ -1,17 +1,27 @@ | |||||||
| <template lang="pug"> | <template lang="pug"> | ||||||
| .mpj-request-card(v-if='shouldDisplay') | md-card(v-if='shouldDisplay' | ||||||
|   header.mpj-card-header(role='toolbar'). |         md-with-hover).mpj-request-card | ||||||
|     #[button(@click='markPrayed()' title='Pray').primary: md-icon(icon='done')] |   md-card-actions(md-alignment='space-between') | ||||||
|     #[button(@click.stop='showEdit()' title='Edit'): md-icon(icon='edit')] |     md-button(@click='markPrayed()').md-icon-button.md-raised.md-primary | ||||||
|     #[button(@click.stop='showNotes()' title='Add Notes'): md-icon(icon='comment')] |       md-icon done | ||||||
|     #[button(@click.stop='snooze()' title='Snooze Request'): md-icon(icon='schedule')] |       md-tooltip(md-direction='top' | ||||||
|   div |                  md-delay=1000) Mark as Prayed | ||||||
|     p.card-text.mpj-request-text |     span | ||||||
|       | {{ request.text }} |       md-button(@click.stop='showEdit()').md-icon-button.md-raised | ||||||
|     p.as-of.mpj-text-right: small.mpj-muted-text: em |         md-icon edit | ||||||
|       = '(last activity ' |         md-tooltip(md-direction='top' | ||||||
|       date-from-now(:value='request.asOf') |                    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> | </template> | ||||||
| 
 | 
 | ||||||
| <script> | <script> | ||||||
| @ -21,10 +31,13 @@ import actions from '@/store/action-types' | |||||||
| 
 | 
 | ||||||
| export default { | export default { | ||||||
|   name: 'request-card', |   name: 'request-card', | ||||||
|  |   inject: [ | ||||||
|  |     'journalEvents', | ||||||
|  |     'messages', | ||||||
|  |     'progress' | ||||||
|  |   ], | ||||||
|   props: { |   props: { | ||||||
|     request: { required: true }, |     request: { required: true } | ||||||
|     toast: { required: true }, |  | ||||||
|     events: { required: true } |  | ||||||
|   }, |   }, | ||||||
|   computed: { |   computed: { | ||||||
|     shouldDisplay () { |     shouldDisplay () { | ||||||
| @ -35,59 +48,31 @@ export default { | |||||||
|   methods: { |   methods: { | ||||||
|     async markPrayed () { |     async markPrayed () { | ||||||
|       await this.$store.dispatch(actions.UPDATE_REQUEST, { |       await this.$store.dispatch(actions.UPDATE_REQUEST, { | ||||||
|         progress: this.$Progress, |         progress: this.progress, | ||||||
|         requestId: this.request.requestId, |         requestId: this.request.requestId, | ||||||
|         status: 'Prayed', |         status: 'Prayed', | ||||||
|         updateText: '' |         updateText: '' | ||||||
|       }) |       }) | ||||||
|       this.toast.showToast('Request marked as prayed', { theme: 'success' }) |       this.messages.$emit('info', 'Request marked as prayed') | ||||||
|     }, |     }, | ||||||
|     showEdit () { |     showEdit () { | ||||||
|       this.$router.push({ name: 'EditRequest', params: { id: this.request.requestId } }) |       this.$router.push({ name: 'EditRequest', params: { id: this.request.requestId } }) | ||||||
|     }, |     }, | ||||||
|     showNotes () { |     showNotes () { | ||||||
|       this.events.$emit('notes', this.request) |       this.journalEvents.$emit('notes', this.request) | ||||||
|     }, |     }, | ||||||
|     snooze () { |     snooze () { | ||||||
|       this.events.$emit('snooze', this.request.requestId) |       this.journalEvents.$emit('snooze', this.request.requestId) | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <style> | <style lang="sass"> | ||||||
| .mpj-request-card { | .mpj-request-card | ||||||
|   border: solid 1px darkgray; |   width: 20rem | ||||||
|   border-radius: 5px; |   margin-bottom: 1rem | ||||||
|   width: 20rem; | @media screen and (max-width: 20rem) | ||||||
|   margin: .5rem; |   .mpj-request-card | ||||||
| } |     width: 100% | ||||||
| @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> | </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"> | <template lang="pug"> | ||||||
| p.mpj-request-text | md-table-row | ||||||
|   | {{ request.text }} |   md-table-cell.mpj-action-cell.mpj-valign-top | ||||||
|   br |     md-button(@click='viewFull').md-icon-button.md-raised | ||||||
|   br |       md-icon description | ||||||
|   button(@click='viewFull' |       md-tooltip(md-direction='top' | ||||||
|          title='View Full Request'). |                  md-delay=250) View Full Request | ||||||
|     #[md-icon(icon='description')] View Full Request |  | ||||||
|   |     |  | ||||||
|     template(v-if='!isAnswered') |     template(v-if='!isAnswered') | ||||||
|     button(@click='editRequest' |       md-button(@click='editRequest').md-icon-button.md-raised | ||||||
|            title='Edit Request'). |         md-icon edit | ||||||
|       #[md-icon(icon='edit')] Edit Request |         md-tooltip(md-direction='top' | ||||||
|     |     |                    md-delay=250) Edit Request | ||||||
|     template(v-if='isSnoozed') |     template(v-if='isSnoozed') | ||||||
|     button(@click='cancelSnooze()'). |       md-button(@click='cancelSnooze()').md-icon-button.md-raised | ||||||
|       #[md-icon(icon='restore')] Cancel Snooze |         md-icon restore | ||||||
|     |     |         md-tooltip(md-direction='top' | ||||||
|  |                    md-delay=250) Cancel Snooze | ||||||
|     template(v-if='isPending') |     template(v-if='isPending') | ||||||
|     button(@click='showNow()'). |       md-button(@click='showNow()').md-icon-button.md-raised | ||||||
|       #[md-icon(icon='restore')] Show Now |         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') |     br(v-if='isSnoozed || isPending || isAnswered') | ||||||
|   small(v-if='isSnoozed').mpj-muted-text: em. |     small(v-if='isSnoozed').mpj-muted-text: em Snooze expires #[date-from-now(:value='request.snoozedUntil')] | ||||||
|       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='isPending').mpj-muted-text: em. |     small(v-if='isAnswered').mpj-muted-text: em Answered #[date-from-now(:value='request.asOf')] | ||||||
|       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')] |  | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| <script> | <script> | ||||||
| @ -35,9 +35,12 @@ import actions from '@/store/action-types' | |||||||
| 
 | 
 | ||||||
| export default { | export default { | ||||||
|   name: 'request-list-item', |   name: 'request-list-item', | ||||||
|  |   inject: [ | ||||||
|  |     'messages', | ||||||
|  |     'progress' | ||||||
|  |   ], | ||||||
|   props: { |   props: { | ||||||
|     request: { required: true }, |     request: { required: true } | ||||||
|     toast: { required: true } |  | ||||||
|   }, |   }, | ||||||
|   data () { |   data () { | ||||||
|     return {} |     return {} | ||||||
| @ -59,11 +62,11 @@ export default { | |||||||
|   methods: { |   methods: { | ||||||
|     async cancelSnooze () { |     async cancelSnooze () { | ||||||
|       await this.$store.dispatch(actions.SNOOZE_REQUEST, { |       await this.$store.dispatch(actions.SNOOZE_REQUEST, { | ||||||
|         progress: this.$Progress, |         progress: this.progress, | ||||||
|         requestId: this.request.requestId, |         requestId: this.request.requestId, | ||||||
|         until: 0 |         until: 0 | ||||||
|       }) |       }) | ||||||
|       this.toast.showToast('Request un-snoozed', { theme: 'success' }) |       this.messages.$emit('info', 'Request un-snoozed') | ||||||
|       this.$parent.$emit('requestUnsnoozed') |       this.$parent.$emit('requestUnsnoozed') | ||||||
|     }, |     }, | ||||||
|     editRequest () { |     editRequest () { | ||||||
| @ -71,11 +74,11 @@ export default { | |||||||
|     }, |     }, | ||||||
|     async showNow () { |     async showNow () { | ||||||
|       await this.$store.dispatch(actions.SHOW_REQUEST_NOW, { |       await this.$store.dispatch(actions.SHOW_REQUEST_NOW, { | ||||||
|         progress: this.$Progress, |         progress: this.progress, | ||||||
|         requestId: this.request.requestId, |         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') |       this.$parent.$emit('requestNowShown') | ||||||
|     }, |     }, | ||||||
|     viewFull () { |     viewFull () { | ||||||
| @ -84,3 +87,9 @@ export default { | |||||||
|   } |   } | ||||||
| } | } | ||||||
| </script> | </script> | ||||||
|  | 
 | ||||||
|  | <style lang="sass"> | ||||||
|  | .mpj-action-cell | ||||||
|  |   width: 1% | ||||||
|  |   white-space: nowrap | ||||||
|  | </style> | ||||||
|  | |||||||
| @ -1,22 +1,15 @@ | |||||||
| <template lang="pug"> | <template lang="pug"> | ||||||
| .mpj-modal(v-show='snoozeVisible') | md-dialog(:md-active.sync='snoozeVisible').mpj-skinny | ||||||
|   .mpj-modal-content.mpj-skinny |   md-dialog-title Snooze Prayer Request | ||||||
|     header.mpj-bg |   md-content.mpj-dialog-content | ||||||
|       h5 Snooze Prayer Request |     span.mpj-text-muted Until | ||||||
|     p.mpj-text-center |     md-datepicker(v-model='form.snoozedUntil' | ||||||
|       label |                   :md-disabled-dates='datesInPast' | ||||||
|         = 'Until ' |                   md-immediately) | ||||||
|         input(v-model='form.snoozedUntil' |   md-dialog-actions | ||||||
|               type='date' |     md-button(:disabled='!isValid' | ||||||
|               autofocus) |               @click='snoozeRequest()').md-primary #[md-icon snooze] Snooze | ||||||
|     br |     md-button(@click='closeDialog()') #[md-icon undo] Cancel | ||||||
|     .mpj-text-right |  | ||||||
|       button.primary(:disabled='!isValid' |  | ||||||
|                      @click='snoozeRequest()'). |  | ||||||
|         #[md-icon(icon='snooze')] Snooze |  | ||||||
|       |     |  | ||||||
|       button(@click='closeDialog()'). |  | ||||||
|         #[md-icon(icon='undo')] Cancel |  | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| <script> | <script> | ||||||
| @ -26,13 +19,18 @@ import actions from '@/store/action-types' | |||||||
| 
 | 
 | ||||||
| export default { | export default { | ||||||
|   name: 'snooze-request', |   name: 'snooze-request', | ||||||
|  |   inject: [ | ||||||
|  |     'journalEvents', | ||||||
|  |     'messages', | ||||||
|  |     'progress' | ||||||
|  |   ], | ||||||
|   props: { |   props: { | ||||||
|     toast: { required: true }, |  | ||||||
|     events: { required: true } |     events: { required: true } | ||||||
|   }, |   }, | ||||||
|   data () { |   data () { | ||||||
|     return { |     return { | ||||||
|       snoozeVisible: false, |       snoozeVisible: false, | ||||||
|  |       datesInPast: date => date < new Date(), | ||||||
|       form: { |       form: { | ||||||
|         requestId: '', |         requestId: '', | ||||||
|         snoozedUntil: '' |         snoozedUntil: '' | ||||||
| @ -40,7 +38,7 @@ export default { | |||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   created () { |   created () { | ||||||
|     this.events.$on('snooze', this.openDialog) |     this.journalEvents.$on('snooze', this.openDialog) | ||||||
|   }, |   }, | ||||||
|   computed: { |   computed: { | ||||||
|     isValid () { |     isValid () { | ||||||
| @ -59,11 +57,11 @@ export default { | |||||||
|     }, |     }, | ||||||
|     async snoozeRequest () { |     async snoozeRequest () { | ||||||
|       await this.$store.dispatch(actions.SNOOZE_REQUEST, { |       await this.$store.dispatch(actions.SNOOZE_REQUEST, { | ||||||
|         progress: this.$Progress, |         progress: this.progress, | ||||||
|         requestId: this.form.requestId, |         requestId: this.form.requestId, | ||||||
|         until: Date.parse(this.form.snoozedUntil) |         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() |       this.closeDialog() | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -1,13 +1,16 @@ | |||||||
| <template lang="pug"> | <template lang="pug"> | ||||||
| article.mpj-main-content(role='main') | article.mpj-main-content(role='main') | ||||||
|   page-title(title='Snoozed Requests') |   page-title(title='Snoozed Requests' | ||||||
|   div(v-if='loaded').mpj-request-list |              hide-on-page=true) | ||||||
|     p.mpj-text-center(v-if='requests.length === 0'): em. |   template(v-if='loaded') | ||||||
|       No snoozed requests found; return to #[router-link(:to='{ name: "Journal" } ') your journal] |     md-empty-state(v-if='requests.length === 0' | ||||||
|     request-list-item(v-for='req in requests' |                    md-icon='sentiment_dissatisfied' | ||||||
|                       :key='req.requestId' |                    md-label='No Snoozed Requests' | ||||||
|                       :request='req' |                    md-description='Your prayer journal has no snoozed requests') | ||||||
|                       :toast='toast') |       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... |   p(v-else) Loading journal... | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| @ -18,12 +21,13 @@ import { mapState } from 'vuex' | |||||||
| 
 | 
 | ||||||
| import actions from '@/store/action-types' | import actions from '@/store/action-types' | ||||||
| 
 | 
 | ||||||
| import RequestListItem from '@/components/request/RequestListItem' | import RequestList from '@/components/request/RequestList' | ||||||
| 
 | 
 | ||||||
| export default { | export default { | ||||||
|   name: 'snoozed-requests', |   name: 'snoozed-requests', | ||||||
|  |   inject: ['progress'], | ||||||
|   components: { |   components: { | ||||||
|     RequestListItem |     RequestList | ||||||
|   }, |   }, | ||||||
|   data () { |   data () { | ||||||
|     return { |     return { | ||||||
| @ -32,9 +36,6 @@ export default { | |||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   computed: { |   computed: { | ||||||
|     toast () { |  | ||||||
|       return this.$parent.$refs.toast |  | ||||||
|     }, |  | ||||||
|     ...mapState(['journal', 'isLoadingJournal']) |     ...mapState(['journal', 'isLoadingJournal']) | ||||||
|   }, |   }, | ||||||
|   created () { |   created () { | ||||||
| @ -44,7 +45,7 @@ export default { | |||||||
|     async ensureJournal () { |     async ensureJournal () { | ||||||
|       if (!Array.isArray(this.journal)) { |       if (!Array.isArray(this.journal)) { | ||||||
|         this.loaded = false |         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 |       this.requests = this.journal | ||||||
|         .filter(req => req.snoozedUntil > Date.now()) |         .filter(req => req.snoozedUntil > Date.now()) | ||||||
|  | |||||||
| @ -7,14 +7,17 @@ article.mpj-main-content(role='main') | |||||||
| <script> | <script> | ||||||
| 'use strict' | 'use strict' | ||||||
| 
 | 
 | ||||||
| import AuthService from '@/auth/AuthService' |  | ||||||
| 
 |  | ||||||
| export default { | export default { | ||||||
|   name: 'log-on', |   name: 'log-on', | ||||||
|   created () { |   inject: ['progress'], | ||||||
|     this.$Progress.start() |   async created () { | ||||||
|     new AuthService().handleAuthentication(this.$store, this.$router) |     this.progress.$emit('show', 'indeterminate') | ||||||
|     // Auth service redirects to dashboard, which restarts the progress bar |     await this.$auth.handleAuthentication(this.$store) | ||||||
|  |   }, | ||||||
|  |   methods: { | ||||||
|  |     handleLoginEvent (data) { | ||||||
|  |       this.$router.push(data.state.target || '/journal') | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
| </script> | </script> | ||||||
|  | |||||||
| @ -1,33 +1,61 @@ | |||||||
|  | /* eslint-disable */ | ||||||
|  | 
 | ||||||
|  | // Vue packages and components
 | ||||||
| import Vue from 'vue' | import Vue from 'vue' | ||||||
| import VueProgressBar from 'vue-progressbar' | import { MdApp, | ||||||
| import VueToast from 'vue-toast' |          MdButton, | ||||||
| 
 |          MdCard, | ||||||
| import 'vue-toast/dist/vue-toast.min.css' |          MdContent, | ||||||
|  |          MdDatepicker, | ||||||
|  |          MdDialog, | ||||||
|  |          MdEmptyState, | ||||||
|  |          MdField, | ||||||
|  |          MdIcon, | ||||||
|  |          MdLayout, | ||||||
|  |          MdProgress, | ||||||
|  |          MdRadio, | ||||||
|  |          MdSnackbar, | ||||||
|  |          MdTable, | ||||||
|  |          MdTabs, | ||||||
|  |          MdToolbar, | ||||||
|  |          MdTooltip } from 'vue-material/dist/components' | ||||||
| 
 | 
 | ||||||
|  | // myPrayerJournal components
 | ||||||
| import App         from './App' | import App         from './App' | ||||||
| import router      from './router' | import router      from './router' | ||||||
| import store       from './store' | import store       from './store' | ||||||
| import DateFromNow from './components/common/DateFromNow' | 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.config.productionTip = false | ||||||
| 
 | 
 | ||||||
| Vue.use(VueProgressBar, { | Vue.use(MdApp) | ||||||
|   color: 'yellow', | Vue.use(MdButton) | ||||||
|   failedColor: 'red', | Vue.use(MdCard) | ||||||
|   height: '5px', | Vue.use(MdContent) | ||||||
|   transition: { | Vue.use(MdDatepicker) | ||||||
|     speed: '0.2s', | Vue.use(MdDialog) | ||||||
|     opacity: '0.6s', | Vue.use(MdEmptyState) | ||||||
|     termination: 1000 | 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('date-from-now', DateFromNow) | ||||||
| Vue.component('md-icon', MaterialDesignIcon) |  | ||||||
| Vue.component('page-title', PageTitle) | Vue.component('page-title', PageTitle) | ||||||
| Vue.component('toast', VueToast) |  | ||||||
| 
 | 
 | ||||||
| new Vue({ | new Vue({ | ||||||
|   router, |   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' | 'use strict' | ||||||
| 
 | 
 | ||||||
|  | /* eslint-disable */ | ||||||
| import Vue    from 'vue' | import Vue    from 'vue' | ||||||
| import Router from 'vue-router' | import Router from 'vue-router' | ||||||
| 
 | 
 | ||||||
| import ActiveRequests from '@/components/request/ActiveRequests' | import auth from './auth/AuthService' | ||||||
| import AnsweredRequests from '@/components/request/AnsweredRequests' |  | ||||||
| import EditRequest from '@/components/request/EditRequest' |  | ||||||
| import FullRequest from '@/components/request/FullRequest' |  | ||||||
| import Home from '@/components/Home' | import Home from '@/components/Home' | ||||||
| import Journal from '@/components/Journal' | /* eslint-enable */ | ||||||
| import LogOn from '@/components/user/LogOn' |  | ||||||
| import PrivacyPolicy from '@/components/legal/PrivacyPolicy' |  | ||||||
| import SnoozedRequests from '@/components/request/SnoozedRequests' |  | ||||||
| import TermsOfService from '@/components/legal/TermsOfService' |  | ||||||
| 
 | 
 | ||||||
| Vue.use(Router) | Vue.use(Router) | ||||||
| 
 | 
 | ||||||
| @ -26,6 +20,12 @@ export default new Router({ | |||||||
|       return { x: 0, y: 0 } |       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: [ |   routes: [ | ||||||
|     { |     { | ||||||
|       path: '/', |       path: '/', | ||||||
| @ -35,49 +35,49 @@ export default new Router({ | |||||||
|     { |     { | ||||||
|       path: '/journal', |       path: '/journal', | ||||||
|       name: 'Journal', |       name: 'Journal', | ||||||
|       component: Journal |       component: () => import('@/components/Journal') | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|       path: '/legal/privacy-policy', |       path: '/legal/privacy-policy', | ||||||
|       name: 'PrivacyPolicy', |       name: 'PrivacyPolicy', | ||||||
|       component: PrivacyPolicy |       component: () => import('@/components/legal/PrivacyPolicy') | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|       path: '/legal/terms-of-service', |       path: '/legal/terms-of-service', | ||||||
|       name: 'TermsOfService', |       name: 'TermsOfService', | ||||||
|       component: TermsOfService |       component: () => import('@/components/legal/TermsOfService') | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|       path: '/request/:id/edit', |       path: '/request/:id/edit', | ||||||
|       name: 'EditRequest', |       name: 'EditRequest', | ||||||
|       component: EditRequest, |       component: () => import('@/components/request/EditRequest'), | ||||||
|       props: true |       props: true | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|       path: '/request/:id/full', |       path: '/request/:id/full', | ||||||
|       name: 'FullRequest', |       name: 'FullRequest', | ||||||
|       component: FullRequest, |       component: () => import('@/components/request/FullRequest'), | ||||||
|       props: true |       props: true | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|       path: '/requests/active', |       path: '/requests/active', | ||||||
|       name: 'ActiveRequests', |       name: 'ActiveRequests', | ||||||
|       component: ActiveRequests |       component: () => import('@/components/request/ActiveRequests') | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|       path: '/requests/answered', |       path: '/requests/answered', | ||||||
|       name: 'AnsweredRequests', |       name: 'AnsweredRequests', | ||||||
|       component: AnsweredRequests |       component: () => import('@/components/request/AnsweredRequests') | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|       path: '/requests/snoozed', |       path: '/requests/snoozed', | ||||||
|       name: 'SnoozedRequests', |       name: 'SnoozedRequests', | ||||||
|       component: SnoozedRequests |       component: () => import('@/components/request/SnoozedRequests') | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|       path: '/user/log-on', |       path: '/user/log-on', | ||||||
|       name: 'LogOn', |       name: 'LogOn', | ||||||
|       component: LogOn |       component: () => import('@/components/user/LogOn') | ||||||
|     } |     } | ||||||
|   ] |   ] | ||||||
| }) | }) | ||||||
|  | |||||||
| @ -3,6 +3,8 @@ | |||||||
| export default { | export default { | ||||||
|   /** Action to add a prayer request (pass request text) */ |   /** Action to add a prayer request (pass request text) */ | ||||||
|   ADD_REQUEST: 'add-request', |   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 */ |   /** Action to load the user's prayer journal */ | ||||||
|   LOAD_JOURNAL: 'load-journal', |   LOAD_JOURNAL: 'load-journal', | ||||||
|   /** Action to update a request */ |   /** Action to update a request */ | ||||||
|  | |||||||
| @ -1,47 +1,59 @@ | |||||||
| 'use strict' | 'use strict' | ||||||
| 
 | 
 | ||||||
|  | /* eslint-disable no-multi-spaces */ | ||||||
| import Vue  from 'vue' | import Vue  from 'vue' | ||||||
| import Vuex from 'vuex' | import Vuex from 'vuex' | ||||||
| 
 | 
 | ||||||
| import api  from '@/api' | import api  from '@/api' | ||||||
| import AuthService from '@/auth/AuthService' | import auth from '@/auth/AuthService' | ||||||
| 
 | 
 | ||||||
| import mutations from './mutation-types' | import mutations from './mutation-types' | ||||||
| import actions   from './action-types' | import actions   from './action-types' | ||||||
|  | /* eslint-enable no-multi-spaces */ | ||||||
| 
 | 
 | ||||||
| Vue.use(Vuex) | Vue.use(Vuex) | ||||||
| 
 | 
 | ||||||
| const auth0 = new AuthService() | /* eslint-disable no-console */ | ||||||
| 
 |  | ||||||
| const logError = function (error) { | const logError = function (error) { | ||||||
|   if (error.response) { |   if (error.response) { | ||||||
|     // The request was made and the server responded with a status code
 |     // The request was made and the server responded with a status code
 | ||||||
|     // that falls out of the range of 2xx
 |     // that falls out of the range of 2xx
 | ||||||
|     console.log(error.response.data) |     console.error(error.response.data) | ||||||
|     console.log(error.response.status) |     console.error(error.response.status) | ||||||
|     console.log(error.response.headers) |     console.error(error.response.headers) | ||||||
|   } else if (error.request) { |   } else if (error.request) { | ||||||
|     // The request was made but no response was received
 |     // The request was made but no response was received
 | ||||||
|     // `error.request` is an instance of XMLHttpRequest in the browser and an instance of
 |     // `error.request` is an instance of XMLHttpRequest in the browser and an instance of
 | ||||||
|     // http.ClientRequest in node.js
 |     // http.ClientRequest in node.js
 | ||||||
|     console.log(error.request) |     console.error(error.request) | ||||||
|   } else { |   } else { | ||||||
|     // Something happened in setting up the request that triggered an Error
 |     // 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({ | export default new Vuex.Store({ | ||||||
|   state: { |   state: { | ||||||
|     user: JSON.parse(localStorage.getItem('user_profile') || '{}'), |     user: auth.session.profile, | ||||||
|     isAuthenticated: (() => { |     isAuthenticated: auth.isAuthenticated(), | ||||||
|       auth0.scheduleRenewal() |  | ||||||
|       if (auth0.isAuthenticated()) { |  | ||||||
|         api.setBearer(localStorage.getItem('id_token')) |  | ||||||
|       } |  | ||||||
|       return auth0.isAuthenticated() |  | ||||||
|     })(), |  | ||||||
|     journal: {}, |     journal: {}, | ||||||
|     isLoadingJournal: false |     isLoadingJournal: false | ||||||
|   }, |   }, | ||||||
| @ -60,49 +72,60 @@ export default new Vuex.Store({ | |||||||
|       if (request.lastStatus !== 'Answered') jrnl.push(request) |       if (request.lastStatus !== 'Answered') jrnl.push(request) | ||||||
|       state.journal = jrnl |       state.journal = jrnl | ||||||
|     }, |     }, | ||||||
|  |     [mutations.SET_AUTHENTICATION] (state, value) { | ||||||
|  |       state.isAuthenticated = value | ||||||
|  |     }, | ||||||
|     [mutations.USER_LOGGED_OFF] (state) { |     [mutations.USER_LOGGED_OFF] (state) { | ||||||
|       state.user = {} |       state.user = {} | ||||||
|       api.removeBearer() |       api.removeBearer() | ||||||
|       state.isAuthenticated = false |       state.isAuthenticated = false | ||||||
|     }, |     }, | ||||||
|     [mutations.USER_LOGGED_ON] (state, user) { |     [mutations.USER_LOGGED_ON] (state, user) { | ||||||
|       localStorage.setItem('user_profile', JSON.stringify(user)) |  | ||||||
|       state.user = user |       state.user = user | ||||||
|       api.setBearer(localStorage.getItem('id_token')) |  | ||||||
|       state.isAuthenticated = true |       state.isAuthenticated = true | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   actions: { |   actions: { | ||||||
|     async [actions.ADD_REQUEST] ({ commit }, { progress, requestText, recurType, recurCount }) { |     async [actions.ADD_REQUEST] ({ commit }, { progress, requestText, recurType, recurCount }) { | ||||||
|       progress.start() |       progress.$emit('show', 'indeterminate') | ||||||
|       try { |       try { | ||||||
|  |         await setBearer() | ||||||
|         const newRequest = await api.addRequest(requestText, recurType, recurCount) |         const newRequest = await api.addRequest(requestText, recurType, recurCount) | ||||||
|         commit(mutations.REQUEST_ADDED, newRequest.data) |         commit(mutations.REQUEST_ADDED, newRequest.data) | ||||||
|         progress.finish() |         progress.$emit('done') | ||||||
|       } catch (err) { |       } catch (err) { | ||||||
|         logError(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) { |     async [actions.LOAD_JOURNAL] ({ commit }, progress) { | ||||||
|       commit(mutations.LOADED_JOURNAL, {}) |       commit(mutations.LOADED_JOURNAL, {}) | ||||||
|       progress.start() |       progress.$emit('show', 'query') | ||||||
|       commit(mutations.LOADING_JOURNAL, true) |       commit(mutations.LOADING_JOURNAL, true) | ||||||
|       api.setBearer(localStorage.getItem('id_token')) |       await setBearer() | ||||||
|       try { |       try { | ||||||
|         const jrnl = await api.journal() |         const jrnl = await api.journal() | ||||||
|         commit(mutations.LOADED_JOURNAL, jrnl.data) |         commit(mutations.LOADED_JOURNAL, jrnl.data) | ||||||
|         progress.finish() |         progress.$emit('done') | ||||||
|       } catch (err) { |       } catch (err) { | ||||||
|         logError(err) |         logError(err) | ||||||
|         progress.fail() |         progress.$emit('done') | ||||||
|       } finally { |       } finally { | ||||||
|         commit(mutations.LOADING_JOURNAL, false) |         commit(mutations.LOADING_JOURNAL, false) | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|     async [actions.UPDATE_REQUEST] ({ commit, state }, { progress, requestId, status, updateText, recurType, recurCount }) { |     async [actions.UPDATE_REQUEST] ({ commit, state }, { progress, requestId, status, updateText, recurType, recurCount }) { | ||||||
|       progress.start() |       progress.$emit('show', 'indeterminate') | ||||||
|       try { |       try { | ||||||
|  |         await setBearer() | ||||||
|         let oldReq = (state.journal.filter(req => req.requestId === requestId) || [])[0] || {} |         let oldReq = (state.journal.filter(req => req.requestId === requestId) || [])[0] || {} | ||||||
|         if (!(status === 'Prayed' && updateText === '')) { |         if (!(status === 'Prayed' && updateText === '')) { | ||||||
|           if (status !== 'Answered' && (oldReq.recurType !== recurType || oldReq.recurCount !== recurCount)) { |           if (status !== 'Answered' && (oldReq.recurType !== recurType || oldReq.recurCount !== recurCount)) { | ||||||
| @ -114,34 +137,36 @@ export default new Vuex.Store({ | |||||||
|         } |         } | ||||||
|         const request = await api.getRequest(requestId) |         const request = await api.getRequest(requestId) | ||||||
|         commit(mutations.REQUEST_UPDATED, request.data) |         commit(mutations.REQUEST_UPDATED, request.data) | ||||||
|         progress.finish() |         progress.$emit('done') | ||||||
|       } catch (err) { |       } catch (err) { | ||||||
|         logError(err) |         logError(err) | ||||||
|         progress.fail() |         progress.$emit('done') | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|     async [actions.SHOW_REQUEST_NOW] ({ commit }, { progress, requestId, showAfter }) { |     async [actions.SHOW_REQUEST_NOW] ({ commit }, { progress, requestId, showAfter }) { | ||||||
|       progress.start() |       progress.$emit('show', 'indeterminate') | ||||||
|       try { |       try { | ||||||
|  |         await setBearer() | ||||||
|         await api.showRequest(requestId, showAfter) |         await api.showRequest(requestId, showAfter) | ||||||
|         const request = await api.getRequest(requestId) |         const request = await api.getRequest(requestId) | ||||||
|         commit(mutations.REQUEST_UPDATED, request.data) |         commit(mutations.REQUEST_UPDATED, request.data) | ||||||
|         progress.finish() |         progress.$emit('done') | ||||||
|       } catch (err) { |       } catch (err) { | ||||||
|         logError(err) |         logError(err) | ||||||
|         progress.fail() |         progress.$emit('done') | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|     async [actions.SNOOZE_REQUEST] ({ commit }, { progress, requestId, until }) { |     async [actions.SNOOZE_REQUEST] ({ commit }, { progress, requestId, until }) { | ||||||
|       progress.start() |       progress.$emit('show', 'indeterminate') | ||||||
|       try { |       try { | ||||||
|  |         await setBearer() | ||||||
|         await api.snoozeRequest(requestId, until) |         await api.snoozeRequest(requestId, until) | ||||||
|         const request = await api.getRequest(requestId) |         const request = await api.getRequest(requestId) | ||||||
|         commit(mutations.REQUEST_UPDATED, request.data) |         commit(mutations.REQUEST_UPDATED, request.data) | ||||||
|         progress.finish() |         progress.$emit('done') | ||||||
|       } catch (err) { |       } catch (err) { | ||||||
|         logError(err) |         logError(err) | ||||||
|         progress.fail() |         progress.$emit('done') | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|  | |||||||
| @ -9,6 +9,8 @@ export default { | |||||||
|   REQUEST_ADDED: 'request-added', |   REQUEST_ADDED: 'request-added', | ||||||
|   /** Mutation to replace a prayer request at the top of the current journal */ |   /** Mutation to replace a prayer request at the top of the current journal */ | ||||||
|   REQUEST_UPDATED: 'request-updated', |   REQUEST_UPDATED: 'request-updated', | ||||||
|  |   /** Mutation for setting the authentication state */ | ||||||
|  |   SET_AUTHENTICATION: 'set-authentication', | ||||||
|   /** Mutation for logging a user off */ |   /** Mutation for logging a user off */ | ||||||
|   USER_LOGGED_OFF: 'user-logged-off', |   USER_LOGGED_OFF: 'user-logged-off', | ||||||
|   /** Mutation for logging a user on (pass user) */ |   /** Mutation for logging a user on (pass user) */ | ||||||
|  | |||||||
| @ -1,9 +1,16 @@ | |||||||
| const webpack = require('webpack') | const webpack = require('webpack') | ||||||
|  | // const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
 | ||||||
| module.exports = { | module.exports = { | ||||||
|   outputDir: '../api/MyPrayerJournal.Api/wwwroot', |   outputDir: '../MyPrayerJournal.Api/wwwroot', | ||||||
|   configureWebpack: { |   configureWebpack: { | ||||||
|     plugins: [ |     plugins: [ | ||||||
|  |       // new BundleAnalyzerPlugin(),
 | ||||||
|       new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/) |       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