Version 3 (#40)

Code for version 3
This commit was merged in pull request #40.
This commit is contained in:
2023-02-02 18:47:28 -05:00
committed by GitHub
parent 323ea83594
commit f3a7b9ea93
126 changed files with 7136 additions and 29577 deletions

View File

@@ -0,0 +1,235 @@
namespace JobsJobsJobs
open NodaTime
open Npgsql.FSharp
/// Helper types and functions for the cache
[<AutoOpen>]
module private CacheHelpers =
open System
open System.Threading.Tasks
open Npgsql
/// The cache entry
type Entry =
{ /// The ID of the cache entry
Id : string
/// The value to be cached
Payload : byte[]
/// When this entry will expire
ExpireAt : Instant
/// The duration by which the expiration should be pushed out when being refreshed
SlidingExpiration : Duration option
/// The must-expire-by date/time for the cache entry
AbsoluteExpiration : Instant option
}
/// Run a task synchronously
let sync<'T> (it : Task<'T>) = it |> (Async.AwaitTask >> Async.RunSynchronously)
/// Get the current instant
let getNow () = SystemClock.Instance.GetCurrentInstant ()
/// Get the first result of the given query
let tryHead<'T> (query : Task<'T list>) = backgroundTask {
let! results = query
return List.tryHead results
}
/// Create a parameter for a non-standard type
let typedParam<'T> name (it : 'T) =
$"@%s{name}", Sql.parameter (NpgsqlParameter ($"@{name}", it))
/// Create a parameter for a possibly-missing non-standard type
let optParam<'T> name (it : 'T option) =
let p = NpgsqlParameter ($"@%s{name}", if Option.isSome it then box it.Value else DBNull.Value)
p.ParameterName, Sql.parameter p
/// Create a parameter for the expire-at time
let expireParam =
typedParam "expireAt"
open System.Threading
open JobsJobsJobs.Common.Data
open Microsoft.Extensions.Caching.Distributed
// getEntry isn't resumable
#nowarn "3511"
/// A distributed cache implementation in PostgreSQL used to handle sessions for Jobs, Jobs, Jobs
type DistributedCache () =
// ~~~ INITIALIZATION ~~~
do
task {
let dataSource = dataSource ()
let! exists =
dataSource
|> Sql.query $"
SELECT EXISTS
(SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = 'session')
AS does_exist"
|> Sql.executeRowAsync (fun row -> row.bool "does_exist")
if not exists then
let! _ =
dataSource
|> Sql.query
"CREATE TABLE session (
id TEXT NOT NULL PRIMARY KEY,
payload BYTEA NOT NULL,
expire_at TIMESTAMPTZ NOT NULL,
sliding_expiration INTERVAL,
absolute_expiration TIMESTAMPTZ);
CREATE INDEX idx_session_expiration ON session (expire_at)"
|> Sql.executeNonQueryAsync
()
} |> sync
// ~~~ SUPPORT FUNCTIONS ~~~
/// Get an entry, updating it for sliding expiration
let getEntry key = backgroundTask {
let dataSource = dataSource ()
let idParam = "@id", Sql.string key
let! tryEntry =
dataSource
|> Sql.query "SELECT * FROM session WHERE id = @id"
|> Sql.parameters [ idParam ]
|> Sql.executeAsync (fun row ->
{ Id = row.string "id"
Payload = row.bytea "payload"
ExpireAt = row.fieldValue<Instant> "expire_at"
SlidingExpiration = row.fieldValueOrNone<Duration> "sliding_expiration"
AbsoluteExpiration = row.fieldValueOrNone<Instant> "absolute_expiration" })
|> tryHead
match tryEntry with
| Some entry ->
let now = getNow ()
let slideExp = defaultArg entry.SlidingExpiration Duration.MinValue
let absExp = defaultArg entry.AbsoluteExpiration Instant.MinValue
let needsRefresh, item =
if entry.ExpireAt = absExp then false, entry
elif slideExp = Duration.MinValue && absExp = Instant.MinValue then false, entry
elif absExp > Instant.MinValue && entry.ExpireAt.Plus slideExp > absExp then
true, { entry with ExpireAt = absExp }
else true, { entry with ExpireAt = now.Plus slideExp }
if needsRefresh then
let! _ =
dataSource
|> Sql.query "UPDATE session SET expire_at = @expireAt WHERE id = @id"
|> Sql.parameters [ expireParam item.ExpireAt; idParam ]
|> Sql.executeNonQueryAsync
()
return if item.ExpireAt > now then Some entry else None
| None -> return None
}
/// The last time expired entries were purged (runs every 30 minutes)
let mutable lastPurge = Instant.MinValue
/// Purge expired entries every 30 minutes
let purge () = backgroundTask {
let now = getNow ()
if lastPurge.Plus (Duration.FromMinutes 30L) < now then
let! _ =
dataSource ()
|> Sql.query "DELETE FROM session WHERE expire_at < @expireAt"
|> Sql.parameters [ expireParam now ]
|> Sql.executeNonQueryAsync
lastPurge <- now
}
/// Remove a cache entry
let removeEntry key = backgroundTask {
let! _ =
dataSource ()
|> Sql.query "DELETE FROM session WHERE id = @id"
|> Sql.parameters [ "@id", Sql.string key ]
|> Sql.executeNonQueryAsync
()
}
/// Save an entry
let saveEntry (opts : DistributedCacheEntryOptions) key payload = backgroundTask {
let now = getNow ()
let expireAt, slideExp, absExp =
if opts.SlidingExpiration.HasValue then
let slide = Duration.FromTimeSpan opts.SlidingExpiration.Value
now.Plus slide, Some slide, None
elif opts.AbsoluteExpiration.HasValue then
let exp = Instant.FromDateTimeOffset opts.AbsoluteExpiration.Value
exp, None, Some exp
elif opts.AbsoluteExpirationRelativeToNow.HasValue then
let exp = now.Plus (Duration.FromTimeSpan opts.AbsoluteExpirationRelativeToNow.Value)
exp, None, Some exp
else
// Default to 1 hour sliding expiration
let slide = Duration.FromHours 1
now.Plus slide, Some slide, None
let! _ =
dataSource ()
|> Sql.query
"INSERT INTO session (
id, payload, expire_at, sliding_expiration, absolute_expiration
) VALUES (
@id, @payload, @expireAt, @slideExp, @absExp
) ON CONFLICT (id) DO UPDATE
SET payload = EXCLUDED.payload,
expire_at = EXCLUDED.expire_at,
sliding_expiration = EXCLUDED.sliding_expiration,
absolute_expiration = EXCLUDED.absolute_expiration"
|> Sql.parameters
[ "@id", Sql.string key
"@payload", Sql.bytea payload
expireParam expireAt
optParam "slideExp" slideExp
optParam "absExp" absExp ]
|> Sql.executeNonQueryAsync
()
}
// ~~~ IMPLEMENTATION FUNCTIONS ~~~
/// Retrieve the data for a cache entry
let get key (_ : CancellationToken) = backgroundTask {
match! getEntry key with
| Some entry ->
do! purge ()
return entry.Payload
| None -> return null
}
/// Refresh an entry
let refresh key (cancelToken : CancellationToken) = backgroundTask {
let! _ = get key cancelToken
()
}
/// Remove an entry
let remove key (_ : CancellationToken) = backgroundTask {
do! removeEntry key
do! purge ()
}
/// Set an entry
let set key value options (_ : CancellationToken) = backgroundTask {
do! saveEntry options key value
do! purge ()
}
interface IDistributedCache with
member _.Get key = get key CancellationToken.None |> sync
member _.GetAsync (key, token) = get key token
member _.Refresh key = refresh key CancellationToken.None |> sync
member _.RefreshAsync (key, token) = refresh key token
member _.Remove key = remove key CancellationToken.None |> sync
member _.RemoveAsync (key, token) = remove key token
member _.Set (key, value, options) = set key value options CancellationToken.None |> sync
member _.SetAsync (key, value, options, token) = set key value options token

