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/**/obj
src/**/appsettings.*.json src/**/appsettings.*.json
src/.vs src/.vs
src/.idea
## This stores history of every deployment
src/JobsJobsJobs/Server/Properties/PublishProfiles/FolderProfile.pubxml.user

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

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-console": process.env.NODE_ENV === "production" ? "warn" : "off",
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off", "no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
"vue/no-multiple-template-root": "off", "vue/no-multiple-template-root": "off",
"vue/multi-word-component-names": "off",
"vue/script-setup-uses-vars": 1, "vue/script-setup-uses-vars": 1,
"quotes": ["error", "double", { avoidEscape: true, allowTemplateLiterals: true }], "quotes": ["error", "double", { avoidEscape: true, allowTemplateLiterals: true }],
"func-call-spacing": "off", "func-call-spacing": "off",

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -10,11 +10,12 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent } from "vue" import { defineComponent, onMounted } from "vue"
import "bootstrap/dist/css/bootstrap.min.css" import "bootstrap/dist/css/bootstrap.min.css"
import { Citizen } from "./api" import { Citizen } from "./api"
import { Mutations, useStore } from "./store"
import AppFooter from "./components/layout/AppFooter.vue" import AppFooter from "./components/layout/AppFooter.vue"
import AppNav from "./components/layout/AppNav.vue" import AppNav from "./components/layout/AppNav.vue"
import AppToaster from "./components/layout/AppToaster.vue" import AppToaster from "./components/layout/AppToaster.vue"
@@ -29,6 +30,10 @@ export default defineComponent({
} }
}) })
const store = useStore()
onMounted(() => store.commit(Mutations.SetTitle, "Jobs, Jobs, Jobs"))
/** /**
* Return "Yes" for true and "No" for false * Return "Yes" for true and "No" for false
* *
@@ -40,13 +45,13 @@ export function yesOrNo (cond : boolean) : string {
} }
/** /**
* Get the display name for a citizen (the first available among real, display, or NAS handle) * Get the display name for a citizen (the first available among real, display, or Mastodon handle)
* *
* @param cit The citizen * @param cit The citizen
* @returns The citizen's display name * @returns The citizen's display name
*/ */
export function citizenName (cit : Citizen) : string { export function citizenName (cit : Citizen) : string {
return cit.realName ?? cit.displayName ?? cit.naUser return cit.realName ?? cit.displayName ?? cit.mastodonUser
} }
</script> </script>

View File

