myPrayerJournal v2 (#27)
App changes: * Move to Vue Material for UI components * Convert request cards to true material design cards, separating the "pray" button from the others and improved highlighting of the current request * Centralize Auth0 integration in one place; modify the Vuex store to rely on it entirely, and add a Vue mixin to make it accessible by any component API changes: * Change backing data store to RavenDB * Evolve domain models (using F# discriminated unions, and JSON converters for storage) to make invalid states unrepresentable * Incorporate the FunctionalCuid library * Create a functional pipeline for app configuration instead of chaining `IWebHostBuilder` calls Bug fixes: * Set showAfter to 0 for immediately recurring requests (#26)
This commit is contained in:
parent
ce588b6a43
commit
fa78e86de6
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…
Reference in New Issue
Block a user