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
[]