@@ -2,6 +2,7 @@ import {
Citizen, Citizen,
Continent, Continent,
Count, Count,
Instance,
Listing, Listing,
ListingExpireForm, ListingExpireForm,
ListingForm, ListingForm,
@@ -25,7 +26,7 @@ import {
* @param url The partial URL for the API * @param url The partial URL for the API
* @returns A full URL for the API * @returns A full URL for the API
*/ */
const apiUrl = (url : string) : string => `http://localhost:5000/api/${url}` const apiUrl = (url : string) : string => `/api/${url}`
/** /**
* Create request init parameters * Create request init parameters
@@ -100,11 +101,12 @@ export default {
/** /**
* Log a citizen on * Log a citizen on
* *
* @param code The authorization code from No Agenda Social * @param abbr The abbreviation of the Mastodon instance that issued the code
* @param code The authorization code from Mastodon
* @returns The user result, or an error * @returns The user result, or an error
*/ */
logOn: async (code : string) : Promise<LogOnSuccess | string> => { logOn: async (abbr : string, code : string) : Promise<LogOnSuccess | string> => {
const resp = await fetch(apiUrl(`citizen/log-on/${code}`), { method: "GET", mode: "cors" }) const resp = await fetch(apiUrl(`citizen/log-on/${abbr}/${code}`), { method: "GET", mode: "cors" })
if (resp.status === 200) return await resp.json() as LogOnSuccess if (resp.status === 200) return await resp.json() as LogOnSuccess
return `Error logging on - ${await resp.text()}` return `Error logging on - ${await resp.text()}`
}, },
@@ -141,6 +143,18 @@ export default {
apiResult<Continent[]>(await fetch(apiUrl("continents"), { method: "GET" }), "retrieving continents") apiResult<Continent[]>(await fetch(apiUrl("continents"), { method: "GET" }), "retrieving continents")
}, },
/** API functions for instances */
instances: {
/**
* Get all Mastodon instances we support
*
* @returns All instances, or an error
*/
all: async () : Promise<Instance[] | string | undefined> =>
apiResult<Instance[]>(await fetch(apiUrl("instances"), { method: "GET" }), "retrieving Mastodon instances")
},
/** API functions for job listings */ /** API functions for job listings */
listings: { listings: {

View File

@@ -3,8 +3,10 @@
export interface Citizen { export interface Citizen {
/** The ID of the user */ /** The ID of the user */
id : string id : string
/** The abbreviation of the instance where this citizen is based */
instance : string
/** The handle by which the user is known on Mastodon */ /** The handle by which the user is known on Mastodon */
naUser : string mastodonUser : string
/** The user's display name from Mastodon (updated every login) */ /** The user's display name from Mastodon (updated every login) */
displayName : string | undefined displayName : string | undefined
/** The user's real name */ /** The user's real name */
@@ -31,6 +33,22 @@ export interface Count {
count : number count : number
} }
/** The Mastodon instance data provided via the Jobs, Jobs, Jobs API */
export interface Instance {
/** The name of the instance */
name : string
/** The URL for this instance */
url : string
/** The abbreviation used in the URL to distinguish this instance's return codes */
abbr : string
/** The client ID (assigned by the Mastodon server) */
clientId : string
/** Whether this instance is enabled */
isEnabled : boolean
/** If disabled, the reason why it is disabled */
reason : string
}
/** A job listing */ /** A job listing */
export interface Listing { export interface Listing {
/** The ID of the job listing */ /** The ID of the job listing */

View File

@@ -1,15 +1,15 @@
<template lang="pug"> <template lang="pug">
form.container form.container
.row .row
.col.col-xs-12.col-sm-6.col-md-4.col-lg-3 .col-xs-12.col-sm-6.col-md-4.col-lg-3
continent-list(v-model="criteria.continentId" topLabel="Any" @update:modelValue="updateContinent") continent-list(v-model="criteria.continentId" topLabel="Any" @update:modelValue="updateContinent")
.col.col-xs-12.col-sm-6.col-lg-3 .col-xs-12.col-sm-6.col-lg-3
.form-floating .form-floating
input.form-control(type="text" id="regionSearch" placeholder="(free-form text)" :value="criteria.region" input.form-control(type="text" id="regionSearch" placeholder="(free-form text)" :value="criteria.region"
@input="updateValue('region', $event.target.value)") @input="updateValue('region', $event.target.value)")
label(for="regionSearch") Region label(for="regionSearch") Region
.form-text (free-form text) .form-text (free-form text)
.col.col-xs-12.col-sm-6.col-offset-md-2.col-lg-3.col-offset-lg-0 .col-xs-12.col-sm-6.col-offset-md-2.col-lg-3.col-offset-lg-0
label.jjj-label Remote Work Opportunity? label.jjj-label Remote Work Opportunity?
br br
.form-check.form-check-inline .form-check.form-check-inline
@@ -24,13 +24,13 @@ form.container
input.form-check-input(type="radio" id="remoteNo" name="remoteWork" :checked="criteria.remoteWork === 'no'" input.form-check-input(type="radio" id="remoteNo" name="remoteWork" :checked="criteria.remoteWork === 'no'"
@click="updateValue('remoteWork', 'no')") @click="updateValue('remoteWork', 'no')")
label.form-check-label(for="remoteNo") No label.form-check-label(for="remoteNo") No
.col.col-xs-12.col-sm-6.col-lg-3 .col-xs-12.col-sm-6.col-lg-3
.form-floating .form-floating
input.form-control(type="text" id="textSearch" placeholder="(free-form text)" :value="criteria.text" input.form-control(type="text" id="textSearch" placeholder="(free-form text)" :value="criteria.text"
@input="updateValue('text', $event.target.value)") @input="updateValue('text', $event.target.value)")
label(for="textSearch") Job Listing Text label(for="textSearch") Job Listing Text
.form-text (free-form text) .form-text (free-form text)
.row: .col.col-xs-12 .row: .col
br br
button.btn.btn-outline-primary(type="submit" @click.prevent="$emit('search')") Search button.btn.btn-outline-primary(type="submit" @click.prevent="$emit('search')") Search
</template> </template>

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"> <template lang="pug">
aside.collapse.show.p-3 #mobileMenu.offcanvas.offcanvas-end(v-if="showMobileMenu" tabindex="-1" aria-labelledby="mobileMenuLabel")
.offcanvas-header
h5#mobileMenuLabel Menu
button.btn-close.text-reset(type="button" data-bs-dismiss="offcanvas" aria-label="Close")
.offcanvas-body: app-links
aside.collapse.show.p-3(v-else)
p.home-link.pb-3: router-link(to="/") Jobs, Jobs, Jobs p.home-link.pb-3: router-link(to="/") Jobs, Jobs, Jobs
p &nbsp; p &nbsp;
nav app-links
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
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue" import { useBreakpoints, breakpointsBootstrapV5 } from "@vueuse/core"
import { useStore } from "@/store" import AppLinks from "./AppLinks.vue"
import {
mdiHelpCircleOutline,
mdiHome,
mdiLoginVariant,
mdiLogoutVariant,
mdiNewspaperVariantMultipleOutline,
mdiPencil,
mdiSignText,
mdiThumbUp,
mdiViewDashboardVariant,
mdiViewListOutline
} from "@mdi/js"
const store = useStore() const breakpoints = useBreakpoints(breakpointsBootstrapV5)
/** Whether a user is logged in or not */ /** Whether the mobile menu or the desktop menu should be shown */
const isLoggedOn = computed(() => store.state.user !== undefined) const showMobileMenu = breakpoints.smaller("md")
</script> </script>
<style lang="sass" scoped> <style lang="sass" scoped>
aside aside,
#mobileMenu
background-image: linear-gradient(180deg, darkgreen 0%, green 70%) background-image: linear-gradient(180deg, darkgreen 0%, green 70%)
color: white color: white
font-size: 1.2rem font-size: 1.2rem
aside
min-height: 100vh min-height: 100vh
width: 250px width: 250px
min-width: 250px min-width: 250px
position: sticky position: sticky
top: 0 top: 0
path .home-link
fill: white
path:hover
fill: black
a:link, a:visited
text-decoration: none
color: white
// font-weight: 500
.home-link
font-size: 1.2rem font-size: 1.2rem
text-align: center text-align: center
background-color: rgba(0, 0, 0, .4) background-color: rgba(0, 0, 0, .4)
margin: -1rem margin: -1rem
padding: 1rem padding: 1rem
nav > a a:link,
display: block a:visited
width: 100%
border-radius: .25rem
padding: .5rem
margin: .5rem 0
font-size: 1rem
> i
vertical-align: top
margin-right: 1rem
&.router-link-exact-active
background-color: rgba(255, 255, 255, .2)
&:hover
background-color: rgba(255, 255, 255, .5)
color: black
text-decoration: none text-decoration: none
nav > div.separator color: white
border-bottom: solid 1px rgba(255, 255, 255, .75)
height: 1px
</style> </style>

View File

@@ -1,16 +1,38 @@
<template lang="pug"> <template lang="pug">
nav.navbar.navbar-light.bg-light nav.navbar.navbar-dark(v-if="showMobileHeader")
span.navbar-text: router-link(to="/") Jobs, Jobs, Jobs
button.btn(data-bs-toggle="offcanvas" data-bs-target="#mobileMenu" aria-controls="mobileMenu")
icon(:icon="mdiMenu")
nav.navbar.navbar-light.bg-light(v-else)
span &nbsp; span &nbsp;
span.navbar-text. span.navbar-text.
(&hellip;and Jobs &ndash; #[audio-clip(clip="pelosi-jobs") Let&rsquo;s Vote for Jobs!]) (&hellip;and Jobs &ndash; #[audio-clip(clip="pelosi-jobs") Let&rsquo;s Vote for Jobs!])
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { mdiMenu } from "@mdi/js"
import { useBreakpoints, breakpointsBootstrapV5 } from "@vueuse/core"
import AudioClip from "@/components/AudioClip.vue" import AudioClip from "@/components/AudioClip.vue"
const breakpoints = useBreakpoints(breakpointsBootstrapV5)
/** Whether to show the mobile or desktop header */
const showMobileHeader = breakpoints.smaller("md")
</script> </script>
<style lang="sass" scoped> <style lang="sass" scoped>
.navbar-text .navbar-dark
font-style: italic background-image: linear-gradient(0deg, green 0%, darkgreen 70%)
padding-left: 1rem
padding-right: 1rem padding-right: 1rem
button
padding: 0
.navbar-text
font-weight: bold
color: white
.navbar-light
.navbar-text
font-style: italic
padding: 0 1rem 0 0
</style> </style>

View File

@@ -1,15 +1,15 @@
<template lang="pug"> <template lang="pug">
form.container form.container
.row .row
.col.col-xs-12.col-sm-6.col-md-4.col-lg-3 .col-xs-12.col-sm-6.col-md-4.col-lg-3
continent-list(v-model="criteria.continentId" topLabel="Any" @update:modelValue="updateContinent") continent-list(v-model="criteria.continentId" topLabel="Any" @update:modelValue="updateContinent")
.col.col-xs-12.col-sm-6.col-md-4.col-lg-3 .col-xs-12.col-sm-6.col-md-4.col-lg-3
.form-floating .form-floating
input.form-control.form-control-sm(type="text" id="region" placeholder="(free-form text)" input.form-control.form-control-sm(type="text" id="region" placeholder="(free-form text)"
:value="criteria.region" @input="updateValue('region', $event.target.value)") :value="criteria.region" @input="updateValue('region', $event.target.value)")
label(for="region") Region label(for="region") Region
.form-text (free-form text) .form-text (free-form text)
.col.col-xs-12.col-sm-6.col-offset-md-2.col-lg-3.col-offset-lg-0 .col-xs-12.col-sm-6.col-offset-md-2.col-lg-3.col-offset-lg-0
label.jjj-label Seeking Remote Work? label.jjj-label Seeking Remote Work?
br br
.form-check.form-check-inline .form-check.form-check-inline
@@ -24,13 +24,13 @@ form.container
input.form-check-input(type="radio" id="remoteNo" name="remoteWork" :checked="criteria.remoteWork === 'no'" input.form-check-input(type="radio" id="remoteNo" name="remoteWork" :checked="criteria.remoteWork === 'no'"
@click="updateValue('remoteWork', 'no')") @click="updateValue('remoteWork', 'no')")
label.form-check-label(for="remoteNo") No label.form-check-label(for="remoteNo") No
.col.col-xs-12.col-sm-6.col-lg-3 .col-xs-12.col-sm-6.col-lg-3
.form-floating .form-floating
input.form-control.form-control-sm(type="text" id="skillSearch" placeholder="(free-form text)" input.form-control.form-control-sm(type="text" id="skillSearch" placeholder="(free-form text)"
:value="criteria.skill" @input="updateValue('skill', $event.target.value)") :value="criteria.skill" @input="updateValue('skill', $event.target.value)")
label(for="skillSearch") Skill label(for="skillSearch") Skill
.form-text (free-form text) .form-text (free-form text)
.row: .col.col-xs-12 .row: .col
br br
button.btn.btn-outline-primary(type="submit" @click.prevent="$emit('search')") Search button.btn.btn-outline-primary(type="submit" @click.prevent="$emit('search')") Search
</template> </template>

View File

@@ -1,9 +1,9 @@
<template lang="pug"> <template lang="pug">
form.container form.container
.row .row
.col.col-xs-12.col-sm-6.col-md-4.col-lg-3 .col-xs-12.col-sm-6.col-md-4.col-lg-3
continent-list(v-model="criteria.continentId" topLabel="Any" @update:modelValue="updateContinent") continent-list(v-model="criteria.continentId" topLabel="Any" @update:modelValue="updateContinent")
.col.col-xs-12.col-sm-6.col-offset-md-2.col-lg-3.col-offset-lg-0 .col-xs-12.col-sm-6.col-offset-md-2.col-lg-3.col-offset-lg-0
label.jjj-label Seeking Remote Work? label.jjj-label Seeking Remote Work?
br br
.form-check.form-check-inline .form-check.form-check-inline
@@ -18,19 +18,19 @@ form.container
input.form-check-input(type="radio" id="remoteNo" name="remoteWork" :checked="criteria.remoteWork === 'no'" input.form-check-input(type="radio" id="remoteNo" name="remoteWork" :checked="criteria.remoteWork === 'no'"
@click="updateValue('remoteWork', 'no')") @click="updateValue('remoteWork', 'no')")
label.form-check-label(for="remoteNo") No label.form-check-label(for="remoteNo") No
.col.col-xs-12.col-sm-6.col-lg-3 .col-xs-12.col-sm-6.col-lg-3
.form-floating .form-floating
input.form-control(type="text" id="skillSearch" placeholder="(free-form text)" :value="criteria.skill" input.form-control(type="text" id="skillSearch" placeholder="(free-form text)" :value="criteria.skill"
@input="updateValue('skill', $event.target.value)") @input="updateValue('skill', $event.target.value)")
label(for="skillSearch") Skill label(for="skillSearch") Skill
.form-text (free-form text) .form-text (free-form text)
.col.col-xs-12.col-sm-6.col-lg-3 .col-xs-12.col-sm-6.col-lg-3
.form-floating .form-floating
input.form-control(type="text" id="bioSearch" placeholder="(free-form text)" :value="criteria.bioExperience" input.form-control(type="text" id="bioSearch" placeholder="(free-form text)" :value="criteria.bioExperience"
@input="updateValue('bioExperience', $event.target.value)") @input="updateValue('bioExperience', $event.target.value)")
label(for="bioSearch") Bio / Experience label(for="bioSearch") Bio / Experience
.form-text (free-form text) .form-text (free-form text)
.row: .col.col-xs-12 .row: .col
br br
button.btn.btn-outline-primary(type="submit" @click.prevent="$emit('search')") Search button.btn.btn-outline-primary(type="submit" @click.prevent="$emit('search')") Search
</template> </template>

View File

@@ -1,15 +1,15 @@
<template lang="pug"> <template lang="pug">
.row.pb-3 .row.pb-3
.col.col-xs-2.col-md-1.align-self-center .col-xs-2.col-md-1.align-self-center
button.btn.btn-sm.btn-outline-danger.rounded-pill(title="Delete" @click.prevent="$emit('remove')") &nbsp;&minus;&nbsp; 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 .form-floating
input.form-control(type="text" :id="`skillDesc${skill.id}`" maxlength="100" input.form-control(type="text" :id="`skillDesc${skill.id}`" maxlength="100"
placeholder="A skill (language, design technique, process, etc.)" :value="skill.description" placeholder="A skill (language, design technique, process, etc.)" :value="skill.description"
@input="updateValue('description', $event.target.value)") @input="updateValue('description', $event.target.value)")
label.jjj-label(:for="`skillDesc${skill.id}`") Skill label.jjj-label(:for="`skillDesc${skill.id}`") Skill
.form-text A skill (language, design technique, process, etc.) .form-text A skill (language, design technique, process, etc.)
.col.col-xs-12.col-md-5 .col-xs-12.col-md-5
.form-floating .form-floating
input.form-control(type="text" :id="`skillNotes${skill.id}`" maxlength="100" input.form-control(type="text" :id="`skillNotes${skill.id}`" maxlength="100"
placeholder="A further description of the skill (100 characters max)" :value="skill.notes" placeholder="A further description of the skill (100 characters max)" :value="skill.notes"

View File

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

View File

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

View File

@@ -3,14 +3,13 @@ import {
createWebHistory, createWebHistory,
RouteLocationNormalized, RouteLocationNormalized,
RouteLocationNormalizedLoaded, RouteLocationNormalizedLoaded,
RouteRecordName,
RouteRecordRaw RouteRecordRaw
} from "vue-router" } from "vue-router"
import store from "@/store" import store, { Mutations } from "@/store"
import Home from "@/views/Home.vue" import Home from "@/views/Home.vue"
import LogOn from "@/views/citizen/LogOn.vue" import LogOn from "@/views/citizen/LogOn.vue"
/** The URL to which the user should be pointed once they have authorized with NAS */ /** The URL to which the user should be pointed once they have authorized with Mastodon */
export const AFTER_LOG_ON_URL = "jjj-after-log-on-url" export const AFTER_LOG_ON_URL = "jjj-after-log-on-url"
/** /**
@@ -29,124 +28,141 @@ const routes: Array<RouteRecordRaw> = [
{ {
path: "/", path: "/",
name: "Home", name: "Home",
component: Home component: Home,
meta: { title: "Welcome!" }
}, },
{ {
path: "/how-it-works", path: "/how-it-works",
name: "HowItWorks", name: "HowItWorks",
component: () => import(/* webpackChunkName: "help" */ "../views/HowItWorks.vue") component: () => import(/* webpackChunkName: "help" */ "../views/HowItWorks.vue"),
meta: { title: "How It Works" }
}, },
{ {
path: "/privacy-policy", path: "/privacy-policy",
name: "PrivacyPolicy", name: "PrivacyPolicy",
component: () => import(/* webpackChunkName: "legal" */ "../views/PrivacyPolicy.vue") component: () => import(/* webpackChunkName: "legal" */ "../views/PrivacyPolicy.vue"),
meta: { title: "Privacy Policy" }
}, },
{ {
path: "/terms-of-service", path: "/terms-of-service",
name: "TermsOfService", name: "TermsOfService",
component: () => import(/* webpackChunkName: "legal" */ "../views/TermsOfService.vue") component: () => import(/* webpackChunkName: "legal" */ "../views/TermsOfService.vue"),
meta: { title: "Terms of Service" }
}, },
// Citizen URLs // Citizen URLs
{ {
path: "/citizen/log-on", path: "/citizen/log-on",
name: "LogOn", name: "LogOn",
component: LogOn component: LogOn,
meta: { title: "Log On" }
}, },
{ {
path: "/citizen/authorized", path: "/citizen/:abbr/authorized",
name: "CitizenAuthorized", name: "CitizenAuthorized",
component: () => import(/* webpackChunkName: "dashboard" */ "../views/citizen/Authorized.vue") component: () => import(/* webpackChunkName: "dashboard" */ "../views/citizen/Authorized.vue"),
meta: { title: "Logging On" }
}, },
{ {
path: "/citizen/dashboard", path: "/citizen/dashboard",
name: "Dashboard", name: "Dashboard",
component: () => import(/* webpackChunkName: "dashboard" */ "../views/citizen/Dashboard.vue") component: () => import(/* webpackChunkName: "dashboard" */ "../views/citizen/Dashboard.vue"),
meta: { auth: true, title: "Dashboard" }
}, },
{ {
path: "/citizen/profile", path: "/citizen/profile",
name: "EditProfile", name: "EditProfile",
component: () => import(/* webpackChunkName: "profedit" */ "../views/citizen/EditProfile.vue") component: () => import(/* webpackChunkName: "profedit" */ "../views/citizen/EditProfile.vue"),
meta: { auth: true, title: "Edit Profile" }
}, },
{ {
path: "/citizen/log-off", path: "/citizen/log-off",
name: "LogOff", name: "LogOff",
component: () => import(/* webpackChunkName: "logoff" */ "../views/citizen/LogOff.vue") component: () => import(/* webpackChunkName: "logoff" */ "../views/citizen/LogOff.vue"),
meta: { auth: true, title: "Logging Off" }
}, },
// Job Listing URLs // Job Listing URLs
{ {
path: "/help-wanted", path: "/help-wanted",
name: "HelpWanted", name: "HelpWanted",
component: () => import(/* webpackChunkName: "joblist" */ "../views/listing/HelpWanted.vue") component: () => import(/* webpackChunkName: "joblist" */ "../views/listing/HelpWanted.vue"),
meta: { auth: true, title: "Help Wanted" }
}, },
{ {
path: "/listing/:id/edit", path: "/listing/:id/edit",
name: "EditListing", name: "EditListing",
component: () => import(/* webpackChunkName: "jobedit" */ "../views/listing/ListingEdit.vue") component: () => import(/* webpackChunkName: "jobedit" */ "../views/listing/ListingEdit.vue"),
meta: { auth: true, title: "Edit Job Listing" }
}, },
{ {
path: "/listing/:id/expire", path: "/listing/:id/expire",
name: "ExpireListing", name: "ExpireListing",
component: () => import(/* webpackChunkName: "jobedit" */ "../views/listing/ListingExpire.vue") component: () => import(/* webpackChunkName: "jobedit" */ "../views/listing/ListingExpire.vue"),
meta: { auth: true, title: "Expire Job Listing" }
}, },
{ {
path: "/listing/:id/view", path: "/listing/:id/view",
name: "ViewListing", name: "ViewListing",
component: () => import(/* webpackChunkName: "joblist" */ "../views/listing/ListingView.vue") component: () => import(/* webpackChunkName: "joblist" */ "../views/listing/ListingView.vue"),
meta: { auth: true, title: "Loading Job Listing..." }
}, },
{ {
path: "/listings/mine", path: "/listings/mine",
name: "MyListings", name: "MyListings",
component: () => import(/* webpackChunkName: "joblist" */ "../views/listing/MyListings.vue") component: () => import(/* webpackChunkName: "joblist" */ "../views/listing/MyListings.vue"),
meta: { auth: true, title: "My Job Listings" }
}, },
// Profile URLs // Profile URLs
{ {
path: "/profile/:id/view", path: "/profile/:id/view",
name: "ViewProfile", name: "ViewProfile",
component: () => import(/* webpackChunkName: "profview" */ "../views/profile/ProfileView.vue") component: () => import(/* webpackChunkName: "profview" */ "../views/profile/ProfileView.vue"),
meta: { auth: true, title: "Loading Profile..." }
}, },
{ {
path: "/profile/search", path: "/profile/search",
name: "SearchProfiles", name: "SearchProfiles",
component: () => import(/* webpackChunkName: "profview" */ "../views/profile/ProfileSearch.vue") component: () => import(/* webpackChunkName: "profview" */ "../views/profile/ProfileSearch.vue"),
meta: { auth: true, title: "Search Profiles" }
}, },
{ {
path: "/profile/seeking", path: "/profile/seeking",
name: "PublicSearchProfiles", name: "PublicSearchProfiles",
component: () => import(/* webpackChunkName: "seeking" */ "../views/profile/Seeking.vue") component: () => import(/* webpackChunkName: "seeking" */ "../views/profile/Seeking.vue"),
meta: { auth: false, title: "People Seeking Work" }
}, },
// "So Long" URLs // "So Long" URLs
{ {
path: "/so-long/options", path: "/so-long/options",
name: "DeletionOptions", name: "DeletionOptions",
component: () => import(/* webpackChunkName: "so-long" */ "../views/so-long/DeletionOptions.vue") component: () => import(/* webpackChunkName: "so-long" */ "../views/so-long/DeletionOptions.vue"),
meta: { auth: true, title: "Account Deletion Options" }
}, },
{ {
path: "/so-long/success", path: "/so-long/success/:abbr",
name: "DeletionSuccess", name: "DeletionSuccess",
component: () => import(/* webpackChunkName: "so-long" */ "../views/so-long/DeletionSuccess.vue") component: () => import(/* webpackChunkName: "so-long" */ "../views/so-long/DeletionSuccess.vue"),
meta: { auth: false, title: "Account Deletion Success" }
}, },
// Success Story URLs // Success Story URLs
{ {
path: "/success-story/list", path: "/success-story/list",
name: "ListStories", name: "ListStories",
component: () => import(/* webpackChunkName: "success" */ "../views/success-story/StoryList.vue") component: () => import(/* webpackChunkName: "success" */ "../views/success-story/StoryList.vue"),
meta: { auth: false, title: "Success Stories" }
}, },
{ {
path: "/success-story/:id/edit", path: "/success-story/:id/edit",
name: "EditStory", name: "EditStory",
component: () => import(/* webpackChunkName: "succedit" */ "../views/success-story/StoryEdit.vue") component: () => import(/* webpackChunkName: "succedit" */ "../views/success-story/StoryEdit.vue"),
meta: { auth: false, title: "Edit Success Story" }
}, },
{ {
path: "/success-story/:id/view", path: "/success-story/:id/view",
name: "ViewStory", name: "ViewStory",
component: () => import(/* webpackChunkName: "success" */ "../views/success-story/StoryView.vue") component: () => import(/* webpackChunkName: "success" */ "../views/success-story/StoryView.vue"),
meta: { auth: false, title: "Success Story" }
} }
] ]
/** The routes that do not require logins */
const publicRoutes : Array<RouteRecordName> = [
"Home", "HowItWorks", "PrivacyPolicy", "TermsOfService", "LogOn", "CitizenAuthorized", "PublicSearchProfiles",
"DeletionSuccess"
]
const router = createRouter({ const router = createRouter({
history: createWebHistory(process.env.BASE_URL), history: createWebHistory(process.env.BASE_URL),
@@ -158,11 +174,12 @@ const router = createRouter({
}) })
// eslint-disable-next-line // eslint-disable-next-line
router.beforeEach((to : RouteLocationNormalized, from : RouteLocationNormalized) =>{ router.beforeEach((to : RouteLocationNormalized, from : RouteLocationNormalized) => {
if (store.state.user === undefined && !publicRoutes.includes(to.name ?? "")) { if (store.state.user === undefined && (to.meta.auth || false)) {
window.localStorage.setItem(AFTER_LOG_ON_URL, to.fullPath) window.localStorage.setItem(AFTER_LOG_ON_URL, to.fullPath)
return "/citizen/log-on" return "/citizen/log-on"
} }
store.commit(Mutations.SetTitle, to.meta.title ?? "")
}) })
export default router export default router

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

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"> <template lang="pug">
article article
page-title(title="Welcome!")
p &nbsp; p &nbsp;
p. p.
Welcome to Jobs, Jobs, Jobs (AKA No Agenda Careers), where citizens of Gitmo Nation can assist one another in Welcome to Jobs, Jobs, Jobs (AKA No Agenda Careers), where citizens of Gitmo Nation can assist one another in

View File

@@ -1,6 +1,5 @@
<template lang="pug"> <template lang="pug">
article article
page-title(title="How It Works")
h3 How It Works h3 How It Works
h5.pb-3.text-muted: em Last Updated August 29#[sup th], 2021 h5.pb-3.text-muted: em Last Updated August 29#[sup th], 2021
p: em. p: em.
@@ -21,8 +20,8 @@ article
p. p.
Clicking the #[span.link View] link on a listing brings up the full view page for a listing. This page displays all Clicking the #[span.link View] link on a listing brings up the full view page for a listing. This page displays all
of the information from the search results, along with the citizen who posted it, and the full details of the job. of the information from the search results, along with the citizen who posted it, and the full details of the job.
The citizen&rsquo;s name is a link to their profile page at No Agenda Social; you can use that to get their handle, The citizen&rsquo;s name is a link to their profile page at their Mastodon instance; you can use that to get their
and use NAS&rsquo;s communication facilites to inquire about the position. handle, and use Mastodon&rsquo;s communication facilites to inquire about the position.
p: em.text-muted. p: em.text-muted.
(If you know of a way to construct a link to Mastodon that would start a direct message, please reach out; (If you know of a way to construct a link to Mastodon that would start a direct message, please reach out;
I&rsquo;ve searched and searched, and asked NAS, but have not yet determined how to do that.) 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 The #[span.link My Job Listings] page will show you all of your active job listings just below the
#[span.button Add a Job Listing] button. Within this table, you can edit the listing, view it, or expire it (more on #[span.button Add a Job Listing] button. Within this table, you can edit the listing, view it, or expire it (more on
that below). The #[span.link View] link will show you the job listing just as other users will see it. You can share that below). The #[span.link View] link will show you the job listing just as other users will see it. You can share
the link from your browser over on No Agenda Social, and those who click on it will be able to view it. (Existing the link from your browser on any No Agenda-affiliated Mastodon instance, and those who click on it will be able to
users of Jobs, Jobs, Jobs will go right to it; others will need to authorize this site&rsquo;s access, but then they view it. (Existing users of Jobs, Jobs, Jobs will go right to it; others will need to authorize this site&rsquo;s
will get there as well.) access, but then they will get there as well.)
h5 Expire a Job Listing h5 Expire a Job Listing
p. p.
@@ -68,7 +67,7 @@ article
The #[span.link Employment Profiles] link at the side allows you to search for profiles by continent, the The #[span.link Employment Profiles] link at the side allows you to search for profiles by continent, the
citizen&rsquo;s desire for remote work, a skill, or any text in their professional biography and experience. If you 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 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 hr
@@ -76,8 +75,8 @@ article
p. p.
The employment profile is your r&eacute;sum&eacute;, visible to other citizens here. It also allows you to specify 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, your real name, if you so desire; if that is filled in, that is how you will be identified in search results,
profile views, etc. If not, you will be identified as you are on No Agenda Social; this system updates your current profile views, etc. If not, you will be identified as you are on your Mastodon instance; this system updates your
display name each time you log on. current display name each time you log on.
h5 Completing Your Profile h5 Completing Your Profile
p. p.
@@ -99,19 +98,19 @@ article
li. li.
If you check the #[span.link Allow my profile to be searched publicly] checkbox #[strong and] you are seeking If you check the #[span.link Allow my profile to be searched publicly] checkbox #[strong and] you are seeking
employment, your continent, region, and skills fields will be searchable and displayed to public users of the employment, your continent, region, and skills fields will be searchable and displayed to public users of the
site. They will not be tied to your No Agenda Social handle or real name; they are there to let people peek site. They will not be tied to your Mastodon handle or real name; they are there to let people peek behind the
behind the curtain a bit, and hopefully inspire them to join us. curtain a bit, and hopefully inspire them to join us.
h5 Viewing and Sharing Your Profile h5 Viewing and Sharing Your Profile
p. p.
Once your profile has been established, the #[span.link My Employment Profile] page will have a button at the bottom Once your profile has been established, the #[span.link My Employment Profile] page will have a button at the bottom
that will let you view your profile the way all other validated users will be able to see it. (There will also be a that will let you view your profile the way all other validated users will be able to see it. (There will also be a
link to this page from the #[span.link Dashboard].) The URL of this page can be shared on No Agenda Social, if you link to this page from the #[span.link Dashboard].) The URL of this page can be shared on any No Agenda-affiliated
would like to share it there. Just as with job listings, existing users will go straight there, while other No Mastodon instance, if you would like to share it there. Just as with job listings, existing users will go straight
Agenda Social users will get there once they authorize this application. there, while others will get there once they authorize this application.
p. p.
The name on employment profiles is a link to that user&rsquo;s profile on No Agenda Social; from there, others can The name on employment profiles is a link to that user&rsquo;s profile on their Mastodon instance; from there,
communicate further with you using the tools Mastodon provides. others can communicate further with you using the tools Mastodon provides.
h5 &ldquo;I Found a Job!&rdquo; h5 &ldquo;I Found a Job!&rdquo;
p. p.

View File

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

View File

@@ -1,8 +1,7 @@
<template lang="pug"> <template lang="pug">
article article
page-title(title="Terms of Service")
h3 Terms of Service h3 Terms of Service
p: em (as of February 6#[sup th], 2021) p: em (as of September 6#[sup th], 2021)
h4 Acceptance of Terms h4 Acceptance of Terms
p. p.
@@ -11,12 +10,19 @@ article
acceptance of these terms. acceptance of these terms.
h4 Description of Service and Registration h4 Description of Service and Registration
p. p
Jobs, Jobs, Jobs is a service that allows individuals to enter and amend employment profiles, restricting access | Jobs, Jobs, Jobs is a service that allows individuals to enter and amend employment profiles, restricting access
to the details of these profiles to other users of | to the details of these profiles to other users of No Agenda-afilliated Mastodon sites (currently
#[a(href="https://noagendasocial.com" target="_blank") No Agenda Social]. Registration is accomplished by allowing = " "
Jobs, Jobs, Jobs to read one&rsquo;s No Agenda Social profile. See our template(v-for="(it, idx) in instances" :key="idx")
#[router-link(to="/privacy-policy") privacy policy] for details on the personal (user) information we maintain. a(:href="it.url" target="_blank") {{it.name}}
template(v-if="idx + 2 < instances.length")= ", "
template(v-else-if="idx + 1 < instances.length")= ", and "
| ). Registration is accomplished by allowing Jobs, Jobs, Jobs to read one&rsquo;s Mastodon profile. See our
= " "
router-link(to="/privacy-policy") privacy policy
= " "
| for details on the personal (user) information we maintain.
h4 Liability h4 Liability
p. p.
@@ -34,4 +40,23 @@ article
p. p.
You may also wish to review our #[router-link(to="/privacy-policy") privacy policy] to learn how we handle your You may also wish to review our #[router-link(to="/privacy-policy") privacy policy] to learn how we handle your
data. data.
hr
p: em.
Change on September 6#[sup th], 2021 &ndash; replaced &ldquo;No Agenda Social&rdquo; with a list of all No
Agenda-affiliated Mastodon instances.
</template> </template>
<script setup lang="ts">
import { computed, onMounted } from "vue"
import { useStore, Actions } from "@/store"
const store = useStore()
/** All instances authorized to view Jobs, Jobs, Jobs */
const instances = computed(() => store.state.instances)
onMounted(async () => { await store.dispatch(Actions.EnsureInstances) })
</script>

View File

@@ -1,24 +1,36 @@
<template lang="pug"> <template lang="pug">
article article
page-title(title="Logging on...")
p &nbsp; p &nbsp;
p(v-html="message") p(v-html="message")
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted } from "vue" import { computed, onMounted } from "vue"
import { useRouter } from "vue-router" import { useRoute, useRouter } from "vue-router"
import { useStore } from "@/store" import { useStore, Actions, Mutations } from "@/store"
import { AFTER_LOG_ON_URL } from "@/router" import { AFTER_LOG_ON_URL } from "@/router"
const router = useRouter()
const store = useStore() const store = useStore()
const route = useRoute()
const router = useRouter()
/** The abbreviation of the instance from which we received the code */
const abbr = route.params.abbr as string
/** Set the message for this component */
const setMessage = (msg : string) => store.commit(Mutations.SetLogOnState, msg)
/** Pass the code to the API and exchange it for a user and a JWT */ /** Pass the code to the API and exchange it for a user and a JWT */
const logOn = async () => { const logOn = async () => {
const code = router.currentRoute.value.query.code await store.dispatch(Actions.EnsureInstances)
const instance = store.state.instances.find(it => it.abbr === abbr)
if (typeof instance === "undefined") {
setMessage(`Mastodon instance ${abbr} not found`)
} else {
setMessage(`<em>Welcome back! Verifying your ${instance.name} account&hellip;</em>`)
const code = route.query.code
if (code) { if (code) {
await store.dispatch("logOn", code) await store.dispatch(Actions.LogOn, { abbr, code })
if (store.state.user !== undefined) { if (store.state.user !== undefined) {
const afterLogOnUrl = window.localStorage.getItem(AFTER_LOG_ON_URL) const afterLogOnUrl = window.localStorage.getItem(AFTER_LOG_ON_URL)
if (afterLogOnUrl) { if (afterLogOnUrl) {
@@ -29,8 +41,8 @@ const logOn = async () => {
} }
} }
} else { } else {
store.commit("setLogOnState", setMessage(`Did not receive a token from ${instance.name} (perhaps you clicked &ldquo;Cancel&rdquo;?)`)
"Did not receive a token from No Agenda Social (perhaps you clicked &ldquo;Cancel&rdquo;?)") }
} }
} }

View File

@@ -1,12 +1,12 @@
<template lang="pug"> <template lang="pug">
article.container article.container
page-title(title="Dashboard")
h3.pb-4 Welcome, {{user.name}} h3.pb-4 Welcome, {{user.name}}
load-data(:load="retrieveData"): .row.row-cols-1.row-cols-md-2 load-data(:load="retrieveData"): .row.row-cols-1.row-cols-md-2
.col: .card.h-100 .col: .card.h-100
h5.card-header Your Profile h5.card-header Your Profile
.card-body .card-body
h6.card-subtitle.mb-3.text-muted.fst-italic Last updated #[full-date-time(:date="profile.lastUpdatedOn")] h6.card-subtitle.mb-3.text-muted.fst-italic(v-if="profile").
Last updated #[full-date-time(:date="profile.lastUpdatedOn")]
p.card-text(v-if="profile") p.card-text(v-if="profile")
| Your profile currently lists {{profile.skills.length}} | Your profile currently lists {{profile.skills.length}}
| skill#[template(v-if="profile.skills.length !== 1") s]. | skill#[template(v-if="profile.skills.length !== 1") s].

View File

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

View File

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

View File

@@ -1,24 +1,53 @@
<template lang="pug"> <template lang="pug">
article article
p &nbsp; 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> </template>
<script setup lang="ts"> <script setup lang="ts">
/** import { computed, onMounted, Ref, ref } from "vue"
* This component simply redirects the user to the No Agenda Social authorization page; it is separate here so that it import { Instance } from "@/api"
* can be called from two different places, and allow the app to support direct links to authorized content. import { useStore, Actions } from "@/store"
*/
import LoadData from "@/components/LoadData.vue"
const store = useStore()
/** The instances configured for Jobs, Jobs, Jobs */
const instances = computed(() => store.state.instances)
/** Whether authorization is in progress */
const selected : Ref<Instance | undefined> = ref(undefined)
/** The authorization URL to which the user should be directed */ /** The authorization URL to which the user should be directed */
const authUrl = (() => { const authUrl = computed(() => {
/** The client ID for Jobs, Jobs, Jobs at No Agenda Social */ if (selected.value) {
const id = "k_06zlMy0N451meL4AqlwMQzs5PYr6g3d2Q_dCT-OjU" const client = `client_id=${selected.value.clientId}`
const client = `client_id=${id}`
const scope = "scope=read:accounts" const scope = "scope=read:accounts"
const redirect = `redirect_uri=${document.location.origin}/citizen/authorized` const redirect = `redirect_uri=${document.location.origin}/citizen/${selected.value.abbr}/authorized`
const respType = "response_type=code" const respType = "response_type=code"
return `https://noagendasocial.com/oauth/authorize?${client}&${scope}&${redirect}&${respType}` return `${selected.value.url}/oauth/authorize?${client}&${scope}&${redirect}&${respType}`
})() }
document.location.assign(authUrl) return ""
})
/**
* Select a given Mastodon instance
*
* @param abbr The abbreviation of the instance being selected
*/
const select = (abbr : string) => {
selected.value = instances.value.find(it => it.abbr === abbr)
document.location.assign(authUrl.value)
}
onMounted(async () => { await store.dispatch(Actions.EnsureInstances) })
</script> </script>

View File

@@ -1,6 +1,5 @@
<template lang="pug"> <template lang="pug">
article article
page-title(title="Help Wanted")
h3.pb-3 Help Wanted h3.pb-3 Help Wanted
p(v-if="!searched"). p(v-if="!searched").
Enter relevant criteria to find results, or just click &ldquo;Search&rdquo; to see all current job listings. 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"> <template lang="pug">
article article
page-title(:title="isNew ? 'Add a Job Listing' : 'Edit Job Listing'")
h3.pb-3(v-if="isNew") Add a Job Listing h3.pb-3(v-if="isNew") Add a Job Listing
h3.pb-3(v-else) Edit Job Listing h3.pb-3(v-else) Edit Job Listing
load-data(:load="retrieveData"): form.row.g-3 load-data(:load="retrieveData"): form.row.g-3
@@ -44,7 +43,7 @@ import { required } from "@vuelidate/validators"
import api, { Listing, ListingForm, LogOnSuccess } from "@/api" import api, { Listing, ListingForm, LogOnSuccess } from "@/api"
import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue" import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue"
import { useStore } from "@/store" import { Mutations, useStore } from "@/store"
import ContinentList from "@/components/ContinentList.vue" import ContinentList from "@/components/ContinentList.vue"
import LoadData from "@/components/LoadData.vue" import LoadData from "@/components/LoadData.vue"
@@ -99,6 +98,7 @@ const v$ = useVuelidate(rules, listing, { $lazy: true })
/** Retrieve the listing being edited (or set up the form for a new listing) */ /** Retrieve the listing being edited (or set up the form for a new listing) */
const retrieveData = async (errors : string[]) => { const retrieveData = async (errors : string[]) => {
if (isNew.value) store.commit(Mutations.SetTitle, "Add a Job Listing")
const listResult = isNew.value ? newListing : await api.listings.retreive(id, user) const listResult = isNew.value ? newListing : await api.listings.retreive(id, user)
if (typeof listResult === "string") { if (typeof listResult === "string") {
errors.push(listResult) errors.push(listResult)

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
<template lang="pug"> <template lang="pug">
article article
page-title(title="My Job Listings")
h3.pb-3 My Job Listings h3.pb-3 My Job Listings
p: router-link.btn.btn-outline-primary(to="/listing/new/edit") Add a New Job Listing p: router-link.btn.btn-outline-primary(to="/listing/new/edit") Add a New Job Listing
load-data(:load="getListings") load-data(:load="getListings")

View File

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

View File

@@ -34,7 +34,7 @@ import { mdiPencil } from "@mdi/js"
import api, { LogOnSuccess, ProfileForView } from "@/api" import api, { LogOnSuccess, ProfileForView } from "@/api"
import { citizenName } from "@/App.vue" import { citizenName } from "@/App.vue"
import { toHtml } from "@/markdown" import { toHtml } from "@/markdown"
import { useStore } from "@/store" import { Mutations, useStore } from "@/store"
import LoadData from "@/components/LoadData.vue" import LoadData from "@/components/LoadData.vue"
const store = useStore() const store = useStore()
@@ -66,14 +66,10 @@ const retrieveProfile = async (errors : string[]) => {
errors.push("Profile not found") errors.push("Profile not found")
} else { } else {
it.value = profileResp it.value = profileResp
store.commit(Mutations.SetTitle, `Employment profile for ${citizenName(profileResp.citizen)}`)
} }
} }
/** The title of the page (changes once the profile is loaded) */
const title = computed(() => it.value
? `Employment profile for ${citizenName(it.value.citizen)}`
: "Loading Profile...")
/** The HTML version of the citizen's professional biography */ /** The HTML version of the citizen's professional biography */
const bioHtml = computed(() => toHtml(it.value?.profile.biography ?? "")) const bioHtml = computed(() => toHtml(it.value?.profile.biography ?? ""))

View File

@@ -1,6 +1,5 @@
<template lang="pug"> <template lang="pug">
article article
page-title(title="People Seeking Work")
h3.pb-3 People Seeking Work h3.pb-3 People Seeking Work
p(v-if="!searched"). p(v-if="!searched").
Enter one or more criteria to filter results, or just click &ldquo;Search&rdquo; to list all profiles. 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"> <template lang="pug">
article article
page-title(title="Account Deletion Options")
h3.pb-3 Account Deletion Options h3.pb-3 Account Deletion Options
h4.pb-3 Option 1 &ndash; Delete Your Profile h4.pb-3 Option 1 &ndash; Delete Your Profile
p. p.
@@ -13,28 +12,31 @@ article
p. p.
This option will make it like you never visited this site. It will delete your profile, skills, success stories, and This option will make it like you never visited this site. It will delete your profile, skills, success stories, and
account. This is what you want to use if you want to disappear from this application. Clicking the button below account. This is what you want to use if you want to disappear from this application. Clicking the button below
#[strong will not] affect your No Agenda Social account in any way; its effects are limited to Jobs, Jobs, Jobs. #[strong will not] affect your Mastodon account in any way; its effects are limited to Jobs, Jobs, Jobs.
p: em. p: em.
(This will not revoke this application&rsquo;s permissions on No Agenda Social; you will have to remove this (This will not revoke this application&rsquo;s permissions on Mastodon; you will have to remove this yourself. The
yourself. The confirmation message has a link where you can do this; once the page loads, find the confirmation message has a link where you can do this; once the page loads, find the
#[strong Jobs, Jobs, Jobs] entry, and click the #[strong &times; Revoke] link for that entry.) #[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 p.text-center: button.btn.btn-danger(@click.prevent="deleteAccount") Delete Your Entire Account
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { onMounted } from "vue"
import { useRouter } from "vue-router" import { useRouter } from "vue-router"
import api, { LogOnSuccess } from "@/api" import api, { LogOnSuccess } from "@/api"
import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue" import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue"
import { useStore } from "@/store" import { useStore, Actions, Mutations } from "@/store"
</script>
<script setup lang="ts">
const store = useStore() const store = useStore()
const router = useRouter() const router = useRouter()
/** The currently logged-on user */
const user = store.state.user as LogOnSuccess
/** Delete the profile only; redirect to home page on success */ /** Delete the profile only; redirect to home page on success */
const deleteProfile = async () => { const deleteProfile = async () => {
const resp = await api.profile.delete(store.state.user as LogOnSuccess) const resp = await api.profile.delete(user)
if (typeof resp === "string") { if (typeof resp === "string") {
toastError(resp, "Deleting Profile") toastError(resp, "Deleting Profile")
} else { } else {
@@ -45,13 +47,28 @@ const deleteProfile = async () => {
/** Delete everything pertaining to the user's account */ /** Delete everything pertaining to the user's account */
const deleteAccount = async () => { const deleteAccount = async () => {
const resp = await api.citizen.delete(store.state.user as LogOnSuccess) const citizenResp = await api.citizen.retrieve(user.citizenId, user)
if (typeof citizenResp === "string") {
toastError(citizenResp, "retrieving citizen")
} else if (typeof citizenResp === "undefined") {
toastError("Could not retrieve citizen record", undefined)
} else {
const instance = store.state.instances.find(it => it.abbr === citizenResp.instance)
if (typeof instance === "undefined") {
toastError("Could not retrieve instance", undefined)
} else {
const resp = await api.citizen.delete(user)
if (typeof resp === "string") { if (typeof resp === "string") {
toastError(resp, "Deleting Account") toastError(resp, "Deleting Account")
} else { } else {
store.commit("clearUser") store.commit(Mutations.ClearUser)
toastSuccess("Account Deleted Successfully") toastSuccess("Account Deleted Successfully")
router.push("/so-long/success") router.push(`/so-long/success/${instance.abbr}`)
}
}
} }
} }
onMounted(async () => { await store.dispatch(Actions.EnsureInstances) })
</script> </script>

View File

@@ -1,11 +1,28 @@
<template lang="pug"> <template lang="pug">
article article
page-title(title="Account Deletion Success")
h3.pb-3 Account Deletion Success h3.pb-3 Account Deletion Success
p. p.
Your account has been successfully deleted. To revoke the permissions you have previously granted to this Your account has been successfully deleted. To revoke the permissions you have previously granted to this
application, find it in #[a(href="https://noagendasocial.com/oauth/authorized_applications") this list] and click application, find it in #[a(:href="`${url}/oauth/authorized_applications`") this list] and click
#[strong &times; Revoke]. Otherwise, clicking &ldquo;Log On&rdquo; in the left-hand menu will create a new, empty #[strong &times; Revoke]. Otherwise, clicking &ldquo;Log On&rdquo; in the left-hand menu will create a new, empty
account without prompting you further. account without prompting you further.
p Thank you for participating, and thank you for your courage. #GitmoNation p Thank you for participating, and thank you for your courage. #GitmoNation
</template> </template>
<script setup lang="ts">
import { computed, onMounted } from "vue"
import { useRoute } from "vue-router"
import { useStore, Actions } from "@/store"
const route = useRoute()
const store = useStore()
/** The abbreviation of the instance from which the deleted user had authorized access */
const abbr = route.params.abbr as string
/** The URL of that instance */
const url = computed(() => store.state.instances.find(it => it.abbr === abbr)?.url ?? "")
onMounted(async () => { await store.dispatch(Actions.EnsureInstances) })
</script>

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
<template lang="pug"> <template lang="pug">
article article
page-title(title="Success Story")
load-data(:load="retrieveStory") load-data(:load="retrieveStory")
h3 h3
| {{citizenName}}&rsquo;s Success Story | {{citizenName}}&rsquo;s Success Story
@@ -31,7 +30,7 @@ const user = store.state.user as LogOnSuccess
/** The story to be displayed */ /** The story to be displayed */
const story : Ref<Success | undefined> = ref(undefined) const story : Ref<Success | undefined> = ref(undefined)
/** The citizen's name (real, display, or NAS, whichever is found first) */ /** The citizen's name (real, display, or Mastodon, whichever is found first) */
const citizenName = ref("") const citizenName = ref("")
/** Retrieve the success story */ /** Retrieve the success story */

View File

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

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> <Project>
<PropertyGroup> <PropertyGroup>
<TargetFramework>net5.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<AssemblyVersion>1.0.1.0</AssemblyVersion> <DebugType>embedded</DebugType>
<FileVersion>1.0.1.0</FileVersion> <AssemblyVersion>2.2.2.0</AssemblyVersion>
<FileVersion>2.2.2.0</FileVersion>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View File

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

View File

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

View File

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

View File

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

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

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