From 6896b0e60e981253147f9cd802f9d1b70dba6a12 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Fri, 26 Aug 2022 16:34:39 -0400 Subject: [PATCH] First cut of Marten data implementation --- build.fsx.lock | 159 ++-- .../Domain/JobsJobsJobs.Domain.fsproj | 4 +- src/JobsJobsJobs/Domain/SharedTypes.fs | 4 +- src/JobsJobsJobs/Domain/Types.fs | 46 ++ src/JobsJobsJobs/JobsJobsJobs.Data/Data.fs | 373 +++++++-- .../JobsJobsJobs.Data.fsproj | 4 +- src/JobsJobsJobs/JobsJobsJobs.Data/Json.fs | 29 +- src/JobsJobsJobs/Server/App.fs | 21 +- src/JobsJobsJobs/Server/Auth.fs | 1 - src/JobsJobsJobs/Server/Data.fs | 745 ------------------ src/JobsJobsJobs/Server/Handlers.fs | 196 ++--- .../Server/JobsJobsJobs.Server.fsproj | 10 +- 12 files changed, 551 insertions(+), 1041 deletions(-) delete mode 100644 src/JobsJobsJobs/Server/Data.fs diff --git a/build.fsx.lock b/build.fsx.lock index 3d3a148..36b916f 100644 --- a/build.fsx.lock +++ b/build.fsx.lock @@ -5,95 +5,95 @@ NUGET BlackFox.VsWhere (1.1) FSharp.Core (>= 4.2.3) Microsoft.Win32.Registry (>= 4.7) - Fake.Core.CommandLineParsing (5.22) + Fake.Core.CommandLineParsing (5.23) FParsec (>= 1.1.1) FSharp.Core (>= 6.0) - Fake.Core.Context (5.22) + Fake.Core.Context (5.23) FSharp.Core (>= 6.0) - Fake.Core.Environment (5.22) + Fake.Core.Environment (5.23) FSharp.Core (>= 6.0) - Fake.Core.FakeVar (5.22) - Fake.Core.Context (>= 5.22) + Fake.Core.FakeVar (5.23) + Fake.Core.Context (>= 5.23) FSharp.Core (>= 6.0) - Fake.Core.Process (5.22) - Fake.Core.Environment (>= 5.22) - Fake.Core.FakeVar (>= 5.22) - Fake.Core.String (>= 5.22) - Fake.Core.Trace (>= 5.22) - Fake.IO.FileSystem (>= 5.22) + Fake.Core.Process (5.23) + Fake.Core.Environment (>= 5.23) + Fake.Core.FakeVar (>= 5.23) + Fake.Core.String (>= 5.23) + Fake.Core.Trace (>= 5.23) + Fake.IO.FileSystem (>= 5.23) FSharp.Core (>= 6.0) System.Collections.Immutable (>= 5.0) - Fake.Core.SemVer (5.22) + Fake.Core.SemVer (5.23) FSharp.Core (>= 6.0) - Fake.Core.String (5.22) + Fake.Core.String (5.23) FSharp.Core (>= 6.0) - Fake.Core.Target (5.22) - Fake.Core.CommandLineParsing (>= 5.22) - Fake.Core.Context (>= 5.22) - Fake.Core.Environment (>= 5.22) - Fake.Core.FakeVar (>= 5.22) - Fake.Core.Process (>= 5.22) - Fake.Core.String (>= 5.22) - Fake.Core.Trace (>= 5.22) + Fake.Core.Target (5.23) + Fake.Core.CommandLineParsing (>= 5.23) + Fake.Core.Context (>= 5.23) + Fake.Core.Environment (>= 5.23) + Fake.Core.FakeVar (>= 5.23) + Fake.Core.Process (>= 5.23) + Fake.Core.String (>= 5.23) + Fake.Core.Trace (>= 5.23) FSharp.Control.Reactive (>= 5.0.2) FSharp.Core (>= 6.0) - Fake.Core.Tasks (5.22) - Fake.Core.Trace (>= 5.22) + Fake.Core.Tasks (5.23) + Fake.Core.Trace (>= 5.23) FSharp.Core (>= 6.0) - Fake.Core.Trace (5.22) - Fake.Core.Environment (>= 5.22) - Fake.Core.FakeVar (>= 5.22) + Fake.Core.Trace (5.23) + Fake.Core.Environment (>= 5.23) + Fake.Core.FakeVar (>= 5.23) FSharp.Core (>= 6.0) - Fake.Core.Xml (5.22) - Fake.Core.String (>= 5.22) + Fake.Core.Xml (5.23) + Fake.Core.String (>= 5.23) FSharp.Core (>= 6.0) - Fake.DotNet.Cli (5.22) - Fake.Core.Environment (>= 5.22) - Fake.Core.Process (>= 5.22) - Fake.Core.String (>= 5.22) - Fake.Core.Trace (>= 5.22) - Fake.DotNet.MSBuild (>= 5.22) - Fake.DotNet.NuGet (>= 5.22) - Fake.IO.FileSystem (>= 5.22) + Fake.DotNet.Cli (5.23) + Fake.Core.Environment (>= 5.23) + Fake.Core.Process (>= 5.23) + Fake.Core.String (>= 5.23) + Fake.Core.Trace (>= 5.23) + Fake.DotNet.MSBuild (>= 5.23) + Fake.DotNet.NuGet (>= 5.23) + Fake.IO.FileSystem (>= 5.23) FSharp.Core (>= 6.0) Mono.Posix.NETStandard (>= 1.0) Newtonsoft.Json (>= 13.0.1) - Fake.DotNet.MSBuild (5.22) + Fake.DotNet.MSBuild (5.23) BlackFox.VsWhere (>= 1.1) - Fake.Core.Environment (>= 5.22) - Fake.Core.Process (>= 5.22) - Fake.Core.String (>= 5.22) - Fake.Core.Trace (>= 5.22) - Fake.IO.FileSystem (>= 5.22) + Fake.Core.Environment (>= 5.23) + Fake.Core.Process (>= 5.23) + Fake.Core.String (>= 5.23) + Fake.Core.Trace (>= 5.23) + Fake.IO.FileSystem (>= 5.23) FSharp.Core (>= 6.0) MSBuild.StructuredLogger (>= 2.1.545) - Fake.DotNet.NuGet (5.22) - Fake.Core.Environment (>= 5.22) - Fake.Core.Process (>= 5.22) - Fake.Core.SemVer (>= 5.22) - Fake.Core.String (>= 5.22) - Fake.Core.Tasks (>= 5.22) - Fake.Core.Trace (>= 5.22) - Fake.Core.Xml (>= 5.22) - Fake.IO.FileSystem (>= 5.22) - Fake.Net.Http (>= 5.22) + Fake.DotNet.NuGet (5.23) + Fake.Core.Environment (>= 5.23) + Fake.Core.Process (>= 5.23) + Fake.Core.SemVer (>= 5.23) + Fake.Core.String (>= 5.23) + Fake.Core.Tasks (>= 5.23) + Fake.Core.Trace (>= 5.23) + Fake.Core.Xml (>= 5.23) + Fake.IO.FileSystem (>= 5.23) + Fake.Net.Http (>= 5.23) FSharp.Core (>= 6.0) Newtonsoft.Json (>= 13.0.1) NuGet.Protocol (>= 5.11) - Fake.IO.FileSystem (5.22) - Fake.Core.String (>= 5.22) + Fake.IO.FileSystem (5.23) + Fake.Core.String (>= 5.23) FSharp.Core (>= 6.0) - Fake.JavaScript.Npm (5.22) - Fake.Core.Environment (>= 5.22) - Fake.Core.Process (>= 5.22) - Fake.IO.FileSystem (>= 5.22) - Fake.Testing.Common (>= 5.22) + Fake.JavaScript.Npm (5.23) + Fake.Core.Environment (>= 5.23) + Fake.Core.Process (>= 5.23) + Fake.IO.FileSystem (>= 5.23) + Fake.Testing.Common (>= 5.23) FSharp.Core (>= 6.0) - Fake.Net.Http (5.22) - Fake.Core.Trace (>= 5.22) + Fake.Net.Http (5.23) + Fake.Core.Trace (>= 5.23) FSharp.Core (>= 6.0) - Fake.Testing.Common (5.22) - Fake.Core.Trace (>= 5.22) + Fake.Testing.Common (5.23) + Fake.Core.Trace (>= 5.23) FSharp.Core (>= 6.0) FParsec (1.1.1) FSharp.Core (>= 4.3.4) @@ -112,9 +112,8 @@ NUGET System.Text.Encoding.CodePages (>= 4.0.1) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net6.0)) System.Text.Json (>= 6.0) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net472)) (&& (== netstandard2.0) (>= net6.0)) System.Threading.Tasks.Dataflow (>= 6.0) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net472)) (&& (== netstandard2.0) (>= net6.0)) - Microsoft.Build.Framework (17.2) - Microsoft.Win32.Registry (>= 4.3) - System.Security.Permissions (>= 4.7) + Microsoft.Build.Framework (17.3.1) + System.Security.Permissions (>= 6.0) Microsoft.Build.Tasks.Core (17.2) Microsoft.Build.Framework (>= 17.2) Microsoft.Build.Utilities.Core (>= 17.2) @@ -139,7 +138,7 @@ NUGET Microsoft.NET.StringTools (1.0) System.Memory (>= 4.5.4) System.Runtime.CompilerServices.Unsafe (>= 5.0) - Microsoft.NETCore.Platforms (6.0.4) - restriction: || (&& (== net6.0) (< netcoreapp3.1)) (&& (== net6.0) (< netstandard1.2)) (&& (== net6.0) (< netstandard1.3)) (&& (== net6.0) (< netstandard1.5)) (== netstandard2.0) + Microsoft.NETCore.Platforms (6.0.5) - restriction: || (&& (== net6.0) (< netcoreapp3.1)) (&& (== net6.0) (< netstandard1.2)) (&& (== net6.0) (< netstandard1.3)) (&& (== net6.0) (< netstandard1.5)) (== netstandard2.0) Microsoft.NETCore.Targets (5.0) - restriction: || (&& (== net6.0) (< netcoreapp3.1)) (&& (== net6.0) (< netstandard1.2)) (&& (== net6.0) (< netstandard1.3)) (&& (== net6.0) (< netstandard1.5)) (== netstandard2.0) Microsoft.Win32.Registry (5.0) System.Buffers (>= 4.5.1) - restriction: || (&& (== net6.0) (>= monoandroid) (< netstandard1.3)) (&& (== net6.0) (>= monotouch)) (&& (== net6.0) (< netcoreapp2.0)) (&& (== net6.0) (>= xamarinios)) (&& (== net6.0) (>= xamarinmac)) (&& (== net6.0) (>= xamarintvos)) (&& (== net6.0) (>= xamarinwatchos)) (== netstandard2.0) @@ -154,21 +153,21 @@ NUGET Microsoft.Build.Tasks.Core (>= 16.10) Microsoft.Build.Utilities.Core (>= 16.10) Newtonsoft.Json (13.0.1) - NuGet.Common (6.2.1) - NuGet.Frameworks (>= 6.2.1) - NuGet.Configuration (6.2.1) - NuGet.Common (>= 6.2.1) + NuGet.Common (6.3) + NuGet.Frameworks (>= 6.3) + NuGet.Configuration (6.3) + NuGet.Common (>= 6.3) System.Security.Cryptography.ProtectedData (>= 4.4) - NuGet.Frameworks (6.2.1) - NuGet.Packaging (6.2.1) + NuGet.Frameworks (6.3) + NuGet.Packaging (6.3) Newtonsoft.Json (>= 13.0.1) - NuGet.Configuration (>= 6.2.1) - NuGet.Versioning (>= 6.2.1) + NuGet.Configuration (>= 6.3) + NuGet.Versioning (>= 6.3) System.Security.Cryptography.Cng (>= 5.0) System.Security.Cryptography.Pkcs (>= 5.0) - NuGet.Protocol (6.2.1) - NuGet.Packaging (>= 6.2.1) - NuGet.Versioning (6.2.1) + NuGet.Protocol (6.3) + NuGet.Packaging (>= 6.3) + NuGet.Versioning (6.3) System.Buffers (4.5.1) - restriction: || (&& (== net6.0) (>= monoandroid) (< netstandard1.3)) (&& (== net6.0) (>= monotouch)) (&& (== net6.0) (< netcoreapp2.0)) (&& (== net6.0) (>= xamarinios)) (&& (== net6.0) (>= xamarinmac)) (&& (== net6.0) (>= xamarintvos)) (&& (== net6.0) (>= xamarinwatchos)) (== netstandard2.0) System.CodeDom (6.0) System.Collections.Immutable (6.0) @@ -210,10 +209,10 @@ NUGET System.Memory (>= 4.5.4) - restriction: || (&& (== net6.0) (< netstandard2.1)) (== netstandard2.0) System.Security.Cryptography.Cng (>= 5.0) - restriction: || (&& (== net6.0) (< netcoreapp3.1)) (&& (== net6.0) (< netstandard2.1)) (== netstandard2.0) System.Security.Cryptography.ProtectedData (6.0) - System.Security.Cryptography.Xml (6.0) + System.Security.Cryptography.Xml (6.0.1) System.Memory (>= 4.5.4) - restriction: == netstandard2.0 System.Security.AccessControl (>= 6.0) - System.Security.Cryptography.Pkcs (>= 6.0) + System.Security.Cryptography.Pkcs (>= 6.0.1) System.Security.Permissions (6.0) System.Security.AccessControl (>= 6.0) System.Windows.Extensions (>= 6.0) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= netcoreapp3.1)) diff --git a/src/JobsJobsJobs/Domain/JobsJobsJobs.Domain.fsproj b/src/JobsJobsJobs/Domain/JobsJobsJobs.Domain.fsproj index ee4d625..6c6db14 100644 --- a/src/JobsJobsJobs/Domain/JobsJobsJobs.Domain.fsproj +++ b/src/JobsJobsJobs/Domain/JobsJobsJobs.Domain.fsproj @@ -13,9 +13,9 @@ - + - + diff --git a/src/JobsJobsJobs/Domain/SharedTypes.fs b/src/JobsJobsJobs/Domain/SharedTypes.fs index d52a101..649f7bf 100644 --- a/src/JobsJobsJobs/Domain/SharedTypes.fs +++ b/src/JobsJobsJobs/Domain/SharedTypes.fs @@ -1,7 +1,7 @@ /// Types intended to be shared between the API and the client application module JobsJobsJobs.Domain.SharedTypes -open JobsJobsJobs.Domain.Types +open JobsJobsJobs.Domain open Microsoft.Extensions.Options open NodaTime @@ -202,7 +202,7 @@ type ProfileForm = module ProfileForm = /// Create an instance of this form from the given profile - let fromProfile (profile : Types.Profile) = + let fromProfile (profile : Profile) = { isSeekingEmployment = profile.seekingEmployment isPublic = profile.isPublic realName = "" diff --git a/src/JobsJobsJobs/Domain/Types.fs b/src/JobsJobsJobs/Domain/Types.fs index c1deb1b..a24354a 100644 --- a/src/JobsJobsJobs/Domain/Types.fs +++ b/src/JobsJobsJobs/Domain/Types.fs @@ -45,6 +45,19 @@ with /// Support functions for citizens module Citizen = + /// An empty citizen + let empty = + { id = CitizenId Guid.Empty + joinedOn = Instant.MinValue + lastSeenOn = Instant.MinValue + email = "" + firstName = "" + lastName = "" + passwordHash = "" + displayName = None + otherContacts = [] + isLegacy = false + } /// 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}" @@ -116,6 +129,26 @@ type Listing = isLegacy : bool } +/// Support functions for job listings +module Listing = + + /// An empty job listing + let empty = + { id = ListingId Guid.Empty + citizenId = CitizenId Guid.Empty + createdOn = Instant.MinValue + title = "" + continentId = ContinentId Guid.Empty + region = "" + remoteWork = false + isExpired = false + updatedOn = Instant.MinValue + text = Text "" + neededBy = None + wasFilledHere = None + isLegacy = false + } + /// Security settings for a user type SecurityInfo = @@ -253,3 +286,16 @@ type Success = /// The success story story : MarkdownString option } + +/// Support functions for success stories +module Success = + + /// An empty success story + let empty = + { id = SuccessId Guid.Empty + citizenId = CitizenId Guid.Empty + recordedOn = Instant.MinValue + fromHere = false + source = "" + story = None + } diff --git a/src/JobsJobsJobs/JobsJobsJobs.Data/Data.fs b/src/JobsJobsJobs/JobsJobsJobs.Data/Data.fs index 69cb3c0..bcd8bba 100644 --- a/src/JobsJobsJobs/JobsJobsJobs.Data/Data.fs +++ b/src/JobsJobsJobs/JobsJobsJobs.Data/Data.fs @@ -1,13 +1,81 @@ namespace JobsJobsJobs.Data +open System open JobsJobsJobs.Domain + +/// Wrapper documents for our record types +module Documents = + + /// A generic type that keeps its ID in sync with the ID value for its content + [] + type Document<'T> (initialValue : 'T, toId : 'T -> Guid) = + + /// The current value for this document + let mutable value = initialValue + + /// The ID for this document + member val Id = toId initialValue with get, set + + /// The value for this document + member this.Value + with get () = value + and set (v : 'T) = + value <- v + this.Id <- toId v + + /// Convert a document to its value + static member ToValue (doc : Document<'T>) = + doc.Value + + /// Convert a document to its value, or None if the document is null + static member TryValue (doc : Document<'T>) = + if isNull doc then None else Some doc.Value + + /// A citizen document + [] + type CitizenDocument (citizen : Citizen) = + inherit Document (citizen, fun c -> CitizenId.value c.id) + new() = CitizenDocument Citizen.empty + + /// A continent document + [] + type ContinentDocument (continent : Continent) = + inherit Document (continent, fun c -> ContinentId.value c.id) + new () = ContinentDocument Continent.empty + + /// A job listing document + [] + type ListingDocument (listing : Listing) = + inherit Document (listing, fun l -> ListingId.value l.id) + new () = ListingDocument Listing.empty + + /// A profile document + [] + type ProfileDocument (profile : Profile) = + inherit Document (profile, fun p -> CitizenId.value p.id) + new () = ProfileDocument Profile.empty + + /// A security information document + [] + type SecurityInfoDocument (securityInfo : SecurityInfo) = + inherit Document (securityInfo, fun si -> CitizenId.value si.Id) + new () = SecurityInfoDocument SecurityInfo.empty + + /// A success story document + [] + type SuccessDocument (success : Success) = + inherit Document (success, fun s -> SuccessId.value s.id) + new () = SuccessDocument Success.empty + + +open Documents open Marten -open Marten.PLv8 -open Microsoft.Extensions.Configuration /// Connection management for the Marten document store module Connection = + open Marten.NodaTime + open Microsoft.Extensions.Configuration open Weasel.Core /// The configuration from which a document store will be created @@ -21,14 +89,19 @@ module Connection = DocumentStore.For(fun opts -> opts.Connection (cfg.GetConnectionString "PostgreSQL") opts.RegisterDocumentTypes [ - typeof; typeof; typeof; typeof; typeof - typeof + typeof; typeof; typeof + typeof; typeof; typeof ] + opts.DatabaseSchemaName <- "jjj" opts.AutoCreateSchemaObjects <- AutoCreate.CreateOrUpdate - opts.UseJavascriptTransformsAndPatching () + opts.UseNodaTime () - let _ = opts.Schema.For().Identity (fun c -> c.DbId) - let _ = opts.Schema.For().Identity (fun si -> si.DbId) + let _ = opts.Schema.For().DocumentAlias "citizen" + let _ = opts.Schema.For().DocumentAlias "continent" + let _ = opts.Schema.For().DocumentAlias "listing" + let _ = opts.Schema.For().DocumentAlias "profile" + let _ = opts.Schema.For().DocumentAlias "security_info" + let _ = opts.Schema.For().DocumentAlias "success" ()) do! store.Storage.ApplyAllConfiguredChangesToDatabaseAsync () return Ok store @@ -38,7 +111,7 @@ module Connection = /// Set up the data connection from the given configuration let setUp (cfg : IConfiguration) = config <- Some cfg - ignore (lazyStore.Force ()) + lazyStore.Force () /// A read-only document session let querySession () = @@ -53,22 +126,12 @@ module Connection = | Error msg -> raise (invalidOp msg) -/// Helper functions for data retrieval -[] -module private Helpers = - - open System.Threading - - /// Convert a possibly-null record type to an option - let optional<'T> (value : 'T) = if isNull (box value) then None else Some value - - /// Shorthand for no cancellation token - let noCnx = CancellationToken.None +/// Shorthand for the generic dictionary +type Dict<'TKey, 'TValue> = System.Collections.Generic.Dictionary<'TKey, 'TValue> open System.Linq open Connection -open Marten.PLv8.Patching /// Citizen data access functions [] @@ -77,16 +140,21 @@ module Citizens = /// Delete a citizen by their ID let deleteById citizenId = backgroundTask { use session = docSession () - session.Delete (CitizenId.value citizenId) + session.DeleteWhere(fun s -> s.Value.citizenId = citizenId) + session.DeleteWhere(fun l -> l.Value.citizenId = citizenId) + let docId = CitizenId.value citizenId + session.Delete docId + session.Delete docId + session.Delete docId do! session.SaveChangesAsync () } /// Find a citizen by their ID let findById citizenId = backgroundTask { use session = querySession () - let! citizen = session.LoadAsync (CitizenId.value citizenId) + let! citizen = session.LoadAsync (CitizenId.value citizenId) return - match optional citizen with + match Document.TryValue citizen with | Some c when not c.isLegacy -> Some c | Some _ | None -> None @@ -95,7 +163,7 @@ module Citizens = /// Save a citizen let save (citizen : Citizen) = backgroundTask { use session = docSession () - session.Store citizen + session.Store (CitizenDocument citizen) do! session.SaveChangesAsync () } @@ -103,29 +171,34 @@ module Citizens = let tryLogOn email (pwCheck : string -> bool) now = backgroundTask { use session = docSession () let! tryCitizen = - session.Query().Where(fun c -> c.email = email && not c.isLegacy).SingleOrDefaultAsync () - match optional tryCitizen with + session.Query() + .Where(fun c -> c.Value.email = email && not c.Value.isLegacy) + .SingleOrDefaultAsync () + match Document.TryValue tryCitizen with | Some citizen -> - let! tryInfo = session.LoadAsync citizen.DbId + let! tryInfo = session.LoadAsync (CitizenId.value citizen.id) let! info = backgroundTask { - match optional tryInfo with + match Document.TryValue tryInfo with | Some it -> return it | None -> let it = { SecurityInfo.empty with Id = citizen.id } - session.Store it + session.Store (SecurityInfoDocument it) do! session.SaveChangesAsync () return it } if info.AccountLocked then return Error "Log on unsuccessful (Account Locked)" elif pwCheck citizen.passwordHash then - session.Patch(citizen.DbId).Set((fun si -> si.FailedLogOnAttempts), 0) - session.Patch(citizen.DbId).Set((fun c -> c.lastSeenOn), now) + session.Store (SecurityInfoDocument { info with FailedLogOnAttempts = 0}) + session.Store (CitizenDocument { citizen with lastSeenOn = now}) do! session.SaveChangesAsync () return Ok { citizen with lastSeenOn = now } else let locked = info.FailedLogOnAttempts >= 4 - session.Patch(citizen.DbId).Increment(fun si -> si.FailedLogOnAttempts) - if locked then session.Patch(citizen.DbId).Set((fun si -> si.AccountLocked), true) + session.Store (SecurityInfoDocument { + info with + FailedLogOnAttempts = info.FailedLogOnAttempts + 1 + AccountLocked = locked + }) do! session.SaveChangesAsync () return Error $"""Log on unsuccessful{if locked then " - Account is now locked" else ""}""" | None -> return Error "Log on unsuccessful" @@ -139,47 +212,47 @@ module Continents = /// Retrieve all continents let all () = backgroundTask { use session = querySession () - let! it = session.Query().ToListAsync noCnx - return List.ofSeq it + let! it = session.Query().AsQueryable().ToListAsync () + return it |> Seq.map Document.ToValue |> List.ofSeq } /// Retrieve a continent by its ID let findById continentId = backgroundTask { use session = querySession () - let! tryContinent = session.LoadAsync (ContinentId.value continentId) - return optional tryContinent + let! tryContinent = session.LoadAsync (ContinentId.value continentId) + return Document.TryValue tryContinent } -open System open JobsJobsJobs.Domain.SharedTypes /// Job listing access functions [] module Listings = - open System.Collections.Generic - /// Find all job listings posted by the given citizen let findByCitizen citizenId = backgroundTask { use session = querySession () - let continents = Dictionary () + let continents = Dict () let! listings = - session.Query() - .Include((fun l -> l.continentId :> obj), continents) - .Where(fun l -> l.citizenId = citizenId && not l.isLegacy) + session.Query() + .Include((fun l -> l.Value.continentId :> obj), continents) + .Where(fun l -> l.Value.citizenId = citizenId && not l.Value.isLegacy) .ToListAsync () return listings - |> Seq.map (fun l -> { listing = l; continent = continents[l.continentId] }) + |> Seq.map (fun l -> { + listing = l.Value + continent = continents[ContinentId.value l.Value.continentId].Value + }) |> List.ofSeq } /// Find a listing by its ID let findById listingId = backgroundTask { use session = querySession () - let! tryListing = session.LoadAsync (ListingId.value listingId) - match optional tryListing with + let! tryListing = session.LoadAsync (ListingId.value listingId) + match Document.TryValue tryListing with | Some listing when not listing.isLegacy -> return Some listing | Some _ | None -> return None @@ -188,52 +261,194 @@ module Listings = /// Find a listing by its ID for viewing (includes continent information) let findByIdForView listingId = backgroundTask { use session = querySession () - let mutable continent = Continent.empty + let mutable continent : ContinentDocument = null let! tryListing = - session.Query() - .Include((fun l -> l.continentId :> obj), fun c -> continent <- c) - .Where(fun l -> l.id = listingId && not l.isLegacy) + session.Query() + .Include((fun l -> l.Value.continentId :> obj), fun c -> continent <- c) + .Where(fun l -> l.Id = ListingId.value listingId && not l.Value.isLegacy) .SingleOrDefaultAsync () - match optional tryListing with - | Some listing -> return Some { listing = listing; continent = continent } + match Document.TryValue tryListing with + | Some listing when not (isNull continent) -> return Some { listing = listing; continent = continent.Value } + | Some _ | None -> return None } /// Save a listing let save (listing : Listing) = backgroundTask { use session = docSession () - session.Store listing + session.Store (ListingDocument listing) do! session.SaveChangesAsync () } /// Search job listings let search (search : ListingSearch) = backgroundTask { use session = querySession () - let continents = Dictionary () + let continents = Dict () let searchQuery = - seq bool> { + seq bool> { match search.continentId with | Some contId -> - fun (l : Listing) -> l.continentId = (ContinentId.ofString contId) + fun (l : ListingDocument) -> l.Value.continentId = (ContinentId.ofString contId) | None -> () match search.region with - | Some region -> fun (l : Listing) -> l.region.Contains (region, StringComparison.OrdinalIgnoreCase) + | Some region -> + fun (l : ListingDocument) -> l.Value.region.Contains (region, StringComparison.OrdinalIgnoreCase) | None -> () if search.remoteWork <> "" then - fun (l : Listing) -> l.remoteWork = (search.remoteWork = "yes") + fun (l : ListingDocument) -> l.Value.remoteWork = (search.remoteWork = "yes") // match search.text with // | Some text -> fun (l : Listing) -> l.text.Contains (text, StringComparison.OrdinalIgnoreCase) // | None -> () } |> Seq.fold (fun q filter -> Queryable.Where(q, filter)) - (session.Query() - .Include((fun l -> l.continentId :> obj), continents) - .Where(fun l -> not l.isExpired && not l.isLegacy)) + (session.Query() + .Include((fun l -> l.Value.continentId :> obj), continents) + .Where(fun l -> not l.Value.isExpired && not l.Value.isLegacy)) let! results = searchQuery.ToListAsync () return results - |> Seq.map (fun l -> { listing = l; continent = continents[l.continentId] }) + |> Seq.map (fun l -> { + listing = l.Value + continent = continents[ContinentId.value l.Value.continentId].Value + }) + |> List.ofSeq + } + + +/// Profile data access functions +[] +module Profiles = + + /// Count the current profiles + let count () = + use session = querySession () + session.Query().Where(fun p -> not p.Value.isLegacy).LongCountAsync () + + /// Delete a profile by its ID + let deleteById citizenId = backgroundTask { + use session = docSession () + session.Delete (CitizenId.value citizenId) + do! session.SaveChangesAsync () + } + /// Find a profile by citizen ID + let findById citizenId = backgroundTask { + use session = querySession () + let! profile = session.LoadAsync (CitizenId.value citizenId) + return + match Document.TryValue profile with + | Some p when not p.isLegacy -> Some p + | Some _ + | None -> None + } + + /// Find a profile by citizen ID for viewing (includes citizen and continent information) + let findByIdForView citizenId = backgroundTask { + use session = querySession () + let mutable citizen : CitizenDocument = null + let mutable continent : ContinentDocument = null + let! tryProfile = + session.Query() + .Include((fun p -> p.Id :> obj), fun c -> citizen <- c) + .Include((fun p -> p.Value.continentId :> obj), fun c -> continent <- c) + .Where(fun p -> p.Id = CitizenId.value citizenId && not p.Value.isLegacy) + .SingleOrDefaultAsync () + match Document.TryValue tryProfile with + | Some profile when not (isNull citizen) && not (isNull continent) -> + return Some { profile = profile; citizen = citizen.Value; continent = continent.Value } + | Some _ + | None -> return None + } + + /// Save a profile + let save (profile : Profile) = backgroundTask { + use session = docSession () + session.Store (ProfileDocument profile) + do! session.SaveChangesAsync () + } + + /// Search profiles (logged-on users) + let search (search : ProfileSearch) = backgroundTask { + use session = querySession () + let citizens = Dict () + let searchQuery = + seq bool> { + match search.continentId with + | Some contId -> fun (p : ProfileDocument) -> p.Value.continentId = ContinentId.ofString contId + | None -> () + if search.remoteWork <> "" then + fun (p : ProfileDocument) -> p.Value.remoteWork = (search.remoteWork = "yes") + match search.skill with + | Some skl -> + fun (p : ProfileDocument) -> + p.Value.skills.Any(fun s -> s.description.Contains (skl, StringComparison.OrdinalIgnoreCase)) + | 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 -> () + } + |> Seq.fold + (fun q filter -> Queryable.Where(q, filter)) + (session.Query() + .Include((fun p -> p.Id :> obj), citizens) + .Where(fun p -> not p.Value.isLegacy)) + let! results = searchQuery.ToListAsync () + return + results + |> Seq.map (fun profileDoc -> + let p = profileDoc.Value + { citizenId = p.id + displayName = Citizen.name citizens[CitizenId.value p.id].Value + seekingEmployment = p.seekingEmployment + remoteWork = p.remoteWork + fullTime = p.fullTime + lastUpdatedOn = p.lastUpdatedOn + }) + |> Seq.sortBy (fun psr -> psr.displayName.ToLowerInvariant ()) + |> List.ofSeq + } + + // Search profiles (public) + let publicSearch (search : PublicSearch) = backgroundTask { + use session = querySession () + let continents = Dict () + let searchQuery = + seq bool> { + match search.continentId with + | Some contId -> fun (p : ProfileDocument) -> p.Value.continentId = ContinentId.ofString contId + | None -> () + match search.region with + | Some region -> + fun (p : ProfileDocument) -> p.Value.region.Contains (region, StringComparison.OrdinalIgnoreCase) + | None -> () + if search.remoteWork <> "" then + fun (p : ProfileDocument) -> p.Value.remoteWork = (search.remoteWork = "yes") + match search.skill with + | Some skl -> + fun (p : ProfileDocument) -> + p.Value.skills.Any(fun s -> s.description.Contains (skl, StringComparison.OrdinalIgnoreCase)) + | None -> () + } + |> Seq.fold + (fun q filter -> Queryable.Where(q, filter)) + (session.Query() + .Include((fun p -> p.Value.continentId :> obj), continents) + .Where(fun p -> p.Value.isPublic && not p.Value.isLegacy)) + let! results = searchQuery.ToListAsync () + return + results + |> Seq.map (fun profileDoc -> + let p = profileDoc.Value + { continent = continents[ContinentId.value p.continentId].Value.name + region = p.region + remoteWork = p.remoteWork + skills = p.skills + |> List.map (fun s -> + let notes = match s.notes with Some n -> $" ({n})" | None -> "" + $"{s.description}{notes}") + }) |> List.ofSeq } @@ -241,10 +456,40 @@ module Listings = [] module Successes = + // Retrieve all success stories + let all () = backgroundTask { + use session = querySession () + let citizens = Dict () + let! stories = + session.Query() + .Include((fun s -> s.Value.citizenId :> obj), citizens) + .OrderByDescending(fun s -> s.Value.recordedOn) + .ToListAsync () + return + stories + |> Seq.map (fun storyDoc -> + let s = storyDoc.Value + { id = s.id + citizenId = s.citizenId + citizenName = Citizen.name citizens[CitizenId.value s.citizenId].Value + recordedOn = s.recordedOn + fromHere = s.fromHere + hasStory = Option.isSome s.story + }) + |> List.ofSeq + } + + /// Find a success story by its ID + let findById successId = backgroundTask { + use session = querySession () + let! success = session.LoadAsync (SuccessId.value successId) + return Document.TryValue success + } + /// Save a success story let save (success : Success) = backgroundTask { use session = docSession () - session.Store success + session.Store (SuccessDocument success) do! session.SaveChangesAsync () } \ No newline at end of file diff --git a/src/JobsJobsJobs/JobsJobsJobs.Data/JobsJobsJobs.Data.fsproj b/src/JobsJobsJobs/JobsJobsJobs.Data/JobsJobsJobs.Data.fsproj index c7191ec..b0f2602 100644 --- a/src/JobsJobsJobs/JobsJobsJobs.Data/JobsJobsJobs.Data.fsproj +++ b/src/JobsJobsJobs/JobsJobsJobs.Data/JobsJobsJobs.Data.fsproj @@ -18,7 +18,9 @@ - + + + diff --git a/src/JobsJobsJobs/JobsJobsJobs.Data/Json.fs b/src/JobsJobsJobs/JobsJobsJobs.Data/Json.fs index de761d0..4f83795 100644 --- a/src/JobsJobsJobs/JobsJobsJobs.Data/Json.fs +++ b/src/JobsJobsJobs/JobsJobsJobs.Data/Json.fs @@ -5,19 +5,32 @@ open System.Text.Json open System.Text.Json.Serialization open JobsJobsJobs.Domain -/// Convert citizen IDs to their string-GUID representation -type CitizenIdJsonConverter () = - inherit JsonConverter () - override this.Read(reader, _, _) = - CitizenId (Guid.Parse (reader.GetString ())) - override this.Write(writer, value, _) = - writer.WriteStringValue ((CitizenId.value value).ToString ()) +/// Convert a wrapped GUID to/from its string representation +type WrappedJsonConverter<'T> (wrap : string -> 'T, unwrap : 'T -> string) = + inherit JsonConverter<'T> () + override _.Read(reader, _, _) = + wrap (reader.GetString ()) + override _.Write(writer, value, _) = + writer.WriteStringValue (unwrap value) + +/// Convert a wrapped GUID to/from its string representation +type WrappedIdJsonConverter<'T> (wrap : Guid -> 'T, unwrap : 'T -> Guid) = + inherit JsonConverter<'T> () + override _.Read(reader, _, _) = + wrap (Guid.Parse (reader.GetString ())) + override _.Write(writer, value, _) = + writer.WriteStringValue ((unwrap value).ToString ()) /// JsonSerializer options that use the custom converters let options = let opts = JsonSerializerOptions () - [ CitizenIdJsonConverter () :> JsonConverter + [ WrappedIdJsonConverter (CitizenId, CitizenId.value) :> JsonConverter + WrappedIdJsonConverter (ContinentId, ContinentId.value) + WrappedIdJsonConverter (ListingId, ListingId.value) + WrappedJsonConverter (Text, MarkdownString.toString) + WrappedIdJsonConverter (SkillId, SkillId.value) + WrappedIdJsonConverter (SuccessId, SuccessId.value) JsonFSharpConverter () ] |> List.iter opts.Converters.Add diff --git a/src/JobsJobsJobs/Server/App.fs b/src/JobsJobsJobs/Server/App.fs index fc88566..e4d213e 100644 --- a/src/JobsJobsJobs/Server/App.fs +++ b/src/JobsJobsJobs/Server/App.fs @@ -8,7 +8,6 @@ open Microsoft.Extensions.Hosting open Giraffe open Giraffe.EndpointRouting - /// Configure the ASP.NET Core pipeline to use Giraffe let configureApp (app : IApplicationBuilder) = app.UseCors(fun p -> p.AllowAnyOrigin().AllowAnyHeader() |> ignore) @@ -22,13 +21,11 @@ let configureApp (app : IApplicationBuilder) = e.MapFallbackToFile "index.html" |> ignore) |> ignore -open Newtonsoft.Json -open NodaTime +open System.Text open Microsoft.AspNetCore.Authentication.JwtBearer open Microsoft.Extensions.Configuration -open Microsoft.Extensions.Logging open Microsoft.IdentityModel.Tokens -open System.Text +open NodaTime open JobsJobsJobs.Data open JobsJobsJobs.Domain.SharedTypes @@ -39,9 +36,7 @@ let configureServices (svc : IServiceCollection) = let _ = svc.AddLogging () let _ = svc.AddCors () - let jsonCfg = JsonSerializerSettings () - Data.Converters.all () |> List.iter jsonCfg.Converters.Add - let _ = svc.AddSingleton (NewtonsoftJson.Serializer jsonCfg) + let _ = svc.AddSingleton (SystemTextJson.Serializer Json.options) let svcs = svc.BuildServiceProvider () let cfg = svcs.GetRequiredService () @@ -64,13 +59,11 @@ let configureServices (svc : IServiceCollection) = let _ = svc.AddAuthorization () let _ = svc.Configure (cfg.GetSection "Auth") - let dbCfg = cfg.GetSection "Rethink" - let log = svcs.GetRequiredService().CreateLogger "JobsJobsJobs.Api.Data.Startup" - let conn = Data.Startup.createConnection dbCfg log - let _ = svc.AddSingleton conn |> ignore // Set up the Marten data store - let _ = Connection.setUp cfg - () + match Connection.setUp cfg |> Async.AwaitTask |> Async.RunSynchronously with + | Ok _ -> () + | Error msg -> failwith $"Error initializing data store: {msg}" + [] let main _ = diff --git a/src/JobsJobsJobs/Server/Auth.fs b/src/JobsJobsJobs/Server/Auth.fs index e2b8b2a..8ea26e3 100644 --- a/src/JobsJobsJobs/Server/Auth.fs +++ b/src/JobsJobsJobs/Server/Auth.fs @@ -78,7 +78,6 @@ let verifyWithMastodon (authCode : string) (inst : MastodonInstance) rtnHost (lo open JobsJobsJobs.Domain -open JobsJobsJobs.Domain.Types open Microsoft.IdentityModel.Tokens open System.IdentityModel.Tokens.Jwt open System.Security.Claims diff --git a/src/JobsJobsJobs/Server/Data.fs b/src/JobsJobsJobs/Server/Data.fs deleted file mode 100644 index 094335b..0000000 --- a/src/JobsJobsJobs/Server/Data.fs +++ /dev/null @@ -1,745 +0,0 @@ -/// Data access functions for Jobs, Jobs, Jobs -module JobsJobsJobs.Api.Data - -open JobsJobsJobs.Domain - -/// JSON converters used with RethinkDB persistence -module Converters = - - open Microsoft.FSharpLu.Json - open Newtonsoft.Json - open System - - /// JSON converter for citizen IDs - type CitizenIdJsonConverter() = - inherit JsonConverter() - override _.WriteJson(writer : JsonWriter, value : CitizenId, _ : JsonSerializer) = - writer.WriteValue (CitizenId.toString value) - override _.ReadJson(reader: JsonReader, _ : Type, _ : CitizenId, _ : bool, _ : JsonSerializer) = - (string >> CitizenId.ofString) reader.Value - - /// JSON converter for continent IDs - type ContinentIdJsonConverter() = - inherit JsonConverter() - override _.WriteJson(writer : JsonWriter, value : ContinentId, _ : JsonSerializer) = - writer.WriteValue (ContinentId.toString value) - override _.ReadJson(reader: JsonReader, _ : Type, _ : ContinentId, _ : bool, _ : JsonSerializer) = - (string >> ContinentId.ofString) reader.Value - - /// JSON converter for Markdown strings - type MarkdownStringJsonConverter() = - inherit JsonConverter() - override _.WriteJson(writer : JsonWriter, value : MarkdownString, _ : JsonSerializer) = - writer.WriteValue (MarkdownString.toString value) - override _.ReadJson(reader: JsonReader, _ : Type, _ : MarkdownString, _ : bool, _ : JsonSerializer) = - (string >> Text) reader.Value - - /// JSON converter for listing IDs - type ListingIdJsonConverter() = - inherit JsonConverter() - override _.WriteJson(writer : JsonWriter, value : ListingId, _ : JsonSerializer) = - writer.WriteValue (ListingId.toString value) - override _.ReadJson(reader: JsonReader, _ : Type, _ : ListingId, _ : bool, _ : JsonSerializer) = - (string >> ListingId.ofString) reader.Value - - /// JSON converter for skill IDs - type SkillIdJsonConverter() = - inherit JsonConverter() - override _.WriteJson(writer : JsonWriter, value : SkillId, _ : JsonSerializer) = - writer.WriteValue (SkillId.toString value) - override _.ReadJson(reader: JsonReader, _ : Type, _ : SkillId, _ : bool, _ : JsonSerializer) = - (string >> SkillId.ofString) reader.Value - - /// JSON converter for success report IDs - type SuccessIdJsonConverter() = - inherit JsonConverter() - override _.WriteJson(writer : JsonWriter, value : SuccessId, _ : JsonSerializer) = - writer.WriteValue (SuccessId.toString value) - override _.ReadJson(reader: JsonReader, _ : Type, _ : SuccessId, _ : bool, _ : JsonSerializer) = - (string >> SuccessId.ofString) reader.Value - - /// All JSON converters needed for the application - let all () : JsonConverter list = - [ CitizenIdJsonConverter () - ContinentIdJsonConverter () - MarkdownStringJsonConverter () - ListingIdJsonConverter () - SkillIdJsonConverter () - SuccessIdJsonConverter () - CompactUnionJsonConverter () - ] - - -/// Table names -[] -module Table = - - /// The user (citizen of Gitmo Nation) table - let Citizen = "citizen" - - /// The continent table - let Continent = "continent" - - /// The citizen employment profile table - let Profile = "profile" - - /// The success story table - let Success = "success" - - /// All tables - let all () = [ Citizen; Continent; Profile; Success ] - -open NodaTime -open Npgsql -open Npgsql.FSharp - - - -open RethinkDb.Driver.FSharp.Functions -open RethinkDb.Driver.Net - -/// Reconnection functions (if the RethinkDB driver has a network error, it will not reconnect on its own) -[] -module private Reconnect = - - /// Retrieve a result using the F# driver's default retry policy - let result<'T> conn expr = runResult<'T> expr |> withRetryDefault |> withConn conn - - /// Retrieve an optional result using the F# driver's default retry policy - let resultOption<'T> conn expr = runResult<'T> expr |> withRetryDefault |> asOption |> withConn conn - - /// Write a query using the F# driver's default retry policy, ignoring the result - let write conn expr = runWrite expr |> withRetryDefault |> ignoreResult |> withConn conn - - -open RethinkDb.Driver.Ast -open Marten - -/// Shorthand for the RethinkDB R variable (how every command starts) -let private r = RethinkDb.Driver.RethinkDB.R - -/// Functions run at startup -[] -module Startup = - - open Microsoft.Extensions.Configuration - open Microsoft.Extensions.Logging - open NodaTime.Serialization.JsonNet - open RethinkDb.Driver.FSharp - - /// Create a RethinkDB connection - let createConnection (cfg : IConfigurationSection) (log : ILogger) = - // Add all required JSON converters - Converter.Serializer.ConfigureForNodaTime DateTimeZoneProviders.Tzdb |> ignore - Converters.all () - |> List.iter Converter.Serializer.Converters.Add - // Connect to the database - let config = DataConfig.FromConfiguration cfg - log.LogInformation $"Connecting to rethinkdb://{config.Hostname}:{config.Port}/{config.Database}" - config.CreateConnection () - - /// Ensure the tables and indexes that are required exist - let establishEnvironment (log : ILogger) conn = task { - - let! tables = - Sql.existingConnection conn - |> Sql.query "SELECT tablename FROM pg_tables WHERE schemaname = 'jjj'" - |> Sql.executeAsync (fun row -> row.string "tablename") - let needsTable table = not (List.contains table tables) - - let sql = seq { - if needsTable "continent" then - "CREATE TABLE jjj.continent ( - id UUID NOT NULL PRIMARY KEY, - name TEXT NOT NULL)" - if needsTable "citizen" then - "CREATE TABLE jjj.citizen ( - 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 "listing" then - "CREATE TABLE jjj.listing ( - id UUID NOT NULL PRIMARY KEY, - citizen_id UUID NOT NULL, - created_on TIMESTAMPTZ NOT NULL, - title TEXT NOT NULL, - continent_id UUID NOT NULL, - region TEXT NOT NULL, - is_remote BOOLEAN NOT NULL, - is_expired BOOLEAN NOT NULL, - updated_on TIMESTAMPTZ NOT NULL, - listing_text TEXT NOT NULL, - needed_by DATE, - was_filled_here BOOLEAN, - FOREIGN KEY fk_listing_citizen (citizen_id) REFERENCES jjj.citizen (id) ON DELETE CASCADE, - FOREIGN KEY fk_listing_continent (continent_id) REFERENCES jjj.continent (id))" - "CREATE INDEX idx_listing_citizen ON jjj.listing (citizen_id)" - "CREATE INDEX idx_listing_continent ON jjj.listing (continent_id)" - if needsTable "profile" then - "CREATE TABLE jjj.profile ( - citizen_id UUID NOT NULL PRIMARY KEY, - is_seeking BOOLEAN NOT NULL, - is_public_searchable BOOLEAN NOT NULL, - is_public_linkable BOOLEAN NOT NULL, - continent_id UUID NOT NULL, - region TEXT NOT NULL, - is_available_remotely BOOLEAN NOT NULL, - is_available_full_time BOOLEAN NOT NULL, - biography TEXT NOT NULL, - last_updated_on TIMESTAMPTZ NOT NULL, - experience TEXT, - FOREIGN KEY fk_profile_citizen (citizen_id) REFERENCES jjj.citizen (id) ON DELETE CASCADE, - FOREIGN KEY fk_profile_continent (continent_id) REFERENCES jjj.continent (id))" - "CREATE INDEX idx_profile_citizen ON jjj.profile (citizen_id)" - "CREATE INDEX idx_profile_continent ON jjj.profile (continent_id)" - "CREATE TABLE jjj.profile_skill ( - id UUID NOT NULL PRIMARY KEY, - citizen_id UUID NOT NULL, - description TEXT NOT NULL, - notes TEXT, - FOREIGN KEY fk_profile_skill_profile (citizen_id) REFERENCES jjj.profile (citizen_id) - ON DELETE CASCADE)" - "CREATE INDEX idx_profile_skill_profile ON jjj.profile_skill (citizen_id)" - if needsTable "security_info" then - "CREATE TABLE jjj.security_info ( - id UUID NOT NULL PRIMARY KEY, - failed_attempts SMALLINT NOT NULL, - is_locked BOOLEAN NOT NULL, - token TEXT, - token_usage TEXT, - token_expires TIMESTAMPTZ, - FOREIGN KEY fk_security_info_citizen (id) REFERENCES jjj.citizen (id) ON DELETE CASCADE)" - "CREATE INDEX idx_security_info_expires ON jjj.security_info (token_expires)" - if needsTable "success" then - "CREATE TABLE jjj.success ( - id UUID NOT NULL PRIMARY KEY, - citizen_id UUID NOT NULL, - recorded_on TIMESTAMPTZ NOT NULL, - was_from_here BOOLEAN NOT NULL, - source TEXT NOT NULL, - story TEXT, - FOREIGN KEY fk_success_citizen (citizen_id) REFERENCES jjj.citizen (id) ON DELETE CASCADE)" - "CREATE INDEX idx_success_citizen ON jjj.success (citizen_id)" - } - if not (Seq.isEmpty sql) then - let! _ = - Sql.existingConnection conn - |> Sql.executeTransactionAsync - (sql - |> Seq.map (fun it -> - let parts = it.Split ' ' - log.LogInformation $"Creating {parts[2]} {parts[1].ToLowerInvariant ()}..." - it, [ [] ]) - |> List.ofSeq) - () - } - - -open JobsJobsJobs.Domain.SharedTypes - -/// Sanitize user input, and create a "contains" pattern for use with RethinkDB queries -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") - -/// Custom SQL parameter functions -module Sql = - - /// Create a citizen ID parameter - let citizenId = CitizenId.value >> Sql.uuid - - /// Create a continent ID parameter - let continentId = ContinentId.value >> Sql.uuid - - /// Create a listing ID parameter - let listingId = ListingId.value >> Sql.uuid - - /// Create a Markdown string parameter - let markdown = MarkdownString.toString >> Sql.string - - /// Create a parameter for the given value - let param<'T> name (value : 'T) = - name, Sql.parameter (NpgsqlParameter (name, value)) - - /// Create a parameter for a possibly-missing value - let paramOrNone<'T> name (value : 'T option) = - name, Sql.parameter (NpgsqlParameter (name, if Option.isSome value then box value else System.DBNull.Value)) - - /// Create a skill ID parameter - let skillId = SkillId.value >> Sql.uuid - - /// Create a success ID parameter - let successId = SuccessId.value >> Sql.uuid - - -/// 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" - isLegacy = false - } - - /// Create a continent from a data row - let toContinent (row : RowReader) : Continent = - { id = (row.uuid >> ContinentId) "continent_id" - name = row.string "continent_name" - } - - /// Extract a count from a row - let toCount (row : RowReader) = - row.int64 "the_count" - - /// Create a job listing from a data row - let toListing (row : RowReader) : Listing = - { id = (row.uuid >> ListingId) "id" - citizenId = (row.uuid >> CitizenId) "citizen_id" - createdOn = row.fieldValue "created_on" - title = row.string "title" - continentId = (row.uuid >> ContinentId) "continent_id" - region = row.string "region" - remoteWork = row.bool "is_remote" - isExpired = row.bool "is_expired" - updatedOn = row.fieldValue "updated_on" - text = (row.string >> Text) "listing_text" - neededBy = row.fieldValueOrNone "needed_by" - wasFilledHere = row.boolOrNone "was_filled_here" - isLegacy = false - } - - /// Create a job listing for viewing from a data row - let toListingForView (row : RowReader) : ListingForView = - { listing = toListing row - continent = toContinent row - } - - /// Create a profile from a data row - let toProfile (row : RowReader) : Profile = - { id = (row.uuid >> CitizenId) "citizen_id" - seekingEmployment = row.bool "is_seeking" - isPublic = row.bool "is_public_searchable" - isPublicLinkable = row.bool "is_public_linkable" - continentId = (row.uuid >> ContinentId) "continent_id" - region = row.string "region" - remoteWork = row.bool "is_available_remotely" - fullTime = row.bool "is_available_full_time" - biography = (row.string >> Text) "biography" - lastUpdatedOn = row.fieldValue "last_updated_on" - experience = row.stringOrNone "experience" |> Option.map Text - skills = [] - isLegacy = false - } - - /// Create a skill from a data row - let toSkill (row : RowReader) : Skill = - { id = (row.uuid >> SkillId) "id" - description = row.string "description" - notes = row.stringOrNone "notes" - } - - /// Create a success story from a data row - let toSuccess (row : RowReader) : Success = - { id = (row.uuid >> SuccessId) "id" - citizenId = (row.uuid >> CitizenId) "citizen_id" - recordedOn = row.fieldValue "recorded_on" - fromHere = row.bool "was_from_here" - source = row.string "source" - story = row.stringOrNone "story" |> Option.map Text - } - - -/// Convert a possibly-null record type to an option -let optional<'T> (value : 'T) = if isNull (box value) then None else Some value - -open System -open System.Linq - -/// Profile data access functions -[] -module Profile = - - /// Count the current profiles - let count (session : IQuerySession) = - session.Query().Where(fun p -> not p.isLegacy).LongCountAsync () - - /// Find a profile by citizen ID - let findById citizenId (session : IQuerySession) = backgroundTask { - let! profile = session.LoadAsync (CitizenId.value citizenId) - return - match optional profile with - | Some p when not p.isLegacy -> Some p - | Some _ - | None -> None - } - - /// Insert or update a profile - [] - let save (profile : Profile) (session : IDocumentSession) = - session.Store profile - - /// Delete a citizen's profile - let delete citizenId conn = backgroundTask { - let! _ = - Sql.existingConnection conn - |> Sql.query "DELETE FROM jjj.profile WHERE citizen_id = @id" - |> Sql.parameters [ "@id", Sql.citizenId citizenId ] - |> Sql.executeNonQueryAsync - () - } - - /// Search profiles (logged-on users) - let search (search : ProfileSearch) conn = - 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 = - 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 -> - {| 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 - -/// Citizen data access functions -[] -module Citizen = - - /// Find a citizen by their ID - let findById citizenId (session : IQuerySession) = backgroundTask { - let! citizen = session.LoadAsync (CitizenId.value citizenId) - return - match optional citizen with - | Some c when not c.isLegacy -> Some c - | Some _ - | None -> None - } - - /// 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 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 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 { - let! _ = - Sql.existingConnection conn - |> Sql.query "DELETE FROM citizen WHERE id = @id" - |> Sql.parameters [ "@id", Sql.citizenId citizenId ] - |> Sql.executeNonQueryAsync - () - } - - -/// Continent data access functions -[] -module Continent = - - /// Get all continents - let all conn = - Sql.existingConnection conn - |> Sql.query "SELECT id AS continent_id, name AS continent_name FROM jjj.continent" - |> Sql.executeAsync Map.toContinent - - /// Get a continent by its ID - let findById contId conn = backgroundTask { - let! continent = - Sql.existingConnection conn - |> Sql.query "SELECT id AS continent_id, name AS continent_name FROM jjj.continent WHERE id = @id" - |> Sql.parameters [ "@id", Sql.continentId contId ] - |> Sql.executeAsync Map.toContinent - return List.tryHead continent - } - - -/// Job listing data access functions -[] -module Listing = - - /// The SQL to select the listing and continent fields - let private forViewSql = - "SELECT l.*, c.name AS continent_name - FROM jjj.listing l - INNER JOIN jjj.continent c ON c.id = l.continent_id" - - /// Find all job listings posted by the given citizen - let findByCitizen citizenId conn = - Sql.existingConnection conn - |> Sql.query $"{forViewSql} WHERE l.citizen_id = @citizenId" - |> Sql.parameters [ "@citizenId", Sql.citizenId citizenId ] - |> Sql.executeAsync Map.toListingForView - - /// Find a listing by its ID - let findById listingId conn = backgroundTask { - let! listing = - Sql.existingConnection conn - |> Sql.query "SELECT * FROM jjj.listing WHERE id = @id" - |> Sql.parameters [ "@id", Sql.listingId listingId ] - |> Sql.executeAsync Map.toListing - return List.tryHead listing - } - - /// Find a listing by its ID for viewing (includes continent information) - let findByIdForView (listingId : ListingId) conn = backgroundTask { - let! listing = - Sql.existingConnection conn - |> Sql.query $"{forViewSql} WHERE l.id = @id" - |> Sql.parameters [ "@id", Sql.listingId listingId ] - |> Sql.executeAsync Map.toListingForView - return List.tryHead listing - } - - /// Add or update a listing - let save (listing : Listing) conn = backgroundTask { - let! _ = - Sql.existingConnection conn - |> Sql.query - "INSERT INTO jjj.listing ( - id, citizen_id, created_on, title, continent_id, region, is_remote, is_expired, updated_on, - listing_text, needed_by, was_filled_here - ) VALUES ( - @id, @citizenId, @createdOn, @title, @continentId, @region, @isRemote, @isExpired, @updatedOn, - @text, @neededBy, @wasFilledHere - ) ON CONFLICT (id) DO UPDATE - SET title = EXCLUDED.title, - continent_id = EXCLUDED.continent_id, - region = EXCLUDED.region, - is_remote = EXCLUDED.is_remote, - is_expired = EXCLUDED.is_expired, - updated_on = EXCLUDED.updated_on, - listing_text = EXCLUDED.listing_text, - needed_by = EXCLUDED.needed_by, - was_filled_here = EXCLUDED.was_filled_here" - |> Sql.parameters - [ "@id", Sql.listingId listing.id - "@citizenId", Sql.citizenId listing.citizenId - "@createdOn" |>Sql.param<| listing.createdOn - "@title", Sql.string listing.title - "@continentId", Sql.continentId listing.continentId - "@region", Sql.string listing.region - "@isRemote", Sql.bool listing.remoteWork - "@isExpired", Sql.bool listing.isExpired - "@updatedOn" |>Sql.param<| listing.updatedOn - "@text", Sql.markdown listing.text - "@neededBy" |>Sql.paramOrNone<| listing.neededBy - "@wasFilledHere", Sql.boolOrNone listing.wasFilledHere - - ] - |> Sql.executeNonQueryAsync - () - } - - /// Expire a listing - let expire listingId fromHere (now : Instant) conn = backgroundTask { - let! _ = - Sql.existingConnection conn - |> Sql.query - "UPDATE jjj.listing - SET is_expired = TRUE, - was_filled_here = @wasFilledHere, - updated_on = @updatedOn - WHERE id = @id" - |> Sql.parameters - [ "@wasFilledHere", Sql.bool fromHere - "@updatedOn" |>Sql.param<| now - "@id", Sql.listingId listingId - ] - |> Sql.executeNonQueryAsync - () - } - - /// Search job listings - let search (search : ListingSearch) conn = - let filters = seq { - match search.continentId with - | Some contId -> - "l.continent = @continentId", [ "@continentId", Sql.continentId (ContinentId.ofString contId) ] - | None -> () - match search.region with - | Some region -> "l.region ILIKE '%@region%'", [ "@region", Sql.string region ] - | None -> () - if search.remoteWork <> "" then - "l.is_remote = @isRemote", ["@isRemote", Sql.bool (search.remoteWork = "yes") ] - match search.text with - | Some text -> "l.listing_text ILIKE '%@text%'", [ "@text", Sql.string text ] - | None -> () - } - let filterSql = filters |> Seq.map fst |> String.concat " AND " - Sql.existingConnection conn - |> Sql.query $"{forViewSql} WHERE l.is_expired = FALSE{filterSql}" - |> Sql.parameters (filters |> Seq.collect snd |> List.ofSeq) - |> Sql.executeAsync Map.toListingForView - - -/// Success story data access functions -[] -module Success = - - /// Find a success report by its ID - let findById successId conn = backgroundTask { - let! success = - Sql.existingConnection conn - |> Sql.query "SELECT * FROM jjj.success WHERE id = @id" - |> Sql.parameters [ "@id", Sql.successId successId ] - |> Sql.executeAsync Map.toSuccess - return List.tryHead success - } - - /// Insert or update a success story - let save (success : Success) conn = backgroundTask { - let! _ = - Sql.existingConnection conn - |> Sql.query - "INSERT INTO jjj.success ( - id, citizen_id, recorded_on, was_from_here, source, story - ) VALUES ( - @id, @citizenId, @recordedOn, @wasFromHere, @source, @story - ) ON CONFLICT (id) DO UPDATE - SET was_from_here = EXCLUDED.was_from_here, - story = EXCLUDED.story" - |> Sql.parameters - [ "@id", Sql.successId success.id - "@citizenId", Sql.citizenId success.citizenId - "@recordedOn" |>Sql.param<| success.recordedOn - "@wasFromHere", Sql.bool success.fromHere - "@source", Sql.string success.source - "@story", Sql.stringOrNone (Option.map MarkdownString.toString success.story) - ] - |> Sql.executeNonQueryAsync - () - } - - // Retrieve all success stories - let all conn = - fromTable Table.Success - |> eqJoin "citizenId" (fromTable Table.Citizen) - |> without [ "right.id" ] - |> zip - |> mergeFunc (fun it -> {| citizenName = deriveDisplayName it; hasStory = it.G("story").Default_("").Gt "" |}) - |> pluck [ "id"; "citizenId"; "citizenName"; "recordedOn"; "fromHere"; "hasStory" ] - |> orderByDescending "recordedOn" - |> result conn diff --git a/src/JobsJobsJobs/Server/Handlers.fs b/src/JobsJobsJobs/Server/Handlers.fs index 2f910ee..ecd51ab 100644 --- a/src/JobsJobsJobs/Server/Handlers.fs +++ b/src/JobsJobsJobs/Server/Handlers.fs @@ -1,7 +1,6 @@ /// Route handlers for Giraffe endpoints module JobsJobsJobs.Api.Handlers -open System.Threading open Giraffe open JobsJobsJobs.Domain open JobsJobsJobs.Domain.SharedTypes @@ -55,15 +54,12 @@ module Error = module Helpers = open System.Security.Claims - open System.Threading.Tasks open NodaTime - open Marten open Microsoft.Extensions.Configuration open Microsoft.Extensions.Options - open RethinkDb.Driver.Net /// Get the NodaTime clock from the request context - let clock (ctx : HttpContext) = ctx.GetService () + let now (ctx : HttpContext) = ctx.GetService().GetCurrentInstant () /// Get the application configuration from the request context let config (ctx : HttpContext) = ctx.GetService () @@ -74,15 +70,6 @@ module Helpers = /// Get the logger factory from the request context let logger (ctx : HttpContext) = ctx.GetService () - /// Get the RethinkDB connection from the request context - let conn (ctx : HttpContext) = ctx.GetService () - - /// Get a query session - let querySession (ctx : HttpContext) = ctx.GetService () - - /// Get a full document session - let docSession (ctx : HttpContext) = ctx.GetService () - /// `None` if a `string option` is `None`, whitespace, or empty let noneIfBlank (s : string option) = s |> Option.map (fun x -> match x.Trim () with "" -> None | _ -> Some x) |> Option.flatten @@ -106,16 +93,6 @@ module Helpers = /// Return an empty OK response let ok : HttpHandler = Successful.OK "" - - /// Convert a potentially-null record type to an option - let opt<'T> (it : Task<'T>) = task { - match! it with - | x when isNull (box x) -> return None - | x -> return Some x - } - - /// Shorthand for no cancellation token - let noCnx = CancellationToken.None open System @@ -127,48 +104,59 @@ module Citizen = // GET: /api/citizen/log-on/[code] let logOn (abbr, authCode) : HttpHandler = fun next ctx -> task { + match! Citizens.tryLogOn "to@do.com" (fun _ -> false) (now ctx) with + | Ok citizen -> + return! + json + { jwt = Auth.createJwt citizen (authConfig ctx) + citizenId = CitizenId.toString citizen.id + name = Citizen.name citizen + } next ctx + | Error msg -> + // TODO: return error message + return! RequestErrors.BAD_REQUEST msg next ctx // Step 1 - Verify with Mastodon - let cfg = authConfig ctx - - match cfg.Instances |> Array.tryFind (fun it -> it.Abbr = abbr) with - | Some instance -> - let log = (logger ctx).CreateLogger (nameof JobsJobsJobs.Api.Auth) - - match! Auth.verifyWithMastodon authCode instance cfg.ReturnHost log with - | Ok account -> - // Step 2 - Find / establish Jobs, Jobs, Jobs account - let now = (clock ctx).GetCurrentInstant () - let dbConn = conn ctx - let! citizen = task { - match! Data.Citizen.findByMastodonUser instance.Abbr account.Username dbConn with - | None -> - let it : Citizen = - { id = CitizenId.create () - instance = instance.Abbr - mastodonUser = account.Username - displayName = noneIfEmpty account.DisplayName - realName = None - profileUrl = account.Url - joinedOn = now - lastSeenOn = now - } - do! Data.Citizen.add it dbConn - return it - | Some citizen -> - let it = { citizen with displayName = noneIfEmpty account.DisplayName; lastSeenOn = now } - do! Data.Citizen.logOnUpdate it dbConn - return it - } - - // Step 3 - Generate JWT - return! - json - { jwt = Auth.createJwt citizen cfg - citizenId = CitizenId.toString citizen.id - name = Citizen.name citizen - } next ctx - | Error err -> return! RequestErrors.BAD_REQUEST err next ctx - | None -> return! Error.notFound next ctx + // let cfg = authConfig ctx + // + // match cfg.Instances |> Array.tryFind (fun it -> it.Abbr = abbr) with + // | Some instance -> + // let log = (logger ctx).CreateLogger (nameof JobsJobsJobs.Api.Auth) + // + // match! Auth.verifyWithMastodon authCode instance cfg.ReturnHost log with + // | Ok account -> + // // Step 2 - Find / establish Jobs, Jobs, Jobs account + // let now = (clock ctx).GetCurrentInstant () + // let dbConn = conn ctx + // let! citizen = task { + // match! Data.Citizen.findByMastodonUser instance.Abbr account.Username dbConn with + // | None -> + // let it : Citizen = + // { id = CitizenId.create () + // instance = instance.Abbr + // mastodonUser = account.Username + // displayName = noneIfEmpty account.DisplayName + // realName = None + // profileUrl = account.Url + // joinedOn = now + // lastSeenOn = now + // } + // do! Data.Citizen.add it dbConn + // return it + // | Some citizen -> + // let it = { citizen with displayName = noneIfEmpty account.DisplayName; lastSeenOn = now } + // do! Data.Citizen.logOnUpdate it dbConn + // return it + // } + // + // // Step 3 - Generate JWT + // return! + // json + // { jwt = Auth.createJwt citizen cfg + // citizenId = CitizenId.toString citizen.id + // name = Citizen.name citizen + // } next ctx + // | Error err -> return! RequestErrors.BAD_REQUEST err next ctx + // | None -> return! Error.notFound next ctx } // GET: /api/citizen/[id] @@ -248,7 +236,7 @@ module Listing = // POST: /listings let add : HttpHandler = authorize >=> fun next ctx -> task { let! form = ctx.BindJsonAsync () - let now = (clock ctx).GetCurrentInstant () + let now = now ctx do! Listings.save { id = ListingId.create () citizenId = currentCitizenId ctx @@ -269,7 +257,6 @@ module Listing = // PUT: /api/listing/[id] let update listingId : HttpHandler = authorize >=> fun next ctx -> task { - let dbConn = conn ctx match! Listings.findById (ListingId listingId) with | Some listing when listing.citizenId <> (currentCitizenId ctx) -> return! Error.notAuthorized next ctx | Some listing -> @@ -282,7 +269,7 @@ module Listing = remoteWork = form.remoteWork text = Text form.text neededBy = form.neededBy |> Option.map parseDate - updatedOn = (clock ctx).GetCurrentInstant () + updatedOn = now ctx } return! ok next ctx | None -> return! Error.notFound next ctx @@ -290,8 +277,7 @@ module Listing = // PATCH: /api/listing/[id] let expire listingId : HttpHandler = authorize >=> fun next ctx -> task { - let dbConn = conn ctx - let now = clock(ctx).GetCurrentInstant () + let now = now ctx match! Listings.findById (ListingId listingId) with | Some listing when listing.citizenId <> (currentCitizenId ctx) -> return! Error.notAuthorized next ctx | Some listing -> @@ -333,56 +319,41 @@ module Profile = // This returns the current citizen's profile, or a 204 if it is not found (a citizen not having a profile yet // is not an error). The "get" handler returns a 404 if a profile is not found. let current : HttpHandler = authorize >=> fun next ctx -> task { - match! Data.Profile.findById (currentCitizenId ctx) (conn ctx) with + match! Profiles.findById (currentCitizenId ctx) with | Some profile -> return! json profile next ctx | None -> return! Successful.NO_CONTENT next ctx } // GET: /api/profile/get/[id] let get citizenId : HttpHandler = authorize >=> fun next ctx -> task { - match! Data.Profile.findById (CitizenId citizenId) (conn ctx) with + match! Profiles.findById (CitizenId citizenId) with | Some profile -> return! json profile next ctx | None -> return! Error.notFound next ctx } // GET: /api/profile/view/[id] let view citizenId : HttpHandler = authorize >=> fun next ctx -> task { - let citId = CitizenId citizenId - let dbConn = conn ctx - match! Data.Profile.findById citId dbConn with - | Some profile -> - match! Data.Citizen.findById citId dbConn with - | Some citizen -> - match! Data.Continent.findById profile.continentId dbConn with - | Some continent -> - return! - json - { profile = profile - citizen = citizen - continent = continent - } next ctx - | None -> return! Error.notFound next ctx - | None -> return! Error.notFound next ctx + match! Profiles.findByIdForView (CitizenId citizenId) with + | Some profile -> return! json profile next ctx | None -> return! Error.notFound next ctx } // GET: /api/profile/count let count : HttpHandler = authorize >=> fun next ctx -> task { - let! theCount = Data.Profile.count (conn ctx) + let! theCount = Profiles.count () return! json { count = theCount } next ctx } // POST: /api/profile/save let save : HttpHandler = authorize >=> fun next ctx -> task { let citizenId = currentCitizenId ctx - let dbConn = conn ctx let! form = ctx.BindJsonAsync() let! profile = task { - match! Data.Profile.findById citizenId dbConn with + match! Profiles.findById citizenId with | Some p -> return p | None -> return { Profile.empty with id = citizenId } } - do! Data.Profile.save + do! Profiles.save { profile with seekingEmployment = form.isSeekingEmployment isPublic = form.isPublic @@ -391,48 +362,45 @@ module Profile = remoteWork = form.remoteWork fullTime = form.fullTime biography = Text form.biography - lastUpdatedOn = (clock ctx).GetCurrentInstant () + lastUpdatedOn = now ctx experience = noneIfBlank form.experience |> Option.map Text skills = form.skills |> List.map (fun s -> - { id = match s.id.StartsWith "new" with - | true -> SkillId.create () - | false -> SkillId.ofString s.id - description = s.description - notes = noneIfBlank s.notes - }) - } dbConn - do! Data.Citizen.realNameUpdate citizenId (noneIfBlank (Some form.realName)) dbConn + { id = if s.id.StartsWith "new" then SkillId.create () + else SkillId.ofString s.id + description = s.description + notes = noneIfBlank s.notes + }) + } return! ok next ctx } // PATCH: /api/profile/employment-found let employmentFound : HttpHandler = authorize >=> fun next ctx -> task { - let dbConn = conn ctx - match! Data.Profile.findById (currentCitizenId ctx) dbConn with + match! Profiles.findById (currentCitizenId ctx) with | Some profile -> - do! Data.Profile.save { profile with seekingEmployment = false } dbConn + do! Profiles.save { profile with seekingEmployment = false } return! ok next ctx | None -> return! Error.notFound next ctx } // DELETE: /api/profile let delete : HttpHandler = authorize >=> fun next ctx -> task { - do! Data.Profile.delete (currentCitizenId ctx) (conn ctx) + do! Profiles.deleteById (currentCitizenId ctx) return! ok next ctx } // GET: /api/profile/search let search : HttpHandler = authorize >=> fun next ctx -> task { let search = ctx.BindQueryString () - let! results = Data.Profile.search search (conn ctx) + let! results = Profiles.search search 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) + let! results = Profiles.publicSearch search return! json results next ctx } @@ -441,39 +409,35 @@ module Profile = [] 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 + match! Successes.findById (SuccessId successId) 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) + let! stories = Successes.all () 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 = SuccessId.create () citizenId = citizenId - recordedOn = now + recordedOn = now ctx fromHere = form.fromHere source = "profile" story = noneIfEmpty form.story |> Option.map Text } | successId -> - match! Data.Success.findById (SuccessId.ofString successId) dbConn with + match! Successes.findById (SuccessId.ofString successId) with | Some story when story.citizenId = citizenId -> return Some { story with fromHere = form.fromHere @@ -483,7 +447,7 @@ module Success = } match success with | Some story -> - do! Data.Success.save story dbConn + do! Successes.save story return! ok next ctx | None -> return! Error.notFound next ctx } diff --git a/src/JobsJobsJobs/Server/JobsJobsJobs.Server.fsproj b/src/JobsJobsJobs/Server/JobsJobsJobs.Server.fsproj index 4dc6bbd..10807ec 100644 --- a/src/JobsJobsJobs/Server/JobsJobsJobs.Server.fsproj +++ b/src/JobsJobsJobs/Server/JobsJobsJobs.Server.fsproj @@ -8,7 +8,6 @@ - @@ -25,15 +24,10 @@ - + - - - - - - +