Version 3 #40
|
@ -6,12 +6,13 @@
|
|||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="SupportTypes.fs" />
|
||||
<Compile Include="Types.fs" />
|
||||
<Compile Include="Modules.fs" />
|
||||
<Compile Include="SharedTypes.fs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Giraffe" Version="6.0.0" />
|
||||
<PackageReference Include="Markdig" Version="0.30.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="6.0.0" />
|
||||
<PackageReference Include="NodaTime" Version="3.1.0" />
|
||||
|
|
|
@ -1,19 +1,10 @@
|
|||
/// Modules to provide support functions for types
|
||||
[<AutoOpen>]
|
||||
module JobsJobsJobs.Domain.Modules
|
||||
namespace JobsJobsJobs.Domain
|
||||
|
||||
open Markdig
|
||||
open System
|
||||
open Types
|
||||
|
||||
/// Format a GUID as a Short GUID
|
||||
let private toShortGuid (guid : Guid) =
|
||||
Convert.ToBase64String(guid.ToByteArray ()).Replace('/', '_').Replace('+', '-')[0..21]
|
||||
|
||||
/// Turn a Short GUID back into a GUID
|
||||
let private fromShortGuid (it : string) =
|
||||
(Convert.FromBase64String >> Guid) $"{it.Replace('_', '/').Replace('-', '+')}=="
|
||||
open Giraffe
|
||||
|
||||
/// The ID of a user (a citizen of Gitmo Nation)
|
||||
type CitizenId = CitizenId of Guid
|
||||
|
||||
/// Support functions for citizen IDs
|
||||
module CitizenId =
|
||||
|
@ -22,24 +13,17 @@ module CitizenId =
|
|||
let create () = (Guid.NewGuid >> CitizenId) ()
|
||||
|
||||
/// A string representation of a citizen ID
|
||||
let toString = function CitizenId it -> toShortGuid it
|
||||
let toString = function CitizenId it -> ShortGuid.fromGuid it
|
||||
|
||||
/// Parse a string into a citizen ID
|
||||
let ofString = fromShortGuid >> CitizenId
|
||||
let ofString = ShortGuid.toGuid >> CitizenId
|
||||
|
||||
/// Get the GUID value of a citizen ID
|
||||
let value = function CitizenId guid -> guid
|
||||
|
||||
|
||||
/// Support functions for citizens
|
||||
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.mastodonUser ]
|
||||
|> List.find Option.isSome
|
||||
|> Option.get
|
||||
|
||||
/// The ID of a continent
|
||||
type ContinentId = ContinentId of Guid
|
||||
|
||||
/// Support functions for continent IDs
|
||||
module ContinentId =
|
||||
|
@ -48,15 +32,18 @@ module ContinentId =
|
|||
let create () = (Guid.NewGuid >> ContinentId) ()
|
||||
|
||||
/// A string representation of a continent ID
|
||||
let toString = function ContinentId it -> toShortGuid it
|
||||
let toString = function ContinentId it -> ShortGuid.fromGuid it
|
||||
|
||||
/// Parse a string into a continent ID
|
||||
let ofString = fromShortGuid >> ContinentId
|
||||
let ofString = ShortGuid.toGuid >> ContinentId
|
||||
|
||||
/// Get the GUID value of a continent ID
|
||||
let value = function ContinentId guid -> guid
|
||||
|
||||
|
||||
/// The ID of a job listing
|
||||
type ListingId = ListingId of Guid
|
||||
|
||||
/// Support functions for listing IDs
|
||||
module ListingId =
|
||||
|
||||
|
@ -64,18 +51,23 @@ module ListingId =
|
|||
let create () = (Guid.NewGuid >> ListingId) ()
|
||||
|
||||
/// A string representation of a listing ID
|
||||
let toString = function ListingId it -> toShortGuid it
|
||||
let toString = function ListingId it -> ShortGuid.fromGuid it
|
||||
|
||||
/// Parse a string into a listing ID
|
||||
let ofString = fromShortGuid >> ListingId
|
||||
let ofString = ShortGuid.toGuid >> ListingId
|
||||
|
||||
/// Get the GUID value of a listing ID
|
||||
let value = function ListingId guid -> guid
|
||||
|
||||
|
||||
/// A string of Markdown text
|
||||
type MarkdownString = Text of string
|
||||
|
||||
/// Support functions for Markdown strings
|
||||
module MarkdownString =
|
||||
|
||||
open Markdig
|
||||
|
||||
/// The Markdown conversion pipeline (enables all advanced features)
|
||||
let private pipeline = MarkdownPipelineBuilder().UseAdvancedExtensions().Build ()
|
||||
|
||||
|
@ -86,26 +78,19 @@ module MarkdownString =
|
|||
let toString = function Text text -> text
|
||||
|
||||
|
||||
/// Support functions for Profiles
|
||||
module Profile =
|
||||
/// Another way to contact a citizen from this site
|
||||
type OtherContact =
|
||||
{ /// The name of the contact (Email, No Agenda Social, LinkedIn, etc.)
|
||||
Name : string
|
||||
|
||||
// An empty profile
|
||||
let empty =
|
||||
{ id = CitizenId Guid.Empty
|
||||
seekingEmployment = false
|
||||
isPublic = false
|
||||
isPublicLinkable = false
|
||||
continentId = ContinentId Guid.Empty
|
||||
region = ""
|
||||
remoteWork = false
|
||||
fullTime = false
|
||||
biography = Text ""
|
||||
lastUpdatedOn = NodaTime.Instant.MinValue
|
||||
experience = None
|
||||
skills = []
|
||||
/// The value for the contact (e-mail address, user name, URL, etc.)
|
||||
Value : string
|
||||
}
|
||||
|
||||
|
||||
/// The ID of a skill
|
||||
type SkillId = SkillId of Guid
|
||||
|
||||
/// Support functions for skill IDs
|
||||
module SkillId =
|
||||
|
||||
|
@ -113,15 +98,18 @@ module SkillId =
|
|||
let create () = (Guid.NewGuid >> SkillId) ()
|
||||
|
||||
/// A string representation of a skill ID
|
||||
let toString = function SkillId it -> toShortGuid it
|
||||
let toString = function SkillId it -> ShortGuid.fromGuid it
|
||||
|
||||
/// Parse a string into a skill ID
|
||||
let ofString = fromShortGuid >> SkillId
|
||||
let ofString = ShortGuid.toGuid >> SkillId
|
||||
|
||||
/// Get the GUID value of a skill ID
|
||||
let value = function SkillId guid -> guid
|
||||
|
||||
|
||||
/// The ID of a success report
|
||||
type SuccessId = SuccessId of Guid
|
||||
|
||||
/// Support functions for success report IDs
|
||||
module SuccessId =
|
||||
|
||||
|
@ -129,10 +117,10 @@ module SuccessId =
|
|||
let create () = (Guid.NewGuid >> SuccessId) ()
|
||||
|
||||
/// A string representation of a success report ID
|
||||
let toString = function SuccessId it -> toShortGuid it
|
||||
let toString = function SuccessId it -> ShortGuid.fromGuid it
|
||||
|
||||
/// Parse a string into a success report ID
|
||||
let ofString = fromShortGuid >> SuccessId
|
||||
let ofString = ShortGuid.toGuid >> SuccessId
|
||||
|
||||
/// Get the GUID value of a success ID
|
||||
let value = function SuccessId guid -> guid
|
|
@ -1,45 +1,49 @@
|
|||
/// Types within Jobs, Jobs, Jobs
|
||||
module JobsJobsJobs.Domain.Types
|
||||
namespace JobsJobsJobs.Domain
|
||||
|
||||
open NodaTime
|
||||
open System
|
||||
|
||||
// fsharplint:disable FieldNames
|
||||
|
||||
/// The ID of a user (a citizen of Gitmo Nation)
|
||||
type CitizenId = CitizenId of Guid
|
||||
|
||||
/// A user of Jobs, Jobs, Jobs
|
||||
/// A user of Jobs, Jobs, Jobs; a citizen of Gitmo Nation
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type Citizen =
|
||||
{ /// The ID of the user
|
||||
id : CitizenId
|
||||
|
||||
/// The Mastodon instance abbreviation from which this citizen is authorized
|
||||
instance : string
|
||||
|
||||
/// The handle by which the user is known on Mastodon
|
||||
mastodonUser : string
|
||||
|
||||
/// The user's display name from Mastodon (updated every login)
|
||||
displayName : string option
|
||||
|
||||
/// The user's real name
|
||||
realName : string option
|
||||
|
||||
/// The URL for the user's Mastodon profile
|
||||
profileUrl : string
|
||||
|
||||
/// When the user joined Jobs, Jobs, Jobs
|
||||
joinedOn : Instant
|
||||
|
||||
/// When the user last logged in
|
||||
lastSeenOn : Instant
|
||||
|
||||
/// The user's e-mail address
|
||||
email : string
|
||||
|
||||
/// The user's first name
|
||||
firstName : string
|
||||
|
||||
/// The user's last name
|
||||
lastName : string
|
||||
|
||||
/// The hash of the user's password
|
||||
passwordHash : string
|
||||
|
||||
/// The name displayed for this user throughout the site
|
||||
displayName : string option
|
||||
|
||||
/// The other contacts for this user
|
||||
otherContacts : OtherContact list
|
||||
|
||||
}
|
||||
|
||||
/// Support functions for citizens
|
||||
module Citizen =
|
||||
|
||||
/// Get the name of the citizen (either their preferred display name or first/last names)
|
||||
let name x =
|
||||
match x.displayName with Some it -> it | None -> $"{x.firstName} {x.lastName}"
|
||||
|
||||
/// The ID of a continent
|
||||
type ContinentId = ContinentId of Guid
|
||||
|
||||
/// A continent
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
|
@ -52,13 +56,6 @@ type Continent =
|
|||
}
|
||||
|
||||
|
||||
/// A string of Markdown text
|
||||
type MarkdownString = Text of string
|
||||
|
||||
|
||||
/// The ID of a job listing
|
||||
type ListingId = ListingId of Guid
|
||||
|
||||
/// A job listing
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type Listing =
|
||||
|
@ -100,9 +97,6 @@ type Listing =
|
|||
}
|
||||
|
||||
|
||||
/// The ID of a skill
|
||||
type SkillId = SkillId of Guid
|
||||
|
||||
/// A skill the job seeker possesses
|
||||
type Skill =
|
||||
{ /// The ID of the skill
|
||||
|
@ -156,8 +150,25 @@ type Profile =
|
|||
skills : Skill list
|
||||
}
|
||||
|
||||
/// The ID of a success report
|
||||
type SuccessId = SuccessId of Guid
|
||||
/// Support functions for Profiles
|
||||
module Profile =
|
||||
|
||||
// An empty profile
|
||||
let empty =
|
||||
{ id = CitizenId Guid.Empty
|
||||
seekingEmployment = false
|
||||
isPublic = false
|
||||
isPublicLinkable = false
|
||||
continentId = ContinentId Guid.Empty
|
||||
region = ""
|
||||
remoteWork = false
|
||||
fullTime = false
|
||||
biography = Text ""
|
||||
lastUpdatedOn = Instant.MinValue
|
||||
experience = None
|
||||
skills = []
|
||||
}
|
||||
|
||||
|
||||
/// A record of success finding employment
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
|
|
|
@ -1,12 +1,11 @@
|
|||
/// Data access functions for Jobs, Jobs, Jobs
|
||||
module JobsJobsJobs.Api.Data
|
||||
|
||||
open JobsJobsJobs.Domain.Types
|
||||
open JobsJobsJobs.Domain
|
||||
|
||||
/// JSON converters used with RethinkDB persistence
|
||||
module Converters =
|
||||
|
||||
open JobsJobsJobs.Domain
|
||||
open Microsoft.FSharpLu.Json
|
||||
open Newtonsoft.Json
|
||||
open System
|
||||
|
@ -155,11 +154,15 @@ module Startup =
|
|||
if needsTable "citizen" then
|
||||
"CREATE TABLE jjj.citizen (
|
||||
id UUID NOT NULL PRIMARY KEY,
|
||||
display_name TEXT,
|
||||
profile_urls TEXT[] NOT NULL DEFAULT '{}',
|
||||
joined_on TIMESTAMPTZ NOT NULL,
|
||||
last_seen_on TIMESTAMPTZ NOT NULL,
|
||||
is_legacy BOOLEAN NOT NULL)"
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_legacy BOOLEAN NOT NULL,
|
||||
display_name TEXT,
|
||||
other_contacts TEXT)"
|
||||
if needsTable "profile" then
|
||||
"CREATE TABLE jjj.profile (
|
||||
citizen_id UUID NOT NULL PRIMARY KEY,
|
||||
|
@ -228,7 +231,6 @@ module Startup =
|
|||
}
|
||||
|
||||
|
||||
open JobsJobsJobs.Domain
|
||||
open JobsJobsJobs.Domain.SharedTypes
|
||||
|
||||
/// Sanitize user input, and create a "contains" pattern for use with RethinkDB queries
|
||||
|
@ -281,6 +283,20 @@ module Sql =
|
|||
/// Map data results to domain types
|
||||
module Map =
|
||||
|
||||
/// Create a citizen from a data row
|
||||
let toCitizen (row : RowReader) : Citizen =
|
||||
{ id = (row.uuid >> CitizenId) "id"
|
||||
joinedOn = row.fieldValue<Instant> "joined_on"
|
||||
lastSeenOn = row.fieldValue<Instant> "last_seen_on"
|
||||
email = row.string "email"
|
||||
firstName = row.string "first_name"
|
||||
lastName = row.string "last_name"
|
||||
passwordHash = row.string "password_hash"
|
||||
displayName = row.stringOrNone "display_name"
|
||||
// TODO: deserialize from JSON
|
||||
otherContacts = [] // row.stringOrNone "other_contacts"
|
||||
}
|
||||
|
||||
/// Create a continent from a data row
|
||||
let toContinent (row : RowReader) : Continent =
|
||||
{ id = (row.uuid >> ContinentId) "continent_id"
|
||||
|
@ -517,33 +533,67 @@ module Profile =
|
|||
module Citizen =
|
||||
|
||||
/// Find a citizen by their ID
|
||||
let findById (citizenId : CitizenId) conn =
|
||||
fromTable Table.Citizen
|
||||
|> get citizenId
|
||||
|> resultOption<Citizen> conn
|
||||
|
||||
/// Find a citizen by their Mastodon username
|
||||
let findByMastodonUser (instance : string) (mastodonUser : string) conn = task {
|
||||
let! u =
|
||||
fromTable Table.Citizen
|
||||
|> getAllWithIndex [ [| instance; mastodonUser |] ] "instanceUser"
|
||||
|> limit 1
|
||||
|> result<Citizen list> conn
|
||||
return List.tryHead u
|
||||
let findById citizenId conn = backgroundTask {
|
||||
let! citizen =
|
||||
Sql.existingConnection conn
|
||||
|> Sql.query "SELECT * FROM jjj.citizen WHERE id = @id AND is_legacy = FALSE"
|
||||
|> Sql.parameters [ "@id", Sql.citizenId citizenId ]
|
||||
|> Sql.executeAsync Map.toCitizen
|
||||
return List.tryHead citizen
|
||||
}
|
||||
|
||||
/// Add a citizen
|
||||
let add (citizen : Citizen) conn =
|
||||
fromTable Table.Citizen
|
||||
|> insert citizen
|
||||
|> write conn
|
||||
/// Find a citizen by their e-mail address
|
||||
let findByEmail email conn = backgroundTask {
|
||||
let! citizen =
|
||||
Sql.existingConnection conn
|
||||
|> Sql.query "SELECT * FROM jjj.citizen WHERE email = @email AND is_legacy = FALSE"
|
||||
|> Sql.parameters [ "@email", Sql.string email ]
|
||||
|> Sql.executeAsync Map.toCitizen
|
||||
return List.tryHead citizen
|
||||
}
|
||||
|
||||
/// Update the display name and last seen on date for a citizen
|
||||
let logOnUpdate (citizen : Citizen) conn =
|
||||
fromTable Table.Citizen
|
||||
|> get citizen.id
|
||||
|> update {| displayName = citizen.displayName; lastSeenOn = citizen.lastSeenOn |}
|
||||
|> write conn
|
||||
/// Add or update a citizen
|
||||
let save (citizen : Citizen) conn = backgroundTask {
|
||||
let! _ =
|
||||
Sql.existingConnection conn
|
||||
|> Sql.query
|
||||
"INSERT INTO jjj.citizen (
|
||||
id, joined_on, last_seen_on, email, first_name, last_name, password_hash, display_name,
|
||||
other_contacts, is_legacy
|
||||
) VALUES (
|
||||
@id, @joinedOn, @lastSeenOn, @email, @firstName, @lastName, @passwordHash, @displayName,
|
||||
@otherContacts, FALSE
|
||||
) ON CONFLICT (id) DO UPDATE
|
||||
SET email = EXCLUDED.email,
|
||||
first_name = EXCLUDED.first_name,
|
||||
last_name = EXCLUDED.last_name,
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
display_name = EXCLUDED.display_name,
|
||||
other_contacts = EXCLUDED.other_contacts"
|
||||
|> Sql.parameters
|
||||
[ "@id", Sql.citizenId citizen.id
|
||||
"@joinedOn" |>Sql.param<| citizen.joinedOn
|
||||
"@lastSeenOn" |>Sql.param<| citizen.lastSeenOn
|
||||
"@email", Sql.string citizen.email
|
||||
"@firstName", Sql.string citizen.firstName
|
||||
"@lastName", Sql.string citizen.lastName
|
||||
"@passwordHash", Sql.string citizen.passwordHash
|
||||
"@displayName", Sql.stringOrNone citizen.displayName
|
||||
"@otherContacts", Sql.stringOrNone (if List.isEmpty citizen.otherContacts then None else Some "")
|
||||
]
|
||||
|> Sql.executeNonQueryAsync
|
||||
()
|
||||
}
|
||||
|
||||
/// Update the last seen on date for a citizen
|
||||
let logOnUpdate (citizen : Citizen) conn = backgroundTask {
|
||||
let! _ =
|
||||
Sql.existingConnection conn
|
||||
|> Sql.query "UPDATE jjj.citizen SET last_seen_on = @lastSeenOn WHERE id = @id"
|
||||
|> Sql.parameters [ "@id", Sql.citizenId citizen.id; "@lastSeenOn" |>Sql.param<| citizen.lastSeenOn ]
|
||||
|> Sql.executeNonQueryAsync
|
||||
()
|
||||
}
|
||||
|
||||
/// Delete a citizen
|
||||
let delete citizenId conn = backgroundTask {
|
||||
|
@ -555,13 +605,6 @@ module Citizen =
|
|||
()
|
||||
}
|
||||
|
||||
/// Update a citizen's real name
|
||||
let realNameUpdate (citizenId : CitizenId) (realName : string option) conn =
|
||||
fromTable Table.Citizen
|
||||
|> get citizenId
|
||||
|> update {| realName = realName |}
|
||||
|> write conn
|
||||
|
||||
|
||||
/// Continent data access functions
|
||||
[<RequireQualifiedAccess>]
|
||||
|
|
Loading…
Reference in New Issue
Block a user