-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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)
- Loading branch information
1 parent
ce588b6
commit fa78e86
Showing
44 changed files
with
2,730 additions
and
1,940 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
Oops, something went wrong.