Migrate job listing edit/view/expire
This commit is contained in:
parent
7001e75ac2
commit
9de255d25a
|
@ -43,24 +43,6 @@ const routes: Array<RouteRecordRaw> = [
|
||||||
component: () => import(/* webpackChunkName: "joblist" */ "../views/listing/HelpWanted.vue"),
|
component: () => import(/* webpackChunkName: "joblist" */ "../views/listing/HelpWanted.vue"),
|
||||||
meta: { auth: true, title: "Help Wanted" }
|
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
|
// Success Story URLs
|
||||||
{
|
{
|
||||||
path: "/success-story/list",
|
path: "/success-story/list",
|
||||||
|
|
|
@ -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" /> 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>
|
|
|
@ -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 “My Job
|
|
||||||
Listings” page, but you will not be able to “un-expire” 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" /> 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>
|
|
|
@ -1,78 +0,0 @@
|
||||||
<template>
|
|
||||||
<article>
|
|
||||||
<load-data :load="retrieveListing">
|
|
||||||
<h3>
|
|
||||||
{{it.listing.title}}
|
|
||||||
<span v-if="it.listing.isExpired" class="jjj-heading-label">
|
|
||||||
<span class="badge bg-warning text-dark">Expired</span>
|
|
||||||
<template v-if="it.listing.wasFilledHere">
|
|
||||||
<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> •
|
|
||||||
</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>
|
|
|
@ -331,13 +331,17 @@ module Listings =
|
||||||
|
|
||||||
/// The SQL to select a listing view
|
/// The SQL to select a listing view
|
||||||
let viewSql =
|
let viewSql =
|
||||||
$"SELECT l.*, c.data AS cont_data
|
$"SELECT l.*, c.data ->> 'name' AS continent_name, u.data AS cit_data
|
||||||
FROM {Table.Listing} l
|
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
|
/// Map a result for a listing view
|
||||||
let private toListingForView row =
|
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
|
/// Find all job listings posted by the given citizen
|
||||||
let findByCitizen citizenId =
|
let findByCitizen citizenId =
|
||||||
|
@ -358,7 +362,7 @@ module Listings =
|
||||||
let findByIdForView listingId = backgroundTask {
|
let findByIdForView listingId = backgroundTask {
|
||||||
let! tryListing =
|
let! tryListing =
|
||||||
connection ()
|
connection ()
|
||||||
|> Sql.query $"{viewSql} WHERE id = @id AND l.data ->> 'isLegacy' = 'false'"
|
|> Sql.query $"{viewSql} WHERE l.id = @id AND l.data ->> 'isLegacy' = 'false'"
|
||||||
|> Sql.parameters [ "@id", Sql.string (ListingId.toString listingId) ]
|
|> Sql.parameters [ "@id", Sql.string (ListingId.toString listingId) ]
|
||||||
|> Sql.executeAsync toListingForView
|
|> Sql.executeAsync toListingForView
|
||||||
return List.tryHead tryListing
|
return List.tryHead tryListing
|
||||||
|
|
|
@ -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
|
/// The data needed to display a listing
|
||||||
type ListingForView =
|
type ListingForView =
|
||||||
{ /// The listing itself
|
{ /// The listing itself
|
||||||
Listing : Listing
|
Listing : Listing
|
||||||
|
|
||||||
/// The continent for that listing
|
/// The name of the continent for the listing
|
||||||
Continent : Continent
|
ContinentName : string
|
||||||
}
|
|
||||||
|
|
||||||
|
/// The display name of the citizen who owns the listing
|
||||||
/// The form submitted to expire a listing
|
ListedBy : string
|
||||||
type ListingExpireForm =
|
|
||||||
{ /// Whether the job was filled from here
|
|
||||||
FromHere : bool
|
|
||||||
|
|
||||||
/// The success story written by the user
|
|
||||||
SuccessStory : string option
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -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
|
/// A successful logon
|
||||||
type LogOnSuccess =
|
type LogOnSuccess =
|
||||||
{ /// The JSON Web Token (JWT) to use for API access
|
{ /// 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
|
/// The various ways profiles can be searched
|
||||||
[<CLIMutable; NoComparison; NoEquality>]
|
[<CLIMutable; NoComparison; NoEquality>]
|
||||||
type ProfileSearchForm =
|
type ProfileSearchForm =
|
||||||
|
|
|
@ -28,31 +28,3 @@ module Passwords =
|
||||||
| PasswordVerificationResult.Success -> Some false
|
| PasswordVerificationResult.Success -> Some false
|
||||||
| PasswordVerificationResult.SuccessRehashNeeded -> Some true
|
| PasswordVerificationResult.SuccessRehashNeeded -> Some true
|
||||||
| _ -> None
|
| _ -> 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
|
|
||||||
|
|
||||||
|
|
|
@ -2,20 +2,13 @@
|
||||||
module JobsJobsJobs.Server.Handlers
|
module JobsJobsJobs.Server.Handlers
|
||||||
|
|
||||||
open Giraffe
|
open Giraffe
|
||||||
|
open Giraffe.Htmx
|
||||||
open JobsJobsJobs.Domain
|
open JobsJobsJobs.Domain
|
||||||
open JobsJobsJobs.Domain.SharedTypes
|
open JobsJobsJobs.Domain.SharedTypes
|
||||||
open JobsJobsJobs.Views
|
open JobsJobsJobs.Views
|
||||||
open Microsoft.AspNetCore.Http
|
open Microsoft.AspNetCore.Http
|
||||||
open Microsoft.Extensions.Logging
|
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>]
|
[<AutoOpen>]
|
||||||
module private HtmxHelpers =
|
module private HtmxHelpers =
|
||||||
|
@ -29,27 +22,15 @@ module private HtmxHelpers =
|
||||||
module Error =
|
module Error =
|
||||||
|
|
||||||
open System.Net
|
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"
|
/// 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 fac = ctx.GetService<ILoggerFactory> ()
|
||||||
let log = fac.CreateLogger "Handler"
|
let log = fac.CreateLogger "Handler"
|
||||||
let path = string ctx.Request.Path
|
let path = string ctx.Request.Path
|
||||||
match [ "GET"; "HEAD" ] |> List.contains ctx.Request.Method with
|
log.LogInformation "Returning 404"
|
||||||
| true when path = "/" || vueUrls |> List.exists path.StartsWith ->
|
RequestErrors.NOT_FOUND $"The URL {path} was not recognized as a valid URL" next ctx
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle unauthorized actions, redirecting to log on for GETs, otherwise returning a 401 Not Authorized response
|
/// Handle unauthorized actions, redirecting to log on for GETs, otherwise returning a 401 Not Authorized response
|
||||||
let notAuthorized : HttpHandler = fun next ctx ->
|
let notAuthorized : HttpHandler = fun next ctx ->
|
||||||
|
@ -90,9 +71,6 @@ module Helpers =
|
||||||
/// Get the application configuration from the request context
|
/// Get the application configuration from the request context
|
||||||
let config (ctx : HttpContext) = ctx.GetService<IConfiguration> ()
|
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
|
/// Get the logger factory from the request context
|
||||||
let logger (ctx : HttpContext) = ctx.GetService<ILoggerFactory> ()
|
let logger (ctx : HttpContext) = ctx.GetService<ILoggerFactory> ()
|
||||||
|
|
||||||
|
@ -418,13 +396,6 @@ module Citizen =
|
||||||
[<RequireQualifiedAccess>]
|
[<RequireQualifiedAccess>]
|
||||||
module CitizenApi =
|
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
|
// PATCH: /api/citizen/account
|
||||||
let account : HttpHandler = authorize >=> fun next ctx -> task {
|
let account : HttpHandler = authorize >=> fun next ctx -> task {
|
||||||
let! form = ctx.BindJsonAsync<AccountProfileForm> ()
|
let! form = ctx.BindJsonAsync<AccountProfileForm> ()
|
||||||
|
@ -490,62 +461,100 @@ module Home =
|
||||||
[<RequireQualifiedAccess>]
|
[<RequireQualifiedAccess>]
|
||||||
module Listing =
|
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 “{listing.Title}” 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
|
// GET: /listings/mine
|
||||||
let mine : HttpHandler = requireUser >=> fun next ctx -> task {
|
let mine : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||||
let! listings = Listings.findByCitizen (currentCitizenId ctx)
|
let! listings = Listings.findByCitizen (currentCitizenId ctx)
|
||||||
return! Listing.mine listings (timeZone ctx) |> render "My Job Listings" next ctx
|
return! Listing.mine listings (timeZone ctx) |> render "My Job Listings" next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST: /listing/save
|
||||||
/// Handlers for /api/listing[s] routes
|
let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
|
||||||
[<RequireQualifiedAccess>]
|
let citizenId = currentCitizenId ctx
|
||||||
module ListingApi =
|
let now = now ctx
|
||||||
|
let! form = ctx.BindFormAsync<EditListingForm> ()
|
||||||
/// Parse the string we receive from JSON into a NodaTime local date
|
let! theListing = task {
|
||||||
let private parseDate = DateTime.Parse >> LocalDate.FromDateTime
|
match form.Id with
|
||||||
|
| "new" ->
|
||||||
// GET: /api/listing/[id]
|
return Some
|
||||||
let get listingId : HttpHandler = authorize >=> fun next ctx -> task {
|
{ Listing.empty with
|
||||||
match! Listings.findById (ListingId listingId) with
|
Id = ListingId.create ()
|
||||||
| Some listing -> return! json listing next ctx
|
CitizenId = currentCitizenId ctx
|
||||||
| None -> return! Error.notFound next ctx
|
CreatedOn = now
|
||||||
}
|
IsExpired = false
|
||||||
|
WasFilledHere = None
|
||||||
// GET: /api/listing/view/[id]
|
IsLegacy = false
|
||||||
let view listingId : HttpHandler = authorize >=> fun next ctx -> task {
|
}
|
||||||
match! Listings.findByIdForView (ListingId listingId) with
|
| _ -> return! Listings.findById (ListingId.ofString form.Id)
|
||||||
| 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
|
|
||||||
}
|
}
|
||||||
return! ok next ctx
|
match theListing with
|
||||||
}
|
| Some listing when listing.CitizenId = citizenId ->
|
||||||
|
|
||||||
// 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> ()
|
|
||||||
do! Listings.save
|
do! Listings.save
|
||||||
{ listing with
|
{ listing with
|
||||||
Title = form.Title
|
Title = form.Title
|
||||||
|
@ -553,41 +562,28 @@ module ListingApi =
|
||||||
Region = form.Region
|
Region = form.Region
|
||||||
IsRemote = form.RemoteWork
|
IsRemote = form.RemoteWork
|
||||||
Text = Text form.Text
|
Text = Text form.Text
|
||||||
NeededBy = form.NeededBy |> Option.map parseDate
|
NeededBy = noneIfEmpty form.NeededBy |> Option.map parseDate
|
||||||
UpdatedOn = now ctx
|
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
|
| None -> return! Error.notFound next ctx
|
||||||
}
|
|
||||||
|
|
||||||
// PATCH: /api/listing/[id]
|
}
|
||||||
let expire listingId : HttpHandler = authorize >=> fun next ctx -> task {
|
|
||||||
let now = now ctx
|
// GET: /listing/[id]/view
|
||||||
match! Listings.findById (ListingId listingId) with
|
let view listingId : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||||
| Some listing when listing.CitizenId <> (currentCitizenId ctx) -> return! Error.notAuthorized next ctx
|
match! Listings.findByIdForView (ListingId listingId) with
|
||||||
| Some listing ->
|
| Some listing -> return! Listing.view listing |> render $"{listing.Listing.Title} | Job Listing" next ctx
|
||||||
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
|
|
||||||
| None -> return! Error.notFound next ctx
|
| None -> return! Error.notFound next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Handlers for /api/listing[s] routes
|
||||||
|
[<RequireQualifiedAccess>]
|
||||||
|
module ListingApi =
|
||||||
|
|
||||||
// GET: /api/listing/search
|
// GET: /api/listing/search
|
||||||
let search : HttpHandler = authorize >=> fun next ctx -> task {
|
let search : HttpHandler = authorize >=> fun next ctx -> task {
|
||||||
let search = ctx.BindQueryString<ListingSearch> ()
|
let search = ctx.BindQueryString<ListingSearch> ()
|
||||||
|
@ -698,7 +694,7 @@ module Profile =
|
||||||
|
|
||||||
// GET: /profile/[id]/view
|
// GET: /profile/[id]/view
|
||||||
let view citizenId : HttpHandler = fun next ctx -> task {
|
let view citizenId : HttpHandler = fun next ctx -> task {
|
||||||
let citId = CitizenId.ofString citizenId
|
let citId = CitizenId citizenId
|
||||||
match! Citizens.findById citId with
|
match! Citizens.findById citId with
|
||||||
| Some citizen ->
|
| Some citizen ->
|
||||||
match! Profiles.findById citId with
|
match! Profiles.findById citId with
|
||||||
|
@ -722,35 +718,6 @@ module Profile =
|
||||||
[<RequireQualifiedAccess>]
|
[<RequireQualifiedAccess>]
|
||||||
module ProfileApi =
|
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
|
// PATCH: /api/profile/employment-found
|
||||||
let employmentFound : HttpHandler = authorize >=> fun next ctx -> task {
|
let employmentFound : HttpHandler = authorize >=> fun next ctx -> task {
|
||||||
match! Profiles.findById (currentCitizenId ctx) with
|
match! Profiles.findById (currentCitizenId ctx) with
|
||||||
|
@ -833,13 +800,20 @@ let allEndpoints = [
|
||||||
GET_HEAD [ route "/how-it-works" Home.howItWorks ]
|
GET_HEAD [ route "/how-it-works" Home.howItWorks ]
|
||||||
subRoute "/listing" [
|
subRoute "/listing" [
|
||||||
GET_HEAD [
|
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 ]
|
GET_HEAD [ route "/privacy-policy" Home.privacyPolicy ]
|
||||||
subRoute "/profile" [
|
subRoute "/profile" [
|
||||||
GET_HEAD [
|
GET_HEAD [
|
||||||
routef "/%s/view" Profile.view
|
routef "/%O/view" Profile.view
|
||||||
route "/edit" Profile.edit
|
route "/edit" Profile.edit
|
||||||
route "/search" Profile.search
|
route "/search" Profile.search
|
||||||
route "/seeking" Profile.seeking
|
route "/seeking" Profile.seeking
|
||||||
|
@ -853,7 +827,6 @@ let allEndpoints = [
|
||||||
|
|
||||||
subRoute "/api" [
|
subRoute "/api" [
|
||||||
subRoute "/citizen" [
|
subRoute "/citizen" [
|
||||||
GET_HEAD [ routef "/%O" CitizenApi.get ]
|
|
||||||
PATCH [
|
PATCH [
|
||||||
route "/account" CitizenApi.account
|
route "/account" CitizenApi.account
|
||||||
]
|
]
|
||||||
|
@ -861,21 +834,11 @@ let allEndpoints = [
|
||||||
GET_HEAD [ route "/continents" Continent.all ]
|
GET_HEAD [ route "/continents" Continent.all ]
|
||||||
subRoute "/listing" [
|
subRoute "/listing" [
|
||||||
GET_HEAD [
|
GET_HEAD [
|
||||||
routef "/%O" ListingApi.get
|
|
||||||
route "/search" ListingApi.search
|
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 ]
|
POST [ route "/markdown-preview" Api.markdownPreview ]
|
||||||
subRoute "/profile" [
|
subRoute "/profile" [
|
||||||
GET_HEAD [
|
|
||||||
route "" ProfileApi.current
|
|
||||||
route "/count" ProfileApi.count
|
|
||||||
routef "/%O" ProfileApi.get
|
|
||||||
]
|
|
||||||
PATCH [ route "/employment-found" ProfileApi.employmentFound ]
|
PATCH [ route "/employment-found" ProfileApi.employmentFound ]
|
||||||
]
|
]
|
||||||
subRoute "/success" [
|
subRoute "/success" [
|
||||||
|
|
|
@ -24,6 +24,52 @@ module SkillForm =
|
||||||
{ Skill.Description = form.Description; Notes = if form.Notes = "" then None else Some form.Notes }
|
{ 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
|
/// The data required to update a profile
|
||||||
[<CLIMutable; NoComparison; NoEquality>]
|
[<CLIMutable; NoComparison; NoEquality>]
|
||||||
type EditProfileViewModel =
|
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
|
/// View model for the log on page
|
||||||
[<CLIMutable; NoComparison; NoEquality>]
|
[<CLIMutable; NoComparison; NoEquality>]
|
||||||
type LogOnViewModel =
|
type LogOnViewModel =
|
||||||
|
|
|
@ -27,10 +27,12 @@ let textBox attrs name value fieldLabel isRequired =
|
||||||
]
|
]
|
||||||
|
|
||||||
/// Create a checkbox that will post "true" if checked
|
/// Create a checkbox that will post "true" if checked
|
||||||
let checkBox name isChecked checkLabel =
|
let checkBox attrs name isChecked checkLabel =
|
||||||
div [ _class "form-check" ] [
|
div [ _class "form-check" ] [
|
||||||
input [ _type "checkbox"; _id name; _name name; _class "form-check-input"; _value "true"
|
List.append attrs
|
||||||
if isChecked then _checked ]
|
[ _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 ]
|
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
|
/// Create a Markdown editor
|
||||||
let markdownEditor attrs name value editorLabel =
|
let markdownEditor attrs name value editorLabel =
|
||||||
div [ _class "col-12" ] [
|
div [ _class "col-12"; _id $"{name}EditRow" ] [
|
||||||
nav [ _class "nav nav-pills pb-1" ] [
|
nav [ _class "nav nav-pills pb-1" ] [
|
||||||
button [ _type "button"; _id $"{name}EditButton"; _class "btn btn-primary btn-sm rounded-pill" ] [
|
button [ _type "button"; _id $"{name}EditButton"; _class "btn btn-primary btn-sm rounded-pill" ] [
|
||||||
rawText "Markdown"
|
rawText "Markdown"
|
||||||
|
@ -93,17 +95,22 @@ let collapsePanel header content =
|
||||||
let yesOrNo value =
|
let yesOrNo value =
|
||||||
if value then "Yes" else "No"
|
if value then "Yes" else "No"
|
||||||
|
|
||||||
|
/// Markdown as a raw HTML text node
|
||||||
|
let md2html value =
|
||||||
|
rawText (MarkdownString.toHtml value)
|
||||||
|
|
||||||
|
|
||||||
open NodaTime
|
open NodaTime
|
||||||
open NodaTime.Text
|
open NodaTime.Text
|
||||||
|
|
||||||
/// Generate a full date in the citizen's local time zone
|
/// Generate a full date in the citizen's local time zone
|
||||||
let fullDate (value : Instant) tz =
|
let fullDate (value : Instant) tz =
|
||||||
(ZonedDateTimePattern.CreateWithCurrentCulture ("MMMM d, yyyy", DateTimeZoneProviders.Tzdb))
|
(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
|
/// Generate a full date/time in the citizen's local time
|
||||||
let fullDateTime (value : Instant) tz =
|
let fullDateTime (value : Instant) tz =
|
||||||
let dtPattern = ZonedDateTimePattern.CreateWithCurrentCulture ("MMMM d, yyyy h:mm", DateTimeZoneProviders.Tzdb)
|
let dtPattern = ZonedDateTimePattern.CreateWithCurrentCulture ("MMMM d, yyyy h:mm", DateTimeZoneProviders.Tzdb)
|
||||||
let amPmPattern = ZonedDateTimePattern.CreateWithCurrentCulture ("tt", DateTimeZoneProviders.Tzdb)
|
let amPmPattern = ZonedDateTimePattern.CreateWithCurrentCulture ("tt", DateTimeZoneProviders.Tzdb)
|
||||||
let tzValue = value.InZone(DateTimeZoneProviders.Tzdb[tz])
|
let tzValue = value.InZone DateTimeZoneProviders.Tzdb[tz]
|
||||||
$"{dtPattern.Format(tzValue)} {amPmPattern.Format(tzValue).ToLowerInvariant()}"
|
$"{dtPattern.Format(tzValue)}{amPmPattern.Format(tzValue).ToLowerInvariant()}"
|
||||||
|
|
|
@ -9,6 +9,75 @@ open JobsJobsJobs.Domain.SharedTypes
|
||||||
open JobsJobsJobs.ViewModels
|
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 " 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 "“My Job Listings” page, but you will not be able to “un-expire” 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 " Expire Listing"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
script [] [
|
||||||
|
rawText """document.addEventListener("DOMContentLoaded", function () { jjj.listing.toggleFromHere() })"""
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
/// "My Listings" page
|
/// "My Listings" page
|
||||||
let mine (listings : ListingForView list) tz =
|
let mine (listings : ListingForView list) tz =
|
||||||
let active = listings |> List.filter (fun it -> not it.Listing.IsExpired)
|
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" ]
|
a [ _href $"/listing/{listId}/expire" ] [ rawText "Expire" ]
|
||||||
]
|
]
|
||||||
td [] [ str it.Listing.Title ]
|
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.CreatedOn tz) ]
|
||||||
td [] [ str (fullDateTime it.Listing.UpdatedOn tz) ]
|
td [] [ str (fullDateTime it.Listing.UpdatedOn tz) ]
|
||||||
])
|
])
|
||||||
|
@ -61,3 +130,32 @@ let mine (listings : ListingForView list) tz =
|
||||||
|> tbody []
|
|> 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 " "; span [ _class "badge bg-warning text-dark" ] [ rawText "Expired" ]
|
||||||
|
if defaultArg it.Listing.WasFilledHere false then
|
||||||
|
rawText " "
|
||||||
|
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 " • "
|
||||||
|
| None -> ()
|
||||||
|
rawText "Listed by "; str it.ListedBy //<!-- a :href="profileUrl" target="_blank" -->{{citizenName(citizen)}}<!-- /a -->
|
||||||
|
]
|
||||||
|
hr []
|
||||||
|
div [] [ md2html it.Listing.Text ]
|
||||||
|
]
|
||||||
|
|
|
@ -49,7 +49,7 @@ let edit (m : EditProfileViewModel) continents isNew citizenId csrf =
|
||||||
form [ _class "row g-3"; _action "/profile/save"; _hxPost "/profile/save" ] [
|
form [ _class "row g-3"; _action "/profile/save"; _hxPost "/profile/save" ] [
|
||||||
antiForgery csrf
|
antiForgery csrf
|
||||||
div [ _class "col-12" ] [
|
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
|
if m.IsSeekingEmployment then
|
||||||
p [] [
|
p [] [
|
||||||
em [] [
|
em [] [
|
||||||
|
@ -67,10 +67,10 @@ let edit (m : EditProfileViewModel) continents isNew citizenId csrf =
|
||||||
]
|
]
|
||||||
markdownEditor [ _required ] (nameof m.Biography) m.Biography "Professional Biography"
|
markdownEditor [ _required ] (nameof m.Biography) m.Biography "Professional Biography"
|
||||||
div [ _class "col-12 col-offset-md-2 col-md-4" ] [
|
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" ] [
|
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" ] [
|
div [ _class "col-12" ] [
|
||||||
hr []
|
hr []
|
||||||
|
@ -95,11 +95,11 @@ let edit (m : EditProfileViewModel) continents isNew citizenId csrf =
|
||||||
]
|
]
|
||||||
markdownEditor [] (nameof m.Experience) (defaultArg m.Experience "") "Experience"
|
markdownEditor [] (nameof m.Experience) (defaultArg m.Experience "") "Experience"
|
||||||
div [ _class "col-12 col-xl-6" ] [
|
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"
|
"Allow my profile to be searched publicly"
|
||||||
]
|
]
|
||||||
div [ _class "col-12 col-xl-6" ] [
|
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"
|
"Show my profile to anyone who has the direct link to it"
|
||||||
]
|
]
|
||||||
div [ _class "col-12" ] [
|
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"
|
rawText (if profile.IsRemote then "I" else "Not i"); rawText "nterested in remote opportunities"
|
||||||
]
|
]
|
||||||
hr []
|
hr []
|
||||||
div [] [ rawText (MarkdownString.toHtml profile.Biography) ]
|
div [] [ md2html profile.Biography ]
|
||||||
if not (List.isEmpty profile.Skills) then
|
if not (List.isEmpty profile.Skills) then
|
||||||
hr []
|
hr []
|
||||||
h4 [ _class "pb-3" ] [ rawText "Skills" ]
|
h4 [ _class "pb-3" ] [ rawText "Skills" ]
|
||||||
|
@ -327,10 +327,7 @@ let view (citizen : Citizen) (profile : Profile) (continentName : string) pageTi
|
||||||
])
|
])
|
||||||
|> ul []
|
|> ul []
|
||||||
match profile.Experience with
|
match profile.Experience with
|
||||||
| Some exp ->
|
| Some exp -> hr []; h4 [ _class "pb-3" ] [ rawText "Experience / Employment History" ]; div [] [ md2html exp ]
|
||||||
hr []
|
|
||||||
h4 [ _class "pb-3" ] [ rawText "Experience / Employment History" ]
|
|
||||||
div [] [ rawText (MarkdownString.toHtml exp) ]
|
|
||||||
| None -> ()
|
| None -> ()
|
||||||
if Option.isSome currentId && currentId.Value = citizen.Id then
|
if Option.isSome currentId && currentId.Value = citizen.Id then
|
||||||
br []; br []
|
br []; br []
|
||||||
|
|
|
@ -130,6 +130,23 @@ this.jjj = {
|
||||||
editDiv.classList.add("jjj-shown")
|
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
|
* Script for profile pages
|
||||||
*/
|
*/
|
||||||
|
|
Loading…
Reference in New Issue
Block a user