diff --git a/src/JobsJobsJobs/Domain/JobsJobsJobs.Domain.fsproj b/src/JobsJobsJobs/Domain/JobsJobsJobs.Domain.fsproj index a005d97..ee4d625 100644 --- a/src/JobsJobsJobs/Domain/JobsJobsJobs.Domain.fsproj +++ b/src/JobsJobsJobs/Domain/JobsJobsJobs.Domain.fsproj @@ -6,12 +6,13 @@ + - + diff --git a/src/JobsJobsJobs/Domain/Modules.fs b/src/JobsJobsJobs/Domain/SupportTypes.fs similarity index 54% rename from src/JobsJobsJobs/Domain/Modules.fs rename to src/JobsJobsJobs/Domain/SupportTypes.fs index 5e944d7..b607fc3 100644 --- a/src/JobsJobsJobs/Domain/Modules.fs +++ b/src/JobsJobsJobs/Domain/SupportTypes.fs @@ -1,19 +1,10 @@ -/// Modules to provide support functions for types -[] -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 = - - // 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 = [] - } +/// Another way to contact a citizen from this site +type OtherContact = + { /// The name of the contact (Email, No Agenda Social, LinkedIn, etc.) + Name : string + + /// 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 diff --git a/src/JobsJobsJobs/Domain/Types.fs b/src/JobsJobsJobs/Domain/Types.fs index 88b7e1f..3bf4dc3 100644 --- a/src/JobsJobsJobs/Domain/Types.fs +++ b/src/JobsJobsJobs/Domain/Types.fs @@ -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 [] 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 [] @@ -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 [] 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 [] diff --git a/src/JobsJobsJobs/Server/Data.fs b/src/JobsJobsJobs/Server/Data.fs index 1f35c5d..742bef0 100644 --- a/src/JobsJobsJobs/Server/Data.fs +++ b/src/JobsJobsJobs/Server/Data.fs @@ -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 "joined_on" + lastSeenOn = row.fieldValue "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 conn + 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 + } - /// 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 conn - return List.tryHead u + /// 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 } - /// Add a citizen - let add (citizen : Citizen) conn = - fromTable Table.Citizen - |> insert citizen - |> 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 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 + /// 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 []