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
|
||||
|
||||
# Compiled files / application
|
||||
src/api/build
|
||||
src/api/MyPrayerJournal.Api/wwwroot/favicon.ico
|
||||
src/api/MyPrayerJournal.Api/wwwroot/index.html
|
||||
src/api/MyPrayerJournal.Api/wwwroot/css
|
||||
src/api/MyPrayerJournal.Api/wwwroot/js
|
||||
src/api/MyPrayerJournal.Api/appsettings.development.json
|
||||
src/build
|
||||
src/MyPrayerJournal.Api/wwwroot/favicon.ico
|
||||
src/MyPrayerJournal.Api/wwwroot/index.html
|
||||
src/MyPrayerJournal.Api/wwwroot/css
|
||||
src/MyPrayerJournal.Api/wwwroot/js
|
||||
src/MyPrayerJournal.Api/appsettings.development.json
|
||||
/build
|
||||
src/*.exe
|
||||
|
184
src/MyPrayerJournal.Api/Data.fs
Normal file
184
src/MyPrayerJournal.Api/Data.fs
Normal file
@ -0,0 +1,184 @@
|
||||
namespace MyPrayerJournal
|
||||
|
||||
open System
|
||||
open System.Collections.Generic
|
||||
|
||||
/// JSON converters for various DUs
|
||||
module Converters =
|
||||
|
||||
open Microsoft.FSharpLu.Json
|
||||
open Newtonsoft.Json
|
||||
|
||||
/// JSON converter for request IDs
|
||||
type RequestIdJsonConverter () =
|
||||
inherit JsonConverter<RequestId> ()
|
||||
override __.WriteJson(writer : JsonWriter, value : RequestId, _ : JsonSerializer) =
|
||||
(RequestId.toString >> writer.WriteValue) value
|
||||
override __.ReadJson(reader: JsonReader, _ : Type, _ : RequestId, _ : bool, _ : JsonSerializer) =
|
||||
(string >> RequestId.fromIdString) reader.Value
|
||||
|
||||
/// JSON converter for user IDs
|
||||
type UserIdJsonConverter () =
|
||||
inherit JsonConverter<UserId> ()
|
||||
override __.WriteJson(writer : JsonWriter, value : UserId, _ : JsonSerializer) =
|
||||
(UserId.toString >> writer.WriteValue) value
|
||||
override __.ReadJson(reader: JsonReader, _ : Type, _ : UserId, _ : bool, _ : JsonSerializer) =
|
||||
(string >> UserId) reader.Value
|
||||
|
||||
/// JSON converter for Ticks
|
||||
type TicksJsonConverter () =
|
||||
inherit JsonConverter<Ticks> ()
|
||||
override __.WriteJson(writer : JsonWriter, value : Ticks, _ : JsonSerializer) =
|
||||
(Ticks.toLong >> writer.WriteValue) value
|
||||
override __.ReadJson(reader: JsonReader, _ : Type, _ : Ticks, _ : bool, _ : JsonSerializer) =
|
||||
(string >> int64 >> Ticks) reader.Value
|
||||
|
||||
/// A sequence of all custom converters needed for myPrayerJournal
|
||||
let all : JsonConverter seq =
|
||||
seq {
|
||||
yield RequestIdJsonConverter ()
|
||||
yield UserIdJsonConverter ()
|
||||
yield TicksJsonConverter ()
|
||||
yield CompactUnionJsonConverter true
|
||||
}
|
||||
|
||||
|
||||
/// RavenDB index declarations
|
||||
module Indexes =
|
||||
|
||||
open Raven.Client.Documents.Indexes
|
||||
|
||||
/// Index requests for a journal view
|
||||
type Requests_AsJournal () as this =
|
||||
inherit AbstractJavaScriptIndexCreationTask ()
|
||||
do
|
||||
this.Maps <- HashSet<string> [
|
||||
"""docs.Requests.Select(req => new {
|
||||
requestId = req.Id.Replace("Requests/", ""),
|
||||
userId = req.userId,
|
||||
text = req.history.Where(hist => hist.text != null).OrderByDescending(hist => hist.asOf).First().text,
|
||||
asOf = req.history.OrderByDescending(hist => hist.asOf).First().asOf,
|
||||
lastStatus = req.history.OrderByDescending(hist => hist.asOf).First().status,
|
||||
snoozedUntil = req.snoozedUntil,
|
||||
showAfter = req.showAfter,
|
||||
recurType = req.recurType,
|
||||
recurCount = req.recurCount
|
||||
})"""
|
||||
]
|
||||
this.Fields <-
|
||||
[ "requestId", IndexFieldOptions (Storage = Nullable FieldStorage.Yes)
|
||||
"text", IndexFieldOptions (Storage = Nullable FieldStorage.Yes)
|
||||
"asOf", IndexFieldOptions (Storage = Nullable FieldStorage.Yes)
|
||||
"lastStatus", IndexFieldOptions (Storage = Nullable FieldStorage.Yes)
|
||||
]
|
||||
|> dict
|
||||
|> Dictionary<string, IndexFieldOptions>
|
||||
|
||||
|
||||
/// All data manipulations within myPrayerJournal
|
||||
module Data =
|
||||
|
||||
open FSharp.Control.Tasks.V2.ContextInsensitive
|
||||
open Indexes
|
||||
open Microsoft.FSharpLu
|
||||
open Raven.Client.Documents
|
||||
open Raven.Client.Documents.Linq
|
||||
open Raven.Client.Documents.Session
|
||||
|
||||
/// Add a history entry
|
||||
let addHistory reqId (hist : History) (sess : IAsyncDocumentSession) =
|
||||
sess.Advanced.Patch<Request, History> (
|
||||
RequestId.toString reqId,
|
||||
(fun r -> r.history :> IEnumerable<History>),
|
||||
fun (h : JavaScriptArray<History>) -> h.Add (hist) :> obj)
|
||||
|
||||
/// Add a note
|
||||
let addNote reqId (note : Note) (sess : IAsyncDocumentSession) =
|
||||
sess.Advanced.Patch<Request, Note> (
|
||||
RequestId.toString reqId,
|
||||
(fun r -> r.notes :> IEnumerable<Note>),
|
||||
fun (h : JavaScriptArray<Note>) -> h.Add (note) :> obj)
|
||||
|
||||
/// Add a request
|
||||
let addRequest req (sess : IAsyncDocumentSession) =
|
||||
sess.StoreAsync (req, req.Id)
|
||||
|
||||
/// Retrieve all answered requests for the given user
|
||||
let answeredRequests userId (sess : IAsyncDocumentSession) =
|
||||
task {
|
||||
let! reqs =
|
||||
sess.Query<JournalRequest, Requests_AsJournal>()
|
||||
.Where(fun r -> r.userId = userId && r.lastStatus = "Answered")
|
||||
.OrderByDescending(fun r -> r.asOf)
|
||||
.ProjectInto<JournalRequest>()
|
||||
.ToListAsync ()
|
||||
return List.ofSeq reqs
|
||||
}
|
||||
|
||||
/// Retrieve the user's current journal
|
||||
let journalByUserId userId (sess : IAsyncDocumentSession) =
|
||||
task {
|
||||
let! jrnl =
|
||||
sess.Query<JournalRequest, Requests_AsJournal>()
|
||||
.Where(fun r -> r.userId = userId && r.lastStatus <> "Answered")
|
||||
.OrderBy(fun r -> r.asOf)
|
||||
.ProjectInto<JournalRequest>()
|
||||
.ToListAsync()
|
||||
return
|
||||
jrnl
|
||||
|> List.ofSeq
|
||||
|> List.map (fun r -> r.history <- []; r.notes <- []; r)
|
||||
}
|
||||
|
||||
/// Save changes in the current document session
|
||||
let saveChanges (sess : IAsyncDocumentSession) =
|
||||
sess.SaveChangesAsync ()
|
||||
|
||||
/// Retrieve a request, including its history and notes, by its ID and user ID
|
||||
let tryFullRequestById reqId userId (sess : IAsyncDocumentSession) =
|
||||
task {
|
||||
let! req = RequestId.toString reqId |> sess.LoadAsync
|
||||
return match Option.fromObject req with Some r when r.userId = userId -> Some r | _ -> None
|
||||
}
|
||||
|
||||
|
||||
/// Retrieve a request by its ID and user ID (without notes and history)
|
||||
let tryRequestById reqId userId (sess : IAsyncDocumentSession) =
|
||||
task {
|
||||
match! tryFullRequestById reqId userId sess with
|
||||
| Some r -> return Some { r with history = []; notes = [] }
|
||||
| _ -> return None
|
||||
}
|
||||
|
||||
/// Retrieve notes for a request by its ID and user ID
|
||||
let notesById reqId userId (sess : IAsyncDocumentSession) =
|
||||
task {
|
||||
match! tryFullRequestById reqId userId sess with
|
||||
| Some req -> return req.notes
|
||||
| None -> return []
|
||||
}
|
||||
|
||||
/// Retrieve a journal request by its ID and user ID
|
||||
let tryJournalById reqId userId (sess : IAsyncDocumentSession) =
|
||||
task {
|
||||
let! req =
|
||||
sess.Query<Request, Requests_AsJournal>()
|
||||
.Where(fun x -> x.Id = (RequestId.toString reqId) && x.userId = userId)
|
||||
.ProjectInto<JournalRequest>()
|
||||
.FirstOrDefaultAsync ()
|
||||
return Option.fromObject req
|
||||
}
|
||||
|
||||
/// Update the recurrence for a request
|
||||
let updateRecurrence reqId recurType recurCount (sess : IAsyncDocumentSession) =
|
||||
sess.Advanced.Patch<Request, Recurrence> (RequestId.toString reqId, (fun r -> r.recurType), recurType)
|
||||
sess.Advanced.Patch<Request, int16> (RequestId.toString reqId, (fun r -> r.recurCount), recurCount)
|
||||
|
||||
/// Update a snoozed request
|
||||
let updateSnoozed reqId until (sess : IAsyncDocumentSession) =
|
||||
sess.Advanced.Patch<Request, Ticks> (RequestId.toString reqId, (fun r -> r.snoozedUntil), until)
|
||||
sess.Advanced.Patch<Request, Ticks> (RequestId.toString reqId, (fun r -> r.showAfter), until)
|
||||
|
||||
/// Update the "show after" timestamp for a request
|
||||
let updateShowAfter reqId showAfter (sess : IAsyncDocumentSession) =
|
||||
sess.Advanced.Patch<Request, Ticks> (RequestId.toString reqId, (fun r -> r.showAfter), showAfter)
|
169
src/MyPrayerJournal.Api/Domain.fs
Normal file
169
src/MyPrayerJournal.Api/Domain.fs
Normal file
@ -0,0 +1,169 @@
|
||||
[<AutoOpen>]
|
||||
/// The data model for myPrayerJournal
|
||||
module MyPrayerJournal.Domain
|
||||
|
||||
open Cuid
|
||||
|
||||
/// Request ID is a CUID
|
||||
type RequestId =
|
||||
| RequestId of Cuid
|
||||
module RequestId =
|
||||
/// The string representation of the request ID
|
||||
let toString x = match x with RequestId y -> (Cuid.toString >> sprintf "Requests/%s") y
|
||||
/// Create a request ID from a string representation
|
||||
let fromIdString (y : string) = (Cuid >> RequestId) <| y.Replace("Requests/", "")
|
||||
|
||||
|
||||
/// User ID is a string (the "sub" part of the JWT)
|
||||
type UserId =
|
||||
| UserId of string
|
||||
module UserId =
|
||||
/// The string representation of the user ID
|
||||
let toString x = match x with UserId y -> y
|
||||
|
||||
|
||||
/// A long integer representing seconds since the epoch
|
||||
type Ticks =
|
||||
| Ticks of int64
|
||||
module Ticks =
|
||||
/// The int64 (long) representation of ticks
|
||||
let toLong x = match x with Ticks y -> y
|
||||
|
||||
|
||||
/// How frequently a request should reappear after it is marked "Prayed"
|
||||
type Recurrence =
|
||||
| Immediate
|
||||
| Hours
|
||||
| Days
|
||||
| Weeks
|
||||
module Recurrence =
|
||||
/// Create a recurrence value from a string
|
||||
let fromString x =
|
||||
match x with
|
||||
| "Immediate" -> Immediate
|
||||
| "Hours" -> Hours
|
||||
| "Days" -> Days
|
||||
| "Weeks" -> Weeks
|
||||
| _ -> invalidOp (sprintf "%s is not a valid recurrence" x)
|
||||
/// The duration of the recurrence
|
||||
let duration x =
|
||||
match x with
|
||||
| Immediate -> 0L
|
||||
| Hours -> 3600000L
|
||||
| Days -> 86400000L
|
||||
| Weeks -> 604800000L
|
||||
|
||||
|
||||
/// The action taken on a request as part of a history entry
|
||||
type RequestAction =
|
||||
| Created
|
||||
| Prayed
|
||||
| Updated
|
||||
| Answered
|
||||
module RequestAction =
|
||||
/// Create a RequestAction from a string
|
||||
let fromString x =
|
||||
match x with
|
||||
| "Created" -> Created
|
||||
| "Prayed" -> Prayed
|
||||
| "Updated" -> Updated
|
||||
| "Answered" -> Answered
|
||||
| _ -> (sprintf "Bad request action %s" >> invalidOp) x
|
||||
|
||||
|
||||
/// History is a record of action taken on a prayer request, including updates to its text
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type History =
|
||||
{ /// The time when this history entry was made
|
||||
asOf : Ticks
|
||||
/// The status for this history entry
|
||||
status : RequestAction
|
||||
/// The text of the update, if applicable
|
||||
text : string option
|
||||
}
|
||||
with
|
||||
/// An empty history entry
|
||||
static member empty =
|
||||
{ asOf = Ticks 0L
|
||||
status = Created
|
||||
text = None
|
||||
}
|
||||
|
||||
/// Note is a note regarding a prayer request that does not result in an update to its text
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type Note =
|
||||
{ /// The time when this note was made
|
||||
asOf : Ticks
|
||||
/// The text of the notes
|
||||
notes : string
|
||||
}
|
||||
with
|
||||
/// An empty note
|
||||
static member empty =
|
||||
{ asOf = Ticks 0L
|
||||
notes = ""
|
||||
}
|
||||
|
||||
/// Request is the identifying record for a prayer request
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type Request =
|
||||
{ /// The ID of the request
|
||||
Id : string
|
||||
/// The time this request was initially entered
|
||||
enteredOn : Ticks
|
||||
/// The ID of the user to whom this request belongs ("sub" from the JWT)
|
||||
userId : UserId
|
||||
/// The time at which this request should reappear in the user's journal by manual user choice
|
||||
snoozedUntil : Ticks
|
||||
/// The time at which this request should reappear in the user's journal by recurrence
|
||||
showAfter : Ticks
|
||||
/// The type of recurrence for this request
|
||||
recurType : Recurrence
|
||||
/// How many of the recurrence intervals should occur between appearances in the journal
|
||||
recurCount : int16
|
||||
/// The history entries for this request
|
||||
history : History list
|
||||
/// The notes for this request
|
||||
notes : Note list
|
||||
}
|
||||
with
|
||||
/// An empty request
|
||||
static member empty =
|
||||
{ Id = ""
|
||||
enteredOn = Ticks 0L
|
||||
userId = UserId ""
|
||||
snoozedUntil = Ticks 0L
|
||||
showAfter = Ticks 0L
|
||||
recurType = Immediate
|
||||
recurCount = 0s
|
||||
history = []
|
||||
notes = []
|
||||
}
|
||||
|
||||
/// JournalRequest is the form of a prayer request returned for the request journal display. It also contains
|
||||
/// properties that may be filled for history and notes.
|
||||
// RavenDB doesn't like the "@"-suffixed properties from record types in a ProjectInto clause
|
||||
[<NoComparison; NoEquality>]
|
||||
type JournalRequest () =
|
||||
/// The ID of the request (just the CUID part)
|
||||
[<DefaultValue>] val mutable requestId : string
|
||||
/// The ID of the user to whom the request belongs
|
||||
[<DefaultValue>] val mutable userId : UserId
|
||||
/// The current text of the request
|
||||
[<DefaultValue>] val mutable text : string
|
||||
/// The last time action was taken on the request
|
||||
[<DefaultValue>] val mutable asOf : Ticks
|
||||
/// The last status for the request
|
||||
[<DefaultValue>] val mutable lastStatus : string
|
||||
/// The time that this request should reappear in the user's journal
|
||||
[<DefaultValue>] val mutable snoozedUntil : Ticks
|
||||
/// The time after which this request should reappear in the user's journal by configured recurrence
|
||||
[<DefaultValue>] val mutable showAfter : Ticks
|
||||
/// The type of recurrence for this request
|
||||
[<DefaultValue>] val mutable recurType : Recurrence
|
||||
/// How many of the recurrence intervals should occur between appearances in the journal
|
||||
[<DefaultValue>] val mutable recurCount : int16
|
||||
/// History entries for the request
|
||||
[<DefaultValue>] val mutable history : History list
|
||||
/// Note entries for the request
|
||||
[<DefaultValue>] val mutable notes : Note list
|
@ -1,11 +1,8 @@
|
||||
/// HTTP handlers for the myPrayerJournal API
|
||||
[<RequireQualifiedAccess>]
|
||||
module MyPrayerJournal.Api.Handlers
|
||||
module MyPrayerJournal.Handlers
|
||||
|
||||
open FSharp.Control.Tasks.V2.ContextInsensitive
|
||||
open Giraffe
|
||||
open MyPrayerJournal
|
||||
open System
|
||||
|
||||
/// Handler to return Vue files
|
||||
module Vue =
|
||||
@ -13,6 +10,7 @@ module Vue =
|
||||
/// The application index page
|
||||
let app : HttpHandler = htmlFile "wwwroot/index.html"
|
||||
|
||||
open System
|
||||
|
||||
/// Handlers for error conditions
|
||||
module Error =
|
||||
@ -34,18 +32,22 @@ module Error =
|
||||
| 0 -> (setStatusCode 404 >=> json ([ "error", "not found" ] |> dict)) next ctx
|
||||
| _ -> Vue.app next ctx
|
||||
|
||||
open Cuid
|
||||
|
||||
/// Handler helpers
|
||||
[<AutoOpen>]
|
||||
module private Helpers =
|
||||
|
||||
open Microsoft.AspNetCore.Http
|
||||
open Raven.Client.Documents
|
||||
open System.Threading.Tasks
|
||||
open System.Security.Claims
|
||||
|
||||
/// Get the database context from DI
|
||||
let db (ctx : HttpContext) =
|
||||
ctx.GetService<AppDbContext> ()
|
||||
/// Create a RavenDB session
|
||||
let session (ctx : HttpContext) =
|
||||
let sess = ctx.GetService<IDocumentStore>().OpenAsyncSession ()
|
||||
sess.Advanced.WaitForIndexesAfterSaveChanges ()
|
||||
sess
|
||||
|
||||
/// Get the user's "sub" claim
|
||||
let user (ctx : HttpContext) =
|
||||
@ -54,15 +56,23 @@ module private Helpers =
|
||||
/// Get the current user's ID
|
||||
// NOTE: this may raise if you don't run the request through the authorize handler first
|
||||
let userId ctx =
|
||||
((user >> Option.get) ctx).Value
|
||||
((user >> Option.get) ctx).Value |> UserId
|
||||
|
||||
/// Create a request ID from a string
|
||||
let toReqId x =
|
||||
let reqId =
|
||||
match Cuid.ofString x with
|
||||
| Ok cuid -> cuid
|
||||
| Error msg -> invalidOp msg
|
||||
RequestId reqId
|
||||
|
||||
/// Return a 201 CREATED response
|
||||
let created next ctx =
|
||||
setStatusCode 201 next ctx
|
||||
|
||||
/// The "now" time in JavaScript
|
||||
/// The "now" time in JavaScript as Ticks
|
||||
let jsNow () =
|
||||
DateTime.UtcNow.Subtract(DateTime (1970, 1, 1, 0, 0, 0)).TotalSeconds |> int64 |> (*) 1000L
|
||||
(int64 >> (*) 1000L >> Ticks) <| DateTime.UtcNow.Subtract(DateTime (1970, 1, 1, 0, 0, 0)).TotalSeconds
|
||||
|
||||
/// Handler to return a 403 Not Authorized reponse
|
||||
let notAuthorized : HttpHandler =
|
||||
@ -116,13 +126,6 @@ module Models =
|
||||
recurCount : int16
|
||||
}
|
||||
|
||||
/// Reset the "showAfter" property on a request
|
||||
[<CLIMutable>]
|
||||
type Show =
|
||||
{ /// The time after which the request should appear
|
||||
showAfter : int64
|
||||
}
|
||||
|
||||
/// The time until which a request should not appear in the journal
|
||||
[<CLIMutable>]
|
||||
type SnoozeUntil =
|
||||
@ -130,6 +133,7 @@ module Models =
|
||||
until : int64
|
||||
}
|
||||
|
||||
open FSharp.Control.Tasks.V2.ContextInsensitive
|
||||
|
||||
/// /api/journal URLs
|
||||
module Journal =
|
||||
@ -138,99 +142,92 @@ module Journal =
|
||||
let journal : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx ->
|
||||
userId ctx
|
||||
|> (db ctx).JournalByUserId
|
||||
|> asJson next ctx
|
||||
task {
|
||||
use sess = session ctx
|
||||
let usrId = userId ctx
|
||||
let! jrnl = Data.journalByUserId usrId sess
|
||||
return! json jrnl next ctx
|
||||
}
|
||||
|
||||
|
||||
/// /api/request URLs
|
||||
module Request =
|
||||
|
||||
open NCuid
|
||||
|
||||
/// Ticks per recurrence
|
||||
let private recurrence =
|
||||
[ "immediate", 0L
|
||||
"hours", 3600000L
|
||||
"days", 86400000L
|
||||
"weeks", 604800000L
|
||||
]
|
||||
|> Map.ofList
|
||||
|
||||
/// POST /api/request
|
||||
let add : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx ->
|
||||
task {
|
||||
let! r = ctx.BindJsonAsync<Models.Request> ()
|
||||
let db = db ctx
|
||||
let reqId = Cuid.Generate ()
|
||||
use sess = session ctx
|
||||
let reqId = (Cuid.generate >> RequestId) ()
|
||||
let usrId = userId ctx
|
||||
let now = jsNow ()
|
||||
{ Request.empty with
|
||||
requestId = reqId
|
||||
userId = usrId
|
||||
enteredOn = now
|
||||
showAfter = now
|
||||
recurType = r.recurType
|
||||
recurCount = r.recurCount
|
||||
}
|
||||
|> db.AddEntry
|
||||
{ History.empty with
|
||||
requestId = reqId
|
||||
asOf = now
|
||||
status = "Created"
|
||||
text = Some r.requestText
|
||||
}
|
||||
|> db.AddEntry
|
||||
let! _ = db.SaveChangesAsync ()
|
||||
match! db.TryJournalById reqId usrId with
|
||||
do! Data.addRequest
|
||||
{ Request.empty with
|
||||
Id = RequestId.toString reqId
|
||||
userId = usrId
|
||||
enteredOn = now
|
||||
showAfter = Ticks 0L
|
||||
recurType = Recurrence.fromString r.recurType
|
||||
recurCount = r.recurCount
|
||||
history = [
|
||||
{ asOf = now
|
||||
status = Created
|
||||
text = Some r.requestText
|
||||
}
|
||||
]
|
||||
} sess
|
||||
do! Data.saveChanges sess
|
||||
match! Data.tryJournalById reqId usrId sess with
|
||||
| Some req -> return! (setStatusCode 201 >=> json req) next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
/// POST /api/request/[req-id]/history
|
||||
let addHistory reqId : HttpHandler =
|
||||
let addHistory requestId : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx ->
|
||||
task {
|
||||
let db = db ctx
|
||||
match! db.TryRequestById reqId (userId ctx) with
|
||||
use sess = session ctx
|
||||
let usrId = userId ctx
|
||||
let reqId = toReqId requestId
|
||||
match! Data.tryRequestById reqId usrId sess with
|
||||
| Some req ->
|
||||
let! hist = ctx.BindJsonAsync<Models.HistoryEntry> ()
|
||||
let now = jsNow ()
|
||||
{ History.empty with
|
||||
requestId = reqId
|
||||
asOf = now
|
||||
status = hist.status
|
||||
text = match hist.updateText with null | "" -> None | x -> Some x
|
||||
}
|
||||
|> db.AddEntry
|
||||
match hist.status with
|
||||
| "Prayed" ->
|
||||
db.UpdateEntry { req with showAfter = now + (recurrence.[req.recurType] * int64 req.recurCount) }
|
||||
let act = RequestAction.fromString hist.status
|
||||
Data.addHistory reqId
|
||||
{ asOf = now
|
||||
status = act
|
||||
text = match hist.updateText with null | "" -> None | x -> Some x
|
||||
} sess
|
||||
match act with
|
||||
| Prayed ->
|
||||
let nextShow =
|
||||
match Recurrence.duration req.recurType with
|
||||
| 0L -> 0L
|
||||
| duration -> (Ticks.toLong now) + (duration * int64 req.recurCount)
|
||||
Data.updateShowAfter reqId (Ticks nextShow) sess
|
||||
| _ -> ()
|
||||
let! _ = db.SaveChangesAsync ()
|
||||
do! Data.saveChanges sess
|
||||
return! created next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
/// POST /api/request/[req-id]/note
|
||||
let addNote reqId : HttpHandler =
|
||||
let addNote requestId : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx ->
|
||||
task {
|
||||
let db = db ctx
|
||||
match! db.TryRequestById reqId (userId ctx) with
|
||||
use sess = session ctx
|
||||
let usrId = userId ctx
|
||||
let reqId = toReqId requestId
|
||||
match! Data.tryRequestById reqId usrId sess with
|
||||
| Some _ ->
|
||||
let! notes = ctx.BindJsonAsync<Models.NoteEntry> ()
|
||||
{ Note.empty with
|
||||
requestId = reqId
|
||||
asOf = jsNow ()
|
||||
notes = notes.notes
|
||||
}
|
||||
|> db.AddEntry
|
||||
let! _ = db.SaveChangesAsync ()
|
||||
Data.addNote reqId { asOf = jsNow (); notes = notes.notes } sess
|
||||
do! Data.saveChanges sess
|
||||
return! created next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
@ -239,83 +236,129 @@ module Request =
|
||||
let answered : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx ->
|
||||
userId ctx
|
||||
|> (db ctx).AnsweredRequests
|
||||
|> asJson next ctx
|
||||
task {
|
||||
use sess = session ctx
|
||||
let usrId = userId ctx
|
||||
let! reqs = Data.answeredRequests usrId sess
|
||||
return! json reqs next ctx
|
||||
}
|
||||
|
||||
/// GET /api/request/[req-id]
|
||||
let get reqId : HttpHandler =
|
||||
let get requestId : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx ->
|
||||
task {
|
||||
match! (db ctx).TryJournalById reqId (userId ctx) with
|
||||
use sess = session ctx
|
||||
let usrId = userId ctx
|
||||
match! Data.tryJournalById (toReqId requestId) usrId sess with
|
||||
| Some req -> return! json req next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
/// GET /api/request/[req-id]/full
|
||||
let getFull reqId : HttpHandler =
|
||||
let getFull requestId : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx ->
|
||||
task {
|
||||
match! (db ctx).TryFullRequestById reqId (userId ctx) with
|
||||
use sess = session ctx
|
||||
let usrId = userId ctx
|
||||
match! Data.tryFullRequestById (toReqId requestId) usrId sess with
|
||||
| Some req -> return! json req next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
/// GET /api/request/[req-id]/notes
|
||||
let getNotes reqId : HttpHandler =
|
||||
let getNotes requestId : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx ->
|
||||
task {
|
||||
let! notes = (db ctx).NotesById reqId (userId ctx)
|
||||
use sess = session ctx
|
||||
let usrId = userId ctx
|
||||
let! notes = Data.notesById (toReqId requestId) usrId sess
|
||||
return! json notes next ctx
|
||||
}
|
||||
|
||||
/// PATCH /api/request/[req-id]/show
|
||||
let show reqId : HttpHandler =
|
||||
let show requestId : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx ->
|
||||
task {
|
||||
let db = db ctx
|
||||
match! db.TryRequestById reqId (userId ctx) with
|
||||
| Some req ->
|
||||
let! show = ctx.BindJsonAsync<Models.Show> ()
|
||||
{ req with showAfter = show.showAfter }
|
||||
|> db.UpdateEntry
|
||||
let! _ = db.SaveChangesAsync ()
|
||||
use sess = session ctx
|
||||
let usrId = userId ctx
|
||||
let reqId = toReqId requestId
|
||||
match! Data.tryRequestById reqId usrId sess with
|
||||
| Some _ ->
|
||||
Data.updateShowAfter reqId (Ticks 0L) sess
|
||||
do! Data.saveChanges sess
|
||||
return! setStatusCode 204 next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
/// PATCH /api/request/[req-id]/snooze
|
||||
let snooze reqId : HttpHandler =
|
||||
let snooze requestId : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx ->
|
||||
task {
|
||||
let db = db ctx
|
||||
match! db.TryRequestById reqId (userId ctx) with
|
||||
| Some req ->
|
||||
use sess = session ctx
|
||||
let usrId = userId ctx
|
||||
let reqId = toReqId requestId
|
||||
match! Data.tryRequestById reqId usrId sess with
|
||||
| Some _ ->
|
||||
let! until = ctx.BindJsonAsync<Models.SnoozeUntil> ()
|
||||
{ req with snoozedUntil = until.until; showAfter = until.until }
|
||||
|> db.UpdateEntry
|
||||
let! _ = db.SaveChangesAsync ()
|
||||
Data.updateSnoozed reqId (Ticks until.until) sess
|
||||
do! Data.saveChanges sess
|
||||
return! setStatusCode 204 next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
/// PATCH /api/request/[req-id]/recurrence
|
||||
let updateRecurrence reqId : HttpHandler =
|
||||
let updateRecurrence requestId : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx ->
|
||||
task {
|
||||
let db = db ctx
|
||||
match! db.TryRequestById reqId (userId ctx) with
|
||||
| Some req ->
|
||||
use sess = session ctx
|
||||
let usrId = userId ctx
|
||||
let reqId = toReqId requestId
|
||||
match! Data.tryRequestById reqId usrId sess with
|
||||
| Some _ ->
|
||||
let! recur = ctx.BindJsonAsync<Models.Recurrence> ()
|
||||
{ req with recurType = recur.recurType; recurCount = recur.recurCount }
|
||||
|> db.UpdateEntry
|
||||
let! _ = db.SaveChangesAsync ()
|
||||
let recurrence = Recurrence.fromString recur.recurType
|
||||
Data.updateRecurrence reqId recurrence recur.recurCount sess
|
||||
match recurrence with Immediate -> Data.updateShowAfter reqId (Ticks 0L) sess | _ -> ()
|
||||
do! Data.saveChanges sess
|
||||
return! setStatusCode 204 next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
open Giraffe.TokenRouter
|
||||
|
||||
/// The routes for myPrayerJournal
|
||||
let webApp : HttpHandler =
|
||||
router Error.notFound [
|
||||
route "/" Vue.app
|
||||
subRoute "/api/" [
|
||||
GET [
|
||||
route "journal" Journal.journal
|
||||
subRoute "request" [
|
||||
route "s/answered" Request.answered
|
||||
routef "/%s/full" Request.getFull
|
||||
routef "/%s/notes" Request.getNotes
|
||||
routef "/%s" Request.get
|
||||
]
|
||||
]
|
||||
PATCH [
|
||||
subRoute "request" [
|
||||
routef "/%s/recurrence" Request.updateRecurrence
|
||||
routef "/%s/show" Request.show
|
||||
routef "/%s/snooze" Request.snooze
|
||||
]
|
||||
]
|
||||
POST [
|
||||
subRoute "request" [
|
||||
route "" Request.add
|
||||
routef "/%s/history" Request.addHistory
|
||||
routef "/%s/note" Request.addNote
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
@ -1,30 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netcoreapp2.2</TargetFramework>
|
||||
<Version>1.2.2.0</Version>
|
||||
<Version>2.0.0.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="Domain.fs" />
|
||||
<Compile Include="Data.fs" />
|
||||
<Compile Include="Handlers.fs" />
|
||||
<Compile Include="Program.fs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FSharp.EFCore.OptionConverter" Version="1.0.0" />
|
||||
<PackageReference Include="FunctionalCuid" Version="1.0.0" />
|
||||
<PackageReference Include="Giraffe" Version="3.6.0" />
|
||||
<PackageReference Include="Giraffe.TokenRouter" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.App" />
|
||||
<PackageReference Include="Microsoft.FSharpLu" Version="0.10.29" />
|
||||
<PackageReference Include="Microsoft.FSharpLu.Json" Version="0.10.29" />
|
||||
<PackageReference Include="NCuid.NetCore" Version="1.0.1" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="2.2.0" />
|
||||
<PackageReference Include="RavenDb.Client" Version="4.2.1" />
|
||||
<PackageReference Include="TaskBuilder.fs" Version="2.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="FSharp.Core" Version="4.6.2" />
|
||||
<PackageReference Update="FSharp.Core" Version="4.7.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
144
src/MyPrayerJournal.Api/Program.fs
Normal file
144
src/MyPrayerJournal.Api/Program.fs
Normal file
@ -0,0 +1,144 @@
|
||||
module MyPrayerJournal.Api
|
||||
|
||||
open Microsoft.AspNetCore.Builder
|
||||
open Microsoft.AspNetCore.Hosting
|
||||
open System.IO
|
||||
|
||||
/// Configuration functions for the application
|
||||
module Configure =
|
||||
|
||||
/// Configure the content root
|
||||
let contentRoot root (bldr : IWebHostBuilder) =
|
||||
bldr.UseContentRoot root
|
||||
|
||||
open Microsoft.Extensions.Configuration
|
||||
|
||||
/// Configure the application configuration
|
||||
let appConfiguration (bldr : IWebHostBuilder) =
|
||||
let configuration (ctx : WebHostBuilderContext) (cfg : IConfigurationBuilder) =
|
||||
cfg.SetBasePath(ctx.HostingEnvironment.ContentRootPath)
|
||||
.AddJsonFile("appsettings.json", optional = true, reloadOnChange = true)
|
||||
.AddJsonFile(sprintf "appsettings.%s.json" ctx.HostingEnvironment.EnvironmentName)
|
||||
.AddEnvironmentVariables ()
|
||||
|> ignore
|
||||
bldr.ConfigureAppConfiguration configuration
|
||||
|
||||
open Microsoft.AspNetCore.Server.Kestrel.Core
|
||||
|
||||
/// Configure Kestrel from appsettings.json
|
||||
let kestrel (bldr : IWebHostBuilder) =
|
||||
let kestrelOpts (ctx : WebHostBuilderContext) (opts : KestrelServerOptions) =
|
||||
(ctx.Configuration.GetSection >> opts.Configure >> ignore) "Kestrel"
|
||||
bldr.UseKestrel().ConfigureKestrel kestrelOpts
|
||||
|
||||
/// Configure the web root directory
|
||||
let webRoot pathSegments (bldr : IWebHostBuilder) =
|
||||
(Path.Combine >> bldr.UseWebRoot) pathSegments
|
||||
|
||||
open Giraffe
|
||||
open Giraffe.Serialization
|
||||
open Microsoft.AspNetCore.Authentication.JwtBearer
|
||||
open Microsoft.Extensions.DependencyInjection
|
||||
open MyPrayerJournal.Indexes
|
||||
open Newtonsoft.Json
|
||||
open Newtonsoft.Json.Serialization
|
||||
open Raven.Client.Documents
|
||||
open Raven.Client.Documents.Indexes
|
||||
open System.Security.Cryptography.X509Certificates
|
||||
|
||||
/// Configure dependency injection
|
||||
let services (bldr : IWebHostBuilder) =
|
||||
let svcs (sc : IServiceCollection) =
|
||||
/// Custom settings for the JSON serializer (uses compact representation for options and DUs)
|
||||
let jsonSettings =
|
||||
let x = NewtonsoftJsonSerializer.DefaultSettings
|
||||
Converters.all |> List.ofSeq |> List.iter x.Converters.Add
|
||||
x.NullValueHandling <- NullValueHandling.Ignore
|
||||
x.MissingMemberHandling <- MissingMemberHandling.Error
|
||||
x.Formatting <- Formatting.Indented
|
||||
x.ContractResolver <- DefaultContractResolver ()
|
||||
x
|
||||
|
||||
use sp = sc.BuildServiceProvider ()
|
||||
let cfg = sp.GetRequiredService<IConfiguration> ()
|
||||
sc.AddGiraffe()
|
||||
.AddAuthentication(
|
||||
/// Use HTTP "Bearer" authentication with JWTs
|
||||
fun opts ->
|
||||
opts.DefaultAuthenticateScheme <- JwtBearerDefaults.AuthenticationScheme
|
||||
opts.DefaultChallengeScheme <- JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(
|
||||
/// Configure JWT options with Auth0 options from configuration
|
||||
fun opts ->
|
||||
let jwtCfg = cfg.GetSection "Auth0"
|
||||
opts.Authority <- sprintf "https://%s/" jwtCfg.["Domain"]
|
||||
opts.Audience <- jwtCfg.["Id"]
|
||||
)
|
||||
|> ignore
|
||||
sc.AddSingleton<IJsonSerializer> (NewtonsoftJsonSerializer jsonSettings)
|
||||
|> ignore
|
||||
let config = sc.BuildServiceProvider().GetRequiredService<IConfiguration>().GetSection "RavenDB"
|
||||
let store = new DocumentStore ()
|
||||
store.Urls <- [| config.["URL"] |]
|
||||
store.Database <- config.["Database"]
|
||||
match isNull config.["Certificate"] with
|
||||
| true -> ()
|
||||
| false -> store.Certificate <- new X509Certificate2 (config.["Certificate"], config.["Password"])
|
||||
store.Conventions.CustomizeJsonSerializer <- fun x -> Converters.all |> List.ofSeq |> List.iter x.Converters.Add
|
||||
store.Initialize () |> (sc.AddSingleton >> ignore)
|
||||
IndexCreation.CreateIndexes (typeof<Requests_AsJournal>.Assembly, store)
|
||||
bldr.ConfigureServices svcs
|
||||
|
||||
open Microsoft.Extensions.Logging
|
||||
|
||||
/// Configure logging
|
||||
let logging (bldr : IWebHostBuilder) =
|
||||
let logz (log : ILoggingBuilder) =
|
||||
let env = log.Services.BuildServiceProvider().GetService<IHostingEnvironment> ()
|
||||
match env.IsDevelopment () with
|
||||
| true -> log
|
||||
| false -> log.AddFilter(fun l -> l > LogLevel.Information)
|
||||
|> function l -> l.AddConsole().AddDebug()
|
||||
|> ignore
|
||||
bldr.ConfigureLogging logz
|
||||
|
||||
open System
|
||||
|
||||
/// Configure the web application
|
||||
let application (bldr : IWebHostBuilder) =
|
||||
let appConfig =
|
||||
Action<IApplicationBuilder> (
|
||||
fun (app : IApplicationBuilder) ->
|
||||
let env = app.ApplicationServices.GetService<IHostingEnvironment> ()
|
||||
match env.IsDevelopment () with
|
||||
| true -> app.UseDeveloperExceptionPage ()
|
||||
| false -> app.UseGiraffeErrorHandler Handlers.Error.error
|
||||
|> function
|
||||
| a ->
|
||||
a.UseAuthentication()
|
||||
.UseStaticFiles()
|
||||
.UseGiraffe Handlers.webApp
|
||||
|> ignore)
|
||||
bldr.Configure appConfig
|
||||
|
||||
/// Compose all the configurations into one
|
||||
let webHost appRoot pathSegments =
|
||||
contentRoot appRoot
|
||||
>> appConfiguration
|
||||
>> kestrel
|
||||
>> webRoot (Array.concat [ [| appRoot |]; pathSegments ])
|
||||
>> services
|
||||
>> logging
|
||||
>> application
|
||||
|
||||
/// Build the web host from the given configuration
|
||||
let buildHost (bldr : IWebHostBuilder) = bldr.Build ()
|
||||
|
||||
let exitCode = 0
|
||||
|
||||
[<EntryPoint>]
|
||||
let main _ =
|
||||
let appRoot = Directory.GetCurrentDirectory ()
|
||||
use host = WebHostBuilder() |> (Configure.webHost appRoot [| "wwwroot" |] >> Configure.buildHost)
|
||||
host.Run ()
|
||||
exitCode
|
37
src/MyPrayerJournal.sln
Normal file
37
src/MyPrayerJournal.sln
Normal file
@ -0,0 +1,37 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 16
|
||||
VisualStudioVersion = 16.0.28721.148
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "MyPrayerJournal.Api", "MyPrayerJournal.Api\MyPrayerJournal.Api.fsproj", "{1887D1E1-544A-4F54-B266-38E7867DC842}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|iPhone = Debug|iPhone
|
||||
Debug|iPhoneSimulator = Debug|iPhoneSimulator
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|iPhone = Release|iPhone
|
||||
Release|iPhoneSimulator = Release|iPhoneSimulator
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{1887D1E1-544A-4F54-B266-38E7867DC842}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{1887D1E1-544A-4F54-B266-38E7867DC842}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{1887D1E1-544A-4F54-B266-38E7867DC842}.Debug|iPhone.ActiveCfg = Debug|Any CPU
|
||||
{1887D1E1-544A-4F54-B266-38E7867DC842}.Debug|iPhone.Build.0 = Debug|Any CPU
|
||||
{1887D1E1-544A-4F54-B266-38E7867DC842}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU
|
||||
{1887D1E1-544A-4F54-B266-38E7867DC842}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU
|
||||
{1887D1E1-544A-4F54-B266-38E7867DC842}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{1887D1E1-544A-4F54-B266-38E7867DC842}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{1887D1E1-544A-4F54-B266-38E7867DC842}.Release|iPhone.ActiveCfg = Release|Any CPU
|
||||
{1887D1E1-544A-4F54-B266-38E7867DC842}.Release|iPhone.Build.0 = Release|Any CPU
|
||||
{1887D1E1-544A-4F54-B266-38E7867DC842}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU
|
||||
{1887D1E1-544A-4F54-B266-38E7867DC842}.Release|iPhoneSimulator.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {8E2447D9-52F0-4A0D-BB61-A83C19353D7C}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
@ -1,275 +0,0 @@
|
||||
namespace MyPrayerJournal
|
||||
|
||||
open FSharp.Control.Tasks.V2.ContextInsensitive
|
||||
open Microsoft.EntityFrameworkCore
|
||||
open Microsoft.FSharpLu
|
||||
|
||||
/// Entities for use in the data model for myPrayerJournal
|
||||
[<AutoOpen>]
|
||||
module Entities =
|
||||
|
||||
open FSharp.EFCore.OptionConverter
|
||||
open System.Collections.Generic
|
||||
|
||||
/// Type alias for a Collision-resistant Unique IDentifier
|
||||
type Cuid = string
|
||||
|
||||
/// Request ID is a CUID
|
||||
type RequestId = Cuid
|
||||
|
||||
/// User ID is a string (the "sub" part of the JWT)
|
||||
type UserId = string
|
||||
|
||||
/// History is a record of action taken on a prayer request, including updates to its text
|
||||
type [<CLIMutable; NoComparison; NoEquality>] History =
|
||||
{ /// The ID of the request to which this history entry applies
|
||||
requestId : RequestId
|
||||
/// The time when this history entry was made
|
||||
asOf : int64
|
||||
/// The status for this history entry
|
||||
status : string
|
||||
/// The text of the update, if applicable
|
||||
text : string option
|
||||
}
|
||||
with
|
||||
/// An empty history entry
|
||||
static member empty =
|
||||
{ requestId = ""
|
||||
asOf = 0L
|
||||
status = ""
|
||||
text = None
|
||||
}
|
||||
|
||||
static member configureEF (mb : ModelBuilder) =
|
||||
mb.Entity<History> (
|
||||
fun m ->
|
||||
m.ToTable "history" |> ignore
|
||||
m.HasKey ("requestId", "asOf") |> ignore
|
||||
m.Property(fun e -> e.requestId).IsRequired () |> ignore
|
||||
m.Property(fun e -> e.asOf).IsRequired () |> ignore
|
||||
m.Property(fun e -> e.status).IsRequired() |> ignore
|
||||
m.Property(fun e -> e.text) |> ignore)
|
||||
|> ignore
|
||||
let typ = mb.Model.FindEntityType(typeof<History>)
|
||||
let prop = typ.FindProperty("text")
|
||||
mb.Model.FindEntityType(typeof<History>).FindProperty("text").SetValueConverter (OptionConverter<string> ())
|
||||
|
||||
/// Note is a note regarding a prayer request that does not result in an update to its text
|
||||
and [<CLIMutable; NoComparison; NoEquality>] Note =
|
||||
{ /// The ID of the request to which this note applies
|
||||
requestId : RequestId
|
||||
/// The time when this note was made
|
||||
asOf : int64
|
||||
/// The text of the notes
|
||||
notes : string
|
||||
}
|
||||
with
|
||||
/// An empty note
|
||||
static member empty =
|
||||
{ requestId = ""
|
||||
asOf = 0L
|
||||
notes = ""
|
||||
}
|
||||
|
||||
static member configureEF (mb : ModelBuilder) =
|
||||
mb.Entity<Note> (
|
||||
fun m ->
|
||||
m.ToTable "note" |> ignore
|
||||
m.HasKey ("requestId", "asOf") |> ignore
|
||||
m.Property(fun e -> e.requestId).IsRequired () |> ignore
|
||||
m.Property(fun e -> e.asOf).IsRequired () |> ignore
|
||||
m.Property(fun e -> e.notes).IsRequired () |> ignore)
|
||||
|> ignore
|
||||
|
||||
/// Request is the identifying record for a prayer request
|
||||
and [<CLIMutable; NoComparison; NoEquality>] Request =
|
||||
{ /// The ID of the request
|
||||
requestId : RequestId
|
||||
/// The time this request was initially entered
|
||||
enteredOn : int64
|
||||
/// The ID of the user to whom this request belongs ("sub" from the JWT)
|
||||
userId : string
|
||||
/// The time at which this request should reappear in the user's journal by manual user choice
|
||||
snoozedUntil : int64
|
||||
/// The time at which this request should reappear in the user's journal by recurrence
|
||||
showAfter : int64
|
||||
/// The type of recurrence for this request
|
||||
recurType : string
|
||||
/// How many of the recurrence intervals should occur between appearances in the journal
|
||||
recurCount : int16
|
||||
/// The history entries for this request
|
||||
history : ICollection<History>
|
||||
/// The notes for this request
|
||||
notes : ICollection<Note>
|
||||
}
|
||||
with
|
||||
/// An empty request
|
||||
static member empty =
|
||||
{ requestId = ""
|
||||
enteredOn = 0L
|
||||
userId = ""
|
||||
snoozedUntil = 0L
|
||||
showAfter = 0L
|
||||
recurType = "immediate"
|
||||
recurCount = 0s
|
||||
history = List<History> ()
|
||||
notes = List<Note> ()
|
||||
}
|
||||
|
||||
static member configureEF (mb : ModelBuilder) =
|
||||
mb.Entity<Request> (
|
||||
fun m ->
|
||||
m.ToTable "request" |> ignore
|
||||
m.HasKey(fun e -> e.requestId :> obj) |> ignore
|
||||
m.Property(fun e -> e.requestId).IsRequired () |> ignore
|
||||
m.Property(fun e -> e.enteredOn).IsRequired () |> ignore
|
||||
m.Property(fun e -> e.userId).IsRequired () |> ignore
|
||||
m.Property(fun e -> e.snoozedUntil).IsRequired () |> ignore
|
||||
m.Property(fun e -> e.showAfter).IsRequired () |> ignore
|
||||
m.Property(fun e -> e.recurType).IsRequired() |> ignore
|
||||
m.Property(fun e -> e.recurCount).IsRequired() |> ignore
|
||||
m.HasMany(fun e -> e.history :> IEnumerable<History>)
|
||||
.WithOne()
|
||||
.HasForeignKey(fun e -> e.requestId :> obj)
|
||||
|> ignore
|
||||
m.HasMany(fun e -> e.notes :> IEnumerable<Note>)
|
||||
.WithOne()
|
||||
.HasForeignKey(fun e -> e.requestId :> obj)
|
||||
|> ignore)
|
||||
|> ignore
|
||||
|
||||
/// JournalRequest is the form of a prayer request returned for the request journal display. It also contains
|
||||
/// properties that may be filled for history and notes
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type JournalRequest =
|
||||
{ /// The ID of the request
|
||||
requestId : RequestId
|
||||
/// The ID of the user to whom the request belongs
|
||||
userId : string
|
||||
/// The current text of the request
|
||||
text : string
|
||||
/// The last time action was taken on the request
|
||||
asOf : int64
|
||||
/// The last status for the request
|
||||
lastStatus : string
|
||||
/// The time that this request should reappear in the user's journal
|
||||
snoozedUntil : int64
|
||||
/// The time after which this request should reappear in the user's journal by configured recurrence
|
||||
showAfter : int64
|
||||
/// The type of recurrence for this request
|
||||
recurType : string
|
||||
/// How many of the recurrence intervals should occur between appearances in the journal
|
||||
recurCount : int16
|
||||
/// History entries for the request
|
||||
history : History list
|
||||
/// Note entries for the request
|
||||
notes : Note list
|
||||
}
|
||||
with
|
||||
static member configureEF (mb : ModelBuilder) =
|
||||
mb.Query<JournalRequest> (
|
||||
fun m ->
|
||||
m.ToView "journal" |> ignore
|
||||
m.Ignore(fun e -> e.history :> obj) |> ignore
|
||||
m.Ignore(fun e -> e.notes :> obj) |> ignore)
|
||||
|> ignore
|
||||
|
||||
|
||||
open System.Linq
|
||||
|
||||
/// Data context
|
||||
type AppDbContext (opts : DbContextOptions<AppDbContext>) =
|
||||
inherit DbContext (opts)
|
||||
|
||||
[<DefaultValue>]
|
||||
val mutable private history : DbSet<History>
|
||||
[<DefaultValue>]
|
||||
val mutable private notes : DbSet<Note>
|
||||
[<DefaultValue>]
|
||||
val mutable private requests : DbSet<Request>
|
||||
[<DefaultValue>]
|
||||
val mutable private journal : DbQuery<JournalRequest>
|
||||
|
||||
member this.History
|
||||
with get () = this.history
|
||||
and set v = this.history <- v
|
||||
member this.Notes
|
||||
with get () = this.notes
|
||||
and set v = this.notes <- v
|
||||
member this.Requests
|
||||
with get () = this.requests
|
||||
and set v = this.requests <- v
|
||||
member this.Journal
|
||||
with get () = this.journal
|
||||
and set v = this.journal <- v
|
||||
|
||||
override __.OnModelCreating (mb : ModelBuilder) =
|
||||
base.OnModelCreating mb
|
||||
[ History.configureEF
|
||||
Note.configureEF
|
||||
Request.configureEF
|
||||
JournalRequest.configureEF
|
||||
]
|
||||
|> List.iter (fun x -> x mb)
|
||||
|
||||
/// Register a disconnected entity with the context, having the given state
|
||||
member private this.RegisterAs<'TEntity when 'TEntity : not struct> state e =
|
||||
this.Entry<'TEntity>(e).State <- state
|
||||
|
||||
/// Add an entity instance to the context
|
||||
member this.AddEntry e =
|
||||
this.RegisterAs EntityState.Added e
|
||||
|
||||
/// Update the entity instance's values
|
||||
member this.UpdateEntry e =
|
||||
this.RegisterAs EntityState.Modified e
|
||||
|
||||
/// Retrieve all answered requests for the given user
|
||||
member this.AnsweredRequests userId : JournalRequest seq =
|
||||
upcast this.Journal
|
||||
.Where(fun r -> r.userId = userId && r.lastStatus = "Answered")
|
||||
.OrderByDescending(fun r -> r.asOf)
|
||||
|
||||
/// Retrieve the user's current journal
|
||||
member this.JournalByUserId userId : JournalRequest seq =
|
||||
upcast this.Journal
|
||||
.Where(fun r -> r.userId = userId && r.lastStatus <> "Answered")
|
||||
.OrderBy(fun r -> r.showAfter)
|
||||
|
||||
/// Retrieve a request by its ID and user ID
|
||||
member this.TryRequestById reqId userId =
|
||||
task {
|
||||
let! req = this.Requests.AsNoTracking().FirstOrDefaultAsync(fun r -> r.requestId = reqId && r.userId = userId)
|
||||
return Option.fromObject req
|
||||
}
|
||||
|
||||
/// Retrieve notes for a request by its ID and user ID
|
||||
member this.NotesById reqId userId =
|
||||
task {
|
||||
match! this.TryRequestById reqId userId with
|
||||
| Some _ -> return this.Notes.AsNoTracking().Where(fun n -> n.requestId = reqId) |> List.ofSeq
|
||||
| None -> return []
|
||||
}
|
||||
|
||||
/// Retrieve a journal request by its ID and user ID
|
||||
member this.TryJournalById reqId userId =
|
||||
task {
|
||||
let! req = this.Journal.FirstOrDefaultAsync(fun r -> r.requestId = reqId && r.userId = userId)
|
||||
return Option.fromObject req
|
||||
}
|
||||
|
||||
/// Retrieve a request, including its history and notes, by its ID and user ID
|
||||
member this.TryFullRequestById requestId userId =
|
||||
task {
|
||||
match! this.TryJournalById requestId userId with
|
||||
| Some req ->
|
||||
let! fullReq =
|
||||
this.Requests.AsNoTracking()
|
||||
.Include(fun r -> r.history)
|
||||
.Include(fun r -> r.notes)
|
||||
.FirstOrDefaultAsync(fun r -> r.requestId = requestId && r.userId = userId)
|
||||
match Option.fromObject fullReq with
|
||||
| Some _ -> return Some { req with history = List.ofSeq fullReq.history; notes = List.ofSeq fullReq.notes }
|
||||
| None -> return None
|
||||
| None -> return None
|
||||
}
|
@ -1,139 +0,0 @@
|
||||
namespace MyPrayerJournal.Api
|
||||
|
||||
open Microsoft.AspNetCore.Builder
|
||||
open Microsoft.AspNetCore.Hosting
|
||||
open System
|
||||
|
||||
/// Configuration functions for the application
|
||||
module Configure =
|
||||
|
||||
open Giraffe
|
||||
open Giraffe.Serialization
|
||||
open Giraffe.TokenRouter
|
||||
open Microsoft.AspNetCore.Authentication.JwtBearer
|
||||
open Microsoft.AspNetCore.Server.Kestrel.Core
|
||||
open Microsoft.EntityFrameworkCore
|
||||
open Microsoft.Extensions.Configuration
|
||||
open Microsoft.Extensions.DependencyInjection
|
||||
open Microsoft.Extensions.Logging
|
||||
open Microsoft.FSharpLu.Json
|
||||
open MyPrayerJournal
|
||||
open Newtonsoft.Json
|
||||
|
||||
/// Set up the configuration for the app
|
||||
let configuration (ctx : WebHostBuilderContext) (cfg : IConfigurationBuilder) =
|
||||
cfg.SetBasePath(ctx.HostingEnvironment.ContentRootPath)
|
||||
.AddJsonFile("appsettings.json", optional = true, reloadOnChange = true)
|
||||
.AddJsonFile(sprintf "appsettings.%s.json" ctx.HostingEnvironment.EnvironmentName)
|
||||
.AddEnvironmentVariables()
|
||||
|> ignore
|
||||
|
||||
/// Configure Kestrel from appsettings.json
|
||||
let kestrel (ctx : WebHostBuilderContext) (opts : KestrelServerOptions) =
|
||||
(ctx.Configuration.GetSection >> opts.Configure >> ignore) "Kestrel"
|
||||
|
||||
/// Custom settings for the JSON serializer (uses compact representation for options and DUs)
|
||||
let jsonSettings =
|
||||
let x = NewtonsoftJsonSerializer.DefaultSettings
|
||||
x.Converters.Add (CompactUnionJsonConverter (true))
|
||||
x.NullValueHandling <- NullValueHandling.Ignore
|
||||
x.MissingMemberHandling <- MissingMemberHandling.Error
|
||||
x.Formatting <- Formatting.Indented
|
||||
x
|
||||
|
||||
/// Configure dependency injection
|
||||
let services (sc : IServiceCollection) =
|
||||
use sp = sc.BuildServiceProvider()
|
||||
let cfg = sp.GetRequiredService<IConfiguration> ()
|
||||
sc.AddGiraffe()
|
||||
.AddAuthentication(
|
||||
/// Use HTTP "Bearer" authentication with JWTs
|
||||
fun opts ->
|
||||
opts.DefaultAuthenticateScheme <- JwtBearerDefaults.AuthenticationScheme
|
||||
opts.DefaultChallengeScheme <- JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(
|
||||
/// Configure JWT options with Auth0 options from configuration
|
||||
fun opts ->
|
||||
let jwtCfg = cfg.GetSection "Auth0"
|
||||
opts.Authority <- sprintf "https://%s/" jwtCfg.["Domain"]
|
||||
opts.Audience <- jwtCfg.["Id"])
|
||||
|> ignore
|
||||
sc.AddDbContext<AppDbContext>(fun opts -> opts.UseNpgsql(cfg.GetConnectionString "mpj") |> ignore)
|
||||
.AddSingleton<IJsonSerializer>(NewtonsoftJsonSerializer jsonSettings)
|
||||
|> ignore
|
||||
|
||||
/// Routes for the available URLs within myPrayerJournal
|
||||
let webApp =
|
||||
router Handlers.Error.notFound [
|
||||
route "/" Handlers.Vue.app
|
||||
subRoute "/api/" [
|
||||
GET [
|
||||
route "journal" Handlers.Journal.journal
|
||||
subRoute "request" [
|
||||
route "s/answered" Handlers.Request.answered
|
||||
routef "/%s/full" Handlers.Request.getFull
|
||||
routef "/%s/notes" Handlers.Request.getNotes
|
||||
routef "/%s" Handlers.Request.get
|
||||
]
|
||||
]
|
||||
PATCH [
|
||||
subRoute "request" [
|
||||
routef "/%s/recurrence" Handlers.Request.updateRecurrence
|
||||
routef "/%s/show" Handlers.Request.show
|
||||
routef "/%s/snooze" Handlers.Request.snooze
|
||||
]
|
||||
]
|
||||
POST [
|
||||
subRoute "request" [
|
||||
route "" Handlers.Request.add
|
||||
routef "/%s/history" Handlers.Request.addHistory
|
||||
routef "/%s/note" Handlers.Request.addNote
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
/// Configure the web application
|
||||
let application (app : IApplicationBuilder) =
|
||||
let env = app.ApplicationServices.GetService<IHostingEnvironment> ()
|
||||
match env.IsDevelopment () with
|
||||
| true -> app.UseDeveloperExceptionPage ()
|
||||
| false -> app.UseGiraffeErrorHandler Handlers.Error.error
|
||||
|> function
|
||||
| a ->
|
||||
a.UseAuthentication()
|
||||
.UseStaticFiles()
|
||||
.UseGiraffe webApp
|
||||
|> ignore
|
||||
|
||||
/// Configure logging
|
||||
let logging (log : ILoggingBuilder) =
|
||||
let env = log.Services.BuildServiceProvider().GetService<IHostingEnvironment> ()
|
||||
match env.IsDevelopment () with
|
||||
| true -> log
|
||||
| false -> log.AddFilter(fun l -> l > LogLevel.Information)
|
||||
|> function l -> l.AddConsole().AddDebug()
|
||||
|> ignore
|
||||
|
||||
|
||||
module Program =
|
||||
|
||||
open System.IO
|
||||
|
||||
let exitCode = 0
|
||||
|
||||
let CreateWebHostBuilder _ =
|
||||
let contentRoot = Directory.GetCurrentDirectory ()
|
||||
WebHostBuilder()
|
||||
.UseContentRoot(contentRoot)
|
||||
.ConfigureAppConfiguration(Configure.configuration)
|
||||
.UseKestrel(Configure.kestrel)
|
||||
.UseWebRoot(Path.Combine (contentRoot, "wwwroot"))
|
||||
.ConfigureServices(Configure.services)
|
||||
.ConfigureLogging(Configure.logging)
|
||||
.Configure(Action<IApplicationBuilder> Configure.application)
|
||||
|
||||
[<EntryPoint>]
|
||||
let main args =
|
||||
CreateWebHostBuilder(args).Build().Run()
|
||||
exitCode
|
@ -1,25 +0,0 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio 15
|
||||
VisualStudioVersion = 15.0.27703.2035
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "MyPrayerJournal.Api", "MyPrayerJournal.Api\MyPrayerJournal.Api.fsproj", "{E0E5240C-00DC-428A-899A-DA4F06625B8A}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{E0E5240C-00DC-428A-899A-DA4F06625B8A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E0E5240C-00DC-428A-899A-DA4F06625B8A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E0E5240C-00DC-428A-899A-DA4F06625B8A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E0E5240C-00DC-428A-899A-DA4F06625B8A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {7EAB6243-94B3-49A5-BA64-7F01B8BE7CB9}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
@ -1,25 +1,24 @@
|
||||
{
|
||||
"name": "my-prayer-journal",
|
||||
"version": "1.2.2",
|
||||
"version": "2.0.0",
|
||||
"description": "myPrayerJournal - Front End",
|
||||
"author": "Daniel J. Summers <daniel@bitbadger.solutions>",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"serve": "vue-cli-service serve --port 8081",
|
||||
"build": "vue-cli-service build --modern",
|
||||
"lint": "vue-cli-service lint",
|
||||
"apistart": "cd ../api/MyPrayerJournal.Api && dotnet run",
|
||||
"vue": "vue-cli-service build --modern && cd ../api/MyPrayerJournal.Api && dotnet run",
|
||||
"publish": "vue-cli-service build --modern && cd ../api/MyPrayerJournal.Api && dotnet publish -c Release"
|
||||
"apistart": "cd ../MyPrayerJournal.Api && dotnet run",
|
||||
"vue": "vue-cli-service build --modern && cd ../MyPrayerJournal.Api && dotnet run",
|
||||
"publish": "vue-cli-service build --modern && cd ../MyPrayerJournal.Api && dotnet publish -c Release"
|
||||
},
|
||||
"dependencies": {
|
||||
"auth0-js": "^9.7.3",
|
||||
"axios": "^0.19.0",
|
||||
"moment": "^2.18.1",
|
||||
"vue": "^2.5.15",
|
||||
"vue-progressbar": "^0.7.3",
|
||||
"vue-material": "^1.0.0-beta-11",
|
||||
"vue-router": "^3.0.0",
|
||||
"vue-toast": "^3.1.0",
|
||||
"vuex": "^3.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -27,8 +26,11 @@
|
||||
"@vue/cli-plugin-eslint": "^3.0.0",
|
||||
"@vue/cli-service": "^3.0.0",
|
||||
"@vue/eslint-config-standard": "^4.0.0",
|
||||
"node-sass": "^4.12.0",
|
||||
"pug": "^2.0.1",
|
||||
"pug-plain-loader": "^1.0.0",
|
||||
"vue-template-compiler": "^2.5.17"
|
||||
"sass-loader": "^7.3.1",
|
||||
"vue-template-compiler": "^2.5.17",
|
||||
"webpack-bundle-analyzer": "^3.4.1"
|
||||
}
|
||||
}
|
||||
|
@ -1,26 +1,41 @@
|
||||
<template lang="pug">
|
||||
#app(role='application')
|
||||
navigation
|
||||
#content
|
||||
router-view
|
||||
vue-progress-bar
|
||||
toast(ref='toast')
|
||||
footer.mpj-text-right.mpj-muted-text
|
||||
p
|
||||
| myPrayerJournal v{{ version }}
|
||||
br
|
||||
em: small.
|
||||
#[router-link(:to="{ name: 'PrivacyPolicy' }") Privacy Policy] •
|
||||
#[router-link(:to="{ name: 'TermsOfService' }") Terms of Service] •
|
||||
#[a(href='https://github.com/bit-badger/myprayerjournal' target='_blank') Developed] and hosted by
|
||||
#[a(href='https://bitbadger.solutions' target='_blank') Bit Badger Solutions]
|
||||
#app.page-container
|
||||
md-app(md-waterfall md-mode='fixed-last' role='application')
|
||||
md-app-toolbar.md-large.md-dense.md-primary
|
||||
.md-toolbar-row
|
||||
.md-toolbar-section-start
|
||||
router-link(to='/').md-title
|
||||
span(style='font-weight:100;') my
|
||||
span(style='font-weight:400;') Prayer
|
||||
span(style='font-weight:700;') Journal
|
||||
navigation
|
||||
md-app-content
|
||||
md-progress-bar(v-if='progress.visible'
|
||||
:md-mode='progress.mode')
|
||||
router-view
|
||||
md-snackbar(:md-active.sync='snackbar.visible'
|
||||
md-position='center'
|
||||
:md-duration='snackbar.interval'
|
||||
ref='snackbar') {{ snackbar.message }}
|
||||
footer
|
||||
p.mpj-muted-text.mpj-text-right
|
||||
| myPrayerJournal v{{ version }}
|
||||
br
|
||||
em: small.
|
||||
#[router-link(to='/legal/privacy-policy') Privacy Policy] •
|
||||
#[router-link(to='/legal/terms-of-service') Terms of Service] •
|
||||
#[a(href='https://github.com/bit-badger/myprayerjournal' target='_blank') Developed] and hosted by
|
||||
#[a(href='https://bitbadger.solutions' target='_blank') Bit Badger Solutions]
|
||||
</template>
|
||||
|
||||
<script>
|
||||
'use strict'
|
||||
|
||||
import Navigation from './components/common/Navigation.vue'
|
||||
import Vue from 'vue'
|
||||
|
||||
import Navigation from '@/components/common/Navigation'
|
||||
|
||||
import actions from '@/store/action-types'
|
||||
import { version } from '../package.json'
|
||||
|
||||
export default {
|
||||
@ -29,216 +44,115 @@ export default {
|
||||
Navigation
|
||||
},
|
||||
data () {
|
||||
return {}
|
||||
return {
|
||||
progress: {
|
||||
events: new Vue(),
|
||||
visible: false,
|
||||
mode: 'query'
|
||||
},
|
||||
snackbar: {
|
||||
events: new Vue(),
|
||||
visible: false,
|
||||
message: '',
|
||||
interval: 4000
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.$refs.toast.setOptions({ position: 'bottom right' })
|
||||
async mounted () {
|
||||
this.progress.events.$on('show', this.showProgress)
|
||||
this.progress.events.$on('done', this.hideProgress)
|
||||
this.snackbar.events.$on('info', this.showInfo)
|
||||
this.snackbar.events.$on('error', this.showError)
|
||||
await this.$store.dispatch(actions.CHECK_AUTHENTICATION)
|
||||
},
|
||||
computed: {
|
||||
toast () {
|
||||
return this.$refs.toast
|
||||
},
|
||||
version () {
|
||||
return version.endsWith('.0') ? version.substr(0, version.length - 2) : version
|
||||
return version.endsWith('.0')
|
||||
? version.endsWith('.0.0')
|
||||
? version.substr(0, version.length - 4)
|
||||
: version.substr(0, version.length - 2)
|
||||
: version
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
showSnackbar (message) {
|
||||
this.snackbar.message = message
|
||||
this.snackbar.visible = true
|
||||
},
|
||||
showInfo (message) {
|
||||
this.snackbar.interval = 4000
|
||||
this.showSnackbar(message)
|
||||
},
|
||||
showError (message) {
|
||||
this.snackbar.interval = Infinity
|
||||
this.showSnackbar(message)
|
||||
},
|
||||
showProgress (mode) {
|
||||
this.progress.mode = mode
|
||||
this.progress.visible = true
|
||||
},
|
||||
hideProgress () {
|
||||
this.progress.visible = false
|
||||
},
|
||||
handleLoginEvent (data) {
|
||||
if (!data.loggedIn) {
|
||||
this.showInfo('Logged out successfully')
|
||||
}
|
||||
}
|
||||
},
|
||||
provide () {
|
||||
return {
|
||||
messages: this.snackbar.events,
|
||||
progress: this.progress.events
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
html, body {
|
||||
background-color: whitesmoke;
|
||||
<style lang="sass">
|
||||
@import "~vue-material/dist/theme/engine"
|
||||
@include md-register-theme("default", (primary: md-get-palette-color(green, 800), accent: md-get-palette-color(gray, 700)))
|
||||
@import "~vue-material/dist/theme/all"
|
||||
|
||||
html, body
|
||||
font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;
|
||||
font-size: 1rem;
|
||||
}
|
||||
body {
|
||||
padding-top: 50px;
|
||||
margin: 0;
|
||||
}
|
||||
h1, h2, h3, h4, h5 {
|
||||
font-weight: 500;
|
||||
margin-top: 0;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
h2 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
h3 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
h4 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
h5 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
input, textarea, select {
|
||||
border-radius: .25rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
textarea {
|
||||
font-family: "SFMono-Regular",Consolas,"Liberation Mono",Menlo,Courier,monospace;
|
||||
}
|
||||
input, select {
|
||||
font-family: inherit;
|
||||
}
|
||||
button,
|
||||
a[role="button"] {
|
||||
border: solid 1px #050;
|
||||
border-radius: .5rem;
|
||||
background-color: rgb(235, 235, 235);
|
||||
padding: .25rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
a[role="button"]:link,
|
||||
a[role="button"]:visited {
|
||||
color: black;
|
||||
}
|
||||
button.primary,
|
||||
a[role="button"].primary {
|
||||
background-color: white;
|
||||
border-width: 3px;
|
||||
}
|
||||
button:hover,
|
||||
a[role="button"]:hover {
|
||||
cursor: pointer;
|
||||
background-color: #050;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
label {
|
||||
font-variant: small-caps;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
label.normal {
|
||||
font-variant: unset;
|
||||
font-size: unset;
|
||||
}
|
||||
footer {
|
||||
border-top: solid 1px lightgray;
|
||||
margin-top: 1rem;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
footer p {
|
||||
margin: 0;
|
||||
}
|
||||
a:link, a:visited {
|
||||
color: #050;
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.mpj-main-content {
|
||||
max-width: 60rem;
|
||||
margin: auto;
|
||||
}
|
||||
.mpj-main-content-wide {
|
||||
margin: .5rem;
|
||||
}
|
||||
@media screen and (max-width: 21rem) {
|
||||
.mpj-main-content-wide {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
.mpj-request-text {
|
||||
white-space: pre-line;
|
||||
}
|
||||
.mpj-request-list p {
|
||||
border-top: solid 1px lightgray;
|
||||
}
|
||||
.mpj-request-list p:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
.mpj-request-log {
|
||||
width: 100%;
|
||||
}
|
||||
.mpj-request-log thead th {
|
||||
border-top: solid 1px lightgray;
|
||||
border-bottom: solid 2px lightgray;
|
||||
text-align: left;
|
||||
}
|
||||
.mpj-request-log tbody td {
|
||||
border-bottom: dotted 1px lightgray;
|
||||
vertical-align: top;
|
||||
}
|
||||
.mpj-bg {
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#050), to(whitesmoke));
|
||||
background-image: -webkit-linear-gradient(top, #050, whitesmoke);
|
||||
background-image: -moz-linear-gradient(top, #050, whitesmoke);
|
||||
background-image: linear-gradient(to bottom, #050, whitesmoke);
|
||||
}
|
||||
.mpj-text-center {
|
||||
text-align: center;
|
||||
}
|
||||
.mpj-text-nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mpj-text-right {
|
||||
text-align: right;
|
||||
}
|
||||
.mpj-muted-text {
|
||||
color: rgba(0, 0, 0, .6);
|
||||
}
|
||||
.mpj-narrow {
|
||||
max-width: 40rem;
|
||||
margin: auto;
|
||||
}
|
||||
.mpj-skinny {
|
||||
max-width: 20rem;
|
||||
margin: auto;
|
||||
}
|
||||
.mpj-full-width {
|
||||
width: 100%;
|
||||
}
|
||||
.mpj-modal {
|
||||
position: fixed;
|
||||
z-index: 8;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
background-color: rgba(0, 0, 0, .4);
|
||||
}
|
||||
.mpj-modal-content {
|
||||
background-color: whitesmoke;
|
||||
border: solid 1px #050;
|
||||
border-radius: .5rem;
|
||||
animation-name: animatetop;
|
||||
animation-duration: 0.4s;
|
||||
padding: 1rem;
|
||||
margin-top: 4rem;
|
||||
}
|
||||
@keyframes animatetop {
|
||||
from {
|
||||
top: -300px;
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
top: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.mpj-modal-content header {
|
||||
margin: -1rem -1rem .5rem;
|
||||
border-radius: .4rem;
|
||||
}
|
||||
.mpj-modal-content header h5 {
|
||||
color: white;
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
}
|
||||
.mpj-margin {
|
||||
margin-left: 1rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
.material-icons {
|
||||
vertical-align: middle;
|
||||
}
|
||||
font-size: 1rem
|
||||
p
|
||||
margin-bottom: 0
|
||||
footer
|
||||
border-top: solid 1px lightgray
|
||||
margin: 1rem -1rem 0
|
||||
padding: 0 1rem
|
||||
footer p
|
||||
margin: 0
|
||||
.mpj-full-page-card
|
||||
font-size: 1rem
|
||||
line-height: 1.25rem
|
||||
.mpj-main-content
|
||||
max-width: 60rem
|
||||
margin: auto
|
||||
.mpj-request-text
|
||||
white-space: pre-line
|
||||
p.mpj-request-text
|
||||
margin-top: 0
|
||||
.mpj-text-center
|
||||
text-align: center
|
||||
.mpj-text-nowrap
|
||||
white-space: nowrap
|
||||
.mpj-text-right
|
||||
text-align: right
|
||||
.mpj-muted-text
|
||||
color: rgba(0, 0, 0, .6)
|
||||
.mpj-valign-top
|
||||
vertical-align: top
|
||||
.mpj-narrow
|
||||
max-width: 40rem
|
||||
margin: auto
|
||||
.mpj-skinny
|
||||
max-width: 20rem
|
||||
margin: auto
|
||||
.mpj-full-width
|
||||
width: 100%
|
||||
.md-progress-bar
|
||||
margin: 24px
|
||||
</style>
|
||||
|
@ -15,12 +15,12 @@ export default {
|
||||
* Set the bearer token for all future requests
|
||||
* @param {string} token The token to use to identify the user to the server
|
||||
*/
|
||||
setBearer: token => { http.defaults.headers.common['authorization'] = `Bearer ${token}` },
|
||||
setBearer: token => { http.defaults.headers.common['Authorization'] = `Bearer ${token}` },
|
||||
|
||||
/**
|
||||
* Remove the bearer token
|
||||
*/
|
||||
removeBearer: () => delete http.defaults.headers.common['authorization'],
|
||||
removeBearer: () => delete http.defaults.headers.common['Authorization'],
|
||||
|
||||
/**
|
||||
* Add a note for a prayer request
|
||||
|
@ -1,31 +1,45 @@
|
||||
'use strict'
|
||||
|
||||
import auth0 from 'auth0-js'
|
||||
/* eslint-disable */
|
||||
import auth0 from 'auth0-js'
|
||||
import EventEmitter from 'events'
|
||||
|
||||
import AUTH_CONFIG from './auth0-variables'
|
||||
import mutations from '@/store/mutation-types'
|
||||
import mutations from '@/store/mutation-types'
|
||||
/* es-lint-enable*/
|
||||
|
||||
var tokenRenewalTimeout
|
||||
// Auth0 web authentication instance to use for our calls
|
||||
const webAuth = new auth0.WebAuth({
|
||||
domain: AUTH_CONFIG.domain,
|
||||
clientID: AUTH_CONFIG.clientId,
|
||||
redirectUri: AUTH_CONFIG.appDomain + AUTH_CONFIG.callbackUrl,
|
||||
audience: `https://${AUTH_CONFIG.domain}/userinfo`,
|
||||
responseType: 'token id_token',
|
||||
scope: 'openid profile email'
|
||||
})
|
||||
|
||||
export default class AuthService {
|
||||
constructor () {
|
||||
this.login = this.login.bind(this)
|
||||
this.setSession = this.setSession.bind(this)
|
||||
this.logout = this.logout.bind(this)
|
||||
this.isAuthenticated = this.isAuthenticated.bind(this)
|
||||
/**
|
||||
* A class to handle all authentication calls and determinations
|
||||
*/
|
||||
class AuthService extends EventEmitter {
|
||||
|
||||
// Local storage key for our session data
|
||||
AUTH_SESSION = 'auth-session'
|
||||
|
||||
// Received and calculated values for our ssesion (initially loaded from local storage if present)
|
||||
session = {}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.refreshSession()
|
||||
}
|
||||
|
||||
auth0 = new auth0.WebAuth({
|
||||
domain: AUTH_CONFIG.domain,
|
||||
clientID: AUTH_CONFIG.clientId,
|
||||
redirectUri: AUTH_CONFIG.appDomain + AUTH_CONFIG.callbackUrl,
|
||||
audience: `https://${AUTH_CONFIG.domain}/userinfo`,
|
||||
responseType: 'token id_token',
|
||||
scope: 'openid profile email'
|
||||
})
|
||||
|
||||
login () {
|
||||
this.auth0.authorize()
|
||||
/**
|
||||
* Starts the user log in flow
|
||||
*/
|
||||
login (customState) {
|
||||
webAuth.authorize({
|
||||
appState: customState
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@ -33,7 +47,7 @@ export default class AuthService {
|
||||
*/
|
||||
parseHash () {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.auth0.parseHash((err, authResult) => {
|
||||
webAuth.parseHash((err, authResult) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
@ -44,95 +58,137 @@ export default class AuthService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Promisified userInfo function
|
||||
*
|
||||
* @param token The auth token from the login result
|
||||
* Handle authentication replies from Auth0
|
||||
*
|
||||
* @param store The Vuex store
|
||||
*/
|
||||
userInfo (token) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.auth0.client.userInfo(token, (err, user) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve(user)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
handleAuthentication (store, router) {
|
||||
this.parseHash()
|
||||
.then(authResult => {
|
||||
if (authResult && authResult.accessToken && authResult.idToken) {
|
||||
this.setSession(authResult)
|
||||
this.userInfo(authResult.accessToken)
|
||||
.then(user => {
|
||||
store.commit(mutations.USER_LOGGED_ON, user)
|
||||
router.replace('/journal')
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
router.replace('/')
|
||||
console.log(err)
|
||||
alert(`Error: ${err.error}. Check the console for further details.`)
|
||||
})
|
||||
}
|
||||
|
||||
scheduleRenewal () {
|
||||
let expiresAt = JSON.parse(localStorage.getItem('expires_at'))
|
||||
let delay = expiresAt - Date.now()
|
||||
if (delay > 0) {
|
||||
tokenRenewalTimeout = setTimeout(() => {
|
||||
this.renewToken()
|
||||
}, delay)
|
||||
async handleAuthentication (store) {
|
||||
try {
|
||||
const authResult = await this.parseHash()
|
||||
if (authResult && authResult.accessToken && authResult.idToken) {
|
||||
this.setSession(authResult)
|
||||
store.commit(mutations.USER_LOGGED_ON, this.session.profile)
|
||||
}
|
||||
} catch(err) {
|
||||
console.error(err)
|
||||
alert(`Error: ${err.error}. Check the console for further details.`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the session and commit it to local storage
|
||||
*
|
||||
* @param authResult The authorization result
|
||||
*/
|
||||
setSession (authResult) {
|
||||
// Set the time that the access token will expire at
|
||||
let expiresAt = JSON.stringify(
|
||||
authResult.expiresIn * 1000 + new Date().getTime()
|
||||
)
|
||||
localStorage.setItem('access_token', authResult.accessToken)
|
||||
localStorage.setItem('id_token', authResult.idToken)
|
||||
localStorage.setItem('expires_at', expiresAt)
|
||||
this.scheduleRenewal()
|
||||
this.session.profile = authResult.idTokenPayload
|
||||
this.session.id.token = authResult.idToken
|
||||
this.session.id.expiry = this.session.profile.exp * 1000
|
||||
this.session.access.token = authResult.accessToken
|
||||
this.session.access.expiry = authResult.expiresIn * 1000 + Date.now()
|
||||
|
||||
localStorage.setItem(this.AUTH_SESSION, JSON.stringify(this.session))
|
||||
|
||||
this.emit('loginEvent', {
|
||||
loggedIn: true,
|
||||
profile: authResult.idTokenPayload,
|
||||
state: authResult.appState || {}
|
||||
})
|
||||
}
|
||||
|
||||
renewToken () {
|
||||
console.log('attempting renewal...')
|
||||
this.auth0.renewAuth(
|
||||
{
|
||||
audience: `https://${AUTH_CONFIG.domain}/userinfo`,
|
||||
redirectUri: `${AUTH_CONFIG.appDomain}/static/silent.html`,
|
||||
usePostMessage: true
|
||||
},
|
||||
(err, result) => {
|
||||
if (err) {
|
||||
console.log(err)
|
||||
} else {
|
||||
this.setSession(result)
|
||||
/**
|
||||
* Refresh this instance's session from the one in local storage
|
||||
*/
|
||||
refreshSession () {
|
||||
this.session =
|
||||
localStorage.getItem(this.AUTH_SESSION)
|
||||
? JSON.parse(localStorage.getItem(this.AUTH_SESSION))
|
||||
: { profile: {},
|
||||
id: {
|
||||
token: null,
|
||||
expiry: null
|
||||
},
|
||||
access: {
|
||||
token: null,
|
||||
expiry: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renew authorzation tokens with Auth0
|
||||
*/
|
||||
renewTokens () {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.refreshSession()
|
||||
if (this.session.id.token !== null) {
|
||||
webAuth.checkSession({}, (err, authResult) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
this.setSession(authResult)
|
||||
resolve(authResult)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
reject('Not logged in')
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
logout (store, router) {
|
||||
/**
|
||||
* Log out of myPrayerJournal
|
||||
*
|
||||
* @param store The Vuex store
|
||||
*/
|
||||
logout (store) {
|
||||
// Clear access token and ID token from local storage
|
||||
clearTimeout(tokenRenewalTimeout)
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('id_token')
|
||||
localStorage.removeItem('expires_at')
|
||||
localStorage.setItem('user_profile', JSON.stringify({}))
|
||||
// navigate to the home route
|
||||
localStorage.removeItem(this.AUTH_SESSION)
|
||||
this.refreshSession()
|
||||
|
||||
store.commit(mutations.USER_LOGGED_OFF)
|
||||
router.replace('/')
|
||||
|
||||
webAuth.logout({
|
||||
returnTo: `${AUTH_CONFIG.appDomain}/`,
|
||||
clientID: AUTH_CONFIG.clientId
|
||||
})
|
||||
this.emit('loginEvent', { loggedIn: false })
|
||||
}
|
||||
|
||||
/**
|
||||
* Check expiration for a token (the way it's stored in the session)
|
||||
*/
|
||||
checkExpiry = (it) => it.token && it.expiry && Date.now() < it.expiry
|
||||
|
||||
/**
|
||||
* Is there a user authenticated?
|
||||
*/
|
||||
isAuthenticated () {
|
||||
// Check whether the current time is past the access token's expiry time
|
||||
let expiresAt = JSON.parse(localStorage.getItem('expires_at'))
|
||||
return new Date().getTime() < expiresAt
|
||||
return this.checkExpiry(this.session.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the current access token valid?
|
||||
*/
|
||||
isAccessTokenValid () {
|
||||
return this.checkExpiry(this.session.access)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's access token, renewing it if required
|
||||
*/
|
||||
async getAccessToken () {
|
||||
if (this.isAccessTokenValid()) {
|
||||
return this.session.access.token
|
||||
} else {
|
||||
try {
|
||||
const authResult = await this.renewTokens()
|
||||
return authResult.accessToken
|
||||
} catch (reject) {
|
||||
throw reject
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new AuthService()
|
||||
|
@ -1,16 +1,16 @@
|
||||
<template lang="pug">
|
||||
article.mpj-main-content(role='main')
|
||||
md-content(role='main').mpj-main-content
|
||||
page-title(title='Welcome!'
|
||||
hideOnPage='true')
|
||||
hideOnPage=true)
|
||||
p
|
||||
p.
|
||||
myPrayerJournal is a place where individuals can record their prayer requests, record that they prayed for them,
|
||||
update them as God moves in the situation, and record a final answer received on that request. It will also allow
|
||||
update them as God moves in the situation, and record a final answer received on that request. It also allows
|
||||
individuals to review their answered prayers.
|
||||
p.
|
||||
This site is currently in beta, but it is open and available to the general public. To get started, simply click
|
||||
the “Log On” link above, and log on with either a Microsoft or Google account. You can also learn more
|
||||
about the site at the “Docs” link, also above.
|
||||
This site is open and available to the general public. To get started, simply click the “Log On” link
|
||||
above, and log on with either a Microsoft or Google account. You can also learn more about the site at the
|
||||
“Docs” link, also above.
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
@ -1,25 +1,24 @@
|
||||
<template lang="pug">
|
||||
article.mpj-main-content-wide(role='main')
|
||||
md-content(role='main').mpj-main-content-wide
|
||||
page-title(:title='title')
|
||||
p(v-if='isLoadingJournal') Loading your prayer journal...
|
||||
template(v-else)
|
||||
.mpj-text-center
|
||||
router-link(:to="{ name: 'EditRequest', params: { id: 'new' } }"
|
||||
role='button').
|
||||
#[md-icon(icon='add_box')] Add a New Request
|
||||
br
|
||||
.mpj-journal(v-if='journal.length > 0')
|
||||
request-card(v-for='request in journal'
|
||||
:key='request.requestId'
|
||||
:request='request'
|
||||
:events='eventBus'
|
||||
:toast='toast')
|
||||
p.text-center(v-else): em.
|
||||
No requests found; click the “Add a New Request” button to add one
|
||||
notes-edit(:events='eventBus'
|
||||
:toast='toast')
|
||||
snooze-request(:events='eventBus'
|
||||
:toast='toast')
|
||||
md-empty-state(v-if='journal.length === 0'
|
||||
md-icon='done_all'
|
||||
md-label='No Requests to Show'
|
||||
md-description='You have no requests to be shown; see the “Active” link above for snoozed/deferred requests, and the “Answered” link for answered requests')
|
||||
md-button(:to="{ name: 'EditRequest', params: { id: 'new' } }").md-primary.md-raised Add a New Request
|
||||
template(v-else)
|
||||
.mpj-text-center
|
||||
md-button(:to="{ name: 'EditRequest', params: { id: 'new' } }"
|
||||
role='button').md-raised.md-accent #[md-icon add_box] Add a New Request
|
||||
br
|
||||
.mpj-journal
|
||||
request-card(v-for='request in journal'
|
||||
:key='request.requestId'
|
||||
:request='request')
|
||||
notes-edit
|
||||
snooze-request
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@ -36,6 +35,10 @@ import actions from '@/store/action-types'
|
||||
|
||||
export default {
|
||||
name: 'journal',
|
||||
inject: [
|
||||
'messages',
|
||||
'progress'
|
||||
],
|
||||
components: {
|
||||
NotesEdit,
|
||||
RequestCard,
|
||||
@ -50,23 +53,29 @@ export default {
|
||||
title () {
|
||||
return `${this.user.given_name}’s Prayer Journal`
|
||||
},
|
||||
toast () {
|
||||
return this.$parent.$refs.toast
|
||||
snackbar () {
|
||||
return this.$parent.$refs.snackbar
|
||||
},
|
||||
...mapState(['user', 'journal', 'isLoadingJournal'])
|
||||
},
|
||||
async created () {
|
||||
await this.$store.dispatch(actions.LOAD_JOURNAL, this.$Progress)
|
||||
this.toast.showToast(`Loaded ${this.journal.length} prayer requests`, { theme: 'success' })
|
||||
await this.$store.dispatch(actions.LOAD_JOURNAL, this.progress)
|
||||
this.messages.$emit('info', `Loaded ${this.journal.length} prayer requests`)
|
||||
},
|
||||
provide () {
|
||||
return {
|
||||
journalEvents: this.eventBus
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.mpj-journal {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
<style lang="sass">
|
||||
.mpj-journal
|
||||
display: flex
|
||||
flex-flow: row wrap
|
||||
justify-content: center
|
||||
align-items: flex-start
|
||||
.mpj-dialog-content
|
||||
padding: 0 1rem
|
||||
</style>
|
||||
|
@ -1,15 +0,0 @@
|
||||
<template lang="pug">
|
||||
i.material-icons(v-html='icon')
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'md-icon',
|
||||
props: {
|
||||
icon: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
@ -1,34 +1,29 @@
|
||||
<template lang="pug">
|
||||
nav.mpj-top-nav.mpj-bg(role='menubar')
|
||||
router-link.title(:to="{ name: 'Home' }"
|
||||
role='menuitem')
|
||||
span(style='font-weight:100;') my
|
||||
span(style='font-weight:600;') Prayer
|
||||
span(style='font-weight:700;') Journal
|
||||
router-link(v-if='isAuthenticated'
|
||||
:to="{ name: 'Journal' }"
|
||||
role='menuitem') Journal
|
||||
router-link(v-if='isAuthenticated'
|
||||
:to="{ name: 'ActiveRequests' }"
|
||||
role='menuitem') Active
|
||||
router-link(v-if='hasSnoozed'
|
||||
:to="{ name: 'SnoozedRequests' }"
|
||||
role='menuitem') Snoozed
|
||||
router-link(v-if='isAuthenticated'
|
||||
:to="{ name: 'AnsweredRequests' }"
|
||||
role='menuitem') Answered
|
||||
a(v-if='isAuthenticated'
|
||||
href='#'
|
||||
role='menuitem'
|
||||
@click.stop='logOff()') Log Off
|
||||
a(v-if='!isAuthenticated'
|
||||
href='#'
|
||||
role='menuitem'
|
||||
@click.stop='logOn()') Log On
|
||||
a(href='https://docs.prayerjournal.me'
|
||||
target='_blank'
|
||||
role='menuitem'
|
||||
@click.stop='') Docs
|
||||
.md-toolbar-row
|
||||
md-tabs(md-sync-route).md-primary
|
||||
template(v-if='isAuthenticated')
|
||||
md-tab(md-label='Journal'
|
||||
to='/journal')
|
||||
md-tab(md-label='Active'
|
||||
to='/requests/active')
|
||||
md-tab(v-if='hasSnoozed'
|
||||
md-label='Snoozed'
|
||||
to='/requests/snoozed')
|
||||
md-tab(md-label='Answered'
|
||||
to='/requests/answered')
|
||||
md-tab(md-label='Log Off'
|
||||
href='/user/log-off'
|
||||
@click.prevent='logOff()')
|
||||
md-tab(md-label='Docs'
|
||||
href='https://docs.prayerjournal.me'
|
||||
@click.prevent='showHelp()')
|
||||
template(v-else)
|
||||
md-tab(md-label='Log On'
|
||||
href='/user/log-on'
|
||||
@click.prevent='logOn()')
|
||||
md-tab(md-label='Docs'
|
||||
href='https://docs.prayerjournal.me'
|
||||
@click.prevent='showHelp()')
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@ -36,14 +31,10 @@ nav.mpj-top-nav.mpj-bg(role='menubar')
|
||||
|
||||
import { mapState } from 'vuex'
|
||||
|
||||
import AuthService from '@/auth/AuthService'
|
||||
|
||||
export default {
|
||||
name: 'navigation',
|
||||
data () {
|
||||
return {
|
||||
auth0: new AuthService()
|
||||
}
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
hasSnoozed () {
|
||||
@ -51,46 +42,18 @@ export default {
|
||||
Array.isArray(this.journal) &&
|
||||
this.journal.filter(req => req.snoozedUntil > Date.now()).length > 0
|
||||
},
|
||||
...mapState([ 'journal', 'isAuthenticated' ])
|
||||
...mapState([ 'isAuthenticated', 'journal' ])
|
||||
},
|
||||
methods: {
|
||||
logOn () {
|
||||
this.auth0.login()
|
||||
this.$auth.login()
|
||||
},
|
||||
logOff () {
|
||||
this.auth0.logout(this.$store, this.$router)
|
||||
this.$auth.logout(this.$store, this.$router)
|
||||
},
|
||||
showHelp () {
|
||||
window.open('https://docs.prayerjournal.me', '_blank')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.mpj-top-nav {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
align-items: center;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
padding-left: .5rem;
|
||||
min-height: 50px;
|
||||
}
|
||||
.mpj-top-nav a:link,
|
||||
.mpj-top-nav a:visited {
|
||||
text-decoration: none;
|
||||
color: rgba(255, 255, 255, .75);
|
||||
padding-left: 1rem;
|
||||
}
|
||||
.mpj-top-nav a:link.router-link-active,
|
||||
.mpj-top-nav a:visited.router-link-active,
|
||||
.mpj-top-nav a:hover {
|
||||
color: white;
|
||||
}
|
||||
.mpj-top-nav .title {
|
||||
font-size: 1.25rem;
|
||||
color: white;
|
||||
padding-left: 1.25rem;
|
||||
padding-right: 1.25rem;
|
||||
}
|
||||
</style>
|
||||
|
@ -1,6 +1,6 @@
|
||||
<template lang="pug">
|
||||
h2.mpj-page-title(v-if='!hideOnPage'
|
||||
v-html='title')
|
||||
h1(v-if='!hideOnPage'
|
||||
v-html='title').md-title
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@ -26,10 +26,3 @@ export default {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mpj-page-title {
|
||||
border-bottom: solid 1px lightgray;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
|
@ -1,54 +1,59 @@
|
||||
<template lang="pug">
|
||||
article
|
||||
page-title(title='Privacy Policy')
|
||||
p: small: em (as of May 21, 2018)
|
||||
p.
|
||||
The nature of the service is one where privacy is a must. The items below will help you understand the data we
|
||||
collect, access, and store on your behalf as you use this service.
|
||||
hr
|
||||
h3 Third Party Services
|
||||
p.
|
||||
myPrayerJournal utilizes a third-party authentication and identity provider. You should familiarize yourself with
|
||||
the privacy policy for #[a(href='https://auth0.com/privacy' target='_blank') Auth0], as well as your chosen provider
|
||||
(#[a(href='https://privacy.microsoft.com/en-us/privacystatement' target='_blank') Microsoft] or
|
||||
#[a(href='https://policies.google.com/privacy' target='_blank') Google]).
|
||||
hr
|
||||
h3 What We Collect
|
||||
h4 Identifying Data
|
||||
ul
|
||||
li.
|
||||
The only identifying data myPrayerJournal stores is the subscriber (“sub”) field from the token we
|
||||
receive from Auth0, once you have signed in through their hosted service. All information is associated with you
|
||||
via this field.
|
||||
li.
|
||||
While you are signed in, within your browser, the service has access to your first and last names, along with a
|
||||
URL to the profile picture (provided by your selected identity provider). This information is not transmitted to
|
||||
the server, and is removed when “Log Off” is clicked.
|
||||
h4 User Provided Data
|
||||
ul
|
||||
li.
|
||||
myPrayerJournal stores the information you provide, including the text of prayer requests, updates, and notes;
|
||||
and the date/time when certain actions are taken.
|
||||
hr
|
||||
h3 How Your Data Is Accessed / Secured
|
||||
ul
|
||||
li.
|
||||
Your provided data is returned to you, as required, to display your journal or your answered requests.
|
||||
On the server, it is stored in a controlled-access database.
|
||||
li.
|
||||
Your data is backed up, along with other Bit Badger Solutions hosted systems, in a rolling manner; backups are
|
||||
preserved for the prior 7 days, and backups from the 1st and 15th are preserved for 3 months. These backups are
|
||||
stored in a private cloud data repository.
|
||||
li.
|
||||
The data collected and stored is the absolute minimum necessary for the functionality of the service. There are
|
||||
no plans to “monetize” this service, and storing the minimum amount of information means that the
|
||||
data we have is not interesting to purchasers (or those who may have more nefarious purposes).
|
||||
li Access to servers and backups is strictly controlled and monitored for unauthorized access attempts.
|
||||
hr
|
||||
h3 Removing Your Data
|
||||
p.
|
||||
At any time, you may choose to discontinue using this service. Both Microsoft and Google provide ways to revoke
|
||||
access from this application. However, if you want your data removed from the database, please contact daniel at
|
||||
bitbadger.solutions (via e-mail, replacing at with @) prior to doing so, to ensure we can determine which
|
||||
subscriber ID belongs to you.
|
||||
md-content(role='main').mpj-main-content
|
||||
page-title(title='Privacy Policy'
|
||||
hide-on-page=true)
|
||||
md-card
|
||||
md-card-header
|
||||
.md-title Privacy Policy
|
||||
.md-subhead as of May 21, 2018
|
||||
md-card-content.mpj-full-page-card
|
||||
p.
|
||||
The nature of the service is one where privacy is a must. The items below will help you understand the data we
|
||||
collect, access, and store on your behalf as you use this service.
|
||||
hr
|
||||
h3 Third Party Services
|
||||
p.
|
||||
myPrayerJournal utilizes a third-party authentication and identity provider. You should familiarize yourself
|
||||
with the privacy policy for #[a(href='https://auth0.com/privacy' target='_blank') Auth0], as well as your
|
||||
chosen provider (#[a(href='https://privacy.microsoft.com/en-us/privacystatement' target='_blank') Microsoft] or
|
||||
#[a(href='https://policies.google.com/privacy' target='_blank') Google]).
|
||||
hr
|
||||
h3 What We Collect
|
||||
h4 Identifying Data
|
||||
ul
|
||||
li.
|
||||
The only identifying data myPrayerJournal stores is the subscriber (“sub”) field from the token we
|
||||
receive from Auth0, once you have signed in through their hosted service. All information is associated with
|
||||
you via this field.
|
||||
li.
|
||||
While you are signed in, within your browser, the service has access to your first and last names, along with
|
||||
a URL to the profile picture (provided by your selected identity provider). This information is not
|
||||
transmitted to the server, and is removed when “Log Off” is clicked.
|
||||
h4 User Provided Data
|
||||
ul
|
||||
li.
|
||||
myPrayerJournal stores the information you provide, including the text of prayer requests, updates, and notes;
|
||||
and the date/time when certain actions are taken.
|
||||
hr
|
||||
h3 How Your Data Is Accessed / Secured
|
||||
ul
|
||||
li.
|
||||
Your provided data is returned to you, as required, to display your journal or your answered requests. On the
|
||||
server, it is stored in a controlled-access database.
|
||||
li.
|
||||
Your data is backed up, along with other Bit Badger Solutions hosted systems, in a rolling manner; backups are
|
||||
preserved for the prior 7 days, and backups from the 1st and 15th are preserved for 3 months. These backups
|
||||
are stored in a private cloud data repository.
|
||||
li.
|
||||
The data collected and stored is the absolute minimum necessary for the functionality of the service. There
|
||||
are no plans to “monetize” this service, and storing the minimum amount of information means that
|
||||
the data we have is not interesting to purchasers (or those who may have more nefarious purposes).
|
||||
li Access to servers and backups is strictly controlled and monitored for unauthorized access attempts.
|
||||
hr
|
||||
h3 Removing Your Data
|
||||
p.
|
||||
At any time, you may choose to discontinue using this service. Both Microsoft and Google provide ways to revoke
|
||||
access from this application. However, if you want your data removed from the database, please contact daniel at
|
||||
bitbadger.solutions (via e-mail, replacing at with @) prior to doing so, to ensure we can determine which
|
||||
subscriber ID belongs to you.
|
||||
</template>
|
||||
|
@ -1,35 +1,40 @@
|
||||
<template lang="pug">
|
||||
article
|
||||
page-title(title='Terms of Service')
|
||||
p: small: em (as of May 21, 2018)
|
||||
h3 1. Acceptance of Terms
|
||||
p.
|
||||
By accessing this web site, you are agreeing to be bound by these Terms and Conditions, and that you are
|
||||
responsible to ensure that your use of this site complies with all applicable laws. Your continued use of this
|
||||
site implies your acceptance of these terms.
|
||||
h3 2. Description of Service and Registration
|
||||
p.
|
||||
myPrayerJournal is a service that allows individuals to enter and amend their prayer requests. It requires no
|
||||
registration by itself, but access is granted based on a successful login with an external identity provider. See
|
||||
#[router-link(:to="{ name: 'PrivacyPolicy' }") our privacy policy] for details on how that information is accessed
|
||||
and stored.
|
||||
h3 3. Third Party Services
|
||||
p.
|
||||
This service utilizes a third-party service provider for identity management. Review the terms of service for
|
||||
#[a(href='https://auth0.com/terms' target='_blank') Auth0], as well as those for the selected authorization
|
||||
provider (#[a(href='https://www.microsoft.com/en-us/servicesagreement' target='_blank') Microsoft] or
|
||||
#[a(href='https://policies.google.com/terms' target='_blank') Google]).
|
||||
h3 4. Liability
|
||||
p.
|
||||
This service is provided "as is", and no warranty (express or implied) exists. The service and its developers may
|
||||
not be held liable for any damages that may arise through the use of this service.
|
||||
h3 5. Updates to Terms
|
||||
p.
|
||||
These terms and conditions may be updated at any time, and this service does not have the capability to notify
|
||||
users when these change. The date at the top of the page will be updated when any of the text of these terms is
|
||||
updated.
|
||||
hr
|
||||
p.
|
||||
You may also wish to review our #[router-link(:to="{ name: 'PrivacyPolicy' }") privacy policy] to learn how we
|
||||
handle your data.
|
||||
md-content(role='main').mpj-main-content
|
||||
page-title(title='Terms of Service'
|
||||
hide-on-page=true)
|
||||
md-card
|
||||
md-card-header
|
||||
.md-title Terms of Service
|
||||
.md-subhead as of May 21, 2018
|
||||
md-card-content.mpj-full-page-card
|
||||
h3 1. Acceptance of Terms
|
||||
p.
|
||||
By accessing this web site, you are agreeing to be bound by these Terms and Conditions, and that you are
|
||||
responsible to ensure that your use of this site complies with all applicable laws. Your continued use of this
|
||||
site implies your acceptance of these terms.
|
||||
h3 2. Description of Service and Registration
|
||||
p.
|
||||
myPrayerJournal is a service that allows individuals to enter and amend their prayer requests. It requires no
|
||||
registration by itself, but access is granted based on a successful login with an external identity provider.
|
||||
See #[router-link(:to="{ name: 'PrivacyPolicy' }") our privacy policy] for details on how that information is
|
||||
accessed and stored.
|
||||
h3 3. Third Party Services
|
||||
p.
|
||||
This service utilizes a third-party service provider for identity management. Review the terms of service for
|
||||
#[a(href='https://auth0.com/terms' target='_blank') Auth0], as well as those for the selected authorization
|
||||
provider (#[a(href='https://www.microsoft.com/en-us/servicesagreement' target='_blank') Microsoft] or
|
||||
#[a(href='https://policies.google.com/terms' target='_blank') Google]).
|
||||
h3 4. Liability
|
||||
p.
|
||||
This service is provided "as is", and no warranty (express or implied) exists. The service and its developers
|
||||
may not be held liable for any damages that may arise through the use of this service.
|
||||
h3 5. Updates to Terms
|
||||
p.
|
||||
These terms and conditions may be updated at any time, and this service does not have the capability to notify
|
||||
users when these change. The date at the top of the page will be updated when any of the text of these terms is
|
||||
updated.
|
||||
hr
|
||||
p.
|
||||
You may also wish to review our #[router-link(:to="{ name: 'PrivacyPolicy' }") privacy policy] to learn how we
|
||||
handle your data.
|
||||
</template>
|
||||
|
@ -1,13 +1,16 @@
|
||||
<template lang="pug">
|
||||
article.mpj-main-content(role='main')
|
||||
page-title(title='Active Requests')
|
||||
div(v-if='loaded').mpj-request-list
|
||||
p.mpj-text-center(v-if='requests.length === 0'): em.
|
||||
No active requests found; return to #[router-link(:to='{ name: "Journal" } ') your journal]
|
||||
request-list-item(v-for='req in requests'
|
||||
:key='req.requestId'
|
||||
:request='req'
|
||||
:toast='toast')
|
||||
md-content(role='main').mpj-main-content
|
||||
page-title(title='Active Requests'
|
||||
hide-on-page=true)
|
||||
template(v-if='loaded')
|
||||
md-empty-state(v-if='requests.length === 0'
|
||||
md-icon='sentiment_dissatisfied'
|
||||
md-label='No Active Requests'
|
||||
md-description='Your prayer journal has no active requests')
|
||||
md-button(to='/journal').md-primary.md-raised Return to your journal
|
||||
request-list(v-if='requests.length !== 0'
|
||||
title='Active Requests'
|
||||
:requests='requests')
|
||||
p(v-else) Loading journal...
|
||||
</template>
|
||||
|
||||
@ -16,14 +19,15 @@ article.mpj-main-content(role='main')
|
||||
|
||||
import { mapState } from 'vuex'
|
||||
|
||||
import RequestListItem from '@/components/request/RequestListItem'
|
||||
import RequestList from '@/components/request/RequestList'
|
||||
|
||||
import actions from '@/store/action-types'
|
||||
|
||||
export default {
|
||||
name: 'active-requests',
|
||||
inject: ['progress'],
|
||||
components: {
|
||||
RequestListItem
|
||||
RequestList
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
@ -32,9 +36,6 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
toast () {
|
||||
return this.$parent.$refs.toast
|
||||
},
|
||||
...mapState(['journal', 'isLoadingJournal'])
|
||||
},
|
||||
created () {
|
||||
@ -45,7 +46,7 @@ export default {
|
||||
async ensureJournal () {
|
||||
if (!Array.isArray(this.journal)) {
|
||||
this.loaded = false
|
||||
await this.$store.dispatch(actions.LOAD_JOURNAL, this.$Progress)
|
||||
await this.$store.dispatch(actions.LOAD_JOURNAL, this.progress)
|
||||
}
|
||||
this.requests = this.journal
|
||||
.sort((a, b) => a.showAfter - b.showAfter)
|
||||
|
@ -1,13 +1,15 @@
|
||||
<template lang="pug">
|
||||
article.mpj-main-content(role='main')
|
||||
page-title(title='Answered Requests')
|
||||
div(v-if='loaded').mpj-request-list
|
||||
p.text-center(v-if='requests.length === 0'): em.
|
||||
No answered requests found; once you have marked one as “Answered”, it will appear here
|
||||
request-list-item(v-for='req in requests'
|
||||
:key='req.requestId'
|
||||
:request='req'
|
||||
:toast='toast')
|
||||
md-content(role='main').mpj-main-content
|
||||
page-title(title='Answered Requests'
|
||||
hide-on-page=true)
|
||||
template(v-if='loaded')
|
||||
md-empty-state(v-if='requests.length === 0'
|
||||
md-icon='sentiment_dissatisfied'
|
||||
md-label='No Answered Requests'
|
||||
md-description='Your prayer journal has no answered requests; once you have marked one as “Answered”, it will appear here')
|
||||
request-list(v-if='requests.length !== 0'
|
||||
title='Answered Requests'
|
||||
:requests='requests')
|
||||
p(v-else) Loading answered requests...
|
||||
</template>
|
||||
|
||||
@ -16,12 +18,16 @@ article.mpj-main-content(role='main')
|
||||
|
||||
import api from '@/api'
|
||||
|
||||
import RequestListItem from '@/components/request/RequestListItem'
|
||||
import RequestList from '@/components/request/RequestList'
|
||||
|
||||
export default {
|
||||
name: 'answered-requests',
|
||||
inject: [
|
||||
'messages',
|
||||
'progress'
|
||||
],
|
||||
components: {
|
||||
RequestListItem
|
||||
RequestList
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
@ -29,21 +35,16 @@ export default {
|
||||
loaded: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
toast () {
|
||||
return this.$parent.$refs.toast
|
||||
}
|
||||
},
|
||||
async mounted () {
|
||||
this.$Progress.start()
|
||||
this.progress.$emit('show', 'query')
|
||||
try {
|
||||
const reqs = await api.getAnsweredRequests()
|
||||
this.requests = reqs.data
|
||||
this.$Progress.finish()
|
||||
this.progress.$emit('done')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
this.toast.showToast('Error loading requests; check console for details', { theme: 'danger' })
|
||||
this.$Progress.fail()
|
||||
this.messages.$emit('error', 'Error loading requests; check console for details')
|
||||
this.progress.$emit('done')
|
||||
} finally {
|
||||
this.loaded = true
|
||||
}
|
||||
|
@ -1,71 +1,52 @@
|
||||
<template lang="pug">
|
||||
article.mpj-main-content(role='main')
|
||||
md-content(role='main').mpj-narrow
|
||||
page-title(:title='title')
|
||||
.mpj-narrow
|
||||
label(for='request_text')
|
||||
| Prayer Request
|
||||
br
|
||||
textarea(v-model='form.requestText'
|
||||
:rows='10'
|
||||
@blur='trimText()'
|
||||
autofocus).mpj-full-width
|
||||
md-field
|
||||
label(for='request_text') Prayer Request
|
||||
md-textarea(v-model='form.requestText'
|
||||
@blur='trimText()'
|
||||
md-autogrow
|
||||
autofocus).mpj-full-width
|
||||
br
|
||||
template(v-if='!isNew')
|
||||
label Also Mark As
|
||||
br
|
||||
template(v-if='!isNew')
|
||||
label Also Mark As
|
||||
br
|
||||
label.normal
|
||||
input(v-model='form.status'
|
||||
type='radio'
|
||||
name='status'
|
||||
value='Updated')
|
||||
| Updated
|
||||
|
|
||||
label.normal
|
||||
input(v-model='form.status'
|
||||
type='radio'
|
||||
name='status'
|
||||
value='Prayed')
|
||||
| Prayed
|
||||
|
|
||||
label.normal
|
||||
input(v-model='form.status'
|
||||
type='radio'
|
||||
name='status'
|
||||
value='Answered')
|
||||
| Answered
|
||||
br
|
||||
label Recurrence
|
||||
|
|
||||
em.mpj-muted-text After prayer, request reappears...
|
||||
md-radio(v-model='form.status'
|
||||
value='Updated') Updated
|
||||
md-radio(v-model='form.status'
|
||||
value='Prayed') Prayed
|
||||
md-radio(v-model='form.status'
|
||||
value='Answered') Answered
|
||||
br
|
||||
label.normal
|
||||
input(v-model='form.recur.typ'
|
||||
type='radio'
|
||||
name='recur'
|
||||
value='immediate')
|
||||
| Immediately
|
||||
|
|
||||
label.normal
|
||||
input(v-model='form.recur.typ'
|
||||
type='radio'
|
||||
name='recur'
|
||||
value='other')
|
||||
| Every...
|
||||
input(v-model='form.recur.count'
|
||||
type='number'
|
||||
:disabled='!showRecurrence').mpj-recur-count
|
||||
select(v-model='form.recur.other'
|
||||
:disabled='!showRecurrence').mpj-recur-type
|
||||
option(value='hours') hours
|
||||
option(value='days') days
|
||||
option(value='weeks') weeks
|
||||
.mpj-text-right
|
||||
button(:disabled='!isValidRecurrence'
|
||||
@click.stop='saveRequest()').primary.
|
||||
#[md-icon(icon='save')] Save
|
||||
|
|
||||
button(@click.stop='goBack()').
|
||||
#[md-icon(icon='arrow_back')] Cancel
|
||||
label Recurrence
|
||||
|
|
||||
em.mpj-muted-text After prayer, request reappears...
|
||||
br
|
||||
.md-layout
|
||||
.md-layout-item.md-size-30
|
||||
md-radio(v-model='form.recur.typ'
|
||||
value='Immediate') Immediately
|
||||
.md-layout-item.md-size-20
|
||||
md-radio(v-model='form.recur.typ'
|
||||
value='other') Every...
|
||||
.md-layout-item.md-size-10
|
||||
md-field(md-inline)
|
||||
label Count
|
||||
md-input(v-model='form.recur.count'
|
||||
type='number'
|
||||
:disabled='!showRecurrence')
|
||||
.md-layout-item.md-size-20
|
||||
md-field
|
||||
label Interval
|
||||
md-select(v-model='form.recur.other'
|
||||
:disabled='!showRecurrence')
|
||||
md-option(value='Hours') hours
|
||||
md-option(value='Days') days
|
||||
md-option(value='Weeks') weeks
|
||||
.mpj-text-right
|
||||
md-button(:disabled='!isValidRecurrence'
|
||||
@click.stop='saveRequest()').md-primary.md-raised #[md-icon save] Save
|
||||
md-button(@click.stop='goBack()').md-raised #[md-icon arrow_back] Cancel
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@ -77,6 +58,10 @@ import actions from '@/store/action-types'
|
||||
|
||||
export default {
|
||||
name: 'edit-request',
|
||||
inject: [
|
||||
'messages',
|
||||
'progress'
|
||||
],
|
||||
props: {
|
||||
id: {
|
||||
type: String,
|
||||
@ -92,7 +77,7 @@ export default {
|
||||
requestText: '',
|
||||
status: 'Updated',
|
||||
recur: {
|
||||
typ: 'immediate',
|
||||
typ: 'Immediate',
|
||||
other: '',
|
||||
count: ''
|
||||
}
|
||||
@ -101,19 +86,16 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
isValidRecurrence () {
|
||||
if (this.form.recur.typ === 'immediate') return true
|
||||
if (this.form.recur.typ === 'Immediate') return true
|
||||
const count = Number.parseInt(this.form.recur.count)
|
||||
if (isNaN(count) || this.form.recur.other === '') return false
|
||||
if (this.form.recur.other === 'hours' && count > (365 * 24)) return false
|
||||
if (this.form.recur.other === 'days' && count > 365) return false
|
||||
if (this.form.recur.other === 'weeks' && count > 52) return false
|
||||
if (this.form.recur.other === 'Hours' && count > (365 * 24)) return false
|
||||
if (this.form.recur.other === 'Days' && count > 365) return false
|
||||
if (this.form.recur.other === 'Weeks' && count > 52) return false
|
||||
return true
|
||||
},
|
||||
showRecurrence () {
|
||||
return this.form.recur.typ !== 'immediate'
|
||||
},
|
||||
toast () {
|
||||
return this.$parent.$refs.toast
|
||||
return this.form.recur.typ !== 'Immediate'
|
||||
},
|
||||
...mapState(['journal'])
|
||||
},
|
||||
@ -125,21 +107,21 @@ export default {
|
||||
this.form.requestId = ''
|
||||
this.form.requestText = ''
|
||||
this.form.status = 'Created'
|
||||
this.form.recur.typ = 'immediate'
|
||||
this.form.recur.typ = 'Immediate'
|
||||
this.form.recur.other = ''
|
||||
this.form.recur.count = ''
|
||||
} else {
|
||||
this.title = 'Edit Prayer Request'
|
||||
this.isNew = false
|
||||
if (this.journal.length === 0) {
|
||||
await this.$store.dispatch(actions.LOAD_JOURNAL, this.$Progress)
|
||||
await this.$store.dispatch(actions.LOAD_JOURNAL, this.progress)
|
||||
}
|
||||
const req = this.journal.filter(r => r.requestId === this.id)[0]
|
||||
this.form.requestId = this.id
|
||||
this.form.requestText = req.text
|
||||
this.form.status = 'Updated'
|
||||
if (req.recurType === 'immediate') {
|
||||
this.form.recur.typ = 'immediate'
|
||||
if (req.recurType === 'Immediate') {
|
||||
this.form.recur.typ = 'Immediate'
|
||||
this.form.recur.other = ''
|
||||
this.form.recur.count = ''
|
||||
} else {
|
||||
@ -158,31 +140,31 @@ export default {
|
||||
},
|
||||
async ensureJournal () {
|
||||
if (!Array.isArray(this.journal)) {
|
||||
await this.$store.dispatch(actions.LOAD_JOURNAL, this.$Progress)
|
||||
await this.$store.dispatch(actions.LOAD_JOURNAL, this.progress)
|
||||
}
|
||||
},
|
||||
async saveRequest () {
|
||||
if (this.isNew) {
|
||||
await this.$store.dispatch(actions.ADD_REQUEST, {
|
||||
progress: this.$Progress,
|
||||
progress: this.progress,
|
||||
requestText: this.form.requestText,
|
||||
recurType: this.form.recur.typ === 'immediate' ? 'immediate' : this.form.recur.other,
|
||||
recurCount: this.form.recur.typ === 'immediate' ? 0 : Number.parseInt(this.form.recur.count)
|
||||
recurType: this.form.recur.typ === 'Immediate' ? 'Immediate' : this.form.recur.other,
|
||||
recurCount: this.form.recur.typ === 'Immediate' ? 0 : Number.parseInt(this.form.recur.count)
|
||||
})
|
||||
this.toast.showToast('New prayer request added', { theme: 'success' })
|
||||
this.messages.$emit('info', 'New prayer request added')
|
||||
} else {
|
||||
await this.$store.dispatch(actions.UPDATE_REQUEST, {
|
||||
progress: this.$Progress,
|
||||
progress: this.progress,
|
||||
requestId: this.form.requestId,
|
||||
updateText: this.form.requestText,
|
||||
status: this.form.status,
|
||||
recurType: this.form.recur.typ === 'immediate' ? 'immediate' : this.form.recur.other,
|
||||
recurCount: this.form.recur.typ === 'immediate' ? 0 : Number.parseInt(this.form.recur.count)
|
||||
recurType: this.form.recur.typ === 'Immediate' ? 'Immediate' : this.form.recur.other,
|
||||
recurCount: this.form.recur.typ === 'Immediate' ? 0 : Number.parseInt(this.form.recur.count)
|
||||
})
|
||||
if (this.form.status === 'Answered') {
|
||||
this.toast.showToast('Request updated and removed from active journal', { theme: 'success' })
|
||||
this.messages.$emit('info', 'Request updated and removed from active journal')
|
||||
} else {
|
||||
this.toast.showToast('Request updated', { theme: 'success' })
|
||||
this.messages.$emit('info', 'Request updated')
|
||||
}
|
||||
}
|
||||
this.goBack()
|
||||
@ -190,15 +172,3 @@ export default {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.mpj-recur-count {
|
||||
width: 3rem;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
.mpj-recur-type {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
</style>
|
||||
|
@ -1,22 +1,24 @@
|
||||
<template lang="pug">
|
||||
article.mpj-main-content(role='main')
|
||||
page-title(title='Full Prayer Request')
|
||||
template(v-if='request')
|
||||
p
|
||||
span(v-if='isAnswered') Answered {{ formatDate(answered) }} (#[date-from-now(:value='answered')])
|
||||
small: em.mpj-muted-text prayed {{ prayedCount }} times, open {{ openDays }} days
|
||||
p.mpj-request-text {{ lastText }}
|
||||
br
|
||||
table.mpj-request-log
|
||||
thead
|
||||
tr
|
||||
th Action
|
||||
th Update / Notes
|
||||
tbody
|
||||
tr(v-for='item in log' :key='item.asOf')
|
||||
td {{ item.status }} on #[span.mpj-text-nowrap {{ formatDate(item.asOf) }}]
|
||||
td(v-if='item.text').mpj-request-text {{ item.text }}
|
||||
td(v-else)
|
||||
md-content(role='main').mpj-main-content
|
||||
page-title(title='Full Prayer Request'
|
||||
hide-on-page=true)
|
||||
md-card(v-if='request')
|
||||
md-card-header
|
||||
.md-title Full Prayer Request
|
||||
.md-subhead
|
||||
span(v-if='isAnswered') Answered {{ formatDate(answered) }} (#[date-from-now(:value='answered')]) !{' • '}
|
||||
| Prayed {{ prayedCount }} times • Open {{ openDays }} days
|
||||
md-card-content.mpj-full-page-card
|
||||
p.mpj-request-text {{ lastText }}
|
||||
md-table
|
||||
md-table-row
|
||||
md-table-head Action
|
||||
md-table-head Update / Notes
|
||||
md-table-row(v-for='item in log'
|
||||
:key='item.asOf')
|
||||
md-table-cell.mpj-valign-top {{ item.status }} on #[span.mpj-text-nowrap {{ formatDate(item.asOf) }}]
|
||||
md-table-cell(v-if='item.text').mpj-request-text.mpj-valign-top {{ item.text }}
|
||||
md-table-cell(v-else)
|
||||
p(v-else) Loading request...
|
||||
</template>
|
||||
|
||||
@ -31,6 +33,7 @@ const asOfDesc = (a, b) => b.asOf - a.asOf
|
||||
|
||||
export default {
|
||||
name: 'full-request',
|
||||
inject: ['progress'],
|
||||
props: {
|
||||
id: {
|
||||
type: String,
|
||||
@ -72,14 +75,14 @@ export default {
|
||||
}
|
||||
},
|
||||
async mounted () {
|
||||
this.$Progress.start()
|
||||
this.progress.$emit('show', 'indeterminate')
|
||||
try {
|
||||
const req = await api.getFullRequest(this.id)
|
||||
this.request = req.data
|
||||
this.$Progress.finish()
|
||||
this.progress.$emit('done')
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
this.$Progress.fail()
|
||||
this.progress.$emit('done')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
@ -1,21 +1,16 @@
|
||||
<template lang="pug">
|
||||
.mpj-modal(v-show='notesVisible')
|
||||
.mpj-modal-content.mpj-narrow
|
||||
header.mpj-bg
|
||||
h5 Add Notes to Prayer Request
|
||||
label
|
||||
| Notes
|
||||
br
|
||||
textarea(v-model='form.notes'
|
||||
:rows='10'
|
||||
@blur='trimText()').mpj-full-width
|
||||
.mpj-text-right
|
||||
button(@click='saveNotes()').primary.
|
||||
#[md-icon(icon='save')] Save
|
||||
|
|
||||
button(@click='closeDialog()').
|
||||
#[md-icon(icon='undo')] Cancel
|
||||
hr
|
||||
md-dialog(:md-active.sync='notesVisible').mpj-note-dialog
|
||||
md-dialog-title Add Notes to Prayer Request
|
||||
md-content.mpj-dialog-content
|
||||
md-field
|
||||
label Notes
|
||||
md-textarea(v-model='form.notes'
|
||||
md-autogrow
|
||||
@blur='trimText()')
|
||||
md-dialog-actions
|
||||
md-button(@click='saveNotes()').md-primary #[md-icon save] Save
|
||||
md-button(@click='closeDialog()') #[md-icon undo] Cancel
|
||||
.mpj-dialog-content
|
||||
div(v-if='hasPriorNotes')
|
||||
p.mpj-text-center: strong Prior Notes for This Request
|
||||
.mpj-note-list
|
||||
@ -26,8 +21,8 @@
|
||||
span.mpj-request-text {{ note.notes }}
|
||||
div(v-else-if='noPriorNotes').mpj-text-center.mpj-muted-text There are no prior notes for this request
|
||||
div(v-else).mpj-text-center
|
||||
button(@click='loadNotes()').
|
||||
#[md-icon(icon='cloud_download')] Load Prior Notes
|
||||
hr
|
||||
md-button(@click='loadNotes()') #[md-icon cloud_download] Load Prior Notes
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@ -37,10 +32,11 @@ import api from '@/api'
|
||||
|
||||
export default {
|
||||
name: 'notes-edit',
|
||||
props: {
|
||||
toast: { required: true },
|
||||
events: { required: true }
|
||||
},
|
||||
inject: [
|
||||
'journalEvents',
|
||||
'messages',
|
||||
'progress'
|
||||
],
|
||||
data () {
|
||||
return {
|
||||
notesVisible: false,
|
||||
@ -61,7 +57,7 @@ export default {
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.events.$on('notes', this.openDialog)
|
||||
this.journalEvents.$on('notes', this.openDialog)
|
||||
},
|
||||
methods: {
|
||||
closeDialog () {
|
||||
@ -72,14 +68,14 @@ export default {
|
||||
this.notesVisible = false
|
||||
},
|
||||
async loadNotes () {
|
||||
this.$Progress.start()
|
||||
this.progress.$emit('show', 'indeterminate')
|
||||
try {
|
||||
const notes = await api.getNotes(this.form.requestId)
|
||||
this.priorNotes = notes.data
|
||||
this.$Progress.finish()
|
||||
this.progress.$emit('done')
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
this.$Progress.fail()
|
||||
this.progress.$emit('done')
|
||||
} finally {
|
||||
this.priorNotesLoaded = true
|
||||
}
|
||||
@ -89,15 +85,15 @@ export default {
|
||||
this.notesVisible = true
|
||||
},
|
||||
async saveNotes () {
|
||||
this.$Progress.start()
|
||||
this.progress.$emit('show', 'indeterminate')
|
||||
try {
|
||||
await api.addNote(this.form.requestId, this.form.notes)
|
||||
this.$Progress.finish()
|
||||
this.toast.showToast('Added notes', { theme: 'success' })
|
||||
this.progress.$emit('done')
|
||||
this.messages.$emit('info', 'Added notes')
|
||||
this.closeDialog()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
this.$Progress.fail()
|
||||
this.progress.$emit('done')
|
||||
}
|
||||
},
|
||||
trimText () {
|
||||
@ -107,8 +103,16 @@ export default {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.mpj-note-list p {
|
||||
border-top: dotted 1px lightgray;
|
||||
}
|
||||
<style lang="sass">
|
||||
.mpj-note-dialog
|
||||
width: 40rem
|
||||
padding-bottom: 1.5rem
|
||||
@media screen and (max-width: 40rem)
|
||||
@media screen and (max-width: 20rem)
|
||||
.mpj-note-dialog
|
||||
width: 100%
|
||||
.mpj-note-dialog
|
||||
width: 20rem
|
||||
.mpj-note-list p
|
||||
border-top: dotted 1px lightgray
|
||||
</style>
|
||||
|
@ -1,17 +1,27 @@
|
||||
<template lang="pug">
|
||||
.mpj-request-card(v-if='shouldDisplay')
|
||||
header.mpj-card-header(role='toolbar').
|
||||
#[button(@click='markPrayed()' title='Pray').primary: md-icon(icon='done')]
|
||||
#[button(@click.stop='showEdit()' title='Edit'): md-icon(icon='edit')]
|
||||
#[button(@click.stop='showNotes()' title='Add Notes'): md-icon(icon='comment')]
|
||||
#[button(@click.stop='snooze()' title='Snooze Request'): md-icon(icon='schedule')]
|
||||
div
|
||||
p.card-text.mpj-request-text
|
||||
| {{ request.text }}
|
||||
p.as-of.mpj-text-right: small.mpj-muted-text: em
|
||||
= '(last activity '
|
||||
date-from-now(:value='request.asOf')
|
||||
| )
|
||||
md-card(v-if='shouldDisplay'
|
||||
md-with-hover).mpj-request-card
|
||||
md-card-actions(md-alignment='space-between')
|
||||
md-button(@click='markPrayed()').md-icon-button.md-raised.md-primary
|
||||
md-icon done
|
||||
md-tooltip(md-direction='top'
|
||||
md-delay=1000) Mark as Prayed
|
||||
span
|
||||
md-button(@click.stop='showEdit()').md-icon-button.md-raised
|
||||
md-icon edit
|
||||
md-tooltip(md-direction='top'
|
||||
md-delay=1000) Edit Request
|
||||
md-button(@click.stop='showNotes()').md-icon-button.md-raised
|
||||
md-icon comment
|
||||
md-tooltip(md-direction='top'
|
||||
md-delay=1000) Add Notes
|
||||
md-button(@click.stop='snooze()').md-icon-button.md-raised
|
||||
md-icon schedule
|
||||
md-tooltip(md-direction='top'
|
||||
md-delay=1000) Snooze Request
|
||||
md-card-content
|
||||
p.mpj-request-text {{ request.text }}
|
||||
p.mpj-text-right: small.mpj-muted-text: em (last activity #[date-from-now(:value='request.asOf')])
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@ -21,10 +31,13 @@ import actions from '@/store/action-types'
|
||||
|
||||
export default {
|
||||
name: 'request-card',
|
||||
inject: [
|
||||
'journalEvents',
|
||||
'messages',
|
||||
'progress'
|
||||
],
|
||||
props: {
|
||||
request: { required: true },
|
||||
toast: { required: true },
|
||||
events: { required: true }
|
||||
request: { required: true }
|
||||
},
|
||||
computed: {
|
||||
shouldDisplay () {
|
||||
@ -35,59 +48,31 @@ export default {
|
||||
methods: {
|
||||
async markPrayed () {
|
||||
await this.$store.dispatch(actions.UPDATE_REQUEST, {
|
||||
progress: this.$Progress,
|
||||
progress: this.progress,
|
||||
requestId: this.request.requestId,
|
||||
status: 'Prayed',
|
||||
updateText: ''
|
||||
})
|
||||
this.toast.showToast('Request marked as prayed', { theme: 'success' })
|
||||
this.messages.$emit('info', 'Request marked as prayed')
|
||||
},
|
||||
showEdit () {
|
||||
this.$router.push({ name: 'EditRequest', params: { id: this.request.requestId } })
|
||||
},
|
||||
showNotes () {
|
||||
this.events.$emit('notes', this.request)
|
||||
this.journalEvents.$emit('notes', this.request)
|
||||
},
|
||||
snooze () {
|
||||
this.events.$emit('snooze', this.request.requestId)
|
||||
this.journalEvents.$emit('snooze', this.request.requestId)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.mpj-request-card {
|
||||
border: solid 1px darkgray;
|
||||
border-radius: 5px;
|
||||
width: 20rem;
|
||||
margin: .5rem;
|
||||
}
|
||||
@media screen and (max-width: 20rem) {
|
||||
.mpj-request-card {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.mpj-card-header {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
justify-content: center;
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(lightgray), to(whitesmoke));
|
||||
background-image: -webkit-linear-gradient(top, lightgray, whitesmoke);
|
||||
background-image: -moz-linear-gradient(top, lightgray, whitesmoke);
|
||||
background-image: linear-gradient(to bottom, lightgray, whitesmoke);
|
||||
}
|
||||
.mpj-card-header button {
|
||||
margin: .25rem;
|
||||
padding: 0 .25rem;
|
||||
}
|
||||
.mpj-card-header button .material-icons {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
.mpj-request-card .card-text {
|
||||
margin-left: 1rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
.mpj-request-card .as-of {
|
||||
margin-right: .25rem;
|
||||
}
|
||||
<style lang="sass">
|
||||
.mpj-request-card
|
||||
width: 20rem
|
||||
margin-bottom: 1rem
|
||||
@media screen and (max-width: 20rem)
|
||||
.mpj-request-card
|
||||
width: 100%
|
||||
</style>
|
||||
|
40
src/app/src/components/request/RequestList.vue
Normal file
40
src/app/src/components/request/RequestList.vue
Normal file
@ -0,0 +1,40 @@
|
||||
<template lang="pug">
|
||||
md-table(md-card)
|
||||
md-table-toolbar
|
||||
h1.md-title {{ title }}
|
||||
md-table-row
|
||||
md-table-head Actions
|
||||
md-table-head Request
|
||||
request-list-item(v-for='req in requests'
|
||||
:key='req.requestId'
|
||||
:request='req')
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
'use strict'
|
||||
|
||||
import RequestListItem from '@/components/request/RequestListItem'
|
||||
|
||||
export default {
|
||||
name: 'request-list',
|
||||
components: { RequestListItem },
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
requests: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return { }
|
||||
},
|
||||
created () {
|
||||
this.$on('requestUnsnoozed', this.$parent.$emit('requestUnsnoozed'))
|
||||
this.$on('requestNowShown', this.$parent.$emit('requestNowShown'))
|
||||
}
|
||||
}
|
||||
</script>
|
@ -1,31 +1,31 @@
|
||||
<template lang="pug">
|
||||
p.mpj-request-text
|
||||
| {{ request.text }}
|
||||
br
|
||||
br
|
||||
button(@click='viewFull'
|
||||
title='View Full Request').
|
||||
#[md-icon(icon='description')] View Full Request
|
||||
|
|
||||
template(v-if='!isAnswered')
|
||||
button(@click='editRequest'
|
||||
title='Edit Request').
|
||||
#[md-icon(icon='edit')] Edit Request
|
||||
|
|
||||
template(v-if='isSnoozed')
|
||||
button(@click='cancelSnooze()').
|
||||
#[md-icon(icon='restore')] Cancel Snooze
|
||||
|
|
||||
template(v-if='isPending')
|
||||
button(@click='showNow()').
|
||||
#[md-icon(icon='restore')] Show Now
|
||||
br(v-if='isSnoozed || isPending || isAnswered')
|
||||
small(v-if='isSnoozed').mpj-muted-text: em.
|
||||
Snooze expires #[date-from-now(:value='request.snoozedUntil')]
|
||||
small(v-if='isPending').mpj-muted-text: em.
|
||||
Request scheduled to reappear #[date-from-now(:value='request.showAfter')]
|
||||
small(v-if='isAnswered').mpj-muted-text: em.
|
||||
Answered #[date-from-now(:value='request.asOf')]
|
||||
md-table-row
|
||||
md-table-cell.mpj-action-cell.mpj-valign-top
|
||||
md-button(@click='viewFull').md-icon-button.md-raised
|
||||
md-icon description
|
||||
md-tooltip(md-direction='top'
|
||||
md-delay=250) View Full Request
|
||||
template(v-if='!isAnswered')
|
||||
md-button(@click='editRequest').md-icon-button.md-raised
|
||||
md-icon edit
|
||||
md-tooltip(md-direction='top'
|
||||
md-delay=250) Edit Request
|
||||
template(v-if='isSnoozed')
|
||||
md-button(@click='cancelSnooze()').md-icon-button.md-raised
|
||||
md-icon restore
|
||||
md-tooltip(md-direction='top'
|
||||
md-delay=250) Cancel Snooze
|
||||
template(v-if='isPending')
|
||||
md-button(@click='showNow()').md-icon-button.md-raised
|
||||
md-icon restore
|
||||
md-tooltip(md-direction='top'
|
||||
md-delay=250) Show Now
|
||||
md-table-cell.mpj-valign-top
|
||||
p.mpj-request-text {{ request.text }}
|
||||
br(v-if='isSnoozed || isPending || isAnswered')
|
||||
small(v-if='isSnoozed').mpj-muted-text: em Snooze expires #[date-from-now(:value='request.snoozedUntil')]
|
||||
small(v-if='isPending').mpj-muted-text: em Request appears next #[date-from-now(:value='request.showAfter')]
|
||||
small(v-if='isAnswered').mpj-muted-text: em Answered #[date-from-now(:value='request.asOf')]
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@ -35,9 +35,12 @@ import actions from '@/store/action-types'
|
||||
|
||||
export default {
|
||||
name: 'request-list-item',
|
||||
inject: [
|
||||
'messages',
|
||||
'progress'
|
||||
],
|
||||
props: {
|
||||
request: { required: true },
|
||||
toast: { required: true }
|
||||
request: { required: true }
|
||||
},
|
||||
data () {
|
||||
return {}
|
||||
@ -59,11 +62,11 @@ export default {
|
||||
methods: {
|
||||
async cancelSnooze () {
|
||||
await this.$store.dispatch(actions.SNOOZE_REQUEST, {
|
||||
progress: this.$Progress,
|
||||
progress: this.progress,
|
||||
requestId: this.request.requestId,
|
||||
until: 0
|
||||
})
|
||||
this.toast.showToast('Request un-snoozed', { theme: 'success' })
|
||||
this.messages.$emit('info', 'Request un-snoozed')
|
||||
this.$parent.$emit('requestUnsnoozed')
|
||||
},
|
||||
editRequest () {
|
||||
@ -71,11 +74,11 @@ export default {
|
||||
},
|
||||
async showNow () {
|
||||
await this.$store.dispatch(actions.SHOW_REQUEST_NOW, {
|
||||
progress: this.$Progress,
|
||||
progress: this.progress,
|
||||
requestId: this.request.requestId,
|
||||
showAfter: Date.now()
|
||||
showAfter: 0
|
||||
})
|
||||
this.toast.showToast('Recurrence skipped; request now shows in journal', { theme: 'success' })
|
||||
this.messages.$emit('info', 'Recurrence skipped; request now shows in journal')
|
||||
this.$parent.$emit('requestNowShown')
|
||||
},
|
||||
viewFull () {
|
||||
@ -84,3 +87,9 @@ export default {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="sass">
|
||||
.mpj-action-cell
|
||||
width: 1%
|
||||
white-space: nowrap
|
||||
</style>
|
||||
|
@ -1,22 +1,15 @@
|
||||
<template lang="pug">
|
||||
.mpj-modal(v-show='snoozeVisible')
|
||||
.mpj-modal-content.mpj-skinny
|
||||
header.mpj-bg
|
||||
h5 Snooze Prayer Request
|
||||
p.mpj-text-center
|
||||
label
|
||||
= 'Until '
|
||||
input(v-model='form.snoozedUntil'
|
||||
type='date'
|
||||
autofocus)
|
||||
br
|
||||
.mpj-text-right
|
||||
button.primary(:disabled='!isValid'
|
||||
@click='snoozeRequest()').
|
||||
#[md-icon(icon='snooze')] Snooze
|
||||
|
|
||||
button(@click='closeDialog()').
|
||||
#[md-icon(icon='undo')] Cancel
|
||||
md-dialog(:md-active.sync='snoozeVisible').mpj-skinny
|
||||
md-dialog-title Snooze Prayer Request
|
||||
md-content.mpj-dialog-content
|
||||
span.mpj-text-muted Until
|
||||
md-datepicker(v-model='form.snoozedUntil'
|
||||
:md-disabled-dates='datesInPast'
|
||||
md-immediately)
|
||||
md-dialog-actions
|
||||
md-button(:disabled='!isValid'
|
||||
@click='snoozeRequest()').md-primary #[md-icon snooze] Snooze
|
||||
md-button(@click='closeDialog()') #[md-icon undo] Cancel
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@ -26,13 +19,18 @@ import actions from '@/store/action-types'
|
||||
|
||||
export default {
|
||||
name: 'snooze-request',
|
||||
inject: [
|
||||
'journalEvents',
|
||||
'messages',
|
||||
'progress'
|
||||
],
|
||||
props: {
|
||||
toast: { required: true },
|
||||
events: { required: true }
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
snoozeVisible: false,
|
||||
datesInPast: date => date < new Date(),
|
||||
form: {
|
||||
requestId: '',
|
||||
snoozedUntil: ''
|
||||
@ -40,7 +38,7 @@ export default {
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.events.$on('snooze', this.openDialog)
|
||||
this.journalEvents.$on('snooze', this.openDialog)
|
||||
},
|
||||
computed: {
|
||||
isValid () {
|
||||
@ -59,11 +57,11 @@ export default {
|
||||
},
|
||||
async snoozeRequest () {
|
||||
await this.$store.dispatch(actions.SNOOZE_REQUEST, {
|
||||
progress: this.$Progress,
|
||||
progress: this.progress,
|
||||
requestId: this.form.requestId,
|
||||
until: Date.parse(this.form.snoozedUntil)
|
||||
})
|
||||
this.toast.showToast(`Request snoozed until ${this.form.snoozedUntil}`, { theme: 'success' })
|
||||
this.messages.$emit('info', `Request snoozed until ${this.form.snoozedUntil}`)
|
||||
this.closeDialog()
|
||||
}
|
||||
}
|
||||
|
@ -1,13 +1,16 @@
|
||||
<template lang="pug">
|
||||
article.mpj-main-content(role='main')
|
||||
page-title(title='Snoozed Requests')
|
||||
div(v-if='loaded').mpj-request-list
|
||||
p.mpj-text-center(v-if='requests.length === 0'): em.
|
||||
No snoozed requests found; return to #[router-link(:to='{ name: "Journal" } ') your journal]
|
||||
request-list-item(v-for='req in requests'
|
||||
:key='req.requestId'
|
||||
:request='req'
|
||||
:toast='toast')
|
||||
page-title(title='Snoozed Requests'
|
||||
hide-on-page=true)
|
||||
template(v-if='loaded')
|
||||
md-empty-state(v-if='requests.length === 0'
|
||||
md-icon='sentiment_dissatisfied'
|
||||
md-label='No Snoozed Requests'
|
||||
md-description='Your prayer journal has no snoozed requests')
|
||||
md-button(to='/journal').md-primary.md-raised Return to your journal
|
||||
request-list(v-if='requests.length !== 0'
|
||||
title='Snoozed Requests'
|
||||
:requests='requests')
|
||||
p(v-else) Loading journal...
|
||||
</template>
|
||||
|
||||
@ -18,12 +21,13 @@ import { mapState } from 'vuex'
|
||||
|
||||
import actions from '@/store/action-types'
|
||||
|
||||
import RequestListItem from '@/components/request/RequestListItem'
|
||||
import RequestList from '@/components/request/RequestList'
|
||||
|
||||
export default {
|
||||
name: 'snoozed-requests',
|
||||
inject: ['progress'],
|
||||
components: {
|
||||
RequestListItem
|
||||
RequestList
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
@ -32,9 +36,6 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
toast () {
|
||||
return this.$parent.$refs.toast
|
||||
},
|
||||
...mapState(['journal', 'isLoadingJournal'])
|
||||
},
|
||||
created () {
|
||||
@ -44,7 +45,7 @@ export default {
|
||||
async ensureJournal () {
|
||||
if (!Array.isArray(this.journal)) {
|
||||
this.loaded = false
|
||||
await this.$store.dispatch(actions.LOAD_JOURNAL, this.$Progress)
|
||||
await this.$store.dispatch(actions.LOAD_JOURNAL, this.progress)
|
||||
}
|
||||
this.requests = this.journal
|
||||
.filter(req => req.snoozedUntil > Date.now())
|
||||
|
@ -7,14 +7,17 @@ article.mpj-main-content(role='main')
|
||||
<script>
|
||||
'use strict'
|
||||
|
||||
import AuthService from '@/auth/AuthService'
|
||||
|
||||
export default {
|
||||
name: 'log-on',
|
||||
created () {
|
||||
this.$Progress.start()
|
||||
new AuthService().handleAuthentication(this.$store, this.$router)
|
||||
// Auth service redirects to dashboard, which restarts the progress bar
|
||||
inject: ['progress'],
|
||||
async created () {
|
||||
this.progress.$emit('show', 'indeterminate')
|
||||
await this.$auth.handleAuthentication(this.$store)
|
||||
},
|
||||
methods: {
|
||||
handleLoginEvent (data) {
|
||||
this.$router.push(data.state.target || '/journal')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -1,33 +1,61 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// Vue packages and components
|
||||
import Vue from 'vue'
|
||||
import VueProgressBar from 'vue-progressbar'
|
||||
import VueToast from 'vue-toast'
|
||||
import { MdApp,
|
||||
MdButton,
|
||||
MdCard,
|
||||
MdContent,
|
||||
MdDatepicker,
|
||||
MdDialog,
|
||||
MdEmptyState,
|
||||
MdField,
|
||||
MdIcon,
|
||||
MdLayout,
|
||||
MdProgress,
|
||||
MdRadio,
|
||||
MdSnackbar,
|
||||
MdTable,
|
||||
MdTabs,
|
||||
MdToolbar,
|
||||
MdTooltip } from 'vue-material/dist/components'
|
||||
|
||||
import 'vue-toast/dist/vue-toast.min.css'
|
||||
|
||||
import App from './App'
|
||||
import router from './router'
|
||||
import store from './store'
|
||||
// myPrayerJournal components
|
||||
import App from './App'
|
||||
import router from './router'
|
||||
import store from './store'
|
||||
import DateFromNow from './components/common/DateFromNow'
|
||||
import MaterialDesignIcon from './components/common/MaterialDesignIcon'
|
||||
import PageTitle from './components/common/PageTitle'
|
||||
import PageTitle from './components/common/PageTitle'
|
||||
import AuthPlugin from './plugins/auth'
|
||||
|
||||
/* eslint-enable */
|
||||
|
||||
// Styles
|
||||
import 'vue-material/dist/vue-material.min.css'
|
||||
import 'vue-material/dist/theme/default.css'
|
||||
|
||||
Vue.config.productionTip = false
|
||||
|
||||
Vue.use(VueProgressBar, {
|
||||
color: 'yellow',
|
||||
failedColor: 'red',
|
||||
height: '5px',
|
||||
transition: {
|
||||
speed: '0.2s',
|
||||
opacity: '0.6s',
|
||||
termination: 1000
|
||||
}
|
||||
})
|
||||
|
||||
Vue.use(MdApp)
|
||||
Vue.use(MdButton)
|
||||
Vue.use(MdCard)
|
||||
Vue.use(MdContent)
|
||||
Vue.use(MdDatepicker)
|
||||
Vue.use(MdDialog)
|
||||
Vue.use(MdEmptyState)
|
||||
Vue.use(MdField)
|
||||
Vue.use(MdIcon)
|
||||
Vue.use(MdLayout)
|
||||
Vue.use(MdProgress)
|
||||
Vue.use(MdRadio)
|
||||
Vue.use(MdSnackbar)
|
||||
Vue.use(MdTable)
|
||||
Vue.use(MdTabs)
|
||||
Vue.use(MdToolbar)
|
||||
Vue.use(MdTooltip)
|
||||
Vue.use(AuthPlugin)
|
||||
Vue.component('date-from-now', DateFromNow)
|
||||
Vue.component('md-icon', MaterialDesignIcon)
|
||||
Vue.component('page-title', PageTitle)
|
||||
Vue.component('toast', VueToast)
|
||||
|
||||
new Vue({
|
||||
router,
|
||||
|
22
src/app/src/plugins/auth.js
Normal file
22
src/app/src/plugins/auth.js
Normal file
@ -0,0 +1,22 @@
|
||||
'use strict'
|
||||
|
||||
import authService from '../auth/AuthService'
|
||||
|
||||
export default {
|
||||
install (Vue) {
|
||||
Vue.prototype.$auth = authService
|
||||
|
||||
Vue.mixin({
|
||||
created () {
|
||||
if (this.handleLoginEvent) {
|
||||
authService.addListener('loginEvent', this.handleLoginEvent)
|
||||
}
|
||||
},
|
||||
destroyed () {
|
||||
if (this.handleLoginEvent) {
|
||||
authService.removeListener('loginEvent', this.handleLoginEvent)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -1,18 +1,12 @@
|
||||
'use strict'
|
||||
|
||||
import Vue from 'vue'
|
||||
/* eslint-disable */
|
||||
import Vue from 'vue'
|
||||
import Router from 'vue-router'
|
||||
|
||||
import ActiveRequests from '@/components/request/ActiveRequests'
|
||||
import AnsweredRequests from '@/components/request/AnsweredRequests'
|
||||
import EditRequest from '@/components/request/EditRequest'
|
||||
import FullRequest from '@/components/request/FullRequest'
|
||||
import auth from './auth/AuthService'
|
||||
import Home from '@/components/Home'
|
||||
import Journal from '@/components/Journal'
|
||||
import LogOn from '@/components/user/LogOn'
|
||||
import PrivacyPolicy from '@/components/legal/PrivacyPolicy'
|
||||
import SnoozedRequests from '@/components/request/SnoozedRequests'
|
||||
import TermsOfService from '@/components/legal/TermsOfService'
|
||||
/* eslint-enable */
|
||||
|
||||
Vue.use(Router)
|
||||
|
||||
@ -26,6 +20,12 @@ export default new Router({
|
||||
return { x: 0, y: 0 }
|
||||
}
|
||||
},
|
||||
beforeEach (to, from, next) {
|
||||
if (to.path === '/' || to.path === '/user/log-on' || auth.isAuthenticated()) {
|
||||
return next()
|
||||
}
|
||||
auth.login({ target: to.path })
|
||||
},
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
@ -35,49 +35,49 @@ export default new Router({
|
||||
{
|
||||
path: '/journal',
|
||||
name: 'Journal',
|
||||
component: Journal
|
||||
component: () => import('@/components/Journal')
|
||||
},
|
||||
{
|
||||
path: '/legal/privacy-policy',
|
||||
name: 'PrivacyPolicy',
|
||||
component: PrivacyPolicy
|
||||
component: () => import('@/components/legal/PrivacyPolicy')
|
||||
},
|
||||
{
|
||||
path: '/legal/terms-of-service',
|
||||
name: 'TermsOfService',
|
||||
component: TermsOfService
|
||||
component: () => import('@/components/legal/TermsOfService')
|
||||
},
|
||||
{
|
||||
path: '/request/:id/edit',
|
||||
name: 'EditRequest',
|
||||
component: EditRequest,
|
||||
component: () => import('@/components/request/EditRequest'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: '/request/:id/full',
|
||||
name: 'FullRequest',
|
||||
component: FullRequest,
|
||||
component: () => import('@/components/request/FullRequest'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: '/requests/active',
|
||||
name: 'ActiveRequests',
|
||||
component: ActiveRequests
|
||||
component: () => import('@/components/request/ActiveRequests')
|
||||
},
|
||||
{
|
||||
path: '/requests/answered',
|
||||
name: 'AnsweredRequests',
|
||||
component: AnsweredRequests
|
||||
component: () => import('@/components/request/AnsweredRequests')
|
||||
},
|
||||
{
|
||||
path: '/requests/snoozed',
|
||||
name: 'SnoozedRequests',
|
||||
component: SnoozedRequests
|
||||
component: () => import('@/components/request/SnoozedRequests')
|
||||
},
|
||||
{
|
||||
path: '/user/log-on',
|
||||
name: 'LogOn',
|
||||
component: LogOn
|
||||
component: () => import('@/components/user/LogOn')
|
||||
}
|
||||
]
|
||||
})
|
||||
|
@ -3,6 +3,8 @@
|
||||
export default {
|
||||
/** Action to add a prayer request (pass request text) */
|
||||
ADD_REQUEST: 'add-request',
|
||||
/** Action to check if a user is authenticated, refreshing the session first if it exists */
|
||||
CHECK_AUTHENTICATION: 'check-authentication',
|
||||
/** Action to load the user's prayer journal */
|
||||
LOAD_JOURNAL: 'load-journal',
|
||||
/** Action to update a request */
|
||||
|
@ -1,47 +1,59 @@
|
||||
'use strict'
|
||||
|
||||
import Vue from 'vue'
|
||||
/* eslint-disable no-multi-spaces */
|
||||
import Vue from 'vue'
|
||||
import Vuex from 'vuex'
|
||||
|
||||
import api from '@/api'
|
||||
import AuthService from '@/auth/AuthService'
|
||||
import api from '@/api'
|
||||
import auth from '@/auth/AuthService'
|
||||
|
||||
import mutations from './mutation-types'
|
||||
import actions from './action-types'
|
||||
import actions from './action-types'
|
||||
/* eslint-enable no-multi-spaces */
|
||||
|
||||
Vue.use(Vuex)
|
||||
|
||||
const auth0 = new AuthService()
|
||||
|
||||
/* eslint-disable no-console */
|
||||
const logError = function (error) {
|
||||
if (error.response) {
|
||||
// The request was made and the server responded with a status code
|
||||
// that falls out of the range of 2xx
|
||||
console.log(error.response.data)
|
||||
console.log(error.response.status)
|
||||
console.log(error.response.headers)
|
||||
console.error(error.response.data)
|
||||
console.error(error.response.status)
|
||||
console.error(error.response.headers)
|
||||
} else if (error.request) {
|
||||
// The request was made but no response was received
|
||||
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
|
||||
// http.ClientRequest in node.js
|
||||
console.log(error.request)
|
||||
console.error(error.request)
|
||||
} else {
|
||||
// Something happened in setting up the request that triggered an Error
|
||||
console.log('Error', error.message)
|
||||
console.error('Error', error.message)
|
||||
}
|
||||
console.log(error.config)
|
||||
console.error(`config: ${error.config}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the "Bearer" authorization header with the current access token
|
||||
*/
|
||||
const setBearer = async function () {
|
||||
try {
|
||||
await auth.getAccessToken()
|
||||
api.setBearer(auth.session.id.token)
|
||||
} catch (err) {
|
||||
if (err === 'Not logged in') {
|
||||
console.warn('API request attempted when user was not logged in')
|
||||
} else {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
/* eslint-enable no-console */
|
||||
|
||||
export default new Vuex.Store({
|
||||
state: {
|
||||
user: JSON.parse(localStorage.getItem('user_profile') || '{}'),
|
||||
isAuthenticated: (() => {
|
||||
auth0.scheduleRenewal()
|
||||
if (auth0.isAuthenticated()) {
|
||||
api.setBearer(localStorage.getItem('id_token'))
|
||||
}
|
||||
return auth0.isAuthenticated()
|
||||
})(),
|
||||
user: auth.session.profile,
|
||||
isAuthenticated: auth.isAuthenticated(),
|
||||
journal: {},
|
||||
isLoadingJournal: false
|
||||
},
|
||||
@ -60,49 +72,60 @@ export default new Vuex.Store({
|
||||
if (request.lastStatus !== 'Answered') jrnl.push(request)
|
||||
state.journal = jrnl
|
||||
},
|
||||
[mutations.SET_AUTHENTICATION] (state, value) {
|
||||
state.isAuthenticated = value
|
||||
},
|
||||
[mutations.USER_LOGGED_OFF] (state) {
|
||||
state.user = {}
|
||||
api.removeBearer()
|
||||
state.isAuthenticated = false
|
||||
},
|
||||
[mutations.USER_LOGGED_ON] (state, user) {
|
||||
localStorage.setItem('user_profile', JSON.stringify(user))
|
||||
state.user = user
|
||||
api.setBearer(localStorage.getItem('id_token'))
|
||||
state.isAuthenticated = true
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
async [actions.ADD_REQUEST] ({ commit }, { progress, requestText, recurType, recurCount }) {
|
||||
progress.start()
|
||||
progress.$emit('show', 'indeterminate')
|
||||
try {
|
||||
await setBearer()
|
||||
const newRequest = await api.addRequest(requestText, recurType, recurCount)
|
||||
commit(mutations.REQUEST_ADDED, newRequest.data)
|
||||
progress.finish()
|
||||
progress.$emit('done')
|
||||
} catch (err) {
|
||||
logError(err)
|
||||
progress.fail()
|
||||
progress.$emit('done')
|
||||
}
|
||||
},
|
||||
async [actions.CHECK_AUTHENTICATION] ({ commit }) {
|
||||
try {
|
||||
await auth.getAccessToken()
|
||||
commit(mutations.SET_AUTHENTICATION, auth.isAuthenticated())
|
||||
} catch (_) {
|
||||
commit(mutations.SET_AUTHENTICATION, false)
|
||||
}
|
||||
},
|
||||
async [actions.LOAD_JOURNAL] ({ commit }, progress) {
|
||||
commit(mutations.LOADED_JOURNAL, {})
|
||||
progress.start()
|
||||
progress.$emit('show', 'query')
|
||||
commit(mutations.LOADING_JOURNAL, true)
|
||||
api.setBearer(localStorage.getItem('id_token'))
|
||||
await setBearer()
|
||||
try {
|
||||
const jrnl = await api.journal()
|
||||
commit(mutations.LOADED_JOURNAL, jrnl.data)
|
||||
progress.finish()
|
||||
progress.$emit('done')
|
||||
} catch (err) {
|
||||
logError(err)
|
||||
progress.fail()
|
||||
progress.$emit('done')
|
||||
} finally {
|
||||
commit(mutations.LOADING_JOURNAL, false)
|
||||
}
|
||||
},
|
||||
async [actions.UPDATE_REQUEST] ({ commit, state }, { progress, requestId, status, updateText, recurType, recurCount }) {
|
||||
progress.start()
|
||||
progress.$emit('show', 'indeterminate')
|
||||
try {
|
||||
await setBearer()
|
||||
let oldReq = (state.journal.filter(req => req.requestId === requestId) || [])[0] || {}
|
||||
if (!(status === 'Prayed' && updateText === '')) {
|
||||
if (status !== 'Answered' && (oldReq.recurType !== recurType || oldReq.recurCount !== recurCount)) {
|
||||
@ -114,34 +137,36 @@ export default new Vuex.Store({
|
||||
}
|
||||
const request = await api.getRequest(requestId)
|
||||
commit(mutations.REQUEST_UPDATED, request.data)
|
||||
progress.finish()
|
||||
progress.$emit('done')
|
||||
} catch (err) {
|
||||
logError(err)
|
||||
progress.fail()
|
||||
progress.$emit('done')
|
||||
}
|
||||
},
|
||||
async [actions.SHOW_REQUEST_NOW] ({ commit }, { progress, requestId, showAfter }) {
|
||||
progress.start()
|
||||
progress.$emit('show', 'indeterminate')
|
||||
try {
|
||||
await setBearer()
|
||||
await api.showRequest(requestId, showAfter)
|
||||
const request = await api.getRequest(requestId)
|
||||
commit(mutations.REQUEST_UPDATED, request.data)
|
||||
progress.finish()
|
||||
progress.$emit('done')
|
||||
} catch (err) {
|
||||
logError(err)
|
||||
progress.fail()
|
||||
progress.$emit('done')
|
||||
}
|
||||
},
|
||||
async [actions.SNOOZE_REQUEST] ({ commit }, { progress, requestId, until }) {
|
||||
progress.start()
|
||||
progress.$emit('show', 'indeterminate')
|
||||
try {
|
||||
await setBearer()
|
||||
await api.snoozeRequest(requestId, until)
|
||||
const request = await api.getRequest(requestId)
|
||||
commit(mutations.REQUEST_UPDATED, request.data)
|
||||
progress.finish()
|
||||
progress.$emit('done')
|
||||
} catch (err) {
|
||||
logError(err)
|
||||
progress.fail()
|
||||
progress.$emit('done')
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -9,6 +9,8 @@ export default {
|
||||
REQUEST_ADDED: 'request-added',
|
||||
/** Mutation to replace a prayer request at the top of the current journal */
|
||||
REQUEST_UPDATED: 'request-updated',
|
||||
/** Mutation for setting the authentication state */
|
||||
SET_AUTHENTICATION: 'set-authentication',
|
||||
/** Mutation for logging a user off */
|
||||
USER_LOGGED_OFF: 'user-logged-off',
|
||||
/** Mutation for logging a user on (pass user) */
|
||||
|
@ -1,9 +1,16 @@
|
||||
const webpack = require('webpack')
|
||||
// const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
|
||||
module.exports = {
|
||||
outputDir: '../api/MyPrayerJournal.Api/wwwroot',
|
||||
outputDir: '../MyPrayerJournal.Api/wwwroot',
|
||||
configureWebpack: {
|
||||
plugins: [
|
||||
// new BundleAnalyzerPlugin(),
|
||||
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/)
|
||||
]
|
||||
],
|
||||
optimization: {
|
||||
splitChunks: {
|
||||
chunks: 'all'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
1372
src/app/yarn.lock
1372
src/app/yarn.lock
File diff suppressed because it is too large
Load Diff
110
src/migrate/Program.fs
Normal file
110
src/migrate/Program.fs
Normal file
@ -0,0 +1,110 @@
|
||||
|
||||
open Microsoft.FSharpLu.Json
|
||||
open MyPrayerJournal
|
||||
open Npgsql
|
||||
open Raven.Client.Documents
|
||||
|
||||
type NpgsqlDataReader with
|
||||
member this.getShort = this.GetOrdinal >> this.GetInt16
|
||||
member this.getString = this.GetOrdinal >> this.GetString
|
||||
member this.getTicks = this.GetOrdinal >> this.GetInt64 >> Ticks
|
||||
member this.isNull = this.GetOrdinal >> this.IsDBNull
|
||||
|
||||
let pgConn connStr =
|
||||
let c = new NpgsqlConnection (connStr)
|
||||
c.Open ()
|
||||
c
|
||||
|
||||
let isValidStatus stat =
|
||||
try
|
||||
(RequestAction.fromString >> ignore) stat
|
||||
true
|
||||
with _ -> false
|
||||
|
||||
let getHistory reqId connStr =
|
||||
use conn = pgConn connStr
|
||||
use cmd = conn.CreateCommand ()
|
||||
cmd.CommandText <- """SELECT "asOf", status, text FROM mpj.history WHERE "requestId" = @reqId ORDER BY "asOf" """
|
||||
(cmd.Parameters.Add >> ignore) (NpgsqlParameter ("@reqId", reqId :> obj))
|
||||
use rdr = cmd.ExecuteReader ()
|
||||
seq {
|
||||
while rdr.Read () do
|
||||
match (rdr.getString >> isValidStatus) "status" with
|
||||
| true ->
|
||||
yield
|
||||
{ asOf = rdr.getTicks "asOf"
|
||||
status = (rdr.getString >> RequestAction.fromString) "status"
|
||||
text = match rdr.isNull "text" with true -> None | false -> (rdr.getString >> Some) "text"
|
||||
}
|
||||
| false ->
|
||||
printf "Invalid status %s; skipped history entry %s/%i\n" (rdr.getString "status") reqId
|
||||
((rdr.getTicks >> Ticks.toLong) "asOf")
|
||||
}
|
||||
|> List.ofSeq
|
||||
|
||||
let getNotes reqId connStr =
|
||||
use conn = pgConn connStr
|
||||
use cmd = conn.CreateCommand ()
|
||||
cmd.CommandText <- """SELECT "asOf", notes FROM mpj.note WHERE "requestId" = @reqId"""
|
||||
(cmd.Parameters.Add >> ignore) (NpgsqlParameter ("@reqId", reqId :> obj))
|
||||
use rdr = cmd.ExecuteReader ()
|
||||
seq {
|
||||
while rdr.Read () do
|
||||
yield
|
||||
{ asOf = rdr.getTicks "asOf"
|
||||
notes = rdr.getString "notes"
|
||||
}
|
||||
}
|
||||
|> List.ofSeq
|
||||
|
||||
let migrateRequests (store : IDocumentStore) connStr =
|
||||
use sess = store.OpenSession ()
|
||||
use conn = pgConn connStr
|
||||
use cmd = conn.CreateCommand ()
|
||||
cmd.CommandText <-
|
||||
"""SELECT "requestId", "enteredOn", "userId", "snoozedUntil", "showAfter", "recurType", "recurCount" FROM mpj.request"""
|
||||
use rdr = cmd.ExecuteReader ()
|
||||
while rdr.Read () do
|
||||
let reqId = rdr.getString "requestId"
|
||||
let recurrence =
|
||||
match rdr.getString "recurType" with
|
||||
| "immediate" -> Immediate
|
||||
| "hours" -> Hours
|
||||
| "days" -> Days
|
||||
| "weeks" -> Weeks
|
||||
| x -> invalidOp (sprintf "%s is not a valid recurrence" x)
|
||||
sess.Store (
|
||||
{ Id = (RequestId.fromIdString >> RequestId.toString) reqId
|
||||
enteredOn = rdr.getTicks "enteredOn"
|
||||
userId = (rdr.getString >> UserId) "userId"
|
||||
snoozedUntil = rdr.getTicks "snoozedUntil"
|
||||
showAfter = match recurrence with Immediate -> Ticks 0L | _ -> rdr.getTicks "showAfter"
|
||||
recurType = recurrence
|
||||
recurCount = rdr.getShort "recurCount"
|
||||
history = getHistory reqId connStr
|
||||
notes = getNotes reqId connStr
|
||||
})
|
||||
sess.SaveChanges ()
|
||||
|
||||
open Converters
|
||||
open System
|
||||
open System.Security.Cryptography.X509Certificates
|
||||
|
||||
[<EntryPoint>]
|
||||
let main argv =
|
||||
match argv.Length with
|
||||
| 4 ->
|
||||
let clientCert = new X509Certificate2 (argv.[1], argv.[2])
|
||||
let raven = new DocumentStore (Urls = [| argv.[0] |], Database = "myPrayerJournal", Certificate = clientCert)
|
||||
raven.Conventions.CustomizeJsonSerializer <-
|
||||
fun x ->
|
||||
x.Converters.Add (RequestIdJsonConverter ())
|
||||
x.Converters.Add (TicksJsonConverter ())
|
||||
x.Converters.Add (UserIdJsonConverter ())
|
||||
x.Converters.Add (CompactUnionJsonConverter ())
|
||||
let store = raven.Initialize ()
|
||||
migrateRequests store argv.[3]
|
||||
printfn "fin"
|
||||
| _ ->
|
||||
Console.WriteLine "Usage: dotnet migrate.dll [raven-url] [raven-cert-file] [raven-cert-pw] [postgres-conn-str]"
|
||||
0
|
23
src/migrate/migrate.fsproj
Normal file
23
src/migrate/migrate.fsproj
Normal file
@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>netcoreapp2.2</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="Program.fs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.FSharpLu.Json" Version="0.11.2" />
|
||||
<PackageReference Include="Npgsql" Version="4.0.8" />
|
||||
<PackageReference Include="RavenDb.Client" Version="4.2.2" />
|
||||
<ProjectReference Include="../MyPrayerJournal.Api/MyPrayerJournal.Api.fsproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="FSharp.Core" Version="4.7.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
Loading…
Reference in New Issue
Block a user