Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 323ea83594 | |||
| 6e52688622 | |||
| 2ff8618272 | |||
| a1d1b53ff4 | |||
| 45861e06f0 |
12
.config/dotnet-tools.json
Normal file
12
.config/dotnet-tools.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"isRoot": true,
|
||||||
|
"tools": {
|
||||||
|
"fake-cli": {
|
||||||
|
"version": "5.22.0",
|
||||||
|
"commands": [
|
||||||
|
"fake"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -4,6 +4,4 @@ src/**/bin
|
|||||||
src/**/obj
|
src/**/obj
|
||||||
src/**/appsettings.*.json
|
src/**/appsettings.*.json
|
||||||
src/.vs
|
src/.vs
|
||||||
|
src/.idea
|
||||||
## This stores history of every deployment
|
|
||||||
src/JobsJobsJobs/Server/Properties/PublishProfiles/FolderProfile.pubxml.user
|
|
||||||
|
|||||||
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"
|
||||||
232
build.fsx.lock
Normal file
232
build.fsx.lock
Normal 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))
|
||||||
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 "$@"
|
||||||
@@ -13,11 +13,9 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "JobsJobsJobs", "JobsJobsJobs", "{FA833B24-B8F6-4CE6-A044-99257EAC02FF}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "JobsJobsJobs", "JobsJobsJobs", "{FA833B24-B8F6-4CE6-A044-99257EAC02FF}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Domain", "JobsJobsJobs\Domain\Domain.fsproj", "{C81278DA-DA97-4E55-AB39-4B88565B615D}"
|
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Domain", "JobsJobsJobs\Domain\JobsJobsJobs.Domain.fsproj", "{C81278DA-DA97-4E55-AB39-4B88565B615D}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Api", "JobsJobsJobs\Api\Api.fsproj", "{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}"
|
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Api", "JobsJobsJobs\Server\JobsJobsJobs.Server.fsproj", "{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}"
|
||||||
EndProject
|
|
||||||
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "DataMigrate", "JobsJobsJobs\DataMigrate\DataMigrate.fsproj", "{C5774E4F-2930-4B64-8407-77BF7EB79F39}"
|
|
||||||
EndProject
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
@@ -33,10 +31,6 @@ Global
|
|||||||
{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{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.ActiveCfg = Release|Any CPU
|
||||||
{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}.Release|Any CPU.Build.0 = Release|Any CPU
|
{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{C5774E4F-2930-4B64-8407-77BF7EB79F39}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{C5774E4F-2930-4B64-8407-77BF7EB79F39}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{C5774E4F-2930-4B64-8407-77BF7EB79F39}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{C5774E4F-2930-4B64-8407-77BF7EB79F39}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@@ -47,6 +41,5 @@ Global
|
|||||||
GlobalSection(NestedProjects) = preSolution
|
GlobalSection(NestedProjects) = preSolution
|
||||||
{C81278DA-DA97-4E55-AB39-4B88565B615D} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF}
|
{C81278DA-DA97-4E55-AB39-4B88565B615D} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF}
|
||||||
{8F5A3D1E-562B-4F27-9787-6CB14B35E69E} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF}
|
{8F5A3D1E-562B-4F27-9787-6CB14B35E69E} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF}
|
||||||
{C5774E4F-2930-4B64-8407-77BF7EB79F39} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF}
|
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
/// The main API application for Jobs, Jobs, Jobs
|
|
||||||
module JobsJobsJobs.Api.App
|
|
||||||
|
|
||||||
open Microsoft.AspNetCore.Builder
|
|
||||||
open Microsoft.AspNetCore.Hosting
|
|
||||||
open Microsoft.Extensions.DependencyInjection
|
|
||||||
open Microsoft.Extensions.Hosting
|
|
||||||
open Giraffe
|
|
||||||
open Giraffe.EndpointRouting
|
|
||||||
|
|
||||||
|
|
||||||
/// Configure the ASP.NET Core pipeline to use Giraffe
|
|
||||||
let configureApp (app : IApplicationBuilder) =
|
|
||||||
app
|
|
||||||
.UseCors(fun p -> p.AllowAnyOrigin().AllowAnyHeader() |> ignore)
|
|
||||||
.UseStaticFiles()
|
|
||||||
.UseRouting()
|
|
||||||
.UseAuthentication()
|
|
||||||
.UseAuthorization()
|
|
||||||
.UseGiraffeErrorHandler(Handlers.Error.unexpectedError)
|
|
||||||
.UseEndpoints(fun e ->
|
|
||||||
e.MapGiraffeEndpoints Handlers.allEndpoints
|
|
||||||
e.MapFallbackToFile "index.html" |> ignore)
|
|
||||||
|> ignore
|
|
||||||
|
|
||||||
open Newtonsoft.Json
|
|
||||||
open NodaTime
|
|
||||||
open Microsoft.AspNetCore.Authentication.JwtBearer
|
|
||||||
open Microsoft.Extensions.Configuration
|
|
||||||
open Microsoft.Extensions.Logging
|
|
||||||
open Microsoft.IdentityModel.Tokens
|
|
||||||
open System.Text
|
|
||||||
|
|
||||||
/// Configure dependency injection
|
|
||||||
let configureServices (svc : IServiceCollection) =
|
|
||||||
svc.AddGiraffe () |> ignore
|
|
||||||
svc.AddSingleton<IClock> SystemClock.Instance |> ignore
|
|
||||||
svc.AddLogging () |> ignore
|
|
||||||
svc.AddCors () |> ignore
|
|
||||||
|
|
||||||
let jsonCfg = JsonSerializerSettings ()
|
|
||||||
Data.Converters.all () |> List.iter jsonCfg.Converters.Add
|
|
||||||
svc.AddSingleton<Json.ISerializer> (NewtonsoftJson.Serializer jsonCfg) |> ignore
|
|
||||||
|
|
||||||
let svcs = svc.BuildServiceProvider ()
|
|
||||||
let cfg = svcs.GetRequiredService<IConfiguration> ()
|
|
||||||
|
|
||||||
svc.AddAuthentication(fun o ->
|
|
||||||
o.DefaultAuthenticateScheme <- JwtBearerDefaults.AuthenticationScheme
|
|
||||||
o.DefaultChallengeScheme <- JwtBearerDefaults.AuthenticationScheme
|
|
||||||
o.DefaultScheme <- JwtBearerDefaults.AuthenticationScheme)
|
|
||||||
.AddJwtBearer(fun o ->
|
|
||||||
o.RequireHttpsMetadata <- false
|
|
||||||
o.TokenValidationParameters <- TokenValidationParameters (
|
|
||||||
ValidateIssuer = true,
|
|
||||||
ValidateAudience = true,
|
|
||||||
ValidAudience = "https://noagendacareers.com",
|
|
||||||
ValidIssuer = "https://noagendacareers.com",
|
|
||||||
IssuerSigningKey = SymmetricSecurityKey (
|
|
||||||
Encoding.UTF8.GetBytes (cfg.GetSection("Auth").["ServerSecret"]))))
|
|
||||||
|> ignore
|
|
||||||
svc.AddAuthorization () |> ignore
|
|
||||||
|
|
||||||
let dbCfg = cfg.GetSection "Rethink"
|
|
||||||
let log = svcs.GetRequiredService<ILoggerFactory>().CreateLogger (nameof Data.Startup)
|
|
||||||
let conn = Data.Startup.createConnection dbCfg log
|
|
||||||
svc.AddSingleton conn |> ignore
|
|
||||||
Data.Startup.establishEnvironment dbCfg log conn |> Data.awaitIgnore
|
|
||||||
|
|
||||||
[<EntryPoint>]
|
|
||||||
let main _ =
|
|
||||||
Host.CreateDefaultBuilder()
|
|
||||||
.ConfigureWebHostDefaults(
|
|
||||||
fun webHostBuilder ->
|
|
||||||
webHostBuilder
|
|
||||||
.Configure(configureApp)
|
|
||||||
.ConfigureServices(configureServices)
|
|
||||||
|> ignore)
|
|
||||||
.Build()
|
|
||||||
.Run ()
|
|
||||||
0
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
/// Authorization / authentication functions
|
|
||||||
module JobsJobsJobs.Api.Auth
|
|
||||||
|
|
||||||
open System.Text.Json.Serialization
|
|
||||||
|
|
||||||
/// The variables we need from the account information we get from No Agenda Social
|
|
||||||
[<NoComparison; NoEquality; AllowNullLiteral>]
|
|
||||||
type MastodonAccount () =
|
|
||||||
/// The user name (what we store as naUser)
|
|
||||||
[<JsonPropertyName "username">]
|
|
||||||
member val Username = "" with get, set
|
|
||||||
/// The account name; will be the same as username for local (non-federated) accounts
|
|
||||||
[<JsonPropertyName "acct">]
|
|
||||||
member val AccountName = "" with get, set
|
|
||||||
/// The user's display name as it currently shows on No Agenda Social
|
|
||||||
[<JsonPropertyName "display_name">]
|
|
||||||
member val DisplayName = "" with get, set
|
|
||||||
/// The user's profile URL
|
|
||||||
[<JsonPropertyName "url">]
|
|
||||||
member val Url = "" with get, set
|
|
||||||
|
|
||||||
|
|
||||||
open FSharp.Control.Tasks
|
|
||||||
open Microsoft.Extensions.Configuration
|
|
||||||
open Microsoft.Extensions.Logging
|
|
||||||
open System
|
|
||||||
open System.Net.Http
|
|
||||||
open System.Net.Http.Headers
|
|
||||||
open System.Net.Http.Json
|
|
||||||
open System.Text.Json
|
|
||||||
|
|
||||||
/// Verify the authorization code with Mastodon and get the user's profile
|
|
||||||
let verifyWithMastodon (authCode : string) (cfg : IConfigurationSection) (log : ILogger) = task {
|
|
||||||
|
|
||||||
use http = new HttpClient()
|
|
||||||
|
|
||||||
// Use authorization code to get an access token from NAS
|
|
||||||
use! codeResult =
|
|
||||||
http.PostAsJsonAsync("https://noagendasocial.com/oauth/token",
|
|
||||||
{| client_id = cfg.["ClientId"]
|
|
||||||
client_secret = cfg.["Secret"]
|
|
||||||
redirect_uri = sprintf "%s/citizen/authorized" cfg.["ReturnHost"]
|
|
||||||
grant_type = "authorization_code"
|
|
||||||
code = authCode
|
|
||||||
scope = "read"
|
|
||||||
|})
|
|
||||||
match codeResult.IsSuccessStatusCode with
|
|
||||||
| true ->
|
|
||||||
let! responseBytes = codeResult.Content.ReadAsByteArrayAsync ()
|
|
||||||
use tokenResponse = JsonSerializer.Deserialize<JsonDocument> (ReadOnlySpan<byte> responseBytes)
|
|
||||||
match tokenResponse with
|
|
||||||
| null ->
|
|
||||||
return Error "Could not parse authorization code result"
|
|
||||||
| _ ->
|
|
||||||
// Use access token to get profile from NAS
|
|
||||||
use req = new HttpRequestMessage (HttpMethod.Get, sprintf "%saccounts/verify_credentials" cfg.["ApiUrl"])
|
|
||||||
req.Headers.Authorization <- AuthenticationHeaderValue
|
|
||||||
("Bearer", tokenResponse.RootElement.GetProperty("access_token").GetString ())
|
|
||||||
use! profileResult = http.SendAsync req
|
|
||||||
|
|
||||||
match profileResult.IsSuccessStatusCode with
|
|
||||||
| true ->
|
|
||||||
let! profileBytes = profileResult.Content.ReadAsByteArrayAsync ()
|
|
||||||
match JsonSerializer.Deserialize<MastodonAccount>(ReadOnlySpan<byte> profileBytes) with
|
|
||||||
| null ->
|
|
||||||
return Error "Could not parse profile result"
|
|
||||||
| x when x.Username <> x.AccountName ->
|
|
||||||
return Error $"Profiles must be from noagendasocial.com; yours is {x.AccountName}"
|
|
||||||
| profile ->
|
|
||||||
return Ok profile
|
|
||||||
| false ->
|
|
||||||
return Error $"Could not get profile ({profileResult.StatusCode:D}: {profileResult.ReasonPhrase})"
|
|
||||||
| false ->
|
|
||||||
let! err = codeResult.Content.ReadAsStringAsync ()
|
|
||||||
log.LogError $"Could not get token result from Mastodon:\n {err}"
|
|
||||||
return Error $"Could not get token ({codeResult.StatusCode:D}: {codeResult.ReasonPhrase})"
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
open JobsJobsJobs.Domain
|
|
||||||
open JobsJobsJobs.Domain.Types
|
|
||||||
open Microsoft.IdentityModel.Tokens
|
|
||||||
open System.IdentityModel.Tokens.Jwt
|
|
||||||
open System.Security.Claims
|
|
||||||
open System.Text
|
|
||||||
|
|
||||||
/// Create a JSON Web Token for this citizen to use for further requests to this API
|
|
||||||
let createJwt (citizen : Citizen) (cfg : IConfigurationSection) =
|
|
||||||
|
|
||||||
let tokenHandler = JwtSecurityTokenHandler ()
|
|
||||||
let token =
|
|
||||||
tokenHandler.CreateToken (
|
|
||||||
SecurityTokenDescriptor (
|
|
||||||
Subject = ClaimsIdentity [|
|
|
||||||
Claim (ClaimTypes.NameIdentifier, CitizenId.toString citizen.id)
|
|
||||||
Claim (ClaimTypes.Name, Citizen.name citizen)
|
|
||||||
|],
|
|
||||||
Expires = DateTime.UtcNow.AddHours 2.,
|
|
||||||
Issuer = "https://noagendacareers.com",
|
|
||||||
Audience = "https://noagendacareers.com",
|
|
||||||
SigningCredentials = SigningCredentials (
|
|
||||||
SymmetricSecurityKey (Encoding.UTF8.GetBytes cfg.["ServerSecret"]),
|
|
||||||
SecurityAlgorithms.HmacSha256Signature)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
tokenHandler.WriteToken token
|
|
||||||
|
|
||||||
@@ -1,554 +0,0 @@
|
|||||||
/// Data access functions for Jobs, Jobs, Jobs
|
|
||||||
module JobsJobsJobs.Api.Data
|
|
||||||
|
|
||||||
open FSharp.Control.Tasks
|
|
||||||
open JobsJobsJobs.Domain.Types
|
|
||||||
open Polly
|
|
||||||
open RethinkDb.Driver
|
|
||||||
open RethinkDb.Driver.Net
|
|
||||||
|
|
||||||
/// 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
|
|
||||||
module Converters =
|
|
||||||
|
|
||||||
open JobsJobsJobs.Domain
|
|
||||||
open Microsoft.FSharpLu.Json
|
|
||||||
open Newtonsoft.Json
|
|
||||||
open System
|
|
||||||
|
|
||||||
/// JSON converter for citizen IDs
|
|
||||||
type CitizenIdJsonConverter() =
|
|
||||||
inherit JsonConverter<CitizenId>()
|
|
||||||
override __.WriteJson(writer : JsonWriter, value : CitizenId, _ : JsonSerializer) =
|
|
||||||
writer.WriteValue (CitizenId.toString value)
|
|
||||||
override __.ReadJson(reader: JsonReader, _ : Type, _ : CitizenId, _ : bool, _ : JsonSerializer) =
|
|
||||||
(string >> CitizenId.ofString) reader.Value
|
|
||||||
|
|
||||||
/// JSON converter for continent IDs
|
|
||||||
type ContinentIdJsonConverter() =
|
|
||||||
inherit JsonConverter<ContinentId>()
|
|
||||||
override __.WriteJson(writer : JsonWriter, value : ContinentId, _ : JsonSerializer) =
|
|
||||||
writer.WriteValue (ContinentId.toString value)
|
|
||||||
override __.ReadJson(reader: JsonReader, _ : Type, _ : ContinentId, _ : bool, _ : JsonSerializer) =
|
|
||||||
(string >> ContinentId.ofString) reader.Value
|
|
||||||
|
|
||||||
/// JSON converter for Markdown strings
|
|
||||||
type MarkdownStringJsonConverter() =
|
|
||||||
inherit JsonConverter<MarkdownString>()
|
|
||||||
override __.WriteJson(writer : JsonWriter, value : MarkdownString, _ : JsonSerializer) =
|
|
||||||
let (Text text) = value
|
|
||||||
writer.WriteValue text
|
|
||||||
override __.ReadJson(reader: JsonReader, _ : Type, _ : MarkdownString, _ : bool, _ : JsonSerializer) =
|
|
||||||
(string >> Text) reader.Value
|
|
||||||
|
|
||||||
/// JSON converter for listing IDs
|
|
||||||
type ListingIdJsonConverter() =
|
|
||||||
inherit JsonConverter<ListingId>()
|
|
||||||
override __.WriteJson(writer : JsonWriter, value : ListingId, _ : JsonSerializer) =
|
|
||||||
writer.WriteValue (ListingId.toString value)
|
|
||||||
override __.ReadJson(reader: JsonReader, _ : Type, _ : ListingId, _ : bool, _ : JsonSerializer) =
|
|
||||||
(string >> ListingId.ofString) reader.Value
|
|
||||||
|
|
||||||
/// JSON converter for skill IDs
|
|
||||||
type SkillIdJsonConverter() =
|
|
||||||
inherit JsonConverter<SkillId>()
|
|
||||||
override __.WriteJson(writer : JsonWriter, value : SkillId, _ : JsonSerializer) =
|
|
||||||
writer.WriteValue (SkillId.toString value)
|
|
||||||
override __.ReadJson(reader: JsonReader, _ : Type, _ : SkillId, _ : bool, _ : JsonSerializer) =
|
|
||||||
(string >> SkillId.ofString) reader.Value
|
|
||||||
|
|
||||||
/// JSON converter for success report IDs
|
|
||||||
type SuccessIdJsonConverter() =
|
|
||||||
inherit JsonConverter<SuccessId>()
|
|
||||||
override __.WriteJson(writer : JsonWriter, value : SuccessId, _ : JsonSerializer) =
|
|
||||||
writer.WriteValue (SuccessId.toString value)
|
|
||||||
override __.ReadJson(reader: JsonReader, _ : Type, _ : SuccessId, _ : bool, _ : JsonSerializer) =
|
|
||||||
(string >> SuccessId.ofString) reader.Value
|
|
||||||
|
|
||||||
/// All JSON converters needed for the application
|
|
||||||
let all () = [
|
|
||||||
CitizenIdJsonConverter () :> JsonConverter
|
|
||||||
upcast ContinentIdJsonConverter ()
|
|
||||||
upcast MarkdownStringJsonConverter ()
|
|
||||||
upcast ListingIdJsonConverter ()
|
|
||||||
upcast SkillIdJsonConverter ()
|
|
||||||
upcast SuccessIdJsonConverter ()
|
|
||||||
upcast CompactUnionJsonConverter ()
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
/// 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 ]
|
|
||||||
|
|
||||||
|
|
||||||
/// Functions run at startup
|
|
||||||
[<RequireQualifiedAccess>]
|
|
||||||
module Startup =
|
|
||||||
|
|
||||||
open Microsoft.Extensions.Configuration
|
|
||||||
open Microsoft.Extensions.Logging
|
|
||||||
open NodaTime
|
|
||||||
open NodaTime.Serialization.JsonNet
|
|
||||||
|
|
||||||
/// Create a RethinkDB connection
|
|
||||||
let createConnection (cfg : IConfigurationSection) (log : ILogger) =
|
|
||||||
|
|
||||||
// Add all required JSON converters
|
|
||||||
Converter.Serializer.ConfigureForNodaTime DateTimeZoneProviders.Tzdb |> ignore
|
|
||||||
Converters.all ()
|
|
||||||
|> List.iter Converter.Serializer.Converters.Add
|
|
||||||
// Read the configuration and create a connection
|
|
||||||
let bldr =
|
|
||||||
seq<Connection.Builder -> Connection.Builder> {
|
|
||||||
yield fun b -> match cfg.["Hostname"] with null -> b | host -> b.Hostname host
|
|
||||||
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
|
|
||||||
let establishEnvironment (cfg : IConfigurationSection) (log : ILogger) conn = task {
|
|
||||||
// Ensure the database exists
|
|
||||||
match cfg.["Db"] |> Option.ofObj with
|
|
||||||
| Some database ->
|
|
||||||
let! dbs = r.DbList().RunResultAsync<string list> conn
|
|
||||||
match dbs |> List.contains database with
|
|
||||||
| true -> ()
|
|
||||||
| false ->
|
|
||||||
log.LogInformation $"Creating database {database}..."
|
|
||||||
let! _ = r.DbCreate(database).RunWriteAsync conn
|
|
||||||
()
|
|
||||||
| None -> ()
|
|
||||||
// Ensure the tables exist
|
|
||||||
let! tables = r.TableList().RunResultAsync<string list> conn
|
|
||||||
Table.all ()
|
|
||||||
|> List.iter (
|
|
||||||
fun tbl ->
|
|
||||||
match tables |> List.contains tbl with
|
|
||||||
| true -> ()
|
|
||||||
| false ->
|
|
||||||
log.LogInformation $"Creating {tbl} table..."
|
|
||||||
r.TableCreate(tbl).RunWriteAsync conn |> awaitIgnore)
|
|
||||||
// Ensure the indexes exist
|
|
||||||
let ensureIndexes table indexes = task {
|
|
||||||
let! tblIdxs = r.Table(table).IndexList().RunResultAsync<string list> conn
|
|
||||||
indexes
|
|
||||||
|> List.iter (
|
|
||||||
fun idx ->
|
|
||||||
match tblIdxs |> List.contains idx with
|
|
||||||
| true -> ()
|
|
||||||
| false ->
|
|
||||||
log.LogInformation $"Creating \"{idx}\" index on {table}"
|
|
||||||
r.Table(table).IndexCreate(idx).RunWriteAsync conn |> awaitIgnore)
|
|
||||||
}
|
|
||||||
do! ensureIndexes Table.Citizen [ "naUser" ]
|
|
||||||
do! ensureIndexes Table.Listing [ "citizenId"; "continentId"; "isExpired" ]
|
|
||||||
do! ensureIndexes Table.Profile [ "continentId" ]
|
|
||||||
do! ensureIndexes Table.Success [ "citizenId" ]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// 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.SharedTypes
|
|
||||||
open RethinkDb.Driver.Ast
|
|
||||||
|
|
||||||
/// Profile data access functions
|
|
||||||
[<RequireQualifiedAccess>]
|
|
||||||
module Profile =
|
|
||||||
|
|
||||||
let count conn =
|
|
||||||
r.Table(Table.Profile)
|
|
||||||
.Count()
|
|
||||||
.RunResultAsync<int64>
|
|
||||||
|> withReconn conn
|
|
||||||
|
|
||||||
/// Find a profile by citizen ID
|
|
||||||
let findById (citizenId : CitizenId) conn =
|
|
||||||
r.Table(Table.Profile)
|
|
||||||
.Get(citizenId)
|
|
||||||
.RunResultAsync<Profile>
|
|
||||||
|> withReconnOption conn
|
|
||||||
|
|
||||||
/// Insert or update a profile
|
|
||||||
let save (profile : Profile) conn =
|
|
||||||
r.Table(Table.Profile)
|
|
||||||
.Get(profile.id)
|
|
||||||
.Replace(profile)
|
|
||||||
.RunWriteAsync
|
|
||||||
|> withReconnIgnore conn
|
|
||||||
|
|
||||||
/// Delete a citizen's profile
|
|
||||||
let delete (citizenId : CitizenId) conn =
|
|
||||||
r.Table(Table.Profile)
|
|
||||||
.Get(citizenId)
|
|
||||||
.Delete()
|
|
||||||
.RunWriteAsync
|
|
||||||
|> withReconnIgnore conn
|
|
||||||
|
|
||||||
/// Search profiles (logged-on users)
|
|
||||||
let search (srch : ProfileSearch) conn =
|
|
||||||
fun c ->
|
|
||||||
(seq {
|
|
||||||
match srch.continentId with
|
|
||||||
| Some conId ->
|
|
||||||
yield (fun (q : ReqlExpr) ->
|
|
||||||
q.Filter (r.HashMap (nameof srch.continentId, ContinentId.ofString conId)) :> ReqlExpr)
|
|
||||||
| None -> ()
|
|
||||||
match srch.remoteWork with
|
|
||||||
| "" -> ()
|
|
||||||
| _ -> yield (fun q -> q.Filter (r.HashMap (nameof srch.remoteWork, srch.remoteWork = "yes")) :> ReqlExpr)
|
|
||||||
match srch.skill with
|
|
||||||
| Some skl ->
|
|
||||||
yield (fun q -> q.Filter (ReqlFunction1(fun it ->
|
|
||||||
upcast it.G("skills").Contains (ReqlFunction1(fun s ->
|
|
||||||
upcast s.G("description").Match (regexContains skl))))) :> ReqlExpr)
|
|
||||||
| None -> ()
|
|
||||||
match srch.bioExperience with
|
|
||||||
| Some text ->
|
|
||||||
let txt = regexContains text
|
|
||||||
yield (fun q -> q.Filter (ReqlFunction1(fun it ->
|
|
||||||
upcast it.G("biography").Match(txt).Or (it.G("experience").Match txt))) :> ReqlExpr)
|
|
||||||
| None -> ()
|
|
||||||
}
|
|
||||||
|> Seq.toList
|
|
||||||
|> List.fold
|
|
||||||
(fun q f -> f q)
|
|
||||||
(r.Table(Table.Profile)
|
|
||||||
.EqJoin("id", r.Table Table.Citizen)
|
|
||||||
.Without(r.HashMap ("right", "id"))
|
|
||||||
.Zip () :> ReqlExpr))
|
|
||||||
.Merge(ReqlFunction1 (fun it ->
|
|
||||||
upcast r
|
|
||||||
.HashMap("displayName",
|
|
||||||
r.Branch (it.G("realName" ).Default_("").Ne "", it.G "realName",
|
|
||||||
it.G("displayName").Default_("").Ne "", it.G "displayName",
|
|
||||||
it.G "naUser"))
|
|
||||||
.With ("citizenId", it.G "id")))
|
|
||||||
.Pluck("citizenId", "displayName", "seekingEmployment", "remoteWork", "fullTime", "lastUpdatedOn")
|
|
||||||
.OrderBy(ReqlFunction1 (fun it -> upcast it.G("displayName").Downcase ()))
|
|
||||||
.RunResultAsync<ProfileSearchResult list> c
|
|
||||||
|> withReconn conn
|
|
||||||
|
|
||||||
// Search profiles (public)
|
|
||||||
let publicSearch (srch : PublicSearch) conn =
|
|
||||||
fun c ->
|
|
||||||
(seq {
|
|
||||||
match srch.continentId with
|
|
||||||
| Some conId ->
|
|
||||||
yield (fun (q : ReqlExpr) ->
|
|
||||||
q.Filter (r.HashMap (nameof srch.continentId, ContinentId.ofString conId)) :> ReqlExpr)
|
|
||||||
| None -> ()
|
|
||||||
match srch.region with
|
|
||||||
| Some reg ->
|
|
||||||
yield (fun q ->
|
|
||||||
q.Filter (ReqlFunction1 (fun it -> upcast it.G("region").Match (regexContains reg))) :> ReqlExpr)
|
|
||||||
| None -> ()
|
|
||||||
match srch.remoteWork with
|
|
||||||
| "" -> ()
|
|
||||||
| _ -> yield (fun q -> q.Filter (r.HashMap (nameof srch.remoteWork, srch.remoteWork = "yes")) :> ReqlExpr)
|
|
||||||
match srch.skill with
|
|
||||||
| Some skl ->
|
|
||||||
yield (fun q -> q.Filter (ReqlFunction1 (fun it ->
|
|
||||||
upcast it.G("skills").Contains (ReqlFunction1(fun s ->
|
|
||||||
upcast s.G("description").Match (regexContains skl))))) :> ReqlExpr)
|
|
||||||
| None -> ()
|
|
||||||
}
|
|
||||||
|> Seq.toList
|
|
||||||
|> List.fold
|
|
||||||
(fun q f -> f q)
|
|
||||||
(r.Table(Table.Profile)
|
|
||||||
.EqJoin("continentId", r.Table Table.Continent)
|
|
||||||
.Without(r.HashMap ("right", "id"))
|
|
||||||
.Zip()
|
|
||||||
.Filter(r.HashMap ("isPublic", true)) :> ReqlExpr))
|
|
||||||
.Merge(ReqlFunction1 (fun it ->
|
|
||||||
upcast r
|
|
||||||
.HashMap("skills",
|
|
||||||
it.G("skills").Map (ReqlFunction1 (fun skill ->
|
|
||||||
upcast r.Branch(skill.G("notes").Default_("").Eq "", skill.G "description",
|
|
||||||
skill.G("description").Add(" (").Add(skill.G("notes")).Add ")"))))
|
|
||||||
.With("continent", it.G "name")))
|
|
||||||
.Pluck("continent", "region", "skills", "remoteWork")
|
|
||||||
.RunResultAsync<PublicSearchResult list> c
|
|
||||||
|> withReconn conn
|
|
||||||
|
|
||||||
/// Citizen data access functions
|
|
||||||
[<RequireQualifiedAccess>]
|
|
||||||
module Citizen =
|
|
||||||
|
|
||||||
/// Find a citizen by their ID
|
|
||||||
let findById (citizenId : CitizenId) conn =
|
|
||||||
r.Table(Table.Citizen)
|
|
||||||
.Get(citizenId)
|
|
||||||
.RunResultAsync<Citizen>
|
|
||||||
|> withReconnOption conn
|
|
||||||
|
|
||||||
/// Find a citizen by their No Agenda Social username
|
|
||||||
let findByNaUser (naUser : string) conn =
|
|
||||||
r.Table(Table.Citizen)
|
|
||||||
.GetAll(naUser).OptArg("index", "naUser").Nth(0)
|
|
||||||
.RunResultAsync<Citizen>
|
|
||||||
|> withReconnOption conn
|
|
||||||
|
|
||||||
/// Add a citizen
|
|
||||||
let add (citizen : Citizen) conn =
|
|
||||||
r.Table(Table.Citizen)
|
|
||||||
.Insert(citizen)
|
|
||||||
.RunWriteAsync
|
|
||||||
|> withReconnIgnore conn
|
|
||||||
|
|
||||||
/// Update the display name and last seen on date for a citizen
|
|
||||||
let logOnUpdate (citizen : Citizen) conn =
|
|
||||||
r.Table(Table.Citizen)
|
|
||||||
.Get(citizen.id)
|
|
||||||
.Update(r.HashMap( nameof citizen.displayName, citizen.displayName)
|
|
||||||
.With (nameof citizen.lastSeenOn, citizen.lastSeenOn))
|
|
||||||
.RunWriteAsync
|
|
||||||
|> withReconnIgnore conn
|
|
||||||
|
|
||||||
/// Delete a citizen
|
|
||||||
let delete citizenId conn =
|
|
||||||
fun c -> task {
|
|
||||||
do! Profile.delete citizenId c
|
|
||||||
let! _ =
|
|
||||||
r.Table(Table.Success)
|
|
||||||
.GetAll(citizenId).OptArg("index", "citizenId")
|
|
||||||
.Delete()
|
|
||||||
.RunWriteAsync c
|
|
||||||
let! _ =
|
|
||||||
r.Table(Table.Listing)
|
|
||||||
.GetAll(citizenId).OptArg("index", "citizenId")
|
|
||||||
.Delete()
|
|
||||||
.RunWriteAsync c
|
|
||||||
let! _ =
|
|
||||||
r.Table(Table.Citizen)
|
|
||||||
.Get(citizenId)
|
|
||||||
.Delete()
|
|
||||||
.RunWriteAsync c
|
|
||||||
()
|
|
||||||
}
|
|
||||||
|> withReconnIgnore conn
|
|
||||||
|
|
||||||
/// Update a citizen's real name
|
|
||||||
let realNameUpdate (citizenId : CitizenId) (realName : string option) conn =
|
|
||||||
r.Table(Table.Citizen)
|
|
||||||
.Get(citizenId)
|
|
||||||
.Update(r.HashMap (nameof realName, realName))
|
|
||||||
.RunWriteAsync
|
|
||||||
|> withReconnIgnore conn
|
|
||||||
|
|
||||||
|
|
||||||
/// Continent data access functions
|
|
||||||
[<RequireQualifiedAccess>]
|
|
||||||
module Continent =
|
|
||||||
|
|
||||||
/// Get all continents
|
|
||||||
let all conn =
|
|
||||||
r.Table(Table.Continent)
|
|
||||||
.RunResultAsync<Continent list>
|
|
||||||
|> withReconn conn
|
|
||||||
|
|
||||||
/// Get a continent by its ID
|
|
||||||
let findById (contId : ContinentId) conn =
|
|
||||||
r.Table(Table.Continent)
|
|
||||||
.Get(contId)
|
|
||||||
.RunResultAsync<Continent>
|
|
||||||
|> withReconnOption conn
|
|
||||||
|
|
||||||
|
|
||||||
/// Job listing data access functions
|
|
||||||
[<RequireQualifiedAccess>]
|
|
||||||
module Listing =
|
|
||||||
|
|
||||||
open NodaTime
|
|
||||||
|
|
||||||
/// Find all job listings posted by the given citizen
|
|
||||||
let findByCitizen (citizenId : CitizenId) conn =
|
|
||||||
r.Table(Table.Listing)
|
|
||||||
.GetAll(citizenId).OptArg("index", nameof citizenId)
|
|
||||||
.EqJoin("continentId", r.Table Table.Continent)
|
|
||||||
.Map(ReqlFunction1 (fun it -> upcast r.HashMap("listing", it.G "left").With ("continent", it.G "right")))
|
|
||||||
.RunResultAsync<ListingForView list>
|
|
||||||
|> withReconn conn
|
|
||||||
|
|
||||||
/// Find a listing by its ID
|
|
||||||
let findById (listingId : ListingId) conn =
|
|
||||||
r.Table(Table.Listing)
|
|
||||||
.Get(listingId)
|
|
||||||
.RunResultAsync<Listing>
|
|
||||||
|> withReconnOption conn
|
|
||||||
|
|
||||||
/// Find a listing by its ID for viewing (includes continent information)
|
|
||||||
let findByIdForView (listingId : ListingId) conn =
|
|
||||||
fun c -> task {
|
|
||||||
let! listing =
|
|
||||||
r.Table(Table.Listing)
|
|
||||||
.Filter(r.HashMap ("id", listingId))
|
|
||||||
.EqJoin("continentId", r.Table Table.Continent)
|
|
||||||
.Map(ReqlFunction1 (fun it -> upcast r.HashMap("listing", it.G "left").With ("continent", it.G "right")))
|
|
||||||
.RunResultAsync<ListingForView list> c
|
|
||||||
return List.tryHead listing
|
|
||||||
}
|
|
||||||
|> withReconn conn
|
|
||||||
|
|
||||||
/// Add a listing
|
|
||||||
let add (listing : Listing) conn =
|
|
||||||
r.Table(Table.Listing)
|
|
||||||
.Insert(listing)
|
|
||||||
.RunWriteAsync
|
|
||||||
|> withReconnIgnore conn
|
|
||||||
|
|
||||||
/// Update a listing
|
|
||||||
let update (listing : Listing) conn =
|
|
||||||
r.Table(Table.Listing)
|
|
||||||
.Get(listing.id)
|
|
||||||
.Replace(listing)
|
|
||||||
.RunWriteAsync
|
|
||||||
|> withReconnIgnore conn
|
|
||||||
|
|
||||||
/// Expire a listing
|
|
||||||
let expire (listingId : ListingId) (fromHere : bool) (now : Instant) conn =
|
|
||||||
r.Table(Table.Listing)
|
|
||||||
.Get(listingId)
|
|
||||||
.Update(r.HashMap("isExpired", true).With("wasFilledHere", fromHere).With ("updatedOn", now))
|
|
||||||
.RunWriteAsync
|
|
||||||
|> withReconnIgnore conn
|
|
||||||
|
|
||||||
/// Search job listings
|
|
||||||
let search (srch : ListingSearch) conn =
|
|
||||||
fun c ->
|
|
||||||
(seq {
|
|
||||||
match srch.continentId with
|
|
||||||
| Some conId ->
|
|
||||||
yield (fun (q : ReqlExpr) ->
|
|
||||||
q.Filter (r.HashMap (nameof srch.continentId, ContinentId.ofString conId)) :> ReqlExpr)
|
|
||||||
| None -> ()
|
|
||||||
match srch.region with
|
|
||||||
| Some rgn ->
|
|
||||||
yield (fun q ->
|
|
||||||
q.Filter (ReqlFunction1 (fun it ->
|
|
||||||
upcast it.G(nameof srch.region).Match (regexContains rgn))) :> ReqlExpr)
|
|
||||||
| None -> ()
|
|
||||||
match srch.remoteWork with
|
|
||||||
| "" -> ()
|
|
||||||
| _ ->
|
|
||||||
yield (fun q -> q.Filter (r.HashMap (nameof srch.remoteWork, srch.remoteWork = "yes")) :> ReqlExpr)
|
|
||||||
match srch.text with
|
|
||||||
| Some text ->
|
|
||||||
yield (fun q ->
|
|
||||||
q.Filter (ReqlFunction1 (fun it ->
|
|
||||||
upcast it.G(nameof srch.text).Match (regexContains text))) :> ReqlExpr)
|
|
||||||
| None -> ()
|
|
||||||
}
|
|
||||||
|> Seq.toList
|
|
||||||
|> List.fold
|
|
||||||
(fun q f -> f q)
|
|
||||||
(r.Table(Table.Listing)
|
|
||||||
.GetAll(false).OptArg ("index", "isExpired") :> ReqlExpr))
|
|
||||||
.EqJoin("continentId", r.Table Table.Continent)
|
|
||||||
.Map(ReqlFunction1 (fun it -> upcast r.HashMap("listing", it.G "left").With ("continent", it.G "right")))
|
|
||||||
.RunResultAsync<ListingForView list> c
|
|
||||||
|> withReconn conn
|
|
||||||
|
|
||||||
|
|
||||||
/// Success story data access functions
|
|
||||||
[<RequireQualifiedAccess>]
|
|
||||||
module Success =
|
|
||||||
|
|
||||||
/// Find a success report by its ID
|
|
||||||
let findById (successId : SuccessId) conn =
|
|
||||||
r.Table(Table.Success)
|
|
||||||
.Get(successId)
|
|
||||||
.RunResultAsync<Success>
|
|
||||||
|> withReconnOption conn
|
|
||||||
|
|
||||||
/// Insert or update a success story
|
|
||||||
let save (success : Success) conn =
|
|
||||||
r.Table(Table.Success)
|
|
||||||
.Get(success.id)
|
|
||||||
.Replace(success)
|
|
||||||
.RunWriteAsync
|
|
||||||
|> withReconnIgnore conn
|
|
||||||
|
|
||||||
// Retrieve all success stories
|
|
||||||
let all conn =
|
|
||||||
r.Table(Table.Success)
|
|
||||||
.EqJoin("citizenId", r.Table Table.Citizen)
|
|
||||||
.Without(r.HashMap ("right", "id"))
|
|
||||||
.Zip()
|
|
||||||
.Merge(ReqlFunction1 (fun it ->
|
|
||||||
upcast r
|
|
||||||
.HashMap("citizenName",
|
|
||||||
r.Branch(it.G("realName" ).Default_("").Ne "", it.G "realName",
|
|
||||||
it.G("displayName").Default_("").Ne "", it.G "displayName",
|
|
||||||
it.G "naUser"))
|
|
||||||
.With ("hasStory", it.G("story").Default_("").Gt "")))
|
|
||||||
.Pluck("id", "citizenId", "citizenName", "recordedOn", "fromHere", "hasStory")
|
|
||||||
.OrderBy(r.Desc "recordedOn")
|
|
||||||
.RunResultAsync<StoryEntry list>
|
|
||||||
|> withReconn conn
|
|
||||||
@@ -1,535 +0,0 @@
|
|||||||
/// Route handlers for Giraffe endpoints
|
|
||||||
module JobsJobsJobs.Api.Handlers
|
|
||||||
|
|
||||||
open FSharp.Control.Tasks
|
|
||||||
open Giraffe
|
|
||||||
open JobsJobsJobs.Domain
|
|
||||||
open JobsJobsJobs.Domain.SharedTypes
|
|
||||||
open JobsJobsJobs.Domain.Types
|
|
||||||
open Microsoft.AspNetCore.Http
|
|
||||||
open Microsoft.Extensions.Logging
|
|
||||||
|
|
||||||
/// Handler to return the files required for the Vue client app
|
|
||||||
module Vue =
|
|
||||||
|
|
||||||
/// Handler that returns index.html (the Vue client app)
|
|
||||||
let app = htmlFile "wwwroot/index.html"
|
|
||||||
|
|
||||||
|
|
||||||
/// Handlers for error conditions
|
|
||||||
module Error =
|
|
||||||
|
|
||||||
open System.Threading.Tasks
|
|
||||||
|
|
||||||
/// URL prefixes for the Vue app
|
|
||||||
let vueUrls = [
|
|
||||||
"/"; "/how-it-works"; "/privacy-policy"; "/terms-of-service"; "/citizen"; "/help-wanted"; "/listing"; "/profile"
|
|
||||||
"/so-long"; "/success-story"
|
|
||||||
]
|
|
||||||
|
|
||||||
/// Handler that will return a status code 404 and the text "Not Found"
|
|
||||||
let notFound : HttpHandler =
|
|
||||||
fun next ctx -> task {
|
|
||||||
let fac = ctx.GetService<ILoggerFactory>()
|
|
||||||
let log = fac.CreateLogger("Handler")
|
|
||||||
match [ "GET"; "HEAD" ] |> List.contains ctx.Request.Method with
|
|
||||||
| true when vueUrls |> List.exists (fun url -> ctx.Request.Path.ToString().StartsWith url) ->
|
|
||||||
log.LogInformation "Returning Vue app"
|
|
||||||
return! Vue.app next ctx
|
|
||||||
| _ ->
|
|
||||||
log.LogInformation "Returning 404"
|
|
||||||
return! RequestErrors.NOT_FOUND $"The URL {string ctx.Request.Path} was not recognized as a valid URL" next
|
|
||||||
ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handler that returns a 403 NOT AUTHORIZED response
|
|
||||||
let notAuthorized : HttpHandler =
|
|
||||||
setStatusCode 403 >=> fun _ _ -> Task.FromResult<HttpContext option> None
|
|
||||||
|
|
||||||
/// Handler to log 500s and return a message we can display in the application
|
|
||||||
let unexpectedError (ex: exn) (log : ILogger) =
|
|
||||||
log.LogError(ex, "An unexpected error occurred")
|
|
||||||
clearResponse >=> ServerErrors.INTERNAL_ERROR ex.Message
|
|
||||||
|
|
||||||
|
|
||||||
/// Helper functions
|
|
||||||
[<AutoOpen>]
|
|
||||||
module Helpers =
|
|
||||||
|
|
||||||
open NodaTime
|
|
||||||
open Microsoft.Extensions.Configuration
|
|
||||||
open RethinkDb.Driver.Net
|
|
||||||
open System.Security.Claims
|
|
||||||
|
|
||||||
/// Get the NodaTime clock from the request context
|
|
||||||
let clock (ctx : HttpContext) = ctx.GetService<IClock> ()
|
|
||||||
|
|
||||||
/// Get the application configuration from the request context
|
|
||||||
let config (ctx : HttpContext) = ctx.GetService<IConfiguration> ()
|
|
||||||
|
|
||||||
/// Get the logger factory from the request context
|
|
||||||
let logger (ctx : HttpContext) = ctx.GetService<ILoggerFactory> ()
|
|
||||||
|
|
||||||
/// Get the RethinkDB connection from the request context
|
|
||||||
let conn (ctx : HttpContext) = ctx.GetService<IConnection> ()
|
|
||||||
|
|
||||||
/// `None` if a `string option` is `None`, whitespace, or empty
|
|
||||||
let noneIfBlank (s : string option) =
|
|
||||||
s |> Option.map (fun x -> match x.Trim () with "" -> None | _ -> Some x) |> Option.flatten
|
|
||||||
|
|
||||||
/// `None` if a `string` is null, empty, or whitespace; otherwise, `Some` and the trimmed string
|
|
||||||
let noneIfEmpty = Option.ofObj >> noneIfBlank
|
|
||||||
|
|
||||||
/// Try to get the current user
|
|
||||||
let tryUser (ctx : HttpContext) =
|
|
||||||
ctx.User.FindFirst ClaimTypes.NameIdentifier
|
|
||||||
|> Option.ofObj
|
|
||||||
|> Option.map (fun x -> x.Value)
|
|
||||||
|
|
||||||
/// Require a user to be logged in
|
|
||||||
let authorize : HttpHandler =
|
|
||||||
fun next ctx -> match tryUser ctx with Some _ -> next ctx | None -> Error.notAuthorized next ctx
|
|
||||||
|
|
||||||
/// Get the ID of the currently logged in citizen
|
|
||||||
// NOTE: if no one is logged in, this will raise an exception
|
|
||||||
let currentCitizenId = tryUser >> Option.get >> CitizenId.ofString
|
|
||||||
|
|
||||||
/// Return an empty OK response
|
|
||||||
let ok : HttpHandler = Successful.OK ""
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/// Handlers for /api/citizen routes
|
|
||||||
[<RequireQualifiedAccess>]
|
|
||||||
module Citizen =
|
|
||||||
|
|
||||||
// GET: /api/citizen/log-on/[code]
|
|
||||||
let logOn authCode : HttpHandler =
|
|
||||||
fun next ctx -> task {
|
|
||||||
// Step 1 - Verify with Mastodon
|
|
||||||
let cfg = (config ctx).GetSection "Auth"
|
|
||||||
let log = (logger ctx).CreateLogger (nameof JobsJobsJobs.Api.Auth)
|
|
||||||
|
|
||||||
match! Auth.verifyWithMastodon authCode cfg log with
|
|
||||||
| Ok account ->
|
|
||||||
// Step 2 - Find / establish Jobs, Jobs, Jobs account
|
|
||||||
let now = (clock ctx).GetCurrentInstant ()
|
|
||||||
let dbConn = conn ctx
|
|
||||||
let! citizen = task {
|
|
||||||
match! Data.Citizen.findByNaUser account.Username dbConn with
|
|
||||||
| None ->
|
|
||||||
let it : Citizen =
|
|
||||||
{ id = CitizenId.create ()
|
|
||||||
naUser = account.Username
|
|
||||||
displayName = noneIfEmpty account.DisplayName
|
|
||||||
realName = None
|
|
||||||
profileUrl = account.Url
|
|
||||||
joinedOn = now
|
|
||||||
lastSeenOn = now
|
|
||||||
}
|
|
||||||
do! Data.Citizen.add it dbConn
|
|
||||||
return it
|
|
||||||
| Some citizen ->
|
|
||||||
let it = { citizen with displayName = noneIfEmpty account.DisplayName; lastSeenOn = now }
|
|
||||||
do! Data.Citizen.logOnUpdate it dbConn
|
|
||||||
return it
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3 - Generate JWT
|
|
||||||
return!
|
|
||||||
json
|
|
||||||
{ jwt = Auth.createJwt citizen cfg
|
|
||||||
citizenId = CitizenId.toString citizen.id
|
|
||||||
name = Citizen.name citizen
|
|
||||||
} next ctx
|
|
||||||
| Error err ->
|
|
||||||
return! RequestErrors.BAD_REQUEST err next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET: /api/citizen/[id]
|
|
||||||
let get citizenId : HttpHandler =
|
|
||||||
authorize
|
|
||||||
>=> fun next ctx -> task {
|
|
||||||
match! Data.Citizen.findById (CitizenId citizenId) (conn ctx) with
|
|
||||||
| Some citizen -> return! json citizen next ctx
|
|
||||||
| None -> return! Error.notFound next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
// DELETE: /api/citizen
|
|
||||||
let delete : HttpHandler =
|
|
||||||
authorize
|
|
||||||
>=> fun next ctx -> task {
|
|
||||||
do! Data.Citizen.delete (currentCitizenId ctx) (conn ctx)
|
|
||||||
return! ok next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// Handlers for /api/continent routes
|
|
||||||
[<RequireQualifiedAccess>]
|
|
||||||
module Continent =
|
|
||||||
|
|
||||||
// GET: /api/continent/all
|
|
||||||
let all : HttpHandler =
|
|
||||||
fun next ctx -> task {
|
|
||||||
let! continents = Data.Continent.all (conn ctx)
|
|
||||||
return! json continents next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// Handlers for /api/listing[s] routes
|
|
||||||
[<RequireQualifiedAccess>]
|
|
||||||
module Listing =
|
|
||||||
|
|
||||||
open NodaTime
|
|
||||||
open System
|
|
||||||
|
|
||||||
/// Parse the string we receive from JSON into a NodaTime local date
|
|
||||||
let private parseDate = DateTime.Parse >> LocalDate.FromDateTime
|
|
||||||
|
|
||||||
// GET: /api/listings/mine
|
|
||||||
let mine : HttpHandler =
|
|
||||||
authorize
|
|
||||||
>=> fun next ctx -> task {
|
|
||||||
let! listings = Data.Listing.findByCitizen (currentCitizenId ctx) (conn ctx)
|
|
||||||
return! json listings next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET: /api/listing/[id]
|
|
||||||
let get listingId : HttpHandler =
|
|
||||||
authorize
|
|
||||||
>=> fun next ctx -> task {
|
|
||||||
match! Data.Listing.findById (ListingId listingId) (conn ctx) with
|
|
||||||
| Some listing -> return! json listing next ctx
|
|
||||||
| None -> return! Error.notFound next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET: /api/listing/view/[id]
|
|
||||||
let view listingId : HttpHandler =
|
|
||||||
authorize
|
|
||||||
>=> fun next ctx -> task {
|
|
||||||
match! Data.Listing.findByIdForView (ListingId listingId) (conn ctx) with
|
|
||||||
| Some listing -> return! json listing next ctx
|
|
||||||
| None -> return! Error.notFound next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST: /listings
|
|
||||||
let add : HttpHandler =
|
|
||||||
authorize
|
|
||||||
>=> fun next ctx -> task {
|
|
||||||
let! form = ctx.BindJsonAsync<ListingForm> ()
|
|
||||||
let now = (clock ctx).GetCurrentInstant ()
|
|
||||||
do! Data.Listing.add
|
|
||||||
{ id = ListingId.create ()
|
|
||||||
citizenId = currentCitizenId ctx
|
|
||||||
createdOn = now
|
|
||||||
title = form.title
|
|
||||||
continentId = ContinentId.ofString form.continentId
|
|
||||||
region = form.region
|
|
||||||
remoteWork = form.remoteWork
|
|
||||||
isExpired = false
|
|
||||||
updatedOn = now
|
|
||||||
text = Text form.text
|
|
||||||
neededBy = (form.neededBy |> Option.map parseDate)
|
|
||||||
wasFilledHere = None
|
|
||||||
} (conn ctx)
|
|
||||||
return! ok next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
// PUT: /api/listing/[id]
|
|
||||||
let update listingId : HttpHandler =
|
|
||||||
authorize
|
|
||||||
>=> fun next ctx -> task {
|
|
||||||
let dbConn = conn ctx
|
|
||||||
match! Data.Listing.findById (ListingId listingId) dbConn with
|
|
||||||
| Some listing when listing.citizenId <> (currentCitizenId ctx) -> return! Error.notAuthorized next ctx
|
|
||||||
| Some listing ->
|
|
||||||
let! form = ctx.BindJsonAsync<ListingForm> ()
|
|
||||||
do! Data.Listing.update
|
|
||||||
{ listing with
|
|
||||||
title = form.title
|
|
||||||
continentId = ContinentId.ofString form.continentId
|
|
||||||
region = form.region
|
|
||||||
remoteWork = form.remoteWork
|
|
||||||
text = Text form.text
|
|
||||||
neededBy = form.neededBy |> Option.map parseDate
|
|
||||||
updatedOn = (clock ctx).GetCurrentInstant ()
|
|
||||||
} dbConn
|
|
||||||
return! ok next ctx
|
|
||||||
| None -> return! Error.notFound next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
// PATCH: /api/listing/[id]
|
|
||||||
let expire listingId : HttpHandler =
|
|
||||||
authorize
|
|
||||||
>=> fun next ctx -> task {
|
|
||||||
let dbConn = conn ctx
|
|
||||||
let now = clock(ctx).GetCurrentInstant ()
|
|
||||||
match! Data.Listing.findById (ListingId listingId) dbConn with
|
|
||||||
| Some listing when listing.citizenId <> (currentCitizenId ctx) -> return! Error.notAuthorized next ctx
|
|
||||||
| Some listing ->
|
|
||||||
let! form = ctx.BindJsonAsync<ListingExpireForm> ()
|
|
||||||
do! Data.Listing.expire listing.id form.fromHere now dbConn
|
|
||||||
match form.successStory with
|
|
||||||
| Some storyText ->
|
|
||||||
do! Data.Success.save
|
|
||||||
{ id = SuccessId.create()
|
|
||||||
citizenId = currentCitizenId ctx
|
|
||||||
recordedOn = now
|
|
||||||
fromHere = form.fromHere
|
|
||||||
source = "listing"
|
|
||||||
story = (Text >> Some) storyText
|
|
||||||
} dbConn
|
|
||||||
| None -> ()
|
|
||||||
return! ok next ctx
|
|
||||||
| None -> return! Error.notFound next ctx
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET: /api/listing/search
|
|
||||||
let search : HttpHandler =
|
|
||||||
authorize
|
|
||||||
>=> fun next ctx -> task {
|
|
||||||
let search = ctx.BindQueryString<ListingSearch> ()
|
|
||||||
let! results = Data.Listing.search search (conn ctx)
|
|
||||||
return! json results next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// Handlers for /api/profile routes
|
|
||||||
[<RequireQualifiedAccess>]
|
|
||||||
module 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
|
|
||||||
// is not an error). The "get" handler returns a 404 if a profile is not found.
|
|
||||||
let current : HttpHandler =
|
|
||||||
authorize
|
|
||||||
>=> fun next ctx -> task {
|
|
||||||
match! Data.Profile.findById (currentCitizenId ctx) (conn ctx) with
|
|
||||||
| Some profile -> return! json profile next ctx
|
|
||||||
| None -> return! Successful.NO_CONTENT next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET: /api/profile/get/[id]
|
|
||||||
let get citizenId : HttpHandler =
|
|
||||||
authorize
|
|
||||||
>=> fun next ctx -> task {
|
|
||||||
match! Data.Profile.findById (CitizenId citizenId) (conn ctx) with
|
|
||||||
| Some profile -> return! json profile next ctx
|
|
||||||
| None -> return! Error.notFound next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET: /api/profile/view/[id]
|
|
||||||
let view citizenId : HttpHandler =
|
|
||||||
authorize
|
|
||||||
>=> fun next ctx -> task {
|
|
||||||
let citId = CitizenId citizenId
|
|
||||||
let dbConn = conn ctx
|
|
||||||
match! Data.Profile.findById citId dbConn with
|
|
||||||
| Some profile ->
|
|
||||||
match! Data.Citizen.findById citId dbConn with
|
|
||||||
| Some citizen ->
|
|
||||||
match! Data.Continent.findById profile.continentId dbConn with
|
|
||||||
| Some continent ->
|
|
||||||
return!
|
|
||||||
json {
|
|
||||||
profile = profile
|
|
||||||
citizen = citizen
|
|
||||||
continent = continent
|
|
||||||
} next ctx
|
|
||||||
| None -> return! Error.notFound next ctx
|
|
||||||
| None -> return! Error.notFound next ctx
|
|
||||||
| None -> return! Error.notFound next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET: /api/profile/count
|
|
||||||
let count : HttpHandler =
|
|
||||||
authorize
|
|
||||||
>=> fun next ctx -> task {
|
|
||||||
let! theCount = Data.Profile.count (conn ctx)
|
|
||||||
return! json { count = theCount } next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST: /api/profile/save
|
|
||||||
let save : HttpHandler =
|
|
||||||
authorize
|
|
||||||
>=> fun next ctx -> task {
|
|
||||||
let citizenId = currentCitizenId ctx
|
|
||||||
let dbConn = conn ctx
|
|
||||||
let! form = ctx.BindJsonAsync<ProfileForm>()
|
|
||||||
let! profile = task {
|
|
||||||
match! Data.Profile.findById citizenId dbConn with
|
|
||||||
| Some p -> return p
|
|
||||||
| None -> return { Profile.empty with id = citizenId }
|
|
||||||
}
|
|
||||||
do! Data.Profile.save
|
|
||||||
{ profile with
|
|
||||||
seekingEmployment = form.isSeekingEmployment
|
|
||||||
isPublic = form.isPublic
|
|
||||||
continentId = ContinentId.ofString form.continentId
|
|
||||||
region = form.region
|
|
||||||
remoteWork = form.remoteWork
|
|
||||||
fullTime = form.fullTime
|
|
||||||
biography = Text form.biography
|
|
||||||
lastUpdatedOn = (clock ctx).GetCurrentInstant ()
|
|
||||||
experience = noneIfBlank form.experience |> Option.map Text
|
|
||||||
skills = form.skills
|
|
||||||
|> List.map (fun s ->
|
|
||||||
{ id = match s.id.StartsWith "new" with
|
|
||||||
| true -> SkillId.create ()
|
|
||||||
| false -> SkillId.ofString s.id
|
|
||||||
description = s.description
|
|
||||||
notes = noneIfBlank s.notes
|
|
||||||
})
|
|
||||||
} dbConn
|
|
||||||
do! Data.Citizen.realNameUpdate citizenId (noneIfBlank (Some form.realName)) dbConn
|
|
||||||
return! ok next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
// PATCH: /api/profile/employment-found
|
|
||||||
let employmentFound : HttpHandler =
|
|
||||||
authorize
|
|
||||||
>=> fun next ctx -> task {
|
|
||||||
let dbConn = conn ctx
|
|
||||||
match! Data.Profile.findById (currentCitizenId ctx) dbConn with
|
|
||||||
| Some profile ->
|
|
||||||
do! Data.Profile.save { profile with seekingEmployment = false } dbConn
|
|
||||||
return! ok next ctx
|
|
||||||
| None -> return! Error.notFound next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
// DELETE: /api/profile
|
|
||||||
let delete : HttpHandler =
|
|
||||||
authorize
|
|
||||||
>=> fun next ctx -> task {
|
|
||||||
do! Data.Profile.delete (currentCitizenId ctx) (conn ctx)
|
|
||||||
return! ok next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET: /api/profile/search
|
|
||||||
let search : HttpHandler =
|
|
||||||
authorize
|
|
||||||
>=> fun next ctx -> task {
|
|
||||||
let search = ctx.BindQueryString<ProfileSearch> ()
|
|
||||||
let! results = Data.Profile.search search (conn ctx)
|
|
||||||
return! json results next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET: /api/profile/public-search
|
|
||||||
let publicSearch : HttpHandler =
|
|
||||||
fun next ctx -> task {
|
|
||||||
let search = ctx.BindQueryString<PublicSearch> ()
|
|
||||||
let! results = Data.Profile.publicSearch search (conn ctx)
|
|
||||||
return! json results next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// Handlers for /api/success routes
|
|
||||||
[<RequireQualifiedAccess>]
|
|
||||||
module Success =
|
|
||||||
|
|
||||||
open System
|
|
||||||
|
|
||||||
// GET: /api/success/[id]
|
|
||||||
let get successId : HttpHandler =
|
|
||||||
authorize
|
|
||||||
>=> fun next ctx -> task {
|
|
||||||
match! Data.Success.findById (SuccessId successId) (conn ctx) with
|
|
||||||
| Some story -> return! json story next ctx
|
|
||||||
| None -> return! Error.notFound next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET: /api/success/list
|
|
||||||
let all : HttpHandler =
|
|
||||||
authorize
|
|
||||||
>=> fun next ctx -> task {
|
|
||||||
let! stories = Data.Success.all (conn ctx)
|
|
||||||
return! json stories next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST: /api/success/save
|
|
||||||
let save : HttpHandler =
|
|
||||||
authorize
|
|
||||||
>=> fun next ctx -> task {
|
|
||||||
let citizenId = currentCitizenId ctx
|
|
||||||
let dbConn = conn ctx
|
|
||||||
let now = (clock ctx).GetCurrentInstant ()
|
|
||||||
let! form = ctx.BindJsonAsync<StoryForm> ()
|
|
||||||
let! success = task {
|
|
||||||
match form.id with
|
|
||||||
| "new" ->
|
|
||||||
return Some { id = SuccessId.create ()
|
|
||||||
citizenId = citizenId
|
|
||||||
recordedOn = now
|
|
||||||
fromHere = form.fromHere
|
|
||||||
source = "profile"
|
|
||||||
story = noneIfEmpty form.story |> Option.map Text
|
|
||||||
}
|
|
||||||
| successId ->
|
|
||||||
match! Data.Success.findById (SuccessId.ofString successId) dbConn with
|
|
||||||
| Some story when story.citizenId = citizenId ->
|
|
||||||
return Some { story with
|
|
||||||
fromHere = form.fromHere
|
|
||||||
story = noneIfEmpty form.story |> Option.map Text
|
|
||||||
}
|
|
||||||
| Some _ | None -> return None
|
|
||||||
}
|
|
||||||
match success with
|
|
||||||
| Some story ->
|
|
||||||
do! Data.Success.save story dbConn
|
|
||||||
return! ok next ctx
|
|
||||||
| None -> return! Error.notFound next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
open Giraffe.EndpointRouting
|
|
||||||
|
|
||||||
/// All available endpoints for the application
|
|
||||||
let allEndpoints = [
|
|
||||||
subRoute "/api" [
|
|
||||||
subRoute "/citizen" [
|
|
||||||
GET_HEAD [
|
|
||||||
routef "/log-on/%s" Citizen.logOn
|
|
||||||
routef "/%O" Citizen.get
|
|
||||||
]
|
|
||||||
DELETE [ route "" Citizen.delete ]
|
|
||||||
]
|
|
||||||
GET_HEAD [ route "/continents" Continent.all ]
|
|
||||||
subRoute "/listing" [
|
|
||||||
GET_HEAD [
|
|
||||||
routef "/%O" Listing.get
|
|
||||||
route "/search" Listing.search
|
|
||||||
routef "/%O/view" Listing.view
|
|
||||||
route "s/mine" Listing.mine
|
|
||||||
]
|
|
||||||
PATCH [
|
|
||||||
routef "/%O" Listing.expire
|
|
||||||
]
|
|
||||||
POST [
|
|
||||||
route "s" Listing.add
|
|
||||||
]
|
|
||||||
PUT [
|
|
||||||
routef "/%O" Listing.update
|
|
||||||
]
|
|
||||||
]
|
|
||||||
subRoute "/profile" [
|
|
||||||
GET_HEAD [
|
|
||||||
route "" Profile.current
|
|
||||||
route "/count" Profile.count
|
|
||||||
routef "/%O" Profile.get
|
|
||||||
routef "/%O/view" Profile.view
|
|
||||||
route "/public-search" Profile.publicSearch
|
|
||||||
route "/search" Profile.search
|
|
||||||
]
|
|
||||||
PATCH [ route "/employment-found" Profile.employmentFound ]
|
|
||||||
POST [ route "" Profile.save ]
|
|
||||||
]
|
|
||||||
subRoute "/success" [
|
|
||||||
GET_HEAD [
|
|
||||||
routef "/%O" Success.get
|
|
||||||
route "es" Success.all
|
|
||||||
]
|
|
||||||
POST [ route "" Success.save ]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"Rethink": {
|
|
||||||
"Hostname": "localhost",
|
|
||||||
"Db": "jobsjobsjobs"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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",
|
||||||
|
|||||||
26278
src/JobsJobsJobs/App/package-lock.json
generated
26278
src/JobsJobsJobs/App/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,23 +1,23 @@
|
|||||||
{
|
{
|
||||||
"name": "jobs-jobs-jobs",
|
"name": "jobs-jobs-jobs",
|
||||||
"version": "2.0.0",
|
"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 ../Api && dotnet run -c Debug"
|
|
||||||
},
|
},
|
||||||
"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": "^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"
|
||||||
@@ -25,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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent } from "vue"
|
import { defineComponent, onMounted } from "vue"
|
||||||
|
|
||||||
import "bootstrap/dist/css/bootstrap.min.css"
|
import "bootstrap/dist/css/bootstrap.min.css"
|
||||||
|
|
||||||
import { Citizen } from "./api"
|
import { Citizen } from "./api"
|
||||||
|
import { Mutations, useStore } from "./store"
|
||||||
import AppFooter from "./components/layout/AppFooter.vue"
|
import AppFooter from "./components/layout/AppFooter.vue"
|
||||||
import AppNav from "./components/layout/AppNav.vue"
|
import AppNav from "./components/layout/AppNav.vue"
|
||||||
import AppToaster from "./components/layout/AppToaster.vue"
|
import AppToaster from "./components/layout/AppToaster.vue"
|
||||||
@@ -29,6 +30,10 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
|
onMounted(() => store.commit(Mutations.SetTitle, "Jobs, Jobs, Jobs"))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return "Yes" for true and "No" for false
|
* Return "Yes" for true and "No" for false
|
||||||
*
|
*
|
||||||
@@ -40,13 +45,13 @@ export function yesOrNo (cond : boolean) : string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the display name for a citizen (the first available among real, display, or NAS handle)
|
* Get the display name for a citizen (the first available among real, display, or Mastodon handle)
|
||||||
*
|
*
|
||||||
* @param cit The citizen
|
* @param cit The citizen
|
||||||
* @returns The citizen's display name
|
* @returns The citizen's display name
|
||||||
*/
|
*/
|
||||||
export function citizenName (cit : Citizen) : string {
|
export function citizenName (cit : Citizen) : string {
|
||||||
return cit.realName ?? cit.displayName ?? cit.naUser
|
return cit.realName ?? cit.displayName ?? cit.mastodonUser
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
Citizen,
|
Citizen,
|
||||||
Continent,
|
Continent,
|
||||||
Count,
|
Count,
|
||||||
|
Instance,
|
||||||
Listing,
|
Listing,
|
||||||
ListingExpireForm,
|
ListingExpireForm,
|
||||||
ListingForm,
|
ListingForm,
|
||||||
@@ -25,7 +26,7 @@ import {
|
|||||||
* @param url The partial URL for the API
|
* @param url The partial URL for the API
|
||||||
* @returns A full URL for the API
|
* @returns A full URL for the API
|
||||||
*/
|
*/
|
||||||
const apiUrl = (url : string) : string => `http://localhost:5000/api/${url}`
|
const apiUrl = (url : string) : string => `/api/${url}`
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create request init parameters
|
* Create request init parameters
|
||||||
@@ -100,11 +101,12 @@ export default {
|
|||||||
/**
|
/**
|
||||||
* Log a citizen on
|
* Log a citizen on
|
||||||
*
|
*
|
||||||
* @param code The authorization code from No Agenda Social
|
* @param abbr The abbreviation of the Mastodon instance that issued the code
|
||||||
|
* @param code The authorization code from Mastodon
|
||||||
* @returns The user result, or an error
|
* @returns The user result, or an error
|
||||||
*/
|
*/
|
||||||
logOn: async (code : string) : Promise<LogOnSuccess | string> => {
|
logOn: async (abbr : string, code : string) : Promise<LogOnSuccess | string> => {
|
||||||
const resp = await fetch(apiUrl(`citizen/log-on/${code}`), { method: "GET", mode: "cors" })
|
const resp = await fetch(apiUrl(`citizen/log-on/${abbr}/${code}`), { method: "GET", mode: "cors" })
|
||||||
if (resp.status === 200) return await resp.json() as LogOnSuccess
|
if (resp.status === 200) return await resp.json() as LogOnSuccess
|
||||||
return `Error logging on - ${await resp.text()}`
|
return `Error logging on - ${await resp.text()}`
|
||||||
},
|
},
|
||||||
@@ -141,6 +143,18 @@ export default {
|
|||||||
apiResult<Continent[]>(await fetch(apiUrl("continents"), { method: "GET" }), "retrieving continents")
|
apiResult<Continent[]>(await fetch(apiUrl("continents"), { method: "GET" }), "retrieving continents")
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** API functions for instances */
|
||||||
|
instances: {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all Mastodon instances we support
|
||||||
|
*
|
||||||
|
* @returns All instances, or an error
|
||||||
|
*/
|
||||||
|
all: async () : Promise<Instance[] | string | undefined> =>
|
||||||
|
apiResult<Instance[]>(await fetch(apiUrl("instances"), { method: "GET" }), "retrieving Mastodon instances")
|
||||||
|
},
|
||||||
|
|
||||||
/** API functions for job listings */
|
/** API functions for job listings */
|
||||||
listings: {
|
listings: {
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
export interface Citizen {
|
export interface Citizen {
|
||||||
/** The ID of the user */
|
/** The ID of the user */
|
||||||
id : string
|
id : string
|
||||||
|
/** The abbreviation of the instance where this citizen is based */
|
||||||
|
instance : string
|
||||||
/** The handle by which the user is known on Mastodon */
|
/** The handle by which the user is known on Mastodon */
|
||||||
naUser : string
|
mastodonUser : string
|
||||||
/** The user's display name from Mastodon (updated every login) */
|
/** The user's display name from Mastodon (updated every login) */
|
||||||
displayName : string | undefined
|
displayName : string | undefined
|
||||||
/** The user's real name */
|
/** The user's real name */
|
||||||
@@ -31,6 +33,22 @@ export interface Count {
|
|||||||
count : number
|
count : number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The Mastodon instance data provided via the Jobs, Jobs, Jobs API */
|
||||||
|
export interface Instance {
|
||||||
|
/** The name of the instance */
|
||||||
|
name : string
|
||||||
|
/** The URL for this instance */
|
||||||
|
url : string
|
||||||
|
/** The abbreviation used in the URL to distinguish this instance's return codes */
|
||||||
|
abbr : string
|
||||||
|
/** The client ID (assigned by the Mastodon server) */
|
||||||
|
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 */
|
||||||
export interface Listing {
|
export interface Listing {
|
||||||
/** The ID of the job listing */
|
/** The ID of the job listing */
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
form.container
|
form.container
|
||||||
.row
|
.row
|
||||||
.col.col-xs-12.col-sm-6.col-md-4.col-lg-3
|
.col-xs-12.col-sm-6.col-md-4.col-lg-3
|
||||||
continent-list(v-model="criteria.continentId" topLabel="Any" @update:modelValue="updateContinent")
|
continent-list(v-model="criteria.continentId" topLabel="Any" @update:modelValue="updateContinent")
|
||||||
.col.col-xs-12.col-sm-6.col-lg-3
|
.col-xs-12.col-sm-6.col-lg-3
|
||||||
.form-floating
|
.form-floating
|
||||||
input.form-control(type="text" id="regionSearch" placeholder="(free-form text)" :value="criteria.region"
|
input.form-control(type="text" id="regionSearch" placeholder="(free-form text)" :value="criteria.region"
|
||||||
@input="updateValue('region', $event.target.value)")
|
@input="updateValue('region', $event.target.value)")
|
||||||
label(for="regionSearch") Region
|
label(for="regionSearch") Region
|
||||||
.form-text (free-form text)
|
.form-text (free-form text)
|
||||||
.col.col-xs-12.col-sm-6.col-offset-md-2.col-lg-3.col-offset-lg-0
|
.col-xs-12.col-sm-6.col-offset-md-2.col-lg-3.col-offset-lg-0
|
||||||
label.jjj-label Remote Work Opportunity?
|
label.jjj-label Remote Work Opportunity?
|
||||||
br
|
br
|
||||||
.form-check.form-check-inline
|
.form-check.form-check-inline
|
||||||
@@ -24,13 +24,13 @@ form.container
|
|||||||
input.form-check-input(type="radio" id="remoteNo" name="remoteWork" :checked="criteria.remoteWork === 'no'"
|
input.form-check-input(type="radio" id="remoteNo" name="remoteWork" :checked="criteria.remoteWork === 'no'"
|
||||||
@click="updateValue('remoteWork', 'no')")
|
@click="updateValue('remoteWork', 'no')")
|
||||||
label.form-check-label(for="remoteNo") No
|
label.form-check-label(for="remoteNo") No
|
||||||
.col.col-xs-12.col-sm-6.col-lg-3
|
.col-xs-12.col-sm-6.col-lg-3
|
||||||
.form-floating
|
.form-floating
|
||||||
input.form-control(type="text" id="textSearch" placeholder="(free-form text)" :value="criteria.text"
|
input.form-control(type="text" id="textSearch" placeholder="(free-form text)" :value="criteria.text"
|
||||||
@input="updateValue('text', $event.target.value)")
|
@input="updateValue('text', $event.target.value)")
|
||||||
label(for="textSearch") Job Listing Text
|
label(for="textSearch") Job Listing Text
|
||||||
.form-text (free-form text)
|
.form-text (free-form text)
|
||||||
.row: .col.col-xs-12
|
.row: .col
|
||||||
br
|
br
|
||||||
button.btn.btn-outline-primary(type="submit" @click.prevent="$emit('search')") Search
|
button.btn.btn-outline-primary(type="submit" @click.prevent="$emit('search')") Search
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
<template lang="pug">
|
|
||||||
p(v-if="false")
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { onMounted, watch } from "vue"
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
title: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
/** The name of the application */
|
|
||||||
const appName = "Jobs, Jobs, Jobs"
|
|
||||||
|
|
||||||
/** Set the page title based on the input title attribute */
|
|
||||||
const setTitle = () => {
|
|
||||||
if (props.title === "") {
|
|
||||||
document.title = appName
|
|
||||||
} else {
|
|
||||||
document.title = `${props.title} | ${appName}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(setTitle)
|
|
||||||
|
|
||||||
/** Change the page title when the property changes */
|
|
||||||
watch(() => props.title, setTitle)
|
|
||||||
</script>
|
|
||||||
81
src/JobsJobsJobs/App/src/components/layout/AppLinks.vue
Normal file
81
src/JobsJobsJobs/App/src/components/layout/AppLinks.vue
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<template lang="pug">
|
||||||
|
nav
|
||||||
|
template(v-if="isLoggedOn")
|
||||||
|
router-link(to="/citizen/dashboard" @click="hide") #[icon(:icon="mdiViewDashboardVariant")] Dashboard
|
||||||
|
router-link(to="/help-wanted" @click="hide") #[icon(:icon="mdiNewspaperVariantMultipleOutline")] Help Wanted!
|
||||||
|
router-link(to="/profile/search" @click="hide") #[icon(:icon="mdiViewListOutline")] Employment Profiles
|
||||||
|
router-link(to="/success-story/list" @click="hide") #[icon(:icon="mdiThumbUp")] Success Stories
|
||||||
|
.separator
|
||||||
|
router-link(to="/listings/mine" @click="hide") #[icon(:icon="mdiSignText")] My Job Listings
|
||||||
|
router-link(to="/citizen/profile" @click="hide") #[icon(:icon="mdiPencil")] My Employment Profile
|
||||||
|
.separator
|
||||||
|
router-link(to="/citizen/log-off" @click="hide") #[icon(:icon="mdiLogoutVariant")] Log Off
|
||||||
|
template(v-else)
|
||||||
|
router-link(to="/" @click="hide") #[icon(:icon="mdiHome")] Home
|
||||||
|
router-link(to="/profile/seeking" @click="hide") #[icon(:icon="mdiViewListOutline")] Job Seekers
|
||||||
|
router-link(to="/citizen/log-on" @click="hide") #[icon(:icon="mdiLoginVariant")] Log On
|
||||||
|
router-link(to="/how-it-works" @click="hide") #[icon(:icon="mdiHelpCircleOutline")] How It Works
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue"
|
||||||
|
import { useRouter } from "vue-router"
|
||||||
|
import { Offcanvas } from "bootstrap"
|
||||||
|
import { useStore } from "@/store"
|
||||||
|
import {
|
||||||
|
mdiHelpCircleOutline,
|
||||||
|
mdiHome,
|
||||||
|
mdiLoginVariant,
|
||||||
|
mdiLogoutVariant,
|
||||||
|
mdiNewspaperVariantMultipleOutline,
|
||||||
|
mdiPencil,
|
||||||
|
mdiSignText,
|
||||||
|
mdiThumbUp,
|
||||||
|
mdiViewDashboardVariant,
|
||||||
|
mdiViewListOutline
|
||||||
|
} from "@mdi/js"
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
/** Whether a user is logged in or not */
|
||||||
|
const isLoggedOn = computed(() => store.state.user !== undefined)
|
||||||
|
|
||||||
|
/** The current mobile menu */
|
||||||
|
const menu = computed(() => {
|
||||||
|
const elt = document.getElementById("mobileMenu")
|
||||||
|
return elt ? Offcanvas.getOrCreateInstance(elt) : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Hide the offcanvas menu (if it exists) when a link is clicked */
|
||||||
|
const hide = () => { if (menu.value) menu.value.hide() }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="sass" scoped>
|
||||||
|
path
|
||||||
|
fill: white
|
||||||
|
path:hover
|
||||||
|
fill: black
|
||||||
|
a:link, a:visited
|
||||||
|
text-decoration: none
|
||||||
|
color: white
|
||||||
|
nav > a
|
||||||
|
display: block
|
||||||
|
width: 100%
|
||||||
|
border-radius: .25rem
|
||||||
|
padding: .5rem
|
||||||
|
margin: .5rem 0
|
||||||
|
font-size: 1rem
|
||||||
|
> i
|
||||||
|
vertical-align: top
|
||||||
|
margin-right: 1rem
|
||||||
|
&.router-link-exact-active
|
||||||
|
background-color: rgba(255, 255, 255, .2)
|
||||||
|
&:hover
|
||||||
|
background-color: rgba(255, 255, 255, .5)
|
||||||
|
color: black
|
||||||
|
text-decoration: none
|
||||||
|
nav > div.separator
|
||||||
|
border-bottom: solid 1px rgba(255, 255, 255, .75)
|
||||||
|
height: 1px
|
||||||
|
</style>
|
||||||
@@ -1,88 +1,45 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
aside.collapse.show.p-3
|
#mobileMenu.offcanvas.offcanvas-end(v-if="showMobileMenu" tabindex="-1" aria-labelledby="mobileMenuLabel")
|
||||||
|
.offcanvas-header
|
||||||
|
h5#mobileMenuLabel Menu
|
||||||
|
button.btn-close.text-reset(type="button" data-bs-dismiss="offcanvas" aria-label="Close")
|
||||||
|
.offcanvas-body: app-links
|
||||||
|
aside.collapse.show.p-3(v-else)
|
||||||
p.home-link.pb-3: router-link(to="/") Jobs, Jobs, Jobs
|
p.home-link.pb-3: router-link(to="/") Jobs, Jobs, Jobs
|
||||||
p
|
p
|
||||||
nav
|
app-links
|
||||||
template(v-if="isLoggedOn")
|
|
||||||
router-link(to="/citizen/dashboard") #[icon(:icon="mdiViewDashboardVariant")] Dashboard
|
|
||||||
router-link(to="/help-wanted") #[icon(:icon="mdiNewspaperVariantMultipleOutline")] Help Wanted!
|
|
||||||
router-link(to="/profile/search") #[icon(:icon="mdiViewListOutline")] Employment Profiles
|
|
||||||
router-link(to="/success-story/list") #[icon(:icon="mdiThumbUp")] Success Stories
|
|
||||||
.separator
|
|
||||||
router-link(to="/listings/mine") #[icon(:icon="mdiSignText")] My Job Listings
|
|
||||||
router-link(to="/citizen/profile") #[icon(:icon="mdiPencil")] My Employment Profile
|
|
||||||
.separator
|
|
||||||
router-link(to="/citizen/log-off") #[icon(:icon="mdiLogoutVariant")] Log Off
|
|
||||||
template(v-else)
|
|
||||||
router-link(to="/") #[icon(:icon="mdiHome")] Home
|
|
||||||
router-link(to="/profile/seeking") #[icon(:icon="mdiViewListOutline")] Job Seekers
|
|
||||||
router-link(to="/citizen/log-on") #[icon(:icon="mdiLoginVariant")] Log On
|
|
||||||
router-link(to="/how-it-works") #[icon(:icon="mdiHelpCircleOutline")] How It Works
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue"
|
import { useBreakpoints, breakpointsBootstrapV5 } from "@vueuse/core"
|
||||||
import { useStore } from "@/store"
|
import AppLinks from "./AppLinks.vue"
|
||||||
import {
|
|
||||||
mdiHelpCircleOutline,
|
|
||||||
mdiHome,
|
|
||||||
mdiLoginVariant,
|
|
||||||
mdiLogoutVariant,
|
|
||||||
mdiNewspaperVariantMultipleOutline,
|
|
||||||
mdiPencil,
|
|
||||||
mdiSignText,
|
|
||||||
mdiThumbUp,
|
|
||||||
mdiViewDashboardVariant,
|
|
||||||
mdiViewListOutline
|
|
||||||
} from "@mdi/js"
|
|
||||||
|
|
||||||
const store = useStore()
|
const breakpoints = useBreakpoints(breakpointsBootstrapV5)
|
||||||
|
|
||||||
/** Whether a user is logged in or not */
|
/** Whether the mobile menu or the desktop menu should be shown */
|
||||||
const isLoggedOn = computed(() => store.state.user !== undefined)
|
const showMobileMenu = breakpoints.smaller("md")
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="sass" scoped>
|
<style lang="sass" scoped>
|
||||||
aside
|
aside,
|
||||||
|
#mobileMenu
|
||||||
background-image: linear-gradient(180deg, darkgreen 0%, green 70%)
|
background-image: linear-gradient(180deg, darkgreen 0%, green 70%)
|
||||||
color: white
|
color: white
|
||||||
font-size: 1.2rem
|
font-size: 1.2rem
|
||||||
|
aside
|
||||||
min-height: 100vh
|
min-height: 100vh
|
||||||
width: 250px
|
width: 250px
|
||||||
min-width: 250px
|
min-width: 250px
|
||||||
position: sticky
|
position: sticky
|
||||||
top: 0
|
top: 0
|
||||||
path
|
.home-link
|
||||||
fill: white
|
|
||||||
path:hover
|
|
||||||
fill: black
|
|
||||||
a:link, a:visited
|
|
||||||
text-decoration: none
|
|
||||||
color: white
|
|
||||||
// font-weight: 500
|
|
||||||
.home-link
|
|
||||||
font-size: 1.2rem
|
font-size: 1.2rem
|
||||||
text-align: center
|
text-align: center
|
||||||
background-color: rgba(0, 0, 0, .4)
|
background-color: rgba(0, 0, 0, .4)
|
||||||
margin: -1rem
|
margin: -1rem
|
||||||
padding: 1rem
|
padding: 1rem
|
||||||
nav > a
|
a:link,
|
||||||
display: block
|
a:visited
|
||||||
width: 100%
|
|
||||||
border-radius: .25rem
|
|
||||||
padding: .5rem
|
|
||||||
margin: .5rem 0
|
|
||||||
font-size: 1rem
|
|
||||||
> i
|
|
||||||
vertical-align: top
|
|
||||||
margin-right: 1rem
|
|
||||||
&.router-link-exact-active
|
|
||||||
background-color: rgba(255, 255, 255, .2)
|
|
||||||
&:hover
|
|
||||||
background-color: rgba(255, 255, 255, .5)
|
|
||||||
color: black
|
|
||||||
text-decoration: none
|
text-decoration: none
|
||||||
nav > div.separator
|
color: white
|
||||||
border-bottom: solid 1px rgba(255, 255, 255, .75)
|
|
||||||
height: 1px
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,16 +1,38 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
nav.navbar.navbar-light.bg-light
|
nav.navbar.navbar-dark(v-if="showMobileHeader")
|
||||||
|
span.navbar-text: router-link(to="/") Jobs, Jobs, Jobs
|
||||||
|
button.btn(data-bs-toggle="offcanvas" data-bs-target="#mobileMenu" aria-controls="mobileMenu")
|
||||||
|
icon(:icon="mdiMenu")
|
||||||
|
nav.navbar.navbar-light.bg-light(v-else)
|
||||||
span
|
span
|
||||||
span.navbar-text.
|
span.navbar-text.
|
||||||
(…and Jobs – #[audio-clip(clip="pelosi-jobs") Let’s Vote for Jobs!])
|
(…and Jobs – #[audio-clip(clip="pelosi-jobs") Let’s Vote for Jobs!])
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { mdiMenu } from "@mdi/js"
|
||||||
|
import { useBreakpoints, breakpointsBootstrapV5 } from "@vueuse/core"
|
||||||
|
|
||||||
import AudioClip from "@/components/AudioClip.vue"
|
import AudioClip from "@/components/AudioClip.vue"
|
||||||
|
|
||||||
|
const breakpoints = useBreakpoints(breakpointsBootstrapV5)
|
||||||
|
|
||||||
|
/** Whether to show the mobile or desktop header */
|
||||||
|
const showMobileHeader = breakpoints.smaller("md")
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="sass" scoped>
|
<style lang="sass" scoped>
|
||||||
.navbar-text
|
.navbar-dark
|
||||||
font-style: italic
|
background-image: linear-gradient(0deg, green 0%, darkgreen 70%)
|
||||||
|
padding-left: 1rem
|
||||||
padding-right: 1rem
|
padding-right: 1rem
|
||||||
|
button
|
||||||
|
padding: 0
|
||||||
|
.navbar-text
|
||||||
|
font-weight: bold
|
||||||
|
color: white
|
||||||
|
.navbar-light
|
||||||
|
.navbar-text
|
||||||
|
font-style: italic
|
||||||
|
padding: 0 1rem 0 0
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
form.container
|
form.container
|
||||||
.row
|
.row
|
||||||
.col.col-xs-12.col-sm-6.col-md-4.col-lg-3
|
.col-xs-12.col-sm-6.col-md-4.col-lg-3
|
||||||
continent-list(v-model="criteria.continentId" topLabel="Any" @update:modelValue="updateContinent")
|
continent-list(v-model="criteria.continentId" topLabel="Any" @update:modelValue="updateContinent")
|
||||||
.col.col-xs-12.col-sm-6.col-md-4.col-lg-3
|
.col-xs-12.col-sm-6.col-md-4.col-lg-3
|
||||||
.form-floating
|
.form-floating
|
||||||
input.form-control.form-control-sm(type="text" id="region" placeholder="(free-form text)"
|
input.form-control.form-control-sm(type="text" id="region" placeholder="(free-form text)"
|
||||||
:value="criteria.region" @input="updateValue('region', $event.target.value)")
|
:value="criteria.region" @input="updateValue('region', $event.target.value)")
|
||||||
label(for="region") Region
|
label(for="region") Region
|
||||||
.form-text (free-form text)
|
.form-text (free-form text)
|
||||||
.col.col-xs-12.col-sm-6.col-offset-md-2.col-lg-3.col-offset-lg-0
|
.col-xs-12.col-sm-6.col-offset-md-2.col-lg-3.col-offset-lg-0
|
||||||
label.jjj-label Seeking Remote Work?
|
label.jjj-label Seeking Remote Work?
|
||||||
br
|
br
|
||||||
.form-check.form-check-inline
|
.form-check.form-check-inline
|
||||||
@@ -24,13 +24,13 @@ form.container
|
|||||||
input.form-check-input(type="radio" id="remoteNo" name="remoteWork" :checked="criteria.remoteWork === 'no'"
|
input.form-check-input(type="radio" id="remoteNo" name="remoteWork" :checked="criteria.remoteWork === 'no'"
|
||||||
@click="updateValue('remoteWork', 'no')")
|
@click="updateValue('remoteWork', 'no')")
|
||||||
label.form-check-label(for="remoteNo") No
|
label.form-check-label(for="remoteNo") No
|
||||||
.col.col-xs-12.col-sm-6.col-lg-3
|
.col-xs-12.col-sm-6.col-lg-3
|
||||||
.form-floating
|
.form-floating
|
||||||
input.form-control.form-control-sm(type="text" id="skillSearch" placeholder="(free-form text)"
|
input.form-control.form-control-sm(type="text" id="skillSearch" placeholder="(free-form text)"
|
||||||
:value="criteria.skill" @input="updateValue('skill', $event.target.value)")
|
:value="criteria.skill" @input="updateValue('skill', $event.target.value)")
|
||||||
label(for="skillSearch") Skill
|
label(for="skillSearch") Skill
|
||||||
.form-text (free-form text)
|
.form-text (free-form text)
|
||||||
.row: .col.col-xs-12
|
.row: .col
|
||||||
br
|
br
|
||||||
button.btn.btn-outline-primary(type="submit" @click.prevent="$emit('search')") Search
|
button.btn.btn-outline-primary(type="submit" @click.prevent="$emit('search')") Search
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
form.container
|
form.container
|
||||||
.row
|
.row
|
||||||
.col.col-xs-12.col-sm-6.col-md-4.col-lg-3
|
.col-xs-12.col-sm-6.col-md-4.col-lg-3
|
||||||
continent-list(v-model="criteria.continentId" topLabel="Any" @update:modelValue="updateContinent")
|
continent-list(v-model="criteria.continentId" topLabel="Any" @update:modelValue="updateContinent")
|
||||||
.col.col-xs-12.col-sm-6.col-offset-md-2.col-lg-3.col-offset-lg-0
|
.col-xs-12.col-sm-6.col-offset-md-2.col-lg-3.col-offset-lg-0
|
||||||
label.jjj-label Seeking Remote Work?
|
label.jjj-label Seeking Remote Work?
|
||||||
br
|
br
|
||||||
.form-check.form-check-inline
|
.form-check.form-check-inline
|
||||||
@@ -18,19 +18,19 @@ form.container
|
|||||||
input.form-check-input(type="radio" id="remoteNo" name="remoteWork" :checked="criteria.remoteWork === 'no'"
|
input.form-check-input(type="radio" id="remoteNo" name="remoteWork" :checked="criteria.remoteWork === 'no'"
|
||||||
@click="updateValue('remoteWork', 'no')")
|
@click="updateValue('remoteWork', 'no')")
|
||||||
label.form-check-label(for="remoteNo") No
|
label.form-check-label(for="remoteNo") No
|
||||||
.col.col-xs-12.col-sm-6.col-lg-3
|
.col-xs-12.col-sm-6.col-lg-3
|
||||||
.form-floating
|
.form-floating
|
||||||
input.form-control(type="text" id="skillSearch" placeholder="(free-form text)" :value="criteria.skill"
|
input.form-control(type="text" id="skillSearch" placeholder="(free-form text)" :value="criteria.skill"
|
||||||
@input="updateValue('skill', $event.target.value)")
|
@input="updateValue('skill', $event.target.value)")
|
||||||
label(for="skillSearch") Skill
|
label(for="skillSearch") Skill
|
||||||
.form-text (free-form text)
|
.form-text (free-form text)
|
||||||
.col.col-xs-12.col-sm-6.col-lg-3
|
.col-xs-12.col-sm-6.col-lg-3
|
||||||
.form-floating
|
.form-floating
|
||||||
input.form-control(type="text" id="bioSearch" placeholder="(free-form text)" :value="criteria.bioExperience"
|
input.form-control(type="text" id="bioSearch" placeholder="(free-form text)" :value="criteria.bioExperience"
|
||||||
@input="updateValue('bioExperience', $event.target.value)")
|
@input="updateValue('bioExperience', $event.target.value)")
|
||||||
label(for="bioSearch") Bio / Experience
|
label(for="bioSearch") Bio / Experience
|
||||||
.form-text (free-form text)
|
.form-text (free-form text)
|
||||||
.row: .col.col-xs-12
|
.row: .col
|
||||||
br
|
br
|
||||||
button.btn.btn-outline-primary(type="submit" @click.prevent="$emit('search')") Search
|
button.btn.btn-outline-primary(type="submit" @click.prevent="$emit('search')") Search
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
.row.pb-3
|
.row.pb-3
|
||||||
.col.col-xs-2.col-md-1.align-self-center
|
.col-xs-2.col-md-1.align-self-center
|
||||||
button.btn.btn-sm.btn-outline-danger.rounded-pill(title="Delete" @click.prevent="$emit('remove')") −
|
button.btn.btn-sm.btn-outline-danger.rounded-pill(title="Delete" @click.prevent="$emit('remove')") −
|
||||||
.col.col-xs-10.col-md-6
|
.col-xs-10.col-md-6
|
||||||
.form-floating
|
.form-floating
|
||||||
input.form-control(type="text" :id="`skillDesc${skill.id}`" maxlength="100"
|
input.form-control(type="text" :id="`skillDesc${skill.id}`" maxlength="100"
|
||||||
placeholder="A skill (language, design technique, process, etc.)" :value="skill.description"
|
placeholder="A skill (language, design technique, process, etc.)" :value="skill.description"
|
||||||
@input="updateValue('description', $event.target.value)")
|
@input="updateValue('description', $event.target.value)")
|
||||||
label.jjj-label(:for="`skillDesc${skill.id}`") Skill
|
label.jjj-label(:for="`skillDesc${skill.id}`") Skill
|
||||||
.form-text A skill (language, design technique, process, etc.)
|
.form-text A skill (language, design technique, process, etc.)
|
||||||
.col.col-xs-12.col-md-5
|
.col-xs-12.col-md-5
|
||||||
.form-floating
|
.form-floating
|
||||||
input.form-control(type="text" :id="`skillNotes${skill.id}`" maxlength="100"
|
input.form-control(type="text" :id="`skillNotes${skill.id}`" maxlength="100"
|
||||||
placeholder="A further description of the skill (100 characters max)" :value="skill.notes"
|
placeholder="A further description of the skill (100 characters max)" :value="skill.notes"
|
||||||
|
|||||||
@@ -3,13 +3,11 @@ import App from "./App.vue"
|
|||||||
import router from "./router"
|
import router from "./router"
|
||||||
import store, { key } from "./store"
|
import store, { key } from "./store"
|
||||||
import Icon from "./components/Icon.vue"
|
import Icon from "./components/Icon.vue"
|
||||||
import PageTitle from "./components/PageTitle.vue"
|
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
.use(router)
|
.use(router)
|
||||||
.use(store, key)
|
.use(store, key)
|
||||||
|
|
||||||
app.component("Icon", Icon)
|
app.component("Icon", Icon)
|
||||||
app.component("PageTitle", PageTitle)
|
|
||||||
|
|
||||||
app.mount("#app")
|
app.mount("#app")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -3,14 +3,13 @@ import {
|
|||||||
createWebHistory,
|
createWebHistory,
|
||||||
RouteLocationNormalized,
|
RouteLocationNormalized,
|
||||||
RouteLocationNormalizedLoaded,
|
RouteLocationNormalizedLoaded,
|
||||||
RouteRecordName,
|
|
||||||
RouteRecordRaw
|
RouteRecordRaw
|
||||||
} from "vue-router"
|
} from "vue-router"
|
||||||
import store from "@/store"
|
import store, { Mutations } from "@/store"
|
||||||
import Home from "@/views/Home.vue"
|
import Home from "@/views/Home.vue"
|
||||||
import LogOn from "@/views/citizen/LogOn.vue"
|
import LogOn from "@/views/citizen/LogOn.vue"
|
||||||
|
|
||||||
/** The URL to which the user should be pointed once they have authorized with NAS */
|
/** The URL to which the user should be pointed once they have authorized with Mastodon */
|
||||||
export const AFTER_LOG_ON_URL = "jjj-after-log-on-url"
|
export const AFTER_LOG_ON_URL = "jjj-after-log-on-url"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,124 +28,141 @@ const routes: Array<RouteRecordRaw> = [
|
|||||||
{
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
name: "Home",
|
name: "Home",
|
||||||
component: Home
|
component: Home,
|
||||||
|
meta: { title: "Welcome!" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/how-it-works",
|
path: "/how-it-works",
|
||||||
name: "HowItWorks",
|
name: "HowItWorks",
|
||||||
component: () => import(/* webpackChunkName: "help" */ "../views/HowItWorks.vue")
|
component: () => import(/* webpackChunkName: "help" */ "../views/HowItWorks.vue"),
|
||||||
|
meta: { title: "How It Works" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/privacy-policy",
|
path: "/privacy-policy",
|
||||||
name: "PrivacyPolicy",
|
name: "PrivacyPolicy",
|
||||||
component: () => import(/* webpackChunkName: "legal" */ "../views/PrivacyPolicy.vue")
|
component: () => import(/* webpackChunkName: "legal" */ "../views/PrivacyPolicy.vue"),
|
||||||
|
meta: { title: "Privacy Policy" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/terms-of-service",
|
path: "/terms-of-service",
|
||||||
name: "TermsOfService",
|
name: "TermsOfService",
|
||||||
component: () => import(/* webpackChunkName: "legal" */ "../views/TermsOfService.vue")
|
component: () => import(/* webpackChunkName: "legal" */ "../views/TermsOfService.vue"),
|
||||||
|
meta: { title: "Terms of Service" }
|
||||||
},
|
},
|
||||||
// Citizen URLs
|
// Citizen URLs
|
||||||
{
|
{
|
||||||
path: "/citizen/log-on",
|
path: "/citizen/log-on",
|
||||||
name: "LogOn",
|
name: "LogOn",
|
||||||
component: LogOn
|
component: LogOn,
|
||||||
|
meta: { title: "Log On" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/citizen/authorized",
|
path: "/citizen/:abbr/authorized",
|
||||||
name: "CitizenAuthorized",
|
name: "CitizenAuthorized",
|
||||||
component: () => import(/* webpackChunkName: "dashboard" */ "../views/citizen/Authorized.vue")
|
component: () => import(/* webpackChunkName: "dashboard" */ "../views/citizen/Authorized.vue"),
|
||||||
|
meta: { title: "Logging On" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/citizen/dashboard",
|
path: "/citizen/dashboard",
|
||||||
name: "Dashboard",
|
name: "Dashboard",
|
||||||
component: () => import(/* webpackChunkName: "dashboard" */ "../views/citizen/Dashboard.vue")
|
component: () => import(/* webpackChunkName: "dashboard" */ "../views/citizen/Dashboard.vue"),
|
||||||
|
meta: { auth: true, title: "Dashboard" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/citizen/profile",
|
path: "/citizen/profile",
|
||||||
name: "EditProfile",
|
name: "EditProfile",
|
||||||
component: () => import(/* webpackChunkName: "profedit" */ "../views/citizen/EditProfile.vue")
|
component: () => import(/* webpackChunkName: "profedit" */ "../views/citizen/EditProfile.vue"),
|
||||||
|
meta: { auth: true, title: "Edit Profile" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/citizen/log-off",
|
path: "/citizen/log-off",
|
||||||
name: "LogOff",
|
name: "LogOff",
|
||||||
component: () => import(/* webpackChunkName: "logoff" */ "../views/citizen/LogOff.vue")
|
component: () => import(/* webpackChunkName: "logoff" */ "../views/citizen/LogOff.vue"),
|
||||||
|
meta: { auth: true, title: "Logging Off" }
|
||||||
},
|
},
|
||||||
// Job Listing URLs
|
// Job Listing URLs
|
||||||
{
|
{
|
||||||
path: "/help-wanted",
|
path: "/help-wanted",
|
||||||
name: "HelpWanted",
|
name: "HelpWanted",
|
||||||
component: () => import(/* webpackChunkName: "joblist" */ "../views/listing/HelpWanted.vue")
|
component: () => import(/* webpackChunkName: "joblist" */ "../views/listing/HelpWanted.vue"),
|
||||||
|
meta: { auth: true, title: "Help Wanted" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/listing/:id/edit",
|
path: "/listing/:id/edit",
|
||||||
name: "EditListing",
|
name: "EditListing",
|
||||||
component: () => import(/* webpackChunkName: "jobedit" */ "../views/listing/ListingEdit.vue")
|
component: () => import(/* webpackChunkName: "jobedit" */ "../views/listing/ListingEdit.vue"),
|
||||||
|
meta: { auth: true, title: "Edit Job Listing" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/listing/:id/expire",
|
path: "/listing/:id/expire",
|
||||||
name: "ExpireListing",
|
name: "ExpireListing",
|
||||||
component: () => import(/* webpackChunkName: "jobedit" */ "../views/listing/ListingExpire.vue")
|
component: () => import(/* webpackChunkName: "jobedit" */ "../views/listing/ListingExpire.vue"),
|
||||||
|
meta: { auth: true, title: "Expire Job Listing" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/listing/:id/view",
|
path: "/listing/:id/view",
|
||||||
name: "ViewListing",
|
name: "ViewListing",
|
||||||
component: () => import(/* webpackChunkName: "joblist" */ "../views/listing/ListingView.vue")
|
component: () => import(/* webpackChunkName: "joblist" */ "../views/listing/ListingView.vue"),
|
||||||
|
meta: { auth: true, title: "Loading Job Listing..." }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/listings/mine",
|
path: "/listings/mine",
|
||||||
name: "MyListings",
|
name: "MyListings",
|
||||||
component: () => import(/* webpackChunkName: "joblist" */ "../views/listing/MyListings.vue")
|
component: () => import(/* webpackChunkName: "joblist" */ "../views/listing/MyListings.vue"),
|
||||||
|
meta: { auth: true, title: "My Job Listings" }
|
||||||
},
|
},
|
||||||
// Profile URLs
|
// Profile URLs
|
||||||
{
|
{
|
||||||
path: "/profile/:id/view",
|
path: "/profile/:id/view",
|
||||||
name: "ViewProfile",
|
name: "ViewProfile",
|
||||||
component: () => import(/* webpackChunkName: "profview" */ "../views/profile/ProfileView.vue")
|
component: () => import(/* webpackChunkName: "profview" */ "../views/profile/ProfileView.vue"),
|
||||||
|
meta: { auth: true, title: "Loading Profile..." }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/profile/search",
|
path: "/profile/search",
|
||||||
name: "SearchProfiles",
|
name: "SearchProfiles",
|
||||||
component: () => import(/* webpackChunkName: "profview" */ "../views/profile/ProfileSearch.vue")
|
component: () => import(/* webpackChunkName: "profview" */ "../views/profile/ProfileSearch.vue"),
|
||||||
|
meta: { auth: true, title: "Search Profiles" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/profile/seeking",
|
path: "/profile/seeking",
|
||||||
name: "PublicSearchProfiles",
|
name: "PublicSearchProfiles",
|
||||||
component: () => import(/* webpackChunkName: "seeking" */ "../views/profile/Seeking.vue")
|
component: () => import(/* webpackChunkName: "seeking" */ "../views/profile/Seeking.vue"),
|
||||||
|
meta: { auth: false, title: "People Seeking Work" }
|
||||||
},
|
},
|
||||||
// "So Long" URLs
|
// "So Long" URLs
|
||||||
{
|
{
|
||||||
path: "/so-long/options",
|
path: "/so-long/options",
|
||||||
name: "DeletionOptions",
|
name: "DeletionOptions",
|
||||||
component: () => import(/* webpackChunkName: "so-long" */ "../views/so-long/DeletionOptions.vue")
|
component: () => import(/* webpackChunkName: "so-long" */ "../views/so-long/DeletionOptions.vue"),
|
||||||
|
meta: { auth: true, title: "Account Deletion Options" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/so-long/success",
|
path: "/so-long/success/:abbr",
|
||||||
name: "DeletionSuccess",
|
name: "DeletionSuccess",
|
||||||
component: () => import(/* webpackChunkName: "so-long" */ "../views/so-long/DeletionSuccess.vue")
|
component: () => import(/* webpackChunkName: "so-long" */ "../views/so-long/DeletionSuccess.vue"),
|
||||||
|
meta: { auth: false, title: "Account Deletion Success" }
|
||||||
},
|
},
|
||||||
// Success Story URLs
|
// Success Story URLs
|
||||||
{
|
{
|
||||||
path: "/success-story/list",
|
path: "/success-story/list",
|
||||||
name: "ListStories",
|
name: "ListStories",
|
||||||
component: () => import(/* webpackChunkName: "success" */ "../views/success-story/StoryList.vue")
|
component: () => import(/* webpackChunkName: "success" */ "../views/success-story/StoryList.vue"),
|
||||||
|
meta: { auth: false, title: "Success Stories" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/success-story/:id/edit",
|
path: "/success-story/:id/edit",
|
||||||
name: "EditStory",
|
name: "EditStory",
|
||||||
component: () => import(/* webpackChunkName: "succedit" */ "../views/success-story/StoryEdit.vue")
|
component: () => import(/* webpackChunkName: "succedit" */ "../views/success-story/StoryEdit.vue"),
|
||||||
|
meta: { auth: false, title: "Edit Success Story" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/success-story/:id/view",
|
path: "/success-story/:id/view",
|
||||||
name: "ViewStory",
|
name: "ViewStory",
|
||||||
component: () => import(/* webpackChunkName: "success" */ "../views/success-story/StoryView.vue")
|
component: () => import(/* webpackChunkName: "success" */ "../views/success-story/StoryView.vue"),
|
||||||
|
meta: { auth: false, title: "Success Story" }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
/** The routes that do not require logins */
|
|
||||||
const publicRoutes : Array<RouteRecordName> = [
|
|
||||||
"Home", "HowItWorks", "PrivacyPolicy", "TermsOfService", "LogOn", "CitizenAuthorized", "PublicSearchProfiles",
|
|
||||||
"DeletionSuccess"
|
|
||||||
]
|
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(process.env.BASE_URL),
|
history: createWebHistory(process.env.BASE_URL),
|
||||||
@@ -158,11 +174,12 @@ const router = createRouter({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
router.beforeEach((to : RouteLocationNormalized, from : RouteLocationNormalized) =>{
|
router.beforeEach((to : RouteLocationNormalized, from : RouteLocationNormalized) => {
|
||||||
if (store.state.user === undefined && !publicRoutes.includes(to.name ?? "")) {
|
if (store.state.user === undefined && (to.meta.auth || false)) {
|
||||||
window.localStorage.setItem(AFTER_LOG_ON_URL, to.fullPath)
|
window.localStorage.setItem(AFTER_LOG_ON_URL, to.fullPath)
|
||||||
return "/citizen/log-on"
|
return "/citizen/log-on"
|
||||||
}
|
}
|
||||||
|
store.commit(Mutations.SetTitle, to.meta.title ?? "")
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
8
src/JobsJobsJobs/App/src/store/actions.ts
Normal file
8
src/JobsJobsJobs/App/src/store/actions.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/** Logs a user on to Jobs, Jobs, Jobs */
|
||||||
|
export const LogOn = "logOn"
|
||||||
|
|
||||||
|
/** Ensures that the continent list in the state has been populated */
|
||||||
|
export const EnsureContinents = "ensureContinents"
|
||||||
|
|
||||||
|
/** Ensures that the Mastodon instance list in the state has been populated */
|
||||||
|
export const EnsureInstances = "ensureInstances"
|
||||||
@@ -1,15 +1,22 @@
|
|||||||
|
import { useTitle } from "@vueuse/core"
|
||||||
import { InjectionKey } from "vue"
|
import { InjectionKey } from "vue"
|
||||||
import { createStore, Store, useStore as baseUseStore } from "vuex"
|
import { createStore, Store, useStore as baseUseStore } from "vuex"
|
||||||
import api, { Continent, LogOnSuccess } from "../api"
|
import api, { Continent, Instance, LogOnSuccess } from "../api"
|
||||||
|
import * as Actions from "./actions"
|
||||||
|
import * as Mutations from "./mutations"
|
||||||
|
|
||||||
/** The state tracked by the application */
|
/** The state tracked by the application */
|
||||||
export interface State {
|
export interface State {
|
||||||
|
/** The document's current title */
|
||||||
|
pageTitle : string
|
||||||
/** The currently logged-on user */
|
/** The currently logged-on user */
|
||||||
user: LogOnSuccess | undefined
|
user : LogOnSuccess | undefined
|
||||||
/** The state of the log on process */
|
/** The state of the log on process */
|
||||||
logOnState: string
|
logOnState : string
|
||||||
/** All continents (use `ensureContinents` action) */
|
/** All continents (use `ensureContinents` action) */
|
||||||
continents: Continent[]
|
continents : Continent[]
|
||||||
|
/** All instances (use `ensureInstances` action) */
|
||||||
|
instances : Instance[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An injection key to identify this state with Vue */
|
/** An injection key to identify this state with Vue */
|
||||||
@@ -20,47 +27,63 @@ export function useStore () : Store<State> {
|
|||||||
return baseUseStore(key)
|
return baseUseStore(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The application name */
|
||||||
|
const appName = "Jobs, Jobs, Jobs"
|
||||||
|
|
||||||
export default createStore({
|
export default createStore({
|
||||||
state: () : State => {
|
state: () : State => {
|
||||||
return {
|
return {
|
||||||
|
pageTitle: "",
|
||||||
user: undefined,
|
user: undefined,
|
||||||
logOnState: "<em>Welcome back! Verifying your No Agenda Social account…</em>",
|
logOnState: "<em>Welcome back!</em>",
|
||||||
continents: []
|
continents: [],
|
||||||
|
instances: []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mutations: {
|
mutations: {
|
||||||
setUser (state, user : LogOnSuccess) {
|
[Mutations.SetTitle]: (state, title : string) => {
|
||||||
state.user = user
|
state.pageTitle = title === "" ? appName : `${title} | ${appName}`
|
||||||
|
useTitle(state.pageTitle)
|
||||||
},
|
},
|
||||||
clearUser (state) {
|
[Mutations.SetUser]: (state, user : LogOnSuccess) => { state.user = user },
|
||||||
state.user = undefined
|
[Mutations.ClearUser]: (state) => { state.user = undefined },
|
||||||
},
|
[Mutations.SetLogOnState]: (state, message : string) => { state.logOnState = message },
|
||||||
setLogOnState (state, message : string) {
|
[Mutations.SetContinents]: (state, continents : Continent[]) => { state.continents = continents },
|
||||||
state.logOnState = message
|
[Mutations.SetInstances]: (state, instances : Instance[]) => { state.instances = instances }
|
||||||
},
|
|
||||||
setContinents (state, continents : Continent[]) {
|
|
||||||
state.continents = continents
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
async logOn ({ commit }, code: string) {
|
[Actions.LogOn]: async ({ commit }, { abbr, code }) => {
|
||||||
const logOnResult = await api.citizen.logOn(code)
|
const logOnResult = await api.citizen.logOn(abbr, code)
|
||||||
if (typeof logOnResult === "string") {
|
if (typeof logOnResult === "string") {
|
||||||
commit("setLogOnState", logOnResult)
|
commit(Mutations.SetLogOnState, logOnResult)
|
||||||
} else {
|
} else {
|
||||||
commit("setUser", logOnResult)
|
commit(Mutations.SetUser, logOnResult)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async ensureContinents ({ state, commit }) {
|
[Actions.EnsureContinents]: async ({ state, commit }) => {
|
||||||
if (state.continents.length > 0) return
|
if (state.continents.length > 0) return
|
||||||
const theSeven = await api.continent.all()
|
const theSeven = await api.continent.all()
|
||||||
if (typeof theSeven === "string") {
|
if (typeof theSeven === "string") {
|
||||||
console.error(theSeven)
|
console.error(theSeven)
|
||||||
} else {
|
} else {
|
||||||
commit("setContinents", theSeven)
|
commit(Mutations.SetContinents, theSeven)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[Actions.EnsureInstances]: async ({ state, commit }) => {
|
||||||
|
if (state.instances.length > 0) return
|
||||||
|
const instResp = await api.instances.all()
|
||||||
|
if (typeof instResp === "string") {
|
||||||
|
console.error(instResp)
|
||||||
|
} else if (typeof instResp === "undefined") {
|
||||||
|
console.error("No instances were found; this should not happen")
|
||||||
|
} else {
|
||||||
|
commit(Mutations.SetInstances, instResp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modules: {
|
modules: {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export * as Actions from "./actions"
|
||||||
|
export * as Mutations from "./mutations"
|
||||||
|
|||||||
17
src/JobsJobsJobs/App/src/store/mutations.ts
Normal file
17
src/JobsJobsJobs/App/src/store/mutations.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/** Set the page title */
|
||||||
|
export const SetTitle = "setTitle"
|
||||||
|
|
||||||
|
/** Set the logged-on user */
|
||||||
|
export const SetUser = "setUser"
|
||||||
|
|
||||||
|
/** Clear the logged-on user */
|
||||||
|
export const ClearUser = "clearUser"
|
||||||
|
|
||||||
|
/** Set the status of the current log on action */
|
||||||
|
export const SetLogOnState = "setLogOnState"
|
||||||
|
|
||||||
|
/** Set the list of continents */
|
||||||
|
export const SetContinents = "setContinents"
|
||||||
|
|
||||||
|
/** Set the list of Mastodon instances */
|
||||||
|
export const SetInstances = "setInstances"
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
article
|
article
|
||||||
page-title(title="Welcome!")
|
|
||||||
p
|
p
|
||||||
p.
|
p.
|
||||||
Welcome to Jobs, Jobs, Jobs (AKA No Agenda Careers), where citizens of Gitmo Nation can assist one another in
|
Welcome to Jobs, Jobs, Jobs (AKA No Agenda Careers), where citizens of Gitmo Nation can assist one another in
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
article
|
article
|
||||||
page-title(title="How It Works")
|
|
||||||
h3 How It Works
|
h3 How It Works
|
||||||
h5.pb-3.text-muted: em Last Updated August 29#[sup th], 2021
|
h5.pb-3.text-muted: em Last Updated August 29#[sup th], 2021
|
||||||
p: em.
|
p: em.
|
||||||
@@ -21,8 +20,8 @@ article
|
|||||||
p.
|
p.
|
||||||
Clicking the #[span.link View] link on a listing brings up the full view page for a listing. This page displays all
|
Clicking the #[span.link View] link on a listing brings up the full view page for a listing. This page displays all
|
||||||
of the information from the search results, along with the citizen who posted it, and the full details of the job.
|
of the information from the search results, along with the citizen who posted it, and the full details of the job.
|
||||||
The citizen’s name is a link to their profile page at No Agenda Social; you can use that to get their handle,
|
The citizen’s name is a link to their profile page at their Mastodon instance; you can use that to get their
|
||||||
and use NAS’s communication facilites to inquire about the position.
|
handle, and use Mastodon’s communication facilites to inquire about the position.
|
||||||
p: em.text-muted.
|
p: em.text-muted.
|
||||||
(If you know of a way to construct a link to Mastodon that would start a direct message, please reach out;
|
(If you know of a way to construct a link to Mastodon that would start a direct message, please reach out;
|
||||||
I’ve searched and searched, and asked NAS, but have not yet determined how to do that.)
|
I’ve searched and searched, and asked NAS, but have not yet determined how to do that.)
|
||||||
@@ -43,9 +42,9 @@ article
|
|||||||
The #[span.link My Job Listings] page will show you all of your active job listings just below the
|
The #[span.link My Job Listings] page will show you all of your active job listings just below the
|
||||||
#[span.button Add a Job Listing] button. Within this table, you can edit the listing, view it, or expire it (more on
|
#[span.button Add a Job Listing] button. Within this table, you can edit the listing, view it, or expire it (more on
|
||||||
that below). The #[span.link View] link will show you the job listing just as other users will see it. You can share
|
that below). The #[span.link View] link will show you the job listing just as other users will see it. You can share
|
||||||
the link from your browser over on No Agenda Social, and those who click on it will be able to view it. (Existing
|
the link from your browser on any No Agenda-affiliated Mastodon instance, and those who click on it will be able to
|
||||||
users of Jobs, Jobs, Jobs will go right to it; others will need to authorize this site’s access, but then they
|
view it. (Existing users of Jobs, Jobs, Jobs will go right to it; others will need to authorize this site’s
|
||||||
will get there as well.)
|
access, but then they will get there as well.)
|
||||||
|
|
||||||
h5 Expire a Job Listing
|
h5 Expire a Job Listing
|
||||||
p.
|
p.
|
||||||
@@ -68,7 +67,7 @@ article
|
|||||||
The #[span.link Employment Profiles] link at the side allows you to search for profiles by continent, the
|
The #[span.link Employment Profiles] link at the side allows you to search for profiles by continent, the
|
||||||
citizen’s desire for remote work, a skill, or any text in their professional biography and experience. If you
|
citizen’s desire for remote work, a skill, or any text in their professional biography and experience. If you
|
||||||
find someone with whom you’d like to discuss potential opportunities, the name at the top of the profile links
|
find someone with whom you’d like to discuss potential opportunities, the name at the top of the profile links
|
||||||
to their No Agenda Social account, where you can use its features to get in touch.
|
to their Mastodon profile, where you can use its features to get in touch.
|
||||||
|
|
||||||
hr
|
hr
|
||||||
|
|
||||||
@@ -76,8 +75,8 @@ article
|
|||||||
p.
|
p.
|
||||||
The employment profile is your résumé, visible to other citizens here. It also allows you to specify
|
The employment profile is your résumé, visible to other citizens here. It also allows you to specify
|
||||||
your real name, if you so desire; if that is filled in, that is how you will be identified in search results,
|
your real name, if you so desire; if that is filled in, that is how you will be identified in search results,
|
||||||
profile views, etc. If not, you will be identified as you are on No Agenda Social; this system updates your current
|
profile views, etc. If not, you will be identified as you are on your Mastodon instance; this system updates your
|
||||||
display name each time you log on.
|
current display name each time you log on.
|
||||||
|
|
||||||
h5 Completing Your Profile
|
h5 Completing Your Profile
|
||||||
p.
|
p.
|
||||||
@@ -99,19 +98,19 @@ article
|
|||||||
li.
|
li.
|
||||||
If you check the #[span.link Allow my profile to be searched publicly] checkbox #[strong and] you are seeking
|
If you check the #[span.link Allow my profile to be searched publicly] checkbox #[strong and] you are seeking
|
||||||
employment, your continent, region, and skills fields will be searchable and displayed to public users of the
|
employment, your continent, region, and skills fields will be searchable and displayed to public users of the
|
||||||
site. They will not be tied to your No Agenda Social handle or real name; they are there to let people peek
|
site. They will not be tied to your Mastodon handle or real name; they are there to let people peek behind the
|
||||||
behind the curtain a bit, and hopefully inspire them to join us.
|
curtain a bit, and hopefully inspire them to join us.
|
||||||
|
|
||||||
h5 Viewing and Sharing Your Profile
|
h5 Viewing and Sharing Your Profile
|
||||||
p.
|
p.
|
||||||
Once your profile has been established, the #[span.link My Employment Profile] page will have a button at the bottom
|
Once your profile has been established, the #[span.link My Employment Profile] page will have a button at the bottom
|
||||||
that will let you view your profile the way all other validated users will be able to see it. (There will also be a
|
that will let you view your profile the way all other validated users will be able to see it. (There will also be a
|
||||||
link to this page from the #[span.link Dashboard].) The URL of this page can be shared on No Agenda Social, if you
|
link to this page from the #[span.link Dashboard].) The URL of this page can be shared on any No Agenda-affiliated
|
||||||
would like to share it there. Just as with job listings, existing users will go straight there, while other No
|
Mastodon instance, if you would like to share it there. Just as with job listings, existing users will go straight
|
||||||
Agenda Social users will get there once they authorize this application.
|
there, while others will get there once they authorize this application.
|
||||||
p.
|
p.
|
||||||
The name on employment profiles is a link to that user’s profile on No Agenda Social; from there, others can
|
The name on employment profiles is a link to that user’s profile on their Mastodon instance; from there,
|
||||||
communicate further with you using the tools Mastodon provides.
|
others can communicate further with you using the tools Mastodon provides.
|
||||||
|
|
||||||
h5 “I Found a Job!”
|
h5 “I Found a Job!”
|
||||||
p.
|
p.
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
article
|
article
|
||||||
page-title(title="Privacy Policy")
|
|
||||||
h3 Privacy Policy
|
h3 Privacy Policy
|
||||||
p: em (as of February 6#[sup th], 2021)
|
p: em (as of September 6#[sup th], 2021)
|
||||||
|
|
||||||
p.
|
p.
|
||||||
{{name}} (“we,” “our,” or “us”) is committed to protecting your privacy. This
|
{{name}} (“we,” “our,” or “us”) is committed to protecting your privacy. This
|
||||||
@@ -58,7 +57,7 @@ article
|
|||||||
li Name / Username
|
li Name / Username
|
||||||
li Coarse Geographic Location
|
li Coarse Geographic Location
|
||||||
li Employment History
|
li Employment History
|
||||||
li No Agenda Social Account Name / Profile
|
li Mastodon Account Name / Profile
|
||||||
|
|
||||||
h4 How Do We Use The Information We Collect?
|
h4 How Do We Use The Information We Collect?
|
||||||
p Any of the information we collect from you may be used in one of the following ways:
|
p Any of the information we collect from you may be used in one of the following ways:
|
||||||
@@ -75,9 +74,9 @@ article
|
|||||||
p {{name}} will collect End User Data necessary to provide the {{name}} services to our customers.
|
p {{name}} will collect End User Data necessary to provide the {{name}} services to our customers.
|
||||||
p.
|
p.
|
||||||
End users may voluntarily provide us with information they have made available on social media websites
|
End users may voluntarily provide us with information they have made available on social media websites
|
||||||
(specifically No Agenda Social). If you provide us with any such information, we may collect publicly available
|
(specifically No Agenda-affiliated Mastodon instances). If you provide us with any such information, we may collect
|
||||||
information from the social media websites you have indicated. You can control how much of your information social
|
publicly available information from the social media websites you have indicated. You can control how much of your
|
||||||
media websites make public by visiting these websites and changing your privacy settings.
|
information social media websites make public by visiting these websites and changing your privacy settings.
|
||||||
|
|
||||||
h4 When does {{name}} use customer information from third parties?
|
h4 When does {{name}} use customer information from third parties?
|
||||||
p We do not utilize third party information apart from the end-user data described above.
|
p We do not utilize third party information apart from the end-user data described above.
|
||||||
@@ -223,10 +222,10 @@ article
|
|||||||
|
|
||||||
h4 Tracking Technologies
|
h4 Tracking Technologies
|
||||||
p.
|
p.
|
||||||
{{name}} does not use any tracking technologies. When an authorization code is received from No Agenda Social, that
|
{{name}} does not use any tracking technologies. When an authorization code is received from Mastodon, that token is
|
||||||
token is stored in the browser’s memory, and the Service uses tokens on each request for data. If the page is
|
stored in the browser’s memory, and the Service uses tokens on each request for data. If the page is refreshed
|
||||||
refreshed or the browser window/tab is closed, this token disappears, and a new one must be generated before the
|
or the browser window/tab is closed, this token disappears, and a new one must be generated before the application
|
||||||
application can be used again.
|
can be used again.
|
||||||
|
|
||||||
h4 Information about General Data Protection Regulation (GDPR)
|
h4 Information about General Data Protection Regulation (GDPR)
|
||||||
p.
|
p.
|
||||||
@@ -335,6 +334,12 @@ article
|
|||||||
h4 Contact Us
|
h4 Contact Us
|
||||||
p Don’t hesitate to contact us if you have any questions.
|
p Don’t hesitate to contact us if you have any questions.
|
||||||
ul: li Via this Link: #[router-link(to="/how-it-works") https://noagendacareers.com/how-it-works]
|
ul: li Via this Link: #[router-link(to="/how-it-works") https://noagendacareers.com/how-it-works]
|
||||||
|
|
||||||
|
hr
|
||||||
|
|
||||||
|
p: em.
|
||||||
|
Change on September 6#[sup th], 2021 – replaced “No Agenda Social” with generic terms for any
|
||||||
|
authorized Mastodon instance.
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
article
|
article
|
||||||
page-title(title="Terms of Service")
|
|
||||||
h3 Terms of Service
|
h3 Terms of Service
|
||||||
p: em (as of February 6#[sup th], 2021)
|
p: em (as of September 6#[sup th], 2021)
|
||||||
|
|
||||||
h4 Acceptance of Terms
|
h4 Acceptance of Terms
|
||||||
p.
|
p.
|
||||||
@@ -11,12 +10,19 @@ article
|
|||||||
acceptance of these terms.
|
acceptance of these terms.
|
||||||
|
|
||||||
h4 Description of Service and Registration
|
h4 Description of Service and Registration
|
||||||
p.
|
p
|
||||||
Jobs, Jobs, Jobs is a service that allows individuals to enter and amend employment profiles, restricting access
|
| Jobs, Jobs, Jobs is a service that allows individuals to enter and amend employment profiles, restricting access
|
||||||
to the details of these profiles to other users of
|
| to the details of these profiles to other users of No Agenda-afilliated Mastodon sites (currently
|
||||||
#[a(href="https://noagendasocial.com" target="_blank") No Agenda Social]. Registration is accomplished by allowing
|
= " "
|
||||||
Jobs, Jobs, Jobs to read one’s No Agenda Social profile. See our
|
template(v-for="(it, idx) in instances" :key="idx")
|
||||||
#[router-link(to="/privacy-policy") privacy policy] for details on the personal (user) information we maintain.
|
a(:href="it.url" target="_blank") {{it.name}}
|
||||||
|
template(v-if="idx + 2 < instances.length")= ", "
|
||||||
|
template(v-else-if="idx + 1 < instances.length")= ", and "
|
||||||
|
| ). Registration is accomplished by allowing Jobs, Jobs, Jobs to read one’s Mastodon profile. See our
|
||||||
|
= " "
|
||||||
|
router-link(to="/privacy-policy") privacy policy
|
||||||
|
= " "
|
||||||
|
| for details on the personal (user) information we maintain.
|
||||||
|
|
||||||
h4 Liability
|
h4 Liability
|
||||||
p.
|
p.
|
||||||
@@ -34,4 +40,23 @@ article
|
|||||||
p.
|
p.
|
||||||
You may also wish to review our #[router-link(to="/privacy-policy") privacy policy] to learn how we handle your
|
You may also wish to review our #[router-link(to="/privacy-policy") privacy policy] to learn how we handle your
|
||||||
data.
|
data.
|
||||||
|
|
||||||
|
hr
|
||||||
|
|
||||||
|
p: em.
|
||||||
|
Change on September 6#[sup th], 2021 – replaced “No Agenda Social” with a list of all No
|
||||||
|
Agenda-affiliated Mastodon instances.
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from "vue"
|
||||||
|
import { useStore, Actions } from "@/store"
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
|
/** All instances authorized to view Jobs, Jobs, Jobs */
|
||||||
|
const instances = computed(() => store.state.instances)
|
||||||
|
|
||||||
|
onMounted(async () => { await store.dispatch(Actions.EnsureInstances) })
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -1,24 +1,36 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
article
|
article
|
||||||
page-title(title="Logging on...")
|
|
||||||
p
|
p
|
||||||
p(v-html="message")
|
p(v-html="message")
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from "vue"
|
import { computed, onMounted } from "vue"
|
||||||
import { useRouter } from "vue-router"
|
import { useRoute, useRouter } from "vue-router"
|
||||||
import { useStore } from "@/store"
|
import { useStore, Actions, Mutations } from "@/store"
|
||||||
import { AFTER_LOG_ON_URL } from "@/router"
|
import { AFTER_LOG_ON_URL } from "@/router"
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
/** The abbreviation of the instance from which we received the code */
|
||||||
|
const abbr = route.params.abbr as string
|
||||||
|
|
||||||
|
/** Set the message for this component */
|
||||||
|
const setMessage = (msg : string) => store.commit(Mutations.SetLogOnState, msg)
|
||||||
|
|
||||||
/** Pass the code to the API and exchange it for a user and a JWT */
|
/** Pass the code to the API and exchange it for a user and a JWT */
|
||||||
const logOn = async () => {
|
const logOn = async () => {
|
||||||
const code = router.currentRoute.value.query.code
|
await store.dispatch(Actions.EnsureInstances)
|
||||||
|
const instance = store.state.instances.find(it => it.abbr === abbr)
|
||||||
|
if (typeof instance === "undefined") {
|
||||||
|
setMessage(`Mastodon instance ${abbr} not found`)
|
||||||
|
} else {
|
||||||
|
setMessage(`<em>Welcome back! Verifying your ${instance.name} account…</em>`)
|
||||||
|
const code = route.query.code
|
||||||
if (code) {
|
if (code) {
|
||||||
await store.dispatch("logOn", code)
|
await store.dispatch(Actions.LogOn, { abbr, code })
|
||||||
if (store.state.user !== undefined) {
|
if (store.state.user !== undefined) {
|
||||||
const afterLogOnUrl = window.localStorage.getItem(AFTER_LOG_ON_URL)
|
const afterLogOnUrl = window.localStorage.getItem(AFTER_LOG_ON_URL)
|
||||||
if (afterLogOnUrl) {
|
if (afterLogOnUrl) {
|
||||||
@@ -29,8 +41,8 @@ const logOn = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
store.commit("setLogOnState",
|
setMessage(`Did not receive a token from ${instance.name} (perhaps you clicked “Cancel”?)`)
|
||||||
"Did not receive a token from No Agenda Social (perhaps you clicked “Cancel”?)")
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
article.container
|
article.container
|
||||||
page-title(title="Dashboard")
|
|
||||||
h3.pb-4 Welcome, {{user.name}}
|
h3.pb-4 Welcome, {{user.name}}
|
||||||
load-data(:load="retrieveData"): .row.row-cols-1.row-cols-md-2
|
load-data(:load="retrieveData"): .row.row-cols-1.row-cols-md-2
|
||||||
.col: .card.h-100
|
.col: .card.h-100
|
||||||
h5.card-header Your Profile
|
h5.card-header Your Profile
|
||||||
.card-body
|
.card-body
|
||||||
h6.card-subtitle.mb-3.text-muted.fst-italic Last updated #[full-date-time(:date="profile.lastUpdatedOn")]
|
h6.card-subtitle.mb-3.text-muted.fst-italic(v-if="profile").
|
||||||
|
Last updated #[full-date-time(:date="profile.lastUpdatedOn")]
|
||||||
p.card-text(v-if="profile")
|
p.card-text(v-if="profile")
|
||||||
| Your profile currently lists {{profile.skills.length}}
|
| Your profile currently lists {{profile.skills.length}}
|
||||||
| skill#[template(v-if="profile.skills.length !== 1") s].
|
| skill#[template(v-if="profile.skills.length !== 1") s].
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
article
|
article
|
||||||
page-title(title="Edit Profile")
|
|
||||||
h3.pb-3 My Employment Profile
|
h3.pb-3 My Employment Profile
|
||||||
load-data(:load="retrieveData"): form.row.g-3
|
load-data(:load="retrieveData"): form.row.g-3
|
||||||
.col-12.col-sm-10.col-md-8.col-lg-6
|
.col-12.col-sm-10.col-md-8.col-lg-6
|
||||||
.form-floating
|
.form-floating
|
||||||
input.form-control(type="text" id="realName" v-model="v$.realName.$model" maxlength="255"
|
input.form-control(type="text" id="realName" v-model="v$.realName.$model" maxlength="255"
|
||||||
placeholder="Leave blank to use your NAS display name")
|
placeholder="Leave blank to use your Mastodon display name")
|
||||||
label(for="realName") Real Name
|
label(for="realName") Real Name
|
||||||
.form-text Leave blank to use your NAS display name
|
.form-text Leave blank to use your Mastodon display name
|
||||||
.col-12
|
.col-12
|
||||||
.form-check
|
.form-check
|
||||||
input.form-check-input(type="checkbox" id="isSeeking" v-model="v$.isSeekingEmployment.$model")
|
input.form-check-input(type="checkbox" id="isSeeking" v-model="v$.isSeekingEmployment.$model")
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
article
|
article
|
||||||
page-title(title="Logging off...")
|
|
||||||
p
|
p
|
||||||
p.fst-italic Logging off…
|
p.fst-italic Logging off…
|
||||||
</template>
|
</template>
|
||||||
@@ -9,13 +8,13 @@ article
|
|||||||
import { onMounted } from "vue"
|
import { onMounted } from "vue"
|
||||||
import { useRouter } from "vue-router"
|
import { useRouter } from "vue-router"
|
||||||
import { toastSuccess } from "@/components/layout/AppToaster.vue"
|
import { toastSuccess } from "@/components/layout/AppToaster.vue"
|
||||||
import { useStore } from "@/store"
|
import { useStore, Mutations } from "@/store"
|
||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
store.commit("clearUser")
|
store.commit(Mutations.ClearUser)
|
||||||
toastSuccess("Log Off Successful | <strong>Have a Nice Day!</strong>")
|
toastSuccess("Log Off Successful | <strong>Have a Nice Day!</strong>")
|
||||||
router.push("/")
|
router.push("/")
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,24 +1,53 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
article
|
article
|
||||||
p
|
p
|
||||||
p.fst-italic Sending you over to No Agenda Social to log on; see you back in just a second…
|
p.fst-italic(v-if="selected") Sending you over to {{selected.name}} to log on; see you back in just a second…
|
||||||
|
template(v-else)
|
||||||
|
p.text-center Please select your No Agenda-affiliated Mastodon instance
|
||||||
|
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}}
|
||||||
|
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">
|
||||||
/**
|
import { computed, onMounted, Ref, ref } from "vue"
|
||||||
* This component simply redirects the user to the No Agenda Social authorization page; it is separate here so that it
|
import { Instance } from "@/api"
|
||||||
* can be called from two different places, and allow the app to support direct links to authorized content.
|
import { useStore, Actions } from "@/store"
|
||||||
*/
|
|
||||||
|
import LoadData from "@/components/LoadData.vue"
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
|
/** The instances configured for Jobs, Jobs, Jobs */
|
||||||
|
const instances = computed(() => store.state.instances)
|
||||||
|
|
||||||
|
/** Whether authorization is in progress */
|
||||||
|
const selected : Ref<Instance | undefined> = ref(undefined)
|
||||||
|
|
||||||
/** The authorization URL to which the user should be directed */
|
/** The authorization URL to which the user should be directed */
|
||||||
const authUrl = (() => {
|
const authUrl = computed(() => {
|
||||||
/** The client ID for Jobs, Jobs, Jobs at No Agenda Social */
|
if (selected.value) {
|
||||||
const id = "k_06zlMy0N451meL4AqlwMQzs5PYr6g3d2Q_dCT-OjU"
|
const client = `client_id=${selected.value.clientId}`
|
||||||
const client = `client_id=${id}`
|
|
||||||
const scope = "scope=read:accounts"
|
const scope = "scope=read:accounts"
|
||||||
const redirect = `redirect_uri=${document.location.origin}/citizen/authorized`
|
const redirect = `redirect_uri=${document.location.origin}/citizen/${selected.value.abbr}/authorized`
|
||||||
const respType = "response_type=code"
|
const respType = "response_type=code"
|
||||||
return `https://noagendasocial.com/oauth/authorize?${client}&${scope}&${redirect}&${respType}`
|
return `${selected.value.url}/oauth/authorize?${client}&${scope}&${redirect}&${respType}`
|
||||||
})()
|
}
|
||||||
document.location.assign(authUrl)
|
return ""
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select a given Mastodon instance
|
||||||
|
*
|
||||||
|
* @param abbr The abbreviation of the instance being selected
|
||||||
|
*/
|
||||||
|
const select = (abbr : string) => {
|
||||||
|
selected.value = instances.value.find(it => it.abbr === abbr)
|
||||||
|
document.location.assign(authUrl.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => { await store.dispatch(Actions.EnsureInstances) })
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
article
|
article
|
||||||
page-title(title="Help Wanted")
|
|
||||||
h3.pb-3 Help Wanted
|
h3.pb-3 Help Wanted
|
||||||
p(v-if="!searched").
|
p(v-if="!searched").
|
||||||
Enter relevant criteria to find results, or just click “Search” to see all current job listings.
|
Enter relevant criteria to find results, or just click “Search” to see all current job listings.
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
article
|
article
|
||||||
page-title(:title="isNew ? 'Add a Job Listing' : 'Edit Job Listing'")
|
|
||||||
h3.pb-3(v-if="isNew") Add a Job Listing
|
h3.pb-3(v-if="isNew") Add a Job Listing
|
||||||
h3.pb-3(v-else) Edit Job Listing
|
h3.pb-3(v-else) Edit Job Listing
|
||||||
load-data(:load="retrieveData"): form.row.g-3
|
load-data(:load="retrieveData"): form.row.g-3
|
||||||
@@ -44,7 +43,7 @@ import { required } from "@vuelidate/validators"
|
|||||||
|
|
||||||
import api, { Listing, ListingForm, LogOnSuccess } from "@/api"
|
import api, { Listing, ListingForm, LogOnSuccess } from "@/api"
|
||||||
import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue"
|
import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue"
|
||||||
import { useStore } from "@/store"
|
import { Mutations, useStore } from "@/store"
|
||||||
|
|
||||||
import ContinentList from "@/components/ContinentList.vue"
|
import ContinentList from "@/components/ContinentList.vue"
|
||||||
import LoadData from "@/components/LoadData.vue"
|
import LoadData from "@/components/LoadData.vue"
|
||||||
@@ -99,6 +98,7 @@ const v$ = useVuelidate(rules, listing, { $lazy: true })
|
|||||||
|
|
||||||
/** Retrieve the listing being edited (or set up the form for a new listing) */
|
/** Retrieve the listing being edited (or set up the form for a new listing) */
|
||||||
const retrieveData = async (errors : string[]) => {
|
const retrieveData = async (errors : string[]) => {
|
||||||
|
if (isNew.value) store.commit(Mutations.SetTitle, "Add a Job Listing")
|
||||||
const listResult = isNew.value ? newListing : await api.listings.retreive(id, user)
|
const listResult = isNew.value ? newListing : await api.listings.retreive(id, user)
|
||||||
if (typeof listResult === "string") {
|
if (typeof listResult === "string") {
|
||||||
errors.push(listResult)
|
errors.push(listResult)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
article
|
article
|
||||||
page-title(title="Expire Job Listing")
|
|
||||||
load-data(:load="retrieveListing")
|
load-data(:load="retrieveListing")
|
||||||
h3.pb-3 Expire Job Listing ({{listing.title}})
|
h3.pb-3 Expire Job Listing ({{listing.title}})
|
||||||
p: em.
|
p: em.
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
article
|
article
|
||||||
page-title(:title="title")
|
|
||||||
load-data(:load="retrieveListing")
|
load-data(:load="retrieveListing")
|
||||||
h3
|
h3
|
||||||
| {{it.listing.title}}
|
| {{it.listing.title}}
|
||||||
@@ -24,7 +23,7 @@ import { formatNeededBy } from "./"
|
|||||||
import api, { Citizen, ListingForView, LogOnSuccess } from "@/api"
|
import api, { Citizen, ListingForView, LogOnSuccess } from "@/api"
|
||||||
import { citizenName } from "@/App.vue"
|
import { citizenName } from "@/App.vue"
|
||||||
import { toHtml } from "@/markdown"
|
import { toHtml } from "@/markdown"
|
||||||
import { useStore } from "@/store"
|
import { Mutations, useStore } from "@/store"
|
||||||
import LoadData from "@/components/LoadData.vue"
|
import LoadData from "@/components/LoadData.vue"
|
||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
@@ -48,6 +47,7 @@ const retrieveListing = async (errors : string[]) => {
|
|||||||
errors.push("Job Listing not found")
|
errors.push("Job Listing not found")
|
||||||
} else {
|
} else {
|
||||||
it.value = listingResp
|
it.value = listingResp
|
||||||
|
store.commit(Mutations.SetTitle, `${listingResp.listing.title} | Job Listing`)
|
||||||
const citizenResp = await api.citizen.retrieve(listingResp.listing.citizenId, user)
|
const citizenResp = await api.citizen.retrieve(listingResp.listing.citizenId, user)
|
||||||
if (typeof citizenResp === "string") {
|
if (typeof citizenResp === "string") {
|
||||||
errors.push(citizenResp)
|
errors.push(citizenResp)
|
||||||
@@ -59,14 +59,11 @@ const retrieveListing = async (errors : string[]) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The page title (changes once the listing is loaded) */
|
|
||||||
const title = computed(() => it.value ? `${it.value.listing.title} | Job Listing` : "Loading Job Listing...")
|
|
||||||
|
|
||||||
/** The HTML details of the job listing */
|
/** The HTML details of the job listing */
|
||||||
const details = computed(() => toHtml(it.value?.listing.text ?? ""))
|
const details = computed(() => toHtml(it.value?.listing.text ?? ""))
|
||||||
|
|
||||||
/** The NAS profile URL for the citizen who posted this job listing */
|
/** The Mastodon profile URL for the citizen who posted this job listing */
|
||||||
const profileUrl = computed(() => citizen.value ? `https://noagendasocial.com/@${citizen.value.naUser}` : "")
|
const profileUrl = computed(() => citizen.value ? citizen.value.profileUrl : "")
|
||||||
|
|
||||||
/** The needed by date, formatted in SHOUTING MODE */
|
/** The needed by date, formatted in SHOUTING MODE */
|
||||||
const neededBy = (nb : string) => formatNeededBy(nb).toUpperCase()
|
const neededBy = (nb : string) => formatNeededBy(nb).toUpperCase()
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
article
|
article
|
||||||
page-title(title="My Job Listings")
|
|
||||||
h3.pb-3 My Job Listings
|
h3.pb-3 My Job Listings
|
||||||
p: router-link.btn.btn-outline-primary(to="/listing/new/edit") Add a New Job Listing
|
p: router-link.btn.btn-outline-primary(to="/listing/new/edit") Add a New Job Listing
|
||||||
load-data(:load="getListings")
|
load-data(:load="getListings")
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
article
|
article
|
||||||
page-title(title="Search Profiles")
|
|
||||||
h3.pb-3 Search Profiles
|
h3.pb-3 Search Profiles
|
||||||
p(v-if="!searched").
|
p(v-if="!searched").
|
||||||
Enter one or more criteria to filter results, or just click “Search” to list all profiles.
|
Enter one or more criteria to filter results, or just click “Search” to list all profiles.
|
||||||
@@ -13,23 +12,25 @@ article
|
|||||||
thead: tr
|
thead: tr
|
||||||
th(scope="col") Profile
|
th(scope="col") Profile
|
||||||
th(scope="col") Name
|
th(scope="col") Name
|
||||||
th.text-center(scope="col") Seeking?
|
th.text-center(scope="col" v-if="wideDisplay") Seeking?
|
||||||
th.text-center(scope="col") Remote?
|
th.text-center(scope="col") Remote?
|
||||||
th.text-center(scope="col") Full-Time?
|
th.text-center(scope="col" v-if="wideDisplay") Full-Time?
|
||||||
th(scope="col") Last Updated
|
th(scope="col" v-if="wideDisplay") Last Updated
|
||||||
tbody: tr(v-for="profile in results" :key="profile.citzenId")
|
tbody: tr(v-for="profile in results" :key="profile.citzenId")
|
||||||
td: router-link(:to="`/profile/${profile.citizenId}/view`") View
|
td: router-link(:to="`/profile/${profile.citizenId}/view`") View
|
||||||
td(:class="{ 'fw-bold' : profile.seekingEmployment }") {{profile.displayName}}
|
td(:class="{ 'fw-bold' : profile.seekingEmployment }") {{profile.displayName}}
|
||||||
td.text-center {{yesOrNo(profile.seekingEmployment)}}
|
td.text-center(v-if="wideDisplay") {{yesOrNo(profile.seekingEmployment)}}
|
||||||
td.text-center {{yesOrNo(profile.remoteWork)}}
|
td.text-center {{yesOrNo(profile.remoteWork)}}
|
||||||
td.text-center {{yesOrNo(profile.fullTime)}}
|
td.text-center(v-if="wideDisplay") {{yesOrNo(profile.fullTime)}}
|
||||||
td: full-date(:date="profile.lastUpdatedOn")
|
td(v-if="wideDisplay"): full-date(:date="profile.lastUpdatedOn")
|
||||||
p.pt-3(v-else-if="searched") No results found for the specified criteria
|
p.pt-3(v-else-if="searched") No results found for the specified criteria
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineComponent, Ref, ref, watch } from "vue"
|
import { Ref, ref, watch } from "vue"
|
||||||
import { useRoute, useRouter } from "vue-router"
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
import { useBreakpoints, breakpointsBootstrapV5 } from "@vueuse/core"
|
||||||
|
|
||||||
import { yesOrNo } from "@/App.vue"
|
import { yesOrNo } from "@/App.vue"
|
||||||
import api, { LogOnSuccess, ProfileSearch, ProfileSearchResult } from "@/api"
|
import api, { LogOnSuccess, ProfileSearch, ProfileSearchResult } from "@/api"
|
||||||
import { queryValue } from "@/router"
|
import { queryValue } from "@/router"
|
||||||
@@ -43,6 +44,7 @@ import ProfileSearchForm from "@/components/profile/SearchForm.vue"
|
|||||||
const store = useStore()
|
const store = useStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const breakpoints = useBreakpoints(breakpointsBootstrapV5)
|
||||||
|
|
||||||
/** Any errors encountered while retrieving data */
|
/** Any errors encountered while retrieving data */
|
||||||
const errors : Ref<string[]> = ref([])
|
const errors : Ref<string[]> = ref([])
|
||||||
@@ -70,6 +72,9 @@ const results : Ref<ProfileSearchResult[]> = ref([])
|
|||||||
/** Whether the search criteria should be collapsed */
|
/** Whether the search criteria should be collapsed */
|
||||||
const isCollapsed = ref(searched.value && results.value.length > 0)
|
const isCollapsed = ref(searched.value && results.value.length > 0)
|
||||||
|
|
||||||
|
/** Hide certain columns if the display is too narrow */
|
||||||
|
const wideDisplay = breakpoints.greater("sm")
|
||||||
|
|
||||||
/** Set up the page to match its requested state */
|
/** Set up the page to match its requested state */
|
||||||
const setUpPage = async () => {
|
const setUpPage = async () => {
|
||||||
if (queryValue(route, "searched") === "true") {
|
if (queryValue(route, "searched") === "true") {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import { mdiPencil } from "@mdi/js"
|
|||||||
import api, { LogOnSuccess, ProfileForView } from "@/api"
|
import api, { LogOnSuccess, ProfileForView } from "@/api"
|
||||||
import { citizenName } from "@/App.vue"
|
import { citizenName } from "@/App.vue"
|
||||||
import { toHtml } from "@/markdown"
|
import { toHtml } from "@/markdown"
|
||||||
import { useStore } from "@/store"
|
import { Mutations, useStore } from "@/store"
|
||||||
import LoadData from "@/components/LoadData.vue"
|
import LoadData from "@/components/LoadData.vue"
|
||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
@@ -66,14 +66,10 @@ const retrieveProfile = async (errors : string[]) => {
|
|||||||
errors.push("Profile not found")
|
errors.push("Profile not found")
|
||||||
} else {
|
} else {
|
||||||
it.value = profileResp
|
it.value = profileResp
|
||||||
|
store.commit(Mutations.SetTitle, `Employment profile for ${citizenName(profileResp.citizen)}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The title of the page (changes once the profile is loaded) */
|
|
||||||
const title = computed(() => it.value
|
|
||||||
? `Employment profile for ${citizenName(it.value.citizen)}`
|
|
||||||
: "Loading Profile...")
|
|
||||||
|
|
||||||
/** The HTML version of the citizen's professional biography */
|
/** The HTML version of the citizen's professional biography */
|
||||||
const bioHtml = computed(() => toHtml(it.value?.profile.biography ?? ""))
|
const bioHtml = computed(() => toHtml(it.value?.profile.biography ?? ""))
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
article
|
article
|
||||||
page-title(title="People Seeking Work")
|
|
||||||
h3.pb-3 People Seeking Work
|
h3.pb-3 People Seeking Work
|
||||||
p(v-if="!searched").
|
p(v-if="!searched").
|
||||||
Enter one or more criteria to filter results, or just click “Search” to list all profiles.
|
Enter one or more criteria to filter results, or just click “Search” to list all profiles.
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
article
|
article
|
||||||
page-title(title="Account Deletion Options")
|
|
||||||
h3.pb-3 Account Deletion Options
|
h3.pb-3 Account Deletion Options
|
||||||
h4.pb-3 Option 1 – Delete Your Profile
|
h4.pb-3 Option 1 – Delete Your Profile
|
||||||
p.
|
p.
|
||||||
@@ -13,28 +12,31 @@ article
|
|||||||
p.
|
p.
|
||||||
This option will make it like you never visited this site. It will delete your profile, skills, success stories, and
|
This option will make it like you never visited this site. It will delete your profile, skills, success stories, and
|
||||||
account. This is what you want to use if you want to disappear from this application. Clicking the button below
|
account. This is what you want to use if you want to disappear from this application. Clicking the button below
|
||||||
#[strong will not] affect your No Agenda Social account in any way; its effects are limited to Jobs, Jobs, Jobs.
|
#[strong will not] affect your Mastodon account in any way; its effects are limited to Jobs, Jobs, Jobs.
|
||||||
p: em.
|
p: em.
|
||||||
(This will not revoke this application’s permissions on No Agenda Social; you will have to remove this
|
(This will not revoke this application’s permissions on Mastodon; you will have to remove this yourself. The
|
||||||
yourself. The confirmation message has a link where you can do this; once the page loads, find the
|
confirmation message has a link where you can do this; once the page loads, find the
|
||||||
#[strong Jobs, Jobs, Jobs] entry, and click the #[strong × Revoke] link for that entry.)
|
#[strong Jobs, Jobs, Jobs] entry, and click the #[strong × Revoke] link for that entry.)
|
||||||
p.text-center: button.btn.btn-danger(@click.prevent="deleteAccount") Delete Your Entire Account
|
p.text-center: button.btn.btn-danger(@click.prevent="deleteAccount") Delete Your Entire Account
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from "vue"
|
||||||
import { useRouter } from "vue-router"
|
import { useRouter } from "vue-router"
|
||||||
|
|
||||||
import api, { LogOnSuccess } from "@/api"
|
import api, { LogOnSuccess } from "@/api"
|
||||||
import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue"
|
import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue"
|
||||||
import { useStore } from "@/store"
|
import { useStore, Actions, Mutations } from "@/store"
|
||||||
</script>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
/** The currently logged-on user */
|
||||||
|
const user = store.state.user as LogOnSuccess
|
||||||
|
|
||||||
/** Delete the profile only; redirect to home page on success */
|
/** Delete the profile only; redirect to home page on success */
|
||||||
const deleteProfile = async () => {
|
const deleteProfile = async () => {
|
||||||
const resp = await api.profile.delete(store.state.user as LogOnSuccess)
|
const resp = await api.profile.delete(user)
|
||||||
if (typeof resp === "string") {
|
if (typeof resp === "string") {
|
||||||
toastError(resp, "Deleting Profile")
|
toastError(resp, "Deleting Profile")
|
||||||
} else {
|
} else {
|
||||||
@@ -45,13 +47,28 @@ const deleteProfile = async () => {
|
|||||||
|
|
||||||
/** Delete everything pertaining to the user's account */
|
/** Delete everything pertaining to the user's account */
|
||||||
const deleteAccount = async () => {
|
const deleteAccount = async () => {
|
||||||
const resp = await api.citizen.delete(store.state.user as LogOnSuccess)
|
const citizenResp = await api.citizen.retrieve(user.citizenId, user)
|
||||||
|
if (typeof citizenResp === "string") {
|
||||||
|
toastError(citizenResp, "retrieving citizen")
|
||||||
|
} else if (typeof citizenResp === "undefined") {
|
||||||
|
toastError("Could not retrieve citizen record", undefined)
|
||||||
|
} else {
|
||||||
|
const instance = store.state.instances.find(it => it.abbr === citizenResp.instance)
|
||||||
|
if (typeof instance === "undefined") {
|
||||||
|
toastError("Could not retrieve instance", undefined)
|
||||||
|
} else {
|
||||||
|
const resp = await api.citizen.delete(user)
|
||||||
if (typeof resp === "string") {
|
if (typeof resp === "string") {
|
||||||
toastError(resp, "Deleting Account")
|
toastError(resp, "Deleting Account")
|
||||||
} else {
|
} else {
|
||||||
store.commit("clearUser")
|
store.commit(Mutations.ClearUser)
|
||||||
toastSuccess("Account Deleted Successfully")
|
toastSuccess("Account Deleted Successfully")
|
||||||
router.push("/so-long/success")
|
router.push(`/so-long/success/${instance.abbr}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(async () => { await store.dispatch(Actions.EnsureInstances) })
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,11 +1,28 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
article
|
article
|
||||||
page-title(title="Account Deletion Success")
|
|
||||||
h3.pb-3 Account Deletion Success
|
h3.pb-3 Account Deletion Success
|
||||||
p.
|
p.
|
||||||
Your account has been successfully deleted. To revoke the permissions you have previously granted to this
|
Your account has been successfully deleted. To revoke the permissions you have previously granted to this
|
||||||
application, find it in #[a(href="https://noagendasocial.com/oauth/authorized_applications") this list] and click
|
application, find it in #[a(:href="`${url}/oauth/authorized_applications`") this list] and click
|
||||||
#[strong × Revoke]. Otherwise, clicking “Log On” in the left-hand menu will create a new, empty
|
#[strong × Revoke]. Otherwise, clicking “Log On” in the left-hand menu will create a new, empty
|
||||||
account without prompting you further.
|
account without prompting you further.
|
||||||
p Thank you for participating, and thank you for your courage. #GitmoNation
|
p Thank you for participating, and thank you for your courage. #GitmoNation
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from "vue"
|
||||||
|
import { useRoute } from "vue-router"
|
||||||
|
import { useStore, Actions } from "@/store"
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
|
/** The abbreviation of the instance from which the deleted user had authorized access */
|
||||||
|
const abbr = route.params.abbr as string
|
||||||
|
|
||||||
|
/** The URL of that instance */
|
||||||
|
const url = computed(() => store.state.instances.find(it => it.abbr === abbr)?.url ?? "")
|
||||||
|
|
||||||
|
onMounted(async () => { await store.dispatch(Actions.EnsureInstances) })
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
article
|
article
|
||||||
page-title(:title="title")
|
|
||||||
h3.pb-3 {{title}}
|
h3.pb-3 {{title}}
|
||||||
load-data(:load="retrieveStory")
|
load-data(:load="retrieveStory")
|
||||||
p(v-if="isNew").
|
p(v-if="isNew").
|
||||||
@@ -26,7 +25,7 @@ import useVuelidate from "@vuelidate/core"
|
|||||||
|
|
||||||
import api, { LogOnSuccess, StoryForm } from "@/api"
|
import api, { LogOnSuccess, StoryForm } from "@/api"
|
||||||
import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue"
|
import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue"
|
||||||
import { useStore } from "@/store"
|
import { Mutations, useStore } from "@/store"
|
||||||
|
|
||||||
import LoadData from "@/components/LoadData.vue"
|
import LoadData from "@/components/LoadData.vue"
|
||||||
import MarkdownEditor from "@/components/MarkdownEditor.vue"
|
import MarkdownEditor from "@/components/MarkdownEditor.vue"
|
||||||
@@ -45,9 +44,6 @@ const id = route.params.id as string
|
|||||||
/** Whether this is a new story */
|
/** Whether this is a new story */
|
||||||
const isNew = computed(() => id === "new")
|
const isNew = computed(() => id === "new")
|
||||||
|
|
||||||
/** The page title */
|
|
||||||
const title = computed(() => isNew.value ? "Tell Your Success Story" : "Edit Success Story")
|
|
||||||
|
|
||||||
/** The form for editing the story */
|
/** The form for editing the story */
|
||||||
const story = reactive(new StoryForm())
|
const story = reactive(new StoryForm())
|
||||||
|
|
||||||
@@ -64,6 +60,7 @@ const v$ = useVuelidate(rules, story, { $lazy: true })
|
|||||||
const retrieveStory = async (errors : string[]) => {
|
const retrieveStory = async (errors : string[]) => {
|
||||||
if (isNew.value) {
|
if (isNew.value) {
|
||||||
story.id = "new"
|
story.id = "new"
|
||||||
|
store.commit(Mutations.SetTitle, "Tell Your Success Story")
|
||||||
} else {
|
} else {
|
||||||
const storyResult = await api.success.retrieve(id, user)
|
const storyResult = await api.success.retrieve(id, user)
|
||||||
if (typeof storyResult === "string") {
|
if (typeof storyResult === "string") {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
article
|
article
|
||||||
page-title(title="Success Stories")
|
|
||||||
h3.pb-3 Success Stories
|
h3.pb-3 Success Stories
|
||||||
load-data(:load="retrieveStories")
|
load-data(:load="retrieveStories")
|
||||||
table.table.table-sm.table-hover(v-if="stories?.length > 0")
|
table.table.table-sm.table-hover(v-if="stories?.length > 0")
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
article
|
article
|
||||||
page-title(title="Success Story")
|
|
||||||
load-data(:load="retrieveStory")
|
load-data(:load="retrieveStory")
|
||||||
h3
|
h3
|
||||||
| {{citizenName}}’s Success Story
|
| {{citizenName}}’s Success Story
|
||||||
@@ -31,7 +30,7 @@ const user = store.state.user as LogOnSuccess
|
|||||||
/** The story to be displayed */
|
/** The story to be displayed */
|
||||||
const story : Ref<Success | undefined> = ref(undefined)
|
const story : Ref<Success | undefined> = ref(undefined)
|
||||||
|
|
||||||
/** The citizen's name (real, display, or NAS, whichever is found first) */
|
/** The citizen's name (real, display, or Mastodon, whichever is found first) */
|
||||||
const citizenName = ref("")
|
const citizenName = ref("")
|
||||||
|
|
||||||
/** Retrieve the success story */
|
/** Retrieve the success story */
|
||||||
|
|||||||
@@ -2,5 +2,14 @@ module.exports = {
|
|||||||
transpileDependencies: [
|
transpileDependencies: [
|
||||||
'vuetify'
|
'vuetify'
|
||||||
],
|
],
|
||||||
outputDir: '../Api/wwwroot'
|
outputDir: '../Server/wwwroot',
|
||||||
|
configureWebpack: {
|
||||||
|
module: {
|
||||||
|
rules: [{
|
||||||
|
test: /\.mjs$/,
|
||||||
|
include: /node_modules/,
|
||||||
|
type: "javascript/auto"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
namespace JobsJobsJobs.DataMigrate
|
|
||||||
|
|
||||||
/// Converters used to translate between database and domain types
|
|
||||||
module Converters =
|
|
||||||
open JobsJobsJobs.Shared
|
|
||||||
open Microsoft.EntityFrameworkCore.Storage.ValueConversion
|
|
||||||
|
|
||||||
/// Citizen ID converter
|
|
||||||
let CitizenIdConverter = ValueConverter<CitizenId, string> ((fun v -> string v), fun v -> CitizenId.Parse v)
|
|
||||||
|
|
||||||
/// Continent ID converter
|
|
||||||
let ContinentIdConverter = ValueConverter<ContinentId, string> ((fun v -> string v), fun v -> ContinentId.Parse v)
|
|
||||||
|
|
||||||
/// Markdown converter
|
|
||||||
let MarkdownStringConverter = ValueConverter<MarkdownString, string> ((fun v -> v.Text), fun v -> MarkdownString v)
|
|
||||||
|
|
||||||
/// Markdown converter for possibly-null values
|
|
||||||
let OptionalMarkdownStringConverter : ValueConverter<MarkdownString, string> =
|
|
||||||
ValueConverter<MarkdownString, string>
|
|
||||||
((fun v -> match v with null -> null | _ -> v.Text), fun v -> match v with null -> null | _ -> MarkdownString v)
|
|
||||||
|
|
||||||
/// Skill ID converter
|
|
||||||
let SkillIdConverter = ValueConverter<SkillId, string> ((fun v -> string v), fun v -> SkillId.Parse v)
|
|
||||||
|
|
||||||
/// Success ID converter
|
|
||||||
let SuccessIdConverter = ValueConverter<SuccessId, string> ((fun v -> string v), fun v -> SuccessId.Parse v)
|
|
||||||
|
|
||||||
|
|
||||||
open JobsJobsJobs.Shared
|
|
||||||
open Microsoft.EntityFrameworkCore
|
|
||||||
open System.Collections.Generic
|
|
||||||
|
|
||||||
/// Data context for Jobs, Jobs, Jobs
|
|
||||||
type JobsDbContext (options : DbContextOptions<JobsDbContext>) =
|
|
||||||
inherit DbContext (options)
|
|
||||||
|
|
||||||
/// Citizens (users known to us)
|
|
||||||
member val Citizens : DbSet<Citizen> = Unchecked.defaultof<DbSet<Citizen>> with get, set
|
|
||||||
|
|
||||||
/// Continents (large land masses - 7 of them!)
|
|
||||||
member val Continents : DbSet<Continent> = Unchecked.defaultof<DbSet<Continent>> with get, set
|
|
||||||
|
|
||||||
/// Employment profiles
|
|
||||||
member val Profiles : DbSet<Profile> = Unchecked.defaultof<DbSet<Profile>> with get, set
|
|
||||||
|
|
||||||
/// Skills held by citizens of Gitmo Nation
|
|
||||||
member val Skills : DbSet<Skill> = Unchecked.defaultof<DbSet<Skill>> with get, set
|
|
||||||
|
|
||||||
/// Success stories from the site
|
|
||||||
member val Successes : DbSet<Success> = Unchecked.defaultof<DbSet<Success>> with get, set
|
|
||||||
|
|
||||||
override _.OnModelCreating (mb : ModelBuilder) =
|
|
||||||
base.OnModelCreating(mb)
|
|
||||||
|
|
||||||
mb.Entity<Citizen>(fun m ->
|
|
||||||
m.ToTable("citizen", "jjj").HasKey(fun e -> e.Id :> obj) |> ignore
|
|
||||||
m.Property(fun e -> e.Id).HasColumnName("id").IsRequired().HasMaxLength(12)
|
|
||||||
.HasConversion(Converters.CitizenIdConverter) |> ignore
|
|
||||||
m.Property(fun e -> e.NaUser).HasColumnName("na_user").IsRequired().HasMaxLength(50) |> ignore
|
|
||||||
m.Property(fun e -> e.DisplayName).HasColumnName("display_name").HasMaxLength(255) |> ignore
|
|
||||||
m.Property(fun e -> e.ProfileUrl).HasColumnName("profile_url").IsRequired().HasMaxLength(1_024) |> ignore
|
|
||||||
m.Property(fun e -> e.JoinedOn).HasColumnName("joined_on").IsRequired() |> ignore
|
|
||||||
m.Property(fun e -> e.LastSeenOn).HasColumnName("last_seen_on").IsRequired() |> ignore
|
|
||||||
m.Property(fun e -> e.RealName).HasColumnName("real_name").HasMaxLength(255) |> ignore
|
|
||||||
m.HasIndex(fun e -> e.NaUser :> obj).IsUnique() |> ignore
|
|
||||||
m.Ignore(fun e -> e.CitizenName :> obj) |> ignore)
|
|
||||||
|> ignore
|
|
||||||
|
|
||||||
mb.Entity<Continent>(fun m ->
|
|
||||||
m.ToTable("continent", "jjj").HasKey(fun e -> e.Id :> obj) |> ignore
|
|
||||||
m.Property(fun e -> e.Id).HasColumnName("id").IsRequired().HasMaxLength(12)
|
|
||||||
.HasConversion(Converters.ContinentIdConverter) |> ignore
|
|
||||||
m.Property(fun e -> e.Name).HasColumnName("name").IsRequired().HasMaxLength(255) |> ignore)
|
|
||||||
|> ignore
|
|
||||||
|
|
||||||
mb.Entity<Profile>(fun m ->
|
|
||||||
m.ToTable("profile", "jjj").HasKey(fun e -> e.Id :> obj) |> ignore
|
|
||||||
m.Property(fun e -> e.Id).HasColumnName("citizen_id").IsRequired().HasMaxLength(12)
|
|
||||||
.HasConversion(Converters.CitizenIdConverter) |> ignore
|
|
||||||
m.Property(fun e -> e.SeekingEmployment).HasColumnName("seeking_employment").IsRequired() |> ignore
|
|
||||||
m.Property(fun e -> e.IsPublic).HasColumnName("is_public").IsRequired() |> ignore
|
|
||||||
m.Property(fun e -> e.ContinentId).HasColumnName("continent_id").IsRequired().HasMaxLength(12)
|
|
||||||
.HasConversion(Converters.ContinentIdConverter) |> ignore
|
|
||||||
m.Property(fun e -> e.Region).HasColumnName("region").IsRequired().HasMaxLength(255) |> ignore
|
|
||||||
m.Property(fun e -> e.RemoteWork).HasColumnName("remote_work").IsRequired() |> ignore
|
|
||||||
m.Property(fun e -> e.FullTime).HasColumnName("full_time").IsRequired() |> ignore
|
|
||||||
m.Property(fun e -> e.Biography).HasColumnName("biography").IsRequired()
|
|
||||||
.HasConversion(Converters.MarkdownStringConverter) |> ignore
|
|
||||||
m.Property(fun e -> e.LastUpdatedOn).HasColumnName("last_updated_on").IsRequired() |> ignore
|
|
||||||
m.Property(fun e -> e.Experience).HasColumnName("experience")
|
|
||||||
.HasConversion(Converters.OptionalMarkdownStringConverter) |> ignore
|
|
||||||
m.HasOne(fun e -> e.Continent :> obj)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(fun e -> e.ContinentId :> obj) |> ignore
|
|
||||||
m.HasMany(fun e -> e.Skills :> IEnumerable<Skill>)
|
|
||||||
.WithOne()
|
|
||||||
.HasForeignKey(fun e -> e.CitizenId :> obj) |> ignore)
|
|
||||||
|> ignore
|
|
||||||
|
|
||||||
mb.Entity<Skill>(fun m ->
|
|
||||||
m.ToTable("skill", "jjj").HasKey(fun e -> e.Id :> obj) |> ignore
|
|
||||||
m.Property(fun e -> e.Id).HasColumnName("id").IsRequired().HasMaxLength(12)
|
|
||||||
.HasConversion(Converters.SkillIdConverter) |> ignore
|
|
||||||
m.Property(fun e -> e.CitizenId).HasColumnName("citizen_id").IsRequired().HasMaxLength(12)
|
|
||||||
.HasConversion(Converters.CitizenIdConverter) |> ignore
|
|
||||||
m.Property(fun e -> e.Description).HasColumnName("skill").IsRequired().HasMaxLength(100) |> ignore
|
|
||||||
m.Property(fun e -> e.Notes).HasColumnName("notes").HasMaxLength(100) |> ignore)
|
|
||||||
|> ignore
|
|
||||||
|
|
||||||
mb.Entity<Success>(fun m ->
|
|
||||||
m.ToTable("success", "jjj").HasKey(fun e -> e.Id :> obj) |> ignore
|
|
||||||
m.Property(fun e -> e.Id).HasColumnName("id").IsRequired().HasMaxLength(12)
|
|
||||||
.HasConversion(Converters.SuccessIdConverter) |> ignore
|
|
||||||
m.Property(fun e -> e.CitizenId).HasColumnName("citizen_id").IsRequired().HasMaxLength(12)
|
|
||||||
.HasConversion(Converters.CitizenIdConverter) |> ignore
|
|
||||||
m.Property(fun e -> e.RecordedOn).HasColumnName("recorded_on").IsRequired() |> ignore
|
|
||||||
m.Property(fun e -> e.FromHere).HasColumnName("from_here").IsRequired() |> ignore
|
|
||||||
// m.Property(fun e -> e.Source).HasColumnName("source").IsRequired().HasMaxLength(7);
|
|
||||||
m.Property(fun e -> e.Story).HasColumnName("story")
|
|
||||||
.HasConversion(Converters.OptionalMarkdownStringConverter) |> ignore)
|
|
||||||
|> ignore
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<OutputType>Exe</OutputType>
|
|
||||||
<TargetFramework>net5.0</TargetFramework>
|
|
||||||
<WarnOn>3390;$(WarnOn)</WarnOn>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Compile Include="DataContext.fs" />
|
|
||||||
<Compile Include="Program.fs" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\Shared\JobsJobsJobs.Shared.csproj" />
|
|
||||||
<ProjectReference Include="..\Api\Api.fsproj" />
|
|
||||||
<ProjectReference Include="..\Domain\Domain.fsproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Npgsql" Version="5.0.7" />
|
|
||||||
<PackageReference Include="Npgsql" Version="5.0.7" />
|
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="5.0.7" />
|
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="5.0.7" />
|
|
||||||
<PackageReference Include="Npgsql.NodaTime" Version="5.0.7" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
// Learn more about F# at http://docs.microsoft.com/dotnet/fsharp
|
|
||||||
|
|
||||||
open FSharp.Control.Tasks
|
|
||||||
open System
|
|
||||||
open System.Linq
|
|
||||||
open JobsJobsJobs.Api.Data
|
|
||||||
open JobsJobsJobs.Domain
|
|
||||||
open JobsJobsJobs.DataMigrate
|
|
||||||
open Microsoft.EntityFrameworkCore
|
|
||||||
open Microsoft.Extensions.Hosting
|
|
||||||
open Microsoft.Extensions.DependencyInjection
|
|
||||||
open Microsoft.Extensions.Logging
|
|
||||||
open RethinkDb.Driver.Net
|
|
||||||
|
|
||||||
/// Create the host (reads configuration and initializes both databases)
|
|
||||||
let createHostBuilder argv =
|
|
||||||
Host.CreateDefaultBuilder(argv)
|
|
||||||
.ConfigureServices(
|
|
||||||
Action<HostBuilderContext, IServiceCollection> (
|
|
||||||
fun hostCtx svcs ->
|
|
||||||
svcs.AddSingleton hostCtx.Configuration |> ignore
|
|
||||||
// PostgreSQL via EF Core
|
|
||||||
svcs.AddDbContext<JobsDbContext>(fun options ->
|
|
||||||
options.UseNpgsql(hostCtx.Configuration.["ConnectionStrings:JobsDb"],
|
|
||||||
fun o -> o.UseNodaTime() |> ignore)
|
|
||||||
|> ignore)
|
|
||||||
|> ignore
|
|
||||||
// RethinkDB
|
|
||||||
let cfg = hostCtx.Configuration.GetSection "Rethink"
|
|
||||||
let log = svcs.BuildServiceProvider().GetRequiredService<ILoggerFactory>().CreateLogger "Data"
|
|
||||||
let conn = Startup.createConnection cfg log
|
|
||||||
svcs.AddSingleton conn |> ignore
|
|
||||||
Startup.establishEnvironment cfg log conn |> awaitIgnore
|
|
||||||
))
|
|
||||||
.Build()
|
|
||||||
|
|
||||||
[<EntryPoint>]
|
|
||||||
let main argv =
|
|
||||||
let host = createHostBuilder argv
|
|
||||||
let r = RethinkDb.Driver.RethinkDB.R
|
|
||||||
|
|
||||||
printfn "0) Connecting to databases..."
|
|
||||||
|
|
||||||
use db = host.Services.GetRequiredService<JobsDbContext> ()
|
|
||||||
let conn = host.Services.GetRequiredService<IConnection> ()
|
|
||||||
|
|
||||||
task {
|
|
||||||
|
|
||||||
printfn "1) Migrating continents..."
|
|
||||||
|
|
||||||
let mutable continentXref = Map.empty<string, Types.ContinentId>
|
|
||||||
let! continents = db.Continents.AsNoTracking().ToListAsync ()
|
|
||||||
let reContinents =
|
|
||||||
continents
|
|
||||||
|> Seq.map (fun c ->
|
|
||||||
let reContinentId = ContinentId.create ()
|
|
||||||
continentXref <- continentXref.Add (string c.Id, reContinentId)
|
|
||||||
let it : Types.Continent = {
|
|
||||||
id = reContinentId
|
|
||||||
name = c.Name
|
|
||||||
}
|
|
||||||
it)
|
|
||||||
|> List.ofSeq
|
|
||||||
let! _ = r.Table(Table.Continent).Insert(reContinents).RunWriteAsync conn
|
|
||||||
|
|
||||||
printfn "2) Migrating citizens..."
|
|
||||||
|
|
||||||
let mutable citizenXref = Map.empty<string, Types.CitizenId>
|
|
||||||
let! citizens = db.Citizens.AsNoTracking().ToListAsync ()
|
|
||||||
let reCitizens =
|
|
||||||
citizens
|
|
||||||
|> Seq.map (fun c ->
|
|
||||||
let reCitizenId = CitizenId.create ()
|
|
||||||
citizenXref <- citizenXref.Add (string c.Id, reCitizenId)
|
|
||||||
let it : Types.Citizen = {
|
|
||||||
id = reCitizenId
|
|
||||||
naUser = c.NaUser
|
|
||||||
displayName = Option.ofObj c.DisplayName
|
|
||||||
realName = Option.ofObj c.RealName
|
|
||||||
profileUrl = c.ProfileUrl
|
|
||||||
joinedOn = c.JoinedOn
|
|
||||||
lastSeenOn = c.LastSeenOn
|
|
||||||
}
|
|
||||||
it)
|
|
||||||
let! _ = r.Table(Table.Citizen).Insert(reCitizens).RunWriteAsync conn
|
|
||||||
|
|
||||||
printfn "3) Migrating profiles and skills..."
|
|
||||||
|
|
||||||
let! profiles = db.Profiles.AsNoTracking().ToListAsync ()
|
|
||||||
let reProfiles =
|
|
||||||
profiles
|
|
||||||
|> Seq.map (fun p ->
|
|
||||||
let skills = db.Skills.AsNoTracking().Where(fun s -> s.CitizenId = p.Id).ToList ()
|
|
||||||
let reSkills =
|
|
||||||
skills
|
|
||||||
|> Seq.map (fun skill ->
|
|
||||||
let it : Types.Skill = {
|
|
||||||
id = SkillId.create()
|
|
||||||
description = skill.Description
|
|
||||||
notes = Option.ofObj skill.Notes
|
|
||||||
}
|
|
||||||
it)
|
|
||||||
|> List.ofSeq
|
|
||||||
let it : Types.Profile = {
|
|
||||||
id = citizenXref.[string p.Id]
|
|
||||||
seekingEmployment = p.SeekingEmployment
|
|
||||||
isPublic = p.IsPublic
|
|
||||||
continentId = continentXref.[string p.ContinentId]
|
|
||||||
region = p.Region
|
|
||||||
remoteWork = p.RemoteWork
|
|
||||||
fullTime = p.FullTime
|
|
||||||
biography = Types.Text p.Biography.Text
|
|
||||||
lastUpdatedOn = p.LastUpdatedOn
|
|
||||||
experience = match p.Experience with null -> None | x -> (Types.Text >> Some) x.Text
|
|
||||||
skills = reSkills
|
|
||||||
}
|
|
||||||
it)
|
|
||||||
let! _ = r.Table(Table.Profile).Insert(reProfiles).RunWriteAsync conn
|
|
||||||
|
|
||||||
printfn "4) Migrating success stories..."
|
|
||||||
|
|
||||||
let! successes = db.Successes.AsNoTracking().ToListAsync ()
|
|
||||||
let reSuccesses =
|
|
||||||
successes
|
|
||||||
|> Seq.map (fun s ->
|
|
||||||
let it : Types.Success = {
|
|
||||||
id = SuccessId.create ()
|
|
||||||
citizenId = citizenXref.[string s.CitizenId]
|
|
||||||
recordedOn = s.RecordedOn
|
|
||||||
fromHere = s.FromHere
|
|
||||||
source = "profile"
|
|
||||||
story = match s.Story with null -> None | x -> (Types.Text >> Some) x.Text
|
|
||||||
}
|
|
||||||
it)
|
|
||||||
let! _ = r.Table(Table.Success).Insert(reSuccesses).RunWriteAsync conn
|
|
||||||
()
|
|
||||||
}
|
|
||||||
|> awaitIgnore
|
|
||||||
|
|
||||||
printfn "Migration complete"
|
|
||||||
|
|
||||||
0
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net5.0</TargetFramework>
|
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
<WarnOn>3390;$(WarnOn)</WarnOn>
|
<WarnOn>3390;$(WarnOn)</WarnOn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
@@ -13,8 +12,10 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Markdig" Version="0.25.0" />
|
<PackageReference Include="Markdig" Version="0.30.2" />
|
||||||
<PackageReference Include="NodaTime" Version="3.0.5" />
|
<PackageReference Include="Microsoft.Extensions.Options" Version="6.0.0" />
|
||||||
|
<PackageReference Include="NodaTime" Version="3.1.0" />
|
||||||
|
<PackageReference Update="FSharp.Core" Version="6.0.5" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -33,7 +29,7 @@ module CitizenId =
|
|||||||
module Citizen =
|
module Citizen =
|
||||||
/// Get the name of the citizen (the first of real name, display name, or handle that is filled in)
|
/// Get the name of the citizen (the first of real name, display name, or handle that is filled in)
|
||||||
let name x =
|
let name x =
|
||||||
[ x.realName; x.displayName; Some x.naUser ]
|
[ x.realName; x.displayName; Some x.mastodonUser ]
|
||||||
|> List.find Option.isSome
|
|> List.find Option.isSome
|
||||||
|> Option.get
|
|> Option.get
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
module JobsJobsJobs.Domain.SharedTypes
|
module JobsJobsJobs.Domain.SharedTypes
|
||||||
|
|
||||||
open JobsJobsJobs.Domain.Types
|
open JobsJobsJobs.Domain.Types
|
||||||
|
open Microsoft.Extensions.Options
|
||||||
open NodaTime
|
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
|
||||||
@@ -26,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
|
||||||
@@ -58,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
|
||||||
@@ -69,15 +70,62 @@ type LogOnSuccess = {
|
|||||||
|
|
||||||
|
|
||||||
/// A count
|
/// A count
|
||||||
type Count = {
|
type Count =
|
||||||
// The count being returned
|
{ // The count being returned
|
||||||
count : int64
|
count : int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// An instance of a Mastodon server which is configured to work with Jobs, Jobs, Jobs
|
||||||
|
type MastodonInstance () =
|
||||||
|
/// The name of the instance
|
||||||
|
member val Name = "" with get, set
|
||||||
|
/// The URL for this instance
|
||||||
|
member val Url = "" with get, set
|
||||||
|
/// The abbreviation used in the URL to distinguish this instance's return codes
|
||||||
|
member val Abbr = "" with get, set
|
||||||
|
/// The client ID (assigned by the Mastodon server)
|
||||||
|
member val ClientId = "" with get, set
|
||||||
|
/// The cryptographic secret (provided by the Mastodon server)
|
||||||
|
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
|
||||||
|
type AuthOptions () =
|
||||||
|
/// The host for the return URL for Mastodon verification
|
||||||
|
member val ReturnHost = "" with get, set
|
||||||
|
/// The secret with which the server signs the JWTs for auth once we've verified with Mastodon
|
||||||
|
member val ServerSecret = "" with get, set
|
||||||
|
/// The instances configured for use
|
||||||
|
member val Instances = Array.empty<MastodonInstance> with get, set
|
||||||
|
interface IOptions<AuthOptions> with
|
||||||
|
override this.Value = this
|
||||||
|
|
||||||
|
|
||||||
|
/// The Mastodon instance data provided via the Jobs, Jobs, Jobs API
|
||||||
|
type Instance =
|
||||||
|
{ /// The name of the instance
|
||||||
|
name : string
|
||||||
|
/// The URL for this instance
|
||||||
|
url : string
|
||||||
|
/// The abbreviation used in the URL to distinguish this instance's return codes
|
||||||
|
abbr : string
|
||||||
|
/// The client ID (assigned by the Mastodon server)
|
||||||
|
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
|
||||||
@@ -87,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
|
||||||
@@ -134,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
|
||||||
@@ -147,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
|
||||||
@@ -164,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
|
||||||
@@ -187,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
|
||||||
@@ -212,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
|
||||||
@@ -223,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
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ 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
|
||||||
|
instance : string
|
||||||
/// The handle by which the user is known on Mastodon
|
/// The handle by which the user is known on Mastodon
|
||||||
naUser : string
|
mastodonUser : string
|
||||||
/// The user's display name from Mastodon (updated every login)
|
/// The user's display name from Mastodon (updated every login)
|
||||||
displayName : string option
|
displayName : string option
|
||||||
/// The user's real name
|
/// The user's real name
|
||||||
@@ -34,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
|
||||||
@@ -51,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
|
||||||
@@ -83,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
|
||||||
@@ -95,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
|
||||||
@@ -125,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
|
||||||
|
|||||||
81
src/JobsJobsJobs/Server/App.fs
Normal file
81
src/JobsJobsJobs/Server/App.fs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/// The main API application for Jobs, Jobs, Jobs
|
||||||
|
module JobsJobsJobs.Api.App
|
||||||
|
|
||||||
|
open Microsoft.AspNetCore.Builder
|
||||||
|
open Microsoft.AspNetCore.Hosting
|
||||||
|
open Microsoft.Extensions.DependencyInjection
|
||||||
|
open Microsoft.Extensions.Hosting
|
||||||
|
open Giraffe
|
||||||
|
open Giraffe.EndpointRouting
|
||||||
|
|
||||||
|
|
||||||
|
/// Configure the ASP.NET Core pipeline to use Giraffe
|
||||||
|
let configureApp (app : IApplicationBuilder) =
|
||||||
|
app.UseCors(fun p -> p.AllowAnyOrigin().AllowAnyHeader() |> ignore)
|
||||||
|
.UseStaticFiles()
|
||||||
|
.UseRouting()
|
||||||
|
.UseAuthentication()
|
||||||
|
.UseAuthorization()
|
||||||
|
.UseGiraffeErrorHandler(Handlers.Error.unexpectedError)
|
||||||
|
.UseEndpoints(fun e ->
|
||||||
|
e.MapGiraffeEndpoints Handlers.allEndpoints
|
||||||
|
e.MapFallbackToFile "index.html" |> ignore)
|
||||||
|
|> ignore
|
||||||
|
|
||||||
|
open Newtonsoft.Json
|
||||||
|
open NodaTime
|
||||||
|
open Microsoft.AspNetCore.Authentication.JwtBearer
|
||||||
|
open Microsoft.Extensions.Configuration
|
||||||
|
open Microsoft.Extensions.Logging
|
||||||
|
open Microsoft.IdentityModel.Tokens
|
||||||
|
open System.Text
|
||||||
|
open JobsJobsJobs.Domain.SharedTypes
|
||||||
|
|
||||||
|
/// Configure dependency injection
|
||||||
|
let configureServices (svc : IServiceCollection) =
|
||||||
|
svc.AddGiraffe () |> ignore
|
||||||
|
svc.AddSingleton<IClock> SystemClock.Instance |> ignore
|
||||||
|
svc.AddLogging () |> ignore
|
||||||
|
svc.AddCors () |> ignore
|
||||||
|
|
||||||
|
let jsonCfg = JsonSerializerSettings ()
|
||||||
|
Data.Converters.all () |> List.iter jsonCfg.Converters.Add
|
||||||
|
svc.AddSingleton<Json.ISerializer> (NewtonsoftJson.Serializer jsonCfg) |> ignore
|
||||||
|
|
||||||
|
let svcs = svc.BuildServiceProvider ()
|
||||||
|
let cfg = svcs.GetRequiredService<IConfiguration> ()
|
||||||
|
|
||||||
|
svc.AddAuthentication(fun o ->
|
||||||
|
o.DefaultAuthenticateScheme <- JwtBearerDefaults.AuthenticationScheme
|
||||||
|
o.DefaultChallengeScheme <- JwtBearerDefaults.AuthenticationScheme
|
||||||
|
o.DefaultScheme <- JwtBearerDefaults.AuthenticationScheme)
|
||||||
|
.AddJwtBearer(fun o ->
|
||||||
|
o.RequireHttpsMetadata <- false
|
||||||
|
o.TokenValidationParameters <- TokenValidationParameters (
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidAudience = "https://noagendacareers.com",
|
||||||
|
ValidIssuer = "https://noagendacareers.com",
|
||||||
|
IssuerSigningKey = SymmetricSecurityKey (
|
||||||
|
Encoding.UTF8.GetBytes (cfg.GetSection "Auth").["ServerSecret"])))
|
||||||
|
|> ignore
|
||||||
|
svc.AddAuthorization () |> ignore
|
||||||
|
svc.Configure<AuthOptions> (cfg.GetSection "Auth") |> ignore
|
||||||
|
|
||||||
|
let dbCfg = cfg.GetSection "Rethink"
|
||||||
|
let log = svcs.GetRequiredService<ILoggerFactory>().CreateLogger "JobsJobsJobs.Api.Data.Startup"
|
||||||
|
let conn = Data.Startup.createConnection dbCfg log
|
||||||
|
svc.AddSingleton conn |> ignore
|
||||||
|
Data.Startup.establishEnvironment dbCfg log conn |> Async.AwaitTask |> Async.RunSynchronously
|
||||||
|
|
||||||
|
[<EntryPoint>]
|
||||||
|
let main _ =
|
||||||
|
Host.CreateDefaultBuilder()
|
||||||
|
.ConfigureWebHostDefaults(fun webHostBuilder ->
|
||||||
|
webHostBuilder
|
||||||
|
.Configure(configureApp)
|
||||||
|
.ConfigureServices(configureServices)
|
||||||
|
|> ignore)
|
||||||
|
.Build()
|
||||||
|
.Run ()
|
||||||
|
0
|
||||||
107
src/JobsJobsJobs/Server/Auth.fs
Normal file
107
src/JobsJobsJobs/Server/Auth.fs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/// Authorization / authentication functions
|
||||||
|
module JobsJobsJobs.Api.Auth
|
||||||
|
|
||||||
|
open System.Text.Json.Serialization
|
||||||
|
|
||||||
|
/// The variables we need from the account information we get from Mastodon
|
||||||
|
[<NoComparison; NoEquality; AllowNullLiteral>]
|
||||||
|
type MastodonAccount () =
|
||||||
|
/// The user name (what we store as mastodonUser)
|
||||||
|
[<JsonPropertyName "username">]
|
||||||
|
member val Username = "" with get, set
|
||||||
|
/// The account name; will generally be the same as username for local accounts, which is all we can verify
|
||||||
|
[<JsonPropertyName "acct">]
|
||||||
|
member val AccountName = "" with get, set
|
||||||
|
/// The user's display name as it currently shows on Mastodon
|
||||||
|
[<JsonPropertyName "display_name">]
|
||||||
|
member val DisplayName = "" with get, set
|
||||||
|
/// The user's profile URL
|
||||||
|
[<JsonPropertyName "url">]
|
||||||
|
member val Url = "" with get, set
|
||||||
|
|
||||||
|
|
||||||
|
open Microsoft.Extensions.Logging
|
||||||
|
open System
|
||||||
|
open System.Net.Http
|
||||||
|
open System.Net.Http.Headers
|
||||||
|
open System.Net.Http.Json
|
||||||
|
open System.Text.Json
|
||||||
|
open JobsJobsJobs.Domain.SharedTypes
|
||||||
|
|
||||||
|
/// HTTP client to use to communication with Mastodon
|
||||||
|
let private http =
|
||||||
|
let h = new HttpClient ()
|
||||||
|
h.Timeout <- TimeSpan.FromSeconds 30.
|
||||||
|
h
|
||||||
|
|
||||||
|
/// Verify the authorization code with Mastodon and get the user's profile
|
||||||
|
let verifyWithMastodon (authCode : string) (inst : MastodonInstance) rtnHost (log : ILogger) = task {
|
||||||
|
|
||||||
|
// Function to create a URL for the given instance
|
||||||
|
let apiUrl = sprintf "%s/api/v1/%s" inst.Url
|
||||||
|
|
||||||
|
// Use authorization code to get an access token from Mastodon
|
||||||
|
use! codeResult =
|
||||||
|
http.PostAsJsonAsync ($"{inst.Url}/oauth/token",
|
||||||
|
{| client_id = inst.ClientId
|
||||||
|
client_secret = inst.Secret
|
||||||
|
redirect_uri = $"{rtnHost}/citizen/{inst.Abbr}/authorized"
|
||||||
|
grant_type = "authorization_code"
|
||||||
|
code = authCode
|
||||||
|
scope = "read"
|
||||||
|
|})
|
||||||
|
match codeResult.IsSuccessStatusCode with
|
||||||
|
| true ->
|
||||||
|
let! responseBytes = codeResult.Content.ReadAsByteArrayAsync ()
|
||||||
|
use tokenResponse = JsonSerializer.Deserialize<JsonDocument> (ReadOnlySpan<byte> responseBytes)
|
||||||
|
match tokenResponse with
|
||||||
|
| null -> return Error "Could not parse authorization code result"
|
||||||
|
| _ ->
|
||||||
|
// Use access token to get profile from NAS
|
||||||
|
use req = new HttpRequestMessage (HttpMethod.Get, apiUrl "accounts/verify_credentials")
|
||||||
|
req.Headers.Authorization <- AuthenticationHeaderValue
|
||||||
|
("Bearer", tokenResponse.RootElement.GetProperty("access_token").GetString ())
|
||||||
|
use! profileResult = http.SendAsync req
|
||||||
|
|
||||||
|
match profileResult.IsSuccessStatusCode with
|
||||||
|
| true ->
|
||||||
|
let! profileBytes = profileResult.Content.ReadAsByteArrayAsync ()
|
||||||
|
match JsonSerializer.Deserialize<MastodonAccount>(ReadOnlySpan<byte> profileBytes) with
|
||||||
|
| null -> return Error "Could not parse profile result"
|
||||||
|
| profile -> return Ok profile
|
||||||
|
| false -> return Error $"Could not get profile ({profileResult.StatusCode:D}: {profileResult.ReasonPhrase})"
|
||||||
|
| false ->
|
||||||
|
let! err = codeResult.Content.ReadAsStringAsync ()
|
||||||
|
log.LogError $"Could not get token result from Mastodon:\n {err}"
|
||||||
|
return Error $"Could not get token ({codeResult.StatusCode:D}: {codeResult.ReasonPhrase})"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
open JobsJobsJobs.Domain
|
||||||
|
open JobsJobsJobs.Domain.Types
|
||||||
|
open Microsoft.IdentityModel.Tokens
|
||||||
|
open System.IdentityModel.Tokens.Jwt
|
||||||
|
open System.Security.Claims
|
||||||
|
open System.Text
|
||||||
|
|
||||||
|
/// Create a JSON Web Token for this citizen to use for further requests to this API
|
||||||
|
let createJwt (citizen : Citizen) (cfg : AuthOptions) =
|
||||||
|
|
||||||
|
let tokenHandler = JwtSecurityTokenHandler ()
|
||||||
|
let token =
|
||||||
|
tokenHandler.CreateToken (
|
||||||
|
SecurityTokenDescriptor (
|
||||||
|
Subject = ClaimsIdentity [|
|
||||||
|
Claim (ClaimTypes.NameIdentifier, CitizenId.toString citizen.id)
|
||||||
|
Claim (ClaimTypes.Name, Citizen.name citizen)
|
||||||
|
|],
|
||||||
|
Expires = DateTime.UtcNow.AddHours 2.,
|
||||||
|
Issuer = "https://noagendacareers.com",
|
||||||
|
Audience = "https://noagendacareers.com",
|
||||||
|
SigningCredentials = SigningCredentials (
|
||||||
|
SymmetricSecurityKey (
|
||||||
|
Encoding.UTF8.GetBytes cfg.ServerSecret), SecurityAlgorithms.HmacSha256Signature)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tokenHandler.WriteToken token
|
||||||
|
|
||||||
471
src/JobsJobsJobs/Server/Data.fs
Normal file
471
src/JobsJobsJobs/Server/Data.fs
Normal file
@@ -0,0 +1,471 @@
|
|||||||
|
/// Data access functions for Jobs, Jobs, Jobs
|
||||||
|
module JobsJobsJobs.Api.Data
|
||||||
|
|
||||||
|
open JobsJobsJobs.Domain.Types
|
||||||
|
|
||||||
|
/// JSON converters used with RethinkDB persistence
|
||||||
|
module Converters =
|
||||||
|
|
||||||
|
open JobsJobsJobs.Domain
|
||||||
|
open Microsoft.FSharpLu.Json
|
||||||
|
open Newtonsoft.Json
|
||||||
|
open System
|
||||||
|
|
||||||
|
/// JSON converter for citizen IDs
|
||||||
|
type CitizenIdJsonConverter() =
|
||||||
|
inherit JsonConverter<CitizenId>()
|
||||||
|
override _.WriteJson(writer : JsonWriter, value : CitizenId, _ : JsonSerializer) =
|
||||||
|
writer.WriteValue (CitizenId.toString value)
|
||||||
|
override _.ReadJson(reader: JsonReader, _ : Type, _ : CitizenId, _ : bool, _ : JsonSerializer) =
|
||||||
|
(string >> CitizenId.ofString) reader.Value
|
||||||
|
|
||||||
|
/// JSON converter for continent IDs
|
||||||
|
type ContinentIdJsonConverter() =
|
||||||
|
inherit JsonConverter<ContinentId>()
|
||||||
|
override _.WriteJson(writer : JsonWriter, value : ContinentId, _ : JsonSerializer) =
|
||||||
|
writer.WriteValue (ContinentId.toString value)
|
||||||
|
override _.ReadJson(reader: JsonReader, _ : Type, _ : ContinentId, _ : bool, _ : JsonSerializer) =
|
||||||
|
(string >> ContinentId.ofString) reader.Value
|
||||||
|
|
||||||
|
/// JSON converter for Markdown strings
|
||||||
|
type MarkdownStringJsonConverter() =
|
||||||
|
inherit JsonConverter<MarkdownString>()
|
||||||
|
override _.WriteJson(writer : JsonWriter, value : MarkdownString, _ : JsonSerializer) =
|
||||||
|
let (Text text) = value
|
||||||
|
writer.WriteValue text
|
||||||
|
override _.ReadJson(reader: JsonReader, _ : Type, _ : MarkdownString, _ : bool, _ : JsonSerializer) =
|
||||||
|
(string >> Text) reader.Value
|
||||||
|
|
||||||
|
/// JSON converter for listing IDs
|
||||||
|
type ListingIdJsonConverter() =
|
||||||
|
inherit JsonConverter<ListingId>()
|
||||||
|
override _.WriteJson(writer : JsonWriter, value : ListingId, _ : JsonSerializer) =
|
||||||
|
writer.WriteValue (ListingId.toString value)
|
||||||
|
override _.ReadJson(reader: JsonReader, _ : Type, _ : ListingId, _ : bool, _ : JsonSerializer) =
|
||||||
|
(string >> ListingId.ofString) reader.Value
|
||||||
|
|
||||||
|
/// JSON converter for skill IDs
|
||||||
|
type SkillIdJsonConverter() =
|
||||||
|
inherit JsonConverter<SkillId>()
|
||||||
|
override _.WriteJson(writer : JsonWriter, value : SkillId, _ : JsonSerializer) =
|
||||||
|
writer.WriteValue (SkillId.toString value)
|
||||||
|
override _.ReadJson(reader: JsonReader, _ : Type, _ : SkillId, _ : bool, _ : JsonSerializer) =
|
||||||
|
(string >> SkillId.ofString) reader.Value
|
||||||
|
|
||||||
|
/// JSON converter for success report IDs
|
||||||
|
type SuccessIdJsonConverter() =
|
||||||
|
inherit JsonConverter<SuccessId>()
|
||||||
|
override _.WriteJson(writer : JsonWriter, value : SuccessId, _ : JsonSerializer) =
|
||||||
|
writer.WriteValue (SuccessId.toString value)
|
||||||
|
override _.ReadJson(reader: JsonReader, _ : Type, _ : SuccessId, _ : bool, _ : JsonSerializer) =
|
||||||
|
(string >> SuccessId.ofString) reader.Value
|
||||||
|
|
||||||
|
/// All JSON converters needed for the application
|
||||||
|
let all () : JsonConverter list =
|
||||||
|
[ CitizenIdJsonConverter ()
|
||||||
|
ContinentIdJsonConverter ()
|
||||||
|
MarkdownStringJsonConverter ()
|
||||||
|
ListingIdJsonConverter ()
|
||||||
|
SkillIdJsonConverter ()
|
||||||
|
SuccessIdJsonConverter ()
|
||||||
|
CompactUnionJsonConverter ()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
/// 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.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
|
||||||
|
[<RequireQualifiedAccess>]
|
||||||
|
module Startup =
|
||||||
|
|
||||||
|
open Microsoft.Extensions.Configuration
|
||||||
|
open Microsoft.Extensions.Logging
|
||||||
|
open NodaTime
|
||||||
|
open NodaTime.Serialization.JsonNet
|
||||||
|
open RethinkDb.Driver.FSharp
|
||||||
|
|
||||||
|
/// Create a RethinkDB connection
|
||||||
|
let createConnection (cfg : IConfigurationSection) (log : ILogger) =
|
||||||
|
// Add all required JSON converters
|
||||||
|
Converter.Serializer.ConfigureForNodaTime DateTimeZoneProviders.Tzdb |> ignore
|
||||||
|
Converters.all ()
|
||||||
|
|> List.iter Converter.Serializer.Converters.Add
|
||||||
|
// Connect to the database
|
||||||
|
let config = DataConfig.FromConfiguration cfg
|
||||||
|
log.LogInformation $"Connecting to rethinkdb://{config.Hostname}:{config.Port}/{config.Database}"
|
||||||
|
config.CreateConnection ()
|
||||||
|
|
||||||
|
/// Ensure the data, tables, and indexes that are required exist
|
||||||
|
let establishEnvironment (cfg : IConfigurationSection) (log : ILogger) conn = task {
|
||||||
|
// Ensure the database exists
|
||||||
|
match cfg["database"] |> Option.ofObj with
|
||||||
|
| Some database ->
|
||||||
|
let! dbs = dbList () |> result<string list> conn
|
||||||
|
match dbs |> List.contains database with
|
||||||
|
| true -> ()
|
||||||
|
| false ->
|
||||||
|
log.LogInformation $"Creating database {database}..."
|
||||||
|
do! dbCreate database |> write conn
|
||||||
|
()
|
||||||
|
| None -> ()
|
||||||
|
// Ensure the tables exist
|
||||||
|
let! tables = tableListFromDefault () |> result<string list> conn
|
||||||
|
for table in Table.all () do
|
||||||
|
if not (List.contains table tables) then
|
||||||
|
log.LogInformation $"Creating {table} table..."
|
||||||
|
do! tableCreateInDefault table |> write conn
|
||||||
|
// Ensure the indexes exist
|
||||||
|
let ensureIndexes table indexes = task {
|
||||||
|
let! tblIndexes = fromTable table |> indexList |> result<string list> conn
|
||||||
|
for index in indexes do
|
||||||
|
if not (List.contains index tblIndexes) then
|
||||||
|
log.LogInformation $"Creating \"{index}\" index on {table}"
|
||||||
|
do! fromTable table |> indexCreate index |> write conn
|
||||||
|
}
|
||||||
|
do! ensureIndexes Table.Listing [ "citizenId"; "continentId"; "isExpired" ]
|
||||||
|
do! ensureIndexes Table.Profile [ "continentId" ]
|
||||||
|
do! ensureIndexes Table.Success [ "citizenId" ]
|
||||||
|
// The instance/user is a compound index
|
||||||
|
let! userIdx = fromTable Table.Citizen |> indexList |> result<string list> conn
|
||||||
|
if not (List.contains "instanceUser" userIdx) then
|
||||||
|
do! fromTable Table.Citizen
|
||||||
|
|> indexCreateFunc "instanceUser" (fun row -> r.Array (row.G "instance", row.G "mastodonUser"))
|
||||||
|
|> write conn
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
open JobsJobsJobs.Domain
|
||||||
|
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
|
||||||
|
[<RequireQualifiedAccess>]
|
||||||
|
module Profile =
|
||||||
|
|
||||||
|
/// Count the current profiles
|
||||||
|
let count conn =
|
||||||
|
fromTable Table.Profile
|
||||||
|
|> count
|
||||||
|
|> result<int64> conn
|
||||||
|
|
||||||
|
/// Find a profile by citizen ID
|
||||||
|
let findById (citizenId : CitizenId) conn =
|
||||||
|
fromTable Table.Profile
|
||||||
|
|> get citizenId
|
||||||
|
|> resultOption<Profile> conn
|
||||||
|
|
||||||
|
/// Insert or update a profile
|
||||||
|
let save (profile : Profile) conn =
|
||||||
|
fromTable Table.Profile
|
||||||
|
|> get profile.id
|
||||||
|
|> replace profile
|
||||||
|
|> write conn
|
||||||
|
|
||||||
|
/// Delete a citizen's profile
|
||||||
|
let delete (citizenId : CitizenId) conn =
|
||||||
|
fromTable Table.Profile
|
||||||
|
|> get citizenId
|
||||||
|
|> delete
|
||||||
|
|> write conn
|
||||||
|
|
||||||
|
/// Search profiles (logged-on users)
|
||||||
|
let search (search : ProfileSearch) conn =
|
||||||
|
(seq<ReqlExpr -> ReqlExpr> {
|
||||||
|
match search.continentId with
|
||||||
|
| Some cId -> yield (fun q -> q.Filter (r.HashMap (nameof search.continentId, ContinentId.ofString cId)))
|
||||||
|
| None -> ()
|
||||||
|
match search.remoteWork with
|
||||||
|
| "" -> ()
|
||||||
|
| _ -> yield (fun q -> q.Filter (r.HashMap (nameof search.remoteWork, search.remoteWork = "yes")))
|
||||||
|
match search.skill with
|
||||||
|
| Some skl ->
|
||||||
|
yield (fun q -> q.Filter (ReqlFunction1(fun it ->
|
||||||
|
it.G("skills").Contains (ReqlFunction1(fun s -> s.G("description").Match (regexContains skl))))))
|
||||||
|
| None -> ()
|
||||||
|
match search.bioExperience with
|
||||||
|
| Some text ->
|
||||||
|
let txt = regexContains text
|
||||||
|
yield (fun q -> q.Filter (ReqlFunction1(fun it ->
|
||||||
|
it.G("biography").Match(txt).Or (it.G("experience").Match txt))))
|
||||||
|
| None -> ()
|
||||||
|
}
|
||||||
|
|> Seq.toList
|
||||||
|
|> List.fold
|
||||||
|
(fun q f -> f q)
|
||||||
|
(r.Table(Table.Profile)
|
||||||
|
.EqJoin("id", r.Table Table.Citizen)
|
||||||
|
.Without(r.HashMap ("right", "id"))
|
||||||
|
.Zip () :> ReqlExpr))
|
||||||
|
|> mergeFunc (fun it ->
|
||||||
|
r.HashMap("displayName",
|
||||||
|
r.Branch (it.G("realName" ).Default_("").Ne "", it.G "realName",
|
||||||
|
it.G("displayName").Default_("").Ne "", it.G "displayName",
|
||||||
|
it.G "mastodonUser"))
|
||||||
|
.With ("citizenId", it.G "id"))
|
||||||
|
|> pluck [ "citizenId"; "displayName"; "seekingEmployment"; "remoteWork"; "fullTime"; "lastUpdatedOn" ]
|
||||||
|
|> orderByFunc (fun it -> it.G("displayName").Downcase ())
|
||||||
|
|> result<ProfileSearchResult list> conn
|
||||||
|
|
||||||
|
// Search profiles (public)
|
||||||
|
let publicSearch (search : PublicSearch) conn =
|
||||||
|
(seq<ReqlExpr -> ReqlExpr> {
|
||||||
|
match search.continentId with
|
||||||
|
| Some cId -> yield (fun q -> q.Filter (r.HashMap (nameof search.continentId, ContinentId.ofString cId)))
|
||||||
|
| None -> ()
|
||||||
|
match search.region with
|
||||||
|
| Some reg ->
|
||||||
|
yield (fun q -> q.Filter (ReqlFunction1 (fun it -> upcast it.G("region").Match (regexContains reg))))
|
||||||
|
| None -> ()
|
||||||
|
match search.remoteWork with
|
||||||
|
| "" -> ()
|
||||||
|
| _ -> yield (fun q -> q.Filter (r.HashMap (nameof search.remoteWork, search.remoteWork = "yes")))
|
||||||
|
match search.skill with
|
||||||
|
| Some skl ->
|
||||||
|
yield (fun q -> q.Filter (ReqlFunction1 (fun it ->
|
||||||
|
it.G("skills").Contains (ReqlFunction1(fun s -> s.G("description").Match (regexContains skl))))))
|
||||||
|
| None -> ()
|
||||||
|
}
|
||||||
|
|> Seq.toList
|
||||||
|
|> List.fold
|
||||||
|
(fun q f -> f q)
|
||||||
|
(r.Table(Table.Profile)
|
||||||
|
.EqJoin("continentId", r.Table Table.Continent)
|
||||||
|
.Without(r.HashMap ("right", "id"))
|
||||||
|
.Zip()
|
||||||
|
.Filter(r.HashMap ("isPublic", true))))
|
||||||
|
|> mergeFunc (fun it ->
|
||||||
|
r.HashMap("skills",
|
||||||
|
it.G("skills").Map (ReqlFunction1 (fun skill ->
|
||||||
|
r.Branch(skill.G("notes").Default_("").Eq "", skill.G "description",
|
||||||
|
skill.G("description").Add(" (").Add(skill.G("notes")).Add ")"))))
|
||||||
|
.With("continent", it.G "name"))
|
||||||
|
|> pluck [ "continent"; "region"; "skills"; "remoteWork" ]
|
||||||
|
|> result<PublicSearchResult list> conn
|
||||||
|
|
||||||
|
/// Citizen data access functions
|
||||||
|
[<RequireQualifiedAccess>]
|
||||||
|
module Citizen =
|
||||||
|
|
||||||
|
/// Find a citizen by their ID
|
||||||
|
let findById (citizenId : CitizenId) conn =
|
||||||
|
fromTable Table.Citizen
|
||||||
|
|> get citizenId
|
||||||
|
|> resultOption<Citizen> conn
|
||||||
|
|
||||||
|
/// Find a citizen by their Mastodon username
|
||||||
|
let findByMastodonUser (instance : string) (mastodonUser : string) conn = task {
|
||||||
|
let! u =
|
||||||
|
fromTable Table.Citizen
|
||||||
|
|> getAllWithIndex [ r.Array (instance, mastodonUser) ] "instanceUser"
|
||||||
|
|> limit 1
|
||||||
|
|> result<Citizen list> conn
|
||||||
|
return List.tryHead u
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a citizen
|
||||||
|
let add (citizen : Citizen) conn =
|
||||||
|
fromTable Table.Citizen
|
||||||
|
|> insert citizen
|
||||||
|
|> write conn
|
||||||
|
|
||||||
|
/// Update the display name and last seen on date for a citizen
|
||||||
|
let logOnUpdate (citizen : Citizen) conn =
|
||||||
|
fromTable Table.Citizen
|
||||||
|
|> get citizen.id
|
||||||
|
|> update (r.HashMap( nameof citizen.displayName, citizen.displayName)
|
||||||
|
.With (nameof citizen.lastSeenOn, citizen.lastSeenOn))
|
||||||
|
|> write conn
|
||||||
|
|
||||||
|
/// Delete a citizen
|
||||||
|
let delete citizenId conn = task {
|
||||||
|
do! Profile.delete citizenId conn
|
||||||
|
do! fromTable Table.Success
|
||||||
|
|> getAllWithIndex [ citizenId ] "citizenId"
|
||||||
|
|> delete
|
||||||
|
|> write conn
|
||||||
|
do! fromTable Table.Listing
|
||||||
|
|> getAllWithIndex [ citizenId ] "citizenId"
|
||||||
|
|> delete
|
||||||
|
|> write conn
|
||||||
|
do! fromTable Table.Citizen
|
||||||
|
|> get citizenId
|
||||||
|
|> delete
|
||||||
|
|> write conn
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a citizen's real name
|
||||||
|
let realNameUpdate (citizenId : CitizenId) (realName : string option) conn =
|
||||||
|
fromTable Table.Citizen
|
||||||
|
|> get citizenId
|
||||||
|
|> update (r.HashMap (nameof realName, realName))
|
||||||
|
|> write conn
|
||||||
|
|
||||||
|
|
||||||
|
/// Continent data access functions
|
||||||
|
[<RequireQualifiedAccess>]
|
||||||
|
module Continent =
|
||||||
|
|
||||||
|
/// Get all continents
|
||||||
|
let all conn =
|
||||||
|
fromTable Table.Continent
|
||||||
|
|> result<Continent list> conn
|
||||||
|
|
||||||
|
/// Get a continent by its ID
|
||||||
|
let findById (contId : ContinentId) conn =
|
||||||
|
fromTable Table.Continent
|
||||||
|
|> get contId
|
||||||
|
|> resultOption<Continent> conn
|
||||||
|
|
||||||
|
|
||||||
|
/// Job listing data access functions
|
||||||
|
[<RequireQualifiedAccess>]
|
||||||
|
module Listing =
|
||||||
|
|
||||||
|
open NodaTime
|
||||||
|
|
||||||
|
/// Find all job listings posted by the given citizen
|
||||||
|
let findByCitizen (citizenId : CitizenId) conn =
|
||||||
|
fromTable Table.Listing
|
||||||
|
|> getAllWithIndex [ citizenId ] (nameof citizenId)
|
||||||
|
|> eqJoin "continentId" (fromTable Table.Continent)
|
||||||
|
|> mapFunc (fun it -> r.HashMap("listing", it.G "left").With ("continent", it.G "right"))
|
||||||
|
|> result<ListingForView list> conn
|
||||||
|
|
||||||
|
/// Find a listing by its ID
|
||||||
|
let findById (listingId : ListingId) conn =
|
||||||
|
fromTable Table.Listing
|
||||||
|
|> get listingId
|
||||||
|
|> resultOption<Listing> conn
|
||||||
|
|
||||||
|
/// Find a listing by its ID for viewing (includes continent information)
|
||||||
|
let findByIdForView (listingId : ListingId) conn = task {
|
||||||
|
let! listing =
|
||||||
|
fromTable Table.Listing
|
||||||
|
|> filter (r.HashMap ("id", listingId))
|
||||||
|
|> eqJoin "continentId" (fromTable Table.Continent)
|
||||||
|
|> mapFunc (fun it -> r.HashMap("listing", it.G "left").With ("continent", it.G "right"))
|
||||||
|
|> result<ListingForView list> conn
|
||||||
|
return List.tryHead listing
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a listing
|
||||||
|
let add (listing : Listing) conn =
|
||||||
|
fromTable Table.Listing
|
||||||
|
|> insert listing
|
||||||
|
|> write conn
|
||||||
|
|
||||||
|
/// Update a listing
|
||||||
|
let update (listing : Listing) conn =
|
||||||
|
fromTable Table.Listing
|
||||||
|
|> get listing.id
|
||||||
|
|> replace listing
|
||||||
|
|> write conn
|
||||||
|
|
||||||
|
/// Expire a listing
|
||||||
|
let expire (listingId : ListingId) (fromHere : bool) (now : Instant) conn =
|
||||||
|
(fromTable Table.Listing
|
||||||
|
|> get listingId)
|
||||||
|
.Update (r.HashMap("isExpired", true).With("wasFilledHere", fromHere).With ("updatedOn", now))
|
||||||
|
|> write conn
|
||||||
|
|
||||||
|
/// Search job listings
|
||||||
|
let search (search : ListingSearch) conn =
|
||||||
|
(seq<ReqlExpr -> ReqlExpr> {
|
||||||
|
match search.continentId with
|
||||||
|
| Some cId -> yield (fun q -> q.Filter (r.HashMap (nameof search.continentId, ContinentId.ofString cId)))
|
||||||
|
| None -> ()
|
||||||
|
match search.region with
|
||||||
|
| Some rgn ->
|
||||||
|
yield (fun q ->
|
||||||
|
q.Filter (ReqlFunction1 (fun it -> it.G(nameof search.region).Match (regexContains rgn))))
|
||||||
|
| None -> ()
|
||||||
|
match search.remoteWork with
|
||||||
|
| "" -> ()
|
||||||
|
| _ -> yield (fun q -> q.Filter (r.HashMap (nameof search.remoteWork, search.remoteWork = "yes")))
|
||||||
|
match search.text with
|
||||||
|
| Some text ->
|
||||||
|
yield (fun q ->
|
||||||
|
q.Filter (ReqlFunction1 (fun it -> it.G(nameof search.text).Match (regexContains text))))
|
||||||
|
| None -> ()
|
||||||
|
}
|
||||||
|
|> Seq.toList
|
||||||
|
|> List.fold
|
||||||
|
(fun q f -> f q)
|
||||||
|
(fromTable Table.Listing
|
||||||
|
|> getAllWithIndex [ false ] "isExpired" :> ReqlExpr))
|
||||||
|
|> eqJoin "continentId" (fromTable Table.Continent)
|
||||||
|
|> mapFunc (fun it -> r.HashMap("listing", it.G "left").With ("continent", it.G "right"))
|
||||||
|
|> result<ListingForView list> conn
|
||||||
|
|
||||||
|
|
||||||
|
/// Success story data access functions
|
||||||
|
[<RequireQualifiedAccess>]
|
||||||
|
module Success =
|
||||||
|
|
||||||
|
/// Find a success report by its ID
|
||||||
|
let findById (successId : SuccessId) conn =
|
||||||
|
fromTable Table.Success
|
||||||
|
|> get successId
|
||||||
|
|> resultOption conn
|
||||||
|
|
||||||
|
/// Insert or update a success story
|
||||||
|
let save (success : Success) conn =
|
||||||
|
fromTable Table.Success
|
||||||
|
|> get success.id
|
||||||
|
|> replace success
|
||||||
|
|> write conn
|
||||||
|
|
||||||
|
// Retrieve all success stories
|
||||||
|
let all conn =
|
||||||
|
(fromTable Table.Success
|
||||||
|
|> eqJoin "citizenId" (fromTable Table.Citizen))
|
||||||
|
.Without(r.HashMap ("right", "id"))
|
||||||
|
|> zip
|
||||||
|
|> mergeFunc (fun it ->
|
||||||
|
r.HashMap("citizenName",
|
||||||
|
r.Branch(it.G("realName" ).Default_("").Ne "", it.G "realName",
|
||||||
|
it.G("displayName").Default_("").Ne "", it.G "displayName",
|
||||||
|
it.G "mastodonUser"))
|
||||||
|
.With ("hasStory", it.G("story").Default_("").Gt ""))
|
||||||
|
|> pluck [ "id"; "citizenId"; "citizenName"; "recordedOn"; "fromHere"; "hasStory" ]
|
||||||
|
|> orderByDescending "recordedOn"
|
||||||
|
|> result<StoryEntry list> conn
|
||||||
512
src/JobsJobsJobs/Server/Handlers.fs
Normal file
512
src/JobsJobsJobs/Server/Handlers.fs
Normal file
@@ -0,0 +1,512 @@
|
|||||||
|
/// Route handlers for Giraffe endpoints
|
||||||
|
module JobsJobsJobs.Api.Handlers
|
||||||
|
|
||||||
|
open Giraffe
|
||||||
|
open JobsJobsJobs.Domain
|
||||||
|
open JobsJobsJobs.Domain.SharedTypes
|
||||||
|
open JobsJobsJobs.Domain.Types
|
||||||
|
open Microsoft.AspNetCore.Http
|
||||||
|
open Microsoft.Extensions.Logging
|
||||||
|
|
||||||
|
/// Handler to return the files required for the Vue client app
|
||||||
|
module Vue =
|
||||||
|
|
||||||
|
/// Handler that returns index.html (the Vue client app)
|
||||||
|
let app = htmlFile "wwwroot/index.html"
|
||||||
|
|
||||||
|
|
||||||
|
/// Handlers for error conditions
|
||||||
|
module Error =
|
||||||
|
|
||||||
|
open System.Threading.Tasks
|
||||||
|
|
||||||
|
/// URL prefixes for the Vue app
|
||||||
|
let vueUrls =
|
||||||
|
[ "/how-it-works"; "/privacy-policy"; "/terms-of-service"; "/citizen"; "/help-wanted"; "/listing"; "/profile"
|
||||||
|
"/so-long"; "/success-story"
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Handler that will return a status code 404 and the text "Not Found"
|
||||||
|
let notFound : HttpHandler = fun next ctx -> task {
|
||||||
|
let fac = ctx.GetService<ILoggerFactory> ()
|
||||||
|
let log = fac.CreateLogger "Handler"
|
||||||
|
let path = string ctx.Request.Path
|
||||||
|
match [ "GET"; "HEAD" ] |> List.contains ctx.Request.Method with
|
||||||
|
| true when path = "/" || vueUrls |> List.exists path.StartsWith ->
|
||||||
|
log.LogInformation "Returning Vue app"
|
||||||
|
return! Vue.app next ctx
|
||||||
|
| _ ->
|
||||||
|
log.LogInformation "Returning 404"
|
||||||
|
return! RequestErrors.NOT_FOUND $"The URL {path} was not recognized as a valid URL" next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handler that returns a 403 NOT AUTHORIZED response
|
||||||
|
let notAuthorized : HttpHandler =
|
||||||
|
setStatusCode 403 >=> fun _ _ -> Task.FromResult<HttpContext option> None
|
||||||
|
|
||||||
|
/// Handler to log 500s and return a message we can display in the application
|
||||||
|
let unexpectedError (ex: exn) (log : ILogger) =
|
||||||
|
log.LogError(ex, "An unexpected error occurred")
|
||||||
|
clearResponse >=> ServerErrors.INTERNAL_ERROR ex.Message
|
||||||
|
|
||||||
|
|
||||||
|
/// Helper functions
|
||||||
|
[<AutoOpen>]
|
||||||
|
module Helpers =
|
||||||
|
|
||||||
|
open NodaTime
|
||||||
|
open Microsoft.Extensions.Configuration
|
||||||
|
open Microsoft.Extensions.Options
|
||||||
|
open RethinkDb.Driver.Net
|
||||||
|
open System.Security.Claims
|
||||||
|
|
||||||
|
/// Get the NodaTime clock from the request context
|
||||||
|
let clock (ctx : HttpContext) = ctx.GetService<IClock> ()
|
||||||
|
|
||||||
|
/// Get the application configuration from the request context
|
||||||
|
let config (ctx : HttpContext) = ctx.GetService<IConfiguration> ()
|
||||||
|
|
||||||
|
/// Get the authorization configuration from the request context
|
||||||
|
let authConfig (ctx : HttpContext) = (ctx.GetService<IOptions<AuthOptions>> ()).Value
|
||||||
|
|
||||||
|
/// Get the logger factory from the request context
|
||||||
|
let logger (ctx : HttpContext) = ctx.GetService<ILoggerFactory> ()
|
||||||
|
|
||||||
|
/// Get the RethinkDB connection from the request context
|
||||||
|
let conn (ctx : HttpContext) = ctx.GetService<IConnection> ()
|
||||||
|
|
||||||
|
/// `None` if a `string option` is `None`, whitespace, or empty
|
||||||
|
let noneIfBlank (s : string option) =
|
||||||
|
s |> Option.map (fun x -> match x.Trim () with "" -> None | _ -> Some x) |> Option.flatten
|
||||||
|
|
||||||
|
/// `None` if a `string` is null, empty, or whitespace; otherwise, `Some` and the trimmed string
|
||||||
|
let noneIfEmpty = Option.ofObj >> noneIfBlank
|
||||||
|
|
||||||
|
/// Try to get the current user
|
||||||
|
let tryUser (ctx : HttpContext) =
|
||||||
|
ctx.User.FindFirst ClaimTypes.NameIdentifier
|
||||||
|
|> Option.ofObj
|
||||||
|
|> Option.map (fun x -> x.Value)
|
||||||
|
|
||||||
|
/// Require a user to be logged in
|
||||||
|
let authorize : HttpHandler =
|
||||||
|
fun next ctx -> match tryUser ctx with Some _ -> next ctx | None -> Error.notAuthorized next ctx
|
||||||
|
|
||||||
|
/// Get the ID of the currently logged in citizen
|
||||||
|
// NOTE: if no one is logged in, this will raise an exception
|
||||||
|
let currentCitizenId = tryUser >> Option.get >> CitizenId.ofString
|
||||||
|
|
||||||
|
/// Return an empty OK response
|
||||||
|
let ok : HttpHandler = Successful.OK ""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/// Handlers for /api/citizen routes
|
||||||
|
[<RequireQualifiedAccess>]
|
||||||
|
module Citizen =
|
||||||
|
|
||||||
|
// GET: /api/citizen/log-on/[code]
|
||||||
|
let logOn (abbr, authCode) : HttpHandler = fun next ctx -> task {
|
||||||
|
// Step 1 - Verify with Mastodon
|
||||||
|
let cfg = authConfig ctx
|
||||||
|
|
||||||
|
match cfg.Instances |> Array.tryFind (fun it -> it.Abbr = abbr) with
|
||||||
|
| Some instance ->
|
||||||
|
let log = (logger ctx).CreateLogger (nameof JobsJobsJobs.Api.Auth)
|
||||||
|
|
||||||
|
match! Auth.verifyWithMastodon authCode instance cfg.ReturnHost log with
|
||||||
|
| Ok account ->
|
||||||
|
// Step 2 - Find / establish Jobs, Jobs, Jobs account
|
||||||
|
let now = (clock ctx).GetCurrentInstant ()
|
||||||
|
let dbConn = conn ctx
|
||||||
|
let! citizen = task {
|
||||||
|
match! Data.Citizen.findByMastodonUser instance.Abbr account.Username dbConn with
|
||||||
|
| None ->
|
||||||
|
let it : Citizen =
|
||||||
|
{ id = CitizenId.create ()
|
||||||
|
instance = instance.Abbr
|
||||||
|
mastodonUser = account.Username
|
||||||
|
displayName = noneIfEmpty account.DisplayName
|
||||||
|
realName = None
|
||||||
|
profileUrl = account.Url
|
||||||
|
joinedOn = now
|
||||||
|
lastSeenOn = now
|
||||||
|
}
|
||||||
|
do! Data.Citizen.add it dbConn
|
||||||
|
return it
|
||||||
|
| Some citizen ->
|
||||||
|
let it = { citizen with displayName = noneIfEmpty account.DisplayName; lastSeenOn = now }
|
||||||
|
do! Data.Citizen.logOnUpdate it dbConn
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3 - Generate JWT
|
||||||
|
return!
|
||||||
|
json
|
||||||
|
{ jwt = Auth.createJwt citizen cfg
|
||||||
|
citizenId = CitizenId.toString citizen.id
|
||||||
|
name = Citizen.name citizen
|
||||||
|
} next ctx
|
||||||
|
| Error err -> return! RequestErrors.BAD_REQUEST err next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET: /api/citizen/[id]
|
||||||
|
let get citizenId : HttpHandler = authorize >=> fun next ctx -> task {
|
||||||
|
match! Data.Citizen.findById (CitizenId citizenId) (conn ctx) with
|
||||||
|
| Some citizen -> return! json citizen next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE: /api/citizen
|
||||||
|
let delete : HttpHandler = authorize >=> fun next ctx -> task {
|
||||||
|
do! Data.Citizen.delete (currentCitizenId ctx) (conn ctx)
|
||||||
|
return! ok next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Handlers for /api/continent routes
|
||||||
|
[<RequireQualifiedAccess>]
|
||||||
|
module Continent =
|
||||||
|
|
||||||
|
// GET: /api/continent/all
|
||||||
|
let all : HttpHandler = fun next ctx -> task {
|
||||||
|
let! continents = Data.Continent.all (conn ctx)
|
||||||
|
return! json continents next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Handlers for /api/instances routes
|
||||||
|
[<RequireQualifiedAccess>]
|
||||||
|
module Instances =
|
||||||
|
|
||||||
|
/// Convert a Mastodon instance to the one we use in the API
|
||||||
|
let private toInstance (inst : MastodonInstance) =
|
||||||
|
{ name = inst.Name
|
||||||
|
url = inst.Url
|
||||||
|
abbr = inst.Abbr
|
||||||
|
clientId = inst.ClientId
|
||||||
|
isEnabled = inst.IsEnabled
|
||||||
|
reason = inst.Reason
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET: /api/instances
|
||||||
|
let all : HttpHandler = fun next ctx -> task {
|
||||||
|
return! json ((authConfig ctx).Instances |> Array.map toInstance) next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Handlers for /api/listing[s] routes
|
||||||
|
[<RequireQualifiedAccess>]
|
||||||
|
module Listing =
|
||||||
|
|
||||||
|
open NodaTime
|
||||||
|
open System
|
||||||
|
|
||||||
|
/// Parse the string we receive from JSON into a NodaTime local date
|
||||||
|
let private parseDate = DateTime.Parse >> LocalDate.FromDateTime
|
||||||
|
|
||||||
|
// GET: /api/listings/mine
|
||||||
|
let mine : HttpHandler = authorize >=> fun next ctx -> task {
|
||||||
|
let! listings = Data.Listing.findByCitizen (currentCitizenId ctx) (conn ctx)
|
||||||
|
return! json listings next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET: /api/listing/[id]
|
||||||
|
let get listingId : HttpHandler = authorize >=> fun next ctx -> task {
|
||||||
|
match! Data.Listing.findById (ListingId listingId) (conn ctx) with
|
||||||
|
| Some listing -> return! json listing next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET: /api/listing/view/[id]
|
||||||
|
let view listingId : HttpHandler = authorize >=> fun next ctx -> task {
|
||||||
|
match! Data.Listing.findByIdForView (ListingId listingId) (conn ctx) with
|
||||||
|
| Some listing -> return! json listing next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST: /listings
|
||||||
|
let add : HttpHandler = authorize >=> fun next ctx -> task {
|
||||||
|
let! form = ctx.BindJsonAsync<ListingForm> ()
|
||||||
|
let now = (clock ctx).GetCurrentInstant ()
|
||||||
|
do! Data.Listing.add
|
||||||
|
{ id = ListingId.create ()
|
||||||
|
citizenId = currentCitizenId ctx
|
||||||
|
createdOn = now
|
||||||
|
title = form.title
|
||||||
|
continentId = ContinentId.ofString form.continentId
|
||||||
|
region = form.region
|
||||||
|
remoteWork = form.remoteWork
|
||||||
|
isExpired = false
|
||||||
|
updatedOn = now
|
||||||
|
text = Text form.text
|
||||||
|
neededBy = (form.neededBy |> Option.map parseDate)
|
||||||
|
wasFilledHere = None
|
||||||
|
} (conn ctx)
|
||||||
|
return! ok next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT: /api/listing/[id]
|
||||||
|
let update listingId : HttpHandler = authorize >=> fun next ctx -> task {
|
||||||
|
let dbConn = conn ctx
|
||||||
|
match! Data.Listing.findById (ListingId listingId) dbConn with
|
||||||
|
| Some listing when listing.citizenId <> (currentCitizenId ctx) -> return! Error.notAuthorized next ctx
|
||||||
|
| Some listing ->
|
||||||
|
let! form = ctx.BindJsonAsync<ListingForm> ()
|
||||||
|
do! Data.Listing.update
|
||||||
|
{ listing with
|
||||||
|
title = form.title
|
||||||
|
continentId = ContinentId.ofString form.continentId
|
||||||
|
region = form.region
|
||||||
|
remoteWork = form.remoteWork
|
||||||
|
text = Text form.text
|
||||||
|
neededBy = form.neededBy |> Option.map parseDate
|
||||||
|
updatedOn = (clock ctx).GetCurrentInstant ()
|
||||||
|
} dbConn
|
||||||
|
return! ok next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH: /api/listing/[id]
|
||||||
|
let expire listingId : HttpHandler = authorize >=> fun next ctx -> task {
|
||||||
|
let dbConn = conn ctx
|
||||||
|
let now = clock(ctx).GetCurrentInstant ()
|
||||||
|
match! Data.Listing.findById (ListingId listingId) dbConn with
|
||||||
|
| Some listing when listing.citizenId <> (currentCitizenId ctx) -> return! Error.notAuthorized next ctx
|
||||||
|
| Some listing ->
|
||||||
|
let! form = ctx.BindJsonAsync<ListingExpireForm> ()
|
||||||
|
do! Data.Listing.expire listing.id form.fromHere now dbConn
|
||||||
|
match form.successStory with
|
||||||
|
| Some storyText ->
|
||||||
|
do! Data.Success.save
|
||||||
|
{ id = SuccessId.create()
|
||||||
|
citizenId = currentCitizenId ctx
|
||||||
|
recordedOn = now
|
||||||
|
fromHere = form.fromHere
|
||||||
|
source = "listing"
|
||||||
|
story = (Text >> Some) storyText
|
||||||
|
} dbConn
|
||||||
|
| None -> ()
|
||||||
|
return! ok next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET: /api/listing/search
|
||||||
|
let search : HttpHandler = authorize >=> fun next ctx -> task {
|
||||||
|
let search = ctx.BindQueryString<ListingSearch> ()
|
||||||
|
let! results = Data.Listing.search search (conn ctx)
|
||||||
|
return! json results next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Handlers for /api/profile routes
|
||||||
|
[<RequireQualifiedAccess>]
|
||||||
|
module 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
|
||||||
|
// is not an error). The "get" handler returns a 404 if a profile is not found.
|
||||||
|
let current : HttpHandler = authorize >=> fun next ctx -> task {
|
||||||
|
match! Data.Profile.findById (currentCitizenId ctx) (conn ctx) with
|
||||||
|
| Some profile -> return! json profile next ctx
|
||||||
|
| None -> return! Successful.NO_CONTENT next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET: /api/profile/get/[id]
|
||||||
|
let get citizenId : HttpHandler = authorize >=> fun next ctx -> task {
|
||||||
|
match! Data.Profile.findById (CitizenId citizenId) (conn ctx) with
|
||||||
|
| Some profile -> return! json profile next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET: /api/profile/view/[id]
|
||||||
|
let view citizenId : HttpHandler = authorize >=> fun next ctx -> task {
|
||||||
|
let citId = CitizenId citizenId
|
||||||
|
let dbConn = conn ctx
|
||||||
|
match! Data.Profile.findById citId dbConn with
|
||||||
|
| Some profile ->
|
||||||
|
match! Data.Citizen.findById citId dbConn with
|
||||||
|
| Some citizen ->
|
||||||
|
match! Data.Continent.findById profile.continentId dbConn with
|
||||||
|
| Some continent ->
|
||||||
|
return!
|
||||||
|
json
|
||||||
|
{ profile = profile
|
||||||
|
citizen = citizen
|
||||||
|
continent = continent
|
||||||
|
} next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET: /api/profile/count
|
||||||
|
let count : HttpHandler = authorize >=> fun next ctx -> task {
|
||||||
|
let! theCount = Data.Profile.count (conn ctx)
|
||||||
|
return! json { count = theCount } next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST: /api/profile/save
|
||||||
|
let save : HttpHandler = authorize >=> fun next ctx -> task {
|
||||||
|
let citizenId = currentCitizenId ctx
|
||||||
|
let dbConn = conn ctx
|
||||||
|
let! form = ctx.BindJsonAsync<ProfileForm>()
|
||||||
|
let! profile = task {
|
||||||
|
match! Data.Profile.findById citizenId dbConn with
|
||||||
|
| Some p -> return p
|
||||||
|
| None -> return { Profile.empty with id = citizenId }
|
||||||
|
}
|
||||||
|
do! Data.Profile.save
|
||||||
|
{ profile with
|
||||||
|
seekingEmployment = form.isSeekingEmployment
|
||||||
|
isPublic = form.isPublic
|
||||||
|
continentId = ContinentId.ofString form.continentId
|
||||||
|
region = form.region
|
||||||
|
remoteWork = form.remoteWork
|
||||||
|
fullTime = form.fullTime
|
||||||
|
biography = Text form.biography
|
||||||
|
lastUpdatedOn = (clock ctx).GetCurrentInstant ()
|
||||||
|
experience = noneIfBlank form.experience |> Option.map Text
|
||||||
|
skills = form.skills
|
||||||
|
|> List.map (fun s ->
|
||||||
|
{ id = match s.id.StartsWith "new" with
|
||||||
|
| true -> SkillId.create ()
|
||||||
|
| false -> SkillId.ofString s.id
|
||||||
|
description = s.description
|
||||||
|
notes = noneIfBlank s.notes
|
||||||
|
})
|
||||||
|
} dbConn
|
||||||
|
do! Data.Citizen.realNameUpdate citizenId (noneIfBlank (Some form.realName)) dbConn
|
||||||
|
return! ok next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH: /api/profile/employment-found
|
||||||
|
let employmentFound : HttpHandler = authorize >=> fun next ctx -> task {
|
||||||
|
let dbConn = conn ctx
|
||||||
|
match! Data.Profile.findById (currentCitizenId ctx) dbConn with
|
||||||
|
| Some profile ->
|
||||||
|
do! Data.Profile.save { profile with seekingEmployment = false } dbConn
|
||||||
|
return! ok next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE: /api/profile
|
||||||
|
let delete : HttpHandler = authorize >=> fun next ctx -> task {
|
||||||
|
do! Data.Profile.delete (currentCitizenId ctx) (conn ctx)
|
||||||
|
return! ok next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET: /api/profile/search
|
||||||
|
let search : HttpHandler = authorize >=> fun next ctx -> task {
|
||||||
|
let search = ctx.BindQueryString<ProfileSearch> ()
|
||||||
|
let! results = Data.Profile.search search (conn ctx)
|
||||||
|
return! json results next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET: /api/profile/public-search
|
||||||
|
let publicSearch : HttpHandler = fun next ctx -> task {
|
||||||
|
let search = ctx.BindQueryString<PublicSearch> ()
|
||||||
|
let! results = Data.Profile.publicSearch search (conn ctx)
|
||||||
|
return! json results next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Handlers for /api/success routes
|
||||||
|
[<RequireQualifiedAccess>]
|
||||||
|
module Success =
|
||||||
|
|
||||||
|
open System
|
||||||
|
|
||||||
|
// GET: /api/success/[id]
|
||||||
|
let get successId : HttpHandler = authorize >=> fun next ctx -> task {
|
||||||
|
match! Data.Success.findById (SuccessId successId) (conn ctx) with
|
||||||
|
| Some story -> return! json story next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET: /api/success/list
|
||||||
|
let all : HttpHandler = authorize >=> fun next ctx -> task {
|
||||||
|
let! stories = Data.Success.all (conn ctx)
|
||||||
|
return! json stories next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST: /api/success/save
|
||||||
|
let save : HttpHandler = authorize >=> fun next ctx -> task {
|
||||||
|
let citizenId = currentCitizenId ctx
|
||||||
|
let dbConn = conn ctx
|
||||||
|
let now = (clock ctx).GetCurrentInstant ()
|
||||||
|
let! form = ctx.BindJsonAsync<StoryForm> ()
|
||||||
|
let! success = task {
|
||||||
|
match form.id with
|
||||||
|
| "new" ->
|
||||||
|
return Some { id = SuccessId.create ()
|
||||||
|
citizenId = citizenId
|
||||||
|
recordedOn = now
|
||||||
|
fromHere = form.fromHere
|
||||||
|
source = "profile"
|
||||||
|
story = noneIfEmpty form.story |> Option.map Text
|
||||||
|
}
|
||||||
|
| successId ->
|
||||||
|
match! Data.Success.findById (SuccessId.ofString successId) dbConn with
|
||||||
|
| Some story when story.citizenId = citizenId ->
|
||||||
|
return Some { story with
|
||||||
|
fromHere = form.fromHere
|
||||||
|
story = noneIfEmpty form.story |> Option.map Text
|
||||||
|
}
|
||||||
|
| Some _ | None -> return None
|
||||||
|
}
|
||||||
|
match success with
|
||||||
|
| Some story ->
|
||||||
|
do! Data.Success.save story dbConn
|
||||||
|
return! ok next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
open Giraffe.EndpointRouting
|
||||||
|
|
||||||
|
/// All available endpoints for the application
|
||||||
|
let allEndpoints = [
|
||||||
|
subRoute "/api" [
|
||||||
|
subRoute "/citizen" [
|
||||||
|
GET_HEAD [
|
||||||
|
routef "/log-on/%s/%s" Citizen.logOn
|
||||||
|
routef "/%O" Citizen.get
|
||||||
|
]
|
||||||
|
DELETE [ route "" Citizen.delete ]
|
||||||
|
]
|
||||||
|
GET_HEAD [ route "/continents" Continent.all ]
|
||||||
|
GET_HEAD [ route "/instances" Instances.all ]
|
||||||
|
subRoute "/listing" [
|
||||||
|
GET_HEAD [
|
||||||
|
routef "/%O" Listing.get
|
||||||
|
route "/search" Listing.search
|
||||||
|
routef "/%O/view" Listing.view
|
||||||
|
route "s/mine" Listing.mine
|
||||||
|
]
|
||||||
|
PATCH [ routef "/%O" Listing.expire ]
|
||||||
|
POST [ route "s" Listing.add ]
|
||||||
|
PUT [ routef "/%O" Listing.update ]
|
||||||
|
]
|
||||||
|
subRoute "/profile" [
|
||||||
|
GET_HEAD [
|
||||||
|
route "" Profile.current
|
||||||
|
route "/count" Profile.count
|
||||||
|
routef "/%O" Profile.get
|
||||||
|
routef "/%O/view" Profile.view
|
||||||
|
route "/public-search" Profile.publicSearch
|
||||||
|
route "/search" Profile.search
|
||||||
|
]
|
||||||
|
PATCH [ route "/employment-found" Profile.employmentFound ]
|
||||||
|
POST [ route "" Profile.save ]
|
||||||
|
]
|
||||||
|
subRoute "/success" [
|
||||||
|
GET_HEAD [
|
||||||
|
routef "/%O" Success.get
|
||||||
|
route "es" Success.all
|
||||||
|
]
|
||||||
|
POST [ route "" Success.save ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<TargetFramework>net5.0</TargetFramework>
|
<PublishSingleFile>true</PublishSingleFile>
|
||||||
|
<SelfContained>false</SelfContained>
|
||||||
<WarnOn>3390;$(WarnOn)</WarnOn>
|
<WarnOn>3390;$(WarnOn)</WarnOn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Domain\Domain.fsproj" />
|
<ProjectReference Include="..\Domain\JobsJobsJobs.Domain.fsproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -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>
|
||||||
28
src/JobsJobsJobs/Server/appsettings.json
Normal file
28
src/JobsJobsJobs/Server/appsettings.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"Auth": {
|
||||||
|
"ReturnHost": "http://localhost:5000",
|
||||||
|
"Instances": {
|
||||||
|
"0": {
|
||||||
|
"Name": "No Agenda Social",
|
||||||
|
"Url": "https://noagendasocial.com",
|
||||||
|
"Abbr": "nas",
|
||||||
|
"IsEnabled": true,
|
||||||
|
"Reason": ""
|
||||||
|
},
|
||||||
|
"1": {
|
||||||
|
"Name": "ITM Slaves!",
|
||||||
|
"Url": "https://itmslaves.com",
|
||||||
|
"Abbr": "itm",
|
||||||
|
"IsEnabled": false,
|
||||||
|
"Reason": "This site has changed platforms, and its integration is not yet restored"
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"Name": "Liberty Woof",
|
||||||
|
"Url": "https://libertywoof.com",
|
||||||
|
"Abbr": "lw",
|
||||||
|
"IsEnabled": false,
|
||||||
|
"Reason": "This site may have gone away; it is currently inaccessible"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace JobsJobsJobs.Shared.Api
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// A transport mechanism to send counts across the wire via JSON
|
|
||||||
/// </summary>
|
|
||||||
public record Count(int Value);
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
namespace JobsJobsJobs.Shared.Api
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// A successful log on; returns JWT, citizen ID, and display name
|
|
||||||
/// </summary>
|
|
||||||
public record LogOnSuccess(string Jwt, string Id, string Name)
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The ID return value as a citizen ID
|
|
||||||
/// </summary>
|
|
||||||
public CitizenId CitizenId => CitizenId.Parse(Id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.Linq;
|
|
||||||
|
|
||||||
namespace JobsJobsJobs.Shared.Api
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The data required to update a profile
|
|
||||||
/// </summary>
|
|
||||||
public class ProfileForm
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Whether the citizen to whom this profile belongs is actively seeking employment
|
|
||||||
/// </summary>
|
|
||||||
public bool IsSeekingEmployment { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether this profile should appear in the public search
|
|
||||||
/// </summary>
|
|
||||||
public bool IsPublic { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The user's real name
|
|
||||||
/// </summary>
|
|
||||||
[StringLength(255)]
|
|
||||||
public string RealName { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The ID of the continent on which the citizen is located
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
[StringLength(12, MinimumLength = 1)]
|
|
||||||
[Display(Name = "Continent")]
|
|
||||||
public string ContinentId { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The area within that continent where the citizen is located
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
[StringLength(255)]
|
|
||||||
public string Region { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// If the citizen is available for remote work
|
|
||||||
/// </summary>
|
|
||||||
public bool RemoteWork { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// If the citizen is seeking full-time employment
|
|
||||||
/// </summary>
|
|
||||||
public bool FullTime { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The user's professional biography
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
public string Biography { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The user's past experience
|
|
||||||
/// </summary>
|
|
||||||
public string Experience { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The skills for the user
|
|
||||||
/// </summary>
|
|
||||||
public ICollection<SkillForm> Skills { get; set; } = new List<SkillForm>();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Create an instance of this form from the given profile
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="profile">The profile off which this form will be based</param>
|
|
||||||
/// <returns>The profile form, popluated with values from the given profile</returns>
|
|
||||||
public static ProfileForm FromProfile(Profile profile) =>
|
|
||||||
new ProfileForm
|
|
||||||
{
|
|
||||||
IsSeekingEmployment = profile.SeekingEmployment,
|
|
||||||
IsPublic = profile.IsPublic,
|
|
||||||
ContinentId = profile.ContinentId.ToString(),
|
|
||||||
Region = profile.Region,
|
|
||||||
RemoteWork = profile.RemoteWork,
|
|
||||||
FullTime = profile.FullTime,
|
|
||||||
Biography = profile.Biography.Text,
|
|
||||||
Experience = profile.Experience?.Text ?? "",
|
|
||||||
Skills = profile.Skills.Select(s => new SkillForm
|
|
||||||
{
|
|
||||||
Id = s.Id.ToString(),
|
|
||||||
Description = s.Description,
|
|
||||||
Notes = s.Notes
|
|
||||||
}).ToList()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
namespace JobsJobsJobs.Shared.Api
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The various ways profiles can be searched
|
|
||||||
/// </summary>
|
|
||||||
public class ProfileSearch
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieve citizens from this continent
|
|
||||||
/// </summary>
|
|
||||||
public string? ContinentId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Text for a search within a citizen's skills
|
|
||||||
/// </summary>
|
|
||||||
public string? Skill { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Text for a search with a citizen's professional biography and experience fields
|
|
||||||
/// </summary>
|
|
||||||
public string? BioExperience { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether to retrieve citizens who do or do not want remote work
|
|
||||||
/// </summary>
|
|
||||||
public string RemoteWork { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Is the search empty?
|
|
||||||
/// </summary>
|
|
||||||
public bool IsEmptySearch =>
|
|
||||||
string.IsNullOrEmpty(ContinentId)
|
|
||||||
&& string.IsNullOrEmpty(Skill)
|
|
||||||
&& string.IsNullOrEmpty(BioExperience)
|
|
||||||
&& string.IsNullOrEmpty(RemoteWork);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
using NodaTime;
|
|
||||||
|
|
||||||
namespace JobsJobsJobs.Shared.Api
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// A user matching the profile search
|
|
||||||
/// </summary>
|
|
||||||
public record ProfileSearchResult(
|
|
||||||
CitizenId CitizenId,
|
|
||||||
string DisplayName,
|
|
||||||
bool SeekingEmployment,
|
|
||||||
bool RemoteWork,
|
|
||||||
bool FullTime,
|
|
||||||
Instant LastUpdated);
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
namespace JobsJobsJobs.Shared.Api
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The parameters for a public job search
|
|
||||||
/// </summary>
|
|
||||||
public class PublicSearch
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieve citizens from this continent
|
|
||||||
/// </summary>
|
|
||||||
public string? ContinentId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieve citizens from this region
|
|
||||||
/// </summary>
|
|
||||||
public string? Region { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Text for a search within a citizen's skills
|
|
||||||
/// </summary>
|
|
||||||
public string? Skill { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether to retrieve citizens who do or do not want remote work
|
|
||||||
/// </summary>
|
|
||||||
public string RemoteWork { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Is the search empty?
|
|
||||||
/// </summary>
|
|
||||||
public bool IsEmptySearch =>
|
|
||||||
string.IsNullOrEmpty(ContinentId)
|
|
||||||
&& string.IsNullOrEmpty(Region)
|
|
||||||
&& string.IsNullOrEmpty(Skill)
|
|
||||||
&& string.IsNullOrEmpty(RemoteWork);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace JobsJobsJobs.Shared.Api
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// A public profile search result
|
|
||||||
/// </summary>
|
|
||||||
public record PublicSearchResult(
|
|
||||||
string Continent,
|
|
||||||
string Region,
|
|
||||||
bool RemoteWork,
|
|
||||||
IEnumerable<string> Skills);
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
|
|
||||||
namespace JobsJobsJobs.Shared.Api
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The fields required for a skill
|
|
||||||
/// </summary>
|
|
||||||
public class SkillForm
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The ID of this skill
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
public string Id { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The description of the skill
|
|
||||||
/// </summary>
|
|
||||||
[StringLength(100)]
|
|
||||||
public string Description { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Notes regarding the skill
|
|
||||||
/// </summary>
|
|
||||||
[StringLength(100)]
|
|
||||||
public string? Notes { get; set; } = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
using NodaTime;
|
|
||||||
|
|
||||||
namespace JobsJobsJobs.Shared.Api
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// An entry in the list of success stories
|
|
||||||
/// </summary>
|
|
||||||
public record StoryEntry(
|
|
||||||
SuccessId Id,
|
|
||||||
CitizenId CitizenId,
|
|
||||||
string CitizenName,
|
|
||||||
Instant RecordedOn,
|
|
||||||
bool FromHere,
|
|
||||||
bool HasStory);
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
namespace JobsJobsJobs.Shared.Api
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The data required to provide a success story
|
|
||||||
/// </summary>
|
|
||||||
public class StoryForm
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The ID of this story
|
|
||||||
/// </summary>
|
|
||||||
public string Id { get; set; } = "new";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether the employment was obtained from Jobs, Jobs, Jobs
|
|
||||||
/// </summary>
|
|
||||||
public bool FromHere { get; set; } = false;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The success story
|
|
||||||
/// </summary>
|
|
||||||
public string Story { get; set; } = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
using NodaTime;
|
|
||||||
|
|
||||||
namespace JobsJobsJobs.Shared
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// A user of Jobs, Jobs, Jobs
|
|
||||||
/// </summary>
|
|
||||||
public record Citizen(
|
|
||||||
CitizenId Id,
|
|
||||||
string NaUser,
|
|
||||||
string? DisplayName,
|
|
||||||
string? RealName,
|
|
||||||
string ProfileUrl,
|
|
||||||
Instant JoinedOn,
|
|
||||||
Instant LastSeenOn)
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The user's name by which they should be known
|
|
||||||
/// </summary>
|
|
||||||
public string CitizenName => RealName ?? DisplayName ?? NaUser;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace JobsJobsJobs.Shared
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// A continent
|
|
||||||
/// </summary>
|
|
||||||
public record Continent(ContinentId Id, string Name);
|
|
||||||
}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace JobsJobsJobs.Shared
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// A short ID
|
|
||||||
/// </summary>
|
|
||||||
public record ShortId(string Id)
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Validate the format of the short ID
|
|
||||||
/// </summary>
|
|
||||||
private static readonly Regex ValidShortId =
|
|
||||||
new Regex("^[a-z0-9_-]{12}", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Create a new short ID
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>A new short ID</returns>
|
|
||||||
public static async Task<ShortId> Create() => new ShortId(await Nanoid.Nanoid.GenerateAsync(size: 12));
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Try to parse a string of text into a short ID
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="text">The text of the prospective short ID</param>
|
|
||||||
/// <returns>The short ID</returns>
|
|
||||||
/// <exception cref="FormatException">If the format is not valid</exception>
|
|
||||||
public static ShortId Parse(string text)
|
|
||||||
{
|
|
||||||
if (text.Length == 12 && ValidShortId.IsMatch(text)) return new ShortId(text);
|
|
||||||
throw new FormatException($"The string {text} is not a valid short ID");
|
|
||||||
}
|
|
||||||
|
|
||||||
public override string ToString() => Id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The ID of a user (a citizen of Gitmo Nation)
|
|
||||||
/// </summary>
|
|
||||||
public record CitizenId(ShortId Id)
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Create a new citizen ID
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>A new citizen ID</returns>
|
|
||||||
public static async Task<CitizenId> Create() => new CitizenId(await ShortId.Create());
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Attempt to create a citizen ID from a string
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The prospective ID</param>
|
|
||||||
/// <returns>The citizen ID</returns>
|
|
||||||
/// <exception cref="System.FormatException">If the string is not a valid citizen ID</exception>
|
|
||||||
public static CitizenId Parse(string id) => new(ShortId.Parse(id));
|
|
||||||
|
|
||||||
public override string ToString() => Id.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The ID of a continent
|
|
||||||
/// </summary>
|
|
||||||
public record ContinentId(ShortId Id)
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Create a new continent ID
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>A new continent ID</returns>
|
|
||||||
public static async Task<ContinentId> Create() => new ContinentId(await ShortId.Create());
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Attempt to create a continent ID from a string
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The prospective ID</param>
|
|
||||||
/// <returns>The continent ID</returns>
|
|
||||||
/// <exception cref="System.FormatException">If the string is not a valid continent ID</exception>
|
|
||||||
public static ContinentId Parse(string id) => new(ShortId.Parse(id));
|
|
||||||
|
|
||||||
public override string ToString() => Id.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The ID of a job listing
|
|
||||||
/// </summary>
|
|
||||||
public record ListingId(ShortId Id)
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Create a new job listing ID
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>A new job listing ID</returns>
|
|
||||||
public static async Task<ListingId> Create() => new ListingId(await ShortId.Create());
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Attempt to create a job listing ID from a string
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The prospective ID</param>
|
|
||||||
/// <returns>The job listing ID</returns>
|
|
||||||
/// <exception cref="System.FormatException">If the string is not a valid job listing ID</exception>
|
|
||||||
public static ListingId Parse(string id) => new(ShortId.Parse(id));
|
|
||||||
|
|
||||||
public override string ToString() => Id.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The ID of a skill
|
|
||||||
/// </summary>
|
|
||||||
public record SkillId(ShortId Id)
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Create a new skill ID
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>A new skill ID</returns>
|
|
||||||
public static async Task<SkillId> Create() => new SkillId(await ShortId.Create());
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Attempt to create a skill ID from a string
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The prospective ID</param>
|
|
||||||
/// <returns>The skill ID</returns>
|
|
||||||
/// <exception cref="System.FormatException">If the string is not a valid skill ID</exception>
|
|
||||||
public static SkillId Parse(string id) => new(ShortId.Parse(id));
|
|
||||||
|
|
||||||
public override string ToString() => Id.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The ID of a success report
|
|
||||||
/// </summary>
|
|
||||||
public record SuccessId(ShortId Id)
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Create a new success report ID
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>A new success report ID</returns>
|
|
||||||
public static async Task<SuccessId> Create() => new SuccessId(await ShortId.Create());
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Attempt to create a success report ID from a string
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The prospective ID</param>
|
|
||||||
/// <returns>The success report ID</returns>
|
|
||||||
/// <exception cref="System.FormatException">If the string is not a valid success report ID</exception>
|
|
||||||
public static SuccessId Parse(string id) => new(ShortId.Parse(id));
|
|
||||||
|
|
||||||
public override string ToString() => Id.ToString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
using NodaTime;
|
|
||||||
using System;
|
|
||||||
|
|
||||||
namespace JobsJobsJobs.Shared
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// A job listing
|
|
||||||
/// </summary>
|
|
||||||
public record Listing(
|
|
||||||
ListingId Id,
|
|
||||||
CitizenId CitizenId,
|
|
||||||
Instant CreatedOn,
|
|
||||||
string Title,
|
|
||||||
ContinentId ContinentId,
|
|
||||||
string Region,
|
|
||||||
bool RemoteWork,
|
|
||||||
bool IsExpired,
|
|
||||||
Instant UpdatedOn,
|
|
||||||
MarkdownString Text,
|
|
||||||
LocalDate? NeededBy,
|
|
||||||
bool? WasFilledHere)
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Navigation property for the citizen who created the job listing
|
|
||||||
/// </summary>
|
|
||||||
public Citizen? Citizen { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Navigation property for the continent
|
|
||||||
/// </summary>
|
|
||||||
public Continent? Continent { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
using Markdig;
|
|
||||||
|
|
||||||
namespace JobsJobsJobs.Shared
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// A string of Markdown text
|
|
||||||
/// </summary>
|
|
||||||
public record MarkdownString(string Text)
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The Markdown conversion pipeline (enables all advanced features)
|
|
||||||
/// </summary>
|
|
||||||
private readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Convert this Markdown string to HTML
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>This Markdown string as HTML</returns>
|
|
||||||
public string ToHtml() => Markdown.ToHtml(Text, Pipeline);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
using NodaTime;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace JobsJobsJobs.Shared
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// A job seeker profile
|
|
||||||
/// </summary>
|
|
||||||
public record Profile(
|
|
||||||
CitizenId Id,
|
|
||||||
bool SeekingEmployment,
|
|
||||||
bool IsPublic,
|
|
||||||
ContinentId ContinentId,
|
|
||||||
string Region,
|
|
||||||
bool RemoteWork,
|
|
||||||
bool FullTime,
|
|
||||||
MarkdownString Biography,
|
|
||||||
Instant LastUpdatedOn,
|
|
||||||
MarkdownString? Experience)
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Navigation property for continent
|
|
||||||
/// </summary>
|
|
||||||
public Continent? Continent { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Convenience property for skills associated with a profile
|
|
||||||
/// </summary>
|
|
||||||
public ICollection<Skill> Skills { get; set; } = new List<Skill>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace JobsJobsJobs.Shared
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// A skill the job seeker possesses
|
|
||||||
/// </summary>
|
|
||||||
public record Skill(SkillId Id, CitizenId CitizenId, string Description, string? Notes);
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
using NodaTime;
|
|
||||||
|
|
||||||
namespace JobsJobsJobs.Shared
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// A record of success finding employment
|
|
||||||
/// </summary>
|
|
||||||
public record Success(
|
|
||||||
SuccessId Id,
|
|
||||||
CitizenId CitizenId,
|
|
||||||
Instant RecordedOn,
|
|
||||||
bool FromHere,
|
|
||||||
// string Source,
|
|
||||||
MarkdownString? Story);
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Markdig" Version="0.22.1" />
|
|
||||||
<PackageReference Include="Nanoid" Version="2.1.0" />
|
|
||||||
<PackageReference Include="NodaTime" Version="3.0.3" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<SupportedPlatform Include="browser" />
|
|
||||||
</ItemGroup>
|
|
||||||
</Project>
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
namespace JobsJobsJobs.Shared
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// A result with two different possibilities
|
|
||||||
/// </summary>
|
|
||||||
/// <typeparam name="TOk">The type of the Ok result</typeparam>
|
|
||||||
public struct Result<TOk>
|
|
||||||
{
|
|
||||||
private readonly TOk? _okValue;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Is this an Ok result?
|
|
||||||
/// </summary>
|
|
||||||
public bool IsOk { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Is this an Error result?
|
|
||||||
/// </summary>
|
|
||||||
public bool IsError
|
|
||||||
{
|
|
||||||
get => !IsOk;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The Ok value
|
|
||||||
/// </summary>
|
|
||||||
public TOk Ok
|
|
||||||
{
|
|
||||||
get => _okValue!;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The error value
|
|
||||||
/// </summary>
|
|
||||||
public string Error { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Constructor (inaccessible - use static creation methods)
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="isOk">Whether this is an Ok result</param>
|
|
||||||
/// <param name="okValue">The value of the Ok result</param>
|
|
||||||
/// <param name="error">The error message of the Error result</param>
|
|
||||||
private Result(bool isOk, TOk? okValue = default, string error = "")
|
|
||||||
{
|
|
||||||
IsOk = isOk;
|
|
||||||
_okValue = okValue;
|
|
||||||
Error = error;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Create an Ok result
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="okValue">The value of the Ok result</param>
|
|
||||||
/// <returns>The Ok result</returns>
|
|
||||||
public static Result<TOk> AsOk(TOk okValue) => new Result<TOk>(true, okValue);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Create an Error result
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="error">The error message</param>
|
|
||||||
/// <returns>The Error result</returns>
|
|
||||||
public static Result<TOk> AsError(string error) => new Result<TOk>(false) { Error = error };
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Transform a result if it is OK, passing the error along if it is an error
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="f">The transforming function</param>
|
|
||||||
/// <param name="result">The existing result</param>
|
|
||||||
/// <returns>The resultant result</returns>
|
|
||||||
public static Result<TOk> Bind(Func<TOk, Result<TOk>> f, Result<TOk> result) =>
|
|
||||||
result.IsOk ? f(result.Ok) : result;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Transform a result to a different type if it is OK, passing the error along if it is an error
|
|
||||||
/// </summary>
|
|
||||||
/// <typeparam name="TOther">The type to which the result is transformed</typeparam>
|
|
||||||
/// <param name="f">The transforming function</param>
|
|
||||||
/// <param name="result">The existing result</param>
|
|
||||||
/// <returns>The resultant result</returns>
|
|
||||||
public static Result<TOther> Map<TOther>(Func<TOk, Result<TOther>> f, Result<TOk> result) =>
|
|
||||||
result.IsOk ? f(result.Ok) : Result<TOther>.AsError(result.Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user