Migrate job listing edit/view/expire

This commit is contained in:
Daniel J. Summers 2023-01-15 21:48:49 -05:00
parent 7001e75ac2
commit 9de255d25a
13 changed files with 328 additions and 622 deletions

View File

@ -43,24 +43,6 @@ const routes: Array<RouteRecordRaw> = [
component: () => import(/* webpackChunkName: "joblist" */ "../views/listing/HelpWanted.vue"),
meta: { auth: true, title: "Help Wanted" }
},
{
path: "/listing/:id/edit",
name: "EditListing",
component: () => import(/* webpackChunkName: "jobedit" */ "../views/listing/ListingEdit.vue"),
meta: { auth: true, title: "Edit Job Listing" }
},
{
path: "/listing/:id/expire",
name: "ExpireListing",
component: () => import(/* webpackChunkName: "jobedit" */ "../views/listing/ListingExpire.vue"),
meta: { auth: true, title: "Expire Job Listing" }
},
{
path: "/listing/:id/view",
name: "ViewListing",
component: () => import(/* webpackChunkName: "joblist" */ "../views/listing/ListingView.vue"),
meta: { auth: true, title: "Loading Job Listing..." }
},
// Success Story URLs
{
path: "/success-story/list",

View File

@ -1,157 +0,0 @@
<template>
<article>
<h3 class="pb-3" v-if="isNew">Add a Job Listing</h3>
<h3 class="pb-3" v-else>Edit Job Listing</h3>
<load-data :load="retrieveData">
<form class="row g-3">
<div class="col-12 col-sm-10 col-md-8 col-lg-6">
<div class="form-floating">
<input type="text" id="title" :class="{ 'form-control': true, 'is-invalid': v$.title.$error }"
maxlength="255" v-model="v$.title.$model" placeholder="The title for the job listing">
<div class="invalid-feedback">Please enter a title for the job listing</div>
<label class="jjj-required" for="title">Title</label>
</div>
<div class="form-text">
No need to put location here; it will always be show to seekers with continent and region
</div>
</div>
<div class="col-12 col-sm-6 col-md-4">
<continent-list v-model="v$.continentId.$model" :isInvalid="v$.continentId.$error"
@touch="v$.continentId.$touch() || true" />
</div>
<div class="col-12 col-sm-6 col-md-8">
<div class="form-floating">
<input type="text" id="region" :class="{ 'form-control': true, 'is-invalid': v$.region.$error }"
maxlength="255" v-model="v$.region.$model" placeholder="Country, state, geographic area, etc.">
<div class="invalid-feedback">Please enter a region</div>
<label class="jjj-required" for="region">Region</label>
</div>
<div class="form-text">Country, state, geographic area, etc.</div>
</div>
<div class="col-12">
<div class="form-check">
<input type="checkbox" id="isRemote" class="form-check-input" v-model="v$.remoteWork.$model">
<label class="form-check-label" for="isRemote">This opportunity is for remote work</label>
</div>
</div>
<markdown-editor id="description" label="Job Description" v-model:text="v$.text.$model"
:isInvalid="v$.text.$error" />
<div class="col-12 col-md-4">
<div class="form-floating">
<input type="date" id="neededBy" class="form-control" v-model="v$.neededBy.$model"
placeholder="Date by which this position needs to be filled">
<label for="neededBy">Needed By</label>
</div>
</div>
<div class="col-12">
<p v-if="v$.$error" class="text-danger">Please correct the errors above</p>
<button class="btn btn-primary" @click.prevent="saveListing(true)">
<icon :icon="mdiContentSaveOutline" />&nbsp; Save
</button>
</div>
</form>
</load-data>
<maybe-save :saveAction="doSave" :validator="v$" />
</article>
</template>
<script setup lang="ts">
import { computed, reactive } from "vue"
import { useRoute, useRouter } from "vue-router"
import { mdiContentSaveOutline } from "@mdi/js"
import useVuelidate from "@vuelidate/core"
import { required } from "@vuelidate/validators"
import api, { Listing, ListingForm, LogOnSuccess } from "@/api"
import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue"
import { Mutations, useStore } from "@/store"
import ContinentList from "@/components/ContinentList.vue"
import LoadData from "@/components/LoadData.vue"
import MarkdownEditor from "@/components/MarkdownEditor.vue"
import MaybeSave from "@/components/MaybeSave.vue"
const store = useStore()
const route = useRoute()
const router = useRouter()
/** The currently logged-on user */
const user = store.state.user as LogOnSuccess
/** A new job listing */
const newListing : Listing = {
id: "",
citizenId: user.citizenId,
createdOn: "",
title: "",
continentId: "",
region: "",
remoteWork: false,
isExpired: false,
updatedOn: "",
text: "",
neededBy: undefined,
wasFilledHere: undefined
}
/** The backing object for the form */
const listing = reactive(new ListingForm())
/** The ID of the listing requested */
const id = route.params.id as string
/** Is this a new job listing? */
const isNew = computed(() => id === "new")
/** Validation rules for the form */
const rules = computed(() => ({
id: { },
title: { required },
continentId: { required },
region: { required },
remoteWork: { },
text: { required },
neededBy: { }
}))
/** Initialize form validation */
const v$ = useVuelidate(rules, listing, { $lazy: true })
/** Retrieve the listing being edited (or set up the form for a new listing) */
const retrieveData = async (errors : string[]) => {
if (isNew.value) store.commit(Mutations.SetTitle, "Add a Job Listing")
const listResult = isNew.value ? newListing : await api.listings.retreive(id, user)
if (typeof listResult === "string") {
errors.push(listResult)
} else if (typeof listResult === "undefined") {
errors.push("Job listing not found")
} else {
listing.id = listResult.id
listing.title = listResult.title
listing.continentId = listResult.continentId
listing.region = listResult.region
listing.remoteWork = listResult.remoteWork
listing.text = listResult.text
listing.neededBy = listResult.neededBy
}
}
/** Save the job listing */
const saveListing = async (navigate : boolean) => {
v$.value.$touch()
if (v$.value.$error) return
const apiFunc = isNew.value ? api.listings.add : api.listings.update
if (listing.neededBy === "") listing.neededBy = undefined
const result = await apiFunc(listing, user)
if (typeof result === "string") {
toastError(result, "saving job listing")
} else {
toastSuccess(`Job Listing ${isNew.value ? "Add" : "Updat"}ed Successfully`)
v$.value.$reset()
if (navigate) await router.push("/listings/mine")
}
}
/** Parameterless save function (used to save when navigating away) */
const doSave = async () => await saveListing(false)
</script>

View File

@ -1,107 +0,0 @@
<template>
<article>
<load-data :load="retrieveListing">
<h3 class="pb-3">Expire Job Listing ({{listing.title}})</h3>
<p class="fst-italic">
Expiring this listing will remove it from search results. You will be able to see it via your &ldquo;My Job
Listings&rdquo; page, but you will not be able to &ldquo;un-expire&rdquo; it.
</p>
<form class="row g-3">
<div class="col-12">
<div class="form-check">
<input type="checkbox" id="fromHere" class="form-check-input" v-model="v$.fromHere.$model">
<label class="form-check-label" for="fromHere">This job was filled due to its listing here</label>
</div>
</div>
<template v-if="expiration.fromHere">
<div class="col-12">
<p>
Consider telling your fellow citizens about your experience! Comments entered here will be visible to
logged-on users here, but not to the general public.
</p>
</div>
<markdown-editor id="successStory" label="Your Success Story" v-model:text="v$.successStory.$model" />
</template>
<div class="col-12">
<button class="btn btn-primary" @click.prevent="expireListing">
<icon :icon="mdiTextBoxRemoveOutline" />&nbsp; Expire Listing
</button>
</div>
</form>
</load-data>
<maybe-save :saveAction="doSave" :validator="v$" />
</article>
</template>
<script setup lang="ts">
import { computed, reactive, Ref, ref } from "vue"
import { useRoute, useRouter } from "vue-router"
import { mdiTextBoxRemoveOutline } from "@mdi/js"
import useVuelidate from "@vuelidate/core"
import api, { Listing, ListingExpireForm, LogOnSuccess } from "@/api"
import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue"
import { useStore } from "@/store"
import LoadData from "@/components/LoadData.vue"
import MarkdownEditor from "@/components/MarkdownEditor.vue"
import MaybeSave from "@/components/MaybeSave.vue"
const store = useStore()
const route = useRoute()
const router = useRouter()
/** The currently logged-on user */
const user = store.state.user as LogOnSuccess
/** The ID of the listing being expired */
const listingId = route.params.id as string
/** The listing being expired */
const listing : Ref<Listing | undefined> = ref(undefined)
/** The data needed to expire a job listing */
const expiration = reactive(new ListingExpireForm())
expiration.successStory = ""
/** The validation rules for the form */
const rules = computed(() => ({
fromHere: { },
successStory: { }
}))
/** Initialize form validation */
const v$ = useVuelidate(rules, expiration, { $lazy: true })
/** Retrieve the job listing being expired */
const retrieveListing = async (errors : string[]) => {
const listingResp = await api.listings.retreive(listingId, user)
if (typeof listingResp === "string") {
errors.push(listingResp)
} else if (typeof listingResp === "undefined") {
errors.push("Listing not found")
} else {
listing.value = listingResp
}
}
/** Expire the listing */
const expireListing = async (navigate : boolean) => {
v$.value.$touch()
if (v$.value.$error) return
if ((expiration.successStory ?? "").trim() === "") expiration.successStory = undefined
const expireResult = await api.listings.expire(listingId, expiration, user)
if (typeof expireResult === "string") {
toastError(expireResult, "expiring job listing")
} else {
toastSuccess(`Job Listing Expired${expiration.successStory ? " and Success Story Recorded" : ""} Successfully`)
v$.value.$reset()
if (navigate) {
await router.push("/listings/mine")
}
}
}
/** No-parameter save function (used for save-on-navigate) */
const doSave = async () => await expireListing(false)
</script>

View File

@ -1,78 +0,0 @@
<template>
<article>
<load-data :load="retrieveListing">
<h3>
{{it.listing.title}}
<span v-if="it.listing.isExpired" class="jjj-heading-label">
&nbsp; &nbsp; <span class="badge bg-warning text-dark">Expired</span>
<template v-if="it.listing.wasFilledHere">
&nbsp; &nbsp;<span class="badge bg-success">Filled via Jobs, Jobs, Jobs</span>
</template>
</span>
</h3>
<h4 class="pb-3 text-muted">{{it.continent.name}} / {{it.listing.region}}</h4>
<p>
<template v-if="it.listing.neededBy">
<strong><em>NEEDED BY {{neededBy(it.listing.neededBy)}}</em></strong> &bull;
</template>
Listed by <!-- a :href="profileUrl" target="_blank" -->{{citizenName(citizen)}}<!-- /a -->
</p>
<hr>
<div v-html="details" />
</load-data>
</article>
</template>
<script setup lang="ts">
import { computed, ref, Ref } from "vue"
import { useRoute } from "vue-router"
import { formatNeededBy } from "./"
import api, { Citizen, ListingForView, LogOnSuccess } from "@/api"
import { citizenName } from "@/App.vue"
import { toHtml } from "@/markdown"
import { Mutations, useStore } from "@/store"
import LoadData from "@/components/LoadData.vue"
const store = useStore()
const route = useRoute()
/** The currently logged-on user */
const user = store.state.user as LogOnSuccess
/** The requested job listing */
const it : Ref<ListingForView | undefined> = ref(undefined)
/** The citizen who posted this job listing */
const citizen : Ref<Citizen | undefined> = ref(undefined)
/** Retrieve the job listing and supporting data */
const retrieveListing = async (errors : string[]) => {
const listingResp = await api.listings.retreiveForView(route.params.id as string, user)
if (typeof listingResp === "string") {
errors.push(listingResp)
} else if (typeof listingResp === "undefined") {
errors.push("Job Listing not found")
} else {
it.value = listingResp
store.commit(Mutations.SetTitle, `${listingResp.listing.title} | Job Listing`)
const citizenResp = await api.citizen.retrieve(listingResp.listing.citizenId, user)
if (typeof citizenResp === "string") {
errors.push(citizenResp)
} else if (typeof citizenResp === "undefined") {
errors.push("Listing Citizen not found (this should not happen)")
} else {
citizen.value = citizenResp
}
}
}
/** The HTML details of the job listing */
const details = computed(() => toHtml(it.value?.listing.text ?? ""))
/** The Mastodon profile URL for the citizen who posted this job listing */
// const profileUrl = computed(() => citizen.value ? citizen.value.profileUrl : "")
/** The needed by date, formatted in SHOUTING MODE */
const neededBy = (nb : string) => formatNeededBy(nb).toUpperCase()
</script>

View File

@ -331,13 +331,17 @@ module Listings =
/// The SQL to select a listing view
let viewSql =
$"SELECT l.*, c.data AS cont_data
$"SELECT l.*, c.data ->> 'name' AS continent_name, u.data AS cit_data
FROM {Table.Listing} l
INNER JOIN {Table.Continent} c ON c.id = l.data ->> 'continentId'"
INNER JOIN {Table.Continent} c ON c.id = l.data ->> 'continentId'
INNER JOIN {Table.Citizen} u ON u.id = l.data ->> 'citizenId'"
/// Map a result for a listing view
let private toListingForView row =
{ Listing = toDocument<Listing> row; Continent = toDocumentFrom<Continent> "cont_data" row }
{ Listing = toDocument<Listing> row
ContinentName = row.string "continent_name"
ListedBy = Citizen.name (toDocumentFrom<Citizen> "cit_data" row)
}
/// Find all job listings posted by the given citizen
let findByCitizen citizenId =
@ -358,7 +362,7 @@ module Listings =
let findByIdForView listingId = backgroundTask {
let! tryListing =
connection ()
|> Sql.query $"{viewSql} WHERE id = @id AND l.data ->> 'isLegacy' = 'false'"
|> Sql.query $"{viewSql} WHERE l.id = @id AND l.data ->> 'isLegacy' = 'false'"
|> Sql.parameters [ "@id", Sql.string (ListingId.toString listingId) ]
|> Sql.executeAsync toListingForView
return List.tryHead tryListing

View File

@ -68,48 +68,16 @@ type CitizenRegistrationForm =
}
/// The data required to add or edit a job listing
type ListingForm =
{ /// The ID of the listing
Id : string
/// The listing title
Title : string
/// The ID of the continent on which this opportunity exists
ContinentId : string
/// The region in which this opportunity exists
Region : string
/// Whether this is a remote work opportunity
RemoteWork : bool
/// The text of the job listing
Text : string
/// The date by which this job listing is needed
NeededBy : string option
}
/// The data needed to display a listing
type ListingForView =
{ /// The listing itself
Listing : Listing
/// The continent for that listing
Continent : Continent
}
/// The name of the continent for the listing
ContinentName : string
/// The form submitted to expire a listing
type ListingExpireForm =
{ /// Whether the job was filled from here
FromHere : bool
/// The success story written by the user
SuccessStory : string option
/// The display name of the citizen who owns the listing
ListedBy : string
}
@ -130,16 +98,6 @@ type ListingSearch =
}
/// The fields needed to log on to Jobs, Jobs, Jobs
type LogOnForm =
{ /// The e-mail address for the citizen
Email : string
/// The password provided by the user
Password : string
}
/// A successful logon
type LogOnSuccess =
{ /// The JSON Web Token (JWT) to use for API access
@ -153,16 +111,6 @@ type LogOnSuccess =
}
/// The authorization options for Jobs, Jobs, Jobs
type AuthOptions () =
/// The secret with which the server signs the JWTs it issues once a user logs on
member val ServerSecret = "" with get, set
interface IOptions<AuthOptions> with
override this.Value = this
/// The various ways profiles can be searched
[<CLIMutable; NoComparison; NoEquality>]
type ProfileSearchForm =

