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 230 additions and 111 deletions
Showing only changes of commit 7d2a2a50eb - Show all commits

View File

@ -17,16 +17,20 @@ let private fromShortGuid (it : string) =
/// Support functions for citizen IDs
module CitizenId =
/// Create a new citizen ID
let create () = (Guid.NewGuid >> CitizenId) ()
/// A string representation of a citizen ID
let toString = function CitizenId it -> toShortGuid it
/// Parse a string into a citizen ID
let ofString = fromShortGuid >> CitizenId
/// 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 ]
@ -36,34 +40,46 @@ module Citizen =
/// Support functions for continent IDs
module ContinentId =
/// Create a new continent ID
let create () = (Guid.NewGuid >> ContinentId) ()
/// A string representation of a continent ID
let toString = function ContinentId it -> toShortGuid it
/// Parse a string into a continent ID
let ofString = fromShortGuid >> ContinentId
/// Support functions for listing IDs
module ListingId =
/// Create a new job listing ID
let create () = (Guid.NewGuid >> ListingId) ()
/// A string representation of a listing ID
let toString = function ListingId it -> toShortGuid it
/// Parse a string into a listing ID
let ofString = fromShortGuid >> ListingId
/// Support functions for Markdown strings
module MarkdownString =
/// The Markdown conversion pipeline (enables all advanced features)
let private pipeline = MarkdownPipelineBuilder().UseAdvancedExtensions().Build ()
/// Convert this Markdown string to HTML
let toHtml = function Text text -> Markdown.ToHtml (text, pipeline)
/// Convert a Markdown string to its string representation
let toString = function Text text -> text
/// Support functions for Profiles
module Profile =
// An empty profile
let empty =
{ id = CitizenId Guid.Empty
@ -79,21 +95,28 @@ module Profile =
skills = []
}
/// Support functions for skill IDs
module SkillId =
/// Create a new skill ID
let create () = (Guid.NewGuid >> SkillId) ()
/// A string representation of a skill ID
let toString = function SkillId it -> toShortGuid it
/// Parse a string into a skill ID
let ofString = fromShortGuid >> SkillId
/// Support functions for success report IDs
module SuccessId =
/// Create a new success report ID
let create () = (Guid.NewGuid >> SuccessId) ()
/// A string representation of a success report ID
let toString = function SuccessId it -> toShortGuid it
/// Parse a string into a success report ID
let ofString = fromShortGuid >> SuccessId

View File

