parent
10658b0d77
commit
2b7dd09630
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1 +1,2 @@
|
||||||
.ionide
|
.ionide
|
||||||
|
.fake
|
||||||
|
|
98
src/JobsJobsJobs.Api/Data.fs
Normal file
98
src/JobsJobsJobs.Api/Data.fs
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
module JobsJobsJobs.Api.Data
|
||||||
|
|
||||||
|
open JobsJobsJobs.Api.Domain
|
||||||
|
open Npgsql.FSharp
|
||||||
|
open System
|
||||||
|
|
||||||
|
/// The connection URI for the database
|
||||||
|
let connectUri = Uri config.dbUri
|
||||||
|
|
||||||
|
|
||||||
|
/// Connect to the database
|
||||||
|
let db () =
|
||||||
|
(Sql.fromUri >> Sql.connect) connectUri
|
||||||
|
|
||||||
|
|
||||||
|
/// Return None if the error is that a single row was expected, but no rows matched
|
||||||
|
let private noneIfNotFound (it : Async<Result<'T, exn>>) = async {
|
||||||
|
match! it with
|
||||||
|
| Ok x ->
|
||||||
|
return (Some >> Ok) x
|
||||||
|
| Error err ->
|
||||||
|
return match err.Message with msg when msg.Contains "at least one" -> Ok None | _ -> Error err
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Get the item count from a single-row result
|
||||||
|
let private itemCount (read: RowReader) = read.int64 "item_count"
|
||||||
|
|
||||||
|
|
||||||
|
/// Functions for manipulating citizens
|
||||||
|
// (SHUT UP, SLAVE!)
|
||||||
|
module Citizens =
|
||||||
|
|
||||||
|
/// Create a Citizen from a row of data
|
||||||
|
let private fromReader (read: RowReader) =
|
||||||
|
match (read.string >> CitizenId.tryParse) "id" with
|
||||||
|
| Ok citizenId -> {
|
||||||
|
id = citizenId
|
||||||
|
naUser = read.string "na_user"
|
||||||
|
displayName = read.string "display_name"
|
||||||
|
profileUrl = read.string "profile_url"
|
||||||
|
joinedOn = (read.int64 >> Millis) "joined_on"
|
||||||
|
lastSeenOn = (read.int64 >> Millis) "last_seen_on"
|
||||||
|
}
|
||||||
|
| Error err -> failwith err
|
||||||
|
|
||||||
|
/// Determine if we already know about this user from No Agenda Social
|
||||||
|
let findIdByNaUser naUser =
|
||||||
|
db ()
|
||||||
|
|> Sql.query "SELECT id FROM citizen WHERE na_user = @na_user"
|
||||||
|
|> Sql.parameters [ "@na_user", Sql.string naUser ]
|
||||||
|
|> Sql.executeRowAsync (fun read ->
|
||||||
|
match (read.string >> CitizenId.tryParse) "id" with
|
||||||
|
| Ok citizenId -> citizenId
|
||||||
|
| Error err -> failwith err)
|
||||||
|
|> noneIfNotFound
|
||||||
|
|
||||||
|
/// Add a citizen
|
||||||
|
let add citizen =
|
||||||
|
db ()
|
||||||
|
|> Sql.query
|
||||||
|
"""INSERT INTO citizen (
|
||||||
|
na_user, display_name, profile_url, joined_on, last_seen_on, id
|
||||||
|
) VALUES (
|
||||||
|
@na_user, @display_name, @profile_url, @joined_on, @last_seen_on, @id
|
||||||
|
)"""
|
||||||
|
|> Sql.parameters [
|
||||||
|
"@na_user", Sql.string citizen.naUser
|
||||||
|
"@display_name", Sql.string citizen.displayName
|
||||||
|
"@profile_url", Sql.string citizen.profileUrl
|
||||||
|
"@joined_on", (Millis.toLong >> Sql.int64) citizen.joinedOn
|
||||||
|
"@last_seen_on", (Millis.toLong >> Sql.int64) citizen.lastSeenOn
|
||||||
|
"@id", (CitizenId.toString >> Sql.string) citizen.id
|
||||||
|
]
|
||||||
|
|> Sql.executeNonQueryAsync
|
||||||
|
|
||||||
|
/// Update a citizen record when they log on
|
||||||
|
let update citizenId displayName =
|
||||||
|
db ()
|
||||||
|
|> Sql.query
|
||||||
|
"""UPDATE citizen
|
||||||
|
SET display_name = @display_name,
|
||||||
|
last_seen_on = @last_seen_on
|
||||||
|
WHERE id = @id"""
|
||||||
|
|> Sql.parameters [
|
||||||
|
"@display_name", Sql.string displayName
|
||||||
|
"@last_seen_on", (DateTime.Now.toMillis >> Millis.toLong >> Sql.int64) ()
|
||||||
|
"@id", (CitizenId.toString >> Sql.string) citizenId
|
||||||
|
]
|
||||||
|
|> Sql.executeNonQueryAsync
|
||||||
|
|
||||||
|
/// Try to find a citizen with the given ID
|
||||||
|
let tryFind citizenId =
|
||||||
|
db ()
|
||||||
|
|> Sql.query "SELECT * FROM citizen WHERE id = @id"
|
||||||
|
|> Sql.parameters [ "@id", (CitizenId.toString >> Sql.string) citizenId ]
|
||||||
|
|> Sql.executeRowAsync fromReader
|
||||||
|
|> noneIfNotFound
|
272
src/JobsJobsJobs.Api/Domain.fs
Normal file
272
src/JobsJobsJobs.Api/Domain.fs
Normal file
|
@ -0,0 +1,272 @@
|
||||||
|
module JobsJobsJobs.Api.Domain
|
||||||
|
|
||||||
|
// fsharplint:disable RecordFieldNames MemberNames
|
||||||
|
|
||||||
|
/// A short ID (12 characters of a Nano ID)
|
||||||
|
type ShortId =
|
||||||
|
| ShortId of string
|
||||||
|
|
||||||
|
/// Functions to maniuplate short IDs
|
||||||
|
module ShortId =
|
||||||
|
|
||||||
|
open Nanoid
|
||||||
|
open System.Text.RegularExpressions
|
||||||
|
|
||||||
|
/// Regular expression to validate a string's format as a short ID
|
||||||
|
let validShortId = Regex ("^[a-z0-9_-]{12}", RegexOptions.Compiled ||| RegexOptions.IgnoreCase)
|
||||||
|
|
||||||
|
/// Convert a short ID to its string representation
|
||||||
|
let toString = function ShortId text -> text
|
||||||
|
|
||||||
|
/// Create a new short ID
|
||||||
|
let create () = async {
|
||||||
|
let! text = Nanoid.GenerateAsync (size = 12)
|
||||||
|
return ShortId text
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to parse a string into a short ID
|
||||||
|
let tryParse (text : string) =
|
||||||
|
match text.Length with
|
||||||
|
| 12 when validShortId.IsMatch text -> (ShortId >> Ok) text
|
||||||
|
| 12 -> Error "ShortId must be 12 characters [a-z,0-9,-, or _]"
|
||||||
|
| x -> Error (sprintf "ShortId must be 12 characters; %d provided" x)
|
||||||
|
|
||||||
|
|
||||||
|
/// The ID for a citizen (user) record
|
||||||
|
type CitizenId =
|
||||||
|
| CitizenId of ShortId
|
||||||
|
|
||||||
|
/// Functions for manipulating citizen (user) IDs
|
||||||
|
module CitizenId =
|
||||||
|
/// Convert a citizen ID to its string representation
|
||||||
|
let toString = function CitizenId shortId -> ShortId.toString shortId
|
||||||
|
|
||||||
|
/// Create a new citizen ID
|
||||||
|
let create () = async {
|
||||||
|
let! shortId = ShortId.create ()
|
||||||
|
return CitizenId shortId
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to parse a string into a CitizenId
|
||||||
|
let tryParse text =
|
||||||
|
match ShortId.tryParse text with
|
||||||
|
| Ok shortId -> (CitizenId >> Ok) shortId
|
||||||
|
| Error err -> Error err
|
||||||
|
|
||||||
|
|
||||||
|
/// The ID for a continent record
|
||||||
|
type ContinentId =
|
||||||
|
| ContinentId of ShortId
|
||||||
|
|
||||||
|
/// Functions for manipulating continent IDs
|
||||||
|
module ContinentId =
|
||||||
|
/// Convert a continent ID to its string representation
|
||||||
|
let toString = function ContinentId shortId -> ShortId.toString shortId
|
||||||
|
|
||||||
|
/// Create a new continent ID
|
||||||
|
let create () = async {
|
||||||
|
let! shortId = ShortId.create ()
|
||||||
|
return ContinentId shortId
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to parse a string into a ContinentId
|
||||||
|
let tryParse text =
|
||||||
|
match ShortId.tryParse text with
|
||||||
|
| Ok shortId -> (ContinentId >> Ok) shortId
|
||||||
|
| Error err -> Error err
|
||||||
|
|
||||||
|
|
||||||
|
/// The ID for a skill record
|
||||||
|
type SkillId =
|
||||||
|
| SkillId of ShortId
|
||||||
|
|
||||||
|
/// Functions for manipulating skill IDs
|
||||||
|
module SkillId =
|
||||||
|
/// Convert a skill ID to its string representation
|
||||||
|
let toString = function SkillId shortId -> ShortId.toString shortId
|
||||||
|
|
||||||
|
/// Create a new skill ID
|
||||||
|
let create () = async {
|
||||||
|
let! shortId = ShortId.create ()
|
||||||
|
return SkillId shortId
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to parse a string into a CitizenId
|
||||||
|
let tryParse text =
|
||||||
|
match ShortId.tryParse text with
|
||||||
|
| Ok shortId -> (SkillId >> Ok) shortId
|
||||||
|
| Error err -> Error err
|
||||||
|
|
||||||
|
|
||||||
|
/// The ID for a success report record
|
||||||
|
type SuccessId =
|
||||||
|
| SuccessId of ShortId
|
||||||
|
|
||||||
|
/// Functions for manipulating success report IDs
|
||||||
|
module SuccessId =
|
||||||
|
/// Convert a success report ID to its string representation
|
||||||
|
let toString = function SuccessId shortId -> ShortId.toString shortId
|
||||||
|
|
||||||
|
/// Create a new success report ID
|
||||||
|
let create () = async {
|
||||||
|
let! shortId = ShortId.create ()
|
||||||
|
return SuccessId shortId
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to parse a string into a SuccessId
|
||||||
|
let tryParse text =
|
||||||
|
match ShortId.tryParse text with
|
||||||
|
| Ok shortId -> (SuccessId >> Ok) shortId
|
||||||
|
| Error err -> Error err
|
||||||
|
|
||||||
|
|
||||||
|
/// A number representing milliseconds since the epoch (AKA JavaScript time)
|
||||||
|
type Millis =
|
||||||
|
| Millis of int64
|
||||||
|
|
||||||
|
/// Functions to manipulate ticks
|
||||||
|
module Millis =
|
||||||
|
/// Convert a Ticks instance to its primitive value
|
||||||
|
let toLong = function Millis millis -> millis
|
||||||
|
|
||||||
|
|
||||||
|
/// A string that holds Markdown-formatted text
|
||||||
|
type MarkdownString =
|
||||||
|
| MarkdownString of string
|
||||||
|
|
||||||
|
/// Functions to manipulate Markdown-formatted text
|
||||||
|
module MarkdownString =
|
||||||
|
|
||||||
|
open Markdig
|
||||||
|
|
||||||
|
/// Markdown pipeline that supports all built-in Markdown extensions
|
||||||
|
let private pipeline = MarkdownPipelineBuilder().UseAdvancedExtensions().Build ()
|
||||||
|
|
||||||
|
/// Get the plain-text (non-rendered) representation of the text
|
||||||
|
let toText = function MarkdownString str -> str
|
||||||
|
|
||||||
|
/// Get the HTML (rendered) representation of the text
|
||||||
|
let toHtml = function MarkdownString str -> Markdown.ToHtml (str, pipeline)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/// A user
|
||||||
|
type Citizen = {
|
||||||
|
/// The ID of the user
|
||||||
|
id : CitizenId
|
||||||
|
/// The user's handle on No Agenda Social
|
||||||
|
naUser : string
|
||||||
|
/// The user's display name from No Agenda Social (as of their last login here)
|
||||||
|
displayName : string
|
||||||
|
/// The URL to the user's profile on No Agenda Social
|
||||||
|
profileUrl : string
|
||||||
|
/// When the user signed up here
|
||||||
|
joinedOn : Millis
|
||||||
|
/// When the user last logged on here
|
||||||
|
lastSeenOn : Millis
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// A continent
|
||||||
|
type Continent = {
|
||||||
|
/// The ID of the continent
|
||||||
|
id : ContinentId
|
||||||
|
/// The name of the continent
|
||||||
|
name : string
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// An employment / skills profile
|
||||||
|
type Profile = {
|
||||||
|
/// The ID of the user to whom the profile applies
|
||||||
|
citizenId : CitizenId
|
||||||
|
/// Whether this user is actively seeking employment
|
||||||
|
seekingEmployment : bool
|
||||||
|
/// Whether information from this profile should appear in the public anonymous list of available skills
|
||||||
|
isPublic : bool
|
||||||
|
/// The continent on which the user is seeking employment
|
||||||
|
continentId : Continent
|
||||||
|
/// The region within that continent where the user would prefer to work
|
||||||
|
region : string
|
||||||
|
/// Whether the user is looking for remote work
|
||||||
|
remoteWork : bool
|
||||||
|
/// Whether the user is looking for full-time work
|
||||||
|
fullTime : bool
|
||||||
|
/// The user's professional biography
|
||||||
|
biography : MarkdownString
|
||||||
|
/// When this profile was last updated
|
||||||
|
lastUpdatedOn : Millis
|
||||||
|
/// The user's experience
|
||||||
|
experience : MarkdownString option
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// A skill which a user possesses
|
||||||
|
type Skill = {
|
||||||
|
/// The ID of the skill
|
||||||
|
id : SkillId
|
||||||
|
/// The ID of the user who possesses this skill
|
||||||
|
citizenId : CitizenId
|
||||||
|
/// The skill
|
||||||
|
skill : string
|
||||||
|
/// Notes about the skill (proficiency, experience, etc.)
|
||||||
|
notes : string option
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// A success story
|
||||||
|
type Success = {
|
||||||
|
/// The ID of the success story
|
||||||
|
id : SuccessId
|
||||||
|
/// The ID of the user who experienced this success story
|
||||||
|
citizenId : CitizenId
|
||||||
|
/// When this story was recorded
|
||||||
|
recordedOn : Millis
|
||||||
|
/// Whether the success came from here; if Jobs, Jobs, Jobs led them to eventual employment
|
||||||
|
fromHere : bool
|
||||||
|
/// Their success story
|
||||||
|
story : MarkdownString option
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Configuration required for authentication with No Agenda Social
|
||||||
|
type AuthConfig = {
|
||||||
|
/// The client ID
|
||||||
|
clientId : string
|
||||||
|
/// The cryptographic secret
|
||||||
|
secret : string
|
||||||
|
/// The base URL for Mastodon's API access
|
||||||
|
apiUrl : string
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Application configuration format
|
||||||
|
type JobsJobsJobsConfig = {
|
||||||
|
/// Auth0 configuration
|
||||||
|
auth : AuthConfig
|
||||||
|
/// Database connection URI
|
||||||
|
dbUri : string
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
open Microsoft.Extensions.Configuration
|
||||||
|
open System.IO
|
||||||
|
|
||||||
|
/// Configuration instance
|
||||||
|
let config =
|
||||||
|
(lazy
|
||||||
|
(let root =
|
||||||
|
ConfigurationBuilder()
|
||||||
|
.SetBasePath(Directory.GetCurrentDirectory ())
|
||||||
|
.AddJsonFile("appsettings.json")
|
||||||
|
.AddJsonFile("appsettings.Development.json", true)
|
||||||
|
.AddJsonFile("appsettings.Production.json", true)
|
||||||
|
.AddEnvironmentVariables("JJJ_")
|
||||||
|
.Build()
|
||||||
|
let auth = root.GetSection "Auth"
|
||||||
|
{ dbUri = root.["dbUri"]
|
||||||
|
auth = {
|
||||||
|
clientId = auth.["ClientId"]
|
||||||
|
secret = auth.["Secret"]
|
||||||
|
apiUrl = auth.["ApiUrl"]
|
||||||
|
}
|
||||||
|
})).Force()
|
31
src/JobsJobsJobs.Api/Extensions.fs
Normal file
31
src/JobsJobsJobs.Api/Extensions.fs
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
[<AutoOpen>]
|
||||||
|
module JobsJobsJobs.Api.Extensions
|
||||||
|
|
||||||
|
open System
|
||||||
|
|
||||||
|
// fsharplint:disable MemberNames
|
||||||
|
|
||||||
|
/// Extensions for the DateTime object
|
||||||
|
type DateTime with
|
||||||
|
|
||||||
|
/// Constant for the ticks at the Unix epoch
|
||||||
|
member __.UnixEpochTicks = (DateTime (1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).Ticks
|
||||||
|
|
||||||
|
/// Convert this DateTime to a JavaScript milliseconds-past-the-epoch value
|
||||||
|
member this.toMillis () =
|
||||||
|
(this.ToUniversalTime().Ticks - this.UnixEpochTicks) / 10000L |> Domain.Millis
|
||||||
|
|
||||||
|
|
||||||
|
open FSharp.Json
|
||||||
|
open Suave
|
||||||
|
open System.Text
|
||||||
|
|
||||||
|
/// Extensions for Suave's context
|
||||||
|
type HttpContext with
|
||||||
|
|
||||||
|
/// Deserialize an object from a JSON request body
|
||||||
|
member this.fromJsonBody<'T> () =
|
||||||
|
try
|
||||||
|
Encoding.UTF8.GetString this.request.rawForm |> Json.deserialize<'T> |> Ok
|
||||||
|
with x ->
|
||||||
|
Error x
|
147
src/JobsJobsJobs.Api/Handlers.fs
Normal file
147
src/JobsJobsJobs.Api/Handlers.fs
Normal file
|
@ -0,0 +1,147 @@
|
||||||
|
module JobsJobsJobs.Api.Handlers
|
||||||
|
|
||||||
|
open Data
|
||||||
|
open Domain
|
||||||
|
open FSharp.Json
|
||||||
|
open Suave
|
||||||
|
open Suave.Operators
|
||||||
|
open System
|
||||||
|
|
||||||
|
[<AutoOpen>]
|
||||||
|
module private Internal =
|
||||||
|
|
||||||
|
open Suave.Writers
|
||||||
|
|
||||||
|
/// Send a JSON response
|
||||||
|
let json x =
|
||||||
|
Successful.OK (Json.serialize x)
|
||||||
|
>=> setMimeType "application/json; charset=utf-8"
|
||||||
|
|
||||||
|
|
||||||
|
module Auth =
|
||||||
|
|
||||||
|
open System.Net.Http
|
||||||
|
open System.Net.Http.Headers
|
||||||
|
|
||||||
|
/// Verify a user's credentials with No Agenda Social
|
||||||
|
let verifyWithMastodon accessToken = async {
|
||||||
|
use client = new HttpClient ()
|
||||||
|
use req = new HttpRequestMessage (HttpMethod.Get, (sprintf "%saccounts/verify_credentials" config.auth.apiUrl))
|
||||||
|
req.Headers.Authorization <- AuthenticationHeaderValue <| sprintf "Bearer %s" accessToken
|
||||||
|
match! client.SendAsync req |> Async.AwaitTask with
|
||||||
|
| res when res.IsSuccessStatusCode ->
|
||||||
|
let! body = res.Content.ReadAsStringAsync ()
|
||||||
|
return
|
||||||
|
match Json.deserialize<ViewModels.Citizen.MastodonAccount> body with
|
||||||
|
| profile when profile.username = profile.acct -> Ok profile
|
||||||
|
| profile -> Error (sprintf "Profiles must be from noagendasocial.com; yours is %s" profile.acct)
|
||||||
|
| res -> return Error (sprintf "Could not retrieve credentials: %d ~ %s" (int res.StatusCode) res.ReasonPhrase)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handler to return the Vue application
|
||||||
|
module Vue =
|
||||||
|
|
||||||
|
/// The application index page
|
||||||
|
let app = Files.file "wwwroot/index.html"
|
||||||
|
|
||||||
|
|
||||||
|
/// Handlers for error conditions
|
||||||
|
module Error =
|
||||||
|
|
||||||
|
open Suave.Logging
|
||||||
|
open Suave.Logging.Message
|
||||||
|
|
||||||
|
/// Handle errors
|
||||||
|
let error (ex : Exception) msg =
|
||||||
|
fun ctx ->
|
||||||
|
seq {
|
||||||
|
string ctx.request.url
|
||||||
|
match msg with "" -> () | _ -> " ~ "; msg
|
||||||
|
"\n"; (ex.GetType().Name); ": "; ex.Message; "\n"
|
||||||
|
ex.StackTrace
|
||||||
|
}
|
||||||
|
|> Seq.reduce (+)
|
||||||
|
|> (eventX >> ctx.runtime.logger.error)
|
||||||
|
ServerErrors.INTERNAL_ERROR (Json.serialize {| error = ex.Message |}) ctx
|
||||||
|
|
||||||
|
/// Handle 404s from the API, sending known URL paths to the Vue app so that they can be handled there
|
||||||
|
let notFound : WebPart =
|
||||||
|
fun ctx ->
|
||||||
|
[ "/user"; "/jobs" ]
|
||||||
|
|> List.filter ctx.request.path.StartsWith
|
||||||
|
|> List.length
|
||||||
|
|> function
|
||||||
|
| 0 -> RequestErrors.NOT_FOUND "err" ctx
|
||||||
|
| _ -> Vue.app ctx
|
||||||
|
|
||||||
|
|
||||||
|
/// /api/citizen route handlers
|
||||||
|
module Citizen =
|
||||||
|
|
||||||
|
open ViewModels.Citizen
|
||||||
|
|
||||||
|
/// Either add the user, or update their display name and last seen date
|
||||||
|
let establishCitizen result profile = async {
|
||||||
|
match result with
|
||||||
|
| Some citId ->
|
||||||
|
match! Citizens.update citId profile.displayName with
|
||||||
|
| Ok _ -> return Ok citId
|
||||||
|
| Error exn -> return Error exn
|
||||||
|
| None ->
|
||||||
|
let now = DateTime.Now.toMillis ()
|
||||||
|
let! citId = CitizenId.create ()
|
||||||
|
match! Citizens.add
|
||||||
|
{ id = citId
|
||||||
|
naUser = profile.username
|
||||||
|
displayName = profile.displayName
|
||||||
|
profileUrl = profile.url
|
||||||
|
joinedOn = now
|
||||||
|
lastSeenOn = now
|
||||||
|
} with
|
||||||
|
| Ok _ -> return Ok citId
|
||||||
|
| Error exn -> return Error exn
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST: /api/citizen/log-on
|
||||||
|
let logOn : WebPart =
|
||||||
|
fun ctx -> async {
|
||||||
|
match ctx.fromJsonBody<LogOn> () with
|
||||||
|
| Ok data ->
|
||||||
|
match! Auth.verifyWithMastodon data.accessToken with
|
||||||
|
| Ok profile ->
|
||||||
|
match! Citizens.findIdByNaUser profile.username with
|
||||||
|
| Ok idResult ->
|
||||||
|
match! establishCitizen idResult profile with
|
||||||
|
| Ok citizenId ->
|
||||||
|
// TODO: replace this with a JWT issued by the server user
|
||||||
|
match! Citizens.tryFind citizenId with
|
||||||
|
| Ok (Some citizen) -> return! json citizen ctx
|
||||||
|
| Ok None -> return! Error.error (exn ()) "Citizen record not found" ctx
|
||||||
|
| Error exn -> return! Error.error exn "Could not retrieve user from database" ctx
|
||||||
|
| Error exn -> return! Error.error exn "Could not update Jobs, Jobs, Jobs database" ctx
|
||||||
|
| Error exn -> return! Error.error exn "Token not received" ctx
|
||||||
|
| Error msg ->
|
||||||
|
// Error message regarding exclusivity to No Agenda Social members
|
||||||
|
return Some ctx
|
||||||
|
| Error exn -> return! Error.error exn "Token not received" ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
open Suave.Filters
|
||||||
|
|
||||||
|
/// The routes for Jobs, Jobs, Jobs
|
||||||
|
let webApp =
|
||||||
|
choose
|
||||||
|
[ GET >=> choose
|
||||||
|
[ path "/" >=> Vue.app
|
||||||
|
Files.browse "wwwroot/"
|
||||||
|
]
|
||||||
|
// PUT >=> choose
|
||||||
|
// [ ]
|
||||||
|
// PATCH >=> choose
|
||||||
|
// [ ]
|
||||||
|
POST >=> choose
|
||||||
|
[ path "/api/citizen/log-on" >=> Citizen.logOn
|
||||||
|
]
|
||||||
|
Error.notFound
|
||||||
|
]
|
|
@ -6,13 +6,24 @@
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<Compile Include="Domain.fs" />
|
||||||
|
<Compile Include="Extensions.fs" />
|
||||||
|
<Compile Include="Data.fs" />
|
||||||
|
<Compile Include="ViewModels.fs" />
|
||||||
|
<Compile Include="Handlers.fs" />
|
||||||
<Compile Include="Program.fs" />
|
<Compile Include="Program.fs" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="FSharp.Json" Version="0.4.0" />
|
<PackageReference Include="FSharp.Json" Version="0.4.0" />
|
||||||
<PackageReference Include="Npgsql.FSharp" Version="3.7.0" />
|
<PackageReference Include="Markdig" Version="0.21.1" />
|
||||||
<PackageReference Include="Suave" Version="2.5.6" />
|
<PackageReference Include="Microsoft.Extensions.Configuration" Version="3.1.8" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="3.1.8" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="3.1.8" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="3.1.8" />
|
||||||
|
<PackageReference Include="Nanoid" Version="2.1.0" />
|
||||||
|
<PackageReference Include="Npgsql.FSharp" Version="3.7.0" />
|
||||||
|
<PackageReference Include="Suave" Version="2.5.6" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
@ -1,8 +1,18 @@
|
||||||
// Learn more about F# at http://fsharp.org
|
module JobsJobsJobs.Api.App
|
||||||
|
|
||||||
|
// Learn more about F# at http://fsharp.org
|
||||||
|
|
||||||
open System
|
open System
|
||||||
|
open Suave
|
||||||
|
|
||||||
[<EntryPoint>]
|
[<EntryPoint>]
|
||||||
let main argv =
|
let main argv =
|
||||||
printfn "Hello World from F#!"
|
{ defaultConfig with
|
||||||
0 // return an integer exit code
|
bindings = [ HttpBinding.createSimple HTTP "127.0.0.1" 3002; HttpBinding.createSimple HTTP "::1" 3002 ]
|
||||||
|
// errorHandler = Handlers.Error.error
|
||||||
|
// serverKey = config.serverKey
|
||||||
|
// cookieSerialiser = FSharpJsonCookieSerialiser()
|
||||||
|
// homeFolder = Some "./wwwroot/"
|
||||||
|
}
|
||||||
|
|> (flip startWebServer) Handlers.webApp
|
||||||
|
0
|
||||||
|
|
27
src/JobsJobsJobs.Api/ViewModels.fs
Normal file
27
src/JobsJobsJobs.Api/ViewModels.fs
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
module JobsJobsJobs.Api.ViewModels
|
||||||
|
|
||||||
|
// fsharplint:disable RecordFieldNames MemberNames
|
||||||
|
|
||||||
|
/// View models uses for /api/citizen routes
|
||||||
|
module Citizen =
|
||||||
|
|
||||||
|
open FSharp.Json
|
||||||
|
|
||||||
|
/// The payload for the log on route
|
||||||
|
type LogOn = {
|
||||||
|
/// The access token obtained from No Agenda Social
|
||||||
|
accessToken : string
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The variables we need from the account information we get from No Agenda Social
|
||||||
|
type MastodonAccount = {
|
||||||
|
/// The user name (what we store as naUser)
|
||||||
|
username : string
|
||||||
|
/// The account name; will be the same as username for local (non-federated) accounts
|
||||||
|
acct : string
|
||||||
|
/// The user's display name as it currently shows on No Agenda Social
|
||||||
|
[<JsonField "display_name">]
|
||||||
|
displayName : string
|
||||||
|
/// The user's profile URL
|
||||||
|
url : string
|
||||||
|
}
|
122
src/database/tables.sql
Normal file
122
src/database/tables.sql
Normal file
|
@ -0,0 +1,122 @@
|
||||||
|
CREATE SCHEMA jjj;
|
||||||
|
COMMENT ON SCHEMA jjj IS 'Jobs, Jobs, Jobs';
|
||||||
|
|
||||||
|
CREATE TABLE jjj.citizen (
|
||||||
|
id VARCHAR(12) NOT NULL,
|
||||||
|
na_user VARCHAR(50) NOT NULL,
|
||||||
|
display_name VARCHAR(255) NOT NULL,
|
||||||
|
profile_url VARCHAR(1024) NOT NULL,
|
||||||
|
joined_on BIGINT NOT NULL,
|
||||||
|
last_seen_on BIGINT NOT NULL,
|
||||||
|
CONSTRAINT pk_citizen PRIMARY KEY (id),
|
||||||
|
CONSTRAINT uk_na_user UNIQUE (na_user)
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE jjj.citizen IS 'Users';
|
||||||
|
COMMENT ON COLUMN jjj.citizen.id
|
||||||
|
IS 'A unique identifier for a user';
|
||||||
|
COMMENT ON COLUMN jjj.citizen.na_user
|
||||||
|
IS 'The ID of this user from No Agenda Social';
|
||||||
|
COMMENT ON COLUMN jjj.citizen.display_name
|
||||||
|
IS 'The display name of the user as it appeared on their profile the last time they logged on';
|
||||||
|
COMMENT ON COLUMN jjj.citizen.profile_url
|
||||||
|
IS 'The URL for the No Agenda Social profile for this user';
|
||||||
|
COMMENT ON COLUMN jjj.citizen.joined_on
|
||||||
|
IS 'When this user joined Jobs, Jobs, Jobs';
|
||||||
|
COMMENT ON COLUMN jjj.citizen.last_seen_on
|
||||||
|
IS 'When this user last logged on to Jobs, Jobs, Jobs';
|
||||||
|
|
||||||
|
CREATE TABLE jjj.continent (
|
||||||
|
id VARCHAR(12) NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
CONSTRAINT pk_continent PRIMARY KEY (id)
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE jjj.continent IS 'Continents';
|
||||||
|
COMMENT ON COLUMN jjj.continent.id
|
||||||
|
IS 'A unique identifier for the continent';
|
||||||
|
COMMENT ON COLUMN jjj.continent.name
|
||||||
|
IS 'The name of the continent';
|
||||||
|
|
||||||
|
CREATE TABLE jjj.profile (
|
||||||
|
citizen_id VARCHAR(12) NOT NULL,
|
||||||
|
seeking_employment BOOLEAN NOT NULL,
|
||||||
|
is_public BOOLEAN NOT NULL,
|
||||||
|
continent_id VARCHAR(12) NOT NULL,
|
||||||
|
region VARCHAR(255) NOT NULL,
|
||||||
|
remote_work BOOLEAN NOT NULL,
|
||||||
|
full_time BOOLEAN NOT NULL,
|
||||||
|
biography TEXT NOT NULL,
|
||||||
|
last_updated_on BIGINT NOT NULL,
|
||||||
|
experience TEXT,
|
||||||
|
CONSTRAINT pk_profile PRIMARY KEY (citizen_id),
|
||||||
|
CONSTRAINT fk_profile_citizen FOREIGN KEY (citizen_id) REFERENCES jjj.citizen (id),
|
||||||
|
CONSTRAINT fk_profile_continent FOREIGN KEY (continent_id) REFERENCES jjj.continent (id)
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE jjj.profile IS 'Employment Profiles';
|
||||||
|
COMMENT ON COLUMN jjj.profile.citizen_id
|
||||||
|
IS 'The ID of the user to whom this profile belongs';
|
||||||
|
COMMENT ON COLUMN jjj.profile.seeking_employment
|
||||||
|
IS 'Whether this user is actively seeking employment';
|
||||||
|
COMMENT ON COLUMN jjj.profile.is_public
|
||||||
|
IS 'Whether this profile should appear on the anonymized public job seeker list';
|
||||||
|
COMMENT ON COLUMN jjj.profile.continent_id
|
||||||
|
IS 'The ID of the continent on which this user is located';
|
||||||
|
COMMENT ON COLUMN jjj.profile.region
|
||||||
|
IS 'The region within the continent where this user is located';
|
||||||
|
COMMENT ON COLUMN jjj.profile.remote_work
|
||||||
|
IS 'Whether this user is open to remote work opportunities';
|
||||||
|
COMMENT ON COLUMN jjj.profile.full_time
|
||||||
|
IS 'Whether this user is looking for full time work';
|
||||||
|
COMMENT ON COLUMN jjj.profile.biography
|
||||||
|
IS 'The professional biography for this user (Markdown)';
|
||||||
|
COMMENT ON COLUMN jjj.profile.last_updated_on
|
||||||
|
IS 'When this profile was last updated';
|
||||||
|
COMMENT ON COLUMN jjj.profile.experience
|
||||||
|
IS 'The prior employment experience for this user (Markdown)';
|
||||||
|
|
||||||
|
CREATE INDEX idx_profile_continent ON jjj.profile (continent_id);
|
||||||
|
COMMENT ON INDEX jjj.idx_profile_continent IS 'FK Index';
|
||||||
|
|
||||||
|
CREATE TABLE jjj.skill (
|
||||||
|
id VARCHAR(12) NOT NULL,
|
||||||
|
citizen_id VARCHAR(12) NOT NULL,
|
||||||
|
skill VARCHAR(100) NOT NULL,
|
||||||
|
notes VARCHAR(100),
|
||||||
|
CONSTRAINT pk_skill PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_skill_citizen FOREIGN KEY (citizen_id) REFERENCES jjj.citizen (id)
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE jjj.skill IS 'Skills';
|
||||||
|
COMMENT ON COLUMN jjj.skill.id
|
||||||
|
IS 'A unique identifier for each skill entry';
|
||||||
|
COMMENT ON COLUMN jjj.skill.citizen_id
|
||||||
|
IS 'The ID of the user to whom this skill applies';
|
||||||
|
COMMENT ON COLUMN jjj.skill.skill
|
||||||
|
IS 'The skill itself';
|
||||||
|
COMMENT ON COLUMN jjj.skill.notes
|
||||||
|
IS 'Proficiency level, length of experience, etc. in this skill';
|
||||||
|
|
||||||
|
CREATE INDEX idx_skill_citizen ON jjj.skill (citizen_id);
|
||||||
|
COMMENT ON INDEX jjj.idx_skill_citizen IS 'FK index';
|
||||||
|
|
||||||
|
CREATE TABLE jjj.success (
|
||||||
|
id VARCHAR(12) NOT NULL,
|
||||||
|
citizen_id VARCHAR(12) NOT NULL,
|
||||||
|
recorded_on BIGINT NOT NULL,
|
||||||
|
from_here BOOLEAN NOT NULL,
|
||||||
|
story TEXT,
|
||||||
|
CONSTRAINT pk_success PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_success_citizen FOREIGN KEY (citizen_id) REFERENCES jjj.citizen (id)
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE jjj.success IS 'Success stories';
|
||||||
|
COMMENT ON COLUMN jjj.success.id
|
||||||
|
IS 'A unique identifier for each success story';
|
||||||
|
COMMENT ON COLUMN jjj.success.citizen_id
|
||||||
|
IS 'The ID of the user to whom this story belongs';
|
||||||
|
COMMENT ON COLUMN jjj.success.recorded_on
|
||||||
|
IS 'When the user recorded this success story';
|
||||||
|
COMMENT ON COLUMN jjj.success.from_here
|
||||||
|
IS 'Whether the user attributes their employment to their need appearing in Jobs, Jobs, Jobs';
|
||||||
|
COMMENT ON COLUMN jjj.success.story
|
||||||
|
IS 'The story of how employment came about (Markdown)';
|
||||||
|
|
||||||
|
CREATE INDEX idx_success_citizen ON jjj.success (citizen_id);
|
||||||
|
COMMENT ON INDEX jjj.idx_success_citizen IS 'FK index';
|
Loading…
Reference in New Issue
Block a user