View File

@@ -0,0 +1,200 @@
module JobsJobsJobs.Common.Data
/// Constants for tables used by Jobs, Jobs, Jobs
[<RequireQualifiedAccess>]
module Table =
/// Citizens
[<Literal>]
let Citizen = "jjj.citizen"
/// Continents
[<Literal>]
let Continent = "jjj.continent"
/// Job Listings
[<Literal>]
let Listing = "jjj.listing"
/// Employment Profiles
[<Literal>]
let Profile = "jjj.profile"
/// User Security Information
[<Literal>]
let SecurityInfo = "jjj.security_info"
/// Success Stories
[<Literal>]
let Success = "jjj.success"
open Npgsql.FSharp
/// Connection management for the document store
[<AutoOpen>]
module DataConnection =
open Microsoft.Extensions.Configuration
open Npgsql
/// The data source for the document store
let mutable private theDataSource : NpgsqlDataSource option = None
/// Get the data source as the start of a SQL statement
let dataSource () =
match theDataSource with
| Some ds -> Sql.fromDataSource ds
| None -> invalidOp "DataConnection.setUp() must be called before accessing the database"
/// Create tables
let private createTables () = backgroundTask {
let sql = [
"CREATE SCHEMA IF NOT EXISTS jjj"
// Tables
$"CREATE TABLE IF NOT EXISTS {Table.Citizen} (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)"
$"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,
text_search TSVECTOR 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)"
// Key indexes
$"CREATE UNIQUE INDEX IF NOT EXISTS uk_citizen_email ON {Table.Citizen} ((data -> 'email'))"
$"CREATE INDEX IF NOT EXISTS idx_listing_citizen ON {Table.Listing} ((data -> 'citizenId'))"
$"CREATE INDEX IF NOT EXISTS idx_listing_continent ON {Table.Listing} ((data -> 'continentId'))"
$"CREATE INDEX IF NOT EXISTS idx_profile_continent ON {Table.Profile} ((data -> 'continentId'))"
$"CREATE INDEX IF NOT EXISTS idx_success_citizen ON {Table.Success} ((data -> 'citizenId'))"
// Profile text search index
$"CREATE INDEX IF NOT EXISTS idx_profile_search ON {Table.Profile} USING GIN(text_search)"
]
let! _ =
dataSource ()
|> Sql.executeTransactionAsync (sql |> List.map (fun sql -> sql, [ [] ]))
()
}
/// Create functions and triggers required to
let createTriggers () = backgroundTask {
let! functions =
dataSource ()
|> Sql.query
"SELECT p.proname
FROM pg_catalog.pg_proc p
LEFT JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'jjj'"
|> Sql.executeAsync (fun row -> row.string "proname")
if not (functions |> List.contains "indexable_array_string") then
let! _ =
dataSource ()
|> Sql.query """
CREATE FUNCTION jjj.indexable_array_string(target jsonb, path jsonpath) RETURNS text AS $$
BEGIN
RETURN REPLACE(REPLACE(REPLACE(REPLACE(jsonb_path_query_array(target, path)::text,
'["', ''), '", "', ' '), '"]', ''), '[]', '');
END;
$$ LANGUAGE plpgsql;"""
|> Sql.executeNonQueryAsync
()
if not (functions |> List.contains "set_text_search") then
let! _ =
dataSource ()
|> Sql.query $"
CREATE FUNCTION jjj.set_text_search() RETURNS trigger AS $$
BEGIN
NEW.text_search := to_tsvector('english',
COALESCE(NEW.data ->> 'region', '') || ' '
|| COALESCE(NEW.data ->> 'biography', '') || ' '
|| COALESCE(NEW.data ->> 'experience', '') || ' '
|| jjj.indexable_array_string(NEW.data, '$.skills[*].description') || ' '
|| jjj.indexable_array_string(NEW.data, '$.history[*].employer') || ' '
|| jjj.indexable_array_string(NEW.data, '$.history[*].position') || ' '
|| jjj.indexable_array_string(NEW.data, '$.history[*].description'));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER set_text_search BEFORE INSERT OR UPDATE ON {Table.Profile}
FOR EACH ROW EXECUTE FUNCTION jjj.set_text_search();"
|> Sql.executeNonQueryAsync
()
}
/// Set up the data connection from the given configuration
let setUp (cfg : IConfiguration) = backgroundTask {
let builder = NpgsqlDataSourceBuilder (cfg.GetConnectionString "PostgreSQL")
let _ = builder.UseNodaTime ()
theDataSource <- Some (builder.Build ())
do! createTables ()
do! createTriggers ()
}
open System.Text.Json
open System.Threading.Tasks
open JobsJobsJobs
/// Map the data field to the requested document type
let toDocumentFrom<'T> fieldName (row : RowReader) =
JsonSerializer.Deserialize<'T> (row.string fieldName, Json.options)
/// Map the data field to the requested document type
let toDocument<'T> (row : RowReader) = toDocumentFrom<'T> "data" row
/// Get a document
let getDocument<'T> table docId sqlProps : Task<'T option> = backgroundTask {
let! doc =
Sql.query $"SELECT * FROM %s{table} where id = @id" sqlProps
|> Sql.parameters [ "@id", Sql.string docId ]
|> Sql.executeAsync toDocument
return List.tryHead doc
}
/// Serialize a document to JSON
let mkDoc<'T> (doc : 'T) =
JsonSerializer.Serialize<'T> (doc, Json.options)
/// Save a document
let saveDocument table docId sqlProps doc = backgroundTask {
let! _ =
Sql.query
$"INSERT INTO %s{table} (id, data) VALUES (@id, @data)
ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data"
sqlProps
|> Sql.parameters
[ "@id", Sql.string docId
"@data", Sql.jsonb doc ]
|> Sql.executeNonQueryAsync
()
}
/// Create a match-anywhere clause for a LIKE or ILIKE clause
let like value =
Sql.string $"%%%s{value}%%"
/// The JSON access operator ->> makes values text; this makes a parameter that will compare the properly
let jsonBool value =
Sql.string (if value then "true" else "false")
/// Get the SQL for a search WHERE clause
let searchSql criteria =
let sql = criteria |> List.map fst |> String.concat " AND "
if sql = "" then "" else $"AND {sql}"
/// Continent data access functions
[<RequireQualifiedAccess>]
module Continents =
open JobsJobsJobs.Domain
/// Retrieve all continents
let all () =
dataSource ()
|> Sql.query $"SELECT * FROM {Table.Continent} ORDER BY data ->> 'name'"
|> Sql.executeAsync toDocument<Continent>
/// Retrieve a continent by its ID
let findById continentId =
dataSource () |> getDocument<Continent> Table.Continent (ContinentId.toString continentId)

