5 Commits
v2 ... v2.2.2

Author SHA1 Message Date
323ea83594 Version 2.2.2 (#35)
- Allow instances to be disabled (partial fix for #29 and #33)
- Use RethinkDB F# driver (partial fix for #34)
- Add FAKE build script (addresses #30)
2022-07-11 22:11:42 -04:00
6e52688622 Add API timeout / Fix .NET 6 conversion (#32)
* API requests to Mastodon instances now time out after 30 seconds (inspired by #29)
* Projects now target .NET 6 (#31)
* Minor repo reorg to support single-file deployment (#31)
2021-09-25 11:32:54 -04:00
2ff8618272 Mobile layout / .NET 6 (#28)
- Mobile menu now shows for small and extra-small windows, with the traditional menu showing above those breakpoints (landscape on larger mobile, desktop)
- The back-end is now running on .NET 6 RC 1.
- Use existing library code for page title vs. hand-rolled component
2021-09-17 12:13:32 -04:00
a1d1b53ff4 Support multiple Mastodon instances (#26)
The application handles multiple instances, and gets that information from configuration, making it much easier to bring in additional NA-affiliated instances in the future

Fixes #22
2021-09-06 21:20:51 -04:00
45861e06f0 Remove migration and shared projects (#24) 2021-09-01 15:27:39 -04:00
90 changed files with 20067 additions and 11614 deletions

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

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

4
.gitignore vendored
View File

@@ -4,6 +4,4 @@ src/**/bin
src/**/obj
src/**/appsettings.*.json
src/.vs
## This stores history of every deployment
src/JobsJobsJobs/Server/Properties/PublishProfiles/FolderProfile.pubxml.user
src/.idea

78
build.fsx Normal file
View File

@@ -0,0 +1,78 @@
#r "paket:
nuget Fake.DotNet.Cli
nuget Fake.IO.FileSystem
nuget Fake.JavaScript.Npm
nuget Fake.Core.Target //"
#load ".fake/build.fsx/intellisense.fsx"
open Fake.Core
open Fake.DotNet
open Fake.IO
open Fake.IO.Globbing.Operators
open Fake.JavaScript
open Fake.Core.TargetOperators
/// The path to the Vue client
let clientPath = "src/JobsJobsJobs/App"
/// The path to the API server
let serverPath = "src/JobsJobsJobs/Server"
Target.initEnvironment ()
Target.create "Clean" (fun _ ->
!! "src/**/bin"
++ "src/**/obj"
++ $"{serverPath}/wwwroot"
|> Shell.cleanDirs
)
Target.create "BuildClient" (fun _ ->
let inClientPath (opts : Npm.NpmParams) = { opts with WorkingDirectory = clientPath; }
Npm.exec "i --legacy-peer-deps" inClientPath
Npm.run "build" inClientPath
)
Target.create "BuildServer" (fun _ ->
DotNet.build (fun opts -> { opts with NoLogo = true }) serverPath
)
Target.create "RunServer" (fun _ ->
DotNet.exec (fun opts -> { opts with WorkingDirectory = serverPath }) "run" "" |> ignore
)
Target.create "Publish" (fun _ ->
DotNet.publish
(fun opts -> { opts with Runtime = Some "linux-x64"; SelfContained = Some false; NoLogo = true })
serverPath
)
Target.create "BuildAndRun" ignore
Target.create "All" ignore
"Clean"
==> "All"
"Clean"
==> "Publish"
"Clean"
?=> "BuildClient"
"Clean"
==> "BuildAndRun"
"BuildClient"
==> "All"
"BuildClient"
?=> "BuildServer"
"BuildClient"
?=> "RunServer"
"BuildClient"
==> "BuildAndRun"
"BuildClient"
==> "Publish"
"BuildServer"
==> "All"
"RunServer"
==> "BuildAndRun"
Target.runOrDefault "All"

232
build.fsx.lock Normal file
View File

@@ -0,0 +1,232 @@
STORAGE: NONE
RESTRICTION: || (== net6.0) (== netstandard2.0)
NUGET
remote: https://api.nuget.org/v3/index.json
BlackFox.VsWhere (1.1)
FSharp.Core (>= 4.2.3)
Microsoft.Win32.Registry (>= 4.7)
Fake.Core.CommandLineParsing (5.22)
FParsec (>= 1.1.1)
FSharp.Core (>= 6.0)
Fake.Core.Context (5.22)
FSharp.Core (>= 6.0)
Fake.Core.Environment (5.22)
FSharp.Core (>= 6.0)
Fake.Core.FakeVar (5.22)
Fake.Core.Context (>= 5.22)
FSharp.Core (>= 6.0)
Fake.Core.Process (5.22)
Fake.Core.Environment (>= 5.22)
Fake.Core.FakeVar (>= 5.22)
Fake.Core.String (>= 5.22)
Fake.Core.Trace (>= 5.22)
Fake.IO.FileSystem (>= 5.22)
FSharp.Core (>= 6.0)
System.Collections.Immutable (>= 5.0)
Fake.Core.SemVer (5.22)
FSharp.Core (>= 6.0)
Fake.Core.String (5.22)
FSharp.Core (>= 6.0)
Fake.Core.Target (5.22)
Fake.Core.CommandLineParsing (>= 5.22)
Fake.Core.Context (>= 5.22)
Fake.Core.Environment (>= 5.22)
Fake.Core.FakeVar (>= 5.22)
Fake.Core.Process (>= 5.22)
Fake.Core.String (>= 5.22)
Fake.Core.Trace (>= 5.22)
FSharp.Control.Reactive (>= 5.0.2)
FSharp.Core (>= 6.0)
Fake.Core.Tasks (5.22)
Fake.Core.Trace (>= 5.22)
FSharp.Core (>= 6.0)
Fake.Core.Trace (5.22)
Fake.Core.Environment (>= 5.22)
Fake.Core.FakeVar (>= 5.22)
FSharp.Core (>= 6.0)
Fake.Core.Xml (5.22)
Fake.Core.String (>= 5.22)
FSharp.Core (>= 6.0)
Fake.DotNet.Cli (5.22)
Fake.Core.Environment (>= 5.22)
Fake.Core.Process (>= 5.22)
Fake.Core.String (>= 5.22)
Fake.Core.Trace (>= 5.22)
Fake.DotNet.MSBuild (>= 5.22)
Fake.DotNet.NuGet (>= 5.22)
Fake.IO.FileSystem (>= 5.22)
FSharp.Core (>= 6.0)
Mono.Posix.NETStandard (>= 1.0)
Newtonsoft.Json (>= 13.0.1)
Fake.DotNet.MSBuild (5.22)
BlackFox.VsWhere (>= 1.1)
Fake.Core.Environment (>= 5.22)
Fake.Core.Process (>= 5.22)
Fake.Core.String (>= 5.22)
Fake.Core.Trace (>= 5.22)
Fake.IO.FileSystem (>= 5.22)
FSharp.Core (>= 6.0)
MSBuild.StructuredLogger (>= 2.1.545)
Fake.DotNet.NuGet (5.22)
Fake.Core.Environment (>= 5.22)
Fake.Core.Process (>= 5.22)
Fake.Core.SemVer (>= 5.22)
Fake.Core.String (>= 5.22)
Fake.Core.Tasks (>= 5.22)
Fake.Core.Trace (>= 5.22)
Fake.Core.Xml (>= 5.22)
Fake.IO.FileSystem (>= 5.22)
Fake.Net.Http (>= 5.22)
FSharp.Core (>= 6.0)
Newtonsoft.Json (>= 13.0.1)
NuGet.Protocol (>= 5.11)
Fake.IO.FileSystem (5.22)
Fake.Core.String (>= 5.22)
FSharp.Core (>= 6.0)
Fake.JavaScript.Npm (5.22)
Fake.Core.Environment (>= 5.22)
Fake.Core.Process (>= 5.22)
Fake.IO.FileSystem (>= 5.22)
Fake.Testing.Common (>= 5.22)
FSharp.Core (>= 6.0)
Fake.Net.Http (5.22)
Fake.Core.Trace (>= 5.22)
FSharp.Core (>= 6.0)
Fake.Testing.Common (5.22)
Fake.Core.Trace (>= 5.22)
FSharp.Core (>= 6.0)
FParsec (1.1.1)
FSharp.Core (>= 4.3.4)
FSharp.Control.Reactive (5.0.5)
FSharp.Core (>= 4.7.2)
System.Reactive (>= 5.0 < 6.0)
FSharp.Core (6.0.5)
Microsoft.Build (17.2)
Microsoft.Build.Framework (>= 17.2) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net472)) (&& (== netstandard2.0) (>= net6.0))
Microsoft.NET.StringTools (>= 1.0) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net472)) (&& (== netstandard2.0) (>= net6.0))
Microsoft.Win32.Registry (>= 4.3) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net6.0))
System.Collections.Immutable (>= 5.0) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net472)) (&& (== netstandard2.0) (>= net6.0))
System.Configuration.ConfigurationManager (>= 4.7) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net472)) (&& (== netstandard2.0) (>= net6.0))
System.Reflection.Metadata (>= 1.6) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net6.0))
System.Security.Principal.Windows (>= 4.7) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net6.0))
System.Text.Encoding.CodePages (>= 4.0.1) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net6.0))
System.Text.Json (>= 6.0) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net472)) (&& (== netstandard2.0) (>= net6.0))
System.Threading.Tasks.Dataflow (>= 6.0) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net472)) (&& (== netstandard2.0) (>= net6.0))
Microsoft.Build.Framework (17.2)
Microsoft.Win32.Registry (>= 4.3)
System.Security.Permissions (>= 4.7)
Microsoft.Build.Tasks.Core (17.2)
Microsoft.Build.Framework (>= 17.2)
Microsoft.Build.Utilities.Core (>= 17.2)
Microsoft.NET.StringTools (>= 1.0)
Microsoft.Win32.Registry (>= 4.3)
System.CodeDom (>= 4.4)
System.Collections.Immutable (>= 5.0)
System.Reflection.Metadata (>= 1.6)
System.Resources.Extensions (>= 4.6)
System.Security.Cryptography.Pkcs (>= 4.7)
System.Security.Cryptography.Xml (>= 4.7)
System.Security.Permissions (>= 4.7)
System.Threading.Tasks.Dataflow (>= 6.0)
Microsoft.Build.Utilities.Core (17.2)
Microsoft.Build.Framework (>= 17.2)
Microsoft.NET.StringTools (>= 1.0)
Microsoft.Win32.Registry (>= 4.3)
System.Collections.Immutable (>= 5.0)
System.Configuration.ConfigurationManager (>= 4.7)
System.Security.Permissions (>= 4.7) - restriction: == netstandard2.0
System.Text.Encoding.CodePages (>= 4.0.1) - restriction: == netstandard2.0
Microsoft.NET.StringTools (1.0)
System.Memory (>= 4.5.4)
System.Runtime.CompilerServices.Unsafe (>= 5.0)
Microsoft.NETCore.Platforms (6.0.4) - restriction: || (&& (== net6.0) (< netcoreapp3.1)) (&& (== net6.0) (< netstandard1.2)) (&& (== net6.0) (< netstandard1.3)) (&& (== net6.0) (< netstandard1.5)) (== netstandard2.0)
Microsoft.NETCore.Targets (5.0) - restriction: || (&& (== net6.0) (< netcoreapp3.1)) (&& (== net6.0) (< netstandard1.2)) (&& (== net6.0) (< netstandard1.3)) (&& (== net6.0) (< netstandard1.5)) (== netstandard2.0)
Microsoft.Win32.Registry (5.0)
System.Buffers (>= 4.5.1) - restriction: || (&& (== net6.0) (>= monoandroid) (< netstandard1.3)) (&& (== net6.0) (>= monotouch)) (&& (== net6.0) (< netcoreapp2.0)) (&& (== net6.0) (>= xamarinios)) (&& (== net6.0) (>= xamarinmac)) (&& (== net6.0) (>= xamarintvos)) (&& (== net6.0) (>= xamarinwatchos)) (== netstandard2.0)
System.Memory (>= 4.5.4) - restriction: || (&& (== net6.0) (< netcoreapp2.0)) (&& (== net6.0) (< netcoreapp2.1)) (&& (== net6.0) (>= uap10.1)) (== netstandard2.0)
System.Security.AccessControl (>= 5.0)
System.Security.Principal.Windows (>= 5.0)
Microsoft.Win32.SystemEvents (6.0.1) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= netcoreapp3.1))
Mono.Posix.NETStandard (1.0)
MSBuild.StructuredLogger (2.1.669)
Microsoft.Build (>= 16.10)
Microsoft.Build.Framework (>= 16.10)
Microsoft.Build.Tasks.Core (>= 16.10)
Microsoft.Build.Utilities.Core (>= 16.10)
Newtonsoft.Json (13.0.1)
NuGet.Common (6.2.1)
NuGet.Frameworks (>= 6.2.1)
NuGet.Configuration (6.2.1)
NuGet.Common (>= 6.2.1)
System.Security.Cryptography.ProtectedData (>= 4.4)
NuGet.Frameworks (6.2.1)
NuGet.Packaging (6.2.1)
Newtonsoft.Json (>= 13.0.1)
NuGet.Configuration (>= 6.2.1)
NuGet.Versioning (>= 6.2.1)
System.Security.Cryptography.Cng (>= 5.0)
System.Security.Cryptography.Pkcs (>= 5.0)
NuGet.Protocol (6.2.1)
NuGet.Packaging (>= 6.2.1)
NuGet.Versioning (6.2.1)
System.Buffers (4.5.1) - restriction: || (&& (== net6.0) (>= monoandroid) (< netstandard1.3)) (&& (== net6.0) (>= monotouch)) (&& (== net6.0) (< netcoreapp2.0)) (&& (== net6.0) (>= xamarinios)) (&& (== net6.0) (>= xamarinmac)) (&& (== net6.0) (>= xamarintvos)) (&& (== net6.0) (>= xamarinwatchos)) (== netstandard2.0)
System.CodeDom (6.0)
System.Collections.Immutable (6.0)
System.Memory (>= 4.5.4) - restriction: || (&& (== net6.0) (>= net461)) (== netstandard2.0)
System.Runtime.CompilerServices.Unsafe (>= 6.0)
System.Configuration.ConfigurationManager (6.0)
System.Security.Cryptography.ProtectedData (>= 6.0)
System.Security.Permissions (>= 6.0)
System.Drawing.Common (6.0) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= netcoreapp3.1))
Microsoft.Win32.SystemEvents (>= 6.0) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= netcoreapp3.1))
System.Formats.Asn1 (6.0)
System.Buffers (>= 4.5.1) - restriction: || (&& (== net6.0) (>= net461)) (== netstandard2.0)
System.Memory (>= 4.5.4) - restriction: || (&& (== net6.0) (>= net461)) (== netstandard2.0)
System.Memory (4.5.5)
System.Buffers (>= 4.5.1) - restriction: || (&& (== net6.0) (>= monotouch)) (&& (== net6.0) (>= net461)) (&& (== net6.0) (< netcoreapp2.0)) (&& (== net6.0) (< netstandard1.1)) (&& (== net6.0) (< netstandard2.0)) (&& (== net6.0) (>= xamarinios)) (&& (== net6.0) (>= xamarinmac)) (&& (== net6.0) (>= xamarintvos)) (&& (== net6.0) (>= xamarinwatchos)) (== netstandard2.0)
System.Numerics.Vectors (>= 4.4) - restriction: || (&& (== net6.0) (< netcoreapp2.0)) (== netstandard2.0)
System.Runtime.CompilerServices.Unsafe (>= 4.5.3) - restriction: || (&& (== net6.0) (>= monotouch)) (&& (== net6.0) (>= net461)) (&& (== net6.0) (< netcoreapp2.0)) (&& (== net6.0) (< netcoreapp2.1)) (&& (== net6.0) (< netstandard1.1)) (&& (== net6.0) (< netstandard2.0)) (&& (== net6.0) (>= uap10.1)) (&& (== net6.0) (>= xamarinios)) (&& (== net6.0) (>= xamarinmac)) (&& (== net6.0) (>= xamarintvos)) (&& (== net6.0) (>= xamarinwatchos)) (== netstandard2.0)
System.Numerics.Vectors (4.5) - restriction: || (&& (== net6.0) (>= net461)) (== netstandard2.0)
System.Reactive (5.0)
System.Runtime.InteropServices.WindowsRuntime (>= 4.3) - restriction: || (&& (== net6.0) (< netcoreapp3.1)) (== netstandard2.0)
System.Threading.Tasks.Extensions (>= 4.5.4) - restriction: || (&& (== net6.0) (>= net472)) (&& (== net6.0) (< netcoreapp3.1)) (&& (== net6.0) (>= uap10.1)) (== netstandard2.0)
System.Reflection.Metadata (6.0.1)
System.Collections.Immutable (>= 6.0)
System.Resources.Extensions (6.0)
System.Memory (>= 4.5.4) - restriction: || (&& (== net6.0) (>= net461)) (== netstandard2.0)
System.Runtime (4.3.1) - restriction: || (&& (== net6.0) (< netcoreapp3.1)) (== netstandard2.0)
Microsoft.NETCore.Platforms (>= 1.1.1)
Microsoft.NETCore.Targets (>= 1.1.3)
System.Runtime.CompilerServices.Unsafe (6.0)
System.Runtime.InteropServices.WindowsRuntime (4.3) - restriction: || (&& (== net6.0) (< netcoreapp3.1)) (== netstandard2.0)
System.Runtime (>= 4.3)
System.Security.AccessControl (6.0)
System.Security.Principal.Windows (>= 5.0) - restriction: || (&& (== net6.0) (>= net461)) (== netstandard2.0)
System.Security.Cryptography.Cng (5.0)
System.Formats.Asn1 (>= 5.0) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= netcoreapp3.0))
System.Security.Cryptography.Pkcs (6.0.1)
System.Buffers (>= 4.5.1) - restriction: || (&& (== net6.0) (< netstandard2.1)) (== netstandard2.0)
System.Formats.Asn1 (>= 6.0)
System.Memory (>= 4.5.4) - restriction: || (&& (== net6.0) (< netstandard2.1)) (== netstandard2.0)
System.Security.Cryptography.Cng (>= 5.0) - restriction: || (&& (== net6.0) (< netcoreapp3.1)) (&& (== net6.0) (< netstandard2.1)) (== netstandard2.0)
System.Security.Cryptography.ProtectedData (6.0)
System.Security.Cryptography.Xml (6.0)
System.Memory (>= 4.5.4) - restriction: == netstandard2.0
System.Security.AccessControl (>= 6.0)
System.Security.Cryptography.Pkcs (>= 6.0)
System.Security.Permissions (6.0)
System.Security.AccessControl (>= 6.0)
System.Windows.Extensions (>= 6.0) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= netcoreapp3.1))
System.Security.Principal.Windows (5.0)
System.Text.Encoding.CodePages (6.0)
System.Runtime.CompilerServices.Unsafe (>= 6.0)
System.Text.Encodings.Web (6.0) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net472)) (&& (== netstandard2.0) (>= net6.0))
System.Runtime.CompilerServices.Unsafe (>= 6.0)
System.Text.Json (6.0.5) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net472)) (&& (== netstandard2.0) (>= net6.0))
System.Runtime.CompilerServices.Unsafe (>= 6.0)
System.Text.Encodings.Web (>= 6.0)
System.Threading.Tasks.Dataflow (6.0)
System.Threading.Tasks.Extensions (4.5.4) - restriction: || (&& (== net6.0) (>= net472)) (&& (== net6.0) (< netcoreapp3.1)) (&& (== net6.0) (>= uap10.1)) (== netstandard2.0)
System.Runtime.CompilerServices.Unsafe (>= 4.5.3) - restriction: || (&& (== net6.0) (>= net461)) (&& (== net6.0) (< netcoreapp2.1)) (&& (== net6.0) (< netstandard1.0)) (&& (== net6.0) (< netstandard2.0)) (&& (== net6.0) (>= wp8)) (== netstandard2.0)
System.Windows.Extensions (6.0) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= netcoreapp3.1))
System.Drawing.Common (>= 6.0) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= netcoreapp3.1))