View File

@ -28,31 +28,3 @@ module Passwords =
| PasswordVerificationResult.Success -> Some false
| PasswordVerificationResult.SuccessRehashNeeded -> Some true
| _ -> None
open System.IdentityModel.Tokens.Jwt
open System.Security.Claims
open Microsoft.IdentityModel.Tokens
open JobsJobsJobs.Domain.SharedTypes
/// Create a JSON Web Token for this citizen to use for further requests to this API
let createJwt (citizen : Citizen) (cfg : AuthOptions) =
let tokenHandler = JwtSecurityTokenHandler ()
let token =
tokenHandler.CreateToken (
SecurityTokenDescriptor (
Subject = ClaimsIdentity [|
Claim (ClaimTypes.NameIdentifier, CitizenId.toString citizen.Id)
Claim (ClaimTypes.Name, Citizen.name citizen)
|],
Expires = DateTime.UtcNow.AddHours 2.,
Issuer = "https://noagendacareers.com",
Audience = "https://noagendacareers.com",
SigningCredentials = SigningCredentials (
SymmetricSecurityKey (
Encoding.UTF8.GetBytes cfg.ServerSecret), SecurityAlgorithms.HmacSha256Signature)
)
)
tokenHandler.WriteToken token

View File

@ -2,20 +2,13 @@
module JobsJobsJobs.Server.Handlers
open Giraffe
open Giraffe.Htmx
open JobsJobsJobs.Domain
open JobsJobsJobs.Domain.SharedTypes
open JobsJobsJobs.Views
open Microsoft.AspNetCore.Http
open Microsoft.Extensions.Logging
/// Handler to return the files required for the Vue client app
module Vue =
/// Handler that returns index.html (the Vue client app)
let app = htmlFile "wwwroot/index.html"
open Giraffe.Htmx
[<AutoOpen>]
module private HtmxHelpers =
@ -29,27 +22,15 @@ module private HtmxHelpers =
module Error =
open System.Net
open System.Threading.Tasks
/// URL prefixes for the Vue app
let vueUrls =
[ "/how-it-works"; "/privacy-policy"; "/terms-of-service"; "/citizen"; "/help-wanted"; "/listing"; "/profile"
"/so-long"; "/success-story"
]
/// Handler that will return a status code 404 and the text "Not Found"
let notFound : HttpHandler = fun next ctx -> task {
let notFound : HttpHandler = fun next ctx ->
let fac = ctx.GetService<ILoggerFactory> ()
let log = fac.CreateLogger "Handler"
let path = string ctx.Request.Path
match [ "GET"; "HEAD" ] |> List.contains ctx.Request.Method with
| true when path = "/" || vueUrls |> List.exists path.StartsWith ->
log.LogInformation "Returning Vue app"
return! Vue.app next ctx
| _ ->
log.LogInformation "Returning 404"
return! RequestErrors.NOT_FOUND $"The URL {path} was not recognized as a valid URL" next ctx
}
log.LogInformation "Returning 404"
RequestErrors.NOT_FOUND $"The URL {path} was not recognized as a valid URL" next ctx
/// Handle unauthorized actions, redirecting to log on for GETs, otherwise returning a 401 Not Authorized response
let notAuthorized : HttpHandler = fun next ctx ->
@ -90,9 +71,6 @@ module Helpers =
/// Get the application configuration from the request context
let config (ctx : HttpContext) = ctx.GetService<IConfiguration> ()
/// Get the authorization configuration from the request context
let authConfig (ctx : HttpContext) = (ctx.GetService<IOptions<AuthOptions>> ()).Value
/// Get the logger factory from the request context
let logger (ctx : HttpContext) = ctx.GetService<ILoggerFactory> ()
@ -418,13 +396,6 @@ module Citizen =
[<RequireQualifiedAccess>]
module CitizenApi =
// GET: /api/citizen/[id]
let get citizenId : HttpHandler = authorize >=> fun next ctx -> task {
match! Citizens.findById (CitizenId citizenId) with
| Some citizen -> return! json { citizen with PasswordHash = "" } next ctx
| None -> return! Error.notFound next ctx
}
// PATCH: /api/citizen/account
let account : HttpHandler = authorize >=> fun next ctx -> task {
let! form = ctx.BindJsonAsync<AccountProfileForm> ()
@ -490,62 +461,100 @@ module Home =
[<RequireQualifiedAccess>]
module Listing =
/// Parse the string we receive from JSON into a NodaTime local date
let private parseDate = DateTime.Parse >> LocalDate.FromDateTime
// GET: /listing/[id]/edit
let edit listId : HttpHandler = requireUser >=> fun next ctx -> task {
let citizenId = currentCitizenId ctx
let! theListing = task {
match listId with
| "new" -> return Some { Listing.empty with CitizenId = citizenId }
| _ -> return! Listings.findById (ListingId.ofString listId)
}
match theListing with
| Some listing when listing.CitizenId = citizenId ->
let! continents = Continents.all ()
return!
Listing.edit (EditListingForm.fromListing listing listId) continents (listId = "new") (csrf ctx)
|> render $"""{if listId = "new" then "Add a" else "Edit"} Job Listing""" next ctx
| Some _ -> return! Error.notAuthorized next ctx
| None -> return! Error.notFound next ctx
}
// GET: /listing/[id]/expire
let expire listingId : HttpHandler = requireUser >=> fun next ctx -> task {
match! Listings.findById (ListingId listingId) with
| Some listing when listing.CitizenId = currentCitizenId ctx ->
if listing.IsExpired then
do! addError $"The listing &ldquo;{listing.Title}&rdquo; is already expired" ctx
return! redirectToGet "/listings/mine" next ctx
else
let form = { Id = ListingId.toString listing.Id; FromHere = false; SuccessStory = "" }
return! Listing.expire form listing (csrf ctx) |> render "Expire Job Listing" next ctx
| Some _ -> return! Error.notAuthorized next ctx
| None -> return! Error.notFound next ctx
}
// POST: /listing/expire
let doExpire : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
let citizenId = currentCitizenId ctx
let now = now ctx
let! form = ctx.BindFormAsync<ExpireListingForm> ()
match! Listings.findById (ListingId.ofString form.Id) with
| Some listing when listing.CitizenId = citizenId ->
if listing.IsExpired then
return! RequestErrors.BAD_REQUEST "Request is already expired" next ctx
else
do! Listings.save
{ listing with
IsExpired = true
WasFilledHere = Some form.FromHere
UpdatedOn = now
}
if form.SuccessStory <> "" then
do! Successes.save
{ Id = SuccessId.create()
CitizenId = citizenId
RecordedOn = now
IsFromHere = form.FromHere
Source = "listing"
Story = (Text >> Some) form.SuccessStory
}
let extraMsg = if form.SuccessStory <> "" then " and success story recorded" else ""
do! addSuccess $"Job listing expired{extraMsg} successfully" ctx
return! redirectToGet "/listings/mine" next ctx
| Some _ -> return! Error.notAuthorized next ctx
| None -> return! Error.notFound next ctx
}
// GET: /listings/mine
let mine : HttpHandler = requireUser >=> fun next ctx -> task {
let! listings = Listings.findByCitizen (currentCitizenId ctx)
return! Listing.mine listings (timeZone ctx) |> render "My Job Listings" next ctx
}
/// Handlers for /api/listing[s] routes
[<RequireQualifiedAccess>]
module ListingApi =
/// Parse the string we receive from JSON into a NodaTime local date
let private parseDate = DateTime.Parse >> LocalDate.FromDateTime
// GET: /api/listing/[id]
let get listingId : HttpHandler = authorize >=> fun next ctx -> task {
match! Listings.findById (ListingId listingId) with
| Some listing -> return! json listing next ctx
| None -> return! Error.notFound next ctx
}
// GET: /api/listing/view/[id]
let view listingId : HttpHandler = authorize >=> fun next ctx -> task {
match! Listings.findByIdForView (ListingId listingId) with
| Some listing -> return! json listing next ctx
| None -> return! Error.notFound next ctx
}
// POST: /listings
let add : HttpHandler = authorize >=> fun next ctx -> task {
let! form = ctx.BindJsonAsync<ListingForm> ()
let now = now ctx
do! Listings.save {
Id = ListingId.create ()
CitizenId = currentCitizenId ctx
CreatedOn = now
Title = form.Title
ContinentId = ContinentId.ofString form.ContinentId
Region = form.Region
IsRemote = form.RemoteWork
IsExpired = false
UpdatedOn = now
Text = Text form.Text
NeededBy = (form.NeededBy |> Option.map parseDate)
WasFilledHere = None
IsLegacy = false
// POST: /listing/save
let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
let citizenId = currentCitizenId ctx
let now = now ctx
let! form = ctx.BindFormAsync<EditListingForm> ()
let! theListing = task {
match form.Id with
| "new" ->
return Some
{ Listing.empty with
Id = ListingId.create ()
CitizenId = currentCitizenId ctx
CreatedOn = now
IsExpired = false
WasFilledHere = None
IsLegacy = false
}
| _ -> return! Listings.findById (ListingId.ofString form.Id)
}
return! ok next ctx
}
// PUT: /api/listing/[id]
let update listingId : HttpHandler = authorize >=> fun next ctx -> task {
match! Listings.findById (ListingId listingId) with
| Some listing when listing.CitizenId <> (currentCitizenId ctx) -> return! Error.notAuthorized next ctx
| Some listing ->
let! form = ctx.BindJsonAsync<ListingForm> ()
match theListing with
| Some listing when listing.CitizenId = citizenId ->
do! Listings.save
{ listing with
Title = form.Title
@ -553,41 +562,28 @@ module ListingApi =
Region = form.Region
IsRemote = form.RemoteWork
Text = Text form.Text
NeededBy = form.NeededBy |> Option.map parseDate
UpdatedOn = now ctx
NeededBy = noneIfEmpty form.NeededBy |> Option.map parseDate
UpdatedOn = now
}
return! ok next ctx
do! addSuccess $"""Job listing {if form.Id = "new" then "add" else "updat"}ed successfully""" ctx
return! redirectToGet $"/listing/{ListingId.toString listing.Id}/edit" next ctx
| Some _ -> return! Error.notAuthorized next ctx
| None -> return! Error.notFound next ctx
}
// PATCH: /api/listing/[id]
let expire listingId : HttpHandler = authorize >=> fun next ctx -> task {
let now = now ctx
match! Listings.findById (ListingId listingId) with
| Some listing when listing.CitizenId <> (currentCitizenId ctx) -> return! Error.notAuthorized next ctx
| Some listing ->
let! form = ctx.BindJsonAsync<ListingExpireForm> ()
do! Listings.save
{ listing with
IsExpired = true
WasFilledHere = Some form.FromHere
UpdatedOn = now
}
match form.SuccessStory with
| Some storyText ->
do! Successes.save
{ Id = SuccessId.create()
CitizenId = currentCitizenId ctx
RecordedOn = now
IsFromHere = form.FromHere
Source = "listing"
Story = (Text >> Some) storyText
}
| None -> ()
return! ok next ctx
}
// GET: /listing/[id]/view
let view listingId : HttpHandler = requireUser >=> fun next ctx -> task {
match! Listings.findByIdForView (ListingId listingId) with
| Some listing -> return! Listing.view listing |> render $"{listing.Listing.Title} | Job Listing" next ctx
| None -> return! Error.notFound next ctx
}
/// Handlers for /api/listing[s] routes
[<RequireQualifiedAccess>]
module ListingApi =
// GET: /api/listing/search
let search : HttpHandler = authorize >=> fun next ctx -> task {
let search = ctx.BindQueryString<ListingSearch> ()
@ -698,7 +694,7 @@ module Profile =
// GET: /profile/[id]/view
let view citizenId : HttpHandler = fun next ctx -> task {
let citId = CitizenId.ofString citizenId
let citId = CitizenId citizenId
match! Citizens.findById citId with
| Some citizen ->
match! Profiles.findById citId with
@ -722,35 +718,6 @@ module Profile =
[<RequireQualifiedAccess>]
module ProfileApi =
// GET: /api/profile
// This returns the current citizen's profile, or a 204 if it is not found (a citizen not having a profile yet
// is not an error). The "get" handler returns a 404 if a profile is not found.
let current : HttpHandler = authorize >=> fun next ctx -> task {
match! Profiles.findById (currentCitizenId ctx) with
| Some profile -> return! json profile next ctx
| None -> return! Successful.NO_CONTENT next ctx
}
// GET: /api/profile/get/[id]
let get citizenId : HttpHandler = authorize >=> fun next ctx -> task {
match! Profiles.findById (CitizenId citizenId) with
| Some profile -> return! json profile next ctx
| None -> return! Error.notFound next ctx
}
// GET: /api/profile/view/[id]
let view citizenId : HttpHandler = authorize >=> fun next ctx -> task {
match! Profiles.findByIdForView (CitizenId citizenId) with
| Some profile -> return! json profile next ctx
| None -> return! Error.notFound next ctx
}
// GET: /api/profile/count
let count : HttpHandler = authorize >=> fun next ctx -> task {
let! theCount = Profiles.count ()
return! json {| Count = theCount |} next ctx
}
// PATCH: /api/profile/employment-found
let employmentFound : HttpHandler = authorize >=> fun next ctx -> task {
match! Profiles.findById (currentCitizenId ctx) with
@ -833,13 +800,20 @@ let allEndpoints = [
GET_HEAD [ route "/how-it-works" Home.howItWorks ]
subRoute "/listing" [
GET_HEAD [
route "s/mine" Listing.mine
route "s/mine" Listing.mine
routef "/%s/edit" Listing.edit
routef "/%O/expire" Listing.expire
routef "/%O/view" Listing.view
]
POST [
route "/expire" Listing.doExpire
route "/save" Listing.save
]
]
GET_HEAD [ route "/privacy-policy" Home.privacyPolicy ]
subRoute "/profile" [
GET_HEAD [
routef "/%s/view" Profile.view
routef "/%O/view" Profile.view
route "/edit" Profile.edit
route "/search" Profile.search
route "/seeking" Profile.seeking
@ -853,7 +827,6 @@ let allEndpoints = [
subRoute "/api" [
subRoute "/citizen" [
GET_HEAD [ routef "/%O" CitizenApi.get ]
PATCH [
route "/account" CitizenApi.account
]
@ -861,21 +834,11 @@ let allEndpoints = [
GET_HEAD [ route "/continents" Continent.all ]
subRoute "/listing" [
GET_HEAD [
routef "/%O" ListingApi.get
route "/search" ListingApi.search
routef "/%O/view" ListingApi.view
]
PATCH [ routef "/%O" ListingApi.expire ]
POST [ route "s" ListingApi.add ]
PUT [ routef "/%O" ListingApi.update ]
]
POST [ route "/markdown-preview" Api.markdownPreview ]
subRoute "/profile" [
GET_HEAD [
route "" ProfileApi.current
route "/count" ProfileApi.count
routef "/%O" ProfileApi.get
]
PATCH [ route "/employment-found" ProfileApi.employmentFound ]
]
subRoute "/success" [

View File

@ -24,6 +24,52 @@ module SkillForm =
{ Skill.Description = form.Description; Notes = if form.Notes = "" then None else Some form.Notes }
/// The data required to add or edit a job listing
[<CLIMutable; NoComparison; NoEquality>]
type EditListingForm =
{ /// The ID of the listing
Id : string
/// The listing title
Title : string
/// The ID of the continent on which this opportunity exists
ContinentId : string
/// The region in which this opportunity exists
Region : string
/// Whether this is a remote work opportunity
RemoteWork : bool
/// The text of the job listing
Text : string
/// The date by which this job listing is needed
NeededBy : string
}
/// Support functions to support listings
module EditListingForm =
open NodaTime.Text
/// Create a listing form from an existing listing
let fromListing (listing : Listing) theId =
let neededBy =
match listing.NeededBy with
| Some dt -> (LocalDatePattern.CreateWithCurrentCulture "yyyy-MM-dd").Format dt
| None -> ""
{ Id = theId
Title = listing.Title
ContinentId = ContinentId.toString listing.ContinentId
Region = listing.Region
RemoteWork = listing.IsRemote
Text = MarkdownString.toString listing.Text
NeededBy = neededBy
}
/// The data required to update a profile
[<CLIMutable; NoComparison; NoEquality>]
type EditProfileViewModel =
@ -90,6 +136,20 @@ module EditProfileViewModel =
}
/// The form submitted to expire a listing
[<CLIMutable; NoComparison; NoEquality>]
type ExpireListingForm =
{ /// The ID of the listing to expire
Id : string
/// Whether the job was filled from here
FromHere : bool
/// The success story written by the user
SuccessStory : string
}
/// View model for the log on page
[<CLIMutable; NoComparison; NoEquality>]
type LogOnViewModel =

View File

@ -27,10 +27,12 @@ let textBox attrs name value fieldLabel isRequired =
]
/// Create a checkbox that will post "true" if checked
let checkBox name isChecked checkLabel =
let checkBox attrs name isChecked checkLabel =
div [ _class "form-check" ] [
input [ _type "checkbox"; _id name; _name name; _class "form-check-input"; _value "true"
if isChecked then _checked ]
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 ] [ str checkLabel ]
]
@ -49,7 +51,7 @@ let continentList attrs name (continents : Continent list) emptyLabel selectedVa
/// Create a Markdown editor
let markdownEditor attrs name value editorLabel =
div [ _class "col-12" ] [
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" ] [
rawText "Markdown"
@ -93,17 +95,22 @@ let collapsePanel header content =
let yesOrNo value =
if value then "Yes" else "No"
/// Markdown as a raw HTML text node
let md2html value =
rawText (MarkdownString.toHtml value)
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]))
.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()}"
let tzValue = value.InZone DateTimeZoneProviders.Tzdb[tz]
$"{dtPattern.Format(tzValue)}{amPmPattern.Format(tzValue).ToLowerInvariant()}"

View File

@ -9,6 +9,75 @@ open JobsJobsJobs.Domain.SharedTypes
open JobsJobsJobs.ViewModels
/// Job listing edit page
let edit (m : EditListingForm) continents isNew csrf =
article [] [
h3 [ _class "pb-3" ] [ rawText (if isNew then "Add a" else "Edit"); rawText " Job Listing" ]
form [ _class "row g-3"; _method "POST"; _action "/listing/save" ] [
antiForgery csrf
input [ _type "hidden"; _name (nameof m.Id); _value m.Id ]
div [ _class "col-12 col-sm-10 col-md-8 col-lg-6" ] [
textBox [ _type "text"; _maxlength "255"; _autofocus ] (nameof m.Title) m.Title "Title" true
div [ _class "form-text" ] [
rawText "No need to put location here; it will always be show to seekers with continent and region"
]
]
div [ _class "col-12 col-sm-6 col-md-4" ] [
continentList [] (nameof m.ContinentId) continents None m.ContinentId true
]
div [ _class "col-12 col-sm-6 col-md-8" ] [
textBox [ _type "text"; _maxlength "255" ] (nameof m.Region) m.Region "Region" true
div [ _class "form-text" ] [ rawText "Country, state, geographic area, etc." ]
]
div [ _class "col-12" ] [
checkBox [] (nameof m.RemoteWork) m.RemoteWork "This opportunity is for remote work"
]
markdownEditor [ _required ] (nameof m.Text) m.Text "Job Description"
div [ _class "col-12 col-md-4" ] [
textBox [ _type "date" ] (nameof m.NeededBy) m.NeededBy "Needed By" false
]
div [ _class "col-12" ] [
button [ _type "submit"; _class "btn btn-primary" ] [
i [ _class "mdi mdi-content-save-outline" ] []; rawText "&nbsp; Save"
]
]
]
]
let expire (m : ExpireListingForm) (listing : Listing) csrf =
article [] [
h3 [ _class "pb-3" ] [ rawText "Expire Job Listing ("; str listing.Title; rawText ")" ]
p [ _class "fst-italic" ] [
rawText "Expiring this listing will remove it from search results. You will be able to see it via your "
rawText "&ldquo;My Job Listings&rdquo; page, but you will not be able to &ldquo;un-expire&rdquo; it."
]
form [ _class "row g-3"; _method "POST"; _action "/listing/expire" ] [
antiForgery csrf
input [ _type "hidden"; _name (nameof m.Id); _value m.Id ]
div [ _class "col-12" ] [
checkBox [ _onclick "jjj.listing.toggleFromHere()" ] (nameof m.FromHere) m.FromHere
"This job was filled due to its listing here"
]
div [ _class "col-12"; _id "successRow" ] [
p [] [
rawText "Consider telling your fellow citizens about your experience! Comments entered here will "
rawText "be visible to logged-on users here, but not to the general public."
]
]
markdownEditor [] (nameof m.SuccessStory) m.SuccessStory "Your Success Story"
div [ _class "col-12" ] [
button [ _type "submit"; _class "btn btn-primary" ] [
i [ _class "mdi mdi-text-box-remove-outline" ] []; rawText "&nbsp; Expire Listing"
]
]
]
script [] [
rawText """document.addEventListener("DOMContentLoaded", function () { jjj.listing.toggleFromHere() })"""
]
]
/// "My Listings" page
let mine (listings : ListingForView list) tz =
let active = listings |> List.filter (fun it -> not it.Listing.IsExpired)
@ -36,7 +105,7 @@ let mine (listings : ListingForView list) tz =
a [ _href $"/listing/{listId}/expire" ] [ rawText "Expire" ]
]
td [] [ str it.Listing.Title ]
td [] [ str it.Continent.Name; rawText " / "; str it.Listing.Region ]
td [] [ str it.ContinentName; rawText " / "; str it.Listing.Region ]
td [] [ str (fullDateTime it.Listing.CreatedOn tz) ]
td [] [ str (fullDateTime it.Listing.UpdatedOn tz) ]
])
@ -61,3 +130,32 @@ let mine (listings : ListingForView list) tz =
|> tbody []
]
]
open NodaTime.Text
/// The job listing view page
let view (it : ListingForView) =
article [] [
h3 [] [
str it.Listing.Title
if it.Listing.IsExpired then
span [ _class "jjj-heading-label" ] [
rawText " &nbsp; &nbsp; "; span [ _class "badge bg-warning text-dark" ] [ rawText "Expired" ]
if defaultArg it.Listing.WasFilledHere false then
rawText " &nbsp; &nbsp; "
span [ _class "badge bg-success" ] [ rawText "Filled via Jobs, Jobs, Jobs" ]
]
]
h4 [ _class "pb-3 text-muted" ] [ str it.ContinentName; rawText " / "; str it.Listing.Region ]
p [] [
match it.Listing.NeededBy with
| Some needed ->
let format dt =
(LocalDatePattern.CreateWithCurrentCulture "MMMM d, yyyy").Format(dt).ToUpperInvariant ()
strong [] [ em [] [ rawText "NEEDED BY "; str (format needed) ] ]; rawText " &bull; "
| None -> ()
rawText "Listed by "; str it.ListedBy //<!-- a :href="profileUrl" target="_blank" -->{{citizenName(citizen)}}<!-- /a -->
]
hr []
div [] [ md2html it.Listing.Text ]
]

View File

@ -49,7 +49,7 @@ let edit (m : EditProfileViewModel) continents isNew citizenId csrf =
form [ _class "row g-3"; _action "/profile/save"; _hxPost "/profile/save" ] [
antiForgery csrf
div [ _class "col-12" ] [
checkBox (nameof m.IsSeekingEmployment) m.IsSeekingEmployment "I am currently seeking employment"
checkBox [] (nameof m.IsSeekingEmployment) m.IsSeekingEmployment "I am currently seeking employment"
if m.IsSeekingEmployment then
p [] [
em [] [
@ -67,10 +67,10 @@ let edit (m : EditProfileViewModel) continents isNew citizenId csrf =
]
markdownEditor [ _required ] (nameof m.Biography) m.Biography "Professional Biography"
div [ _class "col-12 col-offset-md-2 col-md-4" ] [
checkBox (nameof m.RemoteWork) m.RemoteWork "I am looking for remote work"
checkBox [] (nameof m.RemoteWork) m.RemoteWork "I am looking for remote work"
]
div [ _class "col-12 col-md-4" ] [
checkBox (nameof m.FullTime) m.FullTime "I am looking for full-time work"
checkBox [] (nameof m.FullTime) m.FullTime "I am looking for full-time work"
]
div [ _class "col-12" ] [
hr []
@ -95,11 +95,11 @@ let edit (m : EditProfileViewModel) continents isNew citizenId csrf =
]
markdownEditor [] (nameof m.Experience) (defaultArg m.Experience "") "Experience"
div [ _class "col-12 col-xl-6" ] [
checkBox (nameof m.IsPubliclySearchable) m.IsPubliclySearchable
checkBox [] (nameof m.IsPubliclySearchable) m.IsPubliclySearchable
"Allow my profile to be searched publicly"
]
div [ _class "col-12 col-xl-6" ] [
checkBox (nameof m.IsPubliclyLinkable) m.IsPubliclyLinkable
checkBox [] (nameof m.IsPubliclyLinkable) m.IsPubliclyLinkable
"Show my profile to anyone who has the direct link to it"
]
div [ _class "col-12" ] [
@ -312,7 +312,7 @@ let view (citizen : Citizen) (profile : Profile) (continentName : string) pageTi
rawText (if profile.IsRemote then "I" else "Not i"); rawText "nterested in remote opportunities"
]
hr []
div [] [ rawText (MarkdownString.toHtml profile.Biography) ]
div [] [ md2html profile.Biography ]
if not (List.isEmpty profile.Skills) then
hr []
h4 [ _class "pb-3" ] [ rawText "Skills" ]
@ -327,10 +327,7 @@ let view (citizen : Citizen) (profile : Profile) (continentName : string) pageTi
])
|> ul []
match profile.Experience with
| Some exp ->
hr []
h4 [ _class "pb-3" ] [ rawText "Experience / Employment History" ]
div [] [ rawText (MarkdownString.toHtml exp) ]
| Some exp -> hr []; h4 [ _class "pb-3" ] [ rawText "Experience / Employment History" ]; div [] [ md2html exp ]
| None -> ()
if Option.isSome currentId && currentId.Value = citizen.Id then
br []; br []

View File

@ -130,6 +130,23 @@ this.jjj = {
editDiv.classList.add("jjj-shown")
},
/**
* Script for listing pages
*/
listing: {
/**
* Show or hide the success story prompt based on whether a job was filled here
*/
toggleFromHere() {
/** @type {HTMLInputElement} */
const isFromHere = document.getElementById("FromHere")
const display = isFromHere.checked ? "unset" : "none"
document.getElementById("successRow").style.display = display
document.getElementById("SuccessStoryEditRow").style.display = display
}
},
/**
* Script for profile pages
*/