View File

@@ -0,0 +1,496 @@
namespace JobsJobsJobs.Domain
open System
open Giraffe
open NodaTime
// ~~~ SUPPORT TYPES ~~~ //
/// The ID of a user (a citizen of Gitmo Nation)
type CitizenId = CitizenId of Guid
/// Support functions for citizen IDs
module CitizenId =
/// Create a new citizen ID
let create () = (Guid.NewGuid >> CitizenId) ()
/// A string representation of a citizen ID
let toString = function CitizenId it -> ShortGuid.fromGuid it
/// Parse a string into a citizen ID
let ofString = ShortGuid.toGuid >> CitizenId
/// Get the GUID value of a citizen ID
let value = function CitizenId guid -> guid
/// Types of contacts supported by Jobs, Jobs, Jobs
type ContactType =
/// E-mail addresses
| Email
/// Phone numbers (home, work, cell, etc.)
| Phone
/// Websites (personal, social, etc.)
| Website
/// Functions to support contact types
module ContactType =
/// Parse a contact type from a string
let parse typ =
match typ with
| "Email" -> Email
| "Phone" -> Phone
| "Website" -> Website
| it -> invalidOp $"{it} is not a valid contact type"
/// Convert a contact type to its string representation
let toString =
function
| Email -> "Email"
| Phone -> "Phone"
| Website -> "Website"
/// The ID of a continent
type ContinentId = ContinentId of Guid
/// Support functions for continent IDs
module ContinentId =
/// Create a new continent ID
let create () = (Guid.NewGuid >> ContinentId) ()
/// A string representation of a continent ID
let toString = function ContinentId it -> ShortGuid.fromGuid it
/// Parse a string into a continent ID
let ofString = ShortGuid.toGuid >> ContinentId
/// Get the GUID value of a continent ID
let value = function ContinentId guid -> guid
/// A string of Markdown text
type MarkdownString = Text of string
/// Support functions for Markdown strings
module MarkdownString =
open Markdig
/// The Markdown conversion pipeline (enables all advanced features)
let private pipeline = MarkdownPipelineBuilder().UseAdvancedExtensions().Build ()
/// Convert this Markdown string to HTML
let toHtml = function Text text -> Markdown.ToHtml (text, pipeline)
/// Convert a Markdown string to its string representation
let toString = function Text text -> text
/// An employment history entry
[<NoComparison; NoEquality>]
type EmploymentHistory =
{ /// The employer for this period of employment
Employer : string
/// The date employment started
StartDate : LocalDate
/// The date employment ended (None implies ongoing employment)
EndDate : LocalDate option
/// The title / position held
Position : string option
/// A description of the duties entailed during this employment
Description : MarkdownString option
}
/// Support functions for employment history entries
module EmploymentHistory =
let empty =
{ Employer = ""
StartDate = LocalDate.FromDateTime DateTime.Today
EndDate = None
Position = None
Description = None
}
/// The ID of a job listing
type ListingId = ListingId of Guid
/// Support functions for listing IDs
module ListingId =
/// Create a new job listing ID
let create () = (Guid.NewGuid >> ListingId) ()
/// A string representation of a listing ID
let toString = function ListingId it -> ShortGuid.fromGuid it
/// Parse a string into a listing ID
let ofString = ShortGuid.toGuid >> ListingId
/// Get the GUID value of a listing ID
let value = function ListingId guid -> guid
/// Another way to contact a citizen from this site
[<NoComparison; NoEquality>]
type OtherContact =
{ /// The type of contact
ContactType : ContactType
/// The name of the contact (Email, No Agenda Social, LinkedIn, etc.)
Name : string option
/// The value for the contact (e-mail address, user name, URL, etc.)
Value : string
/// Whether this contact is visible in public employment profiles and job listings
IsPublic : bool
}
/// Visibility options for an employment profile
type ProfileVisibility =
/// Profile is only visible to the citizen to whom it belongs
| Hidden
/// Profile is only visible to authenticated users
| Private
/// Anonymous information is visible to public users
| Anonymous
/// The full employment profile is visible to public users
| Public
/// Support functions for profile visibility
module ProfileVisibility =
/// Parse a string into a profile visibility
let parse viz =
match viz with
| "Hidden" -> Hidden
| "Private" -> Private
| "Anonymous" -> Anonymous
| "Public" -> Public
| it -> invalidOp $"{it} is not a valid profile visibility value"
/// Convert a profile visibility to its string representation
let toString =
function
| Hidden -> "Hidden"
| Private -> "Private"
| Anonymous -> "Anonymous"
| Public -> "Public"
/// A skill the job seeker possesses
[<NoComparison; NoEquality>]
type Skill =
{ /// A description of the skill
Description : string
/// Notes regarding this skill (level, duration, etc.)
Notes : string option
}
/// The ID of a success report
type SuccessId = SuccessId of Guid
/// Support functions for success report IDs
module SuccessId =
/// Create a new success report ID
let create () = (Guid.NewGuid >> SuccessId) ()
/// A string representation of a success report ID
let toString = function SuccessId it -> ShortGuid.fromGuid it
/// Parse a string into a success report ID
let ofString = ShortGuid.toGuid >> SuccessId
/// Get the GUID value of a success ID
let value = function SuccessId guid -> guid
// ~~~ DOCUMENT TYPES ~~~ //
/// A user of Jobs, Jobs, Jobs; a citizen of Gitmo Nation
[<NoComparison; NoEquality>]
type Citizen =
{ /// The ID of the user
Id : CitizenId
/// When the user joined Jobs, Jobs, Jobs
JoinedOn : Instant
/// When the user last logged in
LastSeenOn : Instant
/// The user's e-mail address
Email : string
/// The user's first name
FirstName : string
/// The user's last name
LastName : string
/// The hash of the user's password
PasswordHash : string
/// The name displayed for this user throughout the site
DisplayName : string option
/// The other contacts for this user
OtherContacts : OtherContact list
/// Whether this is a legacy citizen
IsLegacy : bool
}
/// 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}"
/// A continent
[<NoComparison; NoEquality>]
type Continent =
{ /// The ID of the continent
Id : ContinentId
/// The name of the continent
Name : string
}
/// Support functions for continents
module Continent =
/// An empty continent
let empty ={
Id = ContinentId Guid.Empty
Name = ""
}
/// A job listing
[<NoComparison; NoEquality>]
type Listing =
{ /// The ID of the job listing
Id : ListingId
/// The ID of the citizen who posted the job listing
CitizenId : CitizenId
/// When this job listing was created
CreatedOn : Instant
/// The short title of the job listing
Title : string
/// The ID of the continent on which the job is located
ContinentId : ContinentId
/// The region in which the job is located
Region : string
/// Whether this listing is for remote work
IsRemote : bool
/// Whether this listing has expired
IsExpired : bool
/// When this listing was last updated
UpdatedOn : Instant
/// The details of this job
Text : MarkdownString
/// When this job needs to be filled
NeededBy : LocalDate option
/// Was this job filled as part of its appearance on Jobs, Jobs, Jobs?
WasFilledHere : bool option
/// Whether this is a legacy 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 = ""
IsRemote = false
IsExpired = false
UpdatedOn = Instant.MinValue
Text = Text ""
NeededBy = None
WasFilledHere = None
IsLegacy = false
}
/// Security settings for a user
[<NoComparison; NoEquality>]
type SecurityInfo =
{ /// The ID of the citizen to whom these settings apply
Id : CitizenId
/// The number of failed log on attempts (reset to 0 on successful log on)
FailedLogOnAttempts : int
/// Whether the account is locked
AccountLocked : bool
/// The token the user must provide to take their desired action
Token : string option
/// The action to which the token applies
TokenUsage : string option
/// When the token expires
TokenExpires : Instant option
}
/// Functions to support security info
module SecurityInfo =
/// An empty set of security info
let empty = {
Id = CitizenId Guid.Empty
FailedLogOnAttempts = 0
AccountLocked = false
Token = None
TokenUsage = None
TokenExpires = None
}
/// A job seeker profile
[<NoComparison; NoEquality>]
type Profile =
{ /// The ID of the citizen to whom this profile belongs
Id : CitizenId
/// The ID of the continent on which the citizen resides
ContinentId : ContinentId
/// The region in which the citizen resides
Region : string
/// Whether this citizen is actively seeking employment
IsSeekingEmployment : bool
/// Whether the citizen is interested in remote work
IsRemote : bool
/// Whether the citizen is interested in full-time work
IsFullTime : bool
/// The citizen's professional biography
Biography : MarkdownString
/// Skills this citizen possesses
Skills : Skill list
/// The citizen's employment history
History : EmploymentHistory list
/// The citizen's experience (topical / chronological)
Experience : MarkdownString option
/// The visibility of this profile
Visibility : ProfileVisibility
/// When the citizen last updated their profile
LastUpdatedOn : Instant
/// Whether this is a legacy profile
IsLegacy : bool
}
/// Support functions for Profiles
module Profile =
// An empty profile
let empty = {
Id = CitizenId Guid.Empty
ContinentId = ContinentId Guid.Empty
Region = ""
IsSeekingEmployment = false
IsRemote = false
IsFullTime = false
Biography = Text ""
Skills = []
History = []
Experience = None
Visibility = Private
LastUpdatedOn = Instant.MinValue
IsLegacy = false
}
/// A record of success finding employment
[<NoComparison; NoEquality>]
type Success =
{ /// The ID of the success report
Id : SuccessId
/// The ID of the citizen who wrote this success report
CitizenId : CitizenId
/// When this success report was recorded
RecordedOn : Instant
/// Whether the success was due, at least in part, to Jobs, Jobs, Jobs
IsFromHere : bool
/// The source of this success (listing or profile)
Source : string
/// 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
IsFromHere = false
Source = ""
Story = None
}

