diff --git a/.gitignore b/.gitignore index 4e6438d..725126e 100644 --- a/.gitignore +++ b/.gitignore @@ -253,11 +253,11 @@ paket-files/ *.sln.iml # 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 diff --git a/src/MyPrayerJournal.Api/Data.fs b/src/MyPrayerJournal.Api/Data.fs new file mode 100644 index 0000000..6da44b0 --- /dev/null +++ b/src/MyPrayerJournal.Api/Data.fs @@ -0,0 +1,156 @@ +namespace MyPrayerJournal + +open FSharp.Control.Tasks.V2.ContextInsensitive +open Microsoft.EntityFrameworkCore +open Microsoft.FSharpLu + +/// Entity Framework configuration for myPrayerJournal +module internal EFConfig = + + open FSharp.EFCore.OptionConverter + open System.Collections.Generic + + /// Configure EF properties for all entity types + let configure (mb : ModelBuilder) = + mb.Entity ( + 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 + mb.Model.FindEntityType(typeof).FindProperty("text").SetValueConverter (OptionConverter ()) + + mb.Entity ( + 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 + + mb.Entity ( + 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) + .WithOne() + .HasForeignKey(fun e -> e.requestId :> obj) + |> ignore + m.HasMany(fun e -> e.notes :> IEnumerable) + .WithOne() + .HasForeignKey(fun e -> e.requestId :> obj) + |> ignore) + |> ignore + + mb.Query ( + 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) = + inherit DbContext (opts) + + [] + val mutable private history : DbSet + [] + val mutable private notes : DbSet + [] + val mutable private requests : DbSet + [] + val mutable private journal : DbQuery + + 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 + EFConfig.configure 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 + } diff --git a/src/api/MyPrayerJournal.Api/Handlers.fs b/src/MyPrayerJournal.Api/Handlers.fs similarity index 100% rename from src/api/MyPrayerJournal.Api/Handlers.fs rename to src/MyPrayerJournal.Api/Handlers.fs diff --git a/src/api/MyPrayerJournal.Api/MyPrayerJournal.Api.fsproj b/src/MyPrayerJournal.Api/MyPrayerJournal.Api.fsproj similarity index 86% rename from src/api/MyPrayerJournal.Api/MyPrayerJournal.Api.fsproj rename to src/MyPrayerJournal.Api/MyPrayerJournal.Api.fsproj index 278fa5f..023bf39 100644 --- a/src/api/MyPrayerJournal.Api/MyPrayerJournal.Api.fsproj +++ b/src/MyPrayerJournal.Api/MyPrayerJournal.Api.fsproj @@ -1,4 +1,4 @@ - + netcoreapp2.2 @@ -30,4 +30,8 @@ + + + + diff --git a/src/api/MyPrayerJournal.Api/Program.fs b/src/MyPrayerJournal.Api/Program.fs similarity index 100% rename from src/api/MyPrayerJournal.Api/Program.fs rename to src/MyPrayerJournal.Api/Program.fs diff --git a/src/api/MyPrayerJournal.Api/Properties/launchSettings.json b/src/MyPrayerJournal.Api/Properties/launchSettings.json similarity index 100% rename from src/api/MyPrayerJournal.Api/Properties/launchSettings.json rename to src/MyPrayerJournal.Api/Properties/launchSettings.json diff --git a/src/api/MyPrayerJournal.Api/appsettings.json b/src/MyPrayerJournal.Api/appsettings.json similarity index 100% rename from src/api/MyPrayerJournal.Api/appsettings.json rename to src/MyPrayerJournal.Api/appsettings.json diff --git a/src/MyPrayerJournal.Domain/Entities.fs b/src/MyPrayerJournal.Domain/Entities.fs new file mode 100644 index 0000000..043aa0e --- /dev/null +++ b/src/MyPrayerJournal.Domain/Entities.fs @@ -0,0 +1,114 @@ +[] +/// Entities for use in the data model for myPrayerJournal +module MyPrayerJournal.Entities + +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 [] 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 + } + +/// Note is a note regarding a prayer request that does not result in an update to its text +and [] 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 = "" + } + +/// Request is the identifying record for a prayer request +and [] 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 + /// The notes for this request + notes : ICollection + } +with + /// An empty request + static member empty = + { requestId = "" + enteredOn = 0L + userId = "" + snoozedUntil = 0L + showAfter = 0L + recurType = "immediate" + recurCount = 0s + history = List () + notes = List () + } + +/// 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 +[] +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 + } diff --git a/src/MyPrayerJournal.Domain/MyPrayerJournal.Domain.fsproj b/src/MyPrayerJournal.Domain/MyPrayerJournal.Domain.fsproj new file mode 100644 index 0000000..028d930 --- /dev/null +++ b/src/MyPrayerJournal.Domain/MyPrayerJournal.Domain.fsproj @@ -0,0 +1,11 @@ + + + + netstandard2.0 + + + + + + + diff --git a/src/MyPrayerJournal.Mobile.Android/Assets/AboutAssets.txt b/src/MyPrayerJournal.Mobile.Android/Assets/AboutAssets.txt new file mode 100644 index 0000000..ee39886 --- /dev/null +++ b/src/MyPrayerJournal.Mobile.Android/Assets/AboutAssets.txt @@ -0,0 +1,19 @@ +Any raw assets you want to be deployed with your application can be placed in +this directory (and child directories) and given a Build Action of "AndroidAsset". + +These files will be deployed with you package and will be accessible using Android's +AssetManager, like this: + +public class ReadAsset : Activity +{ + protected override void OnCreate (Bundle bundle) + { + base.OnCreate (bundle); + + InputStream input = Assets.Open ("my_asset.txt"); + } +} + +Additionally, some Android functions will automatically load asset files: + +Typeface tf = Typeface.CreateFromAsset (Context.Assets, "fonts/samplefont.ttf"); \ No newline at end of file diff --git a/src/MyPrayerJournal.Mobile.Android/MainActivity.fs b/src/MyPrayerJournal.Mobile.Android/MainActivity.fs new file mode 100644 index 0000000..44e9dbc --- /dev/null +++ b/src/MyPrayerJournal.Mobile.Android/MainActivity.fs @@ -0,0 +1,33 @@ +namespace MyPrayerJournal.Mobile.Android + +open System + +open Android.App +open Android.Content +open Android.OS +open Android.Runtime +open Android.Views +open Android.Widget + +type Resources = MyPrayerJournal.Mobile.Android.Resource + +[] +type MainActivity () = + inherit Activity () + + let mutable count:int = 1 + + override this.OnCreate (bundle) = + + base.OnCreate (bundle) + + // Set our view from the "main" layout resource + this.SetContentView (Resources.Layout.Main) + + // Get our button from the layout resource, and attach an event to it + let button = this.FindViewById