2
fake.cmd Normal file
View File

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

7
fake.sh Executable file
View File

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

View File

@@ -13,11 +13,9 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "JobsJobsJobs", "JobsJobsJobs", "{FA833B24-B8F6-4CE6-A044-99257EAC02FF}"
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
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Api", "JobsJobsJobs\Api\Api.fsproj", "{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}"
EndProject
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "DataMigrate", "JobsJobsJobs\DataMigrate\DataMigrate.fsproj", "{C5774E4F-2930-4B64-8407-77BF7EB79F39}"
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Api", "JobsJobsJobs\Server\JobsJobsJobs.Server.fsproj", "{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}"
EndProject
Global
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}.Release|Any CPU.ActiveCfg = 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
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -47,6 +41,5 @@ Global
GlobalSection(NestedProjects) = preSolution
{C81278DA-DA97-4E55-AB39-4B88565B615D} = {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
EndGlobal

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 ]
]
]
]

View File

@@ -1,6 +0,0 @@
{
"Rethink": {
"Hostname": "localhost",
"Db": "jobsjobsjobs"
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +1,23 @@
{
"name": "jobs-jobs-jobs",
"version": "2.0.0",
"version": "2.2.2",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint",
"apiserve": "vue-cli-service build && cd ../Api && dotnet run -c Debug"
"lint": "vue-cli-service lint"
},
"dependencies": {
"@mdi/js": "^5.9.55",
"@mdi/js": "^6.9.96",
"@vuelidate/core": "^2.0.0-alpha.24",
"@vuelidate/validators": "^2.0.0-alpha.21",
"@vueuse/core": "^8.9.1",
"bootstrap": "^5.1.0",
"core-js": "^3.16.3",
"date-fns": "^2.23.0",
"date-fns-tz": "^1.1.6",
"dompurify": "^2.3.1",
"marked": "^2.1.3",
"marked": "^4.0.18",
"vue": "^3.2.6",
"vue-router": "^4.0.11",
"vuex": "^4.0.0-0"
@@ -25,27 +25,28 @@
"devDependencies": {
"@types/bootstrap": "^5.1.2",
"@types/dompurify": "^2.2.3",
"@types/marked": "^2.0.5",
"@typescript-eslint/eslint-plugin": "^4.29.3",
"@typescript-eslint/parser": "^4.29.3",
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-eslint": "~4.5.0",
"@vue/cli-plugin-router": "~4.5.0",
"@vue/cli-plugin-typescript": "~4.5.0",
"@vue/cli-plugin-vuex": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"@types/marked": "^4.0.3",
"@typescript-eslint/eslint-plugin": "^5.30.0",
"@typescript-eslint/parser": "^5.30.0",
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-plugin-router": "~5.0.0",
"@vue/cli-plugin-typescript": "~5.0.0",
"@vue/cli-plugin-vuex": "~5.0.0",
"@vue/cli-service": "~5.0.0",
"@vue/compiler-sfc": "^3.2.6",
"@vue/eslint-config-standard": "^6.1.0",
"@vue/eslint-config-typescript": "^7.0.0",
"eslint": "^7.32.0",
"@vue/eslint-config-standard": "^7.0.0",
"@vue/eslint-config-typescript": "^11.0.0",
"eslint": "^8.19.0",
"eslint-plugin-import": "^2.24.2",
"eslint-plugin-n": "^15.2.4",
"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-vue": "^7.17.0",
"eslint-plugin-vue": "^9.2.0",
"sass": "~1.37.0",
"sass-loader": "^10.0.0",
"typescript": "~4.3.5",
"typescript": "~4.5.0",
"vue-cli-plugin-pug": "~2.0.0"
}
}

View File

@@ -10,11 +10,12 @@
</template>
<script lang="ts">
import { defineComponent } from "vue"
import { defineComponent, onMounted } from "vue"
import "bootstrap/dist/css/bootstrap.min.css"
import { Citizen } from "./api"
import { Mutations, useStore } from "./store"
import AppFooter from "./components/layout/AppFooter.vue"
import AppNav from "./components/layout/AppNav.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
*
@@ -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
* @returns The citizen's display name
*/
export function citizenName (cit : Citizen) : string {
return cit.realName ?? cit.displayName ?? cit.naUser
return cit.realName ?? cit.displayName ?? cit.mastodonUser
}
</script>

View File

@@ -2,6 +2,7 @@ import {
Citizen,
Continent,
Count,
Instance,
Listing,
ListingExpireForm,
ListingForm,
@@ -25,7 +26,7 @@ import {
* @param url The partial 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
@@ -100,11 +101,12 @@ export default {
/**
* 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
*/
logOn: async (code : string) : Promise<LogOnSuccess | string> => {
const resp = await fetch(apiUrl(`citizen/log-on/${code}`), { method: "GET", mode: "cors" })
logOn: async (abbr : string, code : string) : Promise<LogOnSuccess | string> => {
const resp = await fetch(apiUrl(`citizen/log-on/${abbr}/${code}`), { method: "GET", mode: "cors" })
if (resp.status === 200) return await resp.json() as LogOnSuccess
return `Error logging on - ${await resp.text()}`
},
@@ -141,6 +143,18 @@ export default {
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 */
listings: {

View File

@@ -3,8 +3,10 @@
export interface Citizen {
/** The ID of the user */
id : string
/** The abbreviation of the instance where this citizen is based */
instance : string
/** The handle by which the user is known on Mastodon */
naUser : string
mastodonUser : string
/** The user's display name from Mastodon (updated every login) */
displayName : string | undefined
/** The user's real name */
@@ -31,6 +33,22 @@ export interface Count {
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 */
export interface Listing {
/** The ID of the job listing */

View File

@@ -1,15 +1,15 @@
<template lang="pug">
form.container
.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")
.col.col-xs-12.col-sm-6.col-lg-3
.col-xs-12.col-sm-6.col-lg-3
.form-floating
input.form-control(type="text" id="regionSearch" placeholder="(free-form text)" :value="criteria.region"
@input="updateValue('region', $event.target.value)")
label(for="regionSearch") Region
.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?
br
.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'"
@click="updateValue('remoteWork', '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
input.form-control(type="text" id="textSearch" placeholder="(free-form text)" :value="criteria.text"
@input="updateValue('text', $event.target.value)")
label(for="textSearch") Job Listing Text
.form-text (free-form text)
.row: .col.col-xs-12
.row: .col
br
button.btn.btn-outline-primary(type="submit" @click.prevent="$emit('search')") Search
</template>

View File

@@ -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>

View File

@@ -0,0 +1,81 @@
<template lang="pug">
nav
template(v-if="isLoggedOn")
router-link(to="/citizen/dashboard" @click="hide") #[icon(:icon="mdiViewDashboardVariant")]&nbsp; Dashboard
router-link(to="/help-wanted" @click="hide") #[icon(:icon="mdiNewspaperVariantMultipleOutline")]&nbsp; Help Wanted!
router-link(to="/profile/search" @click="hide") #[icon(:icon="mdiViewListOutline")]&nbsp; Employment Profiles
router-link(to="/success-story/list" @click="hide") #[icon(:icon="mdiThumbUp")]&nbsp; Success Stories
.separator
router-link(to="/listings/mine" @click="hide") #[icon(:icon="mdiSignText")]&nbsp; My Job Listings
router-link(to="/citizen/profile" @click="hide") #[icon(:icon="mdiPencil")]&nbsp; My Employment Profile
.separator
router-link(to="/citizen/log-off" @click="hide") #[icon(:icon="mdiLogoutVariant")]&nbsp; Log Off
template(v-else)
router-link(to="/" @click="hide") #[icon(:icon="mdiHome")]&nbsp; Home
router-link(to="/profile/seeking" @click="hide") #[icon(:icon="mdiViewListOutline")]&nbsp; Job Seekers
router-link(to="/citizen/log-on" @click="hide") #[icon(:icon="mdiLoginVariant")]&nbsp; Log On
router-link(to="/how-it-works" @click="hide") #[icon(:icon="mdiHelpCircleOutline")]&nbsp; 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>

View File

@@ -1,88 +1,45 @@
<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 &nbsp;
nav
template(v-if="isLoggedOn")
router-link(to="/citizen/dashboard") #[icon(:icon="mdiViewDashboardVariant")]&nbsp; Dashboard
router-link(to="/help-wanted") #[icon(:icon="mdiNewspaperVariantMultipleOutline")]&nbsp; Help Wanted!
router-link(to="/profile/search") #[icon(:icon="mdiViewListOutline")]&nbsp; Employment Profiles
router-link(to="/success-story/list") #[icon(:icon="mdiThumbUp")]&nbsp; Success Stories
.separator
router-link(to="/listings/mine") #[icon(:icon="mdiSignText")]&nbsp; My Job Listings
router-link(to="/citizen/profile") #[icon(:icon="mdiPencil")]&nbsp; My Employment Profile
.separator
router-link(to="/citizen/log-off") #[icon(:icon="mdiLogoutVariant")]&nbsp; Log Off
template(v-else)
router-link(to="/") #[icon(:icon="mdiHome")]&nbsp; Home
router-link(to="/profile/seeking") #[icon(:icon="mdiViewListOutline")]&nbsp; Job Seekers
router-link(to="/citizen/log-on") #[icon(:icon="mdiLoginVariant")]&nbsp; Log On
router-link(to="/how-it-works") #[icon(:icon="mdiHelpCircleOutline")]&nbsp; How It Works
app-links
</template>
<script setup lang="ts">
import { computed } from "vue"
import { useStore } from "@/store"
import {
mdiHelpCircleOutline,
mdiHome,
mdiLoginVariant,
mdiLogoutVariant,
mdiNewspaperVariantMultipleOutline,
mdiPencil,
mdiSignText,
mdiThumbUp,
mdiViewDashboardVariant,
mdiViewListOutline
} from "@mdi/js"
import { useBreakpoints, breakpointsBootstrapV5 } from "@vueuse/core"
import AppLinks from "./AppLinks.vue"
const store = useStore()
const breakpoints = useBreakpoints(breakpointsBootstrapV5)
/** Whether a user is logged in or not */
const isLoggedOn = computed(() => store.state.user !== undefined)
/** Whether the mobile menu or the desktop menu should be shown */
const showMobileMenu = breakpoints.smaller("md")
</script>
<style lang="sass" scoped>
aside
aside,
#mobileMenu
background-image: linear-gradient(180deg, darkgreen 0%, green 70%)
color: white
font-size: 1.2rem
aside
min-height: 100vh
width: 250px
min-width: 250px
position: sticky
top: 0
path
fill: white
path:hover
fill: black
a:link, a:visited
text-decoration: none
color: white
// font-weight: 500
.home-link
.home-link
font-size: 1.2rem
text-align: center
background-color: rgba(0, 0, 0, .4)
margin: -1rem
padding: 1rem
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
a:link,
a:visited
text-decoration: none
nav > div.separator
border-bottom: solid 1px rgba(255, 255, 255, .75)
height: 1px
color: white
</style>

View File

@@ -1,16 +1,38 @@
<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 &nbsp;
span.navbar-text.
(&hellip;and Jobs &ndash; #[audio-clip(clip="pelosi-jobs") Let&rsquo;s Vote for Jobs!])
</template>
<script setup lang="ts">
import { mdiMenu } from "@mdi/js"
import { useBreakpoints, breakpointsBootstrapV5 } from "@vueuse/core"
import AudioClip from "@/components/AudioClip.vue"
const breakpoints = useBreakpoints(breakpointsBootstrapV5)
/** Whether to show the mobile or desktop header */
const showMobileHeader = breakpoints.smaller("md")
</script>
<style lang="sass" scoped>
.navbar-text
font-style: italic
.navbar-dark
background-image: linear-gradient(0deg, green 0%, darkgreen 70%)
padding-left: 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>

View File

@@ -1,15 +1,15 @@
<template lang="pug">
form.container
.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")
.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
input.form-control.form-control-sm(type="text" id="region" placeholder="(free-form text)"
:value="criteria.region" @input="updateValue('region', $event.target.value)")
label(for="region") Region
.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?
br
.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'"
@click="updateValue('remoteWork', '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
input.form-control.form-control-sm(type="text" id="skillSearch" placeholder="(free-form text)"
:value="criteria.skill" @input="updateValue('skill', $event.target.value)")
label(for="skillSearch") Skill
.form-text (free-form text)
.row: .col.col-xs-12
.row: .col
br
button.btn.btn-outline-primary(type="submit" @click.prevent="$emit('search')") Search
</template>

View File

@@ -1,9 +1,9 @@
<template lang="pug">
form.container
.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")
.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?
br
.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'"
@click="updateValue('remoteWork', '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
input.form-control(type="text" id="skillSearch" placeholder="(free-form text)" :value="criteria.skill"
@input="updateValue('skill', $event.target.value)")
label(for="skillSearch") Skill
.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
input.form-control(type="text" id="bioSearch" placeholder="(free-form text)" :value="criteria.bioExperience"
@input="updateValue('bioExperience', $event.target.value)")
label(for="bioSearch") Bio / Experience
.form-text (free-form text)
.row: .col.col-xs-12
.row: .col
br
button.btn.btn-outline-primary(type="submit" @click.prevent="$emit('search')") Search
</template>

View File

@@ -1,15 +1,15 @@
<template lang="pug">
.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')") &nbsp;&minus;&nbsp;
.col.col-xs-10.col-md-6
.col-xs-10.col-md-6
.form-floating
input.form-control(type="text" :id="`skillDesc${skill.id}`" maxlength="100"
placeholder="A skill (language, design technique, process, etc.)" :value="skill.description"
@input="updateValue('description', $event.target.value)")
label.jjj-label(:for="`skillDesc${skill.id}`") Skill
.form-text A skill (language, design technique, process, etc.)
.col.col-xs-12.col-md-5
.col-xs-12.col-md-5
.form-floating
input.form-control(type="text" :id="`skillNotes${skill.id}`" maxlength="100"
placeholder="A further description of the skill (100 characters max)" :value="skill.notes"

View File

@@ -3,13 +3,11 @@ import App from "./App.vue"
import router from "./router"
import store, { key } from "./store"
import Icon from "./components/Icon.vue"
import PageTitle from "./components/PageTitle.vue"
const app = createApp(App)
.use(router)
.use(store, key)
app.component("Icon", Icon)
app.component("PageTitle", PageTitle)
app.mount("#app")

View File

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

View File

@@ -3,14 +3,13 @@ import {
createWebHistory,
RouteLocationNormalized,
RouteLocationNormalizedLoaded,
RouteRecordName,
RouteRecordRaw
} from "vue-router"
import store from "@/store"
import store, { Mutations } from "@/store"
import Home from "@/views/Home.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"
/**
@@ -29,124 +28,141 @@ const routes: Array<RouteRecordRaw> = [
{
path: "/",
name: "Home",
component: Home
component: Home,
meta: { title: "Welcome!" }
},
{
path: "/how-it-works",
name: "HowItWorks",
component: () => import(/* webpackChunkName: "help" */ "../views/HowItWorks.vue")
component: () => import(/* webpackChunkName: "help" */ "../views/HowItWorks.vue"),
meta: { title: "How It Works" }
},
{
path: "/privacy-policy",
name: "PrivacyPolicy",
component: () => import(/* webpackChunkName: "legal" */ "../views/PrivacyPolicy.vue")
component: () => import(/* webpackChunkName: "legal" */ "../views/PrivacyPolicy.vue"),
meta: { title: "Privacy Policy" }
},
{
path: "/terms-of-service",
name: "TermsOfService",
component: () => import(/* webpackChunkName: "legal" */ "../views/TermsOfService.vue")
component: () => import(/* webpackChunkName: "legal" */ "../views/TermsOfService.vue"),
meta: { title: "Terms of Service" }
},
// Citizen URLs
{
path: "/citizen/log-on",
name: "LogOn",
component: LogOn
component: LogOn,
meta: { title: "Log On" }
},
{
path: "/citizen/authorized",
path: "/citizen/:abbr/authorized",
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",
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",
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",
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
{
path: "/help-wanted",
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",
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",
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",
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",
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
{
path: "/profile/:id/view",
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",
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",
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
{
path: "/so-long/options",
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",
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
{
path: "/success-story/list",
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",
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",
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({
history: createWebHistory(process.env.BASE_URL),
@@ -158,11 +174,12 @@ const router = createRouter({
})
// eslint-disable-next-line
router.beforeEach((to : RouteLocationNormalized, from : RouteLocationNormalized) =>{
if (store.state.user === undefined && !publicRoutes.includes(to.name ?? "")) {
router.beforeEach((to : RouteLocationNormalized, from : RouteLocationNormalized) => {
if (store.state.user === undefined && (to.meta.auth || false)) {
window.localStorage.setItem(AFTER_LOG_ON_URL, to.fullPath)
return "/citizen/log-on"
}
store.commit(Mutations.SetTitle, to.meta.title ?? "")
})
export default router

View 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"

View File

@@ -1,15 +1,22 @@
import { useTitle } from "@vueuse/core"
import { InjectionKey } from "vue"
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 */
export interface State {
/** The document's current title */
pageTitle : string
/** The currently logged-on user */
user: LogOnSuccess | undefined
user : LogOnSuccess | undefined
/** The state of the log on process */
logOnState: string
logOnState : string
/** 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 */
@@ -20,47 +27,63 @@ export function useStore () : Store<State> {
return baseUseStore(key)
}
/** The application name */
const appName = "Jobs, Jobs, Jobs"
export default createStore({
state: () : State => {
return {
pageTitle: "",
user: undefined,
logOnState: "<em>Welcome back! Verifying your No Agenda Social account&hellip;</em>",
continents: []
logOnState: "<em>Welcome back!</em>",
continents: [],
instances: []
}
},
mutations: {
setUser (state, user : LogOnSuccess) {
state.user = user
[Mutations.SetTitle]: (state, title : string) => {
state.pageTitle = title === "" ? appName : `${title} | ${appName}`
useTitle(state.pageTitle)
},
clearUser (state) {
state.user = undefined
},
setLogOnState (state, message : string) {
state.logOnState = message
},
setContinents (state, continents : Continent[]) {
state.continents = continents
}
[Mutations.SetUser]: (state, user : LogOnSuccess) => { state.user = user },
[Mutations.ClearUser]: (state) => { state.user = undefined },
[Mutations.SetLogOnState]: (state, message : string) => { state.logOnState = message },
[Mutations.SetContinents]: (state, continents : Continent[]) => { state.continents = continents },
[Mutations.SetInstances]: (state, instances : Instance[]) => { state.instances = instances }
},
actions: {
async logOn ({ commit }, code: string) {
const logOnResult = await api.citizen.logOn(code)
[Actions.LogOn]: async ({ commit }, { abbr, code }) => {
const logOnResult = await api.citizen.logOn(abbr, code)
if (typeof logOnResult === "string") {
commit("setLogOnState", logOnResult)
commit(Mutations.SetLogOnState, logOnResult)
} else {
commit("setUser", logOnResult)
commit(Mutations.SetUser, logOnResult)
}
},
async ensureContinents ({ state, commit }) {
[Actions.EnsureContinents]: async ({ state, commit }) => {
if (state.continents.length > 0) return
const theSeven = await api.continent.all()
if (typeof theSeven === "string") {
console.error(theSeven)
} 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: {
}
})
export * as Actions from "./actions"
export * as Mutations from "./mutations"

View 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"

View File

@@ -1,6 +1,5 @@
<template lang="pug">
article
page-title(title="Welcome!")
p &nbsp;
p.
Welcome to Jobs, Jobs, Jobs (AKA No Agenda Careers), where citizens of Gitmo Nation can assist one another in

View File

@@ -1,6 +1,5 @@
<template lang="pug">
article
page-title(title="How It Works")
h3 How It Works
h5.pb-3.text-muted: em Last Updated August 29#[sup th], 2021
p: em.
@@ -21,8 +20,8 @@ article
p.
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.
The citizen&rsquo;s name is a link to their profile page at No Agenda Social; you can use that to get their handle,
and use NAS&rsquo;s communication facilites to inquire about the position.
The citizen&rsquo;s name is a link to their profile page at their Mastodon instance; you can use that to get their
handle, and use Mastodon&rsquo;s communication facilites to inquire about the position.
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;
I&rsquo;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
#[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
the link from your browser over on No Agenda Social, and those who click on it will be able to view it. (Existing
users of Jobs, Jobs, Jobs will go right to it; others will need to authorize this site&rsquo;s access, but then they
will get there as well.)
the link from your browser on any No Agenda-affiliated Mastodon instance, and those who click on it will be able to
view it. (Existing users of Jobs, Jobs, Jobs will go right to it; others will need to authorize this site&rsquo;s
access, but then they will get there as well.)
h5 Expire a Job Listing
p.
@@ -68,7 +67,7 @@ article
The #[span.link Employment Profiles] link at the side allows you to search for profiles by continent, the
citizen&rsquo;s desire for remote work, a skill, or any text in their professional biography and experience. If you
find someone with whom you&rsquo;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
@@ -76,8 +75,8 @@ article
p.
The employment profile is your r&eacute;sum&eacute;, 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,
profile views, etc. If not, you will be identified as you are on No Agenda Social; this system updates your current
display name each time you log on.
profile views, etc. If not, you will be identified as you are on your Mastodon instance; this system updates your
current display name each time you log on.
h5 Completing Your Profile
p.
@@ -99,19 +98,19 @@ article
li.
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
site. They will not be tied to your No Agenda Social handle or real name; they are there to let people peek
behind the curtain a bit, and hopefully inspire them to join us.
site. They will not be tied to your Mastodon handle or real name; they are there to let people peek behind the
curtain a bit, and hopefully inspire them to join us.
h5 Viewing and Sharing Your Profile
p.
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
link to this page from the #[span.link Dashboard].) The URL of this page can be shared on No Agenda Social, if you
would like to share it there. Just as with job listings, existing users will go straight there, while other No
Agenda Social users will get there once they authorize this application.
link to this page from the #[span.link Dashboard].) The URL of this page can be shared on any No Agenda-affiliated
Mastodon instance, if you would like to share it there. Just as with job listings, existing users will go straight
there, while others will get there once they authorize this application.
p.
The name on employment profiles is a link to that user&rsquo;s profile on No Agenda Social; from there, others can
communicate further with you using the tools Mastodon provides.
The name on employment profiles is a link to that user&rsquo;s profile on their Mastodon instance; from there,
others can communicate further with you using the tools Mastodon provides.
h5 &ldquo;I Found a Job!&rdquo;
p.

View File

@@ -1,8 +1,7 @@
<template lang="pug">
article
page-title(title="Privacy Policy")
h3 Privacy Policy
p: em (as of February 6#[sup th], 2021)
p: em (as of September 6#[sup th], 2021)
p.
{{name}} (&ldquo;we,&rdquo; &ldquo;our,&rdquo; or &ldquo;us&rdquo;) is committed to protecting your privacy. This
@@ -58,7 +57,7 @@ article
li Name / Username
li Coarse Geographic Location
li Employment History
li No Agenda Social Account Name / Profile
li Mastodon Account Name / Profile
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:
@@ -75,9 +74,9 @@ article
p {{name}} will collect End User Data necessary to provide the {{name}} services to our customers.
p.
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
information from the social media websites you have indicated. You can control how much of your information social
media websites make public by visiting these websites and changing your privacy settings.
(specifically No Agenda-affiliated Mastodon instances). If you provide us with any such information, we may collect
publicly available information from the social media websites you have indicated. You can control how much of your
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?
p We do not utilize third party information apart from the end-user data described above.
@@ -223,10 +222,10 @@ article
h4 Tracking Technologies
p.
{{name}} does not use any tracking technologies. When an authorization code is received from No Agenda Social, that
token is stored in the browser&rsquo;s memory, and the Service uses tokens on each request for data. If the page is
refreshed or the browser window/tab is closed, this token disappears, and a new one must be generated before the
application can be used again.
{{name}} does not use any tracking technologies. When an authorization code is received from Mastodon, that token is
stored in the browser&rsquo;s memory, and the Service uses tokens on each request for data. If the page is refreshed
or the browser window/tab is closed, this token disappears, and a new one must be generated before the application
can be used again.
h4 Information about General Data Protection Regulation (GDPR)
p.
@@ -335,6 +334,12 @@ article
h4 Contact Us
p Don&rsquo;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]
hr
p: em.
Change on September 6#[sup th], 2021 &ndash; replaced &ldquo;No Agenda Social&rdquo; with generic terms for any
authorized Mastodon instance.
</template>
<script setup lang="ts">

View File

@@ -1,8 +1,7 @@
<template lang="pug">
article
page-title(title="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
p.
@@ -11,12 +10,19 @@ article
acceptance of these terms.
h4 Description of Service and Registration
p.
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
#[a(href="https://noagendasocial.com" target="_blank") No Agenda Social]. Registration is accomplished by allowing
Jobs, Jobs, Jobs to read one&rsquo;s No Agenda Social profile. See our
#[router-link(to="/privacy-policy") privacy policy] for details on the personal (user) information we maintain.
p
| 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 No Agenda-afilliated Mastodon sites (currently
= " "
template(v-for="(it, idx) in instances" :key="idx")
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&rsquo;s Mastodon profile. See our
= " "
router-link(to="/privacy-policy") privacy policy
= " "
| for details on the personal (user) information we maintain.
h4 Liability
p.
@@ -34,4 +40,23 @@ article
p.
You may also wish to review our #[router-link(to="/privacy-policy") privacy policy] to learn how we handle your
data.
hr
p: em.
Change on September 6#[sup th], 2021 &ndash; replaced &ldquo;No Agenda Social&rdquo; with a list of all No
Agenda-affiliated Mastodon instances.
</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>

View File

@@ -1,24 +1,36 @@
<template lang="pug">
article
page-title(title="Logging on...")
p &nbsp;
p(v-html="message")
</template>
<script setup lang="ts">
import { computed, onMounted } from "vue"
import { useRouter } from "vue-router"
import { useStore } from "@/store"
import { useRoute, useRouter } from "vue-router"
import { useStore, Actions, Mutations } from "@/store"
import { AFTER_LOG_ON_URL } from "@/router"
const router = useRouter()
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 */
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&hellip;</em>`)
const code = route.query.code
if (code) {
await store.dispatch("logOn", code)
await store.dispatch(Actions.LogOn, { abbr, code })
if (store.state.user !== undefined) {
const afterLogOnUrl = window.localStorage.getItem(AFTER_LOG_ON_URL)
if (afterLogOnUrl) {
@@ -29,8 +41,8 @@ const logOn = async () => {
}
}
} else {
store.commit("setLogOnState",
"Did not receive a token from No Agenda Social (perhaps you clicked &ldquo;Cancel&rdquo;?)")
setMessage(`Did not receive a token from ${instance.name} (perhaps you clicked &ldquo;Cancel&rdquo;?)`)
}
}
}

View File

@@ -1,12 +1,12 @@
<template lang="pug">
article.container
page-title(title="Dashboard")
h3.pb-4 Welcome, {{user.name}}
load-data(:load="retrieveData"): .row.row-cols-1.row-cols-md-2
.col: .card.h-100
h5.card-header Your Profile
.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")
| Your profile currently lists {{profile.skills.length}}
| skill#[template(v-if="profile.skills.length !== 1") s].

View File

@@ -1,14 +1,13 @@
<template lang="pug">
article
page-title(title="Edit Profile")
h3.pb-3 My Employment Profile
load-data(:load="retrieveData"): form.row.g-3
.col-12.col-sm-10.col-md-8.col-lg-6
.form-floating
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
.form-text Leave blank to use your NAS display name
.form-text Leave blank to use your Mastodon display name
.col-12
.form-check
input.form-check-input(type="checkbox" id="isSeeking" v-model="v$.isSeekingEmployment.$model")

View File

@@ -1,6 +1,5 @@
<template lang="pug">
article
page-title(title="Logging off...")
p &nbsp;
p.fst-italic Logging off&hellip;
</template>
@@ -9,13 +8,13 @@ article
import { onMounted } from "vue"
import { useRouter } from "vue-router"
import { toastSuccess } from "@/components/layout/AppToaster.vue"
import { useStore } from "@/store"
import { useStore, Mutations } from "@/store"
const store = useStore()
const router = useRouter()
onMounted(() => {
store.commit("clearUser")
store.commit(Mutations.ClearUser)
toastSuccess("Log Off Successful &nbsp; | &nbsp; <strong>Have a Nice Day!</strong>")
router.push("/")
})

View File

@@ -1,24 +1,53 @@
<template lang="pug">
article
p &nbsp;
p.fst-italic Sending you over to No Agenda Social to log on; see you back in just a second&hellip;
p.fst-italic(v-if="selected") Sending you over to {{selected.name}} to log on; see you back in just a second&hellip;
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>
<script setup lang="ts">
/**
* This component simply redirects the user to the No Agenda Social authorization page; it is separate here so that it
* can be called from two different places, and allow the app to support direct links to authorized content.
*/
import { computed, onMounted, Ref, ref } from "vue"
import { Instance } from "@/api"
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 */
const authUrl = (() => {
/** The client ID for Jobs, Jobs, Jobs at No Agenda Social */
const id = "k_06zlMy0N451meL4AqlwMQzs5PYr6g3d2Q_dCT-OjU"
const client = `client_id=${id}`
const authUrl = computed(() => {
if (selected.value) {
const client = `client_id=${selected.value.clientId}`
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"
return `https://noagendasocial.com/oauth/authorize?${client}&${scope}&${redirect}&${respType}`
})()
document.location.assign(authUrl)
return `${selected.value.url}/oauth/authorize?${client}&${scope}&${redirect}&${respType}`
}
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>

View File

@@ -1,6 +1,5 @@
<template lang="pug">
article
page-title(title="Help Wanted")
h3.pb-3 Help Wanted
p(v-if="!searched").
Enter relevant criteria to find results, or just click &ldquo;Search&rdquo; to see all current job listings.

View File

@@ -1,6 +1,5 @@
<template lang="pug">
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-else) Edit Job Listing
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 { toastError, toastSuccess } from "@/components/layout/AppToaster.vue"
import { useStore } from "@/store"
import { Mutations, useStore } from "@/store"
import ContinentList from "@/components/ContinentList.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) */
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)
if (typeof listResult === "string") {
errors.push(listResult)

View File

@@ -1,6 +1,5 @@
<template lang="pug">
article
page-title(title="Expire Job Listing")
load-data(:load="retrieveListing")
h3.pb-3 Expire Job Listing ({{listing.title}})
p: em.

View File

@@ -1,6 +1,5 @@
<template lang="pug">
article
page-title(:title="title")
load-data(:load="retrieveListing")
h3
| {{it.listing.title}}
@@ -24,7 +23,7 @@ import { formatNeededBy } from "./"
import api, { Citizen, ListingForView, LogOnSuccess } from "@/api"
import { citizenName } from "@/App.vue"
import { toHtml } from "@/markdown"
import { useStore } from "@/store"
import { Mutations, useStore } from "@/store"
import LoadData from "@/components/LoadData.vue"
const store = useStore()
@@ -48,6 +47,7 @@ const retrieveListing = async (errors : string[]) => {
errors.push("Job Listing not found")
} else {
it.value = listingResp
store.commit(Mutations.SetTitle, `${listingResp.listing.title} | Job Listing`)
const citizenResp = await api.citizen.retrieve(listingResp.listing.citizenId, user)
if (typeof citizenResp === "string") {
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 */
const details = computed(() => toHtml(it.value?.listing.text ?? ""))
/** The NAS profile URL for the citizen who posted this job listing */
const profileUrl = computed(() => citizen.value ? `https://noagendasocial.com/@${citizen.value.naUser}` : "")
/** The Mastodon profile URL for the citizen who posted this job listing */
const profileUrl = computed(() => citizen.value ? citizen.value.profileUrl : "")
/** The needed by date, formatted in SHOUTING MODE */
const neededBy = (nb : string) => formatNeededBy(nb).toUpperCase()

View File

@@ -1,6 +1,5 @@
<template lang="pug">
article
page-title(title="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
load-data(:load="getListings")

View File

@@ -1,6 +1,5 @@
<template lang="pug">
article
page-title(title="Search Profiles")
h3.pb-3 Search Profiles
p(v-if="!searched").
Enter one or more criteria to filter results, or just click &ldquo;Search&rdquo; to list all profiles.
@@ -13,23 +12,25 @@ article
thead: tr
th(scope="col") Profile
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") Full-Time?
th(scope="col") Last Updated
th.text-center(scope="col" v-if="wideDisplay") Full-Time?
th(scope="col" v-if="wideDisplay") Last Updated
tbody: tr(v-for="profile in results" :key="profile.citzenId")
td: router-link(:to="`/profile/${profile.citizenId}/view`") View
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.fullTime)}}
td: full-date(:date="profile.lastUpdatedOn")
td.text-center(v-if="wideDisplay") {{yesOrNo(profile.fullTime)}}
td(v-if="wideDisplay"): full-date(:date="profile.lastUpdatedOn")
p.pt-3(v-else-if="searched") No results found for the specified criteria
</template>
<script setup lang="ts">
import { defineComponent, Ref, ref, watch } from "vue"
import { Ref, ref, watch } from "vue"
import { useRoute, useRouter } from "vue-router"
import { useBreakpoints, breakpointsBootstrapV5 } from "@vueuse/core"
import { yesOrNo } from "@/App.vue"
import api, { LogOnSuccess, ProfileSearch, ProfileSearchResult } from "@/api"
import { queryValue } from "@/router"
@@ -43,6 +44,7 @@ import ProfileSearchForm from "@/components/profile/SearchForm.vue"
const store = useStore()
const route = useRoute()
const router = useRouter()
const breakpoints = useBreakpoints(breakpointsBootstrapV5)
/** Any errors encountered while retrieving data */
const errors : Ref<string[]> = ref([])
@@ -70,6 +72,9 @@ const results : Ref<ProfileSearchResult[]> = ref([])
/** Whether the search criteria should be collapsed */
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 */
const setUpPage = async () => {
if (queryValue(route, "searched") === "true") {

View File

@@ -34,7 +34,7 @@ import { mdiPencil } from "@mdi/js"
import api, { LogOnSuccess, ProfileForView } from "@/api"
import { citizenName } from "@/App.vue"
import { toHtml } from "@/markdown"
import { useStore } from "@/store"
import { Mutations, useStore } from "@/store"
import LoadData from "@/components/LoadData.vue"
const store = useStore()
@@ -66,14 +66,10 @@ const retrieveProfile = async (errors : string[]) => {
errors.push("Profile not found")
} else {
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 */
const bioHtml = computed(() => toHtml(it.value?.profile.biography ?? ""))

View File

@@ -1,6 +1,5 @@
<template lang="pug">
article
page-title(title="People Seeking Work")
h3.pb-3 People Seeking Work
p(v-if="!searched").
Enter one or more criteria to filter results, or just click &ldquo;Search&rdquo; to list all profiles.

View File

@@ -1,6 +1,5 @@
<template lang="pug">
article
page-title(title="Account Deletion Options")
h3.pb-3 Account Deletion Options
h4.pb-3 Option 1 &ndash; Delete Your Profile
p.
@@ -13,28 +12,31 @@ article
p.
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
#[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.
(This will not revoke this application&rsquo;s permissions on No Agenda Social; you will have to remove this
yourself. The confirmation message has a link where you can do this; once the page loads, find the
(This will not revoke this application&rsquo;s permissions on Mastodon; you will have to remove this yourself. 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 &times; Revoke] link for that entry.)
p.text-center: button.btn.btn-danger(@click.prevent="deleteAccount") Delete Your Entire Account
</template>
<script lang="ts">
<script setup lang="ts">
import { onMounted } from "vue"
import { useRouter } from "vue-router"
import api, { LogOnSuccess } from "@/api"
import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue"
import { useStore } from "@/store"
</script>
import { useStore, Actions, Mutations } from "@/store"
<script setup lang="ts">
const store = useStore()
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 */
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") {
toastError(resp, "Deleting Profile")
} else {
@@ -45,13 +47,28 @@ const deleteProfile = async () => {
/** Delete everything pertaining to the user's account */
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") {
toastError(resp, "Deleting Account")
} else {
store.commit("clearUser")
store.commit(Mutations.ClearUser)
toastSuccess("Account Deleted Successfully")
router.push("/so-long/success")
router.push(`/so-long/success/${instance.abbr}`)
}
}
}
}
onMounted(async () => { await store.dispatch(Actions.EnsureInstances) })
</script>

View File

@@ -1,11 +1,28 @@
<template lang="pug">
article
page-title(title="Account Deletion Success")
h3.pb-3 Account Deletion Success
p.
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 &times; Revoke]. Otherwise, clicking &ldquo;Log On&rdquo; in the left-hand menu will create a new, empty
account without prompting you further.
p Thank you for participating, and thank you for your courage. #GitmoNation
</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>

View File

@@ -1,6 +1,5 @@
<template lang="pug">
article
page-title(:title="title")
h3.pb-3 {{title}}
load-data(:load="retrieveStory")
p(v-if="isNew").
@@ -26,7 +25,7 @@ import useVuelidate from "@vuelidate/core"
import api, { LogOnSuccess, StoryForm } from "@/api"
import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue"
import { useStore } from "@/store"
import { Mutations, useStore } from "@/store"
import LoadData from "@/components/LoadData.vue"
import MarkdownEditor from "@/components/MarkdownEditor.vue"
@@ -45,9 +44,6 @@ const id = route.params.id as string
/** Whether this is a new story */
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 */
const story = reactive(new StoryForm())
@@ -64,6 +60,7 @@ const v$ = useVuelidate(rules, story, { $lazy: true })
const retrieveStory = async (errors : string[]) => {
if (isNew.value) {
story.id = "new"
store.commit(Mutations.SetTitle, "Tell Your Success Story")
} else {
const storyResult = await api.success.retrieve(id, user)
if (typeof storyResult === "string") {

View File

@@ -1,6 +1,5 @@
<template lang="pug">
article
page-title(title="Success Stories")
h3.pb-3 Success Stories
load-data(:load="retrieveStories")
table.table.table-sm.table-hover(v-if="stories?.length > 0")

View File

@@ -1,6 +1,5 @@
<template lang="pug">
article
page-title(title="Success Story")
load-data(:load="retrieveStory")
h3
| {{citizenName}}&rsquo;s Success Story
@@ -31,7 +30,7 @@ const user = store.state.user as LogOnSuccess
/** The story to be displayed */
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("")
/** Retrieve the success story */

View File

@@ -2,5 +2,14 @@ module.exports = {
transpileDependencies: [
'vuetify'
],
outputDir: '../Api/wwwroot'
outputDir: '../Server/wwwroot',
configureWebpack: {
module: {
rules: [{
test: /\.mjs$/,
include: /node_modules/,
type: "javascript/auto"
}]
}
}
}

View File

@@ -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

View File

@@ -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>

View File

@@ -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

View File

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

View File

@@ -1,7 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<WarnOn>3390;$(WarnOn)</WarnOn>
</PropertyGroup>
@@ -13,8 +12,10 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Markdig" Version="0.25.0" />
<PackageReference Include="NodaTime" Version="3.0.5" />
<PackageReference Include="Markdig" Version="0.30.2" />
<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>
</Project>

View File

@@ -7,16 +7,12 @@ open System
open Types
/// Format a GUID as a Short GUID
let private toShortGuid guid =
let convert (g : Guid) =
Convert.ToBase64String (g.ToByteArray ())
|> String.map (fun x -> match x with '/' -> '_' | '+' -> '-' | _ -> x)
(convert guid).Substring (0, 22)
let private toShortGuid (guid : Guid) =
Convert.ToBase64String(guid.ToByteArray ()).Replace('/', '_').Replace('+', '-')[0..21]
/// Turn a Short GUID back into a GUID
let private fromShortGuid x =
let unBase64 = x |> String.map (fun x -> match x with '_' -> '/' | '-' -> '+' | _ -> x)
(Convert.FromBase64String >> Guid) $"{unBase64}=="
let private fromShortGuid (it : string) =
(Convert.FromBase64String >> Guid) $"{it.Replace('_', '/').Replace('-', '+')}=="
/// Support functions for citizen IDs
@@ -24,7 +20,7 @@ module CitizenId =
/// Create a new citizen ID
let create () = (Guid.NewGuid >> CitizenId) ()
/// 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
let ofString = fromShortGuid >> CitizenId
@@ -33,7 +29,7 @@ module CitizenId =
module Citizen =
/// Get the name of the citizen (the first of real name, display name, or handle that is filled in)
let name x =
[ x.realName; x.displayName; Some x.naUser ]
[ x.realName; x.displayName; Some x.mastodonUser ]
|> List.find Option.isSome
|> Option.get
@@ -43,7 +39,7 @@ module ContinentId =
/// Create a new continent ID
let create () = (Guid.NewGuid >> ContinentId) ()
/// 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
let ofString = fromShortGuid >> ContinentId
@@ -53,7 +49,7 @@ module ListingId =
/// Create a new job listing ID
let create () = (Guid.NewGuid >> ListingId) ()
/// 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
let ofString = fromShortGuid >> ListingId
@@ -63,7 +59,7 @@ module MarkdownString =
/// The Markdown conversion pipeline (enables all advanced features)
let private pipeline = MarkdownPipelineBuilder().UseAdvancedExtensions().Build ()
/// 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
@@ -88,7 +84,7 @@ module SkillId =
/// Create a new skill ID
let create () = (Guid.NewGuid >> SkillId) ()
/// 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
let ofString = fromShortGuid >> SkillId
@@ -98,6 +94,6 @@ module SuccessId =
/// Create a new success report ID
let create () = (Guid.NewGuid >> SuccessId) ()
/// 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
let ofString = fromShortGuid >> SuccessId

View File

@@ -2,13 +2,14 @@
module JobsJobsJobs.Domain.SharedTypes
open JobsJobsJobs.Domain.Types
open Microsoft.Extensions.Options
open NodaTime
// fsharplint:disable FieldNames
/// The data required to add or edit a job listing
type ListingForm = {
/// The ID of the listing
type ListingForm =
{ /// The ID of the listing
id : string
/// The listing title
title : string
@@ -26,27 +27,27 @@ type ListingForm = {
/// The data needed to display a listing
type ListingForView = {
/// The listing itself
type ListingForView =
{ /// The listing itself
listing : Listing
/// The continent for that listing
continent : Continent
}
}
/// The form submitted to expire a listing
type ListingExpireForm = {
/// Whether the job was filled from here
type ListingExpireForm =
{ /// Whether the job was filled from here
fromHere : bool
/// The success story written by the user
successStory : string option
}
}
/// The various ways job listings can be searched
[<CLIMutable>]
type ListingSearch = {
/// Retrieve job listings for this continent
type ListingSearch =
{ /// Retrieve job listings for this continent
continentId : string option
/// Text for a search within a region
region : string option
@@ -58,8 +59,8 @@ type ListingSearch = {
/// A successful logon
type LogOnSuccess = {
/// The JSON Web Token (JWT) to use for API access
type LogOnSuccess =
{ /// The JSON Web Token (JWT) to use for API access
jwt : string
/// The ID of the logged-in citizen (as a string)
citizenId : string
@@ -69,15 +70,62 @@ type LogOnSuccess = {
/// A count
type Count = {
// The count being returned
type Count =
{ // The count being returned
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
type SkillForm = {
/// The ID of this skill
type SkillForm =
{ /// The ID of this skill
id : string
/// The description of the skill
description : string
@@ -87,8 +135,8 @@ type SkillForm = {
/// The data required to update a profile
[<CLIMutable; NoComparison; NoEquality>]
type ProfileForm = {
/// Whether the citizen to whom this profile belongs is actively seeking employment
type ProfileForm =
{ /// Whether the citizen to whom this profile belongs is actively seeking employment
isSeekingEmployment : bool
/// Whether this profile should appear in the public search
isPublic : bool
@@ -134,8 +182,8 @@ module ProfileForm =
/// The various ways profiles can be searched
[<CLIMutable>]
type ProfileSearch = {
/// Retrieve citizens from this continent
type ProfileSearch =
{ /// Retrieve citizens from this continent
continentId : string option
/// Text for a search within a citizen's skills
skill : string option
@@ -147,8 +195,8 @@ type ProfileSearch = {
/// A user matching the profile search
type ProfileSearchResult = {
/// The ID of the citizen
type ProfileSearchResult =
{ /// The ID of the citizen
citizenId : CitizenId
/// The citizen's display name
displayName : string
@@ -164,20 +212,20 @@ type ProfileSearchResult = {
/// The data required to show a viewable profile
type ProfileForView = {
/// The profile itself
type ProfileForView =
{ /// The profile itself
profile : Profile
/// The citizen to whom the profile belongs
citizen : Citizen
/// The continent for the profile
continent : Continent
}
}
/// The parameters for a public job search
[<CLIMutable>]
type PublicSearch = {
/// Retrieve citizens from this continent
type PublicSearch =
{ /// Retrieve citizens from this continent
continentId : string option
/// Retrieve citizens from this region
region : string option
@@ -187,20 +235,20 @@ type PublicSearch = {
remoteWork : string
}
/// Support functions for pblic searches
/// Support functions for public searches
module PublicSearch =
/// Is the search empty?
let isEmptySearch (srch : PublicSearch) =
[ srch.continentId
srch.skill
match srch.remoteWork with "" -> Some srch.remoteWork | _ -> None
let isEmptySearch (search : PublicSearch) =
[ search.continentId
search.skill
match search.remoteWork with "" -> Some search.remoteWork | _ -> None
]
|> List.exists Option.isSome
/// A public profile search result
type PublicSearchResult = {
/// The name of the continent on which the citizen resides
type PublicSearchResult =
{ /// The name of the continent on which the citizen resides
continent : string
/// The region in which the citizen resides
region : string
@@ -212,8 +260,8 @@ type PublicSearchResult = {
/// The data required to provide a success story
type StoryForm = {
/// The ID of this story
type StoryForm =
{ /// The ID of this story
id : string
/// Whether the employment was obtained from Jobs, Jobs, Jobs
fromHere : bool
@@ -223,8 +271,8 @@ type StoryForm = {
/// An entry in the list of success stories
type StoryEntry = {
/// The ID of this success story
type StoryEntry =
{ /// The ID of this success story
id : SuccessId
/// The ID of the citizen who recorded this story
citizenId : CitizenId

View File

@@ -11,11 +11,13 @@ type CitizenId = CitizenId of Guid
/// A user of Jobs, Jobs, Jobs
[<CLIMutable; NoComparison; NoEquality>]
type Citizen = {
/// The ID of the user
type Citizen =
{ /// The ID of the user
id : CitizenId
/// The Mastodon instance abbreviation from which this citizen is authorized
instance : string
/// The handle by which the user is known on Mastodon
naUser : string
mastodonUser : string
/// The user's display name from Mastodon (updated every login)
displayName : string option
/// The user's real name
@@ -34,8 +36,8 @@ type ContinentId = ContinentId of Guid
/// A continent
[<CLIMutable; NoComparison; NoEquality>]
type Continent = {
/// The ID of the continent
type Continent =
{ /// The ID of the continent
id : ContinentId
/// The name of the continent
name : string
@@ -51,8 +53,8 @@ type ListingId = ListingId of Guid
/// A job listing
[<CLIMutable; NoComparison; NoEquality>]
type Listing = {
/// The ID of the job listing
type Listing =
{ /// The ID of the job listing
id : ListingId
/// The ID of the citizen who posted the job listing
citizenId : CitizenId
@@ -83,8 +85,8 @@ type Listing = {
type SkillId = SkillId of Guid
/// A skill the job seeker possesses
type Skill = {
/// The ID of the skill
type Skill =
{ /// The ID of the skill
id : SkillId
/// A description of the skill
description : string
@@ -95,8 +97,8 @@ type Skill = {
/// A job seeker profile
[<CLIMutable; NoComparison; NoEquality>]
type Profile = {
/// The ID of the citizen to whom this profile belongs
type Profile =
{ /// The ID of the citizen to whom this profile belongs
id : CitizenId
/// Whether this citizen is actively seeking employment
seekingEmployment : bool
@@ -125,8 +127,8 @@ type SuccessId = SuccessId of Guid
/// A record of success finding employment
[<CLIMutable; NoComparison; NoEquality>]
type Success = {
/// The ID of the success report
type Success =
{ /// The ID of the success report
id : SuccessId
/// The ID of the citizen who wrote this success report
citizenId : CitizenId

View 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

View 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

View 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

View 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 ]
]
]
]

View File

@@ -2,7 +2,8 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net5.0</TargetFramework>
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>false</SelfContained>
<WarnOn>3390;$(WarnOn)</WarnOn>
</PropertyGroup>
@@ -14,7 +15,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Domain\Domain.fsproj" />
<ProjectReference Include="..\Domain\JobsJobsJobs.Domain.fsproj" />
</ItemGroup>
<ItemGroup>
@@ -22,13 +23,14 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Giraffe" Version="5.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.8" />
<PackageReference Include="Giraffe" Version="6.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.6" />
<PackageReference Include="Microsoft.FSharpLu.Json" Version="0.11.7" />
<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="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>
</Project>

View 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"
}
}
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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()
};
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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; } = "";
}
}

View File

@@ -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;
}
}

View File

@@ -1,7 +0,0 @@
namespace JobsJobsJobs.Shared
{
/// <summary>
/// A continent
/// </summary>
public record Continent(ContinentId Id, string Name);
}

View File

@@ -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();
}
}

View File

@@ -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; }
}
}

View File

@@ -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);
}
}

View File

@@ -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>();
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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);
}
}