parent
10658b0d77
commit
2b7dd09630
1
.gitignore
vendored
1
.gitignore
vendored
@ -1 +1,2 @@
|
||||
.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>
|
||||
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FSharp.Json" Version="0.4.0" />
|
||||
<PackageReference Include="Npgsql.FSharp" Version="3.7.0" />
|
||||
<PackageReference Include="Suave" Version="2.5.6" />
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FSharp.Json" Version="0.4.0" />
|
||||
<PackageReference Include="Markdig" Version="0.21.1" />
|
||||
<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>
|
||||
|
||||
</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 Suave
|
||||
|
||||
[<EntryPoint>]
|
||||
let main argv =
|
||||
printfn "Hello World from F#!"
|
||||
0 // return an integer exit code
|
||||
{ defaultConfig with
|
||||
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