diff --git a/src/JobsJobsJobs/Domain/Modules.fs b/src/JobsJobsJobs/Domain/Modules.fs index 5d062cf..2fc9f76 100644 --- a/src/JobsJobsJobs/Domain/Modules.fs +++ b/src/JobsJobsJobs/Domain/Modules.fs @@ -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 diff --git a/src/JobsJobsJobs/Domain/SharedTypes.fs b/src/JobsJobsJobs/Domain/SharedTypes.fs index 81a60fa..d52a101 100644 --- a/src/JobsJobsJobs/Domain/SharedTypes.fs +++ b/src/JobsJobsJobs/Domain/SharedTypes.fs @@ -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 with get, set + interface IOptions 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 } diff --git a/src/JobsJobsJobs/Domain/Types.fs b/src/JobsJobsJobs/Domain/Types.fs index eefae0d..e881233 100644 --- a/src/JobsJobsJobs/Domain/Types.fs +++ b/src/JobsJobsJobs/Domain/Types.fs @@ -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 } diff --git a/src/JobsJobsJobs/Server/Data.fs b/src/JobsJobsJobs/Server/Data.fs index 772e1b6..30fd8c4 100644 --- a/src/JobsJobsJobs/Server/Data.fs +++ b/src/JobsJobsJobs/Server/Data.fs @@ -31,8 +31,7 @@ module Converters = type MarkdownStringJsonConverter() = inherit JsonConverter() 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 [] 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 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 [] @@ -209,75 +228,62 @@ module Profile = /// Search profiles (logged-on users) let search (search : ProfileSearch) conn = - (seq 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 conn // Search profiles (public) let publicSearch (search : PublicSearch) conn = - (seq 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 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 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 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 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> { - 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 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 conn