Version 2.2.2 (#35)

- Allow instances to be disabled (partial fix for #29 and #33)
- Use RethinkDB F# driver (partial fix for #34)
- Add FAKE build script (addresses #30)
This commit is contained in:
Daniel J. Summers 2022-07-11 22:11:42 -04:00 committed by GitHub
parent 6e52688622
commit 323ea83594
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 10953 additions and 20419 deletions

12
.config/dotnet-tools.json Normal file
View File

@ -0,0 +1,12 @@
{
"version": 1,
"isRoot": true,
"tools": {
"fake-cli": {
"version": "5.22.0",
"commands": [
"fake"
]
}
}
}

1
.gitignore vendored
View File

@ -4,3 +4,4 @@ src/**/bin
src/**/obj src/**/obj
src/**/appsettings.*.json src/**/appsettings.*.json
src/.vs src/.vs
src/.idea

78
build.fsx Normal file
View 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"

232
build.fsx.lock Normal file
View File

@ -0,0 +1,232 @@
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.22)
FParsec (>= 1.1.1)
FSharp.Core (>= 6.0)
Fake.Core.Context (5.22)
FSharp.Core (>= 6.0)
Fake.Core.Environment (5.22)
FSharp.Core (>= 6.0)
Fake.Core.FakeVar (5.22)
Fake.Core.Context (>= 5.22)
FSharp.Core (>= 6.0)
Fake.Core.Process (5.22)
Fake.Core.Environment (>= 5.22)
Fake.Core.FakeVar (>= 5.22)
Fake.Core.String (>= 5.22)
Fake.Core.Trace (>= 5.22)
Fake.IO.FileSystem (>= 5.22)
FSharp.Core (>= 6.0)
System.Collections.Immutable (>= 5.0)
Fake.Core.SemVer (5.22)
FSharp.Core (>= 6.0)
Fake.Core.String (5.22)
FSharp.Core (>= 6.0)
Fake.Core.Target (5.22)
Fake.Core.CommandLineParsing (>= 5.22)
Fake.Core.Context (>= 5.22)
Fake.Core.Environment (>= 5.22)
Fake.Core.FakeVar (>= 5.22)
Fake.Core.Process (>= 5.22)
Fake.Core.String (>= 5.22)
Fake.Core.Trace (>= 5.22)
FSharp.Control.Reactive (>= 5.0.2)
FSharp.Core (>= 6.0)
Fake.Core.Tasks (5.22)
Fake.Core.Trace (>= 5.22)
FSharp.Core (>= 6.0)
Fake.Core.Trace (5.22)
Fake.Core.Environment (>= 5.22)
Fake.Core.FakeVar (>= 5.22)
FSharp.Core (>= 6.0)
Fake.Core.Xml (5.22)
Fake.Core.String (>= 5.22)
FSharp.Core (>= 6.0)
Fake.DotNet.Cli (5.22)
Fake.Core.Environment (>= 5.22)
Fake.Core.Process (>= 5.22)
Fake.Core.String (>= 5.22)
Fake.Core.Trace (>= 5.22)
Fake.DotNet.MSBuild (>= 5.22)
Fake.DotNet.NuGet (>= 5.22)
Fake.IO.FileSystem (>= 5.22)
FSharp.Core (>= 6.0)
Mono.Posix.NETStandard (>= 1.0)
Newtonsoft.Json (>= 13.0.1)
Fake.DotNet.MSBuild (5.22)
BlackFox.VsWhere (>= 1.1)
Fake.Core.Environment (>= 5.22)
Fake.Core.Process (>= 5.22)
Fake.Core.String (>= 5.22)
Fake.Core.Trace (>= 5.22)
Fake.IO.FileSystem (>= 5.22)
FSharp.Core (>= 6.0)
MSBuild.StructuredLogger (>= 2.1.545)
Fake.DotNet.NuGet (5.22)
Fake.Core.Environment (>= 5.22)
Fake.Core.Process (>= 5.22)
Fake.Core.SemVer (>= 5.22)
Fake.Core.String (>= 5.22)
Fake.Core.Tasks (>= 5.22)
Fake.Core.Trace (>= 5.22)
Fake.Core.Xml (>= 5.22)
Fake.IO.FileSystem (>= 5.22)
Fake.Net.Http (>= 5.22)
FSharp.Core (>= 6.0)
Newtonsoft.Json (>= 13.0.1)
NuGet.Protocol (>= 5.11)
Fake.IO.FileSystem (5.22)
Fake.Core.String (>= 5.22)
FSharp.Core (>= 6.0)
Fake.JavaScript.Npm (5.22)
Fake.Core.Environment (>= 5.22)
Fake.Core.Process (>= 5.22)
Fake.IO.FileSystem (>= 5.22)
Fake.Testing.Common (>= 5.22)
FSharp.Core (>= 6.0)
Fake.Net.Http (5.22)
Fake.Core.Trace (>= 5.22)
FSharp.Core (>= 6.0)
Fake.Testing.Common (5.22)
Fake.Core.Trace (>= 5.22)
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.4) - 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.2.1)
NuGet.Frameworks (>= 6.2.1)
NuGet.Configuration (6.2.1)
NuGet.Common (>= 6.2.1)
System.Security.Cryptography.ProtectedData (>= 4.4)
NuGet.Frameworks (6.2.1)
NuGet.Packaging (6.2.1)
Newtonsoft.Json (>= 13.0.1)
NuGet.Configuration (>= 6.2.1)
NuGet.Versioning (>= 6.2.1)
System.Security.Cryptography.Cng (>= 5.0)
System.Security.Cryptography.Pkcs (>= 5.0)
NuGet.Protocol (6.2.1)
NuGet.Packaging (>= 6.2.1)
NuGet.Versioning (6.2.1)
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)
System.Memory (>= 4.5.4) - restriction: == netstandard2.0
System.Security.AccessControl (>= 6.0)
System.Security.Cryptography.Pkcs (>= 6.0)
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))

2
fake.cmd Normal file
View File

@ -0,0 +1,2 @@
dotnet tool restore
dotnet fake %*

7
fake.sh Executable file
View File

@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -eu
set -o pipefail
dotnet tool restore
dotnet fake "$@"

View File

