Version 3 #40

Merged
danieljsummers merged 67 commits from version-2-3 into main 2023-02-02 23:47:28 +00:00
4 changed files with 171 additions and 128 deletions
Showing only changes of commit 93213099ac - Show all commits

View File

@ -6,12 +6,13 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<Compile Include="SupportTypes.fs" />
<Compile Include="Types.fs" /> <Compile Include="Types.fs" />
<Compile Include="Modules.fs" />
<Compile Include="SharedTypes.fs" /> <Compile Include="SharedTypes.fs" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Giraffe" Version="6.0.0" />
<PackageReference Include="Markdig" Version="0.30.2" /> <PackageReference Include="Markdig" Version="0.30.2" />
<PackageReference Include="Microsoft.Extensions.Options" Version="6.0.0" /> <PackageReference Include="Microsoft.Extensions.Options" Version="6.0.0" />
<PackageReference Include="NodaTime" Version="3.1.0" /> <PackageReference Include="NodaTime" Version="3.1.0" />

View File

@ -1,19 +1,10 @@
/// Modules to provide support functions for types namespace JobsJobsJobs.Domain
[<AutoOpen>]
module JobsJobsJobs.Domain.Modules
open Markdig
open System open System
open Types open Giraffe
/// 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('-', '+')}=="
/// The ID of a user (a citizen of Gitmo Nation)
type CitizenId = CitizenId of Guid
/// Support functions for citizen IDs /// Support functions for citizen IDs
module CitizenId = module CitizenId =
@ -22,24 +13,17 @@ module CitizenId =
let create () = (Guid.NewGuid >> CitizenId) () let create () = (Guid.NewGuid >> CitizenId) ()
/// A string representation of a citizen ID /// A string representation of a citizen ID
let toString = function CitizenId it -> toShortGuid it let toString = function CitizenId it -> ShortGuid.fromGuid it
/// Parse a string into a citizen ID /// Parse a string into a citizen ID
let ofString = fromShortGuid >> CitizenId let ofString = ShortGuid.toGuid >> CitizenId
/// Get the GUID value of a citizen ID /// Get the GUID value of a citizen ID
let value = function CitizenId guid -> guid let value = function CitizenId guid -> guid
/// Support functions for citizens /// The ID of a continent
module Citizen = type ContinentId = ContinentId of Guid
/// 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
/// Support functions for continent IDs /// Support functions for continent IDs
module ContinentId = module ContinentId =
@ -48,15 +32,18 @@ module ContinentId =
let create () = (Guid.NewGuid >> ContinentId) () let create () = (Guid.NewGuid >> ContinentId) ()
/// A string representation of a continent ID /// A string representation of a continent ID
let toString = function ContinentId it -> toShortGuid it let toString = function ContinentId it -> ShortGuid.fromGuid it
/// Parse a string into a continent ID /// Parse a string into a continent ID
let ofString = fromShortGuid >> ContinentId let ofString = ShortGuid.toGuid >> ContinentId
/// Get the GUID value of a continent ID /// Get the GUID value of a continent ID
let value = function ContinentId guid -> guid let value = function ContinentId guid -> guid
/// The ID of a job listing
type ListingId = ListingId of Guid
/// Support functions for listing IDs /// Support functions for listing IDs
module ListingId = module ListingId =
@ -64,18 +51,23 @@ module ListingId =
let create () = (Guid.NewGuid >> ListingId) () let create () = (Guid.NewGuid >> ListingId) ()
/// A string representation of a listing ID /// A string representation of a listing ID
let toString = function ListingId it -> toShortGuid it let toString = function ListingId it -> ShortGuid.fromGuid it
/// Parse a string into a listing ID /// Parse a string into a listing ID
let ofString = fromShortGuid >> ListingId let ofString = ShortGuid.toGuid >> ListingId
/// Get the GUID value of a listing ID /// Get the GUID value of a listing ID
let value = function ListingId guid -> guid let value = function ListingId guid -> guid
/// A string of Markdown text
type MarkdownString = Text of string
/// Support functions for Markdown strings /// Support functions for Markdown strings
module MarkdownString = module MarkdownString =
open Markdig
/// The Markdown conversion pipeline (enables all advanced features) /// The Markdown conversion pipeline (enables all advanced features)
let private pipeline = MarkdownPipelineBuilder().UseAdvancedExtensions().Build () let private pipeline = MarkdownPipelineBuilder().UseAdvancedExtensions().Build ()
@ -86,26 +78,19 @@ module MarkdownString =
let toString = function Text text -> text let toString = function Text text -> text
/// Support functions for Profiles /// Another way to contact a citizen from this site
module Profile = type OtherContact =
{ /// The name of the contact (Email, No Agenda Social, LinkedIn, etc.)
Name : string
// An empty profile /// The value for the contact (e-mail address, user name, URL, etc.)
let empty = Value : string
{ 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 ID of a skill
type SkillId = SkillId of Guid
/// Support functions for skill IDs /// Support functions for skill IDs
module SkillId = module SkillId =
@ -113,15 +98,18 @@ module SkillId =
let create () = (Guid.NewGuid >> SkillId) () let create () = (Guid.NewGuid >> SkillId) ()
/// A string representation of a skill ID /// A string representation of a skill ID
let toString = function SkillId it -> toShortGuid it let toString = function SkillId it -> ShortGuid.fromGuid it
/// Parse a string into a skill ID /// Parse a string into a skill ID
let ofString = fromShortGuid >> SkillId let ofString = ShortGuid.toGuid >> SkillId
/// Get the GUID value of a skill ID /// Get the GUID value of a skill ID
let value = function SkillId guid -> guid let value = function SkillId guid -> guid
/// The ID of a success report
type SuccessId = SuccessId of Guid
/// Support functions for success report IDs /// Support functions for success report IDs
module SuccessId = module SuccessId =
@ -129,10 +117,10 @@ module SuccessId =
let create () = (Guid.NewGuid >> SuccessId) () let create () = (Guid.NewGuid >> SuccessId) ()
/// A string representation of a success report ID /// A string representation of a success report ID
let toString = function SuccessId it -> toShortGuid it let toString = function SuccessId it -> ShortGuid.fromGuid it
/// Parse a string into a success report ID /// 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 /// Get the GUID value of a success ID
let value = function SuccessId guid -> guid let value = function SuccessId guid -> guid

View File

@ -1,45 +1,49 @@
/// Types within Jobs, Jobs, Jobs namespace JobsJobsJobs.Domain
module JobsJobsJobs.Domain.Types
open NodaTime open NodaTime
open System open System
// fsharplint:disable FieldNames // fsharplint:disable FieldNames
/// The ID of a user (a citizen of Gitmo Nation) /// A user of Jobs, Jobs, Jobs; a citizen of Gitmo Nation
type CitizenId = CitizenId of Guid
/// A user of Jobs, Jobs, Jobs
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type Citizen = type Citizen =
{ /// The ID of the user { /// The ID of the user
id : CitizenId id : CitizenId
/// The Mastodon instance abbreviation from which this citizen is authorized
instance : string
/// The handle by which the user is known on Mastodon
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 /// When the user joined Jobs, Jobs, Jobs
joinedOn : Instant joinedOn : Instant
/// When the user last logged in /// When the user last logged in
lastSeenOn : Instant 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 /// A continent
[<CLIMutable; NoComparison; NoEquality>] [<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 /// A job listing
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type Listing = type Listing =
@ -100,9 +97,6 @@ type Listing =
} }
/// The ID of a skill
type SkillId = SkillId of Guid
/// A skill the job seeker possesses /// A skill the job seeker possesses
type Skill = type Skill =
{ /// The ID of the skill { /// The ID of the skill
@ -156,8 +150,25 @@ type Profile =
skills : Skill list skills : Skill list
} }
/// The ID of a success report /// Support functions for Profiles
type SuccessId = SuccessId of Guid 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 /// A record of success finding employment
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]

