Version 3 #40
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,5 +1,6 @@
|
||||
.ionide
|
||||
.fake
|
||||
.idea
|
||||
src/**/bin
|
||||
src/**/obj
|
||||
src/**/appsettings.*.json
|
||||
|
@ -7,7 +7,11 @@ open System
|
||||
// fsharplint:disable FieldNames
|
||||
|
||||
/// The ID of a user (a citizen of Gitmo Nation)
|
||||
type CitizenId = CitizenId of Guid
|
||||
type CitizenId =
|
||||
CitizenId of Guid
|
||||
with
|
||||
/// The GUID value of this citizen ID
|
||||
member this.Value = this |> function CitizenId guid -> guid
|
||||
|
||||
/// A user of Jobs, Jobs, Jobs
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
|
@ -1,6 +1,7 @@
|
||||
/// Data access functions for Jobs, Jobs, Jobs
|
||||
module JobsJobsJobs.Api.Data
|
||||
|
||||
open CommonExtensionsAndTypesForNpgsqlFSharp
|
||||
open JobsJobsJobs.Domain.Types
|
||||
|
||||
/// JSON converters used with RethinkDB persistence
|
||||
@ -87,12 +88,20 @@ module Table =
|
||||
/// The citizen employment profile table
|
||||
let Profile = "profile"
|
||||
|
||||
/// The profile / skill cross-reference
|
||||
let ProfileSkill = "profile_skill"
|
||||
|
||||
/// The success story table
|
||||
let Success = "success"
|
||||
|
||||
/// All tables
|
||||
let all () = [ Citizen; Continent; Listing; Profile; Success ]
|
||||
|
||||
open NodaTime
|
||||
open Npgsql
|
||||
open Npgsql.FSharp
|
||||
|
||||
|
||||
|
||||
open RethinkDb.Driver.FSharp.Functions
|
||||
open RethinkDb.Driver.Net
|
||||
@ -122,7 +131,6 @@ module Startup =
|
||||
|
||||
open Microsoft.Extensions.Configuration
|
||||
open Microsoft.Extensions.Logging
|
||||
open NodaTime
|
||||
open NodaTime.Serialization.JsonNet
|
||||
open RethinkDb.Driver.FSharp
|
||||
|
||||
@ -137,42 +145,93 @@ module Startup =
|
||||
log.LogInformation $"Connecting to rethinkdb://{config.Hostname}:{config.Port}/{config.Database}"
|
||||
config.CreateConnection ()
|
||||
|
||||
/// Ensure the data, tables, and indexes that are required exist
|
||||
let establishEnvironment (cfg : IConfigurationSection) (log : ILogger) conn = task {
|
||||
// Ensure the database exists
|
||||
match cfg["database"] |> Option.ofObj with
|
||||
| Some database ->
|
||||
let! dbs = dbList () |> result<string list> conn
|
||||
match dbs |> List.contains database with
|
||||
| true -> ()
|
||||
| false ->
|
||||
log.LogInformation $"Creating database {database}..."
|
||||
do! dbCreate database |> write conn
|
||||
()
|
||||
| None -> ()
|
||||
// Ensure the tables exist
|
||||
let! tables = tableListFromDefault () |> result<string list> conn
|
||||
for table in Table.all () do
|
||||
if not (List.contains table tables) then
|
||||
log.LogInformation $"Creating {table} table..."
|
||||
do! tableCreateInDefault table |> write conn
|
||||
// Ensure the indexes exist
|
||||
let ensureIndexes table indexes = task {
|
||||
let! tblIndexes = fromTable table |> indexList |> result<string list> conn
|
||||
for index in indexes do
|
||||
if not (List.contains index tblIndexes) then
|
||||
log.LogInformation $"Creating \"{index}\" index on {table}"
|
||||
do! fromTable table |> indexCreate index |> write conn
|
||||
/// 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,
|
||||
display_name TEXT,
|
||||
profile_urls TEXT[] NOT NULL DEFAULT '{}',
|
||||
joined_on TIMESTAMPTZ NOT NULL,
|
||||
last_seen_on TIMESTAMPTZ NOT NULL,
|
||||
is_legacy BOOLEAN NOT NULL)"
|
||||
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_remote 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 "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 "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)"
|
||||
}
|
||||
do! ensureIndexes Table.Listing [ "citizenId"; "continentId"; "isExpired" ]
|
||||
do! ensureIndexes Table.Profile [ "continentId" ]
|
||||
do! ensureIndexes Table.Success [ "citizenId" ]
|
||||
// The instance/user is a compound index
|
||||
let! userIdx = fromTable Table.Citizen |> indexList |> result<string list> conn
|
||||
if not (List.contains "instanceUser" userIdx) then
|
||||
do! fromTable Table.Citizen
|
||||
|> indexCreateFunc "instanceUser" (fun row -> [| row.G "instance"; row.G "mastodonUser" |])
|
||||
|> write conn
|
||||
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)
|
||||
()
|
||||
}
|
||||
|
||||
|
||||
@ -196,22 +255,72 @@ let private deriveDisplayName (it : ReqlExpr) =
|
||||
it.G("displayName").Default_("").Ne "", it.G "displayName",
|
||||
it.G "mastodonUser")
|
||||
|
||||
/// Map data results to domain types
|
||||
module Map =
|
||||
|
||||
/// Extract a count from a row
|
||||
let toCount (row : RowReader) =
|
||||
row.int64 "the_count"
|
||||
|
||||
/// Create a profile from a data row
|
||||
let toProfile (row : RowReader) : Profile =
|
||||
{ id = CitizenId (row.uuid "citizen_id")
|
||||
seekingEmployment = row.bool "is_seeking"
|
||||
isPublic = row.bool "is_public_searchable"
|
||||
continentId = ContinentId (row.uuid "continent_id")
|
||||
region = row.string "region"
|
||||
remoteWork = row.bool "is_available_remote"
|
||||
fullTime = row.bool "is_available_full_time"
|
||||
biography = Text (row.string "biography")
|
||||
lastUpdatedOn = row.fieldValue<Instant> "last_updated_on"
|
||||
experience = row.stringOrNone "experience" |> Option.map Text
|
||||
skills = []
|
||||
}
|
||||
|
||||
/// Create a skill from a data row
|
||||
let toSkill (row : RowReader) : Skill =
|
||||
{ id = SkillId (row.uuid "id")
|
||||
description = row.string "description"
|
||||
notes = row.stringOrNone "notes"
|
||||
}
|
||||
|
||||
|
||||
/// Profile data access functions
|
||||
[<RequireQualifiedAccess>]
|
||||
module Profile =
|
||||
|
||||
/// Count the current profiles
|
||||
let count conn =
|
||||
fromTable Table.Profile
|
||||
|> count
|
||||
|> result<int64> conn
|
||||
Sql.existingConnection conn
|
||||
|> Sql.query
|
||||
"SELECT COUNT(p.citizen_id)
|
||||
FROM jjj.profile p
|
||||
INNER JOIN jjj.citizen c ON c.id = p.citizen_id
|
||||
WHERE c.is_legacy = FALSE"
|
||||
|> Sql.executeRowAsync Map.toCount
|
||||
|
||||
/// Find a profile by citizen ID
|
||||
let findById (citizenId : CitizenId) conn =
|
||||
fromTable Table.Profile
|
||||
|> get citizenId
|
||||
|> resultOption<Profile> conn
|
||||
|
||||
let findById (citizenId : CitizenId) conn = backgroundTask {
|
||||
let! tryProfile =
|
||||
Sql.existingConnection conn
|
||||
|> Sql.query
|
||||
"SELECT *
|
||||
FROM jjj.profile p
|
||||
INNER JOIN jjj.citizen ON c.id = p.citizen_id
|
||||
WHERE p.citizen_id = @id
|
||||
AND c.is_legacy = FALSE"
|
||||
|> Sql.parameters [ "@id", Sql.uuid citizenId.Value ]
|
||||
|> Sql.executeAsync Map.toProfile
|
||||
match List.tryHead tryProfile with
|
||||
| Some profile ->
|
||||
let! skills =
|
||||
Sql.existingConnection conn
|
||||
|> Sql.query "SELECT * FROM jjj.profile_skill WHERE citizen_id = @id"
|
||||
|> Sql.parameters [ "@id", Sql.uuid citizenId.Value ]
|
||||
|> Sql.executeAsync Map.toSkill
|
||||
return Some { profile with skills = skills }
|
||||
| None -> return None
|
||||
}
|
||||
/// Insert or update a profile
|
||||
let save (profile : Profile) conn =
|
||||
fromTable Table.Profile
|
||||
@ -220,11 +329,14 @@ module Profile =
|
||||
|> write conn
|
||||
|
||||
/// Delete a citizen's profile
|
||||
let delete (citizenId : CitizenId) conn =
|
||||
fromTable Table.Profile
|
||||
|> get citizenId
|
||||
|> delete
|
||||
|> write conn
|
||||
let delete (citizenId : CitizenId) conn = backgroundTask {
|
||||
let! _ =
|
||||
Sql.existingConnection conn
|
||||
|> Sql.query "DELETE FROM jjj.profile WHERE citizen_id = @id"
|
||||
|> Sql.parameters [ "@id", Sql.uuid citizenId.Value ]
|
||||
|> Sql.executeNonQueryAsync
|
||||
()
|
||||
}
|
||||
|
||||
/// Search profiles (logged-on users)
|
||||
let search (search : ProfileSearch) conn =
|
||||
@ -321,20 +433,13 @@ module Citizen =
|
||||
|> write conn
|
||||
|
||||
/// Delete a citizen
|
||||
let delete citizenId conn = task {
|
||||
do! Profile.delete citizenId conn
|
||||
do! fromTable Table.Success
|
||||
|> getAllWithIndex [ citizenId ] "citizenId"
|
||||
|> delete
|
||||
|> write conn
|
||||
do! fromTable Table.Listing
|
||||
|> getAllWithIndex [ citizenId ] "citizenId"
|
||||
|> delete
|
||||
|> write conn
|
||||
do! fromTable Table.Citizen
|
||||
|> get citizenId
|
||||
|> delete
|
||||
|> write conn
|
||||
let delete (citizenId : CitizenId) conn = backgroundTask {
|
||||
let! _ =
|
||||
Sql.existingConnection conn
|
||||
|> Sql.query "DELETE FROM citizen WHERE id = @id"
|
||||
|> Sql.parameters [ "@id", Sql.uuid citizenId.Value ]
|
||||
|> Sql.executeNonQueryAsync
|
||||
()
|
||||
}
|
||||
|
||||
/// Update a citizen's real name
|
||||
@ -365,8 +470,6 @@ module Continent =
|
||||
[<RequireQualifiedAccess>]
|
||||
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" |}
|
||||
|
||||
|
@ -27,6 +27,9 @@
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.6" />
|
||||
<PackageReference Include="Microsoft.FSharpLu.Json" Version="0.11.7" />
|
||||
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.0.0" />
|
||||
<PackageReference Include="Npgsql" Version="6.0.6" />
|
||||
<PackageReference Include="Npgsql.FSharp" Version="5.3.0" />
|
||||
<PackageReference Include="Npgsql.NodaTime" Version="6.0.6" />
|
||||
<PackageReference Include="RethinkDb.Driver" Version="2.3.150" />
|
||||
<PackageReference Include="RethinkDb.Driver.FSharp" Version="0.9.0-beta-05" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.21.0" />
|
||||
|
Loading…
Reference in New Issue
Block a user