View File

@@ -0,0 +1,94 @@
module JobsJobsJobs.Email
open System.Net
open JobsJobsJobs.Domain
open MailKit.Net.Smtp
open MailKit.Security
open MimeKit
/// Private functions for sending e-mail
[<AutoOpen>]
module private Helpers =
/// Create an SMTP client
let smtpClient () = backgroundTask {
let client = new SmtpClient ()
do! client.ConnectAsync ("localhost", 25, SecureSocketOptions.None)
return client
}
/// Create a message with to, from, and subject completed
let createMessage citizen subject =
let msg = new MimeMessage ()
msg.From.Add (MailboxAddress ("Jobs, Jobs, Jobs", "daniel@bitbadger.solutions"))
msg.To.Add (MailboxAddress (Citizen.name citizen, citizen.Email))
msg.Subject <- subject
msg
/// Send an account confirmation e-mail
let sendAccountConfirmation citizen security = backgroundTask {
let token = WebUtility.UrlEncode security.Token.Value
use! client = smtpClient ()
use msg = createMessage citizen "Account Confirmation Request"
let text =
[ $"ITM, {Citizen.name citizen}!"
""
"This e-mail address was recently used to establish an account on"
"Jobs, Jobs, Jobs (noagendacareers.com). Before this account can be"
"used, it needs to be verified. Please click the link below to do so;"
"it will work for the next 72 hours (3 days)."
""
$"https://noagendacareers.com/citizen/confirm/{token}"
""
"If you did not take this action, you can do nothing, and the account"
"will be deleted at the end of that time. If you wish to delete it"
"immediately, use the link below (also valid for 72 hours)."
""
$"https://noagendacareers.com/citizen/deny/{token}"
""
"TYFYC!"
""
"--"
"Jobs, Jobs, Jobs"
"https://noagendacareers.com"
] |> String.concat "\n"
use msgText = new TextPart (Text = text)
msg.Body <- msgText
return! client.SendAsync msg
}
/// Send a password reset link
let sendPasswordReset citizen security = backgroundTask {
let token = WebUtility.UrlEncode security.Token.Value
use! client = smtpClient ()
use msg = createMessage citizen "Reset Password for Jobs, Jobs, Jobs"
let text =
[ $"ITM, {Citizen.name citizen}!"
""
"We recently receive a request to reset the password for your account"
"on Jobs, Jobs, Jobs (noagendacareers.com). Use the link below to"
"do so; it will work for the next 72 hours (3 days)."
""
$"https://noagendacareers.com/citizen/reset-password/{token}"
""
"If you did not take this action, you can do nothing, and the link"
"will expire normally. If you wish to expire the token immediately,"
"use the link below (also valid for 72 hours)."
""
$"https://noagendacareers.com/citizen/cancel-reset/{token}"
""
"TYFYC!"
""
"--"
"Jobs, Jobs, Jobs"
"https://noagendacareers.com"
] |> String.concat "\n"
use msgText = new TextPart (Text = text)
msg.Body <- msgText
return! client.SendAsync msg
}

