Migration to pg docs complete
This commit is contained in:
parent
1a91f10da2
commit
21957330fe
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -6,3 +6,4 @@ src/**/obj
|
||||||
src/**/appsettings.*.json
|
src/**/appsettings.*.json
|
||||||
src/.vs
|
src/.vs
|
||||||
src/.idea
|
src/.idea
|
||||||
|
src/JobsJobsJobs/JobsJobsJobs.V3Migration/appsettings.json
|
||||||
|
|
|
@ -203,20 +203,20 @@ module ProfileForm =
|
||||||
|
|
||||||
/// Create an instance of this form from the given profile
|
/// Create an instance of this form from the given profile
|
||||||
let fromProfile (profile : Profile) =
|
let fromProfile (profile : Profile) =
|
||||||
{ isSeekingEmployment = profile.seekingEmployment
|
{ isSeekingEmployment = profile.IsSeekingEmployment
|
||||||
isPublic = profile.isPublic
|
isPublic = profile.IsPubliclySearchable
|
||||||
realName = ""
|
realName = ""
|
||||||
continentId = string profile.continentId
|
continentId = string profile.ContinentId
|
||||||
region = profile.region
|
region = profile.Region
|
||||||
remoteWork = profile.remoteWork
|
remoteWork = profile.IsRemote
|
||||||
fullTime = profile.fullTime
|
fullTime = profile.IsFullTime
|
||||||
biography = MarkdownString.toString profile.biography
|
biography = MarkdownString.toString profile.Biography
|
||||||
experience = profile.experience |> Option.map MarkdownString.toString
|
experience = profile.Experience |> Option.map MarkdownString.toString
|
||||||
skills = profile.skills
|
skills = profile.Skills
|
||||||
|> List.map (fun s ->
|
|> List.map (fun s ->
|
||||||
{ id = string s.id
|
{ id = string s.Id
|
||||||
description = s.description
|
description = s.Description
|
||||||
notes = s.notes
|
notes = s.Notes
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9,74 +9,75 @@ open System
|
||||||
[<CLIMutable; NoComparison; NoEquality>]
|
[<CLIMutable; NoComparison; NoEquality>]
|
||||||
type Citizen =
|
type Citizen =
|
||||||
{ /// The ID of the user
|
{ /// The ID of the user
|
||||||
id : CitizenId
|
Id : CitizenId
|
||||||
|
|
||||||
/// When the user joined Jobs, Jobs, Jobs
|
/// When the user joined Jobs, Jobs, Jobs
|
||||||
joinedOn : Instant
|
JoinedOn : Instant
|
||||||
|
|
||||||
/// When the user last logged in
|
/// When the user last logged in
|
||||||
lastSeenOn : Instant
|
LastSeenOn : Instant
|
||||||
|
|
||||||
/// The user's e-mail address
|
/// The user's e-mail address
|
||||||
email : string
|
Email : string
|
||||||
|
|
||||||
/// The user's first name
|
/// The user's first name
|
||||||
firstName : string
|
FirstName : string
|
||||||
|
|
||||||
/// The user's last name
|
/// The user's last name
|
||||||
lastName : string
|
LastName : string
|
||||||
|
|
||||||
/// The hash of the user's password
|
/// The hash of the user's password
|
||||||
passwordHash : string
|
PasswordHash : string
|
||||||
|
|
||||||
/// The name displayed for this user throughout the site
|
/// The name displayed for this user throughout the site
|
||||||
displayName : string option
|
DisplayName : string option
|
||||||
|
|
||||||
/// The other contacts for this user
|
/// The other contacts for this user
|
||||||
otherContacts : OtherContact list
|
OtherContacts : OtherContact list
|
||||||
|
|
||||||
/// Whether this is a legacy citizen
|
/// Whether this is a legacy citizen
|
||||||
isLegacy : bool
|
IsLegacy : bool
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Support functions for citizens
|
/// Support functions for citizens
|
||||||
module Citizen =
|
module Citizen =
|
||||||
|
|
||||||
/// An empty citizen
|
/// An empty citizen
|
||||||
let empty =
|
let empty = {
|
||||||
{ id = CitizenId Guid.Empty
|
Id = CitizenId Guid.Empty
|
||||||
joinedOn = Instant.MinValue
|
JoinedOn = Instant.MinValue
|
||||||
lastSeenOn = Instant.MinValue
|
LastSeenOn = Instant.MinValue
|
||||||
email = ""
|
Email = ""
|
||||||
firstName = ""
|
FirstName = ""
|
||||||
lastName = ""
|
LastName = ""
|
||||||
passwordHash = ""
|
PasswordHash = ""
|
||||||
displayName = None
|
DisplayName = None
|
||||||
otherContacts = []
|
OtherContacts = []
|
||||||
isLegacy = false
|
IsLegacy = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the name of the citizen (either their preferred display name or first/last names)
|
/// Get the name of the citizen (either their preferred display name or first/last names)
|
||||||
let name x =
|
let name x =
|
||||||
match x.displayName with Some it -> it | None -> $"{x.firstName} {x.lastName}"
|
match x.DisplayName with Some it -> it | None -> $"{x.FirstName} {x.LastName}"
|
||||||
|
|
||||||
|
|
||||||
/// A continent
|
/// A continent
|
||||||
[<CLIMutable; NoComparison; NoEquality>]
|
[<CLIMutable; NoComparison; NoEquality>]
|
||||||
type Continent =
|
type Continent =
|
||||||
{ /// The ID of the continent
|
{ /// The ID of the continent
|
||||||
id : ContinentId
|
Id : ContinentId
|
||||||
|
|
||||||
/// The name of the continent
|
/// The name of the continent
|
||||||
name : string
|
Name : string
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Support functions for continents
|
/// Support functions for continents
|
||||||
module Continent =
|
module Continent =
|
||||||
|
|
||||||
/// An empty continent
|
/// An empty continent
|
||||||
let empty =
|
let empty ={
|
||||||
{ id = ContinentId Guid.Empty
|
Id = ContinentId Guid.Empty
|
||||||
name = ""
|
Name = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -84,63 +85,63 @@ module Continent =
|
||||||
[<CLIMutable; NoComparison; NoEquality>]
|
[<CLIMutable; NoComparison; NoEquality>]
|
||||||
type Listing =
|
type Listing =
|
||||||
{ /// The ID of the job listing
|
{ /// The ID of the job listing
|
||||||
id : ListingId
|
Id : ListingId
|
||||||
|
|
||||||
/// The ID of the citizen who posted the job listing
|
/// The ID of the citizen who posted the job listing
|
||||||
citizenId : CitizenId
|
CitizenId : CitizenId
|
||||||
|
|
||||||
/// When this job listing was created
|
/// When this job listing was created
|
||||||
createdOn : Instant
|
CreatedOn : Instant
|
||||||
|
|
||||||
/// The short title of the job listing
|
/// The short title of the job listing
|
||||||
title : string
|
Title : string
|
||||||
|
|
||||||
/// The ID of the continent on which the job is located
|
/// The ID of the continent on which the job is located
|
||||||
continentId : ContinentId
|
ContinentId : ContinentId
|
||||||
|
|
||||||
/// The region in which the job is located
|
/// The region in which the job is located
|
||||||
region : string
|
Region : string
|
||||||
|
|
||||||
/// Whether this listing is for remote work
|
/// Whether this listing is for remote work
|
||||||
remoteWork : bool
|
IsRemote : bool
|
||||||
|
|
||||||
/// Whether this listing has expired
|
/// Whether this listing has expired
|
||||||
isExpired : bool
|
IsExpired : bool
|
||||||
|
|
||||||
/// When this listing was last updated
|
/// When this listing was last updated
|
||||||
updatedOn : Instant
|
UpdatedOn : Instant
|
||||||
|
|
||||||
/// The details of this job
|
/// The details of this job
|
||||||
text : MarkdownString
|
Text : MarkdownString
|
||||||
|
|
||||||
/// When this job needs to be filled
|
/// When this job needs to be filled
|
||||||
neededBy : LocalDate option
|
NeededBy : LocalDate option
|
||||||
|
|
||||||
/// Was this job filled as part of its appearance on Jobs, Jobs, Jobs?
|
/// Was this job filled as part of its appearance on Jobs, Jobs, Jobs?
|
||||||
wasFilledHere : bool option
|
WasFilledHere : bool option
|
||||||
|
|
||||||
/// Whether this is a legacy listing
|
/// Whether this is a legacy listing
|
||||||
isLegacy : bool
|
IsLegacy : bool
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Support functions for job listings
|
/// Support functions for job listings
|
||||||
module Listing =
|
module Listing =
|
||||||
|
|
||||||
/// An empty job listing
|
/// An empty job listing
|
||||||
let empty =
|
let empty = {
|
||||||
{ id = ListingId Guid.Empty
|
Id = ListingId Guid.Empty
|
||||||
citizenId = CitizenId Guid.Empty
|
CitizenId = CitizenId Guid.Empty
|
||||||
createdOn = Instant.MinValue
|
CreatedOn = Instant.MinValue
|
||||||
title = ""
|
Title = ""
|
||||||
continentId = ContinentId Guid.Empty
|
ContinentId = ContinentId Guid.Empty
|
||||||
region = ""
|
Region = ""
|
||||||
remoteWork = false
|
IsRemote = false
|
||||||
isExpired = false
|
IsExpired = false
|
||||||
updatedOn = Instant.MinValue
|
UpdatedOn = Instant.MinValue
|
||||||
text = Text ""
|
Text = Text ""
|
||||||
neededBy = None
|
NeededBy = None
|
||||||
wasFilledHere = None
|
WasFilledHere = None
|
||||||
isLegacy = false
|
IsLegacy = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -169,8 +170,8 @@ type SecurityInfo =
|
||||||
module SecurityInfo =
|
module SecurityInfo =
|
||||||
|
|
||||||
/// An empty set of security info
|
/// An empty set of security info
|
||||||
let empty =
|
let empty = {
|
||||||
{ Id = CitizenId Guid.Empty
|
Id = CitizenId Guid.Empty
|
||||||
FailedLogOnAttempts = 0
|
FailedLogOnAttempts = 0
|
||||||
AccountLocked = false
|
AccountLocked = false
|
||||||
Token = None
|
Token = None
|
||||||
|
@ -182,13 +183,13 @@ module SecurityInfo =
|
||||||
/// A skill the job seeker possesses
|
/// A skill the job seeker possesses
|
||||||
type Skill =
|
type Skill =
|
||||||
{ /// The ID of the skill
|
{ /// The ID of the skill
|
||||||
id : SkillId
|
Id : SkillId
|
||||||
|
|
||||||
/// A description of the skill
|
/// A description of the skill
|
||||||
description : string
|
Description : string
|
||||||
|
|
||||||
/// Notes regarding this skill (level, duration, etc.)
|
/// Notes regarding this skill (level, duration, etc.)
|
||||||
notes : string option
|
Notes : string option
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -196,63 +197,63 @@ type Skill =
|
||||||
[<CLIMutable; NoComparison; NoEquality>]
|
[<CLIMutable; NoComparison; NoEquality>]
|
||||||
type Profile =
|
type Profile =
|
||||||
{ /// The ID of the citizen to whom this profile belongs
|
{ /// The ID of the citizen to whom this profile belongs
|
||||||
id : CitizenId
|
Id : CitizenId
|
||||||
|
|
||||||
/// Whether this citizen is actively seeking employment
|
/// Whether this citizen is actively seeking employment
|
||||||
seekingEmployment : bool
|
IsSeekingEmployment : bool
|
||||||
|
|
||||||
/// Whether this citizen allows their profile to be a part of the publicly-viewable, anonymous data
|
/// Whether this citizen allows their profile to be a part of the publicly-viewable, anonymous data
|
||||||
isPublic : bool
|
IsPubliclySearchable : bool
|
||||||
|
|
||||||
/// Whether this citizen allows their profile to be viewed via a public link
|
/// Whether this citizen allows their profile to be viewed via a public link
|
||||||
isPublicLinkable : bool
|
IsPubliclyLinkable : bool
|
||||||
|
|
||||||
/// The ID of the continent on which the citizen resides
|
/// The ID of the continent on which the citizen resides
|
||||||
continentId : ContinentId
|
ContinentId : ContinentId
|
||||||
|
|
||||||
/// The region in which the citizen resides
|
/// The region in which the citizen resides
|
||||||
region : string
|
Region : string
|
||||||
|
|
||||||
/// Whether the citizen is looking for remote work
|
/// Whether the citizen is looking for remote work
|
||||||
remoteWork : bool
|
IsRemote : bool
|
||||||
|
|
||||||
/// Whether the citizen is looking for full-time work
|
/// Whether the citizen is looking for full-time work
|
||||||
fullTime : bool
|
IsFullTime : bool
|
||||||
|
|
||||||
/// The citizen's professional biography
|
/// The citizen's professional biography
|
||||||
biography : MarkdownString
|
Biography : MarkdownString
|
||||||
|
|
||||||
/// When the citizen last updated their profile
|
/// When the citizen last updated their profile
|
||||||
lastUpdatedOn : Instant
|
LastUpdatedOn : Instant
|
||||||
|
|
||||||
/// The citizen's experience (topical / chronological)
|
/// The citizen's experience (topical / chronological)
|
||||||
experience : MarkdownString option
|
Experience : MarkdownString option
|
||||||
|
|
||||||
/// Skills this citizen possesses
|
/// Skills this citizen possesses
|
||||||
skills : Skill list
|
Skills : Skill list
|
||||||
|
|
||||||
/// Whether this is a legacy profile
|
/// Whether this is a legacy profile
|
||||||
isLegacy : bool
|
IsLegacy : bool
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Support functions for Profiles
|
/// Support functions for Profiles
|
||||||
module Profile =
|
module Profile =
|
||||||
|
|
||||||
// An empty profile
|
// An empty profile
|
||||||
let empty =
|
let empty = {
|
||||||
{ id = CitizenId Guid.Empty
|
Id = CitizenId Guid.Empty
|
||||||
seekingEmployment = false
|
IsSeekingEmployment = false
|
||||||
isPublic = false
|
IsPubliclySearchable = false
|
||||||
isPublicLinkable = false
|
IsPubliclyLinkable = false
|
||||||
continentId = ContinentId Guid.Empty
|
ContinentId = ContinentId Guid.Empty
|
||||||
region = ""
|
Region = ""
|
||||||
remoteWork = false
|
IsRemote = false
|
||||||
fullTime = false
|
IsFullTime = false
|
||||||
biography = Text ""
|
Biography = Text ""
|
||||||
lastUpdatedOn = Instant.MinValue
|
LastUpdatedOn = Instant.MinValue
|
||||||
experience = None
|
Experience = None
|
||||||
skills = []
|
Skills = []
|
||||||
isLegacy = false
|
IsLegacy = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -260,33 +261,33 @@ module Profile =
|
||||||
[<CLIMutable; NoComparison; NoEquality>]
|
[<CLIMutable; NoComparison; NoEquality>]
|
||||||
type Success =
|
type Success =
|
||||||
{ /// The ID of the success report
|
{ /// The ID of the success report
|
||||||
id : SuccessId
|
Id : SuccessId
|
||||||
|
|
||||||
/// The ID of the citizen who wrote this success report
|
/// The ID of the citizen who wrote this success report
|
||||||
citizenId : CitizenId
|
CitizenId : CitizenId
|
||||||
|
|
||||||
/// When this success report was recorded
|
/// When this success report was recorded
|
||||||
recordedOn : Instant
|
RecordedOn : Instant
|
||||||
|
|
||||||
/// Whether the success was due, at least in part, to Jobs, Jobs, Jobs
|
/// Whether the success was due, at least in part, to Jobs, Jobs, Jobs
|
||||||
fromHere : bool
|
IsFromHere : bool
|
||||||
|
|
||||||
/// The source of this success (listing or profile)
|
/// The source of this success (listing or profile)
|
||||||
source : string
|
Source : string
|
||||||
|
|
||||||
/// The success story
|
/// The success story
|
||||||
story : MarkdownString option
|
Story : MarkdownString option
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Support functions for success stories
|
/// Support functions for success stories
|
||||||
module Success =
|
module Success =
|
||||||
|
|
||||||
/// An empty success story
|
/// An empty success story
|
||||||
let empty =
|
let empty = {
|
||||||
{ id = SuccessId Guid.Empty
|
Id = SuccessId Guid.Empty
|
||||||
citizenId = CitizenId Guid.Empty
|
CitizenId = CitizenId Guid.Empty
|
||||||
recordedOn = Instant.MinValue
|
RecordedOn = Instant.MinValue
|
||||||
fromHere = false
|
IsFromHere = false
|
||||||
source = ""
|
Source = ""
|
||||||
story = None
|
Story = None
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,27 +5,27 @@ module Table =
|
||||||
|
|
||||||
/// Citizens
|
/// Citizens
|
||||||
[<Literal>]
|
[<Literal>]
|
||||||
let Citizen = "citizen"
|
let Citizen = "jjj.citizen"
|
||||||
|
|
||||||
/// Continents
|
/// Continents
|
||||||
[<Literal>]
|
[<Literal>]
|
||||||
let Continent = "continent"
|
let Continent = "jjj.continent"
|
||||||
|
|
||||||
/// Job Listings
|
/// Job Listings
|
||||||
[<Literal>]
|
[<Literal>]
|
||||||
let Listing = "listing"
|
let Listing = "jjj.listing"
|
||||||
|
|
||||||
/// Employment Profiles
|
/// Employment Profiles
|
||||||
[<Literal>]
|
[<Literal>]
|
||||||
let Profile = "profile"
|
let Profile = "jjj.profile"
|
||||||
|
|
||||||
/// User Security Information
|
/// User Security Information
|
||||||
[<Literal>]
|
[<Literal>]
|
||||||
let SecurityInfo = "security_info"
|
let SecurityInfo = "jjj.security_info"
|
||||||
|
|
||||||
/// Success Stories
|
/// Success Stories
|
||||||
[<Literal>]
|
[<Literal>]
|
||||||
let Success = "success"
|
let Success = "jjj.success"
|
||||||
|
|
||||||
|
|
||||||
open Npgsql.FSharp
|
open Npgsql.FSharp
|
||||||
|
@ -46,15 +46,25 @@ module DataConnection =
|
||||||
|
|
||||||
/// Create tables
|
/// Create tables
|
||||||
let private createTables () = backgroundTask {
|
let private createTables () = backgroundTask {
|
||||||
let sql =
|
let sql = [
|
||||||
[ Table.Citizen; Table.Continent; Table.Listing; Table.Profile; Table.SecurityInfo; Table.Success ]
|
$"CREATE SCHEMA IF NOT EXISTS jjj"
|
||||||
|> List.map (fun table ->
|
$"CREATE TABLE IF NOT EXISTS {Table.Citizen} (id TEXT NOT NULL PRIMARY KEY, data JSONB NOT NULL)"
|
||||||
$"CREATE TABLE IF NOT EXISTS jjj.{table} (id TEXT NOT NULL PRIMARY KEY, data JSONB NOT NULL)")
|
$"CREATE TABLE IF NOT EXISTS {Table.Continent} (id TEXT NOT NULL PRIMARY KEY, data JSONB NOT NULL)"
|
||||||
|> String.concat "; "
|
$"CREATE TABLE IF NOT EXISTS {Table.Listing} (id TEXT NOT NULL PRIMARY KEY, data JSONB NOT NULL)"
|
||||||
|
$"CREATE TABLE IF NOT EXISTS {Table.Profile} (id TEXT NOT NULL PRIMARY KEY, data JSONB NOT NULL,
|
||||||
|
CONSTRAINT fk_profile_citizen FOREIGN KEY (id) REFERENCES {Table.Citizen} (id) ON DELETE CASCADE)"
|
||||||
|
$"CREATE TABLE IF NOT EXISTS {Table.SecurityInfo} (id TEXT NOT NULL PRIMARY KEY, data JSONB NOT NULL,
|
||||||
|
CONSTRAINT fk_security_info_citizen FOREIGN KEY (id) REFERENCES {Table.Citizen} (id) ON DELETE CASCADE)"
|
||||||
|
$"CREATE TABLE IF NOT EXISTS {Table.Success} (id TEXT NOT NULL PRIMARY KEY, data JSONB NOT NULL)"
|
||||||
|
$"CREATE INDEX IF NOT EXISTS idx_citizen_email ON {Table.Citizen} USING GIN ((data -> 'email'))"
|
||||||
|
$"CREATE INDEX IF NOT EXISTS idx_listing_citizen ON {Table.Listing} USING GIN ((data -> 'citizenId'))"
|
||||||
|
$"CREATE INDEX IF NOT EXISTS idx_listing_continent ON {Table.Listing} USING GIN ((data -> 'continentId'))"
|
||||||
|
$"CREATE INDEX IF NOT EXISTS idx_profile_continent ON {Table.Profile} USING GIN ((data -> 'continentId'))"
|
||||||
|
$"CREATE INDEX IF NOT EXISTS idx_success_citizen ON {Table.Success} USING GIN ((data -> 'citizenId'))"
|
||||||
|
]
|
||||||
let! _ =
|
let! _ =
|
||||||
connection ()
|
connection ()
|
||||||
|> Sql.executeTransactionAsync [ sql, [ [] ] ]
|
|> Sql.executeTransactionAsync (sql |> List.map (fun sql -> sql, [ [] ]))
|
||||||
// TODO: prudent indexes
|
|
||||||
()
|
()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -84,22 +94,26 @@ module private Helpers =
|
||||||
/// Get a document
|
/// Get a document
|
||||||
let getDocument<'T> table docId sqlProps : Task<'T option> = backgroundTask {
|
let getDocument<'T> table docId sqlProps : Task<'T option> = backgroundTask {
|
||||||
let! doc =
|
let! doc =
|
||||||
Sql.query $"SELECT * FROM jjj.%s{table} where id = @id" sqlProps
|
Sql.query $"SELECT * FROM %s{table} where id = @id" sqlProps
|
||||||
|> Sql.parameters [ "@id", Sql.string docId ]
|
|> Sql.parameters [ "@id", Sql.string docId ]
|
||||||
|> Sql.executeAsync toDocument
|
|> Sql.executeAsync toDocument
|
||||||
return List.tryHead doc
|
return List.tryHead doc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Serialize a document to JSON
|
||||||
|
let mkDoc<'T> (doc : 'T) =
|
||||||
|
JsonSerializer.Serialize<'T> (doc, Json.options)
|
||||||
|
|
||||||
/// Save a document
|
/// Save a document
|
||||||
let saveDocument<'T> table docId (doc : 'T) sqlProps = backgroundTask {
|
let saveDocument table docId sqlProps doc = backgroundTask {
|
||||||
let! _ =
|
let! _ =
|
||||||
Sql.query
|
Sql.query
|
||||||
$"INSERT INTO jjj.%s{table} (id, data) VALUES (@id, @data)
|
$"INSERT INTO %s{table} (id, data) VALUES (@id, @data)
|
||||||
ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data"
|
ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data"
|
||||||
sqlProps
|
sqlProps
|
||||||
|> Sql.parameters
|
|> Sql.parameters
|
||||||
[ "@id", Sql.string docId
|
[ "@id", Sql.string docId
|
||||||
"@data", Sql.jsonb (JsonSerializer.Serialize (doc, Json.options)) ]
|
"@data", Sql.jsonb doc ]
|
||||||
|> Sql.executeNonQueryAsync
|
|> Sql.executeNonQueryAsync
|
||||||
()
|
()
|
||||||
}
|
}
|
||||||
|
@ -128,59 +142,60 @@ module Citizens =
|
||||||
let deleteById citizenId = backgroundTask {
|
let deleteById citizenId = backgroundTask {
|
||||||
let! _ =
|
let! _ =
|
||||||
connection ()
|
connection ()
|
||||||
|> Sql.executeTransactionAsync [
|
|> Sql.query $"
|
||||||
"DELETE FROM jjj.success WHERE data->>'citizenId' = @id;
|
DELETE FROM {Table.Success} WHERE data ->> 'citizenId' = @id;
|
||||||
DELETE FROM jjj.listing WHERE data->>'citizenId' = @id;
|
DELETE FROM {Table.Listing} WHERE data ->> 'citizenId' = @id;
|
||||||
DELETE FROM jjj.profile WHERE id = @id;
|
DELETE FROM {Table.Citizen} WHERE id = @id"
|
||||||
DELETE FROM jjj.security_info WHERE id = @id;
|
|> Sql.parameters [ "@id", Sql.string (CitizenId.toString citizenId) ]
|
||||||
DELETE FROM jjj.citizen WHERE id = @id",
|
|> Sql.executeNonQueryAsync
|
||||||
[ [ "@id", Sql.string (CitizenId.toString citizenId) ] ]
|
|
||||||
]
|
|
||||||
()
|
()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find a citizen by their ID
|
/// Find a citizen by their ID
|
||||||
let findById citizenId = backgroundTask {
|
let findById citizenId = backgroundTask {
|
||||||
match! connection () |> getDocument<Citizen> Table.Citizen (CitizenId.toString citizenId) with
|
match! connection () |> getDocument<Citizen> Table.Citizen (CitizenId.toString citizenId) with
|
||||||
| Some c when not c.isLegacy -> return Some c
|
| Some c when not c.IsLegacy -> return Some c
|
||||||
| Some _
|
| Some _
|
||||||
| None -> return None
|
| None -> return None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save a citizen
|
/// Save a citizen
|
||||||
let save (citizen : Citizen) =
|
let save (citizen : Citizen) =
|
||||||
connection () |> saveDocument Table.Citizen (CitizenId.toString citizen.id) citizen
|
connection () |> saveDocument Table.Citizen (CitizenId.toString citizen.Id) <| mkDoc citizen
|
||||||
|
|
||||||
/// Attempt a user log on
|
/// Attempt a user log on
|
||||||
let tryLogOn email (pwCheck : string -> bool) now = backgroundTask {
|
let tryLogOn email (pwCheck : string -> bool) now = backgroundTask {
|
||||||
let connProps = connection ()
|
let connProps = connection ()
|
||||||
let! tryCitizen =
|
let! tryCitizen =
|
||||||
connProps
|
connProps
|
||||||
|> Sql.query $"SELECT * FROM jjj.{Table.Citizen} WHERE data->>email = @email AND data->>isValue <> 'true'"
|
|> Sql.query $"
|
||||||
|
SELECT *
|
||||||
|
FROM {Table.Citizen}
|
||||||
|
WHERE data ->> 'email' = @email
|
||||||
|
AND data ->> 'isLegacy' = 'false'"
|
||||||
|> Sql.parameters [ "@email", Sql.string email ]
|
|> Sql.parameters [ "@email", Sql.string email ]
|
||||||
|> Sql.executeAsync toDocument<Citizen>
|
|> Sql.executeAsync toDocument<Citizen>
|
||||||
match List.tryHead tryCitizen with
|
match List.tryHead tryCitizen with
|
||||||
| Some citizen ->
|
| Some citizen ->
|
||||||
let citizenId = CitizenId.toString citizen.id
|
let citizenId = CitizenId.toString citizen.Id
|
||||||
let! tryInfo = getDocument<SecurityInfo> Table.SecurityInfo citizenId connProps
|
let! tryInfo = getDocument<SecurityInfo> Table.SecurityInfo citizenId connProps
|
||||||
let! info = backgroundTask {
|
let! info = backgroundTask {
|
||||||
match tryInfo with
|
match tryInfo with
|
||||||
| Some it -> return it
|
| Some it -> return it
|
||||||
| None ->
|
| None ->
|
||||||
let it = { SecurityInfo.empty with Id = citizen.id }
|
let it = { SecurityInfo.empty with Id = citizen.Id }
|
||||||
do! saveDocument Table.SecurityInfo citizenId it connProps
|
do! saveDocument Table.SecurityInfo citizenId connProps (mkDoc it)
|
||||||
return it
|
return it
|
||||||
}
|
}
|
||||||
if info.AccountLocked then return Error "Log on unsuccessful (Account Locked)"
|
if info.AccountLocked then return Error "Log on unsuccessful (Account Locked)"
|
||||||
elif pwCheck citizen.passwordHash then
|
elif pwCheck citizen.PasswordHash then
|
||||||
do! saveDocument Table.SecurityInfo citizenId { info with FailedLogOnAttempts = 0 } connProps
|
do! saveDocument Table.SecurityInfo citizenId connProps (mkDoc { info with FailedLogOnAttempts = 0 })
|
||||||
do! saveDocument Table.Citizen citizenId { citizen with lastSeenOn = now } connProps
|
do! saveDocument Table.Citizen citizenId connProps (mkDoc { citizen with LastSeenOn = now })
|
||||||
return Ok { citizen with lastSeenOn = now }
|
return Ok { citizen with LastSeenOn = now }
|
||||||
else
|
else
|
||||||
let locked = info.FailedLogOnAttempts >= 4
|
let locked = info.FailedLogOnAttempts >= 4
|
||||||
do! saveDocument Table.SecurityInfo citizenId
|
do! mkDoc { info with FailedLogOnAttempts = info.FailedLogOnAttempts + 1; AccountLocked = locked }
|
||||||
{ info with FailedLogOnAttempts = info.FailedLogOnAttempts + 1; AccountLocked = locked }
|
|> saveDocument Table.SecurityInfo citizenId connProps
|
||||||
connProps
|
|
||||||
return Error $"""Log on unsuccessful{if locked then " - Account is now locked" else ""}"""
|
return Error $"""Log on unsuccessful{if locked then " - Account is now locked" else ""}"""
|
||||||
| None -> return Error "Log on unsuccessful"
|
| None -> return Error "Log on unsuccessful"
|
||||||
}
|
}
|
||||||
|
@ -193,7 +208,7 @@ module Continents =
|
||||||
/// Retrieve all continents
|
/// Retrieve all continents
|
||||||
let all () =
|
let all () =
|
||||||
connection ()
|
connection ()
|
||||||
|> Sql.query $"SELECT * FROM jjj.{Table.Continent}"
|
|> Sql.query $"SELECT * FROM {Table.Continent}"
|
||||||
|> Sql.executeAsync toDocument<Continent>
|
|> Sql.executeAsync toDocument<Continent>
|
||||||
|
|
||||||
/// Retrieve a continent by its ID
|
/// Retrieve a continent by its ID
|
||||||
|
@ -210,8 +225,8 @@ module Listings =
|
||||||
/// The SQL to select a listing view
|
/// The SQL to select a listing view
|
||||||
let viewSql =
|
let viewSql =
|
||||||
$"SELECT l.*, c.data AS cont_data
|
$"SELECT l.*, c.data AS cont_data
|
||||||
FROM jjj.{Table.Listing} l
|
FROM {Table.Listing} l
|
||||||
INNER JOIN jjj.{Table.Continent} c ON c.id = l.data->>'continentId'"
|
INNER JOIN {Table.Continent} c ON c.id = l.data ->> 'continentId'"
|
||||||
|
|
||||||
/// Map a result for a listing view
|
/// Map a result for a listing view
|
||||||
let private toListingForView row =
|
let private toListingForView row =
|
||||||
|
@ -220,14 +235,14 @@ module Listings =
|
||||||
/// Find all job listings posted by the given citizen
|
/// Find all job listings posted by the given citizen
|
||||||
let findByCitizen citizenId =
|
let findByCitizen citizenId =
|
||||||
connection ()
|
connection ()
|
||||||
|> Sql.query $"{viewSql} WHERE l.data->>'citizenId' = @citizenId AND l.data->>'isLegacy' <> 'true'"
|
|> Sql.query $"{viewSql} WHERE l.data ->> 'citizenId' = @citizenId AND l.data ->> 'isLegacy' = 'false'"
|
||||||
|> Sql.parameters [ "@citizenId", Sql.string (CitizenId.toString citizenId) ]
|
|> Sql.parameters [ "@citizenId", Sql.string (CitizenId.toString citizenId) ]
|
||||||
|> Sql.executeAsync toListingForView
|
|> Sql.executeAsync toListingForView
|
||||||
|
|
||||||
/// Find a listing by its ID
|
/// Find a listing by its ID
|
||||||
let findById listingId = backgroundTask {
|
let findById listingId = backgroundTask {
|
||||||
match! connection () |> getDocument<Listing> Table.Listing (ListingId.toString listingId) with
|
match! connection () |> getDocument<Listing> Table.Listing (ListingId.toString listingId) with
|
||||||
| Some listing when not listing.isLegacy -> return Some listing
|
| Some listing when not listing.IsLegacy -> return Some listing
|
||||||
| Some _
|
| Some _
|
||||||
| None -> return None
|
| None -> return None
|
||||||
}
|
}
|
||||||
|
@ -236,7 +251,7 @@ module Listings =
|
||||||
let findByIdForView listingId = backgroundTask {
|
let findByIdForView listingId = backgroundTask {
|
||||||
let! tryListing =
|
let! tryListing =
|
||||||
connection ()
|
connection ()
|
||||||
|> Sql.query $"{viewSql} WHERE id = @id AND l.data->>'isLegacy' <> 'true'"
|
|> Sql.query $"{viewSql} WHERE id = @id AND l.data ->> 'isLegacy' = 'false'"
|
||||||
|> Sql.parameters [ "@id", Sql.string (ListingId.toString listingId) ]
|
|> Sql.parameters [ "@id", Sql.string (ListingId.toString listingId) ]
|
||||||
|> Sql.executeAsync toListingForView
|
|> Sql.executeAsync toListingForView
|
||||||
return List.tryHead tryListing
|
return List.tryHead tryListing
|
||||||
|
@ -244,7 +259,7 @@ module Listings =
|
||||||
|
|
||||||
/// Save a listing
|
/// Save a listing
|
||||||
let save (listing : Listing) =
|
let save (listing : Listing) =
|
||||||
connection () |> saveDocument Table.Listing (ListingId.toString listing.id) listing
|
connection () |> saveDocument Table.Listing (ListingId.toString listing.Id) <| mkDoc listing
|
||||||
|
|
||||||
/// Search job listings
|
/// Search job listings
|
||||||
let search (search : ListingSearch) =
|
let search (search : ListingSearch) =
|
||||||
|
@ -256,7 +271,7 @@ module Listings =
|
||||||
| Some region -> "l.data ->> 'region' ILIKE @region", [ "@region", like region ]
|
| Some region -> "l.data ->> 'region' ILIKE @region", [ "@region", like region ]
|
||||||
| None -> ()
|
| None -> ()
|
||||||
if search.remoteWork <> "" then
|
if search.remoteWork <> "" then
|
||||||
"l.data->>'remoteWork' = @remote", [ "@remote", jsonBool (search.remoteWork = "yes") ]
|
"l.data ->> 'isRemote' = @remote", [ "@remote", jsonBool (search.remoteWork = "yes") ]
|
||||||
match search.text with
|
match search.text with
|
||||||
| Some text -> "l.data ->> 'text' ILIKE @text", [ "@text", like text ]
|
| Some text -> "l.data ->> 'text' ILIKE @text", [ "@text", like text ]
|
||||||
| None -> ()
|
| None -> ()
|
||||||
|
@ -277,14 +292,14 @@ module Profiles =
|
||||||
/// Count the current profiles
|
/// Count the current profiles
|
||||||
let count () =
|
let count () =
|
||||||
connection ()
|
connection ()
|
||||||
|> Sql.query $"SELECT COUNT(id) AS the_count FROM jjj.{Table.Profile} WHERE data->>'isLegacy' <> 'true'"
|
|> Sql.query $"SELECT COUNT(id) AS the_count FROM {Table.Profile} WHERE data ->> 'isLegacy' = 'false'"
|
||||||
|> Sql.executeRowAsync (fun row -> row.int64 "the_count")
|
|> Sql.executeRowAsync (fun row -> row.int64 "the_count")
|
||||||
|
|
||||||
/// Delete a profile by its ID
|
/// Delete a profile by its ID
|
||||||
let deleteById citizenId = backgroundTask {
|
let deleteById citizenId = backgroundTask {
|
||||||
let! _ =
|
let! _ =
|
||||||
connection ()
|
connection ()
|
||||||
|> Sql.query $"DELETE FROM jjj.{Table.Profile} WHERE id = @id"
|
|> Sql.query $"DELETE FROM {Table.Profile} WHERE id = @id"
|
||||||
|> Sql.parameters [ "@id", Sql.string (CitizenId.toString citizenId) ]
|
|> Sql.parameters [ "@id", Sql.string (CitizenId.toString citizenId) ]
|
||||||
|> Sql.executeNonQueryAsync
|
|> Sql.executeNonQueryAsync
|
||||||
()
|
()
|
||||||
|
@ -293,7 +308,7 @@ module Profiles =
|
||||||
/// Find a profile by citizen ID
|
/// Find a profile by citizen ID
|
||||||
let findById citizenId = backgroundTask {
|
let findById citizenId = backgroundTask {
|
||||||
match! connection () |> getDocument<Profile> Table.Profile (CitizenId.toString citizenId) with
|
match! connection () |> getDocument<Profile> Table.Profile (CitizenId.toString citizenId) with
|
||||||
| Some profile when not profile.isLegacy -> return Some profile
|
| Some profile when not profile.IsLegacy -> return Some profile
|
||||||
| Some _
|
| Some _
|
||||||
| None -> return None
|
| None -> return None
|
||||||
}
|
}
|
||||||
|
@ -304,9 +319,9 @@ module Profiles =
|
||||||
connection ()
|
connection ()
|
||||||
|> Sql.query $"
|
|> Sql.query $"
|
||||||
SELECT p.*, c.data AS cit_data, o.data AS cont_data
|
SELECT p.*, c.data AS cit_data, o.data AS cont_data
|
||||||
FROM jjj.{Table.Profile} p
|
FROM {Table.Profile} p
|
||||||
INNER JOIN jjj.{Table.Citizen} c ON c.id = p.id
|
INNER JOIN {Table.Citizen} c ON c.id = p.id
|
||||||
INNER JOIN jjj.{Table.Continent} o ON o.id = p.data->>'continentId'
|
INNER JOIN {Table.Continent} o ON o.id = p.data ->> 'continentId'
|
||||||
WHERE p.id = @id
|
WHERE p.id = @id
|
||||||
AND p.data ->> 'isLegacy' = 'false'"
|
AND p.data ->> 'isLegacy' = 'false'"
|
||||||
|> Sql.parameters [ "@id", Sql.string (CitizenId.toString citizenId) ]
|
|> Sql.parameters [ "@id", Sql.string (CitizenId.toString citizenId) ]
|
||||||
|
@ -320,7 +335,7 @@ module Profiles =
|
||||||
|
|
||||||
/// Save a profile
|
/// Save a profile
|
||||||
let save (profile : Profile) =
|
let save (profile : Profile) =
|
||||||
connection () |> saveDocument Table.Profile (CitizenId.toString profile.id) profile
|
connection () |> saveDocument Table.Profile (CitizenId.toString profile.Id) <| mkDoc profile
|
||||||
|
|
||||||
/// Search profiles (logged-on users)
|
/// Search profiles (logged-on users)
|
||||||
let search (search : ProfileSearch) = backgroundTask {
|
let search (search : ProfileSearch) = backgroundTask {
|
||||||
|
@ -335,27 +350,28 @@ module Profiles =
|
||||||
| None -> ()
|
| None -> ()
|
||||||
match search.bioExperience with
|
match search.bioExperience with
|
||||||
| Some text ->
|
| Some text ->
|
||||||
"(p.data->>'biography' ILIKE @text OR p.data->>'experience' ILIKE @text)", [ "@text", Sql.string text ]
|
"(p.data ->> 'biography' ILIKE @text OR p.data ->> 'experience' ILIKE @text)",
|
||||||
|
[ "@text", Sql.string text ]
|
||||||
| None -> ()
|
| None -> ()
|
||||||
]
|
]
|
||||||
let! results =
|
let! results =
|
||||||
connection ()
|
connection ()
|
||||||
|> Sql.query $"
|
|> Sql.query $"
|
||||||
SELECT p.*, c.data AS cit_data
|
SELECT p.*, c.data AS cit_data
|
||||||
FROM jjj.{Table.Profile} p
|
FROM {Table.Profile} p
|
||||||
INNER JOIN jjj.{Table.Citizen} c ON c.id = p.id
|
INNER JOIN {Table.Citizen} c ON c.id = p.id
|
||||||
WHERE p.data ->> 'isLegacy' = 'false'
|
WHERE p.data ->> 'isLegacy' = 'false'
|
||||||
{searchSql searches}"
|
{searchSql searches}"
|
||||||
|> Sql.parameters (searches |> List.collect snd)
|
|> Sql.parameters (searches |> List.collect snd)
|
||||||
|> Sql.executeAsync (fun row ->
|
|> Sql.executeAsync (fun row ->
|
||||||
let profile = toDocument<Profile> row
|
let profile = toDocument<Profile> row
|
||||||
let citizen = toDocumentFrom<Citizen> "cit_data" row
|
let citizen = toDocumentFrom<Citizen> "cit_data" row
|
||||||
{ citizenId = profile.id
|
{ citizenId = profile.Id
|
||||||
displayName = Citizen.name citizen
|
displayName = Citizen.name citizen
|
||||||
seekingEmployment = profile.seekingEmployment
|
seekingEmployment = profile.IsSeekingEmployment
|
||||||
remoteWork = profile.remoteWork
|
remoteWork = profile.IsRemote
|
||||||
fullTime = profile.fullTime
|
fullTime = profile.IsFullTime
|
||||||
lastUpdatedOn = profile.lastUpdatedOn
|
lastUpdatedOn = profile.LastUpdatedOn
|
||||||
})
|
})
|
||||||
return results |> List.sortBy (fun psr -> psr.displayName.ToLowerInvariant ())
|
return results |> List.sortBy (fun psr -> psr.displayName.ToLowerInvariant ())
|
||||||
}
|
}
|
||||||
|
@ -379,21 +395,21 @@ module Profiles =
|
||||||
connection ()
|
connection ()
|
||||||
|> Sql.query $"
|
|> Sql.query $"
|
||||||
SELECT p.*, c.data AS cont_data
|
SELECT p.*, c.data AS cont_data
|
||||||
FROM jjj.{Table.Profile} p
|
FROM {Table.Profile} p
|
||||||
INNER JOIN jjj.{Table.Continent} c ON c.id = p.data->>'continentId'
|
INNER JOIN {Table.Continent} c ON c.id = p.data ->> 'continentId'
|
||||||
WHERE p.data ->> 'isPublic' = 'true'
|
WHERE p.data ->> 'isPublic' = 'true'
|
||||||
AND p.data ->> 'isLegacy' = 'false'
|
AND p.data ->> 'isLegacy' = 'false'
|
||||||
{searchSql searches}"
|
{searchSql searches}"
|
||||||
|> Sql.executeAsync (fun row ->
|
|> Sql.executeAsync (fun row ->
|
||||||
let profile = toDocument<Profile> row
|
let profile = toDocument<Profile> row
|
||||||
let continent = toDocumentFrom<Continent> "cont_data" row
|
let continent = toDocumentFrom<Continent> "cont_data" row
|
||||||
{ continent = continent.name
|
{ continent = continent.Name
|
||||||
region = profile.region
|
region = profile.Region
|
||||||
remoteWork = profile.remoteWork
|
remoteWork = profile.IsRemote
|
||||||
skills = profile.skills
|
skills = profile.Skills
|
||||||
|> List.map (fun s ->
|
|> List.map (fun s ->
|
||||||
let notes = match s.notes with Some n -> $" ({n})" | None -> ""
|
let notes = match s.Notes with Some n -> $" ({n})" | None -> ""
|
||||||
$"{s.description}{notes}")
|
$"{s.Description}{notes}")
|
||||||
})
|
})
|
||||||
|
|
||||||
/// Success story data access functions
|
/// Success story data access functions
|
||||||
|
@ -405,18 +421,18 @@ module Successes =
|
||||||
connection ()
|
connection ()
|
||||||
|> Sql.query $"
|
|> Sql.query $"
|
||||||
SELECT s.*, c.data AS cit_data
|
SELECT s.*, c.data AS cit_data
|
||||||
FROM jjj.{Table.Success} s
|
FROM {Table.Success} s
|
||||||
INNER JOIN jjj.{Table.Citizen} c ON c.id = s.data->>'citizenId'
|
INNER JOIN {Table.Citizen} c ON c.id = s.data ->> 'citizenId'
|
||||||
ORDER BY s.data ->> 'recordedOn' DESC"
|
ORDER BY s.data ->> 'recordedOn' DESC"
|
||||||
|> Sql.executeAsync (fun row ->
|
|> Sql.executeAsync (fun row ->
|
||||||
let success = toDocument<Success> row
|
let success = toDocument<Success> row
|
||||||
let citizen = toDocumentFrom<Citizen> "cit_data" row
|
let citizen = toDocumentFrom<Citizen> "cit_data" row
|
||||||
{ id = success.id
|
{ id = success.Id
|
||||||
citizenId = success.citizenId
|
citizenId = success.CitizenId
|
||||||
citizenName = Citizen.name citizen
|
citizenName = Citizen.name citizen
|
||||||
recordedOn = success.recordedOn
|
recordedOn = success.RecordedOn
|
||||||
fromHere = success.fromHere
|
fromHere = success.IsFromHere
|
||||||
hasStory = Option.isSome success.story
|
hasStory = Option.isSome success.Story
|
||||||
})
|
})
|
||||||
|
|
||||||
/// Find a success story by its ID
|
/// Find a success story by its ID
|
||||||
|
@ -425,5 +441,5 @@ module Successes =
|
||||||
|
|
||||||
/// Save a success story
|
/// Save a success story
|
||||||
let save (success : Success) =
|
let save (success : Success) =
|
||||||
connection () |> saveDocument Table.Success (SuccessId.toString success.id) success
|
connection () |> saveDocument Table.Success (SuccessId.toString success.Id) <| mkDoc success
|
||||||
|
|
|
@ -16,9 +16,8 @@
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="FSharp.SystemTextJson" Version="0.19.13" />
|
<PackageReference Include="FSharp.SystemTextJson" Version="0.19.13" />
|
||||||
<PackageReference Include="Marten" Version="5.8.0" />
|
|
||||||
<PackageReference Include="Marten.NodaTime" Version="5.8.0" />
|
|
||||||
<PackageReference Update="FSharp.Core" Version="6.0.5" />
|
<PackageReference Update="FSharp.Core" Version="6.0.5" />
|
||||||
|
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.0.0" />
|
||||||
<PackageReference Include="Npgsql" Version="6.0.6" />
|
<PackageReference Include="Npgsql" Version="6.0.6" />
|
||||||
<PackageReference Include="Npgsql.FSharp" Version="5.3.0" />
|
<PackageReference Include="Npgsql.FSharp" Version="5.3.0" />
|
||||||
<PackageReference Include="Npgsql.NodaTime" Version="6.0.6" />
|
<PackageReference Include="Npgsql.NodaTime" Version="6.0.6" />
|
||||||
|
|
|
@ -12,6 +12,9 @@ type WrappedJsonConverter<'T> (wrap : string -> 'T, unwrap : 'T -> string) =
|
||||||
override _.Write(writer, value, _) =
|
override _.Write(writer, value, _) =
|
||||||
writer.WriteStringValue (unwrap value)
|
writer.WriteStringValue (unwrap value)
|
||||||
|
|
||||||
|
open NodaTime
|
||||||
|
open NodaTime.Serialization.SystemTextJson
|
||||||
|
|
||||||
/// JsonSerializer options that use the custom converters
|
/// JsonSerializer options that use the custom converters
|
||||||
let options =
|
let options =
|
||||||
let opts = JsonSerializerOptions ()
|
let opts = JsonSerializerOptions ()
|
||||||
|
@ -24,4 +27,6 @@ let options =
|
||||||
JsonFSharpConverter ()
|
JsonFSharpConverter ()
|
||||||
]
|
]
|
||||||
|> List.iter opts.Converters.Add
|
|> List.iter opts.Converters.Add
|
||||||
|
let _ = opts.ConfigureForNodaTime DateTimeZoneProviders.Tzdb
|
||||||
|
opts.PropertyNamingPolicy <- JsonNamingPolicy.CamelCase
|
||||||
opts
|
opts
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
|
|
||||||
|
open System.Text.Json
|
||||||
open Microsoft.Extensions.Configuration
|
open Microsoft.Extensions.Configuration
|
||||||
|
|
||||||
/// Data access for v2 Jobs, Jobs, Jobs
|
/// Data access for v2 Jobs, Jobs, Jobs
|
||||||
|
@ -44,8 +45,8 @@ let r = RethinkDb.Driver.RethinkDB.R
|
||||||
open JobsJobsJobs.Data
|
open JobsJobsJobs.Data
|
||||||
open JobsJobsJobs.Domain
|
open JobsJobsJobs.Domain
|
||||||
open Newtonsoft.Json.Linq
|
open Newtonsoft.Json.Linq
|
||||||
open NodaTime
|
|
||||||
open NodaTime.Text
|
open NodaTime.Text
|
||||||
|
open Npgsql.FSharp
|
||||||
open RethinkDb.Driver.FSharp.Functions
|
open RethinkDb.Driver.FSharp.Functions
|
||||||
|
|
||||||
/// Retrieve an instant from a JObject field
|
/// Retrieve an instant from a JObject field
|
||||||
|
@ -62,32 +63,155 @@ task {
|
||||||
// Establish database connections
|
// Establish database connections
|
||||||
let cfg = ConfigurationBuilder().AddJsonFile("appsettings.json").Build ()
|
let cfg = ConfigurationBuilder().AddJsonFile("appsettings.json").Build ()
|
||||||
use rethinkConn = Rethink.Startup.createConnection (cfg.GetConnectionString "RethinkDB")
|
use rethinkConn = Rethink.Startup.createConnection (cfg.GetConnectionString "RethinkDB")
|
||||||
match! DataConnection.setUp cfg with
|
do! DataConnection.setUp cfg
|
||||||
| Ok _ -> ()
|
let pgConn = DataConnection.connection ()
|
||||||
| Error msg -> failwith msg
|
|
||||||
|
|
||||||
// Migrate citizens
|
let getOld table =
|
||||||
let! oldCitizens =
|
fromTable table
|
||||||
fromTable Rethink.Table.Citizen
|
|
||||||
|> runResult<JObject list>
|
|> runResult<JObject list>
|
||||||
|> withRetryOnce
|
|> withRetryOnce
|
||||||
|> withConn rethinkConn
|
|> withConn rethinkConn
|
||||||
|
|
||||||
|
// Migrate citizens
|
||||||
|
let! oldCitizens = getOld Rethink.Table.Citizen
|
||||||
let newCitizens =
|
let newCitizens =
|
||||||
oldCitizens
|
oldCitizens
|
||||||
|> List.map (fun c ->
|
|> List.map (fun c ->
|
||||||
let user = c["mastodonUser"].Value<string> ()
|
let user = c["mastodonUser"].Value<string> ()
|
||||||
{ Citizen.empty with
|
{ Citizen.empty with
|
||||||
id = CitizenId.ofString (c["id"].Value<string> ())
|
Id = CitizenId.ofString (c["id"].Value<string> ())
|
||||||
joinedOn = getInstant c "joinedOn"
|
JoinedOn = getInstant c "joinedOn"
|
||||||
lastSeenOn = getInstant c "lastSeenOn"
|
LastSeenOn = getInstant c "lastSeenOn"
|
||||||
email = $"""{user}@{c["instance"].Value<string> ()}"""
|
Email = $"""{user}@{c["instance"].Value<string> ()}"""
|
||||||
firstName = user
|
FirstName = user
|
||||||
lastName = user
|
LastName = user
|
||||||
isLegacy = true
|
IsLegacy = true
|
||||||
})
|
})
|
||||||
for citizen in newCitizens do
|
for citizen in newCitizens do
|
||||||
do! Citizens.save citizen
|
do! Citizens.save citizen
|
||||||
printfn $"** Migrated {List.length newCitizens} citizen(s)"
|
let! _ =
|
||||||
()
|
pgConn
|
||||||
|
|> Sql.executeTransactionAsync [
|
||||||
|
$"INSERT INTO jjj.{Table.SecurityInfo} VALUES (@id, @data)",
|
||||||
|
newCitizens |> List.map (fun c ->
|
||||||
|
let info = { SecurityInfo.empty with Id = c.Id; AccountLocked = true }
|
||||||
|
[ "@id", Sql.string (CitizenId.toString c.Id)
|
||||||
|
"@data", Sql.jsonb (JsonSerializer.Serialize (info, Json.options))
|
||||||
|
])
|
||||||
|
]
|
||||||
|
printfn $"** Migrated {List.length newCitizens} citizens"
|
||||||
|
|
||||||
|
// Migrate continents
|
||||||
|
let! oldContinents = getOld Rethink.Table.Continent
|
||||||
|
let newContinents =
|
||||||
|
oldContinents
|
||||||
|
|> List.map (fun c ->
|
||||||
|
{ Continent.empty with
|
||||||
|
Id = ContinentId.ofString (c["id"].Value<string> ())
|
||||||
|
Name = c["name"].Value<string> ()
|
||||||
|
})
|
||||||
|
let! _ =
|
||||||
|
pgConn
|
||||||
|
|> Sql.executeTransactionAsync [
|
||||||
|
"INSERT INTO jjj.continent VALUES (@id, @data)",
|
||||||
|
newContinents |> List.map (fun c -> [
|
||||||
|
"@id", Sql.string (ContinentId.toString c.Id)
|
||||||
|
"@data", Sql.jsonb (JsonSerializer.Serialize (c, Json.options))
|
||||||
|
])
|
||||||
|
]
|
||||||
|
printfn $"** Migrated {List.length newContinents} continents"
|
||||||
|
|
||||||
|
// Migrate profiles
|
||||||
|
let! oldProfiles = getOld Rethink.Table.Profile
|
||||||
|
let newProfiles =
|
||||||
|
oldProfiles
|
||||||
|
|> List.map (fun p ->
|
||||||
|
let experience = p["experience"].Value<string> ()
|
||||||
|
{ Profile.empty with
|
||||||
|
Id = CitizenId.ofString (p["id"].Value<string> ())
|
||||||
|
IsSeekingEmployment = p["seekingEmployment"].Value<bool> ()
|
||||||
|
IsPubliclySearchable = p["isPublic"].Value<bool> ()
|
||||||
|
ContinentId = ContinentId.ofString (p["continentId"].Value<string> ())
|
||||||
|
Region = p["region"].Value<string> ()
|
||||||
|
IsRemote = p["remoteWork"].Value<bool> ()
|
||||||
|
IsFullTime = p["fullTime"].Value<bool> ()
|
||||||
|
Biography = Text (p["biography"].Value<string> ())
|
||||||
|
LastUpdatedOn = getInstant p "lastUpdatedOn"
|
||||||
|
Experience = if isNull experience then None else Some (Text experience)
|
||||||
|
Skills = p["skills"].Children()
|
||||||
|
|> Seq.map (fun s ->
|
||||||
|
let notes = s["notes"].Value<string> ()
|
||||||
|
{ Skill.Id = SkillId.ofString (s["id"].Value<string> ())
|
||||||
|
Description = s["description"].Value<string> ()
|
||||||
|
Notes = if isNull notes then None else Some notes
|
||||||
|
})
|
||||||
|
|> List.ofSeq
|
||||||
|
IsLegacy = true
|
||||||
|
})
|
||||||
|
for profile in newProfiles do
|
||||||
|
do! Profiles.save profile
|
||||||
|
printfn $"** Migrated {List.length newProfiles} profiles"
|
||||||
|
|
||||||
|
// Migrate listings
|
||||||
|
let! oldListings = getOld Rethink.Table.Listing
|
||||||
|
let newListings =
|
||||||
|
oldListings
|
||||||
|
|> List.map (fun l ->
|
||||||
|
let neededBy = l["neededBy"].Value<string> ()
|
||||||
|
let wasFilledHere = l["wasFilledHere"].Value<string> ()
|
||||||
|
{ Listing.empty with
|
||||||
|
Id = ListingId.ofString (l["id"].Value<string> ())
|
||||||
|
CitizenId = CitizenId.ofString (l["citizenId"].Value<string> ())
|
||||||
|
CreatedOn = getInstant l "createdOn"
|
||||||
|
Title = l["title"].Value<string> ()
|
||||||
|
ContinentId = ContinentId.ofString (l["continentId"].Value<string> ())
|
||||||
|
Region = l["region"].Value<string> ()
|
||||||
|
IsRemote = l["remoteWork"].Value<bool> ()
|
||||||
|
IsExpired = l["isExpired"].Value<bool> ()
|
||||||
|
UpdatedOn = getInstant l "updatedOn"
|
||||||
|
Text = Text (l["text"].Value<string> ())
|
||||||
|
NeededBy = if isNull neededBy then None else
|
||||||
|
match LocalDatePattern.Iso.Parse neededBy with
|
||||||
|
| it when it.Success -> Some it.Value
|
||||||
|
| it ->
|
||||||
|
eprintfn $"Error parsing date - {it.Exception.Message}"
|
||||||
|
None
|
||||||
|
WasFilledHere = if isNull wasFilledHere then None else Some (bool.Parse wasFilledHere)
|
||||||
|
IsLegacy = true
|
||||||
|
})
|
||||||
|
for listing in newListings do
|
||||||
|
do! Listings.save listing
|
||||||
|
printfn $"** Migrated {List.length newListings} listings"
|
||||||
|
|
||||||
|
// Migrate success stories
|
||||||
|
let! oldSuccesses = getOld Rethink.Table.Success
|
||||||
|
let newSuccesses =
|
||||||
|
oldSuccesses
|
||||||
|
|> List.map (fun s ->
|
||||||
|
let story = s["story"].Value<string> ()
|
||||||
|
{ Success.empty with
|
||||||
|
Id = SuccessId.ofString (s["id"].Value<string> ())
|
||||||
|
CitizenId = CitizenId.ofString (s["citizenId"].Value<string> ())
|
||||||
|
RecordedOn = getInstant s "recordedOn"
|
||||||
|
Source = s["source"].Value<string> ()
|
||||||
|
Story = if isNull story then None else Some (Text story)
|
||||||
|
})
|
||||||
|
for success in newSuccesses do
|
||||||
|
do! Successes.save success
|
||||||
|
printfn $"** Migrated {List.length newSuccesses} successes"
|
||||||
|
|
||||||
|
// Delete any citizens who have no profile, no listing, and no success story recorded
|
||||||
|
let! deleted =
|
||||||
|
pgConn
|
||||||
|
|> Sql.query $"
|
||||||
|
DELETE FROM jjj.{Table.Citizen}
|
||||||
|
WHERE id NOT IN (SELECT id FROM jjj.{Table.Profile})
|
||||||
|
AND id NOT IN (SELECT DISTINCT data->>'citizenId' FROM jjj.{Table.Listing})
|
||||||
|
AND id NOT IN (SELECT DISTINCT data->>'citizenId' FROM jjj.{Table.Success})"
|
||||||
|
|> Sql.executeNonQueryAsync
|
||||||
|
printfn $"** Deleted {deleted} citizens who had no profile, listings, or success stories"
|
||||||
|
|
||||||
|
printfn ""
|
||||||
|
printfn "Migration complete"
|
||||||
} |> Async.AwaitTask |> Async.RunSynchronously
|
} |> Async.AwaitTask |> Async.RunSynchronously
|
||||||
|
|
||||||
|
|
|
@ -91,7 +91,7 @@ let createJwt (citizen : Citizen) (cfg : AuthOptions) =
|
||||||
tokenHandler.CreateToken (
|
tokenHandler.CreateToken (
|
||||||
SecurityTokenDescriptor (
|
SecurityTokenDescriptor (
|
||||||
Subject = ClaimsIdentity [|
|
Subject = ClaimsIdentity [|
|
||||||
Claim (ClaimTypes.NameIdentifier, CitizenId.toString citizen.id)
|
Claim (ClaimTypes.NameIdentifier, CitizenId.toString citizen.Id)
|
||||||
Claim (ClaimTypes.Name, Citizen.name citizen)
|
Claim (ClaimTypes.Name, Citizen.name citizen)
|
||||||
|],
|
|],
|
||||||
Expires = DateTime.UtcNow.AddHours 2.,
|
Expires = DateTime.UtcNow.AddHours 2.,
|
||||||
|
|
|
@ -109,7 +109,7 @@ module Citizen =
|
||||||
return!
|
return!
|
||||||
json
|
json
|
||||||
{ jwt = Auth.createJwt citizen (authConfig ctx)
|
{ jwt = Auth.createJwt citizen (authConfig ctx)
|
||||||
citizenId = CitizenId.toString citizen.id
|
citizenId = CitizenId.toString citizen.Id
|
||||||
name = Citizen.name citizen
|
name = Citizen.name citizen
|
||||||
} next ctx
|
} next ctx
|
||||||
| Error msg ->
|
| Error msg ->
|
||||||
|
@ -238,19 +238,19 @@ module Listing =
|
||||||
let! form = ctx.BindJsonAsync<ListingForm> ()
|
let! form = ctx.BindJsonAsync<ListingForm> ()
|
||||||
let now = now ctx
|
let now = now ctx
|
||||||
do! Listings.save {
|
do! Listings.save {
|
||||||
id = ListingId.create ()
|
Id = ListingId.create ()
|
||||||
citizenId = currentCitizenId ctx
|
CitizenId = currentCitizenId ctx
|
||||||
createdOn = now
|
CreatedOn = now
|
||||||
title = form.title
|
Title = form.title
|
||||||
continentId = ContinentId.ofString form.continentId
|
ContinentId = ContinentId.ofString form.continentId
|
||||||
region = form.region
|
Region = form.region
|
||||||
remoteWork = form.remoteWork
|
IsRemote = form.remoteWork
|
||||||
isExpired = false
|
IsExpired = false
|
||||||
updatedOn = now
|
UpdatedOn = now
|
||||||
text = Text form.text
|
Text = Text form.text
|
||||||
neededBy = (form.neededBy |> Option.map parseDate)
|
NeededBy = (form.neededBy |> Option.map parseDate)
|
||||||
wasFilledHere = None
|
WasFilledHere = None
|
||||||
isLegacy = false
|
IsLegacy = false
|
||||||
}
|
}
|
||||||
return! ok next ctx
|
return! ok next ctx
|
||||||
}
|
}
|
||||||
|
@ -258,18 +258,18 @@ module Listing =
|
||||||
// PUT: /api/listing/[id]
|
// PUT: /api/listing/[id]
|
||||||
let update listingId : HttpHandler = authorize >=> fun next ctx -> task {
|
let update listingId : HttpHandler = authorize >=> fun next ctx -> task {
|
||||||
match! Listings.findById (ListingId listingId) with
|
match! Listings.findById (ListingId listingId) with
|
||||||
| Some listing when listing.citizenId <> (currentCitizenId ctx) -> return! Error.notAuthorized next ctx
|
| Some listing when listing.CitizenId <> (currentCitizenId ctx) -> return! Error.notAuthorized next ctx
|
||||||
| Some listing ->
|
| Some listing ->
|
||||||
let! form = ctx.BindJsonAsync<ListingForm> ()
|
let! form = ctx.BindJsonAsync<ListingForm> ()
|
||||||
do! Listings.save
|
do! Listings.save
|
||||||
{ listing with
|
{ listing with
|
||||||
title = form.title
|
Title = form.title
|
||||||
continentId = ContinentId.ofString form.continentId
|
ContinentId = ContinentId.ofString form.continentId
|
||||||
region = form.region
|
Region = form.region
|
||||||
remoteWork = form.remoteWork
|
IsRemote = form.remoteWork
|
||||||
text = Text form.text
|
Text = Text form.text
|
||||||
neededBy = form.neededBy |> Option.map parseDate
|
NeededBy = form.neededBy |> Option.map parseDate
|
||||||
updatedOn = now ctx
|
UpdatedOn = now ctx
|
||||||
}
|
}
|
||||||
return! ok next ctx
|
return! ok next ctx
|
||||||
| None -> return! Error.notFound next ctx
|
| None -> return! Error.notFound next ctx
|
||||||
|
@ -279,24 +279,24 @@ module Listing =
|
||||||
let expire listingId : HttpHandler = authorize >=> fun next ctx -> task {
|
let expire listingId : HttpHandler = authorize >=> fun next ctx -> task {
|
||||||
let now = now ctx
|
let now = now ctx
|
||||||
match! Listings.findById (ListingId listingId) with
|
match! Listings.findById (ListingId listingId) with
|
||||||
| Some listing when listing.citizenId <> (currentCitizenId ctx) -> return! Error.notAuthorized next ctx
|
| Some listing when listing.CitizenId <> (currentCitizenId ctx) -> return! Error.notAuthorized next ctx
|
||||||
| Some listing ->
|
| Some listing ->
|
||||||
let! form = ctx.BindJsonAsync<ListingExpireForm> ()
|
let! form = ctx.BindJsonAsync<ListingExpireForm> ()
|
||||||
do! Listings.save
|
do! Listings.save
|
||||||
{ listing with
|
{ listing with
|
||||||
isExpired = true
|
IsExpired = true
|
||||||
wasFilledHere = Some form.fromHere
|
WasFilledHere = Some form.fromHere
|
||||||
updatedOn = now
|
UpdatedOn = now
|
||||||
}
|
}
|
||||||
match form.successStory with
|
match form.successStory with
|
||||||
| Some storyText ->
|
| Some storyText ->
|
||||||
do! Successes.save
|
do! Successes.save
|
||||||
{ id = SuccessId.create()
|
{ Id = SuccessId.create()
|
||||||
citizenId = currentCitizenId ctx
|
CitizenId = currentCitizenId ctx
|
||||||
recordedOn = now
|
RecordedOn = now
|
||||||
fromHere = form.fromHere
|
IsFromHere = form.fromHere
|
||||||
source = "listing"
|
Source = "listing"
|
||||||
story = (Text >> Some) storyText
|
Story = (Text >> Some) storyText
|
||||||
}
|
}
|
||||||
| None -> ()
|
| None -> ()
|
||||||
return! ok next ctx
|
return! ok next ctx
|
||||||
|
@ -351,25 +351,25 @@ module Profile =
|
||||||
let! profile = task {
|
let! profile = task {
|
||||||
match! Profiles.findById citizenId with
|
match! Profiles.findById citizenId with
|
||||||
| Some p -> return p
|
| Some p -> return p
|
||||||
| None -> return { Profile.empty with id = citizenId }
|
| None -> return { Profile.empty with Id = citizenId }
|
||||||
}
|
}
|
||||||
do! Profiles.save
|
do! Profiles.save
|
||||||
{ profile with
|
{ profile with
|
||||||
seekingEmployment = form.isSeekingEmployment
|
IsSeekingEmployment = form.isSeekingEmployment
|
||||||
isPublic = form.isPublic
|
IsPubliclySearchable = form.isPublic
|
||||||
continentId = ContinentId.ofString form.continentId
|
ContinentId = ContinentId.ofString form.continentId
|
||||||
region = form.region
|
Region = form.region
|
||||||
remoteWork = form.remoteWork
|
IsRemote = form.remoteWork
|
||||||
fullTime = form.fullTime
|
IsFullTime = form.fullTime
|
||||||
biography = Text form.biography
|
Biography = Text form.biography
|
||||||
lastUpdatedOn = now ctx
|
LastUpdatedOn = now ctx
|
||||||
experience = noneIfBlank form.experience |> Option.map Text
|
Experience = noneIfBlank form.experience |> Option.map Text
|
||||||
skills = form.skills
|
Skills = form.skills
|
||||||
|> List.map (fun s ->
|
|> List.map (fun s ->
|
||||||
{ id = if s.id.StartsWith "new" then SkillId.create ()
|
{ Id = if s.id.StartsWith "new" then SkillId.create ()
|
||||||
else SkillId.ofString s.id
|
else SkillId.ofString s.id
|
||||||
description = s.description
|
Description = s.description
|
||||||
notes = noneIfBlank s.notes
|
Notes = noneIfBlank s.notes
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return! ok next ctx
|
return! ok next ctx
|
||||||
|
@ -379,7 +379,7 @@ module Profile =
|
||||||
let employmentFound : HttpHandler = authorize >=> fun next ctx -> task {
|
let employmentFound : HttpHandler = authorize >=> fun next ctx -> task {
|
||||||
match! Profiles.findById (currentCitizenId ctx) with
|
match! Profiles.findById (currentCitizenId ctx) with
|
||||||
| Some profile ->
|
| Some profile ->
|
||||||
do! Profiles.save { profile with seekingEmployment = false }
|
do! Profiles.save { profile with IsSeekingEmployment = false }
|
||||||
return! ok next ctx
|
return! ok next ctx
|
||||||
| None -> return! Error.notFound next ctx
|
| None -> return! Error.notFound next ctx
|
||||||
}
|
}
|
||||||
|
@ -429,19 +429,19 @@ module Success =
|
||||||
let! success = task {
|
let! success = task {
|
||||||
match form.id with
|
match form.id with
|
||||||
| "new" ->
|
| "new" ->
|
||||||
return Some { id = SuccessId.create ()
|
return Some { Id = SuccessId.create ()
|
||||||
citizenId = citizenId
|
CitizenId = citizenId
|
||||||
recordedOn = now ctx
|
RecordedOn = now ctx
|
||||||
fromHere = form.fromHere
|
IsFromHere = form.fromHere
|
||||||
source = "profile"
|
Source = "profile"
|
||||||
story = noneIfEmpty form.story |> Option.map Text
|
Story = noneIfEmpty form.story |> Option.map Text
|
||||||
}
|
}
|
||||||
| successId ->
|
| successId ->
|
||||||
match! Successes.findById (SuccessId.ofString successId) with
|
match! Successes.findById (SuccessId.ofString successId) with
|
||||||
| Some story when story.citizenId = citizenId ->
|
| Some story when story.CitizenId = citizenId ->
|
||||||
return Some { story with
|
return Some { story with
|
||||||
fromHere = form.fromHere
|
IsFromHere = form.fromHere
|
||||||
story = noneIfEmpty form.story |> Option.map Text
|
Story = noneIfEmpty form.story |> Option.map Text
|
||||||
}
|
}
|
||||||
| Some _ | None -> return None
|
| Some _ | None -> return None
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user