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>
<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" />

View File

@ -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

View File

@ -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
id : CitizenId
/// When the user joined Jobs, Jobs, Jobs
joinedOn : Instant
joinedOn : Instant
/// 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
[<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>]

View File

@ -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
@ -154,12 +153,16 @@ module Startup =
name TEXT NOT NULL)"
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)"
id UUID NOT NULL PRIMARY KEY,
joined_on TIMESTAMPTZ NOT NULL,
last_seen_on TIMESTAMPTZ 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>]