diff --git a/src/MyPrayerJournal.Api/Data.fs b/src/MyPrayerJournal.Api/Data.fs index b173112..9d2c112 100644 --- a/src/MyPrayerJournal.Api/Data.fs +++ b/src/MyPrayerJournal.Api/Data.fs @@ -1,17 +1,14 @@ namespace MyPrayerJournal -open FSharp.Control.Tasks.V2.ContextInsensitive -open Microsoft.FSharpLu -open Newtonsoft.Json -open Raven.Client.Documents -open Raven.Client.Documents.Indexes -open Raven.Client.Documents.Linq 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 () @@ -36,30 +33,28 @@ module Converters = override __.ReadJson(reader: JsonReader, _ : Type, _ : Ticks, _ : bool, _ : JsonSerializer) = (string >> int64 >> Ticks) reader.Value - /// A sequence of all custom converters for myPrayerJournal + /// 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 = - /// Index requests by user ID - type Requests_ByUserId () as this = - inherit AbstractJavaScriptIndexCreationTask () - do - this.Maps <- HashSet [ "docs.Requests.Select(req => new { userId = req.userId })" ] + open Raven.Client.Documents.Indexes /// Index requests for a journal view type Requests_AsJournal () as this = inherit AbstractJavaScriptIndexCreationTask () do this.Maps <- HashSet [ - "docs.Requests.Select(req => new { - requestId = req.Id, + """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, @@ -68,10 +63,11 @@ module Indexes = showAfter = req.showAfter, recurType = req.recurType, recurCount = req.recurCount - })" + })""" ] this.Fields <- - [ "text", IndexFieldOptions (Storage = Nullable FieldStorage.Yes) + [ "requestId", IndexFieldOptions (Storage = Nullable FieldStorage.Yes) + "text", IndexFieldOptions (Storage = Nullable FieldStorage.Yes) "asOf", IndexFieldOptions (Storage = Nullable FieldStorage.Yes) "lastStatus", IndexFieldOptions (Storage = Nullable FieldStorage.Yes) ] @@ -79,39 +75,14 @@ module Indexes = |> Dictionary -/// Extensions on the IAsyncDocumentSession interface to support our data manipulation needs -[] -module Extensions = - - open Indexes - open Raven.Client.Documents.Commands.Batches - open Raven.Client.Documents.Operations - open Raven.Client.Documents.Session - - /// Format an RQL query by a strongly-typed index - let fromIndex (typ : Type) = - typ.Name.Replace ("_", "/") |> sprintf "from index '%s'" - - /// Utility method to create a patch request to push an item on the end of a list - let listPush<'T> listName docId (item : 'T) = - let r = PatchRequest() - r.Script <- sprintf "this.%s.push(args.Item)" listName - r.Values.["Item"] <- item - PatchCommandData (docId, null, r, null) - - /// Utility method to create a patch to update a single field - // TODO: think we need to include quotes if it's a string - let fieldUpdate<'T> fieldName docId (item : 'T) = - let r = PatchRequest() - r.Script <- sprintf "this.%s = args.Item" fieldName - r.Values.["Item"] <- item - PatchCommandData (docId, null, r, null) - - /// 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 @@ -134,11 +105,15 @@ module Data = /// Retrieve all answered requests for the given user let answeredRequests userId (sess : IAsyncDocumentSession) = - sess.Query() - .Where(fun r -> r.userId = userId && r.lastStatus = "Answered") - .OrderByDescending(fun r -> r.asOf) - .ProjectInto() - .ToListAsync() + task { + let! reqs = + sess.Query() + .Where(fun r -> r.userId = userId && r.lastStatus = "Answered") + .OrderByDescending(fun r -> r.asOf) + .ProjectInto() + .ToListAsync () + return List.ofSeq reqs + } /// Retrieve the user's current journal let journalByUserId userId (sess : IAsyncDocumentSession) = diff --git a/src/MyPrayerJournal.Api/Domain.fs b/src/MyPrayerJournal.Api/Domain.fs index 5c96e9f..042f07a 100644 --- a/src/MyPrayerJournal.Api/Domain.fs +++ b/src/MyPrayerJournal.Api/Domain.fs @@ -43,21 +43,13 @@ type Recurrence = | Days | Weeks module Recurrence = - /// The string reprsentation used in the database and the web app - // TODO/FIXME: will this be true in v2? it's not in the database... - let toString x = - match x with - | Immediate -> "immediate" - | Hours -> "hours" - | Days -> "days" - | Weeks -> "weeks" /// Create a recurrence value from a string let fromString x = match x with - | "immediate" -> Immediate - | "hours" -> Hours - | "days" -> Days - | "weeks" -> Weeks + | "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 = @@ -159,8 +151,8 @@ with // RavenDB doesn't like the "@"-suffixed properties from record types in a ProjectInto clause [] type JournalRequest () = - /// The ID of the request - [] val mutable requestId : RequestId + /// The ID of the request (just the CUID part) + [] val mutable requestId : string /// The ID of the user to whom the request belongs [] val mutable userId : UserId /// The current text of the request diff --git a/src/MyPrayerJournal.Api/Handlers.fs b/src/MyPrayerJournal.Api/Handlers.fs index c494713..41db8c2 100644 --- a/src/MyPrayerJournal.Api/Handlers.fs +++ b/src/MyPrayerJournal.Api/Handlers.fs @@ -1,6 +1,6 @@ /// HTTP handlers for the myPrayerJournal API [] -module MyPrayerJournal.Api.Handlers +module MyPrayerJournal.Handlers open FSharp.Control.Tasks.V2.ContextInsensitive open Giraffe @@ -46,7 +46,9 @@ module private Helpers = /// Create a RavenDB session let session (ctx : HttpContext) = - ctx.GetService().OpenAsyncSession () + let sess = ctx.GetService().OpenAsyncSession () + sess.Advanced.WaitForIndexesAfterSaveChanges () + sess /// Get the user's "sub" claim let user (ctx : HttpContext) = diff --git a/src/MyPrayerJournal.Api/MyPrayerJournal.Api.fsproj b/src/MyPrayerJournal.Api/MyPrayerJournal.Api.fsproj index 7c4a819..f202e7c 100644 --- a/src/MyPrayerJournal.Api/MyPrayerJournal.Api.fsproj +++ b/src/MyPrayerJournal.Api/MyPrayerJournal.Api.fsproj @@ -2,7 +2,7 @@ netcoreapp2.2 - 1.2.2.0 + 2.0.0.0 diff --git a/src/MyPrayerJournal.Api/Program.fs b/src/MyPrayerJournal.Api/Program.fs index f0e0b11..5967783 100644 --- a/src/MyPrayerJournal.Api/Program.fs +++ b/src/MyPrayerJournal.Api/Program.fs @@ -1,4 +1,4 @@ -namespace MyPrayerJournal.Api +module MyPrayerJournal.Api open Microsoft.AspNetCore.Builder open Microsoft.AspNetCore.Hosting @@ -40,10 +40,10 @@ module Configure = open Giraffe.TokenRouter open Microsoft.AspNetCore.Authentication.JwtBearer open Microsoft.Extensions.DependencyInjection - open Microsoft.FSharpLu.Json open MyPrayerJournal open MyPrayerJournal.Indexes open Newtonsoft.Json + open Newtonsoft.Json.Serialization open Raven.Client.Documents open Raven.Client.Documents.Indexes open System.Security.Cryptography.X509Certificates @@ -51,19 +51,14 @@ module Configure = /// Configure dependency injection let services (bldr : IWebHostBuilder) = let svcs (sc : IServiceCollection) = - /// A set of JSON converters used for both Giraffe's request serialization and RavenDB's storage - let jsonConverters : JsonConverter seq = - seq { - yield! Converters.all - yield CompactUnionJsonConverter true - } /// Custom settings for the JSON serializer (uses compact representation for options and DUs) let jsonSettings = let x = NewtonsoftJsonSerializer.DefaultSettings - jsonConverters |> List.ofSeq |> List.iter x.Converters.Add + 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 () @@ -87,10 +82,12 @@ module Configure = let store = new DocumentStore () store.Urls <- [| config.["URL"] |] store.Database <- config.["Database"] - // store.Certificate <- new X509Certificate2 (config.["Certificate"], config.["Password"]) - store.Conventions.CustomizeJsonSerializer <- fun x -> jsonConverters |> List.ofSeq |> List.iter x.Converters.Add + 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.Assembly, store) + IndexCreation.CreateIndexes (typeof.Assembly, store) bldr.ConfigureServices svcs open Microsoft.Extensions.Logging @@ -169,13 +166,11 @@ module Configure = /// Build the web host from the given configuration let buildHost (bldr : IWebHostBuilder) = bldr.Build () -module Program = - - let exitCode = 0 +let exitCode = 0 - [] - let main _ = - let appRoot = Directory.GetCurrentDirectory () - use host = WebHostBuilder() |> (Configure.webHost appRoot [| "wwwroot" |] >> Configure.buildHost) - host.Run () - exitCode +[] +let main _ = + let appRoot = Directory.GetCurrentDirectory () + use host = WebHostBuilder() |> (Configure.webHost appRoot [| "wwwroot" |] >> Configure.buildHost) + host.Run () + exitCode diff --git a/src/app/src/App.vue b/src/app/src/App.vue index c28a592..805227f 100644 --- a/src/app/src/App.vue +++ b/src/app/src/App.vue @@ -39,7 +39,11 @@ export default { 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 } } } diff --git a/src/app/src/components/Journal.vue b/src/app/src/components/Journal.vue index e8429bd..f891dac 100644 --- a/src/app/src/components/Journal.vue +++ b/src/app/src/components/Journal.vue @@ -10,7 +10,7 @@ article.mpj-main-content-wide(role='main') br .mpj-journal(v-if='journal.length > 0') request-card(v-for='request in journal' - :key='request.Id' + :key='request.requestId' :request='request' :events='eventBus' :toast='toast') diff --git a/src/app/src/components/request/ActiveRequests.vue b/src/app/src/components/request/ActiveRequests.vue index 5d29752..a5d67b7 100644 --- a/src/app/src/components/request/ActiveRequests.vue +++ b/src/app/src/components/request/ActiveRequests.vue @@ -5,7 +5,7 @@ article.mpj-main-content(role='main') 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.Id' + :key='req.requestId' :request='req' :toast='toast') p(v-else) Loading journal... diff --git a/src/app/src/components/request/AnsweredRequests.vue b/src/app/src/components/request/AnsweredRequests.vue index 56956e6..260ffb9 100644 --- a/src/app/src/components/request/AnsweredRequests.vue +++ b/src/app/src/components/request/AnsweredRequests.vue @@ -5,7 +5,7 @@ article.mpj-main-content(role='main') 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.Id' + :key='req.requestId' :request='req' :toast='toast') p(v-else) Loading answered requests... diff --git a/src/app/src/components/request/EditRequest.vue b/src/app/src/components/request/EditRequest.vue index 9563ceb..4f31cbe 100644 --- a/src/app/src/components/request/EditRequest.vue +++ b/src/app/src/components/request/EditRequest.vue @@ -42,7 +42,7 @@ article.mpj-main-content(role='main') input(v-model='form.recur.typ' type='radio' name='recur' - value='immediate') + value='Immediate') | Immediately |     label.normal @@ -56,9 +56,9 @@ article.mpj-main-content(role='main') :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 + option(value='Hours') hours + option(value='Days') days + option(value='Weeks') weeks .mpj-text-right button(:disabled='!isValidRecurrence' @click.stop='saveRequest()').primary. @@ -92,7 +92,7 @@ export default { requestText: '', status: 'Updated', recur: { - typ: 'immediate', + typ: 'Immediate', other: '', count: '' } @@ -101,16 +101,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' + return this.form.recur.typ !== 'Immediate' }, toast () { return this.$parent.$refs.toast @@ -125,7 +125,7 @@ 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 { @@ -134,12 +134,12 @@ export default { if (this.journal.length === 0) { await this.$store.dispatch(actions.LOAD_JOURNAL, this.$Progress) } - const req = this.journal.filter(r => r.Id === this.id)[0] + 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 { @@ -166,8 +166,8 @@ export default { await this.$store.dispatch(actions.ADD_REQUEST, { 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' }) } else { @@ -176,8 +176,8 @@ export default { 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' }) diff --git a/src/app/src/components/request/NotesEdit.vue b/src/app/src/components/request/NotesEdit.vue index 9818154..85c28e1 100644 --- a/src/app/src/components/request/NotesEdit.vue +++ b/src/app/src/components/request/NotesEdit.vue @@ -85,7 +85,7 @@ export default { } }, openDialog (request) { - this.form.requestId = request.Id + this.form.requestId = request.requestId this.notesVisible = true }, async saveNotes () { diff --git a/src/app/src/components/request/RequestCard.vue b/src/app/src/components/request/RequestCard.vue index a8ab47c..e287784 100644 --- a/src/app/src/components/request/RequestCard.vue +++ b/src/app/src/components/request/RequestCard.vue @@ -36,20 +36,20 @@ export default { async markPrayed () { await this.$store.dispatch(actions.UPDATE_REQUEST, { progress: this.$Progress, - requestId: this.request.Id, + requestId: this.request.requestId, status: 'Prayed', updateText: '' }) this.toast.showToast('Request marked as prayed', { theme: 'success' }) }, showEdit () { - this.$router.push({ name: 'EditRequest', params: { id: this.request.Id } }) + this.$router.push({ name: 'EditRequest', params: { id: this.request.requestId } }) }, showNotes () { this.events.$emit('notes', this.request) }, snooze () { - this.events.$emit('snooze', this.request.Id) + this.events.$emit('snooze', this.request.requestId) } } } diff --git a/src/app/src/components/request/RequestListItem.vue b/src/app/src/components/request/RequestListItem.vue index db53ff7..b855ebe 100644 --- a/src/app/src/components/request/RequestListItem.vue +++ b/src/app/src/components/request/RequestListItem.vue @@ -60,26 +60,26 @@ export default { async cancelSnooze () { await this.$store.dispatch(actions.SNOOZE_REQUEST, { progress: this.$Progress, - requestId: this.request.Id, + requestId: this.request.requestId, until: 0 }) this.toast.showToast('Request un-snoozed', { theme: 'success' }) this.$parent.$emit('requestUnsnoozed') }, editRequest () { - this.$router.push({ name: 'EditRequest', params: { id: this.request.Id } }) + this.$router.push({ name: 'EditRequest', params: { id: this.request.requestId } }) }, async showNow () { await this.$store.dispatch(actions.SHOW_REQUEST_NOW, { progress: this.$Progress, - requestId: this.request.Id, + requestId: this.request.requestId, showAfter: Date.now() }) this.toast.showToast('Recurrence skipped; request now shows in journal', { theme: 'success' }) this.$parent.$emit('requestNowShown') }, viewFull () { - this.$router.push({ name: 'FullRequest', params: { id: this.request.Id } }) + this.$router.push({ name: 'FullRequest', params: { id: this.request.requestId } }) } } } diff --git a/src/app/src/components/request/SnoozedRequests.vue b/src/app/src/components/request/SnoozedRequests.vue index fe844a8..bec1909 100644 --- a/src/app/src/components/request/SnoozedRequests.vue +++ b/src/app/src/components/request/SnoozedRequests.vue @@ -5,7 +5,7 @@ article.mpj-main-content(role='main') 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.Id' + :key='req.requestId' :request='req' :toast='toast') p(v-else) Loading journal... diff --git a/src/app/src/store/index.js b/src/app/src/store/index.js index 8b5a625..b7d0e6b 100644 --- a/src/app/src/store/index.js +++ b/src/app/src/store/index.js @@ -56,7 +56,7 @@ export default new Vuex.Store({ state.journal.push(newRequest) }, [mutations.REQUEST_UPDATED] (state, request) { - let jrnl = state.journal.filter(it => it.Id !== request.Id) + let jrnl = state.journal.filter(it => it.requestId !== request.requestId) if (request.lastStatus !== 'Answered') jrnl.push(request) state.journal = jrnl }, @@ -103,7 +103,7 @@ export default new Vuex.Store({ async [actions.UPDATE_REQUEST] ({ commit, state }, { progress, requestId, status, updateText, recurType, recurCount }) { progress.start() try { - let oldReq = (state.journal.filter(req => req.Id === requestId) || [])[0] || {} + let oldReq = (state.journal.filter(req => req.requestId === requestId) || [])[0] || {} if (!(status === 'Prayed' && updateText === '')) { if (status !== 'Answered' && (oldReq.recurType !== recurType || oldReq.recurCount !== recurCount)) { await api.updateRecurrence(requestId, recurType, recurCount)