myPrayerJournal v2 #27

Merged
danieljsummers merged 27 commits from version-2 into master 2019-09-03 00:01:26 +00:00
44 changed files with 2729 additions and 1939 deletions

12
.gitignore vendored
View File

@ -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

View 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)

View 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

View File

@ -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
]
]
]
]

View File

@ -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>

View 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
View 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

View File

@ -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)