diff --git a/.gitignore b/.gitignore index 8a382a5..70bb569 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .ionide +.fake diff --git a/src/JobsJobsJobs.Api/Data.fs b/src/JobsJobsJobs.Api/Data.fs new file mode 100644 index 0000000..e198744 --- /dev/null +++ b/src/JobsJobsJobs.Api/Data.fs @@ -0,0 +1,98 @@ +module JobsJobsJobs.Api.Data + +open JobsJobsJobs.Api.Domain +open Npgsql.FSharp +open System + +/// The connection URI for the database +let connectUri = Uri config.dbUri + + +/// Connect to the database +let db () = + (Sql.fromUri >> Sql.connect) connectUri + + +/// Return None if the error is that a single row was expected, but no rows matched +let private noneIfNotFound (it : Async>) = async { + match! it with + | Ok x -> + return (Some >> Ok) x + | Error err -> + return match err.Message with msg when msg.Contains "at least one" -> Ok None | _ -> Error err + } + + +/// Get the item count from a single-row result +let private itemCount (read: RowReader) = read.int64 "item_count" + + +/// Functions for manipulating citizens +// (SHUT UP, SLAVE!) +module Citizens = + + /// Create a Citizen from a row of data + let private fromReader (read: RowReader) = + match (read.string >> CitizenId.tryParse) "id" with + | Ok citizenId -> { + id = citizenId + naUser = read.string "na_user" + displayName = read.string "display_name" + profileUrl = read.string "profile_url" + joinedOn = (read.int64 >> Millis) "joined_on" + lastSeenOn = (read.int64 >> Millis) "last_seen_on" + } + | Error err -> failwith err + + /// Determine if we already know about this user from No Agenda Social + let findIdByNaUser naUser = + db () + |> Sql.query "SELECT id FROM citizen WHERE na_user = @na_user" + |> Sql.parameters [ "@na_user", Sql.string naUser ] + |> Sql.executeRowAsync (fun read -> + match (read.string >> CitizenId.tryParse) "id" with + | Ok citizenId -> citizenId + | Error err -> failwith err) + |> noneIfNotFound + + /// Add a citizen + let add citizen = + db () + |> Sql.query + """INSERT INTO citizen ( + na_user, display_name, profile_url, joined_on, last_seen_on, id + ) VALUES ( + @na_user, @display_name, @profile_url, @joined_on, @last_seen_on, @id + )""" + |> Sql.parameters [ + "@na_user", Sql.string citizen.naUser + "@display_name", Sql.string citizen.displayName + "@profile_url", Sql.string citizen.profileUrl + "@joined_on", (Millis.toLong >> Sql.int64) citizen.joinedOn + "@last_seen_on", (Millis.toLong >> Sql.int64) citizen.lastSeenOn + "@id", (CitizenId.toString >> Sql.string) citizen.id + ] + |> Sql.executeNonQueryAsync + + /// Update a citizen record when they log on + let update citizenId displayName = + db () + |> Sql.query + """UPDATE citizen + SET display_name = @display_name, + last_seen_on = @last_seen_on + WHERE id = @id""" + |> Sql.parameters [ + "@display_name", Sql.string displayName + "@last_seen_on", (DateTime.Now.toMillis >> Millis.toLong >> Sql.int64) () + "@id", (CitizenId.toString >> Sql.string) citizenId + ] + |> Sql.executeNonQueryAsync + + /// Try to find a citizen with the given ID + let tryFind citizenId = + db () + |> Sql.query "SELECT * FROM citizen WHERE id = @id" + |> Sql.parameters [ "@id", (CitizenId.toString >> Sql.string) citizenId ] + |> Sql.executeRowAsync fromReader + |> noneIfNotFound diff --git a/src/JobsJobsJobs.Api/Domain.fs b/src/JobsJobsJobs.Api/Domain.fs new file mode 100644 index 0000000..a3da3f2 --- /dev/null +++ b/src/JobsJobsJobs.Api/Domain.fs @@ -0,0 +1,272 @@ +module JobsJobsJobs.Api.Domain + +// fsharplint:disable RecordFieldNames MemberNames + +/// A short ID (12 characters of a Nano ID) +type ShortId = +| ShortId of string + +/// Functions to maniuplate short IDs +module ShortId = + + open Nanoid + open System.Text.RegularExpressions + + /// Regular expression to validate a string's format as a short ID + let validShortId = Regex ("^[a-z0-9_-]{12}", RegexOptions.Compiled ||| RegexOptions.IgnoreCase) + + /// Convert a short ID to its string representation + let toString = function ShortId text -> text + + /// Create a new short ID + let create () = async { + let! text = Nanoid.GenerateAsync (size = 12) + return ShortId text + } + + /// Try to parse a string into a short ID + let tryParse (text : string) = + match text.Length with + | 12 when validShortId.IsMatch text -> (ShortId >> Ok) text + | 12 -> Error "ShortId must be 12 characters [a-z,0-9,-, or _]" + | x -> Error (sprintf "ShortId must be 12 characters; %d provided" x) + + +/// The ID for a citizen (user) record +type CitizenId = +| CitizenId of ShortId + +/// Functions for manipulating citizen (user) IDs +module CitizenId = + /// Convert a citizen ID to its string representation + let toString = function CitizenId shortId -> ShortId.toString shortId + + /// Create a new citizen ID + let create () = async { + let! shortId = ShortId.create () + return CitizenId shortId + } + + /// Try to parse a string into a CitizenId + let tryParse text = + match ShortId.tryParse text with + | Ok shortId -> (CitizenId >> Ok) shortId + | Error err -> Error err + + +/// The ID for a continent record +type ContinentId = +| ContinentId of ShortId + +/// Functions for manipulating continent IDs +module ContinentId = + /// Convert a continent ID to its string representation + let toString = function ContinentId shortId -> ShortId.toString shortId + + /// Create a new continent ID + let create () = async { + let! shortId = ShortId.create () + return ContinentId shortId + } + + /// Try to parse a string into a ContinentId + let tryParse text = + match ShortId.tryParse text with + | Ok shortId -> (ContinentId >> Ok) shortId + | Error err -> Error err + + +/// The ID for a skill record +type SkillId = +| SkillId of ShortId + +/// Functions for manipulating skill IDs +module SkillId = + /// Convert a skill ID to its string representation + let toString = function SkillId shortId -> ShortId.toString shortId + + /// Create a new skill ID + let create () = async { + let! shortId = ShortId.create () + return SkillId shortId + } + + /// Try to parse a string into a CitizenId + let tryParse text = + match ShortId.tryParse text with + | Ok shortId -> (SkillId >> Ok) shortId + | Error err -> Error err + + +/// The ID for a success report record +type SuccessId = +| SuccessId of ShortId + +/// Functions for manipulating success report IDs +module SuccessId = + /// Convert a success report ID to its string representation + let toString = function SuccessId shortId -> ShortId.toString shortId + + /// Create a new success report ID + let create () = async { + let! shortId = ShortId.create () + return SuccessId shortId + } + + /// Try to parse a string into a SuccessId + let tryParse text = + match ShortId.tryParse text with + | Ok shortId -> (SuccessId >> Ok) shortId + | Error err -> Error err + + +/// A number representing milliseconds since the epoch (AKA JavaScript time) +type Millis = +| Millis of int64 + +/// Functions to manipulate ticks +module Millis = + /// Convert a Ticks instance to its primitive value + let toLong = function Millis millis -> millis + + +/// A string that holds Markdown-formatted text +type MarkdownString = +| MarkdownString of string + +/// Functions to manipulate Markdown-formatted text +module MarkdownString = + + open Markdig + + /// Markdown pipeline that supports all built-in Markdown extensions + let private pipeline = MarkdownPipelineBuilder().UseAdvancedExtensions().Build () + + /// Get the plain-text (non-rendered) representation of the text + let toText = function MarkdownString str -> str + + /// Get the HTML (rendered) representation of the text + let toHtml = function MarkdownString str -> Markdown.ToHtml (str, pipeline) + + + +/// A user +type Citizen = { + /// The ID of the user + id : CitizenId + /// The user's handle on No Agenda Social + naUser : string + /// The user's display name from No Agenda Social (as of their last login here) + displayName : string + /// The URL to the user's profile on No Agenda Social + profileUrl : string + /// When the user signed up here + joinedOn : Millis + /// When the user last logged on here + lastSeenOn : Millis +} + + +/// A continent +type Continent = { + /// The ID of the continent + id : ContinentId + /// The name of the continent + name : string +} + + +/// An employment / skills profile +type Profile = { + /// The ID of the user to whom the profile applies + citizenId : CitizenId + /// Whether this user is actively seeking employment + seekingEmployment : bool + /// Whether information from this profile should appear in the public anonymous list of available skills + isPublic : bool + /// The continent on which the user is seeking employment + continentId : Continent + /// The region within that continent where the user would prefer to work + region : string + /// Whether the user is looking for remote work + remoteWork : bool + /// Whether the user is looking for full-time work + fullTime : bool + /// The user's professional biography + biography : MarkdownString + /// When this profile was last updated + lastUpdatedOn : Millis + /// The user's experience + experience : MarkdownString option +} + + +/// A skill which a user possesses +type Skill = { + /// The ID of the skill + id : SkillId + /// The ID of the user who possesses this skill + citizenId : CitizenId + /// The skill + skill : string + /// Notes about the skill (proficiency, experience, etc.) + notes : string option +} + + +/// A success story +type Success = { + /// The ID of the success story + id : SuccessId + /// The ID of the user who experienced this success story + citizenId : CitizenId + /// When this story was recorded + recordedOn : Millis + /// Whether the success came from here; if Jobs, Jobs, Jobs led them to eventual employment + fromHere : bool + /// Their success story + story : MarkdownString option +} + + +/// Configuration required for authentication with No Agenda Social +type AuthConfig = { + /// The client ID + clientId : string + /// The cryptographic secret + secret : string + /// The base URL for Mastodon's API access + apiUrl : string + } + +/// Application configuration format +type JobsJobsJobsConfig = { + /// Auth0 configuration + auth : AuthConfig + /// Database connection URI + dbUri : string + } + + +open Microsoft.Extensions.Configuration +open System.IO + +/// Configuration instance +let config = + (lazy + (let root = + ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory ()) + .AddJsonFile("appsettings.json") + .AddJsonFile("appsettings.Development.json", true) + .AddJsonFile("appsettings.Production.json", true) + .AddEnvironmentVariables("JJJ_") + .Build() + let auth = root.GetSection "Auth" + { dbUri = root.["dbUri"] + auth = { + clientId = auth.["ClientId"] + secret = auth.["Secret"] + apiUrl = auth.["ApiUrl"] + } + })).Force() diff --git a/src/JobsJobsJobs.Api/Extensions.fs b/src/JobsJobsJobs.Api/Extensions.fs new file mode 100644 index 0000000..0bba8bc --- /dev/null +++ b/src/JobsJobsJobs.Api/Extensions.fs @@ -0,0 +1,31 @@ +[] +module JobsJobsJobs.Api.Extensions + +open System + +// fsharplint:disable MemberNames + +/// Extensions for the DateTime object +type DateTime with + + /// Constant for the ticks at the Unix epoch + member __.UnixEpochTicks = (DateTime (1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).Ticks + + /// Convert this DateTime to a JavaScript milliseconds-past-the-epoch value + member this.toMillis () = + (this.ToUniversalTime().Ticks - this.UnixEpochTicks) / 10000L |> Domain.Millis + + +open FSharp.Json +open Suave +open System.Text + +/// Extensions for Suave's context +type HttpContext with + + /// Deserialize an object from a JSON request body + member this.fromJsonBody<'T> () = + try + Encoding.UTF8.GetString this.request.rawForm |> Json.deserialize<'T> |> Ok + with x -> + Error x diff --git a/src/JobsJobsJobs.Api/Handlers.fs b/src/JobsJobsJobs.Api/Handlers.fs new file mode 100644 index 0000000..9367c4d --- /dev/null +++ b/src/JobsJobsJobs.Api/Handlers.fs @@ -0,0 +1,147 @@ +module JobsJobsJobs.Api.Handlers + +open Data +open Domain +open FSharp.Json +open Suave +open Suave.Operators +open System + +[] +module private Internal = + + open Suave.Writers + + /// Send a JSON response + let json x = + Successful.OK (Json.serialize x) + >=> setMimeType "application/json; charset=utf-8" + + +module Auth = + + open System.Net.Http + open System.Net.Http.Headers + + /// Verify a user's credentials with No Agenda Social + let verifyWithMastodon accessToken = async { + use client = new HttpClient () + use req = new HttpRequestMessage (HttpMethod.Get, (sprintf "%saccounts/verify_credentials" config.auth.apiUrl)) + req.Headers.Authorization <- AuthenticationHeaderValue <| sprintf "Bearer %s" accessToken + match! client.SendAsync req |> Async.AwaitTask with + | res when res.IsSuccessStatusCode -> + let! body = res.Content.ReadAsStringAsync () + return + match Json.deserialize body with + | profile when profile.username = profile.acct -> Ok profile + | profile -> Error (sprintf "Profiles must be from noagendasocial.com; yours is %s" profile.acct) + | res -> return Error (sprintf "Could not retrieve credentials: %d ~ %s" (int res.StatusCode) res.ReasonPhrase) + } + +/// Handler to return the Vue application +module Vue = + + /// The application index page + let app = Files.file "wwwroot/index.html" + + +/// Handlers for error conditions +module Error = + + open Suave.Logging + open Suave.Logging.Message + + /// Handle errors + let error (ex : Exception) msg = + fun ctx -> + seq { + string ctx.request.url + match msg with "" -> () | _ -> " ~ "; msg + "\n"; (ex.GetType().Name); ": "; ex.Message; "\n" + ex.StackTrace + } + |> Seq.reduce (+) + |> (eventX >> ctx.runtime.logger.error) + ServerErrors.INTERNAL_ERROR (Json.serialize {| error = ex.Message |}) ctx + + /// Handle 404s from the API, sending known URL paths to the Vue app so that they can be handled there + let notFound : WebPart = + fun ctx -> + [ "/user"; "/jobs" ] + |> List.filter ctx.request.path.StartsWith + |> List.length + |> function + | 0 -> RequestErrors.NOT_FOUND "err" ctx + | _ -> Vue.app ctx + + +/// /api/citizen route handlers +module Citizen = + + open ViewModels.Citizen + + /// Either add the user, or update their display name and last seen date + let establishCitizen result profile = async { + match result with + | Some citId -> + match! Citizens.update citId profile.displayName with + | Ok _ -> return Ok citId + | Error exn -> return Error exn + | None -> + let now = DateTime.Now.toMillis () + let! citId = CitizenId.create () + match! Citizens.add + { id = citId + naUser = profile.username + displayName = profile.displayName + profileUrl = profile.url + joinedOn = now + lastSeenOn = now + } with + | Ok _ -> return Ok citId + | Error exn -> return Error exn + } + + /// POST: /api/citizen/log-on + let logOn : WebPart = + fun ctx -> async { + match ctx.fromJsonBody () with + | Ok data -> + match! Auth.verifyWithMastodon data.accessToken with + | Ok profile -> + match! Citizens.findIdByNaUser profile.username with + | Ok idResult -> + match! establishCitizen idResult profile with + | Ok citizenId -> + // TODO: replace this with a JWT issued by the server user + match! Citizens.tryFind citizenId with + | Ok (Some citizen) -> return! json citizen ctx + | Ok None -> return! Error.error (exn ()) "Citizen record not found" ctx + | Error exn -> return! Error.error exn "Could not retrieve user from database" ctx + | Error exn -> return! Error.error exn "Could not update Jobs, Jobs, Jobs database" ctx + | Error exn -> return! Error.error exn "Token not received" ctx + | Error msg -> + // Error message regarding exclusivity to No Agenda Social members + return Some ctx + | Error exn -> return! Error.error exn "Token not received" ctx + } + + +open Suave.Filters + +/// The routes for Jobs, Jobs, Jobs +let webApp = + choose + [ GET >=> choose + [ path "/" >=> Vue.app + Files.browse "wwwroot/" + ] + // PUT >=> choose + // [ ] + // PATCH >=> choose + // [ ] + POST >=> choose + [ path "/api/citizen/log-on" >=> Citizen.logOn + ] + Error.notFound + ] diff --git a/src/JobsJobsJobs.Api/JobsJobsJobs.Api.fsproj b/src/JobsJobsJobs.Api/JobsJobsJobs.Api.fsproj index 1f0bb7a..2dd6f18 100644 --- a/src/JobsJobsJobs.Api/JobsJobsJobs.Api.fsproj +++ b/src/JobsJobsJobs.Api/JobsJobsJobs.Api.fsproj @@ -6,13 +6,24 @@ + + + + + - - - - + + + + + + + + + + diff --git a/src/JobsJobsJobs.Api/Program.fs b/src/JobsJobsJobs.Api/Program.fs index 09b1452..3d984f4 100644 --- a/src/JobsJobsJobs.Api/Program.fs +++ b/src/JobsJobsJobs.Api/Program.fs @@ -1,8 +1,18 @@ -// Learn more about F# at http://fsharp.org +module JobsJobsJobs.Api.App + +// Learn more about F# at http://fsharp.org open System +open Suave [] let main argv = - printfn "Hello World from F#!" - 0 // return an integer exit code + { defaultConfig with + bindings = [ HttpBinding.createSimple HTTP "127.0.0.1" 3002; HttpBinding.createSimple HTTP "::1" 3002 ] + // errorHandler = Handlers.Error.error + // serverKey = config.serverKey + // cookieSerialiser = FSharpJsonCookieSerialiser() + // homeFolder = Some "./wwwroot/" + } + |> (flip startWebServer) Handlers.webApp + 0 diff --git a/src/JobsJobsJobs.Api/ViewModels.fs b/src/JobsJobsJobs.Api/ViewModels.fs new file mode 100644 index 0000000..40d366e --- /dev/null +++ b/src/JobsJobsJobs.Api/ViewModels.fs @@ -0,0 +1,27 @@ +module JobsJobsJobs.Api.ViewModels + +// fsharplint:disable RecordFieldNames MemberNames + +/// View models uses for /api/citizen routes +module Citizen = + + open FSharp.Json + + /// The payload for the log on route + type LogOn = { + /// The access token obtained from No Agenda Social + accessToken : string + } + + /// The variables we need from the account information we get from No Agenda Social + type MastodonAccount = { + /// The user name (what we store as naUser) + username : string + /// The account name; will be the same as username for local (non-federated) accounts + acct : string + /// The user's display name as it currently shows on No Agenda Social + [] + displayName : string + /// The user's profile URL + url : string + } diff --git a/src/database/tables.sql b/src/database/tables.sql new file mode 100644 index 0000000..0231cc6 --- /dev/null +++ b/src/database/tables.sql @@ -0,0 +1,122 @@ +CREATE SCHEMA jjj; +COMMENT ON SCHEMA jjj IS 'Jobs, Jobs, Jobs'; + +CREATE TABLE jjj.citizen ( + id VARCHAR(12) NOT NULL, + na_user VARCHAR(50) NOT NULL, + display_name VARCHAR(255) NOT NULL, + profile_url VARCHAR(1024) NOT NULL, + joined_on BIGINT NOT NULL, + last_seen_on BIGINT NOT NULL, + CONSTRAINT pk_citizen PRIMARY KEY (id), + CONSTRAINT uk_na_user UNIQUE (na_user) +); +COMMENT ON TABLE jjj.citizen IS 'Users'; +COMMENT ON COLUMN jjj.citizen.id + IS 'A unique identifier for a user'; +COMMENT ON COLUMN jjj.citizen.na_user + IS 'The ID of this user from No Agenda Social'; +COMMENT ON COLUMN jjj.citizen.display_name + IS 'The display name of the user as it appeared on their profile the last time they logged on'; +COMMENT ON COLUMN jjj.citizen.profile_url + IS 'The URL for the No Agenda Social profile for this user'; +COMMENT ON COLUMN jjj.citizen.joined_on + IS 'When this user joined Jobs, Jobs, Jobs'; +COMMENT ON COLUMN jjj.citizen.last_seen_on + IS 'When this user last logged on to Jobs, Jobs, Jobs'; + +CREATE TABLE jjj.continent ( + id VARCHAR(12) NOT NULL, + name VARCHAR(255) NOT NULL, + CONSTRAINT pk_continent PRIMARY KEY (id) +); +COMMENT ON TABLE jjj.continent IS 'Continents'; +COMMENT ON COLUMN jjj.continent.id + IS 'A unique identifier for the continent'; +COMMENT ON COLUMN jjj.continent.name + IS 'The name of the continent'; + +CREATE TABLE jjj.profile ( + citizen_id VARCHAR(12) NOT NULL, + seeking_employment BOOLEAN NOT NULL, + is_public BOOLEAN NOT NULL, + continent_id VARCHAR(12) NOT NULL, + region VARCHAR(255) NOT NULL, + remote_work BOOLEAN NOT NULL, + full_time BOOLEAN NOT NULL, + biography TEXT NOT NULL, + last_updated_on BIGINT NOT NULL, + experience TEXT, + CONSTRAINT pk_profile PRIMARY KEY (citizen_id), + CONSTRAINT fk_profile_citizen FOREIGN KEY (citizen_id) REFERENCES jjj.citizen (id), + CONSTRAINT fk_profile_continent FOREIGN KEY (continent_id) REFERENCES jjj.continent (id) +); +COMMENT ON TABLE jjj.profile IS 'Employment Profiles'; +COMMENT ON COLUMN jjj.profile.citizen_id + IS 'The ID of the user to whom this profile belongs'; +COMMENT ON COLUMN jjj.profile.seeking_employment + IS 'Whether this user is actively seeking employment'; +COMMENT ON COLUMN jjj.profile.is_public + IS 'Whether this profile should appear on the anonymized public job seeker list'; +COMMENT ON COLUMN jjj.profile.continent_id + IS 'The ID of the continent on which this user is located'; +COMMENT ON COLUMN jjj.profile.region + IS 'The region within the continent where this user is located'; +COMMENT ON COLUMN jjj.profile.remote_work + IS 'Whether this user is open to remote work opportunities'; +COMMENT ON COLUMN jjj.profile.full_time + IS 'Whether this user is looking for full time work'; +COMMENT ON COLUMN jjj.profile.biography + IS 'The professional biography for this user (Markdown)'; +COMMENT ON COLUMN jjj.profile.last_updated_on + IS 'When this profile was last updated'; +COMMENT ON COLUMN jjj.profile.experience + IS 'The prior employment experience for this user (Markdown)'; + +CREATE INDEX idx_profile_continent ON jjj.profile (continent_id); +COMMENT ON INDEX jjj.idx_profile_continent IS 'FK Index'; + +CREATE TABLE jjj.skill ( + id VARCHAR(12) NOT NULL, + citizen_id VARCHAR(12) NOT NULL, + skill VARCHAR(100) NOT NULL, + notes VARCHAR(100), + CONSTRAINT pk_skill PRIMARY KEY (id), + CONSTRAINT fk_skill_citizen FOREIGN KEY (citizen_id) REFERENCES jjj.citizen (id) +); +COMMENT ON TABLE jjj.skill IS 'Skills'; +COMMENT ON COLUMN jjj.skill.id + IS 'A unique identifier for each skill entry'; +COMMENT ON COLUMN jjj.skill.citizen_id + IS 'The ID of the user to whom this skill applies'; +COMMENT ON COLUMN jjj.skill.skill + IS 'The skill itself'; +COMMENT ON COLUMN jjj.skill.notes + IS 'Proficiency level, length of experience, etc. in this skill'; + +CREATE INDEX idx_skill_citizen ON jjj.skill (citizen_id); +COMMENT ON INDEX jjj.idx_skill_citizen IS 'FK index'; + +CREATE TABLE jjj.success ( + id VARCHAR(12) NOT NULL, + citizen_id VARCHAR(12) NOT NULL, + recorded_on BIGINT NOT NULL, + from_here BOOLEAN NOT NULL, + story TEXT, + CONSTRAINT pk_success PRIMARY KEY (id), + CONSTRAINT fk_success_citizen FOREIGN KEY (citizen_id) REFERENCES jjj.citizen (id) +); +COMMENT ON TABLE jjj.success IS 'Success stories'; +COMMENT ON COLUMN jjj.success.id + IS 'A unique identifier for each success story'; +COMMENT ON COLUMN jjj.success.citizen_id + IS 'The ID of the user to whom this story belongs'; +COMMENT ON COLUMN jjj.success.recorded_on + IS 'When the user recorded this success story'; +COMMENT ON COLUMN jjj.success.from_here + IS 'Whether the user attributes their employment to their need appearing in Jobs, Jobs, Jobs'; +COMMENT ON COLUMN jjj.success.story + IS 'The story of how employment came about (Markdown)'; + +CREATE INDEX idx_success_citizen ON jjj.success (citizen_id); +COMMENT ON INDEX jjj.idx_success_citizen IS 'FK index';