View File

@@ -0,0 +1,206 @@
/// Common helper functions for views
module JobsJobsJobs.Common.Handlers
open Giraffe
open Giraffe.Htmx
open Microsoft.AspNetCore.Http
open Microsoft.Extensions.Logging
[<AutoOpen>]
module HtmxHelpers =
/// Is the request from htmx?
let isHtmx (ctx : HttpContext) =
ctx.Request.IsHtmx && not ctx.Request.IsHtmxRefresh
/// Handlers for error conditions
module Error =
open System.Net
/// Handler that will return a status code 404 and the text "Not Found"
let notFound : HttpHandler = fun _ ctx ->
let fac = ctx.GetService<ILoggerFactory> ()
let log = fac.CreateLogger "Handler"
let path = string ctx.Request.Path
log.LogInformation "Returning 404"
RequestErrors.NOT_FOUND $"The URL {path} was not recognized as a valid URL" earlyReturn ctx
/// Handle unauthorized actions, redirecting to log on for GETs, otherwise returning a 401 Not Authorized response
let notAuthorized : HttpHandler = fun next ctx ->
if ctx.Request.Method = "GET" then
let redirectUrl = $"/citizen/log-on?returnUrl={WebUtility.UrlEncode ctx.Request.Path}"
if isHtmx ctx then (withHxRedirect redirectUrl >=> redirectTo false redirectUrl) next ctx
else redirectTo false redirectUrl next ctx
else
if isHtmx ctx then
(setHttpHeader "X-Toast" $"error|||You are not authorized to access the URL {ctx.Request.Path.Value}"
>=> setStatusCode 401) earlyReturn ctx
else setStatusCode 401 earlyReturn ctx
/// Handler to log 500s and return a message we can display in the application
let unexpectedError (ex: exn) (log : ILogger) =
log.LogError(ex, "An unexpected error occurred")
clearResponse >=> ServerErrors.INTERNAL_ERROR ex.Message
open System
open System.Security.Claims
open System.Text.Json
open System.Text.RegularExpressions
open JobsJobsJobs.Domain
open Microsoft.AspNetCore.Antiforgery
open Microsoft.Extensions.Configuration
open Microsoft.Extensions.DependencyInjection
open NodaTime
/// Get the NodaTime clock from the request context
let now (ctx : HttpContext) = ctx.GetService<IClock>().GetCurrentInstant ()
/// Get the application configuration from the request context
let config (ctx : HttpContext) = ctx.GetService<IConfiguration> ()
/// Get the logger factory from the request context
let logger (ctx : HttpContext) = ctx.GetService<ILoggerFactory> ()
/// `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
/// `None` if a `string` is null, empty, or whitespace; otherwise, `Some` and the trimmed string
let noneIfEmpty = Option.ofObj >> noneIfBlank
/// Try to get the current user
let tryUser (ctx : HttpContext) =
ctx.User.FindFirst ClaimTypes.NameIdentifier
|> Option.ofObj
|> Option.map (fun x -> x.Value)
/// Get the ID of the currently logged in citizen
// NOTE: if no one is logged in, this will raise an exception
let currentCitizenId ctx = (tryUser >> Option.get >> CitizenId.ofString) ctx
let antiForgerySvc (ctx : HttpContext) =
ctx.RequestServices.GetRequiredService<IAntiforgery> ()
/// Obtain an anti-forgery token set
let csrf ctx =
(antiForgerySvc ctx).GetAndStoreTokens ctx
/// Get the time zone from the citizen's browser
let timeZone (ctx : HttpContext) =
let tz = string ctx.Request.Headers["X-Time-Zone"]
defaultArg (noneIfEmpty tz) "Etc/UTC"
/// The key to use to indicate if we have loaded the session
let private sessionLoadedKey = "session-loaded"
/// Load the session if we have not yet
let private loadSession (ctx : HttpContext) = task {
if not (ctx.Items.ContainsKey sessionLoadedKey) then
do! ctx.Session.LoadAsync ()
ctx.Items.Add (sessionLoadedKey, "yes")
}
/// Save the session if we have loaded it
let private saveSession (ctx : HttpContext) = task {
if ctx.Items.ContainsKey sessionLoadedKey then do! ctx.Session.CommitAsync ()
}
/// Get the messages from the session (destructively)
let popMessages ctx = task {
do! loadSession ctx
let msgs =
match ctx.Session.GetString "messages" with
| null -> []
| m -> JsonSerializer.Deserialize<string list> m
if not (List.isEmpty msgs) then ctx.Session.Remove "messages"
return List.rev msgs
}
/// Add a message to the response
let addMessage (level : string) (msg : string) ctx = task {
do! loadSession ctx
let! msgs = popMessages ctx
ctx.Session.SetString ("messages", JsonSerializer.Serialize ($"{level}|||{msg}" :: msgs))
}
/// Add a success message to the response
let addSuccess msg ctx = task {
do! addMessage "success" msg ctx
}
/// Add an error message to the response
let addError msg ctx = task {
do! addMessage "error" msg ctx
}
/// Add a list of errors to the response
let addErrors (errors : string list) ctx = task {
let errMsg = String.Join ("</li><li>", errors)
do! addError $"Please correct the following errors:<ul><li>{errMsg}</li></ul>" ctx
}
open JobsJobsJobs.Common.Views
/// Create the render context for an HTML response
let private createContext (ctx : HttpContext) pageTitle content messages : Layout.PageRenderContext =
{ IsLoggedOn = Option.isSome (tryUser ctx)
CurrentUrl = ctx.Request.Path.Value
PageTitle = pageTitle
Content = content
Messages = messages
}
/// Render a page-level view
let render pageTitle (_ : HttpFunc) (ctx : HttpContext) content = task {
let! messages = popMessages ctx
let renderCtx = createContext ctx pageTitle content messages
let renderFunc = if isHtmx ctx then Layout.partial else Layout.full
return! ctx.WriteHtmlViewAsync (renderFunc renderCtx)
}
/// Render a printable view (content with styles, but no layout)
let renderPrint pageTitle (_ : HttpFunc) (ctx : HttpContext) content =
createContext ctx pageTitle content []
|> Layout.print
|> ctx.WriteHtmlViewAsync
/// Render a bare (component) view
let renderBare (_ : HttpFunc) (ctx : HttpContext) content =
createContext ctx "" content []
|> Layout.bare
|> ctx.WriteHtmlViewAsync
/// Render as a composable HttpHandler
let renderHandler pageTitle content : HttpHandler = fun next ctx ->
render pageTitle next ctx content
/// Validate the anti cross-site request forgery token in the current request
let validateCsrf : HttpHandler = fun next ctx -> task {
match! (antiForgerySvc ctx).IsRequestValidAsync ctx with
| true -> return! next ctx
| false -> return! RequestErrors.BAD_REQUEST "CSRF token invalid" earlyReturn ctx
}
/// Require a user to be logged on for a route
let requireUser : HttpHandler = requiresAuthentication Error.notAuthorized
/// Regular expression to validate that a URL is a local URL
let isLocal = Regex """^/[^\/\\].*"""
/// Redirect to another page, saving the session before redirecting
let redirectToGet (url : string) next ctx = task {
do! saveSession ctx
let action =
if Option.isSome (noneIfEmpty url) && (url = "/" || isLocal.IsMatch url) then
if isHtmx ctx then withHxRedirect url else redirectTo false url
else RequestErrors.BAD_REQUEST "Invalid redirect URL"
return! action next ctx
}
/// Shorthand for Error.notFound for use in handler functions
let notFound ctx =
Error.notFound earlyReturn ctx

