Compare commits
16 Commits
Author | SHA1 | Date | |
---|---|---|---|
0c1285eaa7 | |||
c9ccfe8b68 | |||
2e5a1426f6 | |||
05394b4461 | |||
14b0a58d98 | |||
bade89dd37 | |||
26f408bb54 | |||
9d71177352 | |||
2b5ec692f2 | |||
d86249c18e | |||
194cd2b5cc | |||
42e3a58131 | |||
facc294d66 | |||
5240b78487 | |||
7fb1eca2a3 | |||
ae8cf9ad80 |
@ -3,10 +3,11 @@
|
|||||||
"isRoot": true,
|
"isRoot": true,
|
||||||
"tools": {
|
"tools": {
|
||||||
"fake-cli": {
|
"fake-cli": {
|
||||||
"version": "5.23.0",
|
"version": "6.1.3",
|
||||||
"commands": [
|
"commands": [
|
||||||
"fake"
|
"fake"
|
||||||
]
|
],
|
||||||
|
"rollForward": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,44 +1,50 @@
|
|||||||
#r "paket:
|
open Fake.Core
|
||||||
nuget Fake.DotNet.Cli
|
open Fake.DotNet
|
||||||
nuget Fake.DotNet.Testing.Expecto
|
open Fake.IO
|
||||||
nuget Fake.IO.FileSystem
|
open Fake.IO.Globbing.Operators
|
||||||
nuget Fake.Core.Target //"
|
|
||||||
#load ".fake/build.fsx/intellisense.fsx"
|
let execContext = Context.FakeExecutionContext.Create false "build.fsx" []
|
||||||
open Fake.Core
|
Context.setExecutionContext (Context.RuntimeContext.Fake execContext)
|
||||||
open Fake.DotNet
|
|
||||||
open Fake.DotNet.Testing
|
/// The root path to the projects within this solution
|
||||||
open Fake.IO
|
let projPath = "src"
|
||||||
open Fake.IO.Globbing.Operators
|
|
||||||
open Fake.Core.TargetOperators
|
Target.create "Clean" (fun _ ->
|
||||||
|
!! "src/**/bin"
|
||||||
Target.initEnvironment ()
|
++ "src/**/obj"
|
||||||
|
|> Shell.cleanDirs
|
||||||
/// The root path to the projects within this solution
|
)
|
||||||
let projPath = "src/PrayerTracker"
|
|
||||||
|
Target.create "Test" (fun _ ->
|
||||||
Target.create "Clean" (fun _ ->
|
let testPath = $"{projPath}/Tests"
|
||||||
!! "src/**/bin"
|
DotNet.build (fun opts -> { opts with NoLogo = true }) $"{testPath}/PrayerTracker.Tests.fsproj"
|
||||||
++ "src/**/obj"
|
Testing.Expecto.run
|
||||||
|> Shell.cleanDirs
|
(fun opts -> { opts with WorkingDirectory = $"{testPath}/bin/Release/net9.0" })
|
||||||
)
|
[ "PrayerTracker.Tests.dll" ])
|
||||||
|
|
||||||
Target.create "Test" (fun _ ->
|
Target.create "Publish" (fun _ ->
|
||||||
let testPath = $"{projPath}.Tests"
|
DotNet.publish
|
||||||
DotNet.build (fun opts -> { opts with NoLogo = true }) $"{testPath}/PrayerTracker.Tests.fsproj"
|
(fun opts -> { opts with Runtime = Some "linux-x64"; SelfContained = Some false; NoLogo = true })
|
||||||
Expecto.run
|
$"{projPath}/PrayerTracker/PrayerTracker.fsproj")
|
||||||
(fun opts -> { opts with WorkingDirectory = $"{testPath}/bin/Release/net7.0" })
|
|
||||||
[ "PrayerTracker.Tests.dll" ])
|
Target.create "All" ignore
|
||||||
|
|
||||||
Target.create "Publish" (fun _ ->
|
open Fake.Core.TargetOperators
|
||||||
DotNet.publish
|
|
||||||
(fun opts -> { opts with Runtime = Some "linux-x64"; SelfContained = Some false; NoLogo = true })
|
let dependencies = [
|
||||||
$"{projPath}/PrayerTracker.fsproj")
|
"Clean"
|
||||||
|
==> "Test"
|
||||||
Target.create "All" ignore
|
==> "Publish"
|
||||||
|
==> "All"
|
||||||
"Clean"
|
]
|
||||||
==> "Test"
|
|
||||||
==> "Publish"
|
[<EntryPoint>]
|
||||||
==> "All"
|
let main args =
|
||||||
|
try
|
||||||
Target.runOrDefault "All"
|
match args with
|
||||||
|
| [| target |] -> Target.runOrDefault target
|
||||||
|
| _ -> Target.runOrDefault "All"
|
||||||
|
0
|
||||||
|
with e ->
|
||||||
|
printfn "%A" e
|
||||||
|
1
|
19
build.fsproj
Normal file
19
build.fsproj
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Include="build.fs" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Fake.Core.Target" Version="6.1.3" />
|
||||||
|
<PackageReference Include="Fake.DotNet.Cli" Version="6.1.3" />
|
||||||
|
<PackageReference Include="Fake.Dotnet.Testing.Expecto" Version="6.1.3" />
|
||||||
|
<PackageReference Include="MSBuild.StructuredLogger" Version="2.2.386" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
233
build.fsx.lock
233
build.fsx.lock
@ -1,233 +0,0 @@
|
|||||||
STORAGE: NONE
|
|
||||||
RESTRICTION: || (== net6.0) (== netstandard2.0)
|
|
||||||
NUGET
|
|
||||||
remote: https://api.nuget.org/v3/index.json
|
|
||||||
BlackFox.VsWhere (1.1)
|
|
||||||
FSharp.Core (>= 4.2.3)
|
|
||||||
Microsoft.Win32.Registry (>= 4.7)
|
|
||||||
Fake.Core.CommandLineParsing (5.23)
|
|
||||||
FParsec (>= 1.1.1)
|
|
||||||
FSharp.Core (>= 6.0)
|
|
||||||
Fake.Core.Context (5.23)
|
|
||||||
FSharp.Core (>= 6.0)
|
|
||||||
Fake.Core.Environment (5.23)
|
|
||||||
FSharp.Core (>= 6.0)
|
|
||||||
Fake.Core.FakeVar (5.23)
|
|
||||||
Fake.Core.Context (>= 5.23)
|
|
||||||
FSharp.Core (>= 6.0)
|
|
||||||
Fake.Core.Process (5.23)
|
|
||||||
Fake.Core.Environment (>= 5.23)
|
|
||||||
Fake.Core.FakeVar (>= 5.23)
|
|
||||||
Fake.Core.String (>= 5.23)
|
|
||||||
Fake.Core.Trace (>= 5.23)
|
|
||||||
Fake.IO.FileSystem (>= 5.23)
|
|
||||||
FSharp.Core (>= 6.0)
|
|
||||||
System.Collections.Immutable (>= 5.0)
|
|
||||||
Fake.Core.SemVer (5.23)
|
|
||||||
FSharp.Core (>= 6.0)
|
|
||||||
Fake.Core.String (5.23)
|
|
||||||
FSharp.Core (>= 6.0)
|
|
||||||
Fake.Core.Target (5.23)
|
|
||||||
Fake.Core.CommandLineParsing (>= 5.23)
|
|
||||||
Fake.Core.Context (>= 5.23)
|
|
||||||
Fake.Core.Environment (>= 5.23)
|
|
||||||
Fake.Core.FakeVar (>= 5.23)
|
|
||||||
Fake.Core.Process (>= 5.23)
|
|
||||||
Fake.Core.String (>= 5.23)
|
|
||||||
Fake.Core.Trace (>= 5.23)
|
|
||||||
FSharp.Control.Reactive (>= 5.0.2)
|
|
||||||
FSharp.Core (>= 6.0)
|
|
||||||
Fake.Core.Tasks (5.23)
|
|
||||||
Fake.Core.Trace (>= 5.23)
|
|
||||||
FSharp.Core (>= 6.0)
|
|
||||||
Fake.Core.Trace (5.23)
|
|
||||||
Fake.Core.Environment (>= 5.23)
|
|
||||||
Fake.Core.FakeVar (>= 5.23)
|
|
||||||
FSharp.Core (>= 6.0)
|
|
||||||
Fake.Core.Xml (5.23)
|
|
||||||
Fake.Core.String (>= 5.23)
|
|
||||||
FSharp.Core (>= 6.0)
|
|
||||||
Fake.DotNet.Cli (5.23)
|
|
||||||
Fake.Core.Environment (>= 5.23)
|
|
||||||
Fake.Core.Process (>= 5.23)
|
|
||||||
Fake.Core.String (>= 5.23)
|
|
||||||
Fake.Core.Trace (>= 5.23)
|
|
||||||
Fake.DotNet.MSBuild (>= 5.23)
|
|
||||||
Fake.DotNet.NuGet (>= 5.23)
|
|
||||||
Fake.IO.FileSystem (>= 5.23)
|
|
||||||
FSharp.Core (>= 6.0)
|
|
||||||
Mono.Posix.NETStandard (>= 1.0)
|
|
||||||
Newtonsoft.Json (>= 13.0.1)
|
|
||||||
Fake.DotNet.MSBuild (5.23)
|
|
||||||
BlackFox.VsWhere (>= 1.1)
|
|
||||||
Fake.Core.Environment (>= 5.23)
|
|
||||||
Fake.Core.Process (>= 5.23)
|
|
||||||
Fake.Core.String (>= 5.23)
|
|
||||||
Fake.Core.Trace (>= 5.23)
|
|
||||||
Fake.IO.FileSystem (>= 5.23)
|
|
||||||
FSharp.Core (>= 6.0)
|
|
||||||
MSBuild.StructuredLogger (>= 2.1.545)
|
|
||||||
Fake.DotNet.NuGet (5.23)
|
|
||||||
Fake.Core.Environment (>= 5.23)
|
|
||||||
Fake.Core.Process (>= 5.23)
|
|
||||||
Fake.Core.SemVer (>= 5.23)
|
|
||||||
Fake.Core.String (>= 5.23)
|
|
||||||
Fake.Core.Tasks (>= 5.23)
|
|
||||||
Fake.Core.Trace (>= 5.23)
|
|
||||||
Fake.Core.Xml (>= 5.23)
|
|
||||||
Fake.IO.FileSystem (>= 5.23)
|
|
||||||
Fake.Net.Http (>= 5.23)
|
|
||||||
FSharp.Core (>= 6.0)
|
|
||||||
Newtonsoft.Json (>= 13.0.1)
|
|
||||||
NuGet.Protocol (>= 5.11)
|
|
||||||
Fake.DotNet.Testing.Expecto (5.23)
|
|
||||||
Fake.Core.Process (>= 5.23)
|
|
||||||
Fake.Core.String (>= 5.23)
|
|
||||||
Fake.Core.Trace (>= 5.23)
|
|
||||||
Fake.IO.FileSystem (>= 5.23)
|
|
||||||
Fake.Testing.Common (>= 5.23)
|
|
||||||
FSharp.Core (>= 6.0)
|
|
||||||
Fake.IO.FileSystem (5.23)
|
|
||||||
Fake.Core.String (>= 5.23)
|
|
||||||
FSharp.Core (>= 6.0)
|
|
||||||
Fake.Net.Http (5.23)
|
|
||||||
Fake.Core.Trace (>= 5.23)
|
|
||||||
FSharp.Core (>= 6.0)
|
|
||||||
Fake.Testing.Common (5.23)
|
|
||||||
Fake.Core.Trace (>= 5.23)
|
|
||||||
FSharp.Core (>= 6.0)
|
|
||||||
FParsec (1.1.1)
|
|
||||||
FSharp.Core (>= 4.3.4)
|
|
||||||
FSharp.Control.Reactive (5.0.5)
|
|
||||||
FSharp.Core (>= 4.7.2)
|
|
||||||
System.Reactive (>= 5.0 < 6.0)
|
|
||||||
FSharp.Core (6.0.5)
|
|
||||||
Microsoft.Build (17.2)
|
|
||||||
Microsoft.Build.Framework (>= 17.2) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net472)) (&& (== netstandard2.0) (>= net6.0))
|
|
||||||
Microsoft.NET.StringTools (>= 1.0) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net472)) (&& (== netstandard2.0) (>= net6.0))
|
|
||||||
Microsoft.Win32.Registry (>= 4.3) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net6.0))
|
|
||||||
System.Collections.Immutable (>= 5.0) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net472)) (&& (== netstandard2.0) (>= net6.0))
|
|
||||||
System.Configuration.ConfigurationManager (>= 4.7) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net472)) (&& (== netstandard2.0) (>= net6.0))
|
|
||||||
System.Reflection.Metadata (>= 1.6) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net6.0))
|
|
||||||
System.Security.Principal.Windows (>= 4.7) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net6.0))
|
|
||||||
System.Text.Encoding.CodePages (>= 4.0.1) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net6.0))
|
|
||||||
System.Text.Json (>= 6.0) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net472)) (&& (== netstandard2.0) (>= net6.0))
|
|
||||||
System.Threading.Tasks.Dataflow (>= 6.0) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net472)) (&& (== netstandard2.0) (>= net6.0))
|
|
||||||
Microsoft.Build.Framework (17.2)
|
|
||||||
Microsoft.Win32.Registry (>= 4.3)
|
|
||||||
System.Security.Permissions (>= 4.7)
|
|
||||||
Microsoft.Build.Tasks.Core (17.2)
|
|
||||||
Microsoft.Build.Framework (>= 17.2)
|
|
||||||
Microsoft.Build.Utilities.Core (>= 17.2)
|
|
||||||
Microsoft.NET.StringTools (>= 1.0)
|
|
||||||
Microsoft.Win32.Registry (>= 4.3)
|
|
||||||
System.CodeDom (>= 4.4)
|
|
||||||
System.Collections.Immutable (>= 5.0)
|
|
||||||
System.Reflection.Metadata (>= 1.6)
|
|
||||||
System.Resources.Extensions (>= 4.6)
|
|
||||||
System.Security.Cryptography.Pkcs (>= 4.7)
|
|
||||||
System.Security.Cryptography.Xml (>= 4.7)
|
|
||||||
System.Security.Permissions (>= 4.7)
|
|
||||||
System.Threading.Tasks.Dataflow (>= 6.0)
|
|
||||||
Microsoft.Build.Utilities.Core (17.2)
|
|
||||||
Microsoft.Build.Framework (>= 17.2)
|
|
||||||
Microsoft.NET.StringTools (>= 1.0)
|
|
||||||
Microsoft.Win32.Registry (>= 4.3)
|
|
||||||
System.Collections.Immutable (>= 5.0)
|
|
||||||
System.Configuration.ConfigurationManager (>= 4.7)
|
|
||||||
System.Security.Permissions (>= 4.7) - restriction: == netstandard2.0
|
|
||||||
System.Text.Encoding.CodePages (>= 4.0.1) - restriction: == netstandard2.0
|
|
||||||
Microsoft.NET.StringTools (1.0)
|
|
||||||
System.Memory (>= 4.5.4)
|
|
||||||
System.Runtime.CompilerServices.Unsafe (>= 5.0)
|
|
||||||
Microsoft.NETCore.Platforms (6.0.5) - restriction: || (&& (== net6.0) (< netcoreapp3.1)) (&& (== net6.0) (< netstandard1.2)) (&& (== net6.0) (< netstandard1.3)) (&& (== net6.0) (< netstandard1.5)) (== netstandard2.0)
|
|
||||||
Microsoft.NETCore.Targets (5.0) - restriction: || (&& (== net6.0) (< netcoreapp3.1)) (&& (== net6.0) (< netstandard1.2)) (&& (== net6.0) (< netstandard1.3)) (&& (== net6.0) (< netstandard1.5)) (== netstandard2.0)
|
|
||||||
Microsoft.Win32.Registry (5.0)
|
|
||||||
System.Buffers (>= 4.5.1) - restriction: || (&& (== net6.0) (>= monoandroid) (< netstandard1.3)) (&& (== net6.0) (>= monotouch)) (&& (== net6.0) (< netcoreapp2.0)) (&& (== net6.0) (>= xamarinios)) (&& (== net6.0) (>= xamarinmac)) (&& (== net6.0) (>= xamarintvos)) (&& (== net6.0) (>= xamarinwatchos)) (== netstandard2.0)
|
|
||||||
System.Memory (>= 4.5.4) - restriction: || (&& (== net6.0) (< netcoreapp2.0)) (&& (== net6.0) (< netcoreapp2.1)) (&& (== net6.0) (>= uap10.1)) (== netstandard2.0)
|
|
||||||
System.Security.AccessControl (>= 5.0)
|
|
||||||
System.Security.Principal.Windows (>= 5.0)
|
|
||||||
Microsoft.Win32.SystemEvents (6.0.1) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= netcoreapp3.1))
|
|
||||||
Mono.Posix.NETStandard (1.0)
|
|
||||||
MSBuild.StructuredLogger (2.1.669)
|
|
||||||
Microsoft.Build (>= 16.10)
|
|
||||||
Microsoft.Build.Framework (>= 16.10)
|
|
||||||
Microsoft.Build.Tasks.Core (>= 16.10)
|
|
||||||
Microsoft.Build.Utilities.Core (>= 16.10)
|
|
||||||
Newtonsoft.Json (13.0.1)
|
|
||||||
NuGet.Common (6.3)
|
|
||||||
NuGet.Frameworks (>= 6.3)
|
|
||||||
NuGet.Configuration (6.3)
|
|
||||||
NuGet.Common (>= 6.3)
|
|
||||||
System.Security.Cryptography.ProtectedData (>= 4.4)
|
|
||||||
NuGet.Frameworks (6.3)
|
|
||||||
NuGet.Packaging (6.3)
|
|
||||||
Newtonsoft.Json (>= 13.0.1)
|
|
||||||
NuGet.Configuration (>= 6.3)
|
|
||||||
NuGet.Versioning (>= 6.3)
|
|
||||||
System.Security.Cryptography.Cng (>= 5.0)
|
|
||||||
System.Security.Cryptography.Pkcs (>= 5.0)
|
|
||||||
NuGet.Protocol (6.3)
|
|
||||||
NuGet.Packaging (>= 6.3)
|
|
||||||
NuGet.Versioning (6.3)
|
|
||||||
System.Buffers (4.5.1) - restriction: || (&& (== net6.0) (>= monoandroid) (< netstandard1.3)) (&& (== net6.0) (>= monotouch)) (&& (== net6.0) (< netcoreapp2.0)) (&& (== net6.0) (>= xamarinios)) (&& (== net6.0) (>= xamarinmac)) (&& (== net6.0) (>= xamarintvos)) (&& (== net6.0) (>= xamarinwatchos)) (== netstandard2.0)
|
|
||||||
System.CodeDom (6.0)
|
|
||||||
System.Collections.Immutable (6.0)
|
|
||||||
System.Memory (>= 4.5.4) - restriction: || (&& (== net6.0) (>= net461)) (== netstandard2.0)
|
|
||||||
System.Runtime.CompilerServices.Unsafe (>= 6.0)
|
|
||||||
System.Configuration.ConfigurationManager (6.0)
|
|
||||||
System.Security.Cryptography.ProtectedData (>= 6.0)
|
|
||||||
System.Security.Permissions (>= 6.0)
|
|
||||||
System.Drawing.Common (6.0) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= netcoreapp3.1))
|
|
||||||
Microsoft.Win32.SystemEvents (>= 6.0) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= netcoreapp3.1))
|
|
||||||
System.Formats.Asn1 (6.0)
|
|
||||||
System.Buffers (>= 4.5.1) - restriction: || (&& (== net6.0) (>= net461)) (== netstandard2.0)
|
|
||||||
System.Memory (>= 4.5.4) - restriction: || (&& (== net6.0) (>= net461)) (== netstandard2.0)
|
|
||||||
System.Memory (4.5.5)
|
|
||||||
System.Buffers (>= 4.5.1) - restriction: || (&& (== net6.0) (>= monotouch)) (&& (== net6.0) (>= net461)) (&& (== net6.0) (< netcoreapp2.0)) (&& (== net6.0) (< netstandard1.1)) (&& (== net6.0) (< netstandard2.0)) (&& (== net6.0) (>= xamarinios)) (&& (== net6.0) (>= xamarinmac)) (&& (== net6.0) (>= xamarintvos)) (&& (== net6.0) (>= xamarinwatchos)) (== netstandard2.0)
|
|
||||||
System.Numerics.Vectors (>= 4.4) - restriction: || (&& (== net6.0) (< netcoreapp2.0)) (== netstandard2.0)
|
|
||||||
System.Runtime.CompilerServices.Unsafe (>= 4.5.3) - restriction: || (&& (== net6.0) (>= monotouch)) (&& (== net6.0) (>= net461)) (&& (== net6.0) (< netcoreapp2.0)) (&& (== net6.0) (< netcoreapp2.1)) (&& (== net6.0) (< netstandard1.1)) (&& (== net6.0) (< netstandard2.0)) (&& (== net6.0) (>= uap10.1)) (&& (== net6.0) (>= xamarinios)) (&& (== net6.0) (>= xamarinmac)) (&& (== net6.0) (>= xamarintvos)) (&& (== net6.0) (>= xamarinwatchos)) (== netstandard2.0)
|
|
||||||
System.Numerics.Vectors (4.5) - restriction: || (&& (== net6.0) (>= net461)) (== netstandard2.0)
|
|
||||||
System.Reactive (5.0)
|
|
||||||
System.Runtime.InteropServices.WindowsRuntime (>= 4.3) - restriction: || (&& (== net6.0) (< netcoreapp3.1)) (== netstandard2.0)
|
|
||||||
System.Threading.Tasks.Extensions (>= 4.5.4) - restriction: || (&& (== net6.0) (>= net472)) (&& (== net6.0) (< netcoreapp3.1)) (&& (== net6.0) (>= uap10.1)) (== netstandard2.0)
|
|
||||||
System.Reflection.Metadata (6.0.1)
|
|
||||||
System.Collections.Immutable (>= 6.0)
|
|
||||||
System.Resources.Extensions (6.0)
|
|
||||||
System.Memory (>= 4.5.4) - restriction: || (&& (== net6.0) (>= net461)) (== netstandard2.0)
|
|
||||||
System.Runtime (4.3.1) - restriction: || (&& (== net6.0) (< netcoreapp3.1)) (== netstandard2.0)
|
|
||||||
Microsoft.NETCore.Platforms (>= 1.1.1)
|
|
||||||
Microsoft.NETCore.Targets (>= 1.1.3)
|
|
||||||
System.Runtime.CompilerServices.Unsafe (6.0)
|
|
||||||
System.Runtime.InteropServices.WindowsRuntime (4.3) - restriction: || (&& (== net6.0) (< netcoreapp3.1)) (== netstandard2.0)
|
|
||||||
System.Runtime (>= 4.3)
|
|
||||||
System.Security.AccessControl (6.0)
|
|
||||||
System.Security.Principal.Windows (>= 5.0) - restriction: || (&& (== net6.0) (>= net461)) (== netstandard2.0)
|
|
||||||
System.Security.Cryptography.Cng (5.0)
|
|
||||||
System.Formats.Asn1 (>= 5.0) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= netcoreapp3.0))
|
|
||||||
System.Security.Cryptography.Pkcs (6.0.1)
|
|
||||||
System.Buffers (>= 4.5.1) - restriction: || (&& (== net6.0) (< netstandard2.1)) (== netstandard2.0)
|
|
||||||
System.Formats.Asn1 (>= 6.0)
|
|
||||||
System.Memory (>= 4.5.4) - restriction: || (&& (== net6.0) (< netstandard2.1)) (== netstandard2.0)
|
|
||||||
System.Security.Cryptography.Cng (>= 5.0) - restriction: || (&& (== net6.0) (< netcoreapp3.1)) (&& (== net6.0) (< netstandard2.1)) (== netstandard2.0)
|
|
||||||
System.Security.Cryptography.ProtectedData (6.0)
|
|
||||||
System.Security.Cryptography.Xml (6.0.1)
|
|
||||||
System.Memory (>= 4.5.4) - restriction: == netstandard2.0
|
|
||||||
System.Security.AccessControl (>= 6.0)
|
|
||||||
System.Security.Cryptography.Pkcs (>= 6.0.1)
|
|
||||||
System.Security.Permissions (6.0)
|
|
||||||
System.Security.AccessControl (>= 6.0)
|
|
||||||
System.Windows.Extensions (>= 6.0) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= netcoreapp3.1))
|
|
||||||
System.Security.Principal.Windows (5.0)
|
|
||||||
System.Text.Encoding.CodePages (6.0)
|
|
||||||
System.Runtime.CompilerServices.Unsafe (>= 6.0)
|
|
||||||
System.Text.Encodings.Web (6.0) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net472)) (&& (== netstandard2.0) (>= net6.0))
|
|
||||||
System.Runtime.CompilerServices.Unsafe (>= 6.0)
|
|
||||||
System.Text.Json (6.0.5) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net472)) (&& (== netstandard2.0) (>= net6.0))
|
|
||||||
System.Runtime.CompilerServices.Unsafe (>= 6.0)
|
|
||||||
System.Text.Encodings.Web (>= 6.0)
|
|
||||||
System.Threading.Tasks.Dataflow (6.0)
|
|
||||||
System.Threading.Tasks.Extensions (4.5.4) - restriction: || (&& (== net6.0) (>= net472)) (&& (== net6.0) (< netcoreapp3.1)) (&& (== net6.0) (>= uap10.1)) (== netstandard2.0)
|
|
||||||
System.Runtime.CompilerServices.Unsafe (>= 4.5.3) - restriction: || (&& (== net6.0) (>= net461)) (&& (== net6.0) (< netcoreapp2.1)) (&& (== net6.0) (< netstandard1.0)) (&& (== net6.0) (< netstandard2.0)) (&& (== net6.0) (>= wp8)) (== netstandard2.0)
|
|
||||||
System.Windows.Extensions (6.0) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= netcoreapp3.1))
|
|
||||||
System.Drawing.Common (>= 6.0) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= netcoreapp3.1))
|
|
7
fake.sh
7
fake.sh
@ -1,7 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
set -eu
|
|
||||||
set -o pipefail
|
|
||||||
|
|
||||||
dotnet tool restore
|
|
||||||
dotnet fake "$@"
|
|
453
src/Data/Access.fs
Normal file
453
src/Data/Access.fs
Normal file
@ -0,0 +1,453 @@
|
|||||||
|
namespace PrayerTracker.Data
|
||||||
|
|
||||||
|
/// Table names
|
||||||
|
[<RequireQualifiedAccess>]
|
||||||
|
module Table =
|
||||||
|
|
||||||
|
/// The church table
|
||||||
|
[<Literal>]
|
||||||
|
let Church = "church"
|
||||||
|
|
||||||
|
/// The small group table
|
||||||
|
[<Literal>]
|
||||||
|
let Group = "small_group"
|
||||||
|
|
||||||
|
/// The small group member table
|
||||||
|
[<Literal>]
|
||||||
|
let Member = "member"
|
||||||
|
|
||||||
|
/// The prayer request table
|
||||||
|
[<Literal>]
|
||||||
|
let Request = "prayer_request"
|
||||||
|
|
||||||
|
/// The user table
|
||||||
|
[<Literal>]
|
||||||
|
let User = "pt_user"
|
||||||
|
|
||||||
|
|
||||||
|
open System
|
||||||
|
open NodaTime
|
||||||
|
open PrayerTracker.Entities
|
||||||
|
|
||||||
|
/// JSON serialization customizations
|
||||||
|
[<RequireQualifiedAccess>]
|
||||||
|
module Json =
|
||||||
|
|
||||||
|
open System.Text.Json.Serialization
|
||||||
|
|
||||||
|
/// Convert a wrapped DU to/from its string representation
|
||||||
|
type WrappedJsonConverter<'T>(wrap: string -> 'T, unwrap: 'T -> string) =
|
||||||
|
inherit JsonConverter<'T>()
|
||||||
|
override _.Read(reader, _, _) = wrap (reader.GetString())
|
||||||
|
override _.Write(writer, value, _) = writer.WriteStringValue(unwrap value)
|
||||||
|
|
||||||
|
open System.Text.Json
|
||||||
|
open NodaTime.Serialization.SystemTextJson
|
||||||
|
|
||||||
|
/// JSON serializer options to support the target domain
|
||||||
|
let options =
|
||||||
|
let opts = JsonSerializerOptions()
|
||||||
|
|
||||||
|
[ WrappedJsonConverter<AsOfDateDisplay>(AsOfDateDisplay.Parse, string) :> JsonConverter
|
||||||
|
WrappedJsonConverter<EmailFormat>(EmailFormat.Parse, string)
|
||||||
|
WrappedJsonConverter<Expiration>(Expiration.Parse, string)
|
||||||
|
WrappedJsonConverter<PrayerRequestType>(PrayerRequestType.Parse, string)
|
||||||
|
WrappedJsonConverter<RequestSort>(RequestSort.Parse, string)
|
||||||
|
WrappedJsonConverter<TimeZoneId>(TimeZoneId, string)
|
||||||
|
WrappedJsonConverter<ChurchId>(Guid.Parse >> ChurchId, string)
|
||||||
|
WrappedJsonConverter<MemberId>(Guid.Parse >> MemberId, string)
|
||||||
|
WrappedJsonConverter<PrayerRequestId>(Guid.Parse >> PrayerRequestId, string)
|
||||||
|
WrappedJsonConverter<SmallGroupId>(Guid.Parse >> SmallGroupId, string)
|
||||||
|
WrappedJsonConverter<UserId>(Guid.Parse >> UserId, string)
|
||||||
|
JsonFSharpConverter() ]
|
||||||
|
|> List.iter opts.Converters.Add
|
||||||
|
|
||||||
|
let _ = opts.ConfigureForNodaTime DateTimeZoneProviders.Tzdb
|
||||||
|
opts.PropertyNamingPolicy <- JsonNamingPolicy.CamelCase
|
||||||
|
opts.DefaultIgnoreCondition <- JsonIgnoreCondition.WhenWritingNull
|
||||||
|
opts
|
||||||
|
|
||||||
|
|
||||||
|
module private Helpers =
|
||||||
|
let instant (it: Instant) =
|
||||||
|
it.ToString()
|
||||||
|
|
||||||
|
open BitBadger.Documents
|
||||||
|
open BitBadger.Documents.Sqlite
|
||||||
|
|
||||||
|
/// Establish the required data environment
|
||||||
|
[<RequireQualifiedAccess>]
|
||||||
|
module Connection =
|
||||||
|
|
||||||
|
open System.Text.Json
|
||||||
|
|
||||||
|
/// Ensure tables and indexes are defined
|
||||||
|
let setUp () =
|
||||||
|
backgroundTask {
|
||||||
|
Configuration.useIdField "id"
|
||||||
|
|
||||||
|
Configuration.useSerializer
|
||||||
|
{ new IDocumentSerializer with
|
||||||
|
member _.Serialize<'T>(it: 'T) =
|
||||||
|
JsonSerializer.Serialize(it, Json.options)
|
||||||
|
|
||||||
|
member _.Deserialize<'T>(it: string) =
|
||||||
|
JsonSerializer.Deserialize<'T>(it, Json.options) }
|
||||||
|
|
||||||
|
let! tables = Custom.list<string> "SELECT name FROM sqlite_master WHERE type = 'table'" [] _.GetString(0)
|
||||||
|
|
||||||
|
if not (List.contains Table.Church tables) then
|
||||||
|
do! Definition.ensureTable Table.Church
|
||||||
|
|
||||||
|
if not (List.contains Table.Group tables) then
|
||||||
|
do! Definition.ensureTable Table.Group
|
||||||
|
do! Definition.ensureFieldIndex Table.Group "church" [ "churchId" ]
|
||||||
|
|
||||||
|
if not (List.contains Table.Member tables) then
|
||||||
|
do! Definition.ensureTable Table.Member
|
||||||
|
do! Definition.ensureFieldIndex Table.Member "group" [ "smallGroupId" ]
|
||||||
|
|
||||||
|
if not (List.contains Table.Request tables) then
|
||||||
|
do! Definition.ensureTable Table.Request
|
||||||
|
do! Definition.ensureFieldIndex Table.Request "group" [ "smallGroupId" ]
|
||||||
|
|
||||||
|
if not (List.contains Table.User tables) then
|
||||||
|
do! Definition.ensureTable Table.User
|
||||||
|
do! Definition.ensureFieldIndex Table.User "email" [ "email" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
open Microsoft.Data.Sqlite
|
||||||
|
|
||||||
|
/// Functions to retrieve small group information
|
||||||
|
module SmallGroups =
|
||||||
|
|
||||||
|
/// Query to retrieve data for a small group info instance
|
||||||
|
let private infoQuery =
|
||||||
|
$"SELECT g.data->>'id' AS id, g.data->>'name' AS groupName, c.data->>'name' AS churchName,
|
||||||
|
g.data->'preferences'->>'timeZoneId' AS timeZoneId, g.data->'preferences'->>'isPublic' AS isPublic
|
||||||
|
FROM {Table.Group} g
|
||||||
|
INNER JOIN {Table.Church} c ON c.data->>'id' = g.data->>'churchId'"
|
||||||
|
|
||||||
|
/// Query to retrieve data for a small group select list item
|
||||||
|
let private itemQuery =
|
||||||
|
$"SELECT g.data->>'name' AS groupName, g.data->>'id' AS id, c.data->>'name' AS churchName
|
||||||
|
FROM {Table.Group} g
|
||||||
|
INNER JOIN {Table.Church} c ON c.data->>'id' = g.data->>'churchId'"
|
||||||
|
|
||||||
|
/// The ORDER BY clause for select list item queries
|
||||||
|
let private itemOrderBy = "ORDER BY c.data->>'name', g.data->>'name'"
|
||||||
|
|
||||||
|
/// Map a row to a Small Group list item
|
||||||
|
let private toSmallGroupItem (rdr: SqliteDataReader) =
|
||||||
|
(rdr.GetOrdinal >> rdr.GetString >> Guid.Parse >> Giraffe.ShortGuid.fromGuid) "id",
|
||||||
|
$"""{(rdr.GetOrdinal >> rdr.GetString) "churchName"} | {(rdr.GetOrdinal >> rdr.GetString) "groupName"}"""
|
||||||
|
|
||||||
|
/// Get the group IDs for the given church
|
||||||
|
let internal groupIdsByChurch (churchId: ChurchId) =
|
||||||
|
backgroundTask {
|
||||||
|
let! groups = Find.byFields<SmallGroup> Table.Group All [ Field.Equal "churchId" (string churchId) ]
|
||||||
|
return groups |> List.map _.Id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count the number of small groups for a church
|
||||||
|
let countByChurch (churchId: ChurchId) =
|
||||||
|
Count.byFields Table.Group All [ Field.Equal "churchId" (string churchId) ]
|
||||||
|
|
||||||
|
/// Delete a small group by its ID
|
||||||
|
let deleteById (groupId: SmallGroupId) =
|
||||||
|
backgroundTask {
|
||||||
|
use conn = Configuration.dbConn ()
|
||||||
|
use! txn = conn.BeginTransactionAsync()
|
||||||
|
|
||||||
|
let! users =
|
||||||
|
Find.byFields<User> Table.User All [ Field.InArray "smallGroups" Table.User [ (string groupId) ] ]
|
||||||
|
|
||||||
|
for user in users do
|
||||||
|
do! Patch.byId Table.User user.Id {| SmallGroups = user.SmallGroups |> List.except [ groupId ] |}
|
||||||
|
|
||||||
|
do! conn.deleteByFields Table.Request All [ Field.Equal "smallGroupId" (string groupId) ]
|
||||||
|
do! conn.deleteById Table.Group (string groupId)
|
||||||
|
|
||||||
|
do! txn.CommitAsync()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get information for all small groups
|
||||||
|
let infoForAll () =
|
||||||
|
Custom.list $"{infoQuery} ORDER BY g.data->>'name'" [] SmallGroupInfo.FromReader
|
||||||
|
|
||||||
|
/// Get a list of small group IDs along with a description that includes the church name
|
||||||
|
let listAll () =
|
||||||
|
Custom.list $"{itemQuery} {itemOrderBy}" [] toSmallGroupItem
|
||||||
|
|
||||||
|
/// Get a list of small group IDs and descriptions for groups with a group password
|
||||||
|
let listProtected () =
|
||||||
|
Custom.list
|
||||||
|
$"{itemQuery} WHERE COALESCE(g.data->'preferences'->>'groupPassword', '') <> '' {itemOrderBy}"
|
||||||
|
[]
|
||||||
|
toSmallGroupItem
|
||||||
|
|
||||||
|
/// Get a list of small group IDs and descriptions for groups that are public or have a group password
|
||||||
|
let listPublicAndProtected () =
|
||||||
|
Custom.list
|
||||||
|
$"{infoQuery}
|
||||||
|
WHERE g.data->'preferences'->>'isPublic' = TRUE
|
||||||
|
OR COALESCE(g.data->'preferences'->>'groupPassword', '') <> ''
|
||||||
|
ORDER BY c.data->>'churchName', g.data->>'groupName'"
|
||||||
|
[]
|
||||||
|
SmallGroupInfo.FromReader
|
||||||
|
|
||||||
|
/// Log on for a small group (includes list preferences)
|
||||||
|
let logOn (groupId: SmallGroupId) (password: string) =
|
||||||
|
Find.firstByFields<SmallGroup>
|
||||||
|
Table.Group
|
||||||
|
All
|
||||||
|
[ Field.Equal "id" (string groupId); Field.Equal "preferences.groupPassword" password ]
|
||||||
|
|
||||||
|
/// Save a small group
|
||||||
|
let save group = save<SmallGroup> Table.Group group
|
||||||
|
|
||||||
|
/// Save a small group's list preferences
|
||||||
|
let savePreferences (groupId: SmallGroupId) (pref: ListPreferences) =
|
||||||
|
Patch.byId Table.Group (string groupId) {| Preferences = pref |}
|
||||||
|
|
||||||
|
/// Get a small group by its ID (including list preferences)
|
||||||
|
let tryById groupId =
|
||||||
|
Find.byId<SmallGroupId, SmallGroup> Table.Group groupId
|
||||||
|
|
||||||
|
|
||||||
|
/// Functions to manipulate churches
|
||||||
|
module Churches =
|
||||||
|
|
||||||
|
/// Get a list of all churches
|
||||||
|
let all () = Find.all<Church> Table.Church
|
||||||
|
|
||||||
|
/// Delete a church by its ID
|
||||||
|
let deleteById churchId =
|
||||||
|
backgroundTask {
|
||||||
|
use conn = Configuration.dbConn ()
|
||||||
|
use! txn = conn.BeginTransactionAsync()
|
||||||
|
|
||||||
|
let! groupIds = SmallGroups.groupIdsByChurch churchId
|
||||||
|
let gIdStrings = groupIds |> List.map string
|
||||||
|
|
||||||
|
do! Delete.byFields Table.Request All [ Field.In "smallGroupId" gIdStrings ]
|
||||||
|
|
||||||
|
let! users = Find.byFields<User> Table.User All [ Field.InArray "smallGroups" Table.User gIdStrings ]
|
||||||
|
|
||||||
|
for user in users do
|
||||||
|
do! Patch.byId Table.User (string user.Id) {| SmallGroups = user.SmallGroups |> List.except groupIds |}
|
||||||
|
|
||||||
|
do! Delete.byFields Table.Group All [ Field.Equal "churchId" (string churchId) ]
|
||||||
|
do! Delete.byId Table.Church (string churchId)
|
||||||
|
do! txn.CommitAsync()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save a church's information
|
||||||
|
let save church = save<Church> Table.Church church
|
||||||
|
|
||||||
|
/// Find a church by its ID
|
||||||
|
let tryById churchId =
|
||||||
|
Find.byId<ChurchId, Church> Table.Church churchId
|
||||||
|
|
||||||
|
|
||||||
|
/// Functions to manipulate small group members
|
||||||
|
module Members =
|
||||||
|
|
||||||
|
/// Count members for the given small group
|
||||||
|
let countByGroup (groupId: SmallGroupId) =
|
||||||
|
Count.byFields Table.Member All [ Field.Equal "smallGroupId" (string groupId) ]
|
||||||
|
|
||||||
|
/// Delete a small group member by its ID
|
||||||
|
let deleteById (memberId: MemberId) = Delete.byId Table.Member (string memberId)
|
||||||
|
|
||||||
|
/// Retrieve all members for a given small group
|
||||||
|
let forGroup (groupId: SmallGroupId) =
|
||||||
|
Find.byFieldsOrdered<Member>
|
||||||
|
Table.Member
|
||||||
|
All
|
||||||
|
[ Field.Equal "smallGroupId" (string groupId) ]
|
||||||
|
[ Field.Named "memberName" ]
|
||||||
|
|
||||||
|
/// Save a small group member
|
||||||
|
let save mbr = save<Member> Table.Member mbr
|
||||||
|
|
||||||
|
/// Retrieve a small group member by its ID
|
||||||
|
let tryById memberId =
|
||||||
|
Find.byId<MemberId, Member> Table.Member memberId
|
||||||
|
|
||||||
|
|
||||||
|
/// Options to retrieve a list of requests
|
||||||
|
type PrayerRequestOptions =
|
||||||
|
{
|
||||||
|
/// The small group for which requests should be retrieved
|
||||||
|
SmallGroup: SmallGroup
|
||||||
|
|
||||||
|
/// The clock instance to use for date/time manipulation
|
||||||
|
Clock: IClock
|
||||||
|
|
||||||
|
/// The date for which the list is being retrieved
|
||||||
|
ListDate: LocalDate option
|
||||||
|
|
||||||
|
/// Whether only active requests should be retrieved
|
||||||
|
ActiveOnly: bool
|
||||||
|
|
||||||
|
/// The page number, for paged lists
|
||||||
|
PageNumber: int
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Functions to manipulate prayer requests
|
||||||
|
module PrayerRequests =
|
||||||
|
|
||||||
|
/// Central place to append sort criteria for prayer request queries
|
||||||
|
let private orderBy sort =
|
||||||
|
match sort with
|
||||||
|
| SortByDate -> [ Field.Named "updatedDate DESC"; Field.Named "enteredDate DESC"; Field.Named "requestor" ]
|
||||||
|
| SortByRequestor -> [ Field.Named "requestor"; Field.Named "updatedDate DESC"; Field.Named "enteredDate DESC" ]
|
||||||
|
|> fun fields -> Query.orderBy fields SQLite
|
||||||
|
|
||||||
|
/// Paginate a prayer request query
|
||||||
|
let private paginate (pageNbr: int) pageSize =
|
||||||
|
if pageNbr > 0 then
|
||||||
|
$"LIMIT {pageSize} OFFSET {(pageNbr - 1) * pageSize}"
|
||||||
|
else
|
||||||
|
""
|
||||||
|
|
||||||
|
/// Count the number of prayer requests for a church
|
||||||
|
let countByChurch churchId =
|
||||||
|
backgroundTask {
|
||||||
|
let! groupIds = SmallGroups.groupIdsByChurch churchId
|
||||||
|
return! Count.byFields Table.Request All [ Field.In "smallGroupId" (List.map string groupIds) ]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count the number of prayer requests for a small group
|
||||||
|
let countByGroup (groupId: SmallGroupId) =
|
||||||
|
Count.byFields Table.Request All [ Field.Equal "smallGroupId" (string groupId) ]
|
||||||
|
|
||||||
|
/// Delete a prayer request by its ID
|
||||||
|
let deleteById (reqId: PrayerRequestId) = Delete.byId Table.Request (string reqId)
|
||||||
|
|
||||||
|
/// Get all (or active) requests for a small group as of now or the specified date
|
||||||
|
let forGroup (opts: PrayerRequestOptions) =
|
||||||
|
let theDate = defaultArg opts.ListDate (opts.SmallGroup.LocalDateNow opts.Clock)
|
||||||
|
|
||||||
|
let sql, parameters =
|
||||||
|
if opts.ActiveOnly then
|
||||||
|
let expDate =
|
||||||
|
(theDate.AtStartOfDayInZone(opts.SmallGroup.TimeZone)
|
||||||
|
- Duration.FromDays opts.SmallGroup.Preferences.DaysToExpire)
|
||||||
|
.ToInstant()
|
||||||
|
$"""AND ( date(data->>'updatedDate') > date(:updatedDate)
|
||||||
|
OR data->>'expiration' = :expManual
|
||||||
|
OR data->>'requestType' IN (:typLongTerm, :typExpecting))
|
||||||
|
AND data->>'expiration' <> :expForced""",
|
||||||
|
[ SqliteParameter(":updatedDate", string expDate)
|
||||||
|
SqliteParameter(":expManual", string Manual)
|
||||||
|
SqliteParameter(":typLongTerm", string LongTermRequest)
|
||||||
|
SqliteParameter(":typExpecting", string Expecting)
|
||||||
|
SqliteParameter(":expForced", string Forced) ]
|
||||||
|
else
|
||||||
|
"", []
|
||||||
|
|
||||||
|
Custom.list
|
||||||
|
$"SELECT data FROM {Table.Request}
|
||||||
|
WHERE data->>'smallGroupId' = :groupId
|
||||||
|
{sql}
|
||||||
|
{orderBy opts.SmallGroup.Preferences.RequestSort}
|
||||||
|
{paginate opts.PageNumber opts.SmallGroup.Preferences.PageSize}"
|
||||||
|
(SqliteParameter(":groupId", string opts.SmallGroup.Id) :: parameters)
|
||||||
|
fromData<PrayerRequest>
|
||||||
|
|
||||||
|
/// Save a prayer request
|
||||||
|
let save req = save<PrayerRequest> Table.Request req
|
||||||
|
|
||||||
|
/// Search prayer requests for the given term
|
||||||
|
let searchForGroup group searchTerm pageNbr =
|
||||||
|
let pct = "%"
|
||||||
|
Custom.list
|
||||||
|
$"WITH results AS (
|
||||||
|
SELECT data FROM {Table.Request}
|
||||||
|
WHERE data->>'smallGroupId' = :groupId
|
||||||
|
AND data->>'text' LIKE :search
|
||||||
|
UNION
|
||||||
|
SELECT data FROM {Table.Request}
|
||||||
|
WHERE data->>'smallGroupId' = :groupId
|
||||||
|
AND COALESCE(data->>'requestor', '') LIKE :search)
|
||||||
|
SELECT data FROM results
|
||||||
|
{orderBy group.Preferences.RequestSort}
|
||||||
|
{paginate pageNbr group.Preferences.PageSize}"
|
||||||
|
[ SqliteParameter(":groupId", string group.Id); SqliteParameter(":search", $"{pct}%s{searchTerm}{pct}") ]
|
||||||
|
fromData<PrayerRequest>
|
||||||
|
|
||||||
|
/// Retrieve a prayer request by its ID
|
||||||
|
let tryById reqId =
|
||||||
|
Find.byId<PrayerRequestId, PrayerRequest> Table.Request reqId
|
||||||
|
|
||||||
|
/// Update the expiration for the given prayer request
|
||||||
|
let updateExpiration (req: PrayerRequest) withTime =
|
||||||
|
if withTime then
|
||||||
|
Patch.byId
|
||||||
|
Table.Request
|
||||||
|
(string req.Id)
|
||||||
|
{| UpdatedDate = req.UpdatedDate
|
||||||
|
Expiration = req.Expiration |}
|
||||||
|
else
|
||||||
|
Patch.byId Table.Request (string req.Id) {| Expiration = req.Expiration |}
|
||||||
|
|
||||||
|
|
||||||
|
/// Functions to manipulate users
|
||||||
|
module Users =
|
||||||
|
|
||||||
|
/// Retrieve all PrayerTracker users
|
||||||
|
let all () =
|
||||||
|
Find.allOrdered<User> Table.User [ Field.Named "lastName"; Field.Named "firstName" ]
|
||||||
|
|
||||||
|
/// Count the number of users for a church
|
||||||
|
let countByChurch churchId =
|
||||||
|
backgroundTask {
|
||||||
|
let! groupIds = SmallGroups.groupIdsByChurch churchId
|
||||||
|
return! Count.byFields Table.User All [ Field.InArray "smallGroups" Table.User (List.map string groupIds) ]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count the number of users for a small group
|
||||||
|
let countByGroup (groupId: SmallGroupId) =
|
||||||
|
Count.byFields Table.User All [ Field.InArray "smallGroups" Table.User [ (string groupId) ] ]
|
||||||
|
|
||||||
|
/// Delete a user by its database ID
|
||||||
|
let deleteById (userId: UserId) = Delete.byId Table.User (string userId)
|
||||||
|
|
||||||
|
/// Get a list of users authorized to administer the given small group
|
||||||
|
let listByGroupId (groupId: SmallGroupId) =
|
||||||
|
Find.byFieldsOrdered<User>
|
||||||
|
Table.User
|
||||||
|
All
|
||||||
|
[ Field.InArray "smallGroups" Table.User [ (string groupId) ] ]
|
||||||
|
[ Field.Named "lastName"; Field.Named "firstName" ]
|
||||||
|
|
||||||
|
/// Save a user's information
|
||||||
|
let save user = save<User> Table.User user
|
||||||
|
|
||||||
|
/// Find a user by its e-mail address and authorized small group
|
||||||
|
let tryByEmailAndGroup (email: string) (groupId: SmallGroupId) =
|
||||||
|
Find.firstByFields<User>
|
||||||
|
Table.User
|
||||||
|
All
|
||||||
|
[ Field.Equal "email" email
|
||||||
|
Field.InArray "smallGroups" Table.User [ (string groupId) ] ]
|
||||||
|
|
||||||
|
/// Find a user by their database ID
|
||||||
|
let tryById userId =
|
||||||
|
Find.byId<UserId, User> Table.User userId
|
||||||
|
|
||||||
|
/// Update a user's last seen date/time
|
||||||
|
let updateLastSeen (userId: UserId) (now: Instant) =
|
||||||
|
Patch.byId Table.User (string userId) {| LastSeen = now |}
|
||||||
|
|
||||||
|
/// Update a user's password hash
|
||||||
|
let updatePassword (user: User) =
|
||||||
|
Patch.byId Table.User (string user.Id) {| PasswordHash = user.PasswordHash |}
|
||||||
|
|
||||||
|
/// Update a user's authorized small groups
|
||||||
|
let updateSmallGroups (userId: UserId) (groupIds: SmallGroupId list) =
|
||||||
|
Patch.byId Table.User (string userId) {| SmallGroups = groupIds |}
|
565
src/Data/Entities.fs
Normal file
565
src/Data/Entities.fs
Normal file
@ -0,0 +1,565 @@
|
|||||||
|
namespace PrayerTracker.Entities
|
||||||
|
|
||||||
|
(*-- SUPPORT TYPES --*)
|
||||||
|
|
||||||
|
/// How as-of dates should (or should not) be displayed with requests
|
||||||
|
type AsOfDateDisplay =
|
||||||
|
/// No as-of date should be displayed
|
||||||
|
| NoDisplay
|
||||||
|
/// The as-of date should be displayed in the culture's short date format
|
||||||
|
| ShortDate
|
||||||
|
/// The as-of date should be displayed in the culture's long date format
|
||||||
|
| LongDate
|
||||||
|
|
||||||
|
/// Convert this to a single-character code
|
||||||
|
override this.ToString() =
|
||||||
|
match this with
|
||||||
|
| NoDisplay -> "N"
|
||||||
|
| ShortDate -> "S"
|
||||||
|
| LongDate -> "L"
|
||||||
|
|
||||||
|
/// <summary>Create an <c>AsOfDateDisplay</c> from a single-character code</summary>
|
||||||
|
static member Parse code =
|
||||||
|
match code with
|
||||||
|
| "N" -> NoDisplay
|
||||||
|
| "S" -> ShortDate
|
||||||
|
| "L" -> LongDate
|
||||||
|
| _ -> invalidArg "code" $"Unknown code {code}"
|
||||||
|
|
||||||
|
|
||||||
|
/// Acceptable e-mail formats
|
||||||
|
type EmailFormat =
|
||||||
|
/// HTML e-mail
|
||||||
|
| HtmlFormat
|
||||||
|
/// Plain-text e-mail
|
||||||
|
| PlainTextFormat
|
||||||
|
|
||||||
|
/// Convert this to a single-character code
|
||||||
|
override this.ToString() =
|
||||||
|
match this with
|
||||||
|
| HtmlFormat -> "H"
|
||||||
|
| PlainTextFormat -> "P"
|
||||||
|
|
||||||
|
/// <summary>Create an <c>EmailFormat</c> from a single-character code</summary>
|
||||||
|
static member Parse code =
|
||||||
|
match code with
|
||||||
|
| "H" -> HtmlFormat
|
||||||
|
| "P" -> PlainTextFormat
|
||||||
|
| _ -> invalidArg "code" $"Unknown code {code}"
|
||||||
|
|
||||||
|
|
||||||
|
/// Expiration for requests
|
||||||
|
type Expiration =
|
||||||
|
/// Follow the rules for normal expiration
|
||||||
|
| Automatic
|
||||||
|
/// Do not expire via rules
|
||||||
|
| Manual
|
||||||
|
/// Force immediate expiration
|
||||||
|
| Forced
|
||||||
|
|
||||||
|
/// Convert this to a single-character code
|
||||||
|
override this.ToString() =
|
||||||
|
match this with
|
||||||
|
| Automatic -> "A"
|
||||||
|
| Manual -> "M"
|
||||||
|
| Forced -> "F"
|
||||||
|
|
||||||
|
/// <summary>Create an <c>Expiration</c> from a single-character code</summary>
|
||||||
|
static member Parse code =
|
||||||
|
match code with
|
||||||
|
| "A" -> Automatic
|
||||||
|
| "M" -> Manual
|
||||||
|
| "F" -> Forced
|
||||||
|
| _ -> invalidArg "code" $"Unknown code {code}"
|
||||||
|
|
||||||
|
|
||||||
|
/// Types of prayer requests
|
||||||
|
type PrayerRequestType =
|
||||||
|
/// Current requests
|
||||||
|
| CurrentRequest
|
||||||
|
/// Long-term/ongoing request
|
||||||
|
| LongTermRequest
|
||||||
|
/// Expectant couples
|
||||||
|
| Expecting
|
||||||
|
/// Praise reports
|
||||||
|
| PraiseReport
|
||||||
|
/// Announcements
|
||||||
|
| Announcement
|
||||||
|
|
||||||
|
/// Convert this to a single-character code
|
||||||
|
override this.ToString() =
|
||||||
|
match this with
|
||||||
|
| CurrentRequest -> "C"
|
||||||
|
| LongTermRequest -> "L"
|
||||||
|
| Expecting -> "E"
|
||||||
|
| PraiseReport -> "P"
|
||||||
|
| Announcement -> "A"
|
||||||
|
|
||||||
|
/// <summary>Create a <c>PrayerRequestType</c> from a single-character code</summary>
|
||||||
|
static member Parse code =
|
||||||
|
match code with
|
||||||
|
| "C" -> CurrentRequest
|
||||||
|
| "L" -> LongTermRequest
|
||||||
|
| "E" -> Expecting
|
||||||
|
| "P" -> PraiseReport
|
||||||
|
| "A" -> Announcement
|
||||||
|
| _ -> invalidArg "code" $"Unknown code {code}"
|
||||||
|
|
||||||
|
|
||||||
|
/// How requests should be sorted
|
||||||
|
type RequestSort =
|
||||||
|
/// Sort by date, then by requestor/subject
|
||||||
|
| SortByDate
|
||||||
|
/// Sort by requestor/subject, then by date
|
||||||
|
| SortByRequestor
|
||||||
|
|
||||||
|
/// Convert this to a single-character code
|
||||||
|
override this.ToString() =
|
||||||
|
match this with
|
||||||
|
| SortByDate -> "D"
|
||||||
|
| SortByRequestor -> "R"
|
||||||
|
|
||||||
|
/// <summary>Create a <c>RequestSort</c> from a single-character code</summary>
|
||||||
|
static member Parse code =
|
||||||
|
match code with
|
||||||
|
| "D" -> SortByDate
|
||||||
|
| "R" -> SortByRequestor
|
||||||
|
| _ -> invalidArg "code" $"Unknown code {code}"
|
||||||
|
|
||||||
|
|
||||||
|
/// Type for a time zone ID
|
||||||
|
type TimeZoneId =
|
||||||
|
| TimeZoneId of string
|
||||||
|
|
||||||
|
override this.ToString() =
|
||||||
|
match this with
|
||||||
|
| TimeZoneId it -> it
|
||||||
|
|
||||||
|
|
||||||
|
open System
|
||||||
|
|
||||||
|
/// PK type for the Church entity
|
||||||
|
type ChurchId =
|
||||||
|
| ChurchId of Guid
|
||||||
|
|
||||||
|
/// The GUID value of the church ID
|
||||||
|
member this.Value =
|
||||||
|
this
|
||||||
|
|> function
|
||||||
|
| ChurchId guid -> guid
|
||||||
|
|
||||||
|
override this.ToString() =
|
||||||
|
this.Value.ToString "N"
|
||||||
|
|
||||||
|
|
||||||
|
/// PK type for the Member entity
|
||||||
|
type MemberId =
|
||||||
|
| MemberId of Guid
|
||||||
|
|
||||||
|
/// The GUID value of the member ID
|
||||||
|
member this.Value =
|
||||||
|
this
|
||||||
|
|> function
|
||||||
|
| MemberId guid -> guid
|
||||||
|
|
||||||
|
override this.ToString() =
|
||||||
|
this.Value.ToString "N"
|
||||||
|
|
||||||
|
|
||||||
|
/// PK type for the PrayerRequest entity
|
||||||
|
type PrayerRequestId =
|
||||||
|
| PrayerRequestId of Guid
|
||||||
|
|
||||||
|
/// The GUID value of the prayer request ID
|
||||||
|
member this.Value =
|
||||||
|
this
|
||||||
|
|> function
|
||||||
|
| PrayerRequestId guid -> guid
|
||||||
|
|
||||||
|
override this.ToString() =
|
||||||
|
this.Value.ToString "N"
|
||||||
|
|
||||||
|
|
||||||
|
/// PK type for the SmallGroup entity
|
||||||
|
type SmallGroupId =
|
||||||
|
| SmallGroupId of Guid
|
||||||
|
|
||||||
|
/// The GUID value of the small group ID
|
||||||
|
member this.Value =
|
||||||
|
this
|
||||||
|
|> function
|
||||||
|
| SmallGroupId guid -> guid
|
||||||
|
|
||||||
|
override this.ToString() =
|
||||||
|
this.Value.ToString "N"
|
||||||
|
|
||||||
|
|
||||||
|
/// PK type for the User entity
|
||||||
|
type UserId =
|
||||||
|
| UserId of Guid
|
||||||
|
|
||||||
|
/// The GUID value of the user ID
|
||||||
|
member this.Value =
|
||||||
|
this
|
||||||
|
|> function
|
||||||
|
| UserId guid -> guid
|
||||||
|
|
||||||
|
override this.ToString() =
|
||||||
|
this.Value.ToString "N"
|
||||||
|
|
||||||
|
(*-- SPECIFIC VIEW TYPES --*)
|
||||||
|
|
||||||
|
open Microsoft.Data.Sqlite
|
||||||
|
|
||||||
|
/// Statistics for churches
|
||||||
|
[<NoComparison; NoEquality>]
|
||||||
|
type ChurchStats =
|
||||||
|
{
|
||||||
|
/// The number of small groups in the church
|
||||||
|
SmallGroups: int
|
||||||
|
|
||||||
|
/// The number of prayer requests in the church
|
||||||
|
PrayerRequests: int
|
||||||
|
|
||||||
|
/// The number of users who can access small groups in the church
|
||||||
|
Users: int
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Information needed to display the public/protected request list and small group maintenance pages
|
||||||
|
[<CLIMutable; NoComparison; NoEquality>]
|
||||||
|
type SmallGroupInfo =
|
||||||
|
{
|
||||||
|
/// The ID of the small group
|
||||||
|
Id: string
|
||||||
|
|
||||||
|
/// The name of the small group
|
||||||
|
Name: string
|
||||||
|
|
||||||
|
/// The name of the church to which the small group belongs
|
||||||
|
ChurchName: string
|
||||||
|
|
||||||
|
/// The ID of the time zone for the small group
|
||||||
|
TimeZoneId: TimeZoneId
|
||||||
|
|
||||||
|
/// Whether the small group has a publicly-available request list
|
||||||
|
IsPublic: bool
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a row to a Small Group information set
|
||||||
|
static member FromReader (rdr: SqliteDataReader) =
|
||||||
|
{ Id = Giraffe.ShortGuid.fromGuid ((rdr.GetOrdinal >> rdr.GetString >> Guid.Parse) "id")
|
||||||
|
Name = (rdr.GetOrdinal >> rdr.GetString) "groupName"
|
||||||
|
ChurchName = (rdr.GetOrdinal >> rdr.GetString) "churchName"
|
||||||
|
TimeZoneId = (rdr.GetOrdinal >> rdr.GetString >> TimeZoneId) "timeZoneId"
|
||||||
|
IsPublic = (rdr.GetOrdinal >> rdr.GetBoolean) "isPublic" }
|
||||||
|
|
||||||
|
|
||||||
|
(*-- ENTITIES --*)
|
||||||
|
|
||||||
|
open NodaTime
|
||||||
|
|
||||||
|
/// This represents a church
|
||||||
|
[<CLIMutable; NoComparison; NoEquality>]
|
||||||
|
type Church =
|
||||||
|
{
|
||||||
|
/// The ID of this church
|
||||||
|
Id: ChurchId
|
||||||
|
|
||||||
|
/// The name of the church
|
||||||
|
Name: string
|
||||||
|
|
||||||
|
/// The city where the church is
|
||||||
|
City: string
|
||||||
|
|
||||||
|
/// The 2-letter state or province code for the church's location
|
||||||
|
State: string
|
||||||
|
|
||||||
|
/// Does this church have an active interface with Virtual Prayer Space?
|
||||||
|
HasVpsInterface: bool
|
||||||
|
|
||||||
|
/// The address for the interface
|
||||||
|
InterfaceAddress: string option
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An empty church
|
||||||
|
// aww... how sad :(
|
||||||
|
static member Empty =
|
||||||
|
{ Id = ChurchId Guid.Empty
|
||||||
|
Name = ""
|
||||||
|
City = ""
|
||||||
|
State = ""
|
||||||
|
HasVpsInterface = false
|
||||||
|
InterfaceAddress = None }
|
||||||
|
|
||||||
|
|
||||||
|
/// Preferences for the form and format of the prayer request list
|
||||||
|
[<NoComparison; NoEquality>]
|
||||||
|
type ListPreferences =
|
||||||
|
{
|
||||||
|
/// The days after which regular requests expire
|
||||||
|
DaysToExpire: int
|
||||||
|
|
||||||
|
/// The number of days a new or updated request is considered new
|
||||||
|
DaysToKeepNew: int
|
||||||
|
|
||||||
|
/// The number of weeks after which long-term requests are flagged for follow-up
|
||||||
|
LongTermUpdateWeeks: int
|
||||||
|
|
||||||
|
/// The name from which e-mails are sent
|
||||||
|
EmailFromName: string
|
||||||
|
|
||||||
|
/// The e-mail address from which e-mails are sent
|
||||||
|
EmailFromAddress: string
|
||||||
|
|
||||||
|
/// The fonts to use in generating the list of prayer requests
|
||||||
|
Fonts: string
|
||||||
|
|
||||||
|
/// The color for the prayer request list headings
|
||||||
|
HeadingColor: string
|
||||||
|
|
||||||
|
/// The color for the lines offsetting the prayer request list headings
|
||||||
|
LineColor: string
|
||||||
|
|
||||||
|
/// The font size for the headings on the prayer request list
|
||||||
|
HeadingFontSize: int
|
||||||
|
|
||||||
|
/// The font size for the text on the prayer request list
|
||||||
|
TextFontSize: int
|
||||||
|
|
||||||
|
/// The order in which the prayer requests are sorted
|
||||||
|
RequestSort: RequestSort
|
||||||
|
|
||||||
|
/// The password used for "small group login" (view-only request list)
|
||||||
|
GroupPassword: string
|
||||||
|
|
||||||
|
/// The default e-mail type for this class
|
||||||
|
DefaultEmailType: EmailFormat
|
||||||
|
|
||||||
|
/// Whether this class makes its request list public
|
||||||
|
IsPublic: bool
|
||||||
|
|
||||||
|
/// The time zone which this class uses (use tzdata names)
|
||||||
|
TimeZoneId: TimeZoneId
|
||||||
|
|
||||||
|
/// The number of requests displayed per page
|
||||||
|
PageSize: int
|
||||||
|
|
||||||
|
/// How the as-of date should be automatically displayed
|
||||||
|
AsOfDateDisplay: AsOfDateDisplay
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list of fonts to use when displaying request lists (converts "native" to native font stack)
|
||||||
|
member this.FontStack =
|
||||||
|
if this.Fonts = "native" then
|
||||||
|
"""system-ui,-apple-system,"Segoe UI",Roboto,Ubuntu,"Liberation Sans",Cantarell,"Helvetica Neue",sans-serif"""
|
||||||
|
else
|
||||||
|
this.Fonts
|
||||||
|
|
||||||
|
/// A set of preferences with their default values
|
||||||
|
static member Empty =
|
||||||
|
{ DaysToExpire = 14
|
||||||
|
DaysToKeepNew = 7
|
||||||
|
LongTermUpdateWeeks = 4
|
||||||
|
EmailFromName = "PrayerTracker"
|
||||||
|
EmailFromAddress = "prayer@bitbadger.solutions"
|
||||||
|
Fonts = "native"
|
||||||
|
HeadingColor = "maroon"
|
||||||
|
LineColor = "navy"
|
||||||
|
HeadingFontSize = 16
|
||||||
|
TextFontSize = 12
|
||||||
|
RequestSort = SortByDate
|
||||||
|
GroupPassword = ""
|
||||||
|
DefaultEmailType = HtmlFormat
|
||||||
|
IsPublic = false
|
||||||
|
TimeZoneId = TimeZoneId "America/Denver"
|
||||||
|
PageSize = 100
|
||||||
|
AsOfDateDisplay = NoDisplay }
|
||||||
|
|
||||||
|
|
||||||
|
/// A member of a small group
|
||||||
|
[<CLIMutable; NoComparison; NoEquality>]
|
||||||
|
type Member =
|
||||||
|
{
|
||||||
|
/// The ID of the small group member
|
||||||
|
Id: MemberId
|
||||||
|
|
||||||
|
/// The Id of the small group to which this member belongs
|
||||||
|
SmallGroupId: SmallGroupId
|
||||||
|
|
||||||
|
/// The name of the member
|
||||||
|
Name: string
|
||||||
|
|
||||||
|
/// The e-mail address for the member
|
||||||
|
Email: string
|
||||||
|
|
||||||
|
/// The type of e-mail preferred by this member
|
||||||
|
Format: EmailFormat option
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An empty member
|
||||||
|
static member Empty =
|
||||||
|
{ Id = MemberId Guid.Empty
|
||||||
|
SmallGroupId = SmallGroupId Guid.Empty
|
||||||
|
Name = ""
|
||||||
|
Email = ""
|
||||||
|
Format = None }
|
||||||
|
|
||||||
|
|
||||||
|
/// This represents a small group (Sunday School class, Bible study group, etc.)
|
||||||
|
[<CLIMutable; NoComparison; NoEquality>]
|
||||||
|
type SmallGroup =
|
||||||
|
{
|
||||||
|
/// The ID of this small group
|
||||||
|
Id: SmallGroupId
|
||||||
|
|
||||||
|
/// The church to which this group belongs
|
||||||
|
ChurchId: ChurchId
|
||||||
|
|
||||||
|
/// The name of the group
|
||||||
|
Name: string
|
||||||
|
|
||||||
|
/// The preferences for the request list
|
||||||
|
Preferences: ListPreferences
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The DateTimeZone for the time zone ID for this small group
|
||||||
|
member this.TimeZone =
|
||||||
|
let tzId = string this.Preferences.TimeZoneId
|
||||||
|
|
||||||
|
if DateTimeZoneProviders.Tzdb.Ids.Contains tzId then
|
||||||
|
DateTimeZoneProviders.Tzdb[tzId]
|
||||||
|
else
|
||||||
|
DateTimeZone.Utc
|
||||||
|
|
||||||
|
/// Get the local date/time for this group
|
||||||
|
member this.LocalTimeNow(clock: IClock) =
|
||||||
|
if isNull clock then
|
||||||
|
nullArg (nameof clock)
|
||||||
|
|
||||||
|
clock.GetCurrentInstant().InZone(this.TimeZone).LocalDateTime
|
||||||
|
|
||||||
|
/// Get the local date for this group
|
||||||
|
member this.LocalDateNow clock = this.LocalTimeNow(clock).Date
|
||||||
|
|
||||||
|
/// An empty small group
|
||||||
|
static member Empty =
|
||||||
|
{ Id = SmallGroupId Guid.Empty
|
||||||
|
ChurchId = ChurchId Guid.Empty
|
||||||
|
Name = ""
|
||||||
|
Preferences = ListPreferences.Empty }
|
||||||
|
|
||||||
|
|
||||||
|
/// This represents a single prayer request
|
||||||
|
[<CLIMutable; NoComparison; NoEquality>]
|
||||||
|
type PrayerRequest =
|
||||||
|
{
|
||||||
|
/// The ID of this request
|
||||||
|
Id: PrayerRequestId
|
||||||
|
|
||||||
|
/// The type of the request
|
||||||
|
RequestType: PrayerRequestType
|
||||||
|
|
||||||
|
/// The ID of the user who entered the request
|
||||||
|
UserId: UserId
|
||||||
|
|
||||||
|
/// The small group to which this request belongs
|
||||||
|
SmallGroupId: SmallGroupId
|
||||||
|
|
||||||
|
/// The date/time on which this request was entered
|
||||||
|
EnteredDate: Instant
|
||||||
|
|
||||||
|
/// The date/time this request was last updated
|
||||||
|
UpdatedDate: Instant
|
||||||
|
|
||||||
|
/// The name of the requestor or subject, or title of announcement
|
||||||
|
Requestor: string option
|
||||||
|
|
||||||
|
/// The text of the request
|
||||||
|
Text: string
|
||||||
|
|
||||||
|
/// Whether the chaplain should be notified for this request
|
||||||
|
NotifyChaplain: bool
|
||||||
|
|
||||||
|
/// Is this request expired?
|
||||||
|
Expiration: Expiration
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is this request expired?
|
||||||
|
member this.IsExpired (asOf: LocalDate) (group: SmallGroup) =
|
||||||
|
match this.Expiration, this.RequestType with
|
||||||
|
| Forced, _ -> true
|
||||||
|
| Manual, _
|
||||||
|
| Automatic, LongTermRequest
|
||||||
|
| Automatic, Expecting -> false
|
||||||
|
| Automatic, _ ->
|
||||||
|
// Automatic expiration
|
||||||
|
Period
|
||||||
|
.Between(this.UpdatedDate.InZone(group.TimeZone).Date, asOf, PeriodUnits.Days)
|
||||||
|
.Days
|
||||||
|
>= group.Preferences.DaysToExpire
|
||||||
|
|
||||||
|
/// Is an update required for this long-term request?
|
||||||
|
member this.UpdateRequired asOf group =
|
||||||
|
if this.IsExpired asOf group then
|
||||||
|
false
|
||||||
|
else
|
||||||
|
asOf.PlusWeeks -group.Preferences.LongTermUpdateWeeks
|
||||||
|
>= this.UpdatedDate.InZone(group.TimeZone).Date
|
||||||
|
|
||||||
|
/// An empty request
|
||||||
|
static member Empty =
|
||||||
|
{ Id = PrayerRequestId Guid.Empty
|
||||||
|
RequestType = CurrentRequest
|
||||||
|
UserId = UserId Guid.Empty
|
||||||
|
SmallGroupId = SmallGroupId Guid.Empty
|
||||||
|
EnteredDate = Instant.MinValue
|
||||||
|
UpdatedDate = Instant.MinValue
|
||||||
|
Requestor = None
|
||||||
|
Text = ""
|
||||||
|
NotifyChaplain = false
|
||||||
|
Expiration = Automatic }
|
||||||
|
|
||||||
|
|
||||||
|
/// This represents a user of PrayerTracker
|
||||||
|
[<CLIMutable; NoComparison; NoEquality>]
|
||||||
|
type User =
|
||||||
|
{
|
||||||
|
/// The ID of this user
|
||||||
|
Id: UserId
|
||||||
|
|
||||||
|
/// The first name of this user
|
||||||
|
FirstName: string
|
||||||
|
|
||||||
|
/// The last name of this user
|
||||||
|
LastName: string
|
||||||
|
|
||||||
|
/// The e-mail address of the user
|
||||||
|
Email: string
|
||||||
|
|
||||||
|
/// Whether this user is a PrayerTracker system administrator
|
||||||
|
IsAdmin: bool
|
||||||
|
|
||||||
|
/// The user's hashed password
|
||||||
|
PasswordHash: string
|
||||||
|
|
||||||
|
/// The last time the user was seen (set whenever the user is loaded into a session)
|
||||||
|
LastSeen: Instant option
|
||||||
|
|
||||||
|
/// The small groups to which this user is authorized
|
||||||
|
SmallGroups: SmallGroupId list
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full name of the user
|
||||||
|
member this.Name = $"{this.FirstName} {this.LastName}"
|
||||||
|
|
||||||
|
/// An empty user
|
||||||
|
static member Empty =
|
||||||
|
{ Id = UserId Guid.Empty
|
||||||
|
FirstName = ""
|
||||||
|
LastName = ""
|
||||||
|
Email = ""
|
||||||
|
IsAdmin = false
|
||||||
|
PasswordHash = ""
|
||||||
|
LastSeen = None
|
||||||
|
SmallGroups = [] }
|
16
src/Data/PrayerTracker.Data.fsproj
Normal file
16
src/Data/PrayerTracker.Data.fsproj
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Include="Entities.fs" />
|
||||||
|
<Compile Include="Access.fs" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="BitBadger.Documents.Sqlite" Version="4.0.1" />
|
||||||
|
<PackageReference Include="Giraffe" Version="7.0.2" />
|
||||||
|
<PackageReference Include="NodaTime" Version="3.2.1" />
|
||||||
|
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
|
||||||
|
<PackageReference Update="FSharp.Core" Version="9.0.101" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
@ -1,11 +1,11 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<AssemblyVersion>8.4.0.0</AssemblyVersion>
|
<AssemblyVersion>9.0.0.0</AssemblyVersion>
|
||||||
<FileVersion>8.4.0.0</FileVersion>
|
<FileVersion>9.0.0.0</FileVersion>
|
||||||
<Authors>danieljsummers</Authors>
|
<Authors>danieljsummers</Authors>
|
||||||
<Company>Bit Badger Solutions</Company>
|
<Company>Bit Badger Solutions</Company>
|
||||||
<Version>8.4.0</Version>
|
<Version>9.0.0</Version>
|
||||||
<DebugType>Embedded</DebugType>
|
<DebugType>Embedded</DebugType>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
@ -1,15 +1,15 @@
|
|||||||
FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build
|
FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build
|
||||||
WORKDIR /pt
|
WORKDIR /pt
|
||||||
COPY ./PrayerTracker.sln ./
|
COPY ./PrayerTracker.sln ./
|
||||||
COPY ./Directory.Build.props ./
|
COPY ./Directory.Build.props ./
|
||||||
COPY ./PrayerTracker/PrayerTracker.fsproj ./PrayerTracker/
|
COPY ./Data/PrayerTracker.Data.fsproj ./Data/
|
||||||
COPY ./PrayerTracker.Data/PrayerTracker.Data.fsproj ./PrayerTracker.Data/
|
COPY ./UI/PrayerTracker.UI.fsproj ./UI/
|
||||||
COPY ./PrayerTracker.Tests/PrayerTracker.Tests.fsproj ./PrayerTracker.Tests/
|
COPY ./PrayerTracker/PrayerTracker.fsproj ./PrayerTracker/
|
||||||
COPY ./PrayerTracker.UI/PrayerTracker.UI.fsproj ./PrayerTracker.UI/
|
COPY ./Tests/PrayerTracker.Tests.fsproj ./Tests/
|
||||||
RUN dotnet restore
|
RUN dotnet restore
|
||||||
|
|
||||||
COPY . ./
|
COPY . ./
|
||||||
WORKDIR /pt/PrayerTracker.Tests
|
WORKDIR /pt/Tests
|
||||||
RUN dotnet run
|
RUN dotnet run
|
||||||
|
|
||||||
WORKDIR /pt/PrayerTracker
|
WORKDIR /pt/PrayerTracker
|
||||||
|
@ -1,589 +0,0 @@
|
|||||||
namespace PrayerTracker.Data
|
|
||||||
|
|
||||||
open NodaTime
|
|
||||||
open Npgsql
|
|
||||||
open Npgsql.FSharp
|
|
||||||
open PrayerTracker.Entities
|
|
||||||
|
|
||||||
/// Helper functions for the PostgreSQL data implementation
|
|
||||||
[<AutoOpen>]
|
|
||||||
module private Helpers =
|
|
||||||
|
|
||||||
/// Map a row to a Church instance
|
|
||||||
let mapToChurch (row : RowReader) =
|
|
||||||
{ Id = ChurchId (row.uuid "id")
|
|
||||||
Name = row.string "church_name"
|
|
||||||
City = row.string "city"
|
|
||||||
State = row.string "state"
|
|
||||||
HasVpsInterface = row.bool "has_vps_interface"
|
|
||||||
InterfaceAddress = row.stringOrNone "interface_address"
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Map a row to a ListPreferences instance
|
|
||||||
let mapToListPreferences (row : RowReader) =
|
|
||||||
{ SmallGroupId = SmallGroupId (row.uuid "small_group_id")
|
|
||||||
DaysToKeepNew = row.int "days_to_keep_new"
|
|
||||||
DaysToExpire = row.int "days_to_expire"
|
|
||||||
LongTermUpdateWeeks = row.int "long_term_update_weeks"
|
|
||||||
EmailFromName = row.string "email_from_name"
|
|
||||||
EmailFromAddress = row.string "email_from_address"
|
|
||||||
Fonts = row.string "fonts"
|
|
||||||
HeadingColor = row.string "heading_color"
|
|
||||||
LineColor = row.string "line_color"
|
|
||||||
HeadingFontSize = row.int "heading_font_size"
|
|
||||||
TextFontSize = row.int "text_font_size"
|
|
||||||
GroupPassword = row.string "group_password"
|
|
||||||
IsPublic = row.bool "is_public"
|
|
||||||
PageSize = row.int "page_size"
|
|
||||||
TimeZoneId = TimeZoneId (row.string "time_zone_id")
|
|
||||||
RequestSort = RequestSort.fromCode (row.string "request_sort")
|
|
||||||
DefaultEmailType = EmailFormat.fromCode (row.string "default_email_type")
|
|
||||||
AsOfDateDisplay = AsOfDateDisplay.fromCode (row.string "as_of_date_display")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Map a row to a Member instance
|
|
||||||
let mapToMember (row : RowReader) =
|
|
||||||
{ Id = MemberId (row.uuid "id")
|
|
||||||
SmallGroupId = SmallGroupId (row.uuid "small_group_id")
|
|
||||||
Name = row.string "member_name"
|
|
||||||
Email = row.string "email"
|
|
||||||
Format = row.stringOrNone "email_format" |> Option.map EmailFormat.fromCode
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Map a row to a Prayer Request instance
|
|
||||||
let mapToPrayerRequest (row : RowReader) =
|
|
||||||
{ Id = PrayerRequestId (row.uuid "id")
|
|
||||||
UserId = UserId (row.uuid "user_id")
|
|
||||||
SmallGroupId = SmallGroupId (row.uuid "small_group_id")
|
|
||||||
EnteredDate = row.fieldValue<Instant> "entered_date"
|
|
||||||
UpdatedDate = row.fieldValue<Instant> "updated_date"
|
|
||||||
Requestor = row.stringOrNone "requestor"
|
|
||||||
Text = row.string "request_text"
|
|
||||||
NotifyChaplain = row.bool "notify_chaplain"
|
|
||||||
RequestType = PrayerRequestType.fromCode (row.string "request_type")
|
|
||||||
Expiration = Expiration.fromCode (row.string "expiration")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Map a row to a Small Group instance
|
|
||||||
let mapToSmallGroup (row : RowReader) =
|
|
||||||
{ Id = SmallGroupId (row.uuid "id")
|
|
||||||
ChurchId = ChurchId (row.uuid "church_id")
|
|
||||||
Name = row.string "group_name"
|
|
||||||
Preferences = ListPreferences.empty
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Map a row to a Small Group information set
|
|
||||||
let mapToSmallGroupInfo (row : RowReader) =
|
|
||||||
{ Id = Giraffe.ShortGuid.fromGuid (row.uuid "id")
|
|
||||||
Name = row.string "group_name"
|
|
||||||
ChurchName = row.string "church_name"
|
|
||||||
TimeZoneId = TimeZoneId (row.string "time_zone_id")
|
|
||||||
IsPublic = row.bool "is_public"
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Map a row to a Small Group list item
|
|
||||||
let mapToSmallGroupItem (row : RowReader) =
|
|
||||||
Giraffe.ShortGuid.fromGuid (row.uuid "id"), $"""{row.string "church_name"} | {row.string "group_name"}"""
|
|
||||||
|
|
||||||
/// Map a row to a Small Group instance with populated list preferences
|
|
||||||
let mapToSmallGroupWithPreferences (row : RowReader) =
|
|
||||||
{ mapToSmallGroup row with
|
|
||||||
Preferences = mapToListPreferences row
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Map a row to a User instance
|
|
||||||
let mapToUser (row : RowReader) =
|
|
||||||
{ Id = UserId (row.uuid "id")
|
|
||||||
FirstName = row.string "first_name"
|
|
||||||
LastName = row.string "last_name"
|
|
||||||
Email = row.string "email"
|
|
||||||
IsAdmin = row.bool "is_admin"
|
|
||||||
PasswordHash = row.string "password_hash"
|
|
||||||
LastSeen = row.fieldValueOrNone<Instant> "last_seen"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
open BitBadger.Documents.Postgres
|
|
||||||
|
|
||||||
/// Functions to manipulate churches
|
|
||||||
module Churches =
|
|
||||||
|
|
||||||
/// Get a list of all churches
|
|
||||||
let all () =
|
|
||||||
Custom.list "SELECT * FROM pt.church ORDER BY church_name" [] mapToChurch
|
|
||||||
|
|
||||||
/// Delete a church by its ID
|
|
||||||
let deleteById (churchId : ChurchId) = backgroundTask {
|
|
||||||
let idParam = [ [ "@churchId", Sql.uuid churchId.Value ] ]
|
|
||||||
let where = "WHERE small_group_id IN (SELECT id FROM pt.small_group WHERE church_id = @churchId)"
|
|
||||||
let! _ =
|
|
||||||
Configuration.dataSource ()
|
|
||||||
|> Sql.fromDataSource
|
|
||||||
|> Sql.executeTransactionAsync
|
|
||||||
[ $"DELETE FROM pt.prayer_request {where}", idParam
|
|
||||||
$"DELETE FROM pt.user_small_group {where}", idParam
|
|
||||||
$"DELETE FROM pt.list_preference {where}", idParam
|
|
||||||
"DELETE FROM pt.small_group WHERE church_id = @churchId", idParam
|
|
||||||
"DELETE FROM pt.church WHERE id = @churchId", idParam ]
|
|
||||||
()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Save a church's information
|
|
||||||
let save (church : Church) =
|
|
||||||
Custom.nonQuery
|
|
||||||
"INSERT INTO pt.church (
|
|
||||||
id, church_name, city, state, has_vps_interface, interface_address
|
|
||||||
) VALUES (
|
|
||||||
@id, @name, @city, @state, @hasVpsInterface, @interfaceAddress
|
|
||||||
) ON CONFLICT (id) DO UPDATE
|
|
||||||
SET church_name = EXCLUDED.church_name,
|
|
||||||
city = EXCLUDED.city,
|
|
||||||
state = EXCLUDED.state,
|
|
||||||
has_vps_interface = EXCLUDED.has_vps_interface,
|
|
||||||
interface_address = EXCLUDED.interface_address"
|
|
||||||
[ "@id", Sql.uuid church.Id.Value
|
|
||||||
"@name", Sql.string church.Name
|
|
||||||
"@city", Sql.string church.City
|
|
||||||
"@state", Sql.string church.State
|
|
||||||
"@hasVpsInterface", Sql.bool church.HasVpsInterface
|
|
||||||
"@interfaceAddress", Sql.stringOrNone church.InterfaceAddress ]
|
|
||||||
|
|
||||||
/// Find a church by its ID
|
|
||||||
let tryById (churchId : ChurchId) =
|
|
||||||
Custom.single "SELECT * FROM pt.church WHERE id = @id" [ "@id", Sql.uuid churchId.Value ] mapToChurch
|
|
||||||
|
|
||||||
|
|
||||||
/// Functions to manipulate small group members
|
|
||||||
module Members =
|
|
||||||
|
|
||||||
/// Count members for the given small group
|
|
||||||
let countByGroup (groupId : SmallGroupId) =
|
|
||||||
Custom.scalar "SELECT COUNT(id) AS mbr_count FROM pt.member WHERE small_group_id = @groupId"
|
|
||||||
[ "@groupId", Sql.uuid groupId.Value ] (fun row -> row.int "mbr_count")
|
|
||||||
|
|
||||||
/// Delete a small group member by its ID
|
|
||||||
let deleteById (memberId : MemberId) =
|
|
||||||
Custom.nonQuery "DELETE FROM pt.member WHERE id = @id" [ "@id", Sql.uuid memberId.Value ]
|
|
||||||
|
|
||||||
/// Retrieve all members for a given small group
|
|
||||||
let forGroup (groupId : SmallGroupId) =
|
|
||||||
Custom.list "SELECT * FROM pt.member WHERE small_group_id = @groupId ORDER BY member_name"
|
|
||||||
[ "@groupId", Sql.uuid groupId.Value ] mapToMember
|
|
||||||
|
|
||||||
/// Save a small group member
|
|
||||||
let save (mbr : Member) =
|
|
||||||
Custom.nonQuery
|
|
||||||
"INSERT INTO pt.member (
|
|
||||||
id, small_group_id, member_name, email, email_format
|
|
||||||
) VALUES (
|
|
||||||
@id, @groupId, @name, @email, @format
|
|
||||||
) ON CONFLICT (id) DO UPDATE
|
|
||||||
SET member_name = EXCLUDED.member_name,
|
|
||||||
email = EXCLUDED.email,
|
|
||||||
email_format = EXCLUDED.email_format"
|
|
||||||
[ "@id", Sql.uuid mbr.Id.Value
|
|
||||||
"@groupId", Sql.uuid mbr.SmallGroupId.Value
|
|
||||||
"@name", Sql.string mbr.Name
|
|
||||||
"@email", Sql.string mbr.Email
|
|
||||||
"@format", Sql.stringOrNone (mbr.Format |> Option.map EmailFormat.toCode) ]
|
|
||||||
|
|
||||||
/// Retrieve a small group member by its ID
|
|
||||||
let tryById (memberId : MemberId) =
|
|
||||||
Custom.single "SELECT * FROM pt.member WHERE id = @id" [ "@id", Sql.uuid memberId.Value ] mapToMember
|
|
||||||
|
|
||||||
|
|
||||||
/// Options to retrieve a list of requests
|
|
||||||
type PrayerRequestOptions =
|
|
||||||
{ /// The small group for which requests should be retrieved
|
|
||||||
SmallGroup : SmallGroup
|
|
||||||
|
|
||||||
/// The clock instance to use for date/time manipulation
|
|
||||||
Clock : IClock
|
|
||||||
|
|
||||||
/// The date for which the list is being retrieved
|
|
||||||
ListDate : LocalDate option
|
|
||||||
|
|
||||||
/// Whether only active requests should be retrieved
|
|
||||||
ActiveOnly : bool
|
|
||||||
|
|
||||||
/// The page number, for paged lists
|
|
||||||
PageNumber : int
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// Functions to manipulate prayer requests
|
|
||||||
module PrayerRequests =
|
|
||||||
|
|
||||||
/// Central place to append sort criteria for prayer request queries
|
|
||||||
let private orderBy sort =
|
|
||||||
match sort with
|
|
||||||
| SortByDate -> "updated_date DESC, entered_date DESC, requestor"
|
|
||||||
| SortByRequestor -> "requestor, updated_date DESC, entered_date DESC"
|
|
||||||
|
|
||||||
/// Paginate a prayer request query
|
|
||||||
let private paginate (pageNbr : int) pageSize =
|
|
||||||
if pageNbr > 0 then $"LIMIT {pageSize} OFFSET {(pageNbr - 1) * pageSize}" else ""
|
|
||||||
|
|
||||||
/// Count the number of prayer requests for a church
|
|
||||||
let countByChurch (churchId : ChurchId) =
|
|
||||||
Custom.scalar
|
|
||||||
"SELECT COUNT(id) AS req_count
|
|
||||||
FROM pt.prayer_request
|
|
||||||
WHERE small_group_id IN (SELECT id FROM pt.small_group WHERE church_id = @churchId)"
|
|
||||||
[ "@churchId", Sql.uuid churchId.Value ] (fun row -> row.int "req_count")
|
|
||||||
|
|
||||||
/// Count the number of prayer requests for a small group
|
|
||||||
let countByGroup (groupId : SmallGroupId) =
|
|
||||||
Custom.scalar "SELECT COUNT(id) AS req_count FROM pt.prayer_request WHERE small_group_id = @groupId"
|
|
||||||
[ "@groupId", Sql.uuid groupId.Value ] (fun row -> row.int "req_count")
|
|
||||||
|
|
||||||
/// Delete a prayer request by its ID
|
|
||||||
let deleteById (reqId : PrayerRequestId) =
|
|
||||||
Custom.nonQuery "DELETE FROM pt.prayer_request WHERE id = @id" [ "@id", Sql.uuid reqId.Value ]
|
|
||||||
|
|
||||||
/// Get all (or active) requests for a small group as of now or the specified date
|
|
||||||
let forGroup (opts : PrayerRequestOptions) =
|
|
||||||
let theDate = defaultArg opts.ListDate (SmallGroup.localDateNow opts.Clock opts.SmallGroup)
|
|
||||||
let where, parameters =
|
|
||||||
if opts.ActiveOnly then
|
|
||||||
let asOf = NpgsqlParameter (
|
|
||||||
"@asOf",
|
|
||||||
(theDate.AtStartOfDayInZone(SmallGroup.timeZone opts.SmallGroup)
|
|
||||||
- Duration.FromDays opts.SmallGroup.Preferences.DaysToExpire)
|
|
||||||
.ToInstant ())
|
|
||||||
" AND ( updated_date > @asOf
|
|
||||||
OR expiration = @manual
|
|
||||||
OR request_type = @longTerm
|
|
||||||
OR request_type = @expecting)
|
|
||||||
AND expiration <> @forced",
|
|
||||||
[ "@asOf", Sql.parameter asOf
|
|
||||||
"@manual", Sql.string (Expiration.toCode Manual)
|
|
||||||
"@longTerm", Sql.string (PrayerRequestType.toCode LongTermRequest)
|
|
||||||
"@expecting", Sql.string (PrayerRequestType.toCode Expecting)
|
|
||||||
"@forced", Sql.string (Expiration.toCode Forced) ]
|
|
||||||
else "", []
|
|
||||||
Custom.list
|
|
||||||
$"SELECT *
|
|
||||||
FROM pt.prayer_request
|
|
||||||
WHERE small_group_id = @groupId {where}
|
|
||||||
ORDER BY {orderBy opts.SmallGroup.Preferences.RequestSort}
|
|
||||||
{paginate opts.PageNumber opts.SmallGroup.Preferences.PageSize}"
|
|
||||||
(("@groupId", Sql.uuid opts.SmallGroup.Id.Value) :: parameters) mapToPrayerRequest
|
|
||||||
|
|
||||||
/// Save a prayer request
|
|
||||||
let save (req : PrayerRequest) =
|
|
||||||
Custom.nonQuery
|
|
||||||
"INSERT into pt.prayer_request (
|
|
||||||
id, request_type, user_id, small_group_id, entered_date, updated_date, requestor, request_text,
|
|
||||||
notify_chaplain, expiration
|
|
||||||
) VALUES (
|
|
||||||
@id, @type, @userId, @groupId, @entered, @updated, @requestor, @text,
|
|
||||||
@notifyChaplain, @expiration
|
|
||||||
) ON CONFLICT (id) DO UPDATE
|
|
||||||
SET request_type = EXCLUDED.request_type,
|
|
||||||
updated_date = EXCLUDED.updated_date,
|
|
||||||
requestor = EXCLUDED.requestor,
|
|
||||||
request_text = EXCLUDED.request_text,
|
|
||||||
notify_chaplain = EXCLUDED.notify_chaplain,
|
|
||||||
expiration = EXCLUDED.expiration"
|
|
||||||
[ "@id", Sql.uuid req.Id.Value
|
|
||||||
"@type", Sql.string (PrayerRequestType.toCode req.RequestType)
|
|
||||||
"@userId", Sql.uuid req.UserId.Value
|
|
||||||
"@groupId", Sql.uuid req.SmallGroupId.Value
|
|
||||||
"@entered", Sql.parameter (NpgsqlParameter ("@entered", req.EnteredDate))
|
|
||||||
"@updated", Sql.parameter (NpgsqlParameter ("@updated", req.UpdatedDate))
|
|
||||||
"@requestor", Sql.stringOrNone req.Requestor
|
|
||||||
"@text", Sql.string req.Text
|
|
||||||
"@notifyChaplain", Sql.bool req.NotifyChaplain
|
|
||||||
"@expiration", Sql.string (Expiration.toCode req.Expiration) ]
|
|
||||||
|
|
||||||
/// Search prayer requests for the given term
|
|
||||||
let searchForGroup group searchTerm pageNbr =
|
|
||||||
Custom.list
|
|
||||||
$"SELECT * FROM pt.prayer_request WHERE small_group_id = @groupId AND request_text ILIKE @search
|
|
||||||
UNION
|
|
||||||
SELECT * FROM pt.prayer_request WHERE small_group_id = @groupId AND COALESCE(requestor, '') ILIKE @search
|
|
||||||
ORDER BY {orderBy group.Preferences.RequestSort}
|
|
||||||
{paginate pageNbr group.Preferences.PageSize}"
|
|
||||||
[ "@groupId", Sql.uuid group.Id.Value; "@search", Sql.string $"%%%s{searchTerm}%%" ] mapToPrayerRequest
|
|
||||||
|
|
||||||
/// Retrieve a prayer request by its ID
|
|
||||||
let tryById (reqId : PrayerRequestId) =
|
|
||||||
Custom.single "SELECT * FROM pt.prayer_request WHERE id = @id" [ "@id", Sql.uuid reqId.Value ]
|
|
||||||
mapToPrayerRequest
|
|
||||||
|
|
||||||
/// Update the expiration for the given prayer request
|
|
||||||
let updateExpiration (req : PrayerRequest) withTime =
|
|
||||||
let sql, parameters =
|
|
||||||
if withTime then
|
|
||||||
", updated_date = @updated",
|
|
||||||
[ "@updated", Sql.parameter (NpgsqlParameter ("@updated", req.UpdatedDate)) ]
|
|
||||||
else "", []
|
|
||||||
Custom.nonQuery $"UPDATE pt.prayer_request SET expiration = @expiration{sql} WHERE id = @id"
|
|
||||||
([ "@expiration", Sql.string (Expiration.toCode req.Expiration)
|
|
||||||
"@id", Sql.uuid req.Id.Value ]
|
|
||||||
|> List.append parameters)
|
|
||||||
|
|
||||||
|
|
||||||
/// Functions to retrieve small group information
|
|
||||||
module SmallGroups =
|
|
||||||
|
|
||||||
/// Count the number of small groups for a church
|
|
||||||
let countByChurch (churchId : ChurchId) =
|
|
||||||
Custom.scalar "SELECT COUNT(id) AS group_count FROM pt.small_group WHERE church_id = @churchId"
|
|
||||||
[ "@churchId", Sql.uuid churchId.Value ] (fun row -> row.int "group_count")
|
|
||||||
|
|
||||||
/// Delete a small group by its ID
|
|
||||||
let deleteById (groupId : SmallGroupId) = backgroundTask {
|
|
||||||
let idParam = [ [ "@groupId", Sql.uuid groupId.Value ] ]
|
|
||||||
let! _ =
|
|
||||||
Configuration.dataSource ()
|
|
||||||
|> Sql.fromDataSource
|
|
||||||
|> Sql.executeTransactionAsync
|
|
||||||
[ "DELETE FROM pt.prayer_request WHERE small_group_id = @groupId", idParam
|
|
||||||
"DELETE FROM pt.user_small_group WHERE small_group_id = @groupId", idParam
|
|
||||||
"DELETE FROM pt.list_preference WHERE small_group_id = @groupId", idParam
|
|
||||||
"DELETE FROM pt.small_group WHERE id = @groupId", idParam ]
|
|
||||||
()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get information for all small groups
|
|
||||||
let infoForAll () =
|
|
||||||
Custom.list
|
|
||||||
"SELECT sg.id, sg.group_name, c.church_name, lp.time_zone_id, lp.is_public
|
|
||||||
FROM pt.small_group sg
|
|
||||||
INNER JOIN pt.church c ON c.id = sg.church_id
|
|
||||||
INNER JOIN pt.list_preference lp ON lp.small_group_id = sg.id
|
|
||||||
ORDER BY sg.group_name"
|
|
||||||
[] mapToSmallGroupInfo
|
|
||||||
|
|
||||||
/// Get a list of small group IDs along with a description that includes the church name
|
|
||||||
let listAll () =
|
|
||||||
Custom.list
|
|
||||||
"SELECT g.group_name, g.id, c.church_name
|
|
||||||
FROM pt.small_group g
|
|
||||||
INNER JOIN pt.church c ON c.id = g.church_id
|
|
||||||
ORDER BY c.church_name, g.group_name"
|
|
||||||
[] mapToSmallGroupItem
|
|
||||||
|
|
||||||
/// Get a list of small group IDs and descriptions for groups with a group password
|
|
||||||
let listProtected () =
|
|
||||||
Custom.list
|
|
||||||
"SELECT g.group_name, g.id, c.church_name, lp.is_public
|
|
||||||
FROM pt.small_group g
|
|
||||||
INNER JOIN pt.church c ON c.id = g.church_id
|
|
||||||
INNER JOIN pt.list_preference lp ON lp.small_group_id = g.id
|
|
||||||
WHERE COALESCE(lp.group_password, '') <> ''
|
|
||||||
ORDER BY c.church_name, g.group_name"
|
|
||||||
[] mapToSmallGroupItem
|
|
||||||
|
|
||||||
/// Get a list of small group IDs and descriptions for groups that are public or have a group password
|
|
||||||
let listPublicAndProtected () =
|
|
||||||
Custom.list
|
|
||||||
"SELECT g.group_name, g.id, c.church_name, lp.time_zone_id, lp.is_public
|
|
||||||
FROM pt.small_group g
|
|
||||||
INNER JOIN pt.church c ON c.id = g.church_id
|
|
||||||
INNER JOIN pt.list_preference lp ON lp.small_group_id = g.id
|
|
||||||
WHERE lp.is_public = TRUE
|
|
||||||
OR COALESCE(lp.group_password, '') <> ''
|
|
||||||
ORDER BY c.church_name, g.group_name"
|
|
||||||
[] mapToSmallGroupInfo
|
|
||||||
|
|
||||||
/// Log on for a small group (includes list preferences)
|
|
||||||
let logOn (groupId : SmallGroupId) password =
|
|
||||||
Custom.single
|
|
||||||
"SELECT sg.*, lp.*
|
|
||||||
FROM pt.small_group sg
|
|
||||||
INNER JOIN pt.list_preference lp ON lp.small_group_id = sg.id
|
|
||||||
WHERE sg.id = @id
|
|
||||||
AND lp.group_password = @password"
|
|
||||||
[ "@id", Sql.uuid groupId.Value; "@password", Sql.string password ] mapToSmallGroupWithPreferences
|
|
||||||
|
|
||||||
/// Save a small group
|
|
||||||
let save (group : SmallGroup) isNew = backgroundTask {
|
|
||||||
let! _ =
|
|
||||||
Configuration.dataSource ()
|
|
||||||
|> Sql.fromDataSource
|
|
||||||
|> Sql.executeTransactionAsync [
|
|
||||||
"INSERT INTO pt.small_group (
|
|
||||||
id, church_id, group_name
|
|
||||||
) VALUES (
|
|
||||||
@id, @churchId, @name
|
|
||||||
) ON CONFLICT (id) DO UPDATE
|
|
||||||
SET church_id = EXCLUDED.church_id,
|
|
||||||
group_name = EXCLUDED.group_name",
|
|
||||||
[ [ "@id", Sql.uuid group.Id.Value
|
|
||||||
"@churchId", Sql.uuid group.ChurchId.Value
|
|
||||||
"@name", Sql.string group.Name ] ]
|
|
||||||
if isNew then
|
|
||||||
"INSERT INTO pt.list_preference (small_group_id) VALUES (@id)",
|
|
||||||
[ [ "@id", Sql.uuid group.Id.Value ] ]
|
|
||||||
]
|
|
||||||
()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Save a small group's list preferences
|
|
||||||
let savePreferences (pref : ListPreferences) =
|
|
||||||
Custom.nonQuery
|
|
||||||
"UPDATE pt.list_preference
|
|
||||||
SET days_to_keep_new = @daysToKeepNew,
|
|
||||||
days_to_expire = @daysToExpire,
|
|
||||||
long_term_update_weeks = @longTermUpdateWeeks,
|
|
||||||
email_from_name = @emailFromName,
|
|
||||||
email_from_address = @emailFromAddress,
|
|
||||||
fonts = @fonts,
|
|
||||||
heading_color = @headingColor,
|
|
||||||
line_color = @lineColor,
|
|
||||||
heading_font_size = @headingFontSize,
|
|
||||||
text_font_size = @textFontSize,
|
|
||||||
request_sort = @requestSort,
|
|
||||||
group_password = @groupPassword,
|
|
||||||
default_email_type = @defaultEmailType,
|
|
||||||
is_public = @isPublic,
|
|
||||||
time_zone_id = @timeZoneId,
|
|
||||||
page_size = @pageSize,
|
|
||||||
as_of_date_display = @asOfDateDisplay
|
|
||||||
WHERE small_group_id = @groupId"
|
|
||||||
[ "@groupId", Sql.uuid pref.SmallGroupId.Value
|
|
||||||
"@daysToKeepNew", Sql.int pref.DaysToKeepNew
|
|
||||||
"@daysToExpire", Sql.int pref.DaysToExpire
|
|
||||||
"@longTermUpdateWeeks", Sql.int pref.LongTermUpdateWeeks
|
|
||||||
"@emailFromName", Sql.string pref.EmailFromName
|
|
||||||
"@emailFromAddress", Sql.string pref.EmailFromAddress
|
|
||||||
"@fonts", Sql.string pref.Fonts
|
|
||||||
"@headingColor", Sql.string pref.HeadingColor
|
|
||||||
"@lineColor", Sql.string pref.LineColor
|
|
||||||
"@headingFontSize", Sql.int pref.HeadingFontSize
|
|
||||||
"@textFontSize", Sql.int pref.TextFontSize
|
|
||||||
"@requestSort", Sql.string (RequestSort.toCode pref.RequestSort)
|
|
||||||
"@groupPassword", Sql.string pref.GroupPassword
|
|
||||||
"@defaultEmailType", Sql.string (EmailFormat.toCode pref.DefaultEmailType)
|
|
||||||
"@isPublic", Sql.bool pref.IsPublic
|
|
||||||
"@timeZoneId", Sql.string (TimeZoneId.toString pref.TimeZoneId)
|
|
||||||
"@pageSize", Sql.int pref.PageSize
|
|
||||||
"@asOfDateDisplay", Sql.string (AsOfDateDisplay.toCode pref.AsOfDateDisplay) ]
|
|
||||||
|
|
||||||
/// Get a small group by its ID
|
|
||||||
let tryById (groupId : SmallGroupId) =
|
|
||||||
Custom.single "SELECT * FROM pt.small_group WHERE id = @id" [ "@id", Sql.uuid groupId.Value ] mapToSmallGroup
|
|
||||||
|
|
||||||
/// Get a small group by its ID with its list preferences populated
|
|
||||||
let tryByIdWithPreferences (groupId : SmallGroupId) =
|
|
||||||
Custom.single
|
|
||||||
"SELECT sg.*, lp.*
|
|
||||||
FROM pt.small_group sg
|
|
||||||
INNER JOIN pt.list_preference lp ON lp.small_group_id = sg.id
|
|
||||||
WHERE sg.id = @id"
|
|
||||||
[ "@id", Sql.uuid groupId.Value ] mapToSmallGroupWithPreferences
|
|
||||||
|
|
||||||
|
|
||||||
/// Functions to manipulate users
|
|
||||||
module Users =
|
|
||||||
|
|
||||||
/// Retrieve all PrayerTracker users
|
|
||||||
let all () =
|
|
||||||
Custom.list "SELECT * FROM pt.pt_user ORDER BY last_name, first_name" [] mapToUser
|
|
||||||
|
|
||||||
/// Count the number of users for a church
|
|
||||||
let countByChurch (churchId : ChurchId) =
|
|
||||||
Custom.scalar
|
|
||||||
"SELECT COUNT(u.id) AS user_count
|
|
||||||
FROM pt.pt_user u
|
|
||||||
WHERE EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM pt.user_small_group usg
|
|
||||||
INNER JOIN pt.small_group sg ON sg.id = usg.small_group_id
|
|
||||||
WHERE usg.user_id = u.id
|
|
||||||
AND sg.church_id = @churchId)"
|
|
||||||
[ "@churchId", Sql.uuid churchId.Value ] (fun row -> row.int "user_count")
|
|
||||||
|
|
||||||
/// Count the number of users for a small group
|
|
||||||
let countByGroup (groupId : SmallGroupId) =
|
|
||||||
Custom.scalar "SELECT COUNT(user_id) AS user_count FROM pt.user_small_group WHERE small_group_id = @groupId"
|
|
||||||
[ "@groupId", Sql.uuid groupId.Value ] (fun row -> row.int "user_count")
|
|
||||||
|
|
||||||
/// Delete a user by its database ID
|
|
||||||
let deleteById (userId : UserId) =
|
|
||||||
Custom.nonQuery "DELETE FROM pt.pt_user WHERE id = @id" [ "@id", Sql.uuid userId.Value ]
|
|
||||||
|
|
||||||
/// Get the IDs of the small groups for which the given user is authorized
|
|
||||||
let groupIdsByUserId (userId : UserId) =
|
|
||||||
Custom.list "SELECT small_group_id FROM pt.user_small_group WHERE user_id = @id"
|
|
||||||
[ "@id", Sql.uuid userId.Value ] (fun row -> SmallGroupId (row.uuid "small_group_id"))
|
|
||||||
|
|
||||||
/// Get a list of users authorized to administer the given small group
|
|
||||||
let listByGroupId (groupId : SmallGroupId) =
|
|
||||||
Custom.list
|
|
||||||
"SELECT u.*
|
|
||||||
FROM pt.pt_user u
|
|
||||||
INNER JOIN pt.user_small_group usg ON usg.user_id = u.id
|
|
||||||
WHERE usg.small_group_id = @groupId
|
|
||||||
ORDER BY u.last_name, u.first_name"
|
|
||||||
[ "@groupId", Sql.uuid groupId.Value ] mapToUser
|
|
||||||
|
|
||||||
/// Save a user's information
|
|
||||||
let save (user : User) =
|
|
||||||
Custom.nonQuery
|
|
||||||
"INSERT INTO pt.pt_user (
|
|
||||||
id, first_name, last_name, email, is_admin, password_hash
|
|
||||||
) VALUES (
|
|
||||||
@id, @firstName, @lastName, @email, @isAdmin, @passwordHash
|
|
||||||
) ON CONFLICT (id) DO UPDATE
|
|
||||||
SET first_name = EXCLUDED.first_name,
|
|
||||||
last_name = EXCLUDED.last_name,
|
|
||||||
email = EXCLUDED.email,
|
|
||||||
is_admin = EXCLUDED.is_admin,
|
|
||||||
password_hash = EXCLUDED.password_hash"
|
|
||||||
[ "@id", Sql.uuid user.Id.Value
|
|
||||||
"@firstName", Sql.string user.FirstName
|
|
||||||
"@lastName", Sql.string user.LastName
|
|
||||||
"@email", Sql.string user.Email
|
|
||||||
"@isAdmin", Sql.bool user.IsAdmin
|
|
||||||
"@passwordHash", Sql.string user.PasswordHash ]
|
|
||||||
|
|
||||||
/// Find a user by its e-mail address and authorized small group
|
|
||||||
let tryByEmailAndGroup email (groupId : SmallGroupId) =
|
|
||||||
Custom.single
|
|
||||||
"SELECT u.*
|
|
||||||
FROM pt.pt_user u
|
|
||||||
INNER JOIN pt.user_small_group usg ON usg.user_id = u.id AND usg.small_group_id = @groupId
|
|
||||||
WHERE u.email = @email"
|
|
||||||
[ "@email", Sql.string email; "@groupId", Sql.uuid groupId.Value ] mapToUser
|
|
||||||
|
|
||||||
/// Find a user by their database ID
|
|
||||||
let tryById (userId : UserId) =
|
|
||||||
Custom.single "SELECT * FROM pt.pt_user WHERE id = @id" [ "@id", Sql.uuid userId.Value ] mapToUser
|
|
||||||
|
|
||||||
/// Update a user's last seen date/time
|
|
||||||
let updateLastSeen (userId : UserId) (now : Instant) =
|
|
||||||
Custom.nonQuery "UPDATE pt.pt_user SET last_seen = @now WHERE id = @id"
|
|
||||||
[ "@id", Sql.uuid userId.Value; "@now", Sql.parameter (NpgsqlParameter ("@now", now)) ]
|
|
||||||
|
|
||||||
/// Update a user's password hash
|
|
||||||
let updatePassword (user : User) =
|
|
||||||
Custom.nonQuery "UPDATE pt.pt_user SET password_hash = @passwordHash WHERE id = @id"
|
|
||||||
[ "@id", Sql.uuid user.Id.Value; "@passwordHash", Sql.string user.PasswordHash ]
|
|
||||||
|
|
||||||
/// Update a user's authorized small groups
|
|
||||||
let updateSmallGroups (userId : UserId) groupIds = backgroundTask {
|
|
||||||
let! existingGroupIds = groupIdsByUserId userId
|
|
||||||
let toAdd =
|
|
||||||
groupIds |> List.filter (fun it -> existingGroupIds |> List.exists (fun grpId -> grpId = it) |> not)
|
|
||||||
let toDelete =
|
|
||||||
existingGroupIds |> List.filter (fun it -> groupIds |> List.exists (fun grpId -> grpId = it) |> not)
|
|
||||||
let queries = seq {
|
|
||||||
if not (List.isEmpty toAdd) then
|
|
||||||
"INSERT INTO pt.user_small_group VALUES (@userId, @smallGroupId)",
|
|
||||||
toAdd |> List.map (fun it -> [ "@userId", Sql.uuid userId.Value; "@smallGroupId", Sql.uuid it.Value ])
|
|
||||||
if not (List.isEmpty toDelete) then
|
|
||||||
"DELETE FROM pt.user_small_group WHERE user_id = @userId AND small_group_id = @smallGroupId",
|
|
||||||
toDelete
|
|
||||||
|> List.map (fun it -> [ "@userId", Sql.uuid userId.Value; "@smallGroupId", Sql.uuid it.Value ])
|
|
||||||
}
|
|
||||||
if not (Seq.isEmpty queries) then
|
|
||||||
let! _ =
|
|
||||||
Configuration.dataSource ()
|
|
||||||
|> Sql.fromDataSource
|
|
||||||
|> Sql.executeTransactionAsync (List.ofSeq queries)
|
|
||||||
()
|
|
||||||
}
|
|
@ -1,193 +0,0 @@
|
|||||||
namespace PrayerTracker.Data
|
|
||||||
|
|
||||||
open System.Threading
|
|
||||||
open System.Threading.Tasks
|
|
||||||
open Microsoft.Extensions.Caching.Distributed
|
|
||||||
open NodaTime
|
|
||||||
open Npgsql
|
|
||||||
open Npgsql.FSharp
|
|
||||||
|
|
||||||
/// Helper types and functions for the cache
|
|
||||||
[<AutoOpen>]
|
|
||||||
module private CacheHelpers =
|
|
||||||
|
|
||||||
open System
|
|
||||||
|
|
||||||
/// The cache entry
|
|
||||||
type Entry =
|
|
||||||
{ /// The ID of the cache entry
|
|
||||||
Id : string
|
|
||||||
|
|
||||||
/// The value to be cached
|
|
||||||
Payload : byte[]
|
|
||||||
|
|
||||||
/// When this entry will expire
|
|
||||||
ExpireAt : Instant
|
|
||||||
|
|
||||||
/// The duration by which the expiration should be pushed out when being refreshed
|
|
||||||
SlidingExpiration : Duration option
|
|
||||||
|
|
||||||
/// The must-expire-by date/time for the cache entry
|
|
||||||
AbsoluteExpiration : Instant option
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run a task synchronously
|
|
||||||
let sync<'T> (it : Task<'T>) = it |> (Async.AwaitTask >> Async.RunSynchronously)
|
|
||||||
|
|
||||||
/// Get the current instant
|
|
||||||
let getNow () = SystemClock.Instance.GetCurrentInstant ()
|
|
||||||
|
|
||||||
/// Create a parameter for the expire-at time
|
|
||||||
let expireParam (it : Instant) =
|
|
||||||
"@expireAt", Sql.parameter (NpgsqlParameter ("@expireAt", it))
|
|
||||||
|
|
||||||
/// Create a parameter for a possibly-missing NodaTime type
|
|
||||||
let optParam<'T> name (it : 'T option) =
|
|
||||||
let p = NpgsqlParameter ($"@%s{name}", if Option.isSome it then box it.Value else DBNull.Value)
|
|
||||||
p.ParameterName, Sql.parameter p
|
|
||||||
|
|
||||||
|
|
||||||
open BitBadger.Documents.Postgres
|
|
||||||
|
|
||||||
/// A distributed cache implementation in PostgreSQL used to handle sessions for myWebLog
|
|
||||||
type DistributedCache () =
|
|
||||||
|
|
||||||
// ~~~ INITIALIZATION ~~~
|
|
||||||
|
|
||||||
do
|
|
||||||
task {
|
|
||||||
let! exists =
|
|
||||||
Custom.scalar
|
|
||||||
$"SELECT EXISTS
|
|
||||||
(SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = 'session')
|
|
||||||
AS does_exist"
|
|
||||||
[] (fun row -> row.bool "does_exist")
|
|
||||||
if not exists then
|
|
||||||
do! Custom.nonQuery
|
|
||||||
"CREATE TABLE session (
|
|
||||||
id TEXT NOT NULL PRIMARY KEY,
|
|
||||||
payload BYTEA NOT NULL,
|
|
||||||
expire_at TIMESTAMPTZ NOT NULL,
|
|
||||||
sliding_expiration INTERVAL,
|
|
||||||
absolute_expiration TIMESTAMPTZ);
|
|
||||||
CREATE INDEX idx_session_expiration ON session (expire_at)" []
|
|
||||||
} |> sync
|
|
||||||
|
|
||||||
// ~~~ SUPPORT FUNCTIONS ~~~
|
|
||||||
|
|
||||||
/// Get an entry, updating it for sliding expiration
|
|
||||||
let getEntry key = backgroundTask {
|
|
||||||
let idParam = "@id", Sql.string key
|
|
||||||
let! tryEntry =
|
|
||||||
Custom.single "SELECT * FROM session WHERE id = @id" [ idParam ]
|
|
||||||
(fun row ->
|
|
||||||
{ Id = row.string "id"
|
|
||||||
Payload = row.bytea "payload"
|
|
||||||
ExpireAt = row.fieldValue<Instant> "expire_at"
|
|
||||||
SlidingExpiration = row.fieldValueOrNone<Duration> "sliding_expiration"
|
|
||||||
AbsoluteExpiration = row.fieldValueOrNone<Instant> "absolute_expiration" })
|
|
||||||
match tryEntry with
|
|
||||||
| Some entry ->
|
|
||||||
let now = getNow ()
|
|
||||||
let slideExp = defaultArg entry.SlidingExpiration Duration.MinValue
|
|
||||||
let absExp = defaultArg entry.AbsoluteExpiration Instant.MinValue
|
|
||||||
let needsRefresh, item =
|
|
||||||
if entry.ExpireAt = absExp then false, entry
|
|
||||||
elif slideExp = Duration.MinValue && absExp = Instant.MinValue then false, entry
|
|
||||||
elif absExp > Instant.MinValue && entry.ExpireAt.Plus slideExp > absExp then
|
|
||||||
true, { entry with ExpireAt = absExp }
|
|
||||||
else true, { entry with ExpireAt = now.Plus slideExp }
|
|
||||||
if needsRefresh then
|
|
||||||
do! Custom.nonQuery "UPDATE session SET expire_at = @expireAt WHERE id = @id"
|
|
||||||
[ expireParam item.ExpireAt; idParam ]
|
|
||||||
return if item.ExpireAt > now then Some entry else None
|
|
||||||
| None -> return None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The last time expired entries were purged (runs every 30 minutes)
|
|
||||||
let mutable lastPurge = Instant.MinValue
|
|
||||||
|
|
||||||
/// Purge expired entries every 30 minutes
|
|
||||||
let purge () = backgroundTask {
|
|
||||||
let now = getNow ()
|
|
||||||
if lastPurge.Plus (Duration.FromMinutes 30L) < now then
|
|
||||||
do! Custom.nonQuery "DELETE FROM session WHERE expire_at < @expireAt" [ expireParam now ]
|
|
||||||
lastPurge <- now
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove a cache entry
|
|
||||||
let removeEntry key =
|
|
||||||
Custom.nonQuery "DELETE FROM session WHERE id = @id" [ "@id", Sql.string key ]
|
|
||||||
|
|
||||||
/// Save an entry
|
|
||||||
let saveEntry (opts : DistributedCacheEntryOptions) key payload =
|
|
||||||
let now = getNow ()
|
|
||||||
let expireAt, slideExp, absExp =
|
|
||||||
if opts.SlidingExpiration.HasValue then
|
|
||||||
let slide = Duration.FromTimeSpan opts.SlidingExpiration.Value
|
|
||||||
now.Plus slide, Some slide, None
|
|
||||||
elif opts.AbsoluteExpiration.HasValue then
|
|
||||||
let exp = Instant.FromDateTimeOffset opts.AbsoluteExpiration.Value
|
|
||||||
exp, None, Some exp
|
|
||||||
elif opts.AbsoluteExpirationRelativeToNow.HasValue then
|
|
||||||
let exp = now.Plus (Duration.FromTimeSpan opts.AbsoluteExpirationRelativeToNow.Value)
|
|
||||||
exp, None, Some exp
|
|
||||||
else
|
|
||||||
// Default to 2 hour sliding expiration
|
|
||||||
let slide = Duration.FromHours 2
|
|
||||||
now.Plus slide, Some slide, None
|
|
||||||
Custom.nonQuery
|
|
||||||
"INSERT INTO session (
|
|
||||||
id, payload, expire_at, sliding_expiration, absolute_expiration
|
|
||||||
) VALUES (
|
|
||||||
@id, @payload, @expireAt, @slideExp, @absExp
|
|
||||||
) ON CONFLICT (id) DO UPDATE
|
|
||||||
SET payload = EXCLUDED.payload,
|
|
||||||
expire_at = EXCLUDED.expire_at,
|
|
||||||
sliding_expiration = EXCLUDED.sliding_expiration,
|
|
||||||
absolute_expiration = EXCLUDED.absolute_expiration"
|
|
||||||
[ "@id", Sql.string key
|
|
||||||
"@payload", Sql.bytea payload
|
|
||||||
expireParam expireAt
|
|
||||||
optParam "slideExp" slideExp
|
|
||||||
optParam "absExp" absExp ]
|
|
||||||
|
|
||||||
// ~~~ IMPLEMENTATION FUNCTIONS ~~~
|
|
||||||
|
|
||||||
/// Retrieve the data for a cache entry
|
|
||||||
let get key (_ : CancellationToken) = backgroundTask {
|
|
||||||
match! getEntry key with
|
|
||||||
| Some entry ->
|
|
||||||
do! purge ()
|
|
||||||
return entry.Payload
|
|
||||||
| None -> return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Refresh an entry
|
|
||||||
let refresh key (cancelToken : CancellationToken) = backgroundTask {
|
|
||||||
let! _ = get key cancelToken
|
|
||||||
()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove an entry
|
|
||||||
let remove key (_ : CancellationToken) = backgroundTask {
|
|
||||||
do! removeEntry key
|
|
||||||
do! purge ()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set an entry
|
|
||||||
let set key value options (_ : CancellationToken) = backgroundTask {
|
|
||||||
do! saveEntry options key value
|
|
||||||
do! purge ()
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IDistributedCache with
|
|
||||||
member this.Get key = get key CancellationToken.None |> sync
|
|
||||||
member this.GetAsync (key, token) = get key token
|
|
||||||
member this.Refresh key = refresh key CancellationToken.None |> sync
|
|
||||||
member this.RefreshAsync (key, token) = refresh key token
|
|
||||||
member this.Remove key = remove key CancellationToken.None |> sync
|
|
||||||
member this.RemoveAsync (key, token) = remove key token
|
|
||||||
member this.Set (key, value, options) = set key value options CancellationToken.None |> sync
|
|
||||||
member this.SetAsync (key, value, options, token) = set key value options token
|
|
||||||
|
|
@ -1,557 +0,0 @@
|
|||||||
namespace PrayerTracker.Entities
|
|
||||||
|
|
||||||
(*-- SUPPORT TYPES --*)
|
|
||||||
|
|
||||||
/// How as-of dates should (or should not) be displayed with requests
|
|
||||||
type AsOfDateDisplay =
|
|
||||||
/// No as-of date should be displayed
|
|
||||||
| NoDisplay
|
|
||||||
/// The as-of date should be displayed in the culture's short date format
|
|
||||||
| ShortDate
|
|
||||||
/// The as-of date should be displayed in the culture's long date format
|
|
||||||
| LongDate
|
|
||||||
|
|
||||||
/// Functions to support as-of date display options
|
|
||||||
module AsOfDateDisplay =
|
|
||||||
|
|
||||||
/// Convert to a DU case from a single-character string
|
|
||||||
let fromCode code =
|
|
||||||
match code with
|
|
||||||
| "N" -> NoDisplay
|
|
||||||
| "S" -> ShortDate
|
|
||||||
| "L" -> LongDate
|
|
||||||
| _ -> invalidArg "code" $"Unknown code {code}"
|
|
||||||
|
|
||||||
/// Convert this DU case to a single-character string
|
|
||||||
let toCode = function NoDisplay -> "N" | ShortDate -> "S" | LongDate -> "L"
|
|
||||||
|
|
||||||
|
|
||||||
/// Acceptable e-mail formats
|
|
||||||
type EmailFormat =
|
|
||||||
/// HTML e-mail
|
|
||||||
| HtmlFormat
|
|
||||||
/// Plain-text e-mail
|
|
||||||
| PlainTextFormat
|
|
||||||
|
|
||||||
/// Functions to support e-mail formats
|
|
||||||
module EmailFormat =
|
|
||||||
|
|
||||||
/// Convert to a DU case from a single-character string
|
|
||||||
let fromCode code =
|
|
||||||
match code with
|
|
||||||
| "H" -> HtmlFormat
|
|
||||||
| "P" -> PlainTextFormat
|
|
||||||
| _ -> invalidArg "code" $"Unknown code {code}"
|
|
||||||
|
|
||||||
/// Convert this DU case to a single-character string
|
|
||||||
let toCode = function HtmlFormat -> "H" | PlainTextFormat -> "P"
|
|
||||||
|
|
||||||
|
|
||||||
/// Expiration for requests
|
|
||||||
type Expiration =
|
|
||||||
/// Follow the rules for normal expiration
|
|
||||||
| Automatic
|
|
||||||
/// Do not expire via rules
|
|
||||||
| Manual
|
|
||||||
/// Force immediate expiration
|
|
||||||
| Forced
|
|
||||||
|
|
||||||
/// Functions to support expirations
|
|
||||||
module Expiration =
|
|
||||||
|
|
||||||
/// Convert to a DU case from a single-character string
|
|
||||||
let fromCode code =
|
|
||||||
match code with
|
|
||||||
| "A" -> Automatic
|
|
||||||
| "M" -> Manual
|
|
||||||
| "F" -> Forced
|
|
||||||
| _ -> invalidArg "code" $"Unknown code {code}"
|
|
||||||
|
|
||||||
/// Convert this DU case to a single-character string
|
|
||||||
let toCode = function Automatic -> "A" | Manual -> "M" | Forced -> "F"
|
|
||||||
|
|
||||||
|
|
||||||
/// Types of prayer requests
|
|
||||||
type PrayerRequestType =
|
|
||||||
/// Current requests
|
|
||||||
| CurrentRequest
|
|
||||||
/// Long-term/ongoing request
|
|
||||||
| LongTermRequest
|
|
||||||
/// Expectant couples
|
|
||||||
| Expecting
|
|
||||||
/// Praise reports
|
|
||||||
| PraiseReport
|
|
||||||
/// Announcements
|
|
||||||
| Announcement
|
|
||||||
|
|
||||||
/// Functions to support prayer request types
|
|
||||||
module PrayerRequestType =
|
|
||||||
|
|
||||||
/// Convert to a DU case from a single-character string
|
|
||||||
let fromCode code =
|
|
||||||
match code with
|
|
||||||
| "C" -> CurrentRequest
|
|
||||||
| "L" -> LongTermRequest
|
|
||||||
| "E" -> Expecting
|
|
||||||
| "P" -> PraiseReport
|
|
||||||
| "A" -> Announcement
|
|
||||||
| _ -> invalidArg "code" $"Unknown code {code}"
|
|
||||||
|
|
||||||
/// Convert this DU case to a single-character string
|
|
||||||
let toCode =
|
|
||||||
function
|
|
||||||
| CurrentRequest -> "C"
|
|
||||||
| LongTermRequest -> "L"
|
|
||||||
| Expecting -> "E"
|
|
||||||
| PraiseReport -> "P"
|
|
||||||
| Announcement -> "A"
|
|
||||||
|
|
||||||
|
|
||||||
/// How requests should be sorted
|
|
||||||
type RequestSort =
|
|
||||||
/// Sort by date, then by requestor/subject
|
|
||||||
| SortByDate
|
|
||||||
/// Sort by requestor/subject, then by date
|
|
||||||
| SortByRequestor
|
|
||||||
|
|
||||||
/// Functions to support request sorts
|
|
||||||
module RequestSort =
|
|
||||||
|
|
||||||
/// Convert to a DU case from a single-character string
|
|
||||||
let fromCode code =
|
|
||||||
match code with
|
|
||||||
| "D" -> SortByDate
|
|
||||||
| "R" -> SortByRequestor
|
|
||||||
| _ -> invalidArg "code" $"Unknown code {code}"
|
|
||||||
|
|
||||||
/// Convert this DU case to a single-character string
|
|
||||||
let toCode = function SortByDate -> "D" | SortByRequestor -> "R"
|
|
||||||
|
|
||||||
|
|
||||||
open System
|
|
||||||
|
|
||||||
/// PK type for the Church entity
|
|
||||||
type ChurchId =
|
|
||||||
| ChurchId of Guid
|
|
||||||
with
|
|
||||||
/// The GUID value of the church ID
|
|
||||||
member this.Value = this |> function ChurchId guid -> guid
|
|
||||||
|
|
||||||
|
|
||||||
/// PK type for the Member entity
|
|
||||||
type MemberId =
|
|
||||||
| MemberId of Guid
|
|
||||||
with
|
|
||||||
/// The GUID value of the member ID
|
|
||||||
member this.Value = this |> function MemberId guid -> guid
|
|
||||||
|
|
||||||
|
|
||||||
/// PK type for the PrayerRequest entity
|
|
||||||
type PrayerRequestId =
|
|
||||||
| PrayerRequestId of Guid
|
|
||||||
with
|
|
||||||
/// The GUID value of the prayer request ID
|
|
||||||
member this.Value = this |> function PrayerRequestId guid -> guid
|
|
||||||
|
|
||||||
|
|
||||||
/// PK type for the SmallGroup entity
|
|
||||||
type SmallGroupId =
|
|
||||||
| SmallGroupId of Guid
|
|
||||||
with
|
|
||||||
/// The GUID value of the small group ID
|
|
||||||
member this.Value = this |> function SmallGroupId guid -> guid
|
|
||||||
|
|
||||||
|
|
||||||
/// PK type for the TimeZone entity
|
|
||||||
type TimeZoneId = TimeZoneId of string
|
|
||||||
|
|
||||||
/// Functions to support time zone IDs
|
|
||||||
module TimeZoneId =
|
|
||||||
|
|
||||||
/// Convert a time zone ID to its string value
|
|
||||||
let toString = function TimeZoneId it -> it
|
|
||||||
|
|
||||||
|
|
||||||
/// PK type for the User entity
|
|
||||||
type UserId =
|
|
||||||
| UserId of Guid
|
|
||||||
with
|
|
||||||
/// The GUID value of the user ID
|
|
||||||
member this.Value = this |> function UserId guid -> guid
|
|
||||||
|
|
||||||
(*-- SPECIFIC VIEW TYPES --*)
|
|
||||||
|
|
||||||
/// Statistics for churches
|
|
||||||
[<NoComparison; NoEquality>]
|
|
||||||
type ChurchStats =
|
|
||||||
{ /// The number of small groups in the church
|
|
||||||
SmallGroups : int
|
|
||||||
|
|
||||||
/// The number of prayer requests in the church
|
|
||||||
PrayerRequests : int
|
|
||||||
|
|
||||||
/// The number of users who can access small groups in the church
|
|
||||||
Users : int
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// Information needed to display the public/protected request list and small group maintenance pages
|
|
||||||
[<NoComparison; NoEquality>]
|
|
||||||
type SmallGroupInfo =
|
|
||||||
{ /// The ID of the small group
|
|
||||||
Id : string
|
|
||||||
|
|
||||||
/// The name of the small group
|
|
||||||
Name : string
|
|
||||||
|
|
||||||
/// The name of the church to which the small group belongs
|
|
||||||
ChurchName : string
|
|
||||||
|
|
||||||
/// The ID of the time zone for the small group
|
|
||||||
TimeZoneId : TimeZoneId
|
|
||||||
|
|
||||||
/// Whether the small group has a publicly-available request list
|
|
||||||
IsPublic : bool
|
|
||||||
}
|
|
||||||
|
|
||||||
(*-- ENTITIES --*)
|
|
||||||
|
|
||||||
open NodaTime
|
|
||||||
|
|
||||||
/// This represents a church
|
|
||||||
[<NoComparison; NoEquality>]
|
|
||||||
type Church =
|
|
||||||
{ /// The ID of this church
|
|
||||||
Id : ChurchId
|
|
||||||
|
|
||||||
/// The name of the church
|
|
||||||
Name : string
|
|
||||||
|
|
||||||
/// The city where the church is
|
|
||||||
City : string
|
|
||||||
|
|
||||||
/// The 2-letter state or province code for the church's location
|
|
||||||
State : string
|
|
||||||
|
|
||||||
/// Does this church have an active interface with Virtual Prayer Space?
|
|
||||||
HasVpsInterface : bool
|
|
||||||
|
|
||||||
/// The address for the interface
|
|
||||||
InterfaceAddress : string option
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Functions to support churches
|
|
||||||
module Church =
|
|
||||||
|
|
||||||
/// An empty church
|
|
||||||
// aww... how sad :(
|
|
||||||
let empty =
|
|
||||||
{ Id = ChurchId Guid.Empty
|
|
||||||
Name = ""
|
|
||||||
City = ""
|
|
||||||
State = ""
|
|
||||||
HasVpsInterface = false
|
|
||||||
InterfaceAddress = None
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// Preferences for the form and format of the prayer request list
|
|
||||||
[<NoComparison; NoEquality>]
|
|
||||||
type ListPreferences =
|
|
||||||
{ /// The Id of the small group to which these preferences belong
|
|
||||||
SmallGroupId : SmallGroupId
|
|
||||||
|
|
||||||
/// The days after which regular requests expire
|
|
||||||
DaysToExpire : int
|
|
||||||
|
|
||||||
/// The number of days a new or updated request is considered new
|
|
||||||
DaysToKeepNew : int
|
|
||||||
|
|
||||||
/// The number of weeks after which long-term requests are flagged for follow-up
|
|
||||||
LongTermUpdateWeeks : int
|
|
||||||
|
|
||||||
/// The name from which e-mails are sent
|
|
||||||
EmailFromName : string
|
|
||||||
|
|
||||||
/// The e-mail address from which e-mails are sent
|
|
||||||
EmailFromAddress : string
|
|
||||||
|
|
||||||
/// The fonts to use in generating the list of prayer requests
|
|
||||||
Fonts : string
|
|
||||||
|
|
||||||
/// The color for the prayer request list headings
|
|
||||||
HeadingColor : string
|
|
||||||
|
|
||||||
/// The color for the lines offsetting the prayer request list headings
|
|
||||||
LineColor : string
|
|
||||||
|
|
||||||
/// The font size for the headings on the prayer request list
|
|
||||||
HeadingFontSize : int
|
|
||||||
|
|
||||||
/// The font size for the text on the prayer request list
|
|
||||||
TextFontSize : int
|
|
||||||
|
|
||||||
/// The order in which the prayer requests are sorted
|
|
||||||
RequestSort : RequestSort
|
|
||||||
|
|
||||||
/// The password used for "small group login" (view-only request list)
|
|
||||||
GroupPassword : string
|
|
||||||
|
|
||||||
/// The default e-mail type for this class
|
|
||||||
DefaultEmailType : EmailFormat
|
|
||||||
|
|
||||||
/// Whether this class makes its request list public
|
|
||||||
IsPublic : bool
|
|
||||||
|
|
||||||
/// The time zone which this class uses (use tzdata names)
|
|
||||||
TimeZoneId : TimeZoneId
|
|
||||||
|
|
||||||
/// The number of requests displayed per page
|
|
||||||
PageSize : int
|
|
||||||
|
|
||||||
/// How the as-of date should be automatically displayed
|
|
||||||
AsOfDateDisplay : AsOfDateDisplay
|
|
||||||
}
|
|
||||||
with
|
|
||||||
|
|
||||||
/// The list of fonts to use when displaying request lists (converts "native" to native font stack)
|
|
||||||
member this.FontStack =
|
|
||||||
if this.Fonts = "native" then
|
|
||||||
"""system-ui,-apple-system,"Segoe UI",Roboto,Ubuntu,"Liberation Sans",Cantarell,"Helvetica Neue",sans-serif"""
|
|
||||||
else this.Fonts
|
|
||||||
|
|
||||||
/// Functions to support list preferences
|
|
||||||
module ListPreferences =
|
|
||||||
|
|
||||||
/// A set of preferences with their default values
|
|
||||||
let empty =
|
|
||||||
{ SmallGroupId = SmallGroupId Guid.Empty
|
|
||||||
DaysToExpire = 14
|
|
||||||
DaysToKeepNew = 7
|
|
||||||
LongTermUpdateWeeks = 4
|
|
||||||
EmailFromName = "PrayerTracker"
|
|
||||||
EmailFromAddress = "prayer@bitbadger.solutions"
|
|
||||||
Fonts = "native"
|
|
||||||
HeadingColor = "maroon"
|
|
||||||
LineColor = "navy"
|
|
||||||
HeadingFontSize = 16
|
|
||||||
TextFontSize = 12
|
|
||||||
RequestSort = SortByDate
|
|
||||||
GroupPassword = ""
|
|
||||||
DefaultEmailType = HtmlFormat
|
|
||||||
IsPublic = false
|
|
||||||
TimeZoneId = TimeZoneId "America/Denver"
|
|
||||||
PageSize = 100
|
|
||||||
AsOfDateDisplay = NoDisplay
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// A member of a small group
|
|
||||||
[<NoComparison; NoEquality>]
|
|
||||||
type Member =
|
|
||||||
{ /// The ID of the small group member
|
|
||||||
Id : MemberId
|
|
||||||
|
|
||||||
/// The Id of the small group to which this member belongs
|
|
||||||
SmallGroupId : SmallGroupId
|
|
||||||
|
|
||||||
/// The name of the member
|
|
||||||
Name : string
|
|
||||||
|
|
||||||
/// The e-mail address for the member
|
|
||||||
Email : string
|
|
||||||
|
|
||||||
/// The type of e-mail preferred by this member
|
|
||||||
Format : EmailFormat option
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Functions to support small group members
|
|
||||||
module Member =
|
|
||||||
|
|
||||||
/// An empty member
|
|
||||||
let empty =
|
|
||||||
{ Id = MemberId Guid.Empty
|
|
||||||
SmallGroupId = SmallGroupId Guid.Empty
|
|
||||||
Name = ""
|
|
||||||
Email = ""
|
|
||||||
Format = None
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// This represents a single prayer request
|
|
||||||
[<NoComparison; NoEquality>]
|
|
||||||
type PrayerRequest =
|
|
||||||
{ /// The ID of this request
|
|
||||||
Id : PrayerRequestId
|
|
||||||
|
|
||||||
/// The type of the request
|
|
||||||
RequestType : PrayerRequestType
|
|
||||||
|
|
||||||
/// The ID of the user who entered the request
|
|
||||||
UserId : UserId
|
|
||||||
|
|
||||||
/// The small group to which this request belongs
|
|
||||||
SmallGroupId : SmallGroupId
|
|
||||||
|
|
||||||
/// The date/time on which this request was entered
|
|
||||||
EnteredDate : Instant
|
|
||||||
|
|
||||||
/// The date/time this request was last updated
|
|
||||||
UpdatedDate : Instant
|
|
||||||
|
|
||||||
/// The name of the requestor or subject, or title of announcement
|
|
||||||
Requestor : string option
|
|
||||||
|
|
||||||
/// The text of the request
|
|
||||||
Text : string
|
|
||||||
|
|
||||||
/// Whether the chaplain should be notified for this request
|
|
||||||
NotifyChaplain : bool
|
|
||||||
|
|
||||||
/// Is this request expired?
|
|
||||||
Expiration : Expiration
|
|
||||||
}
|
|
||||||
// functions are below small group functions
|
|
||||||
|
|
||||||
|
|
||||||
/// This represents a small group (Sunday School class, Bible study group, etc.)
|
|
||||||
[<NoComparison; NoEquality>]
|
|
||||||
type SmallGroup =
|
|
||||||
{ /// The ID of this small group
|
|
||||||
Id : SmallGroupId
|
|
||||||
|
|
||||||
/// The church to which this group belongs
|
|
||||||
ChurchId : ChurchId
|
|
||||||
|
|
||||||
/// The name of the group
|
|
||||||
Name : string
|
|
||||||
|
|
||||||
/// The preferences for the request list
|
|
||||||
Preferences : ListPreferences
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Functions to support small groups
|
|
||||||
module SmallGroup =
|
|
||||||
|
|
||||||
/// An empty small group
|
|
||||||
let empty =
|
|
||||||
{ Id = SmallGroupId Guid.Empty
|
|
||||||
ChurchId = ChurchId Guid.Empty
|
|
||||||
Name = ""
|
|
||||||
Preferences = ListPreferences.empty
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The DateTimeZone for the time zone ID for this small group
|
|
||||||
let timeZone group =
|
|
||||||
let tzId = TimeZoneId.toString group.Preferences.TimeZoneId
|
|
||||||
if DateTimeZoneProviders.Tzdb.Ids.Contains tzId then DateTimeZoneProviders.Tzdb[tzId]
|
|
||||||
else DateTimeZone.Utc
|
|
||||||
|
|
||||||
/// Get the local date/time for this group
|
|
||||||
let localTimeNow (clock : IClock) group =
|
|
||||||
if isNull clock then nullArg (nameof clock)
|
|
||||||
clock.GetCurrentInstant().InZone(timeZone group).LocalDateTime
|
|
||||||
|
|
||||||
/// Get the local date for this group
|
|
||||||
let localDateNow clock group =
|
|
||||||
(localTimeNow clock group).Date
|
|
||||||
|
|
||||||
|
|
||||||
/// Functions to support prayer requests
|
|
||||||
module PrayerRequest =
|
|
||||||
|
|
||||||
/// An empty request
|
|
||||||
let empty =
|
|
||||||
{ Id = PrayerRequestId Guid.Empty
|
|
||||||
RequestType = CurrentRequest
|
|
||||||
UserId = UserId Guid.Empty
|
|
||||||
SmallGroupId = SmallGroupId Guid.Empty
|
|
||||||
EnteredDate = Instant.MinValue
|
|
||||||
UpdatedDate = Instant.MinValue
|
|
||||||
Requestor = None
|
|
||||||
Text = ""
|
|
||||||
NotifyChaplain = false
|
|
||||||
Expiration = Automatic
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Is this request expired?
|
|
||||||
let isExpired (asOf : LocalDate) group req =
|
|
||||||
match req.Expiration, req.RequestType with
|
|
||||||
| Forced, _ -> true
|
|
||||||
| Manual, _
|
|
||||||
| Automatic, LongTermRequest
|
|
||||||
| Automatic, Expecting -> false
|
|
||||||
| Automatic, _ ->
|
|
||||||
// Automatic expiration
|
|
||||||
Period.Between(req.UpdatedDate.InZone(SmallGroup.timeZone group).Date, asOf, PeriodUnits.Days).Days
|
|
||||||
>= group.Preferences.DaysToExpire
|
|
||||||
|
|
||||||
/// Is an update required for this long-term request?
|
|
||||||
let updateRequired asOf group req =
|
|
||||||
if isExpired asOf group req then false
|
|
||||||
else asOf.PlusWeeks -group.Preferences.LongTermUpdateWeeks
|
|
||||||
>= req.UpdatedDate.InZone(SmallGroup.timeZone group).Date
|
|
||||||
|
|
||||||
|
|
||||||
/// This represents a user of PrayerTracker
|
|
||||||
[<NoComparison; NoEquality>]
|
|
||||||
type User =
|
|
||||||
{ /// The ID of this user
|
|
||||||
Id : UserId
|
|
||||||
|
|
||||||
/// The first name of this user
|
|
||||||
FirstName : string
|
|
||||||
|
|
||||||
/// The last name of this user
|
|
||||||
LastName : string
|
|
||||||
|
|
||||||
/// The e-mail address of the user
|
|
||||||
Email : string
|
|
||||||
|
|
||||||
/// Whether this user is a PrayerTracker system administrator
|
|
||||||
IsAdmin : bool
|
|
||||||
|
|
||||||
/// The user's hashed password
|
|
||||||
PasswordHash : string
|
|
||||||
|
|
||||||
/// The last time the user was seen (set whenever the user is loaded into a session)
|
|
||||||
LastSeen : Instant option
|
|
||||||
}
|
|
||||||
with
|
|
||||||
/// The full name of the user
|
|
||||||
member this.Name =
|
|
||||||
$"{this.FirstName} {this.LastName}"
|
|
||||||
|
|
||||||
/// Functions to support users
|
|
||||||
module User =
|
|
||||||
|
|
||||||
/// An empty user
|
|
||||||
let empty =
|
|
||||||
{ Id = UserId Guid.Empty
|
|
||||||
FirstName = ""
|
|
||||||
LastName = ""
|
|
||||||
Email = ""
|
|
||||||
IsAdmin = false
|
|
||||||
PasswordHash = ""
|
|
||||||
LastSeen = None
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// Cross-reference between user and small group
|
|
||||||
[<NoComparison; NoEquality>]
|
|
||||||
type UserSmallGroup =
|
|
||||||
{ /// The Id of the user who has access to the small group
|
|
||||||
UserId : UserId
|
|
||||||
|
|
||||||
/// The Id of the small group to which the user has access
|
|
||||||
SmallGroupId : SmallGroupId
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Functions to support user/small group cross-reference
|
|
||||||
module UserSmallGroup =
|
|
||||||
|
|
||||||
/// An empty user/small group xref
|
|
||||||
let empty =
|
|
||||||
{ UserId = UserId Guid.Empty
|
|
||||||
SmallGroupId = SmallGroupId Guid.Empty
|
|
||||||
}
|
|
@ -1,18 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Compile Include="Entities.fs" />
|
|
||||||
<Compile Include="Access.fs" />
|
|
||||||
<Compile Include="DistributedCache.fs" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="BitBadger.Documents.Postgres" Version="3.1.0" />
|
|
||||||
<PackageReference Include="Giraffe" Version="6.4.0" />
|
|
||||||
<PackageReference Include="NodaTime" Version="3.1.11" />
|
|
||||||
<PackageReference Include="Npgsql.FSharp" Version="5.7.0" />
|
|
||||||
<PackageReference Include="Npgsql.NodaTime" Version="8.0.3" />
|
|
||||||
<PackageReference Update="FSharp.Core" Version="8.0.300" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
21
src/PrayerTracker.MigrateV9/PrayerTracker.MigrateV9.fsproj
Normal file
21
src/PrayerTracker.MigrateV9/PrayerTracker.MigrateV9.fsproj
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Include="Program.fs" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Data\PrayerTracker.Data.fsproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="BitBadger.Documents.Postgres" Version="4.0.1" />
|
||||||
|
<PackageReference Include="Npgsql.NodaTime" Version="9.0.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
133
src/PrayerTracker.MigrateV9/Program.fs
Normal file
133
src/PrayerTracker.MigrateV9/Program.fs
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
|
||||||
|
open NodaTime
|
||||||
|
open PrayerTracker.Entities
|
||||||
|
|
||||||
|
module PgMappings =
|
||||||
|
/// Map a row to a Church instance
|
||||||
|
let mapToChurch (row: RowReader) =
|
||||||
|
{ Id = ChurchId (row.uuid "id")
|
||||||
|
Name = row.string "church_name"
|
||||||
|
City = row.string "city"
|
||||||
|
State = row.string "state"
|
||||||
|
HasVpsInterface = row.bool "has_vps_interface"
|
||||||
|
InterfaceAddress = row.stringOrNone "interface_address" }
|
||||||
|
|
||||||
|
/// Map a row to a Member instance
|
||||||
|
let mapToMember (row: RowReader) =
|
||||||
|
{ Id = MemberId (row.uuid "id")
|
||||||
|
SmallGroupId = SmallGroupId (row.uuid "small_group_id")
|
||||||
|
Name = row.string "member_name"
|
||||||
|
Email = row.string "email"
|
||||||
|
Format = row.stringOrNone "email_format" |> Option.map EmailFormat.Parse }
|
||||||
|
|
||||||
|
/// Map a row to a Prayer Request instance
|
||||||
|
let mapToPrayerRequest (row: RowReader) =
|
||||||
|
{ Id = PrayerRequestId (row.uuid "id")
|
||||||
|
UserId = UserId (row.uuid "user_id")
|
||||||
|
SmallGroupId = SmallGroupId (row.uuid "small_group_id")
|
||||||
|
EnteredDate = row.fieldValue<Instant> "entered_date"
|
||||||
|
UpdatedDate = row.fieldValue<Instant> "updated_date"
|
||||||
|
Requestor = row.stringOrNone "requestor"
|
||||||
|
Text = row.string "request_text"
|
||||||
|
NotifyChaplain = row.bool "notify_chaplain"
|
||||||
|
RequestType = PrayerRequestType.Parse (row.string "request_type")
|
||||||
|
Expiration = Expiration.Parse (row.string "expiration") }
|
||||||
|
|
||||||
|
/// Map a row to a Small Group instance
|
||||||
|
let mapToSmallGroup (row: RowReader) =
|
||||||
|
{ Id = SmallGroupId (row.uuid "id")
|
||||||
|
ChurchId = ChurchId (row.uuid "church_id")
|
||||||
|
Name = row.string "group_name"
|
||||||
|
Preferences =
|
||||||
|
{ DaysToKeepNew = row.int "days_to_keep_new"
|
||||||
|
DaysToExpire = row.int "days_to_expire"
|
||||||
|
LongTermUpdateWeeks = row.int "long_term_update_weeks"
|
||||||
|
EmailFromName = row.string "email_from_name"
|
||||||
|
EmailFromAddress = row.string "email_from_address"
|
||||||
|
Fonts = row.string "fonts"
|
||||||
|
HeadingColor = row.string "heading_color"
|
||||||
|
LineColor = row.string "line_color"
|
||||||
|
HeadingFontSize = row.int "heading_font_size"
|
||||||
|
TextFontSize = row.int "text_font_size"
|
||||||
|
GroupPassword = row.string "group_password"
|
||||||
|
IsPublic = row.bool "is_public"
|
||||||
|
PageSize = row.int "page_size"
|
||||||
|
TimeZoneId = TimeZoneId (row.string "time_zone_id")
|
||||||
|
RequestSort = RequestSort.Parse (row.string "request_sort")
|
||||||
|
DefaultEmailType = EmailFormat.Parse (row.string "default_email_type")
|
||||||
|
AsOfDateDisplay = AsOfDateDisplay.Parse (row.string "as_of_date_display") } }
|
||||||
|
|
||||||
|
/// Map a row to a User instance
|
||||||
|
let mapToUser (row: RowReader) =
|
||||||
|
{ Id = UserId (row.uuid "id")
|
||||||
|
FirstName = row.string "first_name"
|
||||||
|
LastName = row.string "last_name"
|
||||||
|
Email = row.string "email"
|
||||||
|
IsAdmin = row.bool "is_admin"
|
||||||
|
PasswordHash = row.string "password_hash"
|
||||||
|
LastSeen = row.fieldValueOrNone<Instant> "last_seen"
|
||||||
|
SmallGroups = [] }
|
||||||
|
|
||||||
|
|
||||||
|
open System
|
||||||
|
open BitBadger.Documents.Sqlite
|
||||||
|
open Npgsql
|
||||||
|
open Npgsql.FSharp
|
||||||
|
open PrayerTracker.Data
|
||||||
|
|
||||||
|
task {
|
||||||
|
|
||||||
|
Configuration.useConnectionString (Environment.GetEnvironmentVariable "PT_SQLITE_CONN")
|
||||||
|
do! Connection.setUp ()
|
||||||
|
|
||||||
|
let builder = NpgsqlDataSourceBuilder(Environment.GetEnvironmentVariable "PT_PG_CONN")
|
||||||
|
let _ = builder.UseNodaTime()
|
||||||
|
use source = builder.Build()
|
||||||
|
|
||||||
|
let! churches =
|
||||||
|
Sql.fromDataSource source
|
||||||
|
|> Sql.query "SELECT * FROM pt.church"
|
||||||
|
|> Sql.executeAsync PgMappings.mapToChurch
|
||||||
|
for church in churches do
|
||||||
|
do! Churches.save church
|
||||||
|
printfn "Migrated %d churches" churches.Length
|
||||||
|
|
||||||
|
let! groups =
|
||||||
|
Sql.fromDataSource source
|
||||||
|
|> Sql.query "SELECT sg.*, lp.* FROM pt.small_group sg
|
||||||
|
INNER JOIN pt.list_preference lp ON lp.small_group_id = sg.id"
|
||||||
|
|> Sql.executeAsync PgMappings.mapToSmallGroup
|
||||||
|
for group in groups do
|
||||||
|
do! SmallGroups.save group
|
||||||
|
printfn "Migrated %d groups" groups.Length
|
||||||
|
|
||||||
|
let! members =
|
||||||
|
Sql.fromDataSource source
|
||||||
|
|> Sql.query "SELECT * from pt.member"
|
||||||
|
|> Sql.executeAsync PgMappings.mapToMember
|
||||||
|
for mbr in members do
|
||||||
|
do! Members.save mbr
|
||||||
|
printfn "Migrated %d members" members.Length
|
||||||
|
|
||||||
|
let! requests =
|
||||||
|
Sql.fromDataSource source
|
||||||
|
|> Sql.query "SELECT * from pt.prayer_request"
|
||||||
|
|> Sql.executeAsync PgMappings.mapToPrayerRequest
|
||||||
|
for request in requests do
|
||||||
|
do! PrayerRequests.save request
|
||||||
|
printfn "Migrated %d requests" requests.Length
|
||||||
|
|
||||||
|
let! users =
|
||||||
|
Sql.fromDataSource source
|
||||||
|
|> Sql.query "SELECT * FROM pt.pt_user"
|
||||||
|
|> Sql.executeAsync PgMappings.mapToUser
|
||||||
|
for user in users do
|
||||||
|
let! groups =
|
||||||
|
Sql.fromDataSource source
|
||||||
|
|> Sql.query "SELECT small_group_id FROM pt.user_small_group WHERE user_id = @user_id"
|
||||||
|
|> Sql.parameters [ "@user_id", Sql.uuid user.Id.Value ]
|
||||||
|
|> Sql.executeAsync (fun row -> (row.uuid >> SmallGroupId) "small_group_id")
|
||||||
|
do! Users.save { user with SmallGroups = groups }
|
||||||
|
printfn "Migrated %d users" users.Length
|
||||||
|
|
||||||
|
} |> Async.AwaitTask |> Async.RunSynchronously
|
@ -1,361 +0,0 @@
|
|||||||
module PrayerTracker.Entities.EntitiesTests
|
|
||||||
|
|
||||||
open Expecto
|
|
||||||
open NodaTime.Testing
|
|
||||||
open NodaTime
|
|
||||||
open System
|
|
||||||
|
|
||||||
[<Tests>]
|
|
||||||
let asOfDateDisplayTests =
|
|
||||||
testList "AsOfDateDisplay" [
|
|
||||||
test "NoDisplay code is correct" {
|
|
||||||
Expect.equal (AsOfDateDisplay.toCode NoDisplay) "N" "The code for NoDisplay should have been \"N\""
|
|
||||||
}
|
|
||||||
test "ShortDate code is correct" {
|
|
||||||
Expect.equal (AsOfDateDisplay.toCode ShortDate) "S" "The code for ShortDate should have been \"S\""
|
|
||||||
}
|
|
||||||
test "LongDate code is correct" {
|
|
||||||
Expect.equal (AsOfDateDisplay.toCode LongDate) "L" "The code for LongDate should have been \"N\""
|
|
||||||
}
|
|
||||||
test "fromCode N should return NoDisplay" {
|
|
||||||
Expect.equal (AsOfDateDisplay.fromCode "N") NoDisplay "\"N\" should have been converted to NoDisplay"
|
|
||||||
}
|
|
||||||
test "fromCode S should return ShortDate" {
|
|
||||||
Expect.equal (AsOfDateDisplay.fromCode "S") ShortDate "\"S\" should have been converted to ShortDate"
|
|
||||||
}
|
|
||||||
test "fromCode L should return LongDate" {
|
|
||||||
Expect.equal (AsOfDateDisplay.fromCode "L") LongDate "\"L\" should have been converted to LongDate"
|
|
||||||
}
|
|
||||||
test "fromCode X should raise" {
|
|
||||||
Expect.throws (fun () -> AsOfDateDisplay.fromCode "X" |> ignore)
|
|
||||||
"An unknown code should have raised an exception"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
[<Tests>]
|
|
||||||
let churchTests =
|
|
||||||
testList "Church" [
|
|
||||||
test "empty is as expected" {
|
|
||||||
let mt = Church.empty
|
|
||||||
Expect.equal mt.Id.Value Guid.Empty "The church ID should have been an empty GUID"
|
|
||||||
Expect.equal mt.Name "" "The name should have been blank"
|
|
||||||
Expect.equal mt.City "" "The city should have been blank"
|
|
||||||
Expect.equal mt.State "" "The state should have been blank"
|
|
||||||
Expect.isFalse mt.HasVpsInterface "The church should not show that it has an interface"
|
|
||||||
Expect.isNone mt.InterfaceAddress "The interface address should not exist"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
[<Tests>]
|
|
||||||
let emailFormatTests =
|
|
||||||
testList "EmailFormat" [
|
|
||||||
test "HtmlFormat code is correct" {
|
|
||||||
Expect.equal (EmailFormat.toCode HtmlFormat) "H" "The code for HtmlFormat should have been \"H\""
|
|
||||||
}
|
|
||||||
test "PlainTextFormat code is correct" {
|
|
||||||
Expect.equal (EmailFormat.toCode PlainTextFormat) "P" "The code for PlainTextFormat should have been \"P\""
|
|
||||||
}
|
|
||||||
test "fromCode H should return HtmlFormat" {
|
|
||||||
Expect.equal (EmailFormat.fromCode "H") HtmlFormat "\"H\" should have been converted to HtmlFormat"
|
|
||||||
}
|
|
||||||
test "fromCode P should return ShortDate" {
|
|
||||||
Expect.equal (EmailFormat.fromCode "P") PlainTextFormat
|
|
||||||
"\"P\" should have been converted to PlainTextFormat"
|
|
||||||
}
|
|
||||||
test "fromCode Z should raise" {
|
|
||||||
Expect.throws (fun () -> EmailFormat.fromCode "Z" |> ignore)
|
|
||||||
"An unknown code should have raised an exception"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
[<Tests>]
|
|
||||||
let expirationTests =
|
|
||||||
testList "Expiration" [
|
|
||||||
test "Automatic code is correct" {
|
|
||||||
Expect.equal (Expiration.toCode Automatic) "A" "The code for Automatic should have been \"A\""
|
|
||||||
}
|
|
||||||
test "Manual code is correct" {
|
|
||||||
Expect.equal (Expiration.toCode Manual) "M" "The code for Manual should have been \"M\""
|
|
||||||
}
|
|
||||||
test "Forced code is correct" {
|
|
||||||
Expect.equal (Expiration.toCode Forced) "F" "The code for Forced should have been \"F\""
|
|
||||||
}
|
|
||||||
test "fromCode A should return Automatic" {
|
|
||||||
Expect.equal (Expiration.fromCode "A") Automatic "\"A\" should have been converted to Automatic"
|
|
||||||
}
|
|
||||||
test "fromCode M should return Manual" {
|
|
||||||
Expect.equal (Expiration.fromCode "M") Manual "\"M\" should have been converted to Manual"
|
|
||||||
}
|
|
||||||
test "fromCode F should return Forced" {
|
|
||||||
Expect.equal (Expiration.fromCode "F") Forced "\"F\" should have been converted to Forced"
|
|
||||||
}
|
|
||||||
test "fromCode V should raise" {
|
|
||||||
Expect.throws (fun () -> Expiration.fromCode "V" |> ignore)
|
|
||||||
"An unknown code should have raised an exception"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
[<Tests>]
|
|
||||||
let listPreferencesTests =
|
|
||||||
testList "ListPreferences" [
|
|
||||||
test "FontStack is correct for native fonts" {
|
|
||||||
Expect.equal ListPreferences.empty.FontStack
|
|
||||||
"""system-ui,-apple-system,"Segoe UI",Roboto,Ubuntu,"Liberation Sans",Cantarell,"Helvetica Neue",sans-serif"""
|
|
||||||
"The expected native font stack was incorrect"
|
|
||||||
}
|
|
||||||
test "FontStack is correct for specific fonts" {
|
|
||||||
Expect.equal { ListPreferences.empty with Fonts = "Arial,sans-serif" }.FontStack "Arial,sans-serif"
|
|
||||||
"The specified fonts were not returned correctly"
|
|
||||||
}
|
|
||||||
test "empty is as expected" {
|
|
||||||
let mt = ListPreferences.empty
|
|
||||||
Expect.equal mt.SmallGroupId.Value Guid.Empty "The small group ID should have been an empty GUID"
|
|
||||||
Expect.equal mt.DaysToExpire 14 "The default days to expire should have been 14"
|
|
||||||
Expect.equal mt.DaysToKeepNew 7 "The default days to keep new should have been 7"
|
|
||||||
Expect.equal mt.LongTermUpdateWeeks 4 "The default long term update weeks should have been 4"
|
|
||||||
Expect.equal mt.EmailFromName "PrayerTracker" "The default e-mail from name should have been PrayerTracker"
|
|
||||||
Expect.equal mt.EmailFromAddress "prayer@bitbadger.solutions"
|
|
||||||
"The default e-mail from address should have been prayer@bitbadger.solutions"
|
|
||||||
Expect.equal mt.Fonts "native" "The default list fonts were incorrect"
|
|
||||||
Expect.equal mt.HeadingColor "maroon" "The default heading text color should have been maroon"
|
|
||||||
Expect.equal mt.LineColor "navy" "The default heading line color should have been navy"
|
|
||||||
Expect.equal mt.HeadingFontSize 16 "The default heading font size should have been 16"
|
|
||||||
Expect.equal mt.TextFontSize 12 "The default text font size should have been 12"
|
|
||||||
Expect.equal mt.RequestSort SortByDate "The default request sort should have been by date"
|
|
||||||
Expect.equal mt.GroupPassword "" "The default group password should have been blank"
|
|
||||||
Expect.equal mt.DefaultEmailType HtmlFormat "The default e-mail type should have been HTML"
|
|
||||||
Expect.isFalse mt.IsPublic "The isPublic flag should not have been set"
|
|
||||||
Expect.equal (TimeZoneId.toString mt.TimeZoneId) "America/Denver"
|
|
||||||
"The default time zone should have been America/Denver"
|
|
||||||
Expect.equal mt.PageSize 100 "The default page size should have been 100"
|
|
||||||
Expect.equal mt.AsOfDateDisplay NoDisplay "The as-of date display should have been No Display"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
[<Tests>]
|
|
||||||
let memberTests =
|
|
||||||
testList "Member" [
|
|
||||||
test "empty is as expected" {
|
|
||||||
let mt = Member.empty
|
|
||||||
Expect.equal mt.Id.Value Guid.Empty "The member ID should have been an empty GUID"
|
|
||||||
Expect.equal mt.SmallGroupId.Value Guid.Empty "The small group ID should have been an empty GUID"
|
|
||||||
Expect.equal mt.Name "" "The member name should have been blank"
|
|
||||||
Expect.equal mt.Email "" "The member e-mail address should have been blank"
|
|
||||||
Expect.isNone mt.Format "The preferred e-mail format should not exist"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
[<Tests>]
|
|
||||||
let prayerRequestTests =
|
|
||||||
let instantNow = SystemClock.Instance.GetCurrentInstant
|
|
||||||
let localDateNow () = (instantNow ()).InUtc().Date
|
|
||||||
testList "PrayerRequest" [
|
|
||||||
test "empty is as expected" {
|
|
||||||
let mt = PrayerRequest.empty
|
|
||||||
Expect.equal mt.Id.Value Guid.Empty "The request ID should have been an empty GUID"
|
|
||||||
Expect.equal mt.RequestType CurrentRequest "The request type should have been Current"
|
|
||||||
Expect.equal mt.UserId.Value Guid.Empty "The user ID should have been an empty GUID"
|
|
||||||
Expect.equal mt.SmallGroupId.Value Guid.Empty "The small group ID should have been an empty GUID"
|
|
||||||
Expect.equal mt.EnteredDate Instant.MinValue "The entered date should have been the minimum"
|
|
||||||
Expect.equal mt.UpdatedDate Instant.MinValue "The updated date should have been the minimum"
|
|
||||||
Expect.isNone mt.Requestor "The requestor should not exist"
|
|
||||||
Expect.equal mt.Text "" "The request text should have been blank"
|
|
||||||
Expect.isFalse mt.NotifyChaplain "The notify chaplain flag should not have been set"
|
|
||||||
Expect.equal mt.Expiration Automatic "The expiration should have been Automatic"
|
|
||||||
}
|
|
||||||
test "isExpired always returns false for expecting requests" {
|
|
||||||
PrayerRequest.isExpired (localDateNow ()) SmallGroup.empty
|
|
||||||
{ PrayerRequest.empty with RequestType = Expecting }
|
|
||||||
|> Flip.Expect.isFalse "An expecting request should never be considered expired"
|
|
||||||
}
|
|
||||||
test "isExpired always returns false for manually-expired requests" {
|
|
||||||
PrayerRequest.isExpired (localDateNow ()) SmallGroup.empty
|
|
||||||
{ PrayerRequest.empty with UpdatedDate = (instantNow ()) - Duration.FromDays 1; Expiration = Manual }
|
|
||||||
|> Flip.Expect.isFalse "A never-expired request should never be considered expired"
|
|
||||||
}
|
|
||||||
test "isExpired always returns false for long term/recurring requests" {
|
|
||||||
PrayerRequest.isExpired (localDateNow ()) SmallGroup.empty
|
|
||||||
{ PrayerRequest.empty with RequestType = LongTermRequest }
|
|
||||||
|> Flip.Expect.isFalse "A recurring/long-term request should never be considered expired"
|
|
||||||
}
|
|
||||||
test "isExpired always returns true for force-expired requests" {
|
|
||||||
PrayerRequest.isExpired (localDateNow ()) SmallGroup.empty
|
|
||||||
{ PrayerRequest.empty with UpdatedDate = (instantNow ()); Expiration = Forced }
|
|
||||||
|> Flip.Expect.isTrue "A force-expired request should always be considered expired"
|
|
||||||
}
|
|
||||||
test "isExpired returns false for non-expired requests" {
|
|
||||||
let now = instantNow ()
|
|
||||||
PrayerRequest.isExpired (now.InUtc().Date) SmallGroup.empty
|
|
||||||
{ PrayerRequest.empty with UpdatedDate = now - Duration.FromDays 5 }
|
|
||||||
|> Flip.Expect.isFalse "A request updated 5 days ago should not be considered expired"
|
|
||||||
}
|
|
||||||
test "isExpired returns true for expired requests" {
|
|
||||||
let now = instantNow ()
|
|
||||||
PrayerRequest.isExpired (now.InUtc().Date) SmallGroup.empty
|
|
||||||
{ PrayerRequest.empty with UpdatedDate = now - Duration.FromDays 15 }
|
|
||||||
|> Flip.Expect.isTrue "A request updated 15 days ago should be considered expired"
|
|
||||||
}
|
|
||||||
test "isExpired returns true for same-day expired requests" {
|
|
||||||
let now = instantNow ()
|
|
||||||
PrayerRequest.isExpired (now.InUtc().Date) SmallGroup.empty
|
|
||||||
{ PrayerRequest.empty with UpdatedDate = now - (Duration.FromDays 14) - (Duration.FromSeconds 1L) }
|
|
||||||
|> Flip.Expect.isTrue "A request entered a second before midnight should be considered expired"
|
|
||||||
}
|
|
||||||
test "updateRequired returns false for expired requests" {
|
|
||||||
PrayerRequest.updateRequired (localDateNow ()) SmallGroup.empty
|
|
||||||
{ PrayerRequest.empty with Expiration = Forced }
|
|
||||||
|> Flip.Expect.isFalse "An expired request should not require an update"
|
|
||||||
}
|
|
||||||
test "updateRequired returns false when an update is not required for an active request" {
|
|
||||||
let now = instantNow ()
|
|
||||||
PrayerRequest.updateRequired (localDateNow ()) SmallGroup.empty
|
|
||||||
{ PrayerRequest.empty with RequestType = LongTermRequest; UpdatedDate = now - Duration.FromDays 14 }
|
|
||||||
|> Flip.Expect.isFalse "An active request updated 14 days ago should not require an update until 28 days"
|
|
||||||
}
|
|
||||||
test "UpdateRequired returns true when an update is required for an active request" {
|
|
||||||
let now = instantNow ()
|
|
||||||
PrayerRequest.updateRequired (localDateNow ()) SmallGroup.empty
|
|
||||||
{ PrayerRequest.empty with RequestType = LongTermRequest; UpdatedDate = now - Duration.FromDays 34 }
|
|
||||||
|> Flip.Expect.isTrue "An active request updated 34 days ago should require an update (past 28 days)"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
[<Tests>]
|
|
||||||
let prayerRequestTypeTests =
|
|
||||||
testList "PrayerRequestType" [
|
|
||||||
test "CurrentRequest code is correct" {
|
|
||||||
Expect.equal (PrayerRequestType.toCode CurrentRequest) "C"
|
|
||||||
"The code for CurrentRequest should have been \"C\""
|
|
||||||
}
|
|
||||||
test "LongTermRequest code is correct" {
|
|
||||||
Expect.equal (PrayerRequestType.toCode LongTermRequest) "L"
|
|
||||||
"The code for LongTermRequest should have been \"L\""
|
|
||||||
}
|
|
||||||
test "PraiseReport code is correct" {
|
|
||||||
Expect.equal (PrayerRequestType.toCode PraiseReport) "P" "The code for PraiseReport should have been \"P\""
|
|
||||||
}
|
|
||||||
test "Expecting code is correct" {
|
|
||||||
Expect.equal (PrayerRequestType.toCode Expecting) "E" "The code for Expecting should have been \"E\""
|
|
||||||
}
|
|
||||||
test "Announcement code is correct" {
|
|
||||||
Expect.equal (PrayerRequestType.toCode Announcement) "A" "The code for Announcement should have been \"A\""
|
|
||||||
}
|
|
||||||
test "fromCode C should return CurrentRequest" {
|
|
||||||
Expect.equal (PrayerRequestType.fromCode "C") CurrentRequest
|
|
||||||
"\"C\" should have been converted to CurrentRequest"
|
|
||||||
}
|
|
||||||
test "fromCode L should return LongTermRequest" {
|
|
||||||
Expect.equal (PrayerRequestType.fromCode "L") LongTermRequest
|
|
||||||
"\"L\" should have been converted to LongTermRequest"
|
|
||||||
}
|
|
||||||
test "fromCode P should return PraiseReport" {
|
|
||||||
Expect.equal (PrayerRequestType.fromCode "P") PraiseReport
|
|
||||||
"\"P\" should have been converted to PraiseReport"
|
|
||||||
}
|
|
||||||
test "fromCode E should return Expecting" {
|
|
||||||
Expect.equal (PrayerRequestType.fromCode "E") Expecting "\"E\" should have been converted to Expecting"
|
|
||||||
}
|
|
||||||
test "fromCode A should return Announcement" {
|
|
||||||
Expect.equal (PrayerRequestType.fromCode "A") Announcement
|
|
||||||
"\"A\" should have been converted to Announcement"
|
|
||||||
}
|
|
||||||
test "fromCode R should raise" {
|
|
||||||
Expect.throws (fun () -> PrayerRequestType.fromCode "R" |> ignore)
|
|
||||||
"An unknown code should have raised an exception"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
[<Tests>]
|
|
||||||
let requestSortTests =
|
|
||||||
testList "RequestSort" [
|
|
||||||
test "SortByDate code is correct" {
|
|
||||||
Expect.equal (RequestSort.toCode SortByDate) "D" "The code for SortByDate should have been \"D\""
|
|
||||||
}
|
|
||||||
test "SortByRequestor code is correct" {
|
|
||||||
Expect.equal (RequestSort.toCode SortByRequestor) "R" "The code for SortByRequestor should have been \"R\""
|
|
||||||
}
|
|
||||||
test "fromCode D should return SortByDate" {
|
|
||||||
Expect.equal (RequestSort.fromCode "D") SortByDate "\"D\" should have been converted to SortByDate"
|
|
||||||
}
|
|
||||||
test "fromCode R should return SortByRequestor" {
|
|
||||||
Expect.equal (RequestSort.fromCode "R") SortByRequestor
|
|
||||||
"\"R\" should have been converted to SortByRequestor"
|
|
||||||
}
|
|
||||||
test "fromCode Q should raise" {
|
|
||||||
Expect.throws (fun () -> RequestSort.fromCode "Q" |> ignore)
|
|
||||||
"An unknown code should have raised an exception"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
[<Tests>]
|
|
||||||
let smallGroupTests =
|
|
||||||
testList "SmallGroup" [
|
|
||||||
let now = Instant.FromDateTimeUtc (DateTime (2017, 5, 12, 12, 15, 0, DateTimeKind.Utc))
|
|
||||||
let withFakeClock f () =
|
|
||||||
FakeClock now |> f
|
|
||||||
yield test "empty is as expected" {
|
|
||||||
let mt = SmallGroup.empty
|
|
||||||
Expect.equal mt.Id.Value Guid.Empty "The small group ID should have been an empty GUID"
|
|
||||||
Expect.equal mt.ChurchId.Value Guid.Empty "The church ID should have been an empty GUID"
|
|
||||||
Expect.equal mt.Name "" "The name should have been blank"
|
|
||||||
}
|
|
||||||
yield! testFixture withFakeClock [
|
|
||||||
"LocalTimeNow adjusts the time ahead of UTC",
|
|
||||||
fun clock ->
|
|
||||||
let grp =
|
|
||||||
{ SmallGroup.empty with
|
|
||||||
Preferences = { ListPreferences.empty with TimeZoneId = TimeZoneId "Europe/Berlin" }
|
|
||||||
}
|
|
||||||
Expect.isGreaterThan (SmallGroup.localTimeNow clock grp) (now.InUtc().LocalDateTime)
|
|
||||||
"UTC to Europe/Berlin should have added hours"
|
|
||||||
"LocalTimeNow adjusts the time behind UTC",
|
|
||||||
fun clock ->
|
|
||||||
Expect.isLessThan (SmallGroup.localTimeNow clock SmallGroup.empty) (now.InUtc().LocalDateTime)
|
|
||||||
"UTC to America/Denver should have subtracted hours"
|
|
||||||
"LocalTimeNow returns UTC when the time zone is invalid",
|
|
||||||
fun clock ->
|
|
||||||
let grp =
|
|
||||||
{ SmallGroup.empty with
|
|
||||||
Preferences = { ListPreferences.empty with TimeZoneId = TimeZoneId "garbage" }
|
|
||||||
}
|
|
||||||
Expect.equal (SmallGroup.localTimeNow clock grp) (now.InUtc().LocalDateTime)
|
|
||||||
"UTC should have been returned for an invalid time zone"
|
|
||||||
]
|
|
||||||
yield test "localTimeNow fails when clock is not passed" {
|
|
||||||
Expect.throws (fun () -> (SmallGroup.localTimeNow null SmallGroup.empty |> ignore))
|
|
||||||
"Should have raised an exception for null clock"
|
|
||||||
}
|
|
||||||
yield test "LocalDateNow returns the date portion" {
|
|
||||||
let clock = FakeClock (Instant.FromDateTimeUtc (DateTime (2017, 5, 12, 1, 15, 0, DateTimeKind.Utc)))
|
|
||||||
Expect.isLessThan (SmallGroup.localDateNow clock SmallGroup.empty) (now.InUtc().Date)
|
|
||||||
"The date should have been a day earlier"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
[<Tests>]
|
|
||||||
let userTests =
|
|
||||||
testList "User" [
|
|
||||||
test "empty is as expected" {
|
|
||||||
let mt = User.empty
|
|
||||||
Expect.equal mt.Id.Value Guid.Empty "The user ID should have been an empty GUID"
|
|
||||||
Expect.equal mt.FirstName "" "The first name should have been blank"
|
|
||||||
Expect.equal mt.LastName "" "The last name should have been blank"
|
|
||||||
Expect.equal mt.Email "" "The e-mail address should have been blank"
|
|
||||||
Expect.isFalse mt.IsAdmin "The is admin flag should not have been set"
|
|
||||||
Expect.equal mt.PasswordHash "" "The password hash should have been blank"
|
|
||||||
}
|
|
||||||
test "Name concatenates first and last names" {
|
|
||||||
let user = { User.empty with FirstName = "Unit"; LastName = "Test" }
|
|
||||||
Expect.equal user.Name "Unit Test" "The full name should be the first and last, separated by a space"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
[<Tests>]
|
|
||||||
let userSmallGroupTests =
|
|
||||||
testList "UserSmallGroup" [
|
|
||||||
test "empty is as expected" {
|
|
||||||
let mt = UserSmallGroup.empty
|
|
||||||
Expect.equal mt.UserId.Value Guid.Empty "The user ID should have been an empty GUID"
|
|
||||||
Expect.equal mt.SmallGroupId.Value Guid.Empty "The small group ID should have been an empty GUID"
|
|
||||||
}
|
|
||||||
]
|
|
@ -5,17 +5,19 @@ VisualStudioVersion = 17.2.32630.192
|
|||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "PrayerTracker", "PrayerTracker\PrayerTracker.fsproj", "{63780D3F-D811-4BFB-9FB0-C28A83CCE28F}"
|
Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "PrayerTracker", "PrayerTracker\PrayerTracker.fsproj", "{63780D3F-D811-4BFB-9FB0-C28A83CCE28F}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "PrayerTracker.UI", "PrayerTracker.UI\PrayerTracker.UI.fsproj", "{EEE04A2B-818C-4241-90C5-69097CB0BF71}"
|
Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "PrayerTracker.UI", "UI\PrayerTracker.UI.fsproj", "{EEE04A2B-818C-4241-90C5-69097CB0BF71}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "PrayerTracker.Tests", "PrayerTracker.Tests\PrayerTracker.Tests.fsproj", "{786E7BE9-9370-4117-B194-02CC2F71AA09}"
|
Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "PrayerTracker.Tests", "Tests\PrayerTracker.Tests.fsproj", "{786E7BE9-9370-4117-B194-02CC2F71AA09}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "PrayerTracker.Data", "PrayerTracker.Data\PrayerTracker.Data.fsproj", "{2B5BA107-9BDA-4A1D-A9AF-AFEE6BF12270}"
|
Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "PrayerTracker.Data", "Data\PrayerTracker.Data.fsproj", "{2B5BA107-9BDA-4A1D-A9AF-AFEE6BF12270}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{B290BA27-C8B8-44F3-BF01-D103302D815F}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{B290BA27-C8B8-44F3-BF01-D103302D815F}"
|
||||||
ProjectSection(SolutionItems) = preProject
|
ProjectSection(SolutionItems) = preProject
|
||||||
Directory.Build.props = Directory.Build.props
|
Directory.Build.props = Directory.Build.props
|
||||||
EndProjectSection
|
EndProjectSection
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "PrayerTracker.MigrateV9", "PrayerTracker.MigrateV9\PrayerTracker.MigrateV9.fsproj", "{CE7C5972-AC9A-44A8-8265-771483FD87DB}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@ -38,6 +40,10 @@ Global
|
|||||||
{2B5BA107-9BDA-4A1D-A9AF-AFEE6BF12270}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{2B5BA107-9BDA-4A1D-A9AF-AFEE6BF12270}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{2B5BA107-9BDA-4A1D-A9AF-AFEE6BF12270}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{2B5BA107-9BDA-4A1D-A9AF-AFEE6BF12270}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{2B5BA107-9BDA-4A1D-A9AF-AFEE6BF12270}.Release|Any CPU.Build.0 = Release|Any CPU
|
{2B5BA107-9BDA-4A1D-A9AF-AFEE6BF12270}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{CE7C5972-AC9A-44A8-8265-771483FD87DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{CE7C5972-AC9A-44A8-8265-771483FD87DB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{CE7C5972-AC9A-44A8-8265-771483FD87DB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{CE7C5972-AC9A-44A8-8265-771483FD87DB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
@ -3,12 +3,13 @@ namespace PrayerTracker
|
|||||||
open Microsoft.AspNetCore.Http
|
open Microsoft.AspNetCore.Http
|
||||||
|
|
||||||
/// Middleware to add the starting ticks for the request
|
/// Middleware to add the starting ticks for the request
|
||||||
type RequestStartMiddleware (next: RequestDelegate) =
|
type RequestStartMiddleware(next: RequestDelegate) =
|
||||||
|
|
||||||
member this.InvokeAsync (ctx: HttpContext) = task {
|
member this.InvokeAsync(ctx: HttpContext) =
|
||||||
ctx.Items[Key.startTime] <- ctx.Now
|
task {
|
||||||
return! next.Invoke ctx
|
ctx.Items[Key.startTime] <- ctx.Now
|
||||||
}
|
return! next.Invoke ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
open System
|
open System
|
||||||
@ -19,155 +20,179 @@ open Microsoft.Extensions.Configuration
|
|||||||
/// Module to hold configuration for the web app
|
/// Module to hold configuration for the web app
|
||||||
[<RequireQualifiedAccess>]
|
[<RequireQualifiedAccess>]
|
||||||
module Configure =
|
module Configure =
|
||||||
|
|
||||||
/// Set up the configuration for the app
|
/// Set up the configuration for the app
|
||||||
let configuration (ctx: WebHostBuilderContext) (cfg: IConfigurationBuilder) =
|
let configuration (ctx: WebHostBuilderContext) (cfg: IConfigurationBuilder) =
|
||||||
cfg.SetBasePath(ctx.HostingEnvironment.ContentRootPath)
|
cfg
|
||||||
|
.SetBasePath(ctx.HostingEnvironment.ContentRootPath)
|
||||||
.AddJsonFile("appsettings.json", optional = true, reloadOnChange = true)
|
.AddJsonFile("appsettings.json", optional = true, reloadOnChange = true)
|
||||||
.AddJsonFile($"appsettings.{ctx.HostingEnvironment.EnvironmentName}.json", optional = true)
|
.AddJsonFile($"appsettings.{ctx.HostingEnvironment.EnvironmentName}.json", optional = true)
|
||||||
.AddEnvironmentVariables()
|
.AddEnvironmentVariables()
|
||||||
|> ignore
|
|> ignore
|
||||||
|
|
||||||
open Microsoft.AspNetCore.Server.Kestrel.Core
|
open Microsoft.AspNetCore.Server.Kestrel.Core
|
||||||
|
|
||||||
/// Configure Kestrel from appsettings.json
|
/// Configure Kestrel from appsettings.json
|
||||||
let kestrel (ctx: WebHostBuilderContext) (opts: KestrelServerOptions) =
|
let kestrel (ctx: WebHostBuilderContext) (opts: KestrelServerOptions) =
|
||||||
(ctx.Configuration.GetSection >> opts.Configure >> ignore) "Kestrel"
|
(ctx.Configuration.GetSection >> opts.Configure >> ignore) "Kestrel"
|
||||||
|
|
||||||
open System.Globalization
|
open System.Globalization
|
||||||
open BitBadger.Documents.Postgres
|
open BitBadger.Documents.Sqlite
|
||||||
open Microsoft.AspNetCore.Authentication.Cookies
|
open Microsoft.AspNetCore.Authentication.Cookies
|
||||||
open Microsoft.AspNetCore.Localization
|
open Microsoft.AspNetCore.Localization
|
||||||
open Microsoft.Extensions.Caching.Distributed
|
|
||||||
open Microsoft.Extensions.DependencyInjection
|
open Microsoft.Extensions.DependencyInjection
|
||||||
|
open NeoSmart.Caching.Sqlite
|
||||||
open NodaTime
|
open NodaTime
|
||||||
open Npgsql
|
|
||||||
open PrayerTracker.Data
|
open PrayerTracker.Data
|
||||||
|
|
||||||
/// Configure ASP.NET Core's service collection (dependency injection container)
|
/// Configure ASP.NET Core's service collection (dependency injection container)
|
||||||
let services (svc : IServiceCollection) =
|
let services (svc: IServiceCollection) =
|
||||||
let _ = svc.AddOptions()
|
let _ = svc.AddOptions()
|
||||||
let _ = svc.AddLocalization(fun options -> options.ResourcesPath <- "Resources")
|
let _ = svc.AddLocalization(fun options -> options.ResourcesPath <- "Resources")
|
||||||
|
|
||||||
let _ =
|
let _ =
|
||||||
svc.Configure<RequestLocalizationOptions>(fun (opts: RequestLocalizationOptions) ->
|
svc.Configure<RequestLocalizationOptions>(fun (opts: RequestLocalizationOptions) ->
|
||||||
let supportedCultures = [|
|
let supportedCultures =
|
||||||
CultureInfo "en-US"; CultureInfo "en-GB"; CultureInfo "en-AU"; CultureInfo "en"
|
[| CultureInfo "en-US"
|
||||||
CultureInfo "es-MX"; CultureInfo "es-ES"; CultureInfo "es" |]
|
CultureInfo "en-GB"
|
||||||
|
CultureInfo "en-AU"
|
||||||
|
CultureInfo "en"
|
||||||
|
CultureInfo "es-MX"
|
||||||
|
CultureInfo "es-ES"
|
||||||
|
CultureInfo "es" |]
|
||||||
|
|
||||||
opts.DefaultRequestCulture <- RequestCulture("en-US", "en-US")
|
opts.DefaultRequestCulture <- RequestCulture("en-US", "en-US")
|
||||||
opts.SupportedCultures <- supportedCultures
|
opts.SupportedCultures <- supportedCultures
|
||||||
opts.SupportedUICultures <- supportedCultures)
|
opts.SupportedUICultures <- supportedCultures)
|
||||||
|
|
||||||
let _ =
|
let _ =
|
||||||
svc.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
svc
|
||||||
|
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||||
.AddCookie(fun opts ->
|
.AddCookie(fun opts ->
|
||||||
opts.ExpireTimeSpan <- TimeSpan.FromMinutes 120.
|
opts.ExpireTimeSpan <- TimeSpan.FromMinutes 120.
|
||||||
opts.SlidingExpiration <- true
|
opts.SlidingExpiration <- true
|
||||||
opts.AccessDeniedPath <- "/error/403")
|
opts.AccessDeniedPath <- "/error/403")
|
||||||
|
|
||||||
let _ = svc.AddAuthorization()
|
let _ = svc.AddAuthorization()
|
||||||
|
|
||||||
let cfg = svc.BuildServiceProvider().GetService<IConfiguration>()
|
let cfg = svc.BuildServiceProvider().GetService<IConfiguration>()
|
||||||
let dsb = NpgsqlDataSourceBuilder(cfg.GetConnectionString "PrayerTracker")
|
Configuration.useConnectionString (cfg.GetConnectionString "PrayerTracker")
|
||||||
let _ = dsb.UseNodaTime()
|
Connection.setUp () |> Async.AwaitTask |> Async.RunSynchronously
|
||||||
dsb.Build() |> Configuration.useDataSource
|
|
||||||
|
|
||||||
let emailCfg = cfg.GetSection "Email"
|
let emailCfg = cfg.GetSection "Email"
|
||||||
if (emailCfg.GetChildren >> Seq.isEmpty >> not) () then ConfigurationBinder.Bind(emailCfg, Email.smtpOptions)
|
|
||||||
|
|
||||||
let _ = svc.AddSingleton<IDistributedCache, DistributedCache>()
|
if (emailCfg.GetChildren >> Seq.isEmpty >> not) () then
|
||||||
|
ConfigurationBinder.Bind(emailCfg, Email.smtpOptions)
|
||||||
|
|
||||||
|
let cachePath = defaultArg (Option.ofObj (cfg.GetConnectionString "SessionDB")) "./data/session.db"
|
||||||
|
let _ = svc.AddSqliteCache(fun o -> o.CachePath <- cachePath)
|
||||||
let _ = svc.AddSession()
|
let _ = svc.AddSession()
|
||||||
|
let _ = svc.AddLogging()
|
||||||
let _ = svc.AddAntiforgery()
|
let _ = svc.AddAntiforgery()
|
||||||
let _ = svc.AddRouting()
|
let _ = svc.AddRouting()
|
||||||
let _ = svc.AddSingleton<IClock> SystemClock.Instance
|
let _ = svc.AddSingleton<IClock> SystemClock.Instance
|
||||||
|
|
||||||
()
|
()
|
||||||
|
|
||||||
open Giraffe
|
open Giraffe
|
||||||
|
|
||||||
let noWeb : HttpHandler = fun next ctx ->
|
/// <summary>Endpoint to redirect URLs starting with <c>/web</c> to their non-web equivalent</summary>
|
||||||
redirectTo true $"""/{string ctx.Request.RouteValues["path"]}""" next ctx
|
let noWeb: HttpHandler =
|
||||||
|
fun next ctx -> redirectTo true $"""/{string ctx.Request.RouteValues["path"]}""" next ctx
|
||||||
|
|
||||||
open Giraffe.EndpointRouting
|
open Giraffe.EndpointRouting
|
||||||
|
|
||||||
/// Routes for PrayerTracker
|
/// Routes for PrayerTracker
|
||||||
let routes = [
|
let routes =
|
||||||
route "/web/{**path}" noWeb
|
[ route "/web/{**path}" noWeb
|
||||||
GET_HEAD [
|
GET_HEAD
|
||||||
subRoute "/church" [
|
[ subRoute "/church" [ route "es" Handlers.Church.maintain; routef "/%O/edit" Handlers.Church.edit ]
|
||||||
route "es" Handlers.Church.maintain
|
route "/class/logon" (redirectTo true "/small-group/log-on")
|
||||||
routef "/%O/edit" Handlers.Church.edit ]
|
routef "/error/%s" Handlers.Home.error
|
||||||
route "/class/logon" (redirectTo true "/small-group/log-on")
|
subRoute
|
||||||
routef "/error/%s" Handlers.Home.error
|
"/help"
|
||||||
subRoute "/help" [
|
[ route "" Handlers.Help.index
|
||||||
route "" Handlers.Help.index
|
subRoute
|
||||||
subRoute "/requests" [
|
"/requests"
|
||||||
route "/edit" Handlers.Help.Requests.edit
|
[ route "/edit" Handlers.Help.Requests.edit
|
||||||
route "/maintain" Handlers.Help.Requests.maintain
|
route "/maintain" Handlers.Help.Requests.maintain
|
||||||
route "/view" Handlers.Help.Requests.view ]
|
route "/view" Handlers.Help.Requests.view ]
|
||||||
subRoute "/small-group" [
|
subRoute
|
||||||
route "/announcement" Handlers.Help.SmallGroup.announcement
|
"/small-group"
|
||||||
route "/members" Handlers.Help.SmallGroup.members
|
[ route "/announcement" Handlers.Help.SmallGroup.announcement
|
||||||
route "/preferences" Handlers.Help.SmallGroup.preferences ]
|
route "/members" Handlers.Help.SmallGroup.members
|
||||||
subRoute "/user" [
|
route "/preferences" Handlers.Help.SmallGroup.preferences ]
|
||||||
route "/log-on" Handlers.Help.User.logOn
|
subRoute
|
||||||
route "/password" Handlers.Help.User.password ] ]
|
"/user"
|
||||||
routef "/language/%s" Handlers.Home.language
|
[ route "/log-on" Handlers.Help.User.logOn
|
||||||
subRoute "/legal" [
|
route "/password" Handlers.Help.User.password ] ]
|
||||||
route "/privacy-policy" Handlers.Home.privacyPolicy
|
routef "/language/%s" Handlers.Home.language
|
||||||
route "/terms-of-service" Handlers.Home.tos ]
|
subRoute
|
||||||
route "/log-off" Handlers.Home.logOff
|
"/legal"
|
||||||
subRoute "/prayer-request" [
|
[ route "/privacy-policy" Handlers.Home.privacyPolicy
|
||||||
route "s" (Handlers.PrayerRequest.maintain true)
|
route "/terms-of-service" Handlers.Home.tos ]
|
||||||
routef "s/email/%s" Handlers.PrayerRequest.email
|
route "/log-off" Handlers.Home.logOff
|
||||||
route "s/inactive" (Handlers.PrayerRequest.maintain false)
|
subRoute
|
||||||
route "s/lists" Handlers.PrayerRequest.lists
|
"/prayer-request"
|
||||||
routef "s/%O/list" Handlers.PrayerRequest.list
|
[ route "s" (Handlers.PrayerRequest.maintain true)
|
||||||
route "s/maintain" (redirectTo true "/prayer-requests")
|
routef "s/email/%s" Handlers.PrayerRequest.email
|
||||||
routef "s/print/%s" Handlers.PrayerRequest.print
|
route "s/inactive" (Handlers.PrayerRequest.maintain false)
|
||||||
route "s/view" (Handlers.PrayerRequest.view None)
|
route "s/lists" Handlers.PrayerRequest.lists
|
||||||
routef "s/view/%s" (Some >> Handlers.PrayerRequest.view)
|
routef "s/%O/list" Handlers.PrayerRequest.list
|
||||||
routef "/%O/edit" Handlers.PrayerRequest.edit
|
route "s/maintain" (redirectTo true "/prayer-requests")
|
||||||
routef "/%O/expire" Handlers.PrayerRequest.expire
|
routef "s/print/%s" Handlers.PrayerRequest.print
|
||||||
routef "/%O/restore" Handlers.PrayerRequest.restore ]
|
route "s/view" (Handlers.PrayerRequest.view None)
|
||||||
subRoute "/small-group" [
|
routef "s/view/%s" (Some >> Handlers.PrayerRequest.view)
|
||||||
route "" Handlers.SmallGroup.overview
|
routef "/%O/edit" Handlers.PrayerRequest.edit
|
||||||
route "s" Handlers.SmallGroup.maintain
|
routef "/%O/expire" Handlers.PrayerRequest.expire
|
||||||
route "/announcement" Handlers.SmallGroup.announcement
|
routef "/%O/restore" Handlers.PrayerRequest.restore ]
|
||||||
routef "/%O/edit" Handlers.SmallGroup.edit
|
subRoute
|
||||||
route "/log-on" (Handlers.SmallGroup.logOn None)
|
"/small-group"
|
||||||
routef "/log-on/%O" (Some >> Handlers.SmallGroup.logOn)
|
[ route "" Handlers.SmallGroup.overview
|
||||||
route "/logon" (redirectTo true "/small-group/log-on")
|
route "s" Handlers.SmallGroup.maintain
|
||||||
routef "/member/%O/edit" Handlers.SmallGroup.editMember
|
route "/announcement" Handlers.SmallGroup.announcement
|
||||||
route "/members" Handlers.SmallGroup.members
|
routef "/%O/edit" Handlers.SmallGroup.edit
|
||||||
route "/preferences" Handlers.SmallGroup.preferences ]
|
route "/log-on" (Handlers.SmallGroup.logOn None)
|
||||||
route "/unauthorized" Handlers.Home.unauthorized
|
routef "/log-on/%O" (Some >> Handlers.SmallGroup.logOn)
|
||||||
subRoute "/user" [
|
route "/logon" (redirectTo true "/small-group/log-on")
|
||||||
route "s" Handlers.User.maintain
|
routef "/member/%O/edit" Handlers.SmallGroup.editMember
|
||||||
routef "/%O/edit" Handlers.User.edit
|
route "/members" Handlers.SmallGroup.members
|
||||||
routef "/%O/small-groups" Handlers.User.smallGroups
|
route "/preferences" Handlers.SmallGroup.preferences ]
|
||||||
route "/log-on" Handlers.User.logOn
|
route "/unauthorized" Handlers.Home.unauthorized
|
||||||
route "/logon" (redirectTo true "/user/log-on")
|
subRoute
|
||||||
route "/password" Handlers.User.password ]
|
"/user"
|
||||||
route "/" Handlers.Home.homePage ]
|
[ route "s" Handlers.User.maintain
|
||||||
POST [
|
routef "/%O/edit" Handlers.User.edit
|
||||||
subRoute "/church" [
|
routef "/%O/small-groups" Handlers.User.smallGroups
|
||||||
routef "/%O/delete" Handlers.Church.delete
|
route "/log-on" Handlers.User.logOn
|
||||||
route "/save" Handlers.Church.save ]
|
route "/logon" (redirectTo true "/user/log-on")
|
||||||
subRoute "/prayer-request" [
|
route "/password" Handlers.User.password ]
|
||||||
routef "/%O/delete" Handlers.PrayerRequest.delete
|
route "/" Handlers.Home.homePage ]
|
||||||
route "/save" Handlers.PrayerRequest.save ]
|
POST
|
||||||
subRoute "/small-group" [
|
[ subRoute
|
||||||
route "/announcement/send" Handlers.SmallGroup.sendAnnouncement
|
"/church"
|
||||||
routef "/%O/delete" Handlers.SmallGroup.delete
|
[ routef "/%O/delete" Handlers.Church.delete
|
||||||
route "/log-on/submit" Handlers.SmallGroup.logOnSubmit
|
route "/save" Handlers.Church.save ]
|
||||||
routef "/member/%O/delete" Handlers.SmallGroup.deleteMember
|
subRoute
|
||||||
route "/member/save" Handlers.SmallGroup.saveMember
|
"/prayer-request"
|
||||||
route "/preferences/save" Handlers.SmallGroup.savePreferences
|
[ routef "/%O/delete" Handlers.PrayerRequest.delete
|
||||||
route "/save" Handlers.SmallGroup.save ]
|
route "/save" Handlers.PrayerRequest.save ]
|
||||||
subRoute "/user" [
|
subRoute
|
||||||
routef "/%O/delete" Handlers.User.delete
|
"/small-group"
|
||||||
route "/edit/save" Handlers.User.save
|
[ route "/announcement/send" Handlers.SmallGroup.sendAnnouncement
|
||||||
route "/log-on" Handlers.User.doLogOn
|
routef "/%O/delete" Handlers.SmallGroup.delete
|
||||||
route "/password/change" Handlers.User.changePassword
|
route "/log-on/submit" Handlers.SmallGroup.logOnSubmit
|
||||||
route "/small-groups/save" Handlers.User.saveGroups ] ] ]
|
routef "/member/%O/delete" Handlers.SmallGroup.deleteMember
|
||||||
|
route "/member/save" Handlers.SmallGroup.saveMember
|
||||||
|
route "/preferences/save" Handlers.SmallGroup.savePreferences
|
||||||
|
route "/save" Handlers.SmallGroup.save ]
|
||||||
|
subRoute
|
||||||
|
"/user"
|
||||||
|
[ routef "/%O/delete" Handlers.User.delete
|
||||||
|
route "/edit/save" Handlers.User.save
|
||||||
|
route "/log-on" Handlers.User.doLogOn
|
||||||
|
route "/password/change" Handlers.User.changePassword
|
||||||
|
route "/small-groups/save" Handlers.User.saveGroups ] ] ]
|
||||||
|
|
||||||
open Microsoft.Extensions.Logging
|
open Microsoft.Extensions.Logging
|
||||||
|
|
||||||
@ -175,29 +200,35 @@ module Configure =
|
|||||||
let errorHandler (ex: exn) (logger: ILogger) =
|
let errorHandler (ex: exn) (logger: ILogger) =
|
||||||
logger.LogError(EventId(), ex, "An unhandled exception has occurred while executing the request.")
|
logger.LogError(EventId(), ex, "An unhandled exception has occurred while executing the request.")
|
||||||
clearResponse >=> setStatusCode 500 >=> text ex.Message
|
clearResponse >=> setStatusCode 500 >=> text ex.Message
|
||||||
|
|
||||||
open Microsoft.Extensions.Hosting
|
open Microsoft.Extensions.Hosting
|
||||||
|
|
||||||
/// Configure logging
|
/// Configure logging
|
||||||
let logging (log: ILoggingBuilder) =
|
let logging (log: ILoggingBuilder) =
|
||||||
let env = log.Services.BuildServiceProvider().GetService<IWebHostEnvironment>()
|
let env = log.Services.BuildServiceProvider().GetService<IWebHostEnvironment>()
|
||||||
if env.IsDevelopment() then log else log.AddFilter(fun l -> l > LogLevel.Information)
|
|
||||||
|> function l -> l.AddConsole().AddDebug()
|
if env.IsDevelopment() then
|
||||||
|
log
|
||||||
|
else
|
||||||
|
log.AddFilter(fun l -> l > LogLevel.Information)
|
||||||
|
|> function
|
||||||
|
| l -> l.AddConsole().AddDebug()
|
||||||
|> ignore
|
|> ignore
|
||||||
|
|
||||||
open BitBadger.AspNetCore.CanonicalDomains
|
open BitBadger.AspNetCore.CanonicalDomains
|
||||||
open Microsoft.Extensions.Localization
|
open Microsoft.Extensions.Localization
|
||||||
open Microsoft.Extensions.Options
|
open Microsoft.Extensions.Options
|
||||||
|
|
||||||
/// Configure the application
|
/// Configure the application
|
||||||
let app (app : IApplicationBuilder) =
|
let app (app: WebApplication) =
|
||||||
let env = app.ApplicationServices.GetRequiredService<IWebHostEnvironment>()
|
let env = app.Services.GetRequiredService<IWebHostEnvironment>()
|
||||||
|
|
||||||
if env.IsDevelopment() then
|
if env.IsDevelopment() then
|
||||||
app.UseDeveloperExceptionPage()
|
app.UseDeveloperExceptionPage()
|
||||||
else
|
else
|
||||||
app.UseGiraffeErrorHandler errorHandler
|
app.UseGiraffeErrorHandler errorHandler
|
||||||
|> ignore
|
|> ignore
|
||||||
|
|
||||||
let _ = app.UseForwardedHeaders()
|
let _ = app.UseForwardedHeaders()
|
||||||
let _ = app.UseCanonicalDomains()
|
let _ = app.UseCanonicalDomains()
|
||||||
let _ = app.UseStatusCodePagesWithReExecute "/error/{0}"
|
let _ = app.UseStatusCodePagesWithReExecute "/error/{0}"
|
||||||
@ -206,31 +237,50 @@ module Configure =
|
|||||||
let _ = app.UseMiddleware<RequestStartMiddleware>()
|
let _ = app.UseMiddleware<RequestStartMiddleware>()
|
||||||
let _ = app.UseRouting()
|
let _ = app.UseRouting()
|
||||||
let _ = app.UseSession()
|
let _ = app.UseSession()
|
||||||
let _ = app.UseRequestLocalization(
|
let _ = app.UseRequestLocalization(app.Services.GetService<IOptions<RequestLocalizationOptions>>().Value)
|
||||||
app.ApplicationServices.GetService<IOptions<RequestLocalizationOptions>>().Value)
|
|
||||||
let _ = app.UseAuthentication()
|
let _ = app.UseAuthentication()
|
||||||
let _ = app.UseAuthorization()
|
let _ = app.UseAuthorization()
|
||||||
let _ = app.UseEndpoints(fun e -> e.MapGiraffeEndpoints routes)
|
let _ = app.UseEndpoints(fun e -> e.MapGiraffeEndpoints routes)
|
||||||
app.ApplicationServices.GetRequiredService<IStringLocalizerFactory>() |> Views.I18N.setUpFactories
|
|
||||||
|
|
||||||
|
app.Services.GetRequiredService<IStringLocalizerFactory>()
|
||||||
|
|> Views.I18N.setUpFactories
|
||||||
|
|
||||||
|
open Microsoft.Extensions.DependencyInjection
|
||||||
|
open Microsoft.Extensions.Logging
|
||||||
|
|
||||||
/// The web application
|
/// The web application
|
||||||
module App =
|
module App =
|
||||||
|
|
||||||
open System.IO
|
open System.IO
|
||||||
|
|
||||||
[<EntryPoint>]
|
[<EntryPoint>]
|
||||||
let main args =
|
let main args =
|
||||||
|
|
||||||
let contentRoot = Directory.GetCurrentDirectory()
|
let contentRoot = Directory.GetCurrentDirectory()
|
||||||
let app =
|
let builder =
|
||||||
WebHostBuilder()
|
WebApplication.CreateBuilder(
|
||||||
.UseContentRoot(contentRoot)
|
WebApplicationOptions(
|
||||||
|
Args = args,
|
||||||
|
ApplicationName = "PrayerTracker",
|
||||||
|
ContentRootPath = contentRoot,
|
||||||
|
WebRootPath = Path.Combine(contentRoot, "wwwroot")))
|
||||||
|
let _ =
|
||||||
|
builder.WebHost
|
||||||
.ConfigureAppConfiguration(Configure.configuration)
|
.ConfigureAppConfiguration(Configure.configuration)
|
||||||
.UseKestrel(Configure.kestrel)
|
.ConfigureKestrel(Configure.kestrel)
|
||||||
.UseWebRoot(Path.Combine(contentRoot, "wwwroot"))
|
|
||||||
.ConfigureServices(Configure.services)
|
.ConfigureServices(Configure.services)
|
||||||
.ConfigureLogging(Configure.logging)
|
.ConfigureLogging(Configure.logging)
|
||||||
.Configure(System.Action<IApplicationBuilder> Configure.app)
|
|
||||||
.Build()
|
use app = builder.Build()
|
||||||
if args.Length > 0 then printfn $"Unrecognized option {args[0]}" else app.Run()
|
|
||||||
|
Configure.app app
|
||||||
|
|
||||||
|
let fac = app.Services.GetRequiredService<ILoggerFactory>()
|
||||||
|
let log = fac.CreateLogger "PrayerTracker"
|
||||||
|
log.LogInformation "Application Started"
|
||||||
|
|
||||||
|
app.Run()
|
||||||
|
|
||||||
|
log.LogInformation "Application Shutting Down"
|
||||||
|
|
||||||
0
|
0
|
||||||
|
@ -12,7 +12,7 @@ let private findStats churchId = task {
|
|||||||
let! groups = SmallGroups.countByChurch churchId
|
let! groups = SmallGroups.countByChurch churchId
|
||||||
let! requests = PrayerRequests.countByChurch churchId
|
let! requests = PrayerRequests.countByChurch churchId
|
||||||
let! users = Users.countByChurch churchId
|
let! users = Users.countByChurch churchId
|
||||||
return shortGuid churchId.Value, { SmallGroups = groups; PrayerRequests = requests; Users = users }
|
return shortGuid churchId.Value, { SmallGroups = int groups; PrayerRequests = int requests; Users = int users }
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /church/[church-id]/delete
|
// POST /church/[church-id]/delete
|
||||||
@ -40,7 +40,7 @@ let edit churchId : HttpHandler = requireAccess [ Admin ] >=> fun next ctx -> ta
|
|||||||
|> renderHtml next ctx
|
|> renderHtml next ctx
|
||||||
else
|
else
|
||||||
match! Churches.tryById (ChurchId churchId) with
|
match! Churches.tryById (ChurchId churchId) with
|
||||||
| Some church ->
|
| Some church ->
|
||||||
return!
|
return!
|
||||||
viewInfo ctx
|
viewInfo ctx
|
||||||
|> Views.Church.edit (EditChurch.fromChurch church) ctx
|
|> Views.Church.edit (EditChurch.fromChurch church) ctx
|
||||||
@ -63,7 +63,7 @@ let save : HttpHandler = requireAccess [ Admin ] >=> validateCsrf >=> fun next c
|
|||||||
match! ctx.TryBindFormAsync<EditChurch> () with
|
match! ctx.TryBindFormAsync<EditChurch> () with
|
||||||
| Ok model ->
|
| Ok model ->
|
||||||
let! church =
|
let! church =
|
||||||
if model.IsNew then Task.FromResult(Some { Church.empty with Id = (Guid.NewGuid >> ChurchId) () })
|
if model.IsNew then Task.FromResult(Some { Church.Empty with Id = (Guid.NewGuid >> ChurchId) () })
|
||||||
else Churches.tryById (idFromShort ChurchId model.ChurchId)
|
else Churches.tryById (idFromShort ChurchId model.ChurchId)
|
||||||
match church with
|
match church with
|
||||||
| Some ch ->
|
| Some ch ->
|
||||||
|
@ -13,11 +13,11 @@ let toSelectList<'T> valFunc textFunc withDefault emptyText (items: 'T seq) =
|
|||||||
SelectListItem($"""— %A{s[emptyText]} —""", "")
|
SelectListItem($"""— %A{s[emptyText]} —""", "")
|
||||||
| _ -> ()
|
| _ -> ()
|
||||||
yield! items |> Seq.map (fun x -> SelectListItem(textFunc x, valFunc x)) ]
|
yield! items |> Seq.map (fun x -> SelectListItem(textFunc x, valFunc x)) ]
|
||||||
|
|
||||||
/// Create a select list from an enumeration
|
/// Create a select list from an enumeration
|
||||||
let toSelectListWithEmpty<'T> valFunc textFunc emptyText (items: 'T seq) =
|
let toSelectListWithEmpty<'T> valFunc textFunc emptyText (items: 'T seq) =
|
||||||
toSelectList valFunc textFunc true emptyText items
|
toSelectList valFunc textFunc true emptyText items
|
||||||
|
|
||||||
/// Create a select list from an enumeration
|
/// Create a select list from an enumeration
|
||||||
let toSelectListWithDefault<'T> valFunc textFunc (items: 'T seq) =
|
let toSelectListWithDefault<'T> valFunc textFunc (items: 'T seq) =
|
||||||
toSelectList valFunc textFunc true "Select" items
|
toSelectList valFunc textFunc true "Select" items
|
||||||
@ -117,7 +117,7 @@ let addInfo ctx msg =
|
|||||||
/// Add an informational HTML message to the session
|
/// Add an informational HTML message to the session
|
||||||
let addHtmlInfo ctx msg =
|
let addHtmlInfo ctx msg =
|
||||||
addUserMessage ctx { UserMessage.info with Text = htmlString msg }
|
addUserMessage ctx { UserMessage.info with Text = htmlString msg }
|
||||||
|
|
||||||
/// Add a warning message to the session
|
/// Add a warning message to the session
|
||||||
let addWarning ctx msg =
|
let addWarning ctx msg =
|
||||||
addUserMessage ctx { UserMessage.warning with Text = htmlLocString msg }
|
addUserMessage ctx { UserMessage.warning with Text = htmlLocString msg }
|
||||||
|
@ -1,34 +1,30 @@
|
|||||||
[<AutoOpen>]
|
[<AutoOpen>]
|
||||||
module PrayerTracker.Extensions
|
module PrayerTracker.Extensions
|
||||||
|
|
||||||
|
open BitBadger.Documents
|
||||||
open Microsoft.AspNetCore.Http
|
open Microsoft.AspNetCore.Http
|
||||||
open Newtonsoft.Json
|
|
||||||
open NodaTime
|
open NodaTime
|
||||||
open NodaTime.Serialization.JsonNet
|
|
||||||
open PrayerTracker.Data
|
open PrayerTracker.Data
|
||||||
open PrayerTracker.Entities
|
open PrayerTracker.Entities
|
||||||
open PrayerTracker.ViewModels
|
open PrayerTracker.ViewModels
|
||||||
|
|
||||||
/// JSON.NET serializer settings for NodaTime
|
|
||||||
let private jsonSettings = JsonSerializerSettings().ConfigureForNodaTime DateTimeZoneProviders.Tzdb
|
|
||||||
|
|
||||||
/// Extensions on the .NET session object
|
/// Extensions on the .NET session object
|
||||||
type ISession with
|
type ISession with
|
||||||
|
|
||||||
/// Set an object in the session
|
/// Set an object in the session
|
||||||
member this.SetObject<'T> key (value: 'T) =
|
member this.SetObject<'T> key (value: 'T) =
|
||||||
this.SetString(key, JsonConvert.SerializeObject(value, jsonSettings))
|
this.SetString(key, (Configuration.serializer ()).Serialize value)
|
||||||
|
|
||||||
/// Get an object from the session
|
/// Get an object from the session
|
||||||
member this.TryGetObject<'T> key =
|
member this.TryGetObject<'T> key =
|
||||||
match this.GetString key with
|
match this.GetString key with
|
||||||
| null -> None
|
| null -> None
|
||||||
| v -> Some (JsonConvert.DeserializeObject<'T>(v, jsonSettings))
|
| v -> Some ((Configuration.serializer ()).Deserialize<'T> v)
|
||||||
|
|
||||||
/// The currently logged on small group
|
/// The currently logged on small group
|
||||||
member this.CurrentGroup
|
member this.CurrentGroup
|
||||||
with get () = this.TryGetObject<SmallGroup> Key.Session.currentGroup
|
with get () = this.TryGetObject<SmallGroup> Key.Session.currentGroup
|
||||||
and set (v: SmallGroup option) =
|
and set (v: SmallGroup option) =
|
||||||
match v with
|
match v with
|
||||||
| Some group -> this.SetObject Key.Session.currentGroup group
|
| Some group -> this.SetObject Key.Session.currentGroup group
|
||||||
| None -> this.Remove Key.Session.currentGroup
|
| None -> this.Remove Key.Session.currentGroup
|
||||||
@ -40,7 +36,7 @@ type ISession with
|
|||||||
match v with
|
match v with
|
||||||
| Some user -> this.SetObject Key.Session.currentUser { user with PasswordHash = "" }
|
| Some user -> this.SetObject Key.Session.currentUser { user with PasswordHash = "" }
|
||||||
| None -> this.Remove Key.Session.currentUser
|
| None -> this.Remove Key.Session.currentUser
|
||||||
|
|
||||||
/// Current messages for the session
|
/// Current messages for the session
|
||||||
member this.Messages
|
member this.Messages
|
||||||
with get () =
|
with get () =
|
||||||
@ -53,43 +49,42 @@ open System.Security.Claims
|
|||||||
|
|
||||||
/// Extensions on the claims principal
|
/// Extensions on the claims principal
|
||||||
type ClaimsPrincipal with
|
type ClaimsPrincipal with
|
||||||
|
|
||||||
/// The ID of the currently logged on small group
|
/// The ID of the currently logged on small group
|
||||||
member this.SmallGroupId =
|
member this.SmallGroupId =
|
||||||
if this.HasClaim (fun c -> c.Type = ClaimTypes.GroupSid) then
|
this.FindFirstValue ClaimTypes.GroupSid
|
||||||
Some (idFromShort SmallGroupId (this.FindFirst(fun c -> c.Type = ClaimTypes.GroupSid).Value))
|
|> Option.ofObj
|
||||||
else None
|
|> Option.map (idFromShort SmallGroupId)
|
||||||
|
|
||||||
/// The ID of the currently signed in user
|
/// The ID of the currently signed-in user
|
||||||
member this.UserId =
|
member this.UserId =
|
||||||
if this.HasClaim (fun c -> c.Type = ClaimTypes.NameIdentifier) then
|
this.FindFirstValue ClaimTypes.NameIdentifier
|
||||||
Some (idFromShort UserId (this.FindFirst(fun c -> c.Type = ClaimTypes.NameIdentifier).Value))
|
|> Option.ofObj
|
||||||
else None
|
|> Option.map (idFromShort UserId)
|
||||||
|
|
||||||
|
|
||||||
open Giraffe
|
open Giraffe
|
||||||
open Npgsql
|
|
||||||
|
|
||||||
/// Extensions on the ASP.NET Core HTTP context
|
/// Extensions on the ASP.NET Core HTTP context
|
||||||
type HttpContext with
|
type HttpContext with
|
||||||
|
|
||||||
/// The system clock (via DI)
|
/// The system clock (via DI)
|
||||||
member this.Clock = this.GetService<IClock>()
|
member this.Clock = this.GetService<IClock>()
|
||||||
|
|
||||||
/// The current instant
|
/// The current instant
|
||||||
member this.Now = this.Clock.GetCurrentInstant()
|
member this.Now = this.Clock.GetCurrentInstant()
|
||||||
|
|
||||||
/// The common string localizer
|
/// The common string localizer
|
||||||
member _.Strings = Views.I18N.localizer.Force()
|
member _.Strings = Views.I18N.localizer.Force()
|
||||||
|
|
||||||
/// The currently logged on small group (sets the value in the session if it is missing)
|
/// The currently logged on small group (sets the value in the session if it is missing)
|
||||||
member this.CurrentGroup () = task {
|
member this.CurrentGroup() = task {
|
||||||
match this.Session.CurrentGroup with
|
match this.Session.CurrentGroup with
|
||||||
| Some group -> return Some group
|
| Some group -> return Some group
|
||||||
| None ->
|
| None ->
|
||||||
match this.User.SmallGroupId with
|
match this.User.SmallGroupId with
|
||||||
| Some groupId ->
|
| Some groupId ->
|
||||||
match! SmallGroups.tryByIdWithPreferences groupId with
|
match! SmallGroups.tryById groupId with
|
||||||
| Some group ->
|
| Some group ->
|
||||||
this.Session.CurrentGroup <- Some group
|
this.Session.CurrentGroup <- Some group
|
||||||
return Some group
|
return Some group
|
||||||
@ -98,7 +93,7 @@ type HttpContext with
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The currently logged on user (sets the value in the session if it is missing)
|
/// The currently logged on user (sets the value in the session if it is missing)
|
||||||
member this.CurrentUser () = task {
|
member this.CurrentUser() = task {
|
||||||
match this.Session.CurrentUser with
|
match this.Session.CurrentUser with
|
||||||
| Some user -> return Some user
|
| Some user -> return Some user
|
||||||
| None ->
|
| None ->
|
||||||
|
@ -20,7 +20,7 @@ let private findRequest (ctx: HttpContext) reqId = task {
|
|||||||
/// Generate a list of requests for the given date
|
/// Generate a list of requests for the given date
|
||||||
let private generateRequestList (ctx: HttpContext) date = task {
|
let private generateRequestList (ctx: HttpContext) date = task {
|
||||||
let group = ctx.Session.CurrentGroup.Value
|
let group = ctx.Session.CurrentGroup.Value
|
||||||
let listDate = match date with Some d -> d | None -> SmallGroup.localDateNow ctx.Clock group
|
let listDate = defaultArg date (group.LocalDateNow ctx.Clock)
|
||||||
let! reqs =
|
let! reqs =
|
||||||
PrayerRequests.forGroup
|
PrayerRequests.forGroup
|
||||||
{ SmallGroup = group
|
{ SmallGroup = group
|
||||||
@ -50,7 +50,7 @@ open System
|
|||||||
// GET /prayer-request/[request-id]/edit
|
// GET /prayer-request/[request-id]/edit
|
||||||
let edit reqId : HttpHandler = requireAccess [ User ] >=> fun next ctx -> task {
|
let edit reqId : HttpHandler = requireAccess [ User ] >=> fun next ctx -> task {
|
||||||
let group = ctx.Session.CurrentGroup.Value
|
let group = ctx.Session.CurrentGroup.Value
|
||||||
let now = SmallGroup.localDateNow ctx.Clock group
|
let now = group.LocalDateNow ctx.Clock
|
||||||
let requestId = PrayerRequestId reqId
|
let requestId = PrayerRequestId reqId
|
||||||
if requestId.Value = Guid.Empty then
|
if requestId.Value = Guid.Empty then
|
||||||
return!
|
return!
|
||||||
@ -61,7 +61,7 @@ let edit reqId : HttpHandler = requireAccess [ User ] >=> fun next ctx -> task {
|
|||||||
match! findRequest ctx requestId with
|
match! findRequest ctx requestId with
|
||||||
| Ok req ->
|
| Ok req ->
|
||||||
let s = ctx.Strings
|
let s = ctx.Strings
|
||||||
if PrayerRequest.isExpired now group req then
|
if req.IsExpired now group then
|
||||||
{ UserMessage.warning with
|
{ UserMessage.warning with
|
||||||
Text = htmlLocString s["This request is expired."]
|
Text = htmlLocString s["This request is expired."]
|
||||||
Description =
|
Description =
|
||||||
@ -126,7 +126,7 @@ let expire reqId : HttpHandler = requireAccess [ User ] >=> fun next ctx -> task
|
|||||||
|
|
||||||
// GET /prayer-requests/[group-id]/list
|
// GET /prayer-requests/[group-id]/list
|
||||||
let list groupId : HttpHandler = requireAccess [ AccessLevel.Public ] >=> fun next ctx -> task {
|
let list groupId : HttpHandler = requireAccess [ AccessLevel.Public ] >=> fun next ctx -> task {
|
||||||
match! SmallGroups.tryByIdWithPreferences (SmallGroupId groupId) with
|
match! SmallGroups.tryById (SmallGroupId groupId) with
|
||||||
| Some group when group.Preferences.IsPublic ->
|
| Some group when group.Preferences.IsPublic ->
|
||||||
let! reqs =
|
let! reqs =
|
||||||
PrayerRequests.forGroup
|
PrayerRequests.forGroup
|
||||||
@ -139,7 +139,7 @@ let list groupId : HttpHandler = requireAccess [ AccessLevel.Public ] >=> fun ne
|
|||||||
viewInfo ctx
|
viewInfo ctx
|
||||||
|> Views.PrayerRequest.list
|
|> Views.PrayerRequest.list
|
||||||
{ Requests = reqs
|
{ Requests = reqs
|
||||||
Date = SmallGroup.localDateNow ctx.Clock group
|
Date = group.LocalDateNow ctx.Clock
|
||||||
SmallGroup = group
|
SmallGroup = group
|
||||||
ShowHeader = true
|
ShowHeader = true
|
||||||
CanEmail = Option.isSome ctx.User.UserId
|
CanEmail = Option.isSome ctx.User.UserId
|
||||||
@ -226,7 +226,7 @@ let save : HttpHandler = requireAccess [ User ] >=> validateCsrf >=> fun next ct
|
|||||||
let group = ctx.Session.CurrentGroup.Value
|
let group = ctx.Session.CurrentGroup.Value
|
||||||
let! req =
|
let! req =
|
||||||
if model.IsNew then
|
if model.IsNew then
|
||||||
{ PrayerRequest.empty with
|
{ PrayerRequest.Empty with
|
||||||
Id = (Guid.NewGuid >> PrayerRequestId) ()
|
Id = (Guid.NewGuid >> PrayerRequestId) ()
|
||||||
SmallGroupId = group.Id
|
SmallGroupId = group.Id
|
||||||
UserId = ctx.User.UserId.Value
|
UserId = ctx.User.UserId.Value
|
||||||
@ -235,19 +235,19 @@ let save : HttpHandler = requireAccess [ User ] >=> validateCsrf >=> fun next ct
|
|||||||
else PrayerRequests.tryById (idFromShort PrayerRequestId model.RequestId)
|
else PrayerRequests.tryById (idFromShort PrayerRequestId model.RequestId)
|
||||||
match req with
|
match req with
|
||||||
| Some pr when pr.SmallGroupId = group.Id ->
|
| Some pr when pr.SmallGroupId = group.Id ->
|
||||||
let now = SmallGroup.localDateNow ctx.Clock group
|
let now = group.LocalDateNow ctx.Clock
|
||||||
let updated =
|
let updated =
|
||||||
{ pr with
|
{ pr with
|
||||||
RequestType = PrayerRequestType.fromCode model.RequestType
|
RequestType = PrayerRequestType.Parse model.RequestType
|
||||||
Requestor = match model.Requestor with Some x when x.Trim() = "" -> None | x -> x
|
Requestor = match model.Requestor with Some x when x.Trim() = "" -> None | x -> x
|
||||||
Text = ckEditorToText model.Text
|
Text = ckEditorToText model.Text
|
||||||
Expiration = Expiration.fromCode model.Expiration
|
Expiration = Expiration.Parse model.Expiration
|
||||||
}
|
}
|
||||||
|> function
|
|> function
|
||||||
| it when model.IsNew ->
|
| it when model.IsNew ->
|
||||||
let dt =
|
let dt =
|
||||||
(defaultArg (parseListDate model.EnteredDate) now)
|
(defaultArg (parseListDate model.EnteredDate) now)
|
||||||
.AtStartOfDayInZone(SmallGroup.timeZone group)
|
.AtStartOfDayInZone(group.TimeZone)
|
||||||
.ToInstant()
|
.ToInstant()
|
||||||
{ it with EnteredDate = dt; UpdatedDate = dt }
|
{ it with EnteredDate = dt; UpdatedDate = dt }
|
||||||
| it when defaultArg model.SkipDateUpdate false -> it
|
| it when defaultArg model.SkipDateUpdate false -> it
|
||||||
|
@ -25,15 +25,15 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="BitBadger.AspNetCore.CanonicalDomains" Version="1.0.0" />
|
<PackageReference Include="BitBadger.AspNetCore.CanonicalDomains" Version="1.1.0" />
|
||||||
<PackageReference Include="Giraffe.Htmx" Version="2.0.0" />
|
<PackageReference Include="Giraffe.Htmx" Version="2.0.4" />
|
||||||
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.1.0" />
|
<PackageReference Include="NeoSmart.Caching.Sqlite.AspNetCore" Version="9.0.0" />
|
||||||
<PackageReference Update="FSharp.Core" Version="8.0.300" />
|
<PackageReference Update="FSharp.Core" Version="9.0.101" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\PrayerTracker.Data\PrayerTracker.Data.fsproj" />
|
<ProjectReference Include="..\Data\PrayerTracker.Data.fsproj" />
|
||||||
<ProjectReference Include="..\PrayerTracker.UI\PrayerTracker.UI.fsproj" />
|
<ProjectReference Include="..\UI\PrayerTracker.UI.fsproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
@ -152,8 +152,8 @@ let overview : HttpHandler = requireAccess [ User ] >=> fun next ctx -> task {
|
|||||||
let! admins = Users.listByGroupId group.Id
|
let! admins = Users.listByGroupId group.Id
|
||||||
let model =
|
let model =
|
||||||
{ TotalActiveReqs = List.length reqs
|
{ TotalActiveReqs = List.length reqs
|
||||||
AllReqs = reqCount
|
AllReqs = int reqCount
|
||||||
TotalMembers = mbrCount
|
TotalMembers = int mbrCount
|
||||||
ActiveReqsByType = (
|
ActiveReqsByType = (
|
||||||
reqs
|
reqs
|
||||||
|> Seq.ofList
|
|> Seq.ofList
|
||||||
@ -183,11 +183,11 @@ let save : HttpHandler = requireAccess [ Admin ] >=> validateCsrf >=> fun next c
|
|||||||
match! ctx.TryBindFormAsync<EditSmallGroup>() with
|
match! ctx.TryBindFormAsync<EditSmallGroup>() with
|
||||||
| Ok model ->
|
| Ok model ->
|
||||||
let! tryGroup =
|
let! tryGroup =
|
||||||
if model.IsNew then Task.FromResult(Some { SmallGroup.empty with Id = (Guid.NewGuid >> SmallGroupId) () })
|
if model.IsNew then Task.FromResult(Some { SmallGroup.Empty with Id = (Guid.NewGuid >> SmallGroupId) () })
|
||||||
else SmallGroups.tryById (idFromShort SmallGroupId model.SmallGroupId)
|
else SmallGroups.tryById (idFromShort SmallGroupId model.SmallGroupId)
|
||||||
match tryGroup with
|
match tryGroup with
|
||||||
| Some group ->
|
| Some group ->
|
||||||
do! SmallGroups.save (model.populateGroup group) model.IsNew
|
do! SmallGroups.save (model.populateGroup group)
|
||||||
let act = ctx.Strings[if model.IsNew then "Added" else "Updated"].Value.ToLower()
|
let act = ctx.Strings[if model.IsNew then "Added" else "Updated"].Value.ToLower()
|
||||||
addHtmlInfo ctx ctx.Strings["Successfully {0} group “{1}”", act, model.Name]
|
addHtmlInfo ctx ctx.Strings["Successfully {0} group “{1}”", act, model.Name]
|
||||||
return! redirectTo false "/small-groups" next ctx
|
return! redirectTo false "/small-groups" next ctx
|
||||||
@ -202,7 +202,7 @@ let saveMember : HttpHandler = requireAccess [ User ] >=> validateCsrf >=> fun n
|
|||||||
let group = ctx.Session.CurrentGroup.Value
|
let group = ctx.Session.CurrentGroup.Value
|
||||||
let! tryMbr =
|
let! tryMbr =
|
||||||
if model.IsNew then
|
if model.IsNew then
|
||||||
Task.FromResult(Some { Member.empty with Id = (Guid.NewGuid >> MemberId) (); SmallGroupId = group.Id })
|
Task.FromResult(Some { Member.Empty with Id = (Guid.NewGuid >> MemberId) (); SmallGroupId = group.Id })
|
||||||
else Members.tryById (idFromShort MemberId model.MemberId)
|
else Members.tryById (idFromShort MemberId model.MemberId)
|
||||||
match tryMbr with
|
match tryMbr with
|
||||||
| Some mbr when mbr.SmallGroupId = group.Id ->
|
| Some mbr when mbr.SmallGroupId = group.Id ->
|
||||||
@ -210,7 +210,7 @@ let saveMember : HttpHandler = requireAccess [ User ] >=> validateCsrf >=> fun n
|
|||||||
{ mbr with
|
{ mbr with
|
||||||
Name = model.Name
|
Name = model.Name
|
||||||
Email = model.Email
|
Email = model.Email
|
||||||
Format = String.noneIfBlank model.Format |> Option.map EmailFormat.fromCode }
|
Format = String.noneIfBlank model.Format |> Option.map EmailFormat.Parse }
|
||||||
let act = ctx.Strings[if model.IsNew then "Added" else "Updated"].Value.ToLower()
|
let act = ctx.Strings[if model.IsNew then "Added" else "Updated"].Value.ToLower()
|
||||||
addInfo ctx ctx.Strings["Successfully {0} group member", act]
|
addInfo ctx ctx.Strings["Successfully {0} group member", act]
|
||||||
return! redirectTo false "/small-group/members" next ctx
|
return! redirectTo false "/small-group/members" next ctx
|
||||||
@ -227,10 +227,10 @@ let savePreferences : HttpHandler = requireAccess [ User ] >=> validateCsrf >=>
|
|||||||
// we can repopulate the session instance. That way, if the update fails, the page should still show the
|
// we can repopulate the session instance. That way, if the update fails, the page should still show the
|
||||||
// database values, not the then out-of-sync session ones.
|
// database values, not the then out-of-sync session ones.
|
||||||
let group = ctx.Session.CurrentGroup.Value
|
let group = ctx.Session.CurrentGroup.Value
|
||||||
match! SmallGroups.tryByIdWithPreferences group.Id with
|
match! SmallGroups.tryById group.Id with
|
||||||
| Some group ->
|
| Some group ->
|
||||||
let pref = model.PopulatePreferences group.Preferences
|
let pref = model.PopulatePreferences group.Preferences
|
||||||
do! SmallGroups.savePreferences pref
|
do! SmallGroups.savePreferences group.Id pref
|
||||||
// Refresh session instance
|
// Refresh session instance
|
||||||
ctx.Session.CurrentGroup <- Some { group with Preferences = pref }
|
ctx.Session.CurrentGroup <- Some { group with Preferences = pref }
|
||||||
addInfo ctx ctx.Strings["Group preferences updated successfully"]
|
addInfo ctx ctx.Strings["Group preferences updated successfully"]
|
||||||
@ -241,7 +241,6 @@ let savePreferences : HttpHandler = requireAccess [ User ] >=> validateCsrf >=>
|
|||||||
|
|
||||||
open Giraffe.ViewEngine
|
open Giraffe.ViewEngine
|
||||||
open PrayerTracker.Views.CommonFunctions
|
open PrayerTracker.Views.CommonFunctions
|
||||||
open Microsoft.Extensions.Configuration
|
|
||||||
|
|
||||||
// POST /small-group/announcement/send
|
// POST /small-group/announcement/send
|
||||||
let sendAnnouncement : HttpHandler = requireAccess [ User ] >=> validateCsrf >=> fun next ctx -> task {
|
let sendAnnouncement : HttpHandler = requireAccess [ User ] >=> validateCsrf >=> fun next ctx -> task {
|
||||||
@ -250,7 +249,7 @@ let sendAnnouncement : HttpHandler = requireAccess [ User ] >=> validateCsrf >=>
|
|||||||
let group = ctx.Session.CurrentGroup.Value
|
let group = ctx.Session.CurrentGroup.Value
|
||||||
let pref = group.Preferences
|
let pref = group.Preferences
|
||||||
let usr = ctx.Session.CurrentUser.Value
|
let usr = ctx.Session.CurrentUser.Value
|
||||||
let now = SmallGroup.localTimeNow ctx.Clock group
|
let now = group.LocalTimeNow ctx.Clock
|
||||||
let s = ctx.Strings
|
let s = ctx.Strings
|
||||||
// Reformat the text to use the class's font stylings
|
// Reformat the text to use the class's font stylings
|
||||||
let requestText = ckEditorToText model.Text
|
let requestText = ckEditorToText model.Text
|
||||||
@ -262,7 +261,7 @@ let sendAnnouncement : HttpHandler = requireAccess [ User ] >=> validateCsrf >=>
|
|||||||
let! recipients = task {
|
let! recipients = task {
|
||||||
if model.SendToClass = "N" && usr.IsAdmin then
|
if model.SendToClass = "N" && usr.IsAdmin then
|
||||||
let! users = Users.all ()
|
let! users = Users.all ()
|
||||||
return users |> List.map (fun u -> { Member.empty with Name = u.Name; Email = u.Email })
|
return users |> List.map (fun u -> { Member.Empty with Name = u.Name; Email = u.Email })
|
||||||
else return! Members.forGroup group.Id
|
else return! Members.forGroup group.Id
|
||||||
}
|
}
|
||||||
use! client = Email.getConnection ()
|
use! client = Email.getConnection ()
|
||||||
@ -282,13 +281,13 @@ let sendAnnouncement : HttpHandler = requireAccess [ User ] >=> validateCsrf >=>
|
|||||||
| _, None -> ()
|
| _, None -> ()
|
||||||
| _, Some x when not x -> ()
|
| _, Some x when not x -> ()
|
||||||
| _, _ ->
|
| _, _ ->
|
||||||
let zone = SmallGroup.timeZone group
|
let zone = group.TimeZone
|
||||||
do! PrayerRequests.save
|
do! PrayerRequests.save
|
||||||
{ PrayerRequest.empty with
|
{ PrayerRequest.Empty with
|
||||||
Id = (Guid.NewGuid >> PrayerRequestId) ()
|
Id = (Guid.NewGuid >> PrayerRequestId) ()
|
||||||
SmallGroupId = group.Id
|
SmallGroupId = group.Id
|
||||||
UserId = usr.Id
|
UserId = usr.Id
|
||||||
RequestType = (Option.get >> PrayerRequestType.fromCode) model.RequestType
|
RequestType = (Option.get >> PrayerRequestType.Parse) model.RequestType
|
||||||
Text = requestText
|
Text = requestText
|
||||||
EnteredDate = now.Date.AtStartOfDayInZone(zone).ToInstant()
|
EnteredDate = now.Date.AtStartOfDayInZone(zone).ToInstant()
|
||||||
UpdatedDate = now.InZoneLeniently(zone).ToInstant() }
|
UpdatedDate = now.InZoneLeniently(zone).ToInstant() }
|
||||||
|
@ -14,20 +14,20 @@ open PrayerTracker.ViewModels
|
|||||||
/// Password hashing implementation extending ASP.NET Core's identity implementation
|
/// Password hashing implementation extending ASP.NET Core's identity implementation
|
||||||
[<AutoOpen>]
|
[<AutoOpen>]
|
||||||
module Hashing =
|
module Hashing =
|
||||||
|
|
||||||
open System.Security.Cryptography
|
open System.Security.Cryptography
|
||||||
open System.Text
|
open System.Text
|
||||||
|
|
||||||
/// Custom password hasher used to verify and upgrade old password hashes
|
/// Custom password hasher used to verify and upgrade old password hashes
|
||||||
type PrayerTrackerPasswordHasher() =
|
type PrayerTrackerPasswordHasher() =
|
||||||
inherit PasswordHasher<User>()
|
inherit PasswordHasher<User>()
|
||||||
|
|
||||||
override this.VerifyHashedPassword(user, hashedPassword, providedPassword) =
|
override this.VerifyHashedPassword(user, hashedPassword, providedPassword) =
|
||||||
if isNull hashedPassword then nullArg (nameof hashedPassword)
|
if isNull hashedPassword then nullArg (nameof hashedPassword)
|
||||||
if isNull providedPassword then nullArg (nameof providedPassword)
|
if isNull providedPassword then nullArg (nameof providedPassword)
|
||||||
|
|
||||||
let hashBytes = Convert.FromBase64String hashedPassword
|
let hashBytes = Convert.FromBase64String hashedPassword
|
||||||
|
|
||||||
match hashBytes[0] with
|
match hashBytes[0] with
|
||||||
| 255uy ->
|
| 255uy ->
|
||||||
// v2 hashes - PBKDF2 (RFC 2898), 1,024 rounds
|
// v2 hashes - PBKDF2 (RFC 2898), 1,024 rounds
|
||||||
@ -53,7 +53,7 @@ module Hashing =
|
|||||||
PasswordVerificationResult.Failed
|
PasswordVerificationResult.Failed
|
||||||
| _ -> base.VerifyHashedPassword(user, hashedPassword, providedPassword)
|
| _ -> base.VerifyHashedPassword(user, hashedPassword, providedPassword)
|
||||||
|
|
||||||
|
|
||||||
/// Retrieve a user from the database by password, upgrading password hashes if required
|
/// Retrieve a user from the database by password, upgrading password hashes if required
|
||||||
let private findUserByPassword model = task {
|
let private findUserByPassword model = task {
|
||||||
match! Users.tryByEmailAndGroup model.Email (idFromShort SmallGroupId model.SmallGroupId) with
|
match! Users.tryByEmailAndGroup model.Email (idFromShort SmallGroupId model.SmallGroupId) with
|
||||||
@ -125,11 +125,11 @@ open Microsoft.AspNetCore.Html
|
|||||||
// POST /user/log-on
|
// POST /user/log-on
|
||||||
let doLogOn : HttpHandler = requireAccess [ AccessLevel.Public ] >=> validateCsrf >=> fun next ctx -> task {
|
let doLogOn : HttpHandler = requireAccess [ AccessLevel.Public ] >=> validateCsrf >=> fun next ctx -> task {
|
||||||
match! ctx.TryBindFormAsync<UserLogOn>() with
|
match! ctx.TryBindFormAsync<UserLogOn>() with
|
||||||
| Ok model ->
|
| Ok model ->
|
||||||
let s = ctx.Strings
|
let s = ctx.Strings
|
||||||
match! findUserByPassword model with
|
match! findUserByPassword model with
|
||||||
| Some user ->
|
| Some user ->
|
||||||
match! SmallGroups.tryByIdWithPreferences (idFromShort SmallGroupId model.SmallGroupId) with
|
match! SmallGroups.tryById (idFromShort SmallGroupId model.SmallGroupId) with
|
||||||
| Some group ->
|
| Some group ->
|
||||||
ctx.Session.CurrentUser <- Some user
|
ctx.Session.CurrentUser <- Some user
|
||||||
ctx.Session.CurrentGroup <- Some group
|
ctx.Session.CurrentGroup <- Some group
|
||||||
@ -218,7 +218,7 @@ let save : HttpHandler = requireAccess [ Admin ] >=> validateCsrf >=> fun next c
|
|||||||
match! ctx.TryBindFormAsync<EditUser>() with
|
match! ctx.TryBindFormAsync<EditUser>() with
|
||||||
| Ok model ->
|
| Ok model ->
|
||||||
let! user =
|
let! user =
|
||||||
if model.IsNew then Task.FromResult(Some { User.empty with Id = (Guid.NewGuid >> UserId) () })
|
if model.IsNew then Task.FromResult(Some { User.Empty with Id = (Guid.NewGuid >> UserId) () })
|
||||||
else Users.tryById (idFromShort UserId model.UserId)
|
else Users.tryById (idFromShort UserId model.UserId)
|
||||||
match user with
|
match user with
|
||||||
| Some usr ->
|
| Some usr ->
|
||||||
@ -230,7 +230,7 @@ let save : HttpHandler = requireAccess [ Admin ] >=> validateCsrf >=> fun next c
|
|||||||
let h = CommonFunctions.htmlString
|
let h = CommonFunctions.htmlString
|
||||||
{ UserMessage.info with
|
{ UserMessage.info with
|
||||||
Text = h s["Successfully {0} user", s["Added"].Value.ToLower ()]
|
Text = h s["Successfully {0} user", s["Added"].Value.ToLower ()]
|
||||||
Description =
|
Description =
|
||||||
h s["Please select at least one group for which this user ({0}) is authorized",
|
h s["Please select at least one group for which this user ({0}) is authorized",
|
||||||
updatedUser.Name]
|
updatedUser.Name]
|
||||||
|> Some }
|
|> Some }
|
||||||
@ -265,9 +265,9 @@ let smallGroups usrId : HttpHandler = requireAccess [ Admin ] >=> fun next ctx -
|
|||||||
match! Users.tryById userId with
|
match! Users.tryById userId with
|
||||||
| Some user ->
|
| Some user ->
|
||||||
let! groups = SmallGroups.listAll ()
|
let! groups = SmallGroups.listAll ()
|
||||||
let! groupIds = Users.groupIdsByUserId userId
|
let groupIds = user.SmallGroups
|
||||||
let curGroups = groupIds |> List.map (fun g -> shortGuid g.Value)
|
let curGroups = groupIds |> List.map (fun g -> shortGuid g.Value)
|
||||||
return!
|
return!
|
||||||
viewInfo ctx
|
viewInfo ctx
|
||||||
|> Views.User.assignGroups (AssignGroups.fromUser user) groups curGroups ctx
|
|> Views.User.assignGroups (AssignGroups.fromUser user) groups curGroups ctx
|
||||||
|> renderHtml next ctx
|
|> renderHtml next ctx
|
||||||
|
0
src/PrayerTracker/data/.gitkeep
Normal file
0
src/PrayerTracker/data/.gitkeep
Normal file
87
src/PrayerTracker/wwwroot/_/fixi-0.5.7.js
Normal file
87
src/PrayerTracker/wwwroot/_/fixi-0.5.7.js
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
(()=>{
|
||||||
|
let send = (elt, type, detail, bub)=>elt.dispatchEvent(new CustomEvent("fx:" + type, {detail, cancelable:true, bubbles:bub !== false, composed:true}))
|
||||||
|
let attr = (elt, name, defaultVal)=>elt.getAttribute(name) || defaultVal
|
||||||
|
let ignore = (elt)=>elt.matches("[fx-ignore]") || elt.closest("[fx-ignore]") != null
|
||||||
|
let init = (elt)=>{
|
||||||
|
let options = {}
|
||||||
|
if (elt.__fixi || ignore(elt) || !send(elt, "init", {options})) return
|
||||||
|
elt.__fixi = async(evt)=>{
|
||||||
|
let reqs = elt.__fixi.requests ||= new Set()
|
||||||
|
let form = elt.form || elt.closest("form")
|
||||||
|
let body = new FormData(form ?? undefined, evt.submitter)
|
||||||
|
if (!form && elt.name) body.append(elt.name, elt.value)
|
||||||
|
let ac = new AbortController()
|
||||||
|
let cfg = {
|
||||||
|
trigger:evt,
|
||||||
|
action:attr(elt, "fx-action"),
|
||||||
|
method:attr(elt, "fx-method", "GET").toUpperCase(),
|
||||||
|
target: document.querySelector(attr(elt, "fx-target")) ?? elt,
|
||||||
|
swap:attr(elt, "fx-swap", "outerHTML"),
|
||||||
|
body,
|
||||||
|
drop:reqs.size,
|
||||||
|
headers:{"FX-Request":"true"},
|
||||||
|
abort:ac.abort.bind(ac),
|
||||||
|
signal:ac.signal,
|
||||||
|
preventTrigger:true,
|
||||||
|
transition:document.startViewTransition?.bind(document),
|
||||||
|
fetch:fetch.bind(window)
|
||||||
|
}
|
||||||
|
let go = send(elt, "config", {cfg, requests:reqs})
|
||||||
|
if (cfg.preventTrigger) evt.preventDefault()
|
||||||
|
if (!go || cfg.drop) return
|
||||||
|
if (/GET|DELETE/.test(cfg.method)){
|
||||||
|
let params = new URLSearchParams(cfg.body)
|
||||||
|
if (params.size)
|
||||||
|
cfg.action += (/\?/.test(cfg.action) ? "&" : "?") + params
|
||||||
|
cfg.body = null
|
||||||
|
}
|
||||||
|
reqs.add(cfg)
|
||||||
|
try {
|
||||||
|
if (cfg.confirm){
|
||||||
|
let result = await cfg.confirm()
|
||||||
|
if (!result) return
|
||||||
|
}
|
||||||
|
if (!send(elt, "before", {cfg, requests:reqs})) return
|
||||||
|
cfg.response = await cfg.fetch(cfg.action, cfg)
|
||||||
|
cfg.text = await cfg.response.text()
|
||||||
|
if (!send(elt, "after", {cfg})) return
|
||||||
|
} catch(error) {
|
||||||
|
send(elt, "error", {cfg, error})
|
||||||
|
return
|
||||||
|
} finally {
|
||||||
|
reqs.delete(cfg)
|
||||||
|
send(elt, "finally", {cfg})
|
||||||
|
}
|
||||||
|
let doSwap = ()=>{
|
||||||
|
if (cfg.swap instanceof Function)
|
||||||
|
return cfg.swap(cfg)
|
||||||
|
else if (/(before|after)(start|end)/.test(cfg.swap))
|
||||||
|
cfg.target.insertAdjacentHTML(cfg.swap, cfg.text)
|
||||||
|
else if(cfg.swap in cfg.target)
|
||||||
|
cfg.target[cfg.swap] = cfg.text
|
||||||
|
else throw cfg.swap
|
||||||
|
}
|
||||||
|
if (cfg.transition)
|
||||||
|
await cfg.transition(doSwap).finished
|
||||||
|
else
|
||||||
|
await doSwap()
|
||||||
|
send(elt, "swapped", {cfg})
|
||||||
|
}
|
||||||
|
elt.__fixi.evt = attr(elt, "fx-trigger", elt.matches("form") ? "submit" : elt.matches("input:not([type=button]),select,textarea") ? "change" : "click")
|
||||||
|
elt.addEventListener(elt.__fixi.evt, elt.__fixi, options)
|
||||||
|
send(elt, "inited", {}, false)
|
||||||
|
}
|
||||||
|
let process = (elt)=>{
|
||||||
|
if (elt instanceof Element){
|
||||||
|
if (ignore(elt)) return
|
||||||
|
if (elt.matches("[fx-action]")) init(elt)
|
||||||
|
elt.querySelectorAll("[fx-action]").forEach(init)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("fx:process", (evt)=>process(evt.target))
|
||||||
|
document.addEventListener("DOMContentLoaded", ()=>{
|
||||||
|
document.__fixi_mo = new MutationObserver((recs)=>recs.forEach((r)=>r.type === "childList" && r.addedNodes.forEach((n)=>process(n))))
|
||||||
|
document.__fixi_mo.observe(document.body, {childList:true, subtree:true})
|
||||||
|
process(document.body)
|
||||||
|
})
|
||||||
|
})()
|
368
src/Tests/Data/EntitiesTests.fs
Normal file
368
src/Tests/Data/EntitiesTests.fs
Normal file
@ -0,0 +1,368 @@
|
|||||||
|
module PrayerTracker.Entities.EntitiesTests
|
||||||
|
|
||||||
|
open Expecto
|
||||||
|
open NodaTime.Testing
|
||||||
|
open NodaTime
|
||||||
|
open System
|
||||||
|
|
||||||
|
[<Tests>]
|
||||||
|
let asOfDateDisplayTests =
|
||||||
|
testList "AsOfDateDisplay" [
|
||||||
|
testList "ToString" [
|
||||||
|
test "NoDisplay code is correct" {
|
||||||
|
Expect.equal (string NoDisplay) "N" "The code for NoDisplay should have been \"N\""
|
||||||
|
}
|
||||||
|
test "ShortDate code is correct" {
|
||||||
|
Expect.equal (string ShortDate) "S" "The code for ShortDate should have been \"S\""
|
||||||
|
}
|
||||||
|
test "LongDate code is correct" {
|
||||||
|
Expect.equal (string LongDate) "L" "The code for LongDate should have been \"N\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
testList "Parse" [
|
||||||
|
test "N should return NoDisplay" {
|
||||||
|
Expect.equal (AsOfDateDisplay.Parse "N") NoDisplay "\"N\" should have been parsed to NoDisplay"
|
||||||
|
}
|
||||||
|
test "S should return ShortDate" {
|
||||||
|
Expect.equal (AsOfDateDisplay.Parse "S") ShortDate "\"S\" should have been parsed to ShortDate"
|
||||||
|
}
|
||||||
|
test "L should return LongDate" {
|
||||||
|
Expect.equal (AsOfDateDisplay.Parse "L") LongDate "\"L\" should have been parsed to LongDate"
|
||||||
|
}
|
||||||
|
test "X should raise" {
|
||||||
|
Expect.throws (fun () -> AsOfDateDisplay.Parse "X" |> ignore)
|
||||||
|
"An unknown code should have raised an exception"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
[<Tests>]
|
||||||
|
let churchTests =
|
||||||
|
testList "Church" [
|
||||||
|
test "Empty is as expected" {
|
||||||
|
let mt = Church.Empty
|
||||||
|
Expect.equal mt.Id.Value Guid.Empty "The church ID should have been an empty GUID"
|
||||||
|
Expect.equal mt.Name "" "The name should have been blank"
|
||||||
|
Expect.equal mt.City "" "The city should have been blank"
|
||||||
|
Expect.equal mt.State "" "The state should have been blank"
|
||||||
|
Expect.isFalse mt.HasVpsInterface "The church should not show that it has an interface"
|
||||||
|
Expect.isNone mt.InterfaceAddress "The interface address should not exist"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
[<Tests>]
|
||||||
|
let emailFormatTests =
|
||||||
|
testList "EmailFormat" [
|
||||||
|
testList "ToString" [
|
||||||
|
test "HtmlFormat code is correct" {
|
||||||
|
Expect.equal (string HtmlFormat) "H" "The code for HtmlFormat should have been \"H\""
|
||||||
|
}
|
||||||
|
test "PlainTextFormat code is correct" {
|
||||||
|
Expect.equal (string PlainTextFormat) "P" "The code for PlainTextFormat should have been \"P\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
testList "Parse" [
|
||||||
|
test "H should return HtmlFormat" {
|
||||||
|
Expect.equal (EmailFormat.Parse "H") HtmlFormat "\"H\" should have been converted to HtmlFormat"
|
||||||
|
}
|
||||||
|
test "P should return ShortDate" {
|
||||||
|
Expect.equal (EmailFormat.Parse "P") PlainTextFormat
|
||||||
|
"\"P\" should have been converted to PlainTextFormat"
|
||||||
|
}
|
||||||
|
test "Z should raise" {
|
||||||
|
Expect.throws (fun () -> EmailFormat.Parse "Z" |> ignore)
|
||||||
|
"An unknown code should have raised an exception"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
[<Tests>]
|
||||||
|
let expirationTests =
|
||||||
|
testList "Expiration" [
|
||||||
|
testList "ToString" [
|
||||||
|
test "Automatic code is correct" {
|
||||||
|
Expect.equal (string Automatic) "A" "The code for Automatic should have been \"A\""
|
||||||
|
}
|
||||||
|
test "Manual code is correct" {
|
||||||
|
Expect.equal (string Manual) "M" "The code for Manual should have been \"M\""
|
||||||
|
}
|
||||||
|
test "Forced code is correct" {
|
||||||
|
Expect.equal (string Forced) "F" "The code for Forced should have been \"F\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
testList "Parse" [
|
||||||
|
test "A should return Automatic" {
|
||||||
|
Expect.equal (Expiration.Parse "A") Automatic "\"A\" should have been converted to Automatic"
|
||||||
|
}
|
||||||
|
test "M should return Manual" {
|
||||||
|
Expect.equal (Expiration.Parse "M") Manual "\"M\" should have been converted to Manual"
|
||||||
|
}
|
||||||
|
test "F should return Forced" {
|
||||||
|
Expect.equal (Expiration.Parse "F") Forced "\"F\" should have been converted to Forced"
|
||||||
|
}
|
||||||
|
test "fromCode V should raise" {
|
||||||
|
Expect.throws (fun () -> Expiration.Parse "V" |> ignore)
|
||||||
|
"An unknown code should have raised an exception"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
[<Tests>]
|
||||||
|
let listPreferencesTests =
|
||||||
|
testList "ListPreferences" [
|
||||||
|
test "FontStack is correct for native fonts" {
|
||||||
|
Expect.equal ListPreferences.Empty.FontStack
|
||||||
|
"""system-ui,-apple-system,"Segoe UI",Roboto,Ubuntu,"Liberation Sans",Cantarell,"Helvetica Neue",sans-serif"""
|
||||||
|
"The expected native font stack was incorrect"
|
||||||
|
}
|
||||||
|
test "FontStack is correct for specific fonts" {
|
||||||
|
Expect.equal { ListPreferences.Empty with Fonts = "Arial,sans-serif" }.FontStack "Arial,sans-serif"
|
||||||
|
"The specified fonts were not returned correctly"
|
||||||
|
}
|
||||||
|
test "Empty is as expected" {
|
||||||
|
let mt = ListPreferences.Empty
|
||||||
|
Expect.equal mt.DaysToExpire 14 "The default days to expire should have been 14"
|
||||||
|
Expect.equal mt.DaysToKeepNew 7 "The default days to keep new should have been 7"
|
||||||
|
Expect.equal mt.LongTermUpdateWeeks 4 "The default long term update weeks should have been 4"
|
||||||
|
Expect.equal mt.EmailFromName "PrayerTracker" "The default e-mail from name should have been PrayerTracker"
|
||||||
|
Expect.equal mt.EmailFromAddress "prayer@bitbadger.solutions"
|
||||||
|
"The default e-mail from address should have been prayer@bitbadger.solutions"
|
||||||
|
Expect.equal mt.Fonts "native" "The default list fonts were incorrect"
|
||||||
|
Expect.equal mt.HeadingColor "maroon" "The default heading text color should have been maroon"
|
||||||
|
Expect.equal mt.LineColor "navy" "The default heading line color should have been navy"
|
||||||
|
Expect.equal mt.HeadingFontSize 16 "The default heading font size should have been 16"
|
||||||
|
Expect.equal mt.TextFontSize 12 "The default text font size should have been 12"
|
||||||
|
Expect.equal mt.RequestSort SortByDate "The default request sort should have been by date"
|
||||||
|
Expect.equal mt.GroupPassword "" "The default group password should have been blank"
|
||||||
|
Expect.equal mt.DefaultEmailType HtmlFormat "The default e-mail type should have been HTML"
|
||||||
|
Expect.isFalse mt.IsPublic "The isPublic flag should not have been set"
|
||||||
|
Expect.equal (string mt.TimeZoneId) "America/Denver" "The default time zone should have been America/Denver"
|
||||||
|
Expect.equal mt.PageSize 100 "The default page size should have been 100"
|
||||||
|
Expect.equal mt.AsOfDateDisplay NoDisplay "The as-of date display should have been No Display"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
[<Tests>]
|
||||||
|
let memberTests =
|
||||||
|
testList "Member" [
|
||||||
|
test "Empty is as expected" {
|
||||||
|
let mt = Member.Empty
|
||||||
|
Expect.equal mt.Id.Value Guid.Empty "The member ID should have been an empty GUID"
|
||||||
|
Expect.equal mt.SmallGroupId.Value Guid.Empty "The small group ID should have been an empty GUID"
|
||||||
|
Expect.equal mt.Name "" "The member name should have been blank"
|
||||||
|
Expect.equal mt.Email "" "The member e-mail address should have been blank"
|
||||||
|
Expect.isNone mt.Format "The preferred e-mail format should not exist"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
[<Tests>]
|
||||||
|
let prayerRequestTests =
|
||||||
|
let instantNow = SystemClock.Instance.GetCurrentInstant
|
||||||
|
let localDateNow () = (instantNow ()).InUtc().Date
|
||||||
|
testList "PrayerRequest" [
|
||||||
|
test "Empty is as expected" {
|
||||||
|
let mt = PrayerRequest.Empty
|
||||||
|
Expect.equal mt.Id.Value Guid.Empty "The request ID should have been an empty GUID"
|
||||||
|
Expect.equal mt.RequestType CurrentRequest "The request type should have been Current"
|
||||||
|
Expect.equal mt.UserId.Value Guid.Empty "The user ID should have been an empty GUID"
|
||||||
|
Expect.equal mt.SmallGroupId.Value Guid.Empty "The small group ID should have been an empty GUID"
|
||||||
|
Expect.equal mt.EnteredDate Instant.MinValue "The entered date should have been the minimum"
|
||||||
|
Expect.equal mt.UpdatedDate Instant.MinValue "The updated date should have been the minimum"
|
||||||
|
Expect.isNone mt.Requestor "The requestor should not exist"
|
||||||
|
Expect.equal mt.Text "" "The request text should have been blank"
|
||||||
|
Expect.isFalse mt.NotifyChaplain "The notify chaplain flag should not have been set"
|
||||||
|
Expect.equal mt.Expiration Automatic "The expiration should have been Automatic"
|
||||||
|
}
|
||||||
|
test "IsExpired always returns false for expecting requests" {
|
||||||
|
{ PrayerRequest.Empty with RequestType = Expecting }.IsExpired (localDateNow ()) SmallGroup.Empty
|
||||||
|
|> Flip.Expect.isFalse "An expecting request should never be considered expired"
|
||||||
|
}
|
||||||
|
test "IsExpired always returns false for manually-expired requests" {
|
||||||
|
{ PrayerRequest.Empty with
|
||||||
|
UpdatedDate = (instantNow ()) - Duration.FromDays 1
|
||||||
|
Expiration = Manual }.IsExpired (localDateNow ()) SmallGroup.Empty
|
||||||
|
|> Flip.Expect.isFalse "A never-expired request should never be considered expired"
|
||||||
|
}
|
||||||
|
test "IsExpired always returns false for long term/recurring requests" {
|
||||||
|
{ PrayerRequest.Empty with RequestType = LongTermRequest }.IsExpired (localDateNow ()) SmallGroup.Empty
|
||||||
|
|> Flip.Expect.isFalse "A recurring/long-term request should never be considered expired"
|
||||||
|
}
|
||||||
|
test "IsExpired always returns true for force-expired requests" {
|
||||||
|
{ PrayerRequest.Empty with UpdatedDate = (instantNow ()); Expiration = Forced }.IsExpired
|
||||||
|
(localDateNow ()) SmallGroup.Empty
|
||||||
|
|> Flip.Expect.isTrue "A force-expired request should always be considered expired"
|
||||||
|
}
|
||||||
|
test "IsExpired returns false for non-expired requests" {
|
||||||
|
let now = instantNow ()
|
||||||
|
{ PrayerRequest.Empty with UpdatedDate = now - Duration.FromDays 5 }.IsExpired
|
||||||
|
(now.InUtc().Date) SmallGroup.Empty
|
||||||
|
|> Flip.Expect.isFalse "A request updated 5 days ago should not be considered expired"
|
||||||
|
}
|
||||||
|
test "IsExpired returns true for expired requests" {
|
||||||
|
let now = instantNow ()
|
||||||
|
{ PrayerRequest.Empty with UpdatedDate = now - Duration.FromDays 15 }.IsExpired
|
||||||
|
(now.InUtc().Date) SmallGroup.Empty
|
||||||
|
|> Flip.Expect.isTrue "A request updated 15 days ago should be considered expired"
|
||||||
|
}
|
||||||
|
test "IsExpired returns true for same-day expired requests" {
|
||||||
|
let now = instantNow ()
|
||||||
|
{ PrayerRequest.Empty with
|
||||||
|
UpdatedDate = now - (Duration.FromDays 14) - (Duration.FromSeconds 1L) }.IsExpired
|
||||||
|
(now.InUtc().Date) SmallGroup.Empty
|
||||||
|
|> Flip.Expect.isTrue "A request entered a second before midnight should be considered expired"
|
||||||
|
}
|
||||||
|
test "UpdateRequired returns false for expired requests" {
|
||||||
|
{ PrayerRequest.Empty with Expiration = Forced }.UpdateRequired (localDateNow ()) SmallGroup.Empty
|
||||||
|
|> Flip.Expect.isFalse "An expired request should not require an update"
|
||||||
|
}
|
||||||
|
test "UpdateRequired returns false when an update is not required for an active request" {
|
||||||
|
let now = instantNow ()
|
||||||
|
{ PrayerRequest.Empty with
|
||||||
|
RequestType = LongTermRequest
|
||||||
|
UpdatedDate = now - Duration.FromDays 14 }.UpdateRequired (localDateNow ()) SmallGroup.Empty
|
||||||
|
|> Flip.Expect.isFalse "An active request updated 14 days ago should not require an update until 28 days"
|
||||||
|
}
|
||||||
|
test "UpdateRequired returns true when an update is required for an active request" {
|
||||||
|
let now = instantNow ()
|
||||||
|
{ PrayerRequest.Empty with
|
||||||
|
RequestType = LongTermRequest
|
||||||
|
UpdatedDate = now - Duration.FromDays 34 }.UpdateRequired (localDateNow ()) SmallGroup.Empty
|
||||||
|
|> Flip.Expect.isTrue "An active request updated 34 days ago should require an update (past 28 days)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
[<Tests>]
|
||||||
|
let prayerRequestTypeTests =
|
||||||
|
testList "PrayerRequestType" [
|
||||||
|
testList "ToString" [
|
||||||
|
test "CurrentRequest code is correct" {
|
||||||
|
Expect.equal (string CurrentRequest) "C" "The code for CurrentRequest should have been \"C\""
|
||||||
|
}
|
||||||
|
test "LongTermRequest code is correct" {
|
||||||
|
Expect.equal (string LongTermRequest) "L" "The code for LongTermRequest should have been \"L\""
|
||||||
|
}
|
||||||
|
test "PraiseReport code is correct" {
|
||||||
|
Expect.equal (string PraiseReport) "P" "The code for PraiseReport should have been \"P\""
|
||||||
|
}
|
||||||
|
test "Expecting code is correct" {
|
||||||
|
Expect.equal (string Expecting) "E" "The code for Expecting should have been \"E\""
|
||||||
|
}
|
||||||
|
test "Announcement code is correct" {
|
||||||
|
Expect.equal (string Announcement) "A" "The code for Announcement should have been \"A\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
testList "Parse" [
|
||||||
|
test "C should return CurrentRequest" {
|
||||||
|
Expect.equal (PrayerRequestType.Parse "C") CurrentRequest
|
||||||
|
"\"C\" should have been converted to CurrentRequest"
|
||||||
|
}
|
||||||
|
test "L should return LongTermRequest" {
|
||||||
|
Expect.equal (PrayerRequestType.Parse "L") LongTermRequest
|
||||||
|
"\"L\" should have been converted to LongTermRequest"
|
||||||
|
}
|
||||||
|
test "P should return PraiseReport" {
|
||||||
|
Expect.equal (PrayerRequestType.Parse "P") PraiseReport
|
||||||
|
"\"P\" should have been converted to PraiseReport"
|
||||||
|
}
|
||||||
|
test "E should return Expecting" {
|
||||||
|
Expect.equal (PrayerRequestType.Parse "E") Expecting "\"E\" should have been converted to Expecting"
|
||||||
|
}
|
||||||
|
test "A should return Announcement" {
|
||||||
|
Expect.equal (PrayerRequestType.Parse "A") Announcement
|
||||||
|
"\"A\" should have been converted to Announcement"
|
||||||
|
}
|
||||||
|
test "R should raise" {
|
||||||
|
Expect.throws (fun () -> PrayerRequestType.Parse "R" |> ignore)
|
||||||
|
"An unknown code should have raised an exception"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
[<Tests>]
|
||||||
|
let requestSortTests =
|
||||||
|
testList "RequestSort" [
|
||||||
|
testList "ToString" [
|
||||||
|
test "SortByDate code is correct" {
|
||||||
|
Expect.equal (string SortByDate) "D" "The code for SortByDate should have been \"D\""
|
||||||
|
}
|
||||||
|
test "SortByRequestor code is correct" {
|
||||||
|
Expect.equal (string SortByRequestor) "R" "The code for SortByRequestor should have been \"R\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
testList "Parse" [
|
||||||
|
test "D should return SortByDate" {
|
||||||
|
Expect.equal (RequestSort.Parse "D") SortByDate "\"D\" should have been converted to SortByDate"
|
||||||
|
}
|
||||||
|
test "R should return SortByRequestor" {
|
||||||
|
Expect.equal (RequestSort.Parse "R") SortByRequestor
|
||||||
|
"\"R\" should have been converted to SortByRequestor"
|
||||||
|
}
|
||||||
|
test "Q should raise" {
|
||||||
|
Expect.throws (fun () -> RequestSort.Parse "Q" |> ignore)
|
||||||
|
"An unknown code should have raised an exception"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
[<Tests>]
|
||||||
|
let smallGroupTests =
|
||||||
|
testList "SmallGroup" [
|
||||||
|
let now = Instant.FromDateTimeUtc (DateTime (2017, 5, 12, 12, 15, 0, DateTimeKind.Utc))
|
||||||
|
let withFakeClock f () =
|
||||||
|
FakeClock now |> f
|
||||||
|
yield test "Empty is as expected" {
|
||||||
|
let mt = SmallGroup.Empty
|
||||||
|
Expect.equal mt.Id.Value Guid.Empty "The small group ID should have been an empty GUID"
|
||||||
|
Expect.equal mt.ChurchId.Value Guid.Empty "The church ID should have been an empty GUID"
|
||||||
|
Expect.equal mt.Name "" "The name should have been blank"
|
||||||
|
}
|
||||||
|
yield! testFixture withFakeClock [
|
||||||
|
"LocalTimeNow adjusts the time ahead of UTC",
|
||||||
|
fun clock ->
|
||||||
|
let grp =
|
||||||
|
{ SmallGroup.Empty with
|
||||||
|
Preferences = { ListPreferences.Empty with TimeZoneId = TimeZoneId "Europe/Berlin" }
|
||||||
|
}
|
||||||
|
Expect.isGreaterThan (grp.LocalTimeNow clock) (now.InUtc().LocalDateTime)
|
||||||
|
"UTC to Europe/Berlin should have added hours"
|
||||||
|
"LocalTimeNow adjusts the time behind UTC",
|
||||||
|
fun clock ->
|
||||||
|
Expect.isLessThan (SmallGroup.Empty.LocalTimeNow clock) (now.InUtc().LocalDateTime)
|
||||||
|
"UTC to America/Denver should have subtracted hours"
|
||||||
|
"LocalTimeNow returns UTC when the time zone is invalid",
|
||||||
|
fun clock ->
|
||||||
|
let grp =
|
||||||
|
{ SmallGroup.Empty with
|
||||||
|
Preferences = { ListPreferences.Empty with TimeZoneId = TimeZoneId "garbage" }
|
||||||
|
}
|
||||||
|
Expect.equal (grp.LocalTimeNow clock) (now.InUtc().LocalDateTime)
|
||||||
|
"UTC should have been returned for an invalid time zone"
|
||||||
|
]
|
||||||
|
yield test "localTimeNow fails when clock is not passed" {
|
||||||
|
Expect.throws (fun () -> SmallGroup.Empty.LocalTimeNow null |> ignore)
|
||||||
|
"Should have raised an exception for null clock"
|
||||||
|
}
|
||||||
|
yield test "LocalDateNow returns the date portion" {
|
||||||
|
let clock = FakeClock (Instant.FromDateTimeUtc (DateTime (2017, 5, 12, 1, 15, 0, DateTimeKind.Utc)))
|
||||||
|
Expect.isLessThan (SmallGroup.Empty.LocalDateNow clock) (now.InUtc().Date)
|
||||||
|
"The date should have been a day earlier"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
[<Tests>]
|
||||||
|
let userTests =
|
||||||
|
testList "User" [
|
||||||
|
test "Empty is as expected" {
|
||||||
|
let mt = User.Empty
|
||||||
|
Expect.equal mt.Id.Value Guid.Empty "The user ID should have been an empty GUID"
|
||||||
|
Expect.equal mt.FirstName "" "The first name should have been blank"
|
||||||
|
Expect.equal mt.LastName "" "The last name should have been blank"
|
||||||
|
Expect.equal mt.Email "" "The e-mail address should have been blank"
|
||||||
|
Expect.isFalse mt.IsAdmin "The is admin flag should not have been set"
|
||||||
|
Expect.equal mt.PasswordHash "" "The password hash should have been blank"
|
||||||
|
}
|
||||||
|
test "Name concatenates first and last names" {
|
||||||
|
let user = { User.Empty with FirstName = "Unit"; LastName = "Test" }
|
||||||
|
Expect.equal user.Name "Unit Test" "The full name should be the first and last, separated by a space"
|
||||||
|
}
|
||||||
|
]
|
@ -15,13 +15,11 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Expecto" Version="10.2.1" />
|
<PackageReference Include="Expecto" Version="10.2.1" />
|
||||||
<PackageReference Include="NodaTime.Testing" Version="3.1.11" />
|
<PackageReference Include="NodaTime.Testing" Version="3.2.1" />
|
||||||
<PackageReference Update="FSharp.Core" Version="8.0.300" />
|
<PackageReference Update="FSharp.Core" Version="9.0.101" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\PrayerTracker.Data\PrayerTracker.Data.fsproj" />
|
|
||||||
<ProjectReference Include="..\PrayerTracker.UI\PrayerTracker.UI.fsproj" />
|
|
||||||
<ProjectReference Include="..\PrayerTracker\PrayerTracker.fsproj" />
|
<ProjectReference Include="..\PrayerTracker\PrayerTracker.fsproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
@ -15,19 +15,16 @@ let countAll _ = true
|
|||||||
|
|
||||||
|
|
||||||
module ReferenceListTests =
|
module ReferenceListTests =
|
||||||
|
|
||||||
[<Tests>]
|
[<Tests>]
|
||||||
let asOfDateListTests =
|
let asOfDateListTests =
|
||||||
testList "ReferenceList.asOfDateList" [
|
testList "ReferenceList.asOfDateList" [
|
||||||
test "has all three options listed" {
|
test "has all three options listed" {
|
||||||
let asOf = ReferenceList.asOfDateList _s
|
let asOf = ReferenceList.asOfDateList _s
|
||||||
Expect.hasCountOf asOf 3u countAll "There should have been 3 as-of choices returned"
|
Expect.hasCountOf asOf 3u countAll "There should have been 3 as-of choices returned"
|
||||||
Expect.exists asOf (fun (x, _) -> x = AsOfDateDisplay.toCode NoDisplay)
|
Expect.exists asOf (fun (x, _) -> x = string NoDisplay) "The option for no display was not found"
|
||||||
"The option for no display was not found"
|
Expect.exists asOf (fun (x, _) -> x = string ShortDate) "The option for a short date was not found"
|
||||||
Expect.exists asOf (fun (x, _) -> x = AsOfDateDisplay.toCode ShortDate)
|
Expect.exists asOf (fun (x, _) -> x = string LongDate) "The option for a full date was not found"
|
||||||
"The option for a short date was not found"
|
|
||||||
Expect.exists asOf (fun (x, _) -> x = AsOfDateDisplay.toCode LongDate)
|
|
||||||
"The option for a full date was not found"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -41,35 +38,35 @@ module ReferenceListTests =
|
|||||||
Expect.equal (fst top) "" "The default option should have been blank"
|
Expect.equal (fst top) "" "The default option should have been blank"
|
||||||
Expect.equal (snd top).Value "Group Default (HTML Format)" "The default option label was incorrect"
|
Expect.equal (snd top).Value "Group Default (HTML Format)" "The default option label was incorrect"
|
||||||
let nxt = typs |> Seq.skip 1 |> Seq.head
|
let nxt = typs |> Seq.skip 1 |> Seq.head
|
||||||
Expect.equal (fst nxt) (EmailFormat.toCode HtmlFormat) "The 2nd option should have been HTML"
|
Expect.equal (fst nxt) (string HtmlFormat) "The 2nd option should have been HTML"
|
||||||
let lst = typs |> Seq.last
|
let lst = typs |> Seq.last
|
||||||
Expect.equal (fst lst) (EmailFormat.toCode PlainTextFormat) "The 3rd option should have been plain text"
|
Expect.equal (fst lst) (string PlainTextFormat) "The 3rd option should have been plain text"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
[<Tests>]
|
[<Tests>]
|
||||||
let expirationListTests =
|
let expirationListTests =
|
||||||
testList "ReferenceList.expirationList" [
|
testList "ReferenceList.expirationList" [
|
||||||
test "excludes immediate expiration if not required" {
|
test "excludes immediate expiration if not required" {
|
||||||
let exps = ReferenceList.expirationList _s false
|
let exps = ReferenceList.expirationList _s false
|
||||||
Expect.hasCountOf exps 2u countAll "There should have been 2 expiration types returned"
|
Expect.hasCountOf exps 2u countAll "There should have been 2 expiration types returned"
|
||||||
Expect.exists exps (fun (exp, _) -> exp = Expiration.toCode Automatic)
|
Expect.exists exps (fun (exp, _) -> exp = string Automatic)
|
||||||
"The option for automatic expiration was not found"
|
"The option for automatic expiration was not found"
|
||||||
Expect.exists exps (fun (exp, _) -> exp = Expiration.toCode Manual)
|
Expect.exists exps (fun (exp, _) -> exp = string Manual)
|
||||||
"The option for manual expiration was not found"
|
"The option for manual expiration was not found"
|
||||||
}
|
}
|
||||||
test "includes immediate expiration if required" {
|
test "includes immediate expiration if required" {
|
||||||
let exps = ReferenceList.expirationList _s true
|
let exps = ReferenceList.expirationList _s true
|
||||||
Expect.hasCountOf exps 3u countAll "There should have been 3 expiration types returned"
|
Expect.hasCountOf exps 3u countAll "There should have been 3 expiration types returned"
|
||||||
Expect.exists exps (fun (exp, _) -> exp = Expiration.toCode Automatic)
|
Expect.exists exps (fun (exp, _) -> exp = string Automatic)
|
||||||
"The option for automatic expiration was not found"
|
"The option for automatic expiration was not found"
|
||||||
Expect.exists exps (fun (exp, _) -> exp = Expiration.toCode Manual)
|
Expect.exists exps (fun (exp, _) -> exp = string Manual)
|
||||||
"The option for manual expiration was not found"
|
"The option for manual expiration was not found"
|
||||||
Expect.exists exps (fun (exp, _) -> exp = Expiration.toCode Forced)
|
Expect.exists exps (fun (exp, _) -> exp = string Forced)
|
||||||
"The option for immediate expiration was not found"
|
"The option for immediate expiration was not found"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
[<Tests>]
|
[<Tests>]
|
||||||
let requestTypeListTests =
|
let requestTypeListTests =
|
||||||
testList "ReferenceList.requestTypeList" [
|
testList "ReferenceList.requestTypeList" [
|
||||||
@ -132,7 +129,7 @@ let appViewInfoTests =
|
|||||||
let assignGroupsTests =
|
let assignGroupsTests =
|
||||||
testList "AssignGroups" [
|
testList "AssignGroups" [
|
||||||
test "fromUser populates correctly" {
|
test "fromUser populates correctly" {
|
||||||
let usr = { User.empty with Id = (Guid.NewGuid >> UserId) (); FirstName = "Alice"; LastName = "Bob" }
|
let usr = { User.Empty with Id = (Guid.NewGuid >> UserId) (); FirstName = "Alice"; LastName = "Bob" }
|
||||||
let asg = AssignGroups.fromUser usr
|
let asg = AssignGroups.fromUser usr
|
||||||
Expect.equal asg.UserId (shortGuid usr.Id.Value) "The user ID was not filled correctly"
|
Expect.equal asg.UserId (shortGuid usr.Id.Value) "The user ID was not filled correctly"
|
||||||
Expect.equal asg.UserName usr.Name "The user's name was not filled correctly"
|
Expect.equal asg.UserName usr.Name "The user's name was not filled correctly"
|
||||||
@ -145,7 +142,7 @@ let editChurchTests =
|
|||||||
testList "EditChurch" [
|
testList "EditChurch" [
|
||||||
test "fromChurch populates correctly when interface exists" {
|
test "fromChurch populates correctly when interface exists" {
|
||||||
let church =
|
let church =
|
||||||
{ Church.empty with
|
{ Church.Empty with
|
||||||
Id = (Guid.NewGuid >> ChurchId) ()
|
Id = (Guid.NewGuid >> ChurchId) ()
|
||||||
Name = "Unit Test"
|
Name = "Unit Test"
|
||||||
City = "Testlandia"
|
City = "Testlandia"
|
||||||
@ -166,7 +163,7 @@ let editChurchTests =
|
|||||||
test "fromChurch populates correctly when interface does not exist" {
|
test "fromChurch populates correctly when interface does not exist" {
|
||||||
let edit =
|
let edit =
|
||||||
EditChurch.fromChurch
|
EditChurch.fromChurch
|
||||||
{ Church.empty with
|
{ Church.Empty with
|
||||||
Id = (Guid.NewGuid >> ChurchId) ()
|
Id = (Guid.NewGuid >> ChurchId) ()
|
||||||
Name = "Unit Test"
|
Name = "Unit Test"
|
||||||
City = "Testlandia"
|
City = "Testlandia"
|
||||||
@ -201,7 +198,7 @@ let editChurchTests =
|
|||||||
HasInterface = Some true
|
HasInterface = Some true
|
||||||
InterfaceAddress = Some "https://test.units"
|
InterfaceAddress = Some "https://test.units"
|
||||||
}
|
}
|
||||||
let church = edit.PopulateChurch Church.empty
|
let church = edit.PopulateChurch Church.Empty
|
||||||
Expect.notEqual (shortGuid church.Id.Value) edit.ChurchId "The church ID should not have been modified"
|
Expect.notEqual (shortGuid church.Id.Value) edit.ChurchId "The church ID should not have been modified"
|
||||||
Expect.equal church.Name edit.Name "The church name was not updated correctly"
|
Expect.equal church.Name edit.Name "The church name was not updated correctly"
|
||||||
Expect.equal church.City edit.City "The church's city was not updated correctly"
|
Expect.equal church.City edit.City "The church's city was not updated correctly"
|
||||||
@ -216,7 +213,7 @@ let editChurchTests =
|
|||||||
Name = "Test Baptist Church"
|
Name = "Test Baptist Church"
|
||||||
City = "Testerville"
|
City = "Testerville"
|
||||||
State = "TE"
|
State = "TE"
|
||||||
}.PopulateChurch Church.empty
|
}.PopulateChurch Church.Empty
|
||||||
Expect.isFalse church.HasVpsInterface "The church should show that it has an interface"
|
Expect.isFalse church.HasVpsInterface "The church should show that it has an interface"
|
||||||
Expect.isNone church.InterfaceAddress "The interface address should exist"
|
Expect.isNone church.InterfaceAddress "The interface address should exist"
|
||||||
}
|
}
|
||||||
@ -227,7 +224,7 @@ let editMemberTests =
|
|||||||
testList "EditMember" [
|
testList "EditMember" [
|
||||||
test "fromMember populates with group default format" {
|
test "fromMember populates with group default format" {
|
||||||
let mbr =
|
let mbr =
|
||||||
{ Member.empty with
|
{ Member.Empty with
|
||||||
Id = (Guid.NewGuid >> MemberId) ()
|
Id = (Guid.NewGuid >> MemberId) ()
|
||||||
Name = "Test Name"
|
Name = "Test Name"
|
||||||
Email = "test_units@example.com"
|
Email = "test_units@example.com"
|
||||||
@ -239,8 +236,8 @@ let editMemberTests =
|
|||||||
Expect.equal edit.Format "" "The e-mail format should have been blank for group default"
|
Expect.equal edit.Format "" "The e-mail format should have been blank for group default"
|
||||||
}
|
}
|
||||||
test "fromMember populates with specific format" {
|
test "fromMember populates with specific format" {
|
||||||
let edit = EditMember.fromMember { Member.empty with Format = Some HtmlFormat }
|
let edit = EditMember.fromMember { Member.Empty with Format = Some HtmlFormat }
|
||||||
Expect.equal edit.Format (EmailFormat.toCode HtmlFormat) "The e-mail format was not filled correctly"
|
Expect.equal edit.Format (string HtmlFormat) "The e-mail format was not filled correctly"
|
||||||
}
|
}
|
||||||
test "empty is as expected" {
|
test "empty is as expected" {
|
||||||
let edit = EditMember.empty
|
let edit = EditMember.empty
|
||||||
@ -262,17 +259,16 @@ let editMemberTests =
|
|||||||
let editPreferencesTests =
|
let editPreferencesTests =
|
||||||
testList "EditPreferences" [
|
testList "EditPreferences" [
|
||||||
test "fromPreferences succeeds for native fonts, named colors, and private list" {
|
test "fromPreferences succeeds for native fonts, named colors, and private list" {
|
||||||
let prefs = ListPreferences.empty
|
let prefs = ListPreferences.Empty
|
||||||
let edit = EditPreferences.fromPreferences prefs
|
let edit = EditPreferences.fromPreferences prefs
|
||||||
Expect.equal edit.ExpireDays prefs.DaysToExpire "The expiration days were not filled correctly"
|
Expect.equal edit.ExpireDays prefs.DaysToExpire "The expiration days were not filled correctly"
|
||||||
Expect.equal edit.DaysToKeepNew prefs.DaysToKeepNew "The days to keep new were not filled correctly"
|
Expect.equal edit.DaysToKeepNew prefs.DaysToKeepNew "The days to keep new were not filled correctly"
|
||||||
Expect.equal edit.LongTermUpdateWeeks prefs.LongTermUpdateWeeks
|
Expect.equal edit.LongTermUpdateWeeks prefs.LongTermUpdateWeeks
|
||||||
"The weeks for update were not filled correctly"
|
"The weeks for update were not filled correctly"
|
||||||
Expect.equal edit.RequestSort (RequestSort.toCode prefs.RequestSort)
|
Expect.equal edit.RequestSort (string prefs.RequestSort) "The request sort was not filled correctly"
|
||||||
"The request sort was not filled correctly"
|
|
||||||
Expect.equal edit.EmailFromName prefs.EmailFromName "The e-mail from name was not filled correctly"
|
Expect.equal edit.EmailFromName prefs.EmailFromName "The e-mail from name was not filled correctly"
|
||||||
Expect.equal edit.EmailFromAddress prefs.EmailFromAddress "The e-mail from address was not filled correctly"
|
Expect.equal edit.EmailFromAddress prefs.EmailFromAddress "The e-mail from address was not filled correctly"
|
||||||
Expect.equal edit.DefaultEmailType (EmailFormat.toCode prefs.DefaultEmailType)
|
Expect.equal edit.DefaultEmailType (string prefs.DefaultEmailType)
|
||||||
"The default e-mail type was not filled correctly"
|
"The default e-mail type was not filled correctly"
|
||||||
Expect.equal edit.LineColorType "Name" "The heading line color type was not derived correctly"
|
Expect.equal edit.LineColorType "Name" "The heading line color type was not derived correctly"
|
||||||
Expect.equal edit.LineColor prefs.LineColor "The heading line color was not filled correctly"
|
Expect.equal edit.LineColor prefs.LineColor "The heading line color was not filled correctly"
|
||||||
@ -282,17 +278,16 @@ let editPreferencesTests =
|
|||||||
Expect.isNone edit.Fonts "The list fonts should not exist for native font stack"
|
Expect.isNone edit.Fonts "The list fonts should not exist for native font stack"
|
||||||
Expect.equal edit.HeadingFontSize prefs.HeadingFontSize "The heading font size was not filled correctly"
|
Expect.equal edit.HeadingFontSize prefs.HeadingFontSize "The heading font size was not filled correctly"
|
||||||
Expect.equal edit.ListFontSize prefs.TextFontSize "The list text font size was not filled correctly"
|
Expect.equal edit.ListFontSize prefs.TextFontSize "The list text font size was not filled correctly"
|
||||||
Expect.equal edit.TimeZone (TimeZoneId.toString prefs.TimeZoneId) "The time zone was not filled correctly"
|
Expect.equal edit.TimeZone (string prefs.TimeZoneId) "The time zone was not filled correctly"
|
||||||
Expect.isSome edit.GroupPassword "The group password should have been set"
|
Expect.isSome edit.GroupPassword "The group password should have been set"
|
||||||
Expect.equal edit.GroupPassword (Some prefs.GroupPassword) "The group password was not filled correctly"
|
Expect.equal edit.GroupPassword (Some prefs.GroupPassword) "The group password was not filled correctly"
|
||||||
Expect.equal edit.Visibility GroupVisibility.PrivateList
|
Expect.equal edit.Visibility GroupVisibility.PrivateList
|
||||||
"The list visibility was not derived correctly"
|
"The list visibility was not derived correctly"
|
||||||
Expect.equal edit.PageSize prefs.PageSize "The page size was not filled correctly"
|
Expect.equal edit.PageSize prefs.PageSize "The page size was not filled correctly"
|
||||||
Expect.equal edit.AsOfDate (AsOfDateDisplay.toCode prefs.AsOfDateDisplay)
|
Expect.equal edit.AsOfDate (string prefs.AsOfDateDisplay) "The as-of date display was not filled correctly"
|
||||||
"The as-of date display was not filled correctly"
|
|
||||||
}
|
}
|
||||||
test "fromPreferences succeeds for RGB line color and password-protected list" {
|
test "fromPreferences succeeds for RGB line color and password-protected list" {
|
||||||
let prefs = { ListPreferences.empty with LineColor = "#ff0000"; GroupPassword = "pw" }
|
let prefs = { ListPreferences.Empty with LineColor = "#ff0000"; GroupPassword = "pw" }
|
||||||
let edit = EditPreferences.fromPreferences prefs
|
let edit = EditPreferences.fromPreferences prefs
|
||||||
Expect.equal edit.LineColorType "RGB" "The heading line color type was not derived correctly"
|
Expect.equal edit.LineColorType "RGB" "The heading line color type was not derived correctly"
|
||||||
Expect.equal edit.LineColor prefs.LineColor "The heading line color was not filled correctly"
|
Expect.equal edit.LineColor prefs.LineColor "The heading line color was not filled correctly"
|
||||||
@ -302,7 +297,7 @@ let editPreferencesTests =
|
|||||||
"The list visibility was not derived correctly"
|
"The list visibility was not derived correctly"
|
||||||
}
|
}
|
||||||
test "fromPreferences succeeds for RGB text color and public list" {
|
test "fromPreferences succeeds for RGB text color and public list" {
|
||||||
let prefs = { ListPreferences.empty with HeadingColor = "#0000ff"; IsPublic = true }
|
let prefs = { ListPreferences.Empty with HeadingColor = "#0000ff"; IsPublic = true }
|
||||||
let edit = EditPreferences.fromPreferences prefs
|
let edit = EditPreferences.fromPreferences prefs
|
||||||
Expect.equal edit.HeadingColorType "RGB" "The heading text color type was not derived correctly"
|
Expect.equal edit.HeadingColorType "RGB" "The heading text color type was not derived correctly"
|
||||||
Expect.equal edit.HeadingColor prefs.HeadingColor "The heading text color was not filled correctly"
|
Expect.equal edit.HeadingColor prefs.HeadingColor "The heading text color was not filled correctly"
|
||||||
@ -312,7 +307,7 @@ let editPreferencesTests =
|
|||||||
"The list visibility was not derived correctly"
|
"The list visibility was not derived correctly"
|
||||||
}
|
}
|
||||||
test "fromPreferences succeeds for non-native fonts" {
|
test "fromPreferences succeeds for non-native fonts" {
|
||||||
let prefs = { ListPreferences.empty with Fonts = "Arial,sans-serif" }
|
let prefs = { ListPreferences.Empty with Fonts = "Arial,sans-serif" }
|
||||||
let edit = EditPreferences.fromPreferences prefs
|
let edit = EditPreferences.fromPreferences prefs
|
||||||
Expect.isFalse edit.IsNative "The IsNative flag should have been false"
|
Expect.isFalse edit.IsNative "The IsNative flag should have been false"
|
||||||
Expect.isSome edit.Fonts "The fonts should have been filled for non-native fonts"
|
Expect.isSome edit.Fonts "The fonts should have been filled for non-native fonts"
|
||||||
@ -326,18 +321,16 @@ let editRequestTests =
|
|||||||
test "empty is as expected" {
|
test "empty is as expected" {
|
||||||
let mt = EditRequest.empty
|
let mt = EditRequest.empty
|
||||||
Expect.equal mt.RequestId emptyGuid "The request ID should be an empty GUID"
|
Expect.equal mt.RequestId emptyGuid "The request ID should be an empty GUID"
|
||||||
Expect.equal mt.RequestType (PrayerRequestType.toCode CurrentRequest)
|
Expect.equal mt.RequestType (string CurrentRequest) "The request type should have been \"Current\""
|
||||||
"The request type should have been \"Current\""
|
|
||||||
Expect.isNone mt.EnteredDate "The entered date should have been None"
|
Expect.isNone mt.EnteredDate "The entered date should have been None"
|
||||||
Expect.isNone mt.SkipDateUpdate """The "skip date update" flag should have been None"""
|
Expect.isNone mt.SkipDateUpdate """The "skip date update" flag should have been None"""
|
||||||
Expect.isNone mt.Requestor "The requestor should have been None"
|
Expect.isNone mt.Requestor "The requestor should have been None"
|
||||||
Expect.equal mt.Expiration (Expiration.toCode Automatic)
|
Expect.equal mt.Expiration (string Automatic) """The expiration should have been "A" (Automatic)"""
|
||||||
"""The expiration should have been "A" (Automatic)"""
|
|
||||||
Expect.equal mt.Text "" "The text should have been blank"
|
Expect.equal mt.Text "" "The text should have been blank"
|
||||||
}
|
}
|
||||||
test "fromRequest succeeds" {
|
test "fromRequest succeeds" {
|
||||||
let req =
|
let req =
|
||||||
{ PrayerRequest.empty with
|
{ PrayerRequest.Empty with
|
||||||
Id = (Guid.NewGuid >> PrayerRequestId) ()
|
Id = (Guid.NewGuid >> PrayerRequestId) ()
|
||||||
RequestType = CurrentRequest
|
RequestType = CurrentRequest
|
||||||
Requestor = Some "Me"
|
Requestor = Some "Me"
|
||||||
@ -346,10 +339,9 @@ let editRequestTests =
|
|||||||
}
|
}
|
||||||
let edit = EditRequest.fromRequest req
|
let edit = EditRequest.fromRequest req
|
||||||
Expect.equal edit.RequestId (shortGuid req.Id.Value) "The request ID was not filled correctly"
|
Expect.equal edit.RequestId (shortGuid req.Id.Value) "The request ID was not filled correctly"
|
||||||
Expect.equal edit.RequestType (PrayerRequestType.toCode req.RequestType)
|
Expect.equal edit.RequestType (string req.RequestType) "The request type was not filled correctly"
|
||||||
"The request type was not filled correctly"
|
|
||||||
Expect.equal edit.Requestor req.Requestor "The requestor was not filled correctly"
|
Expect.equal edit.Requestor req.Requestor "The requestor was not filled correctly"
|
||||||
Expect.equal edit.Expiration (Expiration.toCode Manual) "The expiration was not filled correctly"
|
Expect.equal edit.Expiration (string Manual) "The expiration was not filled correctly"
|
||||||
Expect.equal edit.Text req.Text "The text was not filled correctly"
|
Expect.equal edit.Text req.Text "The text was not filled correctly"
|
||||||
}
|
}
|
||||||
test "isNew works for a new request" {
|
test "isNew works for a new request" {
|
||||||
@ -366,7 +358,7 @@ let editSmallGroupTests =
|
|||||||
testList "EditSmallGroup" [
|
testList "EditSmallGroup" [
|
||||||
test "fromGroup succeeds" {
|
test "fromGroup succeeds" {
|
||||||
let grp =
|
let grp =
|
||||||
{ SmallGroup.empty with
|
{ SmallGroup.Empty with
|
||||||
Id = (Guid.NewGuid >> SmallGroupId) ()
|
Id = (Guid.NewGuid >> SmallGroupId) ()
|
||||||
Name = "test group"
|
Name = "test group"
|
||||||
ChurchId = (Guid.NewGuid >> ChurchId) ()
|
ChurchId = (Guid.NewGuid >> ChurchId) ()
|
||||||
@ -395,7 +387,7 @@ let editSmallGroupTests =
|
|||||||
Name = "test name"
|
Name = "test name"
|
||||||
ChurchId = (Guid.NewGuid >> shortGuid) ()
|
ChurchId = (Guid.NewGuid >> shortGuid) ()
|
||||||
}
|
}
|
||||||
let grp = edit.populateGroup SmallGroup.empty
|
let grp = edit.populateGroup SmallGroup.Empty
|
||||||
Expect.equal grp.Name edit.Name "The name was not populated correctly"
|
Expect.equal grp.Name edit.Name "The name was not populated correctly"
|
||||||
Expect.equal grp.ChurchId (idFromShort ChurchId edit.ChurchId) "The church ID was not populated correctly"
|
Expect.equal grp.ChurchId (idFromShort ChurchId edit.ChurchId) "The church ID was not populated correctly"
|
||||||
}
|
}
|
||||||
@ -416,7 +408,7 @@ let editUserTests =
|
|||||||
}
|
}
|
||||||
test "fromUser succeeds" {
|
test "fromUser succeeds" {
|
||||||
let usr =
|
let usr =
|
||||||
{ User.empty with
|
{ User.Empty with
|
||||||
Id = (Guid.NewGuid >> UserId) ()
|
Id = (Guid.NewGuid >> UserId) ()
|
||||||
FirstName = "user"
|
FirstName = "user"
|
||||||
LastName = "test"
|
LastName = "test"
|
||||||
@ -446,7 +438,7 @@ let editUserTests =
|
|||||||
Password = "testpw"
|
Password = "testpw"
|
||||||
}
|
}
|
||||||
let hasher = fun x -> x + "+"
|
let hasher = fun x -> x + "+"
|
||||||
let usr = edit.PopulateUser User.empty hasher
|
let usr = edit.PopulateUser User.Empty hasher
|
||||||
Expect.equal usr.FirstName edit.FirstName "The first name was not populated correctly"
|
Expect.equal usr.FirstName edit.FirstName "The first name was not populated correctly"
|
||||||
Expect.equal usr.LastName edit.LastName "The last name was not populated correctly"
|
Expect.equal usr.LastName edit.LastName "The last name was not populated correctly"
|
||||||
Expect.equal usr.Email edit.Email "The e-mail address was not populated correctly"
|
Expect.equal usr.Email edit.Email "The e-mail address was not populated correctly"
|
||||||
@ -508,26 +500,26 @@ let requestListTests =
|
|||||||
let withRequestList f () =
|
let withRequestList f () =
|
||||||
let today = SystemClock.Instance.GetCurrentInstant ()
|
let today = SystemClock.Instance.GetCurrentInstant ()
|
||||||
{ Requests = [
|
{ Requests = [
|
||||||
{ PrayerRequest.empty with
|
{ PrayerRequest.Empty with
|
||||||
RequestType = CurrentRequest
|
RequestType = CurrentRequest
|
||||||
Requestor = Some "Zeb"
|
Requestor = Some "Zeb"
|
||||||
Text = "zyx"
|
Text = "zyx"
|
||||||
UpdatedDate = today
|
UpdatedDate = today
|
||||||
}
|
}
|
||||||
{ PrayerRequest.empty with
|
{ PrayerRequest.Empty with
|
||||||
RequestType = CurrentRequest
|
RequestType = CurrentRequest
|
||||||
Requestor = Some "Aaron"
|
Requestor = Some "Aaron"
|
||||||
Text = "abc"
|
Text = "abc"
|
||||||
UpdatedDate = today - Duration.FromDays 9
|
UpdatedDate = today - Duration.FromDays 9
|
||||||
}
|
}
|
||||||
{ PrayerRequest.empty with
|
{ PrayerRequest.Empty with
|
||||||
RequestType = PraiseReport
|
RequestType = PraiseReport
|
||||||
Text = "nmo"
|
Text = "nmo"
|
||||||
UpdatedDate = today
|
UpdatedDate = today
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
Date = today.InUtc().Date
|
Date = today.InUtc().Date
|
||||||
SmallGroup = SmallGroup.empty
|
SmallGroup = SmallGroup.Empty
|
||||||
ShowHeader = false
|
ShowHeader = false
|
||||||
Recipients = []
|
Recipients = []
|
||||||
CanEmail = false
|
CanEmail = false
|
||||||
@ -604,10 +596,10 @@ let requestListTests =
|
|||||||
}
|
}
|
||||||
let html = htmlList.AsHtml _s
|
let html = htmlList.AsHtml _s
|
||||||
let expected =
|
let expected =
|
||||||
htmlList.Requests[0].UpdatedDate.InZone(SmallGroup.timeZone reqList.SmallGroup).Date.ToString ("d", null)
|
htmlList.Requests[0].UpdatedDate.InZone(reqList.SmallGroup.TimeZone).Date.ToString ("d", null)
|
||||||
|> sprintf """<strong>Zeb</strong> – zyx<i style="font-size:9.60pt"> (as of %s)</i>"""
|
|> sprintf """<strong>Zeb</strong> – zyx<i style="font-size:9.60pt"> (as of %s)</i>"""
|
||||||
// spot check; if one request has it, they all should
|
// spot check; if one request has it, they all should
|
||||||
Expect.stringContains html expected "Expected short as-of date not found"
|
Expect.stringContains html expected "Expected short as-of date not found"
|
||||||
"AsHtml succeeds with long as-of date",
|
"AsHtml succeeds with long as-of date",
|
||||||
fun reqList ->
|
fun reqList ->
|
||||||
let htmlList =
|
let htmlList =
|
||||||
@ -619,10 +611,10 @@ let requestListTests =
|
|||||||
}
|
}
|
||||||
let html = htmlList.AsHtml _s
|
let html = htmlList.AsHtml _s
|
||||||
let expected =
|
let expected =
|
||||||
htmlList.Requests[0].UpdatedDate.InZone(SmallGroup.timeZone reqList.SmallGroup).Date.ToString ("D", null)
|
htmlList.Requests[0].UpdatedDate.InZone(reqList.SmallGroup.TimeZone).Date.ToString ("D", null)
|
||||||
|> sprintf """<strong>Zeb</strong> – zyx<i style="font-size:9.60pt"> (as of %s)</i>"""
|
|> sprintf """<strong>Zeb</strong> – zyx<i style="font-size:9.60pt"> (as of %s)</i>"""
|
||||||
// spot check; if one request has it, they all should
|
// spot check; if one request has it, they all should
|
||||||
Expect.stringContains html expected "Expected long as-of date not found"
|
Expect.stringContains html expected "Expected long as-of date not found"
|
||||||
"AsText succeeds with no as-of date",
|
"AsText succeeds with no as-of date",
|
||||||
fun reqList ->
|
fun reqList ->
|
||||||
let textList = { reqList with SmallGroup = { reqList.SmallGroup with Name = "Test Group" } }
|
let textList = { reqList with SmallGroup = { reqList.SmallGroup with Name = "Test Group" } }
|
||||||
@ -650,10 +642,10 @@ let requestListTests =
|
|||||||
}
|
}
|
||||||
let text = textList.AsText _s
|
let text = textList.AsText _s
|
||||||
let expected =
|
let expected =
|
||||||
textList.Requests[0].UpdatedDate.InZone(SmallGroup.timeZone reqList.SmallGroup).Date.ToString ("d", null)
|
textList.Requests[0].UpdatedDate.InZone(reqList.SmallGroup.TimeZone).Date.ToString ("d", null)
|
||||||
|> sprintf " + Zeb - zyx (as of %s)"
|
|> sprintf " + Zeb - zyx (as of %s)"
|
||||||
// spot check; if one request has it, they all should
|
// spot check; if one request has it, they all should
|
||||||
Expect.stringContains text expected "Expected short as-of date not found"
|
Expect.stringContains text expected "Expected short as-of date not found"
|
||||||
"AsText succeeds with long as-of date",
|
"AsText succeeds with long as-of date",
|
||||||
fun reqList ->
|
fun reqList ->
|
||||||
let textList =
|
let textList =
|
||||||
@ -665,10 +657,10 @@ let requestListTests =
|
|||||||
}
|
}
|
||||||
let text = textList.AsText _s
|
let text = textList.AsText _s
|
||||||
let expected =
|
let expected =
|
||||||
textList.Requests[0].UpdatedDate.InZone(SmallGroup.timeZone reqList.SmallGroup).Date.ToString ("D", null)
|
textList.Requests[0].UpdatedDate.InZone(reqList.SmallGroup.TimeZone).Date.ToString ("D", null)
|
||||||
|> sprintf " + Zeb - zyx (as of %s)"
|
|> sprintf " + Zeb - zyx (as of %s)"
|
||||||
// spot check; if one request has it, they all should
|
// spot check; if one request has it, they all should
|
||||||
Expect.stringContains text expected "Expected long as-of date not found"
|
Expect.stringContains text expected "Expected long as-of date not found"
|
||||||
"IsNew succeeds for both old and new requests",
|
"IsNew succeeds for both old and new requests",
|
||||||
fun reqList ->
|
fun reqList ->
|
||||||
let allReqs = reqList.RequestsByType _s
|
let allReqs = reqList.RequestsByType _s
|
@ -3,40 +3,45 @@ module PrayerTracker.Views.CommonFunctions
|
|||||||
|
|
||||||
open System.IO
|
open System.IO
|
||||||
open System.Text.Encodings.Web
|
open System.Text.Encodings.Web
|
||||||
open Giraffe
|
|
||||||
open Giraffe.ViewEngine
|
open Giraffe.ViewEngine
|
||||||
open Microsoft.AspNetCore.Antiforgery
|
|
||||||
open Microsoft.AspNetCore.Http
|
|
||||||
open Microsoft.AspNetCore.Mvc.Localization
|
open Microsoft.AspNetCore.Mvc.Localization
|
||||||
open Microsoft.Extensions.Localization
|
open Microsoft.Extensions.Localization
|
||||||
|
|
||||||
/// Encoded text for a localized string
|
/// Encoded text for a localized string
|
||||||
let locStr (text : LocalizedString) = str text.Value
|
let locStr (text: LocalizedString) =
|
||||||
|
str text.Value
|
||||||
|
|
||||||
/// Raw text for a localized HTML string
|
/// Raw text for a localized HTML string
|
||||||
let rawLocText (writer : StringWriter) (text : LocalizedHtmlString) =
|
let rawLocText (writer: StringWriter) (text: LocalizedHtmlString) =
|
||||||
text.WriteTo (writer, HtmlEncoder.Default)
|
text.WriteTo(writer, HtmlEncoder.Default)
|
||||||
let txt = string writer
|
let txt = string writer
|
||||||
writer.GetStringBuilder().Clear () |> ignore
|
writer.GetStringBuilder().Clear() |> ignore
|
||||||
rawText txt
|
rawText txt
|
||||||
|
|
||||||
/// A space (used for back-to-back localization string breaks)
|
/// A space (used for back-to-back localization string breaks)
|
||||||
let space = rawText " "
|
let space = rawText " "
|
||||||
|
|
||||||
/// Generate a Material Design icon
|
/// Generate a Material Design icon
|
||||||
let icon name = i [ _class "material-icons" ] [ rawText name ]
|
let icon name =
|
||||||
|
i [ _class "material-icons" ] [ rawText name ]
|
||||||
|
|
||||||
/// Generate a Material Design icon, specifying the point size (must be defined in CSS)
|
/// Generate a Material Design icon, specifying the point size (must be defined in CSS)
|
||||||
let iconSized size name = i [ _class $"material-icons md-%i{size}" ] [ rawText name ]
|
let iconSized size name =
|
||||||
|
i [ _class $"material-icons md-%i{size}" ] [ rawText name ]
|
||||||
|
|
||||||
|
|
||||||
|
open Giraffe
|
||||||
|
open Microsoft.AspNetCore.Antiforgery
|
||||||
|
open Microsoft.AspNetCore.Http
|
||||||
|
|
||||||
/// Generate a CSRF prevention token
|
/// Generate a CSRF prevention token
|
||||||
let csrfToken (ctx : HttpContext) =
|
let csrfToken (ctx: HttpContext) =
|
||||||
let antiForgery = ctx.GetService<IAntiforgery> ()
|
let antiForgery = ctx.GetService<IAntiforgery>()
|
||||||
let tokenSet = antiForgery.GetAndStoreTokens ctx
|
let tokenSet = antiForgery.GetAndStoreTokens ctx
|
||||||
input [ _type "hidden"; _name tokenSet.FormFieldName; _value tokenSet.RequestToken ]
|
input [ _type "hidden"; _name tokenSet.FormFieldName; _value tokenSet.RequestToken ]
|
||||||
|
|
||||||
/// Create a summary for a table of items
|
/// Create a summary for a table of items
|
||||||
let tableSummary itemCount (s : IStringLocalizer) =
|
let tableSummary itemCount (s: IStringLocalizer) =
|
||||||
div [ _class "pt-center-text" ] [
|
div [ _class "pt-center-text" ] [
|
||||||
small [] [
|
small [] [
|
||||||
match itemCount with
|
match itemCount with
|
||||||
@ -46,9 +51,9 @@ let tableSummary itemCount (s : IStringLocalizer) =
|
|||||||
|> locStr
|
|> locStr
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
|
||||||
/// Generate a list of named HTML colors
|
/// Generate a list of named HTML colors
|
||||||
let namedColorList name selected attrs (s : IStringLocalizer) =
|
let namedColorList name selected attrs (s: IStringLocalizer) =
|
||||||
// The list of HTML named colors (name, display, text color)
|
// The list of HTML named colors (name, display, text color)
|
||||||
seq {
|
seq {
|
||||||
("aqua", s["Aqua"], "black")
|
("aqua", s["Aqua"], "black")
|
||||||
@ -79,7 +84,7 @@ let namedColorList name selected attrs (s : IStringLocalizer) =
|
|||||||
|> select (_name name :: attrs)
|
|> select (_name name :: attrs)
|
||||||
|
|
||||||
/// Convert a named color to its hex notation
|
/// Convert a named color to its hex notation
|
||||||
let colorToHex (color : string) =
|
let colorToHex (color: string) =
|
||||||
match color with
|
match color with
|
||||||
| it when it.StartsWith "#" -> color
|
| it when it.StartsWith "#" -> color
|
||||||
| "aqua" -> "#00ffff"
|
| "aqua" -> "#00ffff"
|
||||||
@ -99,8 +104,8 @@ let colorToHex (color : string) =
|
|||||||
| "white" -> "#ffffff"
|
| "white" -> "#ffffff"
|
||||||
| "yellow" -> "#ffff00"
|
| "yellow" -> "#ffff00"
|
||||||
| it -> it
|
| it -> it
|
||||||
|
|
||||||
/// Generate an input[type=radio] that is selected if its value is the current value
|
/// <summary>Generate an <c>input type=radio</c> that is selected if its value is the current value</summary>
|
||||||
let radio name domId value current =
|
let radio name domId value current =
|
||||||
input [ _type "radio"
|
input [ _type "radio"
|
||||||
_name name
|
_name name
|
||||||
@ -108,7 +113,7 @@ let radio name domId value current =
|
|||||||
_value value
|
_value value
|
||||||
if value = current then _checked ]
|
if value = current then _checked ]
|
||||||
|
|
||||||
/// Generate a select list with the current value selected
|
/// <summary>Generate a <c>select</c> list with the current value selected</summary>
|
||||||
let selectList name selected attrs items =
|
let selectList name selected attrs items =
|
||||||
items
|
items
|
||||||
|> Seq.map (fun (value, text) ->
|
|> Seq.map (fun (value, text) ->
|
||||||
@ -119,16 +124,18 @@ let selectList name selected attrs items =
|
|||||||
|> List.ofSeq
|
|> List.ofSeq
|
||||||
|> select (List.concat [ [ _name name; _id name ]; attrs ])
|
|> select (List.concat [ [ _name name; _id name ]; attrs ])
|
||||||
|
|
||||||
/// Generate the text for a default entry at the top of a select list
|
/// <summary>Generate the text for a default entry at the top of a <c>select</c> list</summary>
|
||||||
let selectDefault text = $"— %s{text} —"
|
let selectDefault text =
|
||||||
|
$"— %s{text} —"
|
||||||
|
|
||||||
/// Generate a standard submit button with icon and text
|
/// <summary>Generate a standard <c>button type=submit</c> with icon and text</summary>
|
||||||
let submit attrs ico text = button (_type "submit" :: attrs) [ icon ico; rawText " "; locStr text ]
|
let submit attrs ico text =
|
||||||
|
button (_type "submit" :: attrs) [ icon ico; rawText " "; locStr text ]
|
||||||
|
|
||||||
/// Create an HTML onsubmit event handler
|
/// Create an HTML onsubmit event handler
|
||||||
let _onsubmit = attr "onsubmit"
|
let _onsubmit = attr "onsubmit"
|
||||||
|
|
||||||
/// A "rel='noopener'" attribute
|
/// <summary>A <c>rel="noopener"</c> attribute</summary>
|
||||||
let _relNoOpener = _rel "noopener"
|
let _relNoOpener = _rel "noopener"
|
||||||
|
|
||||||
/// A class attribute that designates a row of fields, with the additional classes passed
|
/// A class attribute that designates a row of fields, with the additional classes passed
|
||||||
@ -153,12 +160,14 @@ let _checkboxField = _class "pt-checkbox-field"
|
|||||||
/// A group of related fields, inputs, links, etc., displayed in a row
|
/// A group of related fields, inputs, links, etc., displayed in a row
|
||||||
let _group = _class "pt-group"
|
let _group = _class "pt-group"
|
||||||
|
|
||||||
/// Create an input field of the given type, with matching name and ID and the given value
|
/// <summary>
|
||||||
|
/// Create an <c>input</c> field of the given <c>type</c>, with matching name and ID and the given value
|
||||||
|
/// </summary>
|
||||||
let inputField typ name value attrs =
|
let inputField typ name value attrs =
|
||||||
List.concat [ [ _type typ; _name name; _id name; if value <> "" then _value value ]; attrs ] |> input
|
List.concat [ [ _type typ; _name name; _id name; if value <> "" then _value value ]; attrs ] |> input
|
||||||
|
|
||||||
/// Generate a table heading with the given localized column names
|
/// Generate a table heading with the given localized column names
|
||||||
let tableHeadings (s : IStringLocalizer) (headings : string list) =
|
let tableHeadings (s: IStringLocalizer) (headings: string list) =
|
||||||
headings
|
headings
|
||||||
|> List.map (fun heading -> th [ _scope "col" ] [ locStr s[heading] ])
|
|> List.map (fun heading -> th [ _scope "col" ] [ locStr s[heading] ])
|
||||||
|> tr []
|
|> tr []
|
||||||
@ -166,7 +175,8 @@ let tableHeadings (s : IStringLocalizer) (headings : string list) =
|
|||||||
|> thead []
|
|> thead []
|
||||||
|
|
||||||
/// For a list of strings, prepend a pound sign and string them together with commas (CSS selector by ID)
|
/// For a list of strings, prepend a pound sign and string them together with commas (CSS selector by ID)
|
||||||
let toHtmlIds it = it |> List.map (fun x -> $"#%s{x}") |> String.concat ", "
|
let toHtmlIds it =
|
||||||
|
it |> List.map (fun x -> $"#%s{x}") |> String.concat ", "
|
||||||
|
|
||||||
/// The name this function used to have when the view engine was part of Giraffe
|
/// The name this function used to have when the view engine was part of Giraffe
|
||||||
let renderHtmlNode = RenderView.AsString.htmlNode
|
let renderHtmlNode = RenderView.AsString.htmlNode
|
||||||
@ -180,7 +190,7 @@ let renderHtmlString = renderHtmlNode >> HtmlString
|
|||||||
|
|
||||||
/// Utility methods to help with time zones (and localization of their names)
|
/// Utility methods to help with time zones (and localization of their names)
|
||||||
module TimeZones =
|
module TimeZones =
|
||||||
|
|
||||||
open PrayerTracker.Entities
|
open PrayerTracker.Entities
|
||||||
|
|
||||||
/// Cross-reference between time zone Ids and their English names
|
/// Cross-reference between time zone Ids and their English names
|
||||||
@ -194,24 +204,28 @@ module TimeZones =
|
|||||||
]
|
]
|
||||||
|
|
||||||
/// Get the name of a time zone, given its Id
|
/// Get the name of a time zone, given its Id
|
||||||
let name timeZoneId (s : IStringLocalizer) =
|
let name timeZoneId (s: IStringLocalizer) =
|
||||||
match xref |> List.tryFind (fun it -> fst it = timeZoneId) with
|
match xref |> List.tryFind (fun it -> fst it = timeZoneId) with
|
||||||
| Some tz -> s[snd tz]
|
| Some tz -> s[snd tz]
|
||||||
| None ->
|
| None ->
|
||||||
let tzId = TimeZoneId.toString timeZoneId
|
let tzId = string timeZoneId
|
||||||
LocalizedString (tzId, tzId)
|
LocalizedString (tzId, tzId)
|
||||||
|
|
||||||
/// All known time zones in their defined order
|
/// All known time zones in their defined order
|
||||||
let all = xref |> List.map fst
|
let all = xref |> List.map fst
|
||||||
|
|
||||||
|
|
||||||
open Giraffe.ViewEngine.Htmx
|
open Giraffe.ViewEngine.Htmx
|
||||||
|
|
||||||
|
/// Create a page link that will make the request with fixi
|
||||||
|
let pageLink href attrs content =
|
||||||
|
a (List.append [ _href href; _hxGet href ] attrs) content
|
||||||
|
|
||||||
/// Known htmx targets
|
/// Known htmx targets
|
||||||
module Target =
|
module Target =
|
||||||
|
|
||||||
/// htmx links target the body element
|
/// htmx links target the body element
|
||||||
let body = _hxTarget "body"
|
let body = _hxTarget "body"
|
||||||
|
|
||||||
/// htmx links target the #pt-body element
|
/// htmx links target the #pt-body element
|
||||||
let content = _hxTarget "#pt-body"
|
let content = _hxTarget "#pt-body"
|
@ -12,11 +12,11 @@ let private resAsmName = typeof<Common>.Assembly.GetName().Name
|
|||||||
/// Set up the string and HTML localizer factories
|
/// Set up the string and HTML localizer factories
|
||||||
let setUpFactories fac =
|
let setUpFactories fac =
|
||||||
stringLocFactory <- fac
|
stringLocFactory <- fac
|
||||||
htmlLocFactory <- HtmlLocalizerFactory stringLocFactory
|
htmlLocFactory <- HtmlLocalizerFactory stringLocFactory
|
||||||
|
|
||||||
/// An instance of the common string localizer
|
/// An instance of the common string localizer
|
||||||
let localizer = lazy (stringLocFactory.Create ("Common", resAsmName))
|
let localizer = lazy stringLocFactory.Create("Common", resAsmName)
|
||||||
|
|
||||||
/// Get a view localizer
|
/// Get a view localizer
|
||||||
let forView (view : string) =
|
let forView (view: string) =
|
||||||
htmlLocFactory.Create ($"Views.{view.Replace ('/', '.')}", resAsmName)
|
htmlLocFactory.Create($"Views.{view.Replace('/', '.')}", resAsmName)
|
@ -12,7 +12,7 @@ let langCode () = if CultureInfo.CurrentCulture.Name.StartsWith "es" then "es" e
|
|||||||
|
|
||||||
/// Navigation items
|
/// Navigation items
|
||||||
module Navigation =
|
module Navigation =
|
||||||
|
|
||||||
/// Top navigation bar
|
/// Top navigation bar
|
||||||
let top m =
|
let top m =
|
||||||
let s = I18N.localizer.Force()
|
let s = I18N.localizer.Force()
|
||||||
@ -25,20 +25,25 @@ module Navigation =
|
|||||||
a [ _dropdown; _ariaLabel s["Requests"].Value; _title s["Requests"].Value; _roleButton ] [
|
a [ _dropdown; _ariaLabel s["Requests"].Value; _title s["Requests"].Value; _roleButton ] [
|
||||||
icon "question_answer"; space; locStr s["Requests"]; space; icon "keyboard_arrow_down" ]
|
icon "question_answer"; space; locStr s["Requests"]; space; icon "keyboard_arrow_down" ]
|
||||||
div [ _class "dropdown-content"; _roleMenuBar ] [
|
div [ _class "dropdown-content"; _roleMenuBar ] [
|
||||||
a [ _href "/prayer-requests"; _roleMenuItem ] [
|
pageLink "/prayer-requests"
|
||||||
icon "compare_arrows"; menuSpacer; locStr s["Maintain"] ]
|
[ _roleMenuItem ]
|
||||||
a [ _href "/prayer-requests/view"; _roleMenuItem ] [
|
[ icon "compare_arrows"; menuSpacer; locStr s["Maintain"] ]
|
||||||
icon "list"; menuSpacer; locStr s["View List"] ] ] ]
|
pageLink "/prayer-requests/view"
|
||||||
|
[ _roleMenuItem ]
|
||||||
|
[ icon "list"; menuSpacer; locStr s["View List"] ] ] ]
|
||||||
li [ _class "dropdown" ] [
|
li [ _class "dropdown" ] [
|
||||||
a [ _dropdown; _ariaLabel s["Group"].Value; _title s["Group"].Value; _roleButton ] [
|
a [ _dropdown; _ariaLabel s["Group"].Value; _title s["Group"].Value; _roleButton ] [
|
||||||
icon "group"; space; locStr s["Group"]; space; icon "keyboard_arrow_down" ]
|
icon "group"; space; locStr s["Group"]; space; icon "keyboard_arrow_down" ]
|
||||||
div [ _class "dropdown-content"; _roleMenuBar ] [
|
div [ _class "dropdown-content"; _roleMenuBar ] [
|
||||||
a [ _href "/small-group/members"; _roleMenuItem ] [
|
pageLink "/small-group/members"
|
||||||
icon "email"; menuSpacer; locStr s["Maintain Group Members"] ]
|
[ _roleMenuItem ]
|
||||||
a [ _href "/small-group/announcement"; _roleMenuItem ] [
|
[ icon "email"; menuSpacer; locStr s["Maintain Group Members"] ]
|
||||||
icon "send"; menuSpacer; locStr s["Send Announcement"] ]
|
pageLink "/small-group/announcement"
|
||||||
a [ _href "/small-group/preferences"; _roleMenuItem ] [
|
[ _roleMenuItem ]
|
||||||
icon "build"; menuSpacer; locStr s["Change Preferences"] ] ] ]
|
[ icon "send"; menuSpacer; locStr s["Send Announcement"] ]
|
||||||
|
pageLink "/small-group/preferences"
|
||||||
|
[ _roleMenuItem ]
|
||||||
|
[ icon "build"; menuSpacer; locStr s["Change Preferences"] ] ] ]
|
||||||
if u.IsAdmin then
|
if u.IsAdmin then
|
||||||
li [ _class "dropdown" ] [
|
li [ _class "dropdown" ] [
|
||||||
a [ _dropdown
|
a [ _dropdown
|
||||||
@ -47,31 +52,31 @@ module Navigation =
|
|||||||
_roleButton ] [
|
_roleButton ] [
|
||||||
icon "settings"; space; locStr s["Administration"]; space; icon "keyboard_arrow_down" ]
|
icon "settings"; space; locStr s["Administration"]; space; icon "keyboard_arrow_down" ]
|
||||||
div [ _class "dropdown-content"; _roleMenuBar ] [
|
div [ _class "dropdown-content"; _roleMenuBar ] [
|
||||||
a [ _href "/churches"; _roleMenuItem ] [ icon "home"; menuSpacer; locStr s["Churches"] ]
|
pageLink "/churches" [ _roleMenuItem ] [ icon "home"; menuSpacer; locStr s["Churches"] ]
|
||||||
a [ _href "/small-groups"; _roleMenuItem ] [
|
pageLink "/small-groups"
|
||||||
icon "send"; menuSpacer; locStr s["Groups"] ]
|
[ _roleMenuItem ]
|
||||||
a [ _href "/users"; _roleMenuItem ] [ icon "build"; menuSpacer; locStr s["Users"] ] ] ]
|
[ icon "send"; menuSpacer; locStr s["Groups"] ]
|
||||||
|
pageLink "/users" [ _roleMenuItem ] [ icon "build"; menuSpacer; locStr s["Users"] ] ] ]
|
||||||
| None ->
|
| None ->
|
||||||
match m.Group with
|
match m.Group with
|
||||||
| Some _ ->
|
| Some _ ->
|
||||||
li [] [
|
li [] [
|
||||||
a [ _href "/prayer-requests/view"
|
pageLink "/prayer-requests/view"
|
||||||
_ariaLabel s["View Request List"].Value
|
[ _ariaLabel s["View Request List"].Value; _title s["View Request List"].Value ]
|
||||||
_title s["View Request List"].Value ] [
|
[ icon "list"; space; locStr s["View Request List"] ] ]
|
||||||
icon "list"; space; locStr s["View Request List"] ] ]
|
|
||||||
| None ->
|
| None ->
|
||||||
li [ _class "dropdown" ] [
|
li [ _class "dropdown" ] [
|
||||||
a [ _dropdown; _ariaLabel s["Log On"].Value; _title s["Log On"].Value; _roleButton ] [
|
a [ _dropdown; _ariaLabel s["Log On"].Value; _title s["Log On"].Value; _roleButton ] [
|
||||||
icon "security"; space; locStr s["Log On"]; space; icon "keyboard_arrow_down" ]
|
icon "security"; space; locStr s["Log On"]; space; icon "keyboard_arrow_down" ]
|
||||||
div [ _class "dropdown-content"; _roleMenuBar ] [
|
div [ _class "dropdown-content"; _roleMenuBar ] [
|
||||||
a [ _href "/user/log-on"; _roleMenuItem ] [ icon "person"; menuSpacer; locStr s["User"] ]
|
pageLink "/user/log-on" [ _roleMenuItem ] [ icon "person"; menuSpacer; locStr s["User"] ]
|
||||||
a [ _href "/small-group/log-on"; _roleMenuItem ] [
|
pageLink "/small-group/log-on"
|
||||||
icon "group"; menuSpacer; locStr s["Group"] ] ] ]
|
[ _roleMenuItem ]
|
||||||
|
[ icon "group"; menuSpacer; locStr s["Group"] ] ] ]
|
||||||
li [] [
|
li [] [
|
||||||
a [ _href "/prayer-requests/lists"
|
pageLink "/prayer-requests/lists"
|
||||||
_ariaLabel s["View Request List"].Value
|
[ _ariaLabel s["View Request List"].Value; _title s["View Request List"].Value ]
|
||||||
_title s["View Request List"].Value ] [
|
[ icon "list"; space; locStr s["View Request List"] ] ]
|
||||||
icon "list"; space; locStr s["View Request List"] ] ]
|
|
||||||
li [] [
|
li [] [
|
||||||
a [ _href "/help"; _ariaLabel s["Help"].Value; _title s["View Help"].Value; _target "_blank" ] [
|
a [ _href "/help"; _ariaLabel s["Help"].Value; _title s["View Help"].Value; _target "_blank" ] [
|
||||||
icon "help"; space; locStr s["Help"] ] ] ]
|
icon "help"; space; locStr s["Help"] ] ] ]
|
||||||
@ -81,24 +86,24 @@ module Navigation =
|
|||||||
match m.User with
|
match m.User with
|
||||||
| Some _ ->
|
| Some _ ->
|
||||||
li [] [
|
li [] [
|
||||||
a [ _href "/user/password"
|
pageLink "/user/password"
|
||||||
_ariaLabel s["Change Your Password"].Value
|
[ _ariaLabel s["Change Your Password"].Value; _title s["Change Your Password"].Value ]
|
||||||
_title s["Change Your Password"].Value ] [
|
[ icon "lock"; space; locStr s["Change Your Password"] ] ]
|
||||||
icon "lock"; space; locStr s["Change Your Password"] ] ]
|
|
||||||
| None -> ()
|
| None -> ()
|
||||||
li [] [
|
li [] [
|
||||||
a [ _href "/log-off"; _ariaLabel s["Log Off"].Value; _title s["Log Off"].Value; Target.body ] [
|
pageLink "/log-off"
|
||||||
icon "power_settings_new"; space; locStr s["Log Off"] ] ] ]
|
[ _ariaLabel s["Log Off"].Value; _title s["Log Off"].Value; Target.body ]
|
||||||
|
[ icon "power_settings_new"; space; locStr s["Log Off"] ] ] ]
|
||||||
| None -> []
|
| None -> []
|
||||||
header [ _class "pt-title-bar"; Target.content ] [
|
header [ _class "pt-title-bar"; Target.content ] [
|
||||||
section [ _class "pt-title-bar-left"; _ariaLabel "Left side of top menu" ] [
|
section [ _class "pt-title-bar-left"; _ariaLabel "Left side of top menu" ] [
|
||||||
span [ _class "pt-title-bar-home" ] [
|
span [ _class "pt-title-bar-home" ] [
|
||||||
a [ _href "/"; _title s["Home"].Value ] [ locStr s["PrayerTracker"] ] ]
|
pageLink "/" [ _title s["Home"].Value ] [ locStr s["PrayerTracker"] ] ]
|
||||||
ul [] leftLinks ]
|
ul [] leftLinks ]
|
||||||
section [ _class "pt-title-bar-center"; _ariaLabel "Empty center space in top menu" ] []
|
section [ _class "pt-title-bar-center"; _ariaLabel "Empty center space in top menu" ] []
|
||||||
section [ _class "pt-title-bar-right"; _roleToolBar; _ariaLabel "Right side of top menu" ] [
|
section [ _class "pt-title-bar-right"; _roleToolBar; _ariaLabel "Right side of top menu" ] [
|
||||||
ul [] rightLinks ] ]
|
ul [] rightLinks ] ]
|
||||||
|
|
||||||
/// Identity bar (below top nav)
|
/// Identity bar (below top nav)
|
||||||
let identity m =
|
let identity m =
|
||||||
let s = I18N.localizer.Force()
|
let s = I18N.localizer.Force()
|
||||||
@ -106,14 +111,14 @@ module Navigation =
|
|||||||
div [] [
|
div [] [
|
||||||
span [ _title s["Language"].Value ] [ icon "record_voice_over"; space ]
|
span [ _title s["Language"].Value ] [ icon "record_voice_over"; space ]
|
||||||
match langCode () with
|
match langCode () with
|
||||||
| "es" ->
|
| "es" ->
|
||||||
strong [] [ locStr s["Spanish"] ]
|
strong [] [ locStr s["Spanish"] ]
|
||||||
rawText " "
|
rawText " "
|
||||||
a [ _href "/language/en" ] [ locStr s["Change to English"] ]
|
pageLink "/language/en" [] [ locStr s["Change to English"] ]
|
||||||
| _ ->
|
| _ ->
|
||||||
strong [] [ locStr s["English"] ]
|
strong [] [ locStr s["English"] ]
|
||||||
rawText " "
|
rawText " "
|
||||||
a [ _href "/language/es" ] [ locStr s["Cambie a Español"] ] ]
|
pageLink "/language/es" [] [ locStr s["Cambie a Español"] ] ]
|
||||||
match m.Group with
|
match m.Group with
|
||||||
| Some g ->
|
| Some g ->
|
||||||
[ match m.User with
|
[ match m.User with
|
||||||
@ -129,7 +134,7 @@ module Navigation =
|
|||||||
icon "group"
|
icon "group"
|
||||||
space
|
space
|
||||||
match m.User with
|
match m.User with
|
||||||
| Some _ -> a [ _href "/small-group"; Target.content ] [ strong [] [ str g.Name ] ]
|
| Some _ -> pageLink "/small-group" [] [ strong [] [ str g.Name ] ]
|
||||||
| None -> strong [] [ str g.Name ] ]
|
| None -> strong [] [ str g.Name ] ]
|
||||||
| None -> []
|
| None -> []
|
||||||
|> div [] ]
|
|> div [] ]
|
||||||
@ -137,14 +142,14 @@ module Navigation =
|
|||||||
|
|
||||||
/// Content layouts
|
/// Content layouts
|
||||||
module Content =
|
module Content =
|
||||||
|
|
||||||
/// Content layout that tops at 60rem
|
/// Content layout that tops at 60rem
|
||||||
let standard = div [ _class "pt-content" ]
|
let standard = div [ _class "pt-content" ]
|
||||||
|
|
||||||
/// Content layout that uses the full width of the browser window
|
/// Content layout that uses the full width of the browser window
|
||||||
let wide = div [ _class "pt-content pt-full-width" ]
|
let wide = div [ _class "pt-content pt-full-width" ]
|
||||||
|
|
||||||
|
|
||||||
/// Separator for parts of the title
|
/// Separator for parts of the title
|
||||||
let private titleSep = rawText " « "
|
let private titleSep = rawText " « "
|
||||||
|
|
||||||
@ -153,7 +158,7 @@ let private commonHead = [
|
|||||||
meta [ _name "viewport"; _content "width=device-width, initial-scale=1" ]
|
meta [ _name "viewport"; _content "width=device-width, initial-scale=1" ]
|
||||||
meta [ _name "generator"; _content "Giraffe" ]
|
meta [ _name "generator"; _content "Giraffe" ]
|
||||||
link [ _rel "stylesheet"; _href "https://fonts.googleapis.com/icon?family=Material+Icons" ]
|
link [ _rel "stylesheet"; _href "https://fonts.googleapis.com/icon?family=Material+Icons" ]
|
||||||
link [ _rel "stylesheet"; _href "/css/app.css" ] ]
|
link [ _rel "stylesheet"; _href "/_/app.css" ] ]
|
||||||
|
|
||||||
/// Render the <head> portion of the page
|
/// Render the <head> portion of the page
|
||||||
let private htmlHead viewInfo pgTitle =
|
let private htmlHead viewInfo pgTitle =
|
||||||
@ -163,19 +168,16 @@ let private htmlHead viewInfo pgTitle =
|
|||||||
title [] [ locStr pgTitle; titleSep; locStr s["PrayerTracker"] ]
|
title [] [ locStr pgTitle; titleSep; locStr s["PrayerTracker"] ]
|
||||||
yield! commonHead
|
yield! commonHead
|
||||||
for cssFile in viewInfo.Style do
|
for cssFile in viewInfo.Style do
|
||||||
link [ _rel "stylesheet"; _href $"/css/{cssFile}.css"; _type "text/css" ] ]
|
link [ _rel "stylesheet"; _href $"/_/{cssFile}.css"; _type "text/css" ] ]
|
||||||
|
|
||||||
|
|
||||||
open Giraffe.ViewEngine.Htmx
|
|
||||||
|
|
||||||
/// Render a link to the help page for the current page
|
/// Render a link to the help page for the current page
|
||||||
let private helpLink link =
|
let private helpLink link =
|
||||||
let s = I18N.localizer.Force()
|
let s = I18N.localizer.Force()
|
||||||
sup [ _class "pt-help-link" ] [
|
sup [ _class "pt-help-link" ] [
|
||||||
a [ _href link
|
a [ _href link
|
||||||
_title s["Click for Help on This Page"].Value
|
_title s["Click for Help on This Page"].Value
|
||||||
_onclick $"return PT.showHelp('{link}')"
|
_onclick $"return PT.showHelp('{link}')" ] [ iconSized 18 "help_outline" ] ]
|
||||||
_hxNoBoost ] [ iconSized 18 "help_outline" ] ]
|
|
||||||
|
|
||||||
/// Render the page title, and optionally a help link
|
/// Render the page title, and optionally a help link
|
||||||
let private renderPageTitle viewInfo pgTitle =
|
let private renderPageTitle viewInfo pgTitle =
|
||||||
@ -217,9 +219,9 @@ let private htmlFooter viewInfo =
|
|||||||
let resultTime = (SystemClock.Instance.GetCurrentInstant() - viewInfo.RequestStart).TotalSeconds
|
let resultTime = (SystemClock.Instance.GetCurrentInstant() - viewInfo.RequestStart).TotalSeconds
|
||||||
footer [ _class "pt-footer" ] [
|
footer [ _class "pt-footer" ] [
|
||||||
div [ _id "pt-legal" ] [
|
div [ _id "pt-legal" ] [
|
||||||
a [ _href "/legal/privacy-policy" ] [ locStr s["Privacy Policy"] ]
|
pageLink "/legal/privacy-policy" [] [ locStr s["Privacy Policy"] ]
|
||||||
rawText " "
|
rawText " "
|
||||||
a [ _href "/legal/terms-of-service" ] [ locStr s["Terms of Service"] ]
|
pageLink "/legal/terms-of-service" [] [ locStr s["Terms of Service"] ]
|
||||||
rawText " "
|
rawText " "
|
||||||
a [ _href "https://git.bitbadger.solutions/bit-badger/PrayerTracker"
|
a [ _href "https://git.bitbadger.solutions/bit-badger/PrayerTracker"
|
||||||
_title s["View source code and get technical support"].Value
|
_title s["View source code and get technical support"].Value
|
||||||
@ -227,7 +229,7 @@ let private htmlFooter viewInfo =
|
|||||||
_relNoOpener ] [
|
_relNoOpener ] [
|
||||||
locStr s["Source & Support"] ] ]
|
locStr s["Source & Support"] ] ]
|
||||||
div [ _id "pt-footer" ] [
|
div [ _id "pt-footer" ] [
|
||||||
a [ _href "/"; _style "line-height:28px;" ] [
|
pageLink "/" [ _style "line-height:28px;" ] [
|
||||||
img [ _src $"""/img/%O{s["footer_en"]}.png"""
|
img [ _src $"""/img/%O{s["footer_en"]}.png"""
|
||||||
_alt imgText
|
_alt imgText
|
||||||
_title imgText
|
_title imgText
|
||||||
@ -268,21 +270,18 @@ let private partialHead pgTitle =
|
|||||||
meta [ _charset "UTF-8" ]
|
meta [ _charset "UTF-8" ]
|
||||||
title [] [ locStr pgTitle; titleSep; locStr s["PrayerTracker"] ] ]
|
title [] [ locStr pgTitle; titleSep; locStr s["PrayerTracker"] ] ]
|
||||||
|
|
||||||
open Giraffe.Htmx.Common
|
|
||||||
|
|
||||||
/// The body of the PrayerTracker layout
|
/// The body of the PrayerTracker layout
|
||||||
let private pageLayout viewInfo pgTitle content =
|
let private pageLayout viewInfo pgTitle content =
|
||||||
body [ _hxBoost ] [
|
body [] [
|
||||||
Navigation.top viewInfo
|
Navigation.top viewInfo
|
||||||
div [ _id "pt-body"; Target.content; _hxSwap $"{HxSwap.InnerHtml} show:window:top" ]
|
div [ _id "pt-body"; Target.content ] (contentSection viewInfo pgTitle content)
|
||||||
(contentSection viewInfo pgTitle content)
|
|
||||||
match viewInfo.Layout with
|
match viewInfo.Layout with
|
||||||
| FullPage ->
|
| FullPage ->
|
||||||
Script.minified
|
|
||||||
script [ _src "/js/ckeditor/ckeditor.js" ] []
|
script [ _src "/js/ckeditor/ckeditor.js" ] []
|
||||||
script [ _src "/js/app.js" ] []
|
Htmx.Script.minified
|
||||||
|
script [ _src "/_/app.js" ] []
|
||||||
| _ -> () ]
|
| _ -> () ]
|
||||||
|
|
||||||
/// The standard layout(s) for PrayerTracker
|
/// The standard layout(s) for PrayerTracker
|
||||||
let standard viewInfo pageTitle content =
|
let standard viewInfo pageTitle content =
|
||||||
let s = I18N.localizer.Force()
|
let s = I18N.localizer.Force()
|
||||||
@ -316,8 +315,8 @@ let help pageTitle isHome content =
|
|||||||
meta [ _name "viewport"; _content "width=device-width, initial-scale=1" ]
|
meta [ _name "viewport"; _content "width=device-width, initial-scale=1" ]
|
||||||
title [] [ locStr pgTitle; titleSep; locStr s["PrayerTracker Help"] ]
|
title [] [ locStr pgTitle; titleSep; locStr s["PrayerTracker Help"] ]
|
||||||
link [ _href "https://fonts.googleapis.com/icon?family=Material+Icons"; _rel "stylesheet" ]
|
link [ _href "https://fonts.googleapis.com/icon?family=Material+Icons"; _rel "stylesheet" ]
|
||||||
link [ _href "/css/app.css"; _rel "stylesheet" ]
|
link [ _href "/_/app.css"; _rel "stylesheet" ]
|
||||||
link [ _href "/css/help.css"; _rel "stylesheet" ] ]
|
link [ _href "/_/help.css"; _rel "stylesheet" ] ]
|
||||||
body [] [
|
body [] [
|
||||||
header [ _class "pt-title-bar" ] [
|
header [ _class "pt-title-bar" ] [
|
||||||
section [ _class "pt-title-bar-left" ] [
|
section [ _class "pt-title-bar-left" ] [
|
||||||
@ -329,7 +328,7 @@ let help pageTitle isHome content =
|
|||||||
div [] [
|
div [] [
|
||||||
locStr s["Language"]; rawText ": "
|
locStr s["Language"]; rawText ": "
|
||||||
match langCode () with
|
match langCode () with
|
||||||
| "es" ->
|
| "es" ->
|
||||||
locStr s["Spanish"]; rawText " • "
|
locStr s["Spanish"]; rawText " • "
|
||||||
a [ _href "/language/en" ] [ locStr s["Change to English"] ]
|
a [ _href "/language/en" ] [ locStr s["Change to English"] ]
|
||||||
| _ ->
|
| _ ->
|
||||||
@ -349,4 +348,3 @@ let help pageTitle isHome content =
|
|||||||
p [ _class "pt-center-text" ] [
|
p [ _class "pt-center-text" ] [
|
||||||
a [ _href "/help"; _title s["Help Index"].Value ] [
|
a [ _href "/help"; _title s["Help Index"].Value ] [
|
||||||
rawText "« "; locStr s["Back to Help Index"] ] ] ] ] ] ] ]
|
rawText "« "; locStr s["Back to Help Index"] ] ] ] ] ] ] ]
|
||||||
|
|
@ -29,7 +29,7 @@ let edit (model : EditRequest) today ctx viewInfo =
|
|||||||
label [ _for (nameof model.RequestType) ] [ locStr s["Request Type"] ]
|
label [ _for (nameof model.RequestType) ] [ locStr s["Request Type"] ]
|
||||||
ReferenceList.requestTypeList s
|
ReferenceList.requestTypeList s
|
||||||
|> Seq.ofList
|
|> Seq.ofList
|
||||||
|> Seq.map (fun (typ, desc) -> PrayerRequestType.toCode typ, desc.Value)
|
|> Seq.map (fun (typ, desc) -> string typ, desc.Value)
|
||||||
|> selectList (nameof model.RequestType) model.RequestType [ _required; _autofocus ]
|
|> selectList (nameof model.RequestType) model.RequestType [ _required; _autofocus ]
|
||||||
]
|
]
|
||||||
div [ _inputField ] [
|
div [ _inputField ] [
|
||||||
@ -98,7 +98,7 @@ let email model viewInfo =
|
|||||||
/// View for a small group's public prayer request list
|
/// View for a small group's public prayer request list
|
||||||
let list (model : RequestList) viewInfo =
|
let list (model : RequestList) viewInfo =
|
||||||
[ br []
|
[ br []
|
||||||
I18N.localizer.Force () |> (model.AsHtml >> rawText)
|
I18N.localizer.Force () |> (model.AsHtml >> rawText)
|
||||||
]
|
]
|
||||||
|> Layout.Content.standard
|
|> Layout.Content.standard
|
||||||
|> Layout.standard viewInfo "View Request List"
|
|> Layout.standard viewInfo "View Request List"
|
||||||
@ -156,7 +156,7 @@ let maintain (model : MaintainRequests) (ctx : HttpContext) viewInfo =
|
|||||||
use sw = new StringWriter ()
|
use sw = new StringWriter ()
|
||||||
let raw = rawLocText sw
|
let raw = rawLocText sw
|
||||||
let group = model.SmallGroup
|
let group = model.SmallGroup
|
||||||
let now = SmallGroup.localDateNow (ctx.GetService<IClock> ()) group
|
let now = group.LocalDateNow (ctx.GetService<IClock>())
|
||||||
let types = ReferenceList.requestTypeList s |> Map.ofList
|
let types = ReferenceList.requestTypeList s |> Map.ofList
|
||||||
let vi = AppViewInfo.withScopedStyles [ "#requestList { grid-template-columns: repeat(5, auto); }" ] viewInfo
|
let vi = AppViewInfo.withScopedStyles [ "#requestList { grid-template-columns: repeat(5, auto); }" ] viewInfo
|
||||||
/// Iterate the sequence once, before we render, so we can get the count of it at the top of the table
|
/// Iterate the sequence once, before we render, so we can get the count of it at the top of the table
|
||||||
@ -164,8 +164,8 @@ let maintain (model : MaintainRequests) (ctx : HttpContext) viewInfo =
|
|||||||
model.Requests
|
model.Requests
|
||||||
|> List.map (fun req ->
|
|> List.map (fun req ->
|
||||||
let updateClass =
|
let updateClass =
|
||||||
_class (if PrayerRequest.updateRequired now group req then "cell pt-request-update" else "cell")
|
_class (if req.UpdateRequired now group then "cell pt-request-update" else "cell")
|
||||||
let isExpired = PrayerRequest.isExpired now group req
|
let isExpired = req.IsExpired now group
|
||||||
let expiredClass = _class (if isExpired then "cell pt-request-expired" else "cell")
|
let expiredClass = _class (if isExpired then "cell pt-request-expired" else "cell")
|
||||||
let reqId = shortGuid req.Id.Value
|
let reqId = shortGuid req.Id.Value
|
||||||
let reqText = htmlToPlainText req.Text
|
let reqText = htmlToPlainText req.Text
|
@ -16,18 +16,13 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Giraffe.ViewEngine" Version="1.4.0" />
|
<PackageReference Include="Giraffe.ViewEngine" Version="1.4.0" />
|
||||||
<PackageReference Include="Giraffe.ViewEngine.Htmx" Version="2.0.0" />
|
<PackageReference Include="Giraffe.ViewEngine.Htmx" Version="2.0.4" />
|
||||||
<PackageReference Include="MailKit" Version="4.6.0" />
|
<PackageReference Include="MailKit" Version="4.10.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Html.Abstractions" Version="2.2.0" />
|
<PackageReference Update="FSharp.Core" Version="9.0.101" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.2.2" />
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Http.Extensions" Version="2.2.0" />
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.2.0" />
|
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
|
||||||
<PackageReference Update="FSharp.Core" Version="8.0.300" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\PrayerTracker.Data\PrayerTracker.Data.fsproj" />
|
<ProjectReference Include="..\Data\PrayerTracker.Data.fsproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
@ -54,8 +54,8 @@ let announcement isAdmin ctx viewInfo =
|
|||||||
label [ _for (nameof model.RequestType) ] [ locStr s["Request Type"] ]
|
label [ _for (nameof model.RequestType) ] [ locStr s["Request Type"] ]
|
||||||
reqTypes
|
reqTypes
|
||||||
|> Seq.ofList
|
|> Seq.ofList
|
||||||
|> Seq.map (fun (typ, desc) -> PrayerRequestType.toCode typ, desc.Value)
|
|> Seq.map (fun (typ, desc) -> string typ, desc.Value)
|
||||||
|> selectList (nameof model.RequestType) (PrayerRequestType.toCode Announcement) []
|
|> selectList (nameof model.RequestType) (string Announcement) []
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
div [ _fieldRow ] [ submit [] "send" s["Send Announcement"] ]
|
div [ _fieldRow ] [ submit [] "send" s["Send Announcement"] ]
|
||||||
@ -99,7 +99,7 @@ let edit (model : EditSmallGroup) (churches : Church list) ctx viewInfo =
|
|||||||
"", selectDefault s["Select Church"].Value
|
"", selectDefault s["Select Church"].Value
|
||||||
yield! churches |> List.map (fun c -> shortGuid c.Id.Value, c.Name)
|
yield! churches |> List.map (fun c -> shortGuid c.Id.Value, c.Name)
|
||||||
}
|
}
|
||||||
|> selectList (nameof model.ChurchId) model.ChurchId [ _required ]
|
|> selectList (nameof model.ChurchId) model.ChurchId [ _required ]
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
div [ _fieldRow ] [ submit [] "save" s["Save Group"] ]
|
div [ _fieldRow ] [ submit [] "save" s["Save Group"] ]
|
||||||
@ -273,7 +273,7 @@ let members (members : Member list) (emailTypes : Map<string, LocalizedString>)
|
|||||||
div [ _class "cell" ] [ str mbr.Name ]
|
div [ _class "cell" ] [ str mbr.Name ]
|
||||||
div [ _class "cell" ] [ str mbr.Email ]
|
div [ _class "cell" ] [ str mbr.Email ]
|
||||||
div [ _class "cell" ] [
|
div [ _class "cell" ] [
|
||||||
locStr emailTypes[defaultArg (mbr.Format |> Option.map EmailFormat.toCode) ""]
|
locStr emailTypes[defaultArg (mbr.Format |> Option.map string) ""]
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
@ -476,7 +476,7 @@ let preferences (model : EditPreferences) ctx viewInfo =
|
|||||||
locStr s["Custom Color"]
|
locStr s["Custom Color"]
|
||||||
]
|
]
|
||||||
space
|
space
|
||||||
input [ _type "color"
|
input [ _type "color"
|
||||||
_name (nameof model.LineColor)
|
_name (nameof model.LineColor)
|
||||||
_id $"{nameof model.LineColor}_Color"
|
_id $"{nameof model.LineColor}_Color"
|
||||||
_value (colorToHex model.LineColor)
|
_value (colorToHex model.LineColor)
|
||||||
@ -589,7 +589,7 @@ let preferences (model : EditPreferences) ctx viewInfo =
|
|||||||
"", selectDefault s["Select"].Value
|
"", selectDefault s["Select"].Value
|
||||||
yield!
|
yield!
|
||||||
TimeZones.all
|
TimeZones.all
|
||||||
|> List.map (fun tz -> TimeZoneId.toString tz, (TimeZones.name tz s).Value)
|
|> List.map (fun tz -> string tz, (TimeZones.name tz s).Value)
|
||||||
}
|
}
|
||||||
|> selectList (nameof model.TimeZone) model.TimeZone [ _required ]
|
|> selectList (nameof model.TimeZone) model.TimeZone [ _required ]
|
||||||
]
|
]
|
@ -19,10 +19,12 @@ let emptyGuid = shortGuid Guid.Empty
|
|||||||
module String =
|
module String =
|
||||||
|
|
||||||
/// string.Trim()
|
/// string.Trim()
|
||||||
let trim (str: string) = str.Trim()
|
let trim (str: string) =
|
||||||
|
str.Trim()
|
||||||
|
|
||||||
/// string.Replace()
|
/// string.Replace()
|
||||||
let replace (find: string) repl (str: string) = str.Replace(find, repl)
|
let replace (find: string) repl (str: string) =
|
||||||
|
str.Replace(find, repl)
|
||||||
|
|
||||||
/// Replace the first occurrence of a string with a second string within a given string
|
/// Replace the first occurrence of a string with a second string within a given string
|
||||||
let replaceFirst (needle: string) replacement (haystack: string) =
|
let replaceFirst (needle: string) replacement (haystack: string) =
|
||||||
@ -51,7 +53,7 @@ let stripTags allowedTags input =
|
|||||||
allowedTags
|
allowedTags
|
||||||
|> List.fold (fun acc t ->
|
|> List.fold (fun acc t ->
|
||||||
acc
|
acc
|
||||||
|| htmlTag.IndexOf $"<{t}>" = 0
|
|| htmlTag.IndexOf $"<%s{t}>" = 0
|
||||||
|| htmlTag.IndexOf $"<{t} " = 0
|
|| htmlTag.IndexOf $"<{t} " = 0
|
||||||
|| htmlTag.IndexOf $"</{t}" = 0) false
|
|| htmlTag.IndexOf $"</{t}" = 0) false
|
||||||
|> not
|
|> not
|
||||||
@ -60,7 +62,7 @@ let stripTags allowedTags input =
|
|||||||
|
|
||||||
|
|
||||||
/// Wrap a string at the specified number of characters
|
/// Wrap a string at the specified number of characters
|
||||||
let wordWrap charPerLine (input : string) =
|
let wordWrap charPerLine (input: string) =
|
||||||
match input.Length with
|
match input.Length with
|
||||||
| len when len <= charPerLine -> input
|
| len when len <= charPerLine -> input
|
||||||
| _ ->
|
| _ ->
|
||||||
@ -92,7 +94,7 @@ let wordWrap charPerLine (input : string) =
|
|||||||
|> String.concat "\n"
|
|> String.concat "\n"
|
||||||
|
|
||||||
/// Modify the text returned by CKEditor into the format we need for request and announcement text
|
/// Modify the text returned by CKEditor into the format we need for request and announcement text
|
||||||
let ckEditorToText (text : string) =
|
let ckEditorToText (text: string) =
|
||||||
[ "\n\t", ""
|
[ "\n\t", ""
|
||||||
" ", " "
|
" ", " "
|
||||||
" ", "  "
|
" ", "  "
|
||||||
@ -119,8 +121,8 @@ let htmlToPlainText html =
|
|||||||
|> String.replace "\u00a0" " "
|
|> String.replace "\u00a0" " "
|
||||||
|
|
||||||
/// Get the second portion of a tuple as a string
|
/// Get the second portion of a tuple as a string
|
||||||
let sndAsString x = (snd >> string) x
|
let sndAsString x =
|
||||||
|
(snd >> string) x
|
||||||
|
|
||||||
/// Make a URL with query string parameters
|
/// Make a URL with query string parameters
|
||||||
let makeUrl url qs =
|
let makeUrl url qs =
|
@ -10,32 +10,31 @@ open PrayerTracker.Entities
|
|||||||
module ReferenceList =
|
module ReferenceList =
|
||||||
|
|
||||||
/// A localized list of the AsOfDateDisplay DU cases
|
/// A localized list of the AsOfDateDisplay DU cases
|
||||||
let asOfDateList (s : IStringLocalizer) = [
|
let asOfDateList (s: IStringLocalizer) = [
|
||||||
AsOfDateDisplay.toCode NoDisplay, s["Do not display the “as of” date"]
|
string NoDisplay, s["Do not display the “as of” date"]
|
||||||
AsOfDateDisplay.toCode ShortDate, s["Display a short “as of” date"]
|
string ShortDate, s["Display a short “as of” date"]
|
||||||
AsOfDateDisplay.toCode LongDate, s["Display a full “as of” date"]
|
string LongDate, s["Display a full “as of” date"]
|
||||||
]
|
]
|
||||||
|
|
||||||
/// A list of e-mail type options
|
/// A list of e-mail type options
|
||||||
let emailTypeList def (s : IStringLocalizer) =
|
let emailTypeList def (s: IStringLocalizer) =
|
||||||
// Localize the default type
|
// Localize the default type
|
||||||
let defaultType =
|
let defaultType =
|
||||||
s[match def with HtmlFormat -> "HTML Format" | PlainTextFormat -> "Plain-Text Format"].Value
|
s[match def with HtmlFormat -> "HTML Format" | PlainTextFormat -> "Plain-Text Format"].Value
|
||||||
seq {
|
seq {
|
||||||
"", LocalizedString ("", $"""{s["Group Default"].Value} ({defaultType})""")
|
"", LocalizedString ("", $"""{s["Group Default"].Value} ({defaultType})""")
|
||||||
EmailFormat.toCode HtmlFormat, s["HTML Format"]
|
string HtmlFormat, s["HTML Format"]
|
||||||
EmailFormat.toCode PlainTextFormat, s["Plain-Text Format"]
|
string PlainTextFormat, s["Plain-Text Format"]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A list of expiration options
|
/// A list of expiration options
|
||||||
let expirationList (s : IStringLocalizer) includeExpireNow = [
|
let expirationList (s: IStringLocalizer) includeExpireNow =
|
||||||
Expiration.toCode Automatic, s["Expire Normally"]
|
[ string Automatic, s["Expire Normally"]
|
||||||
Expiration.toCode Manual, s["Request Never Expires"]
|
string Manual, s["Request Never Expires"]
|
||||||
if includeExpireNow then Expiration.toCode Forced, s["Expire Immediately"]
|
if includeExpireNow then string Forced, s["Expire Immediately"] ]
|
||||||
]
|
|
||||||
|
|
||||||
/// A list of request types
|
/// A list of request types
|
||||||
let requestTypeList (s : IStringLocalizer) = [
|
let requestTypeList (s: IStringLocalizer) = [
|
||||||
CurrentRequest, s["Current Requests"]
|
CurrentRequest, s["Current Requests"]
|
||||||
LongTermRequest, s["Long-Term Requests"]
|
LongTermRequest, s["Long-Term Requests"]
|
||||||
PraiseReport, s["Praise Reports"]
|
PraiseReport, s["Praise Reports"]
|
||||||
@ -55,15 +54,15 @@ type MessageLevel =
|
|||||||
|
|
||||||
/// Support for the MessageLevel type
|
/// Support for the MessageLevel type
|
||||||
module MessageLevel =
|
module MessageLevel =
|
||||||
|
|
||||||
/// Convert a message level to its string representation
|
/// Convert a message level to its string representation
|
||||||
let toString =
|
let toString =
|
||||||
function
|
function
|
||||||
| Info -> "Info"
|
| Info -> "Info"
|
||||||
| Warning -> "WARNING"
|
| Warning -> "WARNING"
|
||||||
| Error -> "ERROR"
|
| Error -> "ERROR"
|
||||||
|
|
||||||
let toCssClass level = (toString level).ToLowerInvariant ()
|
let toCssClass level = (toString level).ToLowerInvariant()
|
||||||
|
|
||||||
|
|
||||||
/// This is used to create a message that is displayed to the user
|
/// This is used to create a message that is displayed to the user
|
||||||
@ -71,31 +70,31 @@ module MessageLevel =
|
|||||||
type UserMessage =
|
type UserMessage =
|
||||||
{ /// The type
|
{ /// The type
|
||||||
Level : MessageLevel
|
Level : MessageLevel
|
||||||
|
|
||||||
/// The actual message
|
/// The actual message
|
||||||
Text : HtmlString
|
Text : HtmlString
|
||||||
|
|
||||||
/// The description (further information)
|
/// The description (further information)
|
||||||
Description : HtmlString option
|
Description : HtmlString option
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Support for the UserMessage type
|
/// Support for the UserMessage type
|
||||||
module UserMessage =
|
module UserMessage =
|
||||||
|
|
||||||
/// Error message template
|
/// Error message template
|
||||||
let error =
|
let error =
|
||||||
{ Level = Error
|
{ Level = Error
|
||||||
Text = HtmlString.Empty
|
Text = HtmlString.Empty
|
||||||
Description = None
|
Description = None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Warning message template
|
/// Warning message template
|
||||||
let warning =
|
let warning =
|
||||||
{ Level = Warning
|
{ Level = Warning
|
||||||
Text = HtmlString.Empty
|
Text = HtmlString.Empty
|
||||||
Description = None
|
Description = None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Info message template
|
/// Info message template
|
||||||
let info =
|
let info =
|
||||||
{ Level = Info
|
{ Level = Info
|
||||||
@ -105,13 +104,13 @@ module UserMessage =
|
|||||||
|
|
||||||
/// The template with which the content will be rendered
|
/// The template with which the content will be rendered
|
||||||
type LayoutType =
|
type LayoutType =
|
||||||
|
|
||||||
/// A full page load
|
/// A full page load
|
||||||
| FullPage
|
| FullPage
|
||||||
|
|
||||||
/// A response that will provide a new body tag
|
/// A response that will provide a new body tag
|
||||||
| PartialPage
|
| PartialPage
|
||||||
|
|
||||||
/// A response that will replace the page content
|
/// A response that will replace the page content
|
||||||
| ContentOnly
|
| ContentOnly
|
||||||
|
|
||||||
@ -123,38 +122,38 @@ open NodaTime
|
|||||||
type AppViewInfo =
|
type AppViewInfo =
|
||||||
{ /// CSS files for the page
|
{ /// CSS files for the page
|
||||||
Style : string list
|
Style : string list
|
||||||
|
|
||||||
/// The link for help on this page
|
/// The link for help on this page
|
||||||
HelpLink : string option
|
HelpLink : string option
|
||||||
|
|
||||||
/// Messages to be displayed to the user
|
/// Messages to be displayed to the user
|
||||||
Messages : UserMessage list
|
Messages : UserMessage list
|
||||||
|
|
||||||
/// The current version of PrayerTracker
|
/// The current version of PrayerTracker
|
||||||
Version : string
|
Version : string
|
||||||
|
|
||||||
/// The ticks when the request started
|
/// The ticks when the request started
|
||||||
RequestStart : Instant
|
RequestStart : Instant
|
||||||
|
|
||||||
/// The currently logged on user, if there is one
|
/// The currently logged on user, if there is one
|
||||||
User : User option
|
User : User option
|
||||||
|
|
||||||
/// The currently logged on small group, if there is one
|
/// The currently logged on small group, if there is one
|
||||||
Group : SmallGroup option
|
Group : SmallGroup option
|
||||||
|
|
||||||
/// The layout with which the content will be rendered
|
/// The layout with which the content will be rendered
|
||||||
Layout : LayoutType
|
Layout : LayoutType
|
||||||
|
|
||||||
/// Scoped styles for this view
|
/// Scoped styles for this view
|
||||||
ScopedStyle : string list
|
ScopedStyle : string list
|
||||||
|
|
||||||
/// A JavaScript function to run on page load
|
/// A JavaScript function to run on page load
|
||||||
OnLoadScript : string option
|
OnLoadScript : string option
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Support for the AppViewInfo type
|
/// Support for the AppViewInfo type
|
||||||
module AppViewInfo =
|
module AppViewInfo =
|
||||||
|
|
||||||
/// A fresh version that can be populated to process the current request
|
/// A fresh version that can be populated to process the current request
|
||||||
let fresh =
|
let fresh =
|
||||||
{ Style = []
|
{ Style = []
|
||||||
@ -168,11 +167,11 @@ module AppViewInfo =
|
|||||||
ScopedStyle = []
|
ScopedStyle = []
|
||||||
OnLoadScript = None
|
OnLoadScript = None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add scoped styles to the given view info object
|
/// Add scoped styles to the given view info object
|
||||||
let withScopedStyles styles viewInfo =
|
let withScopedStyles styles viewInfo =
|
||||||
{ viewInfo with ScopedStyle = styles }
|
{ viewInfo with ScopedStyle = styles }
|
||||||
|
|
||||||
/// Add an onload action to the given view info object
|
/// Add an onload action to the given view info object
|
||||||
let withOnLoadScript script viewInfo =
|
let withOnLoadScript script viewInfo =
|
||||||
{ viewInfo with OnLoadScript = Some script }
|
{ viewInfo with OnLoadScript = Some script }
|
||||||
@ -183,18 +182,18 @@ module AppViewInfo =
|
|||||||
type Announcement =
|
type Announcement =
|
||||||
{ /// Whether the announcement should be sent to the class or to PrayerTracker users
|
{ /// Whether the announcement should be sent to the class or to PrayerTracker users
|
||||||
SendToClass : string
|
SendToClass : string
|
||||||
|
|
||||||
/// The text of the announcement
|
/// The text of the announcement
|
||||||
Text : string
|
Text : string
|
||||||
|
|
||||||
/// Whether this announcement should be added to the "Announcements" of the prayer list
|
/// Whether this announcement should be added to the "Announcements" of the prayer list
|
||||||
AddToRequestList : bool option
|
AddToRequestList : bool option
|
||||||
|
|
||||||
/// The ID of the request type to which this announcement should be added
|
/// The ID of the request type to which this announcement should be added
|
||||||
RequestType : string option
|
RequestType : string option
|
||||||
}
|
}
|
||||||
with
|
with
|
||||||
|
|
||||||
/// The text of the announcement, in plain text
|
/// The text of the announcement, in plain text
|
||||||
member this.PlainText
|
member this.PlainText
|
||||||
with get () = (htmlToPlainText >> wordWrap 74) this.Text
|
with get () = (htmlToPlainText >> wordWrap 74) this.Text
|
||||||
@ -205,19 +204,19 @@ with
|
|||||||
type AssignGroups =
|
type AssignGroups =
|
||||||
{ /// The Id of the user being assigned
|
{ /// The Id of the user being assigned
|
||||||
UserId : string
|
UserId : string
|
||||||
|
|
||||||
/// The full name of the user being assigned
|
/// The full name of the user being assigned
|
||||||
UserName : string
|
UserName : string
|
||||||
|
|
||||||
/// The Ids of the small groups to which the user is authorized
|
/// The Ids of the small groups to which the user is authorized
|
||||||
SmallGroups : string
|
SmallGroups : string
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Support for the AssignGroups type
|
/// Support for the AssignGroups type
|
||||||
module AssignGroups =
|
module AssignGroups =
|
||||||
|
|
||||||
/// Create an instance of this form from an existing user
|
/// Create an instance of this form from an existing user
|
||||||
let fromUser (user : User) =
|
let fromUser (user: User) =
|
||||||
{ UserId = shortGuid user.Id.Value
|
{ UserId = shortGuid user.Id.Value
|
||||||
UserName = user.Name
|
UserName = user.Name
|
||||||
SmallGroups = ""
|
SmallGroups = ""
|
||||||
@ -229,10 +228,10 @@ module AssignGroups =
|
|||||||
type ChangePassword =
|
type ChangePassword =
|
||||||
{ /// The user's current password
|
{ /// The user's current password
|
||||||
OldPassword : string
|
OldPassword : string
|
||||||
|
|
||||||
/// The user's new password
|
/// The user's new password
|
||||||
NewPassword : string
|
NewPassword : string
|
||||||
|
|
||||||
/// The user's new password, confirmed
|
/// The user's new password, confirmed
|
||||||
NewPasswordConfirm : string
|
NewPasswordConfirm : string
|
||||||
}
|
}
|
||||||
@ -243,29 +242,29 @@ type ChangePassword =
|
|||||||
type EditChurch =
|
type EditChurch =
|
||||||
{ /// The ID of the church
|
{ /// The ID of the church
|
||||||
ChurchId : string
|
ChurchId : string
|
||||||
|
|
||||||
/// The name of the church
|
/// The name of the church
|
||||||
Name : string
|
Name : string
|
||||||
|
|
||||||
/// The city for the church
|
/// The city for the church
|
||||||
City : string
|
City : string
|
||||||
|
|
||||||
/// The state or province for the church
|
/// The state or province for the church
|
||||||
State : string
|
State : string
|
||||||
|
|
||||||
/// Whether the church has an active Virtual Prayer Room interface
|
/// Whether the church has an active Virtual Prayer Room interface
|
||||||
HasInterface : bool option
|
HasInterface : bool option
|
||||||
|
|
||||||
/// The address for the interface
|
/// The address for the interface
|
||||||
InterfaceAddress : string option
|
InterfaceAddress : string option
|
||||||
}
|
}
|
||||||
with
|
with
|
||||||
|
|
||||||
/// Is this a new church?
|
/// Is this a new church?
|
||||||
member this.IsNew = emptyGuid = this.ChurchId
|
member this.IsNew = emptyGuid = this.ChurchId
|
||||||
|
|
||||||
/// Populate a church from this form
|
/// Populate a church from this form
|
||||||
member this.PopulateChurch (church : Church) =
|
member this.PopulateChurch (church: Church) =
|
||||||
{ church with
|
{ church with
|
||||||
Name = this.Name
|
Name = this.Name
|
||||||
City = this.City
|
City = this.City
|
||||||
@ -276,9 +275,9 @@ with
|
|||||||
|
|
||||||
/// Support for the EditChurch type
|
/// Support for the EditChurch type
|
||||||
module EditChurch =
|
module EditChurch =
|
||||||
|
|
||||||
/// Create an instance from an existing church
|
/// Create an instance from an existing church
|
||||||
let fromChurch (church : Church) =
|
let fromChurch (church: Church) =
|
||||||
{ ChurchId = shortGuid church.Id.Value
|
{ ChurchId = shortGuid church.Id.Value
|
||||||
Name = church.Name
|
Name = church.Name
|
||||||
City = church.City
|
City = church.City
|
||||||
@ -286,7 +285,7 @@ module EditChurch =
|
|||||||
HasInterface = match church.HasVpsInterface with true -> Some true | false -> None
|
HasInterface = match church.HasVpsInterface with true -> Some true | false -> None
|
||||||
InterfaceAddress = church.InterfaceAddress
|
InterfaceAddress = church.InterfaceAddress
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An instance to use for adding churches
|
/// An instance to use for adding churches
|
||||||
let empty =
|
let empty =
|
||||||
{ ChurchId = emptyGuid
|
{ ChurchId = emptyGuid
|
||||||
@ -297,38 +296,38 @@ module EditChurch =
|
|||||||
InterfaceAddress = None
|
InterfaceAddress = None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Form for adding/editing small group members
|
/// Form for adding/editing small group members
|
||||||
[<CLIMutable; NoComparison; NoEquality>]
|
[<CLIMutable; NoComparison; NoEquality>]
|
||||||
type EditMember =
|
type EditMember =
|
||||||
{ /// The Id for this small group member (not user-entered)
|
{ /// The Id for this small group member (not user-entered)
|
||||||
MemberId : string
|
MemberId : string
|
||||||
|
|
||||||
/// The name of the member
|
/// The name of the member
|
||||||
Name : string
|
Name : string
|
||||||
|
|
||||||
/// The e-mail address
|
/// The e-mail address
|
||||||
Email : string
|
Email : string
|
||||||
|
|
||||||
/// The e-mail format
|
/// The e-mail format
|
||||||
Format : string
|
Format : string
|
||||||
}
|
}
|
||||||
with
|
with
|
||||||
|
|
||||||
/// Is this a new member?
|
/// Is this a new member?
|
||||||
member this.IsNew = emptyGuid = this.MemberId
|
member this.IsNew = emptyGuid = this.MemberId
|
||||||
|
|
||||||
/// Support for the EditMember type
|
/// Support for the EditMember type
|
||||||
module EditMember =
|
module EditMember =
|
||||||
|
|
||||||
/// Create an instance from an existing member
|
/// Create an instance from an existing member
|
||||||
let fromMember (mbr : Member) =
|
let fromMember (mbr: Member) =
|
||||||
{ MemberId = shortGuid mbr.Id.Value
|
{ MemberId = shortGuid mbr.Id.Value
|
||||||
Name = mbr.Name
|
Name = mbr.Name
|
||||||
Email = mbr.Email
|
Email = mbr.Email
|
||||||
Format = match mbr.Format with Some fmt -> EmailFormat.toCode fmt | None -> ""
|
Format = mbr.Format |> Option.map string |> Option.defaultValue ""
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An empty instance
|
/// An empty instance
|
||||||
let empty =
|
let empty =
|
||||||
{ MemberId = emptyGuid
|
{ MemberId = emptyGuid
|
||||||
@ -343,68 +342,68 @@ module EditMember =
|
|||||||
type EditPreferences =
|
type EditPreferences =
|
||||||
{ /// The number of days after which requests are automatically expired
|
{ /// The number of days after which requests are automatically expired
|
||||||
ExpireDays : int
|
ExpireDays : int
|
||||||
|
|
||||||
/// The number of days requests are considered "new"
|
/// The number of days requests are considered "new"
|
||||||
DaysToKeepNew : int
|
DaysToKeepNew : int
|
||||||
|
|
||||||
/// The number of weeks after which a long-term requests is flagged as requiring an update
|
/// The number of weeks after which a long-term requests is flagged as requiring an update
|
||||||
LongTermUpdateWeeks : int
|
LongTermUpdateWeeks : int
|
||||||
|
|
||||||
/// Whether to sort by updated date or requestor/subject
|
/// Whether to sort by updated date or requestor/subject
|
||||||
RequestSort : string
|
RequestSort : string
|
||||||
|
|
||||||
/// The name from which e-mail will be sent
|
/// The name from which e-mail will be sent
|
||||||
EmailFromName : string
|
EmailFromName : string
|
||||||
|
|
||||||
/// The e-mail address from which e-mail will be sent
|
/// The e-mail address from which e-mail will be sent
|
||||||
EmailFromAddress : string
|
EmailFromAddress : string
|
||||||
|
|
||||||
/// The default e-mail type for this group
|
/// The default e-mail type for this group
|
||||||
DefaultEmailType : string
|
DefaultEmailType : string
|
||||||
|
|
||||||
/// Whether the heading line color uses named colors or R/G/B
|
/// Whether the heading line color uses named colors or R/G/B
|
||||||
LineColorType : string
|
LineColorType : string
|
||||||
|
|
||||||
/// The named color for the heading lines
|
/// The named color for the heading lines
|
||||||
LineColor : string
|
LineColor : string
|
||||||
|
|
||||||
/// Whether the heading text color uses named colors or R/G/B
|
/// Whether the heading text color uses named colors or R/G/B
|
||||||
HeadingColorType : string
|
HeadingColorType : string
|
||||||
|
|
||||||
/// The named color for the heading text
|
/// The named color for the heading text
|
||||||
HeadingColor : string
|
HeadingColor : string
|
||||||
|
|
||||||
/// Whether the class uses the native font stack
|
/// Whether the class uses the native font stack
|
||||||
IsNative : bool
|
IsNative : bool
|
||||||
|
|
||||||
/// The fonts to use for the list
|
/// The fonts to use for the list
|
||||||
Fonts : string option
|
Fonts : string option
|
||||||
|
|
||||||
/// The font size for the heading text
|
/// The font size for the heading text
|
||||||
HeadingFontSize : int
|
HeadingFontSize : int
|
||||||
|
|
||||||
/// The font size for the list text
|
/// The font size for the list text
|
||||||
ListFontSize : int
|
ListFontSize : int
|
||||||
|
|
||||||
/// The time zone for the class
|
/// The time zone for the class
|
||||||
TimeZone : string
|
TimeZone : string
|
||||||
|
|
||||||
/// The list visibility
|
/// The list visibility
|
||||||
Visibility : int
|
Visibility : int
|
||||||
|
|
||||||
/// The small group password
|
/// The small group password
|
||||||
GroupPassword : string option
|
GroupPassword : string option
|
||||||
|
|
||||||
/// The page size for search / inactive requests
|
/// The page size for search / inactive requests
|
||||||
PageSize : int
|
PageSize : int
|
||||||
|
|
||||||
/// How the as-of date should be displayed
|
/// How the as-of date should be displayed
|
||||||
AsOfDate : string
|
AsOfDate : string
|
||||||
}
|
}
|
||||||
with
|
with
|
||||||
|
|
||||||
/// Set the properties of a small group based on the form's properties
|
/// Set the properties of a small group based on the form's properties
|
||||||
member this.PopulatePreferences (prefs : ListPreferences) =
|
member this.PopulatePreferences (prefs: ListPreferences) =
|
||||||
let isPublic, grpPw =
|
let isPublic, grpPw =
|
||||||
if this.Visibility = GroupVisibility.PublicList then true, ""
|
if this.Visibility = GroupVisibility.PublicList then true, ""
|
||||||
elif this.Visibility = GroupVisibility.HasPassword then false, (defaultArg this.GroupPassword "")
|
elif this.Visibility = GroupVisibility.HasPassword then false, (defaultArg this.GroupPassword "")
|
||||||
@ -413,10 +412,10 @@ with
|
|||||||
DaysToExpire = this.ExpireDays
|
DaysToExpire = this.ExpireDays
|
||||||
DaysToKeepNew = this.DaysToKeepNew
|
DaysToKeepNew = this.DaysToKeepNew
|
||||||
LongTermUpdateWeeks = this.LongTermUpdateWeeks
|
LongTermUpdateWeeks = this.LongTermUpdateWeeks
|
||||||
RequestSort = RequestSort.fromCode this.RequestSort
|
RequestSort = RequestSort.Parse this.RequestSort
|
||||||
EmailFromName = this.EmailFromName
|
EmailFromName = this.EmailFromName
|
||||||
EmailFromAddress = this.EmailFromAddress
|
EmailFromAddress = this.EmailFromAddress
|
||||||
DefaultEmailType = EmailFormat.fromCode this.DefaultEmailType
|
DefaultEmailType = EmailFormat.Parse this.DefaultEmailType
|
||||||
LineColor = this.LineColor
|
LineColor = this.LineColor
|
||||||
HeadingColor = this.HeadingColor
|
HeadingColor = this.HeadingColor
|
||||||
Fonts = if this.IsNative || Option.isNone this.Fonts then "native" else this.Fonts.Value
|
Fonts = if this.IsNative || Option.isNone this.Fonts then "native" else this.Fonts.Value
|
||||||
@ -426,21 +425,21 @@ with
|
|||||||
IsPublic = isPublic
|
IsPublic = isPublic
|
||||||
GroupPassword = grpPw
|
GroupPassword = grpPw
|
||||||
PageSize = this.PageSize
|
PageSize = this.PageSize
|
||||||
AsOfDateDisplay = AsOfDateDisplay.fromCode this.AsOfDate
|
AsOfDateDisplay = AsOfDateDisplay.Parse this.AsOfDate
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Support for the EditPreferences type
|
/// Support for the EditPreferences type
|
||||||
module EditPreferences =
|
module EditPreferences =
|
||||||
/// Populate an edit form from existing preferences
|
/// Populate an edit form from existing preferences
|
||||||
let fromPreferences (prefs : ListPreferences) =
|
let fromPreferences (prefs: ListPreferences) =
|
||||||
let setType (x : string) = match x.StartsWith "#" with true -> "RGB" | false -> "Name"
|
let setType (x : string) = match x.StartsWith "#" with true -> "RGB" | false -> "Name"
|
||||||
{ ExpireDays = prefs.DaysToExpire
|
{ ExpireDays = prefs.DaysToExpire
|
||||||
DaysToKeepNew = prefs.DaysToKeepNew
|
DaysToKeepNew = prefs.DaysToKeepNew
|
||||||
LongTermUpdateWeeks = prefs.LongTermUpdateWeeks
|
LongTermUpdateWeeks = prefs.LongTermUpdateWeeks
|
||||||
RequestSort = RequestSort.toCode prefs.RequestSort
|
RequestSort = string prefs.RequestSort
|
||||||
EmailFromName = prefs.EmailFromName
|
EmailFromName = prefs.EmailFromName
|
||||||
EmailFromAddress = prefs.EmailFromAddress
|
EmailFromAddress = prefs.EmailFromAddress
|
||||||
DefaultEmailType = EmailFormat.toCode prefs.DefaultEmailType
|
DefaultEmailType = string prefs.DefaultEmailType
|
||||||
LineColorType = setType prefs.LineColor
|
LineColorType = setType prefs.LineColor
|
||||||
LineColor = prefs.LineColor
|
LineColor = prefs.LineColor
|
||||||
HeadingColorType = setType prefs.HeadingColor
|
HeadingColorType = setType prefs.HeadingColor
|
||||||
@ -449,10 +448,10 @@ module EditPreferences =
|
|||||||
Fonts = if prefs.Fonts = "native" then None else Some prefs.Fonts
|
Fonts = if prefs.Fonts = "native" then None else Some prefs.Fonts
|
||||||
HeadingFontSize = prefs.HeadingFontSize
|
HeadingFontSize = prefs.HeadingFontSize
|
||||||
ListFontSize = prefs.TextFontSize
|
ListFontSize = prefs.TextFontSize
|
||||||
TimeZone = TimeZoneId.toString prefs.TimeZoneId
|
TimeZone = string prefs.TimeZoneId
|
||||||
GroupPassword = Some prefs.GroupPassword
|
GroupPassword = Some prefs.GroupPassword
|
||||||
PageSize = prefs.PageSize
|
PageSize = prefs.PageSize
|
||||||
AsOfDate = AsOfDateDisplay.toCode prefs.AsOfDateDisplay
|
AsOfDate = string prefs.AsOfDateDisplay
|
||||||
Visibility =
|
Visibility =
|
||||||
if prefs.IsPublic then GroupVisibility.PublicList
|
if prefs.IsPublic then GroupVisibility.PublicList
|
||||||
elif prefs.GroupPassword = "" then GroupVisibility.PrivateList
|
elif prefs.GroupPassword = "" then GroupVisibility.PrivateList
|
||||||
@ -465,51 +464,51 @@ module EditPreferences =
|
|||||||
type EditRequest =
|
type EditRequest =
|
||||||
{ /// The ID of the request
|
{ /// The ID of the request
|
||||||
RequestId : string
|
RequestId : string
|
||||||
|
|
||||||
/// The type of the request
|
/// The type of the request
|
||||||
RequestType : string
|
RequestType : string
|
||||||
|
|
||||||
/// The date of the request
|
/// The date of the request
|
||||||
EnteredDate : string option
|
EnteredDate : string option
|
||||||
|
|
||||||
/// Whether to update the date or not
|
/// Whether to update the date or not
|
||||||
SkipDateUpdate : bool option
|
SkipDateUpdate : bool option
|
||||||
|
|
||||||
/// The requestor or subject
|
/// The requestor or subject
|
||||||
Requestor : string option
|
Requestor : string option
|
||||||
|
|
||||||
/// How this request is expired
|
/// How this request is expired
|
||||||
Expiration : string
|
Expiration : string
|
||||||
|
|
||||||
/// The text of the request
|
/// The text of the request
|
||||||
Text : string
|
Text : string
|
||||||
}
|
}
|
||||||
with
|
with
|
||||||
|
|
||||||
/// Is this a new request?
|
/// Is this a new request?
|
||||||
member this.IsNew = emptyGuid = this.RequestId
|
member this.IsNew = emptyGuid = this.RequestId
|
||||||
|
|
||||||
/// Support for the EditRequest type
|
/// Support for the EditRequest type
|
||||||
module EditRequest =
|
module EditRequest =
|
||||||
|
|
||||||
/// An empty instance to use for new requests
|
/// An empty instance to use for new requests
|
||||||
let empty =
|
let empty =
|
||||||
{ RequestId = emptyGuid
|
{ RequestId = emptyGuid
|
||||||
RequestType = PrayerRequestType.toCode CurrentRequest
|
RequestType = string CurrentRequest
|
||||||
EnteredDate = None
|
EnteredDate = None
|
||||||
SkipDateUpdate = None
|
SkipDateUpdate = None
|
||||||
Requestor = None
|
Requestor = None
|
||||||
Expiration = Expiration.toCode Automatic
|
Expiration = string Automatic
|
||||||
Text = ""
|
Text = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create an instance from an existing request
|
/// Create an instance from an existing request
|
||||||
let fromRequest (req : PrayerRequest) =
|
let fromRequest (req: PrayerRequest) =
|
||||||
{ empty with
|
{ empty with
|
||||||
RequestId = shortGuid req.Id.Value
|
RequestId = shortGuid req.Id.Value
|
||||||
RequestType = PrayerRequestType.toCode req.RequestType
|
RequestType = string req.RequestType
|
||||||
Requestor = req.Requestor
|
Requestor = req.Requestor
|
||||||
Expiration = Expiration.toCode req.Expiration
|
Expiration = string req.Expiration
|
||||||
Text = req.Text
|
Text = req.Text
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -519,20 +518,20 @@ module EditRequest =
|
|||||||
type EditSmallGroup =
|
type EditSmallGroup =
|
||||||
{ /// The ID of the small group
|
{ /// The ID of the small group
|
||||||
SmallGroupId : string
|
SmallGroupId : string
|
||||||
|
|
||||||
/// The name of the small group
|
/// The name of the small group
|
||||||
Name : string
|
Name : string
|
||||||
|
|
||||||
/// The ID of the church to which this small group belongs
|
/// The ID of the church to which this small group belongs
|
||||||
ChurchId : string
|
ChurchId : string
|
||||||
}
|
}
|
||||||
with
|
with
|
||||||
|
|
||||||
/// Is this a new small group?
|
/// Is this a new small group?
|
||||||
member this.IsNew = emptyGuid = this.SmallGroupId
|
member this.IsNew = emptyGuid = this.SmallGroupId
|
||||||
|
|
||||||
/// Populate a small group from this form
|
/// Populate a small group from this form
|
||||||
member this.populateGroup (grp : SmallGroup) =
|
member this.populateGroup (grp: SmallGroup) =
|
||||||
{ grp with
|
{ grp with
|
||||||
Name = this.Name
|
Name = this.Name
|
||||||
ChurchId = idFromShort ChurchId this.ChurchId
|
ChurchId = idFromShort ChurchId this.ChurchId
|
||||||
@ -540,14 +539,14 @@ with
|
|||||||
|
|
||||||
/// Support for the EditSmallGroup type
|
/// Support for the EditSmallGroup type
|
||||||
module EditSmallGroup =
|
module EditSmallGroup =
|
||||||
|
|
||||||
/// Create an instance from an existing small group
|
/// Create an instance from an existing small group
|
||||||
let fromGroup (grp : SmallGroup) =
|
let fromGroup (grp: SmallGroup) =
|
||||||
{ SmallGroupId = shortGuid grp.Id.Value
|
{ SmallGroupId = shortGuid grp.Id.Value
|
||||||
Name = grp.Name
|
Name = grp.Name
|
||||||
ChurchId = shortGuid grp.ChurchId.Value
|
ChurchId = shortGuid grp.ChurchId.Value
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An empty instance (used when adding a new group)
|
/// An empty instance (used when adding a new group)
|
||||||
let empty =
|
let empty =
|
||||||
{ SmallGroupId = emptyGuid
|
{ SmallGroupId = emptyGuid
|
||||||
@ -561,32 +560,32 @@ module EditSmallGroup =
|
|||||||
type EditUser =
|
type EditUser =
|
||||||
{ /// The ID of the user
|
{ /// The ID of the user
|
||||||
UserId : string
|
UserId : string
|
||||||
|
|
||||||
/// The first name of the user
|
/// The first name of the user
|
||||||
FirstName : string
|
FirstName : string
|
||||||
|
|
||||||
/// The last name of the user
|
/// The last name of the user
|
||||||
LastName : string
|
LastName : string
|
||||||
|
|
||||||
/// The e-mail address for the user
|
/// The e-mail address for the user
|
||||||
Email : string
|
Email : string
|
||||||
|
|
||||||
/// The password for the user
|
/// The password for the user
|
||||||
Password : string
|
Password : string
|
||||||
|
|
||||||
/// The password hash for the user a second time
|
/// The password hash for the user a second time
|
||||||
PasswordConfirm : string
|
PasswordConfirm : string
|
||||||
|
|
||||||
/// Is this user a PrayerTracker administrator?
|
/// Is this user a PrayerTracker administrator?
|
||||||
IsAdmin : bool option
|
IsAdmin : bool option
|
||||||
}
|
}
|
||||||
with
|
with
|
||||||
|
|
||||||
/// Is this a new user?
|
/// Is this a new user?
|
||||||
member this.IsNew = emptyGuid = this.UserId
|
member this.IsNew = emptyGuid = this.UserId
|
||||||
|
|
||||||
/// Populate a user from the form
|
/// Populate a user from the form
|
||||||
member this.PopulateUser (user : User) hasher =
|
member this.PopulateUser (user: User) hasher =
|
||||||
{ user with
|
{ user with
|
||||||
FirstName = this.FirstName
|
FirstName = this.FirstName
|
||||||
LastName = this.LastName
|
LastName = this.LastName
|
||||||
@ -599,7 +598,7 @@ with
|
|||||||
|
|
||||||
/// Support for the EditUser type
|
/// Support for the EditUser type
|
||||||
module EditUser =
|
module EditUser =
|
||||||
|
|
||||||
/// An empty instance
|
/// An empty instance
|
||||||
let empty =
|
let empty =
|
||||||
{ UserId = emptyGuid
|
{ UserId = emptyGuid
|
||||||
@ -610,9 +609,9 @@ module EditUser =
|
|||||||
PasswordConfirm = ""
|
PasswordConfirm = ""
|
||||||
IsAdmin = None
|
IsAdmin = None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create an instance from an existing user
|
/// Create an instance from an existing user
|
||||||
let fromUser (user : User) =
|
let fromUser (user: User) =
|
||||||
{ empty with
|
{ empty with
|
||||||
UserId = shortGuid user.Id.Value
|
UserId = shortGuid user.Id.Value
|
||||||
FirstName = user.FirstName
|
FirstName = user.FirstName
|
||||||
@ -627,17 +626,17 @@ module EditUser =
|
|||||||
type GroupLogOn =
|
type GroupLogOn =
|
||||||
{ /// The ID of the small group to which the user is logging on
|
{ /// The ID of the small group to which the user is logging on
|
||||||
SmallGroupId : string
|
SmallGroupId : string
|
||||||
|
|
||||||
/// The password entered
|
/// The password entered
|
||||||
Password : string
|
Password : string
|
||||||
|
|
||||||
/// Whether to remember the login
|
/// Whether to remember the login
|
||||||
RememberMe : bool option
|
RememberMe : bool option
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Support for the GroupLogOn type
|
/// Support for the GroupLogOn type
|
||||||
module GroupLogOn =
|
module GroupLogOn =
|
||||||
|
|
||||||
/// An empty instance
|
/// An empty instance
|
||||||
let empty =
|
let empty =
|
||||||
{ SmallGroupId = emptyGuid
|
{ SmallGroupId = emptyGuid
|
||||||
@ -651,27 +650,27 @@ module GroupLogOn =
|
|||||||
type MaintainRequests =
|
type MaintainRequests =
|
||||||
{ /// The requests to be displayed
|
{ /// The requests to be displayed
|
||||||
Requests : PrayerRequest list
|
Requests : PrayerRequest list
|
||||||
|
|
||||||
/// The small group to which the requests belong
|
/// The small group to which the requests belong
|
||||||
SmallGroup : SmallGroup
|
SmallGroup : SmallGroup
|
||||||
|
|
||||||
/// Whether only active requests are included
|
/// Whether only active requests are included
|
||||||
OnlyActive : bool option
|
OnlyActive : bool option
|
||||||
|
|
||||||
/// The search term for the requests
|
/// The search term for the requests
|
||||||
SearchTerm : string option
|
SearchTerm : string option
|
||||||
|
|
||||||
/// The page number of the results
|
/// The page number of the results
|
||||||
PageNbr : int option
|
PageNbr : int option
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Support for the MaintainRequests type
|
/// Support for the MaintainRequests type
|
||||||
module MaintainRequests =
|
module MaintainRequests =
|
||||||
|
|
||||||
/// An empty instance
|
/// An empty instance
|
||||||
let empty =
|
let empty =
|
||||||
{ Requests = []
|
{ Requests = []
|
||||||
SmallGroup = SmallGroup.empty
|
SmallGroup = SmallGroup.Empty
|
||||||
OnlyActive = None
|
OnlyActive = None
|
||||||
SearchTerm = None
|
SearchTerm = None
|
||||||
PageNbr = None
|
PageNbr = None
|
||||||
@ -683,16 +682,16 @@ module MaintainRequests =
|
|||||||
type Overview =
|
type Overview =
|
||||||
{ /// The total number of active requests
|
{ /// The total number of active requests
|
||||||
TotalActiveReqs : int
|
TotalActiveReqs : int
|
||||||
|
|
||||||
/// The numbers of active requests by request type
|
/// The numbers of active requests by request type
|
||||||
ActiveReqsByType : Map<PrayerRequestType, int>
|
ActiveReqsByType : Map<PrayerRequestType, int>
|
||||||
|
|
||||||
/// A count of all requests
|
/// A count of all requests
|
||||||
AllReqs : int
|
AllReqs : int
|
||||||
|
|
||||||
/// A count of all members
|
/// A count of all members
|
||||||
TotalMembers : int
|
TotalMembers : int
|
||||||
|
|
||||||
/// The users authorized to administer this group
|
/// The users authorized to administer this group
|
||||||
Admins : User list
|
Admins : User list
|
||||||
}
|
}
|
||||||
@ -703,23 +702,23 @@ type Overview =
|
|||||||
type UserLogOn =
|
type UserLogOn =
|
||||||
{ /// The e-mail address of the user
|
{ /// The e-mail address of the user
|
||||||
Email : string
|
Email : string
|
||||||
|
|
||||||
/// The password entered
|
/// The password entered
|
||||||
Password : string
|
Password : string
|
||||||
|
|
||||||
/// The ID of the small group to which the user is logging on
|
/// The ID of the small group to which the user is logging on
|
||||||
SmallGroupId : string
|
SmallGroupId : string
|
||||||
|
|
||||||
/// Whether to remember the login
|
/// Whether to remember the login
|
||||||
RememberMe : bool option
|
RememberMe : bool option
|
||||||
|
|
||||||
/// The URL to which the user should be redirected once login is successful
|
/// The URL to which the user should be redirected once login is successful
|
||||||
RedirectUrl : string option
|
RedirectUrl : string option
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Support for the UserLogOn type
|
/// Support for the UserLogOn type
|
||||||
module UserLogOn =
|
module UserLogOn =
|
||||||
|
|
||||||
/// An empty instance
|
/// An empty instance
|
||||||
let empty =
|
let empty =
|
||||||
{ Email = ""
|
{ Email = ""
|
||||||
@ -737,32 +736,32 @@ open Giraffe.ViewEngine
|
|||||||
type RequestList =
|
type RequestList =
|
||||||
{ /// The prayer request list
|
{ /// The prayer request list
|
||||||
Requests : PrayerRequest list
|
Requests : PrayerRequest list
|
||||||
|
|
||||||
/// The date for which this list is being generated
|
/// The date for which this list is being generated
|
||||||
Date : LocalDate
|
Date : LocalDate
|
||||||
|
|
||||||
/// The small group to which this list belongs
|
/// The small group to which this list belongs
|
||||||
SmallGroup : SmallGroup
|
SmallGroup : SmallGroup
|
||||||
|
|
||||||
/// Whether to show the class header
|
/// Whether to show the class header
|
||||||
ShowHeader : bool
|
ShowHeader : bool
|
||||||
|
|
||||||
/// The list of recipients (populated if requests are e-mailed)
|
/// The list of recipients (populated if requests are e-mailed)
|
||||||
Recipients : Member list
|
Recipients : Member list
|
||||||
|
|
||||||
/// Whether the user can e-mail this list
|
/// Whether the user can e-mail this list
|
||||||
CanEmail : bool
|
CanEmail : bool
|
||||||
}
|
}
|
||||||
with
|
with
|
||||||
|
|
||||||
/// Group requests by their type, along with the type and its localized string
|
/// Group requests by their type, along with the type and its localized string
|
||||||
member this.RequestsByType (s : IStringLocalizer) =
|
member this.RequestsByType (s: IStringLocalizer) =
|
||||||
ReferenceList.requestTypeList s
|
ReferenceList.requestTypeList s
|
||||||
|> List.map (fun (typ, name) ->
|
|> List.map (fun (typ, name) ->
|
||||||
let sort =
|
let sort =
|
||||||
match this.SmallGroup.Preferences.RequestSort with
|
match this.SmallGroup.Preferences.RequestSort with
|
||||||
| SortByDate -> Seq.sortByDescending (fun req -> req.UpdatedDate)
|
| SortByDate -> Seq.sortByDescending _.UpdatedDate
|
||||||
| SortByRequestor -> Seq.sortBy (fun req -> req.Requestor)
|
| SortByRequestor -> Seq.sortBy _.Requestor
|
||||||
let reqs =
|
let reqs =
|
||||||
this.Requests
|
this.Requests
|
||||||
|> Seq.ofList
|
|> Seq.ofList
|
||||||
@ -771,14 +770,14 @@ with
|
|||||||
|> List.ofSeq
|
|> List.ofSeq
|
||||||
typ, name, reqs)
|
typ, name, reqs)
|
||||||
|> List.filter (fun (_, _, reqs) -> not (List.isEmpty reqs))
|
|> List.filter (fun (_, _, reqs) -> not (List.isEmpty reqs))
|
||||||
|
|
||||||
/// Is this request new?
|
/// Is this request new?
|
||||||
member this.IsNew (req : PrayerRequest) =
|
member this.IsNew (req: PrayerRequest) =
|
||||||
let reqDate = req.UpdatedDate.InZone(SmallGroup.timeZone this.SmallGroup).Date
|
let reqDate = req.UpdatedDate.InZone(this.SmallGroup.TimeZone).Date
|
||||||
Period.Between(reqDate, this.Date, PeriodUnits.Days).Days <= this.SmallGroup.Preferences.DaysToKeepNew
|
Period.Between(reqDate, this.Date, PeriodUnits.Days).Days <= this.SmallGroup.Preferences.DaysToKeepNew
|
||||||
|
|
||||||
/// Generate this list as HTML
|
/// Generate this list as HTML
|
||||||
member this.AsHtml (s : IStringLocalizer) =
|
member this.AsHtml (s: IStringLocalizer) =
|
||||||
let p = this.SmallGroup.Preferences
|
let p = this.SmallGroup.Preferences
|
||||||
let asOfSize = Math.Round (float p.TextFontSize * 0.8, 2)
|
let asOfSize = Math.Round (float p.TextFontSize * 0.8, 2)
|
||||||
[ if this.ShowHeader then
|
[ if this.ShowHeader then
|
||||||
@ -804,7 +803,7 @@ with
|
|||||||
]
|
]
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
let tz = SmallGroup.timeZone this.SmallGroup
|
let tz = this.SmallGroup.TimeZone
|
||||||
reqs
|
reqs
|
||||||
|> List.map (fun req ->
|
|> List.map (fun req ->
|
||||||
let bullet = if this.IsNew req then "circle" else "disc"
|
let bullet = if this.IsNew req then "circle" else "disc"
|
||||||
@ -822,8 +821,8 @@ with
|
|||||||
| LongDate ->
|
| LongDate ->
|
||||||
let dt =
|
let dt =
|
||||||
match p.AsOfDateDisplay with
|
match p.AsOfDateDisplay with
|
||||||
| ShortDate -> req.UpdatedDate.InZone(tz).Date.ToString ("d", null)
|
| ShortDate -> req.UpdatedDate.InZone(tz).Date.ToString("d", null)
|
||||||
| LongDate -> req.UpdatedDate.InZone(tz).Date.ToString ("D", null)
|
| LongDate -> req.UpdatedDate.InZone(tz).Date.ToString("D", null)
|
||||||
| _ -> ""
|
| _ -> ""
|
||||||
i [ _style $"font-size:%.2f{asOfSize}pt" ] [
|
i [ _style $"font-size:%.2f{asOfSize}pt" ] [
|
||||||
rawText " ("; str s["as of"].Value; str " "; str dt; rawText ")"
|
rawText " ("; str s["as of"].Value; str " "; str dt; rawText ")"
|
||||||
@ -835,17 +834,17 @@ with
|
|||||||
|> RenderView.AsString.htmlNodes
|
|> RenderView.AsString.htmlNodes
|
||||||
|
|
||||||
/// Generate this list as plain text
|
/// Generate this list as plain text
|
||||||
member this.AsText (s : IStringLocalizer) =
|
member this.AsText (s: IStringLocalizer) =
|
||||||
let tz = SmallGroup.timeZone this.SmallGroup
|
let tz = this.SmallGroup.TimeZone
|
||||||
seq {
|
seq {
|
||||||
this.SmallGroup.Name
|
this.SmallGroup.Name
|
||||||
s["Prayer Requests"].Value
|
s["Prayer Requests"].Value
|
||||||
this.Date.ToString (s["MMMM d, yyyy"].Value, null)
|
this.Date.ToString(s["MMMM d, yyyy"].Value, null)
|
||||||
" "
|
" "
|
||||||
for _, name, reqs in this.RequestsByType s do
|
for _, name, reqs in this.RequestsByType s do
|
||||||
let dashes = String.replicate (name.Value.Length + 4) "-"
|
let dashes = String.replicate (name.Value.Length + 4) "-"
|
||||||
dashes
|
dashes
|
||||||
$" {name.Value.ToUpper ()}"
|
$" {name.Value.ToUpper()}"
|
||||||
dashes
|
dashes
|
||||||
for req in reqs do
|
for req in reqs do
|
||||||
let bullet = if this.IsNew req then "+" else "-"
|
let bullet = if this.IsNew req then "+" else "-"
|
||||||
@ -855,8 +854,8 @@ with
|
|||||||
| _ ->
|
| _ ->
|
||||||
let dt =
|
let dt =
|
||||||
match this.SmallGroup.Preferences.AsOfDateDisplay with
|
match this.SmallGroup.Preferences.AsOfDateDisplay with
|
||||||
| ShortDate -> req.UpdatedDate.InZone(tz).Date.ToString ("d", null)
|
| ShortDate -> req.UpdatedDate.InZone(tz).Date.ToString("d", null)
|
||||||
| LongDate -> req.UpdatedDate.InZone(tz).Date.ToString ("D", null)
|
| LongDate -> req.UpdatedDate.InZone(tz).Date.ToString("D", null)
|
||||||
| _ -> ""
|
| _ -> ""
|
||||||
$""" ({s["as of"].Value} {dt})"""
|
$""" ({s["as of"].Value} {dt})"""
|
||||||
|> sprintf " %s %s%s%s" bullet requestor (htmlToPlainText req.Text)
|
|> sprintf " %s %s%s%s" bullet requestor (htmlToPlainText req.Text)
|
Loading…
x
Reference in New Issue
Block a user