Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a1d1b53ff4 | |||
| 45861e06f0 |
@@ -17,8 +17,6 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Domain", "JobsJobsJobs\Doma
|
||||
EndProject
|
||||
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Api", "JobsJobsJobs\Api\Api.fsproj", "{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}"
|
||||
EndProject
|
||||
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "DataMigrate", "JobsJobsJobs\DataMigrate\DataMigrate.fsproj", "{C5774E4F-2930-4B64-8407-77BF7EB79F39}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -33,10 +31,6 @@ Global
|
||||
{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C5774E4F-2930-4B64-8407-77BF7EB79F39}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C5774E4F-2930-4B64-8407-77BF7EB79F39}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C5774E4F-2930-4B64-8407-77BF7EB79F39}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C5774E4F-2930-4B64-8407-77BF7EB79F39}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -47,6 +41,5 @@ Global
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{C81278DA-DA97-4E55-AB39-4B88565B615D} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF}
|
||||
{8F5A3D1E-562B-4F27-9787-6CB14B35E69E} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF}
|
||||
{C5774E4F-2930-4B64-8407-77BF7EB79F39} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -30,6 +30,7 @@ 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) =
|
||||
@@ -57,10 +58,11 @@ let configureServices (svc : IServiceCollection) =
|
||||
ValidAudience = "https://noagendacareers.com",
|
||||
ValidIssuer = "https://noagendacareers.com",
|
||||
IssuerSigningKey = SymmetricSecurityKey (
|
||||
Encoding.UTF8.GetBytes (cfg.GetSection("Auth").["ServerSecret"]))))
|
||||
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 (nameof Data.Startup)
|
||||
let conn = Data.Startup.createConnection dbCfg log
|
||||
|
||||
@@ -3,16 +3,16 @@ module JobsJobsJobs.Api.Auth
|
||||
|
||||
open System.Text.Json.Serialization
|
||||
|
||||
/// The variables we need from the account information we get from No Agenda Social
|
||||
/// The variables we need from the account information we get from Mastodon
|
||||
[<NoComparison; NoEquality; AllowNullLiteral>]
|
||||
type MastodonAccount () =
|
||||
/// The user name (what we store as naUser)
|
||||
/// The user name (what we store as mastodonUser)
|
||||
[<JsonPropertyName "username">]
|
||||
member val Username = "" with get, set
|
||||
/// The account name; will be the same as username for local (non-federated) accounts
|
||||
/// 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 No Agenda Social
|
||||
/// 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
|
||||
@@ -21,25 +21,29 @@ type MastodonAccount () =
|
||||
|
||||
|
||||
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
|
||||
open JobsJobsJobs.Domain.SharedTypes
|
||||
|
||||
/// HTTP client to use to communication with Mastodon
|
||||
let private http = new HttpClient()
|
||||
|
||||
/// Verify the authorization code with Mastodon and get the user's profile
|
||||
let verifyWithMastodon (authCode : string) (cfg : IConfigurationSection) (log : ILogger) = task {
|
||||
let verifyWithMastodon (authCode : string) (inst : MastodonInstance) rtnHost (log : ILogger) = task {
|
||||
|
||||
use http = new HttpClient()
|
||||
// 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 NAS
|
||||
// Use authorization code to get an access token from Mastodon
|
||||
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"]
|
||||
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"
|
||||
@@ -49,11 +53,10 @@ let verifyWithMastodon (authCode : string) (cfg : IConfigurationSection) (log :
|
||||
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"
|
||||
| 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"])
|
||||
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
|
||||
@@ -62,19 +65,13 @@ let verifyWithMastodon (authCode : string) (cfg : IConfigurationSection) (log :
|
||||
| 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})"
|
||||
| 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})"
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +83,7 @@ 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 createJwt (citizen : Citizen) (cfg : AuthOptions) =
|
||||
|
||||
let tokenHandler = JwtSecurityTokenHandler ()
|
||||
let token =
|
||||
@@ -100,8 +97,7 @@ let createJwt (citizen : Citizen) (cfg : IConfigurationSection) =
|
||||
Issuer = "https://noagendacareers.com",
|
||||
Audience = "https://noagendacareers.com",
|
||||
SigningCredentials = SigningCredentials (
|
||||
SymmetricSecurityKey (Encoding.UTF8.GetBytes cfg.["ServerSecret"]),
|
||||
SecurityAlgorithms.HmacSha256Signature)
|
||||
SymmetricSecurityKey (Encoding.UTF8.GetBytes cfg.ServerSecret), SecurityAlgorithms.HmacSha256Signature)
|
||||
)
|
||||
)
|
||||
tokenHandler.WriteToken token
|
||||
|
||||
@@ -6,6 +6,7 @@ open JobsJobsJobs.Domain.Types
|
||||
open Polly
|
||||
open RethinkDb.Driver
|
||||
open RethinkDb.Driver.Net
|
||||
open RethinkDb.Driver.Ast
|
||||
|
||||
/// Shorthand for the RethinkDB R variable (how every command starts)
|
||||
let private r = RethinkDB.R
|
||||
@@ -166,10 +167,20 @@ module Startup =
|
||||
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" ]
|
||||
// The instance/user is a compound index
|
||||
let! userIdx = r.Table(Table.Citizen).IndexList().RunResultAsync<string list> conn
|
||||
match userIdx |> List.contains "instanceUser" with
|
||||
| true -> ()
|
||||
| false ->
|
||||
let! _ =
|
||||
r.Table(Table.Citizen)
|
||||
.IndexCreate("instanceUser",
|
||||
ReqlFunction1 (fun row -> upcast r.Array (row.G "instance", row.G "mastodonUser")))
|
||||
.RunWriteAsync conn
|
||||
()
|
||||
}
|
||||
|
||||
|
||||
@@ -215,7 +226,6 @@ let regexContains = System.Text.RegularExpressions.Regex.Escape >> sprintf "(?i)
|
||||
|
||||
open JobsJobsJobs.Domain
|
||||
open JobsJobsJobs.Domain.SharedTypes
|
||||
open RethinkDb.Driver.Ast
|
||||
|
||||
/// Profile data access functions
|
||||
[<RequireQualifiedAccess>]
|
||||
@@ -287,7 +297,7 @@ module Profile =
|
||||
.HashMap("displayName",
|
||||
r.Branch (it.G("realName" ).Default_("").Ne "", it.G "realName",
|
||||
it.G("displayName").Default_("").Ne "", it.G "displayName",
|
||||
it.G "naUser"))
|
||||
it.G "mastodonUser"))
|
||||
.With ("citizenId", it.G "id")))
|
||||
.Pluck("citizenId", "displayName", "seekingEmployment", "remoteWork", "fullTime", "lastUpdatedOn")
|
||||
.OrderBy(ReqlFunction1 (fun it -> upcast it.G("displayName").Downcase ()))
|
||||
@@ -348,12 +358,16 @@ module Citizen =
|
||||
.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
|
||||
/// Find a citizen by their Mastodon username
|
||||
let findByMastodonUser (instance : string) (mastodonUser : string) conn =
|
||||
fun c -> task {
|
||||
let! u =
|
||||
r.Table(Table.Citizen)
|
||||
.GetAll(r.Array (instance, mastodonUser)).OptArg("index", "instanceUser").Limit(1)
|
||||
.RunResultAsync<Citizen list> c
|
||||
return u |> List.tryHead
|
||||
}
|
||||
|> withReconn conn
|
||||
|
||||
/// Add a citizen
|
||||
let add (citizen : Citizen) conn =
|
||||
@@ -546,7 +560,7 @@ module Success =
|
||||
.HashMap("citizenName",
|
||||
r.Branch(it.G("realName" ).Default_("").Ne "", it.G "realName",
|
||||
it.G("displayName").Default_("").Ne "", it.G "displayName",
|
||||
it.G "naUser"))
|
||||
it.G "mastodonUser"))
|
||||
.With ("hasStory", it.G("story").Default_("").Gt "")))
|
||||
.Pluck("id", "citizenId", "citizenName", "recordedOn", "fromHere", "hasStory")
|
||||
.OrderBy(r.Desc "recordedOn")
|
||||
|
||||
@@ -23,23 +23,23 @@ module Error =
|
||||
|
||||
/// URL prefixes for the Vue app
|
||||
let vueUrls = [
|
||||
"/"; "/how-it-works"; "/privacy-policy"; "/terms-of-service"; "/citizen"; "/help-wanted"; "/listing"; "/profile"
|
||||
"/how-it-works"; "/privacy-policy"; "/terms-of-service"; "/citizen"; "/help-wanted"; "/listing"; "/profile"
|
||||
"/so-long"; "/success-story"
|
||||
]
|
||||
|
||||
/// 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 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 vueUrls |> List.exists (fun url -> ctx.Request.Path.ToString().StartsWith url) ->
|
||||
| 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 {string ctx.Request.Path} was not recognized as a valid URL" next
|
||||
ctx
|
||||
return! RequestErrors.NOT_FOUND $"The URL {path} was not recognized as a valid URL" next ctx
|
||||
}
|
||||
|
||||
/// Handler that returns a 403 NOT AUTHORIZED response
|
||||
@@ -58,6 +58,7 @@ module Helpers =
|
||||
|
||||
open NodaTime
|
||||
open Microsoft.Extensions.Configuration
|
||||
open Microsoft.Extensions.Options
|
||||
open RethinkDb.Driver.Net
|
||||
open System.Security.Claims
|
||||
|
||||
@@ -67,6 +68,9 @@ module Helpers =
|
||||
/// 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> ()
|
||||
|
||||
@@ -104,46 +108,50 @@ module Helpers =
|
||||
module Citizen =
|
||||
|
||||
// GET: /api/citizen/log-on/[code]
|
||||
let logOn authCode : HttpHandler =
|
||||
let logOn (abbr, 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)
|
||||
let cfg = authConfig ctx
|
||||
|
||||
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
|
||||
}
|
||||
match cfg.Instances |> Array.tryFind (fun it -> it.Abbr = abbr) with
|
||||
| Some instance ->
|
||||
let log = (logger ctx).CreateLogger (nameof JobsJobsJobs.Api.Auth)
|
||||
|
||||
// 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
|
||||
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]
|
||||
@@ -176,6 +184,25 @@ module Continent =
|
||||
}
|
||||
|
||||
|
||||
/// Handlers for /api/instances routes
|
||||
[<RequireQualifiedAccess>]
|
||||
module Instances =
|
||||
|
||||
/// Convert a Masotodon 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
|
||||
}
|
||||
|
||||
// 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 =
|
||||
@@ -489,12 +516,13 @@ let allEndpoints = [
|
||||
subRoute "/api" [
|
||||
subRoute "/citizen" [
|
||||
GET_HEAD [
|
||||
routef "/log-on/%s" Citizen.logOn
|
||||
routef "/%O" Citizen.get
|
||||
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
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
{
|
||||
"Rethink": {
|
||||
"Hostname": "localhost",
|
||||
"Db": "jobsjobsjobs"
|
||||
"Auth": {
|
||||
"ReturnHost": "http://localhost:5000",
|
||||
"Instances": {
|
||||
"0": {
|
||||
"Name": "No Agenda Social",
|
||||
"Url": "https://noagendasocial.com",
|
||||
"Abbr": "nas"
|
||||
},
|
||||
"1": {
|
||||
"Name": "ITM Slaves!",
|
||||
"Url": "https://itmslaves.com",
|
||||
"Abbr": "itm"
|
||||
},
|
||||
"2": {
|
||||
"Name": "Liberty Woof",
|
||||
"Url": "https://libertywoof.com",
|
||||
"Abbr": "lw"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "jobs-jobs-jobs",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"lint": "vue-cli-service lint",
|
||||
"apiserve": "vue-cli-service build && cd ../Api && dotnet run -c Debug"
|
||||
"apiserve": "vue-cli-service build && cd ../Api && dotnet run -c Debug",
|
||||
"publish": "vue-cli-service build --modern && cd ../Api && dotnet publish -c Release -r linux-x64 --self-contained false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdi/js": "^5.9.55",
|
||||
|
||||
@@ -40,13 +40,13 @@ export function yesOrNo (cond : boolean) : string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display name for a citizen (the first available among real, display, or NAS handle)
|
||||
* Get the display name for a citizen (the first available among real, display, or Mastodon handle)
|
||||
*
|
||||
* @param cit The citizen
|
||||
* @returns The citizen's display name
|
||||
*/
|
||||
export function citizenName (cit : Citizen) : string {
|
||||
return cit.realName ?? cit.displayName ?? cit.naUser
|
||||
return cit.realName ?? cit.displayName ?? cit.mastodonUser
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Citizen,
|
||||
Continent,
|
||||
Count,
|
||||
Instance,
|
||||
Listing,
|
||||
ListingExpireForm,
|
||||
ListingForm,
|
||||
@@ -25,7 +26,7 @@ import {
|
||||
* @param url The partial URL for the API
|
||||
* @returns A full URL for the API
|
||||
*/
|
||||
const apiUrl = (url : string) : string => `http://localhost:5000/api/${url}`
|
||||
const apiUrl = (url : string) : string => `/api/${url}`
|
||||
|
||||
/**
|
||||
* Create request init parameters
|
||||
@@ -100,11 +101,12 @@ export default {
|
||||
/**
|
||||
* Log a citizen on
|
||||
*
|
||||
* @param code The authorization code from No Agenda Social
|
||||
* @param abbr The abbreviation of the Mastodon instance that issued the code
|
||||
* @param code The authorization code from Mastodon
|
||||
* @returns The user result, or an error
|
||||
*/
|
||||
logOn: async (code : string) : Promise<LogOnSuccess | string> => {
|
||||
const resp = await fetch(apiUrl(`citizen/log-on/${code}`), { method: "GET", mode: "cors" })
|
||||
logOn: async (abbr : string, code : string) : Promise<LogOnSuccess | string> => {
|
||||
const resp = await fetch(apiUrl(`citizen/log-on/${abbr}/${code}`), { method: "GET", mode: "cors" })
|
||||
if (resp.status === 200) return await resp.json() as LogOnSuccess
|
||||
return `Error logging on - ${await resp.text()}`
|
||||
},
|
||||
@@ -141,6 +143,18 @@ export default {
|
||||
apiResult<Continent[]>(await fetch(apiUrl("continents"), { method: "GET" }), "retrieving continents")
|
||||
},
|
||||
|
||||
/** API functions for instances */
|
||||
instances: {
|
||||
|
||||
/**
|
||||
* Get all Mastodon instances we support
|
||||
*
|
||||
* @returns All instances, or an error
|
||||
*/
|
||||
all: async () : Promise<Instance[] | string | undefined> =>
|
||||
apiResult<Instance[]>(await fetch(apiUrl("instances"), { method: "GET" }), "retrieving Mastodon instances")
|
||||
},
|
||||
|
||||
/** API functions for job listings */
|
||||
listings: {
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
export interface Citizen {
|
||||
/** The ID of the user */
|
||||
id : string
|
||||
/** The abbreviation of the instance where this citizen is based */
|
||||
instance : string
|
||||
/** The handle by which the user is known on Mastodon */
|
||||
naUser : string
|
||||
mastodonUser : string
|
||||
/** The user's display name from Mastodon (updated every login) */
|
||||
displayName : string | undefined
|
||||
/** The user's real name */
|
||||
@@ -31,6 +33,18 @@ export interface Count {
|
||||
count : number
|
||||
}
|
||||
|
||||
/** The Mastodon instance data provided via the Jobs, Jobs, Jobs API */
|
||||
export interface Instance {
|
||||
/** The name of the instance */
|
||||
name : string
|
||||
/** The URL for this instance */
|
||||
url : string
|
||||
/** The abbreviation used in the URL to distinguish this instance's return codes */
|
||||
abbr : string
|
||||
/** The client ID (assigned by the Mastodon server) */
|
||||
clientId : string
|
||||
}
|
||||
|
||||
/** A job listing */
|
||||
export interface Listing {
|
||||
/** The ID of the job listing */
|
||||
|
||||
@@ -10,7 +10,7 @@ import store from "@/store"
|
||||
import Home from "@/views/Home.vue"
|
||||
import LogOn from "@/views/citizen/LogOn.vue"
|
||||
|
||||
/** The URL to which the user should be pointed once they have authorized with NAS */
|
||||
/** The URL to which the user should be pointed once they have authorized with Mastodon */
|
||||
export const AFTER_LOG_ON_URL = "jjj-after-log-on-url"
|
||||
|
||||
/**
|
||||
@@ -53,7 +53,7 @@ const routes: Array<RouteRecordRaw> = [
|
||||
component: LogOn
|
||||
},
|
||||
{
|
||||
path: "/citizen/authorized",
|
||||
path: "/citizen/:abbr/authorized",
|
||||
name: "CitizenAuthorized",
|
||||
component: () => import(/* webpackChunkName: "dashboard" */ "../views/citizen/Authorized.vue")
|
||||
},
|
||||
@@ -121,7 +121,7 @@ const routes: Array<RouteRecordRaw> = [
|
||||
component: () => import(/* webpackChunkName: "so-long" */ "../views/so-long/DeletionOptions.vue")
|
||||
},
|
||||
{
|
||||
path: "/so-long/success",
|
||||
path: "/so-long/success/:abbr",
|
||||
name: "DeletionSuccess",
|
||||
component: () => import(/* webpackChunkName: "so-long" */ "../views/so-long/DeletionSuccess.vue")
|
||||
},
|
||||
|
||||
8
src/JobsJobsJobs/App/src/store/actions.ts
Normal file
8
src/JobsJobsJobs/App/src/store/actions.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/** Logs a user on to Jobs, Jobs, Jobs */
|
||||
export const LogOn = "logOn"
|
||||
|
||||
/** Ensures that the continent list in the state has been populated */
|
||||
export const EnsureContinents = "ensureContinents"
|
||||
|
||||
/** Ensures that the Mastodon instance list in the state has been populated */
|
||||
export const EnsureInstances = "ensureInstances"
|
||||
@@ -1,6 +1,8 @@
|
||||
import { InjectionKey } from "vue"
|
||||
import { createStore, Store, useStore as baseUseStore } from "vuex"
|
||||
import api, { Continent, LogOnSuccess } from "../api"
|
||||
import api, { Continent, Instance, LogOnSuccess } from "../api"
|
||||
import * as Actions from "./actions"
|
||||
import * as Mutations from "./mutations"
|
||||
|
||||
/** The state tracked by the application */
|
||||
export interface State {
|
||||
@@ -10,6 +12,8 @@ export interface State {
|
||||
logOnState: string
|
||||
/** All continents (use `ensureContinents` action) */
|
||||
continents: Continent[]
|
||||
/** All instances (use `ensureInstances` action) */
|
||||
instances: Instance[]
|
||||
}
|
||||
|
||||
/** An injection key to identify this state with Vue */
|
||||
@@ -24,43 +28,51 @@ export default createStore({
|
||||
state: () : State => {
|
||||
return {
|
||||
user: undefined,
|
||||
logOnState: "<em>Welcome back! Verifying your No Agenda Social account…</em>",
|
||||
continents: []
|
||||
logOnState: "<em>Welcome back!</em>",
|
||||
continents: [],
|
||||
instances: []
|
||||
}
|
||||
},
|
||||
mutations: {
|
||||
setUser (state, user : LogOnSuccess) {
|
||||
state.user = user
|
||||
},
|
||||
clearUser (state) {
|
||||
state.user = undefined
|
||||
},
|
||||
setLogOnState (state, message : string) {
|
||||
state.logOnState = message
|
||||
},
|
||||
setContinents (state, continents : Continent[]) {
|
||||
state.continents = continents
|
||||
}
|
||||
[Mutations.SetUser]: (state, user : LogOnSuccess) => { state.user = user },
|
||||
[Mutations.ClearUser]: (state) => { state.user = undefined },
|
||||
[Mutations.SetLogOnState]: (state, message : string) => { state.logOnState = message },
|
||||
[Mutations.SetContinents]: (state, continents : Continent[]) => { state.continents = continents },
|
||||
[Mutations.SetInstances]: (state, instances : Instance[]) => { state.instances = instances }
|
||||
},
|
||||
actions: {
|
||||
async logOn ({ commit }, code: string) {
|
||||
const logOnResult = await api.citizen.logOn(code)
|
||||
[Actions.LogOn]: async ({ commit }, { abbr, code }) => {
|
||||
const logOnResult = await api.citizen.logOn(abbr, code)
|
||||
if (typeof logOnResult === "string") {
|
||||
commit("setLogOnState", logOnResult)
|
||||
commit(Mutations.SetLogOnState, logOnResult)
|
||||
} else {
|
||||
commit("setUser", logOnResult)
|
||||
commit(Mutations.SetUser, logOnResult)
|
||||
}
|
||||
},
|
||||
async ensureContinents ({ state, commit }) {
|
||||
[Actions.EnsureContinents]: async ({ state, commit }) => {
|
||||
if (state.continents.length > 0) return
|
||||
const theSeven = await api.continent.all()
|
||||
if (typeof theSeven === "string") {
|
||||
console.error(theSeven)
|
||||
} else {
|
||||
commit("setContinents", theSeven)
|
||||
commit(Mutations.SetContinents, theSeven)
|
||||
}
|
||||
},
|
||||
[Actions.EnsureInstances]: async ({ state, commit }) => {
|
||||
if (state.instances.length > 0) return
|
||||
const instResp = await api.instances.all()
|
||||
if (typeof instResp === "string") {
|
||||
console.error(instResp)
|
||||
} else if (typeof instResp === "undefined") {
|
||||
console.error("No instances were found; this should not happen")
|
||||
} else {
|
||||
commit(Mutations.SetInstances, instResp)
|
||||
}
|
||||
}
|
||||
},
|
||||
modules: {
|
||||
}
|
||||
})
|
||||
|
||||
export * as Actions from "./actions"
|
||||
export * as Mutations from "./mutations"
|
||||
|
||||
14
src/JobsJobsJobs/App/src/store/mutations.ts
Normal file
14
src/JobsJobsJobs/App/src/store/mutations.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/** 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"
|
||||
@@ -21,8 +21,8 @@ article
|
||||
p.
|
||||
Clicking the #[span.link View] link on a listing brings up the full view page for a listing. This page displays all
|
||||
of the information from the search results, along with the citizen who posted it, and the full details of the job.
|
||||
The citizen’s name is a link to their profile page at No Agenda Social; you can use that to get their handle,
|
||||
and use NAS’s communication facilites to inquire about the position.
|
||||
The citizen’s name is a link to their profile page at their Mastodon instance; you can use that to get their
|
||||
handle, and use Mastodon’s communication facilites to inquire about the position.
|
||||
p: em.text-muted.
|
||||
(If you know of a way to construct a link to Mastodon that would start a direct message, please reach out;
|
||||
I’ve searched and searched, and asked NAS, but have not yet determined how to do that.)
|
||||
@@ -43,9 +43,9 @@ article
|
||||
The #[span.link My Job Listings] page will show you all of your active job listings just below the
|
||||
#[span.button Add a Job Listing] button. Within this table, you can edit the listing, view it, or expire it (more on
|
||||
that below). The #[span.link View] link will show you the job listing just as other users will see it. You can share
|
||||
the link from your browser over on No Agenda Social, and those who click on it will be able to view it. (Existing
|
||||
users of Jobs, Jobs, Jobs will go right to it; others will need to authorize this site’s access, but then they
|
||||
will get there as well.)
|
||||
the link from your browser on any No Agenda-affiliated Mastodon instance, and those who click on it will be able to
|
||||
view it. (Existing users of Jobs, Jobs, Jobs will go right to it; others will need to authorize this site’s
|
||||
access, but then they will get there as well.)
|
||||
|
||||
h5 Expire a Job Listing
|
||||
p.
|
||||
@@ -68,7 +68,7 @@ article
|
||||
The #[span.link Employment Profiles] link at the side allows you to search for profiles by continent, the
|
||||
citizen’s desire for remote work, a skill, or any text in their professional biography and experience. If you
|
||||
find someone with whom you’d like to discuss potential opportunities, the name at the top of the profile links
|
||||
to their No Agenda Social account, where you can use its features to get in touch.
|
||||
to their Mastodon profile, where you can use its features to get in touch.
|
||||
|
||||
hr
|
||||
|
||||
@@ -76,8 +76,8 @@ article
|
||||
p.
|
||||
The employment profile is your résumé, visible to other citizens here. It also allows you to specify
|
||||
your real name, if you so desire; if that is filled in, that is how you will be identified in search results,
|
||||
profile views, etc. If not, you will be identified as you are on No Agenda Social; this system updates your current
|
||||
display name each time you log on.
|
||||
profile views, etc. If not, you will be identified as you are on your Mastodon instance; this system updates your
|
||||
current display name each time you log on.
|
||||
|
||||
h5 Completing Your Profile
|
||||
p.
|
||||
@@ -99,19 +99,19 @@ article
|
||||
li.
|
||||
If you check the #[span.link Allow my profile to be searched publicly] checkbox #[strong and] you are seeking
|
||||
employment, your continent, region, and skills fields will be searchable and displayed to public users of the
|
||||
site. They will not be tied to your No Agenda Social handle or real name; they are there to let people peek
|
||||
behind the curtain a bit, and hopefully inspire them to join us.
|
||||
site. They will not be tied to your Mastodon handle or real name; they are there to let people peek behind the
|
||||
curtain a bit, and hopefully inspire them to join us.
|
||||
|
||||
h5 Viewing and Sharing Your Profile
|
||||
p.
|
||||
Once your profile has been established, the #[span.link My Employment Profile] page will have a button at the bottom
|
||||
that will let you view your profile the way all other validated users will be able to see it. (There will also be a
|
||||
link to this page from the #[span.link Dashboard].) The URL of this page can be shared on No Agenda Social, if you
|
||||
would like to share it there. Just as with job listings, existing users will go straight there, while other No
|
||||
Agenda Social users will get there once they authorize this application.
|
||||
link to this page from the #[span.link Dashboard].) The URL of this page can be shared on any No Agenda-affiliated
|
||||
Mastodon instance, if you would like to share it there. Just as with job listings, existing users will go straight
|
||||
there, while others will get there once they authorize this application.
|
||||
p.
|
||||
The name on employment profiles is a link to that user’s profile on No Agenda Social; from there, others can
|
||||
communicate further with you using the tools Mastodon provides.
|
||||
The name on employment profiles is a link to that user’s profile on their Mastodon instance; from there,
|
||||
others can communicate further with you using the tools Mastodon provides.
|
||||
|
||||
h5 “I Found a Job!”
|
||||
p.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
article
|
||||
page-title(title="Privacy Policy")
|
||||
h3 Privacy Policy
|
||||
p: em (as of February 6#[sup th], 2021)
|
||||
p: em (as of September 6#[sup th], 2021)
|
||||
|
||||
p.
|
||||
{{name}} (“we,” “our,” or “us”) is committed to protecting your privacy. This
|
||||
@@ -58,7 +58,7 @@ article
|
||||
li Name / Username
|
||||
li Coarse Geographic Location
|
||||
li Employment History
|
||||
li No Agenda Social Account Name / Profile
|
||||
li Mastodon Account Name / Profile
|
||||
|
||||
h4 How Do We Use The Information We Collect?
|
||||
p Any of the information we collect from you may be used in one of the following ways:
|
||||
@@ -75,9 +75,9 @@ article
|
||||
p {{name}} will collect End User Data necessary to provide the {{name}} services to our customers.
|
||||
p.
|
||||
End users may voluntarily provide us with information they have made available on social media websites
|
||||
(specifically No Agenda Social). If you provide us with any such information, we may collect publicly available
|
||||
information from the social media websites you have indicated. You can control how much of your information social
|
||||
media websites make public by visiting these websites and changing your privacy settings.
|
||||
(specifically No Agenda-affiliated Mastodon instances). If you provide us with any such information, we may collect
|
||||
publicly available information from the social media websites you have indicated. You can control how much of your
|
||||
information social media websites make public by visiting these websites and changing your privacy settings.
|
||||
|
||||
h4 When does {{name}} use customer information from third parties?
|
||||
p We do not utilize third party information apart from the end-user data described above.
|
||||
@@ -223,10 +223,10 @@ article
|
||||
|
||||
h4 Tracking Technologies
|
||||
p.
|
||||
{{name}} does not use any tracking technologies. When an authorization code is received from No Agenda Social, that
|
||||
token is stored in the browser’s memory, and the Service uses tokens on each request for data. If the page is
|
||||
refreshed or the browser window/tab is closed, this token disappears, and a new one must be generated before the
|
||||
application can be used again.
|
||||
{{name}} does not use any tracking technologies. When an authorization code is received from Mastodon, that token is
|
||||
stored in the browser’s memory, and the Service uses tokens on each request for data. If the page is refreshed
|
||||
or the browser window/tab is closed, this token disappears, and a new one must be generated before the application
|
||||
can be used again.
|
||||
|
||||
h4 Information about General Data Protection Regulation (GDPR)
|
||||
p.
|
||||
@@ -335,6 +335,12 @@ article
|
||||
h4 Contact Us
|
||||
p Don’t hesitate to contact us if you have any questions.
|
||||
ul: li Via this Link: #[router-link(to="/how-it-works") https://noagendacareers.com/how-it-works]
|
||||
|
||||
hr
|
||||
|
||||
p: em.
|
||||
Change on September 6#[sup th], 2021 – replaced “No Agenda Social” with generic terms for any
|
||||
authorized Mastodon instance.
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
article
|
||||
page-title(title="Terms of Service")
|
||||
h3 Terms of Service
|
||||
p: em (as of February 6#[sup th], 2021)
|
||||
p: em (as of September 6#[sup th], 2021)
|
||||
|
||||
h4 Acceptance of Terms
|
||||
p.
|
||||
@@ -11,12 +11,19 @@ article
|
||||
acceptance of these terms.
|
||||
|
||||
h4 Description of Service and Registration
|
||||
p.
|
||||
Jobs, Jobs, Jobs is a service that allows individuals to enter and amend employment profiles, restricting access
|
||||
to the details of these profiles to other users of
|
||||
#[a(href="https://noagendasocial.com" target="_blank") No Agenda Social]. Registration is accomplished by allowing
|
||||
Jobs, Jobs, Jobs to read one’s No Agenda Social profile. See our
|
||||
#[router-link(to="/privacy-policy") privacy policy] for details on the personal (user) information we maintain.
|
||||
p
|
||||
| Jobs, Jobs, Jobs is a service that allows individuals to enter and amend employment profiles, restricting access
|
||||
| to the details of these profiles to other users of No Agenda-afilliated Mastodon sites (currently
|
||||
= " "
|
||||
template(v-for="(it, idx) in instances" :key="idx")
|
||||
a(:href="it.url" target="_blank") {{it.name}}
|
||||
template(v-if="idx + 2 < instances.length")= ", "
|
||||
template(v-else-if="idx + 1 < instances.length")= ", and "
|
||||
| ). Registration is accomplished by allowing Jobs, Jobs, Jobs to read one’s Mastodon profile. See our
|
||||
= " "
|
||||
router-link(to="/privacy-policy") privacy policy
|
||||
= " "
|
||||
| for details on the personal (user) information we maintain.
|
||||
|
||||
h4 Liability
|
||||
p.
|
||||
@@ -34,4 +41,23 @@ article
|
||||
p.
|
||||
You may also wish to review our #[router-link(to="/privacy-policy") privacy policy] to learn how we handle your
|
||||
data.
|
||||
|
||||
hr
|
||||
|
||||
p: em.
|
||||
Change on September 6#[sup th], 2021 – replaced “No Agenda Social” with a list of all No
|
||||
Agenda-affiliated Mastodon instances.
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from "vue"
|
||||
import { useStore, Actions } from "@/store"
|
||||
|
||||
const store = useStore()
|
||||
|
||||
/** All instances authorized to view Jobs, Jobs, Jobs */
|
||||
const instances = computed(() => store.state.instances)
|
||||
|
||||
onMounted(async () => { await store.dispatch(Actions.EnsureInstances) })
|
||||
|
||||
</script>
|
||||
|
||||
@@ -7,30 +7,43 @@ article
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
import { useStore } from "@/store"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import { useStore, Actions, Mutations } from "@/store"
|
||||
import { AFTER_LOG_ON_URL } from "@/router"
|
||||
|
||||
const router = useRouter()
|
||||
const store = useStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
/** The abbreviation of the instance from which we received the code */
|
||||
const abbr = route.params.abbr as string
|
||||
|
||||
/** Set the message for this component */
|
||||
const setMessage = (msg : string) => store.commit(Mutations.SetLogOnState, msg)
|
||||
|
||||
/** Pass the code to the API and exchange it for a user and a JWT */
|
||||
const logOn = async () => {
|
||||
const code = router.currentRoute.value.query.code
|
||||
if (code) {
|
||||
await store.dispatch("logOn", code)
|
||||
if (store.state.user !== undefined) {
|
||||
const afterLogOnUrl = window.localStorage.getItem(AFTER_LOG_ON_URL)
|
||||
if (afterLogOnUrl) {
|
||||
window.localStorage.removeItem(AFTER_LOG_ON_URL)
|
||||
router.push(afterLogOnUrl)
|
||||
} else {
|
||||
router.push("/citizen/dashboard")
|
||||
}
|
||||
}
|
||||
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 {
|
||||
store.commit("setLogOnState",
|
||||
"Did not receive a token from No Agenda Social (perhaps you clicked “Cancel”?)")
|
||||
setMessage(`<em>Welcome back! Verifying your ${instance.name} account…</em>`)
|
||||
const code = route.query.code
|
||||
if (code) {
|
||||
await store.dispatch(Actions.LogOn, { abbr, code })
|
||||
if (store.state.user !== undefined) {
|
||||
const afterLogOnUrl = window.localStorage.getItem(AFTER_LOG_ON_URL)
|
||||
if (afterLogOnUrl) {
|
||||
window.localStorage.removeItem(AFTER_LOG_ON_URL)
|
||||
router.push(afterLogOnUrl)
|
||||
} else {
|
||||
router.push("/citizen/dashboard")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setMessage(`Did not receive a token from ${instance.name} (perhaps you clicked “Cancel”?)`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ article.container
|
||||
.col: .card.h-100
|
||||
h5.card-header Your Profile
|
||||
.card-body
|
||||
h6.card-subtitle.mb-3.text-muted.fst-italic Last updated #[full-date-time(:date="profile.lastUpdatedOn")]
|
||||
h6.card-subtitle.mb-3.text-muted.fst-italic(v-if="profile").
|
||||
Last updated #[full-date-time(:date="profile.lastUpdatedOn")]
|
||||
p.card-text(v-if="profile")
|
||||
| Your profile currently lists {{profile.skills.length}}
|
||||
| skill#[template(v-if="profile.skills.length !== 1") s].
|
||||
|
||||
@@ -6,9 +6,9 @@ article
|
||||
.col-12.col-sm-10.col-md-8.col-lg-6
|
||||
.form-floating
|
||||
input.form-control(type="text" id="realName" v-model="v$.realName.$model" maxlength="255"
|
||||
placeholder="Leave blank to use your NAS display name")
|
||||
placeholder="Leave blank to use your Mastodon display name")
|
||||
label(for="realName") Real Name
|
||||
.form-text Leave blank to use your NAS display name
|
||||
.form-text Leave blank to use your Mastodon display name
|
||||
.col-12
|
||||
.form-check
|
||||
input.form-check-input(type="checkbox" id="isSeeking" v-model="v$.isSeekingEmployment.$model")
|
||||
|
||||
@@ -9,13 +9,13 @@ article
|
||||
import { onMounted } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
import { toastSuccess } from "@/components/layout/AppToaster.vue"
|
||||
import { useStore } from "@/store"
|
||||
import { useStore, Mutations } from "@/store"
|
||||
|
||||
const store = useStore()
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
store.commit("clearUser")
|
||||
store.commit(Mutations.ClearUser)
|
||||
toastSuccess("Log Off Successful | <strong>Have a Nice Day!</strong>")
|
||||
router.push("/")
|
||||
})
|
||||
|
||||
@@ -1,24 +1,50 @@
|
||||
<template lang="pug">
|
||||
article
|
||||
p
|
||||
p.fst-italic Sending you over to No Agenda Social to log on; see you back in just a second…
|
||||
p.fst-italic(v-if="selected") Sending you over to {{selected.name}} to log on; see you back in just a second…
|
||||
template(v-else)
|
||||
p.text-center Please select your No Agenda-affiliated Mastodon instance
|
||||
p.text-center(v-for="it in instances" :key="it.abbr")
|
||||
button.btn.btn-primary(@click.prevent="select(it.abbr)") {{it.name}}
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* This component simply redirects the user to the No Agenda Social authorization page; it is separate here so that it
|
||||
* can be called from two different places, and allow the app to support direct links to authorized content.
|
||||
*/
|
||||
import { computed, onMounted, Ref, ref } from "vue"
|
||||
import { Instance } from "@/api"
|
||||
import { useStore, Actions } from "@/store"
|
||||
|
||||
import LoadData from "@/components/LoadData.vue"
|
||||
|
||||
const store = useStore()
|
||||
|
||||
/** The instances configured for Jobs, Jobs, Jobs */
|
||||
const instances = computed(() => store.state.instances)
|
||||
|
||||
/** Whether authorization is in progress */
|
||||
const selected : Ref<Instance | undefined> = ref(undefined)
|
||||
|
||||
/** The authorization URL to which the user should be directed */
|
||||
const authUrl = (() => {
|
||||
/** The client ID for Jobs, Jobs, Jobs at No Agenda Social */
|
||||
const id = "k_06zlMy0N451meL4AqlwMQzs5PYr6g3d2Q_dCT-OjU"
|
||||
const client = `client_id=${id}`
|
||||
const scope = "scope=read:accounts"
|
||||
const redirect = `redirect_uri=${document.location.origin}/citizen/authorized`
|
||||
const respType = "response_type=code"
|
||||
return `https://noagendasocial.com/oauth/authorize?${client}&${scope}&${redirect}&${respType}`
|
||||
})()
|
||||
document.location.assign(authUrl)
|
||||
const authUrl = computed(() => {
|
||||
if (selected.value) {
|
||||
const client = `client_id=${selected.value.clientId}`
|
||||
const scope = "scope=read:accounts"
|
||||
const redirect = `redirect_uri=${document.location.origin}/citizen/${selected.value.abbr}/authorized`
|
||||
const respType = "response_type=code"
|
||||
return `${selected.value.url}/oauth/authorize?${client}&${scope}&${redirect}&${respType}`
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
/**
|
||||
* Select a given Mastodon instance
|
||||
*
|
||||
* @param abbr The abbreviation of the instance being selected
|
||||
*/
|
||||
const select = (abbr : string) => {
|
||||
selected.value = instances.value.find(it => it.abbr === abbr)
|
||||
document.location.assign(authUrl.value)
|
||||
}
|
||||
|
||||
onMounted(async () => { await store.dispatch(Actions.EnsureInstances) })
|
||||
|
||||
</script>
|
||||
|
||||
@@ -65,8 +65,8 @@ const title = computed(() => it.value ? `${it.value.listing.title} | Job Listing
|
||||
/** The HTML details of the job listing */
|
||||
const details = computed(() => toHtml(it.value?.listing.text ?? ""))
|
||||
|
||||
/** The NAS profile URL for the citizen who posted this job listing */
|
||||
const profileUrl = computed(() => citizen.value ? `https://noagendasocial.com/@${citizen.value.naUser}` : "")
|
||||
/** The Mastodon profile URL for the citizen who posted this job listing */
|
||||
const profileUrl = computed(() => citizen.value ? citizen.value.profileUrl : "")
|
||||
|
||||
/** The needed by date, formatted in SHOUTING MODE */
|
||||
const neededBy = (nb : string) => formatNeededBy(nb).toUpperCase()
|
||||
|
||||
@@ -13,28 +13,31 @@ article
|
||||
p.
|
||||
This option will make it like you never visited this site. It will delete your profile, skills, success stories, and
|
||||
account. This is what you want to use if you want to disappear from this application. Clicking the button below
|
||||
#[strong will not] affect your No Agenda Social account in any way; its effects are limited to Jobs, Jobs, Jobs.
|
||||
#[strong will not] affect your Mastodon account in any way; its effects are limited to Jobs, Jobs, Jobs.
|
||||
p: em.
|
||||
(This will not revoke this application’s permissions on No Agenda Social; you will have to remove this
|
||||
yourself. The confirmation message has a link where you can do this; once the page loads, find the
|
||||
(This will not revoke this application’s permissions on Mastodon; you will have to remove this yourself. The
|
||||
confirmation message has a link where you can do this; once the page loads, find the
|
||||
#[strong Jobs, Jobs, Jobs] entry, and click the #[strong × Revoke] link for that entry.)
|
||||
p.text-center: button.btn.btn-danger(@click.prevent="deleteAccount") Delete Your Entire Account
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
|
||||
import api, { LogOnSuccess } from "@/api"
|
||||
import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue"
|
||||
import { useStore } from "@/store"
|
||||
</script>
|
||||
import { useStore, Actions, Mutations } from "@/store"
|
||||
|
||||
<script setup lang="ts">
|
||||
const store = useStore()
|
||||
const router = useRouter()
|
||||
|
||||
/** The currently logged-on user */
|
||||
const user = store.state.user as LogOnSuccess
|
||||
|
||||
/** Delete the profile only; redirect to home page on success */
|
||||
const deleteProfile = async () => {
|
||||
const resp = await api.profile.delete(store.state.user as LogOnSuccess)
|
||||
const resp = await api.profile.delete(user)
|
||||
if (typeof resp === "string") {
|
||||
toastError(resp, "Deleting Profile")
|
||||
} else {
|
||||
@@ -45,13 +48,28 @@ const deleteProfile = async () => {
|
||||
|
||||
/** Delete everything pertaining to the user's account */
|
||||
const deleteAccount = async () => {
|
||||
const resp = await api.citizen.delete(store.state.user as LogOnSuccess)
|
||||
if (typeof resp === "string") {
|
||||
toastError(resp, "Deleting Account")
|
||||
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 {
|
||||
store.commit("clearUser")
|
||||
toastSuccess("Account Deleted Successfully")
|
||||
router.push("/so-long/success")
|
||||
const instance = store.state.instances.find(it => it.abbr === citizenResp.instance)
|
||||
if (typeof instance === "undefined") {
|
||||
toastError("Could not retrieve instance", undefined)
|
||||
} else {
|
||||
const resp = await api.citizen.delete(user)
|
||||
if (typeof resp === "string") {
|
||||
toastError(resp, "Deleting Account")
|
||||
} else {
|
||||
store.commit(Mutations.ClearUser)
|
||||
toastSuccess("Account Deleted Successfully")
|
||||
router.push(`/so-long/success/${instance.abbr}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => { await store.dispatch(Actions.EnsureInstances) })
|
||||
|
||||
</script>
|
||||
|
||||
@@ -4,8 +4,26 @@ article
|
||||
h3.pb-3 Account Deletion Success
|
||||
p.
|
||||
Your account has been successfully deleted. To revoke the permissions you have previously granted to this
|
||||
application, find it in #[a(href="https://noagendasocial.com/oauth/authorized_applications") this list] and click
|
||||
application, find it in #[a(:href="`${url}/oauth/authorized_applications`") this list] and click
|
||||
#[strong × Revoke]. Otherwise, clicking “Log On” in the left-hand menu will create a new, empty
|
||||
account without prompting you further.
|
||||
p Thank you for participating, and thank you for your courage. #GitmoNation
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from "vue"
|
||||
import { useRoute } from "vue-router"
|
||||
import { useStore, Actions } from "@/store"
|
||||
|
||||
const route = useRoute()
|
||||
const store = useStore()
|
||||
|
||||
/** The abbreviation of the instance from which the deleted user had authorized access */
|
||||
const abbr = route.params.abbr as string
|
||||
|
||||
/** The URL of that instance */
|
||||
const url = computed(() => store.state.instances.find(it => it.abbr === abbr)?.url ?? "")
|
||||
|
||||
onMounted(async () => { await store.dispatch(Actions.EnsureInstances) })
|
||||
|
||||
</script>
|
||||
|
||||
@@ -31,7 +31,7 @@ const user = store.state.user as LogOnSuccess
|
||||
/** The story to be displayed */
|
||||
const story : Ref<Success | undefined> = ref(undefined)
|
||||
|
||||
/** The citizen's name (real, display, or NAS, whichever is found first) */
|
||||
/** The citizen's name (real, display, or Mastodon, whichever is found first) */
|
||||
const citizenName = ref("")
|
||||
|
||||
/** Retrieve the success story */
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
namespace JobsJobsJobs.DataMigrate
|
||||
|
||||
/// Converters used to translate between database and domain types
|
||||
module Converters =
|
||||
open JobsJobsJobs.Shared
|
||||
open Microsoft.EntityFrameworkCore.Storage.ValueConversion
|
||||
|
||||
/// Citizen ID converter
|
||||
let CitizenIdConverter = ValueConverter<CitizenId, string> ((fun v -> string v), fun v -> CitizenId.Parse v)
|
||||
|
||||
/// Continent ID converter
|
||||
let ContinentIdConverter = ValueConverter<ContinentId, string> ((fun v -> string v), fun v -> ContinentId.Parse v)
|
||||
|
||||
/// Markdown converter
|
||||
let MarkdownStringConverter = ValueConverter<MarkdownString, string> ((fun v -> v.Text), fun v -> MarkdownString v)
|
||||
|
||||
/// Markdown converter for possibly-null values
|
||||
let OptionalMarkdownStringConverter : ValueConverter<MarkdownString, string> =
|
||||
ValueConverter<MarkdownString, string>
|
||||
((fun v -> match v with null -> null | _ -> v.Text), fun v -> match v with null -> null | _ -> MarkdownString v)
|
||||
|
||||
/// Skill ID converter
|
||||
let SkillIdConverter = ValueConverter<SkillId, string> ((fun v -> string v), fun v -> SkillId.Parse v)
|
||||
|
||||
/// Success ID converter
|
||||
let SuccessIdConverter = ValueConverter<SuccessId, string> ((fun v -> string v), fun v -> SuccessId.Parse v)
|
||||
|
||||
|
||||
open JobsJobsJobs.Shared
|
||||
open Microsoft.EntityFrameworkCore
|
||||
open System.Collections.Generic
|
||||
|
||||
/// Data context for Jobs, Jobs, Jobs
|
||||
type JobsDbContext (options : DbContextOptions<JobsDbContext>) =
|
||||
inherit DbContext (options)
|
||||
|
||||
/// Citizens (users known to us)
|
||||
member val Citizens : DbSet<Citizen> = Unchecked.defaultof<DbSet<Citizen>> with get, set
|
||||
|
||||
/// Continents (large land masses - 7 of them!)
|
||||
member val Continents : DbSet<Continent> = Unchecked.defaultof<DbSet<Continent>> with get, set
|
||||
|
||||
/// Employment profiles
|
||||
member val Profiles : DbSet<Profile> = Unchecked.defaultof<DbSet<Profile>> with get, set
|
||||
|
||||
/// Skills held by citizens of Gitmo Nation
|
||||
member val Skills : DbSet<Skill> = Unchecked.defaultof<DbSet<Skill>> with get, set
|
||||
|
||||
/// Success stories from the site
|
||||
member val Successes : DbSet<Success> = Unchecked.defaultof<DbSet<Success>> with get, set
|
||||
|
||||
override _.OnModelCreating (mb : ModelBuilder) =
|
||||
base.OnModelCreating(mb)
|
||||
|
||||
mb.Entity<Citizen>(fun m ->
|
||||
m.ToTable("citizen", "jjj").HasKey(fun e -> e.Id :> obj) |> ignore
|
||||
m.Property(fun e -> e.Id).HasColumnName("id").IsRequired().HasMaxLength(12)
|
||||
.HasConversion(Converters.CitizenIdConverter) |> ignore
|
||||
m.Property(fun e -> e.NaUser).HasColumnName("na_user").IsRequired().HasMaxLength(50) |> ignore
|
||||
m.Property(fun e -> e.DisplayName).HasColumnName("display_name").HasMaxLength(255) |> ignore
|
||||
m.Property(fun e -> e.ProfileUrl).HasColumnName("profile_url").IsRequired().HasMaxLength(1_024) |> ignore
|
||||
m.Property(fun e -> e.JoinedOn).HasColumnName("joined_on").IsRequired() |> ignore
|
||||
m.Property(fun e -> e.LastSeenOn).HasColumnName("last_seen_on").IsRequired() |> ignore
|
||||
m.Property(fun e -> e.RealName).HasColumnName("real_name").HasMaxLength(255) |> ignore
|
||||
m.HasIndex(fun e -> e.NaUser :> obj).IsUnique() |> ignore
|
||||
m.Ignore(fun e -> e.CitizenName :> obj) |> ignore)
|
||||
|> ignore
|
||||
|
||||
mb.Entity<Continent>(fun m ->
|
||||
m.ToTable("continent", "jjj").HasKey(fun e -> e.Id :> obj) |> ignore
|
||||
m.Property(fun e -> e.Id).HasColumnName("id").IsRequired().HasMaxLength(12)
|
||||
.HasConversion(Converters.ContinentIdConverter) |> ignore
|
||||
m.Property(fun e -> e.Name).HasColumnName("name").IsRequired().HasMaxLength(255) |> ignore)
|
||||
|> ignore
|
||||
|
||||
mb.Entity<Profile>(fun m ->
|
||||
m.ToTable("profile", "jjj").HasKey(fun e -> e.Id :> obj) |> ignore
|
||||
m.Property(fun e -> e.Id).HasColumnName("citizen_id").IsRequired().HasMaxLength(12)
|
||||
.HasConversion(Converters.CitizenIdConverter) |> ignore
|
||||
m.Property(fun e -> e.SeekingEmployment).HasColumnName("seeking_employment").IsRequired() |> ignore
|
||||
m.Property(fun e -> e.IsPublic).HasColumnName("is_public").IsRequired() |> ignore
|
||||
m.Property(fun e -> e.ContinentId).HasColumnName("continent_id").IsRequired().HasMaxLength(12)
|
||||
.HasConversion(Converters.ContinentIdConverter) |> ignore
|
||||
m.Property(fun e -> e.Region).HasColumnName("region").IsRequired().HasMaxLength(255) |> ignore
|
||||
m.Property(fun e -> e.RemoteWork).HasColumnName("remote_work").IsRequired() |> ignore
|
||||
m.Property(fun e -> e.FullTime).HasColumnName("full_time").IsRequired() |> ignore
|
||||
m.Property(fun e -> e.Biography).HasColumnName("biography").IsRequired()
|
||||
.HasConversion(Converters.MarkdownStringConverter) |> ignore
|
||||
m.Property(fun e -> e.LastUpdatedOn).HasColumnName("last_updated_on").IsRequired() |> ignore
|
||||
m.Property(fun e -> e.Experience).HasColumnName("experience")
|
||||
.HasConversion(Converters.OptionalMarkdownStringConverter) |> ignore
|
||||
m.HasOne(fun e -> e.Continent :> obj)
|
||||
.WithMany()
|
||||
.HasForeignKey(fun e -> e.ContinentId :> obj) |> ignore
|
||||
m.HasMany(fun e -> e.Skills :> IEnumerable<Skill>)
|
||||
.WithOne()
|
||||
.HasForeignKey(fun e -> e.CitizenId :> obj) |> ignore)
|
||||
|> ignore
|
||||
|
||||
mb.Entity<Skill>(fun m ->
|
||||
m.ToTable("skill", "jjj").HasKey(fun e -> e.Id :> obj) |> ignore
|
||||
m.Property(fun e -> e.Id).HasColumnName("id").IsRequired().HasMaxLength(12)
|
||||
.HasConversion(Converters.SkillIdConverter) |> ignore
|
||||
m.Property(fun e -> e.CitizenId).HasColumnName("citizen_id").IsRequired().HasMaxLength(12)
|
||||
.HasConversion(Converters.CitizenIdConverter) |> ignore
|
||||
m.Property(fun e -> e.Description).HasColumnName("skill").IsRequired().HasMaxLength(100) |> ignore
|
||||
m.Property(fun e -> e.Notes).HasColumnName("notes").HasMaxLength(100) |> ignore)
|
||||
|> ignore
|
||||
|
||||
mb.Entity<Success>(fun m ->
|
||||
m.ToTable("success", "jjj").HasKey(fun e -> e.Id :> obj) |> ignore
|
||||
m.Property(fun e -> e.Id).HasColumnName("id").IsRequired().HasMaxLength(12)
|
||||
.HasConversion(Converters.SuccessIdConverter) |> ignore
|
||||
m.Property(fun e -> e.CitizenId).HasColumnName("citizen_id").IsRequired().HasMaxLength(12)
|
||||
.HasConversion(Converters.CitizenIdConverter) |> ignore
|
||||
m.Property(fun e -> e.RecordedOn).HasColumnName("recorded_on").IsRequired() |> ignore
|
||||
m.Property(fun e -> e.FromHere).HasColumnName("from_here").IsRequired() |> ignore
|
||||
// m.Property(fun e -> e.Source).HasColumnName("source").IsRequired().HasMaxLength(7);
|
||||
m.Property(fun e -> e.Story).HasColumnName("story")
|
||||
.HasConversion(Converters.OptionalMarkdownStringConverter) |> ignore)
|
||||
|> ignore
|
||||
@@ -1,28 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<WarnOn>3390;$(WarnOn)</WarnOn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="DataContext.fs" />
|
||||
<Compile Include="Program.fs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Shared\JobsJobsJobs.Shared.csproj" />
|
||||
<ProjectReference Include="..\Api\Api.fsproj" />
|
||||
<ProjectReference Include="..\Domain\Domain.fsproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Npgsql" Version="5.0.7" />
|
||||
<PackageReference Include="Npgsql" Version="5.0.7" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="5.0.7" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="5.0.7" />
|
||||
<PackageReference Include="Npgsql.NodaTime" Version="5.0.7" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,142 +0,0 @@
|
||||
// Learn more about F# at http://docs.microsoft.com/dotnet/fsharp
|
||||
|
||||
open FSharp.Control.Tasks
|
||||
open System
|
||||
open System.Linq
|
||||
open JobsJobsJobs.Api.Data
|
||||
open JobsJobsJobs.Domain
|
||||
open JobsJobsJobs.DataMigrate
|
||||
open Microsoft.EntityFrameworkCore
|
||||
open Microsoft.Extensions.Hosting
|
||||
open Microsoft.Extensions.DependencyInjection
|
||||
open Microsoft.Extensions.Logging
|
||||
open RethinkDb.Driver.Net
|
||||
|
||||
/// Create the host (reads configuration and initializes both databases)
|
||||
let createHostBuilder argv =
|
||||
Host.CreateDefaultBuilder(argv)
|
||||
.ConfigureServices(
|
||||
Action<HostBuilderContext, IServiceCollection> (
|
||||
fun hostCtx svcs ->
|
||||
svcs.AddSingleton hostCtx.Configuration |> ignore
|
||||
// PostgreSQL via EF Core
|
||||
svcs.AddDbContext<JobsDbContext>(fun options ->
|
||||
options.UseNpgsql(hostCtx.Configuration.["ConnectionStrings:JobsDb"],
|
||||
fun o -> o.UseNodaTime() |> ignore)
|
||||
|> ignore)
|
||||
|> ignore
|
||||
// RethinkDB
|
||||
let cfg = hostCtx.Configuration.GetSection "Rethink"
|
||||
let log = svcs.BuildServiceProvider().GetRequiredService<ILoggerFactory>().CreateLogger "Data"
|
||||
let conn = Startup.createConnection cfg log
|
||||
svcs.AddSingleton conn |> ignore
|
||||
Startup.establishEnvironment cfg log conn |> awaitIgnore
|
||||
))
|
||||
.Build()
|
||||
|
||||
[<EntryPoint>]
|
||||
let main argv =
|
||||
let host = createHostBuilder argv
|
||||
let r = RethinkDb.Driver.RethinkDB.R
|
||||
|
||||
printfn "0) Connecting to databases..."
|
||||
|
||||
use db = host.Services.GetRequiredService<JobsDbContext> ()
|
||||
let conn = host.Services.GetRequiredService<IConnection> ()
|
||||
|
||||
task {
|
||||
|
||||
printfn "1) Migrating continents..."
|
||||
|
||||
let mutable continentXref = Map.empty<string, Types.ContinentId>
|
||||
let! continents = db.Continents.AsNoTracking().ToListAsync ()
|
||||
let reContinents =
|
||||
continents
|
||||
|> Seq.map (fun c ->
|
||||
let reContinentId = ContinentId.create ()
|
||||
continentXref <- continentXref.Add (string c.Id, reContinentId)
|
||||
let it : Types.Continent = {
|
||||
id = reContinentId
|
||||
name = c.Name
|
||||
}
|
||||
it)
|
||||
|> List.ofSeq
|
||||
let! _ = r.Table(Table.Continent).Insert(reContinents).RunWriteAsync conn
|
||||
|
||||
printfn "2) Migrating citizens..."
|
||||
|
||||
let mutable citizenXref = Map.empty<string, Types.CitizenId>
|
||||
let! citizens = db.Citizens.AsNoTracking().ToListAsync ()
|
||||
let reCitizens =
|
||||
citizens
|
||||
|> Seq.map (fun c ->
|
||||
let reCitizenId = CitizenId.create ()
|
||||
citizenXref <- citizenXref.Add (string c.Id, reCitizenId)
|
||||
let it : Types.Citizen = {
|
||||
id = reCitizenId
|
||||
naUser = c.NaUser
|
||||
displayName = Option.ofObj c.DisplayName
|
||||
realName = Option.ofObj c.RealName
|
||||
profileUrl = c.ProfileUrl
|
||||
joinedOn = c.JoinedOn
|
||||
lastSeenOn = c.LastSeenOn
|
||||
}
|
||||
it)
|
||||
let! _ = r.Table(Table.Citizen).Insert(reCitizens).RunWriteAsync conn
|
||||
|
||||
printfn "3) Migrating profiles and skills..."
|
||||
|
||||
let! profiles = db.Profiles.AsNoTracking().ToListAsync ()
|
||||
let reProfiles =
|
||||
profiles
|
||||
|> Seq.map (fun p ->
|
||||
let skills = db.Skills.AsNoTracking().Where(fun s -> s.CitizenId = p.Id).ToList ()
|
||||
let reSkills =
|
||||
skills
|
||||
|> Seq.map (fun skill ->
|
||||
let it : Types.Skill = {
|
||||
id = SkillId.create()
|
||||
description = skill.Description
|
||||
notes = Option.ofObj skill.Notes
|
||||
}
|
||||
it)
|
||||
|> List.ofSeq
|
||||
let it : Types.Profile = {
|
||||
id = citizenXref.[string p.Id]
|
||||
seekingEmployment = p.SeekingEmployment
|
||||
isPublic = p.IsPublic
|
||||
continentId = continentXref.[string p.ContinentId]
|
||||
region = p.Region
|
||||
remoteWork = p.RemoteWork
|
||||
fullTime = p.FullTime
|
||||
biography = Types.Text p.Biography.Text
|
||||
lastUpdatedOn = p.LastUpdatedOn
|
||||
experience = match p.Experience with null -> None | x -> (Types.Text >> Some) x.Text
|
||||
skills = reSkills
|
||||
}
|
||||
it)
|
||||
let! _ = r.Table(Table.Profile).Insert(reProfiles).RunWriteAsync conn
|
||||
|
||||
printfn "4) Migrating success stories..."
|
||||
|
||||
let! successes = db.Successes.AsNoTracking().ToListAsync ()
|
||||
let reSuccesses =
|
||||
successes
|
||||
|> Seq.map (fun s ->
|
||||
let it : Types.Success = {
|
||||
id = SuccessId.create ()
|
||||
citizenId = citizenXref.[string s.CitizenId]
|
||||
recordedOn = s.RecordedOn
|
||||
fromHere = s.FromHere
|
||||
source = "profile"
|
||||
story = match s.Story with null -> None | x -> (Types.Text >> Some) x.Text
|
||||
}
|
||||
it)
|
||||
let! _ = r.Table(Table.Success).Insert(reSuccesses).RunWriteAsync conn
|
||||
()
|
||||
}
|
||||
|> awaitIgnore
|
||||
|
||||
printfn "Migration complete"
|
||||
|
||||
0
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Markdig" Version="0.25.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="5.0.0" />
|
||||
<PackageReference Include="NodaTime" Version="3.0.5" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ module CitizenId =
|
||||
module Citizen =
|
||||
/// Get the name of the citizen (the first of real name, display name, or handle that is filled in)
|
||||
let name x =
|
||||
[ x.realName; x.displayName; Some x.naUser ]
|
||||
[ x.realName; x.displayName; Some x.mastodonUser ]
|
||||
|> List.find Option.isSome
|
||||
|> Option.get
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
module JobsJobsJobs.Domain.SharedTypes
|
||||
|
||||
open JobsJobsJobs.Domain.Types
|
||||
open Microsoft.Extensions.Options
|
||||
open NodaTime
|
||||
|
||||
// fsharplint:disable FieldNames
|
||||
@@ -75,6 +76,45 @@ type Count = {
|
||||
}
|
||||
|
||||
|
||||
/// 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
|
||||
|
||||
|
||||
/// The authorization options for Jobs, Jobs, Jobs
|
||||
type AuthOptions () =
|
||||
/// The host for the return URL for Mastodoon 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
|
||||
}
|
||||
|
||||
|
||||
/// The fields required for a skill
|
||||
type SkillForm = {
|
||||
/// The ID of this skill
|
||||
|
||||
@@ -13,19 +13,21 @@ type CitizenId = CitizenId of Guid
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type Citizen = {
|
||||
/// 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
|
||||
naUser : string
|
||||
mastodonUser : string
|
||||
/// The user's display name from Mastodon (updated every login)
|
||||
displayName : string option
|
||||
displayName : string option
|
||||
/// The user's real name
|
||||
realName : string option
|
||||
realName : string option
|
||||
/// The URL for the user's Mastodon profile
|
||||
profileUrl : string
|
||||
profileUrl : string
|
||||
/// When the user joined Jobs, Jobs, Jobs
|
||||
joinedOn : Instant
|
||||
joinedOn : Instant
|
||||
/// When the user last logged in
|
||||
lastSeenOn : Instant
|
||||
lastSeenOn : Instant
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace JobsJobsJobs.Shared.Api
|
||||
{
|
||||
/// <summary>
|
||||
/// A transport mechanism to send counts across the wire via JSON
|
||||
/// </summary>
|
||||
public record Count(int Value);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
namespace JobsJobsJobs.Shared.Api
|
||||
{
|
||||
/// <summary>
|
||||
/// A successful log on; returns JWT, citizen ID, and display name
|
||||
/// </summary>
|
||||
public record LogOnSuccess(string Jwt, string Id, string Name)
|
||||
{
|
||||
/// <summary>
|
||||
/// The ID return value as a citizen ID
|
||||
/// </summary>
|
||||
public CitizenId CitizenId => CitizenId.Parse(Id);
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
|
||||
namespace JobsJobsJobs.Shared.Api
|
||||
{
|
||||
/// <summary>
|
||||
/// The data required to update a profile
|
||||
/// </summary>
|
||||
public class ProfileForm
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the citizen to whom this profile belongs is actively seeking employment
|
||||
/// </summary>
|
||||
public bool IsSeekingEmployment { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this profile should appear in the public search
|
||||
/// </summary>
|
||||
public bool IsPublic { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The user's real name
|
||||
/// </summary>
|
||||
[StringLength(255)]
|
||||
public string RealName { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// The ID of the continent on which the citizen is located
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(12, MinimumLength = 1)]
|
||||
[Display(Name = "Continent")]
|
||||
public string ContinentId { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// The area within that continent where the citizen is located
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(255)]
|
||||
public string Region { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// If the citizen is available for remote work
|
||||
/// </summary>
|
||||
public bool RemoteWork { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// If the citizen is seeking full-time employment
|
||||
/// </summary>
|
||||
public bool FullTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The user's professional biography
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string Biography { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// The user's past experience
|
||||
/// </summary>
|
||||
public string Experience { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// The skills for the user
|
||||
/// </summary>
|
||||
public ICollection<SkillForm> Skills { get; set; } = new List<SkillForm>();
|
||||
|
||||
/// <summary>
|
||||
/// Create an instance of this form from the given profile
|
||||
/// </summary>
|
||||
/// <param name="profile">The profile off which this form will be based</param>
|
||||
/// <returns>The profile form, popluated with values from the given profile</returns>
|
||||
public static ProfileForm FromProfile(Profile profile) =>
|
||||
new ProfileForm
|
||||
{
|
||||
IsSeekingEmployment = profile.SeekingEmployment,
|
||||
IsPublic = profile.IsPublic,
|
||||
ContinentId = profile.ContinentId.ToString(),
|
||||
Region = profile.Region,
|
||||
RemoteWork = profile.RemoteWork,
|
||||
FullTime = profile.FullTime,
|
||||
Biography = profile.Biography.Text,
|
||||
Experience = profile.Experience?.Text ?? "",
|
||||
Skills = profile.Skills.Select(s => new SkillForm
|
||||
{
|
||||
Id = s.Id.ToString(),
|
||||
Description = s.Description,
|
||||
Notes = s.Notes
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
namespace JobsJobsJobs.Shared.Api
|
||||
{
|
||||
/// <summary>
|
||||
/// The various ways profiles can be searched
|
||||
/// </summary>
|
||||
public class ProfileSearch
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieve citizens from this continent
|
||||
/// </summary>
|
||||
public string? ContinentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Text for a search within a citizen's skills
|
||||
/// </summary>
|
||||
public string? Skill { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Text for a search with a citizen's professional biography and experience fields
|
||||
/// </summary>
|
||||
public string? BioExperience { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to retrieve citizens who do or do not want remote work
|
||||
/// </summary>
|
||||
public string RemoteWork { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Is the search empty?
|
||||
/// </summary>
|
||||
public bool IsEmptySearch =>
|
||||
string.IsNullOrEmpty(ContinentId)
|
||||
&& string.IsNullOrEmpty(Skill)
|
||||
&& string.IsNullOrEmpty(BioExperience)
|
||||
&& string.IsNullOrEmpty(RemoteWork);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
using NodaTime;
|
||||
|
||||
namespace JobsJobsJobs.Shared.Api
|
||||
{
|
||||
/// <summary>
|
||||
/// A user matching the profile search
|
||||
/// </summary>
|
||||
public record ProfileSearchResult(
|
||||
CitizenId CitizenId,
|
||||
string DisplayName,
|
||||
bool SeekingEmployment,
|
||||
bool RemoteWork,
|
||||
bool FullTime,
|
||||
Instant LastUpdated);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
namespace JobsJobsJobs.Shared.Api
|
||||
{
|
||||
/// <summary>
|
||||
/// The parameters for a public job search
|
||||
/// </summary>
|
||||
public class PublicSearch
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieve citizens from this continent
|
||||
/// </summary>
|
||||
public string? ContinentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve citizens from this region
|
||||
/// </summary>
|
||||
public string? Region { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Text for a search within a citizen's skills
|
||||
/// </summary>
|
||||
public string? Skill { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to retrieve citizens who do or do not want remote work
|
||||
/// </summary>
|
||||
public string RemoteWork { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Is the search empty?
|
||||
/// </summary>
|
||||
public bool IsEmptySearch =>
|
||||
string.IsNullOrEmpty(ContinentId)
|
||||
&& string.IsNullOrEmpty(Region)
|
||||
&& string.IsNullOrEmpty(Skill)
|
||||
&& string.IsNullOrEmpty(RemoteWork);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace JobsJobsJobs.Shared.Api
|
||||
{
|
||||
/// <summary>
|
||||
/// A public profile search result
|
||||
/// </summary>
|
||||
public record PublicSearchResult(
|
||||
string Continent,
|
||||
string Region,
|
||||
bool RemoteWork,
|
||||
IEnumerable<string> Skills);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace JobsJobsJobs.Shared.Api
|
||||
{
|
||||
/// <summary>
|
||||
/// The fields required for a skill
|
||||
/// </summary>
|
||||
public class SkillForm
|
||||
{
|
||||
/// <summary>
|
||||
/// The ID of this skill
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// The description of the skill
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string Description { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Notes regarding the skill
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Notes { get; set; } = null;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
using NodaTime;
|
||||
|
||||
namespace JobsJobsJobs.Shared.Api
|
||||
{
|
||||
/// <summary>
|
||||
/// An entry in the list of success stories
|
||||
/// </summary>
|
||||
public record StoryEntry(
|
||||
SuccessId Id,
|
||||
CitizenId CitizenId,
|
||||
string CitizenName,
|
||||
Instant RecordedOn,
|
||||
bool FromHere,
|
||||
bool HasStory);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
namespace JobsJobsJobs.Shared.Api
|
||||
{
|
||||
/// <summary>
|
||||
/// The data required to provide a success story
|
||||
/// </summary>
|
||||
public class StoryForm
|
||||
{
|
||||
/// <summary>
|
||||
/// The ID of this story
|
||||
/// </summary>
|
||||
public string Id { get; set; } = "new";
|
||||
|
||||
/// <summary>
|
||||
/// Whether the employment was obtained from Jobs, Jobs, Jobs
|
||||
/// </summary>
|
||||
public bool FromHere { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// The success story
|
||||
/// </summary>
|
||||
public string Story { get; set; } = "";
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
using NodaTime;
|
||||
|
||||
namespace JobsJobsJobs.Shared
|
||||
{
|
||||
/// <summary>
|
||||
/// A user of Jobs, Jobs, Jobs
|
||||
/// </summary>
|
||||
public record Citizen(
|
||||
CitizenId Id,
|
||||
string NaUser,
|
||||
string? DisplayName,
|
||||
string? RealName,
|
||||
string ProfileUrl,
|
||||
Instant JoinedOn,
|
||||
Instant LastSeenOn)
|
||||
{
|
||||
/// <summary>
|
||||
/// The user's name by which they should be known
|
||||
/// </summary>
|
||||
public string CitizenName => RealName ?? DisplayName ?? NaUser;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace JobsJobsJobs.Shared
|
||||
{
|
||||
/// <summary>
|
||||
/// A continent
|
||||
/// </summary>
|
||||
public record Continent(ContinentId Id, string Name);
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace JobsJobsJobs.Shared
|
||||
{
|
||||
/// <summary>
|
||||
/// A short ID
|
||||
/// </summary>
|
||||
public record ShortId(string Id)
|
||||
{
|
||||
/// <summary>
|
||||
/// Validate the format of the short ID
|
||||
/// </summary>
|
||||
private static readonly Regex ValidShortId =
|
||||
new Regex("^[a-z0-9_-]{12}", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Create a new short ID
|
||||
/// </summary>
|
||||
/// <returns>A new short ID</returns>
|
||||
public static async Task<ShortId> Create() => new ShortId(await Nanoid.Nanoid.GenerateAsync(size: 12));
|
||||
|
||||
/// <summary>
|
||||
/// Try to parse a string of text into a short ID
|
||||
/// </summary>
|
||||
/// <param name="text">The text of the prospective short ID</param>
|
||||
/// <returns>The short ID</returns>
|
||||
/// <exception cref="FormatException">If the format is not valid</exception>
|
||||
public static ShortId Parse(string text)
|
||||
{
|
||||
if (text.Length == 12 && ValidShortId.IsMatch(text)) return new ShortId(text);
|
||||
throw new FormatException($"The string {text} is not a valid short ID");
|
||||
}
|
||||
|
||||
public override string ToString() => Id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The ID of a user (a citizen of Gitmo Nation)
|
||||
/// </summary>
|
||||
public record CitizenId(ShortId Id)
|
||||
{
|
||||
/// <summary>
|
||||
/// Create a new citizen ID
|
||||
/// </summary>
|
||||
/// <returns>A new citizen ID</returns>
|
||||
public static async Task<CitizenId> Create() => new CitizenId(await ShortId.Create());
|
||||
|
||||
/// <summary>
|
||||
/// Attempt to create a citizen ID from a string
|
||||
/// </summary>
|
||||
/// <param name="id">The prospective ID</param>
|
||||
/// <returns>The citizen ID</returns>
|
||||
/// <exception cref="System.FormatException">If the string is not a valid citizen ID</exception>
|
||||
public static CitizenId Parse(string id) => new(ShortId.Parse(id));
|
||||
|
||||
public override string ToString() => Id.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The ID of a continent
|
||||
/// </summary>
|
||||
public record ContinentId(ShortId Id)
|
||||
{
|
||||
/// <summary>
|
||||
/// Create a new continent ID
|
||||
/// </summary>
|
||||
/// <returns>A new continent ID</returns>
|
||||
public static async Task<ContinentId> Create() => new ContinentId(await ShortId.Create());
|
||||
|
||||
/// <summary>
|
||||
/// Attempt to create a continent ID from a string
|
||||
/// </summary>
|
||||
/// <param name="id">The prospective ID</param>
|
||||
/// <returns>The continent ID</returns>
|
||||
/// <exception cref="System.FormatException">If the string is not a valid continent ID</exception>
|
||||
public static ContinentId Parse(string id) => new(ShortId.Parse(id));
|
||||
|
||||
public override string ToString() => Id.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The ID of a job listing
|
||||
/// </summary>
|
||||
public record ListingId(ShortId Id)
|
||||
{
|
||||
/// <summary>
|
||||
/// Create a new job listing ID
|
||||
/// </summary>
|
||||
/// <returns>A new job listing ID</returns>
|
||||
public static async Task<ListingId> Create() => new ListingId(await ShortId.Create());
|
||||
|
||||
/// <summary>
|
||||
/// Attempt to create a job listing ID from a string
|
||||
/// </summary>
|
||||
/// <param name="id">The prospective ID</param>
|
||||
/// <returns>The job listing ID</returns>
|
||||
/// <exception cref="System.FormatException">If the string is not a valid job listing ID</exception>
|
||||
public static ListingId Parse(string id) => new(ShortId.Parse(id));
|
||||
|
||||
public override string ToString() => Id.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The ID of a skill
|
||||
/// </summary>
|
||||
public record SkillId(ShortId Id)
|
||||
{
|
||||
/// <summary>
|
||||
/// Create a new skill ID
|
||||
/// </summary>
|
||||
/// <returns>A new skill ID</returns>
|
||||
public static async Task<SkillId> Create() => new SkillId(await ShortId.Create());
|
||||
|
||||
/// <summary>
|
||||
/// Attempt to create a skill ID from a string
|
||||
/// </summary>
|
||||
/// <param name="id">The prospective ID</param>
|
||||
/// <returns>The skill ID</returns>
|
||||
/// <exception cref="System.FormatException">If the string is not a valid skill ID</exception>
|
||||
public static SkillId Parse(string id) => new(ShortId.Parse(id));
|
||||
|
||||
public override string ToString() => Id.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The ID of a success report
|
||||
/// </summary>
|
||||
public record SuccessId(ShortId Id)
|
||||
{
|
||||
/// <summary>
|
||||
/// Create a new success report ID
|
||||
/// </summary>
|
||||
/// <returns>A new success report ID</returns>
|
||||
public static async Task<SuccessId> Create() => new SuccessId(await ShortId.Create());
|
||||
|
||||
/// <summary>
|
||||
/// Attempt to create a success report ID from a string
|
||||
/// </summary>
|
||||
/// <param name="id">The prospective ID</param>
|
||||
/// <returns>The success report ID</returns>
|
||||
/// <exception cref="System.FormatException">If the string is not a valid success report ID</exception>
|
||||
public static SuccessId Parse(string id) => new(ShortId.Parse(id));
|
||||
|
||||
public override string ToString() => Id.ToString();
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
using NodaTime;
|
||||
using System;
|
||||
|
||||
namespace JobsJobsJobs.Shared
|
||||
{
|
||||
/// <summary>
|
||||
/// A job listing
|
||||
/// </summary>
|
||||
public record Listing(
|
||||
ListingId Id,
|
||||
CitizenId CitizenId,
|
||||
Instant CreatedOn,
|
||||
string Title,
|
||||
ContinentId ContinentId,
|
||||
string Region,
|
||||
bool RemoteWork,
|
||||
bool IsExpired,
|
||||
Instant UpdatedOn,
|
||||
MarkdownString Text,
|
||||
LocalDate? NeededBy,
|
||||
bool? WasFilledHere)
|
||||
{
|
||||
/// <summary>
|
||||
/// Navigation property for the citizen who created the job listing
|
||||
/// </summary>
|
||||
public Citizen? Citizen { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Navigation property for the continent
|
||||
/// </summary>
|
||||
public Continent? Continent { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
using Markdig;
|
||||
|
||||
namespace JobsJobsJobs.Shared
|
||||
{
|
||||
/// <summary>
|
||||
/// A string of Markdown text
|
||||
/// </summary>
|
||||
public record MarkdownString(string Text)
|
||||
{
|
||||
/// <summary>
|
||||
/// The Markdown conversion pipeline (enables all advanced features)
|
||||
/// </summary>
|
||||
private readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build();
|
||||
|
||||
/// <summary>
|
||||
/// Convert this Markdown string to HTML
|
||||
/// </summary>
|
||||
/// <returns>This Markdown string as HTML</returns>
|
||||
public string ToHtml() => Markdown.ToHtml(Text, Pipeline);
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
using NodaTime;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace JobsJobsJobs.Shared
|
||||
{
|
||||
/// <summary>
|
||||
/// A job seeker profile
|
||||
/// </summary>
|
||||
public record Profile(
|
||||
CitizenId Id,
|
||||
bool SeekingEmployment,
|
||||
bool IsPublic,
|
||||
ContinentId ContinentId,
|
||||
string Region,
|
||||
bool RemoteWork,
|
||||
bool FullTime,
|
||||
MarkdownString Biography,
|
||||
Instant LastUpdatedOn,
|
||||
MarkdownString? Experience)
|
||||
{
|
||||
/// <summary>
|
||||
/// Navigation property for continent
|
||||
/// </summary>
|
||||
public Continent? Continent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Convenience property for skills associated with a profile
|
||||
/// </summary>
|
||||
public ICollection<Skill> Skills { get; set; } = new List<Skill>();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace JobsJobsJobs.Shared
|
||||
{
|
||||
/// <summary>
|
||||
/// A skill the job seeker possesses
|
||||
/// </summary>
|
||||
public record Skill(SkillId Id, CitizenId CitizenId, string Description, string? Notes);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
using NodaTime;
|
||||
|
||||
namespace JobsJobsJobs.Shared
|
||||
{
|
||||
/// <summary>
|
||||
/// A record of success finding employment
|
||||
/// </summary>
|
||||
public record Success(
|
||||
SuccessId Id,
|
||||
CitizenId CitizenId,
|
||||
Instant RecordedOn,
|
||||
bool FromHere,
|
||||
// string Source,
|
||||
MarkdownString? Story);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Markdig" Version="0.22.1" />
|
||||
<PackageReference Include="Nanoid" Version="2.1.0" />
|
||||
<PackageReference Include="NodaTime" Version="3.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<SupportedPlatform Include="browser" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,85 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace JobsJobsJobs.Shared
|
||||
{
|
||||
/// <summary>
|
||||
/// A result with two different possibilities
|
||||
/// </summary>
|
||||
/// <typeparam name="TOk">The type of the Ok result</typeparam>
|
||||
public struct Result<TOk>
|
||||
{
|
||||
private readonly TOk? _okValue;
|
||||
|
||||
/// <summary>
|
||||
/// Is this an Ok result?
|
||||
/// </summary>
|
||||
public bool IsOk { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Is this an Error result?
|
||||
/// </summary>
|
||||
public bool IsError
|
||||
{
|
||||
get => !IsOk;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Ok value
|
||||
/// </summary>
|
||||
public TOk Ok
|
||||
{
|
||||
get => _okValue!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The error value
|
||||
/// </summary>
|
||||
public string Error { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Constructor (inaccessible - use static creation methods)
|
||||
/// </summary>
|
||||
/// <param name="isOk">Whether this is an Ok result</param>
|
||||
/// <param name="okValue">The value of the Ok result</param>
|
||||
/// <param name="error">The error message of the Error result</param>
|
||||
private Result(bool isOk, TOk? okValue = default, string error = "")
|
||||
{
|
||||
IsOk = isOk;
|
||||
_okValue = okValue;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create an Ok result
|
||||
/// </summary>
|
||||
/// <param name="okValue">The value of the Ok result</param>
|
||||
/// <returns>The Ok result</returns>
|
||||
public static Result<TOk> AsOk(TOk okValue) => new Result<TOk>(true, okValue);
|
||||
|
||||
/// <summary>
|
||||
/// Create an Error result
|
||||
/// </summary>
|
||||
/// <param name="error">The error message</param>
|
||||
/// <returns>The Error result</returns>
|
||||
public static Result<TOk> AsError(string error) => new Result<TOk>(false) { Error = error };
|
||||
|
||||
/// <summary>
|
||||
/// Transform a result if it is OK, passing the error along if it is an error
|
||||
/// </summary>
|
||||
/// <param name="f">The transforming function</param>
|
||||
/// <param name="result">The existing result</param>
|
||||
/// <returns>The resultant result</returns>
|
||||
public static Result<TOk> Bind(Func<TOk, Result<TOk>> f, Result<TOk> result) =>
|
||||
result.IsOk ? f(result.Ok) : result;
|
||||
|
||||
/// <summary>
|
||||
/// Transform a result to a different type if it is OK, passing the error along if it is an error
|
||||
/// </summary>
|
||||
/// <typeparam name="TOther">The type to which the result is transformed</typeparam>
|
||||
/// <param name="f">The transforming function</param>
|
||||
/// <param name="result">The existing result</param>
|
||||
/// <returns>The resultant result</returns>
|
||||
public static Result<TOther> Map<TOther>(Func<TOk, Result<TOther>> f, Result<TOk> result) =>
|
||||
result.IsOk ? f(result.Ok) : Result<TOther>.AsError(result.Error);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user