@ -21,6 +21,7 @@ module.exports = {
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off", "no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off", "no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
"vue/no-multiple-template-root": "off", "vue/no-multiple-template-root": "off",
"vue/multi-word-component-names": "off",
"vue/script-setup-uses-vars": 1, "vue/script-setup-uses-vars": 1,
"quotes": ["error", "double", { avoidEscape: true, allowTemplateLiterals: true }], "quotes": ["error", "double", { avoidEscape: true, allowTemplateLiterals: true }],
"func-call-spacing": "off", "func-call-spacing": "off",

File diff suppressed because it is too large Load Diff

View File

@ -1,25 +1,23 @@
{ {
"name": "jobs-jobs-jobs", "name": "jobs-jobs-jobs",
"version": "2.2.1", "version": "2.2.2",
"private": true, "private": true,
"scripts": { "scripts": {
"serve": "vue-cli-service serve", "serve": "vue-cli-service serve",
"build": "vue-cli-service build", "build": "vue-cli-service build",
"lint": "vue-cli-service lint", "lint": "vue-cli-service lint"
"apiserve": "vue-cli-service build && cd ../Server && dotnet run -c Debug",
"publish": "vue-cli-service build --modern && cd ../Server && dotnet publish -c Release -r linux-x64 -p:PublishSingleFile=true --self-contained false"
}, },
"dependencies": { "dependencies": {
"@mdi/js": "^5.9.55", "@mdi/js": "^6.9.96",
"@vuelidate/core": "^2.0.0-alpha.24", "@vuelidate/core": "^2.0.0-alpha.24",
"@vuelidate/validators": "^2.0.0-alpha.21", "@vuelidate/validators": "^2.0.0-alpha.21",
"@vueuse/core": "^6.3.3", "@vueuse/core": "^8.9.1",
"bootstrap": "^5.1.0", "bootstrap": "^5.1.0",
"core-js": "^3.16.3", "core-js": "^3.16.3",
"date-fns": "^2.23.0", "date-fns": "^2.23.0",
"date-fns-tz": "^1.1.6", "date-fns-tz": "^1.1.6",
"dompurify": "^2.3.1", "dompurify": "^2.3.1",
"marked": "^2.1.3", "marked": "^4.0.18",
"vue": "^3.2.6", "vue": "^3.2.6",
"vue-router": "^4.0.11", "vue-router": "^4.0.11",
"vuex": "^4.0.0-0" "vuex": "^4.0.0-0"
@ -27,27 +25,28 @@
"devDependencies": { "devDependencies": {
"@types/bootstrap": "^5.1.2", "@types/bootstrap": "^5.1.2",
"@types/dompurify": "^2.2.3", "@types/dompurify": "^2.2.3",
"@types/marked": "^2.0.5", "@types/marked": "^4.0.3",
"@typescript-eslint/eslint-plugin": "^4.29.3", "@typescript-eslint/eslint-plugin": "^5.30.0",
"@typescript-eslint/parser": "^4.29.3", "@typescript-eslint/parser": "^5.30.0",
"@vue/cli-plugin-babel": "~4.5.0", "@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~4.5.0", "@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-plugin-router": "~4.5.0", "@vue/cli-plugin-router": "~5.0.0",
"@vue/cli-plugin-typescript": "~4.5.0", "@vue/cli-plugin-typescript": "~5.0.0",
"@vue/cli-plugin-vuex": "~4.5.0", "@vue/cli-plugin-vuex": "~5.0.0",
"@vue/cli-service": "~4.5.0", "@vue/cli-service": "~5.0.0",
"@vue/compiler-sfc": "^3.2.6", "@vue/compiler-sfc": "^3.2.6",
"@vue/eslint-config-standard": "^6.1.0", "@vue/eslint-config-standard": "^7.0.0",
"@vue/eslint-config-typescript": "^7.0.0", "@vue/eslint-config-typescript": "^11.0.0",
"eslint": "^7.32.0", "eslint": "^8.19.0",
"eslint-plugin-import": "^2.24.2", "eslint-plugin-import": "^2.24.2",
"eslint-plugin-n": "^15.2.4",
"eslint-plugin-node": "^11.1.0", "eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^5.0.0", "eslint-plugin-promise": "^6.0.0",
"eslint-plugin-standard": "^5.0.0", "eslint-plugin-standard": "^5.0.0",
"eslint-plugin-vue": "^7.17.0", "eslint-plugin-vue": "^9.2.0",
"sass": "~1.37.0", "sass": "~1.37.0",
"sass-loader": "^10.0.0", "sass-loader": "^10.0.0",
"typescript": "~4.3.5", "typescript": "~4.5.0",
"vue-cli-plugin-pug": "~2.0.0" "vue-cli-plugin-pug": "~2.0.0"
} }
} }

View File

@ -43,6 +43,10 @@ export interface Instance {
abbr : string abbr : string
/** The client ID (assigned by the Mastodon server) */ /** The client ID (assigned by the Mastodon server) */
clientId : string clientId : string
/** Whether this instance is enabled */
isEnabled : boolean
/** If disabled, the reason why it is disabled */
reason : string
} }
/** A job listing */ /** A job listing */

View File

@ -1,5 +1,5 @@
import { sanitize } from "dompurify" import { sanitize } from "dompurify"
import marked from "marked" import { marked } from "marked"
/** /**
* Transform Markdown to HTML (standardize option, sanitize the output) * Transform Markdown to HTML (standardize option, sanitize the output)

View File

@ -5,7 +5,10 @@ article
template(v-else) template(v-else)
p.text-center Please select your No Agenda-affiliated Mastodon instance p.text-center Please select your No Agenda-affiliated Mastodon instance
p.text-center(v-for="it in instances" :key="it.abbr") p.text-center(v-for="it in instances" :key="it.abbr")
template(v-if="it.isEnabled")
button.btn.btn-primary(@click.prevent="select(it.abbr)") {{it.name}} button.btn.btn-primary(@click.prevent="select(it.abbr)") {{it.name}}
template(v-else).
#[button.btn.btn-secondary(disabled="disabled") {{it.name}}]#[br]#[em {{it.reason}}]
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@ -1,8 +1,9 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<TargetFramework>net5.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<AssemblyVersion>1.0.1.0</AssemblyVersion> <DebugType>embedded</DebugType>
<FileVersion>1.0.1.0</FileVersion> <AssemblyVersion>2.2.2.0</AssemblyVersion>
<FileVersion>2.2.2.0</FileVersion>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View File

@ -1,7 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
<WarnOn>3390;$(WarnOn)</WarnOn> <WarnOn>3390;$(WarnOn)</WarnOn>
</PropertyGroup> </PropertyGroup>
@ -13,9 +12,10 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Markdig" Version="0.25.0" /> <PackageReference Include="Markdig" Version="0.30.2" />
<PackageReference Include="Microsoft.Extensions.Options" Version="5.0.0" /> <PackageReference Include="Microsoft.Extensions.Options" Version="6.0.0" />
<PackageReference Include="NodaTime" Version="3.0.5" /> <PackageReference Include="NodaTime" Version="3.1.0" />
<PackageReference Update="FSharp.Core" Version="6.0.5" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -7,16 +7,12 @@ open System
open Types open Types
/// Format a GUID as a Short GUID /// Format a GUID as a Short GUID
let private toShortGuid guid = let private toShortGuid (guid : Guid) =
let convert (g : Guid) = Convert.ToBase64String(guid.ToByteArray ()).Replace('/', '_').Replace('+', '-')[0..21]
Convert.ToBase64String (g.ToByteArray ())
|> String.map (fun x -> match x with '/' -> '_' | '+' -> '-' | _ -> x)
(convert guid).Substring (0, 22)
/// Turn a Short GUID back into a GUID /// Turn a Short GUID back into a GUID
let private fromShortGuid x = let private fromShortGuid (it : string) =
let unBase64 = x |> String.map (fun x -> match x with '_' -> '/' | '-' -> '+' | _ -> x) (Convert.FromBase64String >> Guid) $"{it.Replace('_', '/').Replace('-', '+')}=="
(Convert.FromBase64String >> Guid) $"{unBase64}=="
/// Support functions for citizen IDs /// Support functions for citizen IDs
@ -24,7 +20,7 @@ module CitizenId =
/// Create a new citizen ID /// Create a new citizen ID
let create () = (Guid.NewGuid >> CitizenId) () let create () = (Guid.NewGuid >> CitizenId) ()
/// A string representation of a citizen ID /// A string representation of a citizen ID
let toString = function (CitizenId it) -> toShortGuid it let toString = function CitizenId it -> toShortGuid it
/// Parse a string into a citizen ID /// Parse a string into a citizen ID
let ofString = fromShortGuid >> CitizenId let ofString = fromShortGuid >> CitizenId
@ -43,7 +39,7 @@ module ContinentId =
/// Create a new continent ID /// Create a new continent ID
let create () = (Guid.NewGuid >> ContinentId) () let create () = (Guid.NewGuid >> ContinentId) ()
/// A string representation of a continent ID /// A string representation of a continent ID
let toString = function (ContinentId it) -> toShortGuid it let toString = function ContinentId it -> toShortGuid it
/// Parse a string into a continent ID /// Parse a string into a continent ID
let ofString = fromShortGuid >> ContinentId let ofString = fromShortGuid >> ContinentId
@ -53,7 +49,7 @@ module ListingId =
/// Create a new job listing ID /// Create a new job listing ID
let create () = (Guid.NewGuid >> ListingId) () let create () = (Guid.NewGuid >> ListingId) ()
/// A string representation of a listing ID /// A string representation of a listing ID
let toString = function (ListingId it) -> toShortGuid it let toString = function ListingId it -> toShortGuid it
/// Parse a string into a listing ID /// Parse a string into a listing ID
let ofString = fromShortGuid >> ListingId let ofString = fromShortGuid >> ListingId
@ -63,7 +59,7 @@ module MarkdownString =
/// The Markdown conversion pipeline (enables all advanced features) /// The Markdown conversion pipeline (enables all advanced features)
let private pipeline = MarkdownPipelineBuilder().UseAdvancedExtensions().Build () let private pipeline = MarkdownPipelineBuilder().UseAdvancedExtensions().Build ()
/// Convert this Markdown string to HTML /// Convert this Markdown string to HTML
let toHtml = function (Text text) -> Markdown.ToHtml (text, pipeline) let toHtml = function Text text -> Markdown.ToHtml (text, pipeline)
/// Support functions for Profiles /// Support functions for Profiles
@ -88,7 +84,7 @@ module SkillId =
/// Create a new skill ID /// Create a new skill ID
let create () = (Guid.NewGuid >> SkillId) () let create () = (Guid.NewGuid >> SkillId) ()
/// A string representation of a skill ID /// A string representation of a skill ID
let toString = function (SkillId it) -> toShortGuid it let toString = function SkillId it -> toShortGuid it
/// Parse a string into a skill ID /// Parse a string into a skill ID
let ofString = fromShortGuid >> SkillId let ofString = fromShortGuid >> SkillId
@ -98,6 +94,6 @@ module SuccessId =
/// Create a new success report ID /// Create a new success report ID
let create () = (Guid.NewGuid >> SuccessId) () let create () = (Guid.NewGuid >> SuccessId) ()
/// A string representation of a success report ID /// A string representation of a success report ID
let toString = function (SuccessId it) -> toShortGuid it let toString = function SuccessId it -> toShortGuid it
/// Parse a string into a success report ID /// Parse a string into a success report ID
let ofString = fromShortGuid >> SuccessId let ofString = fromShortGuid >> SuccessId

View File

@ -8,8 +8,8 @@ open NodaTime
// fsharplint:disable FieldNames // fsharplint:disable FieldNames
/// The data required to add or edit a job listing /// The data required to add or edit a job listing
type ListingForm = { type ListingForm =
/// The ID of the listing { /// The ID of the listing
id : string id : string
/// The listing title /// The listing title
title : string title : string
@ -27,27 +27,27 @@ type ListingForm = {
/// The data needed to display a listing /// The data needed to display a listing
type ListingForView = { type ListingForView =
/// The listing itself { /// The listing itself
listing : Listing listing : Listing
/// The continent for that listing /// The continent for that listing
continent : Continent continent : Continent
} }
/// The form submitted to expire a listing /// The form submitted to expire a listing
type ListingExpireForm = { type ListingExpireForm =
/// Whether the job was filled from here { /// Whether the job was filled from here
fromHere : bool fromHere : bool
/// The success story written by the user /// The success story written by the user
successStory : string option successStory : string option
} }
/// The various ways job listings can be searched /// The various ways job listings can be searched
[<CLIMutable>] [<CLIMutable>]
type ListingSearch = { type ListingSearch =
/// Retrieve job listings for this continent { /// Retrieve job listings for this continent
continentId : string option continentId : string option
/// Text for a search within a region /// Text for a search within a region
region : string option region : string option
@ -59,8 +59,8 @@ type ListingSearch = {
/// A successful logon /// A successful logon
type LogOnSuccess = { type LogOnSuccess =
/// The JSON Web Token (JWT) to use for API access { /// The JSON Web Token (JWT) to use for API access
jwt : string jwt : string
/// The ID of the logged-in citizen (as a string) /// The ID of the logged-in citizen (as a string)
citizenId : string citizenId : string
@ -70,8 +70,8 @@ type LogOnSuccess = {
/// A count /// A count
type Count = { type Count =
// The count being returned { // The count being returned
count : int64 count : int64
} }
@ -88,11 +88,15 @@ type MastodonInstance () =
member val ClientId = "" with get, set member val ClientId = "" with get, set
/// The cryptographic secret (provided by the Mastodon server) /// The cryptographic secret (provided by the Mastodon server)
member val Secret = "" with get, set member val Secret = "" with get, set
/// Whether the instance is currently enabled
member val IsEnabled = true with get, set
/// If an instance is disabled, the reason for it being disabled
member val Reason = "" with get, set
/// The authorization options for Jobs, Jobs, Jobs /// The authorization options for Jobs, Jobs, Jobs
type AuthOptions () = type AuthOptions () =
/// The host for the return URL for Mastodoon verification /// The host for the return URL for Mastodon verification
member val ReturnHost = "" with get, set member val ReturnHost = "" with get, set
/// The secret with which the server signs the JWTs for auth once we've verified with Mastodon /// The secret with which the server signs the JWTs for auth once we've verified with Mastodon
member val ServerSecret = "" with get, set member val ServerSecret = "" with get, set
@ -103,8 +107,8 @@ type AuthOptions () =
/// The Mastodon instance data provided via the Jobs, Jobs, Jobs API /// The Mastodon instance data provided via the Jobs, Jobs, Jobs API
type Instance = { type Instance =
/// The name of the instance { /// The name of the instance
name : string name : string
/// The URL for this instance /// The URL for this instance
url : string url : string
@ -112,12 +116,16 @@ type Instance = {
abbr : string abbr : string
/// The client ID (assigned by the Mastodon server) /// The client ID (assigned by the Mastodon server)
clientId : string clientId : string
/// Whether this instance is currently enabled
isEnabled : bool
/// If not enabled, the reason the instance is disabled
reason : string
} }
/// The fields required for a skill /// The fields required for a skill
type SkillForm = { type SkillForm =
/// The ID of this skill { /// The ID of this skill
id : string id : string
/// The description of the skill /// The description of the skill
description : string description : string
@ -127,8 +135,8 @@ type SkillForm = {
/// The data required to update a profile /// The data required to update a profile
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type ProfileForm = { type ProfileForm =
/// Whether the citizen to whom this profile belongs is actively seeking employment { /// Whether the citizen to whom this profile belongs is actively seeking employment
isSeekingEmployment : bool isSeekingEmployment : bool
/// Whether this profile should appear in the public search /// Whether this profile should appear in the public search
isPublic : bool isPublic : bool
@ -174,8 +182,8 @@ module ProfileForm =
/// The various ways profiles can be searched /// The various ways profiles can be searched
[<CLIMutable>] [<CLIMutable>]
type ProfileSearch = { type ProfileSearch =
/// Retrieve citizens from this continent { /// Retrieve citizens from this continent
continentId : string option continentId : string option
/// Text for a search within a citizen's skills /// Text for a search within a citizen's skills
skill : string option skill : string option
@ -187,8 +195,8 @@ type ProfileSearch = {
/// A user matching the profile search /// A user matching the profile search
type ProfileSearchResult = { type ProfileSearchResult =
/// The ID of the citizen { /// The ID of the citizen
citizenId : CitizenId citizenId : CitizenId
/// The citizen's display name /// The citizen's display name
displayName : string displayName : string
@ -204,20 +212,20 @@ type ProfileSearchResult = {
/// The data required to show a viewable profile /// The data required to show a viewable profile
type ProfileForView = { type ProfileForView =
/// The profile itself { /// The profile itself
profile : Profile profile : Profile
/// The citizen to whom the profile belongs /// The citizen to whom the profile belongs
citizen : Citizen citizen : Citizen
/// The continent for the profile /// The continent for the profile
continent : Continent continent : Continent
} }
/// The parameters for a public job search /// The parameters for a public job search
[<CLIMutable>] [<CLIMutable>]
type PublicSearch = { type PublicSearch =
/// Retrieve citizens from this continent { /// Retrieve citizens from this continent
continentId : string option continentId : string option
/// Retrieve citizens from this region /// Retrieve citizens from this region
region : string option region : string option
@ -227,20 +235,20 @@ type PublicSearch = {
remoteWork : string remoteWork : string
} }
/// Support functions for pblic searches /// Support functions for public searches
module PublicSearch = module PublicSearch =
/// Is the search empty? /// Is the search empty?
let isEmptySearch (srch : PublicSearch) = let isEmptySearch (search : PublicSearch) =
[ srch.continentId [ search.continentId
srch.skill search.skill
match srch.remoteWork with "" -> Some srch.remoteWork | _ -> None match search.remoteWork with "" -> Some search.remoteWork | _ -> None
] ]
|> List.exists Option.isSome |> List.exists Option.isSome
/// A public profile search result /// A public profile search result
type PublicSearchResult = { type PublicSearchResult =
/// The name of the continent on which the citizen resides { /// The name of the continent on which the citizen resides
continent : string continent : string
/// The region in which the citizen resides /// The region in which the citizen resides
region : string region : string
@ -252,8 +260,8 @@ type PublicSearchResult = {
/// The data required to provide a success story /// The data required to provide a success story
type StoryForm = { type StoryForm =
/// The ID of this story { /// The ID of this story
id : string id : string
/// Whether the employment was obtained from Jobs, Jobs, Jobs /// Whether the employment was obtained from Jobs, Jobs, Jobs
fromHere : bool fromHere : bool
@ -263,8 +271,8 @@ type StoryForm = {
/// An entry in the list of success stories /// An entry in the list of success stories
type StoryEntry = { type StoryEntry =
/// The ID of this success story { /// The ID of this success story
id : SuccessId id : SuccessId
/// The ID of the citizen who recorded this story /// The ID of the citizen who recorded this story
citizenId : CitizenId citizenId : CitizenId

View File

@ -11,8 +11,8 @@ type CitizenId = CitizenId of Guid
/// A user of Jobs, Jobs, Jobs /// A user of Jobs, Jobs, Jobs
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type Citizen = { type Citizen =
/// The ID of the user { /// The ID of the user
id : CitizenId id : CitizenId
/// The Mastodon instance abbreviation from which this citizen is authorized /// The Mastodon instance abbreviation from which this citizen is authorized
instance : string instance : string
@ -36,8 +36,8 @@ type ContinentId = ContinentId of Guid
/// A continent /// A continent
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type Continent = { type Continent =
/// The ID of the continent { /// The ID of the continent
id : ContinentId id : ContinentId
/// The name of the continent /// The name of the continent
name : string name : string
@ -53,8 +53,8 @@ type ListingId = ListingId of Guid
/// A job listing /// A job listing
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type Listing = { type Listing =
/// The ID of the job listing { /// The ID of the job listing
id : ListingId id : ListingId
/// The ID of the citizen who posted the job listing /// The ID of the citizen who posted the job listing
citizenId : CitizenId citizenId : CitizenId
@ -85,8 +85,8 @@ type Listing = {
type SkillId = SkillId of Guid type SkillId = SkillId of Guid
/// A skill the job seeker possesses /// A skill the job seeker possesses
type Skill = { type Skill =
/// The ID of the skill { /// The ID of the skill
id : SkillId id : SkillId
/// A description of the skill /// A description of the skill
description : string description : string
@ -97,8 +97,8 @@ type Skill = {
/// A job seeker profile /// A job seeker profile
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type Profile = { type Profile =
/// The ID of the citizen to whom this profile belongs { /// The ID of the citizen to whom this profile belongs
id : CitizenId id : CitizenId
/// Whether this citizen is actively seeking employment /// Whether this citizen is actively seeking employment
seekingEmployment : bool seekingEmployment : bool
@ -127,8 +127,8 @@ type SuccessId = SuccessId of Guid
/// A record of success finding employment /// A record of success finding employment
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type Success = { type Success =
/// The ID of the success report { /// The ID of the success report
id : SuccessId id : SuccessId
/// The ID of the citizen who wrote this success report /// The ID of the citizen who wrote this success report
citizenId : CitizenId citizenId : CitizenId

View File

@ -11,8 +11,7 @@ open Giraffe.EndpointRouting
/// Configure the ASP.NET Core pipeline to use Giraffe /// Configure the ASP.NET Core pipeline to use Giraffe
let configureApp (app : IApplicationBuilder) = let configureApp (app : IApplicationBuilder) =
app app.UseCors(fun p -> p.AllowAnyOrigin().AllowAnyHeader() |> ignore)
.UseCors(fun p -> p.AllowAnyOrigin().AllowAnyHeader() |> ignore)
.UseStaticFiles() .UseStaticFiles()
.UseRouting() .UseRouting()
.UseAuthentication() .UseAuthentication()
@ -64,16 +63,15 @@ let configureServices (svc : IServiceCollection) =
svc.Configure<AuthOptions> (cfg.GetSection "Auth") |> ignore svc.Configure<AuthOptions> (cfg.GetSection "Auth") |> ignore
let dbCfg = cfg.GetSection "Rethink" let dbCfg = cfg.GetSection "Rethink"
let log = svcs.GetRequiredService<ILoggerFactory>().CreateLogger (nameof Data.Startup) let log = svcs.GetRequiredService<ILoggerFactory>().CreateLogger "JobsJobsJobs.Api.Data.Startup"
let conn = Data.Startup.createConnection dbCfg log let conn = Data.Startup.createConnection dbCfg log
svc.AddSingleton conn |> ignore svc.AddSingleton conn |> ignore
Data.Startup.establishEnvironment dbCfg log conn |> Data.awaitIgnore Data.Startup.establishEnvironment dbCfg log conn |> Async.AwaitTask |> Async.RunSynchronously
[<EntryPoint>] [<EntryPoint>]
let main _ = let main _ =
Host.CreateDefaultBuilder() Host.CreateDefaultBuilder()
.ConfigureWebHostDefaults( .ConfigureWebHostDefaults(fun webHostBuilder ->
fun webHostBuilder ->
webHostBuilder webHostBuilder
.Configure(configureApp) .Configure(configureApp)
.ConfigureServices(configureServices) .ConfigureServices(configureServices)

View File

@ -42,7 +42,7 @@ let verifyWithMastodon (authCode : string) (inst : MastodonInstance) rtnHost (lo
// Use authorization code to get an access token from Mastodon // Use authorization code to get an access token from Mastodon
use! codeResult = use! codeResult =
http.PostAsJsonAsync($"{inst.Url}/oauth/token", http.PostAsJsonAsync ($"{inst.Url}/oauth/token",
{| client_id = inst.ClientId {| client_id = inst.ClientId
client_secret = inst.Secret client_secret = inst.Secret
redirect_uri = $"{rtnHost}/citizen/{inst.Abbr}/authorized" redirect_uri = $"{rtnHost}/citizen/{inst.Abbr}/authorized"
@ -99,7 +99,8 @@ let createJwt (citizen : Citizen) (cfg : AuthOptions) =
Issuer = "https://noagendacareers.com", Issuer = "https://noagendacareers.com",
Audience = "https://noagendacareers.com", Audience = "https://noagendacareers.com",
SigningCredentials = SigningCredentials ( SigningCredentials = SigningCredentials (
SymmetricSecurityKey (Encoding.UTF8.GetBytes cfg.ServerSecret), SecurityAlgorithms.HmacSha256Signature) SymmetricSecurityKey (
Encoding.UTF8.GetBytes cfg.ServerSecret), SecurityAlgorithms.HmacSha256Signature)
) )
) )
tokenHandler.WriteToken token tokenHandler.WriteToken token

View File

@ -2,17 +2,6 @@
module JobsJobsJobs.Api.Data module JobsJobsJobs.Api.Data
open JobsJobsJobs.Domain.Types open JobsJobsJobs.Domain.Types
open Polly
open RethinkDb.Driver
open RethinkDb.Driver.Net
open RethinkDb.Driver.Ast
/// Shorthand for the RethinkDB R variable (how every command starts)
let private r = RethinkDB.R
/// Shorthand for await task / run sync / ignore (used in non-async contexts)
let awaitIgnore x = x |> Async.AwaitTask |> Async.RunSynchronously |> ignore
/// JSON converters used with RethinkDB persistence /// JSON converters used with RethinkDB persistence
module Converters = module Converters =
@ -25,61 +14,61 @@ module Converters =
/// JSON converter for citizen IDs /// JSON converter for citizen IDs
type CitizenIdJsonConverter() = type CitizenIdJsonConverter() =
inherit JsonConverter<CitizenId>() inherit JsonConverter<CitizenId>()
override __.WriteJson(writer : JsonWriter, value : CitizenId, _ : JsonSerializer) = override _.WriteJson(writer : JsonWriter, value : CitizenId, _ : JsonSerializer) =
writer.WriteValue (CitizenId.toString value) writer.WriteValue (CitizenId.toString value)
override __.ReadJson(reader: JsonReader, _ : Type, _ : CitizenId, _ : bool, _ : JsonSerializer) = override _.ReadJson(reader: JsonReader, _ : Type, _ : CitizenId, _ : bool, _ : JsonSerializer) =
(string >> CitizenId.ofString) reader.Value (string >> CitizenId.ofString) reader.Value
/// JSON converter for continent IDs /// JSON converter for continent IDs
type ContinentIdJsonConverter() = type ContinentIdJsonConverter() =
inherit JsonConverter<ContinentId>() inherit JsonConverter<ContinentId>()
override __.WriteJson(writer : JsonWriter, value : ContinentId, _ : JsonSerializer) = override _.WriteJson(writer : JsonWriter, value : ContinentId, _ : JsonSerializer) =
writer.WriteValue (ContinentId.toString value) writer.WriteValue (ContinentId.toString value)
override __.ReadJson(reader: JsonReader, _ : Type, _ : ContinentId, _ : bool, _ : JsonSerializer) = override _.ReadJson(reader: JsonReader, _ : Type, _ : ContinentId, _ : bool, _ : JsonSerializer) =
(string >> ContinentId.ofString) reader.Value (string >> ContinentId.ofString) reader.Value
/// JSON converter for Markdown strings /// JSON converter for Markdown strings
type MarkdownStringJsonConverter() = type MarkdownStringJsonConverter() =
inherit JsonConverter<MarkdownString>() inherit JsonConverter<MarkdownString>()
override __.WriteJson(writer : JsonWriter, value : MarkdownString, _ : JsonSerializer) = override _.WriteJson(writer : JsonWriter, value : MarkdownString, _ : JsonSerializer) =
let (Text text) = value let (Text text) = value
writer.WriteValue text writer.WriteValue text
override __.ReadJson(reader: JsonReader, _ : Type, _ : MarkdownString, _ : bool, _ : JsonSerializer) = override _.ReadJson(reader: JsonReader, _ : Type, _ : MarkdownString, _ : bool, _ : JsonSerializer) =
(string >> Text) reader.Value (string >> Text) reader.Value
/// JSON converter for listing IDs /// JSON converter for listing IDs
type ListingIdJsonConverter() = type ListingIdJsonConverter() =
inherit JsonConverter<ListingId>() inherit JsonConverter<ListingId>()
override __.WriteJson(writer : JsonWriter, value : ListingId, _ : JsonSerializer) = override _.WriteJson(writer : JsonWriter, value : ListingId, _ : JsonSerializer) =
writer.WriteValue (ListingId.toString value) writer.WriteValue (ListingId.toString value)
override __.ReadJson(reader: JsonReader, _ : Type, _ : ListingId, _ : bool, _ : JsonSerializer) = override _.ReadJson(reader: JsonReader, _ : Type, _ : ListingId, _ : bool, _ : JsonSerializer) =
(string >> ListingId.ofString) reader.Value (string >> ListingId.ofString) reader.Value
/// JSON converter for skill IDs /// JSON converter for skill IDs
type SkillIdJsonConverter() = type SkillIdJsonConverter() =
inherit JsonConverter<SkillId>() inherit JsonConverter<SkillId>()
override __.WriteJson(writer : JsonWriter, value : SkillId, _ : JsonSerializer) = override _.WriteJson(writer : JsonWriter, value : SkillId, _ : JsonSerializer) =
writer.WriteValue (SkillId.toString value) writer.WriteValue (SkillId.toString value)
override __.ReadJson(reader: JsonReader, _ : Type, _ : SkillId, _ : bool, _ : JsonSerializer) = override _.ReadJson(reader: JsonReader, _ : Type, _ : SkillId, _ : bool, _ : JsonSerializer) =
(string >> SkillId.ofString) reader.Value (string >> SkillId.ofString) reader.Value
/// JSON converter for success report IDs /// JSON converter for success report IDs
type SuccessIdJsonConverter() = type SuccessIdJsonConverter() =
inherit JsonConverter<SuccessId>() inherit JsonConverter<SuccessId>()
override __.WriteJson(writer : JsonWriter, value : SuccessId, _ : JsonSerializer) = override _.WriteJson(writer : JsonWriter, value : SuccessId, _ : JsonSerializer) =
writer.WriteValue (SuccessId.toString value) writer.WriteValue (SuccessId.toString value)
override __.ReadJson(reader: JsonReader, _ : Type, _ : SuccessId, _ : bool, _ : JsonSerializer) = override _.ReadJson(reader: JsonReader, _ : Type, _ : SuccessId, _ : bool, _ : JsonSerializer) =
(string >> SuccessId.ofString) reader.Value (string >> SuccessId.ofString) reader.Value
/// All JSON converters needed for the application /// All JSON converters needed for the application
let all () = [ let all () : JsonConverter list =
CitizenIdJsonConverter () :> JsonConverter [ CitizenIdJsonConverter ()
upcast ContinentIdJsonConverter () ContinentIdJsonConverter ()
upcast MarkdownStringJsonConverter () MarkdownStringJsonConverter ()
upcast ListingIdJsonConverter () ListingIdJsonConverter ()
upcast SkillIdJsonConverter () SkillIdJsonConverter ()
upcast SuccessIdJsonConverter () SuccessIdJsonConverter ()
upcast CompactUnionJsonConverter () CompactUnionJsonConverter ()
] ]
@ -100,6 +89,28 @@ module Table =
let all () = [ Citizen; Continent; Listing; Profile; Success ] let all () = [ Citizen; Continent; Listing; Profile; Success ]
open RethinkDb.Driver.FSharp.Functions
open RethinkDb.Driver.Net
/// Reconnection functions (if the RethinkDB driver has a network error, it will not reconnect on its own)
[<AutoOpen>]
module private Reconnect =
/// Retrieve a result using the F# driver's default retry policy
let result<'T> conn expr = runResult<'T> expr |> withRetryDefault |> withConn conn
/// Retrieve an optional result using the F# driver's default retry policy
let resultOption<'T> conn expr = runResult<'T> expr |> withRetryDefault |> asOption |> withConn conn
/// Write a query using the F# driver's default retry policy, ignoring the result
let write conn expr = runWrite expr |> withRetryDefault |> ignoreResult |> withConn conn
open RethinkDb.Driver.Ast
/// Shorthand for the RethinkDB R variable (how every command starts)
let private r = RethinkDb.Driver.RethinkDB.R
/// Functions run at startup /// Functions run at startup
[<RequireQualifiedAccess>] [<RequireQualifiedAccess>]
module Startup = module Startup =
@ -108,180 +119,113 @@ module Startup =
open Microsoft.Extensions.Logging open Microsoft.Extensions.Logging
open NodaTime open NodaTime
open NodaTime.Serialization.JsonNet open NodaTime.Serialization.JsonNet
open RethinkDb.Driver.FSharp
/// Create a RethinkDB connection /// Create a RethinkDB connection
let createConnection (cfg : IConfigurationSection) (log : ILogger) = let createConnection (cfg : IConfigurationSection) (log : ILogger) =
// Add all required JSON converters // Add all required JSON converters
Converter.Serializer.ConfigureForNodaTime DateTimeZoneProviders.Tzdb |> ignore Converter.Serializer.ConfigureForNodaTime DateTimeZoneProviders.Tzdb |> ignore
Converters.all () Converters.all ()
|> List.iter Converter.Serializer.Converters.Add |> List.iter Converter.Serializer.Converters.Add
// Read the configuration and create a connection // Connect to the database
let bldr = let config = DataConfig.FromConfiguration cfg
seq<Connection.Builder -> Connection.Builder> { log.LogInformation $"Connecting to rethinkdb://{config.Hostname}:{config.Port}/{config.Database}"
yield fun b -> match cfg.["Hostname"] with null -> b | host -> b.Hostname host config.CreateConnection ()
yield fun b -> match cfg.["Port"] with null -> b | port -> (int >> b.Port) port
yield fun b -> match cfg.["AuthKey"] with null -> b | key -> b.AuthKey key
yield fun b -> match cfg.["Db"] with null -> b | db -> b.Db db
yield fun b -> match cfg.["Timeout"] with null -> b | time -> (int >> b.Timeout) time
}
|> Seq.fold (fun b step -> step b) (r.Connection ())
match log.IsEnabled LogLevel.Debug with
| true -> log.LogDebug $"RethinkDB: Connecting to {bldr.Hostname}:{bldr.Port}, database {bldr.Db}"
| false -> ()
bldr.Connect () :> IConnection
/// Ensure the data, tables, and indexes that are required exist /// Ensure the data, tables, and indexes that are required exist
let establishEnvironment (cfg : IConfigurationSection) (log : ILogger) conn = task { let establishEnvironment (cfg : IConfigurationSection) (log : ILogger) conn = task {
// Ensure the database exists // Ensure the database exists
match cfg.["Db"] |> Option.ofObj with match cfg["database"] |> Option.ofObj with
| Some database -> | Some database ->
let! dbs = r.DbList().RunResultAsync<string list> conn let! dbs = dbList () |> result<string list> conn
match dbs |> List.contains database with match dbs |> List.contains database with
| true -> () | true -> ()
| false -> | false ->
log.LogInformation $"Creating database {database}..." log.LogInformation $"Creating database {database}..."
let! _ = r.DbCreate(database).RunWriteAsync conn do! dbCreate database |> write conn
() ()
| None -> () | None -> ()
// Ensure the tables exist // Ensure the tables exist
let! tables = r.TableList().RunResultAsync<string list> conn let! tables = tableListFromDefault () |> result<string list> conn
Table.all () for table in Table.all () do
|> List.iter ( if not (List.contains table tables) then
fun tbl -> log.LogInformation $"Creating {table} table..."
match tables |> List.contains tbl with do! tableCreateInDefault table |> write conn
| true -> ()
| false ->
log.LogInformation $"Creating {tbl} table..."
r.TableCreate(tbl).RunWriteAsync conn |> awaitIgnore)
// Ensure the indexes exist // Ensure the indexes exist
let ensureIndexes table indexes = task { let ensureIndexes table indexes = task {
let! tblIdxs = r.Table(table).IndexList().RunResultAsync<string list> conn let! tblIndexes = fromTable table |> indexList |> result<string list> conn
indexes for index in indexes do
|> List.iter ( if not (List.contains index tblIndexes) then
fun idx -> log.LogInformation $"Creating \"{index}\" index on {table}"
match tblIdxs |> List.contains idx with do! fromTable table |> indexCreate index |> write conn
| true -> ()
| false ->
log.LogInformation $"Creating \"{idx}\" index on {table}"
r.Table(table).IndexCreate(idx).RunWriteAsync conn |> awaitIgnore)
} }
do! ensureIndexes Table.Listing [ "citizenId"; "continentId"; "isExpired" ] do! ensureIndexes Table.Listing [ "citizenId"; "continentId"; "isExpired" ]
do! ensureIndexes Table.Profile [ "continentId" ] do! ensureIndexes Table.Profile [ "continentId" ]
do! ensureIndexes Table.Success [ "citizenId" ] do! ensureIndexes Table.Success [ "citizenId" ]
// The instance/user is a compound index // The instance/user is a compound index
let! userIdx = r.Table(Table.Citizen).IndexList().RunResultAsync<string list> conn let! userIdx = fromTable Table.Citizen |> indexList |> result<string list> conn
match userIdx |> List.contains "instanceUser" with if not (List.contains "instanceUser" userIdx) then
| true -> () do! fromTable Table.Citizen
| false -> |> indexCreateFunc "instanceUser" (fun row -> r.Array (row.G "instance", row.G "mastodonUser"))
let! _ = |> write conn
r.Table(Table.Citizen)
.IndexCreate("instanceUser",
ReqlFunction1 (fun row -> upcast r.Array (row.G "instance", row.G "mastodonUser")))
.RunWriteAsync conn
()
} }
/// Determine if a record type (not nullable) is null
let toOption x = match x |> box |> isNull with true -> None | false -> Some x
[<AutoOpen>]
module private Reconnect =
open System.Threading.Tasks
/// Execute a query with a retry policy that will reconnect to RethinkDB if it has gone away
let withReconn (conn : IConnection) (f : IConnection -> Task<'T>) =
Policy
.Handle<ReqlDriverError>()
.RetryAsync(System.Action<exn, int> (fun ex _ ->
printf "Encountered RethinkDB exception: %s" ex.Message
match ex.Message.Contains "socket" with
| true ->
printf "Reconnecting to RethinkDB"
(conn :?> Connection).Reconnect false
| false -> ()))
.ExecuteAsync(fun () -> f conn)
/// Execute a query that returns one or none item, using the reconnect logic
let withReconnOption (conn : IConnection) (f : IConnection -> Task<'T>) =
fun c -> task {
let! it = f c
return toOption it
}
|> withReconn conn
/// Execute a query that does not return a result, using the above reconnect logic
let withReconnIgnore (conn : IConnection) (f : IConnection -> Task<'T>) =
fun c -> task {
let! _ = f c
()
}
|> withReconn conn
/// Sanitize user input, and create a "contains" pattern for use with RethinkDB queries
let regexContains = System.Text.RegularExpressions.Regex.Escape >> sprintf "(?i)%s"
open JobsJobsJobs.Domain open JobsJobsJobs.Domain
open JobsJobsJobs.Domain.SharedTypes open JobsJobsJobs.Domain.SharedTypes
/// Sanitize user input, and create a "contains" pattern for use with RethinkDB queries
let regexContains = System.Text.RegularExpressions.Regex.Escape >> sprintf "(?i)%s"
/// Profile data access functions /// Profile data access functions
[<RequireQualifiedAccess>] [<RequireQualifiedAccess>]
module Profile = module Profile =
/// Count the current profiles
let count conn = let count conn =
r.Table(Table.Profile) fromTable Table.Profile
.Count() |> count
.RunResultAsync<int64> |> result<int64> conn
|> withReconn conn
/// Find a profile by citizen ID /// Find a profile by citizen ID
let findById (citizenId : CitizenId) conn = let findById (citizenId : CitizenId) conn =
r.Table(Table.Profile) fromTable Table.Profile
.Get(citizenId) |> get citizenId
.RunResultAsync<Profile> |> resultOption<Profile> conn
|> withReconnOption conn
/// Insert or update a profile /// Insert or update a profile
let save (profile : Profile) conn = let save (profile : Profile) conn =
r.Table(Table.Profile) fromTable Table.Profile
.Get(profile.id) |> get profile.id
.Replace(profile) |> replace profile
.RunWriteAsync |> write conn
|> withReconnIgnore conn
/// Delete a citizen's profile /// Delete a citizen's profile
let delete (citizenId : CitizenId) conn = let delete (citizenId : CitizenId) conn =
r.Table(Table.Profile) fromTable Table.Profile
.Get(citizenId) |> get citizenId
.Delete() |> delete
.RunWriteAsync |> write conn
|> withReconnIgnore conn
/// Search profiles (logged-on users) /// Search profiles (logged-on users)
let search (srch : ProfileSearch) conn = let search (search : ProfileSearch) conn =
fun c -> (seq<ReqlExpr -> ReqlExpr> {
(seq { match search.continentId with
match srch.continentId with | Some cId -> yield (fun q -> q.Filter (r.HashMap (nameof search.continentId, ContinentId.ofString cId)))
| Some conId ->
yield (fun (q : ReqlExpr) ->
q.Filter (r.HashMap (nameof srch.continentId, ContinentId.ofString conId)) :> ReqlExpr)
| None -> () | None -> ()
match srch.remoteWork with match search.remoteWork with
| "" -> () | "" -> ()
| _ -> yield (fun q -> q.Filter (r.HashMap (nameof srch.remoteWork, srch.remoteWork = "yes")) :> ReqlExpr) | _ -> yield (fun q -> q.Filter (r.HashMap (nameof search.remoteWork, search.remoteWork = "yes")))
match srch.skill with match search.skill with
| Some skl -> | Some skl ->
yield (fun q -> q.Filter (ReqlFunction1(fun it -> yield (fun q -> q.Filter (ReqlFunction1(fun it ->
upcast it.G("skills").Contains (ReqlFunction1(fun s -> it.G("skills").Contains (ReqlFunction1(fun s -> s.G("description").Match (regexContains skl))))))
upcast s.G("description").Match (regexContains skl))))) :> ReqlExpr)
| None -> () | None -> ()
match srch.bioExperience with match search.bioExperience with
| Some text -> | Some text ->
let txt = regexContains text let txt = regexContains text
yield (fun q -> q.Filter (ReqlFunction1(fun it -> yield (fun q -> q.Filter (ReqlFunction1(fun it ->
upcast it.G("biography").Match(txt).Or (it.G("experience").Match txt))) :> ReqlExpr) it.G("biography").Match(txt).Or (it.G("experience").Match txt))))
| None -> () | None -> ()
} }
|> Seq.toList |> Seq.toList
@ -291,40 +235,33 @@ module Profile =
.EqJoin("id", r.Table Table.Citizen) .EqJoin("id", r.Table Table.Citizen)
.Without(r.HashMap ("right", "id")) .Without(r.HashMap ("right", "id"))
.Zip () :> ReqlExpr)) .Zip () :> ReqlExpr))
.Merge(ReqlFunction1 (fun it -> |> mergeFunc (fun it ->
upcast r r.HashMap("displayName",
.HashMap("displayName",
r.Branch (it.G("realName" ).Default_("").Ne "", it.G "realName", r.Branch (it.G("realName" ).Default_("").Ne "", it.G "realName",
it.G("displayName").Default_("").Ne "", it.G "displayName", it.G("displayName").Default_("").Ne "", it.G "displayName",
it.G "mastodonUser")) it.G "mastodonUser"))
.With ("citizenId", it.G "id"))) .With ("citizenId", it.G "id"))
.Pluck("citizenId", "displayName", "seekingEmployment", "remoteWork", "fullTime", "lastUpdatedOn") |> pluck [ "citizenId"; "displayName"; "seekingEmployment"; "remoteWork"; "fullTime"; "lastUpdatedOn" ]
.OrderBy(ReqlFunction1 (fun it -> upcast it.G("displayName").Downcase ())) |> orderByFunc (fun it -> it.G("displayName").Downcase ())
.RunResultAsync<ProfileSearchResult list> c |> result<ProfileSearchResult list> conn
|> withReconn conn
// Search profiles (public) // Search profiles (public)
let publicSearch (srch : PublicSearch) conn = let publicSearch (search : PublicSearch) conn =
fun c -> (seq<ReqlExpr -> ReqlExpr> {
(seq { match search.continentId with
match srch.continentId with | Some cId -> yield (fun q -> q.Filter (r.HashMap (nameof search.continentId, ContinentId.ofString cId)))
| Some conId ->
yield (fun (q : ReqlExpr) ->
q.Filter (r.HashMap (nameof srch.continentId, ContinentId.ofString conId)) :> ReqlExpr)
| None -> () | None -> ()
match srch.region with match search.region with
| Some reg -> | Some reg ->
yield (fun q -> yield (fun q -> q.Filter (ReqlFunction1 (fun it -> upcast it.G("region").Match (regexContains reg))))
q.Filter (ReqlFunction1 (fun it -> upcast it.G("region").Match (regexContains reg))) :> ReqlExpr)
| None -> () | None -> ()
match srch.remoteWork with match search.remoteWork with
| "" -> () | "" -> ()
| _ -> yield (fun q -> q.Filter (r.HashMap (nameof srch.remoteWork, srch.remoteWork = "yes")) :> ReqlExpr) | _ -> yield (fun q -> q.Filter (r.HashMap (nameof search.remoteWork, search.remoteWork = "yes")))
match srch.skill with match search.skill with
| Some skl -> | Some skl ->
yield (fun q -> q.Filter (ReqlFunction1 (fun it -> yield (fun q -> q.Filter (ReqlFunction1 (fun it ->
upcast it.G("skills").Contains (ReqlFunction1(fun s -> it.G("skills").Contains (ReqlFunction1(fun s -> s.G("description").Match (regexContains skl))))))
upcast s.G("description").Match (regexContains skl))))) :> ReqlExpr)
| None -> () | None -> ()
} }
|> Seq.toList |> Seq.toList
@ -334,17 +271,15 @@ module Profile =
.EqJoin("continentId", r.Table Table.Continent) .EqJoin("continentId", r.Table Table.Continent)
.Without(r.HashMap ("right", "id")) .Without(r.HashMap ("right", "id"))
.Zip() .Zip()
.Filter(r.HashMap ("isPublic", true)) :> ReqlExpr)) .Filter(r.HashMap ("isPublic", true))))
.Merge(ReqlFunction1 (fun it -> |> mergeFunc (fun it ->
upcast r r.HashMap("skills",
.HashMap("skills",
it.G("skills").Map (ReqlFunction1 (fun skill -> it.G("skills").Map (ReqlFunction1 (fun skill ->
upcast r.Branch(skill.G("notes").Default_("").Eq "", skill.G "description", r.Branch(skill.G("notes").Default_("").Eq "", skill.G "description",
skill.G("description").Add(" (").Add(skill.G("notes")).Add ")")))) skill.G("description").Add(" (").Add(skill.G("notes")).Add ")"))))
.With("continent", it.G "name"))) .With("continent", it.G "name"))
.Pluck("continent", "region", "skills", "remoteWork") |> pluck [ "continent"; "region"; "skills"; "remoteWork" ]
.RunResultAsync<PublicSearchResult list> c |> result<PublicSearchResult list> conn
|> withReconn conn
/// Citizen data access functions /// Citizen data access functions
[<RequireQualifiedAccess>] [<RequireQualifiedAccess>]
@ -352,68 +287,57 @@ module Citizen =
/// Find a citizen by their ID /// Find a citizen by their ID
let findById (citizenId : CitizenId) conn = let findById (citizenId : CitizenId) conn =
r.Table(Table.Citizen) fromTable Table.Citizen
.Get(citizenId) |> get citizenId
.RunResultAsync<Citizen> |> resultOption<Citizen> conn
|> withReconnOption conn
/// Find a citizen by their Mastodon username /// Find a citizen by their Mastodon username
let findByMastodonUser (instance : string) (mastodonUser : string) conn = let findByMastodonUser (instance : string) (mastodonUser : string) conn = task {
fun c -> task {
let! u = let! u =
r.Table(Table.Citizen) fromTable Table.Citizen
.GetAll(r.Array (instance, mastodonUser)).OptArg("index", "instanceUser").Limit(1) |> getAllWithIndex [ r.Array (instance, mastodonUser) ] "instanceUser"
.RunResultAsync<Citizen list> c |> limit 1
return u |> List.tryHead |> result<Citizen list> conn
return List.tryHead u
} }
|> withReconn conn
/// Add a citizen /// Add a citizen
let add (citizen : Citizen) conn = let add (citizen : Citizen) conn =
r.Table(Table.Citizen) fromTable Table.Citizen
.Insert(citizen) |> insert citizen
.RunWriteAsync |> write conn
|> withReconnIgnore conn
/// Update the display name and last seen on date for a citizen /// Update the display name and last seen on date for a citizen
let logOnUpdate (citizen : Citizen) conn = let logOnUpdate (citizen : Citizen) conn =
r.Table(Table.Citizen) fromTable Table.Citizen
.Get(citizen.id) |> get citizen.id
.Update(r.HashMap( nameof citizen.displayName, citizen.displayName) |> update (r.HashMap( nameof citizen.displayName, citizen.displayName)
.With (nameof citizen.lastSeenOn, citizen.lastSeenOn)) .With (nameof citizen.lastSeenOn, citizen.lastSeenOn))
.RunWriteAsync |> write conn
|> withReconnIgnore conn
/// Delete a citizen /// Delete a citizen
let delete citizenId conn = let delete citizenId conn = task {
fun c -> task { do! Profile.delete citizenId conn
do! Profile.delete citizenId c do! fromTable Table.Success
let! _ = |> getAllWithIndex [ citizenId ] "citizenId"
r.Table(Table.Success) |> delete
.GetAll(citizenId).OptArg("index", "citizenId") |> write conn
.Delete() do! fromTable Table.Listing
.RunWriteAsync c |> getAllWithIndex [ citizenId ] "citizenId"
let! _ = |> delete
r.Table(Table.Listing) |> write conn
.GetAll(citizenId).OptArg("index", "citizenId") do! fromTable Table.Citizen
.Delete() |> get citizenId
.RunWriteAsync c |> delete
let! _ = |> write conn
r.Table(Table.Citizen)
.Get(citizenId)
.Delete()
.RunWriteAsync c
()
} }
|> withReconnIgnore conn
/// Update a citizen's real name /// Update a citizen's real name
let realNameUpdate (citizenId : CitizenId) (realName : string option) conn = let realNameUpdate (citizenId : CitizenId) (realName : string option) conn =
r.Table(Table.Citizen) fromTable Table.Citizen
.Get(citizenId) |> get citizenId
.Update(r.HashMap (nameof realName, realName)) |> update (r.HashMap (nameof realName, realName))
.RunWriteAsync |> write conn
|> withReconnIgnore conn
/// Continent data access functions /// Continent data access functions
@ -422,16 +346,14 @@ module Continent =
/// Get all continents /// Get all continents
let all conn = let all conn =
r.Table(Table.Continent) fromTable Table.Continent
.RunResultAsync<Continent list> |> result<Continent list> conn
|> withReconn conn
/// Get a continent by its ID /// Get a continent by its ID
let findById (contId : ContinentId) conn = let findById (contId : ContinentId) conn =
r.Table(Table.Continent) fromTable Table.Continent
.Get(contId) |> get contId
.RunResultAsync<Continent> |> resultOption<Continent> conn
|> withReconnOption conn
/// Job listing data access functions /// Job listing data access functions
@ -442,91 +364,77 @@ module Listing =
/// Find all job listings posted by the given citizen /// Find all job listings posted by the given citizen
let findByCitizen (citizenId : CitizenId) conn = let findByCitizen (citizenId : CitizenId) conn =
r.Table(Table.Listing) fromTable Table.Listing
.GetAll(citizenId).OptArg("index", nameof citizenId) |> getAllWithIndex [ citizenId ] (nameof citizenId)
.EqJoin("continentId", r.Table Table.Continent) |> eqJoin "continentId" (fromTable Table.Continent)
.Map(ReqlFunction1 (fun it -> upcast r.HashMap("listing", it.G "left").With ("continent", it.G "right"))) |> mapFunc (fun it -> r.HashMap("listing", it.G "left").With ("continent", it.G "right"))
.RunResultAsync<ListingForView list> |> result<ListingForView list> conn
|> withReconn conn
/// Find a listing by its ID /// Find a listing by its ID
let findById (listingId : ListingId) conn = let findById (listingId : ListingId) conn =
r.Table(Table.Listing) fromTable Table.Listing
.Get(listingId) |> get listingId
.RunResultAsync<Listing> |> resultOption<Listing> conn
|> withReconnOption conn
/// Find a listing by its ID for viewing (includes continent information) /// Find a listing by its ID for viewing (includes continent information)
let findByIdForView (listingId : ListingId) conn = let findByIdForView (listingId : ListingId) conn = task {
fun c -> task {
let! listing = let! listing =
r.Table(Table.Listing) fromTable Table.Listing
.Filter(r.HashMap ("id", listingId)) |> filter (r.HashMap ("id", listingId))
.EqJoin("continentId", r.Table Table.Continent) |> eqJoin "continentId" (fromTable Table.Continent)
.Map(ReqlFunction1 (fun it -> upcast r.HashMap("listing", it.G "left").With ("continent", it.G "right"))) |> mapFunc (fun it -> r.HashMap("listing", it.G "left").With ("continent", it.G "right"))
.RunResultAsync<ListingForView list> c |> result<ListingForView list> conn
return List.tryHead listing return List.tryHead listing
} }
|> withReconn conn
/// Add a listing /// Add a listing
let add (listing : Listing) conn = let add (listing : Listing) conn =
r.Table(Table.Listing) fromTable Table.Listing
.Insert(listing) |> insert listing
.RunWriteAsync |> write conn
|> withReconnIgnore conn
/// Update a listing /// Update a listing
let update (listing : Listing) conn = let update (listing : Listing) conn =
r.Table(Table.Listing) fromTable Table.Listing
.Get(listing.id) |> get listing.id
.Replace(listing) |> replace listing
.RunWriteAsync |> write conn
|> withReconnIgnore conn
/// Expire a listing /// Expire a listing
let expire (listingId : ListingId) (fromHere : bool) (now : Instant) conn = let expire (listingId : ListingId) (fromHere : bool) (now : Instant) conn =
r.Table(Table.Listing) (fromTable Table.Listing
.Get(listingId) |> get listingId)
.Update(r.HashMap("isExpired", true).With("wasFilledHere", fromHere).With ("updatedOn", now)) .Update (r.HashMap("isExpired", true).With("wasFilledHere", fromHere).With ("updatedOn", now))
.RunWriteAsync |> write conn
|> withReconnIgnore conn
/// Search job listings /// Search job listings
let search (srch : ListingSearch) conn = let search (search : ListingSearch) conn =
fun c -> (seq<ReqlExpr -> ReqlExpr> {
(seq { match search.continentId with
match srch.continentId with | Some cId -> yield (fun q -> q.Filter (r.HashMap (nameof search.continentId, ContinentId.ofString cId)))
| Some conId ->
yield (fun (q : ReqlExpr) ->
q.Filter (r.HashMap (nameof srch.continentId, ContinentId.ofString conId)) :> ReqlExpr)
| None -> () | None -> ()
match srch.region with match search.region with
| Some rgn -> | Some rgn ->
yield (fun q -> yield (fun q ->
q.Filter (ReqlFunction1 (fun it -> q.Filter (ReqlFunction1 (fun it -> it.G(nameof search.region).Match (regexContains rgn))))
upcast it.G(nameof srch.region).Match (regexContains rgn))) :> ReqlExpr)
| None -> () | None -> ()
match srch.remoteWork with match search.remoteWork with
| "" -> () | "" -> ()
| _ -> | _ -> yield (fun q -> q.Filter (r.HashMap (nameof search.remoteWork, search.remoteWork = "yes")))
yield (fun q -> q.Filter (r.HashMap (nameof srch.remoteWork, srch.remoteWork = "yes")) :> ReqlExpr) match search.text with
match srch.text with
| Some text -> | Some text ->
yield (fun q -> yield (fun q ->
q.Filter (ReqlFunction1 (fun it -> q.Filter (ReqlFunction1 (fun it -> it.G(nameof search.text).Match (regexContains text))))
upcast it.G(nameof srch.text).Match (regexContains text))) :> ReqlExpr)
| None -> () | None -> ()
} }
|> Seq.toList |> Seq.toList
|> List.fold |> List.fold
(fun q f -> f q) (fun q f -> f q)
(r.Table(Table.Listing) (fromTable Table.Listing
.GetAll(false).OptArg ("index", "isExpired") :> ReqlExpr)) |> getAllWithIndex [ false ] "isExpired" :> ReqlExpr))
.EqJoin("continentId", r.Table Table.Continent) |> eqJoin "continentId" (fromTable Table.Continent)
.Map(ReqlFunction1 (fun it -> upcast r.HashMap("listing", it.G "left").With ("continent", it.G "right"))) |> mapFunc (fun it -> r.HashMap("listing", it.G "left").With ("continent", it.G "right"))
.RunResultAsync<ListingForView list> c |> result<ListingForView list> conn
|> withReconn conn
/// Success story data access functions /// Success story data access functions
@ -535,33 +443,29 @@ module Success =
/// Find a success report by its ID /// Find a success report by its ID
let findById (successId : SuccessId) conn = let findById (successId : SuccessId) conn =
r.Table(Table.Success) fromTable Table.Success
.Get(successId) |> get successId
.RunResultAsync<Success> |> resultOption conn
|> withReconnOption conn
/// Insert or update a success story /// Insert or update a success story
let save (success : Success) conn = let save (success : Success) conn =
r.Table(Table.Success) fromTable Table.Success
.Get(success.id) |> get success.id
.Replace(success) |> replace success
.RunWriteAsync |> write conn
|> withReconnIgnore conn
// Retrieve all success stories // Retrieve all success stories
let all conn = let all conn =
r.Table(Table.Success) (fromTable Table.Success
.EqJoin("citizenId", r.Table Table.Citizen) |> eqJoin "citizenId" (fromTable Table.Citizen))
.Without(r.HashMap ("right", "id")) .Without(r.HashMap ("right", "id"))
.Zip() |> zip
.Merge(ReqlFunction1 (fun it -> |> mergeFunc (fun it ->
upcast r r.HashMap("citizenName",
.HashMap("citizenName",
r.Branch(it.G("realName" ).Default_("").Ne "", it.G "realName", r.Branch(it.G("realName" ).Default_("").Ne "", it.G "realName",
it.G("displayName").Default_("").Ne "", it.G "displayName", it.G("displayName").Default_("").Ne "", it.G "displayName",
it.G "mastodonUser")) it.G "mastodonUser"))
.With ("hasStory", it.G("story").Default_("").Gt ""))) .With ("hasStory", it.G("story").Default_("").Gt ""))
.Pluck("id", "citizenId", "citizenName", "recordedOn", "fromHere", "hasStory") |> pluck [ "id"; "citizenId"; "citizenName"; "recordedOn"; "fromHere"; "hasStory" ]
.OrderBy(r.Desc "recordedOn") |> orderByDescending "recordedOn"
.RunResultAsync<StoryEntry list> |> result<StoryEntry list> conn
|> withReconn conn

View File

@ -21,14 +21,13 @@ module Error =
open System.Threading.Tasks open System.Threading.Tasks
/// URL prefixes for the Vue app /// URL prefixes for the Vue app
let vueUrls = [ let vueUrls =
"/how-it-works"; "/privacy-policy"; "/terms-of-service"; "/citizen"; "/help-wanted"; "/listing"; "/profile" [ "/how-it-works"; "/privacy-policy"; "/terms-of-service"; "/citizen"; "/help-wanted"; "/listing"; "/profile"
"/so-long"; "/success-story" "/so-long"; "/success-story"
] ]
/// Handler that will return a status code 404 and the text "Not Found" /// Handler that will return a status code 404 and the text "Not Found"
let notFound : HttpHandler = let notFound : HttpHandler = fun next ctx -> task {
fun next ctx -> task {
let fac = ctx.GetService<ILoggerFactory> () let fac = ctx.GetService<ILoggerFactory> ()
let log = fac.CreateLogger "Handler" let log = fac.CreateLogger "Handler"
let path = string ctx.Request.Path let path = string ctx.Request.Path
@ -107,8 +106,7 @@ module Helpers =
module Citizen = module Citizen =
// GET: /api/citizen/log-on/[code] // GET: /api/citizen/log-on/[code]
let logOn (abbr, authCode) : HttpHandler = let logOn (abbr, authCode) : HttpHandler = fun next ctx -> task {
fun next ctx -> task {
// Step 1 - Verify with Mastodon // Step 1 - Verify with Mastodon
let cfg = authConfig ctx let cfg = authConfig ctx
@ -154,18 +152,14 @@ module Citizen =
} }
// GET: /api/citizen/[id] // GET: /api/citizen/[id]
let get citizenId : HttpHandler = let get citizenId : HttpHandler = authorize >=> fun next ctx -> task {
authorize
>=> fun next ctx -> task {
match! Data.Citizen.findById (CitizenId citizenId) (conn ctx) with match! Data.Citizen.findById (CitizenId citizenId) (conn ctx) with
| Some citizen -> return! json citizen next ctx | Some citizen -> return! json citizen next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
// DELETE: /api/citizen // DELETE: /api/citizen
let delete : HttpHandler = let delete : HttpHandler = authorize >=> fun next ctx -> task {
authorize
>=> fun next ctx -> task {
do! Data.Citizen.delete (currentCitizenId ctx) (conn ctx) do! Data.Citizen.delete (currentCitizenId ctx) (conn ctx)
return! ok next ctx return! ok next ctx
} }
@ -176,8 +170,7 @@ module Citizen =
module Continent = module Continent =
// GET: /api/continent/all // GET: /api/continent/all
let all : HttpHandler = let all : HttpHandler = fun next ctx -> task {
fun next ctx -> task {
let! continents = Data.Continent.all (conn ctx) let! continents = Data.Continent.all (conn ctx)
return! json continents next ctx return! json continents next ctx
} }
@ -187,17 +180,18 @@ module Continent =
[<RequireQualifiedAccess>] [<RequireQualifiedAccess>]
module Instances = module Instances =
/// Convert a Masotodon instance to the one we use in the API /// Convert a Mastodon instance to the one we use in the API
let private toInstance (inst : MastodonInstance) = let private toInstance (inst : MastodonInstance) =
{ name = inst.Name { name = inst.Name
url = inst.Url url = inst.Url
abbr = inst.Abbr abbr = inst.Abbr
clientId = inst.ClientId clientId = inst.ClientId
isEnabled = inst.IsEnabled
reason = inst.Reason
} }
// GET: /api/instances // GET: /api/instances
let all : HttpHandler = let all : HttpHandler = fun next ctx -> task {
fun next ctx -> task {
return! json ((authConfig ctx).Instances |> Array.map toInstance) next ctx return! json ((authConfig ctx).Instances |> Array.map toInstance) next ctx
} }
@ -213,35 +207,27 @@ module Listing =
let private parseDate = DateTime.Parse >> LocalDate.FromDateTime let private parseDate = DateTime.Parse >> LocalDate.FromDateTime
// GET: /api/listings/mine // GET: /api/listings/mine
let mine : HttpHandler = let mine : HttpHandler = authorize >=> fun next ctx -> task {
authorize
>=> fun next ctx -> task {
let! listings = Data.Listing.findByCitizen (currentCitizenId ctx) (conn ctx) let! listings = Data.Listing.findByCitizen (currentCitizenId ctx) (conn ctx)
return! json listings next ctx return! json listings next ctx
} }
// GET: /api/listing/[id] // GET: /api/listing/[id]
let get listingId : HttpHandler = let get listingId : HttpHandler = authorize >=> fun next ctx -> task {
authorize
>=> fun next ctx -> task {
match! Data.Listing.findById (ListingId listingId) (conn ctx) with match! Data.Listing.findById (ListingId listingId) (conn ctx) with
| Some listing -> return! json listing next ctx | Some listing -> return! json listing next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
// GET: /api/listing/view/[id] // GET: /api/listing/view/[id]
let view listingId : HttpHandler = let view listingId : HttpHandler = authorize >=> fun next ctx -> task {
authorize
>=> fun next ctx -> task {
match! Data.Listing.findByIdForView (ListingId listingId) (conn ctx) with match! Data.Listing.findByIdForView (ListingId listingId) (conn ctx) with
| Some listing -> return! json listing next ctx | Some listing -> return! json listing next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
// POST: /listings // POST: /listings
let add : HttpHandler = let add : HttpHandler = authorize >=> fun next ctx -> task {
authorize
>=> fun next ctx -> task {
let! form = ctx.BindJsonAsync<ListingForm> () let! form = ctx.BindJsonAsync<ListingForm> ()
let now = (clock ctx).GetCurrentInstant () let now = (clock ctx).GetCurrentInstant ()
do! Data.Listing.add do! Data.Listing.add
@ -262,9 +248,7 @@ module Listing =
} }
// PUT: /api/listing/[id] // PUT: /api/listing/[id]
let update listingId : HttpHandler = let update listingId : HttpHandler = authorize >=> fun next ctx -> task {
authorize
>=> fun next ctx -> task {
let dbConn = conn ctx let dbConn = conn ctx
match! Data.Listing.findById (ListingId listingId) dbConn with match! Data.Listing.findById (ListingId listingId) dbConn with
| Some listing when listing.citizenId <> (currentCitizenId ctx) -> return! Error.notAuthorized next ctx | Some listing when listing.citizenId <> (currentCitizenId ctx) -> return! Error.notAuthorized next ctx
@ -285,9 +269,7 @@ module Listing =
} }
// PATCH: /api/listing/[id] // PATCH: /api/listing/[id]
let expire listingId : HttpHandler = let expire listingId : HttpHandler = authorize >=> fun next ctx -> task {
authorize
>=> fun next ctx -> FSharp.Control.Tasks.Affine.task {
let dbConn = conn ctx let dbConn = conn ctx
let now = clock(ctx).GetCurrentInstant () let now = clock(ctx).GetCurrentInstant ()
match! Data.Listing.findById (ListingId listingId) dbConn with match! Data.Listing.findById (ListingId listingId) dbConn with
@ -311,9 +293,7 @@ module Listing =
} }
// GET: /api/listing/search // GET: /api/listing/search
let search : HttpHandler = let search : HttpHandler = authorize >=> fun next ctx -> task {
authorize
>=> fun next ctx -> task {
let search = ctx.BindQueryString<ListingSearch> () let search = ctx.BindQueryString<ListingSearch> ()
let! results = Data.Listing.search search (conn ctx) let! results = Data.Listing.search search (conn ctx)
return! json results next ctx return! json results next ctx
@ -327,27 +307,21 @@ module Profile =
// GET: /api/profile // GET: /api/profile
// This returns the current citizen's profile, or a 204 if it is not found (a citizen not having a profile yet // This returns the current citizen's profile, or a 204 if it is not found (a citizen not having a profile yet
// is not an error). The "get" handler returns a 404 if a profile is not found. // is not an error). The "get" handler returns a 404 if a profile is not found.
let current : HttpHandler = let current : HttpHandler = authorize >=> fun next ctx -> task {
authorize
>=> fun next ctx -> task {
match! Data.Profile.findById (currentCitizenId ctx) (conn ctx) with match! Data.Profile.findById (currentCitizenId ctx) (conn ctx) with
| Some profile -> return! json profile next ctx | Some profile -> return! json profile next ctx
| None -> return! Successful.NO_CONTENT next ctx | None -> return! Successful.NO_CONTENT next ctx
} }
// GET: /api/profile/get/[id] // GET: /api/profile/get/[id]
let get citizenId : HttpHandler = let get citizenId : HttpHandler = authorize >=> fun next ctx -> task {
authorize
>=> fun next ctx -> task {
match! Data.Profile.findById (CitizenId citizenId) (conn ctx) with match! Data.Profile.findById (CitizenId citizenId) (conn ctx) with
| Some profile -> return! json profile next ctx | Some profile -> return! json profile next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
// GET: /api/profile/view/[id] // GET: /api/profile/view/[id]
let view citizenId : HttpHandler = let view citizenId : HttpHandler = authorize >=> fun next ctx -> task {
authorize
>=> fun next ctx -> task {
let citId = CitizenId citizenId let citId = CitizenId citizenId
let dbConn = conn ctx let dbConn = conn ctx
match! Data.Profile.findById citId dbConn with match! Data.Profile.findById citId dbConn with
@ -357,8 +331,8 @@ module Profile =
match! Data.Continent.findById profile.continentId dbConn with match! Data.Continent.findById profile.continentId dbConn with
| Some continent -> | Some continent ->
return! return!
json { json
profile = profile { profile = profile
citizen = citizen citizen = citizen
continent = continent continent = continent
} next ctx } next ctx
@ -368,17 +342,13 @@ module Profile =
} }
// GET: /api/profile/count // GET: /api/profile/count
let count : HttpHandler = let count : HttpHandler = authorize >=> fun next ctx -> task {
authorize
>=> fun next ctx -> task {
let! theCount = Data.Profile.count (conn ctx) let! theCount = Data.Profile.count (conn ctx)
return! json { count = theCount } next ctx return! json { count = theCount } next ctx
} }
// POST: /api/profile/save // POST: /api/profile/save
let save : HttpHandler = let save : HttpHandler = authorize >=> fun next ctx -> task {
authorize
>=> fun next ctx -> task {
let citizenId = currentCitizenId ctx let citizenId = currentCitizenId ctx
let dbConn = conn ctx let dbConn = conn ctx
let! form = ctx.BindJsonAsync<ProfileForm>() let! form = ctx.BindJsonAsync<ProfileForm>()
@ -412,9 +382,7 @@ module Profile =
} }
// PATCH: /api/profile/employment-found // PATCH: /api/profile/employment-found
let employmentFound : HttpHandler = let employmentFound : HttpHandler = authorize >=> fun next ctx -> task {
authorize
>=> fun next ctx -> task {
let dbConn = conn ctx let dbConn = conn ctx
match! Data.Profile.findById (currentCitizenId ctx) dbConn with match! Data.Profile.findById (currentCitizenId ctx) dbConn with
| Some profile -> | Some profile ->
@ -424,25 +392,20 @@ module Profile =
} }
// DELETE: /api/profile // DELETE: /api/profile
let delete : HttpHandler = let delete : HttpHandler = authorize >=> fun next ctx -> task {
authorize
>=> fun next ctx -> task {
do! Data.Profile.delete (currentCitizenId ctx) (conn ctx) do! Data.Profile.delete (currentCitizenId ctx) (conn ctx)
return! ok next ctx return! ok next ctx
} }
// GET: /api/profile/search // GET: /api/profile/search
let search : HttpHandler = let search : HttpHandler = authorize >=> fun next ctx -> task {
authorize
>=> fun next ctx -> task {
let search = ctx.BindQueryString<ProfileSearch> () let search = ctx.BindQueryString<ProfileSearch> ()
let! results = Data.Profile.search search (conn ctx) let! results = Data.Profile.search search (conn ctx)
return! json results next ctx return! json results next ctx
} }
// GET: /api/profile/public-search // GET: /api/profile/public-search
let publicSearch : HttpHandler = let publicSearch : HttpHandler = fun next ctx -> task {
fun next ctx -> task {
let search = ctx.BindQueryString<PublicSearch> () let search = ctx.BindQueryString<PublicSearch> ()
let! results = Data.Profile.publicSearch search (conn ctx) let! results = Data.Profile.publicSearch search (conn ctx)
return! json results next ctx return! json results next ctx
@ -456,26 +419,20 @@ module Success =
open System open System
// GET: /api/success/[id] // GET: /api/success/[id]
let get successId : HttpHandler = let get successId : HttpHandler = authorize >=> fun next ctx -> task {
authorize
>=> fun next ctx -> task {
match! Data.Success.findById (SuccessId successId) (conn ctx) with match! Data.Success.findById (SuccessId successId) (conn ctx) with
| Some story -> return! json story next ctx | Some story -> return! json story next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
// GET: /api/success/list // GET: /api/success/list
let all : HttpHandler = let all : HttpHandler = authorize >=> fun next ctx -> task {
authorize
>=> fun next ctx -> task {
let! stories = Data.Success.all (conn ctx) let! stories = Data.Success.all (conn ctx)
return! json stories next ctx return! json stories next ctx
} }
// POST: /api/success/save // POST: /api/success/save
let save : HttpHandler = let save : HttpHandler = authorize >=> fun next ctx -> task {
authorize
>=> fun next ctx -> task {
let citizenId = currentCitizenId ctx let citizenId = currentCitizenId ctx
let dbConn = conn ctx let dbConn = conn ctx
let now = (clock ctx).GetCurrentInstant () let now = (clock ctx).GetCurrentInstant ()
@ -528,15 +485,9 @@ let allEndpoints = [
routef "/%O/view" Listing.view routef "/%O/view" Listing.view
route "s/mine" Listing.mine route "s/mine" Listing.mine
] ]
PATCH [ PATCH [ routef "/%O" Listing.expire ]
routef "/%O" Listing.expire POST [ route "s" Listing.add ]
] PUT [ routef "/%O" Listing.update ]
POST [
route "s" Listing.add
]
PUT [
routef "/%O" Listing.update
]
] ]
subRoute "/profile" [ subRoute "/profile" [
GET_HEAD [ GET_HEAD [
@ -558,4 +509,4 @@ let allEndpoints = [
POST [ route "" Success.save ] POST [ route "" Success.save ]
] ]
] ]
] ]

View File

@ -2,7 +2,8 @@
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework> <PublishSingleFile>true</PublishSingleFile>
<SelfContained>false</SelfContained>
<WarnOn>3390;$(WarnOn)</WarnOn> <WarnOn>3390;$(WarnOn)</WarnOn>
</PropertyGroup> </PropertyGroup>
@ -22,13 +23,14 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Giraffe" Version="5.0.0" /> <PackageReference Include="Giraffe" Version="6.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.8" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.6" />
<PackageReference Include="Microsoft.FSharpLu.Json" Version="0.11.7" /> <PackageReference Include="Microsoft.FSharpLu.Json" Version="0.11.7" />
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.0.0" /> <PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.0.0" />
<PackageReference Include="Polly" Version="7.2.2" />
<PackageReference Include="RethinkDb.Driver" Version="2.3.150" /> <PackageReference Include="RethinkDb.Driver" Version="2.3.150" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.11.1" /> <PackageReference Include="RethinkDb.Driver.FSharp" Version="0.9.0-beta-05" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.21.0" />
<PackageReference Update="FSharp.Core" Version="6.0.5" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -5,17 +5,23 @@
"0": { "0": {
"Name": "No Agenda Social", "Name": "No Agenda Social",
"Url": "https://noagendasocial.com", "Url": "https://noagendasocial.com",
"Abbr": "nas" "Abbr": "nas",
"IsEnabled": true,
"Reason": ""
}, },
"1": { "1": {
"Name": "ITM Slaves!", "Name": "ITM Slaves!",
"Url": "https://itmslaves.com", "Url": "https://itmslaves.com",
"Abbr": "itm" "Abbr": "itm",
"IsEnabled": false,
"Reason": "This site has changed platforms, and its integration is not yet restored"
}, },
"2": { "2": {
"Name": "Liberty Woof", "Name": "Liberty Woof",
"Url": "https://libertywoof.com", "Url": "https://libertywoof.com",
"Abbr": "lw" "Abbr": "lw",
"IsEnabled": false,
"Reason": "This site may have gone away; it is currently inaccessible"
} }
} }
} }