diff --git a/src/JobsJobsJobs/Api/App.fs b/src/JobsJobsJobs/Api/App.fs index 9404005..e6c8e65 100644 --- a/src/JobsJobsJobs/Api/App.fs +++ b/src/JobsJobsJobs/Api/App.fs @@ -8,34 +8,12 @@ open Microsoft.Extensions.Hosting open Giraffe open Giraffe.EndpointRouting -/// All available routes for the application -let webApp = [ - subRoute "/api" [ - subRoute "/citizen" [ - GET_HEAD [ - routef "/log-on/%s" Handlers.Citizen.logOn - routef "/get/%O" Handlers.Citizen.get - ] - DELETE [ route "" Handlers.Citizen.delete ] - ] - GET_HEAD [ route "/continent/all" Handlers.Continent.all ] - subRoute "/profile" [ - GET_HEAD [ - route "" Handlers.Profile.current - route "/count" Handlers.Profile.count - routef "/get/%O" Handlers.Profile.get - ] - PATCH [ route "/employment-found" Handlers.Profile.employmentFound ] - POST [ route "/save" Handlers.Profile.save ] - ] - ] - ] /// Configure the ASP.NET Core pipeline to use Giraffe let configureApp (app : IApplicationBuilder) = app .UseRouting() - .UseEndpoints(fun e -> e.MapGiraffeEndpoints webApp) + .UseEndpoints(fun e -> e.MapGiraffeEndpoints Handlers.allEndpoints) |> ignore open NodaTime diff --git a/src/JobsJobsJobs/Api/Data.fs b/src/JobsJobsJobs/Api/Data.fs index e4fa42c..c3788bc 100644 --- a/src/JobsJobsJobs/Api/Data.fs +++ b/src/JobsJobsJobs/Api/Data.fs @@ -185,11 +185,15 @@ let withReconn (conn : IConnection) = (conn :?> Connection).Reconnect() | false -> ())) +open JobsJobsJobs.Domain.SharedTypes /// Profile data access functions [] module Profile = + open JobsJobsJobs.Domain + open RethinkDb.Driver.Ast + let count conn = withReconn(conn).ExecuteAsync(fun () -> r.Table(Table.Profile) @@ -227,6 +231,67 @@ module Profile = .RunWriteAsync conn () }) + + /// Search profiles (logged-on users) + let search (srch : ProfileSearch) conn = task { + let results = + seq { + match srch.continentId with + | Some conId -> + yield (fun (q : ReqlExpr) -> + q.Filter(r.HashMap(nameof srch.continentId, ContinentId.ofString conId)) :> ReqlExpr) + | None -> () + match srch.remoteWork with + | "" -> () + | _ -> yield (fun q -> q.Filter(r.HashMap(nameof srch.remoteWork, srch.remoteWork = "yes")) :> ReqlExpr) + match srch.skill with + | Some skl -> + yield (fun q -> q.Filter(ReqlFunction1(fun it -> + upcast it.G("skills.description").Downcase().Match(skl.ToLowerInvariant ()))) :> ReqlExpr) + | None -> () + match srch.bioExperience with + | Some text -> + let txt = text.ToLowerInvariant () + yield (fun q -> q.Filter(ReqlFunction1(fun it -> + upcast it.G("biography" ).Downcase().Match(txt) + .Or(it.G("experience").Downcase().Match(txt)))) :> ReqlExpr) + | None -> () + } + |> Seq.toList + |> List.fold (fun q f -> f q) (r.Table(Table.Profile) :> ReqlExpr) + // TODO: pluck fields, include display name + return! results.RunResultAsync conn + } + + // Search profiles (public) + let publicSearch (srch : PublicSearch) conn = task { + let results = + seq { + match srch.continentId with + | Some conId -> + yield (fun (q : ReqlExpr) -> + q.Filter(r.HashMap(nameof srch.continentId, ContinentId.ofString conId)) :> ReqlExpr) + | None -> () + match srch.region with + | Some reg -> + yield (fun q -> + q.Filter(ReqlFunction1(fun it -> + upcast it.G("region").Downcase().Match(reg.ToLowerInvariant ()))) :> ReqlExpr) + | None -> () + match srch.remoteWork with + | "" -> () + | _ -> yield (fun q -> q.Filter(r.HashMap(nameof srch.remoteWork, srch.remoteWork = "yes")) :> ReqlExpr) + match srch.skill with + | Some skl -> + yield (fun q -> q.Filter(ReqlFunction1(fun it -> + upcast it.G("skills.description").Downcase().Match(skl.ToLowerInvariant ()))) :> ReqlExpr) + | None -> () + } + |> Seq.toList + |> List.fold (fun q f -> f q) (r.Table(Table.Profile) :> ReqlExpr) + // TODO: pluck fields, compile skills + return! results.RunResultAsync conn + } /// Citizen data access functions @@ -315,6 +380,18 @@ module Continent = .RunResultAsync conn) +/// Job listing data access functions +[] +module Listing = + + /// Find all job listings posted by the given citizen + let findByCitizen (citizenId : CitizenId) conn = + withReconn(conn).ExecuteAsync(fun () -> + r.Table(Table.Listing) + .GetAll(citizenId).OptArg("index", nameof citizenId) + .RunResultAsync conn) + + /// Success story data access functions [] module Success = @@ -339,3 +416,10 @@ module Success = .RunWriteAsync conn) () } + + // Retrieve all success stories + let all conn = + // TODO: identify query and fields that will make StoryEntry meaningful + withReconn(conn).ExecuteAsync(fun () -> + r.Table(Table.Success) + .RunResultAsync conn) diff --git a/src/JobsJobsJobs/Api/Handlers.fs b/src/JobsJobsJobs/Api/Handlers.fs index 0124973..24eb545 100644 --- a/src/JobsJobsJobs/Api/Handlers.fs +++ b/src/JobsJobsJobs/Api/Handlers.fs @@ -86,6 +86,7 @@ module Helpers = /// Handlers for /api/citizen routes +[] module Citizen = // GET: /api/citizen/log-on/[code] @@ -161,6 +162,18 @@ module Continent = } +/// Handlers for /api/listing[s] routes +[] +module Listing = + + // GET: /api/listing/mine + let mine : HttpHandler = + authorize + >=> fun next ctx -> task { + let! listings = Data.Listing.findByCitizen (currentCitizenId ctx) (conn ctx) + return! json listings next ctx + } + /// Handlers for /api/profile routes [] module Profile = @@ -246,4 +259,118 @@ module Profile = do! Data.Profile.delete (currentCitizenId ctx) (conn ctx) return! ok next ctx } - \ No newline at end of file + + // GET: /api/profile/search + let search : HttpHandler = + authorize + >=> fun next ctx -> task { + let search = ctx.BindQueryString () + let! results = Data.Profile.search search (conn ctx) + return! json results next ctx + } + + // GET: /api/profile/public-search + let publicSearch : HttpHandler = + fun next ctx -> task { + let search = ctx.BindQueryString () + let! results = Data.Profile.publicSearch search (conn ctx) + return! json results next ctx + } + + +/// Handlers for /api/success routes +[] +module Success = + + open System + + // GET: /api/success/[id] + let get successId : HttpHandler = + authorize + >=> fun next ctx -> task { + match! Data.Success.findById (SuccessId successId) (conn ctx) with + | Some story -> return! json story next ctx + | None -> return! Error.notFound next ctx + } + + // GET: /api/success/list + let all : HttpHandler = + authorize + >=> fun next ctx -> task { + let! stories = Data.Success.all (conn ctx) + return! json stories next ctx + } + + // POST: /api/success/save + let save : HttpHandler = + authorize + >=> fun next ctx -> task { + let citizenId = currentCitizenId ctx + let dbConn = conn ctx + let now = (clock ctx).GetCurrentInstant () + let! form = ctx.BindJsonAsync () + let! success = task { + match form.id with + | "new" -> + return Some { id = (Guid.NewGuid >> SuccessId) () + citizenId = citizenId + recordedOn = now + fromHere = form.fromHere + source = "profile" + story = noneIfEmpty form.story |> Option.map Text + } + | successId -> + match! Data.Success.findById (SuccessId.ofString successId) dbConn with + | Some story when story.citizenId = citizenId -> + return Some { story with + fromHere = form.fromHere + story = noneIfEmpty form.story |> Option.map Text + } + | Some _ | None -> return None + } + match success with + | Some story -> + do! Data.Success.save story dbConn + return! ok next ctx + | None -> return! Error.notFound next ctx + } + + +open Giraffe.EndpointRouting + +/// All available endpoints for the application +let allEndpoints = [ + subRoute "/api" [ + subRoute "/citizen" [ + GET_HEAD [ + routef "/log-on/%s" Citizen.logOn + routef "/get/%O" Citizen.get + ] + DELETE [ route "" Citizen.delete ] + ] + GET_HEAD [ route "/continent/all" Continent.all ] + subRoute "/listing" [ + GET_HEAD [ + route "s/mine" Listing.mine + ] + ] + subRoute "/profile" [ + GET_HEAD [ + route "" Profile.current + route "/count" Profile.count + routef "/get/%O" Profile.get + route "/public-search" Profile.publicSearch + route "/search" Profile.search + ] + PATCH [ route "/employment-found" Profile.employmentFound ] + POST [ route "/save" Profile.save ] + ] + subRoute "/success" [ + GET_HEAD [ + routef "/get/%O" Success.get + route "/list" Success.all + ] + POST [ route "/save" Success.save ] + ] + ] + ] diff --git a/src/JobsJobsJobs/Domain/SharedTypes.fs b/src/JobsJobsJobs/Domain/SharedTypes.fs index 0505c9d..73bc95e 100644 --- a/src/JobsJobsJobs/Domain/SharedTypes.fs +++ b/src/JobsJobsJobs/Domain/SharedTypes.fs @@ -2,6 +2,7 @@ module JobsJobsJobs.Domain.SharedTypes open JobsJobsJobs.Domain.Types +open NodaTime // fsharplint:disable FieldNames @@ -102,3 +103,84 @@ module ProfileSearch = match search.remoteWork with "" -> Some search.remoteWork | _ -> None ] |> List.exists Option.isSome + + +/// A user matching the profile search +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 + lastUpdated : Instant + } + + +/// The parameters for a public job search +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 + } + +/// Support functions for pblic searches +module PublicSearch = + /// Is the search empty? + let isEmptySearch (srch : PublicSearch) = + [ srch.continentId + srch.skill + match srch.remoteWork with "" -> Some srch.remoteWork | _ -> None + ] + |> List.exists Option.isSome + + +/// A public profile search result +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 + } + + +/// The data required to provide a success story +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 + } + + +/// An entry in the list of success stories +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 + }