Unify public/private profile search (#39)

This commit is contained in:
Daniel J. Summers 2023-01-29 21:40:22 -05:00
parent 5c7383c6d1
commit 2aef50400d
6 changed files with 136 additions and 221 deletions

View File

@ -45,7 +45,7 @@ module DataConnection =
let dataSource () = let dataSource () =
match theDataSource with match theDataSource with
| Some ds -> Sql.fromDataSource ds | Some ds -> Sql.fromDataSource ds
| None -> invalidOp "Connection.setUp() must be called before accessing the database" | None -> invalidOp "DataConnection.setUp() must be called before accessing the database"
/// Create tables /// Create tables
let private createTables () = backgroundTask { let private createTables () = backgroundTask {

View File

@ -195,7 +195,7 @@ let isLocal = Regex """^/[^\/\\].*"""
let redirectToGet (url : string) next ctx = task { let redirectToGet (url : string) next ctx = task {
do! saveSession ctx do! saveSession ctx
let action = let action =
if Option.isSome (noneIfEmpty url) && isLocal.IsMatch url then if Option.isSome (noneIfEmpty url) && (url = "/" || isLocal.IsMatch url) then
if isHtmx ctx then withHxRedirect url else redirectTo false url if isHtmx ctx then withHxRedirect url else redirectTo false url
else RequestErrors.BAD_REQUEST "Invalid redirect URL" else RequestErrors.BAD_REQUEST "Invalid redirect URL"
return! action next ctx return! action next ctx

View File

@ -251,9 +251,9 @@ module Layout =
div [ _class "separator" ] [] div [ _class "separator" ] []
navLink "/citizen/log-off" "logout-variant" "Log Off" navLink "/citizen/log-off" "logout-variant" "Log Off"
else else
navLink "/" "home" "Home" navLink "/" "home" "Home"
navLink "/profile/seeking" "view-list-outline" "Job Seekers" navLink "/profile/search" "view-list-outline" "Employment Profiles"
navLink "/citizen/log-on" "login-variant" "Log On" navLink "/citizen/log-on" "login-variant" "Log On"
navLink "/how-it-works" "help-circle-outline" "How It Works" navLink "/how-it-works" "help-circle-outline" "How It Works"
] ]

View File

@ -29,6 +29,13 @@ let findById citizenId = backgroundTask {
| None -> return None | None -> return None
} }
/// Convert a data row to a profile for viewing
let private toProfileForView row =
{ Profile = toDocument<Profile> row
Citizen = toDocumentFrom<Citizen> "cit_data" row
Continent = toDocumentFrom<Continent> "cont_data" row
}
/// Find a profile by citizen ID for viewing (includes citizen and continent information) /// Find a profile by citizen ID for viewing (includes citizen and continent information)
let findByIdForView citizenId = backgroundTask { let findByIdForView citizenId = backgroundTask {
let! tryCitizen = let! tryCitizen =
@ -41,11 +48,7 @@ let findByIdForView citizenId = backgroundTask {
WHERE p.id = @id WHERE p.id = @id
AND p.data ->> 'isLegacy' = 'false'" AND p.data ->> 'isLegacy' = 'false'"
|> Sql.parameters [ "@id", Sql.string (CitizenId.toString citizenId) ] |> Sql.parameters [ "@id", Sql.string (CitizenId.toString citizenId) ]
|> Sql.executeAsync (fun row -> |> Sql.executeAsync toProfileForView
{ Profile = toDocument<Profile> row
Citizen = toDocumentFrom<Citizen> "cit_data" row
Continent = toDocumentFrom<Continent> "cont_data" row
})
return List.tryHead tryCitizen return List.tryHead tryCitizen
} }
@ -53,8 +56,8 @@ let findByIdForView citizenId = backgroundTask {
let save (profile : Profile) = let save (profile : Profile) =
dataSource () |> saveDocument Table.Profile (CitizenId.toString profile.Id) <| mkDoc profile dataSource () |> saveDocument Table.Profile (CitizenId.toString profile.Id) <| mkDoc profile
/// Search profiles (logged-on users) /// Search profiles
let search (search : ProfileSearchForm) = backgroundTask { let search (search : ProfileSearchForm) isPublic = backgroundTask {
let searches = [ let searches = [
if search.ContinentId <> "" then if search.ContinentId <> "" then
"p.data ->> 'continentId' = @continentId", [ "@continentId", Sql.string search.ContinentId ] "p.data ->> 'continentId' = @continentId", [ "@continentId", Sql.string search.ContinentId ]
@ -74,61 +77,21 @@ let search (search : ProfileSearchForm) = backgroundTask {
OR x ->> 'description' ILIKE @text)", OR x ->> 'description' ILIKE @text)",
[ "@text", like search.Text ] [ "@text", like search.Text ]
] ]
let vizSql =
if isPublic then
sprintf "IN ('%s', '%s')" (ProfileVisibility.toString Public) (ProfileVisibility.toString Anonymous)
else sprintf "<> '%s'" (ProfileVisibility.toString Hidden)
let! results = let! results =
dataSource () dataSource ()
|> Sql.query $" |> Sql.query $"
SELECT p.*, c.data AS cit_data SELECT p.*, c.data AS cit_data, o.data AS cont_data
FROM {Table.Profile} p FROM {Table.Profile} p
INNER JOIN {Table.Citizen} c ON c.id = p.id INNER JOIN {Table.Citizen} c ON c.id = p.id
WHERE p.data ->> 'isLegacy' = 'false' INNER JOIN {Table.Continent} o ON o.id = p.data ->> 'continentId'
AND p.data ->> 'visibility' <> '{ProfileVisibility.toString Hidden}' WHERE p.data ->> 'isLegacy' = 'false'
AND p.data ->> 'visibility' {vizSql}
{searchSql searches}" {searchSql searches}"
|> Sql.parameters (searches |> List.collect snd) |> Sql.parameters (searches |> List.collect snd)
|> Sql.executeAsync (fun row -> |> Sql.executeAsync toProfileForView
let profile = toDocument<Profile> row return results |> List.sortBy (fun pfv -> (Citizen.name pfv.Citizen).ToLowerInvariant ())
let citizen = toDocumentFrom<Citizen> "cit_data" row
{ CitizenId = profile.Id
DisplayName = Citizen.name citizen
SeekingEmployment = profile.IsSeekingEmployment
RemoteWork = profile.IsRemote
FullTime = profile.IsFullTime
LastUpdatedOn = profile.LastUpdatedOn
})
return results |> List.sortBy (fun psr -> psr.DisplayName.ToLowerInvariant ())
} }
// Search profiles (public)
let publicSearch (search : PublicSearchForm) =
let searches = [
if search.ContinentId <> "" then
"p.data ->> 'continentId' = @continentId", [ "@continentId", Sql.string search.ContinentId ]
if search.Region <> "" then
"p.data ->> 'region' ILIKE @region", [ "@region", like search.Region ]
if search.RemoteWork <> "" then
"p.data ->> 'isRemote' = @remote", [ "@remote", jsonBool (search.RemoteWork = "yes") ]
if search.Skill <> "" then
"EXISTS (
SELECT 1 FROM jsonb_array_elements(p.data['skills']) x(elt)
WHERE x ->> 'description' ILIKE @description)",
[ "@description", like search.Skill ]
]
dataSource ()
|> Sql.query $"
SELECT p.*, c.data AS cont_data
FROM {Table.Profile} p
INNER JOIN {Table.Continent} c ON c.id = p.data ->> 'continentId'
WHERE p.data ->> 'isPubliclySearchable' = 'true'
AND p.data ->> 'isLegacy' = 'false'
{searchSql searches}"
|> Sql.parameters (searches |> List.collect snd)
|> Sql.executeAsync (fun row ->
let profile = toDocument<Profile> row
let continent = toDocumentFrom<Continent> "cont_data" row
{ Continent = continent.Name
Region = profile.Region
RemoteWork = profile.IsRemote
Skills = profile.Skills
|> List.map (fun s ->
let notes = match s.Notes with Some n -> $" ({n})" | None -> ""
$"{s.Description}{notes}")
})

View File

@ -71,35 +71,20 @@ let saveGeneralInfo : HttpHandler = requireUser >=> fun next ctx -> task {
} }
// GET: /profile/search // GET: /profile/search
let search : HttpHandler = requireUser >=> fun next ctx -> task { let search : HttpHandler = fun next ctx -> task {
let! continents = Common.Data.Continents.all () let! continents = Common.Data.Continents.all ()
let form = let form =
match ctx.TryBindQueryString<ProfileSearchForm> () with match ctx.TryBindQueryString<ProfileSearchForm> () with
| Ok f -> f | Ok f -> f
| Error _ -> { ContinentId = ""; RemoteWork = ""; Text = "" } | Error _ -> { ContinentId = ""; RemoteWork = ""; Text = "" }
let isPublic = tryUser ctx |> Option.isNone
let! results = task { let! results = task {
if string ctx.Request.Query["searched"] = "true" then if string ctx.Request.Query["searched"] = "true" then
let! it = Data.search form let! it = Data.search form isPublic
return Some it return Some it
else return None else return None
} }
return! Views.search form continents (timeZone ctx) results |> render "Profile Search" next ctx return! Views.search form continents (timeZone ctx) results isPublic |> render "Profile Search" next ctx
}
// GET: /profile/seeking
let seeking : HttpHandler = fun next ctx -> task {
let! continents = Common.Data.Continents.all ()
let form =
match ctx.TryBindQueryString<PublicSearchForm> () with
| Ok f -> f
| Error _ -> { ContinentId = ""; Region = ""; RemoteWork = ""; Skill = "" }
let! results = task {
if string ctx.Request.Query["searched"] = "true" then
let! it = Data.publicSearch form
return Some it
else return None
}
return! Views.publicSearch form continents results |> render "Profile Search" next ctx
} }
// GET: /profile/edit/skills // GET: /profile/edit/skills
@ -262,7 +247,6 @@ let endpoints =
route "/edit/skills" skills route "/edit/skills" skills
route "/edit/skills/list" skillList route "/edit/skills/list" skillList
route "/search" search route "/search" search
route "/seeking" seeking
] ]
POST [ POST [
route "/delete" delete route "/delete" delete

View File

@ -353,163 +353,131 @@ let editHistory (history : EmploymentHistory list) idx csrf =
// ~~~ PROFILE SEARCH ~~~ // // ~~~ PROFILE SEARCH ~~~ //
/// The public search page /// The search form
let publicSearch (m : PublicSearchForm) continents (results : PublicSearchResult list option) = let private searchForm (m : ProfileSearchForm) continents =
pageWithTitle "People Seeking Work" [ collapsePanel "Search Criteria" [
if Option.isNone results then form [ _class "container"; _method "GET"; _action "/profile/search" ] [
p [] [ input [ _type "hidden"; _name "searched"; _value "true" ]
txt "Enter one or more criteria to filter results, or just click &ldquo;Search&rdquo; to list all " div [ _class "row" ] [
txt "publicly searchable profiles." div [ _class "col-12 col-sm-6 col-md-4 col-lg-3 mb-3" ] [
] continentList [] "ContinentId" continents (Some "Any") m.ContinentId false
collapsePanel "Search Criteria" [ ]
form [ _class "container"; _method "GET"; _action "/profile/seeking" ] [ div [ _class "col-12 col-sm-6 col-offset-md-2 col-lg-3 col-offset-lg-0 mb-3" ] [
input [ _type "hidden"; _name "searched"; _value "true" ] label [ _class "jjj-label" ] [ txt "Seeking Remote Work?" ]; br []
div [ _class "row" ] [ div [ _class "form-check form-check-inline" ] [
div [ _class "col-12 col-sm-6 col-md-4 col-lg-3" ] [ input [ _type "radio"; _id "remoteNull"; _name (nameof m.RemoteWork); _value ""
continentList [] "ContinentId" continents (Some "Any") m.ContinentId false _class "form-check-input"; if m.RemoteWork = "" then _checked ]
label [ _class "form-check-label"; _for "remoteNull" ] [ txt "No Selection" ]
] ]
div [ _class "col-12 col-sm-6 col-md-4 col-lg-3" ] [ div [ _class "form-check form-check-inline" ] [
textBox [ _maxlength "1000" ] (nameof m.Region) m.Region "Region" false input [ _type "radio"; _id "remoteYes"; _name (nameof m.RemoteWork); _value "yes"
div [ _class "form-text" ] [ txt "(free-form text)" ] _class "form-check-input"; if m.RemoteWork = "yes" then _checked ]
label [ _class "form-check-label"; _for "remoteYes" ] [ txt "Yes" ]
] ]
div [ _class "col-12 col-sm-6 col-offset-md-2 col-lg-3 col-offset-lg-0" ] [ div [ _class "form-check form-check-inline" ] [
label [ _class "jjj-label" ] [ txt "Seeking Remote Work?" ]; br [] input [ _type "radio"; _id "remoteNo"; _name (nameof m.RemoteWork); _value "no"
div [ _class "form-check form-check-inline" ] [ _class "form-check-input"; if m.RemoteWork = "no" then _checked ]
input [ _type "radio"; _id "remoteNull"; _name (nameof m.RemoteWork); _value "" label [ _class "form-check-label"; _for "remoteNo" ] [ txt "No" ]
_class "form-check-input"; if m.RemoteWork = "" then _checked ]
label [ _class "form-check-label"; _for "remoteNull" ] [ txt "No Selection" ]
]
div [ _class "form-check form-check-inline" ] [
input [ _type "radio"; _id "remoteYes"; _name (nameof m.RemoteWork); _value "yes"
_class "form-check-input"; if m.RemoteWork = "yes" then _checked ]
label [ _class "form-check-label"; _for "remoteYes" ] [ txt "Yes" ]
]
div [ _class "form-check form-check-inline" ] [
input [ _type "radio"; _id "remoteNo"; _name (nameof m.RemoteWork); _value "no"
_class "form-check-input"; if m.RemoteWork = "no" then _checked ]
label [ _class "form-check-label"; _for "remoteNo" ] [ txt "No" ]
]
]
div [ _class "col-12 col-sm-6 col-lg-3" ] [
textBox [ _maxlength "1000" ] (nameof m.Skill) m.Skill "Skill" false
div [ _class "form-text" ] [ txt "(free-form text)" ]
] ]
] ]
div [ _class "row" ] [ div [ _class "col-12 col-sm-12 col-lg-6 mb-3" ] [
div [ _class "col" ] [ textBox [ _maxlength "1000" ] (nameof m.Text) m.Text "Text Search" false
br [] div [ _class "form-text" ] [
button [ _type "submit"; _class "btn btn-outline-primary" ] [ txt "Search" ] txt "searches Region, Professional Biography, Skills, Employment History, and Experience"
] ]
] ]
] ]
div [ _class "row" ] [
div [ _class "col" ] [
br []
button [ _type "submit"; _class "btn btn-outline-primary" ] [ txt "Search" ]
]
]
] ]
match results with
| Some r when List.isEmpty r -> p [ _class "pt-3" ] [ txt "No results found for the specified criteria" ]
| Some r ->
p [ _class "py-3" ] [
txt "These profiles match your search criteria. To learn more about these people, join the merry band "
txt "of human resources in the "
a [ _href "https://noagendashow.net"; _target "_blank"; _rel "noopener" ] [ txt "No Agenda" ]
txt " tribe!"
]
table [ _class "table table-sm table-hover" ] [
thead [] [
tr [] [
th [ _scope "col" ] [ txt "Continent" ]
th [ _scope "col"; _class "text-center" ] [ txt "Region" ]
th [ _scope "col"; _class "text-center" ] [ txt "Remote?" ]
th [ _scope "col"; _class "text-center" ] [ txt "Skills" ]
]
]
r |> List.map (fun profile ->
tr [] [
td [] [ str profile.Continent ]
td [] [ str profile.Region ]
td [ _class "text-center" ] [ txt (yesOrNo profile.RemoteWork) ]
profile.Skills
|> List.collect (fun skill -> [ str skill; br [] ])
|> td []
])
|> tbody []
]
| None -> ()
] ]
/// Display search results for public users
let private publicResults (results : ProfileForView list) =
[ p [ _class "py-3" ] [
txt "These profiles match your search criteria. To learn more about these people, join the merry band "
txt "of human resources in the "
a [ _href "https://noagendashow.net"; _target "_blank"; _rel "noopener" ] [ txt "No Agenda" ]
txt " tribe!"
]
table [ _class "table table-sm table-hover" ] [
thead [] [
tr [] [
th [ _scope "col" ] [ txt "Profile" ]
th [ _scope "col" ] [ txt "Continent / Region" ]
th [ _scope "col"; _class "text-center" ] [ txt "Remote?" ]
th [ _scope "col"; _class "text-center" ] [ txt "Skills" ]
]
]
results
|> List.map (fun it ->
tr [] [
td [] [
match it.Profile.Visibility with
| Public -> a [ _href $"/profile/{CitizenId.toString it.Profile.Id}/view" ] [ txt "View" ]
| _ -> txt "&nbsp;"
]
td [] [ txt $"{it.Continent.Name} / "; str it.Profile.Region ]
td [ _class "text-center" ] [ txt (yesOrNo it.Profile.IsRemote) ]
match it.Profile.Visibility with
| Public -> td [ _class "text-muted fst-italic" ] [ txt "See Profile" ]
| _ when List.isEmpty it.Profile.Skills ->
td [ _class "text-muted fst-italic" ] [ txt "None Listed" ]
| _ ->
it.Profile.Skills
|> List.collect (fun skill ->
let notes = match skill.Notes with Some n -> $" ({n})" | None -> ""
[ str $"{skill.Description}{notes}"; br [] ])
|> td []
])
|> tbody []
]
]
/// Display search results for logged-on users
let private privateResults (results : ProfileForView list) tz =
// Bootstrap utility classes to only show at medium or above
let isWide = "d-none d-md-table-cell"
table [ _class "table table-sm table-hover pt-3" ] [
thead [] [
tr [] [
th [ _scope "col" ] [ txt "Profile" ]
th [ _scope "col" ] [ txt "Name" ]
th [ _scope "col"; _class $"{isWide} text-center" ] [ txt "Seeking?" ]
th [ _scope "col"; _class "text-center" ] [ txt "Remote?" ]
th [ _scope "col"; _class $"{isWide} text-center" ] [ txt "Full-Time?" ]
th [ _scope "col"; _class isWide ] [ txt "Last Updated" ]
]
]
results
|> List.map (fun it ->
tr [] [
td [] [ a [ _href $"/profile/{CitizenId.toString it.Profile.Id}/view" ] [ txt "View" ] ]
td [ if it.Profile.IsSeekingEmployment then _class "fw-bold" ] [ str (Citizen.name it.Citizen) ]
td [ _class $"{isWide} text-center" ] [ txt (yesOrNo it.Profile.IsSeekingEmployment) ]
td [ _class "text-center" ] [ txt (yesOrNo it.Profile.IsRemote) ]
td [ _class $"{isWide} text-center" ] [ txt (yesOrNo it.Profile.IsFullTime) ]
td [ _class isWide ] [ str (fullDate it.Profile.LastUpdatedOn tz) ]
])
|> tbody []
]
/// Logged-on search page /// Logged-on search page
let search (m : ProfileSearchForm) continents tz (results : ProfileSearchResult list option) = let search m continents tz (results : ProfileForView list option) isPublic =
[ if Option.isNone results then [ if Option.isNone results then
p [] [ p [] [
txt "Enter one or more criteria to filter results, or just click &ldquo;Search&rdquo; to list all " txt "Enter one or more criteria to filter results, or just click &ldquo;Search&rdquo; to list all "
if isPublic then txt "publicly searchable or viewable "
txt "profiles." txt "profiles."
] ]
collapsePanel "Search Criteria" [ searchForm m continents
form [ _class "container"; _method "GET"; _action "/profile/search" ] [
input [ _type "hidden"; _name "searched"; _value "true" ]
div [ _class "row" ] [
div [ _class "col-12 col-sm-6 col-md-4 col-lg-3 mb-3" ] [
continentList [] "ContinentId" continents (Some "Any") m.ContinentId false
]
div [ _class "col-12 col-sm-6 col-offset-md-2 col-lg-3 col-offset-lg-0 mb-3" ] [
label [ _class "jjj-label" ] [ txt "Seeking Remote Work?" ]; br []
div [ _class "form-check form-check-inline" ] [
input [ _type "radio"; _id "remoteNull"; _name (nameof m.RemoteWork); _value ""
_class "form-check-input"; if m.RemoteWork = "" then _checked ]
label [ _class "form-check-label"; _for "remoteNull" ] [ txt "No Selection" ]
]
div [ _class "form-check form-check-inline" ] [
input [ _type "radio"; _id "remoteYes"; _name (nameof m.RemoteWork); _value "yes"
_class "form-check-input"; if m.RemoteWork = "yes" then _checked ]
label [ _class "form-check-label"; _for "remoteYes" ] [ txt "Yes" ]
]
div [ _class "form-check form-check-inline" ] [
input [ _type "radio"; _id "remoteNo"; _name (nameof m.RemoteWork); _value "no"
_class "form-check-input"; if m.RemoteWork = "no" then _checked ]
label [ _class "form-check-label"; _for "remoteNo" ] [ txt "No" ]
]
]
div [ _class "col-12 col-sm-12 col-lg-6 mb-3" ] [
textBox [ _maxlength "1000" ] (nameof m.Text) m.Text "Text Search" false
div [ _class "form-text" ] [
txt "searches Region, Professional Biography, Skills, Employment History, and Experience"
]
]
]
div [ _class "row" ] [
div [ _class "col" ] [
br []
button [ _type "submit"; _class "btn btn-outline-primary" ] [ txt "Search" ]
]
]
]
]
match results with match results with
| Some r when List.isEmpty r -> p [ _class "pt-3" ] [ txt "No results found for the specified criteria" ] | Some r when List.isEmpty r -> p [ _class "pt-3" ] [ txt "No results found for the specified criteria" ]
| Some r -> | Some r -> if isPublic then yield! publicResults r else privateResults r tz
// Bootstrap utility classes to only show at medium or above
let isWide = "d-none d-md-table-cell"
table [ _class "table table-sm table-hover pt-3" ] [
thead [] [
tr [] [
th [ _scope "col" ] [ txt "Profile" ]
th [ _scope "col" ] [ txt "Name" ]
th [ _scope "col"; _class $"{isWide} text-center" ] [ txt "Seeking?" ]
th [ _scope "col"; _class "text-center" ] [ txt "Remote?" ]
th [ _scope "col"; _class $"{isWide} text-center" ] [ txt "Full-Time?" ]
th [ _scope "col"; _class isWide ] [ txt "Last Updated" ]
]
]
r |> List.map (fun profile ->
tr [] [
td [] [ a [ _href $"/profile/{CitizenId.toString profile.CitizenId}/view" ] [ txt "View" ] ]
td [ if profile.SeekingEmployment then _class "fw-bold" ] [ str profile.DisplayName ]
td [ _class $"{isWide} text-center" ] [ txt (yesOrNo profile.SeekingEmployment) ]
td [ _class "text-center" ] [ txt (yesOrNo profile.RemoteWork) ]
td [ _class $"{isWide} text-center" ] [ txt (yesOrNo profile.FullTime) ]
td [ _class isWide ] [ str (fullDate profile.LastUpdatedOn tz) ]
])
|> tbody []
]
| None -> () | None -> ()
] ]
|> pageWithTitle "Search Profiles" |> pageWithTitle "Search Profiles"