Compare commits
No commits in common. "main" and "v3" have entirely different histories.
|
@ -1,5 +1,12 @@
|
|||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {}
|
||||
"tools": {
|
||||
"fake-cli": {
|
||||
"version": "5.23.0",
|
||||
"commands": [
|
||||
"fake"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -1,9 +1,9 @@
|
|||
.ionide
|
||||
.fake
|
||||
.idea
|
||||
src/**/bin
|
||||
src/**/obj
|
||||
src/**/appsettings.*.json
|
||||
src/.vs
|
||||
src/.idea
|
||||
|
||||
.fake
|
||||
src/JobsJobsJobs/JobsJobsJobs.V3Migration/appsettings.json
|
||||
|
|
78
build.fsx
Normal file
78
build.fsx
Normal file
|
@ -0,0 +1,78 @@
|
|||
#r "paket:
|
||||
nuget Fake.DotNet.Cli
|
||||
nuget Fake.IO.FileSystem
|
||||
nuget Fake.JavaScript.Npm
|
||||
nuget Fake.Core.Target //"
|
||||
#load ".fake/build.fsx/intellisense.fsx"
|
||||
open Fake.Core
|
||||
open Fake.DotNet
|
||||
open Fake.IO
|
||||
open Fake.IO.Globbing.Operators
|
||||
open Fake.JavaScript
|
||||
open Fake.Core.TargetOperators
|
||||
|
||||
/// The path to the Vue client
|
||||
let clientPath = "src/JobsJobsJobs/App"
|
||||
|
||||
/// The path to the API server
|
||||
let serverPath = "src/JobsJobsJobs/Server"
|
||||
|
||||
Target.initEnvironment ()
|
||||
|
||||
Target.create "Clean" (fun _ ->
|
||||
!! "src/**/bin"
|
||||
++ "src/**/obj"
|
||||
++ $"{serverPath}/wwwroot"
|
||||
|> Shell.cleanDirs
|
||||
)
|
||||
|
||||
Target.create "BuildClient" (fun _ ->
|
||||
let inClientPath (opts : Npm.NpmParams) = { opts with WorkingDirectory = clientPath; }
|
||||
Npm.exec "i --legacy-peer-deps" inClientPath
|
||||
Npm.run "build" inClientPath
|
||||
)
|
||||
|
||||
Target.create "BuildServer" (fun _ ->
|
||||
DotNet.build (fun opts -> { opts with NoLogo = true }) serverPath
|
||||
)
|
||||
|
||||
Target.create "RunServer" (fun _ ->
|
||||
DotNet.exec (fun opts -> { opts with WorkingDirectory = serverPath }) "run" "" |> ignore
|
||||
)
|
||||
|
||||
Target.create "Publish" (fun _ ->
|
||||
DotNet.publish
|
||||
(fun opts -> { opts with Runtime = Some "linux-x64"; SelfContained = Some false; NoLogo = true })
|
||||
serverPath
|
||||
)
|
||||
|
||||
Target.create "BuildAndRun" ignore
|
||||
Target.create "All" ignore
|
||||
|
||||
"Clean"
|
||||
==> "All"
|
||||
"Clean"
|
||||
==> "Publish"
|
||||
"Clean"
|
||||
?=> "BuildClient"
|
||||
"Clean"
|
||||
==> "BuildAndRun"
|
||||
|
||||
"BuildClient"
|
||||
==> "All"
|
||||
"BuildClient"
|
||||
?=> "BuildServer"
|
||||
"BuildClient"
|
||||
?=> "RunServer"
|
||||
"BuildClient"
|
||||
==> "BuildAndRun"
|
||||
"BuildClient"
|
||||
==> "Publish"
|
||||
|
||||
"BuildServer"
|
||||
==> "All"
|
||||
|
||||
"RunServer"
|
||||
==> "BuildAndRun"
|
||||
|
||||
Target.runOrDefault "All"
|
231
build.fsx.lock
Normal file
231
build.fsx.lock
Normal file
|
@ -0,0 +1,231 @@
|
|||
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.IO.FileSystem (5.23)
|
||||
Fake.Core.String (>= 5.23)
|
||||
FSharp.Core (>= 6.0)
|
||||
Fake.JavaScript.Npm (5.23)
|
||||
Fake.Core.Environment (>= 5.23)
|
||||
Fake.Core.Process (>= 5.23)
|
||||
Fake.IO.FileSystem (>= 5.23)
|
||||
Fake.Testing.Common (>= 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.3.1)
|
||||
System.Security.Permissions (>= 6.0)
|
||||
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
Executable file
7
fake.sh
Executable file
|
@ -0,0 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -eu
|
||||
set -o pipefail
|
||||
|
||||
dotnet tool restore
|
||||
dotnet fake "$@"
|
|
@ -1,26 +1,10 @@
|
|||
FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build
|
||||
FROM mcr.microsoft.com/dotnet/sdk:5.0 AS build
|
||||
WORKDIR /jjj
|
||||
COPY ./JobsJobsJobs.sln ./
|
||||
COPY ./JobsJobsJobs/Directory.Build.props ./JobsJobsJobs/
|
||||
COPY ./JobsJobsJobs/Application/JobsJobsJobs.Application.fsproj ./JobsJobsJobs/Application/
|
||||
COPY ./JobsJobsJobs/Citizens/JobsJobsJobs.Citizens.fsproj ./JobsJobsJobs/Citizens/
|
||||
COPY ./JobsJobsJobs/Common/JobsJobsJobs.Common.fsproj ./JobsJobsJobs/Common/
|
||||
COPY ./JobsJobsJobs/Home/JobsJobsJobs.Home.fsproj ./JobsJobsJobs/Home/
|
||||
COPY ./JobsJobsJobs/Listings/JobsJobsJobs.Listings.fsproj ./JobsJobsJobs/Listings/
|
||||
COPY ./JobsJobsJobs/Profiles/JobsJobsJobs.Profiles.fsproj ./JobsJobsJobs/Profiles/
|
||||
COPY ./JobsJobsJobs/SuccessStories/JobsJobsJobs.SuccessStories.fsproj ./JobsJobsJobs/SuccessStories/
|
||||
RUN dotnet restore
|
||||
|
||||
COPY . ./
|
||||
WORKDIR /jjj/JobsJobsJobs/Application
|
||||
RUN dotnet publish -c Release -r linux-x64
|
||||
RUN rm bin/Release/net8.0/linux-x64/publish/appsettings.*.json
|
||||
WORKDIR /jjj/JobsJobsJobs/Server
|
||||
RUN dotnet publish JobsJobsJobs.Server.csproj -c Release /p:PublishProfile=Properties/PublishProfiles/FolderProfile.xml
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine as final
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache icu-libs
|
||||
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
|
||||
COPY --from=build /jjj/JobsJobsJobs/Application/bin/Release/net8.0/linux-x64/publish/ ./
|
||||
|
||||
EXPOSE 80
|
||||
CMD [ "dotnet", "/app/JobsJobsJobs.Application.dll" ]
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:5.0
|
||||
WORKDIR /jjj
|
||||
COPY --from=build /jjj/JobsJobsJobs/Server/bin/Release/net5.0/linux-x64/publish/ ./
|
||||
ENTRYPOINT [ "/jjj/JobsJobsJobs.Server" ]
|
||||
|
|
|
@ -27,6 +27,8 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Profiles", "JobsJobsJobs\Pr
|
|||
EndProject
|
||||
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "SuccessStories", "JobsJobsJobs\SuccessStories\JobsJobsJobs.SuccessStories.fsproj", "{8DAFA6F6-0415-4507-B31C-7FEBE0D2E9D7}"
|
||||
EndProject
|
||||
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "JobsJobsJobs.V3Migration", "JobsJobsJobs\JobsJobsJobs.V3Migration\JobsJobsJobs.V3Migration.fsproj", "{DC3E225D-9720-44E8-86AE-DEE71262C9F0}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
|
@ -37,6 +39,10 @@ Global
|
|||
{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{DC3E225D-9720-44E8-86AE-DEE71262C9F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{DC3E225D-9720-44E8-86AE-DEE71262C9F0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{DC3E225D-9720-44E8-86AE-DEE71262C9F0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{DC3E225D-9720-44E8-86AE-DEE71262C9F0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D6E4A943-5113-41ED-A547-8D3BE5516DC0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D6E4A943-5113-41ED-A547-8D3BE5516DC0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D6E4A943-5113-41ED-A547-8D3BE5516DC0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
|
@ -70,6 +76,7 @@ Global
|
|||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{8F5A3D1E-562B-4F27-9787-6CB14B35E69E} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF}
|
||||
{DC3E225D-9720-44E8-86AE-DEE71262C9F0} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF}
|
||||
{D6E4A943-5113-41ED-A547-8D3BE5516DC0} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF}
|
||||
{4C184AB8-DDA7-4545-BC84-A4ACCBE29764} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF}
|
||||
{0B89D606-A094-4E82-8F8A-9D72D6A0E805} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF}
|
||||
|
|
|
@ -3,7 +3,6 @@ module JobsJobsJobs.App
|
|||
|
||||
open System
|
||||
open System.Text
|
||||
open BitBadger.AspNetCore.CanonicalDomains
|
||||
open Giraffe
|
||||
open Giraffe.EndpointRouting
|
||||
open JobsJobsJobs.Common.Data
|
||||
|
@ -31,7 +30,6 @@ type BufferedBodyMiddleware (next : RequestDelegate) =
|
|||
let main args =
|
||||
|
||||
let builder = WebApplication.CreateBuilder args
|
||||
let _ = builder.Configuration.AddEnvironmentVariables "JJJ_"
|
||||
let svc = builder.Services
|
||||
|
||||
let _ = svc.AddGiraffe ()
|
||||
|
@ -59,9 +57,6 @@ let main args =
|
|||
opts.Cookie.HttpOnly <- true
|
||||
opts.Cookie.IsEssential <- true)
|
||||
|
||||
let emailCfg = cfg.GetSection "Email"
|
||||
if (emailCfg.GetChildren >> Seq.isEmpty >> not) () then ConfigurationBinder.Bind(emailCfg, Email.options)
|
||||
|
||||
let app = builder.Build ()
|
||||
|
||||
// Unify the endpoints from all features
|
||||
|
@ -75,7 +70,6 @@ let main args =
|
|||
]
|
||||
|
||||
let _ = app.UseForwardedHeaders ()
|
||||
let _ = app.UseCanonicalDomains ()
|
||||
let _ = app.UseCookiePolicy (CookiePolicyOptions (MinimumSameSitePolicy = SameSiteMode.Strict))
|
||||
let _ = app.UseStaticFiles ()
|
||||
let _ = app.UseRouting ()
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<PublishSingleFile>false</PublishSingleFile>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<SelfContained>false</SelfContained>
|
||||
<WarnOn>3390;$(WarnOn)</WarnOn>
|
||||
</PropertyGroup>
|
||||
|
@ -25,12 +25,4 @@
|
|||
<ProjectReference Include="..\SuccessStories\JobsJobsJobs.SuccessStories.fsproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="FSharp.Core" Version="8.0.300" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BitBadger.AspNetCore.CanonicalDomains" Version="1.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
@ -4,12 +4,5 @@
|
|||
"JobsJobsJobs.Api.Handlers.Citizen": "Information",
|
||||
"Microsoft.AspNetCore.StaticFiles": "Warning"
|
||||
}
|
||||
},
|
||||
"Kestrel": {
|
||||
"EndPoints": {
|
||||
"Http": {
|
||||
"Url": "http://0.0.0.0:80"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
module JobsJobsJobs.Citizens.Data
|
||||
|
||||
open BitBadger.Npgsql.FSharp.Documents
|
||||
open JobsJobsJobs.Common.Data
|
||||
open JobsJobsJobs.Domain
|
||||
open NodaTime
|
||||
|
@ -12,36 +11,44 @@ let mutable private lastPurge = Instant.MinValue
|
|||
/// Lock access to the above
|
||||
let private locker = obj ()
|
||||
|
||||
/// Delete a citizen by their ID
|
||||
let deleteById citizenId = backgroundTask {
|
||||
let citId = CitizenId.toString citizenId
|
||||
do! Custom.nonQuery
|
||||
$"{Query.Delete.byContains Table.Success};
|
||||
{Query.Delete.byContains Table.Listing};
|
||||
{Query.Delete.byId Table.Citizen}"
|
||||
[ "@criteria", Query.jsonbDocParam {| citizenId = citId |}; "@id", Sql.string citId ]
|
||||
/// Delete a citizen by their ID using the given connection properties
|
||||
let private doDeleteById citizenId connProps = backgroundTask {
|
||||
let! _ =
|
||||
connProps
|
||||
|> Sql.query $"
|
||||
DELETE FROM {Table.Success} WHERE data ->> 'citizenId' = @id;
|
||||
DELETE FROM {Table.Listing} WHERE data ->> 'citizenId' = @id;
|
||||
DELETE FROM {Table.Citizen} WHERE id = @id"
|
||||
|> Sql.parameters [ "@id", Sql.string (CitizenId.toString citizenId) ]
|
||||
|> Sql.executeNonQueryAsync
|
||||
()
|
||||
}
|
||||
|
||||
/// Delete a citizen by their ID
|
||||
let deleteById citizenId =
|
||||
doDeleteById citizenId (dataSource ())
|
||||
|
||||
/// Save a citizen
|
||||
let private saveCitizen (citizen : Citizen) =
|
||||
save Table.Citizen (CitizenId.toString citizen.Id) citizen
|
||||
let private saveCitizen (citizen : Citizen) connProps =
|
||||
saveDocument Table.Citizen (CitizenId.toString citizen.Id) connProps (mkDoc citizen)
|
||||
|
||||
/// Save security information for a citizen
|
||||
let saveSecurityInfo (security : SecurityInfo) =
|
||||
save Table.SecurityInfo (CitizenId.toString security.Id) security
|
||||
let private saveSecurity (security : SecurityInfo) connProps =
|
||||
saveDocument Table.SecurityInfo (CitizenId.toString security.Id) connProps (mkDoc security)
|
||||
|
||||
/// Purge expired tokens
|
||||
let private purgeExpiredTokens now = backgroundTask {
|
||||
let connProps = dataSource ()
|
||||
let! info =
|
||||
Custom.list $"{Query.selectFromTable Table.SecurityInfo} WHERE data ->> 'tokenExpires' IS NOT NULL" []
|
||||
fromData<SecurityInfo>
|
||||
Sql.query $"SELECT * FROM {Table.SecurityInfo} WHERE data ->> 'tokenExpires' IS NOT NULL" connProps
|
||||
|> Sql.executeAsync toDocument<SecurityInfo>
|
||||
for expired in info |> List.filter (fun it -> it.TokenExpires.Value < now) do
|
||||
if expired.TokenUsage.Value = "confirm" then
|
||||
// Unconfirmed account; delete the entire thing
|
||||
do! deleteById expired.Id
|
||||
do! doDeleteById expired.Id connProps
|
||||
else
|
||||
// Some other use; just clear the token
|
||||
do! saveSecurityInfo { expired with Token = None; TokenUsage = None; TokenExpires = None }
|
||||
do! saveSecurity { expired with Token = None; TokenUsage = None; TokenExpires = None } connProps
|
||||
}
|
||||
|
||||
/// Check for tokens to purge if it's been more than 10 minutes since we last checked
|
||||
|
@ -54,39 +61,55 @@ let private checkForPurge skipCheck =
|
|||
})
|
||||
|
||||
/// Find a citizen by their ID
|
||||
let findById citizenId =
|
||||
Find.byId Table.Citizen (CitizenId.toString citizenId)
|
||||
let findById citizenId = backgroundTask {
|
||||
match! dataSource () |> getDocument<Citizen> Table.Citizen (CitizenId.toString citizenId) with
|
||||
| Some c when not c.IsLegacy -> return Some c
|
||||
| Some _
|
||||
| None -> return None
|
||||
}
|
||||
|
||||
/// Save a citizen
|
||||
let save citizen =
|
||||
saveCitizen citizen
|
||||
saveCitizen citizen (dataSource ())
|
||||
|
||||
/// Register a citizen (saves citizen and security settings); returns false if the e-mail is already taken
|
||||
let register (citizen : Citizen) (security : SecurityInfo) = backgroundTask {
|
||||
let register citizen (security : SecurityInfo) = backgroundTask {
|
||||
let connProps = dataSource ()
|
||||
use conn = Sql.createConnection connProps
|
||||
use! txn = conn.BeginTransactionAsync ()
|
||||
try
|
||||
let! _ =
|
||||
Configuration.dataSource ()
|
||||
|> Sql.fromDataSource
|
||||
|> Sql.executeTransactionAsync
|
||||
[ Query.save Table.Citizen, [ Query.docParameters (CitizenId.toString citizen.Id) citizen ]
|
||||
Query.save Table.SecurityInfo, [ Query.docParameters (CitizenId.toString citizen.Id) security ]
|
||||
]
|
||||
do! saveCitizen citizen connProps
|
||||
do! saveSecurity security connProps
|
||||
do! txn.CommitAsync ()
|
||||
return true
|
||||
with
|
||||
| :? Npgsql.PostgresException as ex when ex.SqlState = "23505" && ex.ConstraintName = "uk_citizen_email" ->
|
||||
do! txn.RollbackAsync ()
|
||||
return false
|
||||
}
|
||||
|
||||
/// Try to find the security information matching a confirmation token
|
||||
let private tryConfirmToken (token : string) =
|
||||
Find.firstByContains<SecurityInfo> Table.SecurityInfo {| token = token; tokenUsage = "confirm" |}
|
||||
let private tryConfirmToken token connProps = backgroundTask {
|
||||
let! tryInfo =
|
||||
connProps
|
||||
|> Sql.query $"
|
||||
SELECT *
|
||||
FROM {Table.SecurityInfo}
|
||||
WHERE data ->> 'token' = @token
|
||||
AND data ->> 'tokenUsage' = 'confirm'"
|
||||
|> Sql.parameters [ "@token", Sql.string token ]
|
||||
|> Sql.executeAsync toDocument<SecurityInfo>
|
||||
return List.tryHead tryInfo
|
||||
}
|
||||
|
||||
/// Confirm a citizen's account
|
||||
let confirmAccount token = backgroundTask {
|
||||
do! checkForPurge true
|
||||
match! tryConfirmToken token with
|
||||
let connProps = dataSource ()
|
||||
match! tryConfirmToken token connProps with
|
||||
| Some info ->
|
||||
do! saveSecurityInfo { info with AccountLocked = false; Token = None; TokenUsage = None; TokenExpires = None }
|
||||
do! saveSecurity { info with AccountLocked = false; Token = None; TokenUsage = None; TokenExpires = None }
|
||||
connProps
|
||||
return true
|
||||
| None -> return false
|
||||
}
|
||||
|
@ -94,27 +117,38 @@ let confirmAccount token = backgroundTask {
|
|||
/// Deny a citizen's account (user-initiated; used if someone used their e-mail address without their consent)
|
||||
let denyAccount token = backgroundTask {
|
||||
do! checkForPurge true
|
||||
match! tryConfirmToken token with
|
||||
let connProps = dataSource ()
|
||||
match! tryConfirmToken token connProps with
|
||||
| Some info ->
|
||||
do! deleteById info.Id
|
||||
do! doDeleteById info.Id connProps
|
||||
return true
|
||||
| None -> return false
|
||||
}
|
||||
|
||||
/// Attempt a user log on
|
||||
let tryLogOn (email : string) password (pwVerify : Citizen -> string -> bool option)
|
||||
(pwHash : Citizen -> string -> string) now = backgroundTask {
|
||||
let tryLogOn email password (pwVerify : Citizen -> string -> bool option) (pwHash : Citizen -> string -> string)
|
||||
now = backgroundTask {
|
||||
do! checkForPurge false
|
||||
match! Find.firstByContains<Citizen> Table.Citizen {| email = email |} with
|
||||
let connProps = dataSource ()
|
||||
let! tryCitizen =
|
||||
connProps
|
||||
|> Sql.query $"
|
||||
SELECT *
|
||||
FROM {Table.Citizen}
|
||||
WHERE data ->> 'email' = @email
|
||||
AND data ->> 'isLegacy' = 'false'"
|
||||
|> Sql.parameters [ "@email", Sql.string email ]
|
||||
|> Sql.executeAsync toDocument<Citizen>
|
||||
match List.tryHead tryCitizen with
|
||||
| Some citizen ->
|
||||
let citizenId = CitizenId.toString citizen.Id
|
||||
let! tryInfo = Find.byId<SecurityInfo> Table.SecurityInfo citizenId
|
||||
let! tryInfo = getDocument<SecurityInfo> Table.SecurityInfo citizenId connProps
|
||||
let! info = backgroundTask {
|
||||
match tryInfo with
|
||||
| Some it -> return it
|
||||
| None ->
|
||||
let it = { SecurityInfo.empty with Id = citizen.Id }
|
||||
do! saveSecurityInfo it
|
||||
do! saveSecurity it connProps
|
||||
return it
|
||||
}
|
||||
if info.AccountLocked then return Error "Log on unsuccessful (Account Locked)"
|
||||
|
@ -122,29 +156,124 @@ let tryLogOn (email : string) password (pwVerify : Citizen -> string -> bool opt
|
|||
match pwVerify citizen password with
|
||||
| Some rehash ->
|
||||
let hash = if rehash then pwHash citizen password else citizen.PasswordHash
|
||||
do! saveSecurityInfo { info with FailedLogOnAttempts = 0 }
|
||||
do! saveCitizen { citizen with LastSeenOn = now; PasswordHash = hash }
|
||||
do! saveSecurity { info with FailedLogOnAttempts = 0 } connProps
|
||||
do! saveCitizen { citizen with LastSeenOn = now; PasswordHash = hash } connProps
|
||||
return Ok { citizen with LastSeenOn = now }
|
||||
| None ->
|
||||
let locked = info.FailedLogOnAttempts >= 4
|
||||
do! { info with FailedLogOnAttempts = info.FailedLogOnAttempts + 1; AccountLocked = locked }
|
||||
|> saveSecurityInfo
|
||||
|> saveSecurity <| connProps
|
||||
return Error $"""Log on unsuccessful{if locked then " - Account is now locked" else ""}"""
|
||||
| None -> return Error "Log on unsuccessful"
|
||||
}
|
||||
|
||||
/// Try to retrieve a citizen and their security information by their e-mail address
|
||||
let tryByEmailWithSecurity email =
|
||||
Custom.single
|
||||
$"SELECT c.*, s.data AS sec_data
|
||||
let tryByEmailWithSecurity email = backgroundTask {
|
||||
let toCitizenSecurityPair row = (toDocument<Citizen> row, toDocumentFrom<SecurityInfo> "sec_data" row)
|
||||
let! results =
|
||||
dataSource ()
|
||||
|> Sql.query $"
|
||||
SELECT c.*, s.data AS sec_data
|
||||
FROM {Table.Citizen} c
|
||||
INNER JOIN {Table.SecurityInfo} s ON s.id = c.id
|
||||
WHERE c.data @> @criteria"
|
||||
[ "@criteria", Query.jsonbDocParam {| email = email |} ]
|
||||
(fun row -> (fromData<Citizen> row, fromDocument<SecurityInfo> "sec_data" row))
|
||||
WHERE c.data ->> 'email' = @email"
|
||||
|> Sql.parameters [ "@email", Sql.string email ]
|
||||
|> Sql.executeAsync toCitizenSecurityPair
|
||||
return List.tryHead results
|
||||
}
|
||||
|
||||
/// Save an updated security information document
|
||||
let saveSecurityInfo security = backgroundTask {
|
||||
do! saveSecurity security (dataSource ())
|
||||
}
|
||||
|
||||
/// Try to retrieve security information by the given token
|
||||
let trySecurityByToken (token : string) = backgroundTask {
|
||||
let trySecurityByToken token = backgroundTask {
|
||||
do! checkForPurge false
|
||||
return! Find.firstByContains<SecurityInfo> Table.SecurityInfo {| token = token |}
|
||||
let! results =
|
||||
dataSource ()
|
||||
|> Sql.query $"SELECT * FROM {Table.SecurityInfo} WHERE data ->> 'token' = @token"
|
||||
|> Sql.parameters [ "@token", Sql.string token ]
|
||||
|> Sql.executeAsync toDocument<SecurityInfo>
|
||||
return List.tryHead results
|
||||
}
|
||||
|
||||
// ~~~ LEGACY MIGRATION ~~~ //
|
||||
|
||||
/// Get all legacy citizens
|
||||
let legacy () = backgroundTask {
|
||||
return!
|
||||
dataSource ()
|
||||
|> Sql.query $"SELECT * FROM {Table.Citizen} WHERE data ->> 'isLegacy' = 'true' ORDER BY data ->> 'firstName'"
|
||||
|> Sql.executeAsync toDocument<Citizen>
|
||||
}
|
||||
|
||||
/// Get all current citizens with verified accounts but without a profile
|
||||
let current () = backgroundTask {
|
||||
return!
|
||||
dataSource ()
|
||||
|> Sql.query $"
|
||||
SELECT c.*
|
||||
FROM {Table.Citizen} c
|
||||
INNER JOIN {Table.SecurityInfo} si ON si.id = c.id
|
||||
WHERE c.data ->> 'isLegacy' = 'false'
|
||||
AND si.data ->> 'accountLocked' = 'false'
|
||||
AND NOT EXISTS (SELECT 1 FROM {Table.Profile} p WHERE p.id = c.id)"
|
||||
|> Sql.executeAsync toDocument<Citizen>
|
||||
}
|
||||
|
||||
let migrateLegacy currentId legacyId = backgroundTask {
|
||||
let oldId = CitizenId.toString legacyId
|
||||
let connProps = dataSource ()
|
||||
use conn = Sql.createConnection connProps
|
||||
use! txn = conn.BeginTransactionAsync ()
|
||||
try
|
||||
// Add legacy data to current user
|
||||
let! profiles =
|
||||
conn
|
||||
|> Sql.existingConnection
|
||||
|> Sql.query $"SELECT * FROM {Table.Profile} WHERE id = @oldId"
|
||||
|> Sql.parameters [ "@oldId", Sql.string oldId ]
|
||||
|> Sql.executeAsync toDocument<Profile>
|
||||
match List.tryHead profiles with
|
||||
| Some profile ->
|
||||
do! saveDocument
|
||||
Table.Profile (CitizenId.toString currentId) (Sql.existingConnection conn)
|
||||
(mkDoc { profile with Id = currentId; IsLegacy = false })
|
||||
| None -> ()
|
||||
let! listings =
|
||||
conn
|
||||
|> Sql.existingConnection
|
||||
|> Sql.query $"SELECT * FROM {Table.Listing} WHERE data ->> 'citizenId' = @oldId"
|
||||
|> Sql.parameters [ "@oldId", Sql.string oldId ]
|
||||
|> Sql.executeAsync toDocument<Listing>
|
||||
for listing in listings do
|
||||
let newListing = { listing with Id = ListingId.create (); CitizenId = currentId; IsLegacy = false }
|
||||
do! saveDocument
|
||||
Table.Listing (ListingId.toString newListing.Id) (Sql.existingConnection conn) (mkDoc newListing)
|
||||
let! successes =
|
||||
conn
|
||||
|> Sql.existingConnection
|
||||
|> Sql.query $"SELECT * FROM {Table.Success} WHERE data ->> 'citizenId' = @oldId"
|
||||
|> Sql.parameters [ "@oldId", Sql.string oldId ]
|
||||
|> Sql.executeAsync toDocument<Success>
|
||||
for success in successes do
|
||||
let newSuccess = { success with Id = SuccessId.create (); CitizenId = currentId }
|
||||
do! saveDocument
|
||||
Table.Success (SuccessId.toString newSuccess.Id) (Sql.existingConnection conn) (mkDoc newSuccess)
|
||||
// Delete legacy data
|
||||
let! _ =
|
||||
conn
|
||||
|> Sql.existingConnection
|
||||
|> Sql.query $"
|
||||
DELETE FROM {Table.Success} WHERE data ->> 'citizenId' = @oldId;
|
||||
DELETE FROM {Table.Listing} WHERE data ->> 'citizenId' = @oldId;
|
||||
DELETE FROM {Table.Citizen} WHERE id = @oldId"
|
||||
|> Sql.parameters [ "@oldId", Sql.string oldId ]
|
||||
|> Sql.executeNonQueryAsync
|
||||
do! txn.CommitAsync ()
|
||||
return Ok ""
|
||||
with :? Npgsql.PostgresException as ex ->
|
||||
do! txn.RollbackAsync ()
|
||||
return Error ex.MessageText
|
||||
}
|
||||
|
|
|
@ -151,3 +151,14 @@ type ResetPasswordForm =
|
|||
/// The new password for the account
|
||||
Password : string
|
||||
}
|
||||
|
||||
// ~~~ LEGACY MIGRATION ~~ //
|
||||
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type LegacyMigrationForm =
|
||||
{ /// The ID of the current citizen
|
||||
Id : string
|
||||
|
||||
/// The ID of the legacy citizen to be migrated
|
||||
LegacyId : string
|
||||
}
|
||||
|
|
|
@ -59,6 +59,13 @@ module private Auth =
|
|||
| PasswordVerificationResult.SuccessRehashNeeded -> Some true
|
||||
| _ -> None
|
||||
|
||||
/// Require an administrative user (used for legacy migration endpoints)
|
||||
let requireAdmin : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let adminUser = (config ctx)["AdminUser"]
|
||||
if adminUser = defaultArg (tryUser ctx) "" then return! next ctx
|
||||
else return! Error.notAuthorized next ctx
|
||||
}
|
||||
|
||||
|
||||
// GET: /citizen/account
|
||||
let account : HttpHandler = fun next ctx -> task {
|
||||
|
@ -325,6 +332,25 @@ let saveAccount : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx ->
|
|||
let soLong : HttpHandler = requireUser >=> fun next ctx ->
|
||||
Views.deletionOptions (csrf ctx) |> render "Account Deletion Options" next ctx
|
||||
|
||||
// ~~~ LEGACY MIGRATION ~~~ //
|
||||
|
||||
// GET: /citizen/legacy
|
||||
let legacy : HttpHandler = Auth.requireAdmin >=> fun next ctx -> task {
|
||||
let! currentUsers = Data.current ()
|
||||
let! legacyUsers = Data.legacy ()
|
||||
return! Views.legacy currentUsers legacyUsers (csrf ctx) |> render "Migrate Legacy Account" next ctx
|
||||
}
|
||||
|
||||
// POST: /citizen/legacy/migrate
|
||||
let migrateLegacy : HttpHandler = Auth.requireAdmin >=> validateCsrf >=> fun next ctx -> task {
|
||||
let! form = ctx.BindFormAsync<LegacyMigrationForm> ()
|
||||
let currentId = CitizenId.ofString form.Id
|
||||
let legacyId = CitizenId.ofString form.LegacyId
|
||||
match! Data.migrateLegacy currentId legacyId with
|
||||
| Ok _ -> do! addSuccess "Migration successful" ctx
|
||||
| Error err -> do! addError err ctx
|
||||
return! redirectToGet "/citizen/legacy" next ctx
|
||||
}
|
||||
|
||||
open Giraffe.EndpointRouting
|
||||
|
||||
|
@ -343,6 +369,7 @@ let endpoints =
|
|||
route "/register" register
|
||||
routef "/reset-password/%s" resetPassword
|
||||
route "/so-long" soLong
|
||||
route "/legacy" legacy
|
||||
]
|
||||
POST [
|
||||
route "/delete" delete
|
||||
|
@ -351,5 +378,6 @@ let endpoints =
|
|||
route "/register" doRegistration
|
||||
route "/reset-password" doResetPassword
|
||||
route "/save-account" saveAccount
|
||||
route "/legacy/migrate" migrateLegacy
|
||||
]
|
||||
]
|
||||
|
|
|
@ -16,8 +16,4 @@
|
|||
<ProjectReference Include="..\Profiles\JobsJobsJobs.Profiles.fsproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="FSharp.Core" Version="8.0.300" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
@ -189,7 +189,7 @@ let dashboard (citizen : Citizen) (profile : Profile option) profileCount tz =
|
|||
emptyP
|
||||
p [] [
|
||||
txt "To see how this application works, check out “How It Works” in the sidebar (last updated "
|
||||
txt "February 2<sup>nd</sup>, 2023)."
|
||||
txt "August 29<sup>th</sup>, 2021)."
|
||||
]
|
||||
]
|
||||
|
||||
|
@ -360,7 +360,7 @@ let registered =
|
|||
txt "register again."
|
||||
]
|
||||
p [] [
|
||||
txt "If you encounter issues, feel free to reach out to @daniel@fedi.summershome.org for assistance."
|
||||
txt "If you encounter issues, feel free to reach out to @danieljsummers on No Agenda Social for assistance."
|
||||
]
|
||||
]
|
||||
|
||||
|
@ -393,3 +393,54 @@ let resetPassword (m : ResetPasswordForm) isHtmx csrf =
|
|||
jsOnLoad $"jjj.citizen.validatePasswords('{nameof m.Password}', 'ConfirmPassword', true)" isHtmx
|
||||
]
|
||||
]
|
||||
|
||||
// ~~~ LEGACY MIGRATION ~~~ //
|
||||
|
||||
let legacy (current : Citizen list) (legacy : Citizen list) csrf =
|
||||
form [ _class "container"; _hxPost "/citizen/legacy/migrate" ] [
|
||||
antiForgery csrf
|
||||
let canProcess = not (List.isEmpty current)
|
||||
div [ _class "row" ] [
|
||||
if canProcess then
|
||||
div [ _class "col-12 col-lg-6 col-xxl-4" ] [
|
||||
div [ _class "form-floating" ] [
|
||||
select [ _id "current"; _name "Id"; _class "form-control" ] [
|
||||
option [ _value "" ] [ txt "– Select –" ]
|
||||
yield!
|
||||
current
|
||||
|> List.sortBy Citizen.name
|
||||
|> List.map (fun it ->
|
||||
option [ _value (CitizenId.toString it.Id) ] [
|
||||
str (Citizen.name it); txt " ("; str it.Email; txt ")"
|
||||
])
|
||||
]
|
||||
label [ _for "current" ] [ txt "Current User" ]
|
||||
]
|
||||
]
|
||||
else p [] [ txt "There are no current accounts to which legacy accounts can be migrated" ]
|
||||
div [ _class "col-12 col-lg-6 offset-xxl-2"] [
|
||||
table [ _class "table table-sm table-hover" ] [
|
||||
thead [] [
|
||||
tr [] [
|
||||
th [ _scope "col" ] [ txt "Select" ]
|
||||
th [ _scope "col" ] [ txt "NAS Profile" ]
|
||||
]
|
||||
]
|
||||
legacy |> List.map (fun it ->
|
||||
let theId = CitizenId.toString it.Id
|
||||
tr [] [
|
||||
td [] [
|
||||
if canProcess then
|
||||
input [ _type "radio"; _id $"legacy_{theId}"; _name "LegacyId"; _value theId ]
|
||||
else txt " "
|
||||
]
|
||||
td [] [ label [ _for $"legacy_{theId}" ] [ str it.Email ] ]
|
||||
])
|
||||
|> tbody []
|
||||
]
|
||||
]
|
||||
]
|
||||
submitButton "content-save-outline" "Migrate Account"
|
||||
]
|
||||
|> List.singleton
|
||||
|> pageWithTitle "Migrate Legacy Account"
|
||||
|
|
|
@ -35,6 +35,12 @@ module private CacheHelpers =
|
|||
/// Get the current instant
|
||||
let getNow () = SystemClock.Instance.GetCurrentInstant ()
|
||||
|
||||
/// Get the first result of the given query
|
||||
let tryHead<'T> (query : Task<'T list>) = backgroundTask {
|
||||
let! results = query
|
||||
return List.tryHead results
|
||||
}
|
||||
|
||||
/// Create a parameter for a non-standard type
|
||||
let typedParam<'T> name (it : 'T) =
|
||||
$"@%s{name}", Sql.parameter (NpgsqlParameter ($"@{name}", it))
|
||||
|
@ -50,7 +56,6 @@ module private CacheHelpers =
|
|||
|
||||
|
||||
open System.Threading
|
||||
open BitBadger.Npgsql.FSharp.Documents
|
||||
open JobsJobsJobs.Common.Data
|
||||
open Microsoft.Extensions.Caching.Distributed
|
||||
|
||||
|
@ -64,38 +69,46 @@ type DistributedCache () =
|
|||
|
||||
do
|
||||
task {
|
||||
let dataSource = dataSource ()
|
||||
let! exists =
|
||||
Custom.scalar
|
||||
$"SELECT EXISTS
|
||||
(SELECT 1 FROM pg_tables WHERE schemaname = 'jjj' AND tablename = 'session')
|
||||
dataSource
|
||||
|> Sql.query $"
|
||||
SELECT EXISTS
|
||||
(SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = 'session')
|
||||
AS does_exist"
|
||||
[] (fun row -> row.bool "does_exist")
|
||||
|> Sql.executeRowAsync (fun row -> row.bool "does_exist")
|
||||
if not exists then
|
||||
do! Custom.nonQuery
|
||||
"CREATE TABLE jjj.session (
|
||||
let! _ =
|
||||
dataSource
|
||||
|> Sql.query
|
||||
"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 jjj.session (expire_at)" []
|
||||
CREATE INDEX idx_session_expiration ON session (expire_at)"
|
||||
|> Sql.executeNonQueryAsync
|
||||
()
|
||||
} |> sync
|
||||
|
||||
// ~~~ SUPPORT FUNCTIONS ~~~
|
||||
|
||||
/// Get an entry, updating it for sliding expiration
|
||||
let getEntry key = backgroundTask {
|
||||
let dataSource = dataSource ()
|
||||
let idParam = "@id", Sql.string key
|
||||
let! tryEntry =
|
||||
Custom.single
|
||||
"SELECT * FROM jjj.session WHERE id = @id" [ idParam ]
|
||||
(fun row ->
|
||||
dataSource
|
||||
|> Sql.query "SELECT * FROM session WHERE id = @id"
|
||||
|> Sql.parameters [ idParam ]
|
||||
|> Sql.executeAsync (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"
|
||||
})
|
||||
AbsoluteExpiration = row.fieldValueOrNone<Instant> "absolute_expiration" })
|
||||
|> tryHead
|
||||
match tryEntry with
|
||||
| Some entry ->
|
||||
let now = getNow ()
|
||||
|
@ -108,9 +121,12 @@ type DistributedCache () =
|
|||
true, { entry with ExpireAt = absExp }
|
||||
else true, { entry with ExpireAt = now.Plus slideExp }
|
||||
if needsRefresh then
|
||||
do! Custom.nonQuery
|
||||
"UPDATE jjj.session SET expire_at = @expireAt WHERE id = @id"
|
||||
[ expireParam item.ExpireAt; idParam ]
|
||||
let! _ =
|
||||
dataSource
|
||||
|> Sql.query "UPDATE session SET expire_at = @expireAt WHERE id = @id"
|
||||
|> Sql.parameters [ expireParam item.ExpireAt; idParam ]
|
||||
|> Sql.executeNonQueryAsync
|
||||
()
|
||||
return if item.ExpireAt > now then Some entry else None
|
||||
| None -> return None
|
||||
}
|
||||
|
@ -122,13 +138,23 @@ type DistributedCache () =
|
|||
let purge () = backgroundTask {
|
||||
let now = getNow ()
|
||||
if lastPurge.Plus (Duration.FromMinutes 30L) < now then
|
||||
do! Custom.nonQuery "DELETE FROM jjj.session WHERE expire_at < @expireAt" [ expireParam now ]
|
||||
let! _ =
|
||||
dataSource ()
|
||||
|> Sql.query "DELETE FROM session WHERE expire_at < @expireAt"
|
||||
|> Sql.parameters [ expireParam now ]
|
||||
|> Sql.executeNonQueryAsync
|
||||
lastPurge <- now
|
||||
}
|
||||
|
||||
/// Remove a cache entry
|
||||
let removeEntry key =
|
||||
Custom.nonQuery "DELETE FROM jjj.session WHERE id = @id" [ "@id", Sql.string key ]
|
||||
let removeEntry key = backgroundTask {
|
||||
let! _ =
|
||||
dataSource ()
|
||||
|> Sql.query "DELETE FROM session WHERE id = @id"
|
||||
|> Sql.parameters [ "@id", Sql.string key ]
|
||||
|> Sql.executeNonQueryAsync
|
||||
()
|
||||
}
|
||||
|
||||
/// Save an entry
|
||||
let saveEntry (opts : DistributedCacheEntryOptions) key payload = backgroundTask {
|
||||
|
@ -147,8 +173,10 @@ type DistributedCache () =
|
|||
// Default to 1 hour sliding expiration
|
||||
let slide = Duration.FromHours 1
|
||||
now.Plus slide, Some slide, None
|
||||
do! Custom.nonQuery
|
||||
"INSERT INTO jjj.session (
|
||||
let! _ =
|
||||
dataSource ()
|
||||
|> Sql.query
|
||||
"INSERT INTO session (
|
||||
id, payload, expire_at, sliding_expiration, absolute_expiration
|
||||
) VALUES (
|
||||
@id, @payload, @expireAt, @slideExp, @absExp
|
||||
|
@ -157,11 +185,14 @@ type DistributedCache () =
|
|||
expire_at = EXCLUDED.expire_at,
|
||||
sliding_expiration = EXCLUDED.sliding_expiration,
|
||||
absolute_expiration = EXCLUDED.absolute_expiration"
|
||||
|> Sql.parameters
|
||||
[ "@id", Sql.string key
|
||||
"@payload", Sql.bytea payload
|
||||
expireParam expireAt
|
||||
optParam "slideExp" slideExp
|
||||
optParam "absExp" absExp ]
|
||||
|> Sql.executeNonQueryAsync
|
||||
()
|
||||
}
|
||||
|
||||
// ~~~ IMPLEMENTATION FUNCTIONS ~~~
|
||||
|
|
|
@ -29,63 +29,80 @@ module Table =
|
|||
let Success = "jjj.success"
|
||||
|
||||
|
||||
open BitBadger.Npgsql.FSharp.Documents
|
||||
open Npgsql.FSharp
|
||||
|
||||
/// Connection management for the document store
|
||||
[<AutoOpen>]
|
||||
module DataConnection =
|
||||
|
||||
open System.Text.Json
|
||||
open BitBadger.Npgsql.Documents
|
||||
open JobsJobsJobs
|
||||
open Microsoft.Extensions.Configuration
|
||||
open Npgsql
|
||||
|
||||
/// The data source for the document store
|
||||
let mutable private theDataSource : NpgsqlDataSource option = None
|
||||
|
||||
/// Get the data source as the start of a SQL statement
|
||||
let dataSource () =
|
||||
match theDataSource with
|
||||
| Some ds -> Sql.fromDataSource ds
|
||||
| None -> invalidOp "DataConnection.setUp() must be called before accessing the database"
|
||||
|
||||
/// Create tables
|
||||
let private createTables () = backgroundTask {
|
||||
do! Custom.nonQuery "CREATE SCHEMA IF NOT EXISTS jjj" []
|
||||
do! Definition.ensureTable Table.Citizen
|
||||
do! Definition.ensureTable Table.Continent
|
||||
do! Definition.ensureTable Table.Listing
|
||||
do! Definition.ensureTable Table.Success
|
||||
// Tables that use more than the default document configuration, key indexes, and text search index
|
||||
do! Custom.nonQuery
|
||||
$"CREATE TABLE IF NOT EXISTS {Table.Profile}
|
||||
(id TEXT NOT NULL PRIMARY KEY, data JSONB NOT NULL, text_search TSVECTOR NOT NULL,
|
||||
CONSTRAINT fk_profile_citizen FOREIGN KEY (id) REFERENCES {Table.Citizen} (id) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS {Table.SecurityInfo} (id TEXT NOT NULL PRIMARY KEY, data JSONB NOT NULL,
|
||||
CONSTRAINT fk_security_info_citizen
|
||||
FOREIGN KEY (id) REFERENCES {Table.Citizen} (id) ON DELETE CASCADE);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_citizen_email ON {Table.Citizen} ((data -> 'email'));
|
||||
CREATE INDEX IF NOT EXISTS idx_listing_citizen ON {Table.Listing} ((data -> 'citizenId'));
|
||||
CREATE INDEX IF NOT EXISTS idx_listing_continent ON {Table.Listing} ((data -> 'continentId'));
|
||||
CREATE INDEX IF NOT EXISTS idx_profile_continent ON {Table.Profile} ((data -> 'continentId'));
|
||||
CREATE INDEX IF NOT EXISTS idx_success_citizen ON {Table.Success} ((data -> 'citizenId'));
|
||||
CREATE INDEX IF NOT EXISTS idx_profile_search ON {Table.Profile} USING GIN(text_search)"
|
||||
[]
|
||||
let sql = [
|
||||
"CREATE SCHEMA IF NOT EXISTS jjj"
|
||||
// Tables
|
||||
$"CREATE TABLE IF NOT EXISTS {Table.Citizen} (id TEXT NOT NULL PRIMARY KEY, data JSONB NOT NULL)"
|
||||
$"CREATE TABLE IF NOT EXISTS {Table.Continent} (id TEXT NOT NULL PRIMARY KEY, data JSONB NOT NULL)"
|
||||
$"CREATE TABLE IF NOT EXISTS {Table.Listing} (id TEXT NOT NULL PRIMARY KEY, data JSONB NOT NULL)"
|
||||
$"CREATE TABLE IF NOT EXISTS {Table.Profile} (id TEXT NOT NULL PRIMARY KEY, data JSONB NOT NULL,
|
||||
text_search TSVECTOR NOT NULL,
|
||||
CONSTRAINT fk_profile_citizen FOREIGN KEY (id) REFERENCES {Table.Citizen} (id) ON DELETE CASCADE)"
|
||||
$"CREATE TABLE IF NOT EXISTS {Table.SecurityInfo} (id TEXT NOT NULL PRIMARY KEY, data JSONB NOT NULL,
|
||||
CONSTRAINT fk_security_info_citizen FOREIGN KEY (id) REFERENCES {Table.Citizen} (id) ON DELETE CASCADE)"
|
||||
$"CREATE TABLE IF NOT EXISTS {Table.Success} (id TEXT NOT NULL PRIMARY KEY, data JSONB NOT NULL)"
|
||||
// Key indexes
|
||||
$"CREATE UNIQUE INDEX IF NOT EXISTS uk_citizen_email ON {Table.Citizen} ((data -> 'email'))"
|
||||
$"CREATE INDEX IF NOT EXISTS idx_listing_citizen ON {Table.Listing} ((data -> 'citizenId'))"
|
||||
$"CREATE INDEX IF NOT EXISTS idx_listing_continent ON {Table.Listing} ((data -> 'continentId'))"
|
||||
$"CREATE INDEX IF NOT EXISTS idx_profile_continent ON {Table.Profile} ((data -> 'continentId'))"
|
||||
$"CREATE INDEX IF NOT EXISTS idx_success_citizen ON {Table.Success} ((data -> 'citizenId'))"
|
||||
// Profile text search index
|
||||
$"CREATE INDEX IF NOT EXISTS idx_profile_search ON {Table.Profile} USING GIN(text_search)"
|
||||
]
|
||||
let! _ =
|
||||
dataSource ()
|
||||
|> Sql.executeTransactionAsync (sql |> List.map (fun sql -> sql, [ [] ]))
|
||||
()
|
||||
}
|
||||
|
||||
/// Create functions and triggers required to keep the search index current
|
||||
let private createTriggers () = backgroundTask {
|
||||
/// Create functions and triggers required to
|
||||
let createTriggers () = backgroundTask {
|
||||
let! functions =
|
||||
Custom.list
|
||||
dataSource ()
|
||||
|> Sql.query
|
||||
"SELECT p.proname
|
||||
FROM pg_catalog.pg_proc p
|
||||
LEFT JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
|
||||
WHERE n.nspname = 'jjj'"
|
||||
[] (fun row -> row.string "proname")
|
||||
|> Sql.executeAsync (fun row -> row.string "proname")
|
||||
if not (functions |> List.contains "indexable_array_string") then
|
||||
do! Custom.nonQuery
|
||||
"""CREATE FUNCTION jjj.indexable_array_string(target jsonb, path jsonpath) RETURNS text AS $$
|
||||
let! _ =
|
||||
dataSource ()
|
||||
|> Sql.query """
|
||||
CREATE FUNCTION jjj.indexable_array_string(target jsonb, path jsonpath) RETURNS text AS $$
|
||||
BEGIN
|
||||
RETURN REPLACE(REPLACE(REPLACE(REPLACE(jsonb_path_query_array(target, path)::text,
|
||||
'["', ''), '", "', ' '), '"]', ''), '[]', '');
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;""" []
|
||||
$$ LANGUAGE plpgsql;"""
|
||||
|> Sql.executeNonQueryAsync
|
||||
()
|
||||
if not (functions |> List.contains "set_text_search") then
|
||||
do! Custom.nonQuery
|
||||
$"CREATE FUNCTION jjj.set_text_search() RETURNS trigger AS $$
|
||||
let! _ =
|
||||
dataSource ()
|
||||
|> Sql.query $"
|
||||
CREATE FUNCTION jjj.set_text_search() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.text_search := to_tsvector('english',
|
||||
COALESCE(NEW.data ->> 'region', '') || ' '
|
||||
|
@ -99,33 +116,73 @@ module DataConnection =
|
|||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
CREATE TRIGGER set_text_search BEFORE INSERT OR UPDATE ON {Table.Profile}
|
||||
FOR EACH ROW EXECUTE FUNCTION jjj.set_text_search();" []
|
||||
FOR EACH ROW EXECUTE FUNCTION jjj.set_text_search();"
|
||||
|> Sql.executeNonQueryAsync
|
||||
()
|
||||
}
|
||||
|
||||
/// Set up the data connection from the given configuration
|
||||
let setUp (cfg : IConfiguration) = backgroundTask {
|
||||
let builder = NpgsqlDataSourceBuilder (cfg.GetConnectionString "PostgreSQL")
|
||||
let _ = builder.UseNodaTime ()
|
||||
Configuration.useDataSource (builder.Build ())
|
||||
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)
|
||||
}
|
||||
theDataSource <- Some (builder.Build ())
|
||||
do! createTables ()
|
||||
do! createTriggers ()
|
||||
}
|
||||
|
||||
|
||||
open System.Text.Json
|
||||
open System.Threading.Tasks
|
||||
open JobsJobsJobs
|
||||
|
||||
/// Map the data field to the requested document type
|
||||
let toDocumentFrom<'T> fieldName (row : RowReader) =
|
||||
JsonSerializer.Deserialize<'T> (row.string fieldName, Json.options)
|
||||
|
||||
/// Map the data field to the requested document type
|
||||
let toDocument<'T> (row : RowReader) = toDocumentFrom<'T> "data" row
|
||||
|
||||
/// Get a document
|
||||
let getDocument<'T> table docId sqlProps : Task<'T option> = backgroundTask {
|
||||
let! doc =
|
||||
Sql.query $"SELECT * FROM %s{table} where id = @id" sqlProps
|
||||
|> Sql.parameters [ "@id", Sql.string docId ]
|
||||
|> Sql.executeAsync toDocument
|
||||
return List.tryHead doc
|
||||
}
|
||||
|
||||
/// Serialize a document to JSON
|
||||
let mkDoc<'T> (doc : 'T) =
|
||||
JsonSerializer.Serialize<'T> (doc, Json.options)
|
||||
|
||||
/// Save a document
|
||||
let saveDocument table docId sqlProps doc = backgroundTask {
|
||||
let! _ =
|
||||
Sql.query
|
||||
$"INSERT INTO %s{table} (id, data) VALUES (@id, @data)
|
||||
ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data"
|
||||
sqlProps
|
||||
|> Sql.parameters
|
||||
[ "@id", Sql.string docId
|
||||
"@data", Sql.jsonb doc ]
|
||||
|> Sql.executeNonQueryAsync
|
||||
()
|
||||
}
|
||||
|
||||
/// Create a match-anywhere clause for a LIKE or ILIKE clause
|
||||
let like value =
|
||||
Sql.string $"%%%s{value}%%"
|
||||
|
||||
/// The JSON access operator ->> makes values text; this makes a parameter that will compare the properly
|
||||
let jsonBool value =
|
||||
Sql.string (if value then "true" else "false")
|
||||
|
||||
/// Get the SQL for a search WHERE clause
|
||||
let searchSql criteria =
|
||||
let sql = criteria |> List.map fst |> String.concat " AND "
|
||||
if sql = "" then "" else $"AND {sql}"
|
||||
|
||||
|
||||
/// Continent data access functions
|
||||
[<RequireQualifiedAccess>]
|
||||
module Continents =
|
||||
|
@ -134,8 +191,10 @@ module Continents =
|
|||
|
||||
/// Retrieve all continents
|
||||
let all () =
|
||||
Custom.list $"{Query.selectFromTable Table.Continent} ORDER BY data ->> 'name'" [] fromData<Continent>
|
||||
dataSource ()
|
||||
|> Sql.query $"SELECT * FROM {Table.Continent} ORDER BY data ->> 'name'"
|
||||
|> Sql.executeAsync toDocument<Continent>
|
||||
|
||||
/// Retrieve a continent by its ID
|
||||
let findById continentId =
|
||||
Find.byId<Continent> Table.Continent (ContinentId.toString continentId)
|
||||
dataSource () |> getDocument<Continent> Table.Continent (ContinentId.toString continentId)
|
||||
|
|
|
@ -146,7 +146,7 @@ type OtherContact =
|
|||
{ /// The type of contact
|
||||
ContactType : ContactType
|
||||
|
||||
/// The name of the contact (Email, Mastodon, LinkedIn, etc.)
|
||||
/// The name of the contact (Email, No Agenda Social, LinkedIn, etc.)
|
||||
Name : string option
|
||||
|
||||
/// The value for the contact (e-mail address, user name, URL, etc.)
|
||||
|
@ -249,6 +249,9 @@ type Citizen =
|
|||
|
||||
/// The other contacts for this user
|
||||
OtherContacts : OtherContact list
|
||||
|
||||
/// Whether this is a legacy citizen
|
||||
IsLegacy : bool
|
||||
}
|
||||
|
||||
/// Support functions for citizens
|
||||
|
@ -265,6 +268,7 @@ module Citizen =
|
|||
PasswordHash = ""
|
||||
DisplayName = None
|
||||
OtherContacts = []
|
||||
IsLegacy = false
|
||||
}
|
||||
|
||||
/// Get the name of the citizen (either their preferred display name or first/last names)
|
||||
|
@ -330,6 +334,9 @@ type Listing =
|
|||
|
||||
/// Was this job filled as part of its appearance on Jobs, Jobs, Jobs?
|
||||
WasFilledHere : bool option
|
||||
|
||||
/// Whether this is a legacy listing
|
||||
IsLegacy : bool
|
||||
}
|
||||
|
||||
/// Support functions for job listings
|
||||
|
@ -349,6 +356,7 @@ module Listing =
|
|||
Text = Text ""
|
||||
NeededBy = None
|
||||
WasFilledHere = None
|
||||
IsLegacy = false
|
||||
}
|
||||
|
||||
|
||||
|
@ -426,6 +434,9 @@ type Profile =
|
|||
|
||||
/// When the citizen last updated their profile
|
||||
LastUpdatedOn : Instant
|
||||
|
||||
/// Whether this is a legacy profile
|
||||
IsLegacy : bool
|
||||
}
|
||||
|
||||
/// Support functions for Profiles
|
||||
|
@ -445,6 +456,7 @@ module Profile =
|
|||
Experience = None
|
||||
Visibility = Private
|
||||
LastUpdatedOn = Instant.MinValue
|
||||
IsLegacy = false
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -3,41 +3,17 @@ module JobsJobsJobs.Email
|
|||
open System.Net
|
||||
open JobsJobsJobs.Domain
|
||||
open MailKit.Net.Smtp
|
||||
open MailKit.Security
|
||||
open MimeKit
|
||||
|
||||
/// Options to use when sending e-mail
|
||||
type EmailOptions() =
|
||||
/// The hostname of the SMTP server
|
||||
member val SmtpHost : string = "localhost" with get, set
|
||||
|
||||
/// The port over which SMTP communication should occur
|
||||
member val Port : int = 25 with get, set
|
||||
|
||||
/// Whether to use SSL when communicating with the SMTP server
|
||||
member val UseSsl : bool = false with get, set
|
||||
|
||||
/// The authentication to use with the SMTP server
|
||||
member val Authentication : string = "" with get, set
|
||||
|
||||
/// The e-mail address from which messages should be sent
|
||||
member val FromAddress : string = "nobody@noagendacareers.com" with get, set
|
||||
|
||||
/// The name from which messages should be sent
|
||||
member val FromName : string = "Jobs, Jobs, Jobs" with get, set
|
||||
|
||||
|
||||
/// The options for the SMTP server
|
||||
let mutable options = EmailOptions ()
|
||||
|
||||
/// Private functions for sending e-mail
|
||||
[<AutoOpen>]
|
||||
module private Helpers =
|
||||
|
||||
/// Create an SMTP client
|
||||
let createClient () = backgroundTask {
|
||||
let smtpClient () = backgroundTask {
|
||||
let client = new SmtpClient ()
|
||||
do! client.ConnectAsync (options.SmtpHost, options.Port, options.UseSsl)
|
||||
do! client.AuthenticateAsync (options.FromAddress, options.Authentication)
|
||||
do! client.ConnectAsync ("localhost", 25, SecureSocketOptions.None)
|
||||
return client
|
||||
}
|
||||
|
||||
|
@ -49,17 +25,11 @@ module private Helpers =
|
|||
msg.Subject <- subject
|
||||
msg
|
||||
|
||||
/// Send a message
|
||||
let sendMessage msg = backgroundTask {
|
||||
use! client = createClient ()
|
||||
let! result = client.SendAsync msg
|
||||
do! client.DisconnectAsync true
|
||||
return result
|
||||
}
|
||||
|
||||
/// Send an account confirmation e-mail
|
||||
let sendAccountConfirmation citizen security = backgroundTask {
|
||||
let token = WebUtility.UrlEncode security.Token.Value
|
||||
use! client = smtpClient ()
|
||||
use msg = createMessage citizen "Account Confirmation Request"
|
||||
|
||||
let text =
|
||||
|
@ -87,12 +57,13 @@ let sendAccountConfirmation citizen security = backgroundTask {
|
|||
use msgText = new TextPart (Text = text)
|
||||
msg.Body <- msgText
|
||||
|
||||
return! sendMessage msg
|
||||
return! client.SendAsync msg
|
||||
}
|
||||
|
||||
/// Send a password reset link
|
||||
let sendPasswordReset citizen security = backgroundTask {
|
||||
let token = WebUtility.UrlEncode security.Token.Value
|
||||
use! client = smtpClient ()
|
||||
use msg = createMessage citizen "Reset Password for Jobs, Jobs, Jobs"
|
||||
|
||||
let text =
|
||||
|
@ -119,5 +90,5 @@ let sendPasswordReset citizen security = backgroundTask {
|
|||
use msgText = new TextPart (Text = text)
|
||||
msg.Body <- msgText
|
||||
|
||||
return! sendMessage msg
|
||||
return! client.SendAsync msg
|
||||
}
|
||||
|
|
|
@ -15,19 +15,17 @@
|
|||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BitBadger.Npgsql.FSharp.Documents" Version="1.0.0-beta3" />
|
||||
<PackageReference Include="FSharp.SystemTextJson" Version="1.3.13" />
|
||||
<PackageReference Include="Giraffe" Version="6.4.0" />
|
||||
<PackageReference Include="Giraffe.Htmx" Version="2.0.0" />
|
||||
<PackageReference Include="FSharp.SystemTextJson" Version="1.0.7" />
|
||||
<PackageReference Include="Giraffe" Version="6.0.0" />
|
||||
<PackageReference Include="Giraffe.Htmx" Version="1.8.5" />
|
||||
<PackageReference Include="Giraffe.ViewEngine" Version="1.4.0" />
|
||||
<PackageReference Include="Giraffe.ViewEngine.Htmx" Version="2.0.0" />
|
||||
<PackageReference Include="MailKit" Version="4.6.0" />
|
||||
<PackageReference Include="Markdig" Version="0.37.0" />
|
||||
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.1.0" />
|
||||
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.2.0" />
|
||||
<PackageReference Include="Npgsql.FSharp" Version="5.7.0" />
|
||||
<PackageReference Include="Npgsql.NodaTime" Version="8.0.3" />
|
||||
<PackageReference Update="FSharp.Core" Version="8.0.300" />
|
||||
<PackageReference Include="Giraffe.ViewEngine.Htmx" Version="1.8.5" />
|
||||
<PackageReference Include="MailKit" Version="3.3.0" />
|
||||
<PackageReference Include="Markdig" Version="0.30.4" />
|
||||
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.0.0" />
|
||||
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.0.0" />
|
||||
<PackageReference Include="Npgsql.FSharp" Version="5.6.0" />
|
||||
<PackageReference Include="Npgsql.NodaTime" Version="7.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
@ -297,7 +297,7 @@ module Layout =
|
|||
let version =
|
||||
seq {
|
||||
string v.Major
|
||||
if v.Minor > 0 || v.Build > 0 then
|
||||
if v.Minor > 0 then
|
||||
"."; string v.Minor
|
||||
if v.Build > 0 then
|
||||
"."; string v.Build
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<DebugType>embedded</DebugType>
|
||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||
<AssemblyVersion>3.2.0.0</AssemblyVersion>
|
||||
<FileVersion>3.2.0.0</FileVersion>
|
||||
<AssemblyVersion>3.0.0.0</AssemblyVersion>
|
||||
<FileVersion>3.0.0.0</FileVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
|
|
@ -13,8 +13,4 @@
|
|||
<ProjectReference Include="..\Common\JobsJobsJobs.Common.fsproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="FSharp.Core" Version="8.0.300" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
@ -28,7 +28,7 @@ let privacyPolicy =
|
|||
let appName = txt "Jobs, Jobs, Jobs"
|
||||
article [] [
|
||||
h3 [] [ txt "Privacy Policy" ]
|
||||
p [ _class "fst-italic" ] [ txt "(as of February 2<sup>nd</sup>, 2023)" ]
|
||||
p [ _class "fst-italic" ] [ txt "(as of December 27<sup>th</sup>, 2022)" ]
|
||||
|
||||
p [] [
|
||||
appName; txt " (“we,” “our,” or “us”) is committed to protecting your "
|
||||
|
@ -477,7 +477,7 @@ let privacyPolicy =
|
|||
|
||||
hr []
|
||||
|
||||
p [ _class "fst-italic" ] [ txt "Changes for "; appName; txt " v3 (February 2<sup>nd</sup>, 2023)" ]
|
||||
p [ _class "fst-italic" ] [ txt "Changes for "; appName; txt " v3 (December 27<sup>th</sup>, 2022)" ]
|
||||
ul [] [
|
||||
li [ _class "fst-italic" ] [ txt "Removed references to Mastodon" ]
|
||||
li [ _class "fst-italic" ] [ txt "Added references to job listings" ]
|
||||
|
@ -494,7 +494,7 @@ let privacyPolicy =
|
|||
let termsOfService =
|
||||
article [] [
|
||||
h3 [] [ txt "Terms of Service" ]
|
||||
p [ _class "fst-italic" ] [ txt "(as of February 2<sup>nd</sup>, 2023)" ]
|
||||
p [ _class "fst-italic" ] [ txt "(as of August 30<sup>th</sup>, 2022)" ]
|
||||
h4 [] [ txt "Acceptance of Terms" ]
|
||||
p [] [
|
||||
txt "By accessing this web site, you are agreeing to be bound by these Terms and Conditions, and that you "
|
||||
|
@ -528,7 +528,7 @@ let termsOfService =
|
|||
]
|
||||
hr []
|
||||
p [ _class "fst-italic" ] [
|
||||
txt "Change on February 2<sup>nd</sup>, 2023 – added references to job listings, removed references "
|
||||
txt "Change on August 30<sup>th</sup>, 2022 – added references to job listings, removed references "
|
||||
txt "to Mastodon instances."
|
||||
]
|
||||
p [ _class "fst-italic" ] [
|
||||
|
@ -945,7 +945,7 @@ module Help =
|
|||
let index =
|
||||
article [] [
|
||||
h3 [ _class "mb-0" ] [ txt "How It Works" ]
|
||||
h6 [ _class "mb-3 text-muted fst-italic" ] [ txt "Last Updated February 2<sup>nd</sup>, 2023" ]
|
||||
h6 [ _class "mb-3 text-muted fst-italic" ] [ txt "Last Updated January 22<sup>nd</sup>, 2023" ]
|
||||
|
||||
p [ _class "fst-italic" ] [
|
||||
txt "Show me how to "; a [ _href "/how-it-works/listings#searching" ] [ txt "find a job" ]
|
||||
|
@ -984,9 +984,12 @@ module Help =
|
|||
h4 [ mainHeading ] [ txt "Help / Suggestions" ]
|
||||
p [] [
|
||||
txt "This is open-source software "
|
||||
a [ _href "https://git.bitbadger.solutions/bit-badger/jobs-jobs-jobs"; _target "_blank"
|
||||
_rel "noopener" ] [ txt "developed in Git" ]
|
||||
txt "; feel free to ping @daniel@fedi.summershome.org if you run into any issues."
|
||||
a [ _href "https://github.com/bit-badger/jobs-jobs-jobs"; _target "_blank"; _rel "noopener" ] [
|
||||
txt "developed on Github"
|
||||
]; txt "; feel free to "
|
||||
a [ _href "https://github.com/bit-badger/jobs-jobs-jobs/issues"; _target "_blank"; _rel "noopener" ] [
|
||||
txt "create an issue there"
|
||||
]; txt ", or look up @danieljsummers on No Agenda Social."
|
||||
]
|
||||
]
|
||||
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<SelfContained>false</SelfContained>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="Program.fs" />
|
||||
<Content Include="appsettings.Migration.json" CopyToOutputDirectory="Always" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.0.0" />
|
||||
<PackageReference Include="RethinkDb.Driver.FSharp" Version="0.9.0-beta-07" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Application\JobsJobsJobs.Application.fsproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
217
src/JobsJobsJobs/JobsJobsJobs.V3Migration/Program.fs
Normal file
217
src/JobsJobsJobs/JobsJobsJobs.V3Migration/Program.fs
Normal file
|
@ -0,0 +1,217 @@
|
|||
|
||||
open System.Text.Json
|
||||
open Microsoft.Extensions.Configuration
|
||||
|
||||
/// Data access for v2 Jobs, Jobs, Jobs
|
||||
module Rethink =
|
||||
|
||||
/// Table names
|
||||
[<RequireQualifiedAccess>]
|
||||
module Table =
|
||||
/// The user (citizen of Gitmo Nation) table
|
||||
let Citizen = "citizen"
|
||||
/// The continent table
|
||||
let Continent = "continent"
|
||||
/// The job listing table
|
||||
let Listing = "listing"
|
||||
/// The citizen employment profile table
|
||||
let Profile = "profile"
|
||||
/// The success story table
|
||||
let Success = "success"
|
||||
/// All tables
|
||||
let all () = [ Citizen; Continent; Listing; Profile; Success ]
|
||||
|
||||
open RethinkDb.Driver.Net
|
||||
|
||||
/// Functions run at startup
|
||||
[<RequireQualifiedAccess>]
|
||||
module Startup =
|
||||
|
||||
open NodaTime
|
||||
open NodaTime.Serialization.JsonNet
|
||||
open RethinkDb.Driver.FSharp
|
||||
|
||||
/// Create a RethinkDB connection
|
||||
let createConnection (connStr : string) =
|
||||
// Add all required JSON converters
|
||||
Converter.Serializer.ConfigureForNodaTime DateTimeZoneProviders.Tzdb |> ignore
|
||||
// Connect to the database
|
||||
let config = DataConfig.FromUri connStr
|
||||
config.CreateConnection ()
|
||||
|
||||
/// Shorthand for the RethinkDB R variable (how every command starts)
|
||||
let r = RethinkDb.Driver.RethinkDB.R
|
||||
|
||||
open JobsJobsJobs
|
||||
open JobsJobsJobs.Common.Data
|
||||
open JobsJobsJobs.Domain
|
||||
open Newtonsoft.Json.Linq
|
||||
open NodaTime.Text
|
||||
open Npgsql.FSharp
|
||||
open RethinkDb.Driver.FSharp.Functions
|
||||
|
||||
/// Retrieve an instant from a JObject field
|
||||
let getInstant (doc : JObject) name =
|
||||
let text = doc[name].Value<string> ()
|
||||
match InstantPattern.General.Parse text with
|
||||
| it when it.Success -> it.Value
|
||||
| _ ->
|
||||
match InstantPattern.ExtendedIso.Parse text with
|
||||
| it when it.Success -> it.Value
|
||||
| it -> raise it.Exception
|
||||
|
||||
task {
|
||||
// Establish database connections
|
||||
let cfg = ConfigurationBuilder().AddJsonFile("appsettings.Migration.json").Build ()
|
||||
use rethinkConn = Rethink.Startup.createConnection (cfg.GetConnectionString "RethinkDB")
|
||||
do! setUp cfg
|
||||
let pgConn = dataSource ()
|
||||
|
||||
let getOld table =
|
||||
fromTable table
|
||||
|> runResult<JObject list>
|
||||
|> withRetryOnce
|
||||
|> withConn rethinkConn
|
||||
|
||||
// Migrate citizens
|
||||
let! oldCitizens = getOld Rethink.Table.Citizen
|
||||
let newCitizens =
|
||||
oldCitizens
|
||||
|> List.map (fun c ->
|
||||
let user = c["mastodonUser"].Value<string> ()
|
||||
{ Citizen.empty with
|
||||
Id = CitizenId.ofString (c["id"].Value<string> ())
|
||||
JoinedOn = getInstant c "joinedOn"
|
||||
LastSeenOn = getInstant c "lastSeenOn"
|
||||
Email = $"""{user}@{c["instance"].Value<string> ()}"""
|
||||
FirstName = user
|
||||
LastName = user
|
||||
IsLegacy = true
|
||||
})
|
||||
for citizen in newCitizens do
|
||||
do! Citizens.Data.save citizen
|
||||
let! _ =
|
||||
pgConn
|
||||
|> Sql.executeTransactionAsync [
|
||||
$"INSERT INTO {Table.SecurityInfo} VALUES (@id, @data)",
|
||||
newCitizens |> List.map (fun c ->
|
||||
let info = { SecurityInfo.empty with Id = c.Id; AccountLocked = true }
|
||||
[ "@id", Sql.string (CitizenId.toString c.Id)
|
||||
"@data", Sql.jsonb (JsonSerializer.Serialize (info, Json.options))
|
||||
])
|
||||
]
|
||||
printfn $"** Migrated {List.length newCitizens} citizens"
|
||||
|
||||
// Migrate continents
|
||||
let! oldContinents = getOld Rethink.Table.Continent
|
||||
let newContinents =
|
||||
oldContinents
|
||||
|> List.map (fun c ->
|
||||
{ Continent.empty with
|
||||
Id = ContinentId.ofString (c["id"].Value<string> ())
|
||||
Name = c["name"].Value<string> ()
|
||||
})
|
||||
let! _ =
|
||||
pgConn
|
||||
|> Sql.executeTransactionAsync [
|
||||
$"INSERT INTO {Table.Continent} VALUES (@id, @data)",
|
||||
newContinents |> List.map (fun c -> [
|
||||
"@id", Sql.string (ContinentId.toString c.Id)
|
||||
"@data", Sql.jsonb (JsonSerializer.Serialize (c, Json.options))
|
||||
])
|
||||
]
|
||||
printfn $"** Migrated {List.length newContinents} continents"
|
||||
|
||||
// Migrate profiles
|
||||
let! oldProfiles = getOld Rethink.Table.Profile
|
||||
let newProfiles =
|
||||
oldProfiles
|
||||
|> List.map (fun p ->
|
||||
let experience = p["experience"].Value<string> ()
|
||||
{ Profile.empty with
|
||||
Id = CitizenId.ofString (p["id"].Value<string> ())
|
||||
ContinentId = ContinentId.ofString (p["continentId"].Value<string> ())
|
||||
Region = p["region"].Value<string> ()
|
||||
IsSeekingEmployment = p["seekingEmployment"].Value<bool> ()
|
||||
IsRemote = p["remoteWork"].Value<bool> ()
|
||||
IsFullTime = p["fullTime"].Value<bool> ()
|
||||
Biography = Text (p["biography"].Value<string> ())
|
||||
Experience = if isNull experience then None else Some (Text experience)
|
||||
Skills = p["skills"].Children()
|
||||
|> Seq.map (fun s ->
|
||||
let notes = s["notes"].Value<string> ()
|
||||
{ Description = s["description"].Value<string> ()
|
||||
Notes = if isNull notes then None else Some notes
|
||||
})
|
||||
|> List.ofSeq
|
||||
Visibility = if p["isPublic"].Value<bool> () then Anonymous else Private
|
||||
LastUpdatedOn = getInstant p "lastUpdatedOn"
|
||||
IsLegacy = true
|
||||
})
|
||||
for profile in newProfiles do
|
||||
do! Profiles.Data.save profile
|
||||
printfn $"** Migrated {List.length newProfiles} profiles"
|
||||
|
||||
// Migrate listings
|
||||
let! oldListings = getOld Rethink.Table.Listing
|
||||
let newListings =
|
||||
oldListings
|
||||
|> List.map (fun l ->
|
||||
let neededBy = l["neededBy"].Value<string> ()
|
||||
let wasFilledHere = l["wasFilledHere"].Value<string> ()
|
||||
{ Listing.empty with
|
||||
Id = ListingId.ofString (l["id"].Value<string> ())
|
||||
CitizenId = CitizenId.ofString (l["citizenId"].Value<string> ())
|
||||
CreatedOn = getInstant l "createdOn"
|
||||
Title = l["title"].Value<string> ()
|
||||
ContinentId = ContinentId.ofString (l["continentId"].Value<string> ())
|
||||
Region = l["region"].Value<string> ()
|
||||
IsRemote = l["remoteWork"].Value<bool> ()
|
||||
IsExpired = l["isExpired"].Value<bool> ()
|
||||
UpdatedOn = getInstant l "updatedOn"
|
||||
Text = Text (l["text"].Value<string> ())
|
||||
NeededBy = if isNull neededBy then None else
|
||||
match LocalDatePattern.Iso.Parse neededBy with
|
||||
| it when it.Success -> Some it.Value
|
||||
| it ->
|
||||
eprintfn $"Error parsing date - {it.Exception.Message}"
|
||||
None
|
||||
WasFilledHere = if isNull wasFilledHere then None else Some (bool.Parse wasFilledHere)
|
||||
IsLegacy = true
|
||||
})
|
||||
for listing in newListings do
|
||||
do! Listings.Data.save listing
|
||||
printfn $"** Migrated {List.length newListings} listings"
|
||||
|
||||
// Migrate success stories
|
||||
let! oldSuccesses = getOld Rethink.Table.Success
|
||||
let newSuccesses =
|
||||
oldSuccesses
|
||||
|> List.map (fun s ->
|
||||
let story = s["story"].Value<string> ()
|
||||
{ Success.empty with
|
||||
Id = SuccessId.ofString (s["id"].Value<string> ())
|
||||
CitizenId = CitizenId.ofString (s["citizenId"].Value<string> ())
|
||||
RecordedOn = getInstant s "recordedOn"
|
||||
Source = s["source"].Value<string> ()
|
||||
Story = if isNull story then None else Some (Text story)
|
||||
})
|
||||
for success in newSuccesses do
|
||||
do! SuccessStories.Data.save success
|
||||
printfn $"** Migrated {List.length newSuccesses} successes"
|
||||
|
||||
// Delete any citizens who have no profile, no listing, and no success story recorded
|
||||
let! deleted =
|
||||
pgConn
|
||||
|> Sql.query $"
|
||||
DELETE FROM {Table.Citizen}
|
||||
WHERE id NOT IN (SELECT id FROM {Table.Profile})
|
||||
AND id NOT IN (SELECT DISTINCT data ->> 'citizenId' FROM {Table.Listing})
|
||||
AND id NOT IN (SELECT DISTINCT data ->> 'citizenId' FROM {Table.Success})"
|
||||
|> Sql.executeNonQueryAsync
|
||||
printfn $"** Deleted {deleted} citizens who had no profile, listings, or success stories"
|
||||
|
||||
printfn ""
|
||||
printfn "Migration complete"
|
||||
} |> Async.AwaitTask |> Async.RunSynchronously
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
module JobsJobsJobs.Listings.Data
|
||||
|
||||
open BitBadger.Npgsql.FSharp.Documents
|
||||
open JobsJobsJobs.Common.Data
|
||||
open JobsJobsJobs.Domain
|
||||
open JobsJobsJobs.Listings.Domain
|
||||
|
@ -15,46 +14,56 @@ let viewSql =
|
|||
|
||||
/// Map a result for a listing view
|
||||
let private toListingForView row =
|
||||
{ Listing = fromData<Listing> row
|
||||
{ Listing = toDocument<Listing> row
|
||||
ContinentName = row.string "continent_name"
|
||||
Citizen = fromDocument<Citizen> "cit_data" row
|
||||
Citizen = toDocumentFrom<Citizen> "cit_data" row
|
||||
}
|
||||
|
||||
/// Find all job listings posted by the given citizen
|
||||
let findByCitizen citizenId =
|
||||
Custom.list<ListingForView>
|
||||
$"{viewSql} WHERE l.data @> @criteria"
|
||||
[ "@criteria", Query.jsonbDocParam {| citizenId = CitizenId.toString citizenId |} ]
|
||||
toListingForView
|
||||
dataSource ()
|
||||
|> Sql.query $"{viewSql} WHERE l.data ->> 'citizenId' = @citizenId AND l.data ->> 'isLegacy' = 'false'"
|
||||
|> Sql.parameters [ "@citizenId", Sql.string (CitizenId.toString citizenId) ]
|
||||
|> Sql.executeAsync toListingForView
|
||||
|
||||
/// Find a listing by its ID
|
||||
let findById listingId =
|
||||
Find.byId<Listing> Table.Listing (ListingId.toString listingId)
|
||||
let findById listingId = backgroundTask {
|
||||
match! dataSource () |> getDocument<Listing> Table.Listing (ListingId.toString listingId) with
|
||||
| Some listing when not listing.IsLegacy -> return Some listing
|
||||
| Some _
|
||||
| None -> return None
|
||||
}
|
||||
|
||||
/// Find a listing by its ID for viewing (includes continent information)
|
||||
let findByIdForView listingId =
|
||||
Custom.single<ListingForView>
|
||||
$"{viewSql} WHERE l.id = @id" [ "@id", Sql.string (ListingId.toString listingId) ] toListingForView
|
||||
let findByIdForView listingId = backgroundTask {
|
||||
let! tryListing =
|
||||
dataSource ()
|
||||
|> Sql.query $"{viewSql} WHERE l.id = @id AND l.data ->> 'isLegacy' = 'false'"
|
||||
|> Sql.parameters [ "@id", Sql.string (ListingId.toString listingId) ]
|
||||
|> Sql.executeAsync toListingForView
|
||||
return List.tryHead tryListing
|
||||
}
|
||||
|
||||
/// Save a listing
|
||||
let save (listing : Listing) =
|
||||
save Table.Listing (ListingId.toString listing.Id) listing
|
||||
dataSource () |> saveDocument Table.Listing (ListingId.toString listing.Id) <| mkDoc listing
|
||||
|
||||
/// Search job listings
|
||||
let search (search : ListingSearchForm) =
|
||||
let searches = [
|
||||
if search.ContinentId <> "" then
|
||||
"l.data @> @continent", [ "@continent", Query.jsonbDocParam {| continentId = search.ContinentId |} ]
|
||||
"l.data ->> 'continentId' = @continentId", [ "@continentId", Sql.string search.ContinentId ]
|
||||
if search.Region <> "" then
|
||||
"l.data ->> 'region' ILIKE @region", [ "@region", like search.Region ]
|
||||
if search.RemoteWork <> "" then
|
||||
"l.data @> @remote", [ "@remote", Query.jsonbDocParam {| isRemote = search.RemoteWork = "yes" |} ]
|
||||
"l.data ->> 'isRemote' = @remote", [ "@remote", jsonBool (search.RemoteWork = "yes") ]
|
||||
if search.Text <> "" then
|
||||
"l.data ->> 'text' ILIKE @text", [ "@text", like search.Text ]
|
||||
]
|
||||
Custom.list<ListingForView>
|
||||
$"""{viewSql}
|
||||
WHERE l.data @> '{{ "isExpired": false }}'::jsonb
|
||||
{searchSql searches}"""
|
||||
(searches |> List.collect snd)
|
||||
toListingForView
|
||||
dataSource ()
|
||||
|> Sql.query $"
|
||||
{viewSql}
|
||||
WHERE l.data ->> 'isExpired' = 'false' AND l.data ->> 'isLegacy' = 'false'
|
||||
{searchSql searches}"
|
||||
|> Sql.parameters (searches |> List.collect snd)
|
||||
|> Sql.executeAsync toListingForView
|
||||
|
|
|
@ -96,6 +96,7 @@ let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
|
|||
CreatedOn = now
|
||||
IsExpired = false
|
||||
WasFilledHere = None
|
||||
IsLegacy = false
|
||||
}
|
||||
| _ -> return! Data.findById (ListingId.ofString form.Id)
|
||||
}
|
||||
|
|
|
@ -16,8 +16,4 @@
|
|||
<ProjectReference Include="..\SuccessStories\JobsJobsJobs.SuccessStories.fsproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="FSharp.Core" Version="8.0.300" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
module JobsJobsJobs.Profiles.Data
|
||||
|
||||
open BitBadger.Npgsql.FSharp.Documents
|
||||
open JobsJobsJobs.Common.Data
|
||||
open JobsJobsJobs.Domain
|
||||
open JobsJobsJobs.Profiles.Domain
|
||||
|
@ -8,63 +7,80 @@ open Npgsql.FSharp
|
|||
|
||||
/// Count the current profiles
|
||||
let count () =
|
||||
Count.all Table.Profile
|
||||
dataSource ()
|
||||
|> Sql.query $"SELECT COUNT(id) AS the_count FROM {Table.Profile} WHERE data ->> 'isLegacy' = 'false'"
|
||||
|> Sql.executeRowAsync (fun row -> row.int64 "the_count")
|
||||
|
||||
/// Delete a profile by its ID
|
||||
let deleteById citizenId =
|
||||
Delete.byId Table.Profile (CitizenId.toString citizenId)
|
||||
let deleteById citizenId = backgroundTask {
|
||||
let! _ =
|
||||
dataSource ()
|
||||
|> Sql.query $"DELETE FROM {Table.Profile} WHERE id = @id"
|
||||
|> Sql.parameters [ "@id", Sql.string (CitizenId.toString citizenId) ]
|
||||
|> Sql.executeNonQueryAsync
|
||||
()
|
||||
}
|
||||
|
||||
/// Find a profile by citizen ID
|
||||
let findById citizenId =
|
||||
Find.byId<Profile> Table.Profile (CitizenId.toString citizenId)
|
||||
let findById citizenId = backgroundTask {
|
||||
match! dataSource () |> getDocument<Profile> Table.Profile (CitizenId.toString citizenId) with
|
||||
| Some profile when not profile.IsLegacy -> return Some profile
|
||||
| Some _
|
||||
| None -> return None
|
||||
}
|
||||
|
||||
/// Convert a data row to a profile for viewing
|
||||
let private toProfileForView row =
|
||||
{ Profile = fromData<Profile> row
|
||||
Citizen = fromDocument<Citizen> "cit_data" row
|
||||
Continent = fromDocument<Continent> "cont_data" row
|
||||
{ Profile = toDocument<Profile> row
|
||||
Citizen = toDocumentFrom<Citizen> "cit_data" row
|
||||
Continent = toDocumentFrom<Continent> "cont_data" row
|
||||
}
|
||||
|
||||
/// Find a profile by citizen ID for viewing (includes citizen and continent information)
|
||||
let findByIdForView citizenId =
|
||||
Custom.single<ProfileForView>
|
||||
$"SELECT p.*, c.data AS cit_data, o.data AS cont_data
|
||||
let findByIdForView citizenId = backgroundTask {
|
||||
let! tryCitizen =
|
||||
dataSource ()
|
||||
|> Sql.query $"
|
||||
SELECT p.*, c.data AS cit_data, o.data AS cont_data
|
||||
FROM {Table.Profile} p
|
||||
INNER JOIN {Table.Citizen} c ON c.id = p.id
|
||||
INNER JOIN {Table.Continent} o ON o.id = p.data ->> 'continentId'
|
||||
WHERE p.id = @id"
|
||||
[ "@id", Sql.string (CitizenId.toString citizenId) ]
|
||||
toProfileForView
|
||||
WHERE p.id = @id
|
||||
AND p.data ->> 'isLegacy' = 'false'"
|
||||
|> Sql.parameters [ "@id", Sql.string (CitizenId.toString citizenId) ]
|
||||
|> Sql.executeAsync toProfileForView
|
||||
return List.tryHead tryCitizen
|
||||
}
|
||||
|
||||
/// Save a profile
|
||||
let save (profile : Profile) =
|
||||
save Table.Profile (CitizenId.toString profile.Id) profile
|
||||
dataSource () |> saveDocument Table.Profile (CitizenId.toString profile.Id) <| mkDoc profile
|
||||
|
||||
/// Search profiles
|
||||
let search (search : ProfileSearchForm) isPublic = backgroundTask {
|
||||
let searches = [
|
||||
if search.ContinentId <> "" then
|
||||
"p.data @> @continent", [ "@continent", Query.jsonbDocParam {| continentId = search.ContinentId |} ]
|
||||
"p.data ->> 'continentId' = @continentId", [ "@continentId", Sql.string search.ContinentId ]
|
||||
if search.RemoteWork <> "" then
|
||||
"p.data @> @remote", [ "@remote", Query.jsonbDocParam {| isRemote = search.RemoteWork = "yes" |} ]
|
||||
"p.data ->> 'isRemote' = @remote", [ "@remote", jsonBool (search.RemoteWork = "yes") ]
|
||||
if search.Text <> "" then
|
||||
"p.text_search @@ plainto_tsquery(@text_search)", [ "@text_search", Sql.string search.Text ]
|
||||
]
|
||||
let vizSql =
|
||||
if isPublic then
|
||||
sprintf "(p.data @> '%s'::jsonb OR p.data @> '%s'::jsonb)"
|
||||
(Configuration.serializer().Serialize {| visibility = ProfileVisibility.toString Public |})
|
||||
(Configuration.serializer().Serialize {| visibility = ProfileVisibility.toString Anonymous |})
|
||||
else sprintf "p.data ->> 'visibility' <> '%s'" (ProfileVisibility.toString Hidden)
|
||||
sprintf "IN ('%s', '%s')" (ProfileVisibility.toString Public) (ProfileVisibility.toString Anonymous)
|
||||
else sprintf "<> '%s'" (ProfileVisibility.toString Hidden)
|
||||
let! results =
|
||||
Custom.list<ProfileForView>
|
||||
$" SELECT p.*, c.data AS cit_data, o.data AS cont_data
|
||||
dataSource ()
|
||||
|> Sql.query $"
|
||||
SELECT p.*, c.data AS cit_data, o.data AS cont_data
|
||||
FROM {Table.Profile} p
|
||||
INNER JOIN {Table.Citizen} c ON c.id = p.id
|
||||
INNER JOIN {Table.Continent} o ON o.id = p.data ->> 'continentId'
|
||||
WHERE {vizSql}
|
||||
WHERE p.data ->> 'isLegacy' = 'false'
|
||||
AND p.data ->> 'visibility' {vizSql}
|
||||
{searchSql searches}"
|
||||
(searches |> List.collect snd)
|
||||
toProfileForView
|
||||
|> Sql.parameters (searches |> List.collect snd)
|
||||
|> Sql.executeAsync toProfileForView
|
||||
return results |> List.sortBy (fun pfv -> (Citizen.name pfv.Citizen).ToLowerInvariant ())
|
||||
}
|
||||
|
|
|
@ -15,8 +15,4 @@
|
|||
<ProjectReference Include="..\Common\JobsJobsJobs.Common.fsproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="FSharp.Core" Version="8.0.300" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
@ -1,21 +1,21 @@
|
|||
module JobsJobsJobs.SuccessStories.Data
|
||||
|
||||
open BitBadger.Npgsql.FSharp.Documents
|
||||
open JobsJobsJobs.Common.Data
|
||||
open JobsJobsJobs.Domain
|
||||
open JobsJobsJobs.SuccessStories.Domain
|
||||
open Npgsql.FSharp
|
||||
|
||||
// Retrieve all success stories
|
||||
let all () =
|
||||
Custom.list<StoryEntry>
|
||||
$" SELECT s.*, c.data AS cit_data
|
||||
dataSource ()
|
||||
|> Sql.query $"
|
||||
SELECT s.*, c.data AS cit_data
|
||||
FROM {Table.Success} s
|
||||
INNER JOIN {Table.Citizen} c ON c.id = s.data ->> 'citizenId'
|
||||
ORDER BY s.data ->> 'recordedOn' DESC"
|
||||
[]
|
||||
(fun row ->
|
||||
let success = fromData<Success> row
|
||||
let citizen = fromDocument<Citizen> "cit_data" row
|
||||
|> Sql.executeAsync (fun row ->
|
||||
let success = toDocument<Success> row
|
||||
let citizen = toDocumentFrom<Citizen> "cit_data" row
|
||||
{ Id = success.Id
|
||||
CitizenId = success.CitizenId
|
||||
CitizenName = Citizen.name citizen
|
||||
|
@ -26,8 +26,8 @@ let all () =
|
|||
|
||||
/// Find a success story by its ID
|
||||
let findById successId =
|
||||
Find.byId<Success> Table.Success (SuccessId.toString successId)
|
||||
dataSource () |> getDocument<Success> Table.Success (SuccessId.toString successId)
|
||||
|
||||
/// Save a success story
|
||||
let save (success : Success) =
|
||||
save Table.Success (SuccessId.toString success.Id) success
|
||||
dataSource () |> saveDocument Table.Success (SuccessId.toString success.Id) <| mkDoc success
|
||||
|
|
|
@ -17,8 +17,4 @@
|
|||
<ProjectReference Include="..\Profiles\JobsJobsJobs.Profiles.fsproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="FSharp.Core" Version="8.0.300" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
13
vetur.config.js
Normal file
13
vetur.config.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
// vetur.config.js
|
||||
/** @type {import('vls').VeturConfig} */
|
||||
module.exports = {
|
||||
// override vscode settings
|
||||
// Notice: It only affects the settings used by Vetur.
|
||||
settings: {
|
||||
// "vetur.useWorkspaceDependencies": true,
|
||||
// "vetur.experimental.templateInterpolationService": true
|
||||
},
|
||||
projects: [
|
||||
'./src/JobsJobsJobs/App'
|
||||
]
|
||||
}
|
Loading…
Reference in New Issue
Block a user