diff --git a/src/JobsJobsJobs.sln b/src/JobsJobsJobs.sln
index 0de6ec4..91046a9 100644
--- a/src/JobsJobsJobs.sln
+++ b/src/JobsJobsJobs.sln
@@ -20,6 +20,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
database\tables.sql = database\tables.sql
EndProjectSection
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "JobsJobsJobs", "JobsJobsJobs", "{FA833B24-B8F6-4CE6-A044-99257EAC02FF}"
+EndProject
+Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Domain", "JobsJobsJobs\Domain\Domain.fsproj", "{C81278DA-DA97-4E55-AB39-4B88565B615D}"
+EndProject
+Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Api", "JobsJobsJobs\Api\Api.fsproj", "{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -38,6 +44,14 @@ Global
{AE329284-47DA-4E76-B542-47489B271130}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AE329284-47DA-4E76-B542-47489B271130}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AE329284-47DA-4E76-B542-47489B271130}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C81278DA-DA97-4E55-AB39-4B88565B615D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C81278DA-DA97-4E55-AB39-4B88565B615D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C81278DA-DA97-4E55-AB39-4B88565B615D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C81278DA-DA97-4E55-AB39-4B88565B615D}.Release|Any CPU.Build.0 = Release|Any CPU
+ {8F5A3D1E-562B-4F27-9787-6CB14B35E69E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {8F5A3D1E-562B-4F27-9787-6CB14B35E69E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {8F5A3D1E-562B-4F27-9787-6CB14B35E69E}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {8F5A3D1E-562B-4F27-9787-6CB14B35E69E}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -45,4 +59,8 @@ Global
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {5E9ECDBF-634E-43A9-8F89-625A2213831C}
EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {C81278DA-DA97-4E55-AB39-4B88565B615D} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF}
+ {8F5A3D1E-562B-4F27-9787-6CB14B35E69E} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF}
+ EndGlobalSection
EndGlobal
diff --git a/src/JobsJobsJobs/Api/Api.fsproj b/src/JobsJobsJobs/Api/Api.fsproj
new file mode 100644
index 0000000..8cb0cc1
--- /dev/null
+++ b/src/JobsJobsJobs/Api/Api.fsproj
@@ -0,0 +1,25 @@
+
+
+
+ Exe
+ net5.0
+ 3390;$(WarnOn)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/JobsJobsJobs/Api/App.fs b/src/JobsJobsJobs/Api/App.fs
new file mode 100644
index 0000000..887b689
--- /dev/null
+++ b/src/JobsJobsJobs/Api/App.fs
@@ -0,0 +1,51 @@
+/// The main API application for Jobs, Jobs, Jobs
+module JobsJobsJobs.Api.App
+
+//open System
+open Microsoft.AspNetCore.Builder
+open Microsoft.AspNetCore.Hosting
+open Microsoft.Extensions.DependencyInjection
+open Microsoft.Extensions.Hosting
+open Giraffe
+
+/// All available routes for the application
+let webApp =
+ choose [
+ route "/ping" >=> text "pong"
+ route "/" >=> htmlFile "/pages/index.html"
+ ]
+
+/// Configure the ASP.NET Core pipeline to use Giraffe
+let configureApp (app : IApplicationBuilder) =
+ app.UseGiraffe webApp
+
+open NodaTime
+open RethinkDb.Driver.Net
+open Microsoft.Extensions.Configuration
+open Microsoft.Extensions.Logging
+
+/// Configure dependency injection
+let configureServices (svc : IServiceCollection) =
+ svc.AddGiraffe()
+ .AddSingleton(SystemClock.Instance)
+ .AddLogging ()
+ |> ignore
+ let svcs = svc.BuildServiceProvider()
+ let cfg = svcs.GetRequiredService().GetSection "Rethink"
+ let log = svcs.GetRequiredService().CreateLogger "Data.Startup"
+ let conn = Data.Startup.createConnection cfg log
+ svc.AddSingleton conn |> ignore
+ Data.Startup.establishEnvironment cfg log conn |> Data.awaitIgnore
+
+[]
+let main _ =
+ Host.CreateDefaultBuilder()
+ .ConfigureWebHostDefaults(
+ fun webHostBuilder ->
+ webHostBuilder
+ .Configure(configureApp)
+ .ConfigureServices(configureServices)
+ |> ignore)
+ .Build()
+ .Run ()
+ 0
diff --git a/src/JobsJobsJobs/Api/Data.fs b/src/JobsJobsJobs/Api/Data.fs
new file mode 100644
index 0000000..7ede6f9
--- /dev/null
+++ b/src/JobsJobsJobs/Api/Data.fs
@@ -0,0 +1,169 @@
+/// Data access functions for Jobs, Jobs, Jobs
+module JobsJobsJobs.Api.Data
+
+open JobsJobsJobs.Domain
+open JobsJobsJobs.Domain.Types
+open Polly
+open RethinkDb.Driver
+open RethinkDb.Driver.Net
+open Microsoft.Extensions.Configuration
+open FSharp.Control.Tasks
+open Microsoft.Extensions.Logging
+
+/// Shorthand for the RethinkDB R variable (how every command starts)
+let private r = RethinkDB.R
+
+/// Shorthand for await task / run sync / ignore (used in non-async contexts)
+let awaitIgnore x = x |> Async.AwaitTask |> Async.RunSynchronously |> ignore
+
+
+/// JSON converters used with RethinkDB persistence
+module Converters =
+
+ open Microsoft.FSharpLu.Json
+ open Newtonsoft.Json
+ open System
+
+ /// JSON converter for citizen IDs
+ type CitizenIdJsonConverter() =
+ inherit JsonConverter()
+ override __.WriteJson(writer : JsonWriter, value : CitizenId, _ : JsonSerializer) =
+ writer.WriteValue (CitizenId.toString value)
+ override __.ReadJson(reader: JsonReader, _ : Type, _ : CitizenId, _ : bool, _ : JsonSerializer) =
+ (string >> CitizenId.ofString) reader.Value
+
+ /// JSON converter for continent IDs
+ type ContinentIdJsonConverter() =
+ inherit JsonConverter()
+ override __.WriteJson(writer : JsonWriter, value : ContinentId, _ : JsonSerializer) =
+ writer.WriteValue (ContinentId.toString value)
+ override __.ReadJson(reader: JsonReader, _ : Type, _ : ContinentId, _ : bool, _ : JsonSerializer) =
+ (string >> ContinentId.ofString) reader.Value
+
+ /// JSON converter for Markdown strings
+ type MarkdownStringJsonConverter() =
+ inherit JsonConverter()
+ override __.WriteJson(writer : JsonWriter, value : MarkdownString, _ : JsonSerializer) =
+ let (Text text) = value
+ writer.WriteValue text
+ override __.ReadJson(reader: JsonReader, _ : Type, _ : MarkdownString, _ : bool, _ : JsonSerializer) =
+ (string >> Text) reader.Value
+
+ /// JSON converter for listing IDs
+ type ListingIdJsonConverter() =
+ inherit JsonConverter()
+ override __.WriteJson(writer : JsonWriter, value : ListingId, _ : JsonSerializer) =
+ writer.WriteValue (ListingId.toString value)
+ override __.ReadJson(reader: JsonReader, _ : Type, _ : ListingId, _ : bool, _ : JsonSerializer) =
+ (string >> ListingId.ofString) reader.Value
+
+ /// JSON converter for skill IDs
+ type SkillIdJsonConverter() =
+ inherit JsonConverter()
+ override __.WriteJson(writer : JsonWriter, value : SkillId, _ : JsonSerializer) =
+ writer.WriteValue (SkillId.toString value)
+ override __.ReadJson(reader: JsonReader, _ : Type, _ : SkillId, _ : bool, _ : JsonSerializer) =
+ (string >> SkillId.ofString) reader.Value
+
+ /// JSON converter for success report IDs
+ type SuccessIdJsonConverter() =
+ inherit JsonConverter()
+ override __.WriteJson(writer : JsonWriter, value : SuccessId, _ : JsonSerializer) =
+ writer.WriteValue (SuccessId.toString value)
+ override __.ReadJson(reader: JsonReader, _ : Type, _ : SuccessId, _ : bool, _ : JsonSerializer) =
+ (string >> SuccessId.ofString) reader.Value
+
+ /// All JSON converters needed for the application
+ let all () = [
+ CitizenIdJsonConverter () :> JsonConverter
+ upcast ContinentIdJsonConverter ()
+ upcast MarkdownStringJsonConverter ()
+ upcast ListingIdJsonConverter ()
+ upcast SkillIdJsonConverter ()
+ upcast SuccessIdJsonConverter ()
+ upcast CompactUnionJsonConverter ()
+ ]
+
+
+/// Table names
+[]
+module Table =
+ /// The user (citizen of Gitmo Nation) table
+ let Citizen = "citizen"
+ /// The continent table
+ let Continent = "continent"
+ /// The job listing table
+ let Listing = "listing"
+ /// The citizen employment profile table
+ let Profile = "profile"
+ /// The success story table
+ let Success = "success"
+ /// All tables
+ let all () = [ Citizen; Continent; Listing; Profile; Success ]
+
+
+/// Functions run at startup
+[]
+module Startup =
+
+ /// Create a RethinkDB connection
+ let createConnection (cfg : IConfigurationSection) (log : ILogger)=
+
+ // Add all required JSON converters
+ Converters.all ()
+ |> List.iter Converter.Serializer.Converters.Add
+ // Read the configuration and create a connection
+ let bldr =
+ seq Connection.Builder> {
+ yield fun b -> match cfg.["Hostname"] with null -> b | host -> b.Hostname host
+ yield fun b -> match cfg.["Port"] with null -> b | port -> (int >> b.Port) port
+ yield fun b -> match cfg.["AuthKey"] with null -> b | key -> b.AuthKey key
+ yield fun b -> match cfg.["Db"] with null -> b | db -> b.Db db
+ yield fun b -> match cfg.["Timeout"] with null -> b | time -> (int >> b.Timeout) time
+ }
+ |> Seq.fold (fun b step -> step b) (r.Connection ())
+ match log.IsEnabled LogLevel.Debug with
+ | true -> log.LogDebug $"RethinkDB: Connecting to {bldr.Hostname}:{bldr.Port}, database {bldr.Db}"
+ | false -> ()
+ bldr.Connect () :> IConnection
+
+ /// Ensure the data, tables, and indexes that are required exist
+ let establishEnvironment (cfg : IConfigurationSection) (log : ILogger) conn = task {
+ // Ensure the database exists
+ match cfg.["Db"] |> Option.ofObj with
+ | Some database ->
+ let! dbs = r.DbList().RunResultAsync conn
+ match dbs |> List.contains database with
+ | true -> ()
+ | false ->
+ log.LogInformation $"Creating database {database}..."
+ let! _ = r.DbCreate(database).RunWriteAsync conn
+ ()
+ | None -> ()
+ // Ensure the tables exist
+ let! tables = r.TableList().RunResultAsync conn
+ Table.all ()
+ |> List.iter (
+ fun tbl ->
+ match tables |> List.contains tbl with
+ | true -> ()
+ | false ->
+ log.LogInformation $"Creating {tbl} table..."
+ r.TableCreate(tbl).RunWriteAsync conn |> awaitIgnore)
+ // Ensure the indexes exist
+ let ensureIndexes table indexes = task {
+ let! tblIdxs = r.Table(table).IndexList().RunResultAsync conn
+ indexes
+ |> List.iter (
+ fun idx ->
+ match tblIdxs |> List.contains idx with
+ | true -> ()
+ | false ->
+ log.LogInformation $"Creating \"{idx}\" index on {table}"
+ r.Table(table).IndexCreate(idx).RunWriteAsync conn |> awaitIgnore)
+ }
+ do! ensureIndexes Table.Citizen [ "naUser" ]
+ do! ensureIndexes Table.Listing [ "citizenId"; "continentId" ]
+ do! ensureIndexes Table.Profile [ "continentId" ]
+ do! ensureIndexes Table.Success [ "citizenId" ]
+ }
diff --git a/src/JobsJobsJobs/Api/appsettings.json b/src/JobsJobsJobs/Api/appsettings.json
new file mode 100644
index 0000000..cbdb783
--- /dev/null
+++ b/src/JobsJobsJobs/Api/appsettings.json
@@ -0,0 +1,6 @@
+{
+ "Rethink": {
+ "Hostname": "localhost",
+ "Db": "jobsjobsjobs"
+ }
+}
\ No newline at end of file
diff --git a/src/JobsJobsJobs/Domain/Domain.fsproj b/src/JobsJobsJobs/Domain/Domain.fsproj
new file mode 100644
index 0000000..ad1f7fe
--- /dev/null
+++ b/src/JobsJobsJobs/Domain/Domain.fsproj
@@ -0,0 +1,19 @@
+
+
+
+ net5.0
+ true
+ 3390;$(WarnOn)
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/JobsJobsJobs/Domain/Modules.fs b/src/JobsJobsJobs/Domain/Modules.fs
new file mode 100644
index 0000000..9223b70
--- /dev/null
+++ b/src/JobsJobsJobs/Domain/Modules.fs
@@ -0,0 +1,86 @@
+/// Modules to provide support functions for types
+[]
+module JobsJobsJobs.Domain.Modules
+
+open Markdig
+open System
+open Types
+
+/// Format a GUID as a Short GUID
+let private toShortGuid guid =
+ let convert (g : Guid) =
+ Convert.ToBase64String (g.ToByteArray ())
+ |> String.map (fun x -> match x with '/' -> '_' | '+' -> '-' | _ -> x)
+ (convert guid).Substring (0, 22)
+
+/// Turn a Short GUID back into a GUID
+let private fromShortGuid x =
+ let unBase64 = x |> String.map (fun x -> match x with '_' -> '/' | '-' -> '+' | _ -> x)
+ (Convert.FromBase64String >> Guid) $"{unBase64}=="
+
+
+/// 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) -> toShortGuid it
+ /// Parse a string into a citizen ID
+ let ofString = fromShortGuid >> CitizenId
+
+
+/// Support functions for citizens
+module Citizen =
+ /// Get the name of the citizen (the first of real name, display name, or handle that is filled in)
+ let name x =
+ [ x.realName; x.displayName; Some x.naUser ]
+ |> List.find Option.isSome
+ |> Option.get
+
+
+/// 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) -> toShortGuid it
+ /// Parse a string into a continent ID
+ let ofString = fromShortGuid >> ContinentId
+
+
+/// Support functions for Markdown strings
+module MarkdownString =
+ /// 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)
+
+
+/// 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) -> toShortGuid it
+ /// Parse a string into a listing ID
+ let ofString = fromShortGuid >> ListingId
+
+
+/// Support functions for skill IDs
+module SkillId =
+ /// Create a new skill ID
+ let create () = (Guid.NewGuid >> SkillId) ()
+ /// A string representation of a skill ID
+ let toString = function (SkillId it) -> toShortGuid it
+ /// Parse a string into a skill ID
+ let ofString = fromShortGuid >> SkillId
+
+
+/// 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) -> toShortGuid it
+ /// Parse a string into a success report ID
+ let ofString = fromShortGuid >> SuccessId
diff --git a/src/JobsJobsJobs/Domain/Types.fs b/src/JobsJobsJobs/Domain/Types.fs
new file mode 100644
index 0000000..fb746d0
--- /dev/null
+++ b/src/JobsJobsJobs/Domain/Types.fs
@@ -0,0 +1,136 @@
+/// Types within Jobs, Jobs, Jobs
+module JobsJobsJobs.Domain.Types
+
+open NodaTime
+open System
+
+// fsharplint:disable FieldNames
+
+/// The ID of a user (a citizen of Gitmo Nation)
+type CitizenId = CitizenId of Guid
+
+/// A user of Jobs, Jobs, Jobs
+type Citizen = {
+ /// The ID of the user
+ id : CitizenId
+ /// The handle by which the user is known on Mastodon
+ naUser : string
+ /// The user's display name from Mastodon (updated every login)
+ displayName : string option
+ /// The user's real name
+ realName : string option
+ /// The URL for the user's Mastodon profile
+ profileUrl : string
+ /// When the user joined Jobs, Jobs, Jobs
+ joinedOn : Instant
+ /// When the user last logged in
+ lastSeenOn : Instant
+ }
+
+
+/// The ID of a continent
+type ContinentId = ContinentId of Guid
+
+/// A continent
+type Continent = {
+ /// The ID of the continent
+ id : ContinentId
+ /// The name of the continent
+ name : string
+ }
+
+
+/// A string of Markdown text
+type MarkdownString = Text of string
+
+
+/// The ID of a job listing
+type ListingId = ListingId of Guid
+
+/// A job listing
+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
+ remoteWork : 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
+ }
+
+
+/// The ID of a skill
+type SkillId = SkillId of Guid
+
+/// A skill the job seeker possesses
+type Skill = {
+ /// The ID of the skill
+ id : SkillId
+ /// A description of the skill
+ description : string
+ /// Notes regarding this skill (level, duration, etc.)
+ notes : string option
+ }
+
+
+/// A job seeker profile
+type Profile = {
+ /// The ID of the citizen to whom this profile belongs
+ id : CitizenId
+ /// Whether this citizen is actively seeking employment
+ seekingEmployment : bool
+ /// Whether this citizen allows their profile to be a part of the publicly-viewable, anonymous data
+ isPublic : bool
+ /// The ID of the continent on which the citizen resides
+ continentId : ContinentId
+ /// The region in which the citizen resides
+ region : string
+ /// Whether the citizen is looking for remote work
+ remoteWork : bool
+ /// Whether the citizen is looking for full-time work
+ fullTime : bool
+ /// The citizen's professional biography
+ biography : MarkdownString
+ /// When the citizen last updated their profile
+ lastUpdatedOn : Instant
+ /// The citizen's experience (topical / chronological)
+ experience : MarkdownString option
+ /// Skills this citizen possesses
+ skills : Skill list
+ }
+
+/// The ID of a success report
+type SuccessId = SuccessId of Guid
+
+/// A record of success finding employment
+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
+ fromHere : bool
+ /// The source of this success (listing or profile)
+ source : string
+ /// The success story
+ story : MarkdownString option
+ }