View File

@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Compile Include="Domain.fs" />
<Compile Include="Email.fs" />
<Compile Include="Json.fs" />
<Compile Include="Data.fs" />
<Compile Include="Views.fs" />
<Compile Include="Handlers.fs" />
<Compile Include="Cache.fs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FSharp.SystemTextJson" Version="1.0.7" />
<PackageReference Include="Giraffe" Version="6.0.0" />
<PackageReference Include="Giraffe.Htmx" Version="1.8.5" />
<PackageReference Include="Giraffe.ViewEngine" Version="1.4.0" />
<PackageReference Include="Giraffe.ViewEngine.Htmx" Version="1.8.5" />
<PackageReference Include="MailKit" Version="3.3.0" />
<PackageReference Include="Markdig" Version="0.30.4" />
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.0.0" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.0.0" />
<PackageReference Include="Npgsql.FSharp" Version="5.6.0" />
<PackageReference Include="Npgsql.NodaTime" Version="7.0.1" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,34 @@
/// JSON serializer options
module JobsJobsJobs.Json
open System.Text.Json
open System.Text.Json.Serialization
open JobsJobsJobs.Domain
/// Convert a wrapped DU 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)
open NodaTime
open NodaTime.Serialization.SystemTextJson
/// JsonSerializer options that use the custom converters
let options =
let opts = JsonSerializerOptions ()
[ WrappedJsonConverter (CitizenId.ofString, CitizenId.toString) :> JsonConverter
WrappedJsonConverter (ContactType.parse, ContactType.toString)
WrappedJsonConverter (ContinentId.ofString, ContinentId.toString)
WrappedJsonConverter (ListingId.ofString, ListingId.toString)
WrappedJsonConverter (Text, MarkdownString.toString)
WrappedJsonConverter (ProfileVisibility.parse, ProfileVisibility.toString)
WrappedJsonConverter (SuccessId.ofString, SuccessId.toString)
JsonFSharpConverter ()
]
|> List.iter opts.Converters.Add
let _ = opts.ConfigureForNodaTime DateTimeZoneProviders.Tzdb
opts.PropertyNamingPolicy <- JsonNamingPolicy.CamelCase
opts

View File

@@ -0,0 +1,393 @@
/// Common functions for views
module JobsJobsJobs.Common.Views
open Giraffe.ViewEngine
open Giraffe.ViewEngine.Accessibility
open Microsoft.AspNetCore.Antiforgery
open JobsJobsJobs.Domain
/// Create an audio clip with the specified text node
let audioClip clip text =
span [ _class "jjj-audio-clip"; _onclick "jjj.playFile(this)" ] [
text; audio [ _id clip ] [ source [ _src $"/audio/{clip}.mp3" ] ]
]
/// Create an anti-forgery hidden input
let antiForgery (csrf : AntiforgeryTokenSet) =
input [ _type "hidden"; _name csrf.FormFieldName; _value csrf.RequestToken ]
/// Alias for rawText
let txt = rawText
/// Create a page with a title displayed on the page
let pageWithTitle title content =
article [] [
h3 [ _class "pb-3" ] [ txt title ]
yield! content
]
/// Create a floating-label text input box
let textBox attrs name value fieldLabel isRequired =
div [ _class "form-floating" ] [
List.append attrs [
_id name; _name name; _class "form-control"; _placeholder fieldLabel; _value value
if isRequired then _required
] |> input
label [ _class (if isRequired then "jjj-required" else "jjj-label"); _for name ] [ txt fieldLabel ]
]
/// Create a checkbox that will post "true" if checked
let checkBox attrs name isChecked checkLabel =
div [ _class "form-check" ] [
List.append attrs
[ _type "checkbox"; _id name; _name name; _class "form-check-input"; _value "true"
if isChecked then _checked ]
|> input
label [ _class "form-check-label"; _for name ] [ txt checkLabel ]
]
/// Create a select list of continents
let continentList attrs name (continents : Continent list) emptyLabel selectedValue isRequired =
div [ _class "form-floating" ] [
select (List.append attrs [ _id name; _name name; _class "form-select"; if isRequired then _required ]) (
option [ _value ""; if selectedValue = "" then _selected ] [
rawText $"""&ndash; {defaultArg emptyLabel "Select"} &ndash;""" ]
:: (continents
|> List.map (fun c ->
let theId = ContinentId.toString c.Id
option [ _value theId; if theId = selectedValue then _selected ] [ str c.Name ])))
label [ _class (if isRequired then "jjj-required" else "jjj-label"); _for name ] [ txt "Continent" ]
]
/// Create a submit button with the given icon and text
let submitButton icon text =
button [ _type "submit"; _class "btn btn-primary" ] [ i [ _class $"mdi mdi-%s{icon}" ] []; txt $"&nbsp; %s{text}" ]
/// An empty paragraph
let emptyP =
p [] [ txt "&nbsp;" ]
/// Register JavaScript code to run in the DOMContentLoaded event on the page
let jsOnLoad js isHtmx =
script [] [
let (target, event) = if isHtmx then "document.body", "htmx:afterSettle" else "document", "DOMContentLoaded"
txt (sprintf """%s.addEventListener("%s", () => { %s }, { once: true })""" target event js)
]
/// Create a Markdown editor
let markdownEditor attrs name value editorLabel isHtmx =
div [ _class "col-12"; _id $"{name}EditRow" ] [
nav [ _class "nav nav-pills pb-1" ] [
button [ _type "button"; _id $"{name}EditButton"; _class "btn btn-primary btn-sm rounded-pill" ] [
txt "Markdown"
]
rawText " &nbsp; "
button [ _type "button"; _id $"{name}PreviewButton"
_class "btn btn-outline-secondary btn-sm rounded-pill" ] [
txt "Preview"
]
]
section [ _id $"{name}Preview"; _class "jjj-not-shown jjj-markdown-preview px-2 pt-2"
_ariaLabel "Rendered Markdown preview" ] []
div [ _id $"{name}Edit"; _class "form-floating jjj-shown" ] [
textarea (List.append attrs
[ _id name; _name name; _class "form-control jjj-markdown-editor"; _rows "10" ]) [
txt value
]
label [ _for name ] [ txt editorLabel ]
]
jsOnLoad $"jjj.markdownOnLoad('{name}')" isHtmx
]
/// Wrap content in a collapsing panel
let collapsePanel header isShown content =
let showClass = if isShown then " show" else ""
div [ _class "card" ] [
div [ _class "card-header" ] [
h6 [ _class "mb-0 card-title" ] [
a [ _href "#jjjCollapse"; _data "bs-toggle" "collapse"; _roleButton; _ariaControls "#jjjCollapse"
_ariaExpanded (isShown.ToString().ToLowerInvariant ()) ] [ txt header ]
]
]
div [ _id "jjjCollapse"; _class $"card-body collapse{showClass}" ] content
]
/// "Yes" or "No" based on a boolean value
let yesOrNo value =
if value then "Yes" else "No"
/// Markdown as a raw HTML text node
let md2html value =
(MarkdownString.toHtml >> txt) value
/// Display a citizen's contact information
let contactInfo citizen isPublic =
citizen.OtherContacts
|> List.filter (fun it -> (isPublic && it.IsPublic) || not isPublic)
|> List.collect (fun contact ->
match contact.ContactType with
| Website ->
[ i [ _class "mdi mdi-sm mdi-web" ] []; txt " "
a [ _href contact.Value; _target "_blank"; _rel "noopener"; _class "me-4" ] [
str (defaultArg contact.Name "Website")
]
]
| Email ->
[ i [ _class "mdi mdi-sm mdi-email-outline" ] []; txt " "
a [ _href $"mailto:{contact.Value}"; _class "me-4" ] [ str (defaultArg contact.Name "E-mail") ]
]
| Phone ->
[ span [ _class "me-4" ] [
i [ _class "mdi mdi-sm mdi-phone" ] []; txt " "; str contact.Value
match contact.Name with Some name -> str $" ({name})" | None -> ()
]
])
/// Display a citizen's contact information
let contactInfoPrint citizen isPublic =
citizen.OtherContacts
|> List.filter (fun it -> (isPublic && it.IsPublic) || not isPublic)
|> List.collect (fun contact ->
match contact.ContactType with
| Website ->
[ i [ _class "mdi mdi-sm mdi-web" ] []; txt " "; str (defaultArg contact.Name "Website"); txt " &ndash; "
str contact.Value; br []
]
| Email ->
[ i [ _class "mdi mdi-sm mdi-email-outline" ] []; txt " "; str (defaultArg contact.Name "E-mail")
txt " &ndash; "; str contact.Value; br []
]
| Phone ->
[ span [ _class "me-4" ] [
i [ _class "mdi mdi-sm mdi-phone" ] []; rawText " "
match contact.Name with Some name -> str name; txt " &ndash; " | None -> ()
str contact.Value; br []
]
])
open NodaTime
open NodaTime.Text
/// Generate a full date in the citizen's local time zone
let fullDate (value : Instant) tz =
(ZonedDateTimePattern.CreateWithCurrentCulture ("MMMM d, yyyy", DateTimeZoneProviders.Tzdb))
.Format(value.InZone DateTimeZoneProviders.Tzdb[tz])
/// Generate a full date/time in the citizen's local time
let fullDateTime (value : Instant) tz =
let dtPattern = ZonedDateTimePattern.CreateWithCurrentCulture ("MMMM d, yyyy h:mm", DateTimeZoneProviders.Tzdb)
let amPmPattern = ZonedDateTimePattern.CreateWithCurrentCulture ("tt", DateTimeZoneProviders.Tzdb)
let tzValue = value.InZone DateTimeZoneProviders.Tzdb[tz]
$"{dtPattern.Format(tzValue)}{amPmPattern.Format(tzValue).ToLowerInvariant()}"
/// Layout generation functions
[<RequireQualifiedAccess>]
module Layout =
open Giraffe.ViewEngine.Htmx
/// Data items needed to render a view
type PageRenderContext =
{ /// Whether a user is logged on
IsLoggedOn : bool
/// The current URL
CurrentUrl : string
/// The title of this page
PageTitle : string
/// The page content
Content : XmlNode
/// User messages to be displayed
Messages : string list
}
/// Append the application name to the page title
let private constructTitle ctx =
seq {
if ctx.PageTitle <> "" then
ctx.PageTitle; " | "
"Jobs, Jobs, Jobs"
}
|> Seq.reduce (+)
|> str
|> List.singleton
|> title []
/// Generate the HTML head tag
let private htmlHead ctx =
head [] [
meta [ _name "viewport"; _content "width=device-width, initial-scale=1" ]
constructTitle ctx
link [ _href "https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/css/bootstrap.min.css"
_rel "stylesheet"
_integrity "sha384-gH2yIJqKdNHPEq0n4Mqa/HGKIhSkIHeL5AyhkYV8i59U5AR6csBvApHHNl/vI1Bx"
_crossorigin "anonymous" ]
link [ _href "https://cdn.jsdelivr.net/npm/@mdi/font@6.9.96/css/materialdesignicons.min.css"
_rel "stylesheet" ]
link [ _href "/style.css"; _rel "stylesheet" ]
]
/// Display the links available to the current user
let private links ctx =
let navLink url icon text =
a [ _href url
_onclick "jjj.hideMenu()"
if url = ctx.CurrentUrl then _class "jjj-current-page"
] [ i [ _class $"mdi mdi-{icon}"; _ariaHidden "true" ] []; txt text ]
nav [ _class "jjj-nav" ] [
if ctx.IsLoggedOn then
navLink "/citizen/dashboard" "view-dashboard-variant" "Dashboard"
navLink "/help-wanted" "newspaper-variant-multiple-outline" "Help Wanted!"
navLink "/profile/search" "view-list-outline" "Job Seekers"
navLink "/success-stories" "thumb-up" "Success Stories"
div [ _class "separator" ] []
navLink "/citizen/account" "account-edit" "My Account"
navLink "/listings/mine" "sign-text" "My Job Listings"
navLink "/profile/edit" "pencil" "My Employment Profile"
div [ _class "separator" ] []
navLink "/citizen/log-off" "logout-variant" "Log Off"
else
navLink "/" "home" "Home"
navLink "/profile/search" "view-list-outline" "Job Seekers"
navLink "/citizen/log-on" "login-variant" "Log On"
navLink "/how-it-works" "help-circle-outline" "How It Works"
]
/// Generate mobile and desktop side navigation areas
let private sideNavs ctx = [
div [ _id "mobileMenu"; _class "jjj-mobile-menu offcanvas offcanvas-end"; _tabindex "-1"
_ariaLabelledBy "mobileMenuLabel" ] [
div [ _class "offcanvas-header" ] [
h5 [ _id "mobileMenuLabel" ] [ txt "Menu" ]
button [
_class "btn-close text-reset"; _type "button"; _data "bs-dismiss" "offcanvas"; _ariaLabel "Close"
] []
]
div [ _class "offcanvas-body" ] [ links ctx ]
]
aside [ _class "jjj-full-menu d-none d-md-block p-3" ] [
p [ _class "home-link pb-3" ] [ a [ _href "/" ] [ txt "Jobs, Jobs, Jobs" ] ]
emptyP
links ctx
]
]
/// Title bars for mobile and desktop
let private titleBars = [
nav [ _class "d-flex d-md-none navbar navbar-dark" ] [
span [ _class "navbar-text" ] [ a [ _href "/" ] [ txt "Jobs, Jobs, Jobs" ] ]
button [ _class "btn"; _data "bs-toggle" "offcanvas"; _data "bs-target" "#mobileMenu"
_ariaControls "mobileMenu" ] [ i [ _class "mdi mdi-menu" ] [] ]
]
nav [ _class "d-none d-md-flex navbar navbar-light bg-light"] [
span [] [ txt "&nbsp;" ]
span [ _class "navbar-text" ] [
txt "(&hellip;and Jobs &ndash; "; audioClip "pelosi-jobs" (txt "Let&rsquo;s Vote for Jobs!"); txt ")"
]
]
]
/// The HTML footer for the page
let private htmlFoot =
let v = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version
let version =
seq {
string v.Major
if v.Minor > 0 then
"."; string v.Minor
if v.Build > 0 then
"."; string v.Build
} |> Seq.reduce (+)
footer [] [
p [ _class "text-muted" ] [
txt $"Jobs, Jobs, Jobs v{version} &bull; "
a [ _href "/privacy-policy" ] [ txt "Privacy Policy" ]; txt " &bull; "
a [ _href "/terms-of-service" ] [ txt "Terms of Service" ]
]
]
/// Render any messages
let private messages ctx =
ctx.Messages
|> List.map (fun msg ->
let parts = msg.Split "|||"
let level = if parts[0] = "error" then "danger" else parts[0]
let message = parts[1]
div [ _class $"alert alert-{level} alert-dismissable fade show d-flex justify-content-between p-2 mb-1 mt-1"
_roleAlert ] [
p [ _class "mb-0" ] [
if level <> "success" then strong [] [ txt $"{parts[0].ToUpperInvariant ()}: " ]
txt message
]
button [ _type "button"; _class "btn-close"; _data "bs-dismiss" "alert"; _ariaLabel "Close" ] []
])
|> div [ _id "alerts" ]
/// Create a full view
let full ctx =
html [ _lang "en" ] [
htmlHead ctx
body [] [
div [ _class "jjj-app"; _hxBoost; _hxTarget "this" ] [
yield! sideNavs ctx
div [ _class "jjj-main" ] [
yield! titleBars
main [ _class "jjj-content container-fluid" ] [
messages ctx
ctx.Content
]
htmlFoot
]
]
Script.minified
script [ _async
_src "https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/js/bootstrap.bundle.min.js"
_integrity "sha384-A3rJD856KowSb7dwlZdYEkO39Gagi7vIsF0jrRAoQmDKKtQBHUuLZ9AsSv4jD4Xa"
_crossorigin "anonymous" ] []
script [ _src "/script.js" ] []
template [ _id "alertTemplate" ] [
div [ _class $"alert alert-dismissable fade show d-flex justify-content-between p-2 mb-1 mt-1"
_roleAlert ] [
p [ _class "mb-0" ] []
button [ _type "button"; _class "btn-close"; _data "bs-dismiss" "alert"; _ariaLabel "Close" ] []
]
]
]
]
/// Create a partial (boosted response) view
let partial ctx =
html [ _lang "en" ] [
head [] [
constructTitle ctx
]
body [] [
yield! sideNavs ctx
div [ _class "jjj-main" ] [
yield! titleBars
main [ _class "jjj-content container-fluid" ] [
messages ctx
ctx.Content
]
htmlFoot
]
]
]
/// Render a print view (styles, but no other layout)
let print ctx =
html [ _lang "en" ] [
htmlHead ctx
body [ _class "m-1" ] [ ctx.Content ]
]
/// Render a bare view (used for components)
let bare ctx =
html [ _lang "en" ] [
head [] [ title [] [] ]
body [] [ ctx.Content ]
]