View File

@ -1,12 +1,11 @@
/// Data access functions for Jobs, Jobs, Jobs /// Data access functions for Jobs, Jobs, Jobs
module JobsJobsJobs.Api.Data module JobsJobsJobs.Api.Data
open JobsJobsJobs.Domain.Types open JobsJobsJobs.Domain
/// JSON converters used with RethinkDB persistence /// JSON converters used with RethinkDB persistence
module Converters = module Converters =
open JobsJobsJobs.Domain
open Microsoft.FSharpLu.Json open Microsoft.FSharpLu.Json
open Newtonsoft.Json open Newtonsoft.Json
open System open System
@ -154,12 +153,16 @@ module Startup =
name TEXT NOT NULL)" name TEXT NOT NULL)"
if needsTable "citizen" then if needsTable "citizen" then
"CREATE TABLE jjj.citizen ( "CREATE TABLE jjj.citizen (
id UUID NOT NULL PRIMARY KEY, id UUID NOT NULL PRIMARY KEY,
display_name TEXT, joined_on TIMESTAMPTZ NOT NULL,
profile_urls TEXT[] NOT NULL DEFAULT '{}', last_seen_on TIMESTAMPTZ NOT NULL,
joined_on TIMESTAMPTZ NOT NULL, email TEXT NOT NULL UNIQUE,
last_seen_on TIMESTAMPTZ NOT NULL, first_name TEXT NOT NULL,
is_legacy BOOLEAN 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 if needsTable "profile" then
"CREATE TABLE jjj.profile ( "CREATE TABLE jjj.profile (
citizen_id UUID NOT NULL PRIMARY KEY, citizen_id UUID NOT NULL PRIMARY KEY,
@ -228,7 +231,6 @@ module Startup =
} }
open JobsJobsJobs.Domain
open JobsJobsJobs.Domain.SharedTypes open JobsJobsJobs.Domain.SharedTypes
/// Sanitize user input, and create a "contains" pattern for use with RethinkDB queries /// 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 /// Map data results to domain types
module Map = 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 /// Create a continent from a data row
let toContinent (row : RowReader) : Continent = let toContinent (row : RowReader) : Continent =
{ id = (row.uuid >> ContinentId) "continent_id" { id = (row.uuid >> ContinentId) "continent_id"
@ -517,33 +533,67 @@ module Profile =
module Citizen = module Citizen =
/// Find a citizen by their ID /// Find a citizen by their ID
let findById (citizenId : CitizenId) conn = let findById citizenId conn = backgroundTask {
fromTable Table.Citizen let! citizen =
|> get citizenId Sql.existingConnection conn
|> resultOption<Citizen> conn |> Sql.query "SELECT * FROM jjj.citizen WHERE id = @id AND is_legacy = FALSE"
|> Sql.parameters [ "@id", Sql.citizenId citizenId ]
/// Find a citizen by their Mastodon username |> Sql.executeAsync Map.toCitizen
let findByMastodonUser (instance : string) (mastodonUser : string) conn = task { return List.tryHead citizen
let! u =
fromTable Table.Citizen
|> getAllWithIndex [ [| instance; mastodonUser |] ] "instanceUser"
|> limit 1
|> result<Citizen list> conn
return List.tryHead u
} }
/// Add a citizen /// Find a citizen by their e-mail address
let add (citizen : Citizen) conn = let findByEmail email conn = backgroundTask {
fromTable Table.Citizen let! citizen =
|> insert citizen Sql.existingConnection conn
|> write 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 /// Add or update a citizen
let logOnUpdate (citizen : Citizen) conn = let save (citizen : Citizen) conn = backgroundTask {
fromTable Table.Citizen let! _ =
|> get citizen.id Sql.existingConnection conn
|> update {| displayName = citizen.displayName; lastSeenOn = citizen.lastSeenOn |} |> Sql.query
|> write conn "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 /// Delete a citizen
let delete citizenId conn = backgroundTask { 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 /// Continent data access functions
[<RequireQualifiedAccess>] [<RequireQualifiedAccess>]