@ -11,16 +11,22 @@ open NodaTime
type ListingForm =
{ /// The ID of the listing
id : string
/// The listing title
title : string
/// The ID of the continent on which this opportunity exists
continentId : string
/// The region in which this opportunity exists
region : string
/// Whether this is a remote work opportunity
remoteWork : bool
/// The text of the job listing
text : string
/// The date by which this job listing is needed
neededBy : string option
}
@ -30,6 +36,7 @@ type ListingForm =
type ListingForView =
{ /// The listing itself
listing : Listing
/// The continent for that listing
continent : Continent
}
@ -39,6 +46,7 @@ type ListingForView =
type ListingExpireForm =
{ /// Whether the job was filled from here
fromHere : bool
/// The success story written by the user
successStory : string option
}
@ -49,10 +57,13 @@ type ListingExpireForm =
type ListingSearch =
{ /// Retrieve job listings for this continent
continentId : string option
/// Text for a search within a region
region : string option
/// Whether to retrieve job listings for remote work
remoteWork : string
/// Text for a search with the job listing description
text : string option
}
@ -62,8 +73,10 @@ type ListingSearch =
type LogOnSuccess =
{ /// The JSON Web Token (JWT) to use for API access
jwt : string
/// The ID of the logged-in citizen (as a string)
citizenId : string
/// The name of the logged-in citizen
name : string
}
@ -78,30 +91,41 @@ type Count =
/// An instance of a Mastodon server which is configured to work with Jobs, Jobs, Jobs
type MastodonInstance () =
/// The name of the instance
member val Name = "" with get, set
/// The URL for this instance
member val Url = "" with get, set
/// The abbreviation used in the URL to distinguish this instance's return codes
member val Abbr = "" with get, set
/// The client ID (assigned by the Mastodon server)
member val ClientId = "" with get, set
/// The cryptographic secret (provided by the Mastodon server)
member val Secret = "" with get, set
/// Whether the instance is currently enabled
member val IsEnabled = true with get, set
/// If an instance is disabled, the reason for it being disabled
member val Reason = "" with get, set
/// The authorization options for Jobs, Jobs, Jobs
type AuthOptions () =
/// The host for the return URL for Mastodon verification
member val ReturnHost = "" with get, set
/// The secret with which the server signs the JWTs for auth once we've verified with Mastodon
member val ServerSecret = "" with get, set
/// The instances configured for use
member val Instances = Array.empty<MastodonInstance> with get, set
interface IOptions<AuthOptions> with
override this.Value = this
@ -110,14 +134,19 @@ type AuthOptions () =
type Instance =
{ /// The name of the instance
name : string
/// The URL for this instance
url : string
/// The abbreviation used in the URL to distinguish this instance's return codes
abbr : string
/// The client ID (assigned by the Mastodon server)
clientId : string
/// Whether this instance is currently enabled
isEnabled : bool
/// If not enabled, the reason the instance is disabled
reason : string
}
@ -127,8 +156,10 @@ type Instance =
type SkillForm =
{ /// The ID of this skill
id : string
/// The description of the skill
description : string
/// Notes regarding the skill
notes : string option
}
@ -138,30 +169,40 @@ type SkillForm =
type ProfileForm =
{ /// Whether the citizen to whom this profile belongs is actively seeking employment
isSeekingEmployment : bool
/// Whether this profile should appear in the public search
isPublic : bool
/// The user's real name
realName : string
/// The ID of the continent on which the citizen is located
continentId : string
/// The area within that continent where the citizen is located
region : string
/// If the citizen is available for remote work
remoteWork : bool
/// If the citizen is seeking full-time employment
fullTime : bool
/// The user's professional biography
biography : string
/// The user's past experience
experience : string option
/// The skills for the user
skills : SkillForm list
}
/// Support functions for the ProfileForm type
module ProfileForm =
/// Create an instance of this form from the given profile
let fromProfile (profile : Types.Profile) =
/// Create an instance of this form from the given profile
let fromProfile (profile : Types.Profile) =
{ isSeekingEmployment = profile.seekingEmployment
isPublic = profile.isPublic
realName = ""
@ -169,8 +210,8 @@ module ProfileForm =
region = profile.region
remoteWork = profile.remoteWork
fullTime = profile.fullTime
biography = match profile.biography with Text bio -> bio
experience = profile.experience |> Option.map (fun x -> match x with Text exp -> exp)
biography = MarkdownString.toString profile.biography
experience = profile.experience |> Option.map MarkdownString.toString
skills = profile.skills
|> List.map (fun s ->
{ id = string s.id
@ -185,10 +226,13 @@ module ProfileForm =
type ProfileSearch =
{ /// Retrieve citizens from this continent
continentId : string option
/// Text for a search within a citizen's skills
skill : string option
/// Text for a search with a citizen's professional biography and experience fields
bioExperience : string option
/// Whether to retrieve citizens who do or do not want remote work
remoteWork : string
}
@ -198,14 +242,19 @@ type ProfileSearch =
type ProfileSearchResult =
{ /// The ID of the citizen
citizenId : CitizenId
/// The citizen's display name
displayName : string
/// Whether this citizen is currently seeking employment
seekingEmployment : bool
/// Whether this citizen is looking for remote work
remoteWork : bool
/// Whether this citizen is looking for full-time work
fullTime : bool
/// When this profile was last updated
lastUpdatedOn : Instant
}
@ -215,8 +264,10 @@ type ProfileSearchResult =
type ProfileForView =
{ /// The profile itself
profile : Profile
/// The citizen to whom the profile belongs
citizen : Citizen
/// The continent for the profile
continent : Continent
}
@ -227,10 +278,13 @@ type ProfileForView =
type PublicSearch =
{ /// Retrieve citizens from this continent
continentId : string option
/// Retrieve citizens from this region
region : string option
/// Text for a search within a citizen's skills
skill : string option
/// Whether to retrieve citizens who do or do not want remote work
remoteWork : string
}
@ -250,10 +304,13 @@ module PublicSearch =
type PublicSearchResult =
{ /// The name of the continent on which the citizen resides
continent : string
/// The region in which the citizen resides
region : string
/// Whether this citizen is seeking remote work
remoteWork : bool
/// The skills this citizen has identified
skills : string list
}
@ -263,8 +320,10 @@ type PublicSearchResult =
type StoryForm =
{ /// The ID of this story
id : string
/// Whether the employment was obtained from Jobs, Jobs, Jobs
fromHere : bool
/// The success story
story : string
}
@ -274,14 +333,19 @@ type StoryForm =
type StoryEntry =
{ /// The ID of this success story
id : SuccessId
/// The ID of the citizen who recorded this story
citizenId : CitizenId
/// The name of the citizen who recorded this story
citizenName : string
/// When this story was recorded
recordedOn : Instant
/// Whether this story involves an opportunity that arose due to Jobs, Jobs, Jobs
fromHere : bool
/// Whether this report has a further story, or if it is simply a "found work" entry
hasStory : bool
}

View File

@ -14,18 +14,25 @@ type CitizenId = CitizenId of Guid
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
/// When the user joined Jobs, Jobs, Jobs
joinedOn : Instant
/// When the user last logged in
lastSeenOn : Instant
}
@ -39,6 +46,7 @@ type ContinentId = ContinentId of Guid
type Continent =
{ /// The ID of the continent
id : ContinentId
/// The name of the continent
name : string
}
@ -56,26 +64,37 @@ type ListingId = ListingId of Guid
type Listing =
{ /// The ID of the job listing
id : ListingId
/// The ID of the citizen who posted the job listing
citizenId : CitizenId
/// When this job listing was created
createdOn : Instant
/// The short title of the job listing
title : string
/// The ID of the continent on which the job is located
continentId : ContinentId
/// The region in which the job is located
region : string
/// Whether this listing is for remote work
remoteWork : bool
/// Whether this listing has expired
isExpired : bool
/// When this listing was last updated
updatedOn : Instant
/// The details of this job
text : MarkdownString
/// When this job needs to be filled
neededBy : LocalDate option
/// Was this job filled as part of its appearance on Jobs, Jobs, Jobs?
wasFilledHere : bool option
}
@ -88,8 +107,10 @@ type SkillId = SkillId of Guid
type Skill =
{ /// The ID of the skill
id : SkillId
/// A description of the skill
description : string
/// Notes regarding this skill (level, duration, etc.)
notes : string option
}
@ -100,24 +121,34 @@ type Skill =
type Profile =
{ /// The ID of the citizen to whom this profile belongs
id : CitizenId
/// Whether this citizen is actively seeking employment
seekingEmployment : bool
/// Whether this citizen allows their profile to be a part of the publicly-viewable, anonymous data
isPublic : bool
/// The ID of the continent on which the citizen resides
continentId : ContinentId
/// The region in which the citizen resides
region : string
/// Whether the citizen is looking for remote work
remoteWork : bool
/// Whether the citizen is looking for full-time work
fullTime : bool
/// The citizen's professional biography
biography : MarkdownString
/// When the citizen last updated their profile
lastUpdatedOn : Instant
/// The citizen's experience (topical / chronological)
experience : MarkdownString option
/// Skills this citizen possesses
skills : Skill list
}
@ -130,14 +161,19 @@ type SuccessId = SuccessId of Guid
type Success =
{ /// The ID of the success report
id : SuccessId
/// The ID of the citizen who wrote this success report
citizenId : CitizenId
/// When this success report was recorded
recordedOn : Instant
/// Whether the success was due, at least in part, to Jobs, Jobs, Jobs
fromHere : bool
/// The source of this success (listing or profile)
source : string
/// The success story
story : MarkdownString option
}

View File

@ -31,8 +31,7 @@ module Converters =
type MarkdownStringJsonConverter() =
inherit JsonConverter<MarkdownString>()
override _.WriteJson(writer : JsonWriter, value : MarkdownString, _ : JsonSerializer) =
let (Text text) = value
writer.WriteValue text
writer.WriteValue (MarkdownString.toString value)
override _.ReadJson(reader: JsonReader, _ : Type, _ : MarkdownString, _ : bool, _ : JsonSerializer) =
(string >> Text) reader.Value
@ -75,16 +74,22 @@ module Converters =
/// Table names
[<RequireQualifiedAccess>]
module Table =
/// The user (citizen of Gitmo Nation) table
let Citizen = "citizen"
/// The continent table
let Continent = "continent"
/// The job listing table
let Listing = "listing"
/// The citizen employment profile table
let Profile = "profile"
/// The success story table
let Success = "success"
/// All tables
let all () = [ Citizen; Continent; Listing; Profile; Success ]
@ -166,7 +171,7 @@ module Startup =
let! userIdx = fromTable Table.Citizen |> indexList |> result<string list> conn
if not (List.contains "instanceUser" userIdx) then
do! fromTable Table.Citizen
|> indexCreateFunc "instanceUser" (fun row -> r.Array (row.G "instance", row.G "mastodonUser"))
|> indexCreateFunc "instanceUser" (fun row -> [| row.G "instance"; row.G "mastodonUser" |])
|> write conn
}
@ -175,7 +180,21 @@ open JobsJobsJobs.Domain
open JobsJobsJobs.Domain.SharedTypes
/// Sanitize user input, and create a "contains" pattern for use with RethinkDB queries
let regexContains = System.Text.RegularExpressions.Regex.Escape >> sprintf "(?i)%s"
let private regexContains = System.Text.RegularExpressions.Regex.Escape >> sprintf "(?i)%s"
/// Apply filters to a query, ensuring that types all match up
let private applyFilters (filters : (ReqlExpr -> Filter) list) query : ReqlExpr =
if List.isEmpty filters then
query
else
let first = List.head filters query
List.fold (fun q (f : ReqlExpr -> Filter) -> f q) first (List.tail filters)
/// Derive a user's display name from real name, display name, or handle (in that order)
let private deriveDisplayName (it : ReqlExpr) =
r.Branch (it.G("realName" ).Default_("").Ne "", it.G "realName",
it.G("displayName").Default_("").Ne "", it.G "displayName",
it.G "mastodonUser")
/// Profile data access functions
[<RequireQualifiedAccess>]
@ -209,75 +228,62 @@ module Profile =
/// Search profiles (logged-on users)
let search (search : ProfileSearch) conn =
(seq<ReqlExpr -> ReqlExpr> {
match search.continentId with
| Some cId -> yield (fun q -> q.Filter (r.HashMap (nameof search.continentId, ContinentId.ofString cId)))
| None -> ()
match search.remoteWork with
| "" -> ()
| _ -> yield (fun q -> q.Filter (r.HashMap (nameof search.remoteWork, search.remoteWork = "yes")))
match search.skill with
| Some skl ->
yield (fun q -> q.Filter (ReqlFunction1(fun it ->
it.G("skills").Contains (ReqlFunction1(fun s -> s.G("description").Match (regexContains skl))))))
| None -> ()
match search.bioExperience with
| Some text ->
let txt = regexContains text
yield (fun q -> q.Filter (ReqlFunction1(fun it ->
it.G("biography").Match(txt).Or (it.G("experience").Match txt))))
| None -> ()
}
|> Seq.toList
|> List.fold
(fun q f -> f q)
(r.Table(Table.Profile)
.EqJoin("id", r.Table Table.Citizen)
.Without(r.HashMap ("right", "id"))
.Zip () :> ReqlExpr))
|> mergeFunc (fun it ->
r.HashMap("displayName",
r.Branch (it.G("realName" ).Default_("").Ne "", it.G "realName",
it.G("displayName").Default_("").Ne "", it.G "displayName",
it.G "mastodonUser"))
.With ("citizenId", it.G "id"))
fromTable Table.Profile
|> eqJoin "id" (fromTable Table.Citizen)
|> without [ "right.id" ]
|> zip
|> applyFilters
[ match search.continentId with
| Some contId -> yield filter {| continentId = ContinentId.ofString contId |}
| None -> ()
match search.remoteWork with
| "" -> ()
| _ -> yield filter {| remoteWork = search.remoteWork = "yes" |}
match search.skill with
| Some skl ->
yield filterFunc (fun it ->
it.G("skills").Contains (ReqlFunction1 (fun s -> s.G("description").Match (regexContains skl))))
| None -> ()
match search.bioExperience with
| Some text ->
let txt = regexContains text
yield filterFunc (fun it -> it.G("biography").Match(txt).Or (it.G("experience").Match txt))
| None -> ()
]
|> mergeFunc (fun it -> {| displayName = deriveDisplayName it; citizenId = it.G "id" |})
|> pluck [ "citizenId"; "displayName"; "seekingEmployment"; "remoteWork"; "fullTime"; "lastUpdatedOn" ]
|> orderByFunc (fun it -> it.G("displayName").Downcase ())
|> result<ProfileSearchResult list> conn
// Search profiles (public)
let publicSearch (search : PublicSearch) conn =
(seq<ReqlExpr -> ReqlExpr> {
match search.continentId with
| Some cId -> yield (fun q -> q.Filter (r.HashMap (nameof search.continentId, ContinentId.ofString cId)))
| None -> ()
match search.region with
| Some reg ->
yield (fun q -> q.Filter (ReqlFunction1 (fun it -> upcast it.G("region").Match (regexContains reg))))
| None -> ()
match search.remoteWork with
| "" -> ()
| _ -> yield (fun q -> q.Filter (r.HashMap (nameof search.remoteWork, search.remoteWork = "yes")))
match search.skill with
| Some skl ->
yield (fun q -> q.Filter (ReqlFunction1 (fun it ->
it.G("skills").Contains (ReqlFunction1(fun s -> s.G("description").Match (regexContains skl))))))
| None -> ()
}
|> Seq.toList
|> List.fold
(fun q f -> f q)
(r.Table(Table.Profile)
.EqJoin("continentId", r.Table Table.Continent)
.Without(r.HashMap ("right", "id"))
.Zip()
.Filter(r.HashMap ("isPublic", true))))
fromTable Table.Profile
|> eqJoin "continentId" (fromTable Table.Continent)
|> without [ "right.id" ]
|> zip
|> applyFilters
[ yield filter {| isPublic = true |}
match search.continentId with
| Some contId -> yield filter {| continentId = ContinentId.ofString contId |}
| None -> ()
match search.region with
| Some reg -> yield filterFunc (fun it -> it.G("region").Match (regexContains reg))
| None -> ()
match search.remoteWork with
| "" -> ()
| _ -> yield filter {| remoteWork = search.remoteWork = "yes" |}
match search.skill with
| Some skl ->
yield filterFunc (fun it ->
it.G("skills").Contains (ReqlFunction1 (fun s -> s.G("description").Match (regexContains skl))))
| None -> ()
]
|> mergeFunc (fun it ->
r.HashMap("skills",
it.G("skills").Map (ReqlFunction1 (fun skill ->
r.Branch(skill.G("notes").Default_("").Eq "", skill.G "description",
skill.G("description").Add(" (").Add(skill.G("notes")).Add ")"))))
.With("continent", it.G "name"))
{| skills = it.G("skills").Map (ReqlFunction1 (fun skill ->
r.Branch(skill.G("notes").Default_("").Eq "", skill.G "description",
skill.G("description").Add(" (").Add(skill.G("notes")).Add ")")))
continent = it.G "name"
|})
|> pluck [ "continent"; "region"; "skills"; "remoteWork" ]
|> result<PublicSearchResult list> conn
@ -295,7 +301,7 @@ module Citizen =
let findByMastodonUser (instance : string) (mastodonUser : string) conn = task {
let! u =
fromTable Table.Citizen
|> getAllWithIndex [ r.Array (instance, mastodonUser) ] "instanceUser"
|> getAllWithIndex [ [| instance; mastodonUser |] ] "instanceUser"
|> limit 1
|> result<Citizen list> conn
return List.tryHead u
@ -311,8 +317,7 @@ module Citizen =
let logOnUpdate (citizen : Citizen) conn =
fromTable Table.Citizen
|> get citizen.id
|> update (r.HashMap( nameof citizen.displayName, citizen.displayName)
.With (nameof citizen.lastSeenOn, citizen.lastSeenOn))
|> update {| displayName = citizen.displayName; lastSeenOn = citizen.lastSeenOn |}
|> write conn
/// Delete a citizen
@ -336,7 +341,7 @@ module Citizen =
let realNameUpdate (citizenId : CitizenId) (realName : string option) conn =
fromTable Table.Citizen
|> get citizenId
|> update (r.HashMap (nameof realName, realName))
|> update {| realName = realName |}
|> write conn
@ -362,12 +367,15 @@ module Listing =
open NodaTime
/// Convert a joined query to the form needed for ListingForView deserialization
let private toListingForView (it : ReqlExpr) : obj = {| listing = it.G "left"; continent = it.G "right" |}
/// Find all job listings posted by the given citizen
let findByCitizen (citizenId : CitizenId) conn =
fromTable Table.Listing
|> getAllWithIndex [ citizenId ] (nameof citizenId)
|> eqJoin "continentId" (fromTable Table.Continent)
|> mapFunc (fun it -> r.HashMap("listing", it.G "left").With ("continent", it.G "right"))
|> mapFunc toListingForView
|> result<ListingForView list> conn
/// Find a listing by its ID
@ -380,9 +388,9 @@ module Listing =
let findByIdForView (listingId : ListingId) conn = task {
let! listing =
fromTable Table.Listing
|> filter (r.HashMap ("id", listingId))
|> filter {| id = listingId |}
|> eqJoin "continentId" (fromTable Table.Continent)
|> mapFunc (fun it -> r.HashMap("listing", it.G "left").With ("continent", it.G "right"))
|> mapFunc toListingForView
|> result<ListingForView list> conn
return List.tryHead listing
}
@ -404,36 +412,29 @@ module Listing =
let expire (listingId : ListingId) (fromHere : bool) (now : Instant) conn =
(fromTable Table.Listing
|> get listingId)
.Update (r.HashMap("isExpired", true).With("wasFilledHere", fromHere).With ("updatedOn", now))
.Update {| isExpired = true; wasFilledHere = fromHere; updatedOn = now |}
|> write conn
/// Search job listings
let search (search : ListingSearch) conn =
(seq<ReqlExpr -> ReqlExpr> {
match search.continentId with
| Some cId -> yield (fun q -> q.Filter (r.HashMap (nameof search.continentId, ContinentId.ofString cId)))
| None -> ()
match search.region with
| Some rgn ->
yield (fun q ->
q.Filter (ReqlFunction1 (fun it -> it.G(nameof search.region).Match (regexContains rgn))))
| None -> ()
match search.remoteWork with
| "" -> ()
| _ -> yield (fun q -> q.Filter (r.HashMap (nameof search.remoteWork, search.remoteWork = "yes")))
match search.text with
| Some text ->
yield (fun q ->
q.Filter (ReqlFunction1 (fun it -> it.G(nameof search.text).Match (regexContains text))))
| None -> ()
}
|> Seq.toList
|> List.fold
(fun q f -> f q)
(fromTable Table.Listing
|> getAllWithIndex [ false ] "isExpired" :> ReqlExpr))
fromTable Table.Listing
|> getAllWithIndex [ false ] "isExpired"
|> applyFilters
[ match search.continentId with
| Some contId -> yield filter {| continentId = ContinentId.ofString contId |}
| None -> ()
match search.region with
| Some rgn -> yield filterFunc (fun it -> it.G(nameof search.region).Match (regexContains rgn))
| None -> ()
match search.remoteWork with
| "" -> ()
| _ -> yield filter {| remoteWork = search.remoteWork = "yes" |}
match search.text with
| Some text -> yield filterFunc (fun it -> it.G(nameof search.text).Match (regexContains text))
| None -> ()
]
|> eqJoin "continentId" (fromTable Table.Continent)
|> mapFunc (fun it -> r.HashMap("listing", it.G "left").With ("continent", it.G "right"))
|> mapFunc toListingForView
|> result<ListingForView list> conn
@ -456,16 +457,11 @@ module Success =
// Retrieve all success stories
let all conn =
(fromTable Table.Success
|> eqJoin "citizenId" (fromTable Table.Citizen))
.Without(r.HashMap ("right", "id"))
fromTable Table.Success
|> eqJoin "citizenId" (fromTable Table.Citizen)
|> without [ "right.id" ]
|> zip
|> mergeFunc (fun it ->
r.HashMap("citizenName",
r.Branch(it.G("realName" ).Default_("").Ne "", it.G "realName",
it.G("displayName").Default_("").Ne "", it.G "displayName",
it.G "mastodonUser"))
.With ("hasStory", it.G("story").Default_("").Gt ""))
|> mergeFunc (fun it -> {| citizenName = deriveDisplayName it; hasStory = it.G("story").Default_("").Gt "" |})
|> pluck [ "id"; "citizenId"; "citizenName"; "recordedOn"; "fromHere"; "hasStory" ]
|> orderByDescending "recordedOn"
|> result<